From af0b7ffae78ebcaeacff50050b8f29eb3fa5b5f8 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Sat, 27 Jun 2026 02:36:01 +0800 Subject: [PATCH 01/45] fix(app): animate prompt selectors after loading (#34101) --- bun.lock | 4 ++++ packages/app/package.json | 1 + packages/app/src/components/prompt-input.tsx | 17 +++++++++++------ packages/app/src/index.css | 10 +--------- packages/ui/package.json | 3 ++- 5 files changed, 19 insertions(+), 16 deletions(-) diff --git a/bun.lock b/bun.lock index c7ac6ab61c..84c4f9e500 100644 --- a/bun.lock +++ b/bun.lock @@ -83,6 +83,7 @@ "@types/luxon": "catalog:", "@types/node": "catalog:", "@typescript/native-preview": "catalog:", + "tw-animate-css": "1.4.0", "typescript": "catalog:", "vite": "catalog:", "vite-plugin-icons-spritesheet": "3.0.1", @@ -990,6 +991,7 @@ "@typescript/native-preview": "catalog:", "solid-js": "catalog:", "tailwindcss": "catalog:", + "tw-animate-css": "1.4.0", "typescript": "catalog:", "vite": "catalog:", "vite-plugin-icons-spritesheet": "3.0.1", @@ -5315,6 +5317,8 @@ "turndown": ["turndown@7.2.0", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A=="], + "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], + "tw-to-css": ["tw-to-css@0.0.12", "", { "dependencies": { "postcss": "8.4.31", "postcss-css-variables": "0.18.0", "tailwindcss": "3.3.2" } }, "sha512-rQAsQvOtV1lBkyCw+iypMygNHrShYAItES5r8fMsrhhaj5qrV2LkZyXc8ccEH+u5bFjHjQ9iuxe90I7Kykf6pw=="], "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], diff --git a/packages/app/package.json b/packages/app/package.json index b8f52ebfc7..52420afedc 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -38,6 +38,7 @@ "@types/luxon": "catalog:", "@types/node": "catalog:", "@typescript/native-preview": "catalog:", + "tw-animate-css": "1.4.0", "typescript": "catalog:", "vite": "catalog:", "vite-plugin-icons-spritesheet": "3.0.1", diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index f9a99aebb6..56f5296a89 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1343,9 +1343,9 @@ export const PromptInput: Component = (props) => { } const agentsLoading = () => props.controls.agents.loading - const agentsShouldFadeIn = createMemo((prev) => prev ?? agentsLoading()) + const agentsShouldFadeIn = createMemo((prev) => prev ?? agentsLoading()) const providersLoading = () => props.controls.model.loading - const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading()) + const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading()) const [promptReady] = createResource( () => prompt.ready.promise, @@ -1359,6 +1359,7 @@ export const PromptInput: Component = (props) => { const modelControlState = createMemo(() => ({ loading: providersLoading(), + shouldAnimate: providersShouldFadeIn(), paid: props.controls.model.paid, title: language.t("command.model.choose"), keybind: command.keybind("model.choose"), @@ -1519,10 +1520,11 @@ export const PromptInput: Component = (props) => { {props.toolbar} - +
= (props) => {
= (props) => {
= (props) => {
@@ -2000,6 +2004,7 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) { style: props.state.style, class: "min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group", + classList: { "animate-in fade-in": props.state.shouldAnimate }, "data-action": "prompt-model", }} onClose={props.state.onClose} diff --git a/packages/app/src/index.css b/packages/app/src/index.css index 77982626a7..a99034f5da 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -1,6 +1,7 @@ @import "@opencode-ai/ui/styles/tailwind"; @import "@opencode-ai/session-ui/styles"; @import "@opencode-ai/ui/v2/styles/tailwind.css"; +@import "tw-animate-css"; @font-face { font-family: "JetBrainsMono Nerd Font Mono"; @@ -131,13 +132,4 @@ transform: rotate(360deg); } } - - @keyframes fade-in { - from { - opacity: 0; - } - to { - opacity: 1; - } - } } diff --git a/packages/ui/package.json b/packages/ui/package.json index 991ec8232e..6312702331 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -64,15 +64,16 @@ "generate:v2-oc2": "bun run script/build-oc2-v2-overrides.ts" }, "devDependencies": { + "@solidjs/meta": "catalog:", "@tailwindcss/vite": "catalog:", "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", "@types/katex": "0.16.7", "@types/luxon": "catalog:", "@typescript/native-preview": "catalog:", - "@solidjs/meta": "catalog:", "solid-js": "catalog:", "tailwindcss": "catalog:", + "tw-animate-css": "1.4.0", "typescript": "catalog:", "vite": "catalog:", "vite-plugin-icons-spritesheet": "3.0.1", From 65210f2d975afa8c2a05ebdc0c3296f30a9bbcc9 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 26 Jun 2026 20:49:47 +0200 Subject: [PATCH 02/45] feat(api): add finite durable session history pages (#34097) --- .../client/src/generated-effect/client.ts | 37 +- packages/client/src/generated/client.ts | 14 + packages/client/src/generated/types.ts | 478 +++++++++++++++++- packages/client/test/effect.test.ts | 70 ++- packages/client/test/promise.test.ts | 42 +- packages/core/src/event.ts | 75 ++- packages/core/src/session.ts | 17 + packages/core/test/session-history.test.ts | 174 +++++++ packages/httpapi-codegen/src/index.ts | 12 +- .../test/server/httpapi-exercise/index.ts | 34 ++ packages/protocol/src/groups/session.ts | 38 +- packages/protocol/test/session-cursor.test.ts | 12 +- packages/schema/src/durable-event-manifest.ts | 5 + packages/schema/src/event.ts | 6 +- packages/schema/src/session-event.ts | 4 +- packages/sdk/js/package.json | 1 + packages/sdk/js/script/build.ts | 54 ++ packages/sdk/js/src/v2/gen/sdk.gen.ts | 34 ++ packages/sdk/js/src/v2/gen/types.gen.ts | 189 ++++--- packages/sdk/js/test/session-history.test.ts | 12 + packages/server/src/handlers/session.ts | 26 + specs/v2/schema-changelog.md | 7 + specs/v2/session.md | 6 +- 23 files changed, 1235 insertions(+), 112 deletions(-) create mode 100644 packages/core/test/session-history.test.ts create mode 100644 packages/sdk/js/test/session-history.test.ts diff --git a/packages/client/src/generated-effect/client.ts b/packages/client/src/generated-effect/client.ts index 172eb4ca22..4856a3ccde 100644 --- a/packages/client/src/generated-effect/client.ts +++ b/packages/client/src/generated-effect/client.ts @@ -149,12 +149,24 @@ const Endpoint0_12 = (raw: RawClient["server.session"]) => (input: Endpoint0_12I Effect.map((value) => value.data), ) -type Endpoint0_13Request = Parameters[0] +type Endpoint0_13Request = Parameters[0] type Endpoint0_13Input = { readonly sessionID: Endpoint0_13Request["params"]["sessionID"] + readonly limit?: Endpoint0_13Request["query"]["limit"] readonly after?: Endpoint0_13Request["query"]["after"] } const Endpoint0_13 = (raw: RawClient["server.session"]) => (input: Endpoint0_13Input) => + raw["session.history"]({ + params: { sessionID: input.sessionID }, + query: { limit: input.limit, after: input.after }, + }).pipe(Effect.mapError(mapClientError)) + +type Endpoint0_14Request = Parameters[0] +type Endpoint0_14Input = { + readonly sessionID: Endpoint0_14Request["params"]["sessionID"] + readonly after?: Endpoint0_14Request["query"]["after"] +} +const Endpoint0_14 = (raw: RawClient["server.session"]) => (input: Endpoint0_14Input) => Stream.unwrap( raw["session.events"]({ params: { sessionID: input.sessionID }, query: { after: input.after } }).pipe( Effect.mapError(mapClientError), @@ -162,17 +174,17 @@ const Endpoint0_13 = (raw: RawClient["server.session"]) => (input: Endpoint0_13I ), ) -type Endpoint0_14Request = Parameters[0] -type Endpoint0_14Input = { readonly sessionID: Endpoint0_14Request["params"]["sessionID"] } -const Endpoint0_14 = (raw: RawClient["server.session"]) => (input: Endpoint0_14Input) => +type Endpoint0_15Request = Parameters[0] +type Endpoint0_15Input = { readonly sessionID: Endpoint0_15Request["params"]["sessionID"] } +const Endpoint0_15 = (raw: RawClient["server.session"]) => (input: Endpoint0_15Input) => raw["session.interrupt"]({ params: { sessionID: input.sessionID } }).pipe(Effect.mapError(mapClientError)) -type Endpoint0_15Request = Parameters[0] -type Endpoint0_15Input = { - readonly sessionID: Endpoint0_15Request["params"]["sessionID"] - readonly messageID: Endpoint0_15Request["params"]["messageID"] +type Endpoint0_16Request = Parameters[0] +type Endpoint0_16Input = { + readonly sessionID: Endpoint0_16Request["params"]["sessionID"] + readonly messageID: Endpoint0_16Request["params"]["messageID"] } -const Endpoint0_15 = (raw: RawClient["server.session"]) => (input: Endpoint0_15Input) => +const Endpoint0_16 = (raw: RawClient["server.session"]) => (input: Endpoint0_16Input) => raw["session.message"]({ params: { sessionID: input.sessionID, messageID: input.messageID } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), @@ -192,9 +204,10 @@ const adaptGroup0 = (raw: RawClient["server.session"]) => ({ clear: Endpoint0_10(raw), commit: Endpoint0_11(raw), context: Endpoint0_12(raw), - events: Endpoint0_13(raw), - interrupt: Endpoint0_14(raw), - message: Endpoint0_15(raw), + history: Endpoint0_13(raw), + events: Endpoint0_14(raw), + interrupt: Endpoint0_15(raw), + message: Endpoint0_16(raw), }) const adaptClient = (raw: RawClient) => ({ sessions: adaptGroup0(raw["server.session"]) }) diff --git a/packages/client/src/generated/client.ts b/packages/client/src/generated/client.ts index 2d3cd92ee0..6366fbb560 100644 --- a/packages/client/src/generated/client.ts +++ b/packages/client/src/generated/client.ts @@ -24,6 +24,8 @@ import type { SessionsCommitOutput, SessionsContextInput, SessionsContextOutput, + SessionsHistoryInput, + SessionsHistoryOutput, SessionsEventsInput, SessionsEventsOutput, SessionsInterruptInput, @@ -324,6 +326,18 @@ export function make(options: ClientOptions) { }, requestOptions, ).then((value) => value.data), + history: (input: SessionsHistoryInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/session/${encodeURIComponent(input.sessionID)}/history`, + query: { limit: input.limit, after: input.after }, + successStatus: 200, + declaredStatuses: [404, 400, 401], + empty: false, + }, + requestOptions, + ), events: (input: SessionsEventsInput, requestOptions?: RequestOptions): AsyncIterable => sse( { diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index bfd965ae35..bfa543ea23 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -67,7 +67,7 @@ export const isUnknownError = (value: unknown): value is UnknownError => export type SessionsListInput = { readonly workspace?: { readonly workspace?: string | undefined - readonly limit?: string | undefined + readonly limit?: number | undefined readonly order?: "asc" | "desc" | undefined readonly search?: string | undefined readonly directory?: string | undefined @@ -77,7 +77,7 @@ export type SessionsListInput = { }["workspace"] readonly limit?: { readonly workspace?: string | undefined - readonly limit?: string | undefined + readonly limit?: number | undefined readonly order?: "asc" | "desc" | undefined readonly search?: string | undefined readonly directory?: string | undefined @@ -87,7 +87,7 @@ export type SessionsListInput = { }["limit"] readonly order?: { readonly workspace?: string | undefined - readonly limit?: string | undefined + readonly limit?: number | undefined readonly order?: "asc" | "desc" | undefined readonly search?: string | undefined readonly directory?: string | undefined @@ -97,7 +97,7 @@ export type SessionsListInput = { }["order"] readonly search?: { readonly workspace?: string | undefined - readonly limit?: string | undefined + readonly limit?: number | undefined readonly order?: "asc" | "desc" | undefined readonly search?: string | undefined readonly directory?: string | undefined @@ -107,7 +107,7 @@ export type SessionsListInput = { }["search"] readonly directory?: { readonly workspace?: string | undefined - readonly limit?: string | undefined + readonly limit?: number | undefined readonly order?: "asc" | "desc" | undefined readonly search?: string | undefined readonly directory?: string | undefined @@ -117,7 +117,7 @@ export type SessionsListInput = { }["directory"] readonly project?: { readonly workspace?: string | undefined - readonly limit?: string | undefined + readonly limit?: number | undefined readonly order?: "asc" | "desc" | undefined readonly search?: string | undefined readonly directory?: string | undefined @@ -127,7 +127,7 @@ export type SessionsListInput = { }["project"] readonly subpath?: { readonly workspace?: string | undefined - readonly limit?: string | undefined + readonly limit?: number | undefined readonly order?: "asc" | "desc" | undefined readonly search?: string | undefined readonly directory?: string | undefined @@ -137,7 +137,7 @@ export type SessionsListInput = { }["subpath"] readonly cursor?: { readonly workspace?: string | undefined - readonly limit?: string | undefined + readonly limit?: number | undefined readonly order?: "asc" | "desc" | undefined readonly search?: string | undefined readonly directory?: string | undefined @@ -591,9 +591,469 @@ export type SessionsContextOutput = { > }["data"] +export type SessionsHistoryInput = { + readonly sessionID: { readonly sessionID: string }["sessionID"] + readonly limit?: { readonly limit?: number | undefined; readonly after?: number | undefined }["limit"] + readonly after?: { readonly limit?: number | undefined; readonly after?: number | undefined }["after"] +} + +export type SessionsHistoryOutput = { + readonly data: ReadonlyArray< + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.agent.switched" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly agent: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.model.switched" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.moved" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly location: { readonly directory: string; readonly workspaceID?: string } + readonly subdirectory?: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.prompted" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly prompt: { + readonly text: string + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly mime: string + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + } + readonly delivery: "steer" | "queue" + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.prompt.admitted" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly prompt: { + readonly text: string + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly mime: string + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + } + readonly delivery: "steer" | "queue" + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.context.updated" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly text: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.synthetic" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly text: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.shell.started" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly callID: string + readonly command: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.shell.ended" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly callID: string + readonly output: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.step.started" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly agent: string + readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } + readonly snapshot?: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.step.ended" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly finish: string + readonly cost: number + readonly tokens: { + readonly input: number + readonly output: number + readonly reasoning: number + readonly cache: { readonly read: number; readonly write: number } + } + readonly snapshot?: string + readonly files?: ReadonlyArray + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.step.failed" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly error: { readonly type: "unknown"; readonly message: string } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.text.started" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly textID: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.text.ended" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly textID: string + readonly text: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.tool.input.started" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly callID: string + readonly name: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.tool.input.ended" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly callID: string + readonly text: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.tool.called" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly callID: string + readonly tool: string + readonly input: { readonly [x: string]: JsonValue } + readonly provider: { + readonly executed: boolean + readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } + } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.tool.progress" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly callID: string + readonly structured: { readonly [x: string]: JsonValue } + readonly content: ReadonlyArray< + | { readonly type: "text"; readonly text: string } + | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } + > + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.tool.success" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly callID: string + readonly structured: { readonly [x: string]: JsonValue } + 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 result?: JsonValue + readonly provider: { + readonly executed: boolean + readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } + } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.tool.failed" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly callID: string + readonly error: { readonly type: "unknown"; readonly message: string } + readonly result?: JsonValue + readonly provider: { + readonly executed: boolean + readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } + } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.reasoning.started" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly reasoningID: string + readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.reasoning.ended" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly assistantMessageID: string + readonly reasoningID: string + readonly text: string + readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.retried" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly attempt: number + readonly error: { + readonly message: string + readonly statusCode?: number + readonly isRetryable: boolean + readonly responseHeaders?: { readonly [x: string]: string } + readonly responseBody?: string + readonly metadata?: { readonly [x: string]: string } + } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.compaction.started" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly reason: "auto" | "manual" + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.compaction.ended" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly messageID: string + readonly reason: "auto" | "manual" + readonly text: string + readonly recent: string + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.revert.staged" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { + readonly timestamp: number + readonly sessionID: string + readonly revert: { + readonly messageID: string + readonly partID?: string + readonly snapshot?: string + readonly diff?: string + readonly files?: ReadonlyArray<{ + readonly path: string + readonly status: "added" | "modified" | "deleted" + readonly additions: number + readonly deletions: number + readonly patch: string + }> + } + } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.revert.cleared" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { readonly timestamp: number; readonly sessionID: string } + } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: JsonValue } + readonly type: "session.next.revert.committed" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: { readonly timestamp: number; readonly sessionID: string; readonly messageID: string } + } + > + readonly hasMore: boolean +} + export type SessionsEventsInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] - readonly after?: { readonly after?: string | undefined }["after"] + readonly after?: { readonly after?: number | undefined }["after"] } export type SessionsEventsOutput = diff --git a/packages/client/test/effect.test.ts b/packages/client/test/effect.test.ts index 6ca73c22f4..865da23b93 100644 --- a/packages/client/test/effect.test.ts +++ b/packages/client/test/effect.test.ts @@ -1,7 +1,16 @@ import { expect, test } from "bun:test" import { DateTime, Effect, Stream } from "effect" import { HttpClient, HttpClientResponse } from "effect/unstable/http" -import { AbsolutePath, Agent, Location, Model, OpenCode, Prompt, Session, SessionMessage } from "../src/effect" +import { + AbsolutePath, + Agent, + Location, + Model, + OpenCode, + Prompt, + Session, + SessionMessage, +} from "../src/effect" test("sessions.get returns the decoded Effect projection", async () => { const httpClient = HttpClient.make((request) => @@ -16,6 +25,8 @@ test("sessions.get returns the decoded Effect projection", async () => { }) test("session methods retain decoded Effect inputs and outputs", async () => { + const historyQueries: Array> = [] + let historyPage = 0 const httpClient = HttpClient.make((request) => { const url = request.url if (url.includes("/event")) { @@ -28,6 +39,20 @@ test("session methods retain decoded Effect inputs and outputs", async () => { ), ) } + if (url.includes("/history")) { + historyPage++ + historyQueries.push(Object.fromEntries(request.urlParams.params)) + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json( + historyPage === 1 + ? { data: [modelSwitchedEvent], hasMore: true } + : { data: [], hasMore: false }, + ), + ), + ) + } if (url.includes("/prompt")) { return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(admission))) } @@ -72,6 +97,18 @@ test("session methods retain decoded Effect inputs and outputs", async () => { yield* client.sessions.compact({ sessionID: Session.ID.make("ses_test") }) yield* client.sessions.wait({ sessionID: Session.ID.make("ses_test") }) const context = yield* client.sessions.context({ sessionID: Session.ID.make("ses_test") }) + const history = yield* client.sessions.history({ + sessionID: Session.ID.make("ses_test"), + after: 0, + limit: 1, + }) + const historyNext = history.hasMore + ? yield* client.sessions.history({ + sessionID: Session.ID.make("ses_test"), + after: history.data.at(-1)?.durable?.seq, + limit: 2, + }) + : undefined const events = yield* client.sessions .events({ sessionID: Session.ID.make("ses_test"), after: 0 }) .pipe(Stream.runCollect) @@ -80,7 +117,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => { sessionID: Session.ID.make("ses_test"), messageID: SessionMessage.ID.make("msg_model"), }) - return { page, active, created, admitted, context, events, message } + return { page, active, created, admitted, context, history, historyNext, events, message } }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise) expect(DateTime.toEpochMillis(result.page.data[0].time.created)).toBe(1_717_171_717_000) @@ -92,10 +129,39 @@ test("session methods retain decoded Effect inputs and outputs", async () => { expect(Object.getPrototypeOf(result.admitted.prompt)).toBe(Object.prototype) expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000) expect(result.context).toEqual([]) + expect(DateTime.toEpochMillis(result.history.data[0].data.timestamp)).toBe(1_717_171_717_000) + expect(result.history).toEqual(expect.objectContaining({ hasMore: true })) + expect(result.historyNext).toEqual({ data: [], hasMore: false }) + expect(historyQueries[0]).toEqual({ limit: "1", after: "0" }) + expect(historyQueries[1]).toEqual({ limit: "2", after: "1" }) expect(DateTime.toEpochMillis(result.events[0].data.timestamp)).toBe(1_717_171_717_000) expect(result.message).toEqual(expect.objectContaining({ id: "msg_model", type: "model-switched" })) }) +test("sessions.history retains the typed SessionNotFoundError", async () => { + const httpClient = HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json( + { _tag: "SessionNotFoundError", sessionID: "ses_missing", message: "Session not found" }, + { status: 404 }, + ), + ), + ), + ) + const error = await Effect.gen(function* () { + const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" }) + return yield* client.sessions + .history({ + sessionID: Session.ID.make("ses_missing"), + }) + .pipe(Effect.flip) + }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise) + + expect(error._tag).toBe("SessionNotFoundError") +}) + const session = { data: { id: "ses_test", diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index 952a3fc0d7..4f56938024 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test" -import { isUnauthorizedError, OpenCode } from "../src" +import { isSessionNotFoundError, isUnauthorizedError, OpenCode } from "../src" test("sessions.get returns the wire projection", async () => { const client = OpenCode.make({ @@ -19,6 +19,7 @@ test("sessions.get returns the wire projection", async () => { test("session methods use the public HTTP contract", async () => { const requests: Array<{ url: string; init?: RequestInit }> = [] + let historyPage = 0 const client = OpenCode.make({ baseUrl: "http://localhost:3000", fetch: async (input, init) => { @@ -29,6 +30,14 @@ test("session methods use the public HTTP contract", async () => { headers: { "content-type": "text/event-stream" }, }) } + if (url.includes("/history")) { + historyPage++ + return Response.json( + historyPage === 1 + ? { data: [modelSwitchedEvent], hasMore: true } + : { data: [], hasMore: false }, + ) + } if (url.includes("/prompt")) return Response.json(admission) if (url.includes("/context")) return Response.json({ data: [] }) if (url.includes("/message/")) return Response.json({ data: modelSwitchedMessage }) @@ -39,7 +48,7 @@ test("session methods use the public HTTP contract", async () => { }, }) - const page = await client.sessions.list({ limit: "10", order: "desc" }) + const page = await client.sessions.list({ limit: 10, order: "desc" }) const active = await client.sessions.active() const created = await client.sessions.create({ location: { directory: "/tmp/project" } }) await client.sessions.switchAgent({ sessionID: "ses_test", agent: "build" }) @@ -55,8 +64,13 @@ test("session methods use the public HTTP contract", async () => { await client.sessions.compact({ sessionID: "ses_test" }) await client.sessions.wait({ sessionID: "ses_test" }) const context = await client.sessions.context({ sessionID: "ses_test" }) + const history = await client.sessions.history({ sessionID: "ses_test", after: 0, limit: 1 }) + const historyAfter = history.data.at(-1)?.durable?.seq + const historyNext = history.hasMore + ? await client.sessions.history({ sessionID: "ses_test", after: historyAfter, limit: 2 }) + : undefined const events = [] - for await (const event of client.sessions.events({ sessionID: "ses_test", after: "0" })) events.push(event) + for await (const event of client.sessions.events({ sessionID: "ses_test", after: 0 })) events.push(event) await client.sessions.interrupt({ sessionID: "ses_test" }) const message = await client.sessions.message({ sessionID: "ses_test", messageID: "msg_model" }) @@ -65,6 +79,8 @@ test("session methods use the public HTTP contract", async () => { expect(created.id).toBe("ses_test") expect(admitted.id).toBe("msg_test") expect(context).toEqual([]) + expect(history).toEqual({ data: [modelSwitchedEvent], hasMore: true }) + expect(historyNext).toEqual({ data: [], hasMore: false }) expect(events).toEqual([modelSwitchedEvent]) expect(message).toEqual(modelSwitchedMessage) expect(requests.map((request) => [request.init?.method, request.url])).toEqual([ @@ -77,6 +93,8 @@ test("session methods use the public HTTP contract", async () => { ["POST", "http://localhost:3000/api/session/ses_test/compact"], ["POST", "http://localhost:3000/api/session/ses_test/wait"], ["GET", "http://localhost:3000/api/session/ses_test/context"], + ["GET", "http://localhost:3000/api/session/ses_test/history?limit=1&after=0"], + ["GET", "http://localhost:3000/api/session/ses_test/history?limit=2&after=1"], ["GET", "http://localhost:3000/api/session/ses_test/event?after=0"], ["POST", "http://localhost:3000/api/session/ses_test/interrupt"], ["GET", "http://localhost:3000/api/session/ses_test/message/msg_model"], @@ -104,6 +122,24 @@ test("middleware errors remain declared client errors", async () => { } }) +test("sessions.history decodes SessionNotFoundError", async () => { + const client = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: async () => + Response.json( + { _tag: "SessionNotFoundError", sessionID: "ses_missing", message: "Session not found" }, + { status: 404 }, + ), + }) + + try { + await client.sessions.history({ sessionID: "ses_missing" }) + throw new Error("Expected request to fail") + } catch (error) { + expect(isSessionNotFoundError(error)).toBe(true) + } +}) + const session = { data: { id: "ses_test", diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts index 5324c319b0..037cbe4392 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -3,7 +3,7 @@ export * as EventV2 from "./event" import { Cause, Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect" import { Event } from "@opencode-ai/schema/event" import type { Data, Definition, Payload } from "@opencode-ai/schema/event" -import { and, asc, eq, gt } from "drizzle-orm" +import { and, asc, eq, gt, inArray } from "drizzle-orm" import { Database } from "./database/database" import { EventSequenceTable, EventTable } from "./event/sql" import { Location } from "./location" @@ -47,6 +47,66 @@ export class InvalidDurableEventError extends Schema.TaggedErrorClass { + const definition = Durable.get(event.type) + if (!definition?.durable) { + throw new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` }) + } + return { + id: event.id, + type: definition.type, + durable: { aggregateID: event.aggregateID, seq: event.seq, version: definition.durable.version }, + data: Schema.decodeUnknownSync(definition.data)(event.data), + } +} + +export const readAggregate = Effect.fn("EventV2.readAggregate")(function* ( + db: Database.Interface["db"], + input: { + readonly aggregateID: string + readonly after?: number + readonly limit: number + readonly manifest: { + readonly definitions: ReadonlyMap + readonly schema: Schema.Decoder + } + }, +) { + const after = input.after ?? -1 + const rows = yield* db + .select() + .from(EventTable) + .where( + and( + eq(EventTable.aggregate_id, input.aggregateID), + gt(EventTable.seq, after), + inArray(EventTable.type, Array.from(input.manifest.definitions.keys())), + ), + ) + .orderBy(asc(EventTable.seq)) + .limit(input.limit + 1) + .all() + .pipe(Effect.orDie) + const page = rows.slice(0, input.limit) + const decode = Schema.decodeUnknownSync(input.manifest.schema) + const events = page.map((event) => + decode({ + id: event.id, + type: input.manifest.definitions.get(event.type)?.type ?? event.type, + durable: { + aggregateID: event.aggregate_id, + seq: event.seq, + version: input.manifest.definitions.get(event.type)?.durable?.version, + }, + data: event.data, + }), + ) + return { + events, + hasMore: rows.length > input.limit, + } +}) + export const define = Event.define export const versionedType = Event.versionedType @@ -459,19 +519,6 @@ export const layerWith = (options?: LayerOptions) => const streamAll = (): Stream.Stream => Stream.fromPubSub(pubsub.all) - const decodeSerializedEvent = (event: SerializedEvent) => { - const definition = Durable.get(event.type) - if (!definition?.durable) { - throw new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` }) - } - return { - id: event.id, - type: definition.type, - durable: { aggregateID: event.aggregateID, seq: event.seq, version: definition.durable.version }, - data: Schema.decodeUnknownSync(definition.data)(event.data), - } - } - const readAfter = (aggregateID: string, after: number) => (options?.beforeAggregateRead?.(aggregateID) ?? Effect.void).pipe( Effect.andThen( diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 4ce97e133a..8d9ad9cd9f 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -34,6 +34,7 @@ import { Snapshot } from "./snapshot" import { SessionRevert } from "./session/revert" import { Revert } from "@opencode-ai/schema/revert" import { FSUtil } from "./fs-util" +import { SessionDurable } from "@opencode-ai/schema/durable-event-manifest" export const RevertState = Revert.State export type RevertState = Revert.State @@ -131,6 +132,14 @@ export interface Interface { sessionID: SessionSchema.ID after?: number }) => Stream.Stream + readonly history: (input: { + sessionID: SessionSchema.ID + after?: number + limit: number + }) => Effect.Effect< + { events: ReadonlyArray; hasMore: boolean }, + NotFoundError + > readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect readonly switchModel: (input: { sessionID: SessionSchema.ID @@ -347,6 +356,14 @@ export const layer = Layer.unwrap( .get(input.sessionID) .pipe(Effect.as(events.durable({ aggregateID: input.sessionID, after: input.after }))), ).pipe(Stream.filter((event): event is SessionEvent.DurableEvent => isDurableSessionEvent(event))), + history: Effect.fn("V2Session.history")(function* (input) { + yield* result.get(input.sessionID) + return yield* EventV2.readAggregate(db, { + ...input, + aggregateID: input.sessionID, + manifest: SessionDurable, + }) + }), prompt: Effect.fn("V2Session.prompt")((input) => Effect.uninterruptible( Effect.gen(function* () { diff --git a/packages/core/test/session-history.test.ts b/packages/core/test/session-history.test.ts new file mode 100644 index 0000000000..8cee6546b0 --- /dev/null +++ b/packages/core/test/session-history.test.ts @@ -0,0 +1,174 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer, Schema } from "effect" +import { Database } from "@opencode-ai/core/database/database" +import { EventV2 } from "@opencode-ai/core/event" +import { Location } from "@opencode-ai/core/location" +import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { ProjectV2 } from "@opencode-ai/core/project" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { SessionV2 } from "@opencode-ai/core/session" +import { SessionExecution } from "@opencode-ai/core/session/execution" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import { SessionStore } from "@opencode-ai/core/session/store" +import { SessionTable } from "@opencode-ai/core/session/sql" +import { testEffect } from "./lib/effect" + +const projects = Layer.succeed( + ProjectV2.Service, + ProjectV2.Service.of({ + resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }), + directories: () => Effect.succeed([]), + commit: () => Effect.void, + }), +) +const sessions = SessionV2.layer.pipe( + Layer.provide(LocationServiceMap.layer), + Layer.provide(EventV2.defaultLayer), + Layer.provide(Database.defaultLayer), + Layer.provide(SessionStore.defaultLayer), + Layer.provide(projects), + Layer.provide(SessionExecution.noopLayer), +) +const it = testEffect( + Layer.mergeAll( + Database.defaultLayer, + EventV2.defaultLayer, + projects, + SessionProjector.defaultLayer, + SessionStore.defaultLayer, + SessionExecution.noopLayer, + sessions, + ), +) +const location = Location.Ref.make({ directory: AbsolutePath.make("/project") }) + +const GapEvent = EventV2.define({ + type: "test.session.history.gap", + durable: { aggregate: "sessionID", version: 1 }, + schema: { sessionID: SessionV2.ID, value: Schema.String }, +}) + +describe("SessionV2.history", () => { + it.effect("returns an exhausted page for a migrated Session with no event sequence", () => + Effect.gen(function* () { + const db = (yield* Database.Service).db + const session = yield* SessionV2.Service + const sessionID = SessionV2.ID.make("ses_empty_history") + yield* db + .insert(ProjectTable) + .values({ id: ProjectV2.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] }) + .onConflictDoNothing() + .run() + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: ProjectV2.ID.global, + slug: "empty-history", + directory: "/project", + title: "Empty history", + version: "test", + }) + .run() + + const first = yield* session.history({ sessionID, limit: 10 }) + + expect(first).toEqual({ events: [], hasMore: false }) + }), + ) + + it.effect("treats after as an exclusive aggregate sequence", () => + Effect.gen(function* () { + const session = yield* SessionV2.Service + const created = yield* session.create({ location }) + yield* session.switchAgent({ sessionID: created.id, agent: "one" }) + yield* session.switchAgent({ sessionID: created.id, agent: "two" }) + + const page = yield* session.history({ sessionID: created.id, after: 1, limit: 10 }) + + expect(page.events.map((event) => event.durable?.seq)).toEqual([2]) + expect(page.hasMore).toBe(false) + }), + ) + + it.effect("paginates public events in aggregate order across filtered gaps without duplicates", () => + Effect.gen(function* () { + 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* events.publish(GapEvent, { sessionID: created.id, value: "filtered" }) + yield* session.switchAgent({ sessionID: created.id, agent: "two" }) + yield* session.switchAgent({ sessionID: created.id, agent: "three" }) + + const first = yield* session.history({ sessionID: created.id, limit: 2 }) + const after = first.events.at(-1)?.durable?.seq + const second = yield* session.history({ + sessionID: created.id, + after, + limit: 2, + }) + const sequence = [...first.events, ...second.events].map((event) => event.durable?.seq) + + expect(first.hasMore).toBe(true) + expect(second.hasMore).toBe(false) + expect(sequence).toEqual([1, 3, 4]) + expect(new Set(sequence).size).toBe(sequence.length) + }), + ) + + it.effect("includes events committed between pages", () => + Effect.gen(function* () { + const session = yield* SessionV2.Service + const created = yield* session.create({ location }) + yield* session.switchAgent({ sessionID: created.id, agent: "one" }) + yield* session.switchAgent({ sessionID: created.id, agent: "two" }) + + const first = yield* session.history({ sessionID: created.id, limit: 1 }) + yield* session.switchAgent({ sessionID: created.id, agent: "later" }) + const second = yield* session.history({ + sessionID: created.id, + after: first.events.at(-1)?.durable?.seq, + limit: 10, + }) + + expect(first.hasMore).toBe(true) + expect([...first.events, ...second.events].map((event) => event.durable?.seq)).toEqual([1, 2, 3]) + expect(second.hasMore).toBe(false) + }), + ) + + it.effect("reports exhaustion for exact-limit and limit-plus-one pages", () => + Effect.gen(function* () { + const session = yield* SessionV2.Service + const created = yield* session.create({ location }) + yield* session.switchAgent({ sessionID: created.id, agent: "one" }) + yield* session.switchAgent({ sessionID: created.id, agent: "two" }) + + const exact = yield* session.history({ sessionID: created.id, limit: 2 }) + const oneMore = yield* session.history({ sessionID: created.id, limit: 1 }) + const exhausted = yield* session.history({ + sessionID: created.id, + after: oneMore.events.at(-1)?.durable?.seq, + limit: 1, + }) + + expect(exact.events).toHaveLength(2) + expect(exact.hasMore).toBe(false) + expect(oneMore.events).toHaveLength(1) + expect(oneMore.hasMore).toBe(true) + expect(exhausted.events).toHaveLength(1) + expect(exhausted.hasMore).toBe(false) + }), + ) + + it.effect("fails with NotFoundError for a missing Session", () => + Effect.gen(function* () { + const session = yield* SessionV2.Service + const error = yield* session.history({ sessionID: SessionV2.ID.make("ses_missing"), limit: 10 }).pipe(Effect.flip) + + expect(error._tag).toBe("Session.NotFoundError") + }), + ) +}) diff --git a/packages/httpapi-codegen/src/index.ts b/packages/httpapi-codegen/src/index.ts index 289884d961..704264778c 100644 --- a/packages/httpapi-codegen/src/index.ts +++ b/packages/httpapi-codegen/src/index.ts @@ -410,12 +410,12 @@ function renderImportedProjection(groups: ReadonlyArray, endpoints: Reado function renderPromiseTypes(groups: ReadonlyArray) { const types = new Map() - const typeOf = (schema: Schema.Top) => { - const encoded = Schema.toEncoded(schema) - const cached = types.get(encoded.ast) + const typeOf = (schema: Schema.Top, decoded = false) => { + const projected = decoded ? Schema.toType(schema) : Schema.toEncoded(schema) + const cached = types.get(projected.ast) if (cached !== undefined) return cached - const type = structuralType(encoded) - types.set(encoded.ast, type) + const type = structuralType(projected) + types.set(projected.ast, type) return type } const errors = new Map( @@ -449,7 +449,7 @@ function renderPromiseTypes(groups: ReadonlyArray) { const schema = schemas[field.source] if (schema === undefined) throw new GenerationError({ reason: `Missing input schema: ${prefix}.${field.name}` }) - return `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: (${typeOf(schema)})[${JSON.stringify(field.name)}]` + return `readonly ${JSON.stringify(field.name)}${field.optional ? "?" : ""}: (${typeOf(schema, field.source === "query")})[${JSON.stringify(field.name)}]` }) .join("; ") const successSchema = endpoint.successes[0] diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index d3c8981a64..b8368383d0 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -1067,6 +1067,40 @@ const scenarios: Scenario[] = [ headers: ctx.headers(), })) .status(400, undefined, "none"), + http.protected + .get("/api/session/{sessionID}/history", "v2.session.history") + .seeded((ctx) => ctx.session({ title: "Session history" })) + .at((ctx) => ({ + path: `${route("/api/session/{sessionID}/history", { sessionID: ctx.state.id })}?${new URLSearchParams({ + after: "0", + limit: "2", + })}`, + headers: ctx.headers(), + })) + .json( + 200, + (body) => { + object(body) + array(body.data) + check(typeof body.hasMore === "boolean", "Expected a history exhaustion signal") + }, + "none", + ), + http.protected + .get("/api/session/{sessionID}/history", "v2.session.history.missing") + .at((ctx) => ({ + path: route("/api/session/{sessionID}/history", { sessionID: "ses_httpapi_missing" }), + headers: ctx.headers(), + })) + .json(404, object, "status"), + http.protected + .get("/api/session/{sessionID}/history", "v2.session.history.invalid") + .seeded((ctx) => ctx.session({ title: "Invalid history sequence" })) + .at((ctx) => ({ + path: `${route("/api/session/{sessionID}/history", { sessionID: ctx.state.id })}?after=-1`, + headers: ctx.headers(), + })) + .json(400, object, "status"), http.protected .get("/api/session/{sessionID}/event", "v2.session.events.missing") .at((ctx) => ({ diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index 057e08d7c7..8ce85ef796 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -5,7 +5,7 @@ import { Session } from "@opencode-ai/schema/session" import { Project } from "@opencode-ai/schema/project" import { AbsolutePath, NonNegativeInt, PositiveInt, RelativePath, statics } from "@opencode-ai/schema/schema" import { Workspace } from "@opencode-ai/schema/workspace" -import { Context, Encoding, Result, Schema, Struct } from "effect" +import { Context, Effect, Encoding, Result, Schema, Struct } from "effect" import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { ConflictError, @@ -60,6 +60,7 @@ const SessionsCursorInput = Schema.Union([ const SessionsCursorJson = Schema.fromJsonString(SessionsCursorInput) const encodeSessionsCursor = Schema.encodeSync(SessionsCursorJson) const decodeSessionsCursor = Schema.decodeUnknownEffect(SessionsCursorJson) +const invalidCursor = "Invalid cursor" as const export const SessionsCursor = Schema.String.pipe( Schema.brand("SessionsCursor"), @@ -67,7 +68,13 @@ export const SessionsCursor = Schema.String.pipe( const make = schema.make.bind(schema) return { make: (input: typeof SessionsCursorInput.Type) => make(Encoding.encodeBase64Url(encodeSessionsCursor(input))), - parse: (input: string) => decodeSessionsCursor(Result.getOrThrow(Encoding.decodeBase64UrlString(input))), + parse: (input: string) => + Effect.suspend(() => { + const result = Encoding.decodeBase64UrlString(input) + return Result.isFailure(result) + ? Effect.fail(invalidCursor) + : decodeSessionsCursor(result.success).pipe(Effect.mapError(() => invalidCursor)) + }), } }), ) @@ -77,6 +84,13 @@ const SessionActive = Schema.Struct({ type: Schema.Literal("running"), }).annotate({ identifier: "SessionActive" }) +const SessionHistoryLimit = PositiveInt.check(Schema.isLessThanOrEqualTo(100)) + +export const SessionHistoryQuery = Schema.Struct({ + limit: Schema.NumberFromString.pipe(Schema.decodeTo(SessionHistoryLimit), Schema.optional), + after: Schema.NumberFromString.pipe(Schema.decodeTo(NonNegativeInt), Schema.optional), +}) + const SessionsQueryCursor = SessionsCursor.annotate({ description: "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response.", }) @@ -289,6 +303,26 @@ export const makeSessionGroup = (sessionLo }), ), ) + .add( + HttpApiEndpoint.get("session.history", "/api/session/:sessionID/history", { + params: { sessionID: Session.ID }, + query: SessionHistoryQuery, + success: Schema.Struct({ + data: Schema.Array(SessionEvent.Durable), + hasMore: Schema.Boolean, + }).annotate({ identifier: "SessionHistory" }), + error: SessionNotFoundError, + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.history", + summary: "Get session history", + description: + "Read one finite page of public durable Session events after an exclusive aggregate sequence. Newly committed events may appear on later pages.", + }), + ), + ) .add( HttpApiEndpoint.get("session.events", "/api/session/:sessionID/event", { params: { sessionID: Session.ID }, diff --git a/packages/protocol/test/session-cursor.test.ts b/packages/protocol/test/session-cursor.test.ts index 60755f63a5..2680c962e1 100644 --- a/packages/protocol/test/session-cursor.test.ts +++ b/packages/protocol/test/session-cursor.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" -import { Effect } from "effect" -import { SessionsCursor } from "../src/groups/session" +import { Effect, Schema } from "effect" +import { SessionHistoryQuery, SessionsCursor } from "../src/groups/session" import { Session } from "@opencode-ai/schema/session" describe("SessionsCursor", () => { @@ -16,3 +16,11 @@ describe("SessionsCursor", () => { expect(await Effect.runPromise(SessionsCursor.parse(cursor))).toEqual(input) }) }) + +describe("SessionHistoryQuery", () => { + test("decodes numeric paging inputs", async () => { + const query = await Effect.runPromise(Schema.decodeUnknownEffect(SessionHistoryQuery)({ after: "3", limit: "10" })) + + expect(query).toEqual({ after: 3, limit: 10 }) + }) +}) diff --git a/packages/schema/src/durable-event-manifest.ts b/packages/schema/src/durable-event-manifest.ts index ed3fb98bb2..acdcb3e9d5 100644 --- a/packages/schema/src/durable-event-manifest.ts +++ b/packages/schema/src/durable-event-manifest.ts @@ -4,6 +4,11 @@ import { Event } from "./event" import { SessionEvent } from "./session-event" import { SessionV1 } from "./session-v1" +export const SessionDurable = { + definitions: Event.durable(SessionEvent.DurableDefinitions), + schema: SessionEvent.Durable, +} as const + export const Durable = Event.durable([ ...SessionV1.Event.Definitions.filter((definition) => definition.durable !== undefined), ...SessionEvent.DurableDefinitions, diff --git a/packages/schema/src/event.ts b/packages/schema/src/event.ts index 3bd0da9894..0d6ec9775a 100644 --- a/packages/schema/src/event.ts +++ b/packages/schema/src/event.ts @@ -55,7 +55,7 @@ export function define< id: ID, metadata: optional(Schema.Record(Schema.String, Schema.Unknown)), type: Schema.Literal(input.type), - durable: optional(Schema.Struct({ aggregateID: Schema.String, seq: Schema.Number, version: Schema.Number })), + durable: optional(Schema.Struct({ aggregateID: Schema.String, seq: Schema.Int, version: Schema.Int })), location: optional(Location.Ref), data, }) @@ -95,7 +95,7 @@ export function versionedType(type: string, version: number) { return `${type}.${version}` } -export function durable(definitions: ReadonlyArray) { +export function durable>(definitions: Definitions) { return readonlyMap( definitions.reduce((result, definition) => { if (!definition.durable) return result @@ -103,7 +103,7 @@ export function durable(definitions: ReadonlyArray) { if (result.has(key)) throw new Error(`Duplicate durable event definition for ${key}`) result.set(key, definition) return result - }, new Map()), + }, new Map()), ) } diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index f58c3dcce0..3a559c3e38 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -511,7 +511,9 @@ export const Definitions = Event.inventory( RevertEvent.Committed, ) -export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" }).pipe(Schema.toTaggedUnion("type")) +export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" }) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ identifier: "SessionDurableEvent" }) export type DurableEvent = typeof Durable.Type export const All = Schema.Union(Definitions, { mode: "oneOf" }).pipe(Schema.toTaggedUnion("type")) diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 476b2cc5c0..de6c62bc26 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -5,6 +5,7 @@ "type": "module", "license": "MIT", "scripts": { + "test": "bun test", "typecheck": "tsgo --noEmit", "build": "bun ./script/build.ts" }, diff --git a/packages/sdk/js/script/build.ts b/packages/sdk/js/script/build.ts index 72f4e3f3e9..79e0879c9e 100755 --- a/packages/sdk/js/script/build.ts +++ b/packages/sdk/js/script/build.ts @@ -13,6 +13,37 @@ const opencode = path.resolve(dir, "../../opencode") await $`bun dev generate > ${dir}/openapi.json`.cwd(opencode) +const document = (await Bun.file("./openapi.json").json()) as { + components?: { schemas?: Record } + [key: string]: unknown +} +const schemas = document.components?.schemas +if (schemas) { + const reachable = new Set() + const visit = (value: unknown) => { + if (Array.isArray(value)) { + value.forEach(visit) + return + } + if (typeof value !== "object" || value === null) return + for (const [key, child] of Object.entries(value)) { + if (key === "$ref" && typeof child === "string" && child.startsWith("#/components/schemas/")) { + const name = child.slice("#/components/schemas/".length) + if (reachable.has(name)) continue + reachable.add(name) + visit(schemas[name]) + } else { + visit(child) + } + } + } + visit({ ...document, components: { ...document.components, schemas: undefined } }) + for (const name of Object.keys(schemas)) { + if (/^SessionNext\w+1$/.test(name) && !reachable.has(name)) delete schemas[name] + } + await Bun.write("./openapi.json", JSON.stringify(document)) +} + await createClient({ input: "./openapi.json", output: { @@ -40,6 +71,29 @@ await createClient({ ], }) +const generatedTypes = await Bun.file("./src/v2/gen/types.gen.ts").text() +if (/export type SessionNext\w+1 =/.test(generatedTypes)) { + throw new Error("Session history generated duplicate Session event variants") +} +const historyTypesPatched = generatedTypes.replace( + /(export type V2SessionHistoryData = \{[\s\S]*?query\?: \{\s*limit\?: )string([;,]\s*after\?: )string/, + "$1number$2number", +) +if (historyTypesPatched === generatedTypes) { + throw new Error("Session history numeric query patch did not apply") +} +await Bun.write("./src/v2/gen/types.gen.ts", historyTypesPatched) + +const generatedSdk = await Bun.file("./src/v2/gen/sdk.gen.ts").text() +const historySdkPatched = generatedSdk.replace( + /(Get session history[\s\S]*?parameters: \{\s*sessionID: string[;,]\s*limit\?: )string([;,]\s*after\?: )string/, + "$1number$2number", +) +if (historySdkPatched === generatedSdk) { + throw new Error("Session history numeric SDK patch did not apply") +} +await Bun.write("./src/v2/gen/sdk.gen.ts", historySdkPatched) + // Patch a @hey-api/openapi-ts codegen bug: SseFn incorrectly passes the // endpoint's TError into the second generic of ServerSentEventsResult, which // is the AsyncGenerator's TReturn slot. Iterator return values have nothing diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 73fb662271..9ed0084aac 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -345,6 +345,8 @@ import type { V2SessionEventsResponses, V2SessionGetErrors, V2SessionGetResponses, + V2SessionHistoryErrors, + V2SessionHistoryResponses, V2SessionInterruptErrors, V2SessionInterruptResponses, V2SessionListErrors, @@ -5710,6 +5712,38 @@ export class Session3 extends HeyApiClient { }) } + /** + * Get session history + * + * Read one finite page of public durable Session events after an exclusive aggregate sequence. Newly committed events may appear on later pages. + */ + public history( + parameters: { + sessionID: string + limit?: number + after?: number + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "limit" }, + { in: "query", key: "after" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/api/session/{sessionID}/history", + ...options, + ...params, + }) + } + /** * Subscribe to session events * diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 98335fcab0..097b68152e 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2731,6 +2731,43 @@ export type UnknownError1 = { ref?: string } +export type SessionDurableEvent = + | SessionNextAgentSwitched + | SessionNextModelSwitched + | SessionNextMoved + | SessionNextPrompted + | SessionNextPromptAdmitted + | SessionNextContextUpdated + | SessionNextSynthetic + | SessionNextShellStarted + | SessionNextShellEnded + | SessionNextStepStarted + | SessionNextStepEnded + | SessionNextStepFailed + | SessionNextTextStarted + | SessionNextTextEnded + | SessionNextToolInputStarted + | SessionNextToolInputEnded + | SessionNextToolCalled + | SessionNextToolProgress + | SessionNextToolSuccess + | SessionNextToolFailed + | SessionNextReasoningStarted + | SessionNextReasoningEnded + | SessionNextRetried + | SessionNextCompactionStarted + | SessionNextCompactionEnded + | SessionNextRevertStaged + | SessionNextRevertCleared + | SessionNextRevertCommitted + +export type SessionHistory = { + data: Array + hasMore: boolean +} + +export type SessionDurableEvent1 = string + export type SessionMessagesResponse = { data: Array cursor: { @@ -4057,8 +4094,8 @@ export type SessionNextAgentSwitched = { type: "session.next.agent.switched" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number + version: number } location?: LocationRef data: { @@ -4077,8 +4114,8 @@ export type SessionNextModelSwitched = { type: "session.next.model.switched" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number + version: number } location?: LocationRef data: { @@ -4097,8 +4134,8 @@ export type SessionNextMoved = { type: "session.next.moved" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number + version: number } location?: LocationRef data: { @@ -4117,8 +4154,8 @@ export type SessionNextPrompted = { type: "session.next.prompted" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number + version: number } location?: LocationRef data: { @@ -4138,8 +4175,8 @@ export type SessionNextPromptAdmitted = { type: "session.next.prompt.admitted" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number + version: number } location?: LocationRef data: { @@ -4159,8 +4196,8 @@ export type SessionNextContextUpdated = { type: "session.next.context.updated" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number + version: number } location?: LocationRef data: { @@ -4179,8 +4216,8 @@ export type SessionNextSynthetic = { type: "session.next.synthetic" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number + version: number } location?: LocationRef data: { @@ -4199,8 +4236,8 @@ export type SessionNextShellStarted = { type: "session.next.shell.started" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number + version: number } location?: LocationRef data: { @@ -4220,8 +4257,8 @@ export type SessionNextShellEnded = { type: "session.next.shell.ended" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number + version: number } location?: LocationRef data: { @@ -4240,8 +4277,8 @@ export type SessionNextStepStarted = { type: "session.next.step.started" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number + version: number } location?: LocationRef data: { @@ -4262,8 +4299,8 @@ export type SessionNextStepEnded = { type: "session.next.step.ended" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number + version: number } location?: LocationRef data: { @@ -4294,8 +4331,8 @@ export type SessionNextStepFailed = { type: "session.next.step.failed" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number + version: number } location?: LocationRef data: { @@ -4314,8 +4351,8 @@ export type SessionNextTextStarted = { type: "session.next.text.started" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number + version: number } location?: LocationRef data: { @@ -4334,8 +4371,8 @@ export type SessionNextTextEnded = { type: "session.next.text.ended" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number + version: number } location?: LocationRef data: { @@ -4355,8 +4392,8 @@ export type SessionNextToolInputStarted = { type: "session.next.tool.input.started" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number + version: number } location?: LocationRef data: { @@ -4376,8 +4413,8 @@ export type SessionNextToolInputEnded = { type: "session.next.tool.input.ended" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number + version: number } location?: LocationRef data: { @@ -4397,8 +4434,8 @@ export type SessionNextToolCalled = { type: "session.next.tool.called" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number + version: number } location?: LocationRef data: { @@ -4425,8 +4462,8 @@ export type SessionNextToolProgress = { type: "session.next.tool.progress" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number + version: number } location?: LocationRef data: { @@ -4449,8 +4486,8 @@ export type SessionNextToolSuccess = { type: "session.next.tool.success" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number + version: number } location?: LocationRef data: { @@ -4479,8 +4516,8 @@ export type SessionNextToolFailed = { type: "session.next.tool.failed" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number + version: number } location?: LocationRef data: { @@ -4505,8 +4542,8 @@ export type SessionNextReasoningStarted = { type: "session.next.reasoning.started" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number + version: number } location?: LocationRef data: { @@ -4526,8 +4563,8 @@ export type SessionNextReasoningEnded = { type: "session.next.reasoning.ended" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number + version: number } location?: LocationRef data: { @@ -4548,8 +4585,8 @@ export type SessionNextRetried = { type: "session.next.retried" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number + version: number } location?: LocationRef data: { @@ -4568,8 +4605,8 @@ export type SessionNextCompactionStarted = { type: "session.next.compaction.started" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number + version: number } location?: LocationRef data: { @@ -4588,8 +4625,8 @@ export type SessionNextCompactionEnded = { type: "session.next.compaction.ended" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number + version: number } location?: LocationRef data: { @@ -4610,8 +4647,8 @@ export type SessionNextRevertStaged = { type: "session.next.revert.staged" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number + version: number } location?: LocationRef data: { @@ -4629,8 +4666,8 @@ export type SessionNextRevertCleared = { type: "session.next.revert.cleared" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number + version: number } location?: LocationRef data: { @@ -4647,8 +4684,8 @@ export type SessionNextRevertCommitted = { type: "session.next.revert.committed" durable?: { aggregateID: string - seq: number | "NaN" | "Infinity" | "-Infinity" - version: number | "NaN" | "Infinity" | "-Infinity" + seq: number + version: number } location?: LocationRef data: { @@ -12384,6 +12421,44 @@ export type V2SessionContextResponses = { export type V2SessionContextResponse = V2SessionContextResponses[keyof V2SessionContextResponses] +export type V2SessionHistoryData = { + body?: never + path: { + sessionID: string + } + query?: { + limit?: number + after?: number + } + url: "/api/session/{sessionID}/history" +} + +export type V2SessionHistoryErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError +} + +export type V2SessionHistoryError = V2SessionHistoryErrors[keyof V2SessionHistoryErrors] + +export type V2SessionHistoryResponses = { + /** + * SessionHistory + */ + 200: SessionHistory +} + +export type V2SessionHistoryResponse = V2SessionHistoryResponses[keyof V2SessionHistoryResponses] + export type V2SessionEventsData = { body?: never path: { @@ -12419,7 +12494,7 @@ export type V2SessionEventsResponses = { 200: { id: string event: string - data: string + data: SessionDurableEvent1 } } diff --git a/packages/sdk/js/test/session-history.test.ts b/packages/sdk/js/test/session-history.test.ts new file mode 100644 index 0000000000..44a9743331 --- /dev/null +++ b/packages/sdk/js/test/session-history.test.ts @@ -0,0 +1,12 @@ +import { expect, test } from "bun:test" +import type { V2SessionHistoryData } from "../src/v2/gen/types.gen" + +test("uses numeric Session history positions", () => { + const input = { + path: { sessionID: "ses_test" }, + query: { after: 1, limit: 50 }, + url: "/api/session/{sessionID}/history", + } satisfies V2SessionHistoryData + + expect(input.query.after).toBe(1) +}) diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index 602673e1ff..5b7d354b04 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -14,6 +14,7 @@ import { import { AbsolutePath } from "@opencode-ai/core/schema" const DefaultSessionsLimit = 50 +const DefaultSessionHistoryLimit = 50 export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) => Effect.gen(function* () { @@ -328,6 +329,31 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl } }), ) + .handle( + "session.history", + Effect.fn(function* (ctx) { + return yield* session + .history({ + sessionID: ctx.params.sessionID, + after: ctx.query.after, + limit: ctx.query.limit ?? DefaultSessionHistoryLimit, + }) + .pipe( + Effect.map((page) => ({ + data: page.events, + hasMore: page.hasMore, + })), + Effect.catchTag( + "Session.NotFoundError", + (error) => + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ) + }), + ) .handle( "session.events", Effect.fn((ctx) => diff --git a/specs/v2/schema-changelog.md b/specs/v2/schema-changelog.md index 9f0cc83856..379c675d3d 100644 --- a/specs/v2/schema-changelog.md +++ b/specs/v2/schema-changelog.md @@ -1,5 +1,12 @@ # V2 Schema Changelog +## 2026-06-26: Add Finite Session History + +- Add `GET /api/session/:sessionID/history` and generated Promise, Effect, and legacy JavaScript client methods. +- Page public durable Session events after an optional exclusive aggregate sequence, with an explicit `hasMore` exhaustion signal. +- Keep aggregate gaps legal, cap pages at 100 events, and preserve the existing durable replay-and-tail `sessions.events()` stream unchanged. +- Add no migration or durable-event version; this is a finite read API over the existing event manifest. + ## 2026-06-22: Simplify Session Input Promotion - Keep `session.next.prompt.admitted.1` as the durable, client-visible record of pending Session input. diff --git a/specs/v2/session.md b/specs/v2/session.md index 54daa69713..f3c4211c6c 100644 --- a/specs/v2/session.md +++ b/specs/v2/session.md @@ -176,6 +176,10 @@ The synchronized `session.next.*` event family and projected Session-message mod The first `sessions.events(...)` contract is durable-only during both replay and live tailing. This keeps one cursor equal to one persisted aggregate sequence and is sufficient for reconnect-safe consumers. A later UI-facing API may optionally interleave live-only deltas while connected, but those fragments must remain explicitly ephemeral: they cannot advance the durable cursor, replay after reconnect, or be mistaken for publication boundaries. +`sessions.history({ sessionID, after?, limit? })` is the finite counterpart for request/response consumers. `after` is an exclusive aggregate sequence, and omission starts before sequence zero. The response is `{ data, hasMore }`; callers derive the next `after` from the final event's durable sequence when `hasMore` is true. Public durable Session events are selected before pagination, which permits gaps from private or historical aggregate events while preserving strictly increasing unique sequences. The log has a moving head, so events committed between pages may appear on the next page. + +The finite endpoint is `GET /api/session/:sessionID/history`, uses the normal Session Location and authorization middleware, defaults to 50 events, and accepts at most 100. It returns only events in the public durable Session schema. The existing `sessions.events()` replay-and-tail stream is unchanged. + Durable event tail wakeups are advisory and edge-triggered. Each active tail owns one sliding-capacity-1 dirty signal for its aggregate and re-queries SQLite after a wake. Repeated commits coalesce while the tail is busy because durable rows, not in-memory notifications, preserve every event and sequence. Subscribe and register the dirty signal before historical replay, then remove it when the tail closes, so replay handoff cannot miss a commit and inactive aggregates retain no wake state. Event replay owner claims are separate from clustered Session execution ownership. The former already fences synchronized projection reconstruction; the latter still needs distributed active-run acquisition, stale-runtime rejection, interruption, and placement orchestration. @@ -206,7 +210,7 @@ The first V2 `apply_patch` leaf supports add, update, and delete hunks. It parse - Keep eager structured local-tool settlement: durably record each complete call, start its child execution immediately, await all started settlements after provider-turn consumption, persist every result, and reload history once before continuation. - Buffer or coalesce streamed deltas before rewriting growing assistant projections. - Revisit additional covering indexes as larger-history query shapes become concrete. -- Expose replayable Session events over HTTP and the generated SDK where remote consumers need them, deciding whether that public cursor should be opaque rather than the embedded API's branded aggregate sequence. +- Design any global multi-Session event stream separately; the finite history API deliberately reads one authorized Session aggregate and does not change global Event publication. - Decide whether UI-facing Session subscriptions should optionally interleave ephemeral deltas while connected without advancing the durable cursor. - Add provider-aware context control for provider-executed tool results. Generic text truncation cannot replace provider-native structured payloads that must round-trip exactly. From 20f47fec7a79c9fbd408661a0ef793c826926598 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 26 Jun 2026 18:51:42 +0000 Subject: [PATCH 03/45] chore: generate --- packages/client/test/effect.test.ts | 15 +- packages/client/test/promise.test.ts | 4 +- packages/core/src/session.ts | 5 +- packages/sdk/openapi.json | 1301 +++++--------------------- 4 files changed, 263 insertions(+), 1062 deletions(-) diff --git a/packages/client/test/effect.test.ts b/packages/client/test/effect.test.ts index 865da23b93..5b07ad5d08 100644 --- a/packages/client/test/effect.test.ts +++ b/packages/client/test/effect.test.ts @@ -1,16 +1,7 @@ import { expect, test } from "bun:test" import { DateTime, Effect, Stream } from "effect" import { HttpClient, HttpClientResponse } from "effect/unstable/http" -import { - AbsolutePath, - Agent, - Location, - Model, - OpenCode, - Prompt, - Session, - SessionMessage, -} from "../src/effect" +import { AbsolutePath, Agent, Location, Model, OpenCode, Prompt, Session, SessionMessage } from "../src/effect" test("sessions.get returns the decoded Effect projection", async () => { const httpClient = HttpClient.make((request) => @@ -46,9 +37,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => { HttpClientResponse.fromWeb( request, Response.json( - historyPage === 1 - ? { data: [modelSwitchedEvent], hasMore: true } - : { data: [], hasMore: false }, + historyPage === 1 ? { data: [modelSwitchedEvent], hasMore: true } : { data: [], hasMore: false }, ), ), ) diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index 4f56938024..5523da2da3 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -33,9 +33,7 @@ test("session methods use the public HTTP contract", async () => { if (url.includes("/history")) { historyPage++ return Response.json( - historyPage === 1 - ? { data: [modelSwitchedEvent], hasMore: true } - : { data: [], hasMore: false }, + historyPage === 1 ? { data: [modelSwitchedEvent], hasMore: true } : { data: [], hasMore: false }, ) } if (url.includes("/prompt")) return Response.json(admission) diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 8d9ad9cd9f..8ccb9fa293 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -136,10 +136,7 @@ export interface Interface { sessionID: SessionSchema.ID after?: number limit: number - }) => Effect.Effect< - { events: ReadonlyArray; hasMore: boolean }, - NotFoundError - > + }) => Effect.Effect<{ events: ReadonlyArray; hasMore: boolean }, NotFoundError> readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect readonly switchModel: (input: { sessionID: SessionSchema.ID diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 92557bffb8..7a4351907a 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -11130,6 +11130,97 @@ ] } }, + "/api/session/{sessionID}/history": { + "get": { + "tags": ["sessions"], + "operationId": "v2.session.history", + "parameters": [ + { + "name": "sessionID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^ses" + }, + "required": true + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "after", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + } + ], + "security": [], + "responses": { + "200": { + "description": "SessionHistory", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionHistory" + } + } + } + }, + "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": "Read one finite page of public durable Session events after an exclusive aggregate sequence. Newly committed events may appear on later pages.", + "summary": "Get session history", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.v2.session.history({\n ...\n})" + } + ] + } + }, "/api/session/{sessionID}/event": { "get": { "tags": ["sessions"], @@ -11169,96 +11260,7 @@ "type": "string" }, "data": { - "type": "string", - "contentSchema": { - "oneOf": [ - { - "$ref": "#/components/schemas/SessionNextAgentSwitched" - }, - { - "$ref": "#/components/schemas/SessionNextModelSwitched" - }, - { - "$ref": "#/components/schemas/SessionNextMoved" - }, - { - "$ref": "#/components/schemas/SessionNextPrompted" - }, - { - "$ref": "#/components/schemas/SessionNextPromptAdmitted" - }, - { - "$ref": "#/components/schemas/SessionNextContextUpdated" - }, - { - "$ref": "#/components/schemas/SessionNextSynthetic" - }, - { - "$ref": "#/components/schemas/SessionNextShellStarted" - }, - { - "$ref": "#/components/schemas/SessionNextShellEnded" - }, - { - "$ref": "#/components/schemas/SessionNextStepStarted" - }, - { - "$ref": "#/components/schemas/SessionNextStepEnded" - }, - { - "$ref": "#/components/schemas/SessionNextStepFailed" - }, - { - "$ref": "#/components/schemas/SessionNextTextStarted" - }, - { - "$ref": "#/components/schemas/SessionNextTextEnded" - }, - { - "$ref": "#/components/schemas/SessionNextToolInputStarted" - }, - { - "$ref": "#/components/schemas/SessionNextToolInputEnded" - }, - { - "$ref": "#/components/schemas/SessionNextToolCalled" - }, - { - "$ref": "#/components/schemas/SessionNextToolProgress" - }, - { - "$ref": "#/components/schemas/SessionNextToolSuccess" - }, - { - "$ref": "#/components/schemas/SessionNextToolFailed" - }, - { - "$ref": "#/components/schemas/SessionNextReasoningStarted" - }, - { - "$ref": "#/components/schemas/SessionNextReasoningEnded" - }, - { - "$ref": "#/components/schemas/SessionNextRetried" - }, - { - "$ref": "#/components/schemas/SessionNextCompactionStarted" - }, - { - "$ref": "#/components/schemas/SessionNextCompactionEnded" - }, - { - "$ref": "#/components/schemas/SessionNextRevertStaged" - }, - { - "$ref": "#/components/schemas/SessionNextRevertCleared" - }, - { - "$ref": "#/components/schemas/SessionNextRevertCommitted" - } - ] - }, - "contentMediaType": "application/json" + "$ref": "#/components/schemas/SessionDurableEvent1" } }, "required": ["id", "event", "data"], @@ -23626,6 +23628,117 @@ "required": ["_tag", "message"], "additionalProperties": false }, + "SessionDurableEvent": { + "oneOf": [ + { + "$ref": "#/components/schemas/SessionNextAgentSwitched" + }, + { + "$ref": "#/components/schemas/SessionNextModelSwitched" + }, + { + "$ref": "#/components/schemas/SessionNextMoved" + }, + { + "$ref": "#/components/schemas/SessionNextPrompted" + }, + { + "$ref": "#/components/schemas/SessionNextPromptAdmitted" + }, + { + "$ref": "#/components/schemas/SessionNextContextUpdated" + }, + { + "$ref": "#/components/schemas/SessionNextSynthetic" + }, + { + "$ref": "#/components/schemas/SessionNextShellStarted" + }, + { + "$ref": "#/components/schemas/SessionNextShellEnded" + }, + { + "$ref": "#/components/schemas/SessionNextStepStarted" + }, + { + "$ref": "#/components/schemas/SessionNextStepEnded" + }, + { + "$ref": "#/components/schemas/SessionNextStepFailed" + }, + { + "$ref": "#/components/schemas/SessionNextTextStarted" + }, + { + "$ref": "#/components/schemas/SessionNextTextEnded" + }, + { + "$ref": "#/components/schemas/SessionNextToolInputStarted" + }, + { + "$ref": "#/components/schemas/SessionNextToolInputEnded" + }, + { + "$ref": "#/components/schemas/SessionNextToolCalled" + }, + { + "$ref": "#/components/schemas/SessionNextToolProgress" + }, + { + "$ref": "#/components/schemas/SessionNextToolSuccess" + }, + { + "$ref": "#/components/schemas/SessionNextToolFailed" + }, + { + "$ref": "#/components/schemas/SessionNextReasoningStarted" + }, + { + "$ref": "#/components/schemas/SessionNextReasoningEnded" + }, + { + "$ref": "#/components/schemas/SessionNextRetried" + }, + { + "$ref": "#/components/schemas/SessionNextCompactionStarted" + }, + { + "$ref": "#/components/schemas/SessionNextCompactionEnded" + }, + { + "$ref": "#/components/schemas/SessionNextRevertStaged" + }, + { + "$ref": "#/components/schemas/SessionNextRevertCleared" + }, + { + "$ref": "#/components/schemas/SessionNextRevertCommitted" + } + ] + }, + "SessionHistory": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SessionDurableEvent" + } + }, + "hasMore": { + "type": "boolean" + } + }, + "required": ["data", "hasMore"], + "additionalProperties": false + }, + "SessionDurableEvent1": { + "type": "string", + "contentSchema": { + "$ref": "#/components/schemas/SessionDurableEvent" + }, + "contentMediaType": "application/json" + }, "SessionMessagesResponse": { "type": "object", "properties": { @@ -27643,42 +27756,10 @@ "type": "string" }, "seq": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] + "type": "integer" }, "version": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] + "type": "integer" } }, "required": ["aggregateID", "seq", "version"], @@ -27733,42 +27814,10 @@ "type": "string" }, "seq": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] + "type": "integer" }, "version": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] + "type": "integer" } }, "required": ["aggregateID", "seq", "version"], @@ -27823,42 +27872,10 @@ "type": "string" }, "seq": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] + "type": "integer" }, "version": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] + "type": "integer" } }, "required": ["aggregateID", "seq", "version"], @@ -27912,42 +27929,10 @@ "type": "string" }, "seq": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] + "type": "integer" }, "version": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] + "type": "integer" } }, "required": ["aggregateID", "seq", "version"], @@ -28006,42 +27991,10 @@ "type": "string" }, "seq": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] + "type": "integer" }, "version": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] + "type": "integer" } }, "required": ["aggregateID", "seq", "version"], @@ -28100,42 +28053,10 @@ "type": "string" }, "seq": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] + "type": "integer" }, "version": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] + "type": "integer" } }, "required": ["aggregateID", "seq", "version"], @@ -28190,42 +28111,10 @@ "type": "string" }, "seq": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] + "type": "integer" }, "version": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] + "type": "integer" } }, "required": ["aggregateID", "seq", "version"], @@ -28280,42 +28169,10 @@ "type": "string" }, "seq": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] + "type": "integer" }, "version": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] + "type": "integer" } }, "required": ["aggregateID", "seq", "version"], @@ -28373,42 +28230,10 @@ "type": "string" }, "seq": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] + "type": "integer" }, "version": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] + "type": "integer" } }, "required": ["aggregateID", "seq", "version"], @@ -28462,42 +28287,10 @@ "type": "string" }, "seq": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] + "type": "integer" }, "version": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] + "type": "integer" } }, "required": ["aggregateID", "seq", "version"], @@ -28558,42 +28351,10 @@ "type": "string" }, "seq": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] + "type": "integer" }, "version": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] + "type": "integer" } }, "required": ["aggregateID", "seq", "version"], @@ -28689,42 +28450,10 @@ "type": "string" }, "seq": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] + "type": "integer" }, "version": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] + "type": "integer" } }, "required": ["aggregateID", "seq", "version"], @@ -28779,42 +28508,10 @@ "type": "string" }, "seq": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] + "type": "integer" }, "version": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] + "type": "integer" } }, "required": ["aggregateID", "seq", "version"], @@ -28869,42 +28566,10 @@ "type": "string" }, "seq": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] + "type": "integer" }, "version": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] + "type": "integer" } }, "required": ["aggregateID", "seq", "version"], @@ -28962,42 +28627,10 @@ "type": "string" }, "seq": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] + "type": "integer" }, "version": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] + "type": "integer" } }, "required": ["aggregateID", "seq", "version"], @@ -29055,42 +28688,10 @@ "type": "string" }, "seq": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] + "type": "integer" }, "version": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] + "type": "integer" } }, "required": ["aggregateID", "seq", "version"], @@ -29148,42 +28749,10 @@ "type": "string" }, "seq": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] + "type": "integer" }, "version": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] + "type": "integer" } }, "required": ["aggregateID", "seq", "version"], @@ -29257,42 +28826,10 @@ "type": "string" }, "seq": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] + "type": "integer" }, "version": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] + "type": "integer" } }, "required": ["aggregateID", "seq", "version"], @@ -29356,42 +28893,10 @@ "type": "string" }, "seq": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] + "type": "integer" }, "version": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] + "type": "integer" } }, "required": ["aggregateID", "seq", "version"], @@ -29475,42 +28980,10 @@ "type": "string" }, "seq": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] + "type": "integer" }, "version": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] + "type": "integer" } }, "required": ["aggregateID", "seq", "version"], @@ -29582,42 +29055,10 @@ "type": "string" }, "seq": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] + "type": "integer" }, "version": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] + "type": "integer" } }, "required": ["aggregateID", "seq", "version"], @@ -29675,42 +29116,10 @@ "type": "string" }, "seq": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] + "type": "integer" }, "version": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] + "type": "integer" } }, "required": ["aggregateID", "seq", "version"], @@ -29771,42 +29180,10 @@ "type": "string" }, "seq": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] + "type": "integer" }, "version": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] + "type": "integer" } }, "required": ["aggregateID", "seq", "version"], @@ -29860,42 +29237,10 @@ "type": "string" }, "seq": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] + "type": "integer" }, "version": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] + "type": "integer" } }, "required": ["aggregateID", "seq", "version"], @@ -29951,42 +29296,10 @@ "type": "string" }, "seq": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] + "type": "integer" }, "version": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] + "type": "integer" } }, "required": ["aggregateID", "seq", "version"], @@ -30048,42 +29361,10 @@ "type": "string" }, "seq": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] + "type": "integer" }, "version": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] + "type": "integer" } }, "required": ["aggregateID", "seq", "version"], @@ -30134,42 +29415,10 @@ "type": "string" }, "seq": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] + "type": "integer" }, "version": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] + "type": "integer" } }, "required": ["aggregateID", "seq", "version"], @@ -30217,42 +29466,10 @@ "type": "string" }, "seq": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] + "type": "integer" }, "version": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] + "type": "integer" } }, "required": ["aggregateID", "seq", "version"], From 2ec20e576b849a30505cbab0b87a8c0cac888dd4 Mon Sep 17 00:00:00 2001 From: Affan Ali <93028901+affanali2k3@users.noreply.github.com> Date: Fri, 26 Jun 2026 23:53:08 +0500 Subject: [PATCH 04/45] fix(app): slow tooltip display for models (#30745) Co-authored-by: affanali2k3 --- packages/app/src/components/dialog-select-model.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index e16d9a6657..b4e8b3b719 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -59,6 +59,7 @@ const ModelList: Component<{ class="w-full" placement="right-start" gutter={12} + openDelay={0} value={} > {node} From 2c02f8baceead9b6d2748506790078bebf0d1419 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 26 Jun 2026 18:53:34 +0000 Subject: [PATCH 05/45] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index 3b57bf2269..b0212de9b0 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-OiWvZ57vuyHwiIKNtW1n1KX+MLmOXVG3x4fLKvUoGQw=", - "aarch64-linux": "sha256-RnPLxVEg/UsL5IeIFWmXMSLUOG6rVrajYxhyDYj1vTA=", - "aarch64-darwin": "sha256-KPIgcBA0pTFBPrCTSZgIbvEorbtWcMgXvyX9bFAypVs=", - "x86_64-darwin": "sha256-6jVU7/uVId0VD24MVQ8s8Ill5b6PsKdlBgHg+oceKRg=" + "x86_64-linux": "sha256-rB/CcrBUQVZ08nBFSYA8u2w88rQmTpKxKPkIreDEKgI=", + "aarch64-linux": "sha256-ZRTphtic8Ip96MnILteFgZAUxjK9O4YfJu2O6u/0H8k=", + "aarch64-darwin": "sha256-VK5XIzraP0HtqnPwPCejiDKer4ewtNtX1vxP5uuyjSk=", + "x86_64-darwin": "sha256-ZLPHqcCZB1EmxQk95cmUpiODTTKOyi7PSF0yr/rDk6Y=" } } From 42e6b7db32d180f40e4b33669213b4e608dfc517 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 26 Jun 2026 21:16:33 +0200 Subject: [PATCH 06/45] feat(sdk): expose live event stream (#34098) --- packages/client/script/build.ts | 21 +++-- packages/client/src/contract.ts | 4 +- packages/client/src/effect.ts | 1 + .../client/src/generated-effect/client.ts | 25 ++++-- packages/client/src/generated/client.ts | 8 ++ packages/client/src/generated/types.ts | 4 + packages/client/src/index.ts | 1 + .../client/test/contract-identity.test.ts | 3 +- packages/client/test/effect.test.ts | 44 ++++++++++ packages/client/test/promise.test.ts | 29 +++++++ packages/core/src/event.ts | 21 ++++- packages/core/test/event.test.ts | 65 +++++++++++++- packages/httpapi-codegen/src/index.ts | 33 ++++--- .../httpapi-codegen/test/generate.test.ts | 18 ++++ .../test/server/httpapi-v2-location.test.ts | 45 ++++++++-- packages/protocol/src/groups/event.ts | 23 +++-- packages/sdk-next/src/index.ts | 1 + packages/sdk-next/test/embedded.test.ts | 85 ++++++++++++++++++- packages/server/src/handlers/event.ts | 22 +++-- 19 files changed, 396 insertions(+), 57 deletions(-) diff --git a/packages/client/script/build.ts b/packages/client/script/build.ts index 35ce8387a8..88e5ea6e5b 100644 --- a/packages/client/script/build.ts +++ b/packages/client/script/build.ts @@ -1,20 +1,29 @@ import { NodeFileSystem } from "@effect/platform-node" import { compile, emitEffectImported, emitPromise, write } from "@opencode-ai/httpapi-codegen" -import { Api } from "@opencode-ai/server/api" +import { ClientApi } from "../src/contract" import { Effect } from "effect" -import { HttpApi } from "effect/unstable/httpapi" import { fileURLToPath } from "url" -const contract = compile(HttpApi.make("opencode-client").add(Api.groups["server.session"]), { - groupNames: { "server.session": "sessions" }, +const contract = compile(ClientApi, { + groupNames: { "server.session": "sessions", "server.event": "events" }, }) await Effect.runPromise( Effect.all( [ - write(emitPromise(contract), fileURLToPath(new URL("../src/generated", import.meta.url))), write( - emitEffectImported(contract, { module: "../contract", group: "SessionGroup" }), + emitPromise(contract, { + outputTypes: { + "events.subscribe": { + name: "OpenCodeEventEncoded", + import: 'import type { OpenCodeEventEncoded } from "@opencode-ai/protocol/groups/event"', + }, + }, + }), + fileURLToPath(new URL("../src/generated", import.meta.url)), + ), + write( + emitEffectImported(contract, { module: "../contract", api: "ClientApi" }), fileURLToPath(new URL("../src/generated-effect", import.meta.url)), ), ], diff --git a/packages/client/src/contract.ts b/packages/client/src/contract.ts index 2190c130ca..15769ad9c2 100644 --- a/packages/client/src/contract.ts +++ b/packages/client/src/contract.ts @@ -1,6 +1,6 @@ import { makeDefaultApi } from "@opencode-ai/protocol/api" import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors" -import { HttpApiMiddleware } from "effect/unstable/httpapi" +import { HttpApi, HttpApiMiddleware } from "effect/unstable/httpapi" class LocationMiddleware extends HttpApiMiddleware.Service()( "@opencode-ai/client/LocationMiddleware", @@ -17,3 +17,5 @@ const Api = makeDefaultApi({ }) export const SessionGroup = Api.groups["server.session"] +export const EventGroup = Api.groups["server.event"] +export const ClientApi = HttpApi.make("opencode-client").add(SessionGroup).add(EventGroup) diff --git a/packages/client/src/effect.ts b/packages/client/src/effect.ts index 6c0aca564e..6664ff2553 100644 --- a/packages/client/src/effect.ts +++ b/packages/client/src/effect.ts @@ -10,3 +10,4 @@ export { Session } from "@opencode-ai/schema/session" export { SessionInput } from "@opencode-ai/schema/session-input" export { SessionMessage } from "@opencode-ai/schema/session-message" export { Prompt } from "@opencode-ai/schema/prompt" +export type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event" diff --git a/packages/client/src/generated-effect/client.ts b/packages/client/src/generated-effect/client.ts index 4856a3ccde..ac7bba7b1f 100644 --- a/packages/client/src/generated-effect/client.ts +++ b/packages/client/src/generated-effect/client.ts @@ -2,13 +2,11 @@ import { Effect, Stream, Schema } from "effect" import { Sse } from "effect/unstable/encoding" import { HttpClientError } from "effect/unstable/http" -import { HttpApi, HttpApiClient } from "effect/unstable/httpapi" -import { SessionGroup } from "../contract" +import { HttpApiClient } from "effect/unstable/httpapi" +import { ClientApi } from "../contract" import { ClientError } from "./client-error" -const Api = HttpApi.make("generated").add(SessionGroup) - -type RawClient = HttpApiClient.ForApi +type RawClient = HttpApiClient.ForApi const mapClientError = (error: E) => HttpClientError.isHttpClientError(error) || Schema.isSchemaError(error) || Sse.Retry.is(error) @@ -210,7 +208,20 @@ const adaptGroup0 = (raw: RawClient["server.session"]) => ({ message: Endpoint0_16(raw), }) -const adaptClient = (raw: RawClient) => ({ sessions: adaptGroup0(raw["server.session"]) }) +const Endpoint1_0 = (raw: RawClient["server.event"]) => () => + Stream.unwrap( + raw["event.subscribe"]({}).pipe( + Effect.mapError(mapClientError), + Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError))), + ), + ) + +const adaptGroup1 = (raw: RawClient["server.event"]) => ({ subscribe: Endpoint1_0(raw) }) + +const adaptClient = (raw: RawClient) => ({ + sessions: adaptGroup0(raw["server.session"]), + events: adaptGroup1(raw["server.event"]), +}) export const make = (options?: { readonly baseUrl?: URL | string }) => - HttpApiClient.make(Api, options).pipe(Effect.map(adaptClient)) + HttpApiClient.make(ClientApi, options).pipe(Effect.map(adaptClient)) diff --git a/packages/client/src/generated/client.ts b/packages/client/src/generated/client.ts index 6366fbb560..c14015f11c 100644 --- a/packages/client/src/generated/client.ts +++ b/packages/client/src/generated/client.ts @@ -32,6 +32,7 @@ import type { SessionsInterruptOutput, SessionsMessageInput, SessionsMessageOutput, + EventsSubscribeOutput, } from "./types" import { ClientError } from "./client-error" @@ -373,6 +374,13 @@ export function make(options: ClientOptions) { requestOptions, ).then((value) => value.data), }, + events: { + subscribe: (requestOptions?: RequestOptions): AsyncIterable => + sse( + { method: "GET", path: `/api/event`, successStatus: 200, declaredStatuses: [401, 400], empty: false }, + requestOptions, + ), + }, } } diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index bfa543ea23..da0b97a7c0 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -1,3 +1,5 @@ +import type { OpenCodeEventEncoded } from "@opencode-ai/protocol/groups/event" + export type JsonValue = | null | boolean @@ -1666,3 +1668,5 @@ export type SessionsMessageOutput = { readonly time: { readonly created: number } } }["data"] + +export type EventsSubscribeOutput = OpenCodeEventEncoded diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 92e36b1c60..6955d7d8c5 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -1 +1,2 @@ export * from "./generated/index" +export type { EventsSubscribeOutput as OpenCodeEvent } from "./generated/types" diff --git a/packages/client/test/contract-identity.test.ts b/packages/client/test/contract-identity.test.ts index 8c9ca70e2d..e71d70751c 100644 --- a/packages/client/test/contract-identity.test.ts +++ b/packages/client/test/contract-identity.test.ts @@ -20,7 +20,7 @@ import { Workspace } from "@opencode-ai/schema/workspace" import { Api } from "@opencode-ai/server/api" import { compile, emitPromise } from "@opencode-ai/httpapi-codegen" import { HttpApi } from "effect/unstable/httpapi" -import { SessionGroup } from "../src/contract" +import { EventGroup, SessionGroup } from "../src/contract" test("Core and Server reuse the authoritative Schema and Protocol values", () => { expect(AgentV2.ID).toBe(Agent.ID) @@ -32,6 +32,7 @@ test("Core and Server reuse the authoritative Schema and Protocol values", () => expect(CorePrompt).toBe(Prompt) expect(Api.groups["server.session"].identifier).toBe("server.session") expect(SessionGroup.identifier).toBe(Api.groups["server.session"].identifier) + expect(EventGroup.identifier).toBe(Api.groups["server.event"].identifier) expect(Session.ID.create()).toStartWith("ses_") expect(Project.ID.global).toBe("global") expect(Provider.ID.anthropic).toBe("anthropic") diff --git a/packages/client/test/effect.test.ts b/packages/client/test/effect.test.ts index 5b07ad5d08..7bf4d26f8f 100644 --- a/packages/client/test/effect.test.ts +++ b/packages/client/test/effect.test.ts @@ -15,6 +15,50 @@ test("sessions.get returns the decoded Effect projection", async () => { expect(DateTime.toEpochMillis(result.time.created)).toBe(1_717_171_717_000) }) +test("events.subscribe exposes and decodes the native Effect event stream", async () => { + const httpClient = HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response( + `data: ${JSON.stringify({ id: "evt_connected", type: "server.connected", data: {} })}\n\n` + + `data: ${JSON.stringify(modelSwitchedEvent)}\n\n`, + { headers: { "content-type": "text/event-stream" } }, + ), + ), + ), + ) + const events = await Effect.gen(function* () { + const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" }) + return yield* client.events.subscribe().pipe(Stream.runCollect) + }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise) + + expect(Array.from(events).map((event) => event.type)).toEqual(["server.connected", "session.next.model.switched"]) + const durable = events[1] + if (durable?.type !== "session.next.model.switched") throw new Error("Expected model event") + expect(DateTime.toEpochMillis(durable.data.timestamp)).toBe(1_717_171_717_000) + expect(durable.durable).toEqual({ aggregateID: "ses_test", seq: 1, version: 1 }) +}) + +test("events.subscribe terminates on Effect protocol decode failures", async () => { + const httpClient = HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(`data: {"type":"server.connected"}\n\n`, { + headers: { "content-type": "text/event-stream" }, + }), + ), + ), + ) + const error = await Effect.gen(function* () { + const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" }) + return yield* client.events.subscribe().pipe(Stream.runCollect, Effect.flip) + }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise) + + expect(error._tag).toBe("ClientError") +}) + test("session methods retain decoded Effect inputs and outputs", async () => { const historyQueries: Array> = [] let historyPage = 0 diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index 5523da2da3..c5081053e5 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -17,6 +17,35 @@ test("sessions.get returns the wire projection", async () => { expect(result.time.created).toBe(1_717_171_717_000) }) +test("events.subscribe exposes the Promise event stream wire projection", async () => { + const client = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: async () => + new Response( + `: heartbeat\n\ndata: ${JSON.stringify({ id: "evt_connected", type: "server.connected", data: {} })}\n\n` + + `data: ${JSON.stringify(modelSwitchedEvent)}\n\n`, + { headers: { "content-type": "text/event-stream" } }, + ), + }) + const events = [] + for await (const event of client.events.subscribe()) events.push(event) + + expect(events).toEqual([{ id: "evt_connected", type: "server.connected", data: {} }, modelSwitchedEvent]) + expect(events[1]?.type === "session.next.model.switched" && events[1].data.timestamp).toBe(1_717_171_717_000) +}) + +test("events.subscribe terminates on malformed Promise SSE data", async () => { + const client = OpenCode.make({ + baseUrl: "http://localhost:3000", + fetch: async () => new Response("data: {not-json}\n\n", { headers: { "content-type": "text/event-stream" } }), + }) + + await expect(client.events.subscribe()[Symbol.asyncIterator]().next()).rejects.toMatchObject({ + name: "ClientError", + reason: "MalformedResponse", + }) +}) + test("session methods use the public HTTP contract", async () => { const requests: Array<{ url: string; init?: RequestInit }> = [] let historyPage = 0 diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts index 037cbe4392..46cec83beb 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -1,6 +1,6 @@ export * as EventV2 from "./event" -import { Cause, Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect" +import { Cause, Context, Effect, Layer, Option, PubSub, Queue, Schema, Stream } from "effect" import { Event } from "@opencode-ai/schema/event" import type { Data, Definition, Payload } from "@opencode-ai/schema/event" import { and, asc, eq, gt, inArray } from "drizzle-orm" @@ -107,6 +107,11 @@ export const readAggregate = Effect.fn("EventV2.readAggregate")(function* ( } }) +export class SubscriberOverflowError extends Schema.TaggedErrorClass()( + "EventV2.SubscriberOverflow", + { capacity: Schema.Int }, +) {} + export const define = Event.define export const versionedType = Event.versionedType @@ -144,6 +149,20 @@ export interface Interface { export class Service extends Context.Service()("@opencode/Event") {} +export const allBounded = (events: Interface, capacity: number) => + Effect.gen(function* () { + const queue = yield* Queue.dropping(capacity) + const unsubscribe = yield* events.listen((event) => + Queue.offer(queue, event).pipe( + Effect.flatMap((accepted) => + accepted ? Effect.void : Queue.fail(queue, new SubscriberOverflowError({ capacity })).pipe(Effect.asVoid), + ), + ), + ) + yield* Effect.addFinalizer(() => unsubscribe.pipe(Effect.andThen(Queue.shutdown(queue)), Effect.asVoid)) + return Stream.fromQueue(queue) + }) + export interface LayerOptions { readonly beforeAggregateRead?: (aggregateID: string) => Effect.Effect } diff --git a/packages/core/test/event.test.ts b/packages/core/test/event.test.ts index e2b2a5df04..c6eb0d9c82 100644 --- a/packages/core/test/event.test.ts +++ b/packages/core/test/event.test.ts @@ -1,5 +1,5 @@ import { describe, expect } from "bun:test" -import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } from "effect" +import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Option, Schema, Stream } from "effect" import { EventV2 } from "@opencode-ai/core/event" import { Event } from "@opencode-ai/schema/event" import { Session } from "@opencode-ai/schema/session" @@ -285,6 +285,69 @@ describe("EventV2", () => { }), ) + it.effect("notifies global listeners only after a durable event is committed", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const { db } = yield* Database.Service + const aggregateID = EventV2.ID.create() + const observed = new Array<{ id: string; seq: number }>() + yield* events.listen((event) => + event.type !== SyncMessage.type + ? Effect.void + : db + .select({ id: EventTable.id, seq: EventTable.seq }) + .from(EventTable) + .where(eq(EventTable.id, event.id)) + .get() + .pipe( + Effect.orDie, + Effect.tap((row) => + Effect.sync(() => { + if (row) observed.push(row) + }), + ), + Effect.asVoid, + ), + ) + + const event = yield* events.publish(SyncMessage, { id: aggregateID, text: "committed" }) + if (!event.durable) throw new Error("Expected durable event metadata") + + expect(observed).toEqual([{ id: event.id, seq: event.durable.seq }]) + }), + ) + + it.effect("ends only an overflowing bounded subscriber without blocking other listeners", () => + Effect.gen(function* () { + const events = yield* EventV2.Service + const consuming = yield* Deferred.make() + const release = yield* Deferred.make() + const slowStream = yield* EventV2.allBounded(events, 1) + const fastStream = yield* EventV2.allBounded(events, 8) + const slow = yield* slowStream.pipe( + Stream.runForEach(() => Deferred.succeed(consuming, undefined).pipe(Effect.andThen(Deferred.await(release)))), + Effect.forkScoped, + ) + const fast = yield* fastStream.pipe(Stream.take(4), Stream.runCollect, Effect.forkScoped) + + yield* events.publish(Message, { text: "one" }) + yield* Deferred.await(consuming) + yield* events.publish(Message, { text: "two" }) + yield* events.publish(Message, { text: "overflow" }) + const last = yield* events.publish(Message, { text: "still delivered" }) + yield* Deferred.succeed(release, undefined) + + const slowExit = yield* Fiber.await(slow) + expect(Exit.findErrorOption(slowExit).pipe(Option.getOrUndefined)).toBeInstanceOf(EventV2.SubscriberOverflowError) + expect(Array.from(yield* Fiber.join(fast))).toEqual([ + expect.objectContaining({ data: { text: "one" } }), + expect.objectContaining({ data: { text: "two" } }), + expect.objectContaining({ data: { text: "overflow" } }), + last, + ]) + }), + ) + it.effect("preserves observer interruption", () => Effect.gen(function* () { const events = yield* EventV2.Service diff --git a/packages/httpapi-codegen/src/index.ts b/packages/httpapi-codegen/src/index.ts index 704264778c..b993926dd7 100644 --- a/packages/httpapi-codegen/src/index.ts +++ b/packages/httpapi-codegen/src/index.ts @@ -230,7 +230,12 @@ export function emitEffectImported( } } -export function emitPromise(contract: Contract): Output { +export function emitPromise( + contract: Contract, + options?: { + readonly outputTypes?: Readonly> + }, +): Output { const groups = contract.groups for (const group of groups) { for (const endpoint of group.endpoints) assertPromiseEndpoint(endpoint) @@ -238,7 +243,7 @@ export function emitPromise(contract: Contract): Output { return { operations: operations(groups), files: [ - { path: "types.ts", content: renderPromiseTypes(groups) }, + { path: "types.ts", content: renderPromiseTypes(groups, options?.outputTypes) }, { path: "client-error.ts", content: `export type ClientErrorReason = "Transport" | "UnexpectedStatus" | "UnsupportedContentType" | "MalformedResponse"\n\nexport class ClientError extends Error {\n override readonly name = "ClientError"\n constructor(readonly reason: ClientErrorReason, options?: ErrorOptions) {\n super(reason, options)\n }\n}\n`, @@ -408,7 +413,10 @@ function renderImportedProjection(groups: ReadonlyArray, endpoints: Reado return { imports: [...new Set(imports)], source } } -function renderPromiseTypes(groups: ReadonlyArray) { +function renderPromiseTypes( + groups: ReadonlyArray, + outputTypes?: Readonly>, +) { const types = new Map() const typeOf = (schema: Schema.Top, decoded = false) => { const projected = decoded ? Schema.toType(schema) : Schema.toEncoded(schema) @@ -453,13 +461,15 @@ function renderPromiseTypes(groups: ReadonlyArray) { }) .join("; ") const successSchema = endpoint.successes[0] - const success = typeOf( - isStreamSchema(successSchema) && successSchema._tag === "StreamSse" - ? successSchema.sseMode === "data" - ? streamEncodedDataSchema(successSchema) - : successSchema.events - : successSchema, - ) + const success = + outputTypes?.[`${group.identifier}.${endpoint.operation.name}`]?.name ?? + typeOf( + isStreamSchema(successSchema) && successSchema._tag === "StreamSse" + ? successSchema.sseMode === "data" + ? streamEncodedDataSchema(successSchema) + : successSchema.events + : successSchema, + ) return [ ...(endpoint.operation.inputMode === "none" ? [] : [`export type ${prefix}Input = { ${input} }`]), `export type ${prefix}Output = ${endpoint.unwrapData ? `(${success})["data"]` : success}`, @@ -470,7 +480,8 @@ function renderPromiseTypes(groups: ReadonlyArray) { const json = operations.includes("JsonValue") ? "export type JsonValue = null | boolean | number | string | ReadonlyArray | { readonly [key: string]: JsonValue }" : "" - return [json, ...errorTypes, operations].filter(Boolean).join("\n\n") + const imports = [...new Set(Object.values(outputTypes ?? {}).map((override) => override.import))] + return [...imports, json, ...errorTypes, operations].filter(Boolean).join("\n\n") } function renderPromiseClient(groups: ReadonlyArray) { diff --git a/packages/httpapi-codegen/test/generate.test.ts b/packages/httpapi-codegen/test/generate.test.ts index 543b2a13f3..aa59ec60ed 100644 --- a/packages/httpapi-codegen/test/generate.test.ts +++ b/packages/httpapi-codegen/test/generate.test.ts @@ -48,6 +48,24 @@ describe("HttpApiCodegen.generate", () => { ) }) + test("allows Promise outputs to use an authoritative imported wire type", () => { + const contract = compileContract( + api(HttpApiEndpoint.get("events", "/event", { success: HttpApiSchema.StreamSse({ data: Schema.Unknown }) })), + ) + const output = emitPromise(contract, { + outputTypes: { + "session.events": { + name: "EventWire", + import: 'import type { EventWire } from "./event-wire"', + }, + }, + }) + const types = output.files.find((file) => file.path === "types.ts")?.content + + expect(types).toContain('import type { EventWire } from "./event-wire"') + expect(types).toContain("export type SessionEventsOutput = EventWire") + }) + test("emits an Effect client against an imported authoritative API", () => { const output = emitEffectImported( compileContract( diff --git a/packages/opencode/test/server/httpapi-v2-location.test.ts b/packages/opencode/test/server/httpapi-v2-location.test.ts index e178062e7a..cf009812b8 100644 --- a/packages/opencode/test/server/httpapi-v2-location.test.ts +++ b/packages/opencode/test/server/httpapi-v2-location.test.ts @@ -27,13 +27,44 @@ const Event = Schema.Struct({ data: Schema.Unknown, }) -async function readEvent(reader: ReadableStreamDefaultReader) { - const value = await reader.read() - if (value.done) throw new Error("event stream closed") - return Schema.decodeUnknownSync(Event)(JSON.parse(new TextDecoder().decode(value.value).replace(/^data: /, ""))) +async function* eventStream(body: ReadableStream) { + const reader = body.getReader() + const decoder = new TextDecoder() + let buffer = "" + try { + while (true) { + const boundary = buffer.match(/(?:\r\n|\r|\n){2}/) + if (!boundary || boundary.index === undefined) { + const value = await reader.read() + if (value.done) return + buffer += decoder.decode(value.value, { stream: true }) + continue + } + + const record = buffer.slice(0, boundary.index) + buffer = buffer.slice(boundary.index + boundary[0].length) + const data = record + .split(/\r\n|\r|\n/) + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice(5).replace(/^ /, "")) + if (data.length) yield Schema.decodeUnknownSync(Event)(JSON.parse(data.join("\n"))) + } + } finally { + try { + await reader.cancel() + } finally { + reader.releaseLock() + } + } } -async function readEventType(reader: ReadableStreamDefaultReader, type: string) { +async function readEvent(reader: AsyncIterator) { + const value = await reader.next() + if (value.done) throw new Error("event stream closed") + return value.value +} + +async function readEventType(reader: AsyncIterator, type: string) { for (let index = 0; index < 20; index++) { const event = await readEvent(reader) if (event.type === type) return event @@ -78,7 +109,7 @@ describe("v2 location HttpApi", () => { await using subscriber = await tmpdir({ git: true }) await using publisher = await tmpdir({ git: true }) const response = await request("/api/event", subscriber.path) - const reader = response.body!.getReader() + const reader = eventStream(response.body!) const connected = await readEvent(reader) expect(connected.type).toBe("server.connected") expect(connected.location).toBeUndefined() @@ -90,6 +121,6 @@ describe("v2 location HttpApi", () => { location: { directory: publisher.path }, data: { sessionID: expect.any(String) }, }) - await reader.cancel() + await reader.return(undefined) }) }) diff --git a/packages/protocol/src/groups/event.ts b/packages/protocol/src/groups/event.ts index 862337e54b..a6fc5692ea 100644 --- a/packages/protocol/src/groups/event.ts +++ b/packages/protocol/src/groups/event.ts @@ -3,7 +3,7 @@ import { EventManifest } from "@opencode-ai/schema/event-manifest" import { Location } from "@opencode-ai/schema/location" import type { Definition } from "@opencode-ai/schema/event" import { Schema } from "effect" -import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" const fields = { id: Event.ID, @@ -12,15 +12,9 @@ const fields = { location: Schema.optional(Location.Ref), } -const schema = (definitions: ReadonlyArray) => +const schema = >(definitions: Definitions) => Schema.Union([ - ...definitions.map((definition) => - Schema.Struct({ - ...fields, - type: Schema.Literal(definition.type), - data: definition.data, - }).annotate({ identifier: `V2Event.${definition.type}` }), - ), + ...definitions, ...(definitions.some((definition) => definition.type === "server.connected") ? [] : [ @@ -32,14 +26,14 @@ const schema = (definitions: ReadonlyArray) => ]), ]).annotate({ identifier: "V2Event" }) -const make = (definitions: ReadonlyArray) => { +const make = >(definitions: Definitions) => { const EventSchema = schema(definitions) return { schema: EventSchema, group: HttpApiGroup.make("server.event") .add( HttpApiEndpoint.get("event.subscribe", "/api/event", { - success: EventSchema, + success: HttpApiSchema.StreamSse({ data: EventSchema }), }).annotateMerge( OpenApi.annotations({ identifier: "v2.event.subscribe", @@ -52,8 +46,11 @@ const make = (definitions: ReadonlyArray) => { } } -export const makeEventGroup = (definitions: ReadonlyArray) => make(definitions).group +export const makeEventGroup = >(definitions: Definitions) => + make(definitions).group const event = make(EventManifest.ServerDefinitions) export const EventGroup = event.group -export type Event = typeof event.schema.Type +export const OpenCodeEvent = event.schema +export type OpenCodeEvent = typeof OpenCodeEvent.Type +export type OpenCodeEventEncoded = typeof OpenCodeEvent.Encoded diff --git a/packages/sdk-next/src/index.ts b/packages/sdk-next/src/index.ts index ec948e03ca..fc23219fc4 100644 --- a/packages/sdk-next/src/index.ts +++ b/packages/sdk-next/src/index.ts @@ -14,3 +14,4 @@ export { SessionInput, SessionMessage, } from "@opencode-ai/client/effect" +export type { OpenCodeEvent } from "@opencode-ai/client/effect" diff --git a/packages/sdk-next/test/embedded.test.ts b/packages/sdk-next/test/embedded.test.ts index dbd7ed9378..5c1b8b238a 100644 --- a/packages/sdk-next/test/embedded.test.ts +++ b/packages/sdk-next/test/embedded.test.ts @@ -3,7 +3,8 @@ import { mkdtemp, rm } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" import { Flag } from "@opencode-ai/core/flag/flag" -import { Effect, Option, Schema, Stream } from "effect" +import { Deferred, Effect, Latch, Option, Schema, Stream } from "effect" +import type { OpenCodeEvent } from "../src" test("embedded client uses the real router and handlers", async () => { const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-")) @@ -103,6 +104,88 @@ test("embedded client uses the real router and handlers", async () => { } }) +test("Location-owned runner events reach the ready global client", async () => { + const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-events-")) + const database = Flag.OPENCODE_DB + Flag.OPENCODE_DB = join(directory, "opencode.sqlite") + const { AbsolutePath, Location, OpenCode, Prompt, Session } = await import("../src") + const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`) + + try { + const program = Effect.gen(function* () { + const opencode = yield* OpenCode.create() + const connected = yield* Latch.make(false) + const prompted = yield* Deferred.make() + yield* opencode.events.subscribe().pipe( + Stream.runForEach((event) => + event.type === "server.connected" + ? connected.open + : event.type === "session.next.prompted" && event.data.sessionID === sessionID + ? Deferred.succeed(prompted, event).pipe(Effect.asVoid) + : Effect.void, + ), + Effect.forkScoped, + ) + yield* connected.await + yield* opencode.sessions.create({ + id: sessionID, + location: Location.Ref.make({ directory: AbsolutePath.make(directory) }), + }) + yield* opencode.sessions.prompt({ sessionID, prompt: Prompt.make({ text: "Observe this input" }) }) + + const event = yield* Deferred.await(prompted).pipe(Effect.timeout("4 seconds")) + expect(event.durable).toEqual(expect.objectContaining({ aggregateID: sessionID, seq: expect.any(Number) })) + }) + await Effect.runPromise(Effect.scoped(program)) + } finally { + Flag.OPENCODE_DB = database + await rm(directory, { recursive: true, force: true }) + } +}, 10_000) + +test("independent embedded hosts do not share live notifications", async () => { + const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-hosts-")) + const database = Flag.OPENCODE_DB + Flag.OPENCODE_DB = join(directory, "opencode.sqlite") + const { AbsolutePath, Agent, Location, OpenCode, Session } = await import("../src") + const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`) + + try { + const program = Effect.gen(function* () { + const first = yield* OpenCode.create() + const second = yield* OpenCode.create() + const firstReady = yield* Latch.make(false) + const secondReady = yield* Latch.make(false) + const firstEvent = yield* Latch.make(false) + const secondEvent = yield* Latch.make(false) + const observe = (ready: Latch.Latch, event: Latch.Latch) => + Stream.runForEach((notification: OpenCodeEvent) => + notification.type === "server.connected" + ? ready.open + : notification.type === "session.next.agent.switched" && notification.data.sessionID === sessionID + ? event.open + : Effect.void, + ) + + yield* first.events.subscribe().pipe(observe(firstReady, firstEvent), Effect.forkScoped) + yield* second.events.subscribe().pipe(observe(secondReady, secondEvent), Effect.forkScoped) + yield* Effect.all([firstReady.await, secondReady.await], { discard: true }) + yield* first.sessions.create({ + id: sessionID, + location: Location.Ref.make({ directory: AbsolutePath.make(directory) }), + }) + yield* first.sessions.switchAgent({ sessionID, agent: Agent.ID.make("plan") }) + + yield* firstEvent.await.pipe(Effect.timeout("2 seconds")) + expect(Option.isNone(yield* secondEvent.await.pipe(Effect.timeoutOption("100 millis")))).toBe(true) + }) + await Effect.runPromise(Effect.scoped(program)) + } finally { + Flag.OPENCODE_DB = database + await rm(directory, { recursive: true, force: true }) + } +}, 10_000) + test("embedded client is available as a Layer service", async () => { const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-layer-")) const database = Flag.OPENCODE_DB diff --git a/packages/server/src/handlers/event.ts b/packages/server/src/handlers/event.ts index 8001fb8748..47e94731ee 100644 --- a/packages/server/src/handlers/event.ts +++ b/packages/server/src/handlers/event.ts @@ -1,16 +1,19 @@ import { EventV2 } from "@opencode-ai/core/event" -import { Effect, Stream } from "effect" +import { OpenCodeEvent } from "@opencode-ai/protocol/groups/event" +import { Effect, Schema, Stream } from "effect" import { HttpServerResponse } from "effect/unstable/http" import { HttpApiBuilder } from "effect/unstable/httpapi" import * as Sse from "effect/unstable/encoding/Sse" import { Api } from "../api" +const subscriberCapacity = 256 + function eventData(data: unknown): Sse.Event { return { _tag: "Event", event: "message", id: undefined, - data: JSON.stringify(data), + data: JSON.stringify(Schema.encodeUnknownSync(OpenCodeEvent)(data)), } } @@ -24,13 +27,16 @@ export const EventHandler = HttpApiBuilder.group(Api, "server.event", (handlers) type: "server.connected", data: {}, } + const output = Stream.unwrap( + Effect.gen(function* () { + // Acquiring the bounded stream installs its listener before readiness is observable. + const live = yield* EventV2.allBounded(events, subscriberCapacity) + return Stream.make(connected).pipe(Stream.concat(live)) + }), + ).pipe(Stream.map(eventData), Stream.pipeThroughChannel(Sse.encode())) + const heartbeat = Stream.tick("15 seconds").pipe(Stream.map(() => ": heartbeat\n\n")) return HttpServerResponse.stream( - Stream.make(connected).pipe( - Stream.concat(events.all()), - Stream.map(eventData), - Stream.pipeThroughChannel(Sse.encode()), - Stream.encodeText, - ), + output.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }), Stream.encodeText), { contentType: "text/event-stream", headers: { From 44a6787359ec53f24f17fa2e64fbab9633470b8a Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 26 Jun 2026 19:18:18 +0000 Subject: [PATCH 07/45] chore: generate --- packages/sdk/js/src/v2/gen/types.gen.ts | 1445 +++------ packages/sdk/openapi.json | 3895 +++++++---------------- 2 files changed, 1509 insertions(+), 3831 deletions(-) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 097b68152e..3ac6ea713d 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2782,95 +2782,162 @@ export type ProviderNotFoundError = { message: string } -export type V2Event = - | V2EventModelsDevRefreshed - | V2EventIntegrationUpdated - | V2EventIntegrationConnectionUpdated - | V2EventCatalogUpdated - | V2EventSessionCreated - | V2EventSessionUpdated - | V2EventSessionDeleted - | V2EventMessageUpdated - | V2EventMessageRemoved - | V2EventMessagePartUpdated - | V2EventMessagePartRemoved - | V2EventSessionNextAgentSwitched - | V2EventSessionNextModelSwitched - | V2EventSessionNextMoved - | V2EventSessionNextPrompted - | V2EventSessionNextPromptAdmitted - | V2EventSessionNextContextUpdated - | V2EventSessionNextSynthetic - | V2EventSessionNextShellStarted - | V2EventSessionNextShellEnded - | V2EventSessionNextStepStarted - | V2EventSessionNextStepEnded - | V2EventSessionNextStepFailed - | V2EventSessionNextTextStarted - | V2EventSessionNextTextDelta - | V2EventSessionNextTextEnded - | V2EventSessionNextReasoningStarted - | V2EventSessionNextReasoningDelta - | V2EventSessionNextReasoningEnded - | V2EventSessionNextToolInputStarted - | V2EventSessionNextToolInputDelta - | V2EventSessionNextToolInputEnded - | V2EventSessionNextToolCalled - | V2EventSessionNextToolProgress - | V2EventSessionNextToolSuccess - | V2EventSessionNextToolFailed - | V2EventSessionNextRetried - | V2EventSessionNextCompactionStarted - | V2EventSessionNextCompactionDelta - | V2EventSessionNextCompactionEnded - | V2EventSessionNextRevertStaged - | V2EventSessionNextRevertCleared - | V2EventSessionNextRevertCommitted - | V2EventMessagePartDelta - | V2EventSessionDiff - | V2EventSessionError - | V2EventInstallationUpdated - | V2EventInstallationUpdateAvailable - | V2EventFileEdited - | V2EventReferenceUpdated - | V2EventPermissionV2Asked - | V2EventPermissionV2Replied - | V2EventPluginAdded - | V2EventProjectDirectoriesUpdated - | V2EventFileWatcherUpdated - | V2EventPtyCreated - | V2EventPtyUpdated - | V2EventPtyExited - | V2EventPtyDeleted - | V2EventQuestionV2Asked - | V2EventQuestionV2Replied - | V2EventQuestionV2Rejected - | V2EventTodoUpdated - | V2EventLspUpdated - | V2EventPermissionAsked - | V2EventPermissionReplied - | V2EventTuiPromptAppend - | V2EventTuiCommandExecute - | V2EventTuiToastShow - | V2EventTuiSessionSelect - | V2EventMcpToolsChanged - | V2EventMcpBrowserOpenFailed - | V2EventCommandExecuted - | V2EventProjectUpdated - | V2EventSessionStatus - | V2EventSessionIdle - | V2EventQuestionAsked - | V2EventQuestionReplied - | V2EventQuestionRejected - | V2EventSessionCompacted - | V2EventVcsBranchUpdated - | V2EventWorkspaceReady - | V2EventWorkspaceFailed - | V2EventWorkspaceStatus - | V2EventWorktreeReady - | V2EventWorktreeFailed - | V2EventServerConnected - | V2EventGlobalDisposed +export type OutputFormat1 = + | { + type: "text" + } + | { + type: "json_schema" + schema: JsonSchema + retryCount?: number + } + +export type SessionStatus2 = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.status" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + status: SessionStatus + } +} + +export type QuestionReplied2 = { + id: string + metadata?: { + [key: string]: unknown + } + type: "question.replied" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + requestID: string + answers: Array + } +} + +export type QuestionRejected2 = { + id: string + metadata?: { + [key: string]: unknown + } + type: "question.rejected" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + sessionID: string + requestID: string + } +} + +export type V2Event1 = + | ModelsDevRefreshed + | IntegrationUpdated + | IntegrationConnectionUpdated + | CatalogUpdated + | SessionCreated + | SessionUpdated + | SessionDeleted + | MessageUpdated + | MessageRemoved + | MessagePartUpdated + | MessagePartRemoved + | SessionNextAgentSwitched + | SessionNextModelSwitched + | SessionNextMoved + | SessionNextPrompted + | SessionNextPromptAdmitted + | SessionNextContextUpdated + | SessionNextSynthetic + | SessionNextShellStarted + | SessionNextShellEnded + | SessionNextStepStarted + | SessionNextStepEnded + | SessionNextStepFailed + | SessionNextTextStarted + | SessionNextTextDelta + | SessionNextTextEnded + | SessionNextReasoningStarted + | SessionNextReasoningDelta + | SessionNextReasoningEnded + | SessionNextToolInputStarted + | SessionNextToolInputDelta + | SessionNextToolInputEnded + | SessionNextToolCalled + | SessionNextToolProgress + | SessionNextToolSuccess + | SessionNextToolFailed + | SessionNextRetried + | SessionNextCompactionStarted + | SessionNextCompactionDelta + | SessionNextCompactionEnded + | SessionNextRevertStaged + | SessionNextRevertCleared + | SessionNextRevertCommitted + | MessagePartDelta + | SessionDiff + | SessionError + | InstallationUpdated + | InstallationUpdateAvailable + | FileEdited + | ReferenceUpdated + | PermissionV2Asked + | PermissionV2Replied + | PluginAdded + | ProjectDirectoriesUpdated + | FileWatcherUpdated + | PtyCreated + | PtyUpdated + | PtyExited + | PtyDeleted + | QuestionV2Asked + | QuestionV2Replied + | QuestionV2Rejected + | TodoUpdated + | LspUpdated + | PermissionAsked + | PermissionReplied + | TuiPromptAppend + | TuiCommandExecute + | TuiToastShow + | TuiSessionSelect + | McpToolsChanged + | McpBrowserOpenFailed + | CommandExecuted + | ProjectUpdated + | SessionStatus2 + | SessionIdle + | QuestionAsked + | QuestionReplied2 + | QuestionRejected2 + | SessionCompacted + | VcsBranchUpdated + | WorkspaceReady + | WorkspaceFailed + | WorkspaceStatus + | WorktreeReady + | WorktreeFailed + | ServerConnected + | GlobalDisposed + +export type V2Event = string export type ForbiddenError = { _tag: "ForbiddenError" @@ -4945,176 +5012,176 @@ export type SkillV2Info = { content: string } -export type V2EventModelsDevRefreshed = { +export type ModelsDevRefreshed = { id: string metadata?: { [key: string]: unknown } - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef type: "models-dev.refreshed" - data: { - [key: string]: unknown - } -} - -export type V2EventIntegrationUpdated = { - id: string - metadata?: { - [key: string]: unknown - } durable?: { aggregateID: string seq: number version: number } location?: LocationRef + data: { + [key: string]: unknown + } +} + +export type IntegrationUpdated = { + id: string + metadata?: { + [key: string]: unknown + } type: "integration.updated" - data: { - [key: string]: unknown - } -} - -export type V2EventIntegrationConnectionUpdated = { - id: string - metadata?: { - [key: string]: unknown - } durable?: { aggregateID: string seq: number version: number } location?: LocationRef + data: { + [key: string]: unknown + } +} + +export type IntegrationConnectionUpdated = { + id: string + metadata?: { + [key: string]: unknown + } type: "integration.connection.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef data: { integrationID: string } } -export type V2EventCatalogUpdated = { +export type CatalogUpdated = { id: string metadata?: { [key: string]: unknown } - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef type: "catalog.updated" - data: { - [key: string]: unknown - } -} - -export type V2EventSessionCreated = { - id: string - metadata?: { - [key: string]: unknown - } durable?: { aggregateID: string seq: number version: number } location?: LocationRef + data: { + [key: string]: unknown + } +} + +export type SessionCreated = { + id: string + metadata?: { + [key: string]: unknown + } type: "session.created" - data: { - sessionID: string - info: Session - } -} - -export type V2EventSessionUpdated = { - id: string - metadata?: { - [key: string]: unknown - } durable?: { aggregateID: string seq: number version: number } location?: LocationRef + data: { + sessionID: string + info: Session + } +} + +export type SessionUpdated = { + id: string + metadata?: { + [key: string]: unknown + } type: "session.updated" - data: { - sessionID: string - info: Session - } -} - -export type V2EventSessionDeleted = { - id: string - metadata?: { - [key: string]: unknown - } durable?: { aggregateID: string seq: number version: number } location?: LocationRef + data: { + sessionID: string + info: Session + } +} + +export type SessionDeleted = { + id: string + metadata?: { + [key: string]: unknown + } type: "session.deleted" - data: { - sessionID: string - info: Session - } -} - -export type V2EventMessageUpdated = { - id: string - metadata?: { - [key: string]: unknown - } durable?: { aggregateID: string seq: number version: number } location?: LocationRef + data: { + sessionID: string + info: Session + } +} + +export type MessageUpdated = { + id: string + metadata?: { + [key: string]: unknown + } type: "message.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef data: { sessionID: string info: Message } } -export type V2EventMessageRemoved = { +export type MessageRemoved = { id: string metadata?: { [key: string]: unknown } + type: "message.removed" durable?: { aggregateID: string seq: number version: number } location?: LocationRef - type: "message.removed" data: { sessionID: string messageID: string } } -export type V2EventMessagePartUpdated = { +export type MessagePartUpdated = { id: string metadata?: { [key: string]: unknown } + type: "message.part.updated" durable?: { aggregateID: string seq: number version: number } location?: LocationRef - type: "message.part.updated" data: { sessionID: string part: Part @@ -5122,18 +5189,18 @@ export type V2EventMessagePartUpdated = { } } -export type V2EventMessagePartRemoved = { +export type MessagePartRemoved = { id: string metadata?: { [key: string]: unknown } + type: "message.part.removed" durable?: { aggregateID: string seq: number version: number } location?: LocationRef - type: "message.part.removed" data: { sessionID: string messageID: string @@ -5141,295 +5208,18 @@ export type V2EventMessagePartRemoved = { } } -export type V2EventSessionNextAgentSwitched = { +export type SessionNextTextDelta = { id: string metadata?: { [key: string]: unknown } - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef - type: "session.next.agent.switched" - data: { - timestamp: number - sessionID: string - messageID: string - agent: string - } -} - -export type V2EventSessionNextModelSwitched = { - id: string - metadata?: { - [key: string]: unknown - } - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef - type: "session.next.model.switched" - data: { - timestamp: number - sessionID: string - messageID: string - model: ModelRef - } -} - -export type V2EventSessionNextMoved = { - id: string - metadata?: { - [key: string]: unknown - } - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef - type: "session.next.moved" - data: { - timestamp: number - sessionID: string - location: LocationRef - subdirectory?: string - } -} - -export type V2EventSessionNextPrompted = { - id: string - metadata?: { - [key: string]: unknown - } - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef - type: "session.next.prompted" - data: { - timestamp: number - sessionID: string - messageID: string - prompt: Prompt - delivery: "steer" | "queue" - } -} - -export type V2EventSessionNextPromptAdmitted = { - id: string - metadata?: { - [key: string]: unknown - } - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef - type: "session.next.prompt.admitted" - data: { - timestamp: number - sessionID: string - messageID: string - prompt: Prompt - delivery: "steer" | "queue" - } -} - -export type V2EventSessionNextContextUpdated = { - id: string - metadata?: { - [key: string]: unknown - } - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef - type: "session.next.context.updated" - data: { - timestamp: number - sessionID: string - messageID: string - text: string - } -} - -export type V2EventSessionNextSynthetic = { - id: string - metadata?: { - [key: string]: unknown - } - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef - type: "session.next.synthetic" - data: { - timestamp: number - sessionID: string - messageID: string - text: string - } -} - -export type V2EventSessionNextShellStarted = { - id: string - metadata?: { - [key: string]: unknown - } - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef - type: "session.next.shell.started" - data: { - timestamp: number - sessionID: string - messageID: string - callID: string - command: string - } -} - -export type V2EventSessionNextShellEnded = { - id: string - metadata?: { - [key: string]: unknown - } - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef - type: "session.next.shell.ended" - data: { - timestamp: number - sessionID: string - callID: string - output: string - } -} - -export type V2EventSessionNextStepStarted = { - id: string - metadata?: { - [key: string]: unknown - } - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef - type: "session.next.step.started" - data: { - timestamp: number - sessionID: string - assistantMessageID: string - agent: string - model: ModelRef - snapshot?: string - } -} - -export type V2EventSessionNextStepEnded = { - id: string - metadata?: { - [key: string]: unknown - } - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef - type: "session.next.step.ended" - data: { - timestamp: number - sessionID: string - assistantMessageID: string - finish: string - cost: number - tokens: { - input: number - output: number - reasoning: number - cache: { - read: number - write: number - } - } - snapshot?: string - files?: Array - } -} - -export type V2EventSessionNextStepFailed = { - id: string - metadata?: { - [key: string]: unknown - } - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef - type: "session.next.step.failed" - data: { - timestamp: number - sessionID: string - assistantMessageID: string - error: SessionErrorUnknown - } -} - -export type V2EventSessionNextTextStarted = { - id: string - metadata?: { - [key: string]: unknown - } - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef - type: "session.next.text.started" - data: { - timestamp: number - sessionID: string - assistantMessageID: string - textID: string - } -} - -export type V2EventSessionNextTextDelta = { - id: string - metadata?: { - [key: string]: unknown - } - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef type: "session.next.text.delta" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef data: { timestamp: number sessionID: string @@ -5439,60 +5229,18 @@ export type V2EventSessionNextTextDelta = { } } -export type V2EventSessionNextTextEnded = { +export type SessionNextReasoningDelta = { id: string metadata?: { [key: string]: unknown } - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef - type: "session.next.text.ended" - data: { - timestamp: number - sessionID: string - assistantMessageID: string - textID: string - text: string - } -} - -export type V2EventSessionNextReasoningStarted = { - id: string - metadata?: { - [key: string]: unknown - } - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef - type: "session.next.reasoning.started" - data: { - timestamp: number - sessionID: string - assistantMessageID: string - reasoningID: string - providerMetadata?: LlmProviderMetadata - } -} - -export type V2EventSessionNextReasoningDelta = { - id: string - metadata?: { - [key: string]: unknown - } - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef type: "session.next.reasoning.delta" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef data: { timestamp: number sessionID: string @@ -5502,61 +5250,18 @@ export type V2EventSessionNextReasoningDelta = { } } -export type V2EventSessionNextReasoningEnded = { +export type SessionNextToolInputDelta = { id: string metadata?: { [key: string]: unknown } - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef - type: "session.next.reasoning.ended" - data: { - timestamp: number - sessionID: string - assistantMessageID: string - reasoningID: string - text: string - providerMetadata?: LlmProviderMetadata - } -} - -export type V2EventSessionNextToolInputStarted = { - id: string - metadata?: { - [key: string]: unknown - } - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef - type: "session.next.tool.input.started" - data: { - timestamp: number - sessionID: string - assistantMessageID: string - callID: string - name: string - } -} - -export type V2EventSessionNextToolInputDelta = { - id: string - metadata?: { - [key: string]: unknown - } - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef type: "session.next.tool.input.delta" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef data: { timestamp: number sessionID: string @@ -5566,187 +5271,18 @@ export type V2EventSessionNextToolInputDelta = { } } -export type V2EventSessionNextToolInputEnded = { +export type SessionNextCompactionDelta = { id: string metadata?: { [key: string]: unknown } - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef - type: "session.next.tool.input.ended" - data: { - timestamp: number - sessionID: string - assistantMessageID: string - callID: string - text: string - } -} - -export type V2EventSessionNextToolCalled = { - id: string - metadata?: { - [key: string]: unknown - } - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef - type: "session.next.tool.called" - data: { - timestamp: number - sessionID: string - assistantMessageID: string - callID: string - tool: string - input: { - [key: string]: unknown - } - provider: { - executed: boolean - metadata?: LlmProviderMetadata - } - } -} - -export type V2EventSessionNextToolProgress = { - id: string - metadata?: { - [key: string]: unknown - } - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef - type: "session.next.tool.progress" - data: { - timestamp: number - sessionID: string - assistantMessageID: string - callID: string - structured: { - [key: string]: unknown - } - content: Array - } -} - -export type V2EventSessionNextToolSuccess = { - id: string - metadata?: { - [key: string]: unknown - } - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef - type: "session.next.tool.success" - data: { - timestamp: number - sessionID: string - assistantMessageID: string - callID: string - structured: { - [key: string]: unknown - } - content: Array - outputPaths?: Array - result?: unknown - provider: { - executed: boolean - metadata?: LlmProviderMetadata - } - } -} - -export type V2EventSessionNextToolFailed = { - id: string - metadata?: { - [key: string]: unknown - } - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef - type: "session.next.tool.failed" - data: { - timestamp: number - sessionID: string - assistantMessageID: string - callID: string - error: SessionErrorUnknown - result?: unknown - provider: { - executed: boolean - metadata?: LlmProviderMetadata - } - } -} - -export type V2EventSessionNextRetried = { - id: string - metadata?: { - [key: string]: unknown - } - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef - type: "session.next.retried" - data: { - timestamp: number - sessionID: string - attempt: number - error: SessionNextRetryError - } -} - -export type V2EventSessionNextCompactionStarted = { - id: string - metadata?: { - [key: string]: unknown - } - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef - type: "session.next.compaction.started" - data: { - timestamp: number - sessionID: string - messageID: string - reason: "auto" | "manual" - } -} - -export type V2EventSessionNextCompactionDelta = { - id: string - metadata?: { - [key: string]: unknown - } - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef type: "session.next.compaction.delta" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef data: { timestamp: number sessionID: string @@ -5755,96 +5291,18 @@ export type V2EventSessionNextCompactionDelta = { } } -export type V2EventSessionNextCompactionEnded = { +export type MessagePartDelta = { id: string metadata?: { [key: string]: unknown } - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef - type: "session.next.compaction.ended" - data: { - timestamp: number - sessionID: string - messageID: string - reason: "auto" | "manual" - text: string - recent: string - } -} - -export type V2EventSessionNextRevertStaged = { - id: string - metadata?: { - [key: string]: unknown - } - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef - type: "session.next.revert.staged" - data: { - timestamp: number - sessionID: string - revert: RevertState - } -} - -export type V2EventSessionNextRevertCleared = { - id: string - metadata?: { - [key: string]: unknown - } - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef - type: "session.next.revert.cleared" - data: { - timestamp: number - sessionID: string - } -} - -export type V2EventSessionNextRevertCommitted = { - id: string - metadata?: { - [key: string]: unknown - } - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef - type: "session.next.revert.committed" - data: { - timestamp: number - sessionID: string - messageID: string - } -} - -export type V2EventMessagePartDelta = { - id: string - metadata?: { - [key: string]: unknown - } - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef type: "message.part.delta" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef data: { sessionID: string messageID: string @@ -5854,36 +5312,36 @@ export type V2EventMessagePartDelta = { } } -export type V2EventSessionDiff = { +export type SessionDiff = { id: string metadata?: { [key: string]: unknown } + type: "session.diff" durable?: { aggregateID: string seq: number version: number } location?: LocationRef - type: "session.diff" data: { sessionID: string diff: Array } } -export type V2EventSessionError = { +export type SessionError = { id: string metadata?: { [key: string]: unknown } + type: "session.error" durable?: { aggregateID: string seq: number version: number } location?: LocationRef - type: "session.error" data: { sessionID?: string error?: @@ -5898,86 +5356,86 @@ export type V2EventSessionError = { } } -export type V2EventInstallationUpdated = { +export type InstallationUpdated = { id: string metadata?: { [key: string]: unknown } - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef type: "installation.updated" - data: { - version: string - } -} - -export type V2EventInstallationUpdateAvailable = { - id: string - metadata?: { - [key: string]: unknown - } durable?: { aggregateID: string seq: number version: number } location?: LocationRef + data: { + version: string + } +} + +export type InstallationUpdateAvailable = { + id: string + metadata?: { + [key: string]: unknown + } type: "installation.update-available" - data: { - version: string - } -} - -export type V2EventFileEdited = { - id: string - metadata?: { - [key: string]: unknown - } durable?: { aggregateID: string seq: number version: number } location?: LocationRef + data: { + version: string + } +} + +export type FileEdited = { + id: string + metadata?: { + [key: string]: unknown + } type: "file.edited" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef data: { file: string } } -export type V2EventReferenceUpdated = { +export type ReferenceUpdated = { id: string metadata?: { [key: string]: unknown } + type: "reference.updated" durable?: { aggregateID: string seq: number version: number } location?: LocationRef - type: "reference.updated" data: { [key: string]: unknown } } -export type V2EventPermissionV2Asked = { +export type PermissionV2Asked = { id: string metadata?: { [key: string]: unknown } + type: "permission.v2.asked" durable?: { aggregateID: string seq: number version: number } location?: LocationRef - type: "permission.v2.asked" data: { id: string sessionID: string @@ -5991,18 +5449,18 @@ export type V2EventPermissionV2Asked = { } } -export type V2EventPermissionV2Replied = { +export type PermissionV2Replied = { id: string metadata?: { [key: string]: unknown } + type: "permission.v2.replied" durable?: { aggregateID: string seq: number version: number } location?: LocationRef - type: "permission.v2.replied" data: { sessionID: string requestID: string @@ -6010,139 +5468,139 @@ export type V2EventPermissionV2Replied = { } } -export type V2EventPluginAdded = { +export type PluginAdded = { id: string metadata?: { [key: string]: unknown } + type: "plugin.added" durable?: { aggregateID: string seq: number version: number } location?: LocationRef - type: "plugin.added" data: { id: string } } -export type V2EventProjectDirectoriesUpdated = { +export type ProjectDirectoriesUpdated = { id: string metadata?: { [key: string]: unknown } + type: "project.directories.updated" durable?: { aggregateID: string seq: number version: number } location?: LocationRef - type: "project.directories.updated" data: { projectID: string } } -export type V2EventFileWatcherUpdated = { +export type FileWatcherUpdated = { id: string metadata?: { [key: string]: unknown } + type: "file.watcher.updated" durable?: { aggregateID: string seq: number version: number } location?: LocationRef - type: "file.watcher.updated" data: { file: string event: "add" | "change" | "unlink" } } -export type V2EventPtyCreated = { +export type PtyCreated = { id: string metadata?: { [key: string]: unknown } - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef type: "pty.created" - data: { - info: Pty - } -} - -export type V2EventPtyUpdated = { - id: string - metadata?: { - [key: string]: unknown - } durable?: { aggregateID: string seq: number version: number } location?: LocationRef + data: { + info: Pty + } +} + +export type PtyUpdated = { + id: string + metadata?: { + [key: string]: unknown + } type: "pty.updated" - data: { - info: Pty - } -} - -export type V2EventPtyExited = { - id: string - metadata?: { - [key: string]: unknown - } durable?: { aggregateID: string seq: number version: number } location?: LocationRef + data: { + info: Pty + } +} + +export type PtyExited = { + id: string + metadata?: { + [key: string]: unknown + } type: "pty.exited" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef data: { id: string exitCode: number } } -export type V2EventPtyDeleted = { +export type PtyDeleted = { id: string metadata?: { [key: string]: unknown } + type: "pty.deleted" durable?: { aggregateID: string seq: number version: number } location?: LocationRef - type: "pty.deleted" data: { id: string } } -export type V2EventQuestionV2Asked = { +export type QuestionV2Asked = { id: string metadata?: { [key: string]: unknown } + type: "question.v2.asked" durable?: { aggregateID: string seq: number version: number } location?: LocationRef - type: "question.v2.asked" data: { id: string sessionID: string @@ -6154,18 +5612,18 @@ export type V2EventQuestionV2Asked = { } } -export type V2EventQuestionV2Replied = { +export type QuestionV2Replied = { id: string metadata?: { [key: string]: unknown } + type: "question.v2.replied" durable?: { aggregateID: string seq: number version: number } location?: LocationRef - type: "question.v2.replied" data: { sessionID: string requestID: string @@ -6173,71 +5631,71 @@ export type V2EventQuestionV2Replied = { } } -export type V2EventQuestionV2Rejected = { +export type QuestionV2Rejected = { id: string metadata?: { [key: string]: unknown } + type: "question.v2.rejected" durable?: { aggregateID: string seq: number version: number } location?: LocationRef - type: "question.v2.rejected" data: { sessionID: string requestID: string } } -export type V2EventTodoUpdated = { +export type TodoUpdated = { id: string metadata?: { [key: string]: unknown } + type: "todo.updated" durable?: { aggregateID: string seq: number version: number } location?: LocationRef - type: "todo.updated" data: { sessionID: string todos: Array } } -export type V2EventLspUpdated = { +export type LspUpdated = { id: string metadata?: { [key: string]: unknown } + type: "lsp.updated" durable?: { aggregateID: string seq: number version: number } location?: LocationRef - type: "lsp.updated" data: { [key: string]: unknown } } -export type V2EventPermissionAsked = { +export type PermissionAsked = { id: string metadata?: { [key: string]: unknown } + type: "permission.asked" durable?: { aggregateID: string seq: number version: number } location?: LocationRef - type: "permission.asked" data: { id: string sessionID: string @@ -6254,18 +5712,18 @@ export type V2EventPermissionAsked = { } } -export type V2EventPermissionReplied = { +export type PermissionReplied = { id: string metadata?: { [key: string]: unknown } + type: "permission.replied" durable?: { aggregateID: string seq: number version: number } location?: LocationRef - type: "permission.replied" data: { sessionID: string requestID: string @@ -6273,35 +5731,35 @@ export type V2EventPermissionReplied = { } } -export type V2EventTuiPromptAppend = { +export type TuiPromptAppend = { id: string metadata?: { [key: string]: unknown } + type: "tui.prompt.append" durable?: { aggregateID: string seq: number version: number } location?: LocationRef - type: "tui.prompt.append" data: { text: string } } -export type V2EventTuiCommandExecute = { +export type TuiCommandExecute = { id: string metadata?: { [key: string]: unknown } + type: "tui.command.execute" durable?: { aggregateID: string seq: number version: number } location?: LocationRef - type: "tui.command.execute" data: { command: | "session.list" @@ -6324,18 +5782,18 @@ export type V2EventTuiCommandExecute = { } } -export type V2EventTuiToastShow = { +export type TuiToastShow = { id: string metadata?: { [key: string]: unknown } + type: "tui.toast.show" durable?: { aggregateID: string seq: number version: number } location?: LocationRef - type: "tui.toast.show" data: { title?: string message: string @@ -6344,18 +5802,18 @@ export type V2EventTuiToastShow = { } } -export type V2EventTuiSessionSelect = { +export type TuiSessionSelect = { id: string metadata?: { [key: string]: unknown } + type: "tui.session.select" durable?: { aggregateID: string seq: number version: number } location?: LocationRef - type: "tui.session.select" data: { /** * Session ID to navigate to @@ -6364,53 +5822,53 @@ export type V2EventTuiSessionSelect = { } } -export type V2EventMcpToolsChanged = { +export type McpToolsChanged = { id: string metadata?: { [key: string]: unknown } + type: "mcp.tools.changed" durable?: { aggregateID: string seq: number version: number } location?: LocationRef - type: "mcp.tools.changed" data: { server: string } } -export type V2EventMcpBrowserOpenFailed = { +export type McpBrowserOpenFailed = { id: string metadata?: { [key: string]: unknown } + type: "mcp.browser.open.failed" durable?: { aggregateID: string seq: number version: number } location?: LocationRef - type: "mcp.browser.open.failed" data: { mcpName: string url: string } } -export type V2EventCommandExecuted = { +export type CommandExecuted = { id: string metadata?: { [key: string]: unknown } + type: "command.executed" durable?: { aggregateID: string seq: number version: number } location?: LocationRef - type: "command.executed" data: { name: string sessionID: string @@ -6419,18 +5877,18 @@ export type V2EventCommandExecuted = { } } -export type V2EventProjectUpdated = { +export type ProjectUpdated = { id: string metadata?: { [key: string]: unknown } + type: "project.updated" durable?: { aggregateID: string seq: number version: number } location?: LocationRef - type: "project.updated" data: { id: string worktree: string @@ -6443,53 +5901,35 @@ export type V2EventProjectUpdated = { } } -export type V2EventSessionStatus = { +export type SessionIdle = { id: string metadata?: { [key: string]: unknown } - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef - type: "session.status" - data: { - sessionID: string - status: SessionStatus - } -} - -export type V2EventSessionIdle = { - id: string - metadata?: { - [key: string]: unknown - } - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef type: "session.idle" - data: { - sessionID: string - } -} - -export type V2EventQuestionAsked = { - id: string - metadata?: { - [key: string]: unknown - } durable?: { aggregateID: string seq: number version: number } location?: LocationRef + data: { + sessionID: string + } +} + +export type QuestionAsked = { + id: string + metadata?: { + [key: string]: unknown + } type: "question.asked" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef data: { id: string sessionID: string @@ -6501,193 +5941,156 @@ export type V2EventQuestionAsked = { } } -export type V2EventQuestionReplied = { +export type SessionCompacted = { id: string metadata?: { [key: string]: unknown } - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef - type: "question.replied" - data: { - sessionID: string - requestID: string - answers: Array - } -} - -export type V2EventQuestionRejected = { - id: string - metadata?: { - [key: string]: unknown - } - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef - type: "question.rejected" - data: { - sessionID: string - requestID: string - } -} - -export type V2EventSessionCompacted = { - id: string - metadata?: { - [key: string]: unknown - } - durable?: { - aggregateID: string - seq: number - version: number - } - location?: LocationRef type: "session.compacted" - data: { - sessionID: string - } -} - -export type V2EventVcsBranchUpdated = { - id: string - metadata?: { - [key: string]: unknown - } durable?: { aggregateID: string seq: number version: number } location?: LocationRef + data: { + sessionID: string + } +} + +export type VcsBranchUpdated = { + id: string + metadata?: { + [key: string]: unknown + } type: "vcs.branch.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef data: { branch?: string } } -export type V2EventWorkspaceReady = { +export type WorkspaceReady = { id: string metadata?: { [key: string]: unknown } + type: "workspace.ready" durable?: { aggregateID: string seq: number version: number } location?: LocationRef - type: "workspace.ready" data: { name: string } } -export type V2EventWorkspaceFailed = { +export type WorkspaceFailed = { id: string metadata?: { [key: string]: unknown } + type: "workspace.failed" durable?: { aggregateID: string seq: number version: number } location?: LocationRef - type: "workspace.failed" data: { message: string } } -export type V2EventWorkspaceStatus = { +export type WorkspaceStatus = { id: string metadata?: { [key: string]: unknown } + type: "workspace.status" durable?: { aggregateID: string seq: number version: number } location?: LocationRef - type: "workspace.status" data: { workspaceID: string status: "connected" | "connecting" | "disconnected" | "error" } } -export type V2EventWorktreeReady = { +export type WorktreeReady = { id: string metadata?: { [key: string]: unknown } + type: "worktree.ready" durable?: { aggregateID: string seq: number version: number } location?: LocationRef - type: "worktree.ready" data: { name: string branch?: string } } -export type V2EventWorktreeFailed = { +export type WorktreeFailed = { id: string metadata?: { [key: string]: unknown } + type: "worktree.failed" durable?: { aggregateID: string seq: number version: number } location?: LocationRef - type: "worktree.failed" data: { message: string } } -export type V2EventServerConnected = { +export type ServerConnected = { id: string metadata?: { [key: string]: unknown } + type: "server.connected" durable?: { aggregateID: string seq: number version: number } location?: LocationRef - type: "server.connected" data: { [key: string]: unknown } } -export type V2EventGlobalDisposed = { +export type GlobalDisposed = { id: string metadata?: { [key: string]: unknown } + type: "global.disposed" durable?: { aggregateID: string seq: number version: number } location?: LocationRef - type: "global.disposed" data: { [key: string]: unknown } diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 7a4351907a..1c833aa64b 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -23781,274 +23781,475 @@ "required": ["_tag", "providerID", "message"], "additionalProperties": false }, - "V2Event": { + "OutputFormat1": { "anyOf": [ { - "$ref": "#/components/schemas/V2EventModels-devRefreshed" - }, - { - "$ref": "#/components/schemas/V2EventIntegrationUpdated" - }, - { - "$ref": "#/components/schemas/V2EventIntegrationConnectionUpdated" - }, - { - "$ref": "#/components/schemas/V2EventCatalogUpdated" - }, - { - "$ref": "#/components/schemas/V2EventSessionCreated" - }, - { - "$ref": "#/components/schemas/V2EventSessionUpdated" - }, - { - "$ref": "#/components/schemas/V2EventSessionDeleted" - }, - { - "$ref": "#/components/schemas/V2EventMessageUpdated" - }, - { - "$ref": "#/components/schemas/V2EventMessageRemoved" - }, - { - "$ref": "#/components/schemas/V2EventMessagePartUpdated" - }, - { - "$ref": "#/components/schemas/V2EventMessagePartRemoved" - }, - { - "$ref": "#/components/schemas/V2EventSessionNextAgentSwitched" - }, - { - "$ref": "#/components/schemas/V2EventSessionNextModelSwitched" - }, - { - "$ref": "#/components/schemas/V2EventSessionNextMoved" - }, - { - "$ref": "#/components/schemas/V2EventSessionNextPrompted" - }, - { - "$ref": "#/components/schemas/V2EventSessionNextPromptAdmitted" - }, - { - "$ref": "#/components/schemas/V2EventSessionNextContextUpdated" - }, - { - "$ref": "#/components/schemas/V2EventSessionNextSynthetic" - }, - { - "$ref": "#/components/schemas/V2EventSessionNextShellStarted" - }, - { - "$ref": "#/components/schemas/V2EventSessionNextShellEnded" - }, - { - "$ref": "#/components/schemas/V2EventSessionNextStepStarted" - }, - { - "$ref": "#/components/schemas/V2EventSessionNextStepEnded" - }, - { - "$ref": "#/components/schemas/V2EventSessionNextStepFailed" - }, - { - "$ref": "#/components/schemas/V2EventSessionNextTextStarted" - }, - { - "$ref": "#/components/schemas/V2EventSessionNextTextDelta" - }, - { - "$ref": "#/components/schemas/V2EventSessionNextTextEnded" - }, - { - "$ref": "#/components/schemas/V2EventSessionNextReasoningStarted" - }, - { - "$ref": "#/components/schemas/V2EventSessionNextReasoningDelta" - }, - { - "$ref": "#/components/schemas/V2EventSessionNextReasoningEnded" - }, - { - "$ref": "#/components/schemas/V2EventSessionNextToolInputStarted" - }, - { - "$ref": "#/components/schemas/V2EventSessionNextToolInputDelta" - }, - { - "$ref": "#/components/schemas/V2EventSessionNextToolInputEnded" - }, - { - "$ref": "#/components/schemas/V2EventSessionNextToolCalled" - }, - { - "$ref": "#/components/schemas/V2EventSessionNextToolProgress" - }, - { - "$ref": "#/components/schemas/V2EventSessionNextToolSuccess" - }, - { - "$ref": "#/components/schemas/V2EventSessionNextToolFailed" - }, - { - "$ref": "#/components/schemas/V2EventSessionNextRetried" - }, - { - "$ref": "#/components/schemas/V2EventSessionNextCompactionStarted" - }, - { - "$ref": "#/components/schemas/V2EventSessionNextCompactionDelta" - }, - { - "$ref": "#/components/schemas/V2EventSessionNextCompactionEnded" - }, - { - "$ref": "#/components/schemas/V2EventSessionNextRevertStaged" - }, - { - "$ref": "#/components/schemas/V2EventSessionNextRevertCleared" - }, - { - "$ref": "#/components/schemas/V2EventSessionNextRevertCommitted" - }, - { - "$ref": "#/components/schemas/V2EventMessagePartDelta" - }, - { - "$ref": "#/components/schemas/V2EventSessionDiff" - }, - { - "$ref": "#/components/schemas/V2EventSessionError" - }, - { - "$ref": "#/components/schemas/V2EventInstallationUpdated" - }, - { - "$ref": "#/components/schemas/V2EventInstallationUpdate-available" - }, - { - "$ref": "#/components/schemas/V2EventFileEdited" - }, - { - "$ref": "#/components/schemas/V2EventReferenceUpdated" - }, - { - "$ref": "#/components/schemas/V2EventPermissionV2Asked" - }, - { - "$ref": "#/components/schemas/V2EventPermissionV2Replied" - }, - { - "$ref": "#/components/schemas/V2EventPluginAdded" - }, - { - "$ref": "#/components/schemas/V2EventProjectDirectoriesUpdated" - }, - { - "$ref": "#/components/schemas/V2EventFileWatcherUpdated" - }, - { - "$ref": "#/components/schemas/V2EventPtyCreated" - }, - { - "$ref": "#/components/schemas/V2EventPtyUpdated" - }, - { - "$ref": "#/components/schemas/V2EventPtyExited" - }, - { - "$ref": "#/components/schemas/V2EventPtyDeleted" - }, - { - "$ref": "#/components/schemas/V2EventQuestionV2Asked" - }, - { - "$ref": "#/components/schemas/V2EventQuestionV2Replied" - }, - { - "$ref": "#/components/schemas/V2EventQuestionV2Rejected" - }, - { - "$ref": "#/components/schemas/V2EventTodoUpdated" - }, - { - "$ref": "#/components/schemas/V2EventLspUpdated" - }, - { - "$ref": "#/components/schemas/V2EventPermissionAsked" - }, - { - "$ref": "#/components/schemas/V2EventPermissionReplied" - }, - { - "$ref": "#/components/schemas/V2EventTuiPromptAppend" - }, - { - "$ref": "#/components/schemas/V2EventTuiCommandExecute" - }, - { - "$ref": "#/components/schemas/V2EventTuiToastShow" - }, - { - "$ref": "#/components/schemas/V2EventTuiSessionSelect" - }, - { - "$ref": "#/components/schemas/V2EventMcpToolsChanged" - }, - { - "$ref": "#/components/schemas/V2EventMcpBrowserOpenFailed" - }, - { - "$ref": "#/components/schemas/V2EventCommandExecuted" - }, - { - "$ref": "#/components/schemas/V2EventProjectUpdated" - }, - { - "$ref": "#/components/schemas/V2EventSessionStatus" - }, - { - "$ref": "#/components/schemas/V2EventSessionIdle" - }, - { - "$ref": "#/components/schemas/V2EventQuestionAsked" - }, - { - "$ref": "#/components/schemas/V2EventQuestionReplied" - }, - { - "$ref": "#/components/schemas/V2EventQuestionRejected" - }, - { - "$ref": "#/components/schemas/V2EventSessionCompacted" - }, - { - "$ref": "#/components/schemas/V2EventVcsBranchUpdated" - }, - { - "$ref": "#/components/schemas/V2EventWorkspaceReady" - }, - { - "$ref": "#/components/schemas/V2EventWorkspaceFailed" - }, - { - "$ref": "#/components/schemas/V2EventWorkspaceStatus" - }, - { - "$ref": "#/components/schemas/V2EventWorktreeReady" - }, - { - "$ref": "#/components/schemas/V2EventWorktreeFailed" - }, - { - "$ref": "#/components/schemas/V2EventServerConnected" - }, - { - "$ref": "#/components/schemas/V2EventGlobalDisposed" + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["text"] + } + }, + "required": ["type"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["json_schema"] + }, + "schema": { + "$ref": "#/components/schemas/JSONSchema" + }, + "retryCount": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["type", "schema"], + "additionalProperties": false } ] }, + "session.status": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["session.status"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "status": { + "$ref": "#/components/schemas/SessionStatus" + } + }, + "required": ["sessionID", "status"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "question.replied": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["question.replied"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "requestID": { + "type": "string", + "pattern": "^que" + }, + "answers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QuestionAnswer" + } + } + }, + "required": ["sessionID", "requestID", "answers"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "question.rejected": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, + "type": { + "type": "string", + "enum": ["question.rejected"] + }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "requestID": { + "type": "string", + "pattern": "^que" + } + }, + "required": ["sessionID", "requestID"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "V2Event1": { + "anyOf": [ + { + "$ref": "#/components/schemas/Models-devRefreshed" + }, + { + "$ref": "#/components/schemas/IntegrationUpdated" + }, + { + "$ref": "#/components/schemas/IntegrationConnectionUpdated" + }, + { + "$ref": "#/components/schemas/CatalogUpdated" + }, + { + "$ref": "#/components/schemas/SessionCreated" + }, + { + "$ref": "#/components/schemas/SessionUpdated" + }, + { + "$ref": "#/components/schemas/SessionDeleted" + }, + { + "$ref": "#/components/schemas/MessageUpdated" + }, + { + "$ref": "#/components/schemas/MessageRemoved" + }, + { + "$ref": "#/components/schemas/MessagePartUpdated" + }, + { + "$ref": "#/components/schemas/MessagePartRemoved" + }, + { + "$ref": "#/components/schemas/SessionNextAgentSwitched" + }, + { + "$ref": "#/components/schemas/SessionNextModelSwitched" + }, + { + "$ref": "#/components/schemas/SessionNextMoved" + }, + { + "$ref": "#/components/schemas/SessionNextPrompted" + }, + { + "$ref": "#/components/schemas/SessionNextPromptAdmitted" + }, + { + "$ref": "#/components/schemas/SessionNextContextUpdated" + }, + { + "$ref": "#/components/schemas/SessionNextSynthetic" + }, + { + "$ref": "#/components/schemas/SessionNextShellStarted" + }, + { + "$ref": "#/components/schemas/SessionNextShellEnded" + }, + { + "$ref": "#/components/schemas/SessionNextStepStarted" + }, + { + "$ref": "#/components/schemas/SessionNextStepEnded" + }, + { + "$ref": "#/components/schemas/SessionNextStepFailed" + }, + { + "$ref": "#/components/schemas/SessionNextTextStarted" + }, + { + "$ref": "#/components/schemas/SessionNextTextDelta" + }, + { + "$ref": "#/components/schemas/SessionNextTextEnded" + }, + { + "$ref": "#/components/schemas/SessionNextReasoningStarted" + }, + { + "$ref": "#/components/schemas/SessionNextReasoningDelta" + }, + { + "$ref": "#/components/schemas/SessionNextReasoningEnded" + }, + { + "$ref": "#/components/schemas/SessionNextToolInputStarted" + }, + { + "$ref": "#/components/schemas/SessionNextToolInputDelta" + }, + { + "$ref": "#/components/schemas/SessionNextToolInputEnded" + }, + { + "$ref": "#/components/schemas/SessionNextToolCalled" + }, + { + "$ref": "#/components/schemas/SessionNextToolProgress" + }, + { + "$ref": "#/components/schemas/SessionNextToolSuccess" + }, + { + "$ref": "#/components/schemas/SessionNextToolFailed" + }, + { + "$ref": "#/components/schemas/SessionNextRetried" + }, + { + "$ref": "#/components/schemas/SessionNextCompactionStarted" + }, + { + "$ref": "#/components/schemas/SessionNextCompactionDelta" + }, + { + "$ref": "#/components/schemas/SessionNextCompactionEnded" + }, + { + "$ref": "#/components/schemas/SessionNextRevertStaged" + }, + { + "$ref": "#/components/schemas/SessionNextRevertCleared" + }, + { + "$ref": "#/components/schemas/SessionNextRevertCommitted" + }, + { + "$ref": "#/components/schemas/MessagePartDelta" + }, + { + "$ref": "#/components/schemas/SessionDiff" + }, + { + "$ref": "#/components/schemas/SessionError" + }, + { + "$ref": "#/components/schemas/InstallationUpdated" + }, + { + "$ref": "#/components/schemas/InstallationUpdate-available" + }, + { + "$ref": "#/components/schemas/FileEdited" + }, + { + "$ref": "#/components/schemas/ReferenceUpdated" + }, + { + "$ref": "#/components/schemas/PermissionV2Asked" + }, + { + "$ref": "#/components/schemas/PermissionV2Replied" + }, + { + "$ref": "#/components/schemas/PluginAdded" + }, + { + "$ref": "#/components/schemas/ProjectDirectoriesUpdated" + }, + { + "$ref": "#/components/schemas/FileWatcherUpdated" + }, + { + "$ref": "#/components/schemas/PtyCreated" + }, + { + "$ref": "#/components/schemas/PtyUpdated" + }, + { + "$ref": "#/components/schemas/PtyExited" + }, + { + "$ref": "#/components/schemas/PtyDeleted" + }, + { + "$ref": "#/components/schemas/QuestionV2Asked" + }, + { + "$ref": "#/components/schemas/QuestionV2Replied" + }, + { + "$ref": "#/components/schemas/QuestionV2Rejected" + }, + { + "$ref": "#/components/schemas/TodoUpdated" + }, + { + "$ref": "#/components/schemas/LspUpdated" + }, + { + "$ref": "#/components/schemas/PermissionAsked" + }, + { + "$ref": "#/components/schemas/PermissionReplied" + }, + { + "$ref": "#/components/schemas/TuiPromptAppend" + }, + { + "$ref": "#/components/schemas/TuiCommandExecute" + }, + { + "$ref": "#/components/schemas/TuiToastShow" + }, + { + "$ref": "#/components/schemas/TuiSessionSelect" + }, + { + "$ref": "#/components/schemas/McpToolsChanged" + }, + { + "$ref": "#/components/schemas/McpBrowserOpenFailed" + }, + { + "$ref": "#/components/schemas/CommandExecuted" + }, + { + "$ref": "#/components/schemas/ProjectUpdated" + }, + { + "$ref": "#/components/schemas/session.status" + }, + { + "$ref": "#/components/schemas/SessionIdle" + }, + { + "$ref": "#/components/schemas/QuestionAsked" + }, + { + "$ref": "#/components/schemas/question.replied" + }, + { + "$ref": "#/components/schemas/question.rejected" + }, + { + "$ref": "#/components/schemas/SessionCompacted" + }, + { + "$ref": "#/components/schemas/VcsBranchUpdated" + }, + { + "$ref": "#/components/schemas/WorkspaceReady" + }, + { + "$ref": "#/components/schemas/WorkspaceFailed" + }, + { + "$ref": "#/components/schemas/WorkspaceStatus" + }, + { + "$ref": "#/components/schemas/WorktreeReady" + }, + { + "$ref": "#/components/schemas/WorktreeFailed" + }, + { + "$ref": "#/components/schemas/ServerConnected" + }, + { + "$ref": "#/components/schemas/GlobalDisposed" + } + ] + }, + "V2Event": { + "type": "string", + "contentSchema": { + "$ref": "#/components/schemas/V2Event1" + }, + "contentMediaType": "application/json" + }, "ForbiddenError": { "type": "object", "properties": { @@ -30457,7 +30658,7 @@ "required": ["name", "location", "content"], "additionalProperties": false }, - "V2EventModels-devRefreshed": { + "Models-devRefreshed": { "type": "object", "properties": { "id": { @@ -30467,47 +30668,10 @@ "metadata": { "type": "object" }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer" - }, - "version": { - "type": "integer" - } - }, - "required": ["aggregateID", "seq", "version"], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/LocationRef" - }, "type": { "type": "string", "enum": ["models-dev.refreshed"] }, - "data": { - "type": "object", - "properties": {} - } - }, - "required": ["id", "type", "data"], - "additionalProperties": false - }, - "V2EventIntegrationUpdated": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "metadata": { - "type": "object" - }, "durable": { "type": "object", "properties": { @@ -30527,28 +30691,28 @@ "location": { "$ref": "#/components/schemas/LocationRef" }, + "data": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "IntegrationUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, "type": { "type": "string", "enum": ["integration.updated"] }, - "data": { - "type": "object", - "properties": {} - } - }, - "required": ["id", "type", "data"], - "additionalProperties": false - }, - "V2EventIntegrationConnectionUpdated": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "metadata": { - "type": "object" - }, "durable": { "type": "object", "properties": { @@ -30568,10 +30732,47 @@ "location": { "$ref": "#/components/schemas/LocationRef" }, + "data": { + "type": "object", + "properties": {} + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "IntegrationConnectionUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, "type": { "type": "string", "enum": ["integration.connection.updated"] }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, "data": { "type": "object", "properties": { @@ -30586,7 +30787,7 @@ "required": ["id", "type", "data"], "additionalProperties": false }, - "V2EventCatalogUpdated": { + "CatalogUpdated": { "type": "object", "properties": { "id": { @@ -30596,6 +30797,10 @@ "metadata": { "type": "object" }, + "type": { + "type": "string", + "enum": ["catalog.updated"] + }, "durable": { "type": "object", "properties": { @@ -30615,10 +30820,6 @@ "location": { "$ref": "#/components/schemas/LocationRef" }, - "type": { - "type": "string", - "enum": ["catalog.updated"] - }, "data": { "type": "object", "properties": {} @@ -30627,7 +30828,7 @@ "required": ["id", "type", "data"], "additionalProperties": false }, - "V2EventSessionCreated": { + "SessionCreated": { "type": "object", "properties": { "id": { @@ -30637,57 +30838,10 @@ "metadata": { "type": "object" }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer" - }, - "version": { - "type": "integer" - } - }, - "required": ["aggregateID", "seq", "version"], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/LocationRef" - }, "type": { "type": "string", "enum": ["session.created"] }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "info": { - "$ref": "#/components/schemas/Session" - } - }, - "required": ["sessionID", "info"], - "additionalProperties": false - } - }, - "required": ["id", "type", "data"], - "additionalProperties": false - }, - "V2EventSessionUpdated": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "metadata": { - "type": "object" - }, "durable": { "type": "object", "properties": { @@ -30707,38 +30861,38 @@ "location": { "$ref": "#/components/schemas/LocationRef" }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, "type": { "type": "string", "enum": ["session.updated"] }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "info": { - "$ref": "#/components/schemas/Session" - } - }, - "required": ["sessionID", "info"], - "additionalProperties": false - } - }, - "required": ["id", "type", "data"], - "additionalProperties": false - }, - "V2EventSessionDeleted": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "metadata": { - "type": "object" - }, "durable": { "type": "object", "properties": { @@ -30758,38 +30912,38 @@ "location": { "$ref": "#/components/schemas/LocationRef" }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "SessionDeleted": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, "type": { "type": "string", "enum": ["session.deleted"] }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "info": { - "$ref": "#/components/schemas/Session" - } - }, - "required": ["sessionID", "info"], - "additionalProperties": false - } - }, - "required": ["id", "type", "data"], - "additionalProperties": false - }, - "V2EventMessageUpdated": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "metadata": { - "type": "object" - }, "durable": { "type": "object", "properties": { @@ -30809,10 +30963,57 @@ "location": { "$ref": "#/components/schemas/LocationRef" }, + "data": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "info": { + "$ref": "#/components/schemas/Session" + } + }, + "required": ["sessionID", "info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "MessageUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, "type": { "type": "string", "enum": ["message.updated"] }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, "data": { "type": "object", "properties": { @@ -30831,7 +31032,7 @@ "required": ["id", "type", "data"], "additionalProperties": false }, - "V2EventMessageRemoved": { + "MessageRemoved": { "type": "object", "properties": { "id": { @@ -30841,6 +31042,10 @@ "metadata": { "type": "object" }, + "type": { + "type": "string", + "enum": ["message.removed"] + }, "durable": { "type": "object", "properties": { @@ -30860,10 +31065,6 @@ "location": { "$ref": "#/components/schemas/LocationRef" }, - "type": { - "type": "string", - "enum": ["message.removed"] - }, "data": { "type": "object", "properties": { @@ -30883,7 +31084,7 @@ "required": ["id", "type", "data"], "additionalProperties": false }, - "V2EventMessagePartUpdated": { + "MessagePartUpdated": { "type": "object", "properties": { "id": { @@ -30893,6 +31094,10 @@ "metadata": { "type": "object" }, + "type": { + "type": "string", + "enum": ["message.part.updated"] + }, "durable": { "type": "object", "properties": { @@ -30912,10 +31117,6 @@ "location": { "$ref": "#/components/schemas/LocationRef" }, - "type": { - "type": "string", - "enum": ["message.part.updated"] - }, "data": { "type": "object", "properties": { @@ -30937,7 +31138,7 @@ "required": ["id", "type", "data"], "additionalProperties": false }, - "V2EventMessagePartRemoved": { + "MessagePartRemoved": { "type": "object", "properties": { "id": { @@ -30947,6 +31148,10 @@ "metadata": { "type": "object" }, + "type": { + "type": "string", + "enum": ["message.part.removed"] + }, "durable": { "type": "object", "properties": { @@ -30966,10 +31171,6 @@ "location": { "$ref": "#/components/schemas/LocationRef" }, - "type": { - "type": "string", - "enum": ["message.part.removed"] - }, "data": { "type": "object", "properties": { @@ -30993,7 +31194,7 @@ "required": ["id", "type", "data"], "additionalProperties": false }, - "V2EventSessionNextAgentSwitched": { + "SessionNextTextDelta": { "type": "object", "properties": { "id": { @@ -31003,839 +31204,29 @@ "metadata": { "type": "object" }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer" - }, - "version": { - "type": "integer" - } - }, - "required": ["aggregateID", "seq", "version"], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/LocationRef" - }, - "type": { - "type": "string", - "enum": ["session.next.agent.switched"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "messageID": { - "type": "string", - "pattern": "^msg_" - }, - "agent": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "messageID", "agent"], - "additionalProperties": false - } - }, - "required": ["id", "type", "data"], - "additionalProperties": false - }, - "V2EventSessionNextModelSwitched": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "metadata": { - "type": "object" - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer" - }, - "version": { - "type": "integer" - } - }, - "required": ["aggregateID", "seq", "version"], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/LocationRef" - }, - "type": { - "type": "string", - "enum": ["session.next.model.switched"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "messageID": { - "type": "string", - "pattern": "^msg_" - }, - "model": { - "$ref": "#/components/schemas/ModelRef" - } - }, - "required": ["timestamp", "sessionID", "messageID", "model"], - "additionalProperties": false - } - }, - "required": ["id", "type", "data"], - "additionalProperties": false - }, - "V2EventSessionNextMoved": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "metadata": { - "type": "object" - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer" - }, - "version": { - "type": "integer" - } - }, - "required": ["aggregateID", "seq", "version"], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/LocationRef" - }, - "type": { - "type": "string", - "enum": ["session.next.moved"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "location": { - "$ref": "#/components/schemas/LocationRef" - }, - "subdirectory": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "location"], - "additionalProperties": false - } - }, - "required": ["id", "type", "data"], - "additionalProperties": false - }, - "V2EventSessionNextPrompted": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "metadata": { - "type": "object" - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer" - }, - "version": { - "type": "integer" - } - }, - "required": ["aggregateID", "seq", "version"], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/LocationRef" - }, - "type": { - "type": "string", - "enum": ["session.next.prompted"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "messageID": { - "type": "string", - "pattern": "^msg_" - }, - "prompt": { - "$ref": "#/components/schemas/Prompt" - }, - "delivery": { - "type": "string", - "enum": ["steer", "queue"] - } - }, - "required": ["timestamp", "sessionID", "messageID", "prompt", "delivery"], - "additionalProperties": false - } - }, - "required": ["id", "type", "data"], - "additionalProperties": false - }, - "V2EventSessionNextPromptAdmitted": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "metadata": { - "type": "object" - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer" - }, - "version": { - "type": "integer" - } - }, - "required": ["aggregateID", "seq", "version"], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/LocationRef" - }, - "type": { - "type": "string", - "enum": ["session.next.prompt.admitted"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "messageID": { - "type": "string", - "pattern": "^msg_" - }, - "prompt": { - "$ref": "#/components/schemas/Prompt" - }, - "delivery": { - "type": "string", - "enum": ["steer", "queue"] - } - }, - "required": ["timestamp", "sessionID", "messageID", "prompt", "delivery"], - "additionalProperties": false - } - }, - "required": ["id", "type", "data"], - "additionalProperties": false - }, - "V2EventSessionNextContextUpdated": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "metadata": { - "type": "object" - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer" - }, - "version": { - "type": "integer" - } - }, - "required": ["aggregateID", "seq", "version"], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/LocationRef" - }, - "type": { - "type": "string", - "enum": ["session.next.context.updated"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "messageID": { - "type": "string", - "pattern": "^msg_" - }, - "text": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "messageID", "text"], - "additionalProperties": false - } - }, - "required": ["id", "type", "data"], - "additionalProperties": false - }, - "V2EventSessionNextSynthetic": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "metadata": { - "type": "object" - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer" - }, - "version": { - "type": "integer" - } - }, - "required": ["aggregateID", "seq", "version"], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/LocationRef" - }, - "type": { - "type": "string", - "enum": ["session.next.synthetic"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "messageID": { - "type": "string", - "pattern": "^msg_" - }, - "text": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "messageID", "text"], - "additionalProperties": false - } - }, - "required": ["id", "type", "data"], - "additionalProperties": false - }, - "V2EventSessionNextShellStarted": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "metadata": { - "type": "object" - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer" - }, - "version": { - "type": "integer" - } - }, - "required": ["aggregateID", "seq", "version"], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/LocationRef" - }, - "type": { - "type": "string", - "enum": ["session.next.shell.started"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "messageID": { - "type": "string", - "pattern": "^msg_" - }, - "callID": { - "type": "string" - }, - "command": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "messageID", "callID", "command"], - "additionalProperties": false - } - }, - "required": ["id", "type", "data"], - "additionalProperties": false - }, - "V2EventSessionNextShellEnded": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "metadata": { - "type": "object" - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer" - }, - "version": { - "type": "integer" - } - }, - "required": ["aggregateID", "seq", "version"], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/LocationRef" - }, - "type": { - "type": "string", - "enum": ["session.next.shell.ended"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "callID": { - "type": "string" - }, - "output": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "callID", "output"], - "additionalProperties": false - } - }, - "required": ["id", "type", "data"], - "additionalProperties": false - }, - "V2EventSessionNextStepStarted": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "metadata": { - "type": "object" - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer" - }, - "version": { - "type": "integer" - } - }, - "required": ["aggregateID", "seq", "version"], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/LocationRef" - }, - "type": { - "type": "string", - "enum": ["session.next.step.started"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "assistantMessageID": { - "type": "string", - "pattern": "^msg_" - }, - "agent": { - "type": "string" - }, - "model": { - "$ref": "#/components/schemas/ModelRef" - }, - "snapshot": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "assistantMessageID", "agent", "model"], - "additionalProperties": false - } - }, - "required": ["id", "type", "data"], - "additionalProperties": false - }, - "V2EventSessionNextStepEnded": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "metadata": { - "type": "object" - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer" - }, - "version": { - "type": "integer" - } - }, - "required": ["aggregateID", "seq", "version"], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/LocationRef" - }, - "type": { - "type": "string", - "enum": ["session.next.step.ended"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "assistantMessageID": { - "type": "string", - "pattern": "^msg_" - }, - "finish": { - "type": "string" - }, - "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 - }, - "snapshot": { - "type": "string" - }, - "files": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": ["timestamp", "sessionID", "assistantMessageID", "finish", "cost", "tokens"], - "additionalProperties": false - } - }, - "required": ["id", "type", "data"], - "additionalProperties": false - }, - "V2EventSessionNextStepFailed": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "metadata": { - "type": "object" - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer" - }, - "version": { - "type": "integer" - } - }, - "required": ["aggregateID", "seq", "version"], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/LocationRef" - }, - "type": { - "type": "string", - "enum": ["session.next.step.failed"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "assistantMessageID": { - "type": "string", - "pattern": "^msg_" - }, - "error": { - "$ref": "#/components/schemas/SessionErrorUnknown" - } - }, - "required": ["timestamp", "sessionID", "assistantMessageID", "error"], - "additionalProperties": false - } - }, - "required": ["id", "type", "data"], - "additionalProperties": false - }, - "V2EventSessionNextTextStarted": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "metadata": { - "type": "object" - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer" - }, - "version": { - "type": "integer" - } - }, - "required": ["aggregateID", "seq", "version"], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/LocationRef" - }, - "type": { - "type": "string", - "enum": ["session.next.text.started"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "assistantMessageID": { - "type": "string", - "pattern": "^msg_" - }, - "textID": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "assistantMessageID", "textID"], - "additionalProperties": false - } - }, - "required": ["id", "type", "data"], - "additionalProperties": false - }, - "V2EventSessionNextTextDelta": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "metadata": { - "type": "object" - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer" - }, - "version": { - "type": "integer" - } - }, - "required": ["aggregateID", "seq", "version"], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/LocationRef" - }, "type": { "type": "string", "enum": ["session.next.text.delta"] }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, "data": { "type": "object", "properties": { @@ -31864,7 +31255,7 @@ "required": ["id", "type", "data"], "additionalProperties": false }, - "V2EventSessionNextTextEnded": { + "SessionNextReasoningDelta": { "type": "object", "properties": { "id": { @@ -31874,151 +31265,29 @@ "metadata": { "type": "object" }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer" - }, - "version": { - "type": "integer" - } - }, - "required": ["aggregateID", "seq", "version"], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/LocationRef" - }, - "type": { - "type": "string", - "enum": ["session.next.text.ended"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "assistantMessageID": { - "type": "string", - "pattern": "^msg_" - }, - "textID": { - "type": "string" - }, - "text": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "assistantMessageID", "textID", "text"], - "additionalProperties": false - } - }, - "required": ["id", "type", "data"], - "additionalProperties": false - }, - "V2EventSessionNextReasoningStarted": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "metadata": { - "type": "object" - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer" - }, - "version": { - "type": "integer" - } - }, - "required": ["aggregateID", "seq", "version"], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/LocationRef" - }, - "type": { - "type": "string", - "enum": ["session.next.reasoning.started"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "assistantMessageID": { - "type": "string", - "pattern": "^msg_" - }, - "reasoningID": { - "type": "string" - }, - "providerMetadata": { - "$ref": "#/components/schemas/LLMProviderMetadata" - } - }, - "required": ["timestamp", "sessionID", "assistantMessageID", "reasoningID"], - "additionalProperties": false - } - }, - "required": ["id", "type", "data"], - "additionalProperties": false - }, - "V2EventSessionNextReasoningDelta": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "metadata": { - "type": "object" - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer" - }, - "version": { - "type": "integer" - } - }, - "required": ["aggregateID", "seq", "version"], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/LocationRef" - }, "type": { "type": "string", "enum": ["session.next.reasoning.delta"] }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, "data": { "type": "object", "properties": { @@ -32047,7 +31316,7 @@ "required": ["id", "type", "data"], "additionalProperties": false }, - "V2EventSessionNextReasoningEnded": { + "SessionNextToolInputDelta": { "type": "object", "properties": { "id": { @@ -32057,154 +31326,29 @@ "metadata": { "type": "object" }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer" - }, - "version": { - "type": "integer" - } - }, - "required": ["aggregateID", "seq", "version"], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/LocationRef" - }, - "type": { - "type": "string", - "enum": ["session.next.reasoning.ended"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "assistantMessageID": { - "type": "string", - "pattern": "^msg_" - }, - "reasoningID": { - "type": "string" - }, - "text": { - "type": "string" - }, - "providerMetadata": { - "$ref": "#/components/schemas/LLMProviderMetadata" - } - }, - "required": ["timestamp", "sessionID", "assistantMessageID", "reasoningID", "text"], - "additionalProperties": false - } - }, - "required": ["id", "type", "data"], - "additionalProperties": false - }, - "V2EventSessionNextToolInputStarted": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "metadata": { - "type": "object" - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer" - }, - "version": { - "type": "integer" - } - }, - "required": ["aggregateID", "seq", "version"], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/LocationRef" - }, - "type": { - "type": "string", - "enum": ["session.next.tool.input.started"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "assistantMessageID": { - "type": "string", - "pattern": "^msg_" - }, - "callID": { - "type": "string" - }, - "name": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "assistantMessageID", "callID", "name"], - "additionalProperties": false - } - }, - "required": ["id", "type", "data"], - "additionalProperties": false - }, - "V2EventSessionNextToolInputDelta": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "metadata": { - "type": "object" - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer" - }, - "version": { - "type": "integer" - } - }, - "required": ["aggregateID", "seq", "version"], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/LocationRef" - }, "type": { "type": "string", "enum": ["session.next.tool.input.delta"] }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, "data": { "type": "object", "properties": { @@ -32233,7 +31377,7 @@ "required": ["id", "type", "data"], "additionalProperties": false }, - "V2EventSessionNextToolInputEnded": { + "SessionNextCompactionDelta": { "type": "object", "properties": { "id": { @@ -32243,512 +31387,29 @@ "metadata": { "type": "object" }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer" - }, - "version": { - "type": "integer" - } - }, - "required": ["aggregateID", "seq", "version"], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/LocationRef" - }, - "type": { - "type": "string", - "enum": ["session.next.tool.input.ended"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "assistantMessageID": { - "type": "string", - "pattern": "^msg_" - }, - "callID": { - "type": "string" - }, - "text": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "assistantMessageID", "callID", "text"], - "additionalProperties": false - } - }, - "required": ["id", "type", "data"], - "additionalProperties": false - }, - "V2EventSessionNextToolCalled": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "metadata": { - "type": "object" - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer" - }, - "version": { - "type": "integer" - } - }, - "required": ["aggregateID", "seq", "version"], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/LocationRef" - }, - "type": { - "type": "string", - "enum": ["session.next.tool.called"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "assistantMessageID": { - "type": "string", - "pattern": "^msg_" - }, - "callID": { - "type": "string" - }, - "tool": { - "type": "string" - }, - "input": { - "type": "object" - }, - "provider": { - "type": "object", - "properties": { - "executed": { - "type": "boolean" - }, - "metadata": { - "$ref": "#/components/schemas/LLMProviderMetadata" - } - }, - "required": ["executed"], - "additionalProperties": false - } - }, - "required": ["timestamp", "sessionID", "assistantMessageID", "callID", "tool", "input", "provider"], - "additionalProperties": false - } - }, - "required": ["id", "type", "data"], - "additionalProperties": false - }, - "V2EventSessionNextToolProgress": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "metadata": { - "type": "object" - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer" - }, - "version": { - "type": "integer" - } - }, - "required": ["aggregateID", "seq", "version"], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/LocationRef" - }, - "type": { - "type": "string", - "enum": ["session.next.tool.progress"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "assistantMessageID": { - "type": "string", - "pattern": "^msg_" - }, - "callID": { - "type": "string" - }, - "structured": { - "type": "object" - }, - "content": { - "type": "array", - "items": { - "$ref": "#/components/schemas/LLMToolContent" - } - } - }, - "required": ["timestamp", "sessionID", "assistantMessageID", "callID", "structured", "content"], - "additionalProperties": false - } - }, - "required": ["id", "type", "data"], - "additionalProperties": false - }, - "V2EventSessionNextToolSuccess": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "metadata": { - "type": "object" - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer" - }, - "version": { - "type": "integer" - } - }, - "required": ["aggregateID", "seq", "version"], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/LocationRef" - }, - "type": { - "type": "string", - "enum": ["session.next.tool.success"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "assistantMessageID": { - "type": "string", - "pattern": "^msg_" - }, - "callID": { - "type": "string" - }, - "structured": { - "type": "object" - }, - "content": { - "type": "array", - "items": { - "$ref": "#/components/schemas/LLMToolContent" - } - }, - "outputPaths": { - "type": "array", - "items": { - "type": "string" - } - }, - "result": {}, - "provider": { - "type": "object", - "properties": { - "executed": { - "type": "boolean" - }, - "metadata": { - "$ref": "#/components/schemas/LLMProviderMetadata" - } - }, - "required": ["executed"], - "additionalProperties": false - } - }, - "required": ["timestamp", "sessionID", "assistantMessageID", "callID", "structured", "content", "provider"], - "additionalProperties": false - } - }, - "required": ["id", "type", "data"], - "additionalProperties": false - }, - "V2EventSessionNextToolFailed": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "metadata": { - "type": "object" - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer" - }, - "version": { - "type": "integer" - } - }, - "required": ["aggregateID", "seq", "version"], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/LocationRef" - }, - "type": { - "type": "string", - "enum": ["session.next.tool.failed"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "assistantMessageID": { - "type": "string", - "pattern": "^msg_" - }, - "callID": { - "type": "string" - }, - "error": { - "$ref": "#/components/schemas/SessionErrorUnknown" - }, - "result": {}, - "provider": { - "type": "object", - "properties": { - "executed": { - "type": "boolean" - }, - "metadata": { - "$ref": "#/components/schemas/LLMProviderMetadata" - } - }, - "required": ["executed"], - "additionalProperties": false - } - }, - "required": ["timestamp", "sessionID", "assistantMessageID", "callID", "error", "provider"], - "additionalProperties": false - } - }, - "required": ["id", "type", "data"], - "additionalProperties": false - }, - "V2EventSessionNextRetried": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "metadata": { - "type": "object" - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer" - }, - "version": { - "type": "integer" - } - }, - "required": ["aggregateID", "seq", "version"], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/LocationRef" - }, - "type": { - "type": "string", - "enum": ["session.next.retried"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "attempt": { - "type": "number" - }, - "error": { - "$ref": "#/components/schemas/SessionNextRetry_error" - } - }, - "required": ["timestamp", "sessionID", "attempt", "error"], - "additionalProperties": false - } - }, - "required": ["id", "type", "data"], - "additionalProperties": false - }, - "V2EventSessionNextCompactionStarted": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "metadata": { - "type": "object" - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer" - }, - "version": { - "type": "integer" - } - }, - "required": ["aggregateID", "seq", "version"], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/LocationRef" - }, - "type": { - "type": "string", - "enum": ["session.next.compaction.started"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "messageID": { - "type": "string", - "pattern": "^msg_" - }, - "reason": { - "type": "string", - "enum": ["auto", "manual"] - } - }, - "required": ["timestamp", "sessionID", "messageID", "reason"], - "additionalProperties": false - } - }, - "required": ["id", "type", "data"], - "additionalProperties": false - }, - "V2EventSessionNextCompactionDelta": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "metadata": { - "type": "object" - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer" - }, - "version": { - "type": "integer" - } - }, - "required": ["aggregateID", "seq", "version"], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/LocationRef" - }, "type": { "type": "string", "enum": ["session.next.compaction.delta"] }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, "data": { "type": "object", "properties": { @@ -32774,7 +31435,7 @@ "required": ["id", "type", "data"], "additionalProperties": false }, - "V2EventSessionNextCompactionEnded": { + "MessagePartDelta": { "type": "object", "properties": { "id": { @@ -32784,254 +31445,29 @@ "metadata": { "type": "object" }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer" - }, - "version": { - "type": "integer" - } - }, - "required": ["aggregateID", "seq", "version"], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/LocationRef" - }, - "type": { - "type": "string", - "enum": ["session.next.compaction.ended"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "messageID": { - "type": "string", - "pattern": "^msg_" - }, - "reason": { - "type": "string", - "enum": ["auto", "manual"] - }, - "text": { - "type": "string" - }, - "recent": { - "type": "string" - } - }, - "required": ["timestamp", "sessionID", "messageID", "reason", "text", "recent"], - "additionalProperties": false - } - }, - "required": ["id", "type", "data"], - "additionalProperties": false - }, - "V2EventSessionNextRevertStaged": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "metadata": { - "type": "object" - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer" - }, - "version": { - "type": "integer" - } - }, - "required": ["aggregateID", "seq", "version"], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/LocationRef" - }, - "type": { - "type": "string", - "enum": ["session.next.revert.staged"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "revert": { - "$ref": "#/components/schemas/RevertState" - } - }, - "required": ["timestamp", "sessionID", "revert"], - "additionalProperties": false - } - }, - "required": ["id", "type", "data"], - "additionalProperties": false - }, - "V2EventSessionNextRevertCleared": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "metadata": { - "type": "object" - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer" - }, - "version": { - "type": "integer" - } - }, - "required": ["aggregateID", "seq", "version"], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/LocationRef" - }, - "type": { - "type": "string", - "enum": ["session.next.revert.cleared"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - } - }, - "required": ["timestamp", "sessionID"], - "additionalProperties": false - } - }, - "required": ["id", "type", "data"], - "additionalProperties": false - }, - "V2EventSessionNextRevertCommitted": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "metadata": { - "type": "object" - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer" - }, - "version": { - "type": "integer" - } - }, - "required": ["aggregateID", "seq", "version"], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/LocationRef" - }, - "type": { - "type": "string", - "enum": ["session.next.revert.committed"] - }, - "data": { - "type": "object", - "properties": { - "timestamp": { - "type": "number" - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "messageID": { - "type": "string", - "pattern": "^msg_" - } - }, - "required": ["timestamp", "sessionID", "messageID"], - "additionalProperties": false - } - }, - "required": ["id", "type", "data"], - "additionalProperties": false - }, - "V2EventMessagePartDelta": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "metadata": { - "type": "object" - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer" - }, - "version": { - "type": "integer" - } - }, - "required": ["aggregateID", "seq", "version"], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/LocationRef" - }, "type": { "type": "string", "enum": ["message.part.delta"] }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, "data": { "type": "object", "properties": { @@ -33061,7 +31497,7 @@ "required": ["id", "type", "data"], "additionalProperties": false }, - "V2EventSessionDiff": { + "SessionDiff": { "type": "object", "properties": { "id": { @@ -33071,6 +31507,10 @@ "metadata": { "type": "object" }, + "type": { + "type": "string", + "enum": ["session.diff"] + }, "durable": { "type": "object", "properties": { @@ -33090,10 +31530,6 @@ "location": { "$ref": "#/components/schemas/LocationRef" }, - "type": { - "type": "string", - "enum": ["session.diff"] - }, "data": { "type": "object", "properties": { @@ -33115,7 +31551,7 @@ "required": ["id", "type", "data"], "additionalProperties": false }, - "V2EventSessionError": { + "SessionError": { "type": "object", "properties": { "id": { @@ -33125,6 +31561,10 @@ "metadata": { "type": "object" }, + "type": { + "type": "string", + "enum": ["session.error"] + }, "durable": { "type": "object", "properties": { @@ -33144,10 +31584,6 @@ "location": { "$ref": "#/components/schemas/LocationRef" }, - "type": { - "type": "string", - "enum": ["session.error"] - }, "data": { "type": "object", "properties": { @@ -33190,7 +31626,7 @@ "required": ["id", "type", "data"], "additionalProperties": false }, - "V2EventInstallationUpdated": { + "InstallationUpdated": { "type": "object", "properties": { "id": { @@ -33200,53 +31636,10 @@ "metadata": { "type": "object" }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer" - }, - "version": { - "type": "integer" - } - }, - "required": ["aggregateID", "seq", "version"], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/LocationRef" - }, "type": { "type": "string", "enum": ["installation.updated"] }, - "data": { - "type": "object", - "properties": { - "version": { - "type": "string" - } - }, - "required": ["version"], - "additionalProperties": false - } - }, - "required": ["id", "type", "data"], - "additionalProperties": false - }, - "V2EventInstallationUpdate-available": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "metadata": { - "type": "object" - }, "durable": { "type": "object", "properties": { @@ -33266,34 +31659,34 @@ "location": { "$ref": "#/components/schemas/LocationRef" }, + "data": { + "type": "object", + "properties": { + "version": { + "type": "string" + } + }, + "required": ["version"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "InstallationUpdate-available": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, "type": { "type": "string", "enum": ["installation.update-available"] }, - "data": { - "type": "object", - "properties": { - "version": { - "type": "string" - } - }, - "required": ["version"], - "additionalProperties": false - } - }, - "required": ["id", "type", "data"], - "additionalProperties": false - }, - "V2EventFileEdited": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "metadata": { - "type": "object" - }, "durable": { "type": "object", "properties": { @@ -33313,10 +31706,53 @@ "location": { "$ref": "#/components/schemas/LocationRef" }, + "data": { + "type": "object", + "properties": { + "version": { + "type": "string" + } + }, + "required": ["version"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "FileEdited": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, "type": { "type": "string", "enum": ["file.edited"] }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, "data": { "type": "object", "properties": { @@ -33331,7 +31767,7 @@ "required": ["id", "type", "data"], "additionalProperties": false }, - "V2EventReferenceUpdated": { + "ReferenceUpdated": { "type": "object", "properties": { "id": { @@ -33341,6 +31777,10 @@ "metadata": { "type": "object" }, + "type": { + "type": "string", + "enum": ["reference.updated"] + }, "durable": { "type": "object", "properties": { @@ -33360,10 +31800,6 @@ "location": { "$ref": "#/components/schemas/LocationRef" }, - "type": { - "type": "string", - "enum": ["reference.updated"] - }, "data": { "type": "object", "properties": {} @@ -33372,7 +31808,7 @@ "required": ["id", "type", "data"], "additionalProperties": false }, - "V2EventPermissionV2Asked": { + "PermissionV2Asked": { "type": "object", "properties": { "id": { @@ -33382,6 +31818,10 @@ "metadata": { "type": "object" }, + "type": { + "type": "string", + "enum": ["permission.v2.asked"] + }, "durable": { "type": "object", "properties": { @@ -33401,10 +31841,6 @@ "location": { "$ref": "#/components/schemas/LocationRef" }, - "type": { - "type": "string", - "enum": ["permission.v2.asked"] - }, "data": { "type": "object", "properties": { @@ -33445,7 +31881,7 @@ "required": ["id", "type", "data"], "additionalProperties": false }, - "V2EventPermissionV2Replied": { + "PermissionV2Replied": { "type": "object", "properties": { "id": { @@ -33455,6 +31891,10 @@ "metadata": { "type": "object" }, + "type": { + "type": "string", + "enum": ["permission.v2.replied"] + }, "durable": { "type": "object", "properties": { @@ -33474,10 +31914,6 @@ "location": { "$ref": "#/components/schemas/LocationRef" }, - "type": { - "type": "string", - "enum": ["permission.v2.replied"] - }, "data": { "type": "object", "properties": { @@ -33500,7 +31936,7 @@ "required": ["id", "type", "data"], "additionalProperties": false }, - "V2EventPluginAdded": { + "PluginAdded": { "type": "object", "properties": { "id": { @@ -33510,6 +31946,10 @@ "metadata": { "type": "object" }, + "type": { + "type": "string", + "enum": ["plugin.added"] + }, "durable": { "type": "object", "properties": { @@ -33529,10 +31969,6 @@ "location": { "$ref": "#/components/schemas/LocationRef" }, - "type": { - "type": "string", - "enum": ["plugin.added"] - }, "data": { "type": "object", "properties": { @@ -33547,7 +31983,7 @@ "required": ["id", "type", "data"], "additionalProperties": false }, - "V2EventProjectDirectoriesUpdated": { + "ProjectDirectoriesUpdated": { "type": "object", "properties": { "id": { @@ -33557,6 +31993,10 @@ "metadata": { "type": "object" }, + "type": { + "type": "string", + "enum": ["project.directories.updated"] + }, "durable": { "type": "object", "properties": { @@ -33576,10 +32016,6 @@ "location": { "$ref": "#/components/schemas/LocationRef" }, - "type": { - "type": "string", - "enum": ["project.directories.updated"] - }, "data": { "type": "object", "properties": { @@ -33594,7 +32030,7 @@ "required": ["id", "type", "data"], "additionalProperties": false }, - "V2EventFileWatcherUpdated": { + "FileWatcherUpdated": { "type": "object", "properties": { "id": { @@ -33604,6 +32040,10 @@ "metadata": { "type": "object" }, + "type": { + "type": "string", + "enum": ["file.watcher.updated"] + }, "durable": { "type": "object", "properties": { @@ -33623,10 +32063,6 @@ "location": { "$ref": "#/components/schemas/LocationRef" }, - "type": { - "type": "string", - "enum": ["file.watcher.updated"] - }, "data": { "type": "object", "properties": { @@ -33645,7 +32081,7 @@ "required": ["id", "type", "data"], "additionalProperties": false }, - "V2EventPtyCreated": { + "PtyCreated": { "type": "object", "properties": { "id": { @@ -33655,53 +32091,10 @@ "metadata": { "type": "object" }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer" - }, - "version": { - "type": "integer" - } - }, - "required": ["aggregateID", "seq", "version"], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/LocationRef" - }, "type": { "type": "string", "enum": ["pty.created"] }, - "data": { - "type": "object", - "properties": { - "info": { - "$ref": "#/components/schemas/Pty" - } - }, - "required": ["info"], - "additionalProperties": false - } - }, - "required": ["id", "type", "data"], - "additionalProperties": false - }, - "V2EventPtyUpdated": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "metadata": { - "type": "object" - }, "durable": { "type": "object", "properties": { @@ -33721,34 +32114,34 @@ "location": { "$ref": "#/components/schemas/LocationRef" }, + "data": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Pty" + } + }, + "required": ["info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "PtyUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, "type": { "type": "string", "enum": ["pty.updated"] }, - "data": { - "type": "object", - "properties": { - "info": { - "$ref": "#/components/schemas/Pty" - } - }, - "required": ["info"], - "additionalProperties": false - } - }, - "required": ["id", "type", "data"], - "additionalProperties": false - }, - "V2EventPtyExited": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "metadata": { - "type": "object" - }, "durable": { "type": "object", "properties": { @@ -33768,10 +32161,53 @@ "location": { "$ref": "#/components/schemas/LocationRef" }, + "data": { + "type": "object", + "properties": { + "info": { + "$ref": "#/components/schemas/Pty" + } + }, + "required": ["info"], + "additionalProperties": false + } + }, + "required": ["id", "type", "data"], + "additionalProperties": false + }, + "PtyExited": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^evt_" + }, + "metadata": { + "type": "object" + }, "type": { "type": "string", "enum": ["pty.exited"] }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, "data": { "type": "object", "properties": { @@ -33791,7 +32227,7 @@ "required": ["id", "type", "data"], "additionalProperties": false }, - "V2EventPtyDeleted": { + "PtyDeleted": { "type": "object", "properties": { "id": { @@ -33801,6 +32237,10 @@ "metadata": { "type": "object" }, + "type": { + "type": "string", + "enum": ["pty.deleted"] + }, "durable": { "type": "object", "properties": { @@ -33820,10 +32260,6 @@ "location": { "$ref": "#/components/schemas/LocationRef" }, - "type": { - "type": "string", - "enum": ["pty.deleted"] - }, "data": { "type": "object", "properties": { @@ -33839,7 +32275,7 @@ "required": ["id", "type", "data"], "additionalProperties": false }, - "V2EventQuestionV2Asked": { + "QuestionV2Asked": { "type": "object", "properties": { "id": { @@ -33849,6 +32285,10 @@ "metadata": { "type": "object" }, + "type": { + "type": "string", + "enum": ["question.v2.asked"] + }, "durable": { "type": "object", "properties": { @@ -33868,10 +32308,6 @@ "location": { "$ref": "#/components/schemas/LocationRef" }, - "type": { - "type": "string", - "enum": ["question.v2.asked"] - }, "data": { "type": "object", "properties": { @@ -33901,7 +32337,7 @@ "required": ["id", "type", "data"], "additionalProperties": false }, - "V2EventQuestionV2Replied": { + "QuestionV2Replied": { "type": "object", "properties": { "id": { @@ -33911,6 +32347,10 @@ "metadata": { "type": "object" }, + "type": { + "type": "string", + "enum": ["question.v2.replied"] + }, "durable": { "type": "object", "properties": { @@ -33930,10 +32370,6 @@ "location": { "$ref": "#/components/schemas/LocationRef" }, - "type": { - "type": "string", - "enum": ["question.v2.replied"] - }, "data": { "type": "object", "properties": { @@ -33959,7 +32395,7 @@ "required": ["id", "type", "data"], "additionalProperties": false }, - "V2EventQuestionV2Rejected": { + "QuestionV2Rejected": { "type": "object", "properties": { "id": { @@ -33969,6 +32405,10 @@ "metadata": { "type": "object" }, + "type": { + "type": "string", + "enum": ["question.v2.rejected"] + }, "durable": { "type": "object", "properties": { @@ -33988,10 +32428,6 @@ "location": { "$ref": "#/components/schemas/LocationRef" }, - "type": { - "type": "string", - "enum": ["question.v2.rejected"] - }, "data": { "type": "object", "properties": { @@ -34011,7 +32447,7 @@ "required": ["id", "type", "data"], "additionalProperties": false }, - "V2EventTodoUpdated": { + "TodoUpdated": { "type": "object", "properties": { "id": { @@ -34021,6 +32457,10 @@ "metadata": { "type": "object" }, + "type": { + "type": "string", + "enum": ["todo.updated"] + }, "durable": { "type": "object", "properties": { @@ -34040,10 +32480,6 @@ "location": { "$ref": "#/components/schemas/LocationRef" }, - "type": { - "type": "string", - "enum": ["todo.updated"] - }, "data": { "type": "object", "properties": { @@ -34065,7 +32501,7 @@ "required": ["id", "type", "data"], "additionalProperties": false }, - "V2EventLspUpdated": { + "LspUpdated": { "type": "object", "properties": { "id": { @@ -34075,6 +32511,10 @@ "metadata": { "type": "object" }, + "type": { + "type": "string", + "enum": ["lsp.updated"] + }, "durable": { "type": "object", "properties": { @@ -34094,10 +32534,6 @@ "location": { "$ref": "#/components/schemas/LocationRef" }, - "type": { - "type": "string", - "enum": ["lsp.updated"] - }, "data": { "type": "object", "properties": {} @@ -34106,7 +32542,7 @@ "required": ["id", "type", "data"], "additionalProperties": false }, - "V2EventPermissionAsked": { + "PermissionAsked": { "type": "object", "properties": { "id": { @@ -34116,6 +32552,10 @@ "metadata": { "type": "object" }, + "type": { + "type": "string", + "enum": ["permission.asked"] + }, "durable": { "type": "object", "properties": { @@ -34135,10 +32575,6 @@ "location": { "$ref": "#/components/schemas/LocationRef" }, - "type": { - "type": "string", - "enum": ["permission.asked"] - }, "data": { "type": "object", "properties": { @@ -34189,7 +32625,7 @@ "required": ["id", "type", "data"], "additionalProperties": false }, - "V2EventPermissionReplied": { + "PermissionReplied": { "type": "object", "properties": { "id": { @@ -34199,6 +32635,10 @@ "metadata": { "type": "object" }, + "type": { + "type": "string", + "enum": ["permission.replied"] + }, "durable": { "type": "object", "properties": { @@ -34218,10 +32658,6 @@ "location": { "$ref": "#/components/schemas/LocationRef" }, - "type": { - "type": "string", - "enum": ["permission.replied"] - }, "data": { "type": "object", "properties": { @@ -34245,7 +32681,7 @@ "required": ["id", "type", "data"], "additionalProperties": false }, - "V2EventTuiPromptAppend": { + "TuiPromptAppend": { "type": "object", "properties": { "id": { @@ -34255,6 +32691,10 @@ "metadata": { "type": "object" }, + "type": { + "type": "string", + "enum": ["tui.prompt.append"] + }, "durable": { "type": "object", "properties": { @@ -34274,10 +32714,6 @@ "location": { "$ref": "#/components/schemas/LocationRef" }, - "type": { - "type": "string", - "enum": ["tui.prompt.append"] - }, "data": { "type": "object", "properties": { @@ -34292,7 +32728,7 @@ "required": ["id", "type", "data"], "additionalProperties": false }, - "V2EventTuiCommandExecute": { + "TuiCommandExecute": { "type": "object", "properties": { "id": { @@ -34302,6 +32738,10 @@ "metadata": { "type": "object" }, + "type": { + "type": "string", + "enum": ["tui.command.execute"] + }, "durable": { "type": "object", "properties": { @@ -34321,10 +32761,6 @@ "location": { "$ref": "#/components/schemas/LocationRef" }, - "type": { - "type": "string", - "enum": ["tui.command.execute"] - }, "data": { "type": "object", "properties": { @@ -34364,7 +32800,7 @@ "required": ["id", "type", "data"], "additionalProperties": false }, - "V2EventTuiToastShow": { + "TuiToastShow": { "type": "object", "properties": { "id": { @@ -34374,6 +32810,10 @@ "metadata": { "type": "object" }, + "type": { + "type": "string", + "enum": ["tui.toast.show"] + }, "durable": { "type": "object", "properties": { @@ -34393,10 +32833,6 @@ "location": { "$ref": "#/components/schemas/LocationRef" }, - "type": { - "type": "string", - "enum": ["tui.toast.show"] - }, "data": { "type": "object", "properties": { @@ -34422,7 +32858,7 @@ "required": ["id", "type", "data"], "additionalProperties": false }, - "V2EventTuiSessionSelect": { + "TuiSessionSelect": { "type": "object", "properties": { "id": { @@ -34432,6 +32868,10 @@ "metadata": { "type": "object" }, + "type": { + "type": "string", + "enum": ["tui.session.select"] + }, "durable": { "type": "object", "properties": { @@ -34451,10 +32891,6 @@ "location": { "$ref": "#/components/schemas/LocationRef" }, - "type": { - "type": "string", - "enum": ["tui.session.select"] - }, "data": { "type": "object", "properties": { @@ -34471,7 +32907,7 @@ "required": ["id", "type", "data"], "additionalProperties": false }, - "V2EventMcpToolsChanged": { + "McpToolsChanged": { "type": "object", "properties": { "id": { @@ -34481,6 +32917,10 @@ "metadata": { "type": "object" }, + "type": { + "type": "string", + "enum": ["mcp.tools.changed"] + }, "durable": { "type": "object", "properties": { @@ -34500,10 +32940,6 @@ "location": { "$ref": "#/components/schemas/LocationRef" }, - "type": { - "type": "string", - "enum": ["mcp.tools.changed"] - }, "data": { "type": "object", "properties": { @@ -34518,7 +32954,7 @@ "required": ["id", "type", "data"], "additionalProperties": false }, - "V2EventMcpBrowserOpenFailed": { + "McpBrowserOpenFailed": { "type": "object", "properties": { "id": { @@ -34528,6 +32964,10 @@ "metadata": { "type": "object" }, + "type": { + "type": "string", + "enum": ["mcp.browser.open.failed"] + }, "durable": { "type": "object", "properties": { @@ -34547,10 +32987,6 @@ "location": { "$ref": "#/components/schemas/LocationRef" }, - "type": { - "type": "string", - "enum": ["mcp.browser.open.failed"] - }, "data": { "type": "object", "properties": { @@ -34568,7 +33004,7 @@ "required": ["id", "type", "data"], "additionalProperties": false }, - "V2EventCommandExecuted": { + "CommandExecuted": { "type": "object", "properties": { "id": { @@ -34578,6 +33014,10 @@ "metadata": { "type": "object" }, + "type": { + "type": "string", + "enum": ["command.executed"] + }, "durable": { "type": "object", "properties": { @@ -34597,10 +33037,6 @@ "location": { "$ref": "#/components/schemas/LocationRef" }, - "type": { - "type": "string", - "enum": ["command.executed"] - }, "data": { "type": "object", "properties": { @@ -34626,7 +33062,7 @@ "required": ["id", "type", "data"], "additionalProperties": false }, - "V2EventProjectUpdated": { + "ProjectUpdated": { "type": "object", "properties": { "id": { @@ -34636,6 +33072,10 @@ "metadata": { "type": "object" }, + "type": { + "type": "string", + "enum": ["project.updated"] + }, "durable": { "type": "object", "properties": { @@ -34655,10 +33095,6 @@ "location": { "$ref": "#/components/schemas/LocationRef" }, - "type": { - "type": "string", - "enum": ["project.updated"] - }, "data": { "type": "object", "properties": { @@ -34697,7 +33133,7 @@ "required": ["id", "type", "data"], "additionalProperties": false }, - "V2EventSessionStatus": { + "SessionIdle": { "type": "object", "properties": { "id": { @@ -34707,80 +33143,29 @@ "metadata": { "type": "object" }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer" - }, - "version": { - "type": "integer" - } - }, - "required": ["aggregateID", "seq", "version"], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/LocationRef" - }, - "type": { - "type": "string", - "enum": ["session.status"] - }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "status": { - "$ref": "#/components/schemas/SessionStatus" - } - }, - "required": ["sessionID", "status"], - "additionalProperties": false - } - }, - "required": ["id", "type", "data"], - "additionalProperties": false - }, - "V2EventSessionIdle": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "metadata": { - "type": "object" - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer" - }, - "version": { - "type": "integer" - } - }, - "required": ["aggregateID", "seq", "version"], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/LocationRef" - }, "type": { "type": "string", "enum": ["session.idle"] }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, "data": { "type": "object", "properties": { @@ -34796,7 +33181,7 @@ "required": ["id", "type", "data"], "additionalProperties": false }, - "V2EventQuestionAsked": { + "QuestionAsked": { "type": "object", "properties": { "id": { @@ -34806,6 +33191,10 @@ "metadata": { "type": "object" }, + "type": { + "type": "string", + "enum": ["question.asked"] + }, "durable": { "type": "object", "properties": { @@ -34825,10 +33214,6 @@ "location": { "$ref": "#/components/schemas/LocationRef" }, - "type": { - "type": "string", - "enum": ["question.asked"] - }, "data": { "type": "object", "properties": { @@ -34858,7 +33243,7 @@ "required": ["id", "type", "data"], "additionalProperties": false }, - "V2EventQuestionReplied": { + "SessionCompacted": { "type": "object", "properties": { "id": { @@ -34868,139 +33253,29 @@ "metadata": { "type": "object" }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer" - }, - "version": { - "type": "integer" - } - }, - "required": ["aggregateID", "seq", "version"], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/LocationRef" - }, - "type": { - "type": "string", - "enum": ["question.replied"] - }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "requestID": { - "type": "string", - "pattern": "^que" - }, - "answers": { - "type": "array", - "items": { - "$ref": "#/components/schemas/QuestionAnswer" - } - } - }, - "required": ["sessionID", "requestID", "answers"], - "additionalProperties": false - } - }, - "required": ["id", "type", "data"], - "additionalProperties": false - }, - "V2EventQuestionRejected": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "metadata": { - "type": "object" - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer" - }, - "version": { - "type": "integer" - } - }, - "required": ["aggregateID", "seq", "version"], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/LocationRef" - }, - "type": { - "type": "string", - "enum": ["question.rejected"] - }, - "data": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "requestID": { - "type": "string", - "pattern": "^que" - } - }, - "required": ["sessionID", "requestID"], - "additionalProperties": false - } - }, - "required": ["id", "type", "data"], - "additionalProperties": false - }, - "V2EventSessionCompacted": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^evt_" - }, - "metadata": { - "type": "object" - }, - "durable": { - "type": "object", - "properties": { - "aggregateID": { - "type": "string" - }, - "seq": { - "type": "integer" - }, - "version": { - "type": "integer" - } - }, - "required": ["aggregateID", "seq", "version"], - "additionalProperties": false - }, - "location": { - "$ref": "#/components/schemas/LocationRef" - }, "type": { "type": "string", "enum": ["session.compacted"] }, + "durable": { + "type": "object", + "properties": { + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer" + }, + "version": { + "type": "integer" + } + }, + "required": ["aggregateID", "seq", "version"], + "additionalProperties": false + }, + "location": { + "$ref": "#/components/schemas/LocationRef" + }, "data": { "type": "object", "properties": { @@ -35016,7 +33291,7 @@ "required": ["id", "type", "data"], "additionalProperties": false }, - "V2EventVcsBranchUpdated": { + "VcsBranchUpdated": { "type": "object", "properties": { "id": { @@ -35026,6 +33301,10 @@ "metadata": { "type": "object" }, + "type": { + "type": "string", + "enum": ["vcs.branch.updated"] + }, "durable": { "type": "object", "properties": { @@ -35045,10 +33324,6 @@ "location": { "$ref": "#/components/schemas/LocationRef" }, - "type": { - "type": "string", - "enum": ["vcs.branch.updated"] - }, "data": { "type": "object", "properties": { @@ -35062,7 +33337,7 @@ "required": ["id", "type", "data"], "additionalProperties": false }, - "V2EventWorkspaceReady": { + "WorkspaceReady": { "type": "object", "properties": { "id": { @@ -35072,6 +33347,10 @@ "metadata": { "type": "object" }, + "type": { + "type": "string", + "enum": ["workspace.ready"] + }, "durable": { "type": "object", "properties": { @@ -35091,10 +33370,6 @@ "location": { "$ref": "#/components/schemas/LocationRef" }, - "type": { - "type": "string", - "enum": ["workspace.ready"] - }, "data": { "type": "object", "properties": { @@ -35109,7 +33384,7 @@ "required": ["id", "type", "data"], "additionalProperties": false }, - "V2EventWorkspaceFailed": { + "WorkspaceFailed": { "type": "object", "properties": { "id": { @@ -35119,6 +33394,10 @@ "metadata": { "type": "object" }, + "type": { + "type": "string", + "enum": ["workspace.failed"] + }, "durable": { "type": "object", "properties": { @@ -35138,10 +33417,6 @@ "location": { "$ref": "#/components/schemas/LocationRef" }, - "type": { - "type": "string", - "enum": ["workspace.failed"] - }, "data": { "type": "object", "properties": { @@ -35156,7 +33431,7 @@ "required": ["id", "type", "data"], "additionalProperties": false }, - "V2EventWorkspaceStatus": { + "WorkspaceStatus": { "type": "object", "properties": { "id": { @@ -35166,6 +33441,10 @@ "metadata": { "type": "object" }, + "type": { + "type": "string", + "enum": ["workspace.status"] + }, "durable": { "type": "object", "properties": { @@ -35185,10 +33464,6 @@ "location": { "$ref": "#/components/schemas/LocationRef" }, - "type": { - "type": "string", - "enum": ["workspace.status"] - }, "data": { "type": "object", "properties": { @@ -35208,7 +33483,7 @@ "required": ["id", "type", "data"], "additionalProperties": false }, - "V2EventWorktreeReady": { + "WorktreeReady": { "type": "object", "properties": { "id": { @@ -35218,6 +33493,10 @@ "metadata": { "type": "object" }, + "type": { + "type": "string", + "enum": ["worktree.ready"] + }, "durable": { "type": "object", "properties": { @@ -35237,10 +33516,6 @@ "location": { "$ref": "#/components/schemas/LocationRef" }, - "type": { - "type": "string", - "enum": ["worktree.ready"] - }, "data": { "type": "object", "properties": { @@ -35258,7 +33533,7 @@ "required": ["id", "type", "data"], "additionalProperties": false }, - "V2EventWorktreeFailed": { + "WorktreeFailed": { "type": "object", "properties": { "id": { @@ -35268,6 +33543,10 @@ "metadata": { "type": "object" }, + "type": { + "type": "string", + "enum": ["worktree.failed"] + }, "durable": { "type": "object", "properties": { @@ -35287,10 +33566,6 @@ "location": { "$ref": "#/components/schemas/LocationRef" }, - "type": { - "type": "string", - "enum": ["worktree.failed"] - }, "data": { "type": "object", "properties": { @@ -35305,7 +33580,7 @@ "required": ["id", "type", "data"], "additionalProperties": false }, - "V2EventServerConnected": { + "ServerConnected": { "type": "object", "properties": { "id": { @@ -35315,6 +33590,10 @@ "metadata": { "type": "object" }, + "type": { + "type": "string", + "enum": ["server.connected"] + }, "durable": { "type": "object", "properties": { @@ -35334,10 +33613,6 @@ "location": { "$ref": "#/components/schemas/LocationRef" }, - "type": { - "type": "string", - "enum": ["server.connected"] - }, "data": { "type": "object", "properties": {} @@ -35346,7 +33621,7 @@ "required": ["id", "type", "data"], "additionalProperties": false }, - "V2EventGlobalDisposed": { + "GlobalDisposed": { "type": "object", "properties": { "id": { @@ -35356,6 +33631,10 @@ "metadata": { "type": "object" }, + "type": { + "type": "string", + "enum": ["global.disposed"] + }, "durable": { "type": "object", "properties": { @@ -35375,10 +33654,6 @@ "location": { "$ref": "#/components/schemas/LocationRef" }, - "type": { - "type": "string", - "enum": ["global.disposed"] - }, "data": { "type": "object", "properties": {} From 5acb2530b4c64bbcc50effb017c469fad175701d Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Sat, 27 Jun 2026 03:24:33 +0800 Subject: [PATCH 08/45] fix(app): centralize notification state (#34105) --- packages/app/src/app.tsx | 34 +- packages/app/src/context/notification.tsx | 588 +++++++++++++--------- packages/app/src/pages/home.tsx | 10 +- packages/app/src/pages/layout-new.tsx | 10 +- 4 files changed, 371 insertions(+), 271 deletions(-) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 6a1c8f268d..5b9223b5ee 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -38,7 +38,7 @@ import { HighlightsProvider } from "@/context/highlights" import { LanguageProvider, type Locale, useLanguage } from "@/context/language" import { LayoutProvider } from "@/context/layout" import { ModelsProvider } from "@/context/models" -import { NotificationProvider } from "@/context/notification" +import { NotificationProvider, useNotification } from "@/context/notification" import { PermissionProvider } from "@/context/permission" import { PromptProvider } from "@/context/prompt" import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server" @@ -316,9 +316,7 @@ function ServerScopedProviders(props: ServerScopedShellProps) { return ( - - {props.children} - + {props.children} ) @@ -345,13 +343,23 @@ function NewAppLayout(props: ParentProps) { function TargetServerScopedProviders(props: ServerScopedShellProps) { return ( - - {props.children} - + + {props.children} ) } +function MarkSessionNotificationsViewed(props: { sessionID?: () => string | undefined }) { + const notification = useNotification() + createEffect(() => { + const sessionID = props.sessionID?.() + if (!notification.ready() || !sessionID) return + if (notification.session.unseenCount(sessionID) === 0) return + notification.session.markViewed(sessionID) + }) + return null +} + function SessionProviders(props: ParentProps) { return ( @@ -560,11 +568,13 @@ export function AppInterface(props: { component={props.router ?? Router} root={(routerProps) => ( - - - {routerProps.children} - - + + + + {routerProps.children} + + + )} > diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx index 7318af084f..e12f8f1250 100644 --- a/packages/app/src/context/notification.tsx +++ b/packages/app/src/context/notification.tsx @@ -1,9 +1,9 @@ import { createStore, reconcile } from "solid-js/store" -import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js" -import { useParams } from "@solidjs/router" +import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js" +import { useParams, useSearchParams } from "@solidjs/router" import { createSimpleContext } from "@opencode-ai/ui/context" -import { useServerSDK } from "./server-sdk" -import { useServerSync } from "./server-sync" +import type { ServerSDK } from "./server-sdk" +import type { ServerSync } from "./server-sync" import { usePlatform } from "@/context/platform" import { useLanguage } from "@/context/language" import { useSettings } from "@/context/settings" @@ -12,6 +12,11 @@ import { decode64 } from "@/utils/base64" import { EventSessionError } from "@opencode-ai/sdk/v2" import { Persist, persisted } from "@/utils/persist" import { playSoundById } from "@/utils/sound" +import { useGlobal } from "./global" +import { ServerConnection, useServer } from "./server" +import { type DraftTab, useTabs } from "./tabs" +import { requireServerKey } from "@/utils/session-route" +import type { ServerScope } from "@/utils/server-scope" type NotificationBase = { directory?: string @@ -107,267 +112,360 @@ function buildNotificationIndex(list: Notification[]) { export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({ name: "Notification", gate: false, - init: (props: { directory?: Accessor; sessionID?: Accessor }) => { - const params = useParams() - const serverSDK = useServerSDK() - const serverSync = useServerSync() + init: () => { + const params = useParams<{ serverKey?: string; dir?: string; id?: string }>() + const [search] = useSearchParams<{ draftId?: string }>() + const global = useGlobal() + const server = useServer() + const tabs = useTabs() const platform = usePlatform() const settings = useSettings() const language = useLanguage() + const owner = getOwner() + const states = new Map void; state: NotificationState }>() - const empty: Notification[] = [] - - const currentDirectory = createMemo(() => { - return props.directory?.() ?? decode64(params.dir) + const activeServer = createMemo(() => { + if (params.serverKey) return requireServerKey(params.serverKey) + if (search.draftId) { + const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId) + if (draft) return draft.server + } + return server.key }) + const activeDirectory = createMemo(() => decode64(params.dir)) + const activeSession = createMemo(() => params.id) - const currentSession = createMemo(() => props.sessionID?.() ?? params.id) - - const [store, setStore, _, ready] = persisted( - Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]), - createStore({ - list: [] as Notification[], - }), - ) - const [index, setIndex] = createStore(buildNotificationIndex(store.list)) - - const meta = { pruned: false, disposed: false } - - const updateUnseen = (scope: "session" | "project", key: string, unseen: Notification[]) => { - setIndex(scope, "unseen", key, unseen) - setIndex(scope, "unseenCount", key, unseen.length) - setIndex( - scope, - "unseenHasError", - key, - unseen.some((notification) => notification.type === "error"), + const ensure = (key: ServerConnection.Key) => { + const conn = global.servers.list().find((item) => ServerConnection.key(item) === key) + if (!conn) throw new Error(`Notification server not found: ${key}`) + const ctx = global.ensureServerCtx(conn) + const existing = states.get(ctx.sdk.scope) + if (existing) return existing.state + const root = createRoot( + (dispose) => ({ + dispose, + state: createServerNotificationState({ + sdk: ctx.sdk, + sync: ctx.sync, + active: () => server.scope(activeServer()) === ctx.sdk.scope, + directory: activeDirectory, + sessionID: activeSession, + platform, + settings, + language, + }), + }), + owner ?? undefined, ) - } - - const appendToIndex = (notification: Notification) => { - if (notification.session) { - setIndex("session", "all", notification.session, (all = []) => [...all, notification]) - if (!notification.viewed) { - setIndex("session", "unseen", notification.session, (unseen = []) => [...unseen, notification]) - setIndex("session", "unseenCount", notification.session, (count = 0) => count + 1) - if (notification.type === "error") setIndex("session", "unseenHasError", notification.session, true) - } - } - - if (notification.directory) { - setIndex("project", "all", notification.directory, (all = []) => [...all, notification]) - if (!notification.viewed) { - setIndex("project", "unseen", notification.directory, (unseen = []) => [...unseen, notification]) - setIndex("project", "unseenCount", notification.directory, (count = 0) => count + 1) - if (notification.type === "error") setIndex("project", "unseenHasError", notification.directory, true) - } - } - } - - const removeFromIndex = (notification: Notification) => { - if (notification.session) { - setIndex("session", "all", notification.session, (all = []) => all.filter((n) => n !== notification)) - if (!notification.viewed) { - const unseen = (index.session.unseen[notification.session] ?? empty).filter((n) => n !== notification) - updateUnseen("session", notification.session, unseen) - } - } - - if (notification.directory) { - setIndex("project", "all", notification.directory, (all = []) => all.filter((n) => n !== notification)) - if (!notification.viewed) { - const unseen = (index.project.unseen[notification.directory] ?? empty).filter((n) => n !== notification) - updateUnseen("project", notification.directory, unseen) - } - } + states.set(ctx.sdk.scope, root) + return root.state } createEffect(() => { - if (!ready()) return - if (meta.pruned) return - meta.pruned = true - const list = pruneNotifications(store.list) - batch(() => { - setStore("list", list) - setIndex(reconcile(buildNotificationIndex(list), { merge: false })) + global.servers.list().forEach((conn) => ensure(ServerConnection.key(conn))) + }) + + createEffect(() => { + const scopes = new Set(global.servers.list().map((conn) => server.scope(ServerConnection.key(conn)))) + states.forEach((value, scope) => { + if (scopes.has(scope)) return + value.dispose() + states.delete(scope) }) }) - const append = (notification: Notification) => { - const list = pruneNotifications([...store.list, notification]) - const keep = new Set(list) - const removed = store.list.filter((n) => !keep.has(n)) + onCleanup(() => states.forEach((value) => value.dispose())) - batch(() => { - if (keep.has(notification)) appendToIndex(notification) - removed.forEach((n) => removeFromIndex(n)) - setStore("list", list) - }) - } - - const lookup = async (directory: string, sessionID?: string) => { - if (!sessionID) return undefined - const sync = serverSync().ensureDirSyncContext(directory) - const session = sync.session.get(sessionID) - if (session) return session - return sync.session - .sync(sessionID) - .then(() => sync.session.get(sessionID)) - .catch(() => undefined) - } - - const viewedInCurrentSession = (directory: string, sessionID?: string) => { - const activeDirectory = currentDirectory() - const activeSession = currentSession() - if (!activeDirectory) return false - if (!activeSession) return false - if (!sessionID) return false - if (directory !== activeDirectory) return false - return sessionID === activeSession - } - - const handleSessionIdle = (directory: string, event: { properties: { sessionID?: string } }, time: number) => { - const sessionID = event.properties.sessionID - void lookup(directory, sessionID).then((session) => { - if (meta.disposed) return - if (!session) return - if (session.parentID) return - - if (settings.sounds.agentEnabled()) { - void playSoundById(settings.sounds.agent()) - } - - append({ - directory, - time, - viewed: viewedInCurrentSession(directory, sessionID), - type: "turn-complete", - session: sessionID, - }) - - const href = `/${base64Encode(directory)}/session/${sessionID}` - if (settings.notifications.agent()) { - void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, href) - } - }) - } - - const handleSessionError = ( - directory: string, - event: { properties: { sessionID?: string; error?: EventSessionError["properties"]["error"] } }, - time: number, - ) => { - const sessionID = event.properties.sessionID - void lookup(directory, sessionID).then((session) => { - if (meta.disposed) return - if (session?.parentID) return - - if (settings.sounds.errorsEnabled()) { - void playSoundById(settings.sounds.errors()) - } - - const error = "error" in event.properties ? event.properties.error : undefined - append({ - directory, - time, - viewed: viewedInCurrentSession(directory, sessionID), - type: "error", - session: sessionID ?? "global", - error, - }) - const description = - session?.title ?? - (typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription")) - const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}` - if (settings.notifications.errors()) { - void platform.notify(language.t("notification.session.error.title"), description, href) - } - }) - } - - const unsub = serverSDK().event.listen((e) => { - const event = e.details - if (event.type !== "session.idle" && event.type !== "session.error") return - - const directory = e.name - const time = Date.now() - if (event.type === "session.idle") { - handleSessionIdle(directory, event, time) - return - } - handleSessionError(directory, event, time) - }) - onCleanup(() => { - meta.disposed = true - unsub() - }) + const selected = () => ensure(activeServer()) return { - ready, + ready: () => selected().ready(), + ensureServerState: ensure, session: { - all(session: string) { - return index.session.all[session] ?? empty - }, - unseen(session: string) { - return index.session.unseen[session] ?? empty - }, - unseenCount(session: string) { - return index.session.unseenCount[session] ?? 0 - }, - unseenHasError(session: string) { - return index.session.unseenHasError[session] ?? false - }, - markViewed(session: string) { - const unseen = index.session.unseen[session] ?? empty - if (!unseen.length) return - - const projects = [ - ...new Set(unseen.flatMap((notification) => (notification.directory ? [notification.directory] : []))), - ] - batch(() => { - setStore("list", (n) => n.session === session && !n.viewed, "viewed", true) - updateUnseen("session", session, []) - projects.forEach((directory) => { - const next = (index.project.unseen[directory] ?? empty).filter( - (notification) => notification.session !== session, - ) - updateUnseen("project", directory, next) - }) - }) - }, + all: (session: string) => selected().session.all(session), + unseen: (session: string) => selected().session.unseen(session), + unseenCount: (session: string) => selected().session.unseenCount(session), + unseenHasError: (session: string) => selected().session.unseenHasError(session), + markViewed: (session: string) => selected().session.markViewed(session), }, project: { - all(directory: string) { - return index.project.all[directory] ?? empty - }, - unseen(directory: string) { - return index.project.unseen[directory] ?? empty - }, - unseenCount(directory: string) { - return index.project.unseenCount[directory] ?? 0 - }, - unseenHasError(directory: string) { - return index.project.unseenHasError[directory] ?? false - }, - markViewed(directory: string) { - const unseen = index.project.unseen[directory] ?? empty - if (!unseen.length) return - - const sessions = [ - ...new Set(unseen.flatMap((notification) => (notification.session ? [notification.session] : []))), - ] - batch(() => { - setStore("list", (n) => n.directory === directory && !n.viewed, "viewed", true) - updateUnseen("project", directory, []) - sessions.forEach((session) => { - const next = (index.session.unseen[session] ?? empty).filter( - (notification) => notification.directory !== directory, - ) - updateUnseen("session", session, next) - }) - }) - }, + all: (directory: string) => selected().project.all(directory), + unseen: (directory: string) => selected().project.unseen(directory), + unseenCount: (directory: string) => selected().project.unseenCount(directory), + unseenHasError: (directory: string) => selected().project.unseenHasError(directory), + markViewed: (directory: string) => selected().project.markViewed(directory), }, } }, }) + +type NotificationState = ReturnType + +function createServerNotificationState(input: { + sdk: ServerSDK + sync: ServerSync + active: Accessor + directory: Accessor + sessionID: Accessor + platform: ReturnType + settings: ReturnType + language: ReturnType +}) { + const serverSDK = () => input.sdk + const serverSync = () => input.sync + const platform = input.platform + const settings = input.settings + const language = input.language + + const empty: Notification[] = [] + + const currentDirectory = input.directory + const currentSession = input.sessionID + + const [store, setStore, _, ready] = persisted( + Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]), + createStore({ + list: [] as Notification[], + }), + ) + const [index, setIndex] = createStore(buildNotificationIndex(store.list)) + + const meta = { pruned: false, disposed: false } + + const updateUnseen = (scope: "session" | "project", key: string, unseen: Notification[]) => { + setIndex(scope, "unseen", key, unseen) + setIndex(scope, "unseenCount", key, unseen.length) + setIndex( + scope, + "unseenHasError", + key, + unseen.some((notification) => notification.type === "error"), + ) + } + + const appendToIndex = (notification: Notification) => { + if (notification.session) { + setIndex("session", "all", notification.session, (all = []) => [...all, notification]) + if (!notification.viewed) { + setIndex("session", "unseen", notification.session, (unseen = []) => [...unseen, notification]) + setIndex("session", "unseenCount", notification.session, (count = 0) => count + 1) + if (notification.type === "error") setIndex("session", "unseenHasError", notification.session, true) + } + } + + if (notification.directory) { + setIndex("project", "all", notification.directory, (all = []) => [...all, notification]) + if (!notification.viewed) { + setIndex("project", "unseen", notification.directory, (unseen = []) => [...unseen, notification]) + setIndex("project", "unseenCount", notification.directory, (count = 0) => count + 1) + if (notification.type === "error") setIndex("project", "unseenHasError", notification.directory, true) + } + } + } + + const removeFromIndex = (notification: Notification) => { + if (notification.session) { + setIndex("session", "all", notification.session, (all = []) => all.filter((n) => n !== notification)) + if (!notification.viewed) { + const unseen = (index.session.unseen[notification.session] ?? empty).filter((n) => n !== notification) + updateUnseen("session", notification.session, unseen) + } + } + + if (notification.directory) { + setIndex("project", "all", notification.directory, (all = []) => all.filter((n) => n !== notification)) + if (!notification.viewed) { + const unseen = (index.project.unseen[notification.directory] ?? empty).filter((n) => n !== notification) + updateUnseen("project", notification.directory, unseen) + } + } + } + + createEffect(() => { + if (!ready()) return + if (meta.pruned) return + meta.pruned = true + const list = pruneNotifications(store.list) + batch(() => { + setStore("list", list) + setIndex(reconcile(buildNotificationIndex(list), { merge: false })) + }) + }) + + const append = (notification: Notification) => { + const list = pruneNotifications([...store.list, notification]) + const keep = new Set(list) + const removed = store.list.filter((n) => !keep.has(n)) + + batch(() => { + if (keep.has(notification)) appendToIndex(notification) + removed.forEach((n) => removeFromIndex(n)) + setStore("list", list) + }) + } + + const lookup = async (directory: string, sessionID?: string) => { + if (!sessionID) return undefined + const sync = serverSync().ensureDirSyncContext(directory) + const session = sync.session.get(sessionID) + if (session) return session + return sync.session + .sync(sessionID) + .then(() => sync.session.get(sessionID)) + .catch(() => undefined) + } + + const viewedInCurrentSession = (directory: string, sessionID?: string) => { + if (!input.active()) return false + const activeDirectory = currentDirectory() + const activeSession = currentSession() + if (!activeSession) return false + if (!sessionID) return false + if (activeDirectory && directory !== activeDirectory) return false + return sessionID === activeSession + } + + const handleSessionIdle = (directory: string, event: { properties: { sessionID?: string } }, time: number) => { + const sessionID = event.properties.sessionID + void lookup(directory, sessionID).then((session) => { + if (meta.disposed) return + if (!session) return + if (session.parentID) return + + if (settings.sounds.agentEnabled()) { + void playSoundById(settings.sounds.agent()) + } + + append({ + directory, + time, + viewed: viewedInCurrentSession(directory, sessionID), + type: "turn-complete", + session: sessionID, + }) + + const href = `/${base64Encode(directory)}/session/${sessionID}` + if (settings.notifications.agent()) { + void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, href) + } + }) + } + + const handleSessionError = ( + directory: string, + event: { properties: { sessionID?: string; error?: EventSessionError["properties"]["error"] } }, + time: number, + ) => { + const sessionID = event.properties.sessionID + void lookup(directory, sessionID).then((session) => { + if (meta.disposed) return + if (session?.parentID) return + + if (settings.sounds.errorsEnabled()) { + void playSoundById(settings.sounds.errors()) + } + + const error = "error" in event.properties ? event.properties.error : undefined + append({ + directory, + time, + viewed: viewedInCurrentSession(directory, sessionID), + type: "error", + session: sessionID ?? "global", + error, + }) + const description = + session?.title ?? + (typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription")) + const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}` + if (settings.notifications.errors()) { + void platform.notify(language.t("notification.session.error.title"), description, href) + } + }) + } + + const unsub = serverSDK().event.listen((e) => { + const event = e.details + if (event.type !== "session.idle" && event.type !== "session.error") return + + const directory = e.name + const time = Date.now() + if (event.type === "session.idle") { + handleSessionIdle(directory, event, time) + return + } + handleSessionError(directory, event, time) + }) + onCleanup(() => { + meta.disposed = true + unsub() + }) + + return { + ready, + session: { + all(session: string) { + return index.session.all[session] ?? empty + }, + unseen(session: string) { + return index.session.unseen[session] ?? empty + }, + unseenCount(session: string) { + return index.session.unseenCount[session] ?? 0 + }, + unseenHasError(session: string) { + return index.session.unseenHasError[session] ?? false + }, + markViewed(session: string) { + const unseen = index.session.unseen[session] ?? empty + if (!unseen.length) return + + const projects = [ + ...new Set(unseen.flatMap((notification) => (notification.directory ? [notification.directory] : []))), + ] + batch(() => { + setStore("list", (n) => n.session === session && !n.viewed, "viewed", true) + updateUnseen("session", session, []) + projects.forEach((directory) => { + const next = (index.project.unseen[directory] ?? empty).filter( + (notification) => notification.session !== session, + ) + updateUnseen("project", directory, next) + }) + }) + }, + }, + project: { + all(directory: string) { + return index.project.all[directory] ?? empty + }, + unseen(directory: string) { + return index.project.unseen[directory] ?? empty + }, + unseenCount(directory: string) { + return index.project.unseenCount[directory] ?? 0 + }, + unseenHasError(directory: string) { + return index.project.unseenHasError[directory] ?? false + }, + markViewed(directory: string) { + const unseen = index.project.unseen[directory] ?? empty + if (!unseen.length) return + + const sessions = [ + ...new Set(unseen.flatMap((notification) => (notification.session ? [notification.session] : []))), + ] + batch(() => { + setStore("list", (n) => n.directory === directory && !n.viewed, "viewed", true) + updateUnseen("project", directory, []) + sessions.forEach((session) => { + const next = (index.session.unseen[session] ?? empty).filter( + (notification) => notification.directory !== directory, + ) + updateUnseen("session", session, next) + }) + }) + }, + }, + } +} diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 4610608947..8243c07ab9 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -341,15 +341,15 @@ export function NewHome() { } function unseenCount(conn: ServerConnection.Any, project: LocalProject) { - if (ServerConnection.key(conn) !== server.key) return 0 - return directories(project).reduce((total, directory) => total + notification.project.unseenCount(directory), 0) + const state = notification.ensureServerState(ServerConnection.key(conn)) + return directories(project).reduce((total, directory) => total + state.project.unseenCount(directory), 0) } function clearNotifications(conn: ServerConnection.Any, project: LocalProject) { - if (ServerConnection.key(conn) !== server.key) return + const state = notification.ensureServerState(ServerConnection.key(conn)) directories(project) - .filter((directory) => notification.project.unseenCount(directory) > 0) - .forEach((directory) => notification.project.markViewed(directory)) + .filter((directory) => state.project.unseenCount(directory) > 0) + .forEach((directory) => state.project.markViewed(directory)) } function openSession(session: Session) { diff --git a/packages/app/src/pages/layout-new.tsx b/packages/app/src/pages/layout-new.tsx index f9222cdeb7..aa9f41890b 100644 --- a/packages/app/src/pages/layout-new.tsx +++ b/packages/app/src/pages/layout-new.tsx @@ -1,26 +1,18 @@ import { createEffect, Suspense, type ParentProps } from "solid-js" -import { useNavigate, useParams } from "@solidjs/router" +import { useNavigate } from "@solidjs/router" import { DebugBar } from "@/components/debug-bar" import { HelpButton } from "@/components/help-button" import { Titlebar, type TitlebarUpdate } from "@/components/titlebar" -import { useNotification } from "@/context/notification" import { usePlatform } from "@/context/platform" import { setNavigate } from "@/utils/notification-click" import { setV2Toast, ToastRegion } from "@/utils/toast" export default function NewLayout(props: ParentProps) { const platform = usePlatform() - const notification = useNotification() const navigate = useNavigate() - const params = useParams<{ id?: string }>() setNavigate(navigate) createEffect(() => setV2Toast(true)) - createEffect(() => { - if (!notification.ready() || !params.id) return - if (notification.session.unseenCount(params.id) === 0) return - notification.session.markViewed(params.id) - }) const update: TitlebarUpdate = { version: () => { From 7a17925495ca61b76169737b226b834b61942586 Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 26 Jun 2026 16:19:53 -0400 Subject: [PATCH 09/45] zen: new inference --- packages/console/app/src/routes/zen/util/handler.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index 41fb3ed5a9..7487f76333 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -215,6 +215,16 @@ export async function handler( body: reqBody, }) + if (providerInfo.id.startsWith("console.")) { + const resEndpointId = res.headers.get("x-opencode-endpoint-id") + const resEndpointModelId = res.headers.get("x-opencode-upstream-model-id") + if (resEndpointId && resEndpointModelId) + logger.metric({ + provider: resEndpointId, + "provider.model": resEndpointModelId, + }) + } + if (res.status !== 200) { logger.metric({ "llm.error.code": res.status, From 43e39d7f6895217564e3f842cbb80997a9e0d9c3 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 18:04:53 -0400 Subject: [PATCH 10/45] fix(tui): use generated event union (#34118) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 --- packages/tui/src/context/data.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 9b2e58907a..c1dfcd6d7c 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -16,7 +16,7 @@ import type { SessionMessageAssistantTool, SessionV2Info, SkillV2Info, - V2Event, + V2Event1, } from "@opencode-ai/sdk/v2" import { createStore, produce } from "solid-js/store" import { createSimpleContext } from "./helper" @@ -121,7 +121,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ }, } - function handleEvent(event: V2Event) { + function handleEvent(event: V2Event1) { switch (event.type) { case "catalog.updated": void Promise.all([ @@ -408,7 +408,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ ...event, data: event.properties, location: { directory: metadata.directory, workspaceID: metadata.workspace }, - } as V2Event) + } as V2Event1) }) onCleanup(unsub) }) From cd56c51e2d1fabe59e88d7128bb24e546a452053 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 22:21:01 +0000 Subject: [PATCH 11/45] fix(app): keep bare slash as plain inline code (#34122) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 --- packages/session-ui/src/components/markdown-inline-code-kind.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/session-ui/src/components/markdown-inline-code-kind.ts b/packages/session-ui/src/components/markdown-inline-code-kind.ts index c20043f609..3679fe4ff4 100644 --- a/packages/session-ui/src/components/markdown-inline-code-kind.ts +++ b/packages/session-ui/src/components/markdown-inline-code-kind.ts @@ -1,6 +1,7 @@ export function inlineCodeKind(text: string): "path" | "url" | undefined { if (/^https?:\/\//i.test(text)) return "url" if (/^[a-z][a-z0-9+.-]*:\/\//i.test(text)) return + if (text === "/") return if (/^\/[a-z][a-z0-9-]*$/i.test(text)) return if (/\s/.test(text)) return if (/[()\[\]{}*+=<>|&^"';]/.test(text)) return From 4b948c5d74f8b804ec8d6a7910085d5dc443c624 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Sat, 27 Jun 2026 02:23:36 +0000 Subject: [PATCH 12/45] fix(app): hide home session archive action (#34136) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 --- packages/app/src/pages/home.tsx | 35 ++++++++++++++++++--------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 8243c07ab9..38c444e503 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -68,6 +68,7 @@ import { archiveHomeSession } from "./home-session-archive" import { showToast } from "@/utils/toast" const HOME_SESSION_LIMIT = 64 +const SHOW_HOME_SESSION_ARCHIVE = false const HOME_ROW_LAYOUT = "flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none" const HOME_ROW_BASE = `${HOME_ROW_LAYOUT} border-0` @@ -1189,22 +1190,24 @@ function HomeSessionRow(props: { -
- - } - aria-label={language.t("common.archive")} - onClick={(event) => { - event.preventDefault() - event.stopPropagation() - void props.archiveSession(props.record.session) - }} - /> - -
+ +
+ + } + aria-label={language.t("common.archive")} + onClick={(event) => { + event.preventDefault() + event.stopPropagation() + void props.archiveSession(props.record.session) + }} + /> + +
+
) } From ecdfff5a421fb62e23ab00545038888d2c379170 Mon Sep 17 00:00:00 2001 From: James Long Date: Fri, 26 Jun 2026 22:46:07 -0400 Subject: [PATCH 13/45] refactor(core): separate out location node functionality and integrate into v2 (#34119) --- packages/core/package.json | 2 + packages/core/src/agent.ts | 3 + packages/core/src/aisdk.ts | 3 + packages/core/src/background-job.ts | 3 + packages/core/src/catalog.ts | 3 + packages/core/src/command.ts | 3 + packages/core/src/config.ts | 7 + packages/core/src/credential.ts | 3 + packages/core/src/cross-spawn-spawner.ts | 4 +- packages/core/src/database/database.ts | 4 +- .../core/src/effect/layer-node-platform.ts | 12 +- packages/core/src/effect/layer-node.ts | 248 ------------------ packages/core/src/effect/layer-node/index.ts | 2 + .../src/effect/layer-node/layer-node-tree.ts | 118 +++++++++ .../core/src/effect/layer-node/layer-node.ts | 138 ++++++++++ packages/core/src/effect/node-build.ts | 18 ++ packages/core/src/effect/node.ts | 14 + packages/core/src/effect/scoped-node.ts | 11 - packages/core/src/event.ts | 4 +- packages/core/src/file-mutation.ts | 3 + packages/core/src/filesystem.ts | 7 + packages/core/src/filesystem/search.ts | 9 +- packages/core/src/filesystem/watcher.ts | 7 + packages/core/src/fs-util.ts | 4 +- packages/core/src/git.ts | 4 +- packages/core/src/global.ts | 4 +- packages/core/src/image.ts | 3 + packages/core/src/instruction-context.ts | 7 + packages/core/src/integration.ts | 3 + packages/core/src/location-layer.ts | 151 ----------- packages/core/src/location-mutation.ts | 7 + packages/core/src/location-service-map.ts | 18 ++ packages/core/src/location-services.ts | 112 ++++++++ packages/core/src/location.ts | 11 + packages/core/src/models-dev.ts | 4 +- packages/core/src/npm.ts | 4 +- packages/core/src/permission.ts | 7 + packages/core/src/permission/saved.ts | 3 + packages/core/src/plugin.ts | 16 ++ packages/core/src/plugin/internal.ts | 31 ++- packages/core/src/policy.ts | 3 + packages/core/src/process.ts | 4 +- packages/core/src/project.ts | 4 +- packages/core/src/project/copy.ts | 10 +- packages/core/src/project/directories.ts | 4 +- packages/core/src/pty.ts | 3 + packages/core/src/pty/ticket.ts | 4 +- packages/core/src/question.ts | 3 + packages/core/src/reference.ts | 7 + packages/core/src/reference/guidance.ts | 3 + packages/core/src/repository-cache.ts | 7 + packages/core/src/ripgrep.ts | 4 +- packages/core/src/ripgrep/binary.ts | 4 +- packages/core/src/session.ts | 37 +-- packages/core/src/session/execution.ts | 4 + packages/core/src/session/execution/local.ts | 13 +- packages/core/src/session/projector.ts | 4 +- packages/core/src/session/runner/llm.ts | 22 ++ packages/core/src/session/runner/model.ts | 3 + packages/core/src/session/store.ts | 3 + packages/core/src/session/todo.ts | 3 + packages/core/src/skill.ts | 3 + packages/core/src/skill/discovery.ts | 4 + packages/core/src/skill/guidance.ts | 3 + packages/core/src/snapshot.ts | 7 + packages/core/src/system-context/builtins.ts | 9 + packages/core/src/system-context/registry.ts | 3 + packages/core/src/tool-output-store.ts | 5 + packages/core/src/tool/application-tools.ts | 3 + packages/core/src/tool/builtins.ts | 37 +++ packages/core/src/tool/read-filesystem.ts | 3 + packages/core/src/tool/registry.ts | 13 + packages/core/src/util/effect-flock.ts | 4 +- .../layer-node/layer-node-types.test.ts | 138 ++++++++++ .../test/effect/layer-node/layer-node.test.ts | 226 ++++++++++++++++ .../test/effect/layer-node/node-build.test.ts | 119 +++++++++ packages/core/test/location-layer.test.ts | 12 +- packages/core/test/move-session.test.ts | 4 +- packages/core/test/permission.test.ts | 4 +- packages/core/test/session-create.test.ts | 4 +- packages/core/test/session-history.test.ts | 4 +- packages/core/test/session-projector.test.ts | 4 +- packages/core/test/session-prompt.test.ts | 4 +- .../core/test/session-runner-recorded.test.ts | 4 +- packages/core/test/session-runner.test.ts | 4 +- packages/opencode/src/agent/agent.ts | 8 +- packages/opencode/src/cli/cmd/debug/file.ts | 6 +- packages/opencode/src/cli/cmd/debug/v2.ts | 6 +- .../routes/instance/httpapi/handlers/file.ts | 6 +- .../routes/instance/httpapi/handlers/pty.ts | 10 +- .../server/routes/instance/httpapi/server.ts | 23 +- packages/opencode/src/session/message-v2.ts | 3 +- packages/opencode/src/session/session.ts | 4 +- packages/opencode/src/session/system.ts | 8 +- packages/opencode/test/agent/agent.test.ts | 4 +- .../agent/plugin-agent-regression.test.ts | 4 +- .../effect/layer-node-tiers-types.test.ts | 19 -- .../test/effect/layer-node-tiers.test.ts | 169 ------------ .../test/effect/layer-node-types.test.ts | 66 ----- .../opencode/test/effect/layer-node.test.ts | 86 ------ .../test/effect/scoped-node-types.test.ts | 23 -- packages/opencode/test/git/git.test.ts | 3 +- .../opencode/test/session/compaction.test.ts | 5 +- .../test/session/messages-pagination.test.ts | 3 +- .../test/session/processor-effect.test.ts | 26 +- packages/opencode/test/session/prompt.test.ts | 8 +- packages/opencode/test/session/retry.test.ts | 3 +- .../test/session/snapshot-tool-race.test.ts | 12 +- packages/opencode/test/session/system.test.ts | 4 +- .../opencode/test/share/share-next.test.ts | 25 +- packages/opencode/test/tool/registry.test.ts | 15 +- packages/server/src/handlers.ts | 21 -- packages/server/src/location.ts | 6 +- .../server/src/middleware/session-location.ts | 4 +- packages/server/src/pty-environment.ts | 5 +- packages/server/src/routes.ts | 41 ++- specs/layer-node-tiers.md | 235 ----------------- 117 files changed, 1450 insertions(+), 1196 deletions(-) delete mode 100644 packages/core/src/effect/layer-node.ts create mode 100644 packages/core/src/effect/layer-node/index.ts create mode 100644 packages/core/src/effect/layer-node/layer-node-tree.ts create mode 100644 packages/core/src/effect/layer-node/layer-node.ts create mode 100644 packages/core/src/effect/node-build.ts create mode 100644 packages/core/src/effect/node.ts delete mode 100644 packages/core/src/effect/scoped-node.ts delete mode 100644 packages/core/src/location-layer.ts create mode 100644 packages/core/src/location-service-map.ts create mode 100644 packages/core/src/location-services.ts create mode 100644 packages/core/test/effect/layer-node/layer-node-types.test.ts create mode 100644 packages/core/test/effect/layer-node/layer-node.test.ts create mode 100644 packages/core/test/effect/layer-node/node-build.test.ts delete mode 100644 packages/opencode/test/effect/layer-node-tiers-types.test.ts delete mode 100644 packages/opencode/test/effect/layer-node-tiers.test.ts delete mode 100644 packages/opencode/test/effect/layer-node-types.test.ts delete mode 100644 packages/opencode/test/effect/layer-node.test.ts delete mode 100644 packages/opencode/test/effect/scoped-node-types.test.ts delete mode 100644 specs/layer-node-tiers.md diff --git a/packages/core/package.json b/packages/core/package.json index 139d116a78..62d279d311 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -16,6 +16,8 @@ "opencode": "./bin/opencode" }, "exports": { + "./effect/layer-node": "./src/effect/layer-node/index.ts", + "./effect/node": "./src/effect/node.ts", "./session/runner": "./src/session/runner/index.ts", "./system-context": "./src/system-context/index.ts", "./*": "./src/*.ts" diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index 91f411684a..6173551a25 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -1,5 +1,6 @@ export * as AgentV2 from "./agent" +import { makeLocationNode } from "./effect/node" import { Array, Context, Effect, Layer, Types } from "effect" import { Agent } from "@opencode-ai/schema/agent" import { State } from "./state" @@ -106,3 +107,5 @@ export const layer = Layer.effect( ) export const locationLayer = layer + +export const node = makeLocationNode({ service: Service, layer, deps: [] }) diff --git a/packages/core/src/aisdk.ts b/packages/core/src/aisdk.ts index 9ea79394f3..26bcef0ac9 100644 --- a/packages/core/src/aisdk.ts +++ b/packages/core/src/aisdk.ts @@ -1,5 +1,6 @@ export * as AISDK from "./aisdk" +import { makeLocationNode } from "./effect/node" import type { LanguageModelV3 } from "@ai-sdk/provider" import { Cause, Context, Effect, Layer, Schema, Scope } from "effect" import { ModelV2 } from "./model" @@ -231,4 +232,6 @@ export const locationLayer = Layer.effect( }), ) +export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [] }) + export const defaultLayer = locationLayer diff --git a/packages/core/src/background-job.ts b/packages/core/src/background-job.ts index 35724eb8fd..98ba12e515 100644 --- a/packages/core/src/background-job.ts +++ b/packages/core/src/background-job.ts @@ -2,6 +2,7 @@ export * as BackgroundJob from "./background-job" import { Cause, Clock, Context, Deferred, Effect, Exit, Layer, Scope, SynchronizedRef } from "effect" import { Identifier } from "./id/id" +import { makeGlobalNode } from "./effect/node" export type Status = "running" | "completed" | "error" | "cancelled" @@ -362,3 +363,5 @@ export const make = Effect.gen(function* () { export const layer = Layer.effect(Service, make) export const defaultLayer = layer + +export const node = makeGlobalNode({ service: Service, layer, deps: [] }) diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index 63d0ae37cb..f63f3f0e37 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -1,5 +1,6 @@ export * as Catalog from "./catalog" +import { makeLocationNode } from "./effect/node" import { Array, Context, Effect, Layer, Option, Order, pipe, Schema } from "effect" import { Catalog } from "@opencode-ai/schema/catalog" import { ModelV2 } from "./model" @@ -296,3 +297,5 @@ export const locationLayer = layer.pipe( Layer.provideMerge(Integration.locationLayer), Layer.provideMerge(Policy.locationLayer), ) + +export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Policy.node, Integration.node] }) diff --git a/packages/core/src/command.ts b/packages/core/src/command.ts index c65693ebfd..b106eab040 100644 --- a/packages/core/src/command.ts +++ b/packages/core/src/command.ts @@ -1,5 +1,6 @@ export * as CommandV2 from "./command" +import { makeLocationNode } from "./effect/node" import { Context, Effect, Layer, Types } from "effect" import { Command } from "@opencode-ai/schema/command" import { State } from "./state" @@ -59,3 +60,5 @@ export const layer = Layer.effect( ) export const locationLayer = layer + +export const node = makeLocationNode({ service: Service, layer, deps: [] }) diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 1f97194ad5..979386c93d 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -1,5 +1,6 @@ export * as Config from "./config" +import { makeLocationNode } from "./effect/node" import path from "path" import { type ParseError, parse } from "jsonc-parser" import { Context, Effect, Layer, Option, Schema } from "effect" @@ -218,3 +219,9 @@ export const layer = Layer.effect( ) export const locationLayer = layer.pipe(Layer.provideMerge(Policy.locationLayer)) + +export const node = makeLocationNode({ + service: Service, + layer, + deps: [FSUtil.node, Global.node, Location.node, Policy.node], +}) diff --git a/packages/core/src/credential.ts b/packages/core/src/credential.ts index caef1db1b8..1fa6374b70 100644 --- a/packages/core/src/credential.ts +++ b/packages/core/src/credential.ts @@ -5,6 +5,7 @@ import { Context, Effect, Layer, Schema } from "effect" import { Credential } from "@opencode-ai/schema/credential" import { Integration } from "@opencode-ai/schema/integration" import { Database } from "./database/database" +import { makeGlobalNode } from "./effect/node" import { CredentialTable } from "./credential/sql" export const ID = Credential.ID @@ -135,3 +136,5 @@ export const layer = Layer.effect( ) export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer)) + +export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node] }) diff --git a/packages/core/src/cross-spawn-spawner.ts b/packages/core/src/cross-spawn-spawner.ts index 370c07a648..f3b8d067f5 100644 --- a/packages/core/src/cross-spawn-spawner.ts +++ b/packages/core/src/cross-spawn-spawner.ts @@ -24,7 +24,7 @@ import { import * as NodeChildProcess from "node:child_process" import { PassThrough } from "node:stream" import launch from "cross-spawn" -import { LayerNode } from "./effect/layer-node" +import { makeGlobalNode } from "./effect/node" import { filesystem, path } from "./effect/layer-node-platform" const toError = (err: unknown): Error => (err instanceof globalThis.Error ? err : new globalThis.Error(String(err))) @@ -503,6 +503,6 @@ export const layer: Layer.Layer @@ -60,4 +60,4 @@ export const defaultLayer = Layer.unwrap( }), ).pipe(Layer.provide(Global.defaultLayer)) -export const node = LayerNode.make({ service: Service, layer: layerFromPath(path()), deps: [] }) +export const node = makeGlobalNode({ service: Service, layer: layerFromPath(path()), deps: [] }) diff --git a/packages/core/src/effect/layer-node-platform.ts b/packages/core/src/effect/layer-node-platform.ts index ac64943e46..9bd6b1c253 100644 --- a/packages/core/src/effect/layer-node-platform.ts +++ b/packages/core/src/effect/layer-node-platform.ts @@ -3,16 +3,16 @@ import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route" import { FileSystem, Path } from "effect" import { FetchHttpClient } from "effect/unstable/http" import { HttpClient } from "effect/unstable/http" -import { LayerNode } from "./layer-node" +import { makeGlobalNode } from "./node" -export const filesystem = LayerNode.make({ service: FileSystem.FileSystem, layer: NodeFileSystem.layer, deps: [] }) -export const path = LayerNode.make({ service: Path.Path, layer: NodePath.layer, deps: [] }) -export const httpClient = LayerNode.make({ service: HttpClient.HttpClient, layer: FetchHttpClient.layer, deps: [] }) -export const requestExecutor = LayerNode.make({ +export const filesystem = makeGlobalNode({ service: FileSystem.FileSystem, layer: NodeFileSystem.layer, deps: [] }) +export const path = makeGlobalNode({ service: Path.Path, layer: NodePath.layer, deps: [] }) +export const httpClient = makeGlobalNode({ service: HttpClient.HttpClient, layer: FetchHttpClient.layer, deps: [] }) +export const requestExecutor = makeGlobalNode({ service: RequestExecutor.Service, layer: RequestExecutor.layer, deps: [httpClient], }) -export const llmClient = LayerNode.make({ service: LLMClient.Service, layer: LLMClient.layer, deps: [requestExecutor] }) +export const llmClient = makeGlobalNode({ service: LLMClient.Service, layer: LLMClient.layer, deps: [requestExecutor] }) export * as LayerNodePlatform from "./layer-node-platform" diff --git a/packages/core/src/effect/layer-node.ts b/packages/core/src/effect/layer-node.ts deleted file mode 100644 index 587274d6c2..0000000000 --- a/packages/core/src/effect/layer-node.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { Brand, Context, Layer } from "effect" - -type RuntimeLayer = Layer.Layer -type AnyNode = Node -type NodeList = readonly [] | readonly [Item, ...Item[]] -type Output = [Item] extends [never] ? never : Item extends Node ? A : never -type Error = [Item] extends [never] ? never : Item extends Node ? E : never -type Missing = Exclude> -type CheckDependencies = [ - Missing, Dependencies>, -] extends [never] - ? unknown - : { readonly "Missing dependencies": Missing, Dependencies> } -declare const $OutputType: unique symbol -declare const $ErrorType: unique symbol - -export type Tier = Name & Brand.Brand<"LayerNode.Tier"> - -const makeTier = Brand.nominal() - -export type Node = { - readonly kind: "layer" | "group" - readonly name: string - readonly service?: Context.Service.Any - readonly implementation?: Layer.Any - readonly dependencies: readonly AnyNode[] - readonly tier?: T - readonly [$OutputType]?: () => A - readonly [$ErrorType]?: () => E -} - -type NodeIdentity = - | { readonly service: Context.Service.Any; readonly name?: never } - | { readonly name: string; readonly service?: never } -type DistributiveOmit = A extends unknown ? Omit : never - -type NodeInput< - Implementation extends Layer.Any, - Items extends NodeList, - T extends Tier | undefined = undefined, -> = NodeIdentity & { - readonly layer: Implementation - readonly deps: Items & CheckDependencies> - readonly tier?: T -} - -export function make< - const Implementation extends Layer.Any, - const Items extends NodeList, - const T extends Tier | undefined = undefined, ->( - input: NodeInput, -): Node, Layer.Error | Error, T> { - return { - kind: "layer", - name: input.service !== undefined ? input.service.key : input.name, - service: input.service, - implementation: input.layer, - dependencies: input.deps, - tier: input.tier, - } -} - -export function group( - dependencies: Items, -): Node, Error> { - return { kind: "group", name: "group", dependencies } -} - -type AllowedTierNames = Names extends readonly [ - infer Head extends string, - ...infer Tail extends readonly string[], -] - ? Head extends Name - ? Head | Tail[number] - : AllowedTierNames - : never - -type NodeInTiers = Node> - -export interface Tiers { - readonly names: Names - readonly values: { readonly [K in Names[number]]: Tier } - readonly make: ( - name: Name, - ) => < - const Implementation extends Layer.Any, - const Items extends NodeList>>, - >( - input: DistributiveOmit>, "tier">, - ) => Node, Layer.Error | Error, Tier> -} - -export function tiers(names: Names): Tiers { - const values = Object.fromEntries(names.map((name) => [name, makeTier(name)])) as Tiers["values"] - return { - names, - values, - make: ((name: Names[number]) => (input: DistributiveOmit, "tier">) => - make({ ...input, tier: values[name] })) as Tiers["make"], - } -} - -const defaultTiers = tiers(["untiered"]) -const untiered = defaultTiers.values.untiered - -export type Replacement = { - readonly source: Layer.Any - readonly replacement: Layer.Any -} - -type CheckReplacementErrors = [Exclude] extends [never] - ? unknown - : { readonly "New replacement errors": Exclude } - -export function replace( - source: Layer.Layer, - replacement: Layer.Layer, E2, never> & CheckReplacementErrors>, -): Replacement { - return { source, replacement } -} - -export function buildLayer< - A, - E, - const Names extends readonly [string, ...string[]] = readonly ["untiered"], - const Built extends Layer.Any = Layer.Layer, ->( - node: Node, - options?: { - readonly tiers?: Tiers - readonly buildTier?: (tier: Names[number], layers: readonly Layer.Any[]) => Built - readonly replacements?: readonly Replacement[] - }, -): Layer.Layer, E | Layer.Error, never> { - const tiers = options?.tiers ?? (defaultTiers as unknown as Tiers) - const replacementMap = new Map(options?.replacements?.map((item) => [item.source, item.replacement])) - const plans = plan(node, tiers, replacementMap) - const layers: RuntimeLayer[] = tiers.names.map((name) => { - const tier = tiers.values[name as Names[number]] - const layers = plans.get(tier) ?? [] - return (options?.buildTier?.(name, layers) ?? combine(layers)) as RuntimeLayer - }) - if (layers.length === 0) return Layer.empty as never - return layers.slice(1).reduce((result, layer) => result.pipe(Layer.provideMerge(layer)), layers[0]) as never -} - -export function combine(layers: readonly Layer.Any[]): RuntimeLayer { - return layers.reduce( - (result, layer) => (layer as RuntimeLayer).pipe(Layer.provideMerge(result)), - Layer.empty as RuntimeLayer, - ) -} - -function plan( - root: AnyNode, - tiers: Tiers, - replacements: ReadonlyMap, -) { - const indexes = new Map(tiers.names.map((name, index) => [tiers.values[name], index])) - const plans = new Map() - const activeImplementations = new Map>() - const serviceTiers = new Map() - const visiting = new Set() - const stack: AnyNode[] = [] - const boundaryVisited = new Map>() - const boundaryServices = new Map>() - - const validateBoundary = (node: AnyNode, origin: Tier) => { - const checked = boundaryVisited.get(node) ?? new Set() - boundaryVisited.set(node, checked) - if (checked.has(origin)) return false - checked.add(origin) - const services = boundaryServices.get(origin) ?? new Map() - boundaryServices.set(origin, services) - const key = node.name - const existing = services.get(key) - if (existing && existing !== node) { - throw new Error(`Tier ${origin} has conflicting implementations for ${key}`) - } - services.set(key, node) - return true - } - - const visit = (node: AnyNode, currentTier?: Tier, origins: readonly Tier[] = []) => { - if (node.kind === "group") { - node.dependencies.forEach((dependency) => visit(dependency, currentTier, origins)) - return - } - - const tier = node.tier ?? untiered - if (!indexes.has(tier)) throw new Error(`Node ${node.name} is not in the tier configuration`) - const key = node.name - const serviceTier = serviceTiers.get(key) - if (serviceTier && serviceTier !== tier) { - throw new Error(`Service ${key} belongs to both tier ${serviceTier} and tier ${tier}`) - } - serviceTiers.set(key, tier) - const nextOrigins = [...origins] - if (currentTier) { - const current = indexes.get(currentTier)! - const required = indexes.get(tier)! - if (required < current) { - throw new Error(`Tier ${currentTier} cannot depend on lower tier ${tier}`) - } - if (required > current) nextOrigins.push(currentTier) - } - const unseenOrigins = nextOrigins.filter((origin) => validateBoundary(node, origin)) - - // A node may need to be emitted more than once because the final output is a - // flat list of layers applied with Layer.provideMerge. If another node for - // the same service was emitted afterward, this node is no longer the active - // implementation for subsequent consumers. Re-emitting restores the intended - // implementation ordering while Effect memoization avoids reacquiring the layer. - const implementations = activeImplementations.get(tier) ?? new Map() - activeImplementations.set(tier, implementations) - if (implementations.get(key) === node && unseenOrigins.length === 0) return - - if (visiting.has(node)) { - const start = stack.indexOf(node) - throw new Error( - `Cycle detected in layer graph: ${[...stack.slice(start), node].map((item) => item.name).join(" -> ")}`, - ) - } - - visiting.add(node) - stack.push(node) - try { - node.dependencies.forEach((dependency) => visit(dependency, tier, unseenOrigins)) - const layers = plans.get(tier) ?? [] - plans.set(tier, layers) - layers.push(replacements.get(node.implementation!) ?? node.implementation!) - implementations.set(key, node) - } finally { - stack.pop() - visiting.delete(node) - } - } - - visit(root) - return plans -} - -function requireTier(node: AnyNode, indexes: ReadonlyMap) { - if (!node.tier || !indexes.has(node.tier)) throw new Error(`Node ${node.name} is not in the tier configuration`) -} - -export * as LayerNode from "./layer-node" diff --git a/packages/core/src/effect/layer-node/index.ts b/packages/core/src/effect/layer-node/index.ts new file mode 100644 index 0000000000..a031217e12 --- /dev/null +++ b/packages/core/src/effect/layer-node/index.ts @@ -0,0 +1,2 @@ +export * from "./layer-node" +export * as LayerNodeTree from "./layer-node-tree" diff --git a/packages/core/src/effect/layer-node/layer-node-tree.ts b/packages/core/src/effect/layer-node/layer-node-tree.ts new file mode 100644 index 0000000000..44df9d22f4 --- /dev/null +++ b/packages/core/src/effect/layer-node/layer-node-tree.ts @@ -0,0 +1,118 @@ +import { Layer } from "effect" +import { LayerNode } from "./layer-node" + +type AnyNode = LayerNode.Node +type RuntimeLayer = Layer.Layer + +export function hoist( + root: LayerNode.Node, + tag: T, +): { + readonly node: LayerNode.Node + readonly hoisted: LayerNode.Node +} { + const visited = new Map() + const hoisted = new Map() + const visiting = new Set() + const stack: AnyNode[] = [] + + const visit = (node: AnyNode): AnyNode => { + if (node.kind === "group") { + return { ...node, dependencies: node.dependencies.map(visit) } + } + + const existingNode = visited.get(node) + if (existingNode) return existingNode + + if (node.tag === tag) { + const existing = hoisted.get(node.name) + if (existing && existing !== node) { + throw new Error(`Tag ${tag} has conflicting implementations for ${node.name}`) + } + hoisted.set(node.name, node) + const empty = LayerNode.group([]) + visited.set(node, empty) + return empty + } + if (node.kind === "unbound") { + return node + } + + if (visiting.has(node)) { + const start = stack.indexOf(node) + throw new Error( + `Cycle detected in layer tree: ${[...stack.slice(start), node].map((item) => item.name).join(" -> ")}`, + ) + } + visiting.add(node) + stack.push(node) + try { + const dependencies = node.dependencies.map(visit) + const clone = { ...node, dependencies } + visited.set(node, clone) + return clone + } finally { + stack.pop() + visiting.delete(node) + } + } + + return { + node: visit(root) as LayerNode.Node, + hoisted: LayerNode.group(Array.from(hoisted.values())) as LayerNode.Node, + } +} + +export function compile( + root: LayerNode.Node, + replacements?: ReadonlyMap, +): Layer.Layer { + const cache = new Map() + const compileNode = (node: AnyNode): RuntimeLayer => { + if (node.kind === "unbound") throw new Error(`Unbound layer node: ${node.name}`) + const cached = cache.get(node) + if (cached) return cached + const dependencies = node.dependencies.flatMap(flatten).map(compileNode) + const implementation = (replacements?.get(node.implementation!) ?? node.implementation!) as RuntimeLayer + const layer = + dependencies.length === 0 + ? implementation + : implementation.pipe(Layer.provide(dependencies as [RuntimeLayer, ...RuntimeLayer[]])) + cache.set(node, layer) + return layer + } + const layers = flatten(root).map((node) => compileNode(node)) + const layer = layers.reduce((result, layer) => layer.pipe(Layer.provideMerge(result)), Layer.empty) + return layer as Layer.Layer +} + +export function bind( + root: LayerNode.Node, + source: AnyNode, + replacement: AnyNode, +): LayerNode.Node { + if (source.kind !== "unbound") throw new Error(`Cannot bind non-unbound layer node: ${source.name}`) + if (source.name !== replacement.name) { + throw new Error(`Cannot bind ${source.name} to ${replacement.name}`) + } + if (source.tag !== replacement.tag) { + throw new Error(`Cannot bind ${source.name} across tags`) + } + const visited = new Map() + const visit = (node: AnyNode): AnyNode => { + if (node === source) return replacement + const existing = visited.get(node) + if (existing) return existing + if (node.kind === "unbound") return node + const clone = { ...node, dependencies: node.dependencies.map(visit) } + visited.set(node, clone) + return clone + } + return visit(root) as LayerNode.Node +} + +function flatten(node: AnyNode): readonly AnyNode[] { + return node.kind === "group" ? node.dependencies.flatMap(flatten) : [node] +} + +export * as LayerNodeTree from "./layer-node-tree" diff --git a/packages/core/src/effect/layer-node/layer-node.ts b/packages/core/src/effect/layer-node/layer-node.ts new file mode 100644 index 0000000000..52ed1a9fae --- /dev/null +++ b/packages/core/src/effect/layer-node/layer-node.ts @@ -0,0 +1,138 @@ +import { Brand, Context, Layer } from "effect" + +type AnyNode = Node +type NodeList = readonly [] | readonly [Item, ...Item[]] +export type Output = [Item] extends [never] ? never : Item extends Node ? A : never +export type Error = [Item] extends [never] ? never : Item extends Node ? E : never +type NodeTag = [Item] extends [never] ? undefined : Item extends Node ? T : never +type Missing = Exclude> +type CheckDependencies = [ + Missing, Dependencies>, +] extends [never] + ? unknown + : { readonly "Missing dependencies": Missing, Dependencies> } +declare const $OutputType: unique symbol +declare const $ErrorType: unique symbol + +export type Tag = Name & Brand.Brand<"LayerNode.Tag"> + +const makeTag = Brand.nominal() + +export interface Node { + readonly kind: "layer" | "unbound" | "group" + readonly name: string + readonly service?: Context.Service.Any + readonly implementation?: Layer.Any + readonly dependencies: readonly AnyNode[] + readonly tag?: T + readonly [$OutputType]?: () => A + readonly [$ErrorType]?: () => E +} + +type NodeIdentity = + | { readonly service: Context.Service.Any; readonly name?: never } + | { readonly name: string; readonly service?: never } +type DistributiveOmit = A extends unknown ? Omit : never + +type MakeInput< + Implementation extends Layer.Any, + Items extends NodeList, + T extends Tag | undefined = undefined, +> = NodeIdentity & { + readonly layer: Implementation + readonly deps: Items & CheckDependencies> + readonly tag?: T +} + +export function make< + const Implementation extends Layer.Any, + const Items extends NodeList, + const T extends Tag | undefined = undefined, +>( + input: MakeInput, +): Node< + Layer.Success, + Layer.Error | Error, + T +> { + return { + kind: "layer", + name: input.service !== undefined ? input.service.key : input.name, + service: input.service, + implementation: input.layer, + dependencies: input.deps, + tag: input.tag, + } +} + +export function unbound( + service: Context.Key, + tag: T, +): Node { + return { + kind: "unbound", + name: service.key, + service, + dependencies: [], + tag, + } +} + +export function group( + dependencies: Items, +): Node, Error, NodeTag> { + return { kind: "group", name: "group", dependencies } +} + +export type TagConfig = Readonly> +type TagNames = keyof Config & string +type NodeInTags = Node | undefined> +type CheckTags = [Exclude>] extends [ + never, +] + ? unknown + : { readonly "Invalid tag dependencies": Exclude> } + +export interface Tags { + readonly values: { readonly [Name in TagNames]: Tag } + readonly make: >( + name: Name, + ) => ( + input: DistributiveOmit>, "tag"> & + CheckTags>, + ) => Node< + Layer.Success, + Layer.Error | Error, + Tag + > +} + +export function tags< + const Config extends { readonly [Name in keyof Config]: readonly (keyof Config & string)[] }, +>(config: Config): Tags { + const names = Object.keys(config) as TagNames[] + const values = Object.fromEntries(names.map((name) => [name, makeTag(name)])) as Tags["values"] + return { + values, + make: ((name: TagNames) => (input: DistributiveOmit, "tag">) => + make({ ...input, tag: values[name] })) as Tags["make"], + } +} + +export type Replacement = { + readonly source: Layer.Any + readonly replacement: Layer.Any +} + +type CheckReplacementErrors = [Exclude] extends [never] + ? unknown + : { readonly "New replacement errors": Exclude } + +export function replace( + source: Layer.Layer, + replacement: Layer.Layer, E2, never> & CheckReplacementErrors>, +): Replacement { + return { source, replacement } +} + +export * as LayerNode from "./layer-node" diff --git a/packages/core/src/effect/node-build.ts b/packages/core/src/effect/node-build.ts new file mode 100644 index 0000000000..c576da2daf --- /dev/null +++ b/packages/core/src/effect/node-build.ts @@ -0,0 +1,18 @@ +import { Layer } from "effect" +import { buildLocationServiceMap } from "../location-services" +import { LocationServiceMap } from "../location-service-map" +import { LayerNode, LayerNodeTree } from "./layer-node" +import { makeGlobalNode } from "./node" + +export function build(root: LayerNode.Node, replacements?: readonly LayerNode.Replacement[]) { + const replacementMap = new Map(replacements?.map((item) => [item.source, item.replacement])) + + const locationMap = buildLocationServiceMap(replacementMap) + const locationMapNode = makeGlobalNode({ service: LocationServiceMap.Service, layer: locationMap, deps: [] }) + + const app = LayerNodeTree.bind(root, LocationServiceMap.node, locationMapNode) + + return LayerNodeTree.compile(app, replacementMap) +} + +export * as NodeBuild from "./node-build" diff --git a/packages/core/src/effect/node.ts b/packages/core/src/effect/node.ts new file mode 100644 index 0000000000..4b316651c1 --- /dev/null +++ b/packages/core/src/effect/node.ts @@ -0,0 +1,14 @@ +import { LayerNode } from "./layer-node" + +export const tags = LayerNode.tags({ + location: ["global"], + global: [], +}) + +export type GlobalNode = LayerNode.Node +export type LocationNode = LayerNode.Node + +export const makeGlobalNode = tags.make("global") +export const makeLocationNode = tags.make("location") + +export * as Node from "./node" diff --git a/packages/core/src/effect/scoped-node.ts b/packages/core/src/effect/scoped-node.ts deleted file mode 100644 index 6583d2bb8a..0000000000 --- a/packages/core/src/effect/scoped-node.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { LayerNode } from "./layer-node" - -export const tiers = LayerNode.tiers(["location", "global"]) - -export type GlobalNode = LayerNode.Node -export type LocationNode = LayerNode.Node - -export const makeGlobalNode = tiers.make("global") -export const makeLocationNode = tiers.make("location") - -export * as ScopedNode from "./scoped-node" diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts index 46cec83beb..0c3e50ac08 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -7,7 +7,7 @@ import { and, asc, eq, gt, inArray } from "drizzle-orm" import { Database } from "./database/database" import { EventSequenceTable, EventTable } from "./event/sql" import { Location } from "./location" -import { LayerNode } from "./effect/layer-node" +import { makeGlobalNode } from "./effect/node" import { isDeepStrictEqual } from "node:util" import { Durable } from "@opencode-ai/schema/durable-event-manifest" @@ -635,6 +635,6 @@ export const layerWith = (options?: LayerOptions) => ) export const layer = layerWith() -export const node = LayerNode.make({ service: Service, layer: layer, deps: [Database.node] }) +export const node = makeGlobalNode({ service: Service, layer: layer, deps: [Database.node] }) export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer)) diff --git a/packages/core/src/file-mutation.ts b/packages/core/src/file-mutation.ts index a3c6f519a3..913829cdfc 100644 --- a/packages/core/src/file-mutation.ts +++ b/packages/core/src/file-mutation.ts @@ -1,5 +1,6 @@ export * as FileMutation from "./file-mutation" +import { makeLocationNode } from "./effect/node" import { Context, Effect, Layer, Schema } from "effect" import { dirname } from "path" import { KeyedMutex } from "./effect/keyed-mutex" @@ -192,6 +193,8 @@ function sameBytes(left: Uint8Array, right: Uint8Array) { export const locationLayer = layer +export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node] }) + /** * Deferred until the corresponding V2 integrations exist. */ diff --git a/packages/core/src/filesystem.ts b/packages/core/src/filesystem.ts index c02595463f..34c71131e5 100644 --- a/packages/core/src/filesystem.ts +++ b/packages/core/src/filesystem.ts @@ -1,5 +1,6 @@ export * as FileSystem from "./filesystem" +import { makeLocationNode } from "./effect/node" import path from "path" import { Context, Effect, Layer, Schema } from "effect" import { FSUtil } from "./fs-util" @@ -113,3 +114,9 @@ const baseLayer = Layer.effect( export const layer = baseLayer.pipe(Layer.provide(FileSystemSearch.locationLayer), Layer.provide(FSUtil.defaultLayer)) export const locationLayer = layer + +export const node = makeLocationNode({ + service: Service, + layer: baseLayer, + deps: [FSUtil.node, Location.node, FileSystemSearch.node], +}) diff --git a/packages/core/src/filesystem/search.ts b/packages/core/src/filesystem/search.ts index db94711480..0e29228af7 100644 --- a/packages/core/src/filesystem/search.ts +++ b/packages/core/src/filesystem/search.ts @@ -1,5 +1,6 @@ export * as FileSystemSearch from "./search" +import { makeLocationNode } from "../effect/node" import path from "path" import { Context, Effect, Layer, Scope } from "effect" import { Fff } from "#fff" @@ -229,6 +230,8 @@ export const fffLayer = Layer.effect( }), ) -export const locationLayer = Layer.unwrap( - Effect.sync(() => (Flag.OPENCODE_DISABLE_FFF || !Fff.available() ? ripgrepLayer : fffLayer)), -) +const layer = Layer.unwrap(Effect.sync(() => (Flag.OPENCODE_DISABLE_FFF || !Fff.available() ? ripgrepLayer : fffLayer))) + +export const locationLayer = layer + +export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Location.node, Ripgrep.node] }) diff --git a/packages/core/src/filesystem/watcher.ts b/packages/core/src/filesystem/watcher.ts index 5c8930898c..92c330be76 100644 --- a/packages/core/src/filesystem/watcher.ts +++ b/packages/core/src/filesystem/watcher.ts @@ -3,6 +3,7 @@ export * as Watcher from "./watcher" // @ts-ignore import { createWrapper } from "@parcel/watcher/wrapper" import type ParcelWatcher from "@parcel/watcher" +import { makeLocationNode } from "../effect/node" import { Cause, Context, Effect, Layer } from "effect" import { FileSystemWatcher } from "@opencode-ai/schema/filesystem-watcher" import path from "path" @@ -133,3 +134,9 @@ export const layer = Layer.effect( ) export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer), Layer.provide(Git.defaultLayer)) + +export const node = makeLocationNode({ + service: Service, + layer, + deps: [FSUtil.node, Location.node, Config.node, Git.node, EventV2.node], +}) diff --git a/packages/core/src/fs-util.ts b/packages/core/src/fs-util.ts index da3d615b59..8b62b5b6df 100644 --- a/packages/core/src/fs-util.ts +++ b/packages/core/src/fs-util.ts @@ -7,7 +7,7 @@ import { Context, Effect, FileSystem, Layer, Schema } from "effect" import type { PlatformError } from "effect/PlatformError" import { Glob } from "./util/glob" import { serviceUse } from "./effect/service-use" -import { LayerNode } from "./effect/layer-node" +import { makeGlobalNode } from "./effect/node" import { filesystem } from "./effect/layer-node-platform" export namespace FSUtil { @@ -201,7 +201,7 @@ export namespace FSUtil { ) export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer)) - export const node = LayerNode.make({ service: Service, layer: layer, deps: [filesystem] }) + export const node = makeGlobalNode({ service: Service, layer: layer, deps: [filesystem] }) // Pure helpers that don't need Effect (path manipulation, sync operations) export function mimeType(p: string): string { diff --git a/packages/core/src/git.ts b/packages/core/src/git.ts index d6401fcdfe..1c50744d18 100644 --- a/packages/core/src/git.ts +++ b/packages/core/src/git.ts @@ -7,7 +7,7 @@ import { ChildProcess } from "effect/unstable/process" import { AbsolutePath, RelativePath } from "./schema" import { FSUtil } from "./fs-util" import { AppProcess } from "./process" -import { LayerNode } from "./effect/layer-node" +import { makeGlobalNode } from "./effect/node" import { File } from "./file" import { KeyedMutex } from "./effect/keyed-mutex" @@ -944,7 +944,7 @@ export const layer = Layer.effect( ) export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(AppProcess.defaultLayer)) -export const node = LayerNode.make({ service: Service, layer: layer, deps: [FSUtil.node, AppProcess.node] }) +export const node = makeGlobalNode({ service: Service, layer: layer, deps: [FSUtil.node, AppProcess.node] }) interface Result { readonly exitCode: number diff --git a/packages/core/src/global.ts b/packages/core/src/global.ts index eed28b94cf..50d1eec837 100644 --- a/packages/core/src/global.ts +++ b/packages/core/src/global.ts @@ -5,7 +5,7 @@ import os from "os" import { Context, Effect, Layer } from "effect" import { Flock } from "./util/flock" import { Flag } from "./flag/flag" -import { LayerNode } from "./effect/layer-node" +import { makeGlobalNode } from "./effect/node" const app = "opencode" const data = path.join(xdgData!, app) @@ -77,7 +77,7 @@ export const layer = Layer.effect( ) export const defaultLayer = layer -export const node = LayerNode.make({ service: Service, layer: layer, deps: [] }) +export const node = makeGlobalNode({ service: Service, layer: layer, deps: [] }) export const layerWith = (input: Partial) => Layer.effect( diff --git a/packages/core/src/image.ts b/packages/core/src/image.ts index 9c69d6aeb6..64c10e38ca 100644 --- a/packages/core/src/image.ts +++ b/packages/core/src/image.ts @@ -1,5 +1,6 @@ export * as Image from "./image" +import { makeLocationNode } from "./effect/node" import { Context, Effect, Layer, Schema } from "effect" import { Config } from "./config" import { FileSystem } from "./filesystem" @@ -76,3 +77,5 @@ export const layer = Layer.effect( ) export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer)) + +export const node = makeLocationNode({ service: Service, layer, deps: [Config.node] }) diff --git a/packages/core/src/instruction-context.ts b/packages/core/src/instruction-context.ts index fdfe59aa0b..3a376b8d5e 100644 --- a/packages/core/src/instruction-context.ts +++ b/packages/core/src/instruction-context.ts @@ -9,6 +9,7 @@ import { Location } from "./location" import { AbsolutePath } from "./schema" import { SystemContext } from "./system-context/index" import { SystemContextRegistry } from "./system-context/registry" +import { makeLocationNode } from "./effect/node" class File extends Schema.Class("InstructionContext.File")({ path: AbsolutePath, @@ -87,6 +88,12 @@ export const layer = Layer.effectDiscard( }), ) +export const node = makeLocationNode({ + name: "instruction-context", + layer, + deps: [FSUtil.node, Global.node, Location.node, SystemContextRegistry.node], +}) + function render(files: ReadonlyArray) { return files.map((file) => `Instructions from: ${file.path}\n${file.content}`).join("\n\n") } diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index af096df2fd..1d3947ea44 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -1,5 +1,6 @@ export * as Integration from "./integration" +import { makeLocationNode } from "./effect/node" import { Cause, Clock, @@ -515,3 +516,5 @@ export const locationLayer = Layer.effect( }) }), ) + +export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [Credential.node, EventV2.node] }) diff --git a/packages/core/src/location-layer.ts b/packages/core/src/location-layer.ts deleted file mode 100644 index bfbf5c6eed..0000000000 --- a/packages/core/src/location-layer.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { Effect, Layer, LayerMap } from "effect" -import { Location } from "./location" -import { Policy } from "./policy" -import { Config } from "./config" -import { PluginV2 } from "./plugin" -import { Catalog } from "./catalog" -import { Integration } from "./integration" -import { CommandV2 } from "./command" -import { AgentV2 } from "./agent" -import { PluginInternal } from "./plugin/internal" -import { Project } from "./project" -import { ProjectCopy } from "./project/copy" -import { ProjectDirectories } from "./project/directories" -import { EventV2 } from "./event" -import { Credential } from "./credential" -import { Npm } from "./npm" -import { ModelsDev } from "./models-dev" -import { FSUtil } from "./fs-util" -import { Git } from "./git" -import { Global } from "./global" -import { Database } from "./database/database" -import { PermissionV2 } from "./permission" -import { PermissionSaved } from "./permission/saved" -import { FileSystem } from "./filesystem" -import { Ripgrep } from "./ripgrep" -import { Watcher } from "./filesystem/watcher" -import { LocationMutation } from "./location-mutation" -import { FileMutation } from "./file-mutation" -import { Reference } from "./reference" -import { ReferenceGuidance } from "./reference/guidance" -import { RepositoryCache } from "./repository-cache" -import { Pty } from "./pty" -import { SkillV2 } from "./skill" -import { SkillGuidance } from "./skill/guidance" -import { BuiltInTools } from "./tool/builtins" -import { Image } from "./image" -import { ToolRegistry } from "./tool/registry" -import { ApplicationTools } from "./tool/application-tools" -import { ToolOutputStore } from "./tool-output-store" -import { AppProcess } from "./process" -import { SessionStore } from "./session/store" -import { SessionTodo } from "./session/todo" -import { QuestionV2 } from "./question" -import { LLMClient } from "@opencode-ai/llm" -import { RequestExecutor } from "@opencode-ai/llm/route" -import * as SessionRunnerLLM from "./session/runner/llm" -import { SessionRunnerModel } from "./session/runner/model" -import { SystemContextBuiltIns } from "./system-context/builtins" -import { FetchHttpClient } from "effect/unstable/http" -import { Snapshot } from "./snapshot" - -export class LocationServiceMap extends LayerMap.Service()("@opencode/example/LocationServiceMap", { - lookup: (ref: Location.Ref) => { - const boot = Layer.effectDiscard( - Effect.logInfo("booting location services", { directory: ref.directory, workspaceID: ref.workspaceID }), - ) - const location = Location.layer(ref) - const systemContext = SystemContextBuiltIns.locationLayer - const base = Layer.mergeAll( - location, - Policy.locationLayer, - Config.locationLayer, - Reference.locationLayer, - PluginV2.locationLayer, - Catalog.locationLayer, - Integration.locationLayer, - CommandV2.locationLayer, - AgentV2.locationLayer, - PluginInternal.locationLayer, - ProjectCopy.locationLayer, - FileSystem.locationLayer, - Watcher.locationLayer, - Pty.locationLayer, - SkillV2.locationLayer, - systemContext, - LocationMutation.locationLayer.pipe(Layer.orDie), - ).pipe(Layer.provideMerge(location)) - const resources = ToolOutputStore.layer.pipe(Layer.provide(base)) - const permissionsAndTools = ToolRegistry.layer.pipe( - Layer.provideMerge(PermissionV2.locationLayer), - Layer.provide(resources), - Layer.provide(base), - ) - const services = Layer.mergeAll(base, resources, permissionsAndTools) - const image = Image.layer.pipe(Layer.provide(services)) - const mutation = FileMutation.locationLayer.pipe(Layer.provide(services)) - const skillGuidance = SkillGuidance.locationLayer.pipe(Layer.provide(services)) - const referenceGuidance = ReferenceGuidance.locationLayer.pipe(Layer.provide(services)) - const todos = SessionTodo.layer.pipe(Layer.provide(services)) - const questions = QuestionV2.locationLayer.pipe(Layer.provide(services)) - const builtInTools = BuiltInTools.locationLayer.pipe( - Layer.provide(services), - Layer.provide(mutation), - Layer.provide(resources), - Layer.provide(todos), - Layer.provide(questions), - Layer.provide(image), - ) - const model = SessionRunnerModel.locationLayer.pipe(Layer.provide(services)) - const snapshot = Snapshot.locationLayer.pipe(Layer.provide(services)) - const runner = SessionRunnerLLM.defaultLayer.pipe( - Layer.provide(services), - Layer.provide(model), - Layer.provide(skillGuidance), - Layer.provide(referenceGuidance), - Layer.provide(snapshot), - ) - - // Kick off a background project copy refresh to update locations now that we - // have a location - const projectCopyRefresh = Layer.effectDiscard(ProjectCopy.refreshAfterBoot).pipe(Layer.provide(services)) - - return Layer.mergeAll( - boot, - services, - image, - mutation, - resources, - todos, - questions, - model, - snapshot, - runner, - builtInTools, - referenceGuidance, - projectCopyRefresh, - ).pipe(Layer.fresh) - }, - idleTimeToLive: "60 minutes", - dependencies: [ - Project.defaultLayer, - EventV2.defaultLayer, - Credential.defaultLayer, - Npm.defaultLayer, - ModelsDev.defaultLayer, - FSUtil.defaultLayer, - Git.defaultLayer, - AppProcess.defaultLayer, - Global.defaultLayer, - Ripgrep.defaultLayer, - Database.defaultLayer, - ProjectDirectories.defaultLayer, - SessionStore.layer.pipe(Layer.provide(Database.defaultLayer)), - PermissionSaved.defaultLayer, - RepositoryCache.defaultLayer, - LLMClient.layer.pipe(Layer.provide(RequestExecutor.defaultLayer)), - FetchHttpClient.layer, - ToolOutputStore.defaultCleanupLayer, - ApplicationTools.layer, - ], -}) {} diff --git a/packages/core/src/location-mutation.ts b/packages/core/src/location-mutation.ts index a620c66e31..a697fbbf82 100644 --- a/packages/core/src/location-mutation.ts +++ b/packages/core/src/location-mutation.ts @@ -1,5 +1,6 @@ export * as LocationMutation from "./location-mutation" +import { makeLocationNode } from "./effect/node" import path from "path" import { Context, Effect, Layer, Schema } from "effect" import { FSUtil } from "./fs-util" @@ -153,3 +154,9 @@ export const layer = Layer.effect( ) export const locationLayer = layer + +export const node = makeLocationNode({ + service: Service, + layer: layer.pipe(Layer.orDie), + deps: [FSUtil.node, Location.node], +}) diff --git a/packages/core/src/location-service-map.ts b/packages/core/src/location-service-map.ts new file mode 100644 index 0000000000..fa261da519 --- /dev/null +++ b/packages/core/src/location-service-map.ts @@ -0,0 +1,18 @@ +import { Context, Effect, Layer, LayerMap } from "effect" +import { LayerNode } from "./effect/layer-node" +import { Node } from "./effect/node" +import { Location } from "./location" +import type { LocationError, LocationServices } from "./location-services" + +export class Service extends Context.Service< + Service, + LayerMap.LayerMap +>()("@opencode/example/LocationServiceMap") { + static get(ref: Location.Ref) { + return Layer.unwrap(Effect.map(Service, (locations) => locations.get(ref))) + } +} + +export const node = LayerNode.unbound(Service, Node.tags.values.global) + +export * as LocationServiceMap from "./location-service-map" diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts new file mode 100644 index 0000000000..ba06622d22 --- /dev/null +++ b/packages/core/src/location-services.ts @@ -0,0 +1,112 @@ +import { Effect, Layer, LayerMap } from "effect" +import { AgentV2 } from "./agent" +import { AISDK } from "./aisdk" +import { Catalog } from "./catalog" +import { CommandV2 } from "./command" +import { Config } from "./config" +import { LayerNode, LayerNodeTree } from "./effect/layer-node" +import { Node } from "./effect/node" +import { FileMutation } from "./file-mutation" +import { FileSystem } from "./filesystem" +import { FileSystemSearch } from "./filesystem/search" +import { Watcher } from "./filesystem/watcher" +import { Image } from "./image" +import { Integration } from "./integration" +import { Location } from "./location" +import { LocationMutation } from "./location-mutation" +import { LocationServiceMap } from "./location-service-map" +import { PermissionV2 } from "./permission" +import { PluginV2 } from "./plugin" +import { PluginInternal } from "./plugin/internal" +import { Policy } from "./policy" +import { ProjectCopy } from "./project/copy" +import { Pty } from "./pty" +import { QuestionV2 } from "./question" +import { Reference } from "./reference" +import { ReferenceGuidance } from "./reference/guidance" +import * as SessionRunnerLLM from "./session/runner/llm" +import { SessionRunnerModel } from "./session/runner/model" +import { SessionTodo } from "./session/todo" +import { SkillV2 } from "./skill" +import { SkillGuidance } from "./skill/guidance" +import { Snapshot } from "./snapshot" +import { SystemContextBuiltIns } from "./system-context/builtins" +import { SystemContextRegistry } from "./system-context/registry" +import { BuiltInTools } from "./tool/builtins" +import { ReadToolFileSystem } from "./tool/read-filesystem" +import { ToolRegistry } from "./tool/registry" +import { ToolOutputStore } from "./tool-output-store" + +export { LocationServiceMap } from "./location-service-map" + +export const locationServices = LayerNode.group([ + Location.node, + Policy.node, + Config.node, + AgentV2.node, + CommandV2.node, + Reference.node, + Integration.node, + Catalog.node, + AISDK.node, + PluginV2.node, + PluginInternal.node, + ProjectCopy.node, + ProjectCopy.refreshNode, + FileSystemSearch.node, + FileSystem.node, + Watcher.node, + Pty.node, + SkillV2.node, + SystemContextRegistry.node, + SystemContextBuiltIns.node, + LocationMutation.node, + FileMutation.node, + PermissionV2.node, + ToolOutputStore.node, + ToolRegistry.node, + ToolRegistry.toolsNode, + Image.node, + SkillGuidance.node, + ReferenceGuidance.node, + SessionTodo.node, + QuestionV2.node, + ReadToolFileSystem.node, + BuiltInTools.node, + SessionRunnerModel.node, + Snapshot.node, + SessionRunnerLLM.node, +]) + +export type LocationServices = LayerNode.Output +export type LocationError = LayerNode.Error + +export function buildLocationServiceMap( + replacements?: ReadonlyMap, +): Layer.Layer { + return Layer.effect( + LocationServiceMap.Service, + LayerMap.make( + (ref: Location.Ref) => { + const location = LayerNodeTree.hoist( + LayerNodeTree.bind(locationServices, Location.node, Location.boundNode(ref)), + Node.tags.values.global, + ) + return LayerNodeTree.compile(location.node, replacements).pipe( + Layer.fresh, + Layer.tap(() => + Effect.logInfo("booting location services", { + directory: ref.directory, + workspaceID: ref.workspaceID, + }), + ), + Layer.provide(LayerNodeTree.compile(location.hoisted, replacements)), + ) + }, + { idleTimeToLive: "60 minutes" }, + ), + ) +} + +// This is temporary for backwards compatibility +export const locationServiceMapLayer = buildLocationServiceMap() diff --git a/packages/core/src/location.ts b/packages/core/src/location.ts index 21f2150c5c..8a7f03ad88 100644 --- a/packages/core/src/location.ts +++ b/packages/core/src/location.ts @@ -1,6 +1,8 @@ import { Context, Effect, Layer } from "effect" import { Info, Ref, response } from "@opencode-ai/schema/location" import { Project } from "./project" +import { LayerNode } from "./effect/layer-node" +import { makeLocationNode, tags } from "./effect/node" export * as Location from "./location" @@ -12,6 +14,8 @@ export interface Interface extends Info { export class Service extends Context.Service()("@opencode/Location") {} +export const node = LayerNode.unbound(Service, tags.values.location) + export const layer = (ref: Ref) => Layer.effect( Service, @@ -26,3 +30,10 @@ export const layer = (ref: Ref) => }) }), ) + +export const boundNode = (ref: Ref) => + makeLocationNode({ + service: Service, + layer: layer(ref), + deps: [Project.node], + }) diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index 6d4dc09a79..e1d6011b5b 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -9,7 +9,7 @@ import { Hash } from "./util/hash" import { FSUtil } from "./fs-util" import { InstallationChannel, InstallationVersion } from "./installation/version" import { EventV2 } from "./event" -import { LayerNode } from "./effect/layer-node" +import { makeGlobalNode } from "./effect/node" import { httpClient } from "./effect/layer-node-platform" export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"]) @@ -244,6 +244,6 @@ export const defaultLayer = layer.pipe( Layer.provide(FSUtil.defaultLayer), Layer.provide(EventV2.defaultLayer), ) -export const node = LayerNode.make({ service: Service, layer: layer, deps: [FSUtil.node, EventV2.node, httpClient] }) +export const node = makeGlobalNode({ service: Service, layer: layer, deps: [FSUtil.node, EventV2.node, httpClient] }) export * as ModelsDev from "./models-dev" diff --git a/packages/core/src/npm.ts b/packages/core/src/npm.ts index 0a13e0f7c3..49472f93d4 100644 --- a/packages/core/src/npm.ts +++ b/packages/core/src/npm.ts @@ -7,7 +7,7 @@ import { NodeFileSystem } from "@effect/platform-node" import { FSUtil } from "./fs-util" import { Global } from "./global" import { EffectFlock } from "./util/effect-flock" -import { LayerNode } from "./effect/layer-node" +import { makeGlobalNode } from "./effect/node" import { filesystem } from "./effect/layer-node-platform" import { makeRuntime } from "./effect/runtime" import { NpmConfig } from "./npm-config" @@ -253,7 +253,7 @@ export const defaultLayer = layer.pipe( Layer.provide(Global.layer), Layer.provide(NodeFileSystem.layer), ) -export const node = LayerNode.make({ +export const node = makeGlobalNode({ service: Service, layer: layer, deps: [FSUtil.node, Global.node, filesystem, EffectFlock.node], diff --git a/packages/core/src/permission.ts b/packages/core/src/permission.ts index 0ee64a07d7..805ecf01bb 100644 --- a/packages/core/src/permission.ts +++ b/packages/core/src/permission.ts @@ -1,5 +1,6 @@ export * as PermissionV2 from "./permission" +import { makeLocationNode } from "./effect/node" import { Context, Deferred, Effect as EffectRuntime, Layer, Schema } from "effect" import { Permission } from "@opencode-ai/schema/permission" import { EventV2 } from "./event" @@ -300,3 +301,9 @@ export const layer = Layer.effect( ) export const locationLayer = layer.pipe(Layer.provideMerge(AgentV2.locationLayer)) + +export const node = makeLocationNode({ + service: Service, + layer, + deps: [EventV2.node, Location.node, AgentV2.node, SessionStore.node, PermissionSaved.node], +}) diff --git a/packages/core/src/permission/saved.ts b/packages/core/src/permission/saved.ts index dd9fa5a750..0814e2225d 100644 --- a/packages/core/src/permission/saved.ts +++ b/packages/core/src/permission/saved.ts @@ -3,6 +3,7 @@ export * as PermissionSaved from "./saved" import { eq } from "drizzle-orm" import { Context, Effect, Layer, Schema } from "effect" import { Database } from "../database/database" +import { makeGlobalNode } from "../effect/node" import { ProjectV2 } from "../project" import { PermissionTable } from "./sql" import { PermissionSaved } from "@opencode-ai/schema/permission-saved" @@ -76,3 +77,5 @@ export const layer = Layer.effect( ) export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer)) + +export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node] }) diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index 95b6357571..f274b8e08f 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -1,5 +1,6 @@ export * as PluginV2 from "./plugin" +import { makeLocationNode } from "./effect/node" import { Context, Deferred, Effect, Exit, Layer, Scope } from "effect" import type { Plugin as PluginRuntime } from "@opencode-ai/plugin/v2/effect" import { Plugin } from "@opencode-ai/schema/plugin" @@ -150,3 +151,18 @@ export const locationLayer = layer.pipe( Layer.provideMerge(Reference.locationLayer), Layer.provideMerge(SkillV2.locationLayer), ) + +export const node = makeLocationNode({ + service: Service, + layer, + deps: [ + EventV2.node, + AgentV2.node, + AISDK.node, + Catalog.node, + CommandV2.node, + Integration.node, + Reference.node, + SkillV2.node, + ], +}) diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index 51c6e279b7..461d2297f1 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -1,5 +1,7 @@ export * as PluginInternal from "./internal" +import { makeLocationNode } from "../effect/node" +import { httpClient } from "../effect/layer-node-platform" import type { PluginContext } from "@opencode-ai/plugin/v2/effect" import { Effect, Layer, Scope } from "effect" import { AgentV2 } from "../agent" @@ -57,7 +59,7 @@ export function define(plugin: Plugin) { return plugin } -export const locationLayer = Layer.effectDiscard( +const layer = Layer.effectDiscard( Effect.gen(function* () { const catalog = yield* Catalog.Service const commands = yield* CommandV2.Service @@ -117,9 +119,34 @@ export const locationLayer = Layer.effectDiscard( yield* add(VariantPlugin.Plugin) }).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true })) }), -).pipe( +) + +export const locationLayer = layer.pipe( Layer.provideMerge(PluginV2.locationLayer), Layer.provideMerge(Config.locationLayer), Layer.provideMerge(FileSystem.locationLayer), Layer.provideMerge(FetchHttpClient.layer), ) + +export const node = makeLocationNode({ + name: "plugin-internal", + layer, + deps: [ + Catalog.node, + CommandV2.node, + PluginV2.node, + Integration.node, + AgentV2.node, + Config.node, + Location.node, + ModelsDev.node, + Npm.node, + EventV2.node, + FSUtil.node, + FileSystem.node, + Global.node, + httpClient, + SkillV2.node, + Reference.node, + ], +}) diff --git a/packages/core/src/policy.ts b/packages/core/src/policy.ts index 9b7438f4ff..3b27027f76 100644 --- a/packages/core/src/policy.ts +++ b/packages/core/src/policy.ts @@ -1,5 +1,6 @@ export * as Policy from "./policy" +import { makeLocationNode } from "./effect/node" import { Context, Effect as EffectRuntime, Layer, Schema } from "effect" import { Wildcard } from "./util/wildcard" import { Location } from "./location" @@ -44,3 +45,5 @@ export const layer = Layer.effect( ) export const locationLayer = layer + +export const node = makeLocationNode({ service: Service, layer, deps: [Location.node] }) diff --git a/packages/core/src/process.ts b/packages/core/src/process.ts index f8d4ef0331..ef7b6f78ab 100644 --- a/packages/core/src/process.ts +++ b/packages/core/src/process.ts @@ -3,7 +3,7 @@ import type { PlatformError } from "effect/PlatformError" import { ChildProcess } from "effect/unstable/process" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" import { CrossSpawnSpawner } from "./cross-spawn-spawner" -import { LayerNode } from "./effect/layer-node" +import { makeGlobalNode } from "./effect/node" export class AppProcessError extends Schema.TaggedErrorClass()("AppProcessError", { command: Schema.String, @@ -238,6 +238,6 @@ export const layer = Layer.effect( ) export const defaultLayer = layer.pipe(Layer.provide(CrossSpawnSpawner.defaultLayer)) -export const node = LayerNode.make({ service: Service, layer: layer, deps: [CrossSpawnSpawner.node] }) +export const node = makeGlobalNode({ service: Service, layer: layer, deps: [CrossSpawnSpawner.node] }) export * as AppProcess from "./process" diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts index c504c309be..4c4f012ad3 100644 --- a/packages/core/src/project.ts +++ b/packages/core/src/project.ts @@ -6,7 +6,7 @@ import path from "path" import { AbsolutePath } from "./schema" import { FSUtil } from "./fs-util" import { Git } from "./git" -import { LayerNode } from "./effect/layer-node" +import { makeGlobalNode } from "./effect/node" import { Hash } from "./util/hash" import { ProjectDirectories } from "./project/directories" import { ProjectSchema } from "./project/schema" @@ -134,7 +134,7 @@ export const defaultLayer = layer.pipe( Layer.provide(Git.defaultLayer), Layer.provideMerge(ProjectDirectories.defaultLayer), ) -export const node = LayerNode.make({ +export const node = makeGlobalNode({ service: Service, layer: layer, deps: [FSUtil.node, Git.node, ProjectDirectories.node], diff --git a/packages/core/src/project/copy.ts b/packages/core/src/project/copy.ts index d144b15ece..433f667a72 100644 --- a/packages/core/src/project/copy.ts +++ b/packages/core/src/project/copy.ts @@ -5,7 +5,7 @@ import path from "path" import { AbsolutePath } from "../schema" import { FSUtil } from "../fs-util" import { Git } from "../git" -import { LayerNode } from "../effect/layer-node" +import { makeLocationNode } from "../effect/node" import { Project } from "../project" import { ProjectDirectories } from "./directories" import { makeGitWorktreeStrategy } from "./copy-strategies" @@ -279,8 +279,14 @@ export const layer = Layer.effect( ) export const locationLayer = layer -export const node = LayerNode.make({ +export const node = makeLocationNode({ service: Service, layer: layer, deps: [FSUtil.node, Git.node, ProjectDirectories.node, EventV2.node, Database.node], }) + +export const refreshNode = makeLocationNode({ + name: "project-copy-refresh", + layer: Layer.effectDiscard(refreshAfterBoot), + deps: [node, Location.node], +}) diff --git a/packages/core/src/project/directories.ts b/packages/core/src/project/directories.ts index 54c31628bf..525fea413b 100644 --- a/packages/core/src/project/directories.ts +++ b/packages/core/src/project/directories.ts @@ -3,7 +3,7 @@ export * as ProjectDirectories from "./directories" import { and, asc, desc, eq, isNotNull, isNull, ne, or } from "drizzle-orm" import { Context, Effect, Layer, Schema } from "effect" import { Database } from "../database/database" -import { LayerNode } from "../effect/layer-node" +import { makeGlobalNode } from "../effect/node" import { AbsolutePath, optional } from "../schema" import { ProjectSchema } from "./schema" import { ProjectDirectoryTable } from "./sql" @@ -156,4 +156,4 @@ export const layer = Layer.effect( ) export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer)) -export const node = LayerNode.make({ service: Service, layer: layer, deps: [Database.node] }) +export const node = makeGlobalNode({ service: Service, layer: layer, deps: [Database.node] }) diff --git a/packages/core/src/pty.ts b/packages/core/src/pty.ts index 9c5d2455a6..12cad6aa9c 100644 --- a/packages/core/src/pty.ts +++ b/packages/core/src/pty.ts @@ -1,5 +1,6 @@ export * as Pty from "./pty" +import { makeLocationNode } from "./effect/node" import type { Disp, Proc } from "#pty" import { Context, Effect, Layer, Schema, Types } from "effect" import { Pty } from "@opencode-ai/schema/pty" @@ -313,3 +314,5 @@ export const layer = Layer.effect( ) export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer)) + +export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Location.node, Config.node] }) diff --git a/packages/core/src/pty/ticket.ts b/packages/core/src/pty/ticket.ts index aee1debc78..da2a76a77c 100644 --- a/packages/core/src/pty/ticket.ts +++ b/packages/core/src/pty/ticket.ts @@ -4,7 +4,7 @@ import { WorkspaceV2 } from "../workspace" import { PtyTicket } from "@opencode-ai/schema/pty-ticket" import { PtyID } from "./schema" import { Cache, Context, Duration, Effect, Layer } from "effect" -import { LayerNode } from "../effect/layer-node" +import { makeGlobalNode } from "../effect/node" const DEFAULT_TTL = Duration.seconds(60) const CAPACITY = 10_000 @@ -54,4 +54,4 @@ export const make = (ttl: Duration.Input = DEFAULT_TTL) => export const layer = Layer.effect(Service, make()) export const defaultLayer = layer -export const node = LayerNode.make({ service: Service, layer: layer, deps: [] }) +export const node = makeGlobalNode({ service: Service, layer: layer, deps: [] }) diff --git a/packages/core/src/question.ts b/packages/core/src/question.ts index 2f4942af1b..5d1bff9c81 100644 --- a/packages/core/src/question.ts +++ b/packages/core/src/question.ts @@ -1,5 +1,6 @@ export * as QuestionV2 from "./question" +import { makeLocationNode } from "./effect/node" import { Context, Deferred, Effect, Layer, Schema } from "effect" import { Question } from "@opencode-ai/schema/question" import { EventV2 } from "./event" @@ -148,3 +149,5 @@ export const layer = Layer.effect( ) export const locationLayer = layer + +export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node] }) diff --git a/packages/core/src/reference.ts b/packages/core/src/reference.ts index 36065ff920..145254137d 100644 --- a/packages/core/src/reference.ts +++ b/packages/core/src/reference.ts @@ -1,5 +1,6 @@ export * as Reference from "./reference" +import { makeLocationNode } from "./effect/node" import { Context, Effect, Layer, Scope, Types } from "effect" import { Reference } from "@opencode-ai/schema/reference" import { Global } from "./global" @@ -120,3 +121,9 @@ export const layer = Layer.effect( ) export const locationLayer = layer + +export const node = makeLocationNode({ + service: Service, + layer, + deps: [Global.node, EventV2.node, RepositoryCache.node], +}) diff --git a/packages/core/src/reference/guidance.ts b/packages/core/src/reference/guidance.ts index fa3423c58a..528c8c790e 100644 --- a/packages/core/src/reference/guidance.ts +++ b/packages/core/src/reference/guidance.ts @@ -1,5 +1,6 @@ export * as ReferenceGuidance from "./guidance" +import { makeLocationNode } from "../effect/node" import { Context, Effect, Layer, Schema } from "effect" import { Reference } from "../reference" import { SystemContext } from "../system-context/index" @@ -64,3 +65,5 @@ export const layer = Layer.effect( ) export const locationLayer = layer + +export const node = makeLocationNode({ service: Service, layer, deps: [Reference.node] }) diff --git a/packages/core/src/repository-cache.ts b/packages/core/src/repository-cache.ts index 410b82fea5..d185dbd12f 100644 --- a/packages/core/src/repository-cache.ts +++ b/packages/core/src/repository-cache.ts @@ -5,6 +5,7 @@ import { Git } from "./git" import { Global } from "./global" import { Repository } from "./repository" import { AbsolutePath } from "./schema" +import { makeGlobalNode } from "./effect/node" import { EffectFlock } from "./util/effect-flock" export type Result = { @@ -229,6 +230,12 @@ export const defaultLayer: Layer.Layer = layer.pipe( Layer.provide(Global.defaultLayer), ) +export const node = makeGlobalNode({ + service: Service, + layer, + deps: [EffectFlock.node, FSUtil.node, Git.node, Global.node], +}) + function statusForRepository(input: { reuse: boolean; refresh?: boolean; branchMatches?: boolean }) { if (!input.reuse) return "cloned" as const if (input.branchMatches === false || input.refresh) return "refreshed" as const diff --git a/packages/core/src/ripgrep.ts b/packages/core/src/ripgrep.ts index c240c6763f..61a4950f60 100644 --- a/packages/core/src/ripgrep.ts +++ b/packages/core/src/ripgrep.ts @@ -3,7 +3,7 @@ export * as Ripgrep from "./ripgrep" import { Context, Effect, Fiber, Layer, Schema, Stream } from "effect" import { ChildProcess } from "effect/unstable/process" import { Entry, Match } from "@opencode-ai/schema/filesystem" -import { LayerNode } from "./effect/layer-node" +import { makeGlobalNode } from "./effect/node" import { AppProcess, collectStream, waitForAbort } from "./process" import { NonNegativeInt, PositiveInt, RelativePath } from "./schema" import { RipgrepBinary } from "./ripgrep/binary" @@ -279,4 +279,4 @@ export const layer = Layer.effect( ) export const defaultLayer = layer.pipe(Layer.provide(Layer.merge(RipgrepBinary.defaultLayer, AppProcess.defaultLayer))) -export const node = LayerNode.make({ service: Service, layer: layer, deps: [RipgrepBinary.node, AppProcess.node] }) +export const node = makeGlobalNode({ service: Service, layer: layer, deps: [RipgrepBinary.node, AppProcess.node] }) diff --git a/packages/core/src/ripgrep/binary.ts b/packages/core/src/ripgrep/binary.ts index 18cd6a62c2..805d889571 100644 --- a/packages/core/src/ripgrep/binary.ts +++ b/packages/core/src/ripgrep/binary.ts @@ -4,7 +4,7 @@ import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/ import { ChildProcess } from "effect/unstable/process" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" import { CrossSpawnSpawner } from "../cross-spawn-spawner" -import { LayerNode } from "../effect/layer-node" +import { makeGlobalNode } from "../effect/node" import { httpClient } from "../effect/layer-node-platform" import { FSUtil } from "../fs-util" import { Global } from "../global" @@ -130,7 +130,7 @@ export namespace RipgrepBinary { Layer.provide(CrossSpawnSpawner.defaultLayer), ) - export const node = LayerNode.make({ + export const node = makeGlobalNode({ service: Service, layer: layer, deps: [FSUtil.node, httpClient, CrossSpawnSpawner.node], diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 8ccb9fa293..35f9a265af 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -27,6 +27,8 @@ import { fromRow } from "./session/info" import { SessionRunner } from "./session/runner/index" import { SessionStore } from "./session/store" import { SessionExecution } from "./session/execution" +import { makeGlobalNode } from "./effect/node" +import { LocationServiceMap } from "./location-service-map" import { MessageDecodeError } from "./session/error" import { SessionEvent } from "./session/event" import { SessionInput } from "./session/input" @@ -179,19 +181,16 @@ export interface Interface { export class Service extends Context.Service()("@opencode/v2/Session") {} -export const layer = Layer.unwrap( - Effect.promise(() => import("./location-layer")).pipe( - Effect.map(({ LocationServiceMap }) => - Layer.effect( - Service, - Effect.gen(function* () { +export const layer = Layer.effect( + Service, + Effect.gen(function* () { const database = yield* Database.Service const db = database.db const events = yield* EventV2.Service const projects = yield* ProjectV2.Service const execution = yield* SessionExecution.Service const store = yield* SessionStore.Service - const locations = yield* LocationServiceMap + const locations = yield* LocationServiceMap.Service const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message) const isDurableSessionEvent = Schema.is(SessionEvent.Durable) const decode = (row: typeof SessionMessageTable.$inferSelect) => @@ -451,17 +450,11 @@ export const layer = Layer.unwrap( }, }) - return result - }), - ), - ), - ), + return result + }), ) export const defaultLayer = layer.pipe( - Layer.provide( - Layer.unwrap(Effect.promise(() => import("./location-layer")).pipe(Effect.map((m) => m.LocationServiceMap.layer))), - ), Layer.provide(SessionStore.defaultLayer), Layer.provide(SessionProjector.defaultLayer), Layer.provide(EventV2.defaultLayer), @@ -483,3 +476,17 @@ const resolvePrompt = (input: PromptInput.Prompt) => } }), }) + +export const node = makeGlobalNode({ + service: Service, + layer: layer.pipe(Layer.orDie), + deps: [ + Database.node, + EventV2.node, + ProjectV2.node, + SessionExecution.node, + SessionStore.node, + LocationServiceMap.node, + SessionProjector.node, + ], +}) diff --git a/packages/core/src/session/execution.ts b/packages/core/src/session/execution.ts index f92929e6ee..a2b4db4dfd 100644 --- a/packages/core/src/session/execution.ts +++ b/packages/core/src/session/execution.ts @@ -1,6 +1,8 @@ export * as SessionExecution from "./execution" import { Context, Effect, Layer } from "effect" +import { LayerNode } from "../effect/layer-node" +import { Node } from "../effect/node" import { SessionRunner } from "./runner/index" import { SessionSchema } from "./schema" @@ -18,6 +20,8 @@ export interface Interface { /** Routes execution from a Session ID to the runner owned by that Session's Location. */ export class Service extends Context.Service()("@opencode/v2/SessionExecution") {} +export const node = LayerNode.unbound(Service, Node.tags.values.global) + /** Low-level compatibility layer for callers that only need durable Session recording. */ export const noopLayer = Layer.succeed( Service, diff --git a/packages/core/src/session/execution/local.ts b/packages/core/src/session/execution/local.ts index bd1be74539..fc157eeea5 100644 --- a/packages/core/src/session/execution/local.ts +++ b/packages/core/src/session/execution/local.ts @@ -1,5 +1,6 @@ import { Cause, Effect, Layer } from "effect" -import { LocationServiceMap } from "../../location-layer" +import { LocationServiceMap } from "../../location-service-map" +import { makeGlobalNode } from "../../effect/node" import { SessionRunCoordinator } from "../run-coordinator" import { SessionRunner } from "../runner" import { SessionSchema } from "../schema" @@ -11,7 +12,7 @@ export const layer = Layer.effect( SessionExecution.Service, Effect.gen(function* () { const store = yield* SessionStore.Service - const locations = yield* LocationServiceMap + const locations = yield* LocationServiceMap.Service const coordinator = yield* SessionRunCoordinator.make({ drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, force) { const session = yield* store.get(sessionID) @@ -37,3 +38,11 @@ export const layer = Layer.effect( ) export const defaultLayer = layer.pipe(Layer.provide(SessionStore.defaultLayer)) + +export const node = makeGlobalNode({ + service: SessionExecution.Service, + layer, + deps: [SessionStore.node, LocationServiceMap.node], +}) + +export * as SessionExecutionLocal from "./local" diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 4d8b9a05b4..ac79d598c7 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -4,7 +4,7 @@ import { and, desc, eq, gt, or, sql } from "drizzle-orm" import { DateTime, Effect, Layer, Schema } from "effect" import { Database } from "../database/database" import { EventV2 } from "../event" -import { LayerNode } from "../effect/layer-node" +import { makeGlobalNode } from "../effect/node" import { SessionEvent } from "./event" import { SessionV1 } from "../v1/session" import { WorkspaceTable } from "../control-plane/workspace.sql" @@ -456,4 +456,4 @@ export const layer = Layer.effectDiscard( ) export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer)) -export const node = LayerNode.make({ name: "session-projector", layer, deps: [EventV2.node, Database.node] }) +export const node = makeGlobalNode({ name: "session-projector", layer, deps: [EventV2.node, Database.node] }) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 9e29ae71ee..3e22bfe3c6 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -36,6 +36,8 @@ import { createLLMEventPublisher } from "./publish-llm-event" import { toLLMMessages } from "./to-llm-message" import { MAX_STEPS_PROMPT } from "./max-steps" import { Snapshot } from "../../snapshot" +import { makeLocationNode } from "../../effect/node" +import { llmClient } from "../../effect/layer-node-platform" /** * Runs one durable coding-agent Session until it settles. @@ -404,3 +406,23 @@ export const layer = Layer.effect( ) export const defaultLayer = layer + +export const node = makeLocationNode({ + service: Service, + layer, + deps: [ + EventV2.node, + llmClient, + AgentV2.node, + ToolRegistry.node, + SessionRunnerModel.node, + SessionStore.node, + Location.node, + SystemContextRegistry.node, + SkillGuidance.node, + ReferenceGuidance.node, + Config.node, + Snapshot.node, + Database.node, + ], +}) diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index b864df8ba3..ffe6deea01 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -1,5 +1,6 @@ export * as SessionRunnerModel from "./model" +import { makeLocationNode } from "../../effect/node" import { type Model } from "@opencode-ai/llm" import * as AnthropicMessages from "@opencode-ai/llm/protocols/anthropic-messages" import * as OpenAICompatibleChat from "@opencode-ai/llm/protocols/openai-compatible-chat" @@ -213,3 +214,5 @@ export const locationLayer = Layer.effect( }) }), ) + +export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [Catalog.node, Integration.node] }) diff --git a/packages/core/src/session/store.ts b/packages/core/src/session/store.ts index 1cff231306..cf4b68ad05 100644 --- a/packages/core/src/session/store.ts +++ b/packages/core/src/session/store.ts @@ -3,6 +3,7 @@ export * as SessionStore from "./store" import { eq } from "drizzle-orm" import { Context, Effect, Layer, Schema } from "effect" import { Database } from "../database/database" +import { makeGlobalNode } from "../effect/node" import { SessionHistory } from "./history" import { MessageDecodeError } from "./error" import { SessionMessage } from "./message" @@ -60,3 +61,5 @@ export const layer = Layer.effect( ) export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer)) + +export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node] }) diff --git a/packages/core/src/session/todo.ts b/packages/core/src/session/todo.ts index c5ad70afad..fe76807d23 100644 --- a/packages/core/src/session/todo.ts +++ b/packages/core/src/session/todo.ts @@ -4,6 +4,7 @@ import { asc, eq } from "drizzle-orm" import { Context, Effect, Layer } from "effect" import { SessionTodo } from "@opencode-ai/schema/session-todo" import { Database } from "../database/database" +import { makeLocationNode } from "../effect/node" import { EventV2 } from "../event" import { SessionSchema } from "./schema" import { TodoTable } from "./sql" @@ -75,3 +76,5 @@ export const layer = Layer.effect( ) export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer)) + +export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Database.node] }) diff --git a/packages/core/src/skill.ts b/packages/core/src/skill.ts index 6c1d37f305..d6790396f3 100644 --- a/packages/core/src/skill.ts +++ b/packages/core/src/skill.ts @@ -1,5 +1,6 @@ export * as SkillV2 from "./skill" +import { makeLocationNode } from "./effect/node" import path from "path" import { Context, Effect, Layer, Schema, Types } from "effect" import { Skill } from "@opencode-ai/schema/skill" @@ -129,3 +130,5 @@ export const layer = Layer.effect( ) export const locationLayer = layer.pipe(Layer.provide(SkillDiscovery.defaultLayer)) + +export const node = makeLocationNode({ service: Service, layer, deps: [SkillDiscovery.node, FSUtil.node] }) diff --git a/packages/core/src/skill/discovery.ts b/packages/core/src/skill/discovery.ts index 8c885eef1f..1d3758cf8d 100644 --- a/packages/core/src/skill/discovery.ts +++ b/packages/core/src/skill/discovery.ts @@ -5,6 +5,8 @@ import { Context, Effect, Layer, Schedule, Schema } from "effect" import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { FSUtil } from "../fs-util" import { Global } from "../global" +import { makeGlobalNode } from "../effect/node" +import { httpClient } from "../effect/layer-node-platform" import { AbsolutePath } from "../schema" const skillConcurrency = 4 @@ -213,3 +215,5 @@ export const defaultLayer = layer.pipe( Layer.provide(FSUtil.defaultLayer), Layer.provide(Global.defaultLayer), ) + +export const node = makeGlobalNode({ service: Service, layer, deps: [httpClient, FSUtil.node, Global.node] }) diff --git a/packages/core/src/skill/guidance.ts b/packages/core/src/skill/guidance.ts index 81a33f0164..1c73b7ddb8 100644 --- a/packages/core/src/skill/guidance.ts +++ b/packages/core/src/skill/guidance.ts @@ -1,5 +1,6 @@ export * as SkillGuidance from "./guidance" +import { makeLocationNode } from "../effect/node" import { Context, Effect, Layer, Schema } from "effect" import { AgentV2 } from "../agent" import { PermissionV2 } from "../permission" @@ -71,3 +72,5 @@ export const layer = Layer.effect( ) export const locationLayer = layer + +export const node = makeLocationNode({ service: Service, layer, deps: [SkillV2.node] }) diff --git a/packages/core/src/snapshot.ts b/packages/core/src/snapshot.ts index 631bca2a23..bf0d3e60d8 100644 --- a/packages/core/src/snapshot.ts +++ b/packages/core/src/snapshot.ts @@ -1,5 +1,6 @@ export * as Snapshot from "./snapshot" +import { makeLocationNode } from "./effect/node" import path from "path" import { Context, Effect, Layer, Schema } from "effect" import { Config } from "./config" @@ -228,6 +229,12 @@ export const layer = Layer.effect( export const locationLayer = layer.pipe(Layer.provideMerge(Config.locationLayer)) +export const node = makeLocationNode({ + service: Service, + layer, + deps: [Config.node, FSUtil.node, Git.node, Global.node, Location.node], +}) + export const noopLayer = Layer.succeed( Service, Service.of({ diff --git a/packages/core/src/system-context/builtins.ts b/packages/core/src/system-context/builtins.ts index 42cba27b92..23f9777055 100644 --- a/packages/core/src/system-context/builtins.ts +++ b/packages/core/src/system-context/builtins.ts @@ -1,10 +1,13 @@ export * as SystemContextBuiltIns from "./builtins" +import { makeLocationNode } from "../effect/node" import { DateTime, Effect, Layer, Schema } from "effect" import { Location } from "../location" import { SystemContext } from "./index" import { InstructionContext } from "../instruction-context" import { SystemContextRegistry } from "./registry" +import { FSUtil } from "../fs-util" +import { Global } from "../global" const builtIns = Layer.effectDiscard( Effect.gen(function* () { @@ -45,3 +48,9 @@ export const layer = Layer.mergeAll(builtIns, InstructionContext.layer).pipe( ) export const locationLayer = layer + +export const node = makeLocationNode({ + name: "system-context-builtins", + layer, + deps: [Location.node, SystemContextRegistry.node, InstructionContext.node, FSUtil.node, Global.node], +}) diff --git a/packages/core/src/system-context/registry.ts b/packages/core/src/system-context/registry.ts index a2a7ca7e62..87ed1573bb 100644 --- a/packages/core/src/system-context/registry.ts +++ b/packages/core/src/system-context/registry.ts @@ -2,6 +2,7 @@ export * as SystemContextRegistry from "./registry" import { Context, Effect, Layer, Ref, Scope } from "effect" import { SystemContext } from "./index" +import { makeLocationNode } from "../effect/node" export interface Entry { readonly key: SystemContext.Key @@ -44,3 +45,5 @@ export const layer = Layer.effect( }) }), ) + +export const node = makeLocationNode({ service: Service, layer, deps: [] }) diff --git a/packages/core/src/tool-output-store.ts b/packages/core/src/tool-output-store.ts index 134ad68470..e76979e1e5 100644 --- a/packages/core/src/tool-output-store.ts +++ b/packages/core/src/tool-output-store.ts @@ -5,6 +5,7 @@ import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effe import { Config } from "./config" import { FSUtil } from "./fs-util" import { Global } from "./global" +import { makeGlobalNode, makeLocationNode } from "./effect/node" import { SessionSchema } from "./session/schema" import { Identifier } from "./util/identifier" import type { ToolOutput } from "@opencode-ai/llm" @@ -193,6 +194,8 @@ export const layer = Layer.effect( export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(Global.defaultLayer)) +export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Global.node, Config.node] }) + /** Runs retention scanning once globally rather than once per active Location. */ export const cleanupLayer = Layer.effectDiscard( Effect.gen(function* () { @@ -202,3 +205,5 @@ export const cleanupLayer = Layer.effectDiscard( ) export const defaultCleanupLayer = Layer.merge(defaultLayer, cleanupLayer.pipe(Layer.provide(defaultLayer))) + +export const cleanupNode = makeGlobalNode({ name: "tool-output-cleanup", layer: defaultCleanupLayer, deps: [] }) diff --git a/packages/core/src/tool/application-tools.ts b/packages/core/src/tool/application-tools.ts index 5309e4b4ba..c93dd3ac02 100644 --- a/packages/core/src/tool/application-tools.ts +++ b/packages/core/src/tool/application-tools.ts @@ -3,6 +3,7 @@ export * as ApplicationTools from "./application-tools" import { Context, Effect, Layer, Scope } from "effect" import { State } from "../state" import { Tool } from "./tool" +import { makeGlobalNode } from "../effect/node" type Data = { readonly entries: Map @@ -52,3 +53,5 @@ export const layer = Layer.effect( }) }), ) + +export const node = makeGlobalNode({ service: Service, layer, deps: [] }) diff --git a/packages/core/src/tool/builtins.ts b/packages/core/src/tool/builtins.ts index 62926202dc..7484c40f3f 100644 --- a/packages/core/src/tool/builtins.ts +++ b/packages/core/src/tool/builtins.ts @@ -1,5 +1,6 @@ export * as BuiltInTools from "./builtins" +import { makeLocationNode } from "../effect/node" import { Layer } from "effect" import { BashTool } from "./bash" import { ApplyPatchTool } from "./apply-patch" @@ -14,6 +15,20 @@ import { TodoWriteTool } from "./todowrite" import { WebFetchTool } from "./webfetch" import { WebSearchTool } from "./websearch" import { WriteTool } from "./write" +import { FSUtil } from "../fs-util" +import { AppProcess } from "../process" +import { Config } from "../config" +import { Location } from "../location" +import { LocationMutation } from "../location-mutation" +import { FileMutation } from "../file-mutation" +import { PermissionV2 } from "../permission" +import { Ripgrep } from "../ripgrep" +import { Image } from "../image" +import { QuestionV2 } from "../question" +import { SkillV2 } from "../skill" +import { SessionTodo } from "../session/todo" +import { ToolRegistry } from "./registry" +import { httpClient } from "../effect/layer-node-platform" /** * Composes only the shipped Location-scoped built-in tool transforms. @@ -42,3 +57,25 @@ export const locationLayer = Layer.mergeAll( WebSearchTool.layer.pipe(Layer.provide(WebSearchTool.defaultConfigLayer)), WriteTool.layer, ) + +export const node = makeLocationNode({ + name: "built-in-tools", + layer: locationLayer, + deps: [ + ToolRegistry.toolsNode, + FSUtil.node, + AppProcess.node, + Config.node, + Location.node, + LocationMutation.node, + FileMutation.node, + PermissionV2.node, + Ripgrep.node, + Image.node, + QuestionV2.node, + SkillV2.node, + SessionTodo.node, + ReadToolFileSystem.node, + httpClient, + ], +}) diff --git a/packages/core/src/tool/read-filesystem.ts b/packages/core/src/tool/read-filesystem.ts index 6f54f796f3..79d7de3a3d 100644 --- a/packages/core/src/tool/read-filesystem.ts +++ b/packages/core/src/tool/read-filesystem.ts @@ -5,6 +5,7 @@ import { pathToFileURL } from "url" import { Context, Effect, Layer, Option, Schema } from "effect" import { FileSystem } from "../filesystem" import { FSUtil } from "../fs-util" +import { makeLocationNode } from "../effect/node" import { AbsolutePath, PositiveInt, RelativePath } from "../schema" export const MAX_READ_LINES = 2_000 @@ -361,3 +362,5 @@ export const layer = Layer.effect( }) }), ) + +export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node] }) diff --git a/packages/core/src/tool/registry.ts b/packages/core/src/tool/registry.ts index d99cc9014c..6f423e295e 100644 --- a/packages/core/src/tool/registry.ts +++ b/packages/core/src/tool/registry.ts @@ -11,6 +11,7 @@ import { Wildcard } from "../util/wildcard" import { ApplicationTools } from "./application-tools" import { definition, permission, settle, validateName, type AnyTool, type RegistrationError } from "./tool" import { Tools } from "./tools" +import { makeLocationNode } from "../effect/node" export type ExecuteInput = { readonly sessionID: SessionSchema.ID @@ -137,3 +138,15 @@ export const defaultLayer = layer.pipe( Layer.provide(ApplicationTools.layer), Layer.provide(ToolOutputStore.defaultLayer), ) + +export const node = makeLocationNode({ + service: Service, + layer, + deps: [ApplicationTools.node, ToolOutputStore.node], +}) + +export const toolsNode = makeLocationNode({ + service: Tools.Service, + layer, + deps: [ApplicationTools.node, ToolOutputStore.node], +}) diff --git a/packages/core/src/util/effect-flock.ts b/packages/core/src/util/effect-flock.ts index 5d25553893..ded170fc6f 100644 --- a/packages/core/src/util/effect-flock.ts +++ b/packages/core/src/util/effect-flock.ts @@ -6,7 +6,7 @@ import type { FileSystem, Scope } from "effect" import type { PlatformError } from "effect/PlatformError" import { FSUtil } from "../fs-util" import { Global } from "../global" -import { LayerNode } from "../effect/layer-node" +import { makeGlobalNode } from "../effect/node" import { Hash } from "./hash" export namespace EffectFlock { @@ -281,5 +281,5 @@ export namespace EffectFlock { ) export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(Global.layer)) - export const node = LayerNode.make({ service: Service, layer: layer, deps: [Global.node, FSUtil.node] }) + export const node = makeGlobalNode({ service: Service, layer: layer, deps: [Global.node, FSUtil.node] }) } diff --git a/packages/core/test/effect/layer-node/layer-node-types.test.ts b/packages/core/test/effect/layer-node/layer-node-types.test.ts new file mode 100644 index 0000000000..a4dce38869 --- /dev/null +++ b/packages/core/test/effect/layer-node/layer-node-types.test.ts @@ -0,0 +1,138 @@ +import { test } from "bun:test" +import { Context, Effect, Layer } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { LayerNodeTree } from "@opencode-ai/core/effect/layer-node" +import { makeGlobalNode, makeLocationNode } from "@opencode-ai/core/effect/node" + +class A extends Context.Service()("test/LayerNodeA") {} +class B extends Context.Service()("test/LayerNodeB") {} +class C extends Context.Service()("test/LayerNodeC") {} +class LayerError { + readonly _tag = "LayerError" +} +class OtherError { + readonly _tag = "OtherError" +} + +const tags = LayerNode.tags({ app: [] }) +const make = tags.make("app") +const build = (root: LayerNode.Node) => + LayerNodeTree.compile(root) as Layer.Layer +const aLayer = Layer.succeed(A, A.of({})) +const bLayer = Layer.effect(B, Effect.as(A, B.of({}))) +const cLayer = Layer.effect( + C, + Effect.gen(function* () { + yield* A + yield* B + return C.of({}) + }), +) +const failingA = Layer.effect(A, Effect.fail(new LayerError())) +const a = make({ service: A, layer: aLayer, deps: [] }) +const b = make({ service: B, layer: bLayer, deps: [a] }) +const c = make({ service: C, layer: cLayer, deps: [a, b] }) +const failing = make({ service: A, layer: failingA, deps: [] }) +const dependent = make({ service: B, layer: bLayer, deps: [failing] }) +const inputA = LayerNode.unbound(A, tags.values.app) +const inputDependent = make({ service: B, layer: bLayer, deps: [inputA] }) + +make({ name: "manual-a", layer: aLayer, deps: [] }) + +// @ts-expect-error A node must have a service or name +make({ layer: aLayer, deps: [] }) + +// @ts-expect-error Service and name are mutually exclusive +make({ service: A, name: "a", layer: aLayer, deps: [] }) + +// @ts-expect-error B requires A +make({ service: B, layer: bLayer, deps: [] }) + +// @ts-expect-error C requires A and B +make({ service: C, layer: cLayer, deps: [a] }) + +const closed = build(LayerNode.group([c])) +const closedWithError = build(LayerNode.group([dependent])) +const checkClosed: Layer.Layer = closed +const checkError: Layer.Layer = closedWithError +void checkClosed +void checkError + +LayerNode.replace(aLayer, Layer.succeed(A, A.of({}))) + +// @ts-expect-error Replacement must provide A +LayerNode.replace(aLayer, Layer.succeed(B, B.of({}))) + +// @ts-expect-error Replacement cannot introduce a new error +LayerNode.replace(aLayer, Layer.effect(A, Effect.fail(new OtherError()))) + +// @ts-expect-error Replacement must be closed +LayerNode.replace(bLayer, bLayer) + +class TagA extends Context.Service()("test/TagA") {} +class TagB extends Context.Service()("test/TagB") {} +class TagC extends Context.Service()("test/TagC") {} + +const scopedTags = LayerNode.tags({ request: ["global"], global: [] }) +const request = scopedTags.make("request") +const global = scopedTags.make("global") +const globalA = global({ service: TagA, layer: Layer.succeed(TagA, TagA.of({})), deps: [] }) +const requestA = request({ service: TagA, layer: Layer.succeed(TagA, TagA.of({})), deps: [] }) +const requestB = request({ service: TagB, layer: Layer.succeed(TagB, TagB.of({})), deps: [] }) +const tagBLayer = Layer.effect(TagB, Effect.as(TagA, TagB.of({}))) +const tagCLayer = Layer.effect( + TagC, + Effect.gen(function* () { + yield* TagA + yield* TagB + return TagC.of({}) + }), +) + +request({ service: TagB, layer: tagBLayer, deps: [globalA] }) +request({ service: TagC, layer: tagCLayer, deps: [globalA, requestB] }) +request({ service: TagC, layer: tagCLayer, deps: [LayerNode.group([globalA, requestB])] }) + +// @ts-expect-error Tag configuration can only reference declared tags +LayerNode.tags({ request: ["missing"], global: [] }) + +// @ts-expect-error An unrelated dependency cannot satisfy TagA +request({ service: TagB, layer: tagBLayer, deps: [requestB] }) + +// @ts-expect-error Providing only TagA leaves TagB missing +request({ service: TagC, layer: tagCLayer, deps: [globalA] }) + +// @ts-expect-error Providing only TagB leaves TagA missing +request({ service: TagC, layer: tagCLayer, deps: [requestB] }) + +// @ts-expect-error Duplicate TagA providers still leave TagB missing +request({ service: TagC, layer: tagCLayer, deps: [globalA, requestA] }) + +// @ts-expect-error A group with only TagA still leaves TagB missing +request({ service: TagC, layer: tagCLayer, deps: [LayerNode.group([globalA])] }) + +// @ts-expect-error Global cannot depend on request +global({ service: TagB, layer: tagBLayer, deps: [requestA] }) + +// @ts-expect-error Groups preserve their child tags +global({ service: TagB, layer: tagBLayer, deps: [LayerNode.group([requestA])] }) + +class ScopedA extends Context.Service()("test/ScopedA") {} +class ScopedB extends Context.Service()("test/ScopedB") {} + +const scopedA = Layer.succeed(ScopedA, ScopedA.of({})) +const scopedB = Layer.effect(ScopedB, Effect.as(ScopedA, ScopedB.of({}))) +const globalScopedA = makeGlobalNode({ service: ScopedA, layer: scopedA, deps: [] }) +const locationScopedA = makeLocationNode({ service: ScopedA, layer: scopedA, deps: [] }) + +makeGlobalNode({ service: ScopedB, layer: scopedB, deps: [globalScopedA] }) +makeLocationNode({ service: ScopedB, layer: scopedB, deps: [globalScopedA] }) +makeLocationNode({ service: ScopedB, layer: scopedB, deps: [locationScopedA] }) + +// @ts-expect-error Global nodes cannot depend on location nodes +makeGlobalNode({ service: ScopedB, layer: scopedB, deps: [locationScopedA] }) + +// @ts-expect-error ScopedB requires ScopedA +makeLocationNode({ service: ScopedB, layer: scopedB, deps: [] }) + +test("type exploration compiles", () => {}) diff --git a/packages/core/test/effect/layer-node/layer-node.test.ts b/packages/core/test/effect/layer-node/layer-node.test.ts new file mode 100644 index 0000000000..7c0ffe27c1 --- /dev/null +++ b/packages/core/test/effect/layer-node/layer-node.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, test } from "bun:test" +import { Context, Effect, Layer } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { LayerNodeTree } from "@opencode-ai/core/effect/layer-node" + +class Value extends Context.Service()("test/LayerNodeValue") {} +class Greeting extends Context.Service()("test/LayerNodeGreeting") {} +class Left extends Context.Service()("test/LayerNodeLeft") {} +class Right extends Context.Service()("test/LayerNodeRight") {} +class Database extends Context.Service()("test/GraphDatabase") {} +class Users extends Context.Service }>()("test/GraphUsers") {} +class App extends Context.Service }>()("test/GraphApp") {} + +const tags = LayerNode.tags({ app: [] }) +const make = tags.make("app") +const build = (root: LayerNode.Node, replacements?: readonly LayerNode.Replacement[]) => + LayerNodeTree.compile( + root, + new Map(replacements?.map((item) => [item.source, item.replacement])), + ) as Layer.Layer +const valueLayer = Layer.succeed(Value, Value.of({ value: "production" })) +const greetingLayer = Layer.effect( + Greeting, + Effect.map(Value, (value) => Greeting.of({ value: `hello ${value.value}` })), +) +const value = make({ service: Value, layer: valueLayer, deps: [] }) +const greeting = make({ service: Greeting, layer: greetingLayer, deps: [value] }) + +describe("layer node", () => { + test("builds an untagged graph", async () => { + const value = LayerNode.make({ service: Value, layer: valueLayer, deps: [] }) + const greeting = LayerNode.make({ service: Greeting, layer: greetingLayer, deps: [value] }) + const program = Effect.map(Greeting, (item) => item.value).pipe( + Effect.provide(LayerNodeTree.compile(LayerNode.group([greeting]))), + ) + expect(await Effect.runPromise(program)).toBe("hello production") + }) + + test("builds a dependency graph", async () => { + const program = Effect.map(Greeting, (item) => item.value).pipe(Effect.provide(build(LayerNode.group([greeting])))) + expect(await Effect.runPromise(program)).toBe("hello production") + }) + + test("exposes roots but hides transitive dependencies", () => { + const layer = build(LayerNode.group([greeting])) + const check: Layer.Layer = layer + void check + }) + + test("preserves branch-specific implementations across roots", async () => { + const firstValue = make({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "first" })), deps: [] }) + const secondValue = make({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "second" })), deps: [] }) + const leftLayer = Layer.effect( + Left, + Effect.map(Value, (item) => Left.of({ value: item.value })), + ) + const rightLayer = Layer.effect( + Right, + Effect.map(Value, (item) => Right.of({ value: item.value })), + ) + const left = make({ service: Left, layer: leftLayer, deps: [firstValue] }) + const right = make({ service: Right, layer: rightLayer, deps: [secondValue] }) + const layer = build(LayerNode.group([left, right])) + const program = Effect.gen(function* () { + return [(yield* Left).value, (yield* Right).value] + }).pipe(Effect.provide(layer)) + expect(await Effect.runPromise(program)).toEqual(["first", "second"]) + }) + + test("requires unbound nodes to be bound before compilation", async () => { + const unbound = LayerNode.unbound(Value, tags.values.app) + const greeting = make({ service: Greeting, layer: greetingLayer, deps: [unbound] }) + const tree = LayerNode.group([greeting]) + expect(() => LayerNodeTree.compile(tree)).toThrow("Unbound layer node: test/LayerNodeValue") + const bound = LayerNodeTree.bind(tree, unbound, value) + const layer = LayerNodeTree.compile(bound) as Layer.Layer + const program = Effect.map(Greeting, (item) => item.value).pipe(Effect.provide(layer)) + expect(await Effect.runPromise(program)).toBe("hello production") + }) + + test("replaces a layer by identity", async () => { + const replacement = Layer.succeed(Value, Value.of({ value: "simulation" })) + const program = Effect.map(Greeting, (item) => item.value).pipe( + Effect.provide(build(LayerNode.group([greeting]), [LayerNode.replace(valueLayer, replacement)])), + ) + expect(await Effect.runPromise(program)).toBe("hello simulation") + }) + + test("replaces every use of the same layer", async () => { + const leftLayer = Layer.effect( + Left, + Effect.map(Value, (item) => Left.of({ value: item.value })), + ) + const rightLayer = Layer.effect( + Right, + Effect.map(Value, (item) => Right.of({ value: item.value })), + ) + const left = make({ service: Left, layer: leftLayer, deps: [value] }) + const right = make({ service: Right, layer: rightLayer, deps: [value] }) + const replacement = Layer.succeed(Value, Value.of({ value: "replaced" })) + const layer = build(LayerNode.group([left, right]), [LayerNode.replace(valueLayer, replacement)]) + const program = Effect.gen(function* () { + return [(yield* Left).value, (yield* Right).value] + }).pipe(Effect.provide(layer)) + expect(await Effect.runPromise(program)).toEqual(["replaced", "replaced"]) + }) + + test("does not acquire an unused replacement", async () => { + let acquisitions = 0 + const other = Layer.succeed(Value, Value.of({ value: "other" })) + const replacement = Layer.effect( + Value, + Effect.sync(() => { + acquisitions++ + return Value.of({ value: "replacement" }) + }), + ) + await Effect.runPromise( + Effect.map(Greeting, (item) => item.value).pipe( + Effect.provide(build(LayerNode.group([greeting]), [LayerNode.replace(other, replacement)])), + ), + ) + expect(acquisitions).toBe(0) + }) + + test("hoists and compiles tagged graphs", async () => { + const tags = LayerNode.tags({ location: ["global"], global: [] }) + const global = tags.make("global") + const location = tags.make("location") + const database = global({ + service: Database, + layer: Layer.succeed(Database, Database.of({ name: "Alice" })), + deps: [], + }) + const users = location({ + service: Users, + layer: Layer.effect( + Users, + Effect.gen(function* () { + const db = yield* Database + return Users.of({ list: Effect.succeed([db.name]) }) + }), + ), + deps: [database], + }) + const app = location({ + service: App, + layer: Layer.effect( + App, + Effect.gen(function* () { + const service = yield* Users + return App.of({ run: service.list }) + }), + ), + deps: [users], + }) + + const result = LayerNodeTree.hoist(LayerNode.group([app]), tags.values.global) + expect(result.node.dependencies[0]?.dependencies[0]?.dependencies[0]).toMatchObject({ + kind: "group", + dependencies: [], + }) + expect(result.hoisted.dependencies).toEqual([database]) + + const layer = LayerNodeTree.compile(result.node).pipe( + Layer.provide(LayerNodeTree.compile(result.hoisted)), + ) as unknown as Layer.Layer + const program = Effect.gen(function* () { + return yield* (yield* App).run + }).pipe(Effect.provide(layer)) + + expect(await Effect.runPromise(program)).toEqual(["Alice"]) + }) + + test("rejects conflicting hoisted implementations", () => { + const tags = LayerNode.tags({ location: ["global"], global: [] }) + const global = tags.make("global") + const location = tags.make("location") + const first = global({ + service: Database, + layer: Layer.succeed(Database, Database.of({ name: "first" })), + deps: [], + }) + const second = global({ + service: Database, + layer: Layer.succeed(Database, Database.of({ name: "second" })), + deps: [], + }) + const left = location({ + service: Users, + layer: Layer.effect(Users, Effect.as(Database, Users.of({ list: Effect.succeed([]) }))), + deps: [first], + }) + const right = location({ + service: App, + layer: Layer.effect(App, Effect.as(Database, App.of({ run: Effect.succeed([]) }))), + deps: [second], + }) + + expect(() => LayerNodeTree.hoist(LayerNode.group([left, right]), tags.values.global)).toThrow( + "Tag global has conflicting implementations for test/GraphDatabase", + ) + }) + + test("treats dependency groups as transparent while hoisting", () => { + const tags = LayerNode.tags({ location: ["global"], global: [] }) + const global = tags.make("global") + const location = tags.make("location") + const database = global({ + service: Database, + layer: Layer.succeed(Database, Database.of({ name: "Alice" })), + deps: [], + }) + const users = location({ + service: Users, + layer: Layer.effect(Users, Effect.as(Database, Users.of({ list: Effect.succeed([]) }))), + deps: [LayerNode.group([database])], + }) + const result = LayerNodeTree.hoist(LayerNode.group([users]), tags.values.global) + + expect(result.node.dependencies[0]?.dependencies[0]?.dependencies[0]).toMatchObject({ + kind: "group", + dependencies: [], + }) + }) +}) diff --git a/packages/core/test/effect/layer-node/node-build.test.ts b/packages/core/test/effect/layer-node/node-build.test.ts new file mode 100644 index 0000000000..eb09199e5b --- /dev/null +++ b/packages/core/test/effect/layer-node/node-build.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, test } from "bun:test" +import { Context, Effect, Layer } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Node } from "@opencode-ai/core/effect/node" +import { NodeBuild } from "@opencode-ai/core/effect/node-build" +import { Location } from "@opencode-ai/core/location" +import { LocationServiceMap } from "@opencode-ai/core/location-service-map" +import { Project } from "@opencode-ai/core/project" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { tmpdir } from "../../fixture/tmpdir" + +class Value extends Context.Service()("test/TagValue") {} +class Result extends Context.Service()("test/TagResult") {} +class Left extends Context.Service()("test/TagLeft") {} +class Right extends Context.Service()("test/TagRight") {} +class Last extends Context.Service()("test/TagLast") {} + +describe("node build", () => { + test("shares top-level project with location services", async () => { + await using tmp = await tmpdir() + let acquisitions = 0 + const projectLayer = Layer.effect( + Project.Service, + Effect.sync(() => { + acquisitions++ + return Project.Service.of({ + directories: () => Effect.succeed([]), + resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory }), + commit: () => Effect.void, + }) + }), + ) + const layer = NodeBuild.build(LayerNode.group([Project.node, LocationServiceMap.node]), [ + LayerNode.replace(Project.layer, projectLayer), + ]) + const ref = Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }) + const program = Effect.gen(function* () { + yield* Project.Service + const locations = yield* LocationServiceMap.Service + return yield* Location.Service.pipe(Effect.provide(locations.get(ref))) + }).pipe(Effect.provide(layer)) + + expect((await Effect.runPromise(program)).directory).toBe(ref.directory) + expect(acquisitions).toBe(1) + }) + + test("returns a composed application layer", async () => { + const value = Node.makeGlobalNode({ + service: Value, + layer: Layer.succeed(Value, Value.of({ value: "value" })), + deps: [], + }) + const result = Node.makeGlobalNode({ + service: Result, + layer: Layer.effect( + Result, + Effect.gen(function* () { + return Result.of({ value: (yield* Value).value }) + }), + ), + deps: [value], + }) + const serviceLayer = NodeBuild.build(LayerNode.group([result])) + const program = Effect.gen(function* () { + return (yield* Result).value + }).pipe(Effect.provide(serviceLayer)) + + expect(await Effect.runPromise(program)).toBe("value") + }) + + test("rebinds same-tag providers without reacquiring them", async () => { + let firstAcquisitions = 0 + const tags = LayerNode.tags({ global: [] }) + const global = tags.make("global") + const first = global({ + service: Value, + layer: Layer.effect( + Value, + Effect.sync(() => { + firstAcquisitions++ + return Value.of({ value: "first" }) + }), + ), + deps: [], + }) + const second = global({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "second" })), deps: [] }) + const left = global({ + service: Left, + layer: Layer.effect( + Left, + Effect.map(Value, (value) => Left.of({ value: value.value })), + ), + deps: [first], + }) + const right = global({ + service: Right, + layer: Layer.effect( + Right, + Effect.map(Value, (value) => Right.of({ value: value.value })), + ), + deps: [second], + }) + const last = global({ + service: Last, + layer: Layer.effect( + Last, + Effect.map(Value, (value) => Last.of({ value: value.value })), + ), + deps: [first], + }) + const layer = NodeBuild.build(LayerNode.group([left, right, last])) as Layer.Layer + const values = Effect.gen(function* () { + return [(yield* Left).value, (yield* Right).value, (yield* Last).value] + }).pipe(Effect.provide(layer)) + + expect(await Effect.runPromise(values)).toEqual(["first", "second", "first"]) + expect(firstAcquisitions).toBe(1) + }) +}) diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index 6dba16ece6..b48bcb9ecf 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -6,7 +6,7 @@ import { Tool } from "@opencode-ai/core/tool/tool" import { define } from "@opencode-ai/plugin/v2/effect" import { AgentV2 } from "@opencode-ai/core/agent" import { Catalog } from "@opencode-ai/core/catalog" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" import { Location } from "@opencode-ai/core/location" import { PluginV2 } from "@opencode-ai/core/plugin" import { ModelV2 } from "@opencode-ai/core/model" @@ -34,7 +34,7 @@ const applicationTools = ApplicationTools.layer const it = testEffect( Layer.merge( Layer.mergeAll(applicationTools, Database.defaultLayer, EventV2.defaultLayer), - LocationServiceMap.layer.pipe( + locationServiceMapLayer.pipe( Layer.provide(applicationTools), Layer.provide( Layer.mergeAll( @@ -60,7 +60,7 @@ describe("LocationServiceMap", () => { Effect.flatMap((dir) => Effect.scoped( Effect.gen(function* () { - const locations = yield* LocationServiceMap + const locations = yield* LocationServiceMap.Service const directory = AbsolutePath.make(dir.path) const constructed = Location.Ref.make({ directory }) const decoded = Schema.decodeUnknownSync(Location.Ref)({ directory }) @@ -111,7 +111,7 @@ describe("LocationServiceMap", () => { } }).pipe( Effect.scoped, - Effect.provide(LocationServiceMap.get(Location.Ref.make({ directory: AbsolutePath.make(directory) }))), + Effect.provide(LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(directory) }))), ) const blockedState = yield* update(blocked.path) @@ -191,7 +191,7 @@ describe("LocationServiceMap", () => { location, }), ), - ).pipe(Effect.provide(LocationServiceMap.get(location)), Effect.flip) + ).pipe(Effect.provide(LocationServiceMap.Service.get(location)), Effect.flip) expect(failure).toMatchObject({ _tag: "SessionRunnerModel.ModelUnavailableError", @@ -231,7 +231,7 @@ describe("LocationServiceMap", () => { }) }).pipe( Effect.scoped, - Effect.provide(LocationServiceMap.get(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))), + Effect.provide(LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))), ), ), ), diff --git a/packages/core/test/move-session.test.ts b/packages/core/test/move-session.test.ts index 06b40c0154..07f1459335 100644 --- a/packages/core/test/move-session.test.ts +++ b/packages/core/test/move-session.test.ts @@ -14,7 +14,7 @@ import { ProjectTable } from "@opencode-ai/core/project/sql" import { ProjectDirectories } from "@opencode-ai/core/project/directories" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { locationServiceMapLayer } from "@opencode-ai/core/location-services" import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionTable } from "@opencode-ai/core/session/sql" @@ -29,7 +29,7 @@ const project = Project.layer.pipe( Layer.provide(ProjectDirectories.defaultLayer), ) const sessions = SessionV2.layer.pipe( - Layer.provide(LocationServiceMap.layer), + Layer.provide(locationServiceMapLayer), Layer.provide(Database.defaultLayer), Layer.provide(EventV2.defaultLayer), Layer.provide(project), diff --git a/packages/core/test/permission.test.ts b/packages/core/test/permission.test.ts index 024b36d439..dca9f8ead4 100644 --- a/packages/core/test/permission.test.ts +++ b/packages/core/test/permission.test.ts @@ -11,7 +11,7 @@ import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { locationServiceMapLayer } from "@opencode-ai/core/location-services" import { SessionTable } from "@opencode-ai/core/session/sql" import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionStore } from "@opencode-ai/core/session/store" @@ -24,7 +24,7 @@ const current = Layer.succeed( Location.Service.of(location({ directory: AbsolutePath.make("/project") })), ) const sessions = SessionV2.layer.pipe( - Layer.provide(LocationServiceMap.layer), + Layer.provide(locationServiceMapLayer), Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer), Layer.provide(SessionStore.defaultLayer), diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 7826d6bf28..b05745eaa8 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -13,7 +13,7 @@ import { ProjectTable } from "@opencode-ai/core/project/sql" import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { locationServiceMapLayer } from "@opencode-ai/core/location-services" import { SessionV1 } from "@opencode-ai/core/v1/session" import { Prompt } from "@opencode-ai/core/session/prompt" import { SessionProjector } from "@opencode-ai/core/session/projector" @@ -35,7 +35,7 @@ const projects = Layer.succeed( }), ) const sessions = SessionV2.layer.pipe( - Layer.provide(LocationServiceMap.layer), + Layer.provide(locationServiceMapLayer), Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer), Layer.provide(SessionStore.defaultLayer), diff --git a/packages/core/test/session-history.test.ts b/packages/core/test/session-history.test.ts index 8cee6546b0..9fbfb786d3 100644 --- a/packages/core/test/session-history.test.ts +++ b/packages/core/test/session-history.test.ts @@ -3,7 +3,7 @@ import { Effect, Layer, Schema } from "effect" import { Database } from "@opencode-ai/core/database/database" import { EventV2 } from "@opencode-ai/core/event" import { Location } from "@opencode-ai/core/location" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { locationServiceMapLayer } from "@opencode-ai/core/location-services" import { ProjectV2 } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" import { AbsolutePath } from "@opencode-ai/core/schema" @@ -23,7 +23,7 @@ const projects = Layer.succeed( }), ) const sessions = SessionV2.layer.pipe( - Layer.provide(LocationServiceMap.layer), + Layer.provide(locationServiceMapLayer), Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer), Layer.provide(SessionStore.defaultLayer), diff --git a/packages/core/test/session-projector.test.ts b/packages/core/test/session-projector.test.ts index 3b568cb954..da637ae135 100644 --- a/packages/core/test/session-projector.test.ts +++ b/packages/core/test/session-projector.test.ts @@ -10,7 +10,7 @@ import { ProjectTable } from "@opencode-ai/core/project/sql" import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { locationServiceMapLayer } from "@opencode-ai/core/location-services" import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionMessage } from "@opencode-ai/core/session/message" import { Prompt } from "@opencode-ai/core/session/prompt" @@ -164,7 +164,7 @@ describe("SessionProjector", () => { }).pipe( Effect.provide( SessionV2.layer.pipe( - Layer.provide(LocationServiceMap.layer), + Layer.provide(locationServiceMapLayer), Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer), Layer.provide(Project.defaultLayer), diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index d60f9f1ccf..66c8f76914 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -9,7 +9,7 @@ import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { locationServiceMapLayer } from "@opencode-ai/core/location-services" import { Prompt } from "@opencode-ai/core/session/prompt" import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionProjector } from "@opencode-ai/core/session/projector" @@ -42,7 +42,7 @@ const execution = Layer.succeed( }), ) const sessions = SessionV2.layer.pipe( - Layer.provide(LocationServiceMap.layer), + Layer.provide(locationServiceMapLayer), Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer), Layer.provide(SessionStore.defaultLayer), diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index da8699fbd2..a0610dd465 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -12,7 +12,7 @@ import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { locationServiceMapLayer } from "@opencode-ai/core/location-services" import { Snapshot } from "@opencode-ai/core/snapshot" import { Prompt } from "@opencode-ai/core/session/prompt" import { SessionProjector } from "@opencode-ai/core/session/projector" @@ -103,7 +103,7 @@ const execution = Layer.effect( }), ).pipe(Layer.provide(runner)) const sessions = SessionV2.layer.pipe( - Layer.provide(LocationServiceMap.layer), + Layer.provide(locationServiceMapLayer), Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer), Layer.provide(SessionStore.defaultLayer), diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index c9594e8244..4779de38dc 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -19,7 +19,7 @@ import { ProjectTable } from "@opencode-ai/core/project/sql" import { QuestionV2 } from "@opencode-ai/core/question" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { locationServiceMapLayer } from "@opencode-ai/core/location-services" import { Snapshot } from "@opencode-ai/core/snapshot" import { ContextSnapshotDecodeError } from "@opencode-ai/core/session/error" import { SessionEvent } from "@opencode-ai/core/session/event" @@ -262,7 +262,7 @@ const execution = Layer.effect( }), ).pipe(Layer.provide(runner)) const sessions = SessionV2.layer.pipe( - Layer.provide(LocationServiceMap.layer), + Layer.provide(locationServiceMapLayer), Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer), Layer.provide(SessionStore.defaultLayer), diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index e65c843493..d6b89eb6b1 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -27,7 +27,7 @@ import * as OtelTracer from "@effect/opentelemetry/Tracer" import { AbsolutePath, type DeepMutable } from "@opencode-ai/core/schema" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" import { Reference } from "@opencode-ai/core/reference" import { Location } from "@opencode-ai/core/location" import { PluginV2 } from "@opencode-ai/core/plugin" @@ -93,7 +93,7 @@ export const layer = Layer.effect( const plugin = yield* Plugin.Service const skill = yield* Skill.Service const provider = yield* Provider.Service - const locations = yield* LocationServiceMap + const locations = yield* LocationServiceMap.Service const state = yield* InstanceState.make( Effect.fn("Agent.state")(function* (ctx) { @@ -444,10 +444,10 @@ export const defaultLayer = layer.pipe( Layer.provide(Auth.defaultLayer), Layer.provide(Config.defaultLayer), Layer.provide(Skill.defaultLayer), - Layer.provide(LocationServiceMap.layer), + Layer.provide(locationServiceMapLayer), ) -const locationServiceMapNode = LayerNode.make({ service: Service, layer: LocationServiceMap.layer, deps: [] }) +const locationServiceMapNode = LayerNode.make({ service: LocationServiceMap.Service, layer: locationServiceMapLayer, deps: [] }) export const node = LayerNode.make({ service: Service, diff --git a/packages/opencode/src/cli/cmd/debug/file.ts b/packages/opencode/src/cli/cmd/debug/file.ts index 0e6ef98c24..02383aeaf9 100644 --- a/packages/opencode/src/cli/cmd/debug/file.ts +++ b/packages/opencode/src/cli/cmd/debug/file.ts @@ -1,7 +1,7 @@ import { EOL } from "os" import { Effect } from "effect" import { FileSystem } from "@opencode-ai/core/filesystem" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" import { Location } from "@opencode-ai/core/location" import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema" import { effectCmd } from "../../effect-cmd" @@ -9,8 +9,8 @@ import { cmd } from "../cmd" const filesystem = (effect: Effect.Effect) => effect.pipe( - Effect.provide(LocationServiceMap.get(Location.Ref.make({ directory: AbsolutePath.make(process.cwd()) }))), - Effect.provide(LocationServiceMap.layer), + Effect.provide(LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(process.cwd()) }))), + Effect.provide(locationServiceMapLayer), ) const FileSearchCommand = effectCmd({ diff --git a/packages/opencode/src/cli/cmd/debug/v2.ts b/packages/opencode/src/cli/cmd/debug/v2.ts index 87a67b4bc0..c1f82bb79f 100644 --- a/packages/opencode/src/cli/cmd/debug/v2.ts +++ b/packages/opencode/src/cli/cmd/debug/v2.ts @@ -1,7 +1,7 @@ import { EOL } from "os" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" import { Location } from "@opencode-ai/core/location" import { AbsolutePath } from "@opencode-ai/core/schema" import { effectCmd } from "../../effect-cmd" @@ -31,12 +31,12 @@ export const V2Command = effectCmd({ }).pipe( Effect.withSpan("Cli.debug.v2"), Effect.provide( - LocationServiceMap.get( + LocationServiceMap.Service.get( Location.Ref.make({ directory: AbsolutePath.make(process.cwd()), }), ), ), - Effect.provide(LocationServiceMap.layer), + Effect.provide(locationServiceMapLayer), ), }) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts index bacc4b0457..6a826022a0 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts @@ -1,6 +1,6 @@ import * as InstanceState from "@/effect/instance-state" import { FileSystem } from "@opencode-ai/core/filesystem" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" import { Ripgrep } from "@opencode-ai/core/ripgrep" import { FSUtil } from "@opencode-ai/core/fs-util" import { Location } from "@opencode-ai/core/location" @@ -14,7 +14,7 @@ import { InstanceHttpApi } from "../api" export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handlers) => Effect.gen(function* () { const ripgrep = yield* Ripgrep.Service - const locations = yield* LocationServiceMap + const locations = yield* LocationServiceMap.Service const filesystem = Effect.fnUntraced(function* (effect: Effect.Effect) { return yield* effect.pipe( @@ -136,4 +136,4 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl .handle("content", content) .handle("status", status) }), -).pipe(Layer.provide(LocationServiceMap.layer)) +).pipe(Layer.provide(locationServiceMapLayer)) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts index 538920b000..6446c9d28b 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts @@ -6,7 +6,7 @@ import { Pty } from "@opencode-ai/core/pty" import { PtyProtocol } from "@opencode-ai/core/pty/protocol" import { PtyID } from "@opencode-ai/core/pty/schema" import { PtyTicket } from "@opencode-ai/core/pty/ticket" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" import { Location } from "@opencode-ai/core/location" import { AbsolutePath } from "@opencode-ai/core/schema" import { Shell } from "@opencode-ai/core/shell" @@ -43,7 +43,7 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler const tickets = yield* PtyTicket.Service const cors = yield* CorsConfig const plugin = yield* Plugin.Service - const locations = yield* LocationServiceMap + const locations = yield* LocationServiceMap.Service const unregister = registerDisposer((directory) => Effect.runPromise(locations.invalidate(Location.Ref.make({ directory: AbsolutePath.make(directory) }))), ) @@ -158,13 +158,13 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler .handle("remove", remove) .handle("connectToken", connectToken) }), -).pipe(Layer.provide(LocationServiceMap.layer)) +).pipe(Layer.provide(locationServiceMapLayer)) export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-connect", (handlers) => Effect.gen(function* () { const tickets = yield* PtyTicket.Service const cors = yield* CorsConfig - const locations = yield* LocationServiceMap + const locations = yield* LocationServiceMap.Service const unregister = registerDisposer((directory) => Effect.runPromise(locations.invalidate(Location.Ref.make({ directory: AbsolutePath.make(directory) }))), ) @@ -270,4 +270,4 @@ export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-conne }), ) }), -).pipe(Layer.provide(LocationServiceMap.layer)) +).pipe(Layer.provide(locationServiceMapLayer)) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index b74df8deb8..92251f6025 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -52,15 +52,19 @@ import { RuntimeFlags } from "@/effect/runtime-flags" import { MoveSession } from "@opencode-ai/core/control-plane/move-session" import { Database } from "@opencode-ai/core/database/database" import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { LayerNodeTree } from "@opencode-ai/core/effect/layer-node" import { httpClient } from "@opencode-ai/core/effect/layer-node-platform" import { EventV2 } from "@opencode-ai/core/event" import { ModelsDev } from "@opencode-ai/core/models-dev" import { Npm } from "@opencode-ai/core/npm" +import { PermissionSaved } from "@opencode-ai/core/permission/saved" import { ProjectV2 } from "@opencode-ai/core/project" import { ProjectCopy } from "@opencode-ai/core/project/copy" import { PtyTicket } from "@opencode-ai/core/pty/ticket" import { Ripgrep } from "@opencode-ai/core/ripgrep" import { SessionProjector } from "@opencode-ai/core/session/projector" +import { SessionV2 } from "@opencode-ai/core/session" +import * as SessionExecutionLocal from "@opencode-ai/core/session/execution/local" import { lazy } from "@/util/lazy" import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@opencode-ai/server/cors" import { serveUIEffect } from "@/server/shared/ui" @@ -95,6 +99,10 @@ import { sessionHandlers } from "./handlers/session" import { syncHandlers } from "./handlers/sync" import { tuiHandlers } from "./handlers/tui" import { handlers } from "@opencode-ai/server/handlers" +import { locationServiceMapLayer } from "@opencode-ai/core/location-services" +import { layer as locationLayer } from "@opencode-ai/server/location" +import { sessionLocationLayer } from "@opencode-ai/server/middleware/session-location" +import { PtyEnvironment } from "@opencode-ai/server/pty-environment" import { schemaErrorLayer as v2SchemaErrorLayer } from "@opencode-ai/server/middleware/schema-error" import { workspaceHandlers } from "./handlers/workspace" import { instanceContextLayer } from "./middleware/instance-context" @@ -221,6 +229,7 @@ const app = LayerNode.group([ Discovery.node, Question.node, Permission.node, + PermissionSaved.node, Todo.node, Session.node, SessionProjector.node, @@ -279,9 +288,21 @@ export function createRoutes( MoveSession.defaultLayer, HttpServer.layerServices, ]), - Layer.provide(LayerNode.buildLayer(app)), Layer.provide(Layer.succeed(CorsConfig)(corsOptions)), Layer.provideMerge(Observability.layer), + + Layer.provide(sessionLocationLayer), + Layer.provide(locationLayer), + Layer.provide(PtyEnvironment.layer), + Layer.provide( + SessionV2.defaultLayer.pipe( + Layer.provide(SessionExecutionLocal.defaultLayer), + Layer.provide(locationServiceMapLayer), + ), + ), + Layer.provide(locationServiceMapLayer), + + Layer.provide(LayerNodeTree.compile(app)), ) } diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 798518d0ba..1bea9f52c3 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -36,8 +36,6 @@ import type { SystemError } from "bun" import type { Provider } from "@/provider/provider" import { Effect, Schema } from "effect" -export const node = LayerNode.group([Database.node]) - /** Error shape thrown by Bun's fetch() when gzip/br decompression fails mid-stream */ interface FetchDecompressionError extends Error { code: "ZlibError" @@ -733,3 +731,4 @@ export function fromError( } export * as MessageV2 from "./message-v2" +export const node = LayerNode.group([Database.node]) diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 48c43ca0e8..f4edf50ed6 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -13,7 +13,7 @@ import { makeRuntime } from "@opencode-ai/core/effect/runtime" import { EventV2Bridge } from "@/event-v2-bridge" import { SessionV2 } from "@opencode-ai/core/session" import * as SessionExecutionLocal from "@opencode-ai/core/session/execution/local" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { locationServiceMapLayer } from "@opencode-ai/core/location-services" import { NotFoundError } from "@/storage/storage" import { eq } from "drizzle-orm" @@ -947,7 +947,7 @@ export const defaultLayer = layer.pipe( Layer.provide( SessionV2.defaultLayer.pipe( Layer.provide(SessionExecutionLocal.defaultLayer), - Layer.provide(LocationServiceMap.layer), + Layer.provide(locationServiceMapLayer), ), ), Layer.provide(RuntimeFlags.defaultLayer), diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index f88795dc77..29c66bab80 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -18,7 +18,7 @@ import { Permission } from "@/permission" import { Skill } from "@/skill" import { AbsolutePath } from "@opencode-ai/core/schema" import { Location } from "@opencode-ai/core/location" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" import { Reference } from "@opencode-ai/core/reference" import { MCP } from "@/mcp" import { PermissionV1 } from "@opencode-ai/core/v1/permission" @@ -52,7 +52,7 @@ export const layer = Layer.effect( Effect.gen(function* () { const skill = yield* Skill.Service const mcp = yield* MCP.Service - const locations = yield* LocationServiceMap + const locations = yield* LocationServiceMap.Service return Service.of({ environment: Effect.fn("SystemPrompt.environment")(function* (model: Provider.Model) { @@ -131,10 +131,10 @@ export const layer = Layer.effect( export const defaultLayer = layer.pipe( Layer.provide(Skill.defaultLayer), Layer.provide(MCP.defaultLayer), - Layer.provide(LocationServiceMap.layer), + Layer.provide(locationServiceMapLayer), ) -const locationServiceMapNode = LayerNode.make({ service: Service, layer: LocationServiceMap.layer, deps: [] }) +const locationServiceMapNode = LayerNode.make({ service: LocationServiceMap.Service, layer: locationServiceMapLayer, deps: [] }) export const node = LayerNode.make({ service: Service, diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index 1df95b5c0f..b1162bb7e4 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -14,7 +14,7 @@ import { Plugin } from "../../src/plugin" import { Provider } from "../../src/provider/provider" import { Skill } from "../../src/skill" import { Truncate } from "../../src/tool/truncate" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" const agentLayer = (flags: Partial = {}) => Agent.layer.pipe( @@ -23,7 +23,7 @@ const agentLayer = (flags: Partial = {}) => Layer.provide(Auth.defaultLayer), Layer.provide(Config.defaultLayer), Layer.provide(Skill.defaultLayer), - Layer.provide(LocationServiceMap.layer), + Layer.provide(locationServiceMapLayer), Layer.provide(RuntimeFlags.layer(flags)), ) diff --git a/packages/opencode/test/agent/plugin-agent-regression.test.ts b/packages/opencode/test/agent/plugin-agent-regression.test.ts index 4c894b0a01..a9a26c7226 100644 --- a/packages/opencode/test/agent/plugin-agent-regression.test.ts +++ b/packages/opencode/test/agent/plugin-agent-regression.test.ts @@ -1,6 +1,6 @@ import { expect } from "bun:test" import { FSUtil } from "@opencode-ai/core/fs-util" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" import { Effect, Layer } from "effect" import { FetchHttpClient } from "effect/unstable/http" import path from "path" @@ -44,7 +44,7 @@ const agentLayer = Agent.layer.pipe( Layer.provide(SkillTest.empty), Layer.provide(provider.layer), Layer.provide(pluginLayer), - Layer.provide(LocationServiceMap.layer), + Layer.provide(locationServiceMapLayer), Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true })), ) diff --git a/packages/opencode/test/effect/layer-node-tiers-types.test.ts b/packages/opencode/test/effect/layer-node-tiers-types.test.ts deleted file mode 100644 index c41b0ccb3c..0000000000 --- a/packages/opencode/test/effect/layer-node-tiers-types.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { test } from "bun:test" -import { Context, Effect, Layer } from "effect" -import { LayerNode } from "@opencode-ai/core/effect/layer-node" - -class A extends Context.Service()("test/TierA") {} -class B extends Context.Service()("test/TierB") {} - -const tiers = LayerNode.tiers(["request", "global"]) -const request = tiers.make("request") -const global = tiers.make("global") -const globalA = global({ service: A, layer: Layer.succeed(A, A.of({})), deps: [] }) -const bLayer = Layer.effect(B, Effect.as(A, B.of({}))) - -request({ service: B, layer: bLayer, deps: [globalA] }) - -// @ts-expect-error Global cannot depend on request -global({ service: B, layer: bLayer, deps: [request({ service: A, layer: Layer.succeed(A, A.of({})), deps: [] })] }) - -test("type exploration compiles", () => {}) diff --git a/packages/opencode/test/effect/layer-node-tiers.test.ts b/packages/opencode/test/effect/layer-node-tiers.test.ts deleted file mode 100644 index ec460214a5..0000000000 --- a/packages/opencode/test/effect/layer-node-tiers.test.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { expect, test } from "bun:test" -import { Context, Effect, Layer } from "effect" -import { LayerNode } from "@opencode-ai/core/effect/layer-node" - -class Value extends Context.Service()("test/TierValue") {} -class Result extends Context.Service()("test/TierResult") {} -class Left extends Context.Service()("test/TierLeft") {} -class Right extends Context.Service()("test/TierRight") {} -class Last extends Context.Service()("test/TierLast") {} - -test("builds tiers with a custom builder", async () => { - let locationBuilds = 0 - const tiers = LayerNode.tiers(["location", "global"]) - const global = tiers.make("global") - const location = tiers.make("location") - const value = global({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "value" })), deps: [] }) - const result = location({ - service: Result, - layer: Layer.effect( - Result, - Effect.gen(function* () { - return Result.of({ value: (yield* Value).value }) - }), - ), - deps: [value], - }) - const layer = LayerNode.buildLayer(LayerNode.group([result]), { - tiers, - buildTier: (tier, layers) => { - if (tier !== "location") return LayerNode.combine(layers) - locationBuilds++ - return LayerNode.combine(layers).pipe(Layer.fresh) - }, - }) - const program = Effect.gen(function* () { - return (yield* Result).value - }).pipe(Effect.provide(layer)) - - expect(await Effect.runPromise(program)).toBe("value") - expect(locationBuilds).toBe(1) -}) - -test("rejects conflicting higher-tier service implementations", () => { - const tiers = LayerNode.tiers(["location", "global"]) - const global = tiers.make("global") - const location = tiers.make("location") - const first = global({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "first" })), deps: [] }) - const second = global({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "second" })), deps: [] }) - const left = location({ - service: Left, - layer: Layer.effect(Left, Effect.as(Value, Left.of({ value: "left" }))), - deps: [first], - }) - const right = location({ - service: Right, - layer: Layer.effect(Right, Effect.as(Value, Right.of({ value: "right" }))), - deps: [second], - }) - - expect(() => LayerNode.buildLayer(LayerNode.group([left, right]), { tiers })).toThrow( - "conflicting implementations for test/TierValue", - ) -}) - -test("validates tier dependencies through groups", () => { - const tiers = LayerNode.tiers(["location", "global"]) - const global = tiers.make("global") - const location = tiers.make("location") - const local = location({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "local" })), deps: [] }) - const invalid = global({ - service: Result, - layer: Layer.effect( - Result, - Effect.map(Value, (value) => Result.of({ value: value.value })), - ), - deps: [LayerNode.group([local])], - }) - - expect(() => LayerNode.buildLayer(invalid, { tiers })).toThrow("Tier global cannot depend on lower tier location") -}) - -test("validates shared groups in each consumer tier", () => { - const tiers = LayerNode.tiers(["location", "global"]) - const global = tiers.make("global") - const location = tiers.make("location") - const local = location({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "local" })), deps: [] }) - const shared = LayerNode.group([local]) - const valid = location({ - service: Left, - layer: Layer.effect( - Left, - Effect.map(Value, (value) => Left.of({ value: value.value })), - ), - deps: [shared], - }) - const invalid = global({ - service: Result, - layer: Layer.effect( - Result, - Effect.map(Value, (value) => Result.of({ value: value.value })), - ), - deps: [shared], - }) - - expect(() => LayerNode.buildLayer(LayerNode.group([valid, invalid]), { tiers })).toThrow( - "Tier global cannot depend on lower tier location", - ) -}) - -test("rejects a service assigned to multiple tiers", () => { - const tiers = LayerNode.tiers(["location", "global"]) - const global = tiers.make("global") - const location = tiers.make("location") - const local = location({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "local" })), deps: [] }) - const shared = global({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "global" })), deps: [] }) - - expect(() => LayerNode.buildLayer(LayerNode.group([local, shared]), { tiers })).toThrow( - "Service test/TierValue belongs to both tier location and tier global", - ) -}) - -test("rebinds same-tier providers without reacquiring them", async () => { - let firstAcquisitions = 0 - const tiers = LayerNode.tiers(["global"]) - const global = tiers.make("global") - const first = global({ - service: Value, - layer: Layer.effect( - Value, - Effect.sync(() => { - firstAcquisitions++ - return Value.of({ value: "first" }) - }), - ), - deps: [], - }) - const second = global({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "second" })), deps: [] }) - const left = global({ - service: Left, - layer: Layer.effect( - Left, - Effect.map(Value, (value) => Left.of({ value: value.value })), - ), - deps: [first], - }) - const right = global({ - service: Right, - layer: Layer.effect( - Right, - Effect.map(Value, (value) => Right.of({ value: value.value })), - ), - deps: [second], - }) - const last = global({ - service: Last, - layer: Layer.effect( - Last, - Effect.map(Value, (value) => Last.of({ value: value.value })), - ), - deps: [first], - }) - const layer = LayerNode.buildLayer(LayerNode.group([left, right, last]), { tiers }) - const values = Effect.gen(function* () { - return [(yield* Left).value, (yield* Right).value, (yield* Last).value] - }).pipe(Effect.provide(layer)) - - expect(await Effect.runPromise(values)).toEqual(["first", "second", "first"]) - expect(firstAcquisitions).toBe(1) -}) diff --git a/packages/opencode/test/effect/layer-node-types.test.ts b/packages/opencode/test/effect/layer-node-types.test.ts deleted file mode 100644 index 4ff88cf80f..0000000000 --- a/packages/opencode/test/effect/layer-node-types.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { test } from "bun:test" -import { Context, Effect, Layer } from "effect" -import { LayerNode } from "@opencode-ai/core/effect/layer-node" - -class A extends Context.Service()("test/LayerNodeA") {} -class B extends Context.Service()("test/LayerNodeB") {} -class C extends Context.Service()("test/LayerNodeC") {} -class LayerError { - readonly _tag = "LayerError" -} -class OtherError { - readonly _tag = "OtherError" -} - -const tiers = LayerNode.tiers(["app"]) -const make = tiers.make("app") -const aLayer = Layer.succeed(A, A.of({})) -const bLayer = Layer.effect(B, Effect.as(A, B.of({}))) -const cLayer = Layer.effect( - C, - Effect.gen(function* () { - yield* A - yield* B - return C.of({}) - }), -) -const failingA = Layer.effect(A, Effect.fail(new LayerError())) -const a = make({ service: A, layer: aLayer, deps: [] }) -const b = make({ service: B, layer: bLayer, deps: [a] }) -const c = make({ service: C, layer: cLayer, deps: [a, b] }) -const failing = make({ service: A, layer: failingA, deps: [] }) -const dependent = make({ service: B, layer: bLayer, deps: [failing] }) - -make({ name: "manual-a", layer: aLayer, deps: [] }) - -// @ts-expect-error A node must have a service or name -make({ layer: aLayer, deps: [] }) - -// @ts-expect-error Service and name are mutually exclusive -make({ service: A, name: "a", layer: aLayer, deps: [] }) - -// @ts-expect-error B requires A -make({ service: B, layer: bLayer, deps: [] }) - -// @ts-expect-error C requires A and B -make({ service: C, layer: cLayer, deps: [a] }) - -const closed = LayerNode.buildLayer(c, { tiers }) -const closedWithError = LayerNode.buildLayer(dependent, { tiers }) -const checkClosed: Layer.Layer = closed -const checkError: Layer.Layer = closedWithError -void checkClosed -void checkError - -LayerNode.replace(aLayer, Layer.succeed(A, A.of({}))) - -// @ts-expect-error Replacement must provide A -LayerNode.replace(aLayer, Layer.succeed(B, B.of({}))) - -// @ts-expect-error Replacement cannot introduce a new error -LayerNode.replace(aLayer, Layer.effect(A, Effect.fail(new OtherError()))) - -// @ts-expect-error Replacement must be closed -LayerNode.replace(bLayer, bLayer) - -test("type exploration compiles", () => {}) diff --git a/packages/opencode/test/effect/layer-node.test.ts b/packages/opencode/test/effect/layer-node.test.ts deleted file mode 100644 index 036d4a501b..0000000000 --- a/packages/opencode/test/effect/layer-node.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { Context, Effect, Layer } from "effect" -import { LayerNode } from "@opencode-ai/core/effect/layer-node" - -class Value extends Context.Service()("test/LayerNodeValue") {} -class Greeting extends Context.Service()("test/LayerNodeGreeting") {} -class Left extends Context.Service()("test/LayerNodeLeft") {} -class Right extends Context.Service()("test/LayerNodeRight") {} - -const tiers = LayerNode.tiers(["app"]) -const make = tiers.make("app") -const valueLayer = Layer.succeed(Value, Value.of({ value: "production" })) -const greetingLayer = Layer.effect( - Greeting, - Effect.map(Value, (value) => Greeting.of({ value: `hello ${value.value}` })), -) -const value = make({ service: Value, layer: valueLayer, deps: [] }) -const greeting = make({ service: Greeting, layer: greetingLayer, deps: [value] }) - -describe("layer node", () => { - test("builds an untiered graph", async () => { - const value = LayerNode.make({ service: Value, layer: valueLayer, deps: [] }) - const greeting = LayerNode.make({ service: Greeting, layer: greetingLayer, deps: [value] }) - const program = Effect.map(Greeting, (item) => item.value).pipe(Effect.provide(LayerNode.buildLayer(greeting))) - expect(await Effect.runPromise(program)).toBe("hello production") - }) - - test("builds a dependency graph", async () => { - const program = Effect.map(Greeting, (item) => item.value).pipe( - Effect.provide(LayerNode.buildLayer(greeting, { tiers })), - ) - expect(await Effect.runPromise(program)).toBe("hello production") - }) - - test("replaces a layer by identity", async () => { - const replacement = Layer.succeed(Value, Value.of({ value: "simulation" })) - const program = Effect.map(Greeting, (item) => item.value).pipe( - Effect.provide( - LayerNode.buildLayer(greeting, { tiers, replacements: [LayerNode.replace(valueLayer, replacement)] }), - ), - ) - expect(await Effect.runPromise(program)).toBe("hello simulation") - }) - - test("replaces every use of the same layer", async () => { - const leftLayer = Layer.effect( - Left, - Effect.map(Value, (item) => Left.of({ value: item.value })), - ) - const rightLayer = Layer.effect( - Right, - Effect.map(Value, (item) => Right.of({ value: item.value })), - ) - const left = make({ service: Left, layer: leftLayer, deps: [value] }) - const right = make({ service: Right, layer: rightLayer, deps: [value] }) - const replacement = Layer.succeed(Value, Value.of({ value: "replaced" })) - const layer = LayerNode.buildLayer(LayerNode.group([left, right]), { - tiers, - replacements: [LayerNode.replace(valueLayer, replacement)], - }) - const program = Effect.gen(function* () { - return [(yield* Left).value, (yield* Right).value] - }).pipe(Effect.provide(layer)) - expect(await Effect.runPromise(program)).toEqual(["replaced", "replaced"]) - }) - - test("does not acquire an unused replacement", async () => { - let acquisitions = 0 - const other = Layer.succeed(Value, Value.of({ value: "other" })) - const replacement = Layer.effect( - Value, - Effect.sync(() => { - acquisitions++ - return Value.of({ value: "replacement" }) - }), - ) - await Effect.runPromise( - Effect.map(Greeting, (item) => item.value).pipe( - Effect.provide( - LayerNode.buildLayer(greeting, { tiers, replacements: [LayerNode.replace(other, replacement)] }), - ), - ), - ) - expect(acquisitions).toBe(0) - }) -}) diff --git a/packages/opencode/test/effect/scoped-node-types.test.ts b/packages/opencode/test/effect/scoped-node-types.test.ts deleted file mode 100644 index 0521e5c6a4..0000000000 --- a/packages/opencode/test/effect/scoped-node-types.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { test } from "bun:test" -import { Context, Effect, Layer } from "effect" -import { makeGlobalNode, makeLocationNode } from "@opencode-ai/core/effect/scoped-node" - -class A extends Context.Service()("test/ScopedA") {} -class B extends Context.Service()("test/ScopedB") {} - -const a = Layer.succeed(A, A.of({})) -const b = Layer.effect(B, Effect.as(A, B.of({}))) -const globalA = makeGlobalNode({ service: A, layer: a, deps: [] }) -const locationA = makeLocationNode({ service: A, layer: a, deps: [] }) - -makeGlobalNode({ service: B, layer: b, deps: [globalA] }) -makeLocationNode({ service: B, layer: b, deps: [globalA] }) -makeLocationNode({ service: B, layer: b, deps: [locationA] }) - -// @ts-expect-error Global nodes cannot depend on location nodes -makeGlobalNode({ service: B, layer: b, deps: [locationA] }) - -// @ts-expect-error B requires A -makeLocationNode({ service: B, layer: b, deps: [] }) - -test("type exploration compiles", () => {}) diff --git a/packages/opencode/test/git/git.test.ts b/packages/opencode/test/git/git.test.ts index c786513c63..ed48fa6405 100644 --- a/packages/opencode/test/git/git.test.ts +++ b/packages/opencode/test/git/git.test.ts @@ -1,4 +1,5 @@ import { $ } from "bun" +import { LayerNodeTree } from "@opencode-ai/core/effect/layer-node" import { describe, expect } from "bun:test" import fs from "fs/promises" import path from "path" @@ -9,7 +10,7 @@ import { tmpdir } from "../fixture/fixture" import { testEffect } from "../lib/effect" const weird = process.platform === "win32" ? "space file.txt" : "tab\tfile.txt" -const it = testEffect(LayerNode.buildLayer(Git.node)) +const it = testEffect(LayerNodeTree.compile(LayerNode.group([Git.node]))) const scopedTmpdir = (options?: Parameters[0]) => Effect.acquireRelease( diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 92fc6ee622..641daeead4 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -21,6 +21,7 @@ import { MessageID, PartID, SessionID } from "../../src/session/schema" import { SessionStatus } from "../../src/session/status" import { SessionSummary } from "../../src/session/summary" import { SessionV2 } from "@opencode-ai/core/session" +import { locationServiceMapLayer } from "@opencode-ai/core/location-services" import { SessionExecution } from "@opencode-ai/core/session/execution" import type { Provider } from "@/provider/provider" @@ -613,7 +614,9 @@ describe("session.compaction.create", () => { }) const v2 = yield* SessionV2.Service.use((svc) => svc.messages({ sessionID: info.id })).pipe( - Effect.provide(SessionV2.defaultLayer.pipe(Layer.provide(SessionExecution.noopLayer))), + Effect.provide(SessionV2.defaultLayer), + Effect.provide(SessionExecution.noopLayer), + Effect.provide(locationServiceMapLayer), ) expect(v2.at(-1)).toMatchObject({ type: "compaction", diff --git a/packages/opencode/test/session/messages-pagination.test.ts b/packages/opencode/test/session/messages-pagination.test.ts index 4706235f35..c9e1c3634c 100644 --- a/packages/opencode/test/session/messages-pagination.test.ts +++ b/packages/opencode/test/session/messages-pagination.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test" +import { LayerNodeTree } from "@opencode-ai/core/effect/layer-node" import { SessionV1 } from "@opencode-ai/core/v1/session" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { SessionProjector } from "@opencode-ai/core/session/projector" @@ -12,7 +13,7 @@ import { testEffect } from "../lib/effect" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" -const it = testEffect(LayerNode.buildLayer(LayerNode.group([SessionNs.node, MessageV2.node, SessionProjector.node]))) +const it = testEffect(LayerNodeTree.compile(LayerNode.group([SessionNs.node, MessageV2.node, SessionProjector.node]))) const withSession = ( fn: (input: { session: SessionNs.Interface; sessionID: SessionID }) => Effect.Effect, diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index b653bc10b2..4263c01bee 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -1,6 +1,7 @@ import { SessionV1 } from "@opencode-ai/core/v1/session" import { Database } from "@opencode-ai/core/database/database" import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { LayerNodeTree } from "@opencode-ai/core/effect/layer-node" import { EventV2Bridge } from "@/event-v2-bridge" import { expect } from "bun:test" import { tool } from "ai" @@ -179,9 +180,9 @@ const replacements = [ LayerNode.replace(SessionSummary.layer, summary), LayerNode.replace(RuntimeFlags.defaultLayer, RuntimeFlags.layer({ experimentalEventSystem: true })), ] -const env = LayerNode.buildLayer( +const env = LayerNodeTree.compile( LayerNode.group([root, LayerNode.make({ service: TestLLMServer, layer: TestLLMServer.layer, deps: [] })]), - { replacements }, + new Map(replacements.map((item) => [item.source, item.replacement])), ) const it = testEffect(env) @@ -206,9 +207,12 @@ const providerErrorLLM = Layer.succeed( ), }), ) -const providerErrorEnv = LayerNode.buildLayer(root, { - replacements: [...replacements, LayerNode.replace(LLM.layer, providerErrorLLM)], -}) +const providerErrorEnv = LayerNodeTree.compile( + root, + new Map( + [...replacements, LayerNode.replace(LLM.layer, providerErrorLLM)].map((item) => [item.source, item.replacement]), + ), +) const itProviderError = testEffect(providerErrorEnv) const fragmentFailureLLM = Layer.succeed( @@ -225,9 +229,15 @@ const fragmentFailureLLM = Layer.succeed( ), }), ) -const fragmentFailureEnv = LayerNode.buildLayer(root, { - replacements: [...replacements, LayerNode.replace(LLM.layer, fragmentFailureLLM)], -}) +const fragmentFailureEnv = LayerNodeTree.compile( + root, + new Map( + [...replacements, LayerNode.replace(LLM.layer, fragmentFailureLLM)].map((item) => [ + item.source, + item.replacement, + ]), + ), +) const itFragmentFailure = testEffect(fragmentFailureEnv) const boot = Effect.fn("test.boot")(function* () { diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 460f1acc74..bb98a867fa 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -56,7 +56,7 @@ import { reply, TestLLMServer } from "../lib/llm-server" import { RuntimeFlags } from "@/effect/runtime-flags" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" const summary = Layer.succeed( SessionSummary.Service, @@ -230,7 +230,7 @@ function makePrompt(input?: { mcpInstructions?: MCP.ServerInstructions[]; proces Layer.provide( SystemPrompt.layer.pipe( Layer.provide(Skill.defaultLayer), - Layer.provide(LocationServiceMap.layer), + Layer.provide(locationServiceMapLayer), Layer.provide(deps), ), ), @@ -697,7 +697,9 @@ noLLMServer.instance.skip( }) const messages = yield* SessionV2.Service.use((session) => session.messages({ sessionID: chat.id })).pipe( - Effect.provide(SessionV2.defaultLayer.pipe(Layer.provide(SessionExecution.noopLayer))), + Effect.provide(SessionV2.defaultLayer), + Effect.provide(SessionExecution.noopLayer), + Effect.provide(locationServiceMapLayer), ) const { db } = yield* Database.Service const row = yield* db diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index e53a6c1f18..acb82a6685 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test" +import { LayerNodeTree } from "@opencode-ai/core/effect/layer-node" import { SessionV1 } from "@opencode-ai/core/v1/session" import type { NamedError } from "@opencode-ai/core/util/error" import { APICallError } from "ai" @@ -16,7 +17,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider" const providerID = ProviderV2.ID.make("test") const retryProvider = "test" -const it = testEffect(LayerNode.buildLayer(LayerNode.group([SessionStatus.node, CrossSpawnSpawner.node]))) +const it = testEffect(LayerNodeTree.compile(LayerNode.group([SessionStatus.node, CrossSpawnSpawner.node]))) function apiError(headers?: Record): SessionV1.APIError { return Schema.decodeUnknownSync(SessionV1.APIError.Schema)( diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 313e5cf9c7..3ffc6a2636 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -14,6 +14,7 @@ import { expect } from "bun:test" import { Effect, Layer } from "effect" import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { LayerNodeTree } from "@opencode-ai/core/effect/layer-node" import fs from "fs/promises" import path from "path" import { Session } from "@/session/session" @@ -87,13 +88,16 @@ const root = LayerNode.group([ LayerNode.make({ service: TestLLMServer, layer: TestLLMServer.layer, deps: [] }), ]) const it = testEffect( - LayerNode.buildLayer(root, { - replacements: [ + LayerNodeTree.compile( + root, + new Map( + [ LayerNode.replace(MCP.layer, mcp), LayerNode.replace(LSP.layer, lsp), LayerNode.replace(RuntimeFlags.defaultLayer, RuntimeFlags.layer({ experimentalEventSystem: true })), - ], - }), + ].map((item) => [item.source, item.replacement]), + ), + ), ) const providerCfg = (url: string) => ({ diff --git a/packages/opencode/test/session/system.test.ts b/packages/opencode/test/session/system.test.ts index 548f0028c5..c3ff3788d6 100644 --- a/packages/opencode/test/session/system.test.ts +++ b/packages/opencode/test/session/system.test.ts @@ -6,7 +6,7 @@ import { Skill } from "../../src/skill" import { Permission } from "../../src/permission" import { SystemPrompt } from "../../src/session/system" import { MCP } from "../../src/mcp" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" import { testEffect } from "../lib/effect" const skills: Skill.Info[] = [ @@ -44,7 +44,7 @@ const build: Agent.Info = { const it = testEffect( SystemPrompt.layer.pipe( - Layer.provide(LocationServiceMap.layer), + Layer.provide(locationServiceMapLayer), Layer.provide( Layer.mock(MCP.Service, { instructions: () => diff --git a/packages/opencode/test/share/share-next.test.ts b/packages/opencode/test/share/share-next.test.ts index c541af5281..bce95e993a 100644 --- a/packages/opencode/test/share/share-next.test.ts +++ b/packages/opencode/test/share/share-next.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect } from "bun:test" import { Effect, Exit, Layer, Option } from "effect" import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { LayerNodeTree } from "@opencode-ai/core/effect/layer-node" import { httpClient } from "@opencode-ai/core/effect/layer-node-platform" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { SessionProjector } from "@opencode-ai/core/session/projector" @@ -19,7 +20,7 @@ import { provideTmpdirInstance } from "../fixture/fixture" import { resetDatabase } from "../fixture/db" import { pollWithTimeout, testEffect } from "../lib/effect" -const env = LayerNode.buildLayer(CrossSpawnSpawner.node) +const env = LayerNodeTree.compile(LayerNode.group([CrossSpawnSpawner.node])) const it = testEffect(env) const json = (req: Parameters[0], body: unknown, status = 200) => @@ -34,22 +35,18 @@ const json = (req: Parameters[0], body: unkno const none = HttpClient.make(() => Effect.die("unexpected http call")) function requestLayer(client: HttpClient.HttpClient) { - return LayerNode.buildLayer(LayerNode.group([ShareNext.node, AccountRepo.node]), { - replacements: [LayerNode.replace(FetchHttpClient.layer, Layer.succeed(HttpClient.HttpClient, client))], - }) + const replacement = LayerNode.replace(FetchHttpClient.layer, Layer.succeed(HttpClient.HttpClient, client)) + return LayerNodeTree.compile( + LayerNode.group([ShareNext.node, AccountRepo.node]), + new Map([[replacement.source, replacement.replacement]]), + ) } function integrationLayer(client: HttpClient.HttpClient) { - return LayerNode.buildLayer( - LayerNode.group([ - ShareNext.node, - EventV2Bridge.node, - Session.node, - SessionProjector.node, - AccountRepo.node, - Database.node, - ]), - { replacements: [LayerNode.replace(FetchHttpClient.layer, Layer.succeed(HttpClient.HttpClient, client))] }, + const replacement = LayerNode.replace(FetchHttpClient.layer, Layer.succeed(HttpClient.HttpClient, client)) + return LayerNodeTree.compile( + LayerNode.group([ShareNext.node, EventV2Bridge.node, Session.node, SessionProjector.node, AccountRepo.node, Database.node]), + new Map([[replacement.source, replacement.replacement]]), ) } diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 9c616f1a06..b32a48e9c6 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -4,6 +4,7 @@ import fs from "fs/promises" import { fileURLToPath, pathToFileURL } from "url" import { Effect, Layer, Result, Schema } from "effect" import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { LayerNodeTree } from "@opencode-ai/core/effect/layer-node" import { ToolRegistry } from "@/tool/registry" import { Tool } from "@/tool/tool" import { disposeAllInstances, TestInstance } from "../fixture/fixture" @@ -54,11 +55,17 @@ const replacements = [ LayerNode.replace(RuntimeFlags.defaultLayer, RuntimeFlags.layer()), ] -const it = testEffect(LayerNode.buildLayer(root, { replacements })) +const it = testEffect(LayerNodeTree.compile(root, new Map(replacements.map((item) => [item.source, item.replacement])))) const withBrokenPlugin = testEffect( - LayerNode.buildLayer(root, { - replacements: [...replacements, LayerNode.replace(Plugin.layer, brokenPluginLayer)], - }), + LayerNodeTree.compile( + root, + new Map( + [...replacements, LayerNode.replace(Plugin.layer, brokenPluginLayer)].map((item) => [ + item.source, + item.replacement, + ]), + ), + ), ) afterEach(async () => { diff --git a/packages/server/src/handlers.ts b/packages/server/src/handlers.ts index 33ecd4117d..3a5e2e777e 100644 --- a/packages/server/src/handlers.ts +++ b/packages/server/src/handlers.ts @@ -1,10 +1,4 @@ -import { SessionV2 } from "@opencode-ai/core/session" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" -import { PermissionSaved } from "@opencode-ai/core/permission/saved" -import { PtyTicket } from "@opencode-ai/core/pty/ticket" import { Layer } from "effect" -import { layer as locationLayer } from "./location" -import { sessionLocationLayer } from "./middleware/session-location" import { MessageHandler } from "./handlers/message" import { ModelHandler } from "./handlers/model" import { ProviderHandler } from "./handlers/provider" @@ -19,18 +13,11 @@ import { HealthHandler } from "./handlers/health" import { PtyHandler } from "./handlers/pty" import { QuestionHandler } from "./handlers/question" import { ReferenceHandler } from "./handlers/reference" -import * as SessionExecutionLocal from "@opencode-ai/core/session/execution/local" import { LocationHandler } from "./handlers/location" import { IntegrationHandler } from "./handlers/integration" import { CredentialHandler } from "./handlers/credential" -import { Credential } from "@opencode-ai/core/credential" import { ProjectCopyHandler } from "./handlers/project-copy" -const sessionLayer = SessionV2.defaultLayer.pipe( - Layer.provide(SessionExecutionLocal.defaultLayer), - Layer.provide(LocationServiceMap.layer), -) - export const handlers = Layer.mergeAll( HealthHandler, LocationHandler, @@ -50,12 +37,4 @@ export const handlers = Layer.mergeAll( QuestionHandler, ReferenceHandler, ProjectCopyHandler, -).pipe( - Layer.provide(sessionLocationLayer), - Layer.provide(locationLayer), - Layer.provide(sessionLayer), - Layer.provide(PermissionSaved.defaultLayer), - Layer.provide(PtyTicket.defaultLayer), - Layer.provide(LocationServiceMap.layer), - Layer.provide(Credential.defaultLayer), ) diff --git a/packages/server/src/location.ts b/packages/server/src/location.ts index 483a0733fd..8ae5aa6e6d 100644 --- a/packages/server/src/location.ts +++ b/packages/server/src/location.ts @@ -1,12 +1,12 @@ import { Location } from "@opencode-ai/core/location" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { LocationServiceMap } from "@opencode-ai/core/location-services" import { AbsolutePath } from "@opencode-ai/core/schema" import { WorkspaceV2 } from "@opencode-ai/core/workspace" import { Effect, Layer } from "effect" import { HttpServerRequest } from "effect/unstable/http" import { HttpApiMiddleware } from "effect/unstable/httpapi" -export type LocationServices = Layer.Success> +export type LocationServices = Layer.Success> export class LocationMiddleware extends HttpApiMiddleware.Service()( "@opencode/HttpApiLocation", @@ -49,7 +49,7 @@ function decode(input: string) { export const layer = Layer.effect( LocationMiddleware, Effect.gen(function* () { - const locations = yield* LocationServiceMap + const locations = yield* LocationServiceMap.Service return LocationMiddleware.of((effect) => Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest diff --git a/packages/server/src/middleware/session-location.ts b/packages/server/src/middleware/session-location.ts index d4e691954b..86fa80f75c 100644 --- a/packages/server/src/middleware/session-location.ts +++ b/packages/server/src/middleware/session-location.ts @@ -1,5 +1,5 @@ import { Database } from "@opencode-ai/core/database/database" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { LocationServiceMap } from "@opencode-ai/core/location-services" import { Location } from "@opencode-ai/core/location" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" @@ -25,7 +25,7 @@ export const sessionLocationLayer = Layer.effect( SessionLocationMiddleware, Effect.gen(function* () { const { db } = yield* Database.Service - const locations = yield* LocationServiceMap + const locations = yield* LocationServiceMap.Service return SessionLocationMiddleware.of((effect) => Effect.gen(function* () { diff --git a/packages/server/src/pty-environment.ts b/packages/server/src/pty-environment.ts index fe3a375cdc..44f5b5d4cf 100644 --- a/packages/server/src/pty-environment.ts +++ b/packages/server/src/pty-environment.ts @@ -1,6 +1,7 @@ export * as PtyEnvironment from "./pty-environment" import { Context, Effect, Layer } from "effect" +import { makeGlobalNode } from "@opencode-ai/core/effect/node" export interface Interface { readonly get: (input: { directory: string; cwd: string }) => Effect.Effect> @@ -8,9 +9,11 @@ export interface Interface { export class Service extends Context.Service()("@opencode/ServerPtyEnvironment") {} -export const defaultLayer = Layer.succeed( +export const layer = Layer.succeed( Service, Service.of({ get: () => Effect.succeed({}), }), ) + +export const node = makeGlobalNode({ service: Service, layer, deps: [] }) diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index 7db252a9bf..2ec8be545a 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -1,7 +1,17 @@ import { Database } from "@opencode-ai/core/database/database" +import { LayerNode, LayerNodeTree } from "@opencode-ai/core/effect/layer-node" +import { httpClient } from "@opencode-ai/core/effect/layer-node-platform" +import { NodeBuild } from "@opencode-ai/core/effect/node-build" import { EventV2 } from "@opencode-ai/core/event" -import { LocationServiceMap } from "@opencode-ai/core/location-layer" -import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http" +import { Credential } from "@opencode-ai/core/credential" +import { PermissionSaved } from "@opencode-ai/core/permission/saved" +import { PtyTicket } from "@opencode-ai/core/pty/ticket" +import { SessionV2 } from "@opencode-ai/core/session" +import { SessionExecution } from "@opencode-ai/core/session/execution" +import { LocationServiceMap } from "@opencode-ai/core/location-service-map" +import { SessionExecutionLocal } from "@opencode-ai/core/session/execution/local" +import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" +import { HttpRouter, HttpServer } from "effect/unstable/http" import { HttpApiBuilder } from "effect/unstable/httpapi" import { Layer, Option } from "effect" import { Api } from "./api" @@ -10,6 +20,21 @@ import { handlers } from "./handlers" import { authorizationLayer } from "./middleware/authorization" import { schemaErrorLayer } from "./middleware/schema-error" import { PtyEnvironment } from "./pty-environment" +import { layer as locationLayer } from "./location" +import { sessionLocationLayer } from "./middleware/session-location" + +const applicationServices = LayerNode.group([ + Database.node, + EventV2.node, + httpClient, + ToolOutputStore.cleanupNode, + SessionV2.node, + PermissionSaved.node, + PtyTicket.node, + Credential.node, + PtyEnvironment.node, + LocationServiceMap.node, +]) export function createRoutes(password?: string) { return makeRoutes( @@ -24,16 +49,18 @@ export function createEmbeddedRoutes() { } function makeRoutes(auth: Layer.Layer) { + const serviceLayer = NodeBuild.build( + LayerNodeTree.bind(applicationServices, SessionExecution.node, SessionExecutionLocal.node), + ) + return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe( Layer.provide(handlers), - Layer.provide(PtyEnvironment.defaultLayer), + Layer.provide(sessionLocationLayer), + Layer.provide(locationLayer), Layer.provide(authorizationLayer), Layer.provide(schemaErrorLayer), Layer.provide(auth), - Layer.provide(LocationServiceMap.layer), - Layer.provide(Database.defaultLayer), - Layer.provide(EventV2.defaultLayer), - Layer.provide(FetchHttpClient.layer), + Layer.provide(serviceLayer), ) } diff --git a/specs/layer-node-tiers.md b/specs/layer-node-tiers.md deleted file mode 100644 index fc2212ece8..0000000000 --- a/specs/layer-node-tiers.md +++ /dev/null @@ -1,235 +0,0 @@ -# Layer Node Tiers - -## Goal - -`LayerNode` describes the complete dependency graph while allowing groups of nodes to be constructed with different lifecycle boundaries. The abstraction must not hard-code concepts such as global or location services. - -## Node Definition - -Nodes have an Effect service tag, a layer, dependencies, and a tier. The service tag's runtime `key` identifies the node in diagnostics: - -```ts -export const node = LayerNode.make({ - service: Watcher.Service, - layer, - deps: [], - tier: location, -}) -``` - -Tier-specific makers supply the tier automatically: - -```ts -export const node = makeLocationNode({ - service: Watcher.Service, - layer, - deps: [Config.node, Git.node], -}) -``` - -## Tier Declaration - -Tiers are declared bottom-up, from the most specific lifecycle to the most foundational: - -```ts -const tiers = LayerNode.tiers(["location", "global"]) -``` - -An earlier tier may depend on its own tier or any later tier. A later tier cannot depend on an earlier tier. - -For the example above: - -- `location` may depend on `location` or `global`. -- `global` may depend only on `global`. - -### Cross-Tier Dependencies - -Dependencies on a later, more foundational tier are hoisted outside the current tier's construction boundary. For example, global dependencies of location nodes must be built outside a location `Layer.fresh` boundary. - -From the perspective of a lower tier, each service crossing into a higher tier must resolve to one unique node identity: - -- Multiple consumers may depend on the same higher-tier node. -- Two different higher-tier nodes for the same service are a conflict, even if both satisfy the same dependency type. -- This constraint applies to transitive higher-tier dependencies as well as direct dependencies. - -This validation happens while traversing dependency edges for the single complete-graph topological sort. It is not reconstructed from the flattened sorted list and is not a separate validation pass. - -The traversal must retain the lower-tier perspective when following a dependency into a higher tier. For each lower tier, it tracks the higher-tier node selected for every service key. Reaching the same service through the same node identity is valid; reaching it through a different node identity is a conflict. - -Topological visitation and boundary validation are distinct traversal state: - -- A node is emitted into the topological order once. -- A higher-tier node may need boundary validation once for each lower tier from which it is reachable. - -This distinction is required for transitive dependencies. A higher-tier node may already be topologically visited when another lower-tier branch reaches it, but that later branch must still participate in service uniqueness validation. - -The tier configuration generates correctly constrained makers: - -```ts -export const makeLocationNode = tiers.make("location") -export const makeGlobalNode = tiers.make("global") -``` - -This must reject invalid dependencies at compile time: - -```ts -makeGlobalNode({ - service: Database.Service, - layer, - deps: [locationNode], // type error -}) -``` - -## Building - -`buildLayer` remains a top-level `LayerNode` function and receives the tier configuration: - -```ts -const appLayer = LayerNode.buildLayer(root, tiers) -``` - -It performs these steps: - -1. Traverse and topologically sort the complete reachable graph once, dependency-first. -2. While traversing dependency edges, detect cycles, validate tier direction, and validate unique cross-tier service implementations from each lower tier's perspective. -3. Partition the one sorted list by tier while preserving its relative order. -4. Process tiers in their declared bottom-up order. -5. Build one layer for each tier. -6. Connect tier layers with `Layer.provideMerge` according to tier dependencies. -7. Return one final closed Effect layer. - -### One Topological Sort - -There is one topological sort for the entire graph, not one sort per tier. Every reachable node is emitted into the sorted result once. Boundary-validation state may separately process a higher-tier node once per originating lower tier; this does not create another topological sort. - -For example, one dependency-first result may be: - -```text -[globalDatabase, globalGit, locationConfig, locationWatcher] -``` - -Stable partitioning then produces: - -```text -global: [globalDatabase, globalGit] -location: [locationConfig, locationWatcher] -``` - -Because partitioning preserves relative order, dependencies within each tier remain before their consumers. Cross-tier dependencies were already validated while their dependency edges were available during traversal; validation is not attempted from the partitioned lists. - -### Provider Rebinding Within A Tier - -Topological node deduplication is not sufficient when a tier contains different nodes that provide the same service. The final linear layer plan must preserve which implementation each consumer depends on. - -For example: - -```text -ConsumerX -> X provides Service -ConsumerY -> Y provides Service -ConsumerX2 -> X provides Service -``` - -The resulting dependency-first plan must be able to represent: - -```text -ConsumerX, X, ConsumerY, Y, ConsumerX2, X -``` - -After `Y` becomes the active implementation, the later dependency on `X` must emit `X` again. A global visited set must not incorrectly remove that second placement. - -While constructing a tier's linear plan, track the active provider node for each service key: - -- If the required provider is already active, its repeated placement may be omitted. -- If a different provider for the same service is active, emit the required provider again and make it active. -- If no provider is active, emit the provider and make it active. - -Repeated placement of the same node does not imply repeated resource acquisition. Effect layer memoization may still reuse the same layer instance. The repeated placement restores the intended provider binding for subsequent consumers. - -This differs from cross-tier uniqueness. Multiple implementations may be rebound within one tier, but different implementations of the same service cannot both be hoisted across a tier boundary. - -Without a custom build function, a tier's sorted layers are combined with the default `Layer.provideMerge` behavior. - -## Custom Tier Build Function - -The optional third argument customizes how each tier's sorted layers are constructed: - -```ts -const appLayer = LayerNode.buildLayer(root, tiers, (tier, layers) => { - const combined = LayerNode.combine(layers) - - if (tier !== "location") return combined - - return Layer.effect( - LocationServiceMap, - LayerMap.make((ref: Location.Ref) => combined.pipe(Layer.provide(Location.layer(ref)), Layer.fresh), { - idleTimeToLive: "60 minutes", - }), - ) -}) -``` - -The callback receives: - -- The tier name. -- The tier's layers in the dependency-first order preserved from the single complete-graph topological sort. - -It returns the final layer representing that tier. This permits a tier to introduce a lifecycle boundary, wrap its layers in a `LayerMap`, or otherwise transform how the tier is built. - -## Replacements - -Tests and alternate runtimes may replace a specific layer implementation by exact object identity: - -```ts -const layer = LayerNode.buildLayer(root, tiers, buildTier, [LayerNode.replace(Config.layer, testConfigLayer)]) -``` - -The replacement applies to every placement of that exact source layer in the generated plans. Unused replacements are not acquired. A replacement must provide the same service output, must not introduce new errors, and must not have unresolved dependencies. - -## Freshness - -Global implementations must remain outside the location freshness boundary. Conceptually: - -```ts -locationTier.pipe(Layer.fresh).pipe(Layer.provideMerge(globalTier)) -``` - -The location tier contains only location implementations. Global dependencies are connected after the location build function creates its fresh or `LayerMap` boundary, so global services remain shared. - -## Responsibilities - -`LayerNode` owns: - -- Service tags and dependency edges. -- Tier declarations and type-safe tier makers. -- Cycle detection and diagnostics using service keys such as `Watcher.Service.key`. -- One dependency-first topological sort of the complete graph. -- Cross-tier service uniqueness validation during dependency traversal, tracked per originating lower tier. -- Stable partitioning of the sorted nodes by tier. -- Provider-aware linearization within each tier, including rebinding when different nodes provide the same service. -- Invoking the optional tier build function. -- Wiring the resulting tier layers into one final layer. - -The caller owns: - -- The meaning of each tier. -- Tier-specific lifecycle behavior. -- Specialized wrappers such as `LocationServiceMap`. - -The abstraction must not contain built-in knowledge of global, location, request, workspace, or other application-specific tiers. - -## Deferred: packages/opencode Compatibility - -The first implementation will not migrate or redesign the existing `packages/opencode` integration with core's `LocationServiceMap`. - -`packages/opencode` currently uses its own `InstanceState` lifecycle while bridging to core location services through `LocationServiceMap`. Production consumers include: - -- `packages/opencode/src/session/system.ts` -- `packages/opencode/src/agent/agent.ts` -- `packages/opencode/src/cli/cmd/debug/file.ts` -- `packages/opencode/src/cli/cmd/debug/v2.ts` -- `packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts` -- `packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts` - -Some consumers wrap `LocationServiceMap.layer` as an opaque `LayerNode`; others provide the layer directly. We need to determine how these bridges consume the tier-built core graph and how unresolved global dependencies are exposed after the new core location builder is implemented. - -This compatibility work must happen after the first tier implementation. The first implementation should preserve existing `packages/opencode` behavior and avoid changing these bridges. From 71c3a7c8f239110dfca00700216b4340d305f953 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 27 Jun 2026 02:47:33 +0000 Subject: [PATCH 14/45] chore: generate --- .../core/src/effect/layer-node/layer-node.ts | 23 +- packages/core/src/session.ts | 523 +++++++++--------- .../layer-node/layer-node-types.test.ts | 3 +- .../test/effect/layer-node/layer-node.test.ts | 8 +- packages/core/test/location-layer.test.ts | 4 +- packages/opencode/src/agent/agent.ts | 6 +- packages/opencode/src/session/system.ts | 6 +- .../test/session/processor-effect.test.ts | 5 +- .../test/session/snapshot-tool-race.test.ts | 6 +- .../opencode/test/share/share-next.test.ts | 9 +- 10 files changed, 296 insertions(+), 297 deletions(-) diff --git a/packages/core/src/effect/layer-node/layer-node.ts b/packages/core/src/effect/layer-node/layer-node.ts index 52ed1a9fae..e010d1044f 100644 --- a/packages/core/src/effect/layer-node/layer-node.ts +++ b/packages/core/src/effect/layer-node/layer-node.ts @@ -50,11 +50,7 @@ export function make< const T extends Tag | undefined = undefined, >( input: MakeInput, -): Node< - Layer.Success, - Layer.Error | Error, - T -> { +): Node, Layer.Error | Error, T> { return { kind: "layer", name: input.service !== undefined ? input.service.key : input.name, @@ -65,10 +61,7 @@ export function make< } } -export function unbound( - service: Context.Key, - tag: T, -): Node { +export function unbound(service: Context.Key, tag: T): Node { return { kind: "unbound", name: service.key, @@ -100,16 +93,12 @@ export interface Tags { ) => ( input: DistributiveOmit>, "tag"> & CheckTags>, - ) => Node< - Layer.Success, - Layer.Error | Error, - Tag - > + ) => Node, Layer.Error | Error, Tag> } -export function tags< - const Config extends { readonly [Name in keyof Config]: readonly (keyof Config & string)[] }, ->(config: Config): Tags { +export function tags( + config: Config, +): Tags { const names = Object.keys(config) as TagNames[] const values = Object.fromEntries(names.map((name) => [name, makeTag(name)])) as Tags["values"] return { diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 35f9a265af..13df855e10 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -184,271 +184,268 @@ export class Service extends Context.Service()("@opencode/v2 export const layer = Layer.effect( Service, Effect.gen(function* () { - const database = yield* Database.Service - const db = database.db - const events = yield* EventV2.Service - const projects = yield* ProjectV2.Service - const execution = yield* SessionExecution.Service - const store = yield* SessionStore.Service - const locations = yield* LocationServiceMap.Service - const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message) - const isDurableSessionEvent = Schema.is(SessionEvent.Durable) - const decode = (row: typeof SessionMessageTable.$inferSelect) => - decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe( - Effect.mapError( - () => - new MessageDecodeError({ - sessionID: SessionSchema.ID.make(row.session_id), - messageID: SessionMessage.ID.make(row.id), - }), + const database = yield* Database.Service + const db = database.db + const events = yield* EventV2.Service + const projects = yield* ProjectV2.Service + const execution = yield* SessionExecution.Service + const store = yield* SessionStore.Service + const locations = yield* LocationServiceMap.Service + const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message) + const isDurableSessionEvent = Schema.is(SessionEvent.Durable) + const decode = (row: typeof SessionMessageTable.$inferSelect) => + decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe( + Effect.mapError( + () => + new MessageDecodeError({ + sessionID: SessionSchema.ID.make(row.session_id), + messageID: SessionMessage.ID.make(row.id), + }), + ), + ) + + const result = Service.of({ + create: Effect.fn("V2Session.create")(function* (input) { + const sessionID = input.id ?? SessionSchema.ID.create() + const recorded = yield* store.get(sessionID) + if (recorded) return recorded + const project = yield* projects.resolve(input.location.directory) + yield* db + .insert(ProjectTable) + .values({ id: project.id, worktree: project.directory, vcs: project.vcs?.type, sandboxes: [] }) + .onConflictDoNothing() + .run() + .pipe(Effect.orDie) + const now = Date.now() + const info = SessionV1.SessionInfo.make({ + id: sessionID, + slug: Slug.create(), + version: InstallationVersion, + projectID: project.id, + directory: input.location.directory, + path: path.relative(project.directory, input.location.directory).replaceAll("\\", "/"), + workspaceID: input.location.workspaceID ? WorkspaceV2.ID.make(input.location.workspaceID) : undefined, + title: `New session - ${new Date(now).toISOString()}`, + agent: input.agent, + model: input.model + ? { + id: ModelV2.ID.make(input.model.id), + providerID: input.model.providerID, + variant: input.model.variant, + } + : undefined, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: now, updated: now }, + }) + const projected = yield* events + .publish(SessionV1.Event.Created, { sessionID, info }, { location: input.location }) + .pipe( + Effect.as({ type: "created" } as const), + Effect.catchDefect((defect) => { + if (!(defect instanceof SessionProjector.SessionAlreadyProjected)) { + return Effect.die(defect) + } + // Concurrent creation lost the projection race. The existing Session identity wins. + return store + .get(sessionID) + .pipe( + Effect.flatMap((session) => + session ? Effect.succeed({ type: "existing", session } as const) : Effect.die(defect), + ), + ) + }), + ) + if (projected.type === "existing") return projected.session + // TODO: Restore recorded sessions onto replacement synchronized workspaces in a future API slice. + return yield* result.get(sessionID).pipe(Effect.orDie) + }), + get: Effect.fn("V2Session.get")(function* (sessionID) { + const session = yield* store.get(sessionID) + if (!session) return yield* new NotFoundError({ sessionID }) + return session + }), + list: Effect.fn("V2Session.list")(function* (input = {}) { + const direction = input.anchor?.direction ?? "next" + const requestedOrder = input.order ?? "desc" + const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder + const sortColumn = SessionTable.time_created + const conditions: SQL[] = [] + if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory)) + if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID)) + if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project)) + if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`)) + if (input.anchor) { + conditions.push( + order === "asc" + ? or( + gt(sortColumn, input.anchor.time), + and(eq(sortColumn, input.anchor.time), gt(SessionTable.id, input.anchor.id)), + )! + : or( + lt(sortColumn, input.anchor.time), + and(eq(sortColumn, input.anchor.time), lt(SessionTable.id, input.anchor.id)), + )!, + ) + } + const query = db + .select() + .from(SessionTable) + .where(conditions.length > 0 ? and(...conditions) : undefined) + .orderBy( + order === "asc" ? asc(sortColumn) : desc(sortColumn), + order === "asc" ? asc(SessionTable.id) : desc(SessionTable.id), + ) + const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe( + Effect.orDie, + ) + return (direction === "previous" ? rows.toReversed() : rows).map((row) => fromRow(row)) + }), + messages: Effect.fn("V2Session.messages")(function* (input) { + yield* result.get(input.sessionID) + const direction = input.cursor?.direction ?? "next" + const requestedOrder = input.order ?? "desc" + const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder + const anchor = input.cursor + ? yield* db + .select({ seq: SessionMessageTable.seq }) + .from(SessionMessageTable) + .where( + and(eq(SessionMessageTable.session_id, input.sessionID), eq(SessionMessageTable.id, input.cursor.id)), + ) + .get() + .pipe(Effect.orDie) + : undefined + if (input.cursor && !anchor) return [] + const boundary = anchor + ? order === "asc" + ? gt(SessionMessageTable.seq, anchor.seq) + : lt(SessionMessageTable.seq, anchor.seq) + : undefined + const where = boundary + ? and(eq(SessionMessageTable.session_id, input.sessionID), boundary) + : eq(SessionMessageTable.session_id, input.sessionID) + const query = db + .select() + .from(SessionMessageTable) + .where(where) + .orderBy(order === "asc" ? asc(SessionMessageTable.seq) : desc(SessionMessageTable.seq)) + const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe( + Effect.orDie, + ) + return yield* Effect.forEach(direction === "previous" ? rows.toReversed() : rows, decode) + }), + message: Effect.fn("V2Session.message")(function* (input) { + const stored = yield* store.message(input.messageID) + return stored?.sessionID === input.sessionID ? stored.message : undefined + }), + context: Effect.fn("V2Session.context")(function* (sessionID) { + yield* result.get(sessionID) + return yield* store.context(sessionID) + }), + events: (input) => + Stream.unwrap( + result + .get(input.sessionID) + .pipe(Effect.as(events.durable({ aggregateID: input.sessionID, after: input.after }))), + ).pipe(Stream.filter((event): event is SessionEvent.DurableEvent => isDurableSessionEvent(event))), + history: Effect.fn("V2Session.history")(function* (input) { + yield* result.get(input.sessionID) + return yield* EventV2.readAggregate(db, { + ...input, + aggregateID: input.sessionID, + manifest: SessionDurable, + }) + }), + prompt: Effect.fn("V2Session.prompt")((input) => + Effect.uninterruptible( + Effect.gen(function* () { + yield* result.get(input.sessionID) + const prompt = resolvePrompt(input.prompt) + const messageID = input.id ?? SessionMessage.ID.create() + const delivery = input.delivery ?? "steer" + const expected = { sessionID: input.sessionID, messageID, prompt, delivery } + const admitted = yield* SessionInput.admit(db, events, { + id: messageID, + sessionID: input.sessionID, + prompt, + delivery, + }).pipe( + Effect.catchDefect((defect) => + defect instanceof SessionInput.LifecycleConflict + ? new PromptConflictError({ sessionID: input.sessionID, messageID }) + : Effect.die(defect), ), ) - - const result = Service.of({ - create: Effect.fn("V2Session.create")(function* (input) { - const sessionID = input.id ?? SessionSchema.ID.create() - const recorded = yield* store.get(sessionID) - if (recorded) return recorded - const project = yield* projects.resolve(input.location.directory) - yield* db - .insert(ProjectTable) - .values({ id: project.id, worktree: project.directory, vcs: project.vcs?.type, sandboxes: [] }) - .onConflictDoNothing() - .run() - .pipe(Effect.orDie) - const now = Date.now() - const info = SessionV1.SessionInfo.make({ - id: sessionID, - slug: Slug.create(), - version: InstallationVersion, - projectID: project.id, - directory: input.location.directory, - path: path.relative(project.directory, input.location.directory).replaceAll("\\", "/"), - workspaceID: input.location.workspaceID ? WorkspaceV2.ID.make(input.location.workspaceID) : undefined, - title: `New session - ${new Date(now).toISOString()}`, - agent: input.agent, - model: input.model - ? { - id: ModelV2.ID.make(input.model.id), - providerID: input.model.providerID, - variant: input.model.variant, - } - : undefined, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - time: { created: now, updated: now }, - }) - const projected = yield* events - .publish(SessionV1.Event.Created, { sessionID, info }, { location: input.location }) - .pipe( - Effect.as({ type: "created" } as const), - Effect.catchDefect((defect) => { - if (!(defect instanceof SessionProjector.SessionAlreadyProjected)) { - return Effect.die(defect) - } - // Concurrent creation lost the projection race. The existing Session identity wins. - return store - .get(sessionID) - .pipe( - Effect.flatMap((session) => - session ? Effect.succeed({ type: "existing", session } as const) : Effect.die(defect), - ), - ) - }), - ) - if (projected.type === "existing") return projected.session - // TODO: Restore recorded sessions onto replacement synchronized workspaces in a future API slice. - return yield* result.get(sessionID).pipe(Effect.orDie) - }), - get: Effect.fn("V2Session.get")(function* (sessionID) { - const session = yield* store.get(sessionID) - if (!session) return yield* new NotFoundError({ sessionID }) - return session - }), - list: Effect.fn("V2Session.list")(function* (input = {}) { - const direction = input.anchor?.direction ?? "next" - const requestedOrder = input.order ?? "desc" - const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder - const sortColumn = SessionTable.time_created - const conditions: SQL[] = [] - if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory)) - if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID)) - if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project)) - if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`)) - if (input.anchor) { - conditions.push( - order === "asc" - ? or( - gt(sortColumn, input.anchor.time), - and(eq(sortColumn, input.anchor.time), gt(SessionTable.id, input.anchor.id)), - )! - : or( - lt(sortColumn, input.anchor.time), - and(eq(sortColumn, input.anchor.time), lt(SessionTable.id, input.anchor.id)), - )!, - ) - } - const query = db - .select() - .from(SessionTable) - .where(conditions.length > 0 ? and(...conditions) : undefined) - .orderBy( - order === "asc" ? asc(sortColumn) : desc(sortColumn), - order === "asc" ? asc(SessionTable.id) : desc(SessionTable.id), - ) - const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe( - Effect.orDie, - ) - return (direction === "previous" ? rows.toReversed() : rows).map((row) => fromRow(row)) - }), - messages: Effect.fn("V2Session.messages")(function* (input) { - yield* result.get(input.sessionID) - const direction = input.cursor?.direction ?? "next" - const requestedOrder = input.order ?? "desc" - const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder - const anchor = input.cursor - ? yield* db - .select({ seq: SessionMessageTable.seq }) - .from(SessionMessageTable) - .where( - and( - eq(SessionMessageTable.session_id, input.sessionID), - eq(SessionMessageTable.id, input.cursor.id), - ), - ) - .get() - .pipe(Effect.orDie) - : undefined - if (input.cursor && !anchor) return [] - const boundary = anchor - ? order === "asc" - ? gt(SessionMessageTable.seq, anchor.seq) - : lt(SessionMessageTable.seq, anchor.seq) - : undefined - const where = boundary - ? and(eq(SessionMessageTable.session_id, input.sessionID), boundary) - : eq(SessionMessageTable.session_id, input.sessionID) - const query = db - .select() - .from(SessionMessageTable) - .where(where) - .orderBy(order === "asc" ? asc(SessionMessageTable.seq) : desc(SessionMessageTable.seq)) - const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe( - Effect.orDie, - ) - return yield* Effect.forEach(direction === "previous" ? rows.toReversed() : rows, decode) - }), - message: Effect.fn("V2Session.message")(function* (input) { - const stored = yield* store.message(input.messageID) - return stored?.sessionID === input.sessionID ? stored.message : undefined - }), - context: Effect.fn("V2Session.context")(function* (sessionID) { - yield* result.get(sessionID) - return yield* store.context(sessionID) - }), - events: (input) => - Stream.unwrap( - result - .get(input.sessionID) - .pipe(Effect.as(events.durable({ aggregateID: input.sessionID, after: input.after }))), - ).pipe(Stream.filter((event): event is SessionEvent.DurableEvent => isDurableSessionEvent(event))), - history: Effect.fn("V2Session.history")(function* (input) { - yield* result.get(input.sessionID) - return yield* EventV2.readAggregate(db, { - ...input, - aggregateID: input.sessionID, - manifest: SessionDurable, - }) - }), - prompt: Effect.fn("V2Session.prompt")((input) => - Effect.uninterruptible( - Effect.gen(function* () { - yield* result.get(input.sessionID) - const prompt = resolvePrompt(input.prompt) - const messageID = input.id ?? SessionMessage.ID.create() - const delivery = input.delivery ?? "steer" - const expected = { sessionID: input.sessionID, messageID, prompt, delivery } - const admitted = yield* SessionInput.admit(db, events, { - id: messageID, - sessionID: input.sessionID, - prompt, - delivery, - }).pipe( - Effect.catchDefect((defect) => - defect instanceof SessionInput.LifecycleConflict - ? new PromptConflictError({ sessionID: input.sessionID, messageID }) - : Effect.die(defect), - ), - ) - if (!SessionInput.equivalent(admitted, expected)) - return yield* new PromptConflictError({ sessionID: input.sessionID, messageID }) - if (input.resume !== false) yield* execution.wake(admitted.sessionID) - return admitted - }), - ), - ), - shell: Effect.fn("V2Session.shell")(function* () { - return yield* new OperationUnavailableError({ operation: "shell" }) - }), - skill: Effect.fn("V2Session.skill")(function* () { - return yield* new OperationUnavailableError({ operation: "skill" }) - }), - switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) { - yield* result.get(input.sessionID) - yield* events.publish(SessionEvent.AgentSwitched, { - sessionID: input.sessionID, - messageID: SessionMessage.ID.create(), - timestamp: yield* DateTime.now, - agent: input.agent, - }) - }), - switchModel: Effect.fn("V2Session.switchModel")(function* (input) { - yield* result.get(input.sessionID) - yield* events.publish(SessionEvent.ModelSwitched, { - sessionID: input.sessionID, - messageID: SessionMessage.ID.create(), - timestamp: yield* DateTime.now, - model: input.model, - }) - }), - compact: Effect.fn("V2Session.compact")(function* (input) { - yield* result.get(input.sessionID) - return yield* new OperationUnavailableError({ operation: "compact" }) - }), - wait: Effect.fn("V2Session.wait")(function* (sessionID) { - yield* result.get(sessionID) - return yield* new OperationUnavailableError({ operation: "wait" }) - }), - active: execution.active, - resume: Effect.fn("V2Session.resume")(function* (sessionID) { - yield* result.get(sessionID) - yield* execution.resume(sessionID) - }), - interrupt: Effect.fn("V2Session.interrupt")((sessionID) => - Effect.uninterruptible(execution.interrupt(sessionID)), - ), - revert: { - stage: Effect.fn("V2Session.revert.stage")(function* (input) { - const session = yield* result.get(input.sessionID) - return yield* SessionRevert.stage({ session, messageID: input.messageID, files: input.files }).pipe( - Effect.provideService(Database.Service, database), - Effect.provideService(EventV2.Service, events), - Effect.provide(locations.get(session.location)), - ) - }), - clear: Effect.fn("V2Session.revert.clear")(function* (sessionID) { - const session = yield* result.get(sessionID) - yield* SessionRevert.clear(session).pipe( - Effect.provideService(EventV2.Service, events), - Effect.provide(locations.get(session.location)), - ) - }), - commit: Effect.fn("V2Session.revert.commit")(function* (sessionID) { - const session = yield* result.get(sessionID) - yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events)) - }), - }, - }) + if (!SessionInput.equivalent(admitted, expected)) + return yield* new PromptConflictError({ sessionID: input.sessionID, messageID }) + if (input.resume !== false) yield* execution.wake(admitted.sessionID) + return admitted + }), + ), + ), + shell: Effect.fn("V2Session.shell")(function* () { + return yield* new OperationUnavailableError({ operation: "shell" }) + }), + skill: Effect.fn("V2Session.skill")(function* () { + return yield* new OperationUnavailableError({ operation: "skill" }) + }), + switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) { + yield* result.get(input.sessionID) + yield* events.publish(SessionEvent.AgentSwitched, { + sessionID: input.sessionID, + messageID: SessionMessage.ID.create(), + timestamp: yield* DateTime.now, + agent: input.agent, + }) + }), + switchModel: Effect.fn("V2Session.switchModel")(function* (input) { + yield* result.get(input.sessionID) + yield* events.publish(SessionEvent.ModelSwitched, { + sessionID: input.sessionID, + messageID: SessionMessage.ID.create(), + timestamp: yield* DateTime.now, + model: input.model, + }) + }), + compact: Effect.fn("V2Session.compact")(function* (input) { + yield* result.get(input.sessionID) + return yield* new OperationUnavailableError({ operation: "compact" }) + }), + wait: Effect.fn("V2Session.wait")(function* (sessionID) { + yield* result.get(sessionID) + return yield* new OperationUnavailableError({ operation: "wait" }) + }), + active: execution.active, + resume: Effect.fn("V2Session.resume")(function* (sessionID) { + yield* result.get(sessionID) + yield* execution.resume(sessionID) + }), + interrupt: Effect.fn("V2Session.interrupt")((sessionID) => + Effect.uninterruptible(execution.interrupt(sessionID)), + ), + revert: { + stage: Effect.fn("V2Session.revert.stage")(function* (input) { + const session = yield* result.get(input.sessionID) + return yield* SessionRevert.stage({ session, messageID: input.messageID, files: input.files }).pipe( + Effect.provideService(Database.Service, database), + Effect.provideService(EventV2.Service, events), + Effect.provide(locations.get(session.location)), + ) + }), + clear: Effect.fn("V2Session.revert.clear")(function* (sessionID) { + const session = yield* result.get(sessionID) + yield* SessionRevert.clear(session).pipe( + Effect.provideService(EventV2.Service, events), + Effect.provide(locations.get(session.location)), + ) + }), + commit: Effect.fn("V2Session.revert.commit")(function* (sessionID) { + const session = yield* result.get(sessionID) + yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events)) + }), + }, + }) return result }), diff --git a/packages/core/test/effect/layer-node/layer-node-types.test.ts b/packages/core/test/effect/layer-node/layer-node-types.test.ts index a4dce38869..8019452014 100644 --- a/packages/core/test/effect/layer-node/layer-node-types.test.ts +++ b/packages/core/test/effect/layer-node/layer-node-types.test.ts @@ -16,8 +16,7 @@ class OtherError { const tags = LayerNode.tags({ app: [] }) const make = tags.make("app") -const build = (root: LayerNode.Node) => - LayerNodeTree.compile(root) as Layer.Layer +const build = (root: LayerNode.Node) => LayerNodeTree.compile(root) as Layer.Layer const aLayer = Layer.succeed(A, A.of({})) const bLayer = Layer.effect(B, Effect.as(A, B.of({}))) const cLayer = Layer.effect( diff --git a/packages/core/test/effect/layer-node/layer-node.test.ts b/packages/core/test/effect/layer-node/layer-node.test.ts index 7c0ffe27c1..757a264f22 100644 --- a/packages/core/test/effect/layer-node/layer-node.test.ts +++ b/packages/core/test/effect/layer-node/layer-node.test.ts @@ -14,10 +14,10 @@ class App extends Context.Service } const tags = LayerNode.tags({ app: [] }) const make = tags.make("app") const build = (root: LayerNode.Node, replacements?: readonly LayerNode.Replacement[]) => - LayerNodeTree.compile( - root, - new Map(replacements?.map((item) => [item.source, item.replacement])), - ) as Layer.Layer + LayerNodeTree.compile(root, new Map(replacements?.map((item) => [item.source, item.replacement]))) as Layer.Layer< + A, + E + > const valueLayer = Layer.succeed(Value, Value.of({ value: "production" })) const greetingLayer = Layer.effect( Greeting, diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index b48bcb9ecf..5c4f21ef1f 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -111,7 +111,9 @@ describe("LocationServiceMap", () => { } }).pipe( Effect.scoped, - Effect.provide(LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(directory) }))), + Effect.provide( + LocationServiceMap.Service.get(Location.Ref.make({ directory: AbsolutePath.make(directory) })), + ), ) const blockedState = yield* update(blocked.path) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index d6b89eb6b1..1b079a7db3 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -447,7 +447,11 @@ export const defaultLayer = layer.pipe( Layer.provide(locationServiceMapLayer), ) -const locationServiceMapNode = LayerNode.make({ service: LocationServiceMap.Service, layer: locationServiceMapLayer, deps: [] }) +const locationServiceMapNode = LayerNode.make({ + service: LocationServiceMap.Service, + layer: locationServiceMapLayer, + deps: [], +}) export const node = LayerNode.make({ service: Service, diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index 29c66bab80..a4664b9b37 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -134,7 +134,11 @@ export const defaultLayer = layer.pipe( Layer.provide(locationServiceMapLayer), ) -const locationServiceMapNode = LayerNode.make({ service: LocationServiceMap.Service, layer: locationServiceMapLayer, deps: [] }) +const locationServiceMapNode = LayerNode.make({ + service: LocationServiceMap.Service, + layer: locationServiceMapLayer, + deps: [], +}) export const node = LayerNode.make({ service: Service, diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index 4263c01bee..16cd8470b1 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -232,10 +232,7 @@ const fragmentFailureLLM = Layer.succeed( const fragmentFailureEnv = LayerNodeTree.compile( root, new Map( - [...replacements, LayerNode.replace(LLM.layer, fragmentFailureLLM)].map((item) => [ - item.source, - item.replacement, - ]), + [...replacements, LayerNode.replace(LLM.layer, fragmentFailureLLM)].map((item) => [item.source, item.replacement]), ), ) const itFragmentFailure = testEffect(fragmentFailureEnv) diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 3ffc6a2636..9dbea18c14 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -92,9 +92,9 @@ const it = testEffect( root, new Map( [ - LayerNode.replace(MCP.layer, mcp), - LayerNode.replace(LSP.layer, lsp), - LayerNode.replace(RuntimeFlags.defaultLayer, RuntimeFlags.layer({ experimentalEventSystem: true })), + LayerNode.replace(MCP.layer, mcp), + LayerNode.replace(LSP.layer, lsp), + LayerNode.replace(RuntimeFlags.defaultLayer, RuntimeFlags.layer({ experimentalEventSystem: true })), ].map((item) => [item.source, item.replacement]), ), ), diff --git a/packages/opencode/test/share/share-next.test.ts b/packages/opencode/test/share/share-next.test.ts index bce95e993a..694bedc1ce 100644 --- a/packages/opencode/test/share/share-next.test.ts +++ b/packages/opencode/test/share/share-next.test.ts @@ -45,7 +45,14 @@ function requestLayer(client: HttpClient.HttpClient) { function integrationLayer(client: HttpClient.HttpClient) { const replacement = LayerNode.replace(FetchHttpClient.layer, Layer.succeed(HttpClient.HttpClient, client)) return LayerNodeTree.compile( - LayerNode.group([ShareNext.node, EventV2Bridge.node, Session.node, SessionProjector.node, AccountRepo.node, Database.node]), + LayerNode.group([ + ShareNext.node, + EventV2Bridge.node, + Session.node, + SessionProjector.node, + AccountRepo.node, + Database.node, + ]), new Map([[replacement.source, replacement.replacement]]), ) } From e1e0304a9636b8fcc745334e90fc24a4ec7e6f01 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 26 Jun 2026 22:21:16 -0500 Subject: [PATCH 15/45] fix(mcp): surface OAuth completion errors (#34145) --- packages/opencode/src/mcp/index.ts | 21 ++++++++++--------- .../test/mcp/oauth-auto-connect.test.ts | 2 +- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 7f6141ca3d..e5dbaefa41 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -28,7 +28,7 @@ import { McpAuth } from "./auth" import { EventV2Bridge } from "@/event-v2-bridge" import { TuiEvent } from "@/server/tui-event" import open from "open" -import { Cause, Effect, Exit, Layer, Option, Context, Schema, Stream } from "effect" +import { Cause, Effect, Exit, Layer, Context, Schema, Stream } from "effect" import { EffectBridge } from "@/effect/bridge" import { InstanceState } from "@/effect/instance-state" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" @@ -928,16 +928,17 @@ export const layer = Layer.effect( const pending = pendingOAuthTransports.get(mcpName) if (!pending) throw new Error(`No pending OAuth flow for MCP server: ${mcpName}`) - const result = yield* Effect.tryPromise({ - try: () => pending.transport.finishAuth(authorizationCode).then(() => true as const), - catch: (error) => { - return error - }, - }).pipe(Effect.option) + const error = yield* Effect.tryPromise({ + try: () => pending.transport.finishAuth(authorizationCode), + catch: (error) => error, + }).pipe( + Effect.match({ + onFailure: (error) => (error instanceof Error ? error.message : String(error)), + onSuccess: () => undefined, + }), + ) - if (Option.isNone(result)) { - return { status: "failed", error: "OAuth completion failed" } satisfies Status - } + if (error) return { status: "failed", error: `OAuth completion failed: ${error}` } satisfies Status yield* Effect.promise(() => pending.provider?.commit() ?? Promise.resolve()) yield* auth.clearCodeVerifier(mcpName) diff --git a/packages/opencode/test/mcp/oauth-auto-connect.test.ts b/packages/opencode/test/mcp/oauth-auto-connect.test.ts index 43b10929e4..866bf82901 100644 --- a/packages/opencode/test/mcp/oauth-auto-connect.test.ts +++ b/packages/opencode/test/mcp/oauth-auto-connect.test.ts @@ -263,7 +263,7 @@ mcpTest.instance( expect(yield* mcp.finishAuth(name, "invalid-code")).toEqual({ status: "failed", - error: "OAuth completion failed", + error: "OAuth completion failed: Token exchange failed", }) const entry = yield* auth.get(name) expect(entry?.tokens?.accessToken).toBe("working-token") From 36c416e1435819355c74a7ad7bc68cd414aa79fd Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 26 Jun 2026 22:27:43 -0500 Subject: [PATCH 16/45] fix(mcp): request refresh token scope (#34125) --- .../opencode/test/mcp/oauth-provider.test.ts | 41 +++++ .../@modelcontextprotocol%2Fsdk@1.29.0.patch | 152 ++++++++++++++++++ 2 files changed, 193 insertions(+) diff --git a/packages/opencode/test/mcp/oauth-provider.test.ts b/packages/opencode/test/mcp/oauth-provider.test.ts index 64c2cb6687..249c49e8f9 100644 --- a/packages/opencode/test/mcp/oauth-provider.test.ts +++ b/packages/opencode/test/mcp/oauth-provider.test.ts @@ -1,4 +1,5 @@ import { test, expect, describe } from "bun:test" +import { determineScope } from "@modelcontextprotocol/sdk/client/auth.js" import { McpOAuthProvider, OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "../../src/mcp/oauth-provider" import type { McpAuth } from "../../src/mcp/auth" @@ -59,3 +60,43 @@ describe("McpOAuthProvider.clientMetadata", () => { expect(provider.clientMetadata.token_endpoint_auth_method).toBe("none") }) }) + +describe("MCP OAuth scope selection", () => { + test("adds offline_access when the authorization server and client support refresh tokens", () => { + expect( + determineScope({ + resourceMetadata: { + resource: "https://mcp.example.com/mcp", + scopes_supported: ["resource.read"], + }, + authServerMetadata: { + issuer: "https://auth.example.com", + authorization_endpoint: "https://auth.example.com/authorize", + token_endpoint: "https://auth.example.com/token", + response_types_supported: ["code"], + scopes_supported: ["resource.read", "offline_access"], + }, + clientMetadata: makeProvider({}).clientMetadata, + }), + ).toBe("resource.read offline_access") + }) + + test("does not add unsupported authorization server scopes", () => { + expect( + determineScope({ + resourceMetadata: { + resource: "https://mcp.example.com/mcp", + scopes_supported: ["resource.read"], + }, + authServerMetadata: { + issuer: "https://auth.example.com", + authorization_endpoint: "https://auth.example.com/authorize", + token_endpoint: "https://auth.example.com/token", + response_types_supported: ["code"], + scopes_supported: ["resource.read"], + }, + clientMetadata: makeProvider({}).clientMetadata, + }), + ).toBe("resource.read") + }) +}) diff --git a/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch b/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch index ae25202873..e38e68e75d 100644 --- a/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch +++ b/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch @@ -221,6 +221,158 @@ index 3617e787f0ba70447c99501aee7aa67584d89758..4a96d6a0328fa348b96f3869ab7e0bb7 this._cleanupTimeout(messageId); reject(error); }); +diff --git a/dist/cjs/client/auth.d.ts b/dist/cjs/client/auth.d.ts +index f4363ce7c94fbddf0e1d5943b1b26682bdbaa40e..e7dd57096e4f056bcd735d5081433beea1b32f04 100644 +--- a/dist/cjs/client/auth.d.ts ++++ b/dist/cjs/client/auth.d.ts +@@ -205,6 +205,15 @@ export declare function parseErrorResponse(input: Response | string): Promise; ++/** ++ * Selects scopes per the MCP spec and augments them for refresh token support. ++ */ ++export declare function determineScope(options: { ++ requestedScope?: string; ++ resourceMetadata?: OAuthProtectedResourceMetadata; ++ authServerMetadata?: AuthorizationServerMetadata; ++ clientMetadata: OAuthClientMetadata; ++}): string | undefined; + /** + * Orchestrates the full auth flow with a server. + * +diff --git a/dist/cjs/client/auth.js b/dist/cjs/client/auth.js +index c2e4fa91d26f5336889f6afa416147db75fc4872..178d7cfd96412d53bc14bbc13a8f76c11f727ee7 100644 +--- a/dist/cjs/client/auth.js ++++ b/dist/cjs/client/auth.js +@@ -7,6 +7,7 @@ exports.UnauthorizedError = void 0; + exports.selectClientAuthMethod = selectClientAuthMethod; + exports.parseErrorResponse = parseErrorResponse; + exports.auth = auth; ++exports.determineScope = determineScope; + exports.isHttpsUrl = isHttpsUrl; + exports.selectResourceURL = selectResourceURL; + exports.extractWWWAuthenticateParams = extractWWWAuthenticateParams; +@@ -186,6 +187,19 @@ async function auth(provider, options) { + throw error; + } + } ++/** ++ * Selects scopes per the MCP spec and augments them for refresh token support. ++ */ ++function determineScope({ requestedScope, resourceMetadata, authServerMetadata, clientMetadata }) { ++ let effectiveScope = requestedScope || resourceMetadata?.scopes_supported?.join(' ') || clientMetadata.scope; ++ if (effectiveScope && ++ authServerMetadata?.scopes_supported?.includes('offline_access') && ++ !effectiveScope.split(' ').includes('offline_access') && ++ clientMetadata.grant_types?.includes('refresh_token')) { ++ effectiveScope = `${effectiveScope} offline_access`; ++ } ++ return effectiveScope; ++} + async function authInternal(provider, { serverUrl, authorizationCode, scope, resourceMetadataUrl, fetchFn }) { + // Check if the provider has cached discovery state to skip discovery + const cachedState = await provider.discoveryState?.(); +@@ -241,12 +255,12 @@ async function authInternal(provider, { serverUrl, authorizationCode, scope, res + }); + } + const resource = await selectResourceURL(serverUrl, provider, resourceMetadata); +- // Apply scope selection strategy (SEP-835): +- // 1. WWW-Authenticate scope (passed via `scope` param) +- // 2. PRM scopes_supported +- // 3. Client metadata scope (user-configured fallback) +- // The resolved scope is used consistently for both DCR and the authorization request. +- const resolvedScope = scope || resourceMetadata?.scopes_supported?.join(' ') || provider.clientMetadata.scope; ++ const resolvedScope = determineScope({ ++ requestedScope: scope, ++ resourceMetadata, ++ authServerMetadata: metadata, ++ clientMetadata: provider.clientMetadata ++ }); + // Handle client registration if needed + let clientInformation = await Promise.resolve(provider.clientInformation()); + if (!clientInformation) { +@@ -741,7 +755,7 @@ async function startAuthorization(authorizationServerUrl, { metadata, clientInfo + if (scope) { + authorizationUrl.searchParams.set('scope', scope); + } +- if (scope?.includes('offline_access')) { ++ if (scope?.split(' ').includes('offline_access')) { + // if the request includes the OIDC-only "offline_access" scope, + // we need to set the prompt to "consent" to ensure the user is prompted to grant offline access + // https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess +diff --git a/dist/esm/client/auth.d.ts b/dist/esm/client/auth.d.ts +index f4363ce7c94fbddf0e1d5943b1b26682bdbaa40e..e7dd57096e4f056bcd735d5081433beea1b32f04 100644 +--- a/dist/esm/client/auth.d.ts ++++ b/dist/esm/client/auth.d.ts +@@ -205,6 +205,15 @@ export declare function parseErrorResponse(input: Response | string): Promise; ++/** ++ * Selects scopes per the MCP spec and augments them for refresh token support. ++ */ ++export declare function determineScope(options: { ++ requestedScope?: string; ++ resourceMetadata?: OAuthProtectedResourceMetadata; ++ authServerMetadata?: AuthorizationServerMetadata; ++ clientMetadata: OAuthClientMetadata; ++}): string | undefined; + /** + * Orchestrates the full auth flow with a server. + * +diff --git a/dist/esm/client/auth.js b/dist/esm/client/auth.js +index e183040fc2bba22ca1ccc784984f3310854403b7..d367661e580ee61a96654f7af78b2af61dcad98b 100644 +--- a/dist/esm/client/auth.js ++++ b/dist/esm/client/auth.js +@@ -161,6 +161,19 @@ export async function auth(provider, options) { + throw error; + } + } ++/** ++ * Selects scopes per the MCP spec and augments them for refresh token support. ++ */ ++export function determineScope({ requestedScope, resourceMetadata, authServerMetadata, clientMetadata }) { ++ let effectiveScope = requestedScope || resourceMetadata?.scopes_supported?.join(' ') || clientMetadata.scope; ++ if (effectiveScope && ++ authServerMetadata?.scopes_supported?.includes('offline_access') && ++ !effectiveScope.split(' ').includes('offline_access') && ++ clientMetadata.grant_types?.includes('refresh_token')) { ++ effectiveScope = `${effectiveScope} offline_access`; ++ } ++ return effectiveScope; ++} + async function authInternal(provider, { serverUrl, authorizationCode, scope, resourceMetadataUrl, fetchFn }) { + // Check if the provider has cached discovery state to skip discovery + const cachedState = await provider.discoveryState?.(); +@@ -216,12 +229,12 @@ async function authInternal(provider, { serverUrl, authorizationCode, scope, res + }); + } + const resource = await selectResourceURL(serverUrl, provider, resourceMetadata); +- // Apply scope selection strategy (SEP-835): +- // 1. WWW-Authenticate scope (passed via `scope` param) +- // 2. PRM scopes_supported +- // 3. Client metadata scope (user-configured fallback) +- // The resolved scope is used consistently for both DCR and the authorization request. +- const resolvedScope = scope || resourceMetadata?.scopes_supported?.join(' ') || provider.clientMetadata.scope; ++ const resolvedScope = determineScope({ ++ requestedScope: scope, ++ resourceMetadata, ++ authServerMetadata: metadata, ++ clientMetadata: provider.clientMetadata ++ }); + // Handle client registration if needed + let clientInformation = await Promise.resolve(provider.clientInformation()); + if (!clientInformation) { +@@ -716,7 +729,7 @@ export async function startAuthorization(authorizationServerUrl, { metadata, cli + if (scope) { + authorizationUrl.searchParams.set('scope', scope); + } +- if (scope?.includes('offline_access')) { ++ if (scope?.split(' ').includes('offline_access')) { + // if the request includes the OIDC-only "offline_access" scope, + // we need to set the prompt to "consent" to ensure the user is prompted to grant offline access + // https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess diff --git a/dist/esm/client/index.js b/dist/esm/client/index.js index 49b12c6cd918c457420fef7ad5528a9443d1a191..2afe2e22e960f26c9d516ef135d89f8eb9e4caff 100644 --- a/dist/esm/client/index.js From a3035c53eac1067c49c2b358408b7b015922f71f Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 27 Jun 2026 03:47:28 +0000 Subject: [PATCH 17/45] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index b0212de9b0..03ca8007f8 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-rB/CcrBUQVZ08nBFSYA8u2w88rQmTpKxKPkIreDEKgI=", - "aarch64-linux": "sha256-ZRTphtic8Ip96MnILteFgZAUxjK9O4YfJu2O6u/0H8k=", - "aarch64-darwin": "sha256-VK5XIzraP0HtqnPwPCejiDKer4ewtNtX1vxP5uuyjSk=", - "x86_64-darwin": "sha256-ZLPHqcCZB1EmxQk95cmUpiODTTKOyi7PSF0yr/rDk6Y=" + "x86_64-linux": "sha256-gwtjcIUnd0qdzrlbn2lLf+R75MCEivFT9sBGMX05xy0=", + "aarch64-linux": "sha256-vP3htk3wMHwS/dy0idYQNVNtUbIGCz+SriESOTYA11I=", + "aarch64-darwin": "sha256-OGhb0sQBYdRzSVnbC6lBWM+HLgcFhgIrhC9sUuHDGZY=", + "x86_64-darwin": "sha256-nWUyoGJk0YaupqRjaHeK4IVDKNPkdXQ/n6RGJiS7u6I=" } } From fab8ec4f54b48fdf9168d47f73c5d1d98a005863 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Sat, 27 Jun 2026 14:01:45 +1000 Subject: [PATCH 18/45] fix(app): migrate composer tooltips to v2 (#34147) Co-authored-by: Jay V Co-authored-by: opencode-agent[bot] --- packages/app/src/components/prompt-input.tsx | 77 +++++++++++++++----- 1 file changed, 57 insertions(+), 20 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 56f5296a89..e6f933f0f7 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -35,6 +35,8 @@ import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface" import { Icon } from "@opencode-ai/ui/icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" +import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { IconButton } from "@opencode-ai/ui/icon-button" import { Select } from "@opencode-ai/ui/select" import { useDialog } from "@opencode-ai/ui/context/dialog" @@ -1362,7 +1364,7 @@ export const PromptInput: Component = (props) => { shouldAnimate: providersShouldFadeIn(), paid: props.controls.model.paid, title: language.t("command.model.choose"), - keybind: command.keybind("model.choose"), + keybind: command.keybindParts("model.choose"), model: props.controls.model.selection, providerID: props.controls.model.selection.current()?.provider?.id, modelName: props.controls.model.selection.current()?.name ?? language.t("dialog.model.select.title"), @@ -1379,7 +1381,7 @@ export const PromptInput: Component = (props) => { const showAgentControl = createMemo(() => props.controls.agents.visible && props.controls.agents.options.length > 0) const agentControlState = createMemo(() => ({ title: language.t("command.agent.cycle"), - keybind: command.keybind("agent.cycle"), + keybind: command.keybindParts("agent.cycle"), options: props.controls.agents.options, current: props.controls.agents.current, style: control(), @@ -1497,10 +1499,14 @@ export const PromptInput: Component = (props) => {
{fileAttachmentInput()} - + {language.t("prompt.action.attachFile")} + + + } > = (props) => { tabIndex={store.mode === "normal" ? undefined : -1} aria-label={language.t("prompt.action.attachFile")} /> - + @@ -1529,11 +1535,15 @@ export const PromptInput: Component = (props) => { !props.controls.model.selection.variant.current() && !store.variantOpen, }} > - + {language.t("command.model.variant.cycle")} + + + } > - +
) } @@ -1966,7 +1985,16 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) { + + {props.state.title} + + + } + > - + } > - + + {props.state.title} + + + } + > - + ) From 93159bccbf32c63a9f4510780d940131c2b3438b Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 27 Jun 2026 00:05:29 -0400 Subject: [PATCH 19/45] feat(core): port v2 runtime fixes onto dev Cherry-picks the packages/core changes from the v2 branch onto dev: - combined ordered stdout/stderr in AppProcess + bash structured output - edit/apply-patch return FileDiff info with status and line stats - ignore no-op model switches; record reasoning timestamps - return unexpected local tool defects to the model and continue - keep OAuth account metadata out of request bodies - nest OpenAI reasoning effort/summary options - load OpenCode provider config asynchronously; batch plugin boot - export latest public event manifest Includes the supporting schema reasoning time field and regenerated client/SDK types. --- bun.lock | 1 + packages/client/src/generated/types.ts | 2 + packages/core/package.json | 1 + packages/core/src/plugin/internal.ts | 31 +++---- packages/core/src/plugin/provider/opencode.ts | 9 ++- packages/core/src/process.ts | 19 +++++ packages/core/src/public-event-manifest.ts | 6 +- packages/core/src/session.ts | 8 +- packages/core/src/session/message-updater.ts | 2 + packages/core/src/session/runner/llm.ts | 3 +- packages/core/src/session/runner/model.ts | 2 +- packages/core/src/tool/apply-patch.ts | 56 ++++++++++--- packages/core/src/tool/bash.ts | 66 +++++++-------- packages/core/src/tool/edit.ts | 28 +++++-- packages/core/src/tool/tool.ts | 34 ++++++-- .../core/src/v1/config/provider-options.ts | 9 +++ packages/core/test/config/config.test.ts | 4 +- .../core/test/config/provider-options.test.ts | 8 +- .../test/plugin/provider-opencode.test.ts | 30 ++++++- packages/core/test/process/process.test.ts | 16 ++++ packages/core/test/session-create.test.ts | 22 ++++- .../core/test/session-runner-model.test.ts | 22 +++++ packages/core/test/session-runner.test.ts | 16 +++- packages/core/test/tool-apply-patch.test.ts | 23 ++++++ packages/core/test/tool-bash.test.ts | 80 +++++++++++-------- packages/core/test/tool-edit.test.ts | 13 ++- packages/schema/src/session-message.ts | 4 + packages/sdk/js/src/v2/gen/types.gen.ts | 4 + 28 files changed, 387 insertions(+), 132 deletions(-) diff --git a/bun.lock b/bun.lock index 84c4f9e500..2bdc2813c4 100644 --- a/bun.lock +++ b/bun.lock @@ -317,6 +317,7 @@ "ai-gateway-provider": "3.1.2", "bun-pty": "0.4.8", "cross-spawn": "catalog:", + "diff": "catalog:", "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index da0b97a7c0..88f1f89b31 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -512,6 +512,7 @@ export type SessionsContextOutput = { readonly id: string readonly text: string readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } + readonly time?: { readonly created: number; readonly completed?: number } } | { readonly type: "tool" @@ -1589,6 +1590,7 @@ export type SessionsMessageOutput = { readonly id: string readonly text: string readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } + readonly time?: { readonly created: number; readonly completed?: number } } | { readonly type: "tool" diff --git a/packages/core/package.json b/packages/core/package.json index 62d279d311..636385e991 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -104,6 +104,7 @@ "ai-gateway-provider": "3.1.2", "bun-pty": "0.4.8", "cross-spawn": "catalog:", + "diff": "catalog:", "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index 461d2297f1..6e25e48a91 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -25,6 +25,7 @@ import { Npm } from "../npm" import { PluginV2 } from "../plugin" import { Reference } from "../reference" import { SkillV2 } from "../skill" +import { State } from "../state" import { FetchHttpClient, HttpClient } from "effect/unstable/http" import { AgentPlugin } from "./agent" import { CommandPlugin } from "./command" @@ -104,20 +105,22 @@ const layer = Layer.effectDiscard( return plugin.add(PluginV2.ID.make(loaded.id), loaded.effect) } - yield* Effect.gen(function* () { - yield* add(ConfigReferencePlugin.Plugin) - yield* add(AgentPlugin.Plugin) - yield* add(CommandPlugin.Plugin) - yield* add(SkillPlugin.Plugin) - yield* add(ModelsDevPlugin) - yield* add(ConfigAgentPlugin.Plugin) - yield* add(ConfigCommandPlugin.Plugin) - yield* add(ConfigSkillPlugin.Plugin) - for (const item of ProviderPlugins) yield* add(item) - yield* add(ConfigExternalPlugin.Plugin) - yield* add(ConfigProviderPlugin.Plugin) - yield* add(VariantPlugin.Plugin) - }).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true })) + yield* State.batch( + Effect.gen(function* () { + yield* add(ConfigReferencePlugin.Plugin) + yield* add(AgentPlugin.Plugin) + yield* add(CommandPlugin.Plugin) + yield* add(SkillPlugin.Plugin) + yield* add(ModelsDevPlugin) + yield* add(ConfigAgentPlugin.Plugin) + yield* add(ConfigCommandPlugin.Plugin) + yield* add(ConfigSkillPlugin.Plugin) + for (const item of ProviderPlugins) yield* add(item) + yield* add(ConfigExternalPlugin.Plugin) + yield* add(ConfigProviderPlugin.Plugin) + yield* add(VariantPlugin.Plugin) + }), + ).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true })) }), ) diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index 7faa107856..f07d72e5f4 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/packages/core/src/plugin/provider/opencode.ts @@ -1,4 +1,4 @@ -import { Duration, Effect, Schema, Stream } from "effect" +import { Duration, Effect, Schema, Semaphore, Stream } from "effect" import type { Scope } from "effect" import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration" import { define } from "@opencode-ai/plugin/v2/effect/plugin" @@ -79,6 +79,7 @@ export const OpencodePlugin = define { for (const [providerID, item] of Object.entries(providers ?? {})) { catalog.provider.update(providerID, (provider) => { @@ -176,11 +177,13 @@ export const OpencodePlugin = define loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload()))) yield* events.subscribe(Integration.Event.ConnectionUpdated).pipe( Stream.filter((event) => event.data.integrationID === Integration.ID.make("opencode")), - Stream.runForEach(() => load().pipe(Effect.andThen(ctx.catalog.reload()))), + Stream.runForEach(refresh), Effect.forkScoped({ startImmediately: true }), ) + yield* refresh().pipe(Effect.forkScoped) }), }) diff --git a/packages/core/src/process.ts b/packages/core/src/process.ts index ef7b6f78ab..417413e93b 100644 --- a/packages/core/src/process.ts +++ b/packages/core/src/process.ts @@ -20,6 +20,7 @@ export class AppProcessError extends Schema.TaggedErrorClass()( } export interface RunOptions { + readonly combineOutput?: boolean readonly maxOutputBytes?: number readonly maxErrorBytes?: number readonly signal?: AbortSignal @@ -37,8 +38,10 @@ export interface RunStreamOptions { export interface RunResult { readonly command: string readonly exitCode: number + readonly output?: Buffer readonly stdout: Buffer readonly stderr: Buffer + readonly outputTruncated?: boolean readonly stdoutTruncated: boolean readonly stderrTruncated: boolean } @@ -143,6 +146,22 @@ export const layer = Layer.effect( const collect = Effect.scoped( Effect.gen(function* () { const handle = yield* spawner.spawn(command) + if (options?.combineOutput) { + const [output, exitCode] = yield* Effect.all( + [collectStream(handle.all, options.maxOutputBytes), handle.exitCode], + { concurrency: "unbounded" }, + ) + return { + command: description, + exitCode, + output: output.buffer, + stdout: Buffer.alloc(0), + stderr: Buffer.alloc(0), + outputTruncated: output.truncated, + stdoutTruncated: false, + stderrTruncated: false, + } satisfies RunResult + } const [stdout, stderr, exitCode] = yield* Effect.all( [ collectStream(handle.stdout, options?.maxOutputBytes), diff --git a/packages/core/src/public-event-manifest.ts b/packages/core/src/public-event-manifest.ts index 99c208d906..11b84f7905 100644 --- a/packages/core/src/public-event-manifest.ts +++ b/packages/core/src/public-event-manifest.ts @@ -1,3 +1,7 @@ export * as PublicEventManifest from "./public-event-manifest" -export { ServerDefinitions as Definitions } from "@opencode-ai/schema/event-manifest" +import { Event } from "@opencode-ai/schema/event" +import { EventManifest } from "@opencode-ai/schema/event-manifest" + +export const Definitions = EventManifest.ServerDefinitions +export const Latest = Event.latest(Definitions) diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 13df855e10..ad2cea6989 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -400,7 +400,13 @@ export const layer = Layer.effect( }) }), switchModel: Effect.fn("V2Session.switchModel")(function* (input) { - yield* result.get(input.sessionID) + const session = yield* result.get(input.sessionID) + if ( + session.model?.providerID === input.model.providerID && + session.model.id === input.model.id && + (session.model.variant ?? "default") === (input.model.variant ?? "default") + ) + return yield* events.publish(SessionEvent.ModelSwitched, { sessionID: input.sessionID, messageID: SessionMessage.ID.create(), diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index a97afb58b6..46118a89fe 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -349,6 +349,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { id: event.data.reasoningID, text: "", providerMetadata: event.data.providerMetadata, + time: { created: event.data.timestamp }, }), ), ) @@ -365,6 +366,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { const match = latestReasoning(draft, event.data.reasoningID) if (match) { match.text = event.data.text + match.time = { created: match.time?.created ?? event.data.timestamp, completed: event.data.timestamp } if (event.data.providerMetadata !== undefined) match.providerMetadata = event.data.providerMetadata } }) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 3e22bfe3c6..07d6e6f5f9 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -335,7 +335,8 @@ export const layer = Layer.effect( if (stream._tag === "Success" && !publisher.hasProviderError()) yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true)) if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause) - if (settled._tag === "Failure") return yield* Effect.failCause(settled.cause) + if (settled._tag === "Failure" && Cause.hasInterrupts(settled.cause)) + return yield* Effect.failCause(settled.cause) return { needsContinuation: !publisher.hasProviderError() && needsContinuation, step: currentStep } }), ) diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index ffe6deea01..5ce173d150 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -133,7 +133,7 @@ export const fromCatalogModel = ( credential?: Credential.Value, ): Effect.Effect => { const resolved = - credential?.metadata === undefined + credential?.type !== "key" || credential.metadata === undefined ? model : produce(model, (draft) => { Object.assign(draft.request.body, credential.metadata) diff --git a/packages/core/src/tool/apply-patch.ts b/packages/core/src/tool/apply-patch.ts index 78e5b0f40d..05e8e59fbb 100644 --- a/packages/core/src/tool/apply-patch.ts +++ b/packages/core/src/tool/apply-patch.ts @@ -1,6 +1,8 @@ export * as ApplyPatchTool from "./apply-patch" import { ToolFailure } from "@opencode-ai/llm" +import { FileDiff } from "@opencode-ai/schema/file-diff" +import { createTwoFilesPatch, diffLines } from "diff" import { Effect, Layer, Schema } from "effect" import { FileMutation } from "../file-mutation" import { FSUtil } from "../fs-util" @@ -24,7 +26,10 @@ export const Applied = Schema.Struct({ target: Schema.String, }) -export const Output = Schema.Struct({ applied: Schema.Array(Applied) }) +export const Output = Schema.Struct({ + applied: Schema.Array(Applied), + files: Schema.Array(FileDiff.Info), +}) export type Output = typeof Output.Type export const toModelOutput = (output: Output) => @@ -36,11 +41,17 @@ export const toModelOutput = (output: Output) => ].join("\n") type Prepared = - | (Extract & { readonly target: LocationMutation.Target }) + | (Extract & { + readonly target: LocationMutation.Target + readonly before: string + readonly after: string + }) | (Extract & { readonly target: LocationMutation.Target readonly source: Uint8Array readonly content: string + readonly before: string + readonly after: string }) export const layer = Layer.effectDiscard( @@ -113,29 +124,36 @@ export const layer = Layer.effectDiscard( for (const { hunk, target } of targets) { yield* Effect.gen(function* () { if (hunk.type === "add") { - prepared.push({ ...hunk, target }) + prepared.push({ + ...hunk, + target, + before: "", + after: + hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`, + }) return } if ((yield* fs.stat(target.canonical)).type !== "File") yield* fail(hunk.path) + const source = yield* fs.readFile(target.canonical) + const original = new TextDecoder("utf-8", { ignoreBOM: true }).decode(source) + const before = original.replace(/^\uFEFF/, "") if (hunk.type === "delete") { - prepared.push({ ...hunk, target }) + prepared.push({ ...hunk, target, before, after: "" }) return } - const source = yield* fs.readFile(target.canonical) - const update = Patch.derive( - hunk.path, - hunk.chunks, - new TextDecoder("utf-8", { ignoreBOM: true }).decode(source), - ) + const update = Patch.derive(hunk.path, hunk.chunks, original) prepared.push({ ...hunk, target, source, content: Patch.joinBom(update.content, update.bom), + before, + after: update.content, }) }).pipe(Effect.mapError(() => fail(hunk.path))) } + const patchFiles = prepared.map(patchFile) yield* Effect.forEach( prepared, (change) => @@ -165,7 +183,7 @@ export const layer = Layer.effectDiscard( }).pipe(Effect.mapError(() => fail(change.path))), { discard: true }, ) - return { applied } + return { applied, files: patchFiles } }).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch")))) }, }), @@ -175,3 +193,19 @@ export const layer = Layer.effectDiscard( .pipe(Effect.orDie) }), ) + +function patchFile(change: Prepared): typeof FileDiff.Info.Type { + const counts = diffLines(change.before, change.after).reduce( + (result, item) => ({ + additions: result.additions + (item.added ? (item.count ?? 0) : 0), + deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0), + }), + { additions: 0, deletions: 0 }, + ) + return { + file: change.target.resource, + patch: createTwoFilesPatch(change.target.resource, change.target.resource, change.before, change.after), + status: change.type === "add" ? "added" : change.type === "delete" ? "deleted" : "modified", + ...counts, + } +} diff --git a/packages/core/src/tool/bash.ts b/packages/core/src/tool/bash.ts index f86cbdd69e..19507310c2 100644 --- a/packages/core/src/tool/bash.ts +++ b/packages/core/src/tool/bash.ts @@ -30,16 +30,15 @@ export const Input = Schema.Struct({ }), }) -const Output = Schema.Struct({ - command: Schema.String, - cwd: Schema.String, - exitCode: Schema.Number.pipe(Schema.optional), - /** Bounded compact equivalent of stdout/stderr: stderr is labeled when present. */ - output: Schema.String, +const StructuredOutput = Schema.Struct({ + exit: Schema.Number.pipe(Schema.optional), truncated: Schema.Boolean, - stdoutTruncated: Schema.Boolean.pipe(Schema.optional), - stderrTruncated: Schema.Boolean.pipe(Schema.optional), - timedOut: Schema.Boolean.pipe(Schema.optional), + timeout: Schema.Boolean.pipe(Schema.optional), +}) + +const Output = Schema.Struct({ + ...StructuredOutput.fields, + output: Schema.String, warnings: Schema.Array(Schema.String).pipe(Schema.optional), }) @@ -47,24 +46,12 @@ type Output = typeof Output.Type const defaultShell = () => (process.platform === "win32" ? (process.env.COMSPEC ?? "cmd.exe") : "/bin/sh") -const compactOutput = (stdout: string, stderr: string) => { - const output = stdout && stderr ? `${stdout}\n\nstderr:\n${stderr}` : stderr ? `stderr:\n${stderr}` : stdout - return output || "(no output)" -} - -const captureNotice = (stdoutTruncated: boolean, stderrTruncated: boolean) => { - if (stdoutTruncated && stderrTruncated) return "[stdout and stderr capture truncated at the in-memory safety limit]" - if (stdoutTruncated) return "[stdout capture truncated at the in-memory safety limit]" - if (stderrTruncated) return "[stderr capture truncated at the in-memory safety limit]" - return undefined -} - const modelOutput = (output: Output) => { const warnings = output.warnings?.length ? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}` : "" - if (output.timedOut) return `${output.output}${warnings}\n\nCommand timed out before completion.` - return `${output.output}${warnings}\n\nCommand exited with code ${output.exitCode}.` + if (output.timeout) return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command timed out before completion.` + return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command exited with code ${output.exit}.` } const isTimeout = (error: AppProcess.AppProcessError) => @@ -116,7 +103,16 @@ export const layer = Layer.effectDiscard( description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. Timeout values are milliseconds (default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows.`, input: Input, output: Output, - toModelOutput: ({ output }) => [{ type: "text", text: modelOutput(output) }], + structured: StructuredOutput, + toStructuredOutput: ({ output }) => ({ + truncated: output.truncated, + ...(output.exit === undefined ? {} : { exit: output.exit }), + ...(output.timeout === undefined ? {} : { timeout: output.timeout }), + }), + toModelOutput: ({ output }) => [ + { type: "text", text: output.output }, + { type: "text", text: modelOutput(output) }, + ], execute: (input, context) => Effect.gen(function* () { const source = { @@ -163,9 +159,9 @@ export const layer = Layer.effectDiscard( const timeout = input.timeout ?? DEFAULT_TIMEOUT_MS const result = yield* appProcess .run(command, { + combineOutput: true, timeout: Duration.millis(timeout), maxOutputBytes: MAX_CAPTURE_BYTES, - maxErrorBytes: MAX_CAPTURE_BYTES, }) .pipe( Effect.catchTag("AppProcessError", (error) => @@ -174,26 +170,22 @@ export const layer = Layer.effectDiscard( ) if (!result) { return { - command: input.command, - cwd: target.canonical, output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`, truncated: false, - timedOut: true, + timeout: true, ...(warnings.length ? { warnings } : {}), } } - const compact = compactOutput(result.stdout.toString("utf8"), result.stderr.toString("utf8")) - const notice = captureNotice(result.stdoutTruncated, result.stderrTruncated) + const output = result.output?.toString("utf8") || "(no output)" + const notice = result.outputTruncated + ? "[output capture truncated at the in-memory safety limit]" + : undefined return { - command: input.command, - cwd: target.canonical, - exitCode: result.exitCode, - output: notice ? `${compact}\n\n${notice}` : compact, - truncated: result.stdoutTruncated || result.stderrTruncated, + exit: result.exitCode, + output: notice ? `${output}\n\n${notice}` : output, + truncated: result.outputTruncated === true, ...(warnings.length ? { warnings } : {}), - ...(result.stdoutTruncated ? { stdoutTruncated: true } : {}), - ...(result.stderrTruncated ? { stderrTruncated: true } : {}), } }).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` }))), }), diff --git a/packages/core/src/tool/edit.ts b/packages/core/src/tool/edit.ts index 9b12704a22..fb5af18706 100644 --- a/packages/core/src/tool/edit.ts +++ b/packages/core/src/tool/edit.ts @@ -7,6 +7,8 @@ export * as EditTool from "./edit" import { ToolFailure } from "@opencode-ai/llm" +import { FileDiff } from "@opencode-ai/schema/file-diff" +import { createTwoFilesPatch, diffLines } from "diff" import { Effect, Layer, Schema } from "effect" import { FileMutation } from "../file-mutation" import { FSUtil } from "../fs-util" @@ -30,10 +32,7 @@ export const Input = Schema.Struct({ }) export const Output = Schema.Struct({ - operation: Schema.Literal("write"), - target: Schema.String, - resource: Schema.String, - existed: Schema.Boolean, + files: Schema.Array(FileDiff.Info), replacements: Schema.Number, }) export type Output = typeof Output.Type @@ -71,7 +70,7 @@ const previewLines = (value: string, prefix: "+" | "-") => { export const toModelOutput = (output: Output, oldString: string, newString: string) => [ - `Edited file successfully: ${output.resource}`, + `Edited file successfully: ${output.files[0]?.file}`, `Replacements: ${output.replacements}`, "```diff", ...previewLines(oldString, "-"), @@ -179,6 +178,13 @@ export const layer = Layer.effectDiscard( input.replaceAll === true ? source.text.replaceAll(oldString, newString) : source.text.replace(oldString, newString) + const counts = diffLines(source.text, replaced).reduce( + (result, item) => ({ + additions: result.additions + (item.added ? (item.count ?? 0) : 0), + deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0), + }), + { additions: 0, deletions: 0 }, + ) const next = splitBom(replaced) const result = yield* unableToEdit( files.writeIfUnchanged({ @@ -187,7 +193,17 @@ export const layer = Layer.effectDiscard( content: joinBom(next.text, source.bom || next.bom), }), ) - return { ...result, replacements } satisfies Output + return { + files: [ + { + file: result.resource, + patch: createTwoFilesPatch(result.resource, result.resource, source.text, replaced), + status: "modified" as const, + ...counts, + }, + ], + replacements, + } satisfies Output }) }, }), diff --git a/packages/core/src/tool/tool.ts b/packages/core/src/tool/tool.ts index eb70f2cb47..1d9a82e952 100644 --- a/packages/core/src/tool/tool.ts +++ b/packages/core/src/tool/tool.ts @@ -37,10 +37,19 @@ export type Content = | { readonly type: "text"; readonly text: string } | { readonly type: "file"; readonly data: string; readonly mime: string; readonly name?: string } -type Config, Output extends SchemaType> = { +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, @@ -59,10 +68,12 @@ type Runtime = { const runtimes = new WeakMap() -export function make, Output extends SchemaType>( - config: Config, -): Definition { - const tool = Object.freeze({}) as Definition +export function make< + Input extends SchemaType, + Output extends SchemaType, + Structured extends SchemaType = Output, +>(config: Config): Definition { + const tool = Object.freeze({}) as Definition const definitions = new Map() runtimes.set(tool, { definition: (name) => { @@ -72,7 +83,7 @@ export function make, Output extends SchemaType, Output extends SchemaType Schema.encodeEffect(config.output)(output).pipe( + Effect.flatMap((output) => { + if (!config.structured || !config.toStructuredOutput) + return Effect.succeed({ output, structured: output }) + return Schema.encodeEffect(config.structured)(config.toStructuredOutput({ input, output })).pipe( + Effect.map((structured) => ({ output, structured })), + ) + }), Effect.mapError( (error) => new ToolFailure({ @@ -92,8 +110,8 @@ export function make, Output extends SchemaType ({ - structured: output, + Effect.map(({ output, structured }) => ({ + structured, content: config.toModelOutput?.({ input, output }).map((part) => part.type === "text" diff --git a/packages/core/src/v1/config/provider-options.ts b/packages/core/src/v1/config/provider-options.ts index 9506a6a2b0..6bf0bd9e1a 100644 --- a/packages/core/src/v1/config/provider-options.ts +++ b/packages/core/src/v1/config/provider-options.ts @@ -42,6 +42,15 @@ const openai: Lowerer = { }, request(options) { const result = snake(options) + if (options.reasoningEffort !== undefined || options.reasoningSummary !== undefined) { + result.reasoning = { + ...(isRecord(result.reasoning) ? result.reasoning : {}), + ...(options.reasoningEffort !== undefined ? { effort: options.reasoningEffort } : {}), + ...(options.reasoningSummary !== undefined ? { summary: options.reasoningSummary } : {}), + } + delete result.reasoning_effort + delete result.reasoning_summary + } if (options.textVerbosity !== undefined) { result.text = { ...(isRecord(result.text) ? result.text : {}), verbosity: options.textVerbosity } delete result.text_verbosity diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 3e23e20c70..836975937a 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -601,9 +601,9 @@ describe("Config", () => { models: { model: { request: { - body: { temperature: 0.3, reasoning_effort: "high", service_tier: "priority" }, + body: { temperature: 0.3, reasoning: { effort: "high" }, service_tier: "priority" }, }, - variants: [{ id: "high", body: { reasoning_effort: "high", reasoning_summary: "auto" } }], + variants: [{ id: "high", body: { reasoning: { effort: "high", summary: "auto" } } }], }, }, }) diff --git a/packages/core/test/config/provider-options.test.ts b/packages/core/test/config/provider-options.test.ts index 3edd9ed074..44a1c0c9db 100644 --- a/packages/core/test/config/provider-options.test.ts +++ b/packages/core/test/config/provider-options.test.ts @@ -42,12 +42,14 @@ describe("ConfigProviderOptionsV1", () => { expect( lowerer.request({ reasoningEffort: "high", + reasoningSummary: "auto", + reasoning: { encryptedContent: true }, textVerbosity: "low", text: { outputFormat: "plain" }, nestedValue: { camelCase: true }, }), ).toEqual({ - reasoning_effort: "high", + reasoning: { encrypted_content: true, effort: "high", summary: "auto" }, text: { output_format: "plain", verbosity: "low" }, nested_value: { camel_case: true }, }) @@ -138,8 +140,8 @@ describe("ConfigProviderOptionsV1", () => { body: { trace: true }, settings: { resourceName: "resource" }, }) - expect(lowerer.request({ reasoningEffort: "high", textVerbosity: "low" })).toEqual({ - reasoning_effort: "high", + expect(lowerer.request({ reasoningEffort: "high", reasoningSummary: "auto", textVerbosity: "low" })).toEqual({ + reasoning: { effort: "high", summary: "auto" }, text: { verbosity: "low" }, }) }) diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index 30757a042c..45d423f958 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -25,6 +25,20 @@ function required(value: T | undefined): T { return value } +function eventually
( + effect: Effect.Effect, + predicate: (value: A) => boolean, + remaining = 1000, +): Effect.Effect { + return Effect.gen(function* () { + const value = yield* effect + if (predicate(value)) return value + if (remaining === 0) return yield* Effect.fail(new Error("Timed out waiting for value")) + yield* Effect.promise(() => Bun.sleep(1)) + return yield* eventually(effect, predicate, remaining - 1) + }) +} + function withEnv(vars: Record, effect: () => Effect.Effect) { return Effect.acquireUseRelease( Effect.sync(() => { @@ -67,11 +81,14 @@ describe("OpencodePlugin", () => { Effect.acquireUseRelease( Effect.sync(() => { const authorization: Array = [] + const gate = Promise.withResolvers() return { authorization, + release: gate.resolve, server: Bun.serve({ port: 0, - fetch: (request) => { + fetch: async (request) => { + await gate.promise authorization.push(request.headers.get("authorization")) const origin = new URL(request.url).origin return Response.json({ @@ -110,7 +127,7 @@ describe("OpencodePlugin", () => { }), } }), - ({ authorization, server }) => + ({ authorization, release, server }) => Effect.gen(function* () { const credentials = yield* Credential.Service const catalog = yield* Catalog.Service @@ -128,8 +145,15 @@ describe("OpencodePlugin", () => { }) yield* addPlugin() + expect(authorization).toEqual([]) + release() - const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("remote"))) + const provider = required( + yield* eventually( + catalog.provider.get(ProviderV2.ID.make("remote")), + (item) => item?.integrationID === Integration.ID.make("opencode"), + ), + ) expect(provider).toMatchObject({ name: "Remote", integrationID: "opencode", diff --git a/packages/core/test/process/process.test.ts b/packages/core/test/process/process.test.ts index 580ff0fbf7..fe16936117 100644 --- a/packages/core/test/process/process.test.ts +++ b/packages/core/test/process/process.test.ts @@ -39,6 +39,22 @@ describe("AppProcess", () => { }), ) + it.effect( + "captures stdout and stderr in emission order", + Effect.gen(function* () { + const svc = yield* AppProcess.Service + const script = [ + 'process.stdout.write("out 1\\n")', + 'setTimeout(() => process.stderr.write("err 1\\n"), 10)', + 'setTimeout(() => process.stdout.write("out 2\\n"), 20)', + ].join(";") + const result = yield* svc.run(cmd("-e", script), { combineOutput: true }) + expect(result.output?.toString("utf8")).toBe("out 1\nerr 1\nout 2\n") + expect(result.stdout.toString("utf8")).toBe("") + expect(result.stderr.toString("utf8")).toBe("") + }), + ) + it.effect( "non-zero exit returns RunResult; caller can require success", Effect.gen(function* () { diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index b05745eaa8..81efeb7623 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -377,7 +377,7 @@ describe("SessionV2.create", () => { }), ) - it.effect("persists repeated switches as distinct durable Session events", () => + it.effect("ignores a model switch when the selected model is unchanged", () => Effect.gen(function* () { const session = yield* SessionV2.Service const created = yield* session.create({ location }) @@ -389,11 +389,29 @@ describe("SessionV2.create", () => { const { db } = yield* Database.Service expect( yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, created.id)).all().pipe(Effect.orDie), - ).toHaveLength(3) + ).toHaveLength(2) expect(yield* session.get(created.id)).toMatchObject({ model }) }), ) + it.effect("treats an omitted variant as the default variant", () => + Effect.gen(function* () { + const session = yield* SessionV2.Service + const model = ModelV2.Ref.make({ id: ModelV2.ID.make("sonnet"), providerID: ProviderV2.ID.anthropic }) + const created = yield* session.create({ location, model }) + + yield* session.switchModel({ + sessionID: created.id, + model: ModelV2.Ref.make({ ...model, variant: ModelV2.VariantID.make("default") }), + }) + + const { db } = yield* Database.Service + expect( + yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, created.id)).all().pipe(Effect.orDie), + ).toHaveLength(1) + }), + ) + it.effect("rejects a model switch for a missing Session", () => Effect.gen(function* () { const session = yield* SessionV2.Service diff --git a/packages/core/test/session-runner-model.test.ts b/packages/core/test/session-runner-model.test.ts index 94669b24be..49bbce95a3 100644 --- a/packages/core/test/session-runner-model.test.ts +++ b/packages/core/test/session-runner-model.test.ts @@ -4,6 +4,7 @@ import { LLMClient } from "@opencode-ai/llm/route" import { DateTime, Effect } from "effect" import { Headers } from "effect/unstable/http" import { Credential } from "@opencode-ai/core/credential" +import { Integration } from "@opencode-ai/core/integration" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" import { ProjectV2 } from "@opencode-ai/core/project" @@ -291,6 +292,27 @@ describe("SessionRunnerModel", () => { }), ) + it.effect("does not project OAuth account metadata into the request body", () => + Effect.gen(function* () { + const resolved = yield* SessionRunnerModel.fromCatalogModel( + ModelV2.Info.make({ + ...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }), + request: { headers: {}, body: {} }, + }), + Credential.OAuth.make({ + type: "oauth", + methodID: Integration.MethodID.make("device"), + access: "secret", + refresh: "refresh", + expires: Date.now() + 60_000, + metadata: { server: "https://console.example", orgID: "org_123" }, + }), + ) + + expect(resolved.route.defaults.http?.body).toEqual({}) + }), + ) + it.effect("rejects catalog APIs without a native route", () => Effect.gen(function* () { const failure = yield* SessionRunnerModel.fromCatalogModel( diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 4779de38dc..2cb286804c 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -2565,7 +2565,7 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("propagates unexpected local tool defects operationally", () => + it.effect("returns unexpected local tool defects to the model and continues", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service @@ -2579,11 +2579,20 @@ describe("SessionRunnerLLM", () => { 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" }), + ], ] - expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe("unexpected tool defect") + yield* session.resume(sessionID) - expect(requests).toHaveLength(1) + expect(requests).toHaveLength(2) + expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "assistant", "tool"]) expect(yield* session.context(sessionID)).toMatchObject([ { type: "user", text: "Call defect" }, { @@ -2599,6 +2608,7 @@ describe("SessionRunnerLLM", () => { }, ], }, + { type: "assistant", finish: "stop", content: [{ type: "text", text: "Recovered" }] }, ]) }), ) diff --git a/packages/core/test/tool-apply-patch.test.ts b/packages/core/test/tool-apply-patch.test.ts index 74eda3378e..9b666f0917 100644 --- a/packages/core/test/tool-apply-patch.test.ts +++ b/packages/core/test/tool-apply-patch.test.ts @@ -149,6 +149,29 @@ describe("ApplyPatchTool", () => { { type: "update", resource: "update.txt" }, { type: "delete", resource: "remove.txt" }, ], + files: [ + { + file: "nested/new.txt", + status: "added", + additions: 1, + deletions: 0, + patch: expect.stringContaining("+created"), + }, + { + file: "update.txt", + status: "modified", + additions: 1, + deletions: 1, + patch: expect.stringContaining("-before\n+after"), + }, + { + file: "remove.txt", + status: "deleted", + additions: 0, + deletions: 1, + patch: expect.stringContaining("-remove"), + }, + ], }) expect(assertions).toMatchObject([ { sessionID, action: "edit", resources: ["nested/new.txt", "update.txt", "remove.txt"], save: ["*"] }, diff --git a/packages/core/test/tool-bash.test.ts b/packages/core/test/tool-bash.test.ts index 9bbea5f0c3..f15621ef58 100644 --- a/packages/core/test/tool-bash.test.ts +++ b/packages/core/test/tool-bash.test.ts @@ -31,8 +31,10 @@ let denyAction: string | undefined let result: AppProcess.RunResult = { command: "mock", exitCode: 0, + output: Buffer.from("hello\n"), stdout: Buffer.from("hello\n"), stderr: Buffer.alloc(0), + outputTruncated: false, stdoutTruncated: false, stderrTruncated: false, } @@ -83,8 +85,10 @@ const reset = () => { result = { command: "mock", exitCode: 0, + output: Buffer.from("hello\n"), stdout: Buffer.from("hello\n"), stderr: Buffer.alloc(0), + outputTruncated: false, stdoutTruncated: false, stderrTruncated: false, } @@ -135,24 +139,33 @@ describe("BashTool", () => { expect(definitions.map((tool) => tool.name)).toEqual(["bash"]) expect(definitions[0]?.inputSchema).not.toHaveProperty("properties.background") expect(definitions[0]?.inputSchema).not.toHaveProperty("properties.description") + expect(definitions[0]?.outputSchema).not.toHaveProperty("properties.output") + expect(definitions[0]?.outputSchema).not.toHaveProperty("properties.command") + expect(definitions[0]?.outputSchema).not.toHaveProperty("properties.cwd") expect(yield* toolDefinitions(registry, [{ action: "bash", resource: "*", effect: "deny" }])).toEqual([]) expect(yield* settleTool(registry, call({ command: "pwd" }))).toEqual({ - result: { type: "text", value: "hello\n\n\nCommand exited with code 0." }, + result: { + type: "content", + value: [ + { type: "text", text: "hello\n" }, + { type: "text", text: "Command exited with code 0." }, + ], + }, output: { structured: { - command: "pwd", - cwd: realpathSync(tmp.path), - exitCode: 0, - output: "hello\n", + exit: 0, truncated: false, }, - content: [{ type: "text", text: "hello\n\n\nCommand exited with code 0." }], + content: [ + { type: "text", text: "hello\n" }, + { type: "text", text: "Command exited with code 0." }, + ], }, }) expect(runs).toMatchObject([{ command: "pwd", cwd: realpathSync(tmp.path) }]) expect(runs[0]?.options).toMatchObject({ + combineOutput: true, maxOutputBytes: BashTool.MAX_CAPTURE_BYTES, - maxErrorBytes: BashTool.MAX_CAPTURE_BYTES, }) expect(assertions).toMatchObject([{ sessionID, action: "bash", resources: ["pwd"], save: ["pwd"] }]) }), @@ -222,13 +235,17 @@ describe("BashTool", () => { ).pipe( Effect.andThen((settled) => Effect.sync(() => { - expect(settled.result).toEqual({ type: "text", value: "core-bash\n\nCommand exited with code 0." }) - expect(settled.output?.structured).toMatchObject({ - command: "printf core-bash", - cwd: realpathSync(tmp.path), - exitCode: 0, - output: "core-bash", + expect(settled.result).toEqual({ + type: "content", + value: [ + { type: "text", text: "core-bash" }, + { type: "text", text: "Command exited with code 0." }, + ], }) + expect(settled.output?.structured).toMatchObject({ + exit: 0, + }) + expect(settled.output?.structured).not.toHaveProperty("output") }), ), ) @@ -303,11 +320,13 @@ describe("BashTool", () => { expect(assertions.map((item) => item.action)).toEqual(["bash"]) expect(runs).toHaveLength(1) expect(settled.output?.structured).toMatchObject({ - warnings: [ - `Command argument references external directory ${path.join(realpathSync(outside.path), "*").replaceAll("\\", "/")}. Bash runs with host-user filesystem, process, and network authority; this scan is advisory only.`, - ], + truncated: false, + }) + expect(settled.output?.structured).not.toHaveProperty("warnings") + expect(settled.output?.content[1]).toMatchObject({ + type: "text", + text: expect.stringContaining("Warnings:"), }) - expect(settled.result).toMatchObject({ type: "text", value: expect.stringContaining("Warnings:") }) }), ), ) @@ -324,21 +343,19 @@ describe("BashTool", () => { Effect.promise(() => tmpdir()), (tmp) => { reset() - result = { ...result, exitCode: 7, stdout: Buffer.from("HEAD full output TAIL") } + result = { ...result, exitCode: 7, output: Buffer.from("HEAD full output TAIL") } return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "false" }, "call-overflow"))).pipe( Effect.andThen((settled) => Effect.sync(() => { - expect(settled.result).toMatchObject({ + expect(settled.output?.content[1]).toMatchObject({ type: "text", - value: expect.stringContaining("Command exited with code 7"), + text: expect.stringContaining("Command exited with code 7"), }) expect(settled.output?.structured).toMatchObject({ - command: "false", - cwd: realpathSync(tmp.path), - exitCode: 7, - output: "HEAD full output TAIL", + exit: 7, truncated: false, }) + expect(settled.output?.content[0]).toEqual({ type: "text", text: "HEAD full output TAIL" }) }), ), ) @@ -352,14 +369,14 @@ describe("BashTool", () => { Effect.promise(() => tmpdir()), (tmp) => { reset() - result = { ...result, stdoutTruncated: true } + result = { ...result, outputTruncated: true } return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "verbose" }))).pipe( Effect.andThen((settled) => Effect.sync(() => { - expect(settled.output?.structured).toMatchObject({ truncated: true, stdoutTruncated: true }) - expect(settled.result).toMatchObject({ + expect(settled.output?.structured).toMatchObject({ truncated: true }) + expect(settled.output?.content[0]).toMatchObject({ type: "text", - value: expect.stringContaining("stdout capture truncated"), + text: expect.stringContaining("output capture truncated"), }) expect(settled.output?.structured).not.toHaveProperty("resource") }), @@ -379,13 +396,12 @@ describe("BashTool", () => { return withTool(tmp.path, (registry) => settleTool(registry, call({ command: "sleep 60", timeout: 10 }))).pipe( Effect.andThen((settled) => Effect.sync(() => { - expect(settled.result).toMatchObject({ + expect(settled.output?.content[1]).toMatchObject({ type: "text", - value: expect.stringContaining("Command timed out"), + text: expect.stringContaining("Command timed out"), }) expect(settled.output?.structured).toMatchObject({ - command: "sleep 60", - timedOut: true, + timeout: true, truncated: false, }) }), diff --git a/packages/core/test/tool-edit.test.ts b/packages/core/test/tool-edit.test.ts index d8f96a5803..f95cd1c923 100644 --- a/packages/core/test/tool-edit.test.ts +++ b/packages/core/test/tool-edit.test.ts @@ -125,11 +125,16 @@ describe("EditTool", () => { value: "Edited file successfully: hello.txt\nReplacements: 1\n```diff\n-before\n+after\n```", }) expect(settled.output?.structured).toEqual({ - operation: "write", - target: yield* Effect.promise(() => fs.realpath(target)), - resource: "hello.txt", - existed: true, replacements: 1, + files: [ + { + file: "hello.txt", + status: "modified", + additions: 1, + deletions: 1, + patch: expect.stringContaining("-before\n+after"), + }, + ], }) expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\nrest\n") expect(assertions).toMatchObject([{ sessionID, action: "edit", resources: ["hello.txt"], save: ["*"] }]) diff --git a/packages/schema/src/session-message.ts b/packages/schema/src/session-message.ts index f32bb0582e..58ff532063 100644 --- a/packages/schema/src/session-message.ts +++ b/packages/schema/src/session-message.ts @@ -150,6 +150,10 @@ export const AssistantReasoning = Schema.Struct({ id: Schema.String, text: Schema.String, providerMetadata: ProviderMetadata.pipe(optional), + time: Schema.Struct({ + created: DateTimeUtcFromMillis, + completed: DateTimeUtcFromMillis.pipe(optional), + }).pipe(optional), }).annotate({ identifier: "Session.Message.Assistant.Reasoning" }) export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool]).pipe( diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 3ac6ea713d..8159d14591 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -4030,6 +4030,10 @@ export type SessionMessageAssistantReasoning = { id: string text: string providerMetadata?: LlmProviderMetadata + time?: { + created: number + completed?: number + } } export type SessionMessageToolStatePending = { From 225a1fbf35a58aa61ea365ac4f2e14c447b5792b Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 27 Jun 2026 04:10:30 +0000 Subject: [PATCH 20/45] chore: generate --- packages/sdk/openapi.json | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 1c833aa64b..b0ed3df760 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -27588,6 +27588,19 @@ }, "providerMetadata": { "$ref": "#/components/schemas/LLMProviderMetadata" + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + }, + "completed": { + "type": "number" + } + }, + "required": ["created"], + "additionalProperties": false } }, "required": ["type", "id", "text"], From 9903abc7042d0ea095e55cad1684520008cdc165 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 23:19:42 -0500 Subject: [PATCH 21/45] fix(opencode): allow empty provider config default (#34167) Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: opencode-agent[bot] --- packages/opencode/src/provider/provider.ts | 3 ++- packages/opencode/test/provider/provider.test.ts | 11 +++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index a7fae742d7..409d890057 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1934,7 +1934,8 @@ export const layer = Layer.effect( return { providerID: entry.providerID, modelID: entry.modelID } } - const provider = Object.values(s.providers).find((p) => !cfg.provider || Object.keys(cfg.provider).includes(p.id)) + const configured = Object.keys(cfg.provider ?? {}) + const provider = Object.values(s.providers).find((p) => configured.length === 0 || configured.includes(p.id)) if (!provider) return yield* new NoProvidersError() const [model] = sort(Object.values(provider.models)) if (!model) return yield* new NoModelsError({ providerID: provider.id }) diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index d3f29204be..2d2e8cb8a0 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -356,6 +356,17 @@ it.instance( { config: { model: "anthropic/claude-sonnet-4-20250514" } }, ) +it.instance( + "defaultModel treats empty provider config as no allowlist", + Effect.gen(function* () { + yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key") + const model = yield* Provider.use.defaultModel() + expect(model.providerID).toBeDefined() + expect(model.modelID).toBeDefined() + }), + { config: { provider: {} } }, +) + it.instance( "defaultModel returns a typed error when config excludes every provider", Effect.gen(function* () { From eb923c27caa509d31df460bfbaf2b6c83502a706 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 27 Jun 2026 04:27:56 +0000 Subject: [PATCH 22/45] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index 03ca8007f8..47a41939a1 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-gwtjcIUnd0qdzrlbn2lLf+R75MCEivFT9sBGMX05xy0=", - "aarch64-linux": "sha256-vP3htk3wMHwS/dy0idYQNVNtUbIGCz+SriESOTYA11I=", - "aarch64-darwin": "sha256-OGhb0sQBYdRzSVnbC6lBWM+HLgcFhgIrhC9sUuHDGZY=", - "x86_64-darwin": "sha256-nWUyoGJk0YaupqRjaHeK4IVDKNPkdXQ/n6RGJiS7u6I=" + "x86_64-linux": "sha256-A3dgTsHBfIn+trqbserr1CAERfKeJjTiUxZxzkz4tPU=", + "aarch64-linux": "sha256-G72CWFF7gTNHxmq5TFMRFiffqp9Voqcoe1oz8Kk47vU=", + "aarch64-darwin": "sha256-7/lxVQWuebPDFo+cuel/S1IpwLuTxFYIUYBsNimqcI4=", + "x86_64-darwin": "sha256-GTRYa17f0SpwrWh+M3Q2MTIUzzGINknKMmRzYUgqMus=" } } From 8870d36e0f96d84c87b0eb2624976eb3954ff060 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Sat, 27 Jun 2026 12:46:33 +0800 Subject: [PATCH 23/45] fix(app): space home sessions from scrollbar (#34132) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 --- packages/app/src/pages/home.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 38c444e503..6e3436a5a7 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -455,7 +455,7 @@ export function NewHome() { onClose={closeSearch} onSelect={selectSearchSession} /> - + 0} fallback={} > -
+
{(group, index) => (
From 5a55135d893a6426db960fa6d33ffd7b7b6d91a9 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Sat, 27 Jun 2026 12:47:32 +0800 Subject: [PATCH 24/45] fix(ui): make select hover feedback immediate (#34121) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 --- packages/ui/src/components/select.css | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/ui/src/components/select.css b/packages/ui/src/components/select.css index a765850d49..1c42882f4a 100644 --- a/packages/ui/src/components/select.css +++ b/packages/ui/src/components/select.css @@ -140,9 +140,6 @@ color: var(--text-strong); - transition: - background-color 0.2s ease-in-out, - color 0.2s ease-in-out; outline: none; user-select: none; From a2d08fb63b508ddda6141d9d20a4f52e13779c79 Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Sat, 27 Jun 2026 10:30:52 +0530 Subject: [PATCH 25/45] feat(app): update home screen alignment + markdown styles (#34172) --- packages/app/src/pages/home.tsx | 2 +- packages/session-ui/src/components/markdown.css | 12 ++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 6e3436a5a7..7a8a33db84 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -1133,7 +1133,7 @@ function HomeSessionSearchResultRow(props: { function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => void }) { const language = useLanguage() return ( -
+
{(onNewSession) => ( diff --git a/packages/session-ui/src/components/markdown.css b/packages/session-ui/src/components/markdown.css index 0b7be93d25..0f60da229c 100644 --- a/packages/session-ui/src/components/markdown.css +++ b/packages/session-ui/src/components/markdown.css @@ -49,12 +49,20 @@ line-height: var(--line-height-large); } - h3, + h3 { + font-size: 13px; + color: var(--v2-text-text-base); + font-weight: var(--font-weight-medium); + margin-top: 0px; + margin-bottom: 24px; + line-height: var(--line-height-large); + } + h4, h5, h6 { font-size: 13px; - color: var(--v2-text-text-base); + color: var(--v2-text-text-muted); font-weight: var(--font-weight-medium); margin-top: 0px; margin-bottom: 24px; From bdfea046db27c4249dd3181eb369b1530d7b3fe0 Mon Sep 17 00:00:00 2001 From: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Date: Sat, 27 Jun 2026 07:19:59 +0200 Subject: [PATCH 26/45] fix(desktop): recognize normal auth metadata input prompts in connect provider dialog (#33024) --- .../components/dialog-connect-provider.tsx | 31 ++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index 0fb89d29f9..3a57769b5a 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -65,6 +65,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc const [store, setStore] = createStore({ methodIndex: undefined as undefined | number, authorization: undefined as undefined | ProviderAuthAuthorization, + promptInputs: undefined as undefined | Record, state: "pending" as undefined | "pending" | "complete" | "error" | "prompt", error: undefined as string | undefined, }) @@ -73,6 +74,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc | { type: "method.select"; index: number } | { type: "method.reset" } | { type: "auth.prompt" } + | { type: "auth.inputs"; inputs: Record } | { type: "auth.pending" } | { type: "auth.complete"; authorization: ProviderAuthAuthorization } | { type: "auth.error"; error: string } @@ -83,6 +85,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc if (action.type === "method.select") { draft.methodIndex = action.index draft.authorization = undefined + draft.promptInputs = undefined draft.state = undefined draft.error = undefined return @@ -90,6 +93,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc if (action.type === "method.reset") { draft.methodIndex = undefined draft.authorization = undefined + draft.promptInputs = undefined draft.state = undefined draft.error = undefined return @@ -99,6 +103,12 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc draft.error = undefined return } + if (action.type === "auth.inputs") { + draft.promptInputs = action.inputs + draft.state = undefined + draft.error = undefined + return + } if (action.type === "auth.pending") { draft.state = "pending" draft.error = undefined @@ -151,6 +161,15 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc const method = methods()[index] dispatch({ type: "method.select", index }) + if (method.type === "api" && method.prompts?.length) { + if (!inputs) { + dispatch({ type: "auth.prompt" }) + return + } + dispatch({ type: "auth.inputs", inputs }) + return + } + if (method.type === "oauth") { if (method.prompts?.length && !inputs) { dispatch({ type: "auth.prompt" }) @@ -190,7 +209,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc } } - function OAuthPromptsView() { + function AuthPromptsView() { const [formStore, setFormStore] = createStore({ value: {} as Record, index: 0, @@ -198,8 +217,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc const prompts = createMemo>(() => { const value = method() - if (value?.type !== "oauth") return [] - return value.prompts ?? [] + return value?.prompts ?? [] }) const matches = (prompt: NonNullable[number]>, value: Record) => { if (!prompt.when) return true @@ -230,6 +248,10 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc setFormStore("index", next) return } + if (method()?.type === "api") { + dispatch({ type: "auth.inputs", inputs: value }) + return + } await selectMethod(store.methodIndex, value) } @@ -414,6 +436,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc auth: { type: "api", key: apiKey, + ...(store.promptInputs ? { metadata: store.promptInputs } : {}), }, }) await complete() @@ -622,7 +645,7 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
- +
From 3d072112ceff73d7f767dec9f05bc3c1e77bc3b1 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Sat, 27 Jun 2026 17:42:17 +1000 Subject: [PATCH 27/45] fix(app): reconcile session pages with concurrent events (#34042) --- packages/app/src/context/server-sdk.test.ts | 124 +- packages/app/src/context/server-sdk.tsx | 94 +- .../app/src/context/server-session.test.ts | 1038 ++++++++++++++++- packages/app/src/context/server-session.ts | 601 +++++++++- 4 files changed, 1734 insertions(+), 123 deletions(-) diff --git a/packages/app/src/context/server-sdk.test.ts b/packages/app/src/context/server-sdk.test.ts index 08390d667c..523a9ebed5 100644 --- a/packages/app/src/context/server-sdk.test.ts +++ b/packages/app/src/context/server-sdk.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { coalesceServerEvents, resumeStreamAfterPageShow } from "./server-sdk" +import { coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk" import type { Event } from "@opencode-ai/sdk/v2/client" describe("resumeStreamAfterPageShow", () => { @@ -15,19 +15,23 @@ describe("resumeStreamAfterPageShow", () => { }) describe("coalesceServerEvents", () => { - const delta = (value: string, field = "text") => ({ + const delta = (value: string, field = "text", partID = "part") => ({ directory: "/repo", payload: { type: "message.part.delta", - properties: { messageID: "msg", partID: "part", field, delta: value }, + properties: { messageID: "msg", partID, field, delta: value }, } as Event, }) test("merges adjacent deltas for the same field", () => { - const result = coalesceServerEvents([delta("hello "), delta("world")]) + const first = delta("hello ") + const second = delta("world") + first.payload.id = "first" + second.payload.id = "second" + const result = coalesceServerEvents([first, second]) expect(result).toHaveLength(1) - expect(result[0]?.payload).toMatchObject({ properties: { delta: "hello world" } }) + expect(result[0]?.payload).toMatchObject({ id: "second", properties: { delta: "hello world" } }) }) test("preserves event boundaries and distinct fields", () => { @@ -45,9 +49,113 @@ describe("coalesceServerEvents", () => { ]) }) - test("drops stale deltas", () => { - const result = coalesceServerEvents([delta("stale")], new Set(["/repo:msg:part"])) + test("preserves event ID order across interleaved deltas", () => { + const first = delta("a") + const other = delta("b", "text", "other") + const last = delta("c") + first.payload.id = "1" + other.payload.id = "2" + last.payload.id = "3" - expect(result).toEqual([]) + const result = coalesceServerEvents([first, other, last]) + + expect(result.map((event) => event.payload.id)).toEqual(["1", "2", "3"]) + }) + +}) + +describe("enqueueServerEvent", () => { + const partUpdated = (text: string) => + ({ + type: "message.part.updated", + properties: { + sessionID: "session", + part: { id: "part", sessionID: "session", messageID: "message", type: "text", text }, + }, + }) as Event + + test("preserves part updates across message remove and re-add barriers", () => { + const events: Array<{ directory: string; payload: Event }> = [] + const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload }) + + enqueue(partUpdated("old")) + enqueue({ type: "message.removed", properties: { sessionID: "session", messageID: "message" } } as Event) + enqueue({ + type: "message.updated", + properties: { + sessionID: "session", + info: { + id: "message", + sessionID: "session", + role: "user", + time: { created: 1 }, + agent: "build", + model: { providerID: "provider", modelID: "model" }, + }, + }, + } as Event) + enqueue(partUpdated("new")) + + expect(events.map((event) => event.payload.type)).toEqual([ + "message.part.updated", + "message.removed", + "message.updated", + "message.part.updated", + ]) + }) + + test("preserves deltas after a replacement snapshot", () => { + const events: Array<{ directory: string; payload: Event }> = [] + const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload }) + + enqueue(partUpdated("a")) + enqueue(partUpdated("ab")) + enqueue({ + type: "message.part.delta", + properties: { sessionID: "session", messageID: "message", partID: "part", field: "text", delta: "c" }, + } as Event) + + const result = coalesceServerEvents(events) + expect(result.map((event) => event.payload.type)).toEqual(["message.part.updated", "message.part.delta"]) + expect(result[0]?.payload).toMatchObject({ properties: { part: { text: "ab" } } }) + expect(result[1]?.payload).toMatchObject({ properties: { delta: "c" } }) + }) + + test("preserves updates after session deletion", () => { + const events: Array<{ directory: string; payload: Event }> = [] + const enqueue = (payload: Event) => enqueueServerEvent(events, { directory: "/repo", payload }) + + enqueue(partUpdated("old")) + enqueue({ + type: "session.deleted", + properties: { sessionID: "session", info: { id: "session" } }, + } as Event) + enqueue(partUpdated("new")) + + expect(events.map((event) => event.payload.type)).toEqual([ + "message.part.updated", + "session.deleted", + "message.part.updated", + ]) + }) + + test("does not coalesce edge-triggered session statuses", () => { + const events: Array<{ directory: string; payload: Event }> = [] + const enqueue = (status: "retry" | "busy") => + enqueueServerEvent(events, { + directory: "/repo", + payload: { + type: "session.status", + properties: { + sessionID: "session", + status: status === "retry" ? { type: "retry", attempt: 1, message: "retry", next: 1 } : { type: "busy" }, + }, + } as Event, + }) + + enqueue("retry") + enqueue("busy") + + expect(events).toHaveLength(2) }) }) diff --git a/packages/app/src/context/server-sdk.tsx b/packages/app/src/context/server-sdk.tsx index 903c0697a1..59d9a1f76d 100644 --- a/packages/app/src/context/server-sdk.tsx +++ b/packages/app/src/context/server-sdk.tsx @@ -17,34 +17,59 @@ const isAbortError = (error: unknown) => const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true type QueuedServerEvent = { directory: string; payload: Event } -const deltaKey = (directory: string, messageID: string, partID: string) => `${directory}:${messageID}:${partID}` +const coalescedKey = (event: QueuedServerEvent) => { + if (event.payload.type === "lsp.updated") return `lsp.updated:${event.directory}` + if (event.payload.type === "message.part.updated") { + const part = event.payload.properties.part + return `message.part.updated:${event.directory}:${part.messageID}:${part.id}` + } + return undefined +} -export function coalesceServerEvents(events: QueuedServerEvent[], stale?: Set) { +export function enqueueServerEvent( + queue: QueuedServerEvent[], + event: QueuedServerEvent, +) { + const key = coalescedKey(event) + const previous = queue[queue.length - 1] + if (key && previous && coalescedKey(previous) === key) { + queue[queue.length - 1] = event + return false + } + queue.push(event) + return true +} + +export function coalesceServerEvents(events: QueuedServerEvent[]) { const output: QueuedServerEvent[] = [] - const deltas = new Map() events.forEach((event) => { - if (stale && event.payload.type === "message.part.delta") { - const props = event.payload.properties - if (stale.has(deltaKey(event.directory, props.messageID, props.partID))) return - } if (event.payload.type !== "message.part.delta") { - deltas.clear() output.push(event) return } const props = event.payload.properties - const id = `${deltaKey(event.directory, props.messageID, props.partID)}:${props.field}` - const index = deltas.get(id) - const existing = index === undefined ? undefined : output[index] - if (!existing || existing.payload.type !== "message.part.delta") { - deltas.set(id, output.length) + const previous = output[output.length - 1] + if ( + !previous || + previous.payload.type !== "message.part.delta" || + previous.directory !== event.directory || + previous.payload.properties.messageID !== props.messageID || + previous.payload.properties.partID !== props.partID || + previous.payload.properties.field !== props.field + ) { output.push({ directory: event.directory, payload: { ...event.payload, properties: { ...props } }, }) return } - existing.payload.properties.delta += props.delta + output[output.length - 1] = { + directory: event.directory, + payload: { + ...event.payload, + properties: { ...props, delta: previous.payload.properties.delta + props.delta }, + }, + } }) return output } @@ -85,20 +110,9 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS let queue: Queued[] = [] let buffer: Queued[] = [] - const coalesced = new Map() - const staleDeltas = new Set() let timer: ReturnType | undefined let last = 0 - const key = (directory: string, payload: Event) => { - if (payload.type === "session.status") return `session.status:${directory}:${payload.properties.sessionID}` - if (payload.type === "lsp.updated") return `lsp.updated:${directory}` - if (payload.type === "message.part.updated") { - const part = payload.properties.part - return `message.part.updated:${directory}:${part.messageID}:${part.id}` - } - } - const flush = () => { if (timer) clearTimeout(timer) timer = undefined @@ -106,15 +120,12 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS if (queue.length === 0) return const events = queue - const skip = staleDeltas.size > 0 ? new Set(staleDeltas) : undefined queue = buffer buffer = events queue.length = 0 - coalesced.clear() - staleDeltas.clear() last = Date.now() - const output = coalesceServerEvents(events, skip) + const output = coalesceServerEvents(events) batch(() => { output.forEach((event) => emitter.emit(event.directory, event.payload)) }) @@ -184,29 +195,12 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS for await (const event of events.stream) { resetHeartbeat() streamErrorLogged = false - const directory = event.directory ?? "global" - if (event.payload.type === "sync") { - continue + if (event.payload.type !== "sync") { + const directory = event.directory ?? "global" + const payload = event.payload as Event + if (enqueueServerEvent(queue, { directory, payload })) schedule() } - const payload = event.payload as Event - - const k = key(directory, payload) - if (k) { - const i = coalesced.get(k) - if (i !== undefined) { - queue[i] = { directory, payload } - if (payload.type === "message.part.updated") { - const part = payload.properties.part - staleDeltas.add(deltaKey(directory, part.messageID, part.id)) - } - continue - } - coalesced.set(k, queue.length) - } - queue.push({ directory, payload }) - schedule() - if (Date.now() - yielded < STREAM_YIELD_MS) continue yielded = Date.now() await wait(0) diff --git a/packages/app/src/context/server-session.test.ts b/packages/app/src/context/server-session.test.ts index 8986b63cdd..86021e90d8 100644 --- a/packages/app/src/context/server-session.test.ts +++ b/packages/app/src/context/server-session.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" -import type { OpencodeClient, Session } from "@opencode-ai/sdk/v2/client" +import type { retry } from "@opencode-ai/core/util/retry" +import type { Message, OpencodeClient, Part, Session } from "@opencode-ai/sdk/v2/client" import { createServerSession } from "./server-session" const session = (id: string, parentID?: string): Session => ({ @@ -13,6 +14,74 @@ const session = (id: string, parentID?: string): Session => ({ time: { created: 1, updated: 1 }, }) +type UserMessage = Extract +type TextPart = Extract +type MessageResponse = { + data: { info: Message; parts: Part[] }[] + response: { headers: Headers } +} + +const userMessage = (id: string, input: Partial = {}): UserMessage => ({ + id, + sessionID: "child", + role: "user", + time: { created: 1 }, + agent: "build", + model: { providerID: "provider", modelID: "model" }, + ...input, +}) + +const textPart = (messageID: string, input: Partial = {}): TextPart => ({ + id: "part", + sessionID: "child", + messageID, + type: "text", + text: "text", + ...input, +}) + +const response = (data: MessageResponse["data"] = [], cursor?: string): MessageResponse => ({ + data, + response: { headers: new Headers(cursor ? { "x-next-cursor": cursor } : undefined) }, +}) + +const deferredResponse = () => Promise.withResolvers() + +function messageClient(...responses: Array>) { + let index = 0 + const requests: unknown[] = [] + const waiting = new Map void>() + const client = { + session: { + get: async () => ({ data: session("child", "root") }), + messages: (input: unknown) => { + requests.push(input) + waiting.get(requests.length)?.() + waiting.delete(requests.length) + return responses[index++] + }, + }, + } as unknown as OpencodeClient + return Object.assign(client, { + requests, + requested(count: number) { + if (requests.length >= count) return Promise.resolve() + return new Promise((resolve) => waiting.set(count, resolve)) + }, + }) +} + +const retryImmediately: typeof retry = async (task, options = {}) => { + const attempts = options.attempts ?? 3 + for (let attempt = 0; ; attempt++) { + try { + return await task() + } catch (error) { + if (attempt === attempts - 1) throw error + } + } +} + function setup(sessions: Record) { const get: unknown[] = [] const messages: unknown[] = [] @@ -25,7 +94,7 @@ function setup(sessions: Record) { }, messages: async (input: unknown) => { messages.push(input) - return { data: [], response: { headers: new Headers() } } + return response() }, diff: async () => ({ data: [] }), todo: async () => ({ data: [] }), @@ -55,13 +124,972 @@ describe("server session", () => { expect(ctx.store.data.message.root).toEqual([]) }) + test("merges live events into the initial page", async () => { + const pending = deferredResponse() + const user = userMessage("message-1") + const live = userMessage("message-2", { time: { created: 2 } }) + const livePart = textPart(live.id, { text: "live" }) + const store = createServerSession(messageClient(pending.promise)) + const loading = store.sync("child") + + store.apply({ type: "message.updated", properties: { info: live } }) + store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: livePart, time: 2 } }) + pending.resolve(response([{ info: user, parts: [] }])) + await loading + + expect(store.data.message.child).toEqual([user, live]) + expect(store.data.part[live.id]).toEqual([livePart]) + }) + + test("preserves same-ID live updates over the initial page", async () => { + const pending = deferredResponse() + const fetched = userMessage("message") + const fetchedPart = textPart(fetched.id, { text: "fetched" }) + const live = { ...fetched, time: { created: 2 } } + const livePart = { ...fetchedPart, text: "live" } + const store = createServerSession(messageClient(pending.promise)) + const loading = store.sync("child") + + store.apply({ type: "message.updated", properties: { info: live } }) + store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: livePart, time: 2 } }) + pending.resolve(response([{ info: fetched, parts: [fetchedPart] }])) + await loading + + expect(store.data.message.child).toEqual([live]) + expect(store.data.part[live.id]).toEqual([livePart]) + }) + + test("preserves removals received during the initial load", async () => { + const pending = deferredResponse() + const removed = userMessage("message-1") + const kept = { ...removed, id: "message-2" } + const part = textPart(kept.id, { text: "removed" }) + const store = createServerSession(messageClient(pending.promise)) + const loading = store.sync("child") + + store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: removed.id } }) + store.apply({ + type: "message.part.removed", + properties: { sessionID: "child", messageID: kept.id, partID: part.id }, + }) + pending.resolve( + response([ + { info: removed, parts: [] }, + { info: kept, parts: [part] }, + ]), + ) + await loading + + expect(store.data.message.child).toEqual([kept]) + expect(store.data.part[kept.id]).toBeUndefined() + }) + + test("keeps removal tracking isolated across load generations", async () => { + const firstResponse = deferredResponse() + const secondResponse = deferredResponse() + const message = userMessage("message") + const store = createServerSession(messageClient(firstResponse.promise, secondResponse.promise)) + const first = store.sync("child") + + store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } }) + store.apply({ + type: "session.deleted", + properties: { sessionID: "child", info: session("child", "root") }, + }) + const second = store.sync("child") + + firstResponse.resolve(response()) + await first + secondResponse.resolve(response([{ info: message, parts: [] }])) + await second + + expect(store.data.message.child).toEqual([message]) + }) + + test("tracks removals in a replacement load generation", async () => { + const firstResponse = deferredResponse() + const secondResponse = deferredResponse() + const message = userMessage("message") + const store = createServerSession(messageClient(firstResponse.promise, secondResponse.promise)) + const first = store.sync("child") + store.apply({ + type: "session.deleted", + properties: { sessionID: "child", info: session("child", "root") }, + }) + const second = store.sync("child") + + store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } }) + firstResponse.resolve(response()) + await first + secondResponse.resolve(response([{ info: message, parts: [] }])) + await second + + expect(store.data.message.child).toEqual([]) + }) + + test("preserves remove then re-add when a refresh omits the message", async () => { + const pending = deferredResponse() + const message = userMessage("message") + const store = createServerSession(messageClient(response([{ info: message, parts: [] }]), pending.promise)) + await store.sync("child") + const refreshing = store.sync("child", { force: true }) + + store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } }) + store.apply({ type: "message.updated", properties: { info: message } }) + pending.resolve(response()) + await refreshing + + expect(store.data.message.child).toEqual([message]) + }) + + test("preserves a re-added message without restoring removed parts", async () => { + const pending = deferredResponse() + const message = userMessage("message") + const part = textPart(message.id, { text: "stale" }) + const store = createServerSession(messageClient(response([{ info: message, parts: [] }]), pending.promise)) + await store.sync("child") + const refreshing = store.sync("child", { force: true }) + + store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } }) + store.apply({ type: "message.updated", properties: { info: message } }) + pending.resolve(response([{ info: message, parts: [part] }])) + await refreshing + + expect(store.data.message.child).toEqual([message]) + expect(store.data.part[message.id]).toBeUndefined() + }) + + test("preserves optimistic parts re-added after removal during a refresh", async () => { + const pending = deferredResponse() + const message = userMessage("message") + const stale = textPart(message.id, { id: "stale", text: "stale" }) + const part = textPart(message.id, { id: "optimistic", text: "optimistic" }) + const store = createServerSession(messageClient(response([{ info: message, parts: [] }]), pending.promise, response())) + await store.sync("child") + const refreshing = store.sync("child", { force: true }) + + store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } }) + store.optimistic.add({ sessionID: "child", message, parts: [part] }) + pending.resolve(response([{ info: message, parts: [stale] }])) + await refreshing + + expect(store.data.message.child).toEqual([message]) + expect(store.data.part[message.id]).toEqual([part]) + + await store.sync("child", { force: true }) + expect(store.data.message.child).toEqual([message]) + expect(store.data.part[message.id]).toEqual([part]) + }) + + test("drops stale event content omitted by a complete initial page", async () => { + const stale = userMessage("stale") + const store = createServerSession(messageClient(response())) + store.apply({ type: "message.updated", properties: { info: stale } }) + + await store.sync("child") + + expect(store.data.message.child).toEqual([]) + }) + + test("preserves event content outside an incomplete initial page", async () => { + const live = userMessage("message-1") + const fetched = userMessage("message-2", { time: { created: 2 } }) + const store = createServerSession(messageClient(response([{ info: fetched, parts: [] }], "older"))) + store.apply({ type: "message.updated", properties: { info: live } }) + + await store.sync("child") + + expect(store.data.message.child).toEqual([live, fetched]) + }) + + test("does not restore removed optimistic content on refresh", async () => { + const message = userMessage("message") + const part = textPart(message.id, { text: "removed" }) + const kept = { ...message, id: "kept" } + const keptPart = { ...part, id: "kept-part", messageID: kept.id } + const store = createServerSession(messageClient(response([{ info: kept, parts: [] }]))) + store.optimistic.add({ sessionID: "child", message, parts: [part] }) + store.optimistic.add({ sessionID: "child", message: kept, parts: [keptPart] }) + + store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } }) + store.apply({ + type: "message.part.removed", + properties: { sessionID: "child", messageID: kept.id, partID: keptPart.id }, + }) + await store.sync("child", { force: true }) + + expect(store.data.message.child).toEqual([kept]) + expect(store.data.part[message.id]).toBeUndefined() + expect(store.data.part[kept.id]).toBeUndefined() + }) + + test("replaces confirmed optimistic content with the initial page", async () => { + const optimistic = userMessage("message") + const fetched = { ...optimistic, time: { created: 2 } } + const store = createServerSession(messageClient(response([{ info: fetched, parts: [] }]))) + store.optimistic.add({ sessionID: "child", message: optimistic, parts: [] }) + + await store.sync("child") + + expect(store.data.message.child).toEqual([fetched]) + }) + + test("replaces a confirmed optimistic part with fetched content", async () => { + const pending = deferredResponse() + const message = userMessage("message") + const optimistic = textPart(message.id, { text: "optimistic" }) + const fetched = { ...optimistic, text: "fetched" } + const store = createServerSession(messageClient(pending.promise)) + const loading = store.sync("child") + + store.optimistic.add({ sessionID: "child", message, parts: [optimistic] }) + pending.resolve(response([{ info: message, parts: [fetched] }])) + await loading + + expect(store.data.part[message.id]).toEqual([fetched]) + }) + + test("rolls back only unconfirmed optimistic parts", async () => { + const pending = deferredResponse() + const message = userMessage("message") + const confirmed = textPart(message.id, { id: "confirmed", text: "confirmed" }) + const pendingPart = textPart(message.id, { id: "pending", text: "pending" }) + const store = createServerSession(messageClient(pending.promise)) + const loading = store.sync("child") + store.optimistic.add({ sessionID: "child", message, parts: [confirmed, pendingPart] }) + + pending.resolve(response([{ info: message, parts: [confirmed] }])) + await loading + store.optimistic.remove({ sessionID: "child", messageID: message.id }) + + expect(store.data.message.child).toEqual([message]) + expect(store.data.part[message.id]).toEqual([confirmed]) + }) + + test("updates confirmed optimistic parts from later pages", async () => { + const message = userMessage("message") + const confirmed = textPart(message.id, { id: "confirmed", text: "first" }) + const updated = { ...confirmed, text: "updated" } + const pendingPart = textPart(message.id, { id: "pending", text: "pending" }) + const store = createServerSession( + messageClient( + response([{ info: message, parts: [confirmed] }]), + response([{ info: message, parts: [updated] }]), + ), + ) + store.optimistic.add({ sessionID: "child", message, parts: [confirmed, pendingPart] }) + await store.sync("child") + + await store.sync("child", { force: true }) + store.optimistic.remove({ sessionID: "child", messageID: message.id }) + + expect(store.data.part[message.id]).toEqual([updated]) + }) + + test("does not restore a confirmed optimistic part after its removal event", async () => { + const message = userMessage("message") + const confirmed = textPart(message.id, { id: "confirmed", text: "confirmed" }) + const pendingPart = textPart(message.id, { id: "pending", text: "pending" }) + const store = createServerSession( + messageClient(response([{ info: message, parts: [confirmed] }]), response([{ info: message, parts: [] }])), + ) + store.optimistic.add({ sessionID: "child", message, parts: [confirmed, pendingPart] }) + await store.sync("child") + store.apply({ + type: "message.part.removed", + properties: { sessionID: "child", messageID: message.id, partID: confirmed.id }, + }) + + await store.sync("child", { force: true }) + + expect(store.data.part[message.id]).toEqual([pendingPart]) + }) + + test("clears delta buffers when removing optimistic content", () => { + const message = userMessage("message") + const part = textPart(message.id, { text: "optimistic" }) + const store = setup({ child: session("child") }).store + store.optimistic.add({ sessionID: "child", message, parts: [part] }) + store.apply({ + type: "message.part.delta", + properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: " delta" }, + }) + + store.optimistic.remove({ sessionID: "child", messageID: message.id }) + + expect(store.data.part[message.id]).toBeUndefined() + expect(store.data.part_text_accum_delta[part.id]).toBeUndefined() + }) + + test("does not remove content confirmed by a message event", () => { + const message = userMessage("message") + const part = textPart(message.id) + const store = setup({ child: session("child") }).store + store.optimistic.add({ sessionID: "child", message, parts: [part] }) + store.apply({ type: "message.updated", properties: { sessionID: "child", info: message } }) + + store.optimistic.remove({ sessionID: "child", messageID: message.id }) + + expect(store.data.message.child).toEqual([message]) + expect(store.data.part[message.id]).toBeUndefined() + }) + + test("does not remove parts confirmed by part events", () => { + const message = userMessage("message") + const part = textPart(message.id) + const store = setup({ child: session("child") }).store + store.optimistic.add({ sessionID: "child", message, parts: [part] }) + store.apply({ type: "message.updated", properties: { sessionID: "child", info: message } }) + store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 2 } }) + + store.optimistic.remove({ sessionID: "child", messageID: message.id }) + + expect(store.data.message.child).toEqual([message]) + expect(store.data.part[message.id]).toEqual([part]) + }) + + test("treats a part event as confirmation when it precedes the message event", () => { + const message = userMessage("message") + const part = textPart(message.id) + const store = setup({ child: session("child") }).store + store.optimistic.add({ sessionID: "child", message, parts: [part] }) + store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 2 } }) + + store.optimistic.remove({ sessionID: "child", messageID: message.id }) + + expect(store.data.message.child).toEqual([message]) + expect(store.data.part[message.id]).toEqual([part]) + }) + + test("clears stale parts when the initial page has none", async () => { + const pending = deferredResponse() + const message = userMessage("message") + const part = textPart(message.id, { text: "stale" }) + const store = createServerSession(messageClient(pending.promise)) + store.apply({ type: "message.updated", properties: { info: message } }) + store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 1 } }) + const loading = store.sync("child") + + pending.resolve(response([{ info: message, parts: [] }])) + await loading + + expect(store.data.part[message.id]).toBeUndefined() + }) + + test("clears delta buffers for parts omitted by the initial page", async () => { + const pending = deferredResponse() + const message = userMessage("message") + const kept = textPart(message.id, { id: "part-1", text: "kept" }) + const removed: Part = { ...kept, id: "part-2", text: "removed" } + const store = createServerSession(messageClient(pending.promise)) + store.apply({ type: "message.updated", properties: { info: message } }) + store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: kept, time: 1 } }) + store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: removed, time: 1 } }) + store.apply({ + type: "message.part.delta", + properties: { sessionID: "child", messageID: message.id, partID: removed.id, field: "text", delta: " delta" }, + }) + const loading = store.sync("child") + + pending.resolve(response([{ info: message, parts: [kept] }])) + await loading + + expect(store.data.part[message.id]).toEqual([kept]) + expect(store.data.part_text_accum_delta[removed.id]).toBeUndefined() + }) + + test("clears a stale delta buffer when a refresh replaces its part", async () => { + const message = userMessage("message") + const stale = textPart(message.id, { text: "stale" }) + const fetched = { ...stale, text: "fetched" } + const store = createServerSession( + messageClient(response([{ info: message, parts: [stale] }]), response([{ info: message, parts: [fetched] }])), + ) + await store.sync("child") + store.apply({ + type: "message.part.delta", + properties: { sessionID: "child", messageID: message.id, partID: stale.id, field: "text", delta: " delta" }, + }) + + await store.sync("child", { force: true }) + + expect(store.data.part[message.id]).toEqual([fetched]) + expect(store.data.part_text_accum_delta[stale.id]).toBeUndefined() + }) + + test("preserves a non-durable delta received before refresh", async () => { + const message = userMessage("message") + const part = textPart(message.id, { text: "stale" }) + const store = createServerSession( + messageClient(response([{ info: message, parts: [part] }]), response([{ info: message, parts: [{ ...part }] }])), + ) + await store.sync("child") + store.apply({ + type: "message.part.delta", + properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: " delta" }, + }) + + await store.sync("child", { force: true }) + + expect(store.data.part[message.id]).toEqual([{ ...part, text: "stale delta" }]) + expect(store.data.part_text_accum_delta[part.id]).toBe("stale delta") + }) + + test("accepts fetched text that intentionally replaces an accumulated prefix", async () => { + const message = userMessage("message") + const part = textPart(message.id, { text: "abc" }) + const fetched = { ...part, text: "ab" } + const store = createServerSession( + messageClient(response([{ info: message, parts: [part] }]), response([{ info: message, parts: [fetched] }])), + ) + await store.sync("child") + store.apply({ + type: "message.part.delta", + properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: "def" }, + }) + + await store.sync("child", { force: true }) + + expect(store.data.part[message.id]).toEqual([fetched]) + expect(store.data.part_text_accum_delta[part.id]).toBeUndefined() + }) + + test("preserves an unpersisted delta suffix after partial server catch-up", async () => { + const message = userMessage("message") + const part = textPart(message.id, { text: "a" }) + const fetched = { ...part, text: "ab" } + const store = createServerSession( + messageClient(response([{ info: message, parts: [part] }]), response([{ info: message, parts: [fetched] }])), + ) + await store.sync("child") + store.apply({ + type: "message.part.delta", + properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: "bc" }, + }) + + await store.sync("child", { force: true }) + + expect(store.data.part[message.id]).toEqual([{ ...part, text: "abc" }]) + expect(store.data.part_text_accum_delta[part.id]).toBe("abc") + }) + + test("clears delta state after exact server catch-up", async () => { + const message = userMessage("message") + const part = textPart(message.id, { text: "a" }) + const fetched = { ...part, text: "ab" } + const store = createServerSession( + messageClient(response([{ info: message, parts: [part] }]), response([{ info: message, parts: [fetched] }])), + ) + await store.sync("child") + store.apply({ + type: "message.part.delta", + properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: "b" }, + }) + + await store.sync("child", { force: true }) + + expect(store.data.part[message.id]).toEqual([fetched]) + expect(store.data.part_text_accum_delta[part.id]).toBeUndefined() + }) + + test("uses the successful retry response over events from a failed attempt", async () => { + const failed = Promise.withResolvers() + const retried = Promise.withResolvers() + const message = userMessage("message") + const stale = textPart(message.id, { text: "stale" }) + const intermediate = { ...stale, text: "intermediate" } + const fetched = { ...stale, text: "fetched" } + const client = messageClient(failed.promise, retried.promise) + const store = createServerSession(client, { retry: retryImmediately }) + store.apply({ type: "message.updated", properties: { info: message } }) + store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: stale, time: 1 } }) + const loading = store.sync("child") + + store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: intermediate, time: 2 } }) + failed.reject(new Error("failed to fetch")) + await client.requested(2) + retried.resolve(response([{ info: message, parts: [fetched] }])) + await loading + + expect(store.data.part[message.id]).toEqual([fetched]) + }) + + test("preserves non-durable deltas across message retries", async () => { + const failed = Promise.withResolvers() + const retried = Promise.withResolvers() + const message = userMessage("message") + const part = textPart(message.id, { text: "stale" }) + const client = messageClient(failed.promise, retried.promise) + const store = createServerSession(client, { retry: retryImmediately }) + store.apply({ type: "message.updated", properties: { info: message } }) + store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 1 } }) + const loading = store.sync("child") + + store.apply({ + type: "message.part.delta", + properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: " delta" }, + }) + failed.reject(new Error("failed to fetch")) + await client.requested(2) + retried.resolve(response([{ info: message, parts: [part] }])) + await loading + + expect(store.data.part[message.id]).toEqual([{ ...part, text: "stale delta" }]) + }) + + test("preserves part removals across message retries", async () => { + const failed = Promise.withResolvers() + const retried = Promise.withResolvers() + const message = userMessage("message") + const part = textPart(message.id) + const client = messageClient(response([{ info: message, parts: [part] }]), failed.promise, retried.promise) + const store = createServerSession(client, { retry: retryImmediately }) + await store.sync("child") + const loading = store.sync("child", { force: true }) + + store.apply({ + type: "message.part.removed", + properties: { sessionID: "child", messageID: message.id, partID: part.id }, + }) + failed.reject(new Error("failed to fetch")) + await client.requested(3) + retried.resolve(response([{ info: message, parts: [part] }])) + await loading + + expect(store.data.part[message.id]).toBeUndefined() + }) + + test("preserves message removals across message retries", async () => { + const failed = Promise.withResolvers() + const retried = Promise.withResolvers() + const message = userMessage("message") + const part = textPart(message.id) + const client = messageClient(response([{ info: message, parts: [part] }]), failed.promise, retried.promise) + const store = createServerSession(client, { retry: retryImmediately }) + await store.sync("child") + const loading = store.sync("child", { force: true }) + + store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } }) + failed.reject(new Error("failed to fetch")) + await client.requested(3) + retried.resolve(response([{ info: message, parts: [part] }])) + await loading + + expect(store.data.message.child).toEqual([]) + expect(store.data.part[message.id]).toBeUndefined() + }) + + test("preserves optimistic re-adds across message retries", async () => { + const failed = Promise.withResolvers() + const retried = Promise.withResolvers() + const message = userMessage("message") + const stale = textPart(message.id, { id: "stale", text: "stale" }) + const optimistic = textPart(message.id, { id: "optimistic", text: "optimistic" }) + const client = messageClient(response([{ info: message, parts: [stale] }]), failed.promise, retried.promise) + const store = createServerSession(client, { retry: retryImmediately }) + await store.sync("child") + const loading = store.sync("child", { force: true }) + + store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } }) + store.optimistic.add({ sessionID: "child", message, parts: [optimistic] }) + failed.reject(new Error("failed to fetch")) + await client.requested(3) + retried.resolve(response([{ info: message, parts: [stale] }])) + await loading + + expect(store.data.message.child).toEqual([message]) + expect(store.data.part[message.id]).toEqual([optimistic]) + }) + + test("accepts part omission from a successful retry after an earlier delta", async () => { + const failed = Promise.withResolvers() + const retried = Promise.withResolvers() + const message = userMessage("message") + const part = textPart(message.id) + const client = messageClient(response([{ info: message, parts: [part] }]), failed.promise, retried.promise) + const store = createServerSession(client, { retry: retryImmediately }) + await store.sync("child") + const loading = store.sync("child", { force: true }) + + store.apply({ + type: "message.part.delta", + properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: " delta" }, + }) + failed.reject(new Error("failed to fetch")) + await client.requested(3) + retried.resolve(response([{ info: message, parts: [] }])) + await loading + + expect(store.data.part[message.id]).toBeUndefined() + expect(store.data.part_text_accum_delta[part.id]).toBeUndefined() + }) + + test("clears load-owned orphan parts when all retries fail", async () => { + const first = Promise.withResolvers() + const second = Promise.withResolvers() + const third = Promise.withResolvers() + const message = userMessage("message") + const part = textPart(message.id) + const client = messageClient(first.promise, second.promise, third.promise) + const store = createServerSession(client, { retry: retryImmediately }) + const loading = store.sync("child").catch((error) => error) + + store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 2 } }) + first.reject(new Error("failed to fetch")) + await client.requested(2) + second.reject(new Error("failed to fetch")) + await client.requested(3) + third.reject(new Error("failed to fetch")) + await loading + + expect(store.data.part[message.id]).toBeUndefined() + }) + + test("preserves live updates during a forced refresh", async () => { + const pending = deferredResponse() + const stale = userMessage("message") + const stalePart = textPart(stale.id, { text: "stale" }) + const store = createServerSession(messageClient(response([{ info: stale, parts: [stalePart] }]), pending.promise)) + await store.sync("child") + const refreshing = store.sync("child", { force: true }) + const live = { ...stale, time: { created: 2 } } + + store.apply({ type: "message.updated", properties: { info: live } }) + store.apply({ + type: "message.part.delta", + properties: { sessionID: "child", messageID: stale.id, partID: stalePart.id, field: "text", delta: " live" }, + }) + pending.resolve(response([{ info: stale, parts: [stalePart] }])) + await refreshing + + expect(store.data.message.child).toEqual([live]) + expect(store.data.part[stale.id]).toEqual([{ ...stalePart, text: "stale live" }]) + }) + + test("keeps fetched message metadata when only a part changes", async () => { + const pending = deferredResponse() + const stale = userMessage("message") + const fetched = { ...stale, time: { created: 2 } } + const part = textPart(stale.id, { text: "stale" }) + const store = createServerSession(messageClient(response([{ info: stale, parts: [part] }]), pending.promise)) + await store.sync("child") + const refreshing = store.sync("child", { force: true }) + + store.apply({ + type: "message.part.delta", + properties: { sessionID: "child", messageID: stale.id, partID: part.id, field: "text", delta: " live" }, + }) + pending.resolve(response([{ info: fetched, parts: [part] }])) + await refreshing + + expect(store.data.message.child).toEqual([fetched]) + expect(store.data.part[stale.id]).toEqual([{ ...part, text: "stale live" }]) + }) + + test("preserves a part update when a forced refresh omits its message", async () => { + const pending = deferredResponse() + const message = userMessage("message") + const stale = textPart(message.id, { text: "stale" }) + const live = { ...stale, text: "live" } + const store = createServerSession(messageClient(response([{ info: message, parts: [stale] }]), pending.promise)) + await store.sync("child") + const refreshing = store.sync("child", { force: true }) + + store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: live, time: 2 } }) + pending.resolve(response()) + await refreshing + + expect(store.data.message.child).toEqual([message]) + expect(store.data.part[message.id]).toEqual([live]) + }) + + test("ignores a late part update after its message is removed", async () => { + const pending = deferredResponse() + const message = userMessage("message") + const part = textPart(message.id) + const store = createServerSession(messageClient(pending.promise)) + const loading = store.sync("child") + + store.apply({ type: "message.updated", properties: { info: message } }) + store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } }) + store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 2 } }) + pending.resolve(response([{ info: message, parts: [part] }])) + await loading + + expect(store.data.message.child).toEqual([]) + expect(store.data.part[message.id]).toBeUndefined() + }) + + test("ignores a late part update after a completed message removal", () => { + const message = userMessage("message") + const part = textPart(message.id) + const store = setup({ child: session("child") }).store + store.apply({ type: "message.updated", properties: { info: message } }) + store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } }) + + store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 2 } }) + + expect(store.data.part[message.id]).toBeUndefined() + }) + + test("does not restore a completed message removal from a stale refresh", async () => { + const message = userMessage("message") + const part = textPart(message.id) + const store = createServerSession( + messageClient(response([{ info: message, parts: [part] }]), response([{ info: message, parts: [part] }])), + ) + await store.sync("child") + store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } }) + + await store.sync("child", { force: true }) + + expect(store.data.message.child).toEqual([]) + expect(store.data.part[message.id]).toBeUndefined() + }) + + test("does not restore a completed part removal from a stale refresh", async () => { + const message = userMessage("message") + const part = textPart(message.id) + const store = createServerSession( + messageClient(response([{ info: message, parts: [part] }]), response([{ info: message, parts: [part] }])), + ) + await store.sync("child") + store.apply({ + type: "message.part.removed", + properties: { sessionID: "child", messageID: message.id, partID: part.id }, + }) + + await store.sync("child", { force: true }) + + expect(store.data.part[message.id]).toBeUndefined() + }) + + test("does not cache skipped optimistic parts", () => { + const message = userMessage("message") + const part = { id: "part", sessionID: "child", messageID: message.id, type: "step-start" as const } + const store = setup({ child: session("child") }).store + + store.optimistic.add({ sessionID: "child", message, parts: [part] }) + + expect(store.data.part[message.id]).toEqual([]) + }) + + test("clears stale delta buffers when replacing optimistic parts", () => { + const message = userMessage("message") + const stale = textPart(message.id, { id: "stale", text: "stale" }) + const optimistic = textPart(message.id, { id: "optimistic", text: "optimistic" }) + const store = setup({ child: session("child") }).store + store.optimistic.add({ sessionID: "child", message, parts: [stale] }) + store.apply({ + type: "message.part.delta", + properties: { sessionID: "child", messageID: message.id, partID: stale.id, field: "text", delta: " delta" }, + }) + + store.optimistic.add({ sessionID: "child", message, parts: [optimistic] }) + + expect(store.data.part_text_accum_delta[stale.id]).toBeUndefined() + expect(store.data.part_text_accum_delta[optimistic.id]).toBeUndefined() + }) + + test("preserves removals during history prepend", async () => { + const pending = deferredResponse() + const latest = userMessage("message-2", { time: { created: 2 } }) + const older = { ...latest, id: "message-1", time: { created: 1 } } + const store = createServerSession(messageClient(response([{ info: latest, parts: [] }], "older"), pending.promise)) + await store.sync("child") + const loading = store.history.loadMore("child") + + store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: older.id } }) + pending.resolve(response([{ info: older, parts: [] }])) + await loading + + expect(store.data.message.child).toEqual([latest]) + }) + + test("preserves loaded history during an incomplete refresh", async () => { + const older = userMessage("message-1") + const latest = userMessage("message-2", { time: { created: 2 } }) + const fresh = userMessage("message-3", { time: { created: 3 } }) + const store = createServerSession( + messageClient( + response( + [ + { info: older, parts: [] }, + { info: latest, parts: [] }, + ], + "older", + ), + response( + [ + { info: latest, parts: [] }, + { info: fresh, parts: [] }, + ], + "older", + ), + ), + ) + await store.sync("child") + + await store.sync("child", { force: true }) + + expect(store.data.message.child).toEqual([older, latest, fresh]) + }) + + test("drops stale recent messages omitted by an incomplete refresh", async () => { + const third = userMessage("message-3", { time: { created: 3 } }) + const fourth = userMessage("message-4", { time: { created: 4 } }) + const stale = userMessage("message-5", { time: { created: 5 } }) + const store = createServerSession( + messageClient( + response( + [ + { info: fourth, parts: [] }, + { info: stale, parts: [] }, + ], + "older", + ), + response( + [ + { info: third, parts: [] }, + { info: fourth, parts: [] }, + ], + "older", + ), + ), + ) + await store.sync("child") + + await store.sync("child", { force: true }) + + expect(store.data.message.child).toEqual([third, fourth]) + }) + + test("uses message creation time for incomplete refresh boundaries", async () => { + const older = userMessage("msg_z", { time: { created: 1 } }) + const boundary = userMessage("msg_m", { time: { created: 2 } }) + const stale = userMessage("msg_a", { time: { created: 3 } }) + const store = createServerSession( + messageClient( + response( + [ + { info: older, parts: [] }, + { info: stale, parts: [] }, + ], + "older", + ), + response([{ info: boundary, parts: [] }], "older"), + ), + ) + await store.sync("child") + + await store.sync("child", { force: true }) + + expect(store.data.message.child).toEqual([boundary, older]) + }) + + test("preserves a part update for a message being loaded from history", async () => { + const pending = deferredResponse() + const latest = userMessage("message-2", { time: { created: 2 } }) + const older = userMessage("message-1") + const stale = textPart(older.id, { text: "stale" }) + const live = { ...stale, text: "live" } + const store = createServerSession(messageClient(response([{ info: latest, parts: [] }], "older"), pending.promise)) + await store.sync("child") + const loading = store.history.loadMore("child") + + store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: live, time: 2 } }) + pending.resolve(response([{ info: older, parts: [stale] }])) + await loading + + expect(store.data.part[older.id]).toEqual([live]) + }) + + test("does not clear newer orphan parts after terminal history prepend", async () => { + const pending = deferredResponse() + const latest = userMessage("message-2", { time: { created: 2 } }) + const older = userMessage("message-1") + const newer = userMessage("message-3", { time: { created: 3 } }) + const part = textPart(newer.id, { text: "live" }) + const store = createServerSession(messageClient(response([{ info: latest, parts: [] }], "older"), pending.promise)) + await store.sync("child") + const loading = store.history.loadMore("child") + + store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 3 } }) + pending.resolve(response([{ info: older, parts: [] }])) + await loading + store.apply({ type: "message.updated", properties: { sessionID: "child", info: newer } }) + + expect(store.data.part[newer.id]).toEqual([part]) + }) + + test("accepts an authoritative history part after an earlier unknown-parent update", async () => { + const pending = deferredResponse() + const history = deferredResponse() + const latest = userMessage("message-2", { time: { created: 2 } }) + const older = userMessage("message-1") + const part = textPart(older.id, { text: "live" }) + const store = createServerSession(messageClient(pending.promise, history.promise)) + const loading = store.sync("child") + + store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 2 } }) + pending.resolve(response([{ info: latest, parts: [] }], "older")) + await loading + + expect(store.data.part[older.id]).toEqual([part]) + + const loadingHistory = store.history.loadMore("child") + history.resolve(response([{ info: older, parts: [{ ...part, text: "stale" }] }])) + await loadingHistory + + expect(store.data.part[older.id]).toEqual([{ ...part, text: "stale" }]) + }) + + test("preserves an unknown-parent part removal across pages", async () => { + const initial = deferredResponse() + const history = deferredResponse() + const latest = userMessage("message-2", { time: { created: 2 } }) + const older = userMessage("message-1") + const part = textPart(older.id) + const store = createServerSession(messageClient(initial.promise, history.promise)) + const loading = store.sync("child") + + store.apply({ + type: "message.part.removed", + properties: { sessionID: "child", messageID: older.id, partID: part.id }, + }) + initial.resolve(response([{ info: latest, parts: [] }], "older")) + await loading + const loadingHistory = store.history.loadMore("child") + history.resolve(response([{ info: older, parts: [part] }])) + await loadingHistory + + expect(store.data.part[older.id]).toBeUndefined() + }) + + test("clears orphaned parts when a refresh drops a message", async () => { + const message = userMessage("message") + const part = textPart(message.id, { text: "stale" }) + const store = createServerSession(messageClient(response([{ info: message, parts: [part] }]), response())) + await store.sync("child") + store.apply({ + type: "message.part.delta", + properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: " delta" }, + }) + await store.sync("child", { force: true }) + + expect(store.data.message.child).toEqual([]) + expect(store.data.part[message.id]).toBeUndefined() + expect(store.data.part_text_accum_delta[part.id]).toBeUndefined() + }) + test("applies events without a directory store", () => { const ctx = setup({}) - ctx.store.apply({ type: "session.created", properties: { info: session("root") } }) + ctx.store.apply({ type: "session.created", properties: { sessionID: "root", info: session("root") } }) ctx.store.apply({ type: "session.status", properties: { sessionID: "root", status: { type: "busy" } } }) expect(ctx.store.get("root")?.directory).toBe("/repo") expect(ctx.store.data.session_working("root")).toBe(true) + expect(ctx.get).toEqual([]) }) test("preserves pinned session content under server-wide cache pressure", () => { @@ -87,12 +1115,14 @@ describe("server session", () => { }) for (let index = 0; index < 50; index++) { + ctx.store.remember(session(`session-${index}`)) ctx.store.apply({ type: "session.status", - properties: { sessionID: `session-${index}`, status: { type: "busy" } }, + properties: { sessionID: `session-${index}`, status: { type: "idle" } }, }) } expect(ctx.store.data.message.active?.map((message) => message.id)).toEqual(["message"]) + expect(ctx.store.data.session_status["session-0"]).toBeUndefined() }) }) diff --git a/packages/app/src/context/server-session.ts b/packages/app/src/context/server-session.ts index 7bad408862..c1731a077b 100644 --- a/packages/app/src/context/server-session.ts +++ b/packages/app/src/context/server-session.ts @@ -18,44 +18,70 @@ import { rootSession } from "@/utils/session-route" import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache" const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) +const cmpMessage = (a: Message, b: Message) => a.time.created - b.time.created || cmp(a.id, b.id) const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) const initialMessagePageSize = 2 const historyMessagePageSize = 200 const sessionInfoLimit = 2_048 +const emptyIDs: ReadonlySet = new Set() type OptimisticItem = { message: Message parts: Part[] + confirmedParts?: Part[] + confirmedMessage?: boolean } -const hasParts = (parts: Part[] | undefined, want: Part[]) => { - if (!parts) return want.length === 0 - return want.every((part) => Binary.search(parts, part.id, (item) => item.id).found) +type MessagePage = { + session: Message[] + part: { id: string; part: Part[] }[] + cursor?: string + complete: boolean +} + +// Most markers describe the current HTTP attempt; deltaParts persists non-durable stream state across retries. +type MessageLoadState = { + touchedMessages: Set + removedMessages: Set + retainedMessages: Set + touchedParts: Map> + deltaParts: Map> + carriedDeltaParts: Map> + removedParts: Map> + optimisticParts: Map> + orphanParents: Set + clearedMessageParts: Set } function mergeOptimisticPage( - page: { session: Message[]; part: { id: string; part: Part[] }[]; cursor?: string; complete: boolean }, + page: MessagePage, items: OptimisticItem[], ) { - if (items.length === 0) return { ...page, confirmed: [] as string[] } + if (items.length === 0) return { ...page, observed: [] as { messageID: string; parts: Part[] }[] } const session = [...page.session] const part = new Map(page.part.map((item) => [item.id, item.part])) - const confirmed: string[] = [] + const observed: { messageID: string; parts: Part[] }[] = [] for (const item of items) { const result = Binary.search(session, item.message.id, (message) => message.id) if (!result.found) session.splice(result.index, 0, item.message) const current = part.get(item.message.id) - if (result.found && hasParts(current, item.parts)) { - confirmed.push(item.message.id) - continue - } - part.set(item.message.id, merge(current ?? [], item.parts)) + const confirmed = result.found + ? item.parts.filter((part) => Binary.search(current ?? [], part.id, (value) => value.id).found) + : [] + if (result.found) observed.push({ messageID: item.message.id, parts: confirmed }) + part.set( + item.message.id, + merge( + result.found ? (current ?? []) : merge(item.confirmedParts ?? [], current ?? []), + item.parts.filter((part) => !confirmed.includes(part)), + ), + ) } return { ...page, session, part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, parts]) => ({ id, part: parts })), - confirmed, + observed, } } @@ -75,7 +101,38 @@ function merge(a: readonly T[], b: readonly T[]) { return [...items.values()].sort((x, y) => cmp(x.id, y.id)) } -export function createServerSession(client: OpencodeClient) { +function reconcileFetched( + fetched: T[], + current: readonly T[], + options: { + touched?: ReadonlySet + retained?: ReadonlySet + preserveUnfetched?: boolean | ((item: T) => boolean) + } = {}, +) { + const result = new Map(fetched.map((item) => [item.id, item])) + const live = new Map(current.map((item) => [item.id, item])) + if (options.preserveUnfetched) { + for (const item of current) { + if (!result.has(item.id) && (options.preserveUnfetched === true || options.preserveUnfetched(item))) + result.set(item.id, item) + } + } + for (const id of options.retained ?? emptyIDs) { + if (result.has(id)) continue + const item = live.get(id) + if (item) result.set(id, item) + } + // Events observed while the request is pending are the freshest client state for those identities. + for (const id of options.touched ?? emptyIDs) { + const item = live.get(id) + if (item) result.set(id, item) + if (!item) result.delete(id) + } + return [...result.values()].sort((a, b) => cmp(a.id, b.id)) +} + +export function createServerSession(client: OpencodeClient, options?: { retry?: typeof retry }) { const [data, setData] = createStore({ info: {} as Record, session_status: {} as Record, @@ -95,10 +152,29 @@ export function createServerSession(client: OpencodeClient) { const inflightDiff = new Map>() const inflightTodo = new Map>() const optimistic = new Map>() + const messageLoads = new Map() + const pendingParts = new Map>>() + const orphanParts = new Map>() + const removedMessages = new Map>() + const deltaBases = new Map() + const deleteMessageParts = (cache: { part: Record; part_text_accum_delta: Record }, messageID: string) => { + for (const part of cache.part[messageID] ?? []) { + delete cache.part_text_accum_delta[part.id] + deltaBases.delete(part.id) + } + delete cache.part[messageID] + } const seen = new Set() const infoSeen = new Set() const pinned = new Map() - const generations = new Map() + const generations = new Map() + const generation = (sessionID: string) => { + const current = generations.get(sessionID) + if (current) return current + const created = {} + generations.set(sessionID, created) + return created + } const [meta, setMeta] = createStore({ limit: {} as Record, cursor: {} as Record, @@ -115,6 +191,11 @@ export function createServerSession(client: OpencodeClient) { const preserve = new Set([ ...pinned.keys(), ...requests.keys(), + ...inflight.keys(), + ...inflightDiff.keys(), + ...inflightTodo.keys(), + ...messageLoads.keys(), + ...optimistic.keys(), ...Object.entries(data.permission) .filter(([, items]) => items.length > 0) .map(([sessionID]) => sessionID), @@ -138,6 +219,7 @@ export function createServerSession(client: OpencodeClient) { if (!preserve.has(sessionID)) stale.push(sessionID) } stale.forEach((sessionID) => infoSeen.delete(sessionID)) + stale.forEach((sessionID) => generations.delete(sessionID)) setData( "info", produce((draft) => stale.forEach((sessionID) => delete draft[sessionID])), @@ -151,20 +233,29 @@ export function createServerSession(client: OpencodeClient) { if (cached && !options?.force) return Promise.resolve(cached) const pending = requests.get(sessionID) if (pending) return pending - const generation = generations.get(sessionID) ?? 0 + const active = generation(sessionID) const request = client.session.get({ sessionID }).then((result) => { if (!result.data) throw new Error(`Session not found: ${sessionID}`) - if ((generations.get(sessionID) ?? 0) !== generation) return result.data + if (generations.get(sessionID) !== active) return result.data return remember(result.data) }) requests.set(sessionID, request) + const cleanup = () => { + if (requests.get(sessionID) === request) requests.delete(sessionID) + if ( + generations.get(sessionID) === active && + !data.info[sessionID] && + !requests.has(sessionID) && + !messageLoads.has(sessionID) && + !inflight.has(sessionID) && + !inflightDiff.has(sessionID) && + !inflightTodo.has(sessionID) + ) + generations.delete(sessionID) + } void request.then( - () => { - if (requests.get(sessionID) === request) requests.delete(sessionID) - }, - () => { - if (requests.get(sessionID) === request) requests.delete(sessionID) - }, + cleanup, + cleanup, ) return request } @@ -195,15 +286,120 @@ export function createServerSession(client: OpencodeClient) { if (items.size === 0) optimistic.delete(sessionID) } + const clearOptimisticPart = (sessionID: string, messageID: string, partID: string) => { + const items = optimistic.get(sessionID) + const item = items?.get(messageID) + if (!items || !item) return + const parts = item.parts.filter((part) => part.id !== partID) + const confirmedParts = item.confirmedParts?.filter((part) => part.id !== partID) + if (parts.length === 0) { + clearOptimistic(sessionID, messageID) + return + } + items.set(messageID, { ...item, parts, confirmedParts, confirmedMessage: true }) + } + + const confirmOptimisticPart = (sessionID: string, messageID: string, part: Part) => { + const items = optimistic.get(sessionID) + const item = items?.get(messageID) + if (!items || !item) return + const parts = item.parts.filter((value) => value.id !== part.id) + if (parts.length === 0) { + clearOptimistic(sessionID, messageID) + return + } + items.set(messageID, { + ...item, + parts, + confirmedParts: merge(item.confirmedParts ?? [], [part]), + confirmedMessage: true, + }) + } + + const confirmOptimistic = (sessionID: string, messageID: string, confirmedParts: Part[]) => { + const items = optimistic.get(sessionID) + const item = items?.get(messageID) + if (!items || !item) return + const confirmed = new Set(confirmedParts.map((part) => part.id)) + const parts = item.parts.filter((part) => !confirmed.has(part.id)) + if (parts.length === 0) { + clearOptimistic(sessionID, messageID) + return + } + items.set(messageID, { + ...item, + parts, + confirmedParts: merge(item.confirmedParts ?? [], confirmedParts), + confirmedMessage: true, + }) + } + + const trackPartChange = (sessionID: string, messageID: string, partID: string) => { + const load = messageLoads.get(sessionID) + if (!load) return + // A part event keeps an existing parent when the fetched page omits it without overriding fetched metadata. + const messages = data.message[sessionID] + if (messages && Binary.search(messages, messageID, (message) => message.id).found) load.retainedMessages.add(messageID) + const parts = load.touchedParts.get(messageID) + if (parts) { + parts.add(partID) + return + } + load.touchedParts.set(messageID, new Set([partID])) + } + + const resetMessageLoad = (sessionID: string, load: MessageLoadState) => { + load.touchedMessages.clear() + load.retainedMessages.clear() + load.touchedParts.clear() + load.carriedDeltaParts.clear() + load.clearedMessageParts.clear() + for (const messageID of load.removedMessages) { + load.touchedMessages.add(messageID) + load.clearedMessageParts.add(messageID) + } + for (const [messageID, parts] of load.deltaParts) { + load.touchedParts.set(messageID, new Set(parts)) + load.carriedDeltaParts.set(messageID, new Set(parts)) + const messages = data.message[sessionID] + if (messages && Binary.search(messages, messageID, (message) => message.id).found) + load.retainedMessages.add(messageID) + } + for (const [messageID, parts] of load.removedParts) { + const touched = load.touchedParts.get(messageID) ?? new Set() + parts.forEach((partID) => touched.add(partID)) + load.touchedParts.set(messageID, touched) + const messages = data.message[sessionID] + if (messages && Binary.search(messages, messageID, (message) => message.id).found) + load.retainedMessages.add(messageID) + } + for (const [messageID, parts] of load.optimisticParts) { + load.removedMessages.delete(messageID) + load.clearedMessageParts.add(messageID) + load.touchedMessages.add(messageID) + const touched = load.touchedParts.get(messageID) ?? new Set() + parts.forEach((partID) => touched.add(partID)) + load.touchedParts.set(messageID, touched) + } + } + const evict = (sessionIDs: string[]) => { if (sessionIDs.length === 0) return + const evicted = new Set(sessionIDs) + for (const [partID, item] of deltaBases) { + if (evicted.has(item.sessionID)) deltaBases.delete(partID) + } sessionIDs.forEach((sessionID) => { - generations.set(sessionID, (generations.get(sessionID) ?? 0) + 1) + generations.delete(sessionID) clearOptimistic(sessionID) requests.delete(sessionID) inflight.delete(sessionID) inflightDiff.delete(sessionID) inflightTodo.delete(sessionID) + messageLoads.delete(sessionID) + pendingParts.delete(sessionID) + orphanParts.delete(sessionID) + removedMessages.delete(sessionID) }) setData( produce((draft) => { @@ -230,6 +426,7 @@ export function createServerSession(client: OpencodeClient) { ...inflight.keys(), ...inflightDiff.keys(), ...inflightTodo.keys(), + ...messageLoads.keys(), ...optimistic.keys(), ...Object.entries(data.permission) .filter(([, items]) => items.length > 0) @@ -247,8 +444,11 @@ export function createServerSession(client: OpencodeClient) { pickSessionCacheEvictions({ seen, keep: sessionID, limit: SESSION_CACHE_LIMIT, preserve: protectedSessions() }), ) - const fetchMessages = async (sessionID: string, limit: number, before?: string) => { - const response = await retry(() => client.session.messages({ sessionID, limit, before })) + const fetchMessages = async (sessionID: string, limit: number, before?: string, onAttempt?: () => void) => { + const response = await (options?.retry ?? retry)(() => { + onAttempt?.() + return client.session.messages({ sessionID, limit, before }) + }) const items = (response.data ?? []).filter((item) => !!item?.info?.id) return { session: items.map((item) => cleanMessage(item.info)).sort((a, b) => cmp(a.id, b.id)), @@ -261,30 +461,164 @@ export function createServerSession(client: OpencodeClient) { } } + const replaceMessages = (sessionID: string, messages: Message[]) => { + const messageIDs = new Set(messages.map((message) => message.id)) + const dropped = (data.message[sessionID] ?? []).filter((message) => !messageIDs.has(message.id)) + setData("message", sessionID, reconcile(messages, { key: "id" })) + setData( + produce((draft) => { + for (const message of dropped) deleteMessageParts(draft, message.id) + }), + ) + return messageIDs + } + + const replaceParts = ( + sessionID: string, + items: MessagePage["part"], + messageIDs: Set, + load?: MessageLoadState, + ) => { + for (const item of items) { + if (!messageIDs.has(item.id)) continue + const fetched = load?.clearedMessageParts.has(item.id) + ? [] + : item.part.filter((part) => !SKIP_PARTS.has(part.type)) + const fetchedIDs = new Set(fetched.map((part) => part.id)) + const pending = pendingParts.get(sessionID)?.get(item.id) + const touched = new Set([...(load?.touchedParts.get(item.id) ?? []), ...(pending ?? [])]) + for (const part of fetched) { + const accumulated = data.part_text_accum_delta[part.id] + const base = deltaBases.get(part.id)?.base + const preserveDelta = + base !== undefined && + accumulated !== undefined && + "text" in part && + typeof part.text === "string" && + part.text.startsWith(base) && + accumulated.startsWith(part.text) && + accumulated !== part.text + if (preserveDelta) touched.add(part.id) + if (load?.carriedDeltaParts.get(item.id)?.has(part.id) && !preserveDelta) touched.delete(part.id) + } + for (const partID of load?.carriedDeltaParts.get(item.id) ?? []) { + if (!fetchedIDs.has(partID)) touched.delete(partID) + } + const parts = reconcileFetched( + fetched, + data.part[item.id] ?? [], + { touched }, + ) + if (!parts.length) { + orphanParts.get(sessionID)?.delete(item.id) + setData(produce((draft) => deleteMessageParts(draft, item.id))) + continue + } + const partIDs = new Set(parts.map((part) => part.id)) + setData( + "part_text_accum_delta", + produce((draft) => { + for (const part of data.part[item.id] ?? []) { + if (!partIDs.has(part.id) || !touched.has(part.id)) { + delete draft[part.id] + deltaBases.delete(part.id) + } + } + }), + ) + setData("part", item.id, reconcile(parts, { key: "id" })) + orphanParts.get(sessionID)?.delete(item.id) + } + } + + const applyMessagePage = ( + sessionID: string, + page: MessagePage, + load: MessageLoadState | undefined, + preserveUnfetched: boolean | ((message: Message) => boolean), + cleanupOrphans: boolean, + ) => { + const merged = mergeOptimisticPage(page, [...(optimistic.get(sessionID)?.values() ?? [])]) + merged.observed.forEach((item) => { + if (!load?.clearedMessageParts.has(item.messageID)) confirmOptimistic(sessionID, item.messageID, item.parts) + }) + const touchedMessages = new Set([ + ...(load?.touchedMessages ?? []), + ...(removedMessages.get(sessionID) ?? []), + ]) + const messages = reconcileFetched( + merged.session, + data.message[sessionID] ?? [], + { + touched: touchedMessages, + retained: load?.retainedMessages, + preserveUnfetched, + }, + ) + batch(() => { + const messageIDs = replaceMessages(sessionID, messages) + replaceParts(sessionID, merged.part, messageIDs, load) + const orphans = orphanParts.get(sessionID) + if (cleanupOrphans && page.complete && orphans) { + for (const messageID of orphans) { + if (!messageIDs.has(messageID)) setData(produce((draft) => deleteMessageParts(draft, messageID))) + } + orphanParts.delete(sessionID) + } + setMeta("limit", sessionID, messages.length) + setMeta("cursor", sessionID, merged.cursor) + setMeta("complete", sessionID, merged.complete) + setMeta("at", sessionID, Date.now()) + }) + } + const loadMessages = async (sessionID: string, limit: number, before?: string, mode?: "replace" | "prepend") => { if (meta.loading[sessionID]) return - const generation = generations.get(sessionID) ?? 0 + const active = generation(sessionID) + const load: MessageLoadState = { + touchedMessages: new Set(), + removedMessages: new Set(), + retainedMessages: new Set(), + touchedParts: new Map(), + deltaParts: new Map(), + carriedDeltaParts: new Map(), + removedParts: new Map(), + optimisticParts: new Map(), + orphanParents: new Set(), + clearedMessageParts: new Set(), + } + messageLoads.set(sessionID, load) setMeta("loading", sessionID, true) - await fetchMessages(sessionID, limit, before) + let applied = false + await fetchMessages(sessionID, limit, before, () => resetMessageLoad(sessionID, load)) .then((page) => { - if ((generations.get(sessionID) ?? 0) !== generation) return - const next = mergeOptimisticPage(page, [...(optimistic.get(sessionID)?.values() ?? [])]) - next.confirmed.forEach((messageID) => clearOptimistic(sessionID, messageID)) - const messages = mode === "prepend" ? merge(data.message[sessionID] ?? [], next.session) : next.session - batch(() => { - setData("message", sessionID, reconcile(messages, { key: "id" })) - for (const item of next.part) { - const parts = item.part.filter((part) => !SKIP_PARTS.has(part.type)) - if (parts.length) setData("part", item.id, reconcile(parts, { key: "id" })) - } - setMeta("limit", sessionID, messages.length) - setMeta("cursor", sessionID, next.cursor) - setMeta("complete", sessionID, next.complete) - setMeta("at", sessionID, Date.now()) - }) + if (generations.get(sessionID) !== active) return + const first = page.session.reduce( + (oldest, message) => (!oldest || cmpMessage(message, oldest) < 0 ? message : oldest), + undefined, + ) + const preserveUnfetched = + mode === "prepend" || (!page.complete && (!first || ((message: Message) => cmpMessage(message, first) < 0))) + applyMessagePage( + sessionID, + page, + messageLoads.get(sessionID) === load ? load : undefined, + preserveUnfetched, + mode !== "prepend", + ) + applied = true }) .finally(() => { - if ((generations.get(sessionID) ?? 0) === generation) setMeta("loading", sessionID, false) + if (!applied && generations.get(sessionID) === active && messageLoads.get(sessionID) === load) { + for (const messageID of load.orphanParents) { + if (!orphanParts.get(sessionID)?.has(messageID)) continue + setData(produce((draft) => deleteMessageParts(draft, messageID))) + orphanParts.get(sessionID)?.delete(messageID) + } + if (orphanParts.get(sessionID)?.size === 0) orphanParts.delete(sessionID) + } + if (messageLoads.get(sessionID) === load) messageLoads.delete(sessionID) + if (generations.get(sessionID) === active) setMeta("loading", sessionID, false) }) } @@ -339,7 +673,13 @@ export function createServerSession(client: OpencodeClient) { const eventID = eventSessionID(event) if (eventID) { touch(eventID) - if (!data.info[eventID]) void resolve(eventID).catch(() => {}) + if ( + !data.info[eventID] && + event.type !== "session.created" && + event.type !== "session.updated" && + event.type !== "session.deleted" + ) + void resolve(eventID).catch(() => {}) } switch (event.type) { case "session.created": @@ -378,6 +718,21 @@ export function createServerSession(client: OpencodeClient) { } case "message.updated": { const info = cleanMessage((event.properties as { info: Message }).info) + const load = messageLoads.get(info.sessionID) + load?.touchedMessages.add(info.id) + load?.removedMessages.delete(info.id) + const items = optimistic.get(info.sessionID) + const item = items?.get(info.id) + if (items && item) { + if (item.parts.length === 0) clearOptimistic(info.sessionID, info.id) + if (item.parts.length > 0) items.set(info.id, { ...item, confirmedMessage: true }) + } + const orphans = orphanParts.get(info.sessionID) + orphans?.delete(info.id) + if (orphans?.size === 0) orphanParts.delete(info.sessionID) + const removedMessagesForSession = removedMessages.get(info.sessionID) + removedMessagesForSession?.delete(info.id) + if (removedMessagesForSession?.size === 0) removedMessages.delete(info.sessionID) const messages = data.message[info.sessionID] if (!messages) { setData("message", info.sessionID, [info]) @@ -395,6 +750,20 @@ export function createServerSession(client: OpencodeClient) { } case "message.removed": { const props = event.properties as { sessionID: string; messageID: string } + const load = messageLoads.get(props.sessionID) + load?.touchedMessages.add(props.messageID) + load?.removedMessages.add(props.messageID) + load?.clearedMessageParts.add(props.messageID) + load?.deltaParts.delete(props.messageID) + load?.carriedDeltaParts.delete(props.messageID) + load?.removedParts.delete(props.messageID) + load?.optimisticParts.delete(props.messageID) + pendingParts.get(props.sessionID)?.delete(props.messageID) + if (pendingParts.get(props.sessionID)?.size === 0) pendingParts.delete(props.sessionID) + const removedMessagesForSession = removedMessages.get(props.sessionID) ?? new Set() + removedMessagesForSession.add(props.messageID) + removedMessages.set(props.sessionID, removedMessagesForSession) + clearOptimistic(props.sessionID, props.messageID) setData( produce((draft) => { const messages = draft.message[props.sessionID] @@ -402,8 +771,7 @@ export function createServerSession(client: OpencodeClient) { const result = Binary.search(messages, props.messageID, (message) => message.id) if (result.found) messages.splice(result.index, 1) } - for (const part of draft.part[props.messageID] ?? []) delete draft.part_text_accum_delta[part.id] - delete draft.part[props.messageID] + deleteMessageParts(draft, props.messageID) }), ) return @@ -411,6 +779,40 @@ export function createServerSession(client: OpencodeClient) { case "message.part.updated": { const part = (event.properties as { part: Part }).part if (SKIP_PARTS.has(part.type)) return + const messages = data.message[part.sessionID] + const load = messageLoads.get(part.sessionID) + const missing = !messages || !Binary.search(messages, part.messageID, (message) => message.id).found + // Outside a page load, accepting a part without its ordered parent event would create an unbounded orphan. + if ( + missing && + (!load || load.clearedMessageParts.has(part.messageID) || removedMessages.get(part.sessionID)?.has(part.messageID)) + ) + return + if (missing) { + const orphans = orphanParts.get(part.sessionID) ?? new Set() + orphans.add(part.messageID) + orphanParts.set(part.sessionID, orphans) + load?.orphanParents.add(part.messageID) + } + const deltas = load?.deltaParts.get(part.messageID) + deltas?.delete(part.id) + if (deltas?.size === 0) load?.deltaParts.delete(part.messageID) + const carried = load?.carriedDeltaParts.get(part.messageID) + carried?.delete(part.id) + if (carried?.size === 0) load?.carriedDeltaParts.delete(part.messageID) + const removed = load?.removedParts.get(part.messageID) + removed?.delete(part.id) + if (removed?.size === 0) load?.removedParts.delete(part.messageID) + const pending = pendingParts.get(part.sessionID)?.get(part.messageID) + pending?.delete(part.id) + if (pending?.size === 0) pendingParts.get(part.sessionID)?.delete(part.messageID) + if (pendingParts.get(part.sessionID)?.size === 0) pendingParts.delete(part.sessionID) + const optimistic = load?.optimisticParts.get(part.messageID) + optimistic?.delete(part.id) + if (optimistic?.size === 0) load?.optimisticParts.delete(part.messageID) + deltaBases.delete(part.id) + trackPartChange(part.sessionID, part.messageID, part.id) + confirmOptimisticPart(part.sessionID, part.messageID, part) setData( "part_text_accum_delta", produce((draft) => void delete draft[part.id]), @@ -431,10 +833,34 @@ export function createServerSession(client: OpencodeClient) { return } case "message.part.removed": { - const props = event.properties as { messageID: string; partID: string } + const props = event.properties as { sessionID: string; messageID: string; partID: string } + // Part removal is event-only on the server, so its tombstone lasts until a later update or eviction. + const pending = pendingParts.get(props.sessionID) ?? new Map>() + const parts = pending.get(props.messageID) ?? new Set() + parts.add(props.partID) + pending.set(props.messageID, parts) + pendingParts.set(props.sessionID, pending) + const deltas = messageLoads.get(props.sessionID)?.deltaParts.get(props.messageID) + deltas?.delete(props.partID) + if (deltas?.size === 0) messageLoads.get(props.sessionID)?.deltaParts.delete(props.messageID) + const load = messageLoads.get(props.sessionID) + const carried = load?.carriedDeltaParts.get(props.messageID) + carried?.delete(props.partID) + if (carried?.size === 0) load?.carriedDeltaParts.delete(props.messageID) + if (load) { + const parts = load.removedParts.get(props.messageID) ?? new Set() + parts.add(props.partID) + load.removedParts.set(props.messageID, parts) + const optimistic = load.optimisticParts.get(props.messageID) + optimistic?.delete(props.partID) + if (optimistic?.size === 0) load.optimisticParts.delete(props.messageID) + } + trackPartChange(props.sessionID, props.messageID, props.partID) + clearOptimisticPart(props.sessionID, props.messageID, props.partID) setData( produce((draft) => { delete draft.part_text_accum_delta[props.partID] + deltaBases.delete(props.partID) const parts = draft.part[props.messageID] if (!parts) return const result = Binary.search(parts, props.partID, (part) => part.id) @@ -445,13 +871,31 @@ export function createServerSession(client: OpencodeClient) { return } case "message.part.delta": { - const props = event.properties as { messageID: string; partID: string; field: string; delta: string } + const props = event.properties as { + sessionID: string + messageID: string + partID: string + field: string + delta: string + } const parts = data.part[props.messageID] if (!parts) return const result = Binary.search(parts, props.partID, (part) => part.id) if (!result.found) return + trackPartChange(props.sessionID, props.messageID, props.partID) + const load = messageLoads.get(props.sessionID) + if (load) { + const parts = load.deltaParts.get(props.messageID) ?? new Set() + parts.add(props.partID) + load.deltaParts.set(props.messageID, parts) + const carried = load.carriedDeltaParts.get(props.messageID) + carried?.delete(props.partID) + if (carried?.size === 0) load.carriedDeltaParts.delete(props.messageID) + } const field = props.field as keyof (typeof parts)[number] const current = parts[result.index]?.[field] + if (!deltaBases.has(props.partID) && typeof current === "string") + deltaBases.set(props.partID, { base: current, sessionID: props.sessionID }) setData( "part_text_accum_delta", props.partID, @@ -559,32 +1003,67 @@ export function createServerSession(client: OpencodeClient) { }, optimistic: { add(input: { sessionID: string; message: Message; parts: Part[] }) { + const parts = input.parts.filter((part) => !!part?.id && !SKIP_PARTS.has(part.type)).sort((a, b) => cmp(a.id, b.id)) + const load = messageLoads.get(input.sessionID) + if (load?.clearedMessageParts.has(input.message.id)) { + const touched = load.touchedParts.get(input.message.id) ?? new Set() + parts.forEach((part) => touched.add(part.id)) + load.touchedParts.set(input.message.id, touched) + } + if (load) { + load.removedMessages.delete(input.message.id) + load.optimisticParts.set(input.message.id, new Set(parts.map((part) => part.id))) + } const items = optimistic.get(input.sessionID) - if (items) items.set(input.message.id, input) - if (!items) optimistic.set(input.sessionID, new Map([[input.message.id, input]])) + const removedMessagesForSession = removedMessages.get(input.sessionID) + removedMessagesForSession?.delete(input.message.id) + if (removedMessagesForSession?.size === 0) removedMessages.delete(input.sessionID) + if (items) items.set(input.message.id, { ...input, parts, confirmedParts: [] }) + if (!items) optimistic.set(input.sessionID, new Map([[input.message.id, { ...input, parts, confirmedParts: [] }]])) setData("message", input.sessionID, (messages = []) => merge(messages, [input.message])) setData( - "part", - input.message.id, - input.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)), + "part_text_accum_delta", + produce((draft) => { + for (const part of [...(data.part[input.message.id] ?? []), ...parts]) { + delete draft[part.id] + deltaBases.delete(part.id) + } + }), ) + setData("part", input.message.id, parts) }, remove(input: { sessionID: string; messageID: string }) { + const item = optimistic.get(input.sessionID)?.get(input.messageID) + if (!item) return + messageLoads.get(input.sessionID)?.optimisticParts.delete(input.messageID) clearOptimistic(input.sessionID, input.messageID) + if (item.confirmedMessage) { + const partIDs = new Set(item.parts.map((part) => part.id)) + setData( + produce((draft) => { + for (const part of item.parts) { + delete draft.part_text_accum_delta[part.id] + deltaBases.delete(part.id) + } + const parts = draft.part[input.messageID] + if (!parts) return + draft.part[input.messageID] = parts.filter((part) => !partIDs.has(part.id)) + if (draft.part[input.messageID]?.length === 0) delete draft.part[input.messageID] + }), + ) + return + } setData("message", input.sessionID, (messages) => messages?.filter((message) => message.id !== input.messageID)) - setData( - "part", - produce((draft) => void delete draft[input.messageID]), - ) + setData(produce((draft) => deleteMessageParts(draft, input.messageID))) }, }, diff(sessionID: string, options?: { force?: boolean }) { touch(sessionID) if (data.session_diff[sessionID] !== undefined && !options?.force) return Promise.resolve() return runInflight(inflightDiff, sessionID, () => { - const generation = generations.get(sessionID) ?? 0 + const active = generation(sessionID) return retry(() => client.session.diff({ sessionID })).then((result) => { - if ((generations.get(sessionID) ?? 0) !== generation) return + if (generations.get(sessionID) !== active) return setData("session_diff", sessionID, reconcile(cleanDiffs(result.data), { key: "file" })) }) }) @@ -593,9 +1072,9 @@ export function createServerSession(client: OpencodeClient) { touch(sessionID) if (data.todo[sessionID] !== undefined && !options?.force) return Promise.resolve() return runInflight(inflightTodo, sessionID, () => { - const generation = generations.get(sessionID) ?? 0 + const active = generation(sessionID) return retry(() => client.session.todo({ sessionID })).then((result) => { - if ((generations.get(sessionID) ?? 0) !== generation) return + if (generations.get(sessionID) !== active) return setData("todo", sessionID, reconcile(result.data ?? [], { key: "id" })) }) }) From 6861fedd09f954eee51217a265ca52efa7ebcf04 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 27 Jun 2026 07:43:42 +0000 Subject: [PATCH 28/45] chore: generate --- packages/app/src/context/server-sdk.test.ts | 1 - packages/app/src/context/server-sdk.tsx | 5 +- .../app/src/context/server-session.test.ts | 9 ++-- packages/app/src/context/server-session.ts | 54 ++++++++----------- 4 files changed, 28 insertions(+), 41 deletions(-) diff --git a/packages/app/src/context/server-sdk.test.ts b/packages/app/src/context/server-sdk.test.ts index 523a9ebed5..7b592178fa 100644 --- a/packages/app/src/context/server-sdk.test.ts +++ b/packages/app/src/context/server-sdk.test.ts @@ -61,7 +61,6 @@ describe("coalesceServerEvents", () => { expect(result.map((event) => event.payload.id)).toEqual(["1", "2", "3"]) }) - }) describe("enqueueServerEvent", () => { diff --git a/packages/app/src/context/server-sdk.tsx b/packages/app/src/context/server-sdk.tsx index 59d9a1f76d..06597e56e7 100644 --- a/packages/app/src/context/server-sdk.tsx +++ b/packages/app/src/context/server-sdk.tsx @@ -26,10 +26,7 @@ const coalescedKey = (event: QueuedServerEvent) => { return undefined } -export function enqueueServerEvent( - queue: QueuedServerEvent[], - event: QueuedServerEvent, -) { +export function enqueueServerEvent(queue: QueuedServerEvent[], event: QueuedServerEvent) { const key = coalescedKey(event) const previous = queue[queue.length - 1] if (key && previous && coalescedKey(previous) === key) { diff --git a/packages/app/src/context/server-session.test.ts b/packages/app/src/context/server-session.test.ts index 86021e90d8..01f5174789 100644 --- a/packages/app/src/context/server-session.test.ts +++ b/packages/app/src/context/server-session.test.ts @@ -264,7 +264,9 @@ describe("server session", () => { const message = userMessage("message") const stale = textPart(message.id, { id: "stale", text: "stale" }) const part = textPart(message.id, { id: "optimistic", text: "optimistic" }) - const store = createServerSession(messageClient(response([{ info: message, parts: [] }]), pending.promise, response())) + const store = createServerSession( + messageClient(response([{ info: message, parts: [] }]), pending.promise, response()), + ) await store.sync("child") const refreshing = store.sync("child", { force: true }) @@ -372,10 +374,7 @@ describe("server session", () => { const updated = { ...confirmed, text: "updated" } const pendingPart = textPart(message.id, { id: "pending", text: "pending" }) const store = createServerSession( - messageClient( - response([{ info: message, parts: [confirmed] }]), - response([{ info: message, parts: [updated] }]), - ), + messageClient(response([{ info: message, parts: [confirmed] }]), response([{ info: message, parts: [updated] }])), ) store.optimistic.add({ sessionID: "child", message, parts: [confirmed, pendingPart] }) await store.sync("child") diff --git a/packages/app/src/context/server-session.ts b/packages/app/src/context/server-session.ts index c1731a077b..9ba712ef25 100644 --- a/packages/app/src/context/server-session.ts +++ b/packages/app/src/context/server-session.ts @@ -53,10 +53,7 @@ type MessageLoadState = { clearedMessageParts: Set } -function mergeOptimisticPage( - page: MessagePage, - items: OptimisticItem[], -) { +function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) { if (items.length === 0) return { ...page, observed: [] as { messageID: string; parts: Part[] }[] } const session = [...page.session] const part = new Map(page.part.map((item) => [item.id, item.part])) @@ -157,7 +154,10 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: const orphanParts = new Map>() const removedMessages = new Map>() const deltaBases = new Map() - const deleteMessageParts = (cache: { part: Record; part_text_accum_delta: Record }, messageID: string) => { + const deleteMessageParts = ( + cache: { part: Record; part_text_accum_delta: Record }, + messageID: string, + ) => { for (const part of cache.part[messageID] ?? []) { delete cache.part_text_accum_delta[part.id] deltaBases.delete(part.id) @@ -253,10 +253,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: ) generations.delete(sessionID) } - void request.then( - cleanup, - cleanup, - ) + void request.then(cleanup, cleanup) return request } @@ -339,7 +336,8 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: if (!load) return // A part event keeps an existing parent when the fetched page omits it without overriding fetched metadata. const messages = data.message[sessionID] - if (messages && Binary.search(messages, messageID, (message) => message.id).found) load.retainedMessages.add(messageID) + if (messages && Binary.search(messages, messageID, (message) => message.id).found) + load.retainedMessages.add(messageID) const parts = load.touchedParts.get(messageID) if (parts) { parts.add(partID) @@ -504,11 +502,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: for (const partID of load?.carriedDeltaParts.get(item.id) ?? []) { if (!fetchedIDs.has(partID)) touched.delete(partID) } - const parts = reconcileFetched( - fetched, - data.part[item.id] ?? [], - { touched }, - ) + const parts = reconcileFetched(fetched, data.part[item.id] ?? [], { touched }) if (!parts.length) { orphanParts.get(sessionID)?.delete(item.id) setData(produce((draft) => deleteMessageParts(draft, item.id))) @@ -542,19 +536,12 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: merged.observed.forEach((item) => { if (!load?.clearedMessageParts.has(item.messageID)) confirmOptimistic(sessionID, item.messageID, item.parts) }) - const touchedMessages = new Set([ - ...(load?.touchedMessages ?? []), - ...(removedMessages.get(sessionID) ?? []), - ]) - const messages = reconcileFetched( - merged.session, - data.message[sessionID] ?? [], - { - touched: touchedMessages, - retained: load?.retainedMessages, - preserveUnfetched, - }, - ) + const touchedMessages = new Set([...(load?.touchedMessages ?? []), ...(removedMessages.get(sessionID) ?? [])]) + const messages = reconcileFetched(merged.session, data.message[sessionID] ?? [], { + touched: touchedMessages, + retained: load?.retainedMessages, + preserveUnfetched, + }) batch(() => { const messageIDs = replaceMessages(sessionID, messages) replaceParts(sessionID, merged.part, messageIDs, load) @@ -785,7 +772,9 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: // Outside a page load, accepting a part without its ordered parent event would create an unbounded orphan. if ( missing && - (!load || load.clearedMessageParts.has(part.messageID) || removedMessages.get(part.sessionID)?.has(part.messageID)) + (!load || + load.clearedMessageParts.has(part.messageID) || + removedMessages.get(part.sessionID)?.has(part.messageID)) ) return if (missing) { @@ -1003,7 +992,9 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: }, optimistic: { add(input: { sessionID: string; message: Message; parts: Part[] }) { - const parts = input.parts.filter((part) => !!part?.id && !SKIP_PARTS.has(part.type)).sort((a, b) => cmp(a.id, b.id)) + const parts = input.parts + .filter((part) => !!part?.id && !SKIP_PARTS.has(part.type)) + .sort((a, b) => cmp(a.id, b.id)) const load = messageLoads.get(input.sessionID) if (load?.clearedMessageParts.has(input.message.id)) { const touched = load.touchedParts.get(input.message.id) ?? new Set() @@ -1019,7 +1010,8 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: removedMessagesForSession?.delete(input.message.id) if (removedMessagesForSession?.size === 0) removedMessages.delete(input.sessionID) if (items) items.set(input.message.id, { ...input, parts, confirmedParts: [] }) - if (!items) optimistic.set(input.sessionID, new Map([[input.message.id, { ...input, parts, confirmedParts: [] }]])) + if (!items) + optimistic.set(input.sessionID, new Map([[input.message.id, { ...input, parts, confirmedParts: [] }]])) setData("message", input.sessionID, (messages = []) => merge(messages, [input.message])) setData( "part_text_accum_delta", From 2caa016fe1f3d8533e38e8d92fda73ce5c93082d Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Sat, 27 Jun 2026 17:13:09 +0800 Subject: [PATCH 29/45] fix(app): batch new session tab navigation (#34196) --- packages/app/src/components/titlebar.tsx | 4 +++- packages/app/src/context/tabs.tsx | 22 +++++++++++----------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 3e06787cea..b826001387 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -99,6 +99,8 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { const path = () => `${location.pathname}${location.search}${location.hash}` const creating = createMemo(() => { + const route = layout.route() + if (route.type === "draft" || route.type === "dir-new-sesssion") return true if (!params.dir) return false if (params.id) return false const parts = location.pathname.replace(/\/+$/, "").split("/") @@ -466,7 +468,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { }} onReorder={(keys) => tabsStoreActions.reorder(keys)} /> - + , prompt?: string) { const draftID = uuid() - setStore( - produce((tabs) => { - tabs.push({ type: "draft", draftID, ...draft }) - }), - ) - navigate(prompt ? `${draftHref(draftID)}&prompt=${encodeURIComponent(prompt)}` : draftHref(draftID)) + void startTransition(() => { + setStore( + produce((tabs) => { + tabs.push({ type: "draft", draftID, ...draft }) + }), + ) + navigate(prompt ? `${draftHref(draftID)}&prompt=${encodeURIComponent(prompt)}` : draftHref(draftID)) + }) }, updateDraft(draftID: string, draft: Partial>) { setStore( @@ -177,13 +179,11 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ ) }, promoteDraft(draftID: string, session: Omit) { - // We're viewing this draft when /new-session?draftId=… points at it. Promoting - // replaces the draft tab with a session tab, so the draft route would stop resolving - // and fall back home. Navigate to the new session first so we leave /new-session - // before the draft is removed from the store. + // Keep the replacement and navigation atomic so /new-session never renders + // after its backing draft tab has been removed from the store. const active = location.pathname === "/new-session" && location.query.draftId === draftID const next = { type: "session" as const, ...session } - startTransition(() => { + void startTransition(() => { setStore( produce((tabs) => { const index = tabs.findIndex((tab) => tab.type === "draft" && tab.draftID === draftID) From f5a0b920a2430a9682b45b1148e90e43d0a9c126 Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Sat, 27 Jun 2026 15:37:17 +0530 Subject: [PATCH 30/45] feat(app): minor visual updates (#34205) --- packages/app/src/pages/home.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 7a8a33db84..8817ef22b7 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -408,7 +408,7 @@ export function NewHome() { return (
-
+
0} fallback={} > -
+
{(group, index) => (
@@ -965,7 +965,7 @@ function HomeSessionSearch(props: { style={{ top: "-6px", left: "-6px", - width: "calc(100% + 14px)", + width: "calc(100% + 12px)", }} >
From ecc5c44d9a2b787620221982a9b8f50ed898bdef Mon Sep 17 00:00:00 2001 From: James Long Date: Sat, 27 Jun 2026 11:09:07 -0400 Subject: [PATCH 31/45] refactor(core): make node build bind maps conditionally (#34218) --- .../src/effect/layer-node/layer-node-tree.ts | 141 +++++++++++------- packages/core/src/effect/node-build.ts | 6 + .../test/effect/layer-node/node-build.test.ts | 112 +++++++------- 3 files changed, 149 insertions(+), 110 deletions(-) diff --git a/packages/core/src/effect/layer-node/layer-node-tree.ts b/packages/core/src/effect/layer-node/layer-node-tree.ts index 44df9d22f4..5a35af693d 100644 --- a/packages/core/src/effect/layer-node/layer-node-tree.ts +++ b/packages/core/src/effect/layer-node/layer-node-tree.ts @@ -3,6 +3,52 @@ import { LayerNode } from "./layer-node" type AnyNode = LayerNode.Node type RuntimeLayer = Layer.Layer +type Visit = (node: AnyNode, context: VisitContext) => Result + +type VisitContext = { + readonly cache: Map + readonly visit: (node: AnyNode) => Result +} + +function walk( + root: AnyNode, + visit: Visit, + options: { + readonly cache?: Map + readonly resolve?: (node: AnyNode) => AnyNode + readonly detectCycles?: boolean + } = {}, +) { + const cache = options.cache ?? new Map() + const visiting = new Set() + const stack: AnyNode[] = [] + + const recur = (node: AnyNode): Result => { + const target = options.resolve?.(node) ?? node + const cached = cache.get(target) + if (cached !== undefined || cache.has(target)) return cached! + + if (options.detectCycles !== false && visiting.has(target)) { + const start = stack.indexOf(target) + throw new Error( + `Cycle detected in layer tree: ${[...stack.slice(start), target].map((item) => item.name).join(" -> ")}`, + ) + } + + visiting.add(target) + stack.push(target) + try { + const result = visit(target, { cache, visit: recur }) + if (!cache.has(target)) cache.set(target, result) + return result + } finally { + stack.pop() + visiting.delete(target) + } + } + + return recur(root) +} export function hoist( root: LayerNode.Node, @@ -11,54 +57,28 @@ export function hoist( readonly node: LayerNode.Node readonly hoisted: LayerNode.Node } { - const visited = new Map() const hoisted = new Map() - const visiting = new Set() - const stack: AnyNode[] = [] - const visit = (node: AnyNode): AnyNode => { + const node = walk(root, (node, context) => { if (node.kind === "group") { - return { ...node, dependencies: node.dependencies.map(visit) } + return { ...node, dependencies: node.dependencies.map(context.visit) } } - - const existingNode = visited.get(node) - if (existingNode) return existingNode - if (node.tag === tag) { const existing = hoisted.get(node.name) if (existing && existing !== node) { throw new Error(`Tag ${tag} has conflicting implementations for ${node.name}`) } hoisted.set(node.name, node) - const empty = LayerNode.group([]) - visited.set(node, empty) - return empty + return LayerNode.group([]) } if (node.kind === "unbound") { return node } - - if (visiting.has(node)) { - const start = stack.indexOf(node) - throw new Error( - `Cycle detected in layer tree: ${[...stack.slice(start), node].map((item) => item.name).join(" -> ")}`, - ) - } - visiting.add(node) - stack.push(node) - try { - const dependencies = node.dependencies.map(visit) - const clone = { ...node, dependencies } - visited.set(node, clone) - return clone - } finally { - stack.pop() - visiting.delete(node) - } - } + return { ...node, dependencies: node.dependencies.map(context.visit) } + }) return { - node: visit(root) as LayerNode.Node, + node: node as LayerNode.Node, hoisted: LayerNode.group(Array.from(hoisted.values())) as LayerNode.Node, } } @@ -68,24 +88,32 @@ export function compile( replacements?: ReadonlyMap, ): Layer.Layer { const cache = new Map() - const compileNode = (node: AnyNode): RuntimeLayer => { - if (node.kind === "unbound") throw new Error(`Unbound layer node: ${node.name}`) - const cached = cache.get(node) - if (cached) return cached - const dependencies = node.dependencies.flatMap(flatten).map(compileNode) - const implementation = (replacements?.get(node.implementation!) ?? node.implementation!) as RuntimeLayer - const layer = - dependencies.length === 0 - ? implementation - : implementation.pipe(Layer.provide(dependencies as [RuntimeLayer, ...RuntimeLayer[]])) - cache.set(node, layer) - return layer - } + const compileNode = (node: AnyNode) => + walk( + node, + (node, context) => { + if (node.kind === "unbound") throw new Error(`Unbound layer node: ${node.name}`) + const dependencies = node.dependencies.flatMap(flatten).map(context.visit) + const implementation = (replacements?.get(node.implementation!) ?? node.implementation!) as RuntimeLayer + return dependencies.length === 0 + ? implementation + : implementation.pipe(Layer.provide(dependencies as [RuntimeLayer, ...RuntimeLayer[]])) + }, + { cache }, + ) const layers = flatten(root).map((node) => compileNode(node)) const layer = layers.reduce((result, layer) => layer.pipe(Layer.provideMerge(result)), Layer.empty) return layer as Layer.Layer } +export function hasUnbound(root: LayerNode.Node, source: AnyNode): boolean { + if (source.kind !== "unbound") throw new Error(`Cannot check non-unbound layer node: ${source.name}`) + return walk(root, (node, context) => { + if (node === source) return true + return node.dependencies.some(context.visit) + }) +} + export function bind( root: LayerNode.Node, source: AnyNode, @@ -98,17 +126,18 @@ export function bind( if (source.tag !== replacement.tag) { throw new Error(`Cannot bind ${source.name} across tags`) } - const visited = new Map() - const visit = (node: AnyNode): AnyNode => { - if (node === source) return replacement - const existing = visited.get(node) - if (existing) return existing - if (node.kind === "unbound") return node - const clone = { ...node, dependencies: node.dependencies.map(visit) } - visited.set(node, clone) - return clone - } - return visit(root) as LayerNode.Node + return walk( + root, + (target, context) => { + if (target.kind === "unbound") return target + const dependencies: AnyNode[] = [] + const clone = { ...target, dependencies } + context.cache.set(target, clone) + dependencies.push(...target.dependencies.map(context.visit)) + return clone + }, + { detectCycles: false, resolve: (node) => (node === source ? replacement : node) }, + ) as LayerNode.Node } function flatten(node: AnyNode): readonly AnyNode[] { diff --git a/packages/core/src/effect/node-build.ts b/packages/core/src/effect/node-build.ts index c576da2daf..b9917a1311 100644 --- a/packages/core/src/effect/node-build.ts +++ b/packages/core/src/effect/node-build.ts @@ -7,6 +7,12 @@ import { makeGlobalNode } from "./node" export function build(root: LayerNode.Node, replacements?: readonly LayerNode.Replacement[]) { const replacementMap = new Map(replacements?.map((item) => [item.source, item.replacement])) + if (!LayerNodeTree.hasUnbound(root, LocationServiceMap.node)) { + // If the location service map is not needed, we shouldn't pull it + // in. Compile the graph normally + return LayerNodeTree.compile(root, replacementMap) + } + const locationMap = buildLocationServiceMap(replacementMap) const locationMapNode = makeGlobalNode({ service: LocationServiceMap.Service, layer: locationMap, deps: [] }) diff --git a/packages/core/test/effect/layer-node/node-build.test.ts b/packages/core/test/effect/layer-node/node-build.test.ts index eb09199e5b..ed169555e3 100644 --- a/packages/core/test/effect/layer-node/node-build.test.ts +++ b/packages/core/test/effect/layer-node/node-build.test.ts @@ -1,21 +1,72 @@ import { describe, expect, test } from "bun:test" -import { Context, Effect, Layer } from "effect" -import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Context, Effect, Layer, LayerMap, Option } from "effect" +import { LayerNode, LayerNodeTree } from "@opencode-ai/core/effect/layer-node" import { Node } from "@opencode-ai/core/effect/node" import { NodeBuild } from "@opencode-ai/core/effect/node-build" import { Location } from "@opencode-ai/core/location" import { LocationServiceMap } from "@opencode-ai/core/location-service-map" +import type { LocationError, LocationServices } from "@opencode-ai/core/location-services" import { Project } from "@opencode-ai/core/project" import { AbsolutePath } from "@opencode-ai/core/schema" import { tmpdir } from "../../fixture/tmpdir" class Value extends Context.Service()("test/TagValue") {} class Result extends Context.Service()("test/TagResult") {} -class Left extends Context.Service()("test/TagLeft") {} -class Right extends Context.Service()("test/TagRight") {} -class Last extends Context.Service()("test/TagLast") {} +class CycleA extends Context.Service()("test/NodeBuildA") {} +class CycleB extends Context.Service()("test/NodeBuildB") {} describe("node build", () => { + test("does not build a location service map when the graph does not require it", async () => { + const result = Node.makeGlobalNode({ + service: Result, + layer: Layer.succeed(Result, Result.of({ value: "plain" })), + deps: [], + }) + const layer = NodeBuild.build(LayerNode.group([result])) + const program = Effect.gen(function* () { + expect(Option.isNone(yield* Effect.serviceOption(LocationServiceMap.Service))).toBe(true) + return (yield* Result).value + }).pipe(Effect.provide(layer)) + + expect(await Effect.runPromise(program)).toBe("plain") + }) + + test("detects cycles through a bound location service map", () => { + const a = Node.makeGlobalNode({ + service: CycleA, + layer: Layer.effect(CycleA, Effect.as(LocationServiceMap.Service, CycleA.of({}))), + deps: [LocationServiceMap.node], + }) + const b = Node.makeGlobalNode({ + service: CycleB, + layer: Layer.effect( + CycleB, + Effect.map(CycleA, () => CycleB.of({ directory: AbsolutePath.make(process.cwd()) })), + ), + deps: [a], + }) + const mapEffect = Effect.gen(function* () { + const service = yield* CycleB + return yield* LayerMap.make( + (ref: Location.Ref) => + Layer.succeed( + Location.Service, + Location.Service.of({ + directory: ref.directory, + workspaceID: ref.workspaceID, + project: { id: Project.ID.global, directory: service.directory }, + }), + ), + { idleTimeToLive: "1 minute" }, + ) + }) as unknown as Effect.Effect, never, CycleB> + const mapLayer = Layer.effect(LocationServiceMap.Service, mapEffect) + const map = Node.makeGlobalNode({ service: LocationServiceMap.Service, layer: mapLayer, deps: [b] }) + const graph = LayerNodeTree.bind(LayerNode.group([a]), LocationServiceMap.node, map) + + expect(() => NodeBuild.build(graph)).toThrow("Cycle detected in layer tree") + }) + test("shares top-level project with location services", async () => { await using tmp = await tmpdir() let acquisitions = 0 @@ -37,6 +88,7 @@ describe("node build", () => { const program = Effect.gen(function* () { yield* Project.Service const locations = yield* LocationServiceMap.Service + expect(Option.isSome(yield* Effect.serviceOption(LocationServiceMap.Service))).toBe(true) return yield* Location.Service.pipe(Effect.provide(locations.get(ref))) }).pipe(Effect.provide(layer)) @@ -62,58 +114,10 @@ describe("node build", () => { }) const serviceLayer = NodeBuild.build(LayerNode.group([result])) const program = Effect.gen(function* () { + expect(Option.isNone(yield* Effect.serviceOption(LocationServiceMap.Service))).toBe(true) return (yield* Result).value }).pipe(Effect.provide(serviceLayer)) expect(await Effect.runPromise(program)).toBe("value") }) - - test("rebinds same-tag providers without reacquiring them", async () => { - let firstAcquisitions = 0 - const tags = LayerNode.tags({ global: [] }) - const global = tags.make("global") - const first = global({ - service: Value, - layer: Layer.effect( - Value, - Effect.sync(() => { - firstAcquisitions++ - return Value.of({ value: "first" }) - }), - ), - deps: [], - }) - const second = global({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "second" })), deps: [] }) - const left = global({ - service: Left, - layer: Layer.effect( - Left, - Effect.map(Value, (value) => Left.of({ value: value.value })), - ), - deps: [first], - }) - const right = global({ - service: Right, - layer: Layer.effect( - Right, - Effect.map(Value, (value) => Right.of({ value: value.value })), - ), - deps: [second], - }) - const last = global({ - service: Last, - layer: Layer.effect( - Last, - Effect.map(Value, (value) => Last.of({ value: value.value })), - ), - deps: [first], - }) - const layer = NodeBuild.build(LayerNode.group([left, right, last])) as Layer.Layer - const values = Effect.gen(function* () { - return [(yield* Left).value, (yield* Right).value, (yield* Last).value] - }).pipe(Effect.provide(layer)) - - expect(await Effect.runPromise(values)).toEqual(["first", "second", "first"]) - expect(firstAcquisitions).toBe(1) - }) }) From 25702e01ce4afcbd57c9a32137650301f7c24ccc Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:07:02 +0530 Subject: [PATCH 32/45] feat(app): new debug bar (#34237) --- packages/app/src/components/debug-bar.tsx | 69 +++++++++++++++++++---- packages/app/src/pages/layout-new.tsx | 2 +- 2 files changed, 59 insertions(+), 12 deletions(-) diff --git a/packages/app/src/components/debug-bar.tsx b/packages/app/src/components/debug-bar.tsx index eda025546a..3699a1a4d8 100644 --- a/packages/app/src/components/debug-bar.tsx +++ b/packages/app/src/components/debug-bar.tsx @@ -3,6 +3,7 @@ import { batch, createEffect, onCleanup, onMount } from "solid-js" import { createStore } from "solid-js/store" import { makeEventListener } from "@solid-primitives/event-listener" import { Tooltip } from "@opencode-ai/ui/tooltip" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { useLanguage } from "@/context/language" type Mem = Performance & { @@ -51,19 +52,29 @@ const bad = (n: number | undefined, limit: number, low = false) => { const session = (path: string) => path.includes("/session") -function Cell(props: { bad?: boolean; dim?: boolean; label: string; tip: string; value: string; wide?: boolean }) { - return ( - +function Cell(props: { bad?: boolean; dim?: boolean; inline?: boolean; label: string; tip: string; value: string; wide?: boolean }) { + const content = () => (
-
{props.label}
+ {props.label} +
+
+ ) + + if (props.inline) { + return ( + + {content()} + + ) + } + + return ( + + {content()} ) } -export function DebugBar() { +export function DebugBar(props: { inline?: boolean } = {}) { const language = useLanguage() const location = useLocation() const routing = useIsRouting() @@ -101,7 +125,7 @@ export function DebugBar() { }, }) - const na = () => language.t("debugBar.na") + const na = () => language.t("debugBar.na").toUpperCase() const heap = () => (state.heap.limit ? (state.heap.used ?? 0) / state.heap.limit : undefined) const heapv = () => { const value = heap() @@ -363,15 +387,30 @@ export function DebugBar() { return (