diff --git a/AGENTS.md b/AGENTS.md index 649f0ae055..703bae8912 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -159,4 +159,5 @@ const table = sqliteTable("session", { - Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash continuation recovery requires a separate explicit design before it may retry provider work. A drain has no durable identity or transcript boundary. - Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe provider-turn boundary while the current drain requires continuation. An explicit `queue` input remains pending until the Session would otherwise become idle; promote one queued input at that boundary, then reevaluate continuation before promoting another. Promoting any new user input resets the selected agent's provider-turn allowance; a batch of steers resets it once. - Keep EventV2 replay owner claims separate from clustered Session execution ownership. -- Keep the System Context algebra, registry, and built-ins in `src/system-context`; keep Context Source producers with their observed domains, and keep Session History selection plus Context Epoch persistence Session-owned. +- Keep the System Context algebra and built-ins in `src/system-context`; keep Context Source producers with their observed domains, and keep Session History selection plus Context Checkpoint persistence Session-owned. The runner composes all context producers explicitly in `loadSystemContext`; there is no context registry. +- The durable Applied record is what the model was last told, per source. Reconcile narrates drift as chronological System updates and never rewrites the baseline; only completed compaction rebaselines, and move or committed revert resets the checkpoint. Unavailable sources keep the model's prior belief, blocking only a session's first baseline. diff --git a/packages/client/src/generated-effect/client.ts b/packages/client/src/generated-effect/client.ts index 80f38d287c..776862e7eb 100644 --- a/packages/client/src/generated-effect/client.ts +++ b/packages/client/src/generated-effect/client.ts @@ -151,49 +151,81 @@ const Endpoint4_8 = (raw: RawClient["server.session"]) => (input: Endpoint4_8Inp Effect.map((value) => value.data), ) -type Endpoint4_9Request = Parameters[0] +type Endpoint4_9Request = Parameters[0] type Endpoint4_9Input = { readonly sessionID: Endpoint4_9Request["params"]["sessionID"] readonly id?: Endpoint4_9Request["payload"]["id"] - readonly skill: Endpoint4_9Request["payload"]["skill"] + readonly command: Endpoint4_9Request["payload"]["command"] + readonly arguments?: Endpoint4_9Request["payload"]["arguments"] + readonly agent?: Endpoint4_9Request["payload"]["agent"] + readonly model?: Endpoint4_9Request["payload"]["model"] + readonly files?: Endpoint4_9Request["payload"]["files"] + readonly agents?: Endpoint4_9Request["payload"]["agents"] + readonly delivery?: Endpoint4_9Request["payload"]["delivery"] readonly resume?: Endpoint4_9Request["payload"]["resume"] } const Endpoint4_9 = (raw: RawClient["server.session"]) => (input: Endpoint4_9Input) => + raw["session.command"]({ + params: { sessionID: input["sessionID"] }, + payload: { + id: input["id"], + command: input["command"], + arguments: input["arguments"], + agent: input["agent"], + model: input["model"], + files: input["files"], + agents: input["agents"], + delivery: input["delivery"], + resume: input["resume"], + }, + }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ) + +type Endpoint4_10Request = Parameters[0] +type Endpoint4_10Input = { + readonly sessionID: Endpoint4_10Request["params"]["sessionID"] + readonly id?: Endpoint4_10Request["payload"]["id"] + readonly skill: Endpoint4_10Request["payload"]["skill"] + readonly resume?: Endpoint4_10Request["payload"]["resume"] +} +const Endpoint4_10 = (raw: RawClient["server.session"]) => (input: Endpoint4_10Input) => raw["session.skill"]({ params: { sessionID: input["sessionID"] }, payload: { id: input["id"], skill: input["skill"], resume: input["resume"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_10Request = Parameters[0] -type Endpoint4_10Input = { - readonly sessionID: Endpoint4_10Request["params"]["sessionID"] - readonly text: Endpoint4_10Request["payload"]["text"] - readonly description?: Endpoint4_10Request["payload"]["description"] - readonly metadata?: Endpoint4_10Request["payload"]["metadata"] +type Endpoint4_11Request = Parameters[0] +type Endpoint4_11Input = { + readonly sessionID: Endpoint4_11Request["params"]["sessionID"] + readonly text: Endpoint4_11Request["payload"]["text"] + readonly description?: Endpoint4_11Request["payload"]["description"] + readonly metadata?: Endpoint4_11Request["payload"]["metadata"] } -const Endpoint4_10 = (raw: RawClient["server.session"]) => (input: Endpoint4_10Input) => +const Endpoint4_11 = (raw: RawClient["server.session"]) => (input: Endpoint4_11Input) => raw["session.synthetic"]({ params: { sessionID: input["sessionID"] }, payload: { text: input["text"], description: input["description"], metadata: input["metadata"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_11Request = Parameters[0] -type Endpoint4_11Input = { readonly sessionID: Endpoint4_11Request["params"]["sessionID"] } -const Endpoint4_11 = (raw: RawClient["server.session"]) => (input: Endpoint4_11Input) => - raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) - -type Endpoint4_12Request = Parameters[0] +type Endpoint4_12Request = Parameters[0] type Endpoint4_12Input = { readonly sessionID: Endpoint4_12Request["params"]["sessionID"] } const Endpoint4_12 = (raw: RawClient["server.session"]) => (input: Endpoint4_12Input) => + raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint4_13Request = Parameters[0] +type Endpoint4_13Input = { readonly sessionID: Endpoint4_13Request["params"]["sessionID"] } +const Endpoint4_13 = (raw: RawClient["server.session"]) => (input: Endpoint4_13Input) => raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_13Request = Parameters[0] -type Endpoint4_13Input = { - readonly sessionID: Endpoint4_13Request["params"]["sessionID"] - readonly messageID: Endpoint4_13Request["payload"]["messageID"] - readonly files?: Endpoint4_13Request["payload"]["files"] +type Endpoint4_14Request = Parameters[0] +type Endpoint4_14Input = { + readonly sessionID: Endpoint4_14Request["params"]["sessionID"] + readonly messageID: Endpoint4_14Request["payload"]["messageID"] + readonly files?: Endpoint4_14Request["payload"]["files"] } -const Endpoint4_13 = (raw: RawClient["server.session"]) => (input: Endpoint4_13Input) => +const Endpoint4_14 = (raw: RawClient["server.session"]) => (input: Endpoint4_14Input) => raw["session.revert.stage"]({ params: { sessionID: input["sessionID"] }, payload: { messageID: input["messageID"], files: input["files"] }, @@ -202,42 +234,72 @@ const Endpoint4_13 = (raw: RawClient["server.session"]) => (input: Endpoint4_13I Effect.map((value) => value.data), ) -type Endpoint4_14Request = Parameters[0] -type Endpoint4_14Input = { readonly sessionID: Endpoint4_14Request["params"]["sessionID"] } -const Endpoint4_14 = (raw: RawClient["server.session"]) => (input: Endpoint4_14Input) => - raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) - -type Endpoint4_15Request = Parameters[0] +type Endpoint4_15Request = Parameters[0] type Endpoint4_15Input = { readonly sessionID: Endpoint4_15Request["params"]["sessionID"] } const Endpoint4_15 = (raw: RawClient["server.session"]) => (input: Endpoint4_15Input) => - raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_16Request = Parameters[0] +type Endpoint4_16Request = Parameters[0] type Endpoint4_16Input = { readonly sessionID: Endpoint4_16Request["params"]["sessionID"] } const Endpoint4_16 = (raw: RawClient["server.session"]) => (input: Endpoint4_16Input) => + raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint4_17Request = Parameters[0] +type Endpoint4_17Input = { readonly sessionID: Endpoint4_17Request["params"]["sessionID"] } +const Endpoint4_17 = (raw: RawClient["server.session"]) => (input: Endpoint4_17Input) => raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint4_17Request = Parameters[0] -type Endpoint4_17Input = { - readonly sessionID: Endpoint4_17Request["params"]["sessionID"] - readonly limit?: Endpoint4_17Request["query"]["limit"] - readonly after?: Endpoint4_17Request["query"]["after"] +type Endpoint4_18Request = Parameters[0] +type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["sessionID"] } +const Endpoint4_18 = (raw: RawClient["server.session"]) => (input: Endpoint4_18Input) => + raw["session.context.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ) + +type Endpoint4_19Request = Parameters[0] +type Endpoint4_19Input = { + readonly sessionID: Endpoint4_19Request["params"]["sessionID"] + readonly key: Endpoint4_19Request["params"]["key"] + readonly value: Endpoint4_19Request["payload"]["value"] } -const Endpoint4_17 = (raw: RawClient["server.session"]) => (input: Endpoint4_17Input) => +const Endpoint4_19 = (raw: RawClient["server.session"]) => (input: Endpoint4_19Input) => + raw["session.context.entry.put"]({ + params: { sessionID: input["sessionID"], key: input["key"] }, + payload: { value: input["value"] }, + }).pipe(Effect.mapError(mapClientError)) + +type Endpoint4_20Request = Parameters[0] +type Endpoint4_20Input = { + readonly sessionID: Endpoint4_20Request["params"]["sessionID"] + readonly key: Endpoint4_20Request["params"]["key"] +} +const Endpoint4_20 = (raw: RawClient["server.session"]) => (input: Endpoint4_20Input) => + raw["session.context.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe( + Effect.mapError(mapClientError), + ) + +type Endpoint4_21Request = Parameters[0] +type Endpoint4_21Input = { + readonly sessionID: Endpoint4_21Request["params"]["sessionID"] + readonly limit?: Endpoint4_21Request["query"]["limit"] + readonly after?: Endpoint4_21Request["query"]["after"] +} +const Endpoint4_21 = (raw: RawClient["server.session"]) => (input: Endpoint4_21Input) => raw["session.history"]({ params: { sessionID: input["sessionID"] }, query: { limit: input["limit"], after: input["after"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_18Request = Parameters[0] -type Endpoint4_18Input = { - readonly sessionID: Endpoint4_18Request["params"]["sessionID"] - readonly after?: Endpoint4_18Request["query"]["after"] +type Endpoint4_22Request = Parameters[0] +type Endpoint4_22Input = { + readonly sessionID: Endpoint4_22Request["params"]["sessionID"] + readonly after?: Endpoint4_22Request["query"]["after"] } -const Endpoint4_18 = (raw: RawClient["server.session"]) => (input: Endpoint4_18Input) => +const Endpoint4_22 = (raw: RawClient["server.session"]) => (input: Endpoint4_22Input) => Stream.unwrap( raw["session.events"]({ params: { sessionID: input["sessionID"] }, query: { after: input["after"] } }).pipe( Effect.mapError(mapClientError), @@ -245,22 +307,22 @@ const Endpoint4_18 = (raw: RawClient["server.session"]) => (input: Endpoint4_18I ), ) -type Endpoint4_19Request = Parameters[0] -type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] } -const Endpoint4_19 = (raw: RawClient["server.session"]) => (input: Endpoint4_19Input) => +type Endpoint4_23Request = Parameters[0] +type Endpoint4_23Input = { readonly sessionID: Endpoint4_23Request["params"]["sessionID"] } +const Endpoint4_23 = (raw: RawClient["server.session"]) => (input: Endpoint4_23Input) => raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_20Request = Parameters[0] -type Endpoint4_20Input = { readonly sessionID: Endpoint4_20Request["params"]["sessionID"] } -const Endpoint4_20 = (raw: RawClient["server.session"]) => (input: Endpoint4_20Input) => +type Endpoint4_24Request = Parameters[0] +type Endpoint4_24Input = { readonly sessionID: Endpoint4_24Request["params"]["sessionID"] } +const Endpoint4_24 = (raw: RawClient["server.session"]) => (input: Endpoint4_24Input) => raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_21Request = Parameters[0] -type Endpoint4_21Input = { - readonly sessionID: Endpoint4_21Request["params"]["sessionID"] - readonly messageID: Endpoint4_21Request["params"]["messageID"] +type Endpoint4_25Request = Parameters[0] +type Endpoint4_25Input = { + readonly sessionID: Endpoint4_25Request["params"]["sessionID"] + readonly messageID: Endpoint4_25Request["params"]["messageID"] } -const Endpoint4_21 = (raw: RawClient["server.session"]) => (input: Endpoint4_21Input) => +const Endpoint4_25 = (raw: RawClient["server.session"]) => (input: Endpoint4_25Input) => raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), @@ -276,19 +338,23 @@ const adaptGroup4 = (raw: RawClient["server.session"]) => ({ switchModel: Endpoint4_6(raw), rename: Endpoint4_7(raw), prompt: Endpoint4_8(raw), - skill: Endpoint4_9(raw), - synthetic: Endpoint4_10(raw), - compact: Endpoint4_11(raw), - wait: Endpoint4_12(raw), - revertStage: Endpoint4_13(raw), - revertClear: Endpoint4_14(raw), - revertCommit: Endpoint4_15(raw), - context: Endpoint4_16(raw), - history: Endpoint4_17(raw), - events: Endpoint4_18(raw), - interrupt: Endpoint4_19(raw), - background: Endpoint4_20(raw), - message: Endpoint4_21(raw), + command: Endpoint4_9(raw), + skill: Endpoint4_10(raw), + synthetic: Endpoint4_11(raw), + compact: Endpoint4_12(raw), + wait: Endpoint4_13(raw), + revertStage: Endpoint4_14(raw), + revertClear: Endpoint4_15(raw), + revertCommit: Endpoint4_16(raw), + context: Endpoint4_17(raw), + listContextEntries: Endpoint4_18(raw), + putContextEntry: Endpoint4_19(raw), + removeContextEntry: Endpoint4_20(raw), + history: Endpoint4_21(raw), + events: Endpoint4_22(raw), + interrupt: Endpoint4_23(raw), + background: Endpoint4_24(raw), + message: Endpoint4_25(raw), }) type Endpoint5_0Request = Parameters[0] @@ -311,7 +377,12 @@ type Endpoint6_0Input = { readonly location?: Endpoint6_0Request["query"]["locat const Endpoint6_0 = (raw: RawClient["server.model"]) => (input?: Endpoint6_0Input) => raw["model.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) -const adaptGroup6 = (raw: RawClient["server.model"]) => ({ list: Endpoint6_0(raw) }) +type Endpoint6_1Request = Parameters[0] +type Endpoint6_1Input = { readonly location?: Endpoint6_1Request["query"]["location"] } +const Endpoint6_1 = (raw: RawClient["server.model"]) => (input?: Endpoint6_1Input) => + raw["model.default"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)) + +const adaptGroup6 = (raw: RawClient["server.model"]) => ({ list: Endpoint6_0(raw), default: Endpoint6_1(raw) }) type Endpoint7_0Request = Parameters[0] type Endpoint7_0Input = { diff --git a/packages/client/src/generated/client.ts b/packages/client/src/generated/client.ts index d1e7225bed..485ee78039 100644 --- a/packages/client/src/generated/client.ts +++ b/packages/client/src/generated/client.ts @@ -23,6 +23,8 @@ import type { SessionRenameOutput, SessionPromptInput, SessionPromptOutput, + SessionCommandInput, + SessionCommandOutput, SessionSkillInput, SessionSkillOutput, SessionSyntheticInput, @@ -39,6 +41,12 @@ import type { SessionRevertCommitOutput, SessionContextInput, SessionContextOutput, + SessionListContextEntriesInput, + SessionListContextEntriesOutput, + SessionPutContextEntryInput, + SessionPutContextEntryOutput, + SessionRemoveContextEntryInput, + SessionRemoveContextEntryOutput, SessionHistoryInput, SessionHistoryOutput, SessionEventsInput, @@ -53,6 +61,8 @@ import type { MessageListOutput, ModelListInput, ModelListOutput, + ModelDefaultInput, + ModelDefaultOutput, GenerateTextInput, GenerateTextOutput, ProviderListInput, @@ -451,6 +461,28 @@ export function make(options: ClientOptions) { }, requestOptions, ).then((value) => value.data), + command: (input: SessionCommandInput, requestOptions?: RequestOptions) => + request<{ readonly data: SessionCommandOutput }>( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/command`, + body: { + id: input["id"], + command: input["command"], + arguments: input["arguments"], + agent: input["agent"], + model: input["model"], + files: input["files"], + agents: input["agents"], + delivery: input["delivery"], + resume: input["resume"], + }, + successStatus: 200, + declaredStatuses: [409, 404, 500, 400, 401], + empty: false, + }, + requestOptions, + ).then((value) => value.data), skill: (input: SessionSkillInput, requestOptions?: RequestOptions) => request( { @@ -542,6 +574,40 @@ export function make(options: ClientOptions) { }, requestOptions, ).then((value) => value.data), + listContextEntries: (input: SessionListContextEntriesInput, requestOptions?: RequestOptions) => + request<{ readonly data: SessionListContextEntriesOutput }>( + { + method: "GET", + path: `/api/session/${encodeURIComponent(input.sessionID)}/context-entry`, + successStatus: 200, + declaredStatuses: [404, 400, 401], + empty: false, + }, + requestOptions, + ).then((value) => value.data), + putContextEntry: (input: SessionPutContextEntryInput, requestOptions?: RequestOptions) => + request( + { + method: "PUT", + path: `/api/session/${encodeURIComponent(input.sessionID)}/context-entry/${encodeURIComponent(input.key)}`, + body: { value: input["value"] }, + successStatus: 204, + declaredStatuses: [404, 400, 401], + empty: true, + }, + requestOptions, + ), + removeContextEntry: (input: SessionRemoveContextEntryInput, requestOptions?: RequestOptions) => + request( + { + method: "DELETE", + path: `/api/session/${encodeURIComponent(input.sessionID)}/context-entry/${encodeURIComponent(input.key)}`, + successStatus: 204, + declaredStatuses: [404, 400, 401], + empty: true, + }, + requestOptions, + ), history: (input: SessionHistoryInput, requestOptions?: RequestOptions) => request( { @@ -627,6 +693,18 @@ export function make(options: ClientOptions) { }, requestOptions, ), + default: (input?: ModelDefaultInput, requestOptions?: RequestOptions) => + request( + { + method: "GET", + path: `/api/model/default`, + query: { location: input?.["location"] }, + successStatus: 200, + declaredStatuses: [503, 401, 400], + empty: false, + }, + requestOptions, + ), }, generate: { text: (input: GenerateTextInput, requestOptions?: RequestOptions) => diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index c37beb6290..723f2ebc03 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -50,6 +50,22 @@ export type ConflictError = { export const isConflictError = (value: unknown): value is ConflictError => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ConflictError" +export type CommandNotFoundError = { + readonly _tag: "CommandNotFoundError" + readonly command: string + readonly message: string +} +export const isCommandNotFoundError = (value: unknown): value is CommandNotFoundError => + typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "CommandNotFoundError" + +export type CommandEvaluationError = { + readonly _tag: "CommandEvaluationError" + readonly command: string + readonly message: string +} +export const isCommandEvaluationError = (value: unknown): value is CommandEvaluationError => + typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "CommandEvaluationError" + export type SkillNotFoundError = { readonly _tag: "SkillNotFoundError" readonly skill: string @@ -151,6 +167,7 @@ export type AgentListOutput = { readonly id: string readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } readonly request: { + readonly settings: { readonly [x: string]: JsonValue } readonly headers: { readonly [x: string]: string } readonly body: { readonly [x: string]: JsonValue } } @@ -563,6 +580,206 @@ export type SessionPromptOutput = { } }["data"] +export type SessionCommandInput = { + readonly sessionID: { readonly sessionID: string }["sessionID"] + readonly id?: { + readonly id?: string | null + readonly command: string + readonly arguments?: string | null + readonly agent?: string | null + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null + readonly files?: ReadonlyArray<{ + readonly uri: 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" | null + readonly resume?: boolean | null + }["id"] + readonly command: { + readonly id?: string | null + readonly command: string + readonly arguments?: string | null + readonly agent?: string | null + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null + readonly files?: ReadonlyArray<{ + readonly uri: 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" | null + readonly resume?: boolean | null + }["command"] + readonly arguments?: { + readonly id?: string | null + readonly command: string + readonly arguments?: string | null + readonly agent?: string | null + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null + readonly files?: ReadonlyArray<{ + readonly uri: 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" | null + readonly resume?: boolean | null + }["arguments"] + readonly agent?: { + readonly id?: string | null + readonly command: string + readonly arguments?: string | null + readonly agent?: string | null + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null + readonly files?: ReadonlyArray<{ + readonly uri: 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" | null + readonly resume?: boolean | null + }["agent"] + readonly model?: { + readonly id?: string | null + readonly command: string + readonly arguments?: string | null + readonly agent?: string | null + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null + readonly files?: ReadonlyArray<{ + readonly uri: 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" | null + readonly resume?: boolean | null + }["model"] + readonly files?: { + readonly id?: string | null + readonly command: string + readonly arguments?: string | null + readonly agent?: string | null + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null + readonly files?: ReadonlyArray<{ + readonly uri: 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" | null + readonly resume?: boolean | null + }["files"] + readonly agents?: { + readonly id?: string | null + readonly command: string + readonly arguments?: string | null + readonly agent?: string | null + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null + readonly files?: ReadonlyArray<{ + readonly uri: 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" | null + readonly resume?: boolean | null + }["agents"] + readonly delivery?: { + readonly id?: string | null + readonly command: string + readonly arguments?: string | null + readonly agent?: string | null + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null + readonly files?: ReadonlyArray<{ + readonly uri: 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" | null + readonly resume?: boolean | null + }["delivery"] + readonly resume?: { + readonly id?: string | null + readonly command: string + readonly arguments?: string | null + readonly agent?: string | null + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null + readonly files?: ReadonlyArray<{ + readonly uri: 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" | null + readonly resume?: boolean | null + }["resume"] +} + +export type SessionCommandOutput = { + readonly data: { + readonly admittedSeq: number + readonly id: string + readonly sessionID: 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 timeCreated: number + readonly promotedSeq?: number + } +}["data"] + export type SessionSkillInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] readonly id?: { @@ -809,6 +1026,27 @@ export type SessionContextOutput = { > }["data"] +export type SessionListContextEntriesInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } + +export type SessionListContextEntriesOutput = { + readonly data: ReadonlyArray<{ readonly key: string; readonly value: JsonValue }> +}["data"] + +export type SessionPutContextEntryInput = { + readonly sessionID: { readonly sessionID: string; readonly key: string }["sessionID"] + readonly key: { readonly sessionID: string; readonly key: string }["key"] + readonly value: { readonly value: JsonValue }["value"] +} + +export type SessionPutContextEntryOutput = void + +export type SessionRemoveContextEntryInput = { + readonly sessionID: { readonly sessionID: string; readonly key: string }["sessionID"] + readonly key: { readonly sessionID: string; readonly key: string }["key"] +} + +export type SessionRemoveContextEntryOutput = void + export type SessionHistoryInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] readonly limit?: { readonly limit?: number | undefined; readonly after?: number | undefined }["limit"] @@ -2197,12 +2435,14 @@ export type ModelListOutput = { readonly output: ReadonlyArray } readonly request: { + readonly settings: { readonly [x: string]: JsonValue } readonly headers: { readonly [x: string]: string } readonly body: { readonly [x: string]: JsonValue } readonly variant?: string } readonly variants: ReadonlyArray<{ readonly id: string + readonly settings: { readonly [x: string]: JsonValue } readonly headers: { readonly [x: string]: string } readonly body: { readonly [x: string]: JsonValue } }> @@ -2219,6 +2459,67 @@ export type ModelListOutput = { }> } +export type ModelDefaultInput = { + readonly location?: { + readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined + }["location"] +} + +export type ModelDefaultOutput = { + readonly location: { + readonly directory: string + readonly workspaceID?: string + readonly project: { readonly id: string; readonly directory: string } + } + readonly data: { + readonly id: string + readonly providerID: string + readonly family?: string + readonly name: string + readonly api: + | { + readonly id: string + readonly type: "aisdk" + readonly package: string + readonly url?: string + readonly settings?: { readonly [x: string]: JsonValue } + } + | { + readonly id: string + readonly type: "native" + readonly url?: string + readonly settings: { readonly [x: string]: JsonValue } + } + readonly capabilities: { + readonly tools: boolean + readonly input: ReadonlyArray + readonly output: ReadonlyArray + } + readonly request: { + readonly settings: { readonly [x: string]: JsonValue } + readonly headers: { readonly [x: string]: string } + readonly body: { readonly [x: string]: JsonValue } + readonly variant?: string + } + readonly variants: ReadonlyArray<{ + readonly id: string + readonly settings: { readonly [x: string]: JsonValue } + readonly headers: { readonly [x: string]: string } + readonly body: { readonly [x: string]: JsonValue } + }> + readonly time: { readonly released: number } + readonly cost: ReadonlyArray<{ + readonly tier?: { readonly type: "context"; readonly size: number } + readonly input: number + readonly output: number + readonly cache: { readonly read: number; readonly write: number } + }> + readonly status: "alpha" | "beta" | "deprecated" | "active" + readonly enabled: boolean + readonly limit: { readonly context: number; readonly input?: number; readonly output: number } + } | null +} + export type GenerateTextInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined @@ -2261,6 +2562,7 @@ export type ProviderListOutput = { } | { readonly type: "native"; readonly url?: string; readonly settings: { readonly [x: string]: JsonValue } } readonly request: { + readonly settings: { readonly [x: string]: JsonValue } readonly headers: { readonly [x: string]: string } readonly body: { readonly [x: string]: JsonValue } } @@ -2294,6 +2596,7 @@ export type ProviderGetOutput = { } | { readonly type: "native"; readonly url?: string; readonly settings: { readonly [x: string]: JsonValue } } readonly request: { + readonly settings: { readonly [x: string]: JsonValue } readonly headers: { readonly [x: string]: string } readonly body: { readonly [x: string]: JsonValue } } @@ -3534,6 +3837,19 @@ export type EventSubscribeOutput = readonly delivery: "steer" | "queue" } } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "session.next.execution.settled" + 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 outcome: "success" | "failure" | "interrupted" + readonly error?: { readonly type: "unknown"; readonly message: string } + } + } | { readonly id: string readonly metadata?: { readonly [x: string]: unknown } @@ -4020,6 +4336,14 @@ export type EventSubscribeOutput = readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly projectID: string } } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "command.updated" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: {} + } | { readonly id: string readonly metadata?: { readonly [x: string]: unknown } diff --git a/packages/core/schema.json b/packages/core/schema.json index d0eeeebd5c..e8a2502a34 100644 --- a/packages/core/schema.json +++ b/packages/core/schema.json @@ -1,8 +1,10 @@ { "version": "7", "dialect": "sqlite", - "id": "f14a9b18-8207-487e-a3d3-227e629ba9ad", - "prevIds": ["169a0f0f-d58f-479f-b024-fa1c7b9a09db"], + "id": "22e57fed-b9b8-4e94-a3b4-f94bece680a8", + "prevIds": [ + "f14a9b18-8207-487e-a3d3-227e629ba9ad" + ], "ddl": [ { "name": "workspace", @@ -60,6 +62,10 @@ "name": "session_context_epoch", "entityType": "tables" }, + { + "name": "session_context_entry", + "entityType": "tables" + }, { "name": "session_input", "entityType": "tables" @@ -920,6 +926,56 @@ "entityType": "columns", "table": "session_context_epoch" }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_context_entry" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "session_context_entry" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "value", + "entityType": "columns", + "table": "session_context_entry" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_context_entry" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_context_entry" + }, { "type": "text", "notNull": false, @@ -1481,9 +1537,13 @@ "table": "session_share" }, { - "columns": ["project_id"], + "columns": [ + "project_id" + ], "tableTo": "project", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1492,9 +1552,13 @@ "table": "workspace" }, { - "columns": ["active_account_id"], + "columns": [ + "active_account_id" + ], "tableTo": "account", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "SET NULL", "nameExplicit": false, @@ -1503,9 +1567,13 @@ "table": "account_state" }, { - "columns": ["aggregate_id"], + "columns": [ + "aggregate_id" + ], "tableTo": "event_sequence", - "columnsTo": ["aggregate_id"], + "columnsTo": [ + "aggregate_id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1514,9 +1582,13 @@ "table": "event" }, { - "columns": ["project_id"], + "columns": [ + "project_id" + ], "tableTo": "project", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1525,9 +1597,13 @@ "table": "permission" }, { - "columns": ["project_id"], + "columns": [ + "project_id" + ], "tableTo": "project", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1536,9 +1612,13 @@ "table": "project_directory" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "tableTo": "session", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1547,9 +1627,13 @@ "table": "message" }, { - "columns": ["message_id"], + "columns": [ + "message_id" + ], "tableTo": "message", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1558,9 +1642,13 @@ "table": "part" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "tableTo": "session", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1569,9 +1657,28 @@ "table": "session_context_epoch" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "tableTo": "session", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_context_entry_session_id_session_id_fk", + "entityType": "fks", + "table": "session_context_entry" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1580,9 +1687,13 @@ "table": "session_input" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "tableTo": "session", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1591,9 +1702,13 @@ "table": "session_message" }, { - "columns": ["project_id"], + "columns": [ + "project_id" + ], "tableTo": "project", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1602,9 +1717,13 @@ "table": "session" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "tableTo": "session", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1613,9 +1732,13 @@ "table": "todo" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "tableTo": "session", - "columnsTo": ["id"], + "columnsTo": [ + "id" + ], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1624,133 +1747,184 @@ "table": "session_share" }, { - "columns": ["email", "url"], + "columns": [ + "email", + "url" + ], "nameExplicit": false, "name": "control_account_pk", "entityType": "pks", "table": "control_account" }, { - "columns": ["project_id", "directory"], + "columns": [ + "project_id", + "directory" + ], "nameExplicit": false, "name": "project_directory_pk", "entityType": "pks", "table": "project_directory" }, { - "columns": ["session_id", "position"], + "columns": [ + "session_id", + "key" + ], + "nameExplicit": false, + "name": "session_context_entry_pk", + "entityType": "pks", + "table": "session_context_entry" + }, + { + "columns": [ + "session_id", + "position" + ], "nameExplicit": false, "name": "todo_pk", "entityType": "pks", "table": "todo" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "workspace_pk", "table": "workspace", "entityType": "pks" }, { - "columns": ["name"], + "columns": [ + "name" + ], "nameExplicit": false, "name": "data_migration_pk", "table": "data_migration", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "account_state_pk", "table": "account_state", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "account_pk", "table": "account", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "credential_pk", "table": "credential", "entityType": "pks" }, { - "columns": ["aggregate_id"], + "columns": [ + "aggregate_id" + ], "nameExplicit": false, "name": "event_sequence_pk", "table": "event_sequence", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "event_pk", "table": "event", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "permission_pk", "table": "permission", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "project_pk", "table": "project", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "message_pk", "table": "message", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "part_pk", "table": "part", "entityType": "pks" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "nameExplicit": false, "name": "session_context_epoch_pk", "table": "session_context_epoch", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "session_input_pk", "table": "session_input", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "session_message_pk", "table": "session_message", "entityType": "pks" }, { - "columns": ["id"], + "columns": [ + "id" + ], "nameExplicit": false, "name": "session_pk", "table": "session", "entityType": "pks" }, { - "columns": ["session_id"], + "columns": [ + "session_id" + ], "nameExplicit": false, "name": "session_share_pk", "table": "session_share", @@ -2068,4 +2242,4 @@ } ], "renames": [] -} +} \ No newline at end of file diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index ec3eed83d7..78688ce12c 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -85,6 +85,7 @@ const layer = Layer.effect( ? { ...model.api, settings: { ...provider.api.settings, ...model.api.settings } } : model.api const request = { + settings: { ...provider.request.settings, ...model.request.settings }, headers: { ...provider.request.headers, ...model.request.headers }, body: { ...provider.request.body, ...model.request.body }, variant: model.request.variant, diff --git a/packages/core/src/command.ts b/packages/core/src/command.ts index 0f6d83d92e..44f3138ba7 100644 --- a/packages/core/src/command.ts +++ b/packages/core/src/command.ts @@ -1,17 +1,39 @@ export * as CommandV2 from "./command" import { makeLocationNode } from "./effect/app-node" -import { Context, Effect, Layer, Types } from "effect" +import { Context, Effect, Layer, Schema, Types } from "effect" import { Command } from "@opencode-ai/schema/command" import { State } from "./state" +import { MCP } from "./mcp/index" +import { EventV2 } from "./event" +import { AppProcess } from "./process" +import { ChildProcess } from "effect/unstable/process" +import { Config } from "./config" +import { Location } from "./location" +import { ShellSelect } from "./shell/select" export const Info = Command.Info export type Info = Command.Info +export const Event = Command.Event + +export type Evaluation = { + readonly text: string +} export type Data = { commands: Map> } +export class NotFoundError extends Schema.TaggedErrorClass()("Command.NotFoundError", { + command: Schema.String, + message: Schema.String, +}) {} + +export class EvaluationError extends Schema.TaggedErrorClass()("Command.EvaluationError", { + command: Schema.String, + message: Schema.String, +}) {} + export type Draft = { list: () => readonly Info[] get: (name: string) => Info | undefined @@ -22,13 +44,22 @@ export type Draft = { export interface Interface extends State.Transformable { readonly get: (name: string) => Effect.Effect readonly list: () => Effect.Effect + readonly evaluate: (input: { + readonly name: string + readonly arguments?: string + }) => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/Command") {} const layer = Layer.effect( Service, - Effect.sync(() => { + Effect.gen(function* () { + const mcp = yield* MCP.Service + const events = yield* EventV2.Service + const processes = yield* AppProcess.Service + const config = yield* Config.Service + const location = yield* Location.Service const state = State.create({ initial: () => ({ commands: new Map() }), draft: (draft) => ({ @@ -44,19 +75,172 @@ const layer = Layer.effect( draft.commands.delete(name) }, }), + finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid), + }) + const staticCommand = (name: string) => state.get().commands.get(name) as Info | undefined + const mcpCommands = Effect.fnUntraced(function* () { + return (yield* mcp.prompts()).map((prompt) => + Info.make({ + name: mcpCommandName(prompt.server, prompt.name), + template: "", + description: prompt.description, + }), + ) }) return Service.of({ reload: state.reload, transform: state.transform, get: Effect.fn("CommandV2.get")(function* (name) { - return state.get().commands.get(name) + const command = staticCommand(name) + if (command) return command + return (yield* mcpCommands()).find((command) => command.name === name) }), list: Effect.fn("CommandV2.list")(function* () { - return Array.from(state.get().commands.values()) + const commands = Array.from(state.get().commands.values()) as Info[] + const names = new Set(commands.map((command) => command.name)) + return [ + ...commands, + ...(yield* mcpCommands()).filter((command) => !names.has(command.name)), + ] + }), + evaluate: Effect.fn("CommandV2.evaluate")(function* (input) { + const command = staticCommand(input.name) + if (command) return yield* evaluateTemplate(input.name, command.template, input.arguments ?? "", { + config, + location, + processes, + }) + + const prompt = (yield* mcp.prompts()).find((prompt) => mcpCommandName(prompt.server, prompt.name) === input.name) + if (!prompt) return yield* new NotFoundError({ command: input.name, message: `Command not found: ${input.name}` }) + const result = yield* mcp + .prompt({ + server: prompt.server, + name: prompt.name, + args: Object.fromEntries( + (prompt.arguments ?? []).map((argument, index) => [ + argument.name, + parseArguments(input.arguments ?? "")[index] ?? "", + ]), + ), + }) + .pipe( + Effect.catchTag( + "MCP.NotFoundError", + () => + Effect.fail( + new EvaluationError({ + command: input.name, + message: `MCP server could not be found while evaluating prompt: ${prompt.server}`, + }), + ), + ), + ) + if (!result) + return yield* new EvaluationError({ + command: input.name, + message: `MCP prompt could not be evaluated: ${prompt.server}:${prompt.name}`, + }) + return { text: result.messages.map((message) => promptMessageText(message.content)).join("\n").trim() } }), }) }), ) -export const node = makeLocationNode({ service: Service, layer, deps: [] }) +function evaluateTemplate( + command: string, + template: string, + input: string, + services: { + readonly config: Config.Interface + readonly location: Location.Info + readonly processes: AppProcess.Interface + }, +) { + return Effect.gen(function* () { + const expanded = evaluateArguments(template, input) + return { text: yield* evaluateShell(command, expanded, services) } + }) +} + +function evaluateArguments(template: string, input: string) { + const args = parseArguments(input) + const placeholders = template.match(placeholderRegex) ?? [] + const last = Math.max(0, ...placeholders.map((item) => Number(item.slice(1)))) + const expanded = template.replaceAll(placeholderRegex, (_, index) => { + const position = Number(index) + const argIndex = position - 1 + if (argIndex >= args.length) return "" + if (position === last) return args.slice(argIndex).join(" ") + return args[argIndex] + }) + const withArguments = expanded.replaceAll("$ARGUMENTS", input) + if (placeholders.length === 0 && !template.includes("$ARGUMENTS") && input.trim()) return `${withArguments}\n\n${input}`.trim() + return withArguments.trim() +} + +const evaluateShell = Effect.fnUntraced(function* ( + command: string, + text: string, + services: { + readonly config: Config.Interface + readonly location: Location.Info + readonly processes: AppProcess.Interface + }, +) { + const matches = Array.from(text.matchAll(shellRegex)) + if (matches.length === 0) return text + const shell = ShellSelect.preferred(Config.latest(yield* services.config.entries(), "shell")) + const outputs = yield* Effect.forEach( + matches, + (match) => { + const source = match[1] ?? "" + return services.processes + .run(ChildProcess.make(shell, ShellSelect.args(shell, source), { cwd: services.location.directory, stdin: "ignore" }), { + combineOutput: true, + }) + .pipe( + Effect.map((result) => (result.output ?? Buffer.concat([result.stdout, result.stderr])).toString("utf8")), + Effect.mapError( + (error) => + new EvaluationError({ command, message: `Shell interpolation failed for ${JSON.stringify(source)}: ${error.message}` }), + ), + ) + }, + { concurrency: 2 }, + ) + const iterator = outputs[Symbol.iterator]() + return text.replace(shellRegex, () => iterator.next().value ?? "") +}) + +function parseArguments(input: string) { + return (input.match(argsRegex) ?? []).map((arg) => arg.replace(quoteTrimRegex, "")) +} + +function promptMessageText(content: unknown) { + if (typeof content === "string") return content + if (!content || typeof content !== "object") return "" + if (!("type" in content) || content.type !== "text") return "" + if (!("text" in content) || typeof content.text !== "string") return "" + return content.text +} + +function mcpCommandName(server: string, prompt: string) { + return `${sanitize(server)}:${sanitize(prompt)}` +} + +function sanitize(value: string) { + return value.replace(/[^a-zA-Z0-9_-]/g, "_") +} + +const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi +const placeholderRegex = /\$(\d+)/g +const quoteTrimRegex = /^["']|["']$/g +const shellRegex = /!`([^`]+)`/g + +export const node = makeLocationNode({ + service: Service, + layer, + deps: [MCP.node, EventV2.node, AppProcess.node, Config.node, Location.node], +}) diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts index 6f6e0528da..d8ec3a58ae 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -4,7 +4,6 @@ import { define } from "../../plugin/internal" import { Effect } from "effect" import { Config } from "../../config" import { ModelV2 } from "../../model" -import { ProviderV2 } from "../../provider" export const Plugin = define({ id: "config-provider", @@ -54,6 +53,7 @@ export const Plugin = define({ if (item.name !== undefined) provider.name = item.name if (item.api !== undefined) provider.api = { ...item.api } if (item.request !== undefined) { + Object.assign(provider.request.settings, item.request.settings) Object.assign(provider.request.headers, item.request.headers) Object.assign(provider.request.body, item.request.body) } @@ -71,6 +71,7 @@ export const Plugin = define({ } } if (config.request !== undefined) { + Object.assign(model.request.settings, config.request.settings) Object.assign(model.request.headers, config.request.headers) Object.assign(model.request.body, config.request.body) if (config.request.variant !== undefined) model.request.variant = config.request.variant @@ -81,11 +82,13 @@ export const Plugin = define({ if (!existing) { existing = { id: variant.id, + settings: {}, headers: {}, body: {}, } model.variants.push(existing) } + Object.assign(existing.settings, variant.settings) Object.assign(existing.headers, variant.headers) Object.assign(existing.body, variant.body) } diff --git a/packages/core/src/config/provider.ts b/packages/core/src/config/provider.ts index 1b54757078..c78d7979ae 100644 --- a/packages/core/src/config/provider.ts +++ b/packages/core/src/config/provider.ts @@ -5,6 +5,7 @@ import { ProviderV2 } from "../provider" import { ModelV2 } from "../model" export class Request extends Schema.Class("ConfigV2.Provider.Request")({ + settings: ProviderV2.Settings.pipe(Schema.optional), headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), body: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), }) {} diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index e6ea4eaa14..55c1b212cd 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -40,5 +40,6 @@ export const migrations = ( import("./migration/20260622142730_simplify_session_context_epoch"), import("./migration/20260622170816_reset_v2_session_state"), import("./migration/20260622202450_simplify_session_input"), + import("./migration/20260702134641_add_session_context_entry"), ]) ).map((module) => module.default) satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20260702134641_add_session_context_entry.ts b/packages/core/src/database/migration/20260702134641_add_session_context_entry.ts new file mode 100644 index 0000000000..13b37d7fef --- /dev/null +++ b/packages/core/src/database/migration/20260702134641_add_session_context_entry.ts @@ -0,0 +1,21 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260702134641_add_session_context_entry", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(` + CREATE TABLE \`session_context_entry\` ( + \`session_id\` text NOT NULL, + \`key\` text NOT NULL, + \`value\` text NOT NULL, + \`time_created\` integer NOT NULL, + \`time_updated\` integer NOT NULL, + CONSTRAINT \`session_context_entry_pk\` PRIMARY KEY(\`session_id\`, \`key\`), + CONSTRAINT \`fk_session_context_entry_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE + ); + `) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/database/schema.gen.ts b/packages/core/src/database/schema.gen.ts index ed60fde6c5..d8f0f1c665 100644 --- a/packages/core/src/database/schema.gen.ts +++ b/packages/core/src/database/schema.gen.ts @@ -154,6 +154,17 @@ export default { CONSTRAINT \`fk_session_context_epoch_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE ); `) + yield* tx.run(` + CREATE TABLE \`session_context_entry\` ( + \`session_id\` text NOT NULL, + \`key\` text NOT NULL, + \`value\` text NOT NULL, + \`time_created\` integer NOT NULL, + \`time_updated\` integer NOT NULL, + CONSTRAINT \`session_context_entry_pk\` PRIMARY KEY(\`session_id\`, \`key\`), + CONSTRAINT \`fk_session_context_entry_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE + ); + `) yield* tx.run(` CREATE TABLE \`session_input\` ( \`id\` text PRIMARY KEY, diff --git a/packages/core/src/instruction-context.ts b/packages/core/src/instruction-context.ts index b0cc330678..94d9e787ce 100644 --- a/packages/core/src/instruction-context.ts +++ b/packages/core/src/instruction-context.ts @@ -1,6 +1,6 @@ export * as InstructionContext from "./instruction-context" -import { Array, Effect, Layer, Schema } from "effect" +import { Array, Context, Effect, Layer, Schema } from "effect" import { isAbsolute, join, relative, sep } from "path" import { FSUtil } from "./fs-util" import { Flag } from "./flag/flag" @@ -8,7 +8,6 @@ import { Global } from "./global" import { Location } from "./location" import { AbsolutePath } from "./schema" import { SystemContext } from "./system-context/index" -import { SystemContextRegistry } from "./system-context/registry" import { makeLocationNode } from "./effect/app-node" class File extends Schema.Class("InstructionContext.File")({ @@ -19,12 +18,18 @@ class File extends Schema.Class("InstructionContext.File")({ const Files = Schema.Array(File) const key = SystemContext.Key.make("core/instructions") -const layer = Layer.effectDiscard( +export interface Interface { + readonly load: () => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/InstructionContext") {} + +const layer = Layer.effect( + Service, Effect.gen(function* () { const fs = yield* FSUtil.Service const global = yield* Global.Service const location = yield* Location.Service - const registry = yield* SystemContextRegistry.Service const source = (value: ReadonlyArray | SystemContext.Unavailable) => SystemContext.make({ @@ -71,28 +76,24 @@ const layer = Layer.effectDiscard( return files.filter((file): file is File => file !== undefined) }) - yield* registry.register({ - key, - load: observe().pipe( - Effect.map((files) => - files === SystemContext.unavailable - ? source(files) - : files.length === 0 - ? SystemContext.empty - : source(files), + return Service.of({ + load: () => + observe().pipe( + Effect.map((files) => + files === SystemContext.unavailable + ? source(files) + : files.length === 0 + ? SystemContext.empty + : source(files), + ), + Effect.catch(() => Effect.succeed(source(SystemContext.unavailable))), + Effect.catchDefect(() => Effect.succeed(source(SystemContext.unavailable))), ), - Effect.catch(() => Effect.succeed(source(SystemContext.unavailable))), - Effect.catchDefect(() => Effect.succeed(source(SystemContext.unavailable))), - ), }) }), ) -export const node = makeLocationNode({ - name: "instruction-context", - layer, - deps: [FSUtil.node, Global.node, Location.node, SystemContextRegistry.node], -}) +export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node, Global.node, Location.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/location-services.ts b/packages/core/src/location-services.ts index 0d913c10fb..4a3e29bd86 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -36,8 +36,9 @@ import { SessionTodo } from "./session/todo" import { SkillV2 } from "./skill" import { SkillGuidance } from "./skill/guidance" import { Snapshot } from "./snapshot" +import { InstructionContext } from "./instruction-context" import { SystemContextBuiltIns } from "./system-context/builtins" -import { SystemContextRegistry } from "./system-context/registry" +import { SessionContextEntry } from "./session/context-entry" import { SessionInstructions } from "./session/instructions" import { BuiltInTools } from "./tool/builtins" import { McpTool } from "./tool/mcp" @@ -68,8 +69,8 @@ export const locationServices = LayerNode.group([ Pty.node, Shell.node, SkillV2.node, - SystemContextRegistry.node, SystemContextBuiltIns.node, + InstructionContext.node, LocationMutation.node, FileMutation.node, MCP.node, @@ -81,6 +82,7 @@ export const locationServices = LayerNode.group([ SkillGuidance.node, ReferenceGuidance.node, SessionTodo.node, + SessionContextEntry.node, QuestionV2.node, Generate.node, ReadToolFileSystem.node, diff --git a/packages/core/src/mcp/client.ts b/packages/core/src/mcp/client.ts index 4a9b17f6b1..b1ef6f390b 100644 --- a/packages/core/src/mcp/client.ts +++ b/packages/core/src/mcp/client.ts @@ -9,8 +9,12 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/ import { UnauthorizedError, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js" import { CallToolResultSchema, + GetPromptResultSchema, + ListPromptsResultSchema, ListRootsRequestSchema, ListToolsResultSchema, + PromptListChangedNotificationSchema, + PromptSchema, type LoggingMessageNotification, LoggingMessageNotificationSchema, ToolListChangedNotificationSchema, @@ -30,6 +34,9 @@ type Transport = StdioClientTransport | StreamableHTTPClientTransport const TolerantListToolsResult = ListToolsResultSchema.extend({ tools: ToolSchema.omit({ outputSchema: true }).array(), }) +const TolerantListPromptsResult = ListPromptsResultSchema.extend({ + prompts: PromptSchema.array(), +}) export class NeedsAuthError extends Schema.TaggedErrorClass()("MCP.NeedsAuthError", { server: Schema.String, @@ -46,6 +53,25 @@ export interface ToolDefinition { readonly inputSchema: unknown } +export interface PromptDefinition { + readonly name: string + readonly description: string | undefined + readonly arguments: ReadonlyArray<{ + readonly name: string + readonly description: string | undefined + readonly required: boolean | undefined + }> | undefined +} + +export interface PromptMessage { + readonly role: string + readonly content: unknown +} + +export interface PromptResult { + readonly messages: ReadonlyArray +} + export type CallToolContent = | { readonly type: "text"; readonly text: string } | { readonly type: "media"; readonly data: string; readonly mimeType: string } @@ -68,6 +94,13 @@ export interface Connection { readonly instructions: string | undefined /** Lists the server's tools; returns [] when the server doesn't advertise tool support, fails on a transport error. */ readonly tools: () => Effect.Effect + /** Lists the server's prompts; returns [] when the server doesn't advertise prompt support, fails on a transport error. */ + readonly prompts: () => Effect.Effect + /** Invokes a prompt on the server. Interruption aborts the in-flight request. */ + readonly prompt: (input: { + readonly name: string + readonly args?: Record + }) => Effect.Effect /** Invokes a tool on the server. Interruption aborts the in-flight request. */ readonly callTool: (input: { readonly name: string @@ -78,6 +111,8 @@ export interface Connection { readonly onLog: (callback: (message: LogMessage) => void) => void /** Registers a callback fired when the server announces its tool list changed; no-op if unsupported. */ readonly onToolsChanged: (callback: () => void) => void + /** Registers a callback fired when the server announces its prompt list changed; no-op if unsupported. */ + readonly onPromptsChanged: (callback: () => void) => void } /** Connects an MCP server; closing the calling scope tears down the transport and any spawned process. */ @@ -166,6 +201,48 @@ export const connect = Effect.fnUntraced(function* ( inputSchema: tool.inputSchema, })) }), + prompts: () => + Effect.gen(function* () { + if (!client.getServerCapabilities()?.prompts) return [] + const prompts = yield* Effect.tryPromise({ + try: () => + paginate( + async (cursor) => { + const params = cursor === undefined ? undefined : { cursor } + return client.request({ method: "prompts/list", params }, TolerantListPromptsResult, { + timeout: requestTimeout, + }) + }, + (result) => result.prompts, + ), + catch: (error) => (error instanceof Error ? error : new Error(String(error))), + }).pipe( + Effect.tapError((error) => Effect.logWarning("failed to list MCP prompts", { server, error: error.message })), + ) + return prompts.map((prompt) => ({ + name: prompt.name, + description: prompt.description, + arguments: prompt.arguments?.map((argument) => ({ + name: argument.name, + description: argument.description, + required: argument.required, + })), + })) + }), + prompt: (input) => + Effect.tryPromise({ + try: (signal) => + client.request( + { method: "prompts/get", params: { name: input.name, arguments: input.args ?? {} } }, + GetPromptResultSchema, + { signal, timeout: requestTimeout, resetTimeoutOnProgress: true, onprogress: () => {} }, + ), + catch: (error) => (error instanceof Error ? error : new Error(String(error))), + }).pipe( + Effect.map((result) => ({ + messages: result.messages.map((message) => ({ role: message.role, content: message.content })), + })), + ), callTool: (input) => Effect.tryPromise({ try: (signal) => @@ -207,6 +284,10 @@ export const connect = Effect.fnUntraced(function* ( if (!client.getServerCapabilities()?.tools?.listChanged) return client.setNotificationHandler(ToolListChangedNotificationSchema, async () => callback()) }, + onPromptsChanged: (callback) => { + if (!client.getServerCapabilities()?.prompts?.listChanged) return + client.setNotificationHandler(PromptListChangedNotificationSchema, async () => callback()) + }, } satisfies Connection } diff --git a/packages/core/src/mcp/guidance.ts b/packages/core/src/mcp/guidance.ts index 8334e7b063..eed26170b0 100644 --- a/packages/core/src/mcp/guidance.ts +++ b/packages/core/src/mcp/guidance.ts @@ -14,16 +14,40 @@ const Summary = Schema.Struct({ }) type Summary = typeof Summary.Type +const entries = (servers: ReadonlyArray) => + servers.flatMap((server) => [ + ` `, + ...server.instructions.split("\n").map((line) => ` ${line}`), + " ", + ]) + const render = (servers: ReadonlyArray) => - [ - "", - ...servers.flatMap((server) => [ - ` `, - ...server.instructions.split("\n").map((line) => ` ${line}`), - " ", - ]), - "", + ["", ...entries(servers), ""].join("\n") + +const update = (previous: ReadonlyArray, current: ReadonlyArray) => { + const diff = SystemContext.diffByKey( + previous, + current, + (server) => server.server, + (before, after) => before.instructions !== after.instructions, + ) + // Additions and removals render as small deltas; anything else restates the full list. + if (diff.changed.length > 0 || (diff.added.length === 0 && diff.removed.length === 0)) + return [ + "The available MCP server instructions have changed. This list supersedes the previous one.", + render(current), + ].join("\n") + return [ + ...(diff.added.length === 0 + ? [] + : ["New MCP server instructions are available in addition to those previously listed:", ...entries(diff.added)]), + ...(diff.removed.length === 0 + ? [] + : [ + `Instructions for the following MCP servers are no longer available: ${diff.removed.map((server) => server.server).join(", ")}.`, + ]), ].join("\n") +} export interface Interface { readonly load: (agent: AgentV2.Selection) => Effect.Effect @@ -50,7 +74,8 @@ export const layer = Layer.effect( return ( owned.length === 0 || owned.some( - (tool) => PermissionV2.evaluate(McpTool.name(tool.server, tool.name), "*", agent.permissions).effect !== "deny", + (tool) => + PermissionV2.evaluate(McpTool.name(tool.server, tool.name), "*", agent.permissions).effect !== "deny", ) ) }) @@ -61,11 +86,7 @@ export const layer = Layer.effect( codec: Schema.toCodecJson(Schema.Array(Summary)), load: Effect.succeed(visible), baseline: render, - update: (_previous, current) => - [ - "The available MCP server instructions have changed. This list supersedes the previous one.", - render(current), - ].join("\n"), + update, removed: () => "MCP server instructions are no longer available.", }) }), diff --git a/packages/core/src/mcp/index.ts b/packages/core/src/mcp/index.ts index 4e41795ec3..6992942674 100644 --- a/packages/core/src/mcp/index.ts +++ b/packages/core/src/mcp/index.ts @@ -2,6 +2,7 @@ export * as MCP from "./index" import { Mcp } from "@opencode-ai/schema/mcp" import { McpEvent } from "@opencode-ai/schema/mcp-event" +import { Command } from "@opencode-ai/schema/command" import { createHash } from "node:crypto" import { Cause, Context, Deferred, Effect, Exit, FiberSet, Layer, Schema, Scope, Stream } from "effect" import { makeLocationNode } from "../effect/app-node" @@ -139,6 +140,7 @@ type ServerEntry = { scope?: Scope.Closeable client?: MCPClient.Connection tools?: ReadonlyArray + prompts?: ReadonlyArray // Set when a remote server is registered as an OAuth integration; the credential lives in the global store. integrationID?: Integration.ID } @@ -309,6 +311,21 @@ export const layer = Layer.effect( const toTool = (server: ServerName, def: MCPClient.ToolDefinition) => new Tool({ server, name: def.name, description: def.description, inputSchema: def.inputSchema }) + const toPrompt = (server: ServerName, def: MCPClient.PromptDefinition) => + new Prompt({ + server, + name: def.name, + description: def.description, + arguments: def.arguments?.map( + (argument) => + new PromptArgument({ + name: argument.name, + description: argument.description, + required: argument.required, + }), + ), + }) + const refreshTools = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) => connection.tools().pipe( Effect.map((defs) => { @@ -316,6 +333,17 @@ export const layer = Layer.effect( }), ) + const refreshPrompts = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) => + connection.prompts().pipe( + Effect.map((defs) => { + entry.prompts = defs.map((def) => toPrompt(name, def)) + }), + Effect.andThen(events.publish(Command.Event.Updated, {})), + Effect.catch(() => + Effect.sync(() => (entry.prompts = [])).pipe(Effect.andThen(events.publish(Command.Event.Updated, {}))), + ), + ) + const watch = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) => { connection.onClose(() => { // A reconnect closes the previous scope, but the SDK may fire this onclose after the new @@ -323,8 +351,10 @@ export const layer = Layer.effect( if (entry.client !== connection) return entry.client = undefined entry.tools = undefined + entry.prompts = undefined entry.status = { status: "failed", error: "Connection closed" } fork(events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)) + fork(events.publish(Command.Event.Updated, {}).pipe(Effect.ignore)) fork(events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)) }) connection.onLog((message) => fork(serverLog(name, message).pipe(Effect.ignore))) @@ -336,6 +366,9 @@ export const layer = Layer.effect( ), ) }) + connection.onPromptsChanged(() => { + fork(refreshPrompts(name, entry, connection).pipe(Effect.ignore)) + }) } const serverLog = (server: ServerName, message: MCPClient.LogMessage) => { @@ -364,13 +397,14 @@ export const layer = Layer.effect( // List tools as part of connect so a failure here marks the server failed rather than // leaving it connected with a silently empty tool list and no path to recover. const result = yield* MCPClient.connect(name, entry.config, location.directory, authProvider).pipe( - Effect.flatMap((connection) => connection.tools().pipe(Effect.map((defs) => ({ connection, defs })))), + Effect.flatMap((connection) => connection.tools().pipe(Effect.map((tools) => ({ connection, tools })))), Scope.provide(scope), Effect.exit, ) if (Exit.isSuccess(result)) { entry.client = result.value.connection - entry.tools = result.value.defs.map((def) => toTool(name, def)) + entry.tools = result.value.tools.map((def) => toTool(name, def)) + entry.prompts = [] entry.status = { status: "connected" } watch(name, entry, result.value.connection) yield* Effect.logInfo("mcp connected", { server: name, tools: entry.tools.length }) @@ -379,6 +413,7 @@ export const layer = Layer.effect( // stay invisible to the model. yield* events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore) yield* events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore) + fork(refreshPrompts(name, entry, result.value.connection).pipe(Effect.ignore)) return } yield* Scope.close(scope, Exit.void) @@ -416,6 +451,8 @@ export const layer = Layer.effect( entry.scope = undefined entry.client = undefined entry.tools = undefined + entry.prompts = undefined + yield* events.publish(Command.Event.Updated, {}).pipe(Effect.ignore) } yield* startServer(name, entry) }) @@ -489,12 +526,25 @@ export const layer = Layer.effect( .toSorted((a, b) => a.server.localeCompare(b.server)) }), prompts: Effect.fn("MCP.prompts")(function* () { - yield* whenAllReady - return [] + return Array.from(runtime.values()) + .flatMap((entry) => entry.prompts ?? []) + .toSorted((a, b) => a.server.localeCompare(b.server) || a.name.localeCompare(b.name)) }), prompt: Effect.fn("MCP.prompt")(function* (input) { - yield* gate(input.server) - return undefined + const target = yield* requireServer(input.server) + yield* Deferred.await(target.entry.startup) + if (!target.entry.client) return undefined + const result = yield* target.entry.client + .prompt({ name: input.name, args: input.args }) + .pipe(Effect.catch(() => Effect.succeed(undefined))) + if (!result) return undefined + return new PromptResult({ + server: target.name, + name: input.name, + messages: result.messages.map( + (message) => new PromptMessage({ role: message.role, content: message.content }), + ), + }) }), resourceCatalog: Effect.fn("MCP.resourceCatalog")(function* () { yield* whenAllReady diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index 52fff98733..41abbcb2d2 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -26,8 +26,13 @@ export type Api = Model.Api export const Info = Model.Info export type Info = Model.Info -export type MutableInfo = Omit, "api"> & { +export type MutableRequest = ProviderV2.MutableRequest & { variant?: string } +export type MutableVariant = ProviderV2.MutableRequest & { id: VariantID } + +export type MutableInfo = Omit, "api" | "request" | "variants"> & { api: ProviderV2.MutableApi + request: MutableRequest + variants: MutableVariant[] } export function parse(input: string): { providerID: ProviderV2.ID; modelID: ID } { diff --git a/packages/core/src/plugin/command.ts b/packages/core/src/plugin/command.ts index cbafd68b50..1989641238 100644 --- a/packages/core/src/plugin/command.ts +++ b/packages/core/src/plugin/command.ts @@ -18,7 +18,6 @@ export const Plugin = define({ draft.update("review", (command) => { command.template = PROMPT_REVIEW.replace("${path}", location.project.directory) command.description = "review changes [commit|branch|pr], defaults to uncommitted" - command.subtask = true }) }) }), diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index 1ab46b794d..00ff7c82af 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -302,6 +302,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int }), get: (input) => runtime.session.get(input.sessionID), prompt: runtime.session.prompt, + command: runtime.session.command, interrupt: (input) => runtime.session.interrupt(input.sessionID), }, } satisfies Interface diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index 25a8d4a9ca..f522e85a66 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -3,7 +3,7 @@ export * as PluginInternal from "./internal" import { makeLocationNode } from "../effect/app-node" import { httpClient } from "../effect/app-node-platform" import type { PluginContext } from "@opencode-ai/plugin/v2/effect" -import { Effect, Layer, Scope } from "effect" +import { Context, Effect, Layer, Scope } from "effect" import { AgentV2 } from "../agent" import { Catalog } from "../catalog" import { CommandV2 } from "../command" @@ -27,6 +27,7 @@ import { PluginV2 } from "../plugin" import { PluginRuntime } from "../plugin/runtime" import { PermissionV2 } from "../permission" import { Reference } from "../reference" +import { Ripgrep } from "../ripgrep" import { Shell } from "../shell" import { SkillV2 } from "../skill" import { State } from "../state" @@ -40,6 +41,7 @@ import { ProviderPlugins } from "./provider" import { SdkPlugins } from "./sdk" import { SkillPlugin } from "./skill" import { VariantPlugin } from "./variant" +import { GlobTool } from "../tool/glob" import { ShellTool } from "../tool/shell" import { SubagentTool } from "../tool/subagent" @@ -61,6 +63,7 @@ export type Requirements = | PermissionV2.Service | PluginRuntime.Service | Reference.Service + | Ripgrep.Service | Shell.Service | SkillV2.Service | Tools.Service @@ -76,59 +79,35 @@ export function define(plugin: Plugin) { const layer = Layer.effectDiscard( Effect.gen(function* () { - const catalog = yield* Catalog.Service - const commands = yield* CommandV2.Service const plugin = yield* PluginV2.Service const sdkPlugins = yield* SdkPlugins.Service - const integration = yield* Integration.Service - const agents = yield* AgentV2.Service - const config = yield* Config.Service - const location = yield* Location.Service - const modelsDev = yield* ModelsDev.Service - const npm = yield* Npm.Service - const events = yield* EventV2.Service - const fs = yield* FSUtil.Service - const filesystem = yield* FileSystem.Service - const global = yield* Global.Service - const http = yield* HttpClient.HttpClient - const mutation = yield* LocationMutation.Service - const permission = yield* PermissionV2.Service - const skill = yield* SkillV2.Service - const reference = yield* Reference.Service - const shell = yield* Shell.Service - const tools = yield* Tools.Service - const runtime = yield* PluginRuntime.Service - const add = (input: Plugin) => { - const loaded = { - id: input.id, - effect: (context: PluginContext) => - input - .effect(context) - .pipe( - Effect.provideService(Catalog.Service, catalog), - Effect.provideService(CommandV2.Service, commands), - Effect.provideService(Integration.Service, integration), - Effect.provideService(AgentV2.Service, agents), - Effect.provideService(Config.Service, config), - Effect.provideService(Location.Service, location), - Effect.provideService(ModelsDev.Service, modelsDev), - Effect.provideService(Npm.Service, npm), - Effect.provideService(EventV2.Service, events), - Effect.provideService(FSUtil.Service, fs), - Effect.provideService(FileSystem.Service, filesystem), - Effect.provideService(Global.Service, global), - Effect.provideService(HttpClient.HttpClient, http), - Effect.provideService(LocationMutation.Service, mutation), - Effect.provideService(PermissionV2.Service, permission), - Effect.provideService(SkillV2.Service, skill), - Effect.provideService(Reference.Service, reference), - Effect.provideService(Shell.Service, shell), - Effect.provideService(Tools.Service, tools), - Effect.provideService(PluginRuntime.Service, runtime), - ), - } - return plugin.add(PluginV2.ID.make(loaded.id), loaded.effect) - } + const services = Context.mergeAll( + Context.make(Catalog.Service, yield* Catalog.Service), + Context.make(CommandV2.Service, yield* CommandV2.Service), + Context.make(Integration.Service, yield* Integration.Service), + Context.make(AgentV2.Service, yield* AgentV2.Service), + Context.make(Config.Service, yield* Config.Service), + Context.make(Location.Service, yield* Location.Service), + Context.make(ModelsDev.Service, yield* ModelsDev.Service), + Context.make(Npm.Service, yield* Npm.Service), + Context.make(EventV2.Service, yield* EventV2.Service), + Context.make(FSUtil.Service, yield* FSUtil.Service), + Context.make(FileSystem.Service, yield* FileSystem.Service), + Context.make(Global.Service, yield* Global.Service), + Context.make(HttpClient.HttpClient, yield* HttpClient.HttpClient), + Context.make(LocationMutation.Service, yield* LocationMutation.Service), + Context.make(PermissionV2.Service, yield* PermissionV2.Service), + Context.make(SkillV2.Service, yield* SkillV2.Service), + Context.make(Reference.Service, yield* Reference.Service), + Context.make(Ripgrep.Service, yield* Ripgrep.Service), + Context.make(Shell.Service, yield* Shell.Service), + Context.make(Tools.Service, yield* Tools.Service), + Context.make(PluginRuntime.Service, yield* PluginRuntime.Service), + ) + const add = (input: Plugin) => + plugin.add(PluginV2.ID.make(input.id), (context: PluginContext) => + input.effect(context).pipe(Effect.provide(services)), + ) yield* State.batch( Effect.gen(function* () { @@ -138,6 +117,7 @@ const layer = Layer.effectDiscard( yield* add(SkillPlugin.Plugin) yield* add(ModelsDevPlugin) yield* add(ConfigExternalPlugin.Plugin) + yield* add(GlobTool.Plugin) yield* add(ShellTool.Plugin) yield* add(SubagentTool.Plugin) yield* add(ConfigAgentPlugin.Plugin) @@ -175,6 +155,7 @@ export const node = makeLocationNode({ PermissionV2.node, SkillV2.node, Reference.node, + Ripgrep.node, Shell.node, ToolRegistry.toolsNode, PluginRuntime.node, diff --git a/packages/core/src/plugin/models-dev.ts b/packages/core/src/plugin/models-dev.ts index aacc3bd2c5..2ab445b248 100644 --- a/packages/core/src/plugin/models-dev.ts +++ b/packages/core/src/plugin/models-dev.ts @@ -70,25 +70,73 @@ function mergeCost(base: ModelV2Info["cost"], override: ModelsDev.Model["cost"] return [merge(baseDefault ?? { input: 0, output: 0, cache: { read: 0, write: 0 } }, nextDefault), ...tiers.values()] } -function reasoningVariants(model: ModelsDev.Model, packageName: string | undefined): ModelV2Info["variants"] { - const result = new Map() - if (packageName === "@ai-sdk/openai" || packageName === "@ai-sdk/openai-compatible") { - const option = model.reasoning_options?.find((option) => option.type === "effort") - for (const value of option?.values ?? []) { - const id = value === null ? "none" : value - if (typeof id !== "string") continue - const variantID = ModelV2.VariantID.make(id) - result.set(variantID, { - id: variantID, - headers: {}, - body: - packageName === "@ai-sdk/openai" - ? { include: ["reasoning.encrypted_content"], reasoning: { effort: id, summary: "auto" } } - : { reasoning_effort: id }, - }) +const OPENAI_INCLUDE_ENCRYPTED_REASONING = ["reasoning.encrypted_content"] + +function reasoningVariants(provider: ModelsDev.Provider, model: ModelsDev.Model): ModelV2Info["variants"] { + const npm = model.provider?.npm ?? provider.npm + const options = model.reasoning_options ?? [] + const effort = options.find((option) => option.type === "effort") + if (effort?.type === "effort") { + return effort.values.flatMap((value) => { + const raw: unknown = value + const id = raw === null ? "none" : typeof raw === "string" ? raw : undefined + if (id === undefined) return [] + const settings = settingsForEffort(npm, id) + return settings ? [{ id, settings, headers: {}, body: {} }] : [] + }) + } + + const budget = options.find((option) => option.type === "budget_tokens") + if (budget?.type === "budget_tokens") return budgetVariants(npm, budget) + + // Toggle-only reasoning is intentionally left for a follow-up because V1 has + // provider/model-specific behavior like MiniMax M3 adaptive thinking and + // Qwen/GLM enable_thinking request shapes in packages/opencode. + return [] +} + +function settingsForEffort(npm: string | undefined, effort: string): ProviderV2.Settings | undefined { + if (npm === "@openrouter/ai-sdk-provider") return { reasoning: { effort } } + if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic") { + return { thinking: { type: "adaptive", display: "summarized" }, effort } + } + if (npm === "@ai-sdk/google" || npm === "@ai-sdk/google-vertex") { + return { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } } + } + if (npm === "@ai-sdk/azure") return { reasoningEffort: effort } + if (npm === "@ai-sdk/openai") { + return { + reasoningEffort: effort, + reasoningSummary: "auto", + include: OPENAI_INCLUDE_ENCRYPTED_REASONING, } } - return [...result.values()] + if (npm === "@ai-sdk/openai-compatible") return { reasoningEffort: effort } +} + +function budgetVariants( + npm: string | undefined, + option: Extract[number], { type: "budget_tokens" }>, +): ModelV2Info["variants"] { + const max = option.max + const high = option.max === undefined ? Math.max(option.min ?? 0, 16_000) : Math.min(Math.max(option.min ?? 0, 16_000), option.max) + return [ + { id: "high", budget: high }, + ...(max === undefined || max === high ? [] : [{ id: "max", budget: max }]), + ].flatMap((item) => { + const settings = settingsForBudget(npm, item.budget) + return settings ? [{ id: item.id, settings, headers: {}, body: {} }] : [] + }) +} + +function settingsForBudget(npm: string | undefined, budget: number): ProviderV2.Settings | undefined { + if (npm === "@openrouter/ai-sdk-provider") return { reasoning: { max_tokens: budget } } + if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic") { + return { thinking: { type: "enabled", budgetTokens: budget } } + } + if (npm === "@ai-sdk/google" || npm === "@ai-sdk/google-vertex") { + return { thinkingConfig: { includeThoughts: true, thinkingBudget: budget } } + } } function modeName(model: ModelsDev.Model, mode: string) { @@ -193,7 +241,7 @@ export const ModelsDevPlugin = define({ for (const model of Object.values(item.models)) { const baseCost = cost(model.cost) - const variants = reasoningVariants(model, model.provider?.npm ?? item.npm) + const variants = reasoningVariants(item, model) catalog.model.update(providerID, model.id, (draft) => applyModel(draft, model, { cost: baseCost, variants })) for (const [mode, options] of Object.entries(model.experimental?.modes ?? {})) { catalog.model.update(providerID, `${model.id}-${mode}`, (draft) => diff --git a/packages/core/src/plugin/provider/openai-codex.ts b/packages/core/src/plugin/provider/openai-codex.ts new file mode 100644 index 0000000000..de31d39b39 --- /dev/null +++ b/packages/core/src/plugin/provider/openai-codex.ts @@ -0,0 +1,42 @@ +export * as OpenAICodex from "./openai-codex" + +// TEMPORARY SEAM (#34765): plugins have no hook into LLM route construction, so +// codex routing lives in SessionRunnerModel.fromCatalogModel and catalog filtering +// in OpenAIPlugin, sharing this module. Once the native provider packages land +// (#33689/#33925/#34462) this should collapse into the native OpenAI provider. +// The eligibility rules mirror V1's CodexAuthPlugin allowlist; models.dev has no +// plan-eligibility data for OpenAI today, but models other vendors' subscriptions +// as dedicated providers (e.g. zai-coding-plan) - a future openai-chatgpt-plan +// provider entry could replace the hardcoded rules with catalog data. + +/** ChatGPT-plan requests must target the codex backend instead of the public API. */ +export const baseURL = "https://chatgpt.com/backend-api/codex" + +const methodIDs: readonly string[] = ["chatgpt-browser", "chatgpt-headless"] + +/** Structural credential shape so both core and plugin-facing credential types fit. */ +type CredentialLike = { + readonly type: string + readonly methodID?: string + readonly metadata?: Record | undefined +} + +export const isChatGPT = (credential: CredentialLike | undefined) => + credential?.type === "oauth" && credential.methodID !== undefined && methodIDs.includes(credential.methodID) + +export const accountID = (credential: CredentialLike | undefined) => { + if (!isChatGPT(credential)) return undefined + const value = credential?.metadata?.accountID + return typeof value === "string" ? value : undefined +} + +const allowed = new Set(["gpt-5.5", "gpt-5.3-codex-spark", "gpt-5.4", "gpt-5.4-mini"]) +const disallowed = new Set(["gpt-5.5-pro"]) + +/** Which API model ids a ChatGPT subscription may call through the codex backend. */ +export const eligible = (apiID: string) => { + if (allowed.has(apiID)) return true + if (disallowed.has(apiID)) return false + const match = apiID.match(/^gpt-(\d+\.\d+)/) + return match ? Number.parseFloat(match[1]) > 5.4 : false +} diff --git a/packages/core/src/plugin/provider/openai.ts b/packages/core/src/plugin/provider/openai.ts index 46553eaff4..82e2319a1b 100644 --- a/packages/core/src/plugin/provider/openai.ts +++ b/packages/core/src/plugin/provider/openai.ts @@ -1,15 +1,17 @@ import { createServer } from "node:http" import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/v2/effect/integration" import { define } from "@opencode-ai/plugin/v2/effect/plugin" -import { Deferred, Effect } from "effect" +import { Deferred, Effect, Semaphore, Stream } from "effect" import type { Scope } from "effect" import { Credential } from "../../credential" +import { EventV2 } from "../../event" import { InstallationVersion } from "../../installation/version" import { Integration } from "../../integration" import { ModelV2 } from "../../model" import { OauthCallbackPage } from "../../oauth/page" import { ProviderV2 } from "../../provider" import type { PluginInternal } from "../internal" +import { OpenAICodex } from "./openai-codex" const clientID = "app_EMoamEEZ73f0CkXaXp7hrann" const issuer = "https://auth.openai.com" @@ -154,6 +156,18 @@ const headless = { export const OpenAIPlugin = define({ id: "openai", effect: Effect.fn(function* (ctx) { + const events = yield* EventV2.Service + const loading = Semaphore.makeUnsafe(1) + let chatgpt = false + + const load = Effect.fn("OpenAIPlugin.load")(function* () { + const connection = yield* ctx.integration.connection.active("openai") + const credential = connection + ? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined))) + : undefined + chatgpt = OpenAICodex.isChatGPT(credential) + }) + yield* ctx.integration.transform((draft) => { draft.method.update(browser) draft.method.update(headless) @@ -170,8 +184,30 @@ export const OpenAIPlugin = define({ model.enabled = false }) } + if (!chatgpt) return + const item = evt.provider.get(ProviderV2.ID.openai) + if (!item) return + for (const model of item.models.values()) { + // ChatGPT-plan tokens only authorize codex-eligible models, and the + // subscription covers usage, so hide the rest and zero the cost. + evt.model.update(item.provider.id, model.id, (draft) => { + if (!OpenAICodex.eligible(draft.api.id)) { + draft.enabled = false + return + } + draft.cost = [] + }) + } }), ) + + const refresh = () => 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("openai")), + Stream.runForEach(refresh), + Effect.forkScoped({ startImmediately: true }), + ) + yield* refresh().pipe(Effect.forkScoped) yield* ctx.aisdk.sdk( Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/openai") return diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index 9b37b54ed1..bfa84ecd78 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/packages/core/src/plugin/provider/opencode.ts @@ -146,7 +146,7 @@ export const OpencodePlugin = define item.id === variantID) if (!existing) { - existing = { id: variantID, headers: {}, body: {} } + existing = { id: variantID, settings: {}, headers: {}, body: {} } model.variants.push(existing) } Object.assign(existing.headers, options.headers) diff --git a/packages/core/src/plugin/runtime.ts b/packages/core/src/plugin/runtime.ts index c521c9dd30..cb5b40fd17 100644 --- a/packages/core/src/plugin/runtime.ts +++ b/packages/core/src/plugin/runtime.ts @@ -11,7 +11,7 @@ import { SessionV2 } from "../session" export interface Interface { readonly session: Pick< SessionV2.Interface, - "get" | "create" | "messages" | "prompt" | "resume" | "interrupt" | "synthetic" + "get" | "create" | "messages" | "prompt" | "command" | "resume" | "interrupt" | "synthetic" > readonly job: Pick readonly location: { @@ -50,6 +50,7 @@ export const layerWithCell = (cell: Cell) => create: (input) => require(cell, (runtime) => runtime.session.create(input)), messages: (input) => require(cell, (runtime) => runtime.session.messages(input)), prompt: (input) => require(cell, (runtime) => runtime.session.prompt(input)), + command: (input) => require(cell, (runtime) => runtime.session.command(input)), resume: (sessionID) => require(cell, (runtime) => runtime.session.resume(sessionID)), interrupt: (sessionID) => require(cell, (runtime) => runtime.session.interrupt(sessionID)), synthetic: (input) => require(cell, (runtime) => runtime.session.synthetic(input)), diff --git a/packages/core/src/plugin/variant.ts b/packages/core/src/plugin/variant.ts index 05e1653b6e..7c6e688399 100644 --- a/packages/core/src/plugin/variant.ts +++ b/packages/core/src/plugin/variant.ts @@ -33,7 +33,8 @@ export function generate(model: ModelV2Info): ModelV2Info["variants"] { if (!["glm-5.2", "glm-5-2", "glm-5p2"].some((name) => ids.includes(name))) return [] return ["high", "max"].map((id) => ({ id, + settings: { reasoningEffort: id }, headers: {}, - body: { reasoning_effort: id }, + body: {}, })) } diff --git a/packages/core/src/provider.ts b/packages/core/src/provider.ts index 03f7d7eef3..eb443ca710 100644 --- a/packages/core/src/provider.ts +++ b/packages/core/src/provider.ts @@ -19,7 +19,15 @@ export type MutableApi = T extends Api export const Request = Provider.Request export type Request = Provider.Request +export const Settings = Provider.Settings +export type Settings = Provider.Settings + export const Info = Provider.Info export type Info = Provider.Info -export type MutableInfo = Omit, "api"> & { api: MutableApi } +export type MutableRequest = Types.DeepMutable + +export type MutableInfo = Omit, "api" | "request"> & { + api: MutableApi + request: MutableRequest +} diff --git a/packages/core/src/reference/guidance.ts b/packages/core/src/reference/guidance.ts index 11fcb9ecea..7fc4fc486c 100644 --- a/packages/core/src/reference/guidance.ts +++ b/packages/core/src/reference/guidance.ts @@ -11,20 +11,48 @@ const Summary = Schema.Struct({ description: Schema.String.pipe(Schema.optional), }) +const entries = (references: ReadonlyArray) => + references.flatMap((reference) => [ + " ", + ` ${reference.name}`, + ` ${reference.path}`, + ...(reference.description === undefined ? [] : [` ${reference.description}`]), + " ", + ]) + const render = (references: ReadonlyArray) => [ "Project references provide additional directories that can be accessed when relevant.", "", - ...references.flatMap((reference) => [ - " ", - ` ${reference.name}`, - ` ${reference.path}`, - ...(reference.description === undefined ? [] : [` ${reference.description}`]), - " ", - ]), + ...entries(references), "", ].join("\n") +const update = (previous: ReadonlyArray, current: ReadonlyArray) => { + const diff = SystemContext.diffByKey( + previous, + current, + (reference) => reference.name, + (before, after) => before.path !== after.path || before.description !== after.description, + ) + // Additions and removals render as small deltas; anything else restates the full list. + if (diff.changed.length > 0 || (diff.added.length === 0 && diff.removed.length === 0)) + return [ + "The available project references have changed. This list supersedes the previous reference list.", + render(current), + ].join("\n") + return [ + ...(diff.added.length === 0 + ? [] + : ["New project references are available in addition to those previously listed:", ...entries(diff.added)]), + ...(diff.removed.length === 0 + ? [] + : [ + `The following project references are no longer available and must not be used: ${diff.removed.map((reference) => reference.name).join(", ")}.`, + ]), + ].join("\n") +} + export interface Interface { readonly load: () => Effect.Effect } @@ -52,11 +80,7 @@ const layer = Layer.effect( codec: Schema.toCodecJson(Schema.Array(Summary)), load: Effect.succeed(available), baseline: render, - update: (_previous, current) => - [ - "The available project references have changed. This list supersedes the previous reference list.", - render(current), - ].join("\n"), + update, removed: () => "Project reference guidance is no longer available. Do not use previously listed references.", }) }), diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index bbd6e2cc4b..797546259f 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -40,6 +40,7 @@ import { FSUtil } from "./fs-util" import { SessionDurable } from "@opencode-ai/schema/durable-event-manifest" import { SkillV2 } from "./skill" import { Job } from "./job" +import { CommandV2 } from "./command" export const RevertState = Revert.State export type RevertState = Revert.State @@ -108,7 +109,7 @@ export class OperationUnavailableError extends Schema.TaggedErrorClass()("Session.PromptConflictError", { sessionID: SessionSchema.ID, @@ -130,6 +131,8 @@ export type Error = | PromptConflictError | BusyError | SkillNotFoundError + | CommandV2.NotFoundError + | CommandV2.EvaluationError | MessageNotFoundError export interface Interface { @@ -175,6 +178,18 @@ export interface Interface { delivery?: SessionInput.Delivery resume?: boolean }) => Effect.Effect + readonly command: (input: { + id?: SessionMessage.ID + sessionID: SessionSchema.ID + command: string + arguments?: string + agent?: string + model?: ModelV2.Ref + files?: PromptInput.Prompt["files"] + agents?: PromptInput.Prompt["agents"] + delivery?: SessionInput.Delivery + resume?: boolean + }) => Effect.Effect readonly shell: (input: { id?: EventV2.ID sessionID: SessionSchema.ID @@ -450,6 +465,37 @@ const layer = Layer.effect( }), ), ), + command: Effect.fn("V2Session.command")(function* (input) { + const session = yield* result.get(input.sessionID) + const commands = yield* CommandV2.Service.pipe(Effect.provide(locations.get(session.location))) + const command = yield* commands.get(input.command) + if (!command) + return yield* new CommandV2.NotFoundError({ + command: input.command, + message: `Command not found: ${input.command}`, + }) + const evaluated = yield* commands.evaluate({ name: input.command, arguments: input.arguments }) + + // TODO(v2 commands): decide whether command-level subtask/background execution belongs in v2 commands. + const agent = command.agent ?? input.agent + const commandAgent = yield* Effect.gen(function* () { + if (!command.agent) return undefined + const agents = yield* AgentV2.Service.pipe(Effect.provide(locations.get(session.location))) + return yield* agents.get(AgentV2.ID.make(command.agent)) + }) + const model = command.model ?? commandAgent?.model ?? input.model + if (agent !== undefined && session.agent !== AgentV2.ID.make(agent)) + yield* result.switchAgent({ sessionID: input.sessionID, agent }) + if (model !== undefined) yield* result.switchModel({ sessionID: input.sessionID, model }) + + return yield* result.prompt({ + id: input.id, + sessionID: input.sessionID, + prompt: { text: evaluated.text, files: input.files, agents: input.agents }, + delivery: input.delivery, + resume: input.resume, + }) + }), shell: Effect.fn("V2Session.shell")(function* () { return yield* new OperationUnavailableError({ operation: "shell" }) }), @@ -466,7 +512,9 @@ const layer = Layer.effect( text: skill.content, }) if (input.resume !== false) - yield* execution.resume(input.sessionID).pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid) + yield* execution + .resume(input.sessionID) + .pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid) }), switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) { yield* result.get(input.sessionID) @@ -550,7 +598,9 @@ const layer = Layer.effect( description: input.description, metadata: input.metadata, }) - yield* execution.resume(input.sessionID).pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid) + yield* execution + .resume(input.sessionID) + .pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid) }), interrupt: Effect.fn("V2Session.interrupt")((sessionID) => Effect.uninterruptible(execution.interrupt(sessionID)), diff --git a/packages/core/src/session/context-checkpoint.ts b/packages/core/src/session/context-checkpoint.ts new file mode 100644 index 0000000000..c2036e726a --- /dev/null +++ b/packages/core/src/session/context-checkpoint.ts @@ -0,0 +1,131 @@ +export * as SessionContextCheckpoint from "./context-checkpoint" + +import { eq } from "drizzle-orm" +import { DateTime, Effect, Option, Schema } from "effect" +import type { Database } from "../database/database" +import { EventV2 } from "../event" +import { SystemContext } from "../system-context/index" +import { SessionEvent } from "./event" +import { SessionHistory } from "./history" +import { SessionMessage } from "./message" +import { SessionSchema } from "./schema" +import { SessionContextCheckpointTable } from "./sql" + +type DatabaseService = Database.Interface["db"] + +const decodeApplied = Schema.decodeUnknownOption(SystemContext.Applied) + +/** + * Loads or creates the session's durable context checkpoint, narrating any + * drift since the model was last told as a chronological update. Completed + * compaction rebaselines; nothing else rewrites the baseline. Runs before + * input promotion so a blocked first turn leaves pending inputs untouched. + */ +export const prepare = Effect.fn("SessionContextCheckpoint.prepare")(function* ( + db: DatabaseService, + events: EventV2.Interface, + context: Effect.Effect, + sessionID: SessionSchema.ID, +) { + const [value, stored, compaction] = yield* Effect.all( + [context, find(db, sessionID), SessionHistory.latestCompaction(db, sessionID)], + { concurrency: "unbounded" }, + ) + if (!stored) { + const baseline = yield* SystemContext.initialize(value) + const baselineSeq = yield* insert(db, sessionID, baseline) + return { baseline: baseline.text, baselineSeq } + } + + // The applied record is comparison state only; an undecodable one heals by + // treating every source as new, re-announcing baselines as updates. + const applied = Option.getOrElse(decodeApplied(stored.snapshot), () => ({})) + if (compaction !== undefined && compaction.seq > stored.baseline_seq) { + const baseline = yield* SystemContext.rebaseline(value, applied) + yield* rewrite(db, sessionID, compaction.seq, baseline) + return { baseline: baseline.text, baselineSeq: compaction.seq } + } + const result = yield* SystemContext.reconcile(value, applied) + if (result._tag === "Unchanged") return { baseline: stored.baseline, baselineSeq: stored.baseline_seq } + + yield* events.publish( + SessionEvent.ContextUpdated, + { sessionID, messageID: SessionMessage.ID.create(), timestamp: yield* DateTime.now, text: result.text }, + { commit: () => advance(db, sessionID, result.applied).pipe(Effect.orDie) }, + ) + return { baseline: stored.baseline, baselineSeq: stored.baseline_seq } +}) + +export const reset = Effect.fn("SessionContextCheckpoint.reset")(function* ( + db: DatabaseService, + sessionID: SessionSchema.ID, +) { + yield* db + .delete(SessionContextCheckpointTable) + .where(eq(SessionContextCheckpointTable.session_id, sessionID)) + .run() + .pipe(Effect.orDie) +}) + +const find = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) { + return yield* db + .select() + .from(SessionContextCheckpointTable) + .where(eq(SessionContextCheckpointTable.session_id, sessionID)) + .get() + .pipe(Effect.orDie) +}) + +const insert = Effect.fnUntraced(function* ( + db: DatabaseService, + sessionID: SessionSchema.ID, + baseline: SystemContext.Baseline, +) { + const baselineSeq = yield* EventV2.latestSequence(db, sessionID) + yield* db + .insert(SessionContextCheckpointTable) + .values({ + session_id: sessionID, + baseline: baseline.text, + snapshot: baseline.applied, + baseline_seq: baselineSeq, + }) + .run() + .pipe(Effect.orDie) + return baselineSeq +}) + +const rewrite = Effect.fnUntraced(function* ( + db: DatabaseService, + sessionID: SessionSchema.ID, + baselineSeq: number, + baseline: SystemContext.Baseline, +) { + const updated = yield* db + .update(SessionContextCheckpointTable) + .set({ + baseline: baseline.text, + snapshot: baseline.applied, + baseline_seq: baselineSeq, + }) + .where(eq(SessionContextCheckpointTable.session_id, sessionID)) + .returning({ sessionID: SessionContextCheckpointTable.session_id }) + .get() + .pipe(Effect.orDie) + if (!updated) return yield* Effect.die("Context checkpoint not found") +}) + +const advance = Effect.fnUntraced(function* ( + db: DatabaseService, + sessionID: SessionSchema.ID, + applied: SystemContext.Applied, +) { + const updated = yield* db + .update(SessionContextCheckpointTable) + .set({ snapshot: applied }) + .where(eq(SessionContextCheckpointTable.session_id, sessionID)) + .returning({ sessionID: SessionContextCheckpointTable.session_id }) + .get() + .pipe(Effect.orDie) + if (!updated) return yield* Effect.die("Context checkpoint not found") +}) diff --git a/packages/core/src/session/context-entry.ts b/packages/core/src/session/context-entry.ts new file mode 100644 index 0000000000..1f3ffdb053 --- /dev/null +++ b/packages/core/src/session/context-entry.ts @@ -0,0 +1,106 @@ +export * as SessionContextEntry from "./context-entry" + +import { and, asc, eq } from "drizzle-orm" +import { Context, Effect, Layer, Schema } from "effect" +import { SessionContextEntry } from "@opencode-ai/schema/session-context-entry" +import { Database } from "../database/database" +import { makeLocationNode } from "../effect/app-node" +import { SystemContext } from "../system-context/index" +import { SessionSchema } from "./schema" +import { SessionContextEntryTable } from "./sql" + +export const Key = SessionContextEntry.Key +export type Key = typeof Key.Type +export const Info = SessionContextEntry.Info +export type Info = typeof Info.Type + +export interface Interface { + readonly list: (sessionID: SessionSchema.ID) => Effect.Effect> + readonly put: (input: { + readonly sessionID: SessionSchema.ID + readonly key: Key + readonly value: Schema.Json + }) => Effect.Effect + readonly remove: (input: { readonly sessionID: SessionSchema.ID; readonly key: Key }) => Effect.Effect + /** Produces one SystemContext source per stored entry, keyed `api/`. */ + readonly load: (sessionID: SessionSchema.ID) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/SessionContextEntry") {} + +const renderValue = (value: Schema.Json) => (typeof value === "string" ? value : JSON.stringify(value, null, 2)) + +const renderBlock = (key: Key, value: Schema.Json) => + [``, renderValue(value), ""].join("\n") + +// Rendering stays mechanism-neutral: the model sees session context, not how +// it was attached. Only chronological updates and removals carry narration. +const source = (entry: Info) => + SystemContext.make({ + key: SystemContext.Key.make(`api/${entry.key}`), + codec: Schema.toCodecJson(Schema.Json), + load: Effect.succeed(entry.value), + baseline: (value) => renderBlock(entry.key, value), + update: (_previous, value) => + [ + `The context under "${entry.key}" changed and supersedes the previous value:`, + renderBlock(entry.key, value), + ].join("\n"), + removed: () => `The context under "${entry.key}" no longer applies. Disregard it.`, + }) + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const { db } = yield* Database.Service + + const list = Effect.fn("SessionContextEntry.list")(function* (sessionID: SessionSchema.ID) { + const rows = yield* db + .select() + .from(SessionContextEntryTable) + .where(eq(SessionContextEntryTable.session_id, sessionID)) + .orderBy(asc(SessionContextEntryTable.key)) + .all() + .pipe(Effect.orDie) + return rows.map((row) => ({ key: row.key, value: row.value })) + }) + + const put = Effect.fn("SessionContextEntry.put")(function* (input: { + readonly sessionID: SessionSchema.ID + readonly key: Key + readonly value: Schema.Json + }) { + yield* db + .insert(SessionContextEntryTable) + .values({ session_id: input.sessionID, key: input.key, value: input.value }) + .onConflictDoUpdate({ + target: [SessionContextEntryTable.session_id, SessionContextEntryTable.key], + set: { value: input.value, time_updated: Date.now() }, + }) + .run() + .pipe(Effect.orDie) + }) + + const remove = Effect.fn("SessionContextEntry.remove")(function* (input: { + readonly sessionID: SessionSchema.ID + readonly key: Key + }) { + yield* db + .delete(SessionContextEntryTable) + .where( + and(eq(SessionContextEntryTable.session_id, input.sessionID), eq(SessionContextEntryTable.key, input.key)), + ) + .run() + .pipe(Effect.orDie) + }) + + const load = Effect.fn("SessionContextEntry.load")(function* (sessionID: SessionSchema.ID) { + const entries = yield* list(sessionID) + return SystemContext.combine(entries.map(source)) + }) + + return Service.of({ list, put, remove, load }) + }), +) + +export const node = makeLocationNode({ service: Service, layer, deps: [Database.node] }) diff --git a/packages/core/src/session/context-epoch.ts b/packages/core/src/session/context-epoch.ts deleted file mode 100644 index 65b17a86a4..0000000000 --- a/packages/core/src/session/context-epoch.ts +++ /dev/null @@ -1,174 +0,0 @@ -export * as SessionContextEpoch from "./context-epoch" - -import { eq } from "drizzle-orm" -import { DateTime, Effect, Schema } from "effect" -import type { Database } from "../database/database" -import { EventV2 } from "../event" -import { SystemContext } from "../system-context/index" -import { ContextSnapshotDecodeError } from "./error" -import { SessionEvent } from "./event" -import { SessionHistory } from "./history" -import { SessionInput } from "./input" -import { SessionMessage } from "./message" -import { SessionSchema } from "./schema" -import { SessionContextEpochTable } from "./sql" - -type DatabaseService = Database.Interface["db"] - -interface Prepared { - readonly baseline: string - readonly baselineSeq: number -} - -export function initialize( - db: DatabaseService, - context: Effect.Effect, - sessionID: SessionSchema.ID, -): Effect.Effect { - return initializeOnce(db, context, sessionID).pipe(Effect.withSpan("SessionContextEpoch.initialize")) -} - -export function prepare( - db: DatabaseService, - events: EventV2.Interface, - context: Effect.Effect, - sessionID: SessionSchema.ID, -): Effect.Effect { - return prepareOnce(db, events, context, sessionID).pipe(Effect.withSpan("SessionContextEpoch.prepare")) -} - -const prepareOnce = Effect.fnUntraced(function* ( - db: DatabaseService, - events: EventV2.Interface, - context: Effect.Effect, - sessionID: SessionSchema.ID, -) { - const [value, stored, compaction] = yield* Effect.all( - [context, find(db, sessionID), SessionHistory.latestCompaction(db, sessionID)], - { concurrency: "unbounded" }, - ) - if (!stored) { - const generation = yield* SystemContext.initialize(value) - const baselineSeq = yield* insert(db, sessionID, generation) - return { baseline: generation.baseline, baselineSeq } - } - - const snapshot = yield* Schema.decodeUnknownEffect(SystemContext.Snapshot)(stored.snapshot).pipe( - Effect.mapError((error) => new ContextSnapshotDecodeError({ sessionID, details: String(error) })), - ) - const replacementSeq = compaction !== undefined && compaction.seq > stored.baseline_seq ? compaction.seq : undefined - const result = replacementSeq - ? yield* SystemContext.replace(value, snapshot) - : yield* SystemContext.reconcile(value, snapshot) - if (result._tag === "Unchanged" || result._tag === "ReplacementBlocked") { - return { baseline: stored.baseline, baselineSeq: stored.baseline_seq } - } - if (result._tag === "ReplacementReady") { - const baselineSeq = replacementSeq ?? (yield* EventV2.latestSequence(db, sessionID)) - yield* replace(db, sessionID, baselineSeq, result.generation) - return { baseline: result.generation.baseline, baselineSeq } - } - - yield* events.publish( - SessionEvent.ContextUpdated, - { sessionID, messageID: SessionMessage.ID.create(), timestamp: yield* DateTime.now, text: result.text }, - { commit: () => advance(db, sessionID, result.snapshot).pipe(Effect.orDie) }, - ) - return { baseline: stored.baseline, baselineSeq: stored.baseline_seq } -}) - -const initializeOnce = Effect.fnUntraced(function* ( - db: DatabaseService, - context: Effect.Effect, - sessionID: SessionSchema.ID, -) { - if (yield* exists(db, sessionID)) return - const generation = yield* context.pipe(Effect.flatMap(SystemContext.initialize)) - const baselineSeq = yield* insert(db, sessionID, generation) - return { baseline: generation.baseline, baselineSeq } -}) - -const exists = Effect.fn("SessionContextEpoch.exists")(function* (db: DatabaseService, sessionID: SessionSchema.ID) { - return ( - (yield* db - .select({ sessionID: SessionContextEpochTable.session_id }) - .from(SessionContextEpochTable) - .where(eq(SessionContextEpochTable.session_id, sessionID)) - .get() - .pipe(Effect.orDie)) !== undefined - ) -}) - -const find = Effect.fn("SessionContextEpoch.find")(function* (db: DatabaseService, sessionID: SessionSchema.ID) { - return yield* db - .select() - .from(SessionContextEpochTable) - .where(eq(SessionContextEpochTable.session_id, sessionID)) - .get() - .pipe(Effect.orDie) -}) - -export const reset = Effect.fn("SessionContextEpoch.reset")(function* ( - db: DatabaseService, - sessionID: SessionSchema.ID, -) { - yield* db - .delete(SessionContextEpochTable) - .where(eq(SessionContextEpochTable.session_id, sessionID)) - .run() - .pipe(Effect.orDie) -}) - -const insert = Effect.fnUntraced(function* ( - db: DatabaseService, - sessionID: SessionSchema.ID, - generation: SystemContext.Generation, -) { - const baselineSeq = yield* EventV2.latestSequence(db, sessionID) - yield* db - .insert(SessionContextEpochTable) - .values({ - session_id: sessionID, - baseline: generation.baseline, - snapshot: generation.snapshot, - baseline_seq: baselineSeq, - }) - .run() - .pipe(Effect.orDie) - return baselineSeq -}) - -const replace = Effect.fnUntraced(function* ( - db: DatabaseService, - sessionID: SessionSchema.ID, - baselineSeq: number, - generation: SystemContext.Generation, -) { - const updated = yield* db - .update(SessionContextEpochTable) - .set({ - baseline: generation.baseline, - snapshot: generation.snapshot, - baseline_seq: baselineSeq, - }) - .where(eq(SessionContextEpochTable.session_id, sessionID)) - .returning({ sessionID: SessionContextEpochTable.session_id }) - .get() - .pipe(Effect.orDie) - if (!updated) return yield* Effect.die("Context Epoch not found") -}) - -const advance = Effect.fnUntraced(function* ( - db: DatabaseService, - sessionID: SessionSchema.ID, - snapshot: SystemContext.Snapshot, -) { - const updated = yield* db - .update(SessionContextEpochTable) - .set({ snapshot }) - .where(eq(SessionContextEpochTable.session_id, sessionID)) - .returning({ sessionID: SessionContextEpochTable.session_id }) - .get() - .pipe(Effect.orDie) - if (!updated) return yield* Effect.die("Context Epoch not found") -}) diff --git a/packages/core/src/session/error.ts b/packages/core/src/session/error.ts index 158e46dd07..68f4fd32a4 100644 --- a/packages/core/src/session/error.ts +++ b/packages/core/src/session/error.ts @@ -10,15 +10,3 @@ export class MessageDecodeError extends Schema.TaggedErrorClass()( - "Session.ContextSnapshotDecodeError", - { - sessionID: SessionSchema.ID, - details: Schema.String, - }, -) { - override get message() { - return `Failed to decode context snapshot for session ${this.sessionID}: ${this.details}` - } -} diff --git a/packages/core/src/session/history.ts b/packages/core/src/session/history.ts index f72ff91ab8..a704a631f7 100644 --- a/packages/core/src/session/history.ts +++ b/packages/core/src/session/history.ts @@ -4,7 +4,7 @@ import { Database } from "../database/database" import { MessageDecodeError } from "./error" import { SessionMessage } from "./message" import { SessionSchema } from "./schema" -import { SessionContextEpochTable, SessionMessageTable } from "./sql" +import { SessionContextCheckpointTable, SessionMessageTable } from "./sql" type DatabaseService = Database.Interface["db"] @@ -33,6 +33,9 @@ const messageRows = Effect.fnUntraced(function* ( .where( and( eq(SessionMessageTable.session_id, sessionID), + // Keep system updates visible in the gap between a completed compaction + // and the next prepared turn's rebaseline, when their content is not yet + // folded into a new baseline. compaction ? or( gte(SessionMessageTable.seq, compaction.seq), @@ -67,9 +70,9 @@ export const load = Effect.fn("SessionHistory.load")(function* (db: DatabaseServ const [epoch, compaction] = yield* Effect.all( [ db - .select({ baselineSeq: SessionContextEpochTable.baseline_seq }) - .from(SessionContextEpochTable) - .where(eq(SessionContextEpochTable.session_id, sessionID)) + .select({ baselineSeq: SessionContextCheckpointTable.baseline_seq }) + .from(SessionContextCheckpointTable) + .where(eq(SessionContextCheckpointTable.session_id, sessionID)) .get() .pipe(Effect.orDie), latestCompaction(db, sessionID), @@ -79,14 +82,6 @@ export const load = Effect.fn("SessionHistory.load")(function* (db: DatabaseServ return yield* Effect.forEach(yield* messageRows(db, sessionID, compaction, epoch?.baselineSeq), decodeMessageRow) }) -export const loadForRunner = Effect.fn("SessionHistory.loadForRunner")(function* ( - db: DatabaseService, - sessionID: SessionSchema.ID, - baselineSeq: number, -) { - return (yield* entriesForRunner(db, sessionID, baselineSeq)).map((entry) => entry.message) -}) - export const entriesForRunner = Effect.fn("SessionHistory.entriesForRunner")(function* ( db: DatabaseService, sessionID: SessionSchema.ID, diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index 45171cca2f..244e465bb7 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -143,6 +143,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { ) }, "session.next.prompt.admitted": () => Effect.void, + "session.next.execution.settled": () => Effect.void, "session.next.context.updated": (event) => adapter.appendMessage( SessionMessage.System.make({ diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index ce6ebcd4ec..8f4cc4e5df 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -12,8 +12,15 @@ import { SessionMessage } from "./message" import { SessionMessageUpdater } from "./message-updater" import { SessionInput } from "./input" import { WorkspaceV2 } from "../workspace" -import { SessionContextEpoch } from "./context-epoch" -import { MessageTable, PartTable, SessionInputTable, SessionMessageTable, SessionTable } from "./sql" +import { SessionContextCheckpoint } from "./context-checkpoint" +import { + MessageTable, + PartTable, + SessionContextCheckpointTable, + SessionInputTable, + SessionMessageTable, + SessionTable, +} from "./sql" import type { DeepMutable } from "../schema" import { Slug } from "../util/slug" @@ -156,12 +163,16 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( .select({ seq: SessionMessageTable.seq }) .from(SessionMessageTable) .where( - and(eq(SessionMessageTable.session_id, event.data.parentID), eq(SessionMessageTable.id, event.data.messageID)), + and( + eq(SessionMessageTable.session_id, event.data.parentID), + eq(SessionMessageTable.id, event.data.messageID), + ), ) .get() .pipe(Effect.orDie) : undefined - if (event.data.messageID && !boundary) return yield* Effect.die(`Fork boundary message not found: ${event.data.messageID}`) + if (event.data.messageID && !boundary) + return yield* Effect.die(`Fork boundary message not found: ${event.data.messageID}`) const copied = yield* db .select({ seq: SessionMessageTable.seq }) .from(SessionMessageTable) @@ -206,6 +217,23 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* ( .pipe(Effect.orDie) if (!stored) return yield* Effect.die(new SessionAlreadyProjected()) + // The fork inherits the parent's transcript, so it inherits the context + // checkpoint that transcript was built against: copied message seqs keep + // folding at the same baseline horizon. + const checkpoint = yield* db + .select() + .from(SessionContextCheckpointTable) + .where(eq(SessionContextCheckpointTable.session_id, event.data.parentID)) + .get() + .pipe(Effect.orDie) + if (checkpoint) { + yield* db + .insert(SessionContextCheckpointTable) + .values({ ...checkpoint, session_id: event.data.sessionID }) + .run() + .pipe(Effect.orDie) + } + const usage = emptyUsage() let cursor = -1 while (true) { @@ -452,7 +480,7 @@ const layer = Layer.effectDiscard( .where(eq(SessionTable.id, event.data.sessionID)) .run() .pipe(Effect.orDie) - yield* SessionContextEpoch.reset(db, event.data.sessionID) + yield* SessionContextCheckpoint.reset(db, event.data.sessionID) }), ) yield* events.project(SessionV1.Event.Deleted, (event) => @@ -666,7 +694,7 @@ const layer = Layer.effectDiscard( .where(eq(SessionTable.id, event.data.sessionID)) .run() .pipe(Effect.orDie) - yield* SessionContextEpoch.reset(db, event.data.sessionID) + yield* SessionContextCheckpoint.reset(db, event.data.sessionID) }), ) }), diff --git a/packages/core/src/session/runner/index.ts b/packages/core/src/session/runner/index.ts index 634075dd91..11141c3447 100644 --- a/packages/core/src/session/runner/index.ts +++ b/packages/core/src/session/runner/index.ts @@ -3,7 +3,7 @@ export * as SessionRunner from "./index" import type { LLMError } from "@opencode-ai/llm" import { Context, Effect } from "effect" import { SessionSchema } from "../schema" -import type { ContextSnapshotDecodeError, MessageDecodeError } from "../error" +import type { MessageDecodeError } from "../error" import { SessionRunnerModel } from "./model" import type { SystemContext } from "../../system-context/index" import type { ToolOutputStore } from "../../tool-output-store" @@ -12,7 +12,6 @@ export type RunError = | LLMError | SessionRunnerModel.Error | MessageDecodeError - | ContextSnapshotDecodeError | SystemContext.InitializationBlocked | ToolOutputStore.Error diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 617c6ab5e3..775d96e934 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -8,7 +8,7 @@ import { isContextOverflowFailure, type ProviderErrorEvent, } from "@opencode-ai/llm" -import { Cause, DateTime, Effect, FiberSet, Layer, Option, Semaphore, Stream } from "effect" +import { Cause, DateTime, Effect, Exit, FiberSet, Layer, Option, Semaphore, Stream } from "effect" import { AgentV2 } from "../../agent" import { Config } from "../../config" import { Database } from "../../database/database" @@ -19,14 +19,16 @@ import { ModelV2 } from "../../model" import { ProviderV2 } from "../../provider" import { QuestionV2 } from "../../question" import { SystemContext } from "../../system-context/index" -import { SystemContextRegistry } from "../../system-context/registry" +import { SystemContextBuiltIns } from "../../system-context/builtins" +import { InstructionContext } from "../../instruction-context" import { SkillGuidance } from "../../skill/guidance" import { ReferenceGuidance } from "../../reference/guidance" import { McpGuidance } from "../../mcp/guidance" +import { SessionContextEntry } from "../context-entry" import { ToolRegistry } from "../../tool/registry" import { ReadToolFileSystem } from "../../tool/read-filesystem" import { ToolOutputStore } from "../../tool-output-store" -import { SessionContextEpoch } from "../context-epoch" +import { SessionContextCheckpoint } from "../context-checkpoint" import { SessionCompaction } from "../compaction" import { SessionEvent } from "../event" import { SessionHistory } from "../history" @@ -111,10 +113,12 @@ const layer = Layer.effect( const models = yield* SessionRunnerModel.Service const store = yield* SessionStore.Service const location = yield* Location.Service - const systemContext = yield* SystemContextRegistry.Service + const builtins = yield* SystemContextBuiltIns.Service + const instructions = yield* InstructionContext.Service const skillGuidance = yield* SkillGuidance.Service const referenceGuidance = yield* ReferenceGuidance.Service const mcpGuidance = yield* McpGuidance.Service + const contextEntries = yield* SessionContextEntry.Service const snapshots = yield* Snapshot.Service const db = (yield* Database.Service).db const compaction = yield* SessionCompaction.Service @@ -178,10 +182,18 @@ const layer = Layer.effect( const continueAfterOverflowCompaction = (step: number) => new TurnTransitionError({ _tag: "ContinueAfterOverflowCompaction", step }) - const loadSystemContext = (agent: AgentV2.Selection) => - Effect.all([systemContext.load(), skillGuidance.load(agent), referenceGuidance.load(), mcpGuidance.load(agent)], { - concurrency: "unbounded", - }).pipe(Effect.map(SystemContext.combine)) + const loadSystemContext = (agent: AgentV2.Selection, sessionID: SessionSchema.ID) => + Effect.all( + [ + builtins.load(), + instructions.load(), + skillGuidance.load(agent), + referenceGuidance.load(), + mcpGuidance.load(agent), + contextEntries.load(sessionID), + ], + { concurrency: "unbounded" }, + ).pipe(Effect.map(SystemContext.combine)) const runTurnAttempt = Effect.fn("SessionRunner.runTurn")(function* ( sessionID: SessionSchema.ID, @@ -193,7 +205,14 @@ const layer = Layer.effect( if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID) return yield* Effect.interrupt const agent = yield* agents.select(session.agent) - const initialized = yield* SessionContextEpoch.initialize(db, loadSystemContext(agent), session.id) + // Establish what the model knows before admitting what the user said, so + // a blocked first turn leaves pending inputs untouched. + const checkpoint = yield* SessionContextCheckpoint.prepare( + db, + events, + loadSystemContext(agent, session.id), + session.id, + ) const toolFibers = yield* FiberSet.make() let needsContinuation = false let currentStep = step @@ -208,10 +227,8 @@ const layer = Layer.effect( } if (promoted > 0) currentStep = 1 } - const system = - initialized ?? (yield* SessionContextEpoch.prepare(db, events, loadSystemContext(agent), session.id)) const model = yield* models.resolve(session) - const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq) + const entries = yield* SessionHistory.entriesForRunner(db, session.id, checkpoint.baselineSeq) const context = entries.map((entry) => entry.message) const isLastStep = agent.info?.steps !== undefined && currentStep >= agent.info.steps const toolMaterialization = isLastStep @@ -221,7 +238,10 @@ const layer = Layer.effect( const request = LLM.request({ model, providerOptions: { openai: { promptCacheKey } }, - system: [agent.info?.system ? agent.info.system : SessionRunnerSystemPrompt.provider(model), system.baseline] + system: [ + agent.info?.system ? agent.info.system : SessionRunnerSystemPrompt.provider(model), + checkpoint.baseline, + ] .filter((part): part is string => part !== undefined && part.length > 0) .map(SystemPart.make), messages: [...toLLMMessages(context, model), ...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : [])], @@ -397,7 +417,7 @@ const layer = Layer.effect( ) }) - const run = Effect.fn("SessionRunner.run")(function* (input: { + const drain = Effect.fnUntraced(function* (input: { readonly sessionID: SessionSchema.ID readonly force: boolean }) { @@ -428,6 +448,30 @@ const layer = Layer.effect( } }) + const run = Effect.fn("SessionRunner.run")( + (input: { readonly sessionID: SessionSchema.ID; readonly force: boolean }) => + drain(input).pipe( + Effect.onExit((exit) => + Effect.gen(function* () { + const failure = + Exit.isFailure(exit) && !Cause.hasInterrupts(exit.cause) ? Cause.squash(exit.cause) : undefined + yield* events.publish(SessionEvent.ExecutionSettled, { + sessionID: input.sessionID, + timestamp: yield* DateTime.now, + outcome: Exit.isSuccess(exit) ? "success" : Cause.hasInterrupts(exit.cause) ? "interrupted" : "failure", + error: + failure !== undefined + ? { type: "unknown", message: failure instanceof Error ? failure.message : String(failure) } + : undefined, + }) + }).pipe( + Effect.catchCause(() => Effect.void), + Effect.asVoid, + ), + ), + ), + ) + return Service.of({ run, }) @@ -447,10 +491,12 @@ export const node = makeLocationNode({ SessionRunnerModel.node, SessionStore.node, Location.node, - SystemContextRegistry.node, + SystemContextBuiltIns.node, + InstructionContext.node, SkillGuidance.node, ReferenceGuidance.node, McpGuidance.node, + SessionContextEntry.node, SessionCompaction.node, SessionTitle.node, Config.node, diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index cc67437572..4583695a31 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -12,6 +12,7 @@ import { Catalog } from "../../catalog" import { Credential } from "../../credential" import { Integration } from "../../integration" import { ModelV2 } from "../../model" +import { OpenAICodex } from "../../plugin/provider/openai-codex" import { ProviderV2 } from "../../provider" import { SessionSchema } from "../schema" @@ -96,11 +97,22 @@ const withDefaults = (model: ModelV2.Info, route: AnyRoute) => { provider: model.providerID, endpoint: model.api.url === undefined ? undefined : { baseURL: model.api.url }, headers: model.request.headers, + providerOptions: providerOptions(model), http: { body: httpBody }, limits: { context: model.limit.context, output: model.limit.output }, }) } +const providerOptions = ( + model: ModelV2.Info, +): { readonly [key: string]: { readonly [key: string]: unknown } } | undefined => { + if (Object.keys(model.request.settings).length === 0) return undefined + if (model.api.type !== "aisdk") return undefined + if (model.api.package === "@ai-sdk/openai") return { openai: model.request.settings } + if (model.api.package === "@ai-sdk/anthropic") return { anthropic: model.request.settings } + if (model.api.package === "@ai-sdk/openai-compatible") return { openai: model.request.settings } +} + export const withVariant = ( model: ModelV2.Info, variantID: ModelV2.VariantID | undefined, @@ -118,6 +130,7 @@ export const withVariant = ( return Effect.succeed( variant ? produce(model, (draft) => { + Object.assign(draft.request.settings, variant.settings) Object.assign(draft.request.headers, variant.headers) Object.assign(draft.request.body, variant.body) }) @@ -140,6 +153,21 @@ export const fromCatalogModel = ( }) const key = apiKey(resolved, credential) if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai") { + // ChatGPT-plan OAuth tokens are not API-key credentials: the public API rejects + // them, so requests must target the codex backend with the account header. + if (OpenAICodex.isChatGPT(credential)) { + const account = OpenAICodex.accountID(credential) + return Effect.succeed( + withDefaults(resolved, OpenAIResponses.route) + .with({ + endpoint: { baseURL: OpenAICodex.baseURL }, + auth: (key === undefined ? Auth.none : Auth.bearer(key)).andThen( + account === undefined ? Auth.none : Auth.headers({ "chatgpt-account-id": account }), + ), + }) + .model({ id: resolved.api.id }), + ) + } return Effect.succeed( withDefaults(resolved, OpenAIResponses.route) .with({ auth: key === undefined ? Auth.none : Auth.bearer(key) }) diff --git a/packages/core/src/session/sql.ts b/packages/core/src/session/sql.ts index 264a1d2cca..d1e923b40f 100644 --- a/packages/core/src/session/sql.ts +++ b/packages/core/src/session/sql.ts @@ -14,6 +14,7 @@ import { Timestamps } from "../database/schema.sql" import type { SystemContext } from "../system-context/index" import { AgentV2 } from "../agent" import type { Revert } from "@opencode-ai/schema/revert" +import type { Schema } from "effect" type SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id"> type V1MessageData = Omit @@ -165,12 +166,26 @@ export const SessionInputTable = sqliteTable( ], ) -export const SessionContextEpochTable = sqliteTable("session_context_epoch", { +export const SessionContextEntryTable = sqliteTable( + "session_context_entry", + { + session_id: text() + .$type() + .notNull() + .references(() => SessionTable.id, { onDelete: "cascade" }), + key: text().notNull(), + value: text({ mode: "json" }).notNull().$type(), + ...Timestamps, + }, + (table) => [primaryKey({ columns: [table.session_id, table.key] })], +) + +export const SessionContextCheckpointTable = sqliteTable("session_context_epoch", { session_id: text() .$type() .primaryKey() .references(() => SessionTable.id, { onDelete: "cascade" }), baseline: text().notNull(), - snapshot: text({ mode: "json" }).notNull().$type(), + snapshot: text({ mode: "json" }).notNull().$type(), baseline_seq: integer().notNull(), }) diff --git a/packages/core/src/session/store.ts b/packages/core/src/session/store.ts index 273444d25c..c3ffeedc3c 100644 --- a/packages/core/src/session/store.ts +++ b/packages/core/src/session/store.ts @@ -14,10 +14,6 @@ import { fromRow } from "./info" export interface Interface { readonly get: (sessionID: SessionSchema.ID) => Effect.Effect readonly context: (sessionID: SessionSchema.ID) => Effect.Effect - readonly runnerContext: ( - sessionID: SessionSchema.ID, - baselineSeq: number, - ) => Effect.Effect readonly message: ( messageID: SessionMessage.ID, ) => Effect.Effect<{ readonly sessionID: SessionSchema.ID; readonly message: SessionMessage.Message } | undefined> @@ -39,9 +35,6 @@ const layer = Layer.effect( context: Effect.fn("SessionStore.context")(function* (sessionID) { return yield* SessionHistory.load(db, sessionID) }), - runnerContext: Effect.fn("SessionStore.runnerContext")(function* (sessionID, baselineSeq) { - return yield* SessionHistory.loadForRunner(db, sessionID, baselineSeq) - }), message: Effect.fn("SessionStore.message")(function* (messageID) { const row = yield* db .select() diff --git a/packages/core/src/skill/guidance.ts b/packages/core/src/skill/guidance.ts index f2ec815744..a95a1ab0b8 100644 --- a/packages/core/src/skill/guidance.ts +++ b/packages/core/src/skill/guidance.ts @@ -13,23 +13,47 @@ const Summary = Schema.Struct({ }) type Summary = typeof Summary.Type +const entries = (skills: ReadonlyArray) => + skills.flatMap((skill) => [ + " ", + ` ${skill.name}`, + ` ${skill.description}`, + " ", + ]) + const render = (skills: ReadonlyArray) => [ "Skills provide specialized instructions and workflows for specific tasks.", "Use the skill tool to load a skill when a task matches its description.", ...(skills.length === 0 ? ["No skills are currently available."] + : ["", ...entries(skills), ""]), + ].join("\n") + +const update = (previous: ReadonlyArray, current: ReadonlyArray) => { + const diff = SystemContext.diffByKey( + previous, + current, + (skill) => skill.name, + (before, after) => before.description !== after.description, + ) + // Additions and removals render as small deltas; anything else restates the full list. + if (diff.changed.length > 0 || (diff.added.length === 0 && diff.removed.length === 0)) + return [ + "The available skills have changed. This list supersedes the previous available skills list.", + render(current), + ].join("\n") + return [ + ...(diff.added.length === 0 + ? [] + : ["New skills are available in addition to those previously listed:", ...entries(diff.added)]), + ...(diff.removed.length === 0 + ? [] : [ - "", - ...skills.flatMap((skill) => [ - " ", - ` ${skill.name}`, - ` ${skill.description}`, - " ", - ]), - "", + `The following skills are no longer available and must not be used: ${diff.removed.map((skill) => skill.name).join(", ")}.`, ]), ].join("\n") +} export interface Interface { readonly load: (agent: AgentV2.Selection) => Effect.Effect @@ -61,11 +85,7 @@ const layer = Layer.effect( codec: Schema.toCodecJson(Schema.Array(Summary)), load: Effect.succeed(available), baseline: render, - update: (_previous, current) => - [ - "The available skills have changed. This list supersedes the previous available skills list.", - render(current), - ].join("\n"), + update, removed: () => "Skill guidance is no longer available. Do not use any previously listed skill.", }) }), diff --git a/packages/core/src/system-context/builtins.ts b/packages/core/src/system-context/builtins.ts index b8b50577cc..1cf470fb4a 100644 --- a/packages/core/src/system-context/builtins.ts +++ b/packages/core/src/system-context/builtins.ts @@ -1,18 +1,20 @@ export * as SystemContextBuiltIns from "./builtins" import { makeLocationNode } from "../effect/app-node" -import { DateTime, Effect, Layer, Schema } from "effect" +import { Context, 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( +export interface Interface { + readonly load: () => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/SystemContextBuiltIns") {} + +const layer = Layer.effect( + Service, Effect.gen(function* () { const location = yield* Location.Service - const registry = yield* SystemContextRegistry.Service const environment = [ "", ` Working directory: ${location.directory}`, @@ -39,12 +41,8 @@ const builtIns = Layer.effectDiscard( }), ]) - yield* registry.register({ key: SystemContext.Key.make("core/builtins"), load: Effect.succeed(context) }) + return Service.of({ load: () => Effect.succeed(context) }) }), ) -export const node = makeLocationNode({ - name: "system-context-builtins", - layer: builtIns, - deps: [Location.node, SystemContextRegistry.node, InstructionContext.node, FSUtil.node, Global.node], -}) +export const node = makeLocationNode({ service: Service, layer, deps: [Location.node] }) diff --git a/packages/core/src/system-context/index.ts b/packages/core/src/system-context/index.ts index c0a583c08c..ce6aaae454 100644 --- a/packages/core/src/system-context/index.ts +++ b/packages/core/src/system-context/index.ts @@ -7,13 +7,18 @@ import { Effect, Option, Schema } from "effect" * * `Source` describes how to observe, compare, and render one value. `make` * closes over `A`, producing an opaque `SystemContext` that composes uniformly - * with contexts built from other value types. Interpreters observe the composed - * context once, then produce a durable structured - * `Snapshot` alongside the exact model-visible baseline or update text. + * with contexts built from other value types. + * + * The durable `Applied` record tracks what the model was last told, per source: + * it is the model's current belief. Interpreters uphold one invariant — + * `reconcile` never rewrites the baseline; it only narrates drift as update + * text. Only `rebaseline` (compaction) and `initialize` (first turn) produce + * baseline text. * * Returning `unavailable` means observation failed temporarily. It differs from - * removing a source from the context: refresh preserves the admitted snapshot, - * and replacement waits rather than silently constructing an incomplete baseline. + * removing a source from the context: the model's prior belief stands. + * `reconcile` retains the applied value silently, and `rebaseline` restates the + * belief by rendering the last-applied value instead of a live observation. * * @module */ @@ -45,39 +50,30 @@ export interface SystemContext { readonly [ContextTypeId]: ReadonlyArray } -/** Durable comparison state for one admitted source. */ -export const SourceSnapshot = Schema.Struct({ +/** The value last applied to the model for one admitted source. */ +export const AppliedSource = Schema.Struct({ value: Schema.Json, removed: Schema.optional(Schema.NonEmptyString), }) -export type SourceSnapshot = typeof SourceSnapshot.Type +export type AppliedSource = typeof AppliedSource.Type -/** Durable structured comparison state for one active context generation. */ -export const Snapshot = Schema.Record(Key, SourceSnapshot) -export type Snapshot = Readonly> +/** Durable record of what the model currently believes, per source. */ +export const Applied = Schema.Record(Key, AppliedSource) +export type Applied = Readonly> -export interface Generation { - readonly baseline: string - readonly snapshot: Snapshot +/** A rendered baseline together with the applied values it was rendered from. */ +export interface Baseline { + readonly text: string + readonly applied: Applied } export interface Updated { readonly _tag: "Updated" readonly text: string - readonly snapshot: Snapshot + readonly applied: Applied } -export interface ReplacementReady { - readonly _tag: "ReplacementReady" - readonly generation: Generation -} - -export interface ReplacementBlocked { - readonly _tag: "ReplacementBlocked" -} - -export type ReplacementResult = ReplacementReady | ReplacementBlocked -export type ReconcileResult = { readonly _tag: "Unchanged" } | Updated | ReplacementResult +export type ReconcileResult = { readonly _tag: "Unchanged" } | Updated export class InitializationBlocked extends Schema.TaggedErrorClass()( "SystemContext.InitializationBlocked", @@ -98,36 +94,24 @@ export class DuplicateKeyError extends Schema.TaggedErrorClass + readonly load: Effect.Effect + /** Restates the model's belief from a last-applied value when the source cannot be observed. */ + readonly recall: (stored: AppliedSource) => string | undefined } -interface Loaded { - readonly baseline: () => Rendered - readonly compare: (previous: Schema.Json) => Compared +interface Observed { + readonly applied: AppliedSource + readonly baseline: () => string + /** `undefined` means unchanged. An undecodable previous value re-renders the baseline (treat-as-new). */ + readonly update: (previous: AppliedSource) => string | undefined } -interface Rendered { - readonly text: string - readonly snapshot: SourceSnapshot -} - -type Compared = - | { readonly _tag: "Incompatible" } - | { readonly _tag: "Unchanged" } - | { readonly _tag: "Updated"; readonly render: () => Rendered } - -interface AvailableEntry extends Loaded { - readonly _tag: "Available" +interface Entry { readonly key: Key + readonly recall: PackedSource["recall"] + readonly observed: Observed | Unavailable } -interface UnavailableEntry { - readonly _tag: "Unavailable" - readonly key: Key -} - -type Entry = AvailableEntry | UnavailableEntry - /** The identity context. */ export const empty = context([]) @@ -136,42 +120,65 @@ export function make(source: Source): SystemContext { const decode = Schema.decodeUnknownOption(source.codec) const encode = Schema.encodeSync(source.codec) const equivalent = Schema.toEquivalence(source.codec) + const baseline = (value: A) => requireText(source.key, "baseline", source.baseline(value)) return context([ { key: source.key, + recall: (stored) => + Option.match(decode(stored.value), { + onNone: () => undefined, + onSome: baseline, + }), load: source.load.pipe( Effect.map((value) => { if (isUnavailable(value)) return value - const snapshot = (): SourceSnapshot => ({ - value: encode(value), - ...(source.removed ? { removed: requireText(source.key, "removal", source.removed(value)) } : {}), - }) return { - baseline: (): Rendered => ({ - text: requireText(source.key, "baseline", source.baseline(value)), - snapshot: snapshot(), - }), - compare: (previous): Compared => - Option.match(decode(previous), { - onNone: (): Compared => ({ _tag: "Incompatible" }), - onSome: (decoded): Compared => + applied: { + value: encode(value), + ...(source.removed ? { removed: requireText(source.key, "removal", source.removed(value)) } : {}), + }, + baseline: () => baseline(value), + update: (previous) => + Option.match(decode(previous.value), { + onNone: () => baseline(value), + onSome: (decoded) => equivalent(decoded, value) - ? { _tag: "Unchanged" } - : { - _tag: "Updated", - render: () => ({ - text: requireText(source.key, "update", source.update(decoded, value)), - snapshot: snapshot(), - }), - }, + ? undefined + : requireText(source.key, "update", source.update(decoded, value)), }), - } + } satisfies Observed }), ), }, ]) } +/** + * Keyed three-way diff for list-shaped sources rendering delta updates. + * `changed` compares two values sharing a key; entries equal under it are dropped. + */ +export function diffByKey( + previous: ReadonlyArray, + current: ReadonlyArray, + key: (value: A) => string, + changed: (previous: A, current: A) => boolean, +): { + readonly added: ReadonlyArray + readonly removed: ReadonlyArray + readonly changed: ReadonlyArray<{ readonly previous: A; readonly current: A }> +} { + const currentKeys = new Set(current.map(key)) + const previousByKey = new Map(previous.map((value) => [key(value), value] as const)) + return { + added: current.filter((value) => !previousByKey.has(key(value))), + removed: previous.filter((value) => !currentKeys.has(key(value))), + changed: current.flatMap((value) => { + const before = previousByKey.get(key(value)) + return before === undefined || !changed(before, value) ? [] : [{ previous: before, current: value }] + }), + } +} + /** Combines contexts in order and rejects duplicate source keys immediately. */ export function combine(values: ReadonlyArray): SystemContext { const sources = values.flatMap((value) => value[ContextTypeId]) @@ -183,111 +190,91 @@ const observe = (value: SystemContext) => Effect.forEach( value[ContextTypeId], (source) => - source.load.pipe( - Effect.map( - (result): Entry => - result === unavailable - ? { _tag: "Unavailable", key: source.key } - : { _tag: "Available", key: source.key, ...result }, - ), - ), + source.load.pipe(Effect.map((observed): Entry => ({ key: source.key, recall: source.recall, observed }))), { concurrency: "unbounded" }, ) -/** Creates the immutable baseline and durable snapshot for a new generation. */ -export function initialize(value: SystemContext): Effect.Effect { +/** Creates the first baseline. Blocks rather than admit a baseline missing an unobservable source. */ +export function initialize(value: SystemContext): Effect.Effect { return observe(value).pipe( Effect.flatMap((entries) => { - const unavailable = entries.flatMap((entry) => (entry._tag === "Unavailable" ? [entry.key] : [])) - if (unavailable.length > 0) return new InitializationBlocked({ keys: unavailable }) - return Effect.succeed(initializeObservation(entries)) + const blocked = entries.flatMap((entry) => (entry.observed === unavailable ? [entry.key] : [])) + if (blocked.length > 0) return new InitializationBlocked({ keys: blocked }) + const parts: string[] = [] + const applied: Record = {} + for (const entry of entries) { + if (entry.observed === unavailable) continue + parts.push(entry.observed.baseline()) + applied[entry.key] = entry.observed.applied + } + return Effect.succeed({ text: render(parts), applied }) }), ) } -function initializeObservation(entries: ReadonlyArray): Generation { - const available = entries.filter((entry): entry is AvailableEntry => entry._tag === "Available") - const rendered = available.map((entry) => [entry.key, entry.baseline()] as const) - return { - baseline: render(rendered.map(([, result]) => result.text)), - snapshot: Object.fromEntries(rendered.map(([key, result]) => [key, result.snapshot])), - } -} - -/** Reconciles current source values with one active generation. */ -export function reconcile(value: SystemContext, previous: Snapshot): Effect.Effect { +/** Narrates drift between current source values and the model's beliefs. Never rewrites the baseline. */ +export function reconcile(value: SystemContext, previous: Applied): Effect.Effect { return observe(value).pipe( Effect.map((entries): ReconcileResult => { - const result = reconcileObservation(entries, previous) - if (result._tag === "Unchanged" || result._tag === "Updated") return result - return replaceObservation(entries, previous) + const updates: string[] = [] + const applied: Record = {} + for (const entry of entries) { + const stored = get(previous, entry.key) + if (entry.observed === unavailable) { + // The prior belief stands while the source cannot be observed. + if (stored) applied[entry.key] = stored + continue + } + if (!stored) { + updates.push(entry.observed.baseline()) + applied[entry.key] = entry.observed.applied + continue + } + const text = entry.observed.update(stored) + if (text === undefined) { + applied[entry.key] = stored + continue + } + updates.push(text) + applied[entry.key] = entry.observed.applied + } + const keys = new Set(entries.map((entry) => entry.key)) + for (const key of Object.keys(previous).sort()) { + if (keys.has(key)) continue + const removed = previous[key].removed + // An unannounced removal retains the belief; it clears at the next rebaseline. + if (removed === undefined) applied[key] = previous[key] + else updates.push(removed) + } + if (updates.length === 0) return { _tag: "Unchanged" } + return { _tag: "Updated", text: render(updates), applied } }), ) } -function reconcileObservation( - entries: ReadonlyArray, - previous: Snapshot, -): { readonly _tag: "Unchanged" } | Updated | { readonly _tag: "Replace" } { - const keys = new Set(entries.map((entry) => entry.key)) - const comparisons = new Map() - for (const entry of entries) { - if (entry._tag === "Unavailable") continue - const stored = getSnapshot(previous, entry.key) - if (!stored) continue - const compared = entry.compare(stored.value) - if (compared._tag === "Incompatible") return { _tag: "Replace" } - comparisons.set(entry.key, compared) - } - for (const key of Object.keys(previous).sort()) { - if (keys.has(Key.make(key))) continue - if (previous[key].removed === undefined) return { _tag: "Replace" } - } - - const snapshot: Record = {} - const updates: string[] = [] - for (const entry of entries) { - const stored = getSnapshot(previous, entry.key) - if (entry._tag === "Unavailable") { - if (stored) snapshot[entry.key] = stored - continue - } - if (!stored) { - const rendered = entry.baseline() - updates.push(rendered.text) - snapshot[entry.key] = rendered.snapshot - continue - } - const compared = comparisons.get(entry.key) - if (!compared || compared._tag === "Incompatible") - throw new Error(`Missing comparison for system context source ${entry.key}`) - if (compared._tag === "Unchanged") { - snapshot[entry.key] = stored - continue - } - const rendered = compared.render() - updates.push(rendered.text) - snapshot[entry.key] = rendered.snapshot - } - for (const key of Object.keys(previous).sort()) { - if (keys.has(Key.make(key))) continue - const removed = previous[key].removed - if (removed === undefined) throw new Error(`Missing removal rendering for system context source ${key}`) - updates.push(removed) - } - if (updates.length === 0) return { _tag: "Unchanged" } - return { _tag: "Updated", text: render(updates), snapshot } -} - -/** Creates a complete replacement generation or blocks while admitted context is unavailable. */ -export function replace(value: SystemContext, previous: Snapshot): Effect.Effect { - return observe(value).pipe(Effect.map((entries) => replaceObservation(entries, previous))) -} - -function replaceObservation(entries: ReadonlyArray, previous: Snapshot): ReplacementResult { - if (entries.some((entry) => entry._tag === "Unavailable" && getSnapshot(previous, entry.key) !== undefined)) - return { _tag: "ReplacementBlocked" } - return { _tag: "ReplacementReady", generation: initializeObservation(entries) } +/** Rebuilds the baseline, restating unobservable sources from the model's last-applied beliefs. */ +export function rebaseline(value: SystemContext, previous: Applied): Effect.Effect { + return observe(value).pipe( + Effect.map((entries): Baseline => { + const parts: string[] = [] + const applied: Record = {} + for (const entry of entries) { + if (entry.observed !== unavailable) { + parts.push(entry.observed.baseline()) + applied[entry.key] = entry.observed.applied + continue + } + const stored = get(previous, entry.key) + if (!stored) continue + const text = entry.recall(stored) + // An undecodable belief cannot be restated; the source re-announces when observable again. + if (text === undefined) continue + parts.push(text) + applied[entry.key] = stored + } + return { text: render(parts), applied } + }), + ) } function context(sources: ReadonlyArray): SystemContext { @@ -298,8 +285,8 @@ function render(parts: ReadonlyArray) { return parts.join("\n\n") } -function getSnapshot(snapshot: Snapshot, key: Key) { - return Object.hasOwn(snapshot, key) ? snapshot[key] : undefined +function get(applied: Applied, key: Key) { + return Object.hasOwn(applied, key) ? applied[key] : undefined } function isUnavailable(value: unknown): value is Unavailable { diff --git a/packages/core/src/system-context/registry.ts b/packages/core/src/system-context/registry.ts deleted file mode 100644 index c1e7ca5e85..0000000000 --- a/packages/core/src/system-context/registry.ts +++ /dev/null @@ -1,49 +0,0 @@ -export * as SystemContextRegistry from "./registry" - -import { Context, Effect, Layer, Ref, Scope } from "effect" -import { SystemContext } from "./index" -import { makeLocationNode } from "../effect/app-node" - -export interface Entry { - readonly key: SystemContext.Key - readonly load: Effect.Effect -} - -export interface Interface { - readonly register: (entry: Entry) => Effect.Effect - readonly load: () => Effect.Effect -} - -export class Service extends Context.Service()("@opencode/v2/SystemContextRegistry") {} - -const layer = Layer.effect( - Service, - Effect.gen(function* () { - const entries = yield* Ref.make>([]) - - return Service.of({ - register: Effect.fn("SystemContextRegistry.register")(function* (entry) { - yield* Effect.acquireRelease( - Ref.modify(entries, (current) => { - if (current.some((item) => item.key === entry.key)) return [false, current] - return [true, [...current, entry]] - }).pipe( - Effect.flatMap((added) => - added ? Effect.void : Effect.die(`Duplicate system context entry key: ${entry.key}`), - ), - Effect.as(entry), - ), - (entry) => Ref.update(entries, (current) => current.filter((item) => item !== entry)), - ) - }), - load: Effect.fn("SystemContextRegistry.load")(function* () { - const current = (yield* Ref.get(entries)).toSorted((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)) - return SystemContext.combine( - yield* Effect.forEach(current, (entry) => entry.load, { concurrency: "unbounded" }), - ) - }), - }) - }), -) - -export const node = makeLocationNode({ service: Service, layer, deps: [] }) diff --git a/packages/core/src/tool/builtins.ts b/packages/core/src/tool/builtins.ts index 65473d1620..c72d07d0e7 100644 --- a/packages/core/src/tool/builtins.ts +++ b/packages/core/src/tool/builtins.ts @@ -4,7 +4,6 @@ import { makeLocationNode } from "../effect/app-node" import { Context, Layer } from "effect" import { ApplyPatchTool } from "./apply-patch" import { EditTool } from "./edit" -import { GlobTool } from "./glob" import { GrepTool } from "./grep" import { QuestionTool } from "./question" import { ReadTool } from "./read" @@ -38,7 +37,6 @@ export const node = makeLocationNode({ deps: [ ApplyPatchTool.node, EditTool.node, - GlobTool.node, GrepTool.node, QuestionTool.node, ReadTool.node, diff --git a/packages/core/src/tool/glob.ts b/packages/core/src/tool/glob.ts index f8bd1869e1..d4412ae302 100644 --- a/packages/core/src/tool/glob.ts +++ b/packages/core/src/tool/glob.ts @@ -1,17 +1,15 @@ export * as GlobTool from "./glob" import { ToolFailure } from "@opencode-ai/llm" -import { Effect, Layer, Schema } from "effect" +import type { PluginContext } from "@opencode-ai/plugin/v2/effect" +import { Effect, Schema } from "effect" import path from "path" -import { makeLocationNode } from "../effect/app-node" import { FileSystem } from "../filesystem" import { Location } from "../location" import { Ripgrep } from "../ripgrep" import { RelativePath } from "../schema" import { PermissionV2 } from "../permission" -import { ToolRegistry } from "./registry" import { Tool } from "./tool" -import { Tools } from "./tools" export const name = "glob" @@ -35,14 +33,14 @@ export const toModelOutput = (output: ModelOutput) => { } /** Glob leaf that defaults its filesystem root to the active Location. */ -const layer = Layer.effectDiscard( - Effect.gen(function* () { - const tools = yield* Tools.Service +export const Plugin = { + id: "core-glob-tool", + effect: Effect.fn("GlobTool.Plugin")(function* (ctx: PluginContext) { const ripgrep = yield* Ripgrep.Service const location = yield* Location.Service const permission = yield* PermissionV2.Service - yield* tools + yield* ctx.tool .register({ [name]: Tool.make({ description: @@ -96,10 +94,4 @@ const layer = Layer.effectDiscard( }) .pipe(Effect.orDie) }), -) - -export const node = makeLocationNode({ - name: "tool/glob", - layer, - deps: [ToolRegistry.node, Ripgrep.node, Location.node, PermissionV2.node], -}) +} diff --git a/packages/core/test/command.test.ts b/packages/core/test/command.test.ts index 3da8523c5b..72c3dda2c1 100644 --- a/packages/core/test/command.test.ts +++ b/packages/core/test/command.test.ts @@ -1,12 +1,22 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" import { CommandV2 } from "@opencode-ai/core/command" +import { Config } from "@opencode-ai/core/config" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { Location } from "@opencode-ai/core/location" +import { MCP } from "@opencode-ai/core/mcp/index" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" +import { emptyConfigLayer, emptyMcpLayer, testLocationLayer } from "./fixture/mcp" import { testEffect } from "./lib/effect" -const it = testEffect(AppNodeBuilder.build(CommandV2.node)) +const it = testEffect( + AppNodeBuilder.build(CommandV2.node, [ + [MCP.node, emptyMcpLayer], + [Config.node, emptyConfigLayer], + [Location.node, testLocationLayer], + ]), +) describe("CommandV2", () => { it.effect("applies command transforms and preserves later overrides", () => @@ -53,4 +63,18 @@ describe("CommandV2", () => { ]) }), ) + + it.effect("evaluates command template shell blocks", () => + Effect.gen(function* () { + const command = yield* CommandV2.Service + yield* command.transform((editor) => { + editor.update("review", (command) => { + command.template = "Output: !`echo command-output`" + }) + }) + + expect((yield* command.evaluate({ name: "review" })).text.replace(/\r?\n$/, "")).toEqual("Output: command-output") + }), + ) + }) diff --git a/packages/core/test/config/agent.test.ts b/packages/core/test/config/agent.test.ts index b94b66f50d..7f1d7ff053 100644 --- a/packages/core/test/config/agent.test.ts +++ b/packages/core/test/config/agent.test.ts @@ -173,6 +173,7 @@ describe("ConfigAgentPlugin.Plugin", () => { model: { providerID: "anthropic", id: "claude-sonnet", variant: undefined }, }) expect(reviewer.request).toEqual({ + settings: {}, headers: { first: "one", shared: "last", second: "two" }, body: { enabled: true, profile: "review", retries: 2, effort: "high" }, }) diff --git a/packages/core/test/config/command.test.ts b/packages/core/test/config/command.test.ts index f5a08aab63..362a8e5da5 100644 --- a/packages/core/test/config/command.test.ts +++ b/packages/core/test/config/command.test.ts @@ -8,14 +8,23 @@ import { ConfigCommandPlugin } from "@opencode-ai/core/config/plugin/command" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FSUtil } from "@opencode-ai/core/fs-util" +import { Location } from "@opencode-ai/core/location" +import { MCP } from "@opencode-ai/core/mcp/index" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" +import { emptyConfigLayer, emptyMcpLayer, testLocationLayer } from "../fixture/mcp" import { tmpdir } from "../fixture/tmpdir" import { testEffect } from "../lib/effect" import { host } from "../plugin/host" -const it = testEffect(AppNodeBuilder.build(LayerNode.group([CommandV2.node, FSUtil.node]))) +const it = testEffect( + AppNodeBuilder.build(LayerNode.group([CommandV2.node, FSUtil.node]), [ + [MCP.node, emptyMcpLayer], + [Config.node, emptyConfigLayer], + [Location.node, testLocationLayer], + ]), +) const decode = Schema.decodeUnknownSync(Config.Info) describe("ConfigCommandPlugin.Plugin", () => { diff --git a/packages/core/test/fixture/mcp.ts b/packages/core/test/fixture/mcp.ts new file mode 100644 index 0000000000..2c4d1f86a6 --- /dev/null +++ b/packages/core/test/fixture/mcp.ts @@ -0,0 +1,30 @@ +import { Effect, Layer } from "effect" +import { Config } from "@opencode-ai/core/config" +import { Location } from "@opencode-ai/core/location" +import { MCP } from "@opencode-ai/core/mcp/index" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { location } from "./location" + +export const emptyMcpLayer = Layer.succeed( + MCP.Service, + MCP.Service.of({ + servers: () => Effect.succeed([]), + tools: () => Effect.succeed([]), + callTool: () => Effect.die("unused mcp.callTool"), + instructions: () => Effect.succeed([]), + prompts: () => Effect.succeed([]), + prompt: () => Effect.succeed(undefined), + resourceCatalog: () => Effect.succeed(new MCP.ResourceCatalog({ resources: [], templates: [] })), + readResource: () => Effect.succeed(undefined), + }), +) + +export const emptyConfigLayer = Layer.succeed( + Config.Service, + Config.Service.of({ entries: () => Effect.succeed([]) }), +) + +export const testLocationLayer = Layer.succeed( + Location.Service, + Location.Service.of(location({ directory: AbsolutePath.make(process.cwd()) })), +) diff --git a/packages/core/test/instruction-context.test.ts b/packages/core/test/instruction-context.test.ts index 0a61ede3f0..8bd04d305a 100644 --- a/packages/core/test/instruction-context.test.ts +++ b/packages/core/test/instruction-context.test.ts @@ -10,7 +10,6 @@ import { InstructionContext } from "@opencode-ai/core/instruction-context" import { Location } from "@opencode-ai/core/location" import { AbsolutePath } from "@opencode-ai/core/schema" import { SystemContext } from "@opencode-ai/core/system-context" -import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry" import { location } from "./fixture/location" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" @@ -22,7 +21,7 @@ const instructionLayer = (input: { locationServiceLayer: Layer.Layer filesystemLayer?: Layer.Layer }) => - AppNodeBuilder.build(LayerNode.group([SystemContextRegistry.node, InstructionContext.node]), [ + AppNodeBuilder.build(InstructionContext.node, [ [Global.node, Global.layerWith({ config: input.config })], [Location.node, input.locationServiceLayer], ...(input.filesystemLayer ? [[FSUtil.node, input.filesystemLayer] as const] : []), @@ -52,7 +51,7 @@ describe("InstructionContext", () => { await fs.writeFile(packageFile, "package") }) - const load = SystemContextRegistry.Service.pipe( + const load = InstructionContext.Service.pipe( Effect.flatMap((service) => service.load()), Effect.provide( instructionLayer({ @@ -71,23 +70,23 @@ describe("InstructionContext", () => { ) const initialized = yield* SystemContext.initialize(yield* load) - expect(initialized.baseline).toBe( + expect(initialized.text).toBe( [ `Instructions from: ${globalFile}\nglobal`, `Instructions from: ${packageFile}\npackage`, `Instructions from: ${projectFile}\nproject`, ].join("\n\n"), ) - expect(initialized.baseline).not.toContain("outside") + expect(initialized.text).not.toContain("outside") yield* Effect.promise(() => fs.writeFile(packageFile, "changed")) - expect(yield* SystemContext.reconcile(yield* load, initialized.snapshot)).toMatchObject({ + expect(yield* SystemContext.reconcile(yield* load, initialized.applied)).toMatchObject({ _tag: "Updated", text: expect.stringContaining(`Instructions from: ${packageFile}\nchanged`), }) yield* Effect.promise(() => fs.rm(packageFile)) - const partial = yield* SystemContext.reconcile(yield* load, initialized.snapshot) + const partial = yield* SystemContext.reconcile(yield* load, initialized.applied) expect(partial).toEqual({ _tag: "Updated", text: [ @@ -95,14 +94,14 @@ describe("InstructionContext", () => { `Instructions from: ${globalFile}\nglobal`, `Instructions from: ${projectFile}\nproject`, ].join("\n\n"), - snapshot: expect.any(Object), + applied: expect.any(Object), }) yield* Effect.promise(() => Promise.all([fs.rm(globalFile), fs.rm(projectFile)])) - expect(yield* SystemContext.reconcile(yield* load, initialized.snapshot)).toEqual({ + expect(yield* SystemContext.reconcile(yield* load, initialized.applied)).toEqual({ _tag: "Updated", text: "Previously loaded instructions no longer apply.", - snapshot: {}, + applied: {}, }) }), ), @@ -118,7 +117,7 @@ describe("InstructionContext", () => { Effect.gen(function* () { const file = path.join(tmp.path, "AGENTS.md") yield* Effect.promise(() => fs.writeFile(file, "")) - const context = yield* SystemContextRegistry.Service.pipe( + const context = yield* InstructionContext.Service.pipe( Effect.flatMap((service) => service.load()), Effect.provide( instructionLayer({ @@ -131,7 +130,7 @@ describe("InstructionContext", () => { ), ) - expect((yield* SystemContext.initialize(context)).baseline).toBe(`Instructions from: ${file}\n`) + expect((yield* SystemContext.initialize(context)).text).toBe(`Instructions from: ${file}\n`) }), ), ), @@ -147,7 +146,7 @@ describe("InstructionContext", () => { ), ), ).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) - const context = yield* SystemContextRegistry.Service.pipe( + const context = yield* InstructionContext.Service.pipe( Effect.flatMap((service) => service.load()), Effect.provide( instructionLayer({ @@ -187,7 +186,7 @@ describe("InstructionContext", () => { ), ), ).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) - const context = yield* SystemContextRegistry.Service.pipe( + const context = yield* InstructionContext.Service.pipe( Effect.flatMap((service) => service.load()), Effect.provide( instructionLayer({ @@ -231,7 +230,7 @@ describe("InstructionContext", () => { ), ).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) - yield* SystemContextRegistry.Service.pipe( + yield* InstructionContext.Service.pipe( Effect.flatMap((service) => service.load()), Effect.provide( instructionLayer({ @@ -261,7 +260,7 @@ describe("InstructionContext", () => { let scanned = false process.env.OPENCODE_DISABLE_PROJECT_CONFIG = "1" - yield* SystemContextRegistry.Service.pipe( + yield* InstructionContext.Service.pipe( Effect.flatMap((service) => service.load()), Effect.provide( instructionLayer({ @@ -293,7 +292,7 @@ describe("InstructionContext", () => { it.effect("does not discover project instructions outside the canonical project root", () => Effect.gen(function* () { let scanned = false - yield* SystemContextRegistry.Service.pipe( + yield* InstructionContext.Service.pipe( Effect.flatMap((service) => service.load()), Effect.provide( instructionLayer({ diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index f1fa19864e..2b5bd55c52 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -24,9 +24,7 @@ import { EventV2 } from "../src/event" import { Reference } from "../src/reference" import { ToolRegistry } from "../src/tool/registry" -const it = testEffect( - AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, LocationServiceMap.node])), -) +const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, LocationServiceMap.node]))) describe("LocationServiceMap", () => { it.live("reuses cached services for constructed and decoded location refs", () => @@ -75,6 +73,7 @@ describe("LocationServiceMap", () => { const catalog = yield* Catalog.Service yield* catalog.transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {})) const registry = yield* ToolRegistry.Service + yield* waitForTool(registry, "glob") yield* waitForTool(registry, "shell") yield* waitForTool(registry, "subagent") return { diff --git a/packages/core/test/plugin/command.test.ts b/packages/core/test/plugin/command.test.ts index d163cd903a..be84975f64 100644 --- a/packages/core/test/plugin/command.test.ts +++ b/packages/core/test/plugin/command.test.ts @@ -40,7 +40,6 @@ describe("CommandPlugin.Plugin", () => { expect(yield* command.get("review")).toMatchObject({ name: "review", description: "review changes [commit|branch|pr], defaults to uncommitted", - subtask: true, }) }), ) diff --git a/packages/core/test/plugin/fixtures/models-dev-reasoning.json b/packages/core/test/plugin/fixtures/models-dev-reasoning.json new file mode 100644 index 0000000000..410b7c2572 --- /dev/null +++ b/packages/core/test/plugin/fixtures/models-dev-reasoning.json @@ -0,0 +1,67 @@ +{ + "openai": { + "id": "openai", + "name": "OpenAI", + "env": ["OPENAI_API_KEY"], + "npm": "@ai-sdk/openai", + "api": "https://api.openai.com/v1", + "models": { + "gpt-reasoning": { + "id": "gpt-reasoning", + "name": "GPT Reasoning", + "release_date": "2026-01-01", + "attachment": false, + "reasoning": true, + "reasoning_options": [ + { "type": "effort", "values": ["low", "high"] }, + { "type": "budget_tokens", "min": 1024, "max": 64000 }, + { "type": "toggle" } + ], + "temperature": true, + "tool_call": true, + "limit": { "context": 128000, "output": 8192 }, + "experimental": { + "modes": { + "high": { + "provider": { + "headers": { "x-mode": "high" }, + "body": { "service_tier": "priority" } + } + } + } + } + } + } + }, + "anthropic": { + "id": "anthropic", + "name": "Anthropic", + "env": ["ANTHROPIC_API_KEY"], + "npm": "@ai-sdk/anthropic", + "api": "https://api.anthropic.com/v1", + "models": { + "claude-budget": { + "id": "claude-budget", + "name": "Claude Budget", + "release_date": "2026-01-01", + "attachment": false, + "reasoning": true, + "reasoning_options": [{ "type": "budget_tokens", "min": 1024, "max": 64000 }], + "temperature": true, + "tool_call": true, + "limit": { "context": 128000, "output": 8192 } + }, + "claude-effort": { + "id": "claude-effort", + "name": "Claude Effort", + "release_date": "2026-01-01", + "attachment": false, + "reasoning": true, + "reasoning_options": [{ "type": "effort", "values": ["low"] }], + "temperature": true, + "tool_call": true, + "limit": { "context": 128000, "output": 8192 } + } + } + } +} diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index e572d9b624..b9fc03b9c9 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -61,6 +61,7 @@ export function host(overrides: Overrides = {}): PluginContext { create: () => Effect.die("unused session.create"), get: () => Effect.die("unused session.get"), prompt: () => Effect.die("unused session.prompt"), + command: () => Effect.die("unused session.command"), interrupt: () => Effect.die("unused session.interrupt"), }, } @@ -279,7 +280,11 @@ function agentInfo(value: AgentV2.Info) { return { ...value, model: value.model && { ...value.model }, - request: { headers: { ...value.request.headers }, body: { ...value.request.body } }, + request: { + settings: { ...value.request.settings }, + headers: { ...value.request.headers }, + body: { ...value.request.body }, + }, permissions: value.permissions.map((permission) => ({ ...permission })), } } @@ -288,7 +293,11 @@ function providerInfo(value: ProviderV2.MutableInfo) { return { ...value, api: { ...value.api, settings: value.api.settings && { ...value.api.settings } }, - request: { headers: { ...value.request.headers }, body: { ...value.request.body } }, + request: { + settings: { ...value.request.settings }, + headers: { ...value.request.headers }, + body: { ...value.request.body }, + }, } } @@ -303,11 +312,13 @@ function modelInfo(value: ModelV2.Info | ModelV2.MutableInfo) { }, request: { ...value.request, + settings: { ...value.request.settings }, headers: { ...value.request.headers }, body: { ...value.request.body }, }, variants: value.variants.map((variant) => ({ ...variant, + settings: { ...variant.settings }, headers: { ...variant.headers }, body: { ...variant.body }, })), diff --git a/packages/core/test/plugin/models-dev.test.ts b/packages/core/test/plugin/models-dev.test.ts index 90ef94f312..b3dfe275ba 100644 --- a/packages/core/test/plugin/models-dev.test.ts +++ b/packages/core/test/plugin/models-dev.test.ts @@ -168,14 +168,14 @@ describe("ModelsDevPlugin", () => { ), ) - it.effect("derives OpenAI reasoning variants from models.dev reasoning options", () => + it.effect("converts reasoning options into settings variants", () => Effect.acquireUseRelease( Effect.sync(() => { const previous = { path: Flag.OPENCODE_MODELS_PATH, disabled: Flag.OPENCODE_DISABLE_MODELS_FETCH, } - Flag.OPENCODE_MODELS_PATH = path.join(import.meta.dir, "fixtures", "models-dev.json") + Flag.OPENCODE_MODELS_PATH = path.join(import.meta.dir, "fixtures", "models-dev-reasoning.json") Flag.OPENCODE_DISABLE_MODELS_FETCH = true return previous }), @@ -183,17 +183,6 @@ describe("ModelsDevPlugin", () => { Effect.gen(function* () { const catalog = yield* Catalog.Service const integrations = yield* Integration.Service - yield* catalog.transform((catalog) => { - catalog.model.update(ProviderV2.ID.opencode, ModelV2.ID.make("gpt-5.5"), (model) => { - model.variants = [ - { - id: ModelV2.VariantID.make("high"), - headers: { custom: "true" }, - body: { custom: true }, - }, - ] - }) - }) yield* ModelsDevPlugin.effect( host({ catalog: catalogHost(catalog), @@ -201,42 +190,67 @@ describe("ModelsDevPlugin", () => { }), ) - expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("gpt-5.5")))?.variants).toEqual([ - { - id: ModelV2.VariantID.make("none"), - headers: {}, - body: { - include: ["reasoning.encrypted_content"], - reasoning: { effort: "none", summary: "auto" }, - }, - }, - expect.objectContaining({ - id: "low", - body: { - include: ["reasoning.encrypted_content"], - reasoning: { effort: "low", summary: "auto" }, - }, - }), - expect.objectContaining({ - id: "medium", - body: { - include: ["reasoning.encrypted_content"], - reasoning: { effort: "medium", summary: "auto" }, - }, - }), - expect.objectContaining({ - id: "high", - headers: { custom: "true" }, - body: { custom: true }, - }), - expect.objectContaining({ - id: "xhigh", - body: { - include: ["reasoning.encrypted_content"], - reasoning: { effort: "xhigh", summary: "auto" }, - }, - }), + const model = yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-reasoning")) + expect(model?.variants.map((variant) => variant.id)).toEqual([ + ModelV2.VariantID.make("low"), + ModelV2.VariantID.make("high"), ]) + expect(model?.variants).toContainEqual({ + id: ModelV2.VariantID.make("low"), + settings: { + reasoningEffort: "low", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }, + headers: {}, + body: {}, + }) + expect(model?.variants).toContainEqual({ + id: ModelV2.VariantID.make("high"), + settings: { + reasoningEffort: "high", + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }, + headers: {}, + body: {}, + }) + + const mode = yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-reasoning-high")) + expect(mode).toMatchObject({ + id: "gpt-reasoning-high", + name: "GPT Reasoning High", + request: { + headers: { "x-mode": "high" }, + body: { service_tier: "priority" }, + }, + }) + expect(mode?.variants.map((variant) => variant.id)).toEqual([ + ModelV2.VariantID.make("low"), + ModelV2.VariantID.make("high"), + ]) + + const budgetModel = yield* catalog.model.get(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-budget")) + expect(budgetModel?.variants).toContainEqual({ + id: ModelV2.VariantID.make("high"), + settings: { thinking: { type: "enabled", budgetTokens: 16000 } }, + headers: {}, + body: {}, + }) + expect(budgetModel?.variants).toContainEqual({ + id: ModelV2.VariantID.make("max"), + settings: { thinking: { type: "enabled", budgetTokens: 64000 } }, + headers: {}, + body: {}, + }) + + const anthropicEffortModel = yield* catalog.model.get(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-effort")) + expect(anthropicEffortModel?.variants).toContainEqual({ + id: ModelV2.VariantID.make("low"), + settings: { thinking: { type: "adaptive", display: "summarized" }, effort: "low" }, + headers: {}, + body: {}, + }) }).pipe(Effect.provide(AppNodeBuilder.build(ModelsDev.node))), (previous) => Effect.sync(() => { @@ -245,5 +259,4 @@ describe("ModelsDevPlugin", () => { }), ), ) - }) diff --git a/packages/core/test/plugin/provider-amazon-bedrock.test.ts b/packages/core/test/plugin/provider-amazon-bedrock.test.ts index 5d24879e49..31065c2e3d 100644 --- a/packages/core/test/plugin/provider-amazon-bedrock.test.ts +++ b/packages/core/test/plugin/provider-amazon-bedrock.test.ts @@ -93,7 +93,7 @@ describe("AmazonBedrockPlugin", () => { }) catalog.provider.update(bedrock.id, (item) => { item.api = bedrock.api - item.request = bedrock.request + item.request = { settings: {}, headers: {}, body: { endpoint: "https://bedrock.example" } } }) }) yield* addPlugin() diff --git a/packages/core/test/plugin/provider-anthropic.test.ts b/packages/core/test/plugin/provider-anthropic.test.ts index a4ad4d7950..df05a07f75 100644 --- a/packages/core/test/plugin/provider-anthropic.test.ts +++ b/packages/core/test/plugin/provider-anthropic.test.ts @@ -36,7 +36,7 @@ describe("AnthropicPlugin", () => { }) catalog.provider.update(item.id, (draft) => { draft.api = item.api - draft.request = item.request + draft.request = { settings: {}, headers: { Existing: "1" }, body: {} } }) }) yield* addPlugin() diff --git a/packages/core/test/plugin/provider-azure.test.ts b/packages/core/test/plugin/provider-azure.test.ts index 1d8172854c..42af292e15 100644 --- a/packages/core/test/plugin/provider-azure.test.ts +++ b/packages/core/test/plugin/provider-azure.test.ts @@ -87,7 +87,7 @@ describe("AzurePlugin", () => { }) catalog.provider.update(azure.id, (item) => { item.api = azure.api - item.request = azure.request + item.request = { settings: {}, headers: {}, body: { resourceName: "from-config" } } }) catalog.provider.update(ProviderV2.ID.openai, () => {}) }) @@ -110,7 +110,7 @@ describe("AzurePlugin", () => { }) catalog.provider.update(azure.id, (item) => { item.api = azure.api - item.request = azure.request + item.request = { settings: {}, headers: {}, body: { resourceName: "" } } }) }) yield* addPlugin() @@ -131,7 +131,7 @@ describe("AzurePlugin", () => { }) catalog.provider.update(azure.id, (item) => { item.api = azure.api - item.request = azure.request + item.request = { settings: {}, headers: {}, body: { resourceName: " " } } }) }) yield* addPlugin() diff --git a/packages/core/test/plugin/provider-kilo.test.ts b/packages/core/test/plugin/provider-kilo.test.ts index 1df0fd3156..b34ceb2d5b 100644 --- a/packages/core/test/plugin/provider-kilo.test.ts +++ b/packages/core/test/plugin/provider-kilo.test.ts @@ -32,7 +32,7 @@ describe("KiloPlugin", () => { package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway", } - provider.request = { headers: { Existing: "value" }, body: {} } + provider.request = { settings: {}, headers: { Existing: "value" }, body: {} } }) catalog.provider.update(ProviderV2.ID.openrouter, () => {}) }) diff --git a/packages/core/test/plugin/provider-llmgateway.test.ts b/packages/core/test/plugin/provider-llmgateway.test.ts index d7f9d0d73d..5dce7cdcf7 100644 --- a/packages/core/test/plugin/provider-llmgateway.test.ts +++ b/packages/core/test/plugin/provider-llmgateway.test.ts @@ -39,7 +39,7 @@ describe("LLMGatewayPlugin", () => { package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1", } - provider.request = { headers: { Existing: "value" }, body: {} } + provider.request = { settings: {}, headers: { Existing: "value" }, body: {} } }) catalog.provider.update(ProviderV2.ID.openrouter, () => {}) }) diff --git a/packages/core/test/plugin/provider-nvidia.test.ts b/packages/core/test/plugin/provider-nvidia.test.ts index a1c05df335..260ffff689 100644 --- a/packages/core/test/plugin/provider-nvidia.test.ts +++ b/packages/core/test/plugin/provider-nvidia.test.ts @@ -32,7 +32,7 @@ describe("NvidiaPlugin", () => { package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1", } - provider.request = { headers: { Existing: "value" }, body: {} } + provider.request = { settings: {}, headers: { Existing: "value" }, body: {} } }) catalog.provider.update(ProviderV2.ID.openrouter, () => {}) }) @@ -80,6 +80,7 @@ describe("NvidiaPlugin", () => { url: "https://integrate.api.nvidia.com/v1", } provider.request = { + settings: {}, headers: { "X-BILLING-INVOKE-ORIGIN": "CustomOrigin" }, body: { baseURL: "https://integrate.api.nvidia.com/v1" }, } diff --git a/packages/core/test/plugin/provider-openai.test.ts b/packages/core/test/plugin/provider-openai.test.ts index 31a80f9319..44941a4aa1 100644 --- a/packages/core/test/plugin/provider-openai.test.ts +++ b/packages/core/test/plugin/provider-openai.test.ts @@ -3,6 +3,7 @@ import { describe, expect } from "bun:test" import type { LanguageModelV3 } from "@ai-sdk/provider" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" +import { Credential } from "@opencode-ai/core/credential" import { Integration } from "@opencode-ai/core/integration" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" @@ -27,6 +28,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 fakeSelectorSdk(calls: string[]) { const make = (method: string) => (id: string) => { calls.push(`${method}:${id}`) @@ -153,6 +168,80 @@ describe("OpenAIPlugin", () => { }), ) + it.effect("filters the OpenAI catalog to codex-eligible models under a ChatGPT connection", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const credentials = yield* Credential.Service + yield* catalog.transform((catalog) => { + const item = ProviderV2.Info.make({ + ...ProviderV2.Info.empty(ProviderV2.ID.openai), + api: { type: "aisdk", package: "@ai-sdk/openai" }, + }) + catalog.provider.update(item.id, (draft) => { + draft.api = item.api + }) + catalog.model.update(item.id, ModelV2.ID.make("gpt-5.5"), (model) => { + model.cost = [{ input: 1, output: 2, cache: { read: 0.1, write: 0 } }] + }) + catalog.model.update(item.id, ModelV2.ID.make("gpt-5.5-pro"), () => {}) + catalog.model.update(item.id, ModelV2.ID.make("gpt-4.1"), () => {}) + }) + yield* credentials.create({ + integrationID: Integration.ID.make("openai"), + value: Credential.OAuth.make({ + type: "oauth", + methodID: Integration.MethodID.make("chatgpt-browser"), + access: "chatgpt-token", + refresh: "refresh", + expires: Date.now() + 60_000, + metadata: { accountID: "acct_123" }, + }), + }) + yield* addPlugin() + + const eligible = required( + yield* eventually( + catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.5")), + (model) => model?.cost.length === 0, + ), + ) + expect(eligible.enabled).toBe(true) + expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.5-pro"))).enabled).toBe( + false, + ) + expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-4.1"))).enabled).toBe(false) + }), + ) + + it.effect("keeps the full OpenAI catalog under an API key connection", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const credentials = yield* Credential.Service + yield* catalog.transform((catalog) => { + const item = ProviderV2.Info.make({ + ...ProviderV2.Info.empty(ProviderV2.ID.openai), + api: { type: "aisdk", package: "@ai-sdk/openai" }, + }) + catalog.provider.update(item.id, (draft) => { + draft.api = item.api + }) + catalog.model.update(item.id, ModelV2.ID.make("gpt-5.5"), () => {}) + catalog.model.update(item.id, ModelV2.ID.make("gpt-4.1"), () => {}) + }) + yield* credentials.create({ + integrationID: Integration.ID.make("openai"), + value: Credential.Key.make({ type: "key", key: "sk-test" }), + }) + yield* addPlugin() + // The connection refresh is asynchronous; give it time to settle before + // asserting nothing was filtered. + yield* Effect.promise(() => Bun.sleep(25)) + + expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5.5"))).enabled).toBe(true) + expect(required(yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-4.1"))).enabled).toBe(true) + }), + ) + it.effect("does not disable gpt-5-chat-latest for non-OpenAI providers", () => Effect.gen(function* () { const catalog = yield* Catalog.Service diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index a3bc595ef7..d2b1f2bdae 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -142,6 +142,7 @@ describe("OpencodePlugin", () => { model.variants = [ { id: ModelV2.VariantID.make("custom"), + settings: {}, headers: { "x-custom": "true" }, body: { custom: true }, }, @@ -177,7 +178,7 @@ describe("OpencodePlugin", () => { url: `${server.url.origin}/v1`, }, }) - expect(provider.request).toEqual({ headers: { "x-org-id": "org" }, body: { custom: "value" } }) + expect(provider.request).toEqual({ settings: {}, headers: { "x-org-id": "org" }, body: { custom: "value" } }) expect(yield* (yield* Integration.Service).get(Integration.ID.make("remote"))).toBeUndefined() const model = required(yield* catalog.model.get(ProviderV2.ID.make("remote"), ModelV2.ID.make("model"))) @@ -192,11 +193,13 @@ describe("OpencodePlugin", () => { expect(model.variants).toEqual([ { id: ModelV2.VariantID.make("custom"), + settings: {}, headers: { "x-custom": "true" }, body: { custom: true }, }, { id: ModelV2.VariantID.make("high"), + settings: {}, headers: {}, body: { temperature: 0.2 }, }, @@ -359,6 +362,7 @@ describe("OpencodePlugin", () => { ...ProviderV2.Info.empty(ProviderV2.ID.opencode), api: { type: "aisdk", package: "test-provider" }, request: { + settings: {}, headers: {}, body: { apiKey: "configured" }, }, @@ -369,7 +373,7 @@ describe("OpencodePlugin", () => { cost: cost(1), }) catalog.provider.update(provider.id, (draft) => { - draft.request = provider.request + draft.request = { settings: {}, headers: {}, body: { apiKey: "configured" } } }) catalog.model.update(provider.id, model.id, (draft) => { draft.cost = [...model.cost] diff --git a/packages/core/test/plugin/provider-openrouter.test.ts b/packages/core/test/plugin/provider-openrouter.test.ts index 953943673f..d611f40363 100644 --- a/packages/core/test/plugin/provider-openrouter.test.ts +++ b/packages/core/test/plugin/provider-openrouter.test.ts @@ -31,7 +31,7 @@ describe("OpenRouterPlugin", () => { yield* catalog.transform((catalog) => { catalog.provider.update(ProviderV2.ID.openrouter, (provider) => { provider.api = { type: "aisdk", package: "@openrouter/ai-sdk-provider" } - provider.request = { headers: { Existing: "value" }, body: {} } + provider.request = { settings: {}, headers: { Existing: "value" }, body: {} } }) catalog.provider.update(ProviderV2.ID.make("nvidia"), () => {}) }) diff --git a/packages/core/test/plugin/variant.test.ts b/packages/core/test/plugin/variant.test.ts index 84af04a4df..61f392caf8 100644 --- a/packages/core/test/plugin/variant.test.ts +++ b/packages/core/test/plugin/variant.test.ts @@ -37,8 +37,8 @@ describe("VariantPlugin", () => { yield* VariantPlugin.Plugin.effect(host({ catalog: catalogHost(service) })) expect((yield* service.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("glm-5.2")))?.variants).toEqual([ - expect.objectContaining({ id: "high", body: { reasoning_effort: "high" } }), - expect.objectContaining({ id: "max", body: { reasoning_effort: "max" } }), + expect.objectContaining({ id: "high", settings: { reasoningEffort: "high" } }), + expect.objectContaining({ id: "max", settings: { reasoningEffort: "max" } }), ]) }), ) @@ -53,14 +53,14 @@ describe("VariantPlugin", () => { type: "aisdk", package: "@ai-sdk/openai-compatible", } - model.variants = [{ id: ModelV2.VariantID.make("high"), headers: { custom: "true" }, body: {} }] + model.variants = [{ id: ModelV2.VariantID.make("high"), settings: {}, headers: { custom: "true" }, body: {} }] }) }) yield* VariantPlugin.Plugin.effect(host({ catalog: catalogHost(service) })) expect((yield* service.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("glm-5.2")))?.variants).toEqual([ expect.objectContaining({ id: "high", headers: { custom: "true" } }), - expect.objectContaining({ id: "max", body: { reasoning_effort: "max" } }), + expect.objectContaining({ id: "max", settings: { reasoningEffort: "max" } }), ]) }), ) diff --git a/packages/core/test/reference-guidance.test.ts b/packages/core/test/reference-guidance.test.ts index 2a317af2ca..3db3212c3c 100644 --- a/packages/core/test/reference-guidance.test.ts +++ b/packages/core/test/reference-guidance.test.ts @@ -16,10 +16,10 @@ describe("ReferenceGuidance", () => { const guidance = yield* ReferenceGuidance.Service const generation = yield* SystemContext.initialize(yield* guidance.load()) - expect(generation.baseline).toContain("") - expect(generation.baseline).toContain("docs") - expect(generation.baseline).toContain("/docs") - expect(generation.baseline).toContain("Use for product documentation") + expect(generation.text).toContain("") + expect(generation.text).toContain("docs") + expect(generation.text).toContain("/docs") + expect(generation.text).toContain("Use for product documentation") }).pipe( Effect.provide( guidanceLayer( @@ -47,7 +47,7 @@ describe("ReferenceGuidance", () => { Effect.gen(function* () { const guidance = yield* ReferenceGuidance.Service const generation = yield* SystemContext.initialize(yield* guidance.load()) - expect(generation.baseline).toBe("") + expect(generation.text).toBe("") }).pipe(Effect.provide(guidanceLayer(Layer.mock(Reference.Service, { list: () => Effect.succeed([]) })))), ) @@ -55,7 +55,7 @@ describe("ReferenceGuidance", () => { Effect.gen(function* () { const guidance = yield* ReferenceGuidance.Service const generation = yield* SystemContext.initialize(yield* guidance.load()) - expect(generation.baseline).toBe("") + expect(generation.text).toBe("") }).pipe( Effect.provide( guidanceLayer( @@ -73,4 +73,41 @@ describe("ReferenceGuidance", () => { ), ), ) + + it.effect("announces added and removed references as deltas", () => { + const reference = (name: string, description: string) => + new Reference.Info({ + name, + path: AbsolutePath.make(`/${name}`), + description, + source: Reference.LocalSource.make({ type: "local", path: AbsolutePath.make(`/${name}`), description }), + }) + let references = [reference("docs", "Use for product documentation")] + return Effect.gen(function* () { + const guidance = yield* ReferenceGuidance.Service + const initialized = yield* SystemContext.initialize(yield* guidance.load()) + + references = [reference("docs", "Use for product documentation"), reference("examples", "Use for examples")] + const added = yield* SystemContext.reconcile(yield* guidance.load(), initialized.applied) + expect(added).toMatchObject({ + _tag: "Updated", + text: [ + "New project references are available in addition to those previously listed:", + " ", + " examples", + " /examples", + " Use for examples", + " ", + ].join("\n"), + }) + + references = [reference("examples", "Use for examples")] + expect( + yield* SystemContext.reconcile(yield* guidance.load(), added._tag === "Updated" ? added.applied : {}), + ).toMatchObject({ + _tag: "Updated", + text: "The following project references are no longer available and must not be used: docs.", + }) + }).pipe(Effect.provide(guidanceLayer(Layer.mock(Reference.Service, { list: () => Effect.succeed(references) })))) + }) }) diff --git a/packages/core/test/session-projector.test.ts b/packages/core/test/session-projector.test.ts index c78abd17d5..7b6f2f219e 100644 --- a/packages/core/test/session-projector.test.ts +++ b/packages/core/test/session-projector.test.ts @@ -19,7 +19,12 @@ import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionInput } from "@opencode-ai/core/session/input" -import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql" +import { + SessionContextCheckpointTable, + SessionInputTable, + SessionMessageTable, + SessionTable, +} from "@opencode-ai/core/session/sql" import { testEffect } from "./lib/effect" import { Snapshot } from "@opencode-ai/core/snapshot" @@ -67,6 +72,10 @@ describe("SessionProjector", () => { .insert(SessionMessageTable) .values([assistantRow(boundary, 1), assistantRow(SessionMessage.ID.make("msg_later"), 2)]) .run() + yield* db + .insert(SessionContextCheckpointTable) + .values({ session_id: sessionID, baseline: "baseline", snapshot: {}, baseline_seq: 0 }) + .run() const events = yield* EventV2.Service yield* events.publish(SessionEvent.RevertEvent.Staged, { sessionID, @@ -93,6 +102,8 @@ describe("SessionProjector", () => { expect( (yield* db.select({ id: SessionMessageTable.id }).from(SessionMessageTable).all()).map((row) => row.id), ).toEqual([boundary]) + // A committed revert resets the context checkpoint so the next turn re-initializes. + expect(yield* db.select().from(SessionContextCheckpointTable).get().pipe(Effect.orDie)).toBeUndefined() }), ) diff --git a/packages/core/test/session-runner-model.test.ts b/packages/core/test/session-runner-model.test.ts index 49bbce95a3..6d11bd877a 100644 --- a/packages/core/test/session-runner-model.test.ts +++ b/packages/core/test/session-runner-model.test.ts @@ -30,6 +30,7 @@ const model = (api: Api, variants: ModelV2.Info["variants"] = []) => api: { id: ModelV2.ID.make("api-test-model"), ...api }, capabilities: { tools: true, input: ["text"], output: ["text"] }, request: { + settings: {}, headers: { "x-test": "header" }, body: { apiKey: "secret", custom_extension: { enabled: true } }, }, @@ -83,7 +84,7 @@ describe("SessionRunnerModel", () => { url: "https://compatible.example/v1", settings: { apiKey: "settings-secret", compatibility: "strict" }, }), - request: { headers: {}, body: {} }, + request: { settings: {}, headers: {}, body: {} }, }), ) const request = LLM.request({ model: resolved, prompt: "Hello" }) @@ -100,17 +101,17 @@ describe("SessionRunnerModel", () => { }), ) - it.effect("overlays selected OpenAI Session variant bodies", () => + it.effect("overlays selected OpenAI Session variant settings and bodies", () => Effect.gen(function* () { const catalog = model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }, [ { id: ModelV2.VariantID.make("high"), + settings: { reasoningEffort: "high" }, headers: { "x-variant": "high" }, body: { store: false, service_tier: "priority", temperature: 0.2, - reasoning: { effort: "high" }, }, }, ]) @@ -137,7 +138,9 @@ describe("SessionRunnerModel", () => { store: false, service_tier: "priority", temperature: 0.2, - reasoning: { effort: "high" }, + }) + expect(resolved.route.defaults.providerOptions).toEqual({ + openai: { store: false, reasoningEffort: "high" }, }) }), ) @@ -149,6 +152,7 @@ describe("SessionRunnerModel", () => { [ { id: ModelV2.VariantID.make("high"), + settings: {}, headers: {}, body: { store: false, reasoning_effort: "high" }, }, @@ -205,13 +209,14 @@ describe("SessionRunnerModel", () => { }), ) - it.effect("overlays selected Anthropic Session variant bodies", () => + it.effect("overlays selected Anthropic Session variant settings", () => Effect.gen(function* () { const catalog = model({ type: "aisdk", package: "@ai-sdk/anthropic", url: "https://anthropic.example/v1" }, [ { id: ModelV2.VariantID.make("high"), + settings: { thinking: { type: "enabled", budgetTokens: 12000 } }, headers: {}, - body: { thinking: { type: "enabled", budget_tokens: 12000 } }, + body: {}, }, ]) const session = SessionV2.Info.make({ @@ -229,7 +234,9 @@ describe("SessionRunnerModel", () => { expect(resolved.route.defaults.http?.body).toEqual({ custom_extension: { enabled: true }, - thinking: { type: "enabled", budget_tokens: 12000 }, + }) + expect(resolved.route.defaults.providerOptions).toEqual({ + anthropic: { thinking: { type: "enabled", budgetTokens: 12000 } }, }) }), ) @@ -252,7 +259,7 @@ describe("SessionRunnerModel", () => { const resolved = yield* SessionRunnerModel.fromCatalogModel( ModelV2.Info.make({ ...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }), - request: { headers: {}, body: {} }, + request: { settings: {}, headers: {}, body: {} }, }), Credential.Key.make({ type: "key", key: "secret" }), ) @@ -275,7 +282,7 @@ describe("SessionRunnerModel", () => { const resolved = yield* SessionRunnerModel.fromCatalogModel( ModelV2.Info.make({ ...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }), - request: { headers: {}, body: { apiKey: "configured-secret" } }, + request: { settings: {}, headers: {}, body: { apiKey: "configured-secret" } }, }), credential, ) @@ -297,7 +304,7 @@ describe("SessionRunnerModel", () => { const resolved = yield* SessionRunnerModel.fromCatalogModel( ModelV2.Info.make({ ...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }), - request: { headers: {}, body: {} }, + request: { settings: {}, headers: {}, body: {} }, }), Credential.OAuth.make({ type: "oauth", @@ -313,6 +320,101 @@ describe("SessionRunnerModel", () => { }), ) + it.effect("routes ChatGPT OAuth credentials to the codex backend", () => + 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("chatgpt-browser"), + access: "chatgpt-token", + refresh: "refresh", + expires: Date.now() + 60_000, + metadata: { accountID: "acct_123" }, + }), + ) + const request = LLM.request({ model: resolved, prompt: "Hello" }) + const headers = yield* resolved.route.auth.apply({ + request, + method: "POST", + url: "https://chatgpt.com/backend-api/codex/responses", + body: "{}", + headers: Headers.empty, + }) + + expect(resolved.route).toMatchObject({ + id: "openai-responses", + endpoint: { baseURL: "https://chatgpt.com/backend-api/codex" }, + }) + expect(headers.authorization).toBe("Bearer chatgpt-token") + expect(headers["chatgpt-account-id"]).toBe("acct_123") + }), + ) + + it.effect("routes ChatGPT OAuth credentials without an account id to the codex backend", () => + 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("chatgpt-headless"), + access: "chatgpt-token", + refresh: "refresh", + expires: Date.now() + 60_000, + }), + ) + const request = LLM.request({ model: resolved, prompt: "Hello" }) + const headers = yield* resolved.route.auth.apply({ + request, + method: "POST", + url: "https://chatgpt.com/backend-api/codex/responses", + body: "{}", + headers: Headers.empty, + }) + + expect(resolved.route.endpoint.baseURL).toBe("https://chatgpt.com/backend-api/codex") + expect(headers.authorization).toBe("Bearer chatgpt-token") + expect(headers["chatgpt-account-id"]).toBeUndefined() + }), + ) + + it.effect("keeps non-ChatGPT OAuth credentials on the configured endpoint", () => + 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: "oauth-token", + refresh: "refresh", + expires: Date.now() + 60_000, + metadata: { accountID: "acct_123" }, + }), + ) + const request = LLM.request({ model: resolved, prompt: "Hello" }) + const headers = yield* resolved.route.auth.apply({ + request, + method: "POST", + url: "https://openai.example/v1/responses", + body: "{}", + headers: Headers.empty, + }) + + expect(resolved.route.endpoint.baseURL).toBe("https://openai.example/v1") + expect(headers.authorization).toBe("Bearer oauth-token") + expect(headers["chatgpt-account-id"]).toBeUndefined() + }), + ) + 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-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index 7366105a7c..57e0ba42f6 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -31,7 +31,8 @@ import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { SessionTable } from "@opencode-ai/core/session/sql" import { SessionStore } from "@opencode-ai/core/session/store" import { Location } from "@opencode-ai/core/location" -import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry" +import { SystemContextBuiltIns } from "@opencode-ai/core/system-context/builtins" +import { InstructionContext } from "@opencode-ai/core/instruction-context" import { SystemContext } from "@opencode-ai/core/system-context" import { SkillGuidance } from "@opencode-ai/core/skill/guidance" import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance" @@ -72,7 +73,8 @@ const model = OpenAIChat.route }) .model({ id: "gpt-4o-mini" }) const models = SessionRunnerModel.layerWith(() => Effect.succeed(model)) -const systemContext = AppNodeBuilder.build(SystemContextRegistry.node) +const systemContext = Layer.mock(SystemContextBuiltIns.Service, { load: () => Effect.succeed(SystemContext.empty) }) +const instructionContext = Layer.mock(InstructionContext.Service, { load: () => Effect.succeed(SystemContext.empty) }) const skillGuidance = Layer.mock(SkillGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) }) const referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) }) const mcpGuidance = Layer.mock(McpGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) }) @@ -81,7 +83,8 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [ [Snapshot.node, Snapshot.noopLayer], [LayerNodePlatform.llmClient, client], [SessionRunnerModel.node, models], - [SystemContextRegistry.node, systemContext], + [SystemContextBuiltIns.node, systemContext], + [InstructionContext.node, instructionContext], [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], [SkillGuidance.node, skillGuidance], [ReferenceGuidance.node, referenceGuidance], @@ -116,7 +119,8 @@ const it = testEffect( AgentV2.node, ToolRegistry.node, SessionRunnerModel.node, - SystemContextRegistry.node, + SystemContextBuiltIns.node, + InstructionContext.node, SkillGuidance.node, ReferenceGuidance.node, Config.node, @@ -129,7 +133,8 @@ const it = testEffect( [PermissionV2.node, permission], [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], [SessionRunnerModel.node, models], - [SystemContextRegistry.node, systemContext], + [SystemContextBuiltIns.node, systemContext], + [InstructionContext.node, instructionContext], [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], [SkillGuidance.node, skillGuidance], [ReferenceGuidance.node, referenceGuidance], diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index dc67776e9e..1bbfcbf470 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -29,7 +29,6 @@ import { QuestionV2 } from "@opencode-ai/core/question" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" import { Snapshot } from "@opencode-ai/core/snapshot" -import { ContextSnapshotDecodeError } from "@opencode-ai/core/session/error" import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionCompaction } from "@opencode-ai/core/session/compaction" import { SessionTitle } from "@opencode-ai/core/session/title" @@ -50,14 +49,16 @@ import { Config } from "@opencode-ai/core/config" import { ConfigCompaction } from "@opencode-ai/core/config/compaction" import { Tool } from "@opencode-ai/core/tool/tool" import { - SessionContextEpochTable, + SessionContextCheckpointTable, SessionInputTable, SessionMessageTable, SessionTable, } from "@opencode-ai/core/session/sql" +import { SessionContextEntry } from "@opencode-ai/core/session/context-entry" import { SessionStore } from "@opencode-ai/core/session/store" import { SystemContext } from "@opencode-ai/core/system-context" -import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry" +import { SystemContextBuiltIns } from "@opencode-ai/core/system-context/builtins" +import { InstructionContext } from "@opencode-ai/core/instruction-context" import { SkillGuidance } from "@opencode-ai/core/skill/guidance" import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance" import { McpGuidance } from "@opencode-ai/core/mcp/guidance" @@ -173,35 +174,28 @@ let systemRemoved = false let systemUnavailable = false let systemLoadHook = Effect.void const skillBaselines = new Map() -const systemContext = Layer.effectDiscard( - SystemContextRegistry.Service.pipe( - Effect.flatMap((registry) => - registry.register({ - key: systemContextKey, - load: Effect.sync(() => - SystemContext.combine( - systemRemoved - ? [] - : [ - SystemContext.make({ - key: systemContextKey, - codec: Schema.toCodecJson(Schema.String), - load: systemLoadHook.pipe( - Effect.andThen( - Effect.sync(() => (systemUnavailable ? SystemContext.unavailable : systemBaseline)), - ), - ), - baseline: String, - update: (_previous, current) => current, - removed: () => "System context source removed: test/context", - }), - ], - ), - ), - }), +const systemContext = Layer.mock(SystemContextBuiltIns.Service, { + load: () => + Effect.sync(() => + SystemContext.combine( + systemRemoved + ? [] + : [ + SystemContext.make({ + key: systemContextKey, + codec: Schema.toCodecJson(Schema.String), + load: systemLoadHook.pipe( + Effect.andThen(Effect.sync(() => (systemUnavailable ? SystemContext.unavailable : systemBaseline))), + ), + baseline: String, + update: (_previous, current) => current, + removed: () => "System context source removed: test/context", + }), + ], + ), ), - ), -).pipe(Layer.provideMerge(AppNodeBuilder.build(SystemContextRegistry.node))) +}) +const instructionContext = Layer.mock(InstructionContext.Service, { load: () => Effect.succeed(SystemContext.empty) }) const skillGuidance = Layer.mock(SkillGuidance.Service, { load: (agent) => Effect.succeed( @@ -240,7 +234,8 @@ const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [ [Snapshot.node, Snapshot.noopLayer], [LayerNodePlatform.llmClient, client], [SessionRunnerModel.node, models], - [SystemContextRegistry.node, systemContext], + [SystemContextBuiltIns.node, systemContext], + [InstructionContext.node, instructionContext], [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], [SkillGuidance.node, skillGuidance], [ReferenceGuidance.node, referenceGuidance], @@ -278,7 +273,9 @@ const it = testEffect( ToolRegistry.toolsNode, echoNode, SessionRunnerModel.node, - SystemContextRegistry.node, + SystemContextBuiltIns.node, + InstructionContext.node, + SessionContextEntry.node, SkillGuidance.node, ReferenceGuidance.node, Config.node, @@ -291,7 +288,8 @@ const it = testEffect( [LayerNodePlatform.llmClient, client], [PermissionV2.node, permission], [SessionRunnerModel.node, models], - [SystemContextRegistry.node, systemContext], + [SystemContextBuiltIns.node, systemContext], + [InstructionContext.node, instructionContext], [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], [SkillGuidance.node, skillGuidance], [ReferenceGuidance.node, referenceGuidance], @@ -740,8 +738,8 @@ describe("SessionRunnerLLM", () => { expect( yield* db .select() - .from(SessionContextEpochTable) - .where(eq(SessionContextEpochTable.session_id, sessionID)) + .from(SessionContextCheckpointTable) + .where(eq(SessionContextCheckpointTable.session_id, sessionID)) .get(), ).toBeUndefined() @@ -772,8 +770,8 @@ describe("SessionRunnerLLM", () => { expect( yield* db .select() - .from(SessionContextEpochTable) - .where(eq(SessionContextEpochTable.session_id, sessionID)) + .from(SessionContextCheckpointTable) + .where(eq(SessionContextCheckpointTable.session_id, sessionID)) .get(), ).toBeUndefined() @@ -786,7 +784,36 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("fails gracefully when a stored context snapshot cannot be decoded", () => + it.effect("copies the context checkpoint to a fork", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const { db } = yield* Database.Service + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) + response = [] + yield* session.resume(sessionID) + + const forked = yield* session.fork({ sessionID }) + + const parent = yield* db + .select() + .from(SessionContextCheckpointTable) + .where(eq(SessionContextCheckpointTable.session_id, sessionID)) + .get() + .pipe(Effect.orDie) + expect(parent).toBeDefined() + expect( + yield* db + .select() + .from(SessionContextCheckpointTable) + .where(eq(SessionContextCheckpointTable.session_id, forked.id)) + .get() + .pipe(Effect.orDie), + ).toEqual({ ...parent!, session_id: forked.id }) + }), + ) + + it.effect("heals an undecodable stored applied record by re-announcing context", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service @@ -795,19 +822,28 @@ describe("SessionRunnerLLM", () => { response = [] yield* session.resume(sessionID) yield* db - .update(SessionContextEpochTable) + .update(SessionContextCheckpointTable) .set({ snapshot: { invalid: { value: "bad" } } }) - .where(eq(SessionContextEpochTable.session_id, sessionID)) + .where(eq(SessionContextCheckpointTable.session_id, sessionID)) .run() .pipe(Effect.orDie) yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) requests.length = 0 - const exit = yield* session.resume(sessionID).pipe(Effect.exit) + yield* session.resume(sessionID) - expect(Exit.isFailure(exit)).toBe(true) - if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(ContextSnapshotDecodeError) - expect(requests).toHaveLength(0) + // Comparison state was lost, so every source re-announces as new. + expect(requests).toHaveLength(1) + expect(requests[0]?.system.map((part) => part.text)).toEqual([defaultSystem, "Initial context"]) + expect(requests[0]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"]) + expect(requests[0]?.messages.at(1)?.content).toEqual([{ type: "text", text: "Initial context" }]) + const healed = yield* db + .select({ snapshot: SessionContextCheckpointTable.snapshot }) + .from(SessionContextCheckpointTable) + .where(eq(SessionContextCheckpointTable.session_id, sessionID)) + .get() + .pipe(Effect.orDie) + expect(healed?.snapshot).toEqual({ "test/context": { value: "Initial context", removed: expect.any(String) } }) }), ) @@ -828,8 +864,8 @@ describe("SessionRunnerLLM", () => { [defaultSystem, "Initial context"], [defaultSystem, "Initial context"], ]) - expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "user", "system"]) - expect(requests[1]?.messages.at(-1)?.content).toEqual([{ type: "text", text: "Changed context" }]) + expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"]) + expect(requests[1]?.messages.at(1)?.content).toEqual([{ type: "text", text: "Changed context" }]) expect(yield* session.messages({ sessionID })).toHaveLength(3) const { db } = yield* Database.Service expect( @@ -1090,14 +1126,66 @@ describe("SessionRunnerLLM", () => { yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) yield* session.resume(sessionID) - expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "user", "system"]) - expect(requests[1]?.messages.at(-1)?.content).toEqual([ + expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"]) + expect(requests[1]?.messages.at(1)?.content).toEqual([ { type: "text", text: "System context source removed: test/context" }, ]) expect(yield* session.messages({ sessionID })).toHaveLength(3) }), ) + it.effect("renders API context entries through the belief lifecycle", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + const contextEntries = yield* SessionContextEntry.Service + yield* contextEntries.put({ sessionID, key: "deploy-target", value: "production" }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "First" }), resume: false }) + + requests.length = 0 + response = [] + yield* session.resume(sessionID) + + // String values render verbatim inside the tagged block at baseline. + expect(requests[0]?.system.map((part) => part.text)).toEqual([ + defaultSystem, + ["Initial context", "", '', "production", ""].join("\n"), + ]) + + // Non-string JSON pretty-prints; the change narrates as a System update. + yield* contextEntries.put({ sessionID, key: "deploy-target", value: { region: "us-east-1" } }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Second" }), resume: false }) + yield* session.resume(sessionID) + + expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"]) + expect(requests[1]?.messages.at(1)?.content).toEqual([ + { + type: "text", + text: [ + 'The context under "deploy-target" changed and supersedes the previous value:', + '', + "{", + ' "region": "us-east-1"', + "}", + "", + ].join("\n"), + }, + ]) + expect(yield* contextEntries.list(sessionID)).toEqual([{ key: "deploy-target", value: { region: "us-east-1" } }]) + + // Deleting the row announces removal through the stored removal text. + yield* contextEntries.remove({ sessionID, key: "deploy-target" }) + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Third" }), resume: false }) + yield* session.resume(sessionID) + + expect(requests[2]?.messages.map((message) => message.role)).toEqual(["user", "system", "user", "system", "user"]) + expect(requests[2]?.messages.at(-2)?.content).toEqual([ + { type: "text", text: 'The context under "deploy-target" no longer applies. Disregard it.' }, + ]) + expect(yield* contextEntries.list(sessionID)).toEqual([]) + }), + ) + it.effect("keeps the baseline and chronological System updates after a model switch", () => Effect.gen(function* () { yield* setup @@ -1126,15 +1214,15 @@ describe("SessionRunnerLLM", () => { [defaultSystem, "Initial context"], [defaultSystem, "Initial context"], ]) - expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "user", "system"]) + expect(requests[1]?.messages.map((message) => message.role)).toEqual(["user", "system", "user"]) expect(requests[2]?.messages.filter((message) => message.role === "system")).toHaveLength(2) expect((yield* session.context(sessionID)).map((message) => message.type)).toEqual([ - "user", "user", "system", + "user", "model-switched", - "user", "system", + "user", ]) yield* replaySessionProjection(sessionID) expect(yield* session.messages({ sessionID })).toHaveLength(6) @@ -1402,7 +1490,7 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("preserves effective System updates while compaction rebaseline is blocked", () => + it.effect("rebaselines after compaction from the last-applied belief while unobservable", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service @@ -1434,8 +1522,9 @@ describe("SessionRunnerLLM", () => { yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Third" }), resume: false }) yield* session.resume(sessionID) - expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([defaultSystem, "Initial context"]) - expect(systemTexts(requests.at(-1)!)).toContain("Changed context") + // The rebaseline proceeds while the source is unobservable, restating the model's belief. + expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([defaultSystem, "Changed context"]) + expect(systemTexts(requests.at(-1)!)).not.toContain("Changed context") }), ) diff --git a/packages/core/test/skill/guidance.test.ts b/packages/core/test/skill/guidance.test.ts index 50e877917b..b26b75fe71 100644 --- a/packages/core/test/skill/guidance.test.ts +++ b/packages/core/test/skill/guidance.test.ts @@ -53,7 +53,7 @@ describe("SkillGuidance", () => { .load({ id: agent.id, info: agent }) .pipe(Effect.flatMap(SystemContext.initialize)) - expect(initialized.baseline).toBe( + expect(initialized.text).toBe( [ "Skills provide specialized instructions and workflows for specific tasks.", "Use the skill tool to load a skill when a task matches its description.", @@ -65,16 +65,82 @@ describe("SkillGuidance", () => { "", ].join("\n"), ) - expect(initialized.baseline).not.toContain("manual") + expect(initialized.text).not.toContain("manual") skills = [] expect( yield* guidance .load({ id: agent.id, info: agent }) - .pipe(Effect.flatMap((context) => SystemContext.reconcile(context, initialized.snapshot))), + .pipe(Effect.flatMap((context) => SystemContext.reconcile(context, initialized.applied))), ).toMatchObject({ _tag: "Updated", - text: expect.stringContaining("No skills are currently available."), + text: "The following skills are no longer available and must not be used: effect.", + }) + }).pipe(Effect.provide(layer(() => skills))) + }) + + it.effect("announces added and removed skills as deltas without restating the list", () => { + const agent = AgentV2.Info.make(AgentV2.Info.empty(build)) + const debugging = SkillV2.Info.make({ + name: "debugging", + description: "Diagnose hard bugs", + location: AbsolutePath.make(path.resolve("/skills/debugging/SKILL.md")), + content: "Debugging guidance", + }) + let skills = [effect] + return Effect.gen(function* () { + const guidance = yield* SkillGuidance.Service + const initialized = yield* guidance + .load({ id: agent.id, info: agent }) + .pipe(Effect.flatMap(SystemContext.initialize)) + + skills = [effect, debugging] + const added = yield* guidance + .load({ id: agent.id, info: agent }) + .pipe(Effect.flatMap((context) => SystemContext.reconcile(context, initialized.applied))) + expect(added).toMatchObject({ + _tag: "Updated", + text: [ + "New skills are available in addition to those previously listed:", + " ", + " debugging", + " Diagnose hard bugs", + " ", + ].join("\n"), + }) + + skills = [debugging] + const removed = yield* guidance + .load({ id: agent.id, info: agent }) + .pipe( + Effect.flatMap((context) => SystemContext.reconcile(context, added._tag === "Updated" ? added.applied : {})), + ) + expect(removed).toMatchObject({ + _tag: "Updated", + text: "The following skills are no longer available and must not be used: effect.", + }) + }).pipe(Effect.provide(layer(() => skills))) + }) + + it.effect("restates the full skill list when a description changes", () => { + const agent = AgentV2.Info.make(AgentV2.Info.empty(build)) + let skills = [effect] + return Effect.gen(function* () { + const guidance = yield* SkillGuidance.Service + const initialized = yield* guidance + .load({ id: agent.id, info: agent }) + .pipe(Effect.flatMap(SystemContext.initialize)) + + skills = [SkillV2.Info.make({ ...effect, description: "Build applications with Effect v4" })] + expect( + yield* guidance + .load({ id: agent.id, info: agent }) + .pipe(Effect.flatMap((context) => SystemContext.reconcile(context, initialized.applied))), + ).toMatchObject({ + _tag: "Updated", + text: expect.stringContaining( + "The available skills have changed. This list supersedes the previous available skills list.", + ), }) }).pipe(Effect.provide(layer(() => skills))) }) @@ -89,8 +155,8 @@ describe("SkillGuidance", () => { expect( yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(SystemContext.initialize)), ).toEqual({ - baseline: "", - snapshot: {}, + text: "", + applied: {}, }) }).pipe(Effect.provide(layer(() => [effect]))) }) @@ -108,8 +174,8 @@ describe("SkillGuidance", () => { expect( yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(SystemContext.initialize)), ).toEqual({ - baseline: "", - snapshot: {}, + text: "", + applied: {}, }) }).pipe(Effect.provide(layer(() => [effect]))) }) @@ -125,7 +191,7 @@ describe("SkillGuidance", () => { return Effect.gen(function* () { const guidance = yield* SkillGuidance.Service expect( - (yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(SystemContext.initialize))).baseline, + (yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(SystemContext.initialize))).text, ).toContain("effect") }).pipe(Effect.provide(layer(() => [effect]))) }) @@ -144,8 +210,8 @@ describe("SkillGuidance", () => { expect( yield* guidance.load({ id: agent.id, info: agent }).pipe(Effect.flatMap(SystemContext.initialize)), ).toEqual({ - baseline: "", - snapshot: {}, + text: "", + applied: {}, }) }).pipe(Effect.provide(layer(() => [effect]))) }) diff --git a/packages/core/test/system-context/builtins.test.ts b/packages/core/test/system-context/builtins.test.ts index 26b9175223..2d4ac356b9 100644 --- a/packages/core/test/system-context/builtins.test.ts +++ b/packages/core/test/system-context/builtins.test.ts @@ -9,7 +9,7 @@ import { Global } from "@opencode-ai/core/global" import { AbsolutePath } from "@opencode-ai/core/schema" import { SystemContext } from "@opencode-ai/core/system-context" import { SystemContextBuiltIns } from "@opencode-ai/core/system-context/builtins" -import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry" +import { InstructionContext } from "@opencode-ai/core/instruction-context" import { location } from "../fixture/location" import { testEffect } from "../lib/effect" @@ -27,7 +27,7 @@ const locationLayer = Layer.succeed( ), ), ) -const builtInsNode = LayerNode.group([SystemContextBuiltIns.node, SystemContextRegistry.node]) +const builtInsNode = LayerNode.group([SystemContextBuiltIns.node, InstructionContext.node]) const it = testEffect( AppNodeBuilder.build(builtInsNode, [ [Location.node, locationLayer], @@ -58,10 +58,10 @@ describe("SystemContextBuiltIns", () => { it.effect("loads location-scoped environment and host-local date context", () => Effect.gen(function* () { yield* TestClock.setTime(timestamp) - const context = yield* SystemContextRegistry.Service + const context = yield* SystemContextBuiltIns.Service const initialized = yield* SystemContext.initialize(yield* context.load()) - expect(initialized.baseline).toBe( + expect(initialized.text).toBe( [ "Here is some useful information about the environment you are running in:", "", @@ -80,11 +80,11 @@ describe("SystemContextBuiltIns", () => { it.effect("reconciles the date without repeating unchanged environment context", () => Effect.gen(function* () { yield* TestClock.setTime(timestamp) - const context = yield* SystemContextRegistry.Service + const context = yield* SystemContextBuiltIns.Service const initialized = yield* SystemContext.initialize(yield* context.load()) yield* TestClock.setTime(timestamp + 24 * 60 * 60 * 1000) - const refreshed = yield* SystemContext.reconcile(yield* context.load(), initialized.snapshot) + const refreshed = yield* SystemContext.reconcile(yield* context.load(), initialized.applied) expect(refreshed).toMatchObject({ _tag: "Updated", @@ -96,20 +96,24 @@ describe("SystemContextBuiltIns", () => { it.effect("does not update again within the same local calendar day", () => Effect.gen(function* () { yield* TestClock.setTime(timestamp) - const context = yield* SystemContextRegistry.Service + const context = yield* SystemContextBuiltIns.Service const initialized = yield* SystemContext.initialize(yield* context.load()) yield* TestClock.setTime(timestamp + 60 * 60 * 1000) - expect(yield* SystemContext.reconcile(yield* context.load(), initialized.snapshot)).toEqual({ _tag: "Unchanged" }) + expect(yield* SystemContext.reconcile(yield* context.load(), initialized.applied)).toEqual({ _tag: "Unchanged" }) }), ) itWithInstructions.effect("composes ambient instructions after built-in context", () => Effect.gen(function* () { yield* TestClock.setTime(timestamp) - const context = yield* SystemContextRegistry.Service + const builtIns = yield* SystemContextBuiltIns.Service + const instructions = yield* InstructionContext.Service + const context = { + load: () => Effect.all([builtIns.load(), instructions.load()]).pipe(Effect.map(SystemContext.combine)), + } - expect((yield* SystemContext.initialize(yield* context.load())).baseline).toBe( + expect((yield* SystemContext.initialize(yield* context.load())).text).toBe( [ "Here is some useful information about the environment you are running in:", "", diff --git a/packages/core/test/system-context/index.test.ts b/packages/core/test/system-context/index.test.ts index 704843ba23..f62053b088 100644 --- a/packages/core/test/system-context/index.test.ts +++ b/packages/core/test/system-context/index.test.ts @@ -32,11 +32,11 @@ describe("SystemContext", () => { removed: () => "Date removed", }) - expect((yield* SystemContext.initialize(context)).snapshot["core/date"].value).toBe("2026-06-03T12:00:00.000Z") + expect((yield* SystemContext.initialize(context)).applied["core/date"].value).toBe("2026-06-03T12:00:00.000Z") }), ) - it.effect("loads once and initializes a baseline with a structured snapshot", () => + it.effect("loads once and initializes a baseline with the applied values", () => Effect.gen(function* () { let loads = 0 const context = SystemContext.combine([ @@ -55,8 +55,8 @@ describe("SystemContext", () => { ]) expect(yield* SystemContext.initialize(context)).toEqual({ - baseline: "Today's date is 2026-06-03.\n\nDirectory: /repo", - snapshot: { + text: "Today's date is 2026-06-03.\n\nDirectory: /repo", + applied: { "core/date": { value: "2026-06-03", removed: "The date was removed." }, "core/location": { value: "/repo" }, }, @@ -84,7 +84,7 @@ describe("SystemContext", () => { expect(yield* SystemContext.reconcile(changed, previous)).toEqual({ _tag: "Updated", text: "The date changed from 2026-06-03 to 2026-06-04.", - snapshot: { + applied: { "core/date": { value: "2026-06-04", removed: "The date was removed." }, "core/location": { value: "/repo", removed: "Removed: /repo" }, }, @@ -113,19 +113,17 @@ describe("SystemContext", () => { expect(yield* SystemContext.reconcile(context, {})).toEqual({ _tag: "Updated", text: "Available skill: effect", - snapshot: { "core/skills": { value: "effect" } }, + applied: { "core/skills": { value: "effect" } }, }) }), ) - it.effect("retains admitted snapshots while a source is temporarily unavailable", () => + it.effect("retains the belief while a source is temporarily unavailable", () => Effect.gen(function* () { const previous = { "core/remote": { value: "instructions", removed: "Instructions removed" } } const context = stringContext({ key: "core/remote", value: SystemContext.unavailable }) expect(yield* SystemContext.reconcile(context, previous)).toEqual({ _tag: "Unchanged" }) - expect(yield* SystemContext.replace(context, previous)).toEqual({ _tag: "ReplacementBlocked" }) - expect(yield* SystemContext.replace(context, {})).toMatchObject({ _tag: "ReplacementReady" }) }), ) @@ -152,17 +150,29 @@ describe("SystemContext", () => { ).toEqual({ _tag: "Updated", text: "Instructions removed; stop applying them.", - snapshot: {}, + applied: {}, }) }), ) - it.effect("requests replacement when a source without removal text disappears", () => + it.effect("retains an unannounced removal silently", () => Effect.gen(function* () { + expect(yield* SystemContext.reconcile(SystemContext.empty, { "core/date": { value: "2026-06-04" } })).toEqual({ + _tag: "Unchanged", + }) + + // The retained belief survives alongside other updates. expect( - yield* SystemContext.reconcile(SystemContext.empty, { "core/date": { value: "2026-06-04" } }), - ).toMatchObject({ - _tag: "ReplacementReady", + yield* SystemContext.reconcile(stringContext({ key: "core/skills", value: "effect" }), { + "core/date": { value: "2026-06-04" }, + }), + ).toEqual({ + _tag: "Updated", + text: "effect", + applied: { + "core/skills": { value: "effect" }, + "core/date": { value: "2026-06-04" }, + }, }) }), ) @@ -189,17 +199,48 @@ describe("SystemContext", () => { }), ) - it.effect("requests replacement when a stored value no longer decodes", () => + it.effect("re-announces the baseline when a stored value no longer decodes", () => Effect.gen(function* () { expect( yield* SystemContext.reconcile(stringContext({ key: "core/date", value: "2026-06-04" }), { "core/date": { value: 42, removed: "Date removed" }, }), - ).toMatchObject({ _tag: "ReplacementReady" }) + ).toEqual({ + _tag: "Updated", + text: "2026-06-04", + applied: { "core/date": { value: "2026-06-04" } }, + }) }), ) - it.effect("replaces from one coherent source observation", () => + it.effect("renders undecodable re-announcements alongside other updates", () => + Effect.gen(function* () { + const context = SystemContext.combine([ + stringContext({ + key: "core/date", + value: "2026-06-04", + update: (before, current) => `${before} -> ${current}`, + }), + stringContext({ key: "core/location", value: "/repo" }), + ]) + + expect( + yield* SystemContext.reconcile(context, { + "core/date": { value: "2026-06-03" }, + "core/location": { value: 42 }, + }), + ).toEqual({ + _tag: "Updated", + text: "2026-06-03 -> 2026-06-04\n\n/repo", + applied: { + "core/date": { value: "2026-06-04" }, + "core/location": { value: "/repo" }, + }, + }) + }), + ) + + it.effect("rebaselines from one coherent source observation", () => Effect.gen(function* () { let loads = 0 const context = SystemContext.make({ @@ -213,52 +254,83 @@ describe("SystemContext", () => { update: (_previous, current) => current, }) - expect(yield* SystemContext.reconcile(context, { "core/date": { value: 42 } })).toMatchObject({ - _tag: "ReplacementReady", - generation: { baseline: "2026-06-04" }, + expect(yield* SystemContext.rebaseline(context, { "core/date": { value: "2026-06-03" } })).toEqual({ + text: "2026-06-04", + applied: { "core/date": { value: "2026-06-04" } }, }) expect(loads).toBe(1) }), ) - it.effect("does not render discarded updates while replacing", () => + it.effect("rebaselines an unavailable source from the last-applied belief", () => Effect.gen(function* () { - let updates = 0 const context = SystemContext.combine([ + stringContext({ key: "core/date", value: "2026-06-04" }), stringContext({ - key: "core/date", - value: "2026-06-04", - update: () => { - updates++ - return "updated" - }, + key: "core/remote", + value: SystemContext.unavailable, + baseline: (value) => `Instructions: ${value}`, }), - stringContext({ key: "core/location", value: "/repo" }), ]) expect( - yield* SystemContext.reconcile(context, { - "core/date": { value: "2026-06-03" }, - "core/location": { value: 42 }, + yield* SystemContext.rebaseline(context, { + "core/remote": { value: "contents", removed: "Instructions removed" }, }), - ).toMatchObject({ _tag: "ReplacementReady" }) - expect(updates).toBe(0) + ).toEqual({ + text: "2026-06-04\n\nInstructions: contents", + applied: { + "core/date": { value: "2026-06-04" }, + "core/remote": { value: "contents", removed: "Instructions removed" }, + }, + }) }), ) - it.effect("blocks an incompatible replacement while another admitted source is unavailable", () => + it.effect("drops undecodable beliefs and removed sources at rebaseline", () => Effect.gen(function* () { - const previous = { - "core/date": { value: 42, removed: "Date removed" }, - "core/remote": { value: "instructions", removed: "Instructions removed" }, - } - const context = SystemContext.combine([ - stringContext({ key: "core/date", value: "2026-06-04" }), - stringContext({ key: "core/remote", value: SystemContext.unavailable }), - ]) + const context = stringContext({ key: "core/remote", value: SystemContext.unavailable }) - expect(yield* SystemContext.reconcile(context, previous)).toEqual({ _tag: "ReplacementBlocked" }) - expect(yield* SystemContext.replace(context, previous)).toEqual({ _tag: "ReplacementBlocked" }) + // Undecodable belief cannot be restated; removed source entries self-clean. + expect( + yield* SystemContext.rebaseline(context, { + "core/remote": { value: 42 }, + "core/gone": { value: "gone" }, + }), + ).toEqual({ text: "", applied: {} }) + }), + ) + + it.effect("diffs list values by key with a changed comparator", () => + Effect.sync(() => { + const previous = [ + { name: "effect", description: "Build with Effect" }, + { name: "debugging", description: "Diagnose bugs" }, + { name: "retired", description: "Old" }, + ] + const current = [ + { name: "effect", description: "Build with Effect v4" }, + { name: "debugging", description: "Diagnose bugs" }, + { name: "writing", description: "Write prose" }, + ] + + expect( + SystemContext.diffByKey( + previous, + current, + (value) => value.name, + (before, after) => before.description !== after.description, + ), + ).toEqual({ + added: [{ name: "writing", description: "Write prose" }], + removed: [{ name: "retired", description: "Old" }], + changed: [ + { + previous: { name: "effect", description: "Build with Effect" }, + current: { name: "effect", description: "Build with Effect v4" }, + }, + ], + }) }), ) @@ -281,7 +353,7 @@ describe("SystemContext", () => { stringContext({ key: "core/date", value: "date" }), stringContext({ key: "core/location", value: "location" }), ]), - )).baseline, + )).text, ).toBe("date\n\nlocation") }), ) @@ -295,13 +367,13 @@ describe("SystemContext", () => { }), ) - it.effect("requires namespaced durable snapshot keys", () => + it.effect("requires namespaced applied keys", () => Effect.sync(() => { - const decodeSnapshot = Schema.decodeUnknownSync(SystemContext.Snapshot) + const decodeApplied = Schema.decodeUnknownSync(SystemContext.Applied) - expect(Object.keys(decodeSnapshot({ "core/date": { value: "date" } }))).toEqual(["core/date"]) - expect(() => decodeSnapshot({ date: { value: "date" } })).toThrow() - expect(() => decodeSnapshot({ "core/date": { value: "date", removed: "" } })).toThrow() + expect(Object.keys(decodeApplied({ "core/date": { value: "date" } }))).toEqual(["core/date"]) + expect(() => decodeApplied({ date: { value: "date" } })).toThrow() + expect(() => decodeApplied({ "core/date": { value: "date", removed: "" } })).toThrow() }), ) }) diff --git a/packages/core/test/system-context/registry.test.ts b/packages/core/test/system-context/registry.test.ts deleted file mode 100644 index 3e68493078..0000000000 --- a/packages/core/test/system-context/registry.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { describe, expect } from "bun:test" -import { Cause, Effect, Exit, Schema, Scope } from "effect" -import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" -import { SystemContext } from "@opencode-ai/core/system-context" -import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry" -import { testEffect } from "../lib/effect" - -const entry = (key: string, text: string, sourceKey = key) => ({ - key: SystemContext.Key.make(key), - load: Effect.succeed( - SystemContext.make({ - key: SystemContext.Key.make(sourceKey), - codec: Schema.toCodecJson(Schema.String), - load: Effect.succeed(text), - baseline: String, - update: (_previous, current) => current, - }), - ), -}) - -const it = testEffect(AppNodeBuilder.build(SystemContextRegistry.node)) - -describe("SystemContextRegistry", () => { - it.effect("loads empty system context when there are no entries", () => - Effect.gen(function* () { - const registry = yield* SystemContextRegistry.Service - - expect(yield* SystemContext.initialize(yield* registry.load())).toEqual({ baseline: "", snapshot: {} }) - }), - ) - - it.effect("loads scoped entries in stable key order", () => - Effect.gen(function* () { - const registry = yield* SystemContextRegistry.Service - yield* registry.register(entry("test/second", "second")) - yield* registry.register(entry("test/first", "first")) - - expect((yield* SystemContext.initialize(yield* registry.load())).baseline).toBe("first\n\nsecond") - }), - ) - - it.effect("re-evaluates entry producers on each load", () => - Effect.gen(function* () { - const registry = yield* SystemContextRegistry.Service - let loads = 0 - yield* registry.register({ - key: SystemContext.Key.make("test/dynamic"), - load: Effect.sync(() => { - loads++ - return SystemContext.empty - }), - }) - - yield* registry.load() - yield* registry.load() - - expect(loads).toBe(2) - }), - ) - - it.effect("propagates entry producer failures", () => - Effect.gen(function* () { - const registry = yield* SystemContextRegistry.Service - const failure = new Error("entry failed") - yield* registry.register({ key: SystemContext.Key.make("test/failure"), load: Effect.die(failure) }) - - const exit = yield* registry.load().pipe(Effect.exit) - - expect(Exit.isFailure(exit)).toBe(true) - if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBe(failure) - }), - ) - - it.effect("rejects duplicate source keys from separate entries", () => - Effect.gen(function* () { - const registry = yield* SystemContextRegistry.Service - yield* registry.register(entry("test/first", "first", "test/duplicate")) - yield* registry.register(entry("test/second", "second", "test/duplicate")) - - const exit = yield* registry.load().pipe(Effect.exit) - - expect(Exit.isFailure(exit)).toBe(true) - if (Exit.isFailure(exit)) { - expect(Cause.squash(exit.cause)).toBeInstanceOf(SystemContext.DuplicateKeyError) - expect(Cause.squash(exit.cause)).toMatchObject({ key: SystemContext.Key.make("test/duplicate") }) - } - }), - ) - - it.effect("rejects duplicate entry keys", () => - Effect.gen(function* () { - const registry = yield* SystemContextRegistry.Service - yield* registry.register(entry("test/duplicate", "first")) - - const exit = yield* registry.register(entry("test/duplicate", "second", "test/other")).pipe(Effect.exit) - - expect(Exit.isFailure(exit)).toBe(true) - if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("Duplicate system context entry key") - }), - ) - - it.effect("removes an entry when its owning scope closes", () => - Effect.gen(function* () { - const registry = yield* SystemContextRegistry.Service - const scope = yield* Scope.make() - yield* registry.register(entry("test/scoped", "scoped")).pipe(Scope.provide(scope)) - - expect((yield* SystemContext.initialize(yield* registry.load())).baseline).toBe("scoped") - - yield* Scope.close(scope, Exit.void) - expect(yield* SystemContext.initialize(yield* registry.load())).toEqual({ baseline: "", snapshot: {} }) - }), - ) -}) diff --git a/packages/llm/README.md b/packages/llm/README.md index 020198dd64..330222de93 100644 --- a/packages/llm/README.md +++ b/packages/llm/README.md @@ -127,5 +127,6 @@ This package is built on Effect. Public methods return `Effect` or `Stream`; pro ## See also - `AGENTS.md` — architecture, route construction, contributor guide +- `STATUS.md` — native provider parity status and AI SDK migration gaps - `example/tutorial.ts` — runnable end-to-end walkthrough - `test/provider/*.test.ts` — fixture-first protocol tests; `*.recorded.test.ts` files cover live cassettes diff --git a/packages/llm/STATUS.md b/packages/llm/STATUS.md new file mode 100644 index 0000000000..874862d9af --- /dev/null +++ b/packages/llm/STATUS.md @@ -0,0 +1,96 @@ +# LLM Provider Parity Status + +Last reviewed: 2026-07-02 + +This file tracks the gap between the native `@opencode-ai/llm` package and the AI SDK provider packages that opencode still depends on for many catalog/runtime paths. + +## Existing Status Sources + +| File | What it tracks | Limitation | +| --- | --- | --- | +| `packages/llm/DESIGN.md` | Future clean-break API proposal, currently named `@opencode-ai/ai` in the draft. | Not a provider parity tracker. | +| `packages/llm/example/call-sites.md` | Route/value/provider-facade migration checklist and call-site sketches. | Architecture migration only; not AI SDK package parity. | +| `specs/v2/provider-model.md` | V2 catalog endpoint schema and current Session runner adaptation surface. | Runner-specific; not a native LLM package status matrix. | + +## Current Implementation Snapshot + +| Native slice | Source | Current state | Main gaps | +| --- | --- | --- | --- | +| OpenAI Chat | `src/protocols/openai-chat.ts`, `src/providers/openai.ts` | Usable. Streams text, reasoning deltas, tool calls, usage, images, and common generation controls. | No typed structured-output / `response_format` path. Limited typed OpenAI option surface compared with SDK escape hatches. | +| OpenAI Responses HTTP | `src/protocols/openai-responses.ts`, `src/providers/openai.ts` | Usable. Supports hosted-tool event surfacing, reasoning replay metadata, GPT-5 defaults, and cache usage. | No explicit `previous_response_id` path. Typed options cover only a subset of Responses fields. Structured output is still mostly synthetic-tool based. | +| OpenAI Responses WebSocket | `src/protocols/openai-responses.ts`, `src/route/transport/websocket.ts` | Present as `OpenAI.responsesWebSocket(...)`. | Runner/catalog support explicitly must not downgrade WebSocket routes; broader runtime selection is not complete. | +| OpenAI-compatible Chat | `src/protocols/openai-compatible-chat.ts`, `src/providers/openai-compatible.ts` | Usable for generic Chat and several profiles: Baseten, Cerebras, DeepInfra, DeepSeek, Fireworks, Groq, TogetherAI. | No OpenAI-compatible Responses protocol/facade. Family quirks are mostly endpoint defaults, not full typed behavior. | +| Anthropic Messages | `src/protocols/anthropic-messages.ts`, `src/providers/anthropic.ts` | Usable. Supports tools, thinking, cache control, images, server-hosted tool events, and usage. | Provider option surface is small. Beta/header handling, metadata, and newer Messages fields need a typed parity pass. | +| Gemini Developer API | `src/protocols/gemini.ts`, `src/providers/google.ts` | Usable for Google API key flow. Supports text, images, tools, thinking signatures, and cache usage. | This is not Vertex. Typed provider options are narrow; many Gemini request fields currently require raw `http.body` overlays. | +| Bedrock Converse | `src/protocols/bedrock-converse.ts`, `src/providers/amazon-bedrock.ts` | Partial but real. Supports AWS event-stream framing, SigV4 with supplied credentials, bearer auth, tools, reasoning signatures, media, cache points, and recorded tests. | Native facade does not mirror the AI SDK plugin's default AWS credential chain/profile behavior. Runner/catalog mapping is missing. Guardrails, inference profiles, region-specific model ID fixes, and model-specific request fields need a parity pass. | +| Azure OpenAI | `src/providers/azure.ts` using OpenAI Chat/Responses protocols | Partial. Supports resource/base URL setup, API key auth, API version query, Chat, and Responses selectors. | Core runner does not map `@ai-sdk/azure` to this native facade. AAD/token auth and Azure-specific endpoint variants need review. | +| Cloudflare AI Gateway / Workers AI | `src/providers/cloudflare.ts` | Present via OpenAI-compatible Chat routes. | Useful but not part of the critical AI SDK replacement set yet. Needs per-product recorded coverage before relying on it broadly. | +| OpenRouter | `src/providers/openrouter.ts` | Present with OpenRouter-specific usage/reasoning/prompt-cache options over Chat. | Responses-style OpenRouter support is absent. | +| xAI | `src/providers/xai.ts` | Present with Responses and Chat selectors. | Needs package-parity review against the AI SDK xAI provider. | +| GitHub Copilot | `src/providers/github-copilot.ts` | Present as explicit-base-URL OpenAI Chat/Responses facade. | Runtime/catalog integration remains specialized and should stay separate from public OpenAI-compatible defaults. | + +## V2 Runner Status + +`packages/core/src/session/runner/model.ts` currently resolves only this native subset from catalog `aisdk` metadata: + +| Catalog API | Native route used today | +| --- | --- | +| `aisdk:@ai-sdk/openai` | `OpenAIResponses.route` | +| `aisdk:@ai-sdk/anthropic` | `AnthropicMessages.route` | +| `aisdk:@ai-sdk/openai-compatible` with explicit URL | `OpenAICompatibleChat.route` | + +Everything else currently fails with `SessionRunnerModel.UnsupportedApiError` when the V2 native runner tries to resolve it. This includes `@ai-sdk/google`, `@ai-sdk/google-vertex`, `@ai-sdk/google-vertex/anthropic`, `@ai-sdk/azure`, `@ai-sdk/amazon-bedrock`, and `@ai-sdk/amazon-bedrock/mantle`. + +## AI SDK Package Parity Matrix + +| AI SDK package | Intended native target | Status | Biggest gaps | +| --- | --- | --- | --- | +| `@ai-sdk/openai` | `OpenAI.chat`, `OpenAI.responses`, `OpenAI.responsesWebSocket` | Partial / usable | Add complete typed option coverage, structured output strategy, explicit Responses continuation support, and runner route selection between Chat/Responses/WebSocket. | +| `@ai-sdk/openai-compatible` | Generic OpenAI-compatible Chat plus future Responses | Partial | Add OpenAI-compatible Responses. Decide per-family namespace/profile behavior for providers that support Responses versus Chat only. | +| `@ai-sdk/anthropic` | `AnthropicMessages` | Partial / usable | Finish Messages API parity for headers/betas/metadata/newer fields and document hosted-tool continuation expectations. | +| `@ai-sdk/google` | Gemini Developer API | Partial / usable | Add typed options for safety, response schema/modalities, cached content, grounding/search/code execution, and non-text output modes where supported. | +| `@ai-sdk/google-vertex` | Vertex Gemini namespace/facade | Missing | Implement Vertex endpoint derivation, ADC/OAuth auth, project/location/env resolution, OpenAI-compatible Vertex endpoint handling, and runner/catalog mapping. | +| `@ai-sdk/google-vertex/anthropic` | Anthropic Messages over Vertex namespace/facade | Missing | Implement Vertex Anthropic endpoint/auth selection, regional endpoint behavior, and compatibility with Anthropic Messages lowering/parsing. | +| `@ai-sdk/azure` | Azure OpenAI Chat/Responses facade | Partial | Map runner/catalog metadata to native Azure, handle resourceName/baseURL/apiVersion variants, add AAD/token auth story, and verify Chat vs Responses deployment selection. | +| `@ai-sdk/amazon-bedrock` | Bedrock Converse | Partial | Add default AWS credential chain/profile support, region/inference-profile model ID handling, provider option parity via `additionalModelRequestFields`, guardrails/performance config, and runner/catalog mapping. | +| `@ai-sdk/amazon-bedrock/mantle` | Bedrock Mantle OpenAI-compatible Chat/Responses namespace | Missing | Decide native Mantle shape, likely separate from Converse because it uses OpenAI-compatible Chat/Responses semantics over Bedrock. Add package mapping and tests. | + +## Highest-Risk Gaps + +1. Runner support is narrower than the LLM package. The package has native provider facades for Google, Azure, and Bedrock, but the V2 Session runner only maps OpenAI, Anthropic, and explicit OpenAI-compatible Chat from `aisdk` catalog metadata. +2. OpenAI-compatible is Chat-only. We need a separate OpenAI-compatible Responses slice for providers/deployments that expose `/responses`, not an overloaded Chat route. +3. Bedrock native auth is not AI SDK parity. The AI SDK plugin uses the default AWS provider chain, profile, container credentials, and Bedrock bearer token env behavior. Native Bedrock currently expects explicit credentials or bearer auth on the facade. +4. Vertex is not implemented natively. Google Gemini Developer API exists, but Vertex Gemini and Vertex Anthropic are separate auth/endpoint products and should be separate namespaces/facades. +5. Azure is only a provider facade, not a full runtime replacement. Native Azure exists, but the catalog runner does not select it, and token auth/resource variants need review. +6. Provider option typing is uneven. OpenAI, Anthropic, Gemini, Bedrock, and OpenRouter each expose a small typed subset plus raw HTTP overlays; this is useful but not equivalent to AI SDK provider option coverage. +7. Structured output is not provider-native yet. `LLM.generateObject` still uses a synthetic tool strategy, while the future design expects native structured output where reliable and tool fallback where needed. +8. Package/namespace boundaries need to be made explicit in docs and exports. Protocol namespaces exist, but planned public groupings should call out OpenAI Chat, OpenAI Responses, OpenAI-compatible Chat, OpenAI-compatible Responses, Anthropic Messages, Gemini, Vertex Gemini, Vertex Anthropic Messages, Bedrock Converse, and Bedrock Mantle as separate API slices. +9. Recorded coverage is uneven. OpenAI, Anthropic, Gemini, Bedrock Converse, Cloudflare, OpenRouter, and several OpenAI-compatible Chat providers have cassettes. Azure, Vertex, and Mantle need first-class recorded scenarios before switching defaults. + +## Proposed Native Namespace Shape + +These are implementation/API slices, not separate npm packages. + +| Namespace | Purpose | +| --- | --- | +| `OpenAI.Chat` or `OpenAIChat` | OpenAI `/chat/completions` semantics. | +| `OpenAI.Responses` or `OpenAIResponses` | OpenAI `/responses` HTTP and WebSocket semantics. | +| `OpenAICompatible.Chat` or `OpenAICompatibleChat` | Generic OpenAI-compatible `/chat/completions`. | +| `OpenAICompatible.Responses` or `OpenAICompatibleResponses` | Generic OpenAI-compatible `/responses`. Missing today. | +| `Anthropic.Messages` or `AnthropicMessages` | Anthropic Messages API. | +| `Google.Gemini` or `Gemini` | Gemini Developer API. | +| `GoogleVertex.Gemini` | Vertex Gemini API. Missing today. | +| `GoogleVertex.AnthropicMessages` | Vertex-hosted Anthropic Messages API. Missing today. | +| `Bedrock.Converse` or `BedrockConverse` | AWS Bedrock Converse API. | +| `Bedrock.Mantle` | AWS Bedrock Mantle OpenAI-compatible APIs. Missing today. | +| `Azure.OpenAIChat` / `Azure.OpenAIResponses` | Azure deployment specializations over OpenAI protocols. | + +## Suggested Next Work Slices + +1. Add native runner/catalog mappings for `@ai-sdk/azure`, `@ai-sdk/google`, and `@ai-sdk/amazon-bedrock` where the existing native facades are already close. +2. Implement `OpenAICompatibleResponses` as a separate protocol/route/facade instead of extending Chat. +3. Bring Bedrock native auth/config to AI SDK parity: region, profile, default AWS credential chain, bearer token env, endpoint override, and cross-region inference profile handling. +4. Add Vertex Gemini and Vertex Anthropic native facades with ADC/OAuth auth and project/location endpoint derivation. +5. Add Bedrock Mantle as a separate OpenAI-compatible Bedrock namespace after deciding whether it uses Chat, Responses, or both by model. +6. Expand typed provider options from the existing V1 lowerer knowledge in `packages/core/src/v1/config/provider-options.ts` before adding more raw overlay examples. +7. Add recorded provider tests for Azure, Vertex Gemini, Vertex Anthropic, Bedrock credential-chain behavior, and Mantle before making native runtime the default for those packages. diff --git a/packages/llm/src/protocols/anthropic-messages.ts b/packages/llm/src/protocols/anthropic-messages.ts index 1c0dcd32a4..f626827292 100644 --- a/packages/llm/src/protocols/anthropic-messages.ts +++ b/packages/llm/src/protocols/anthropic-messages.ts @@ -148,9 +148,22 @@ const AnthropicToolChoice = Schema.Union([ Schema.Struct({ type: Schema.tag("tool"), name: Schema.String }), ]) -const AnthropicThinking = Schema.Struct({ - type: Schema.tag("enabled"), - budget_tokens: Schema.Number, +const AnthropicThinking = Schema.Union([ + Schema.Struct({ + type: Schema.tag("enabled"), + budget_tokens: Schema.Number, + }), + Schema.Struct({ + type: Schema.tag("adaptive"), + display: Schema.optional(Schema.Literals(["summarized", "omitted"])), + }), + Schema.Struct({ + type: Schema.tag("disabled"), + }), +]) + +const AnthropicOutputConfig = Schema.Struct({ + effort: Schema.optional(Schema.String), }) const AnthropicBodyFields = { @@ -166,6 +179,7 @@ const AnthropicBodyFields = { top_k: Schema.optional(Schema.Number), stop_sequences: optionalArray(Schema.String), thinking: Schema.optional(AnthropicThinking), + output_config: Schema.optional(AnthropicOutputConfig), } const AnthropicMessagesBody = Schema.Struct(AnthropicBodyFields) export type AnthropicMessagesBody = Schema.Schema.Type @@ -492,7 +506,18 @@ const anthropicOptions = (request: LLMRequest) => request.providerOptions?.anthr const lowerThinking = Effect.fn("AnthropicMessages.lowerThinking")(function* (request: LLMRequest) { const thinking = anthropicOptions(request)?.thinking - if (!ProviderShared.isRecord(thinking) || thinking.type !== "enabled") return undefined + if (!ProviderShared.isRecord(thinking)) return undefined + if (thinking.type === "adaptive") { + const display = + thinking.display === "summarized" + ? ("summarized" as const) + : thinking.display === "omitted" + ? ("omitted" as const) + : undefined + return { type: "adaptive" as const, ...(display === undefined ? {} : { display }) } + } + if (thinking.type === "disabled") return { type: "disabled" as const } + if (thinking.type !== "enabled") return undefined const budget = typeof thinking.budgetTokens === "number" ? thinking.budgetTokens @@ -503,6 +528,11 @@ const lowerThinking = Effect.fn("AnthropicMessages.lowerThinking")(function* (re return { type: "enabled" as const, budget_tokens: budget } }) +const outputConfig = (request: LLMRequest) => { + const effort = anthropicOptions(request)?.effort + return typeof effort === "string" ? { effort } : undefined +} + const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) { const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined const generation = request.generation @@ -549,6 +579,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques top_k: generation?.topK, stop_sequences: generation?.stop, thinking: yield* lowerThinking(request), + output_config: outputConfig(request), } }) diff --git a/packages/llm/src/protocols/openai-chat.ts b/packages/llm/src/protocols/openai-chat.ts index 9ac85b07b1..656f8179d6 100644 --- a/packages/llm/src/protocols/openai-chat.ts +++ b/packages/llm/src/protocols/openai-chat.ts @@ -168,8 +168,6 @@ interface ParserState { readonly lifecycle: Lifecycle.State } -const invalid = ProviderShared.invalidRequest - // ============================================================================= // Request Lowering // ============================================================================= @@ -333,8 +331,6 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request: const lowerOptions = Effect.fn("OpenAIChat.lowerOptions")(function* (request: LLMRequest) { const store = OpenAIOptions.store(request) const reasoningEffort = OpenAIOptions.reasoningEffort(request) - if (reasoningEffort && !OpenAIOptions.isReasoningEffort(reasoningEffort)) - return yield* invalid(`OpenAI Chat does not support reasoning effort ${reasoningEffort}`) return { ...(store !== undefined ? { store } : {}), ...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}), diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index 4936d31c92..de18bf42a0 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -457,8 +457,6 @@ const lowerOptions = Effect.fn("OpenAIResponses.lowerOptions")(function* (reques const store = OpenAIOptions.store(request) const promptCacheKey = OpenAIOptions.promptCacheKey(request) const effort = OpenAIOptions.reasoningEffort(request) - if (effort && !OpenAIOptions.isReasoningEffort(effort)) - return yield* invalid(`OpenAI Responses does not support reasoning effort ${effort}`) const summary = OpenAIOptions.reasoningSummary(request) const include = OpenAIOptions.include(request) const verbosity = OpenAIOptions.textVerbosity(request) diff --git a/packages/llm/src/protocols/utils/openai-options.ts b/packages/llm/src/protocols/utils/openai-options.ts index 51e56ae216..5414923eda 100644 --- a/packages/llm/src/protocols/utils/openai-options.ts +++ b/packages/llm/src/protocols/utils/openai-options.ts @@ -1,11 +1,9 @@ import { Schema } from "effect" -import type { LLMRequest, ReasoningEffort, TextVerbosity as TextVerbosityValue } from "../../schema" +import type { LLMRequest, TextVerbosity as TextVerbosityValue } from "../../schema" import { ReasoningEfforts, TextVerbosity } from "../../schema" -export const OpenAIReasoningEfforts = ReasoningEfforts.filter( - (effort): effort is Exclude => effort !== "max", -) -export type OpenAIReasoningEffort = (typeof OpenAIReasoningEfforts)[number] +export const OpenAIReasoningEfforts = ReasoningEfforts +export type OpenAIReasoningEffort = string // Mirrors OpenAI's `ResponseIncludable` union from the official SDK. Keep this // in lockstep with `openai-node/src/resources/responses/responses.ts`. @@ -23,22 +21,16 @@ export type OpenAIResponseIncludable = (typeof OpenAIResponseIncludables)[number export const OpenAIServiceTiers = ["auto", "default", "flex", "priority"] as const export type OpenAIServiceTier = (typeof OpenAIServiceTiers)[number] -const REASONING_EFFORTS = new Set(ReasoningEfforts) -const OPENAI_REASONING_EFFORTS = new Set(OpenAIReasoningEfforts) const TEXT_VERBOSITY = new Set(["low", "medium", "high"]) const INCLUDABLES = new Set(OpenAIResponseIncludables) const SERVICE_TIERS = new Set(OpenAIServiceTiers) -export const OpenAIReasoningEffort = Schema.Literals(OpenAIReasoningEfforts) +export const OpenAIReasoningEffort = Schema.String export const OpenAITextVerbosity = TextVerbosity export const OpenAIResponseIncludable = Schema.Literals(OpenAIResponseIncludables) export const OpenAIServiceTier = Schema.Literals(OpenAIServiceTiers) -const isAnyReasoningEffort = (effort: unknown): effort is ReasoningEffort => - typeof effort === "string" && REASONING_EFFORTS.has(effort) - -export const isReasoningEffort = (effort: unknown): effort is OpenAIReasoningEffort => - typeof effort === "string" && OPENAI_REASONING_EFFORTS.has(effort) +export const isReasoningEffort = (effort: unknown): effort is OpenAIReasoningEffort => typeof effort === "string" const isTextVerbosity = (value: unknown): value is TextVerbosityValue => typeof value === "string" && TEXT_VERBOSITY.has(value) @@ -50,9 +42,9 @@ export const store = (request: LLMRequest): boolean | undefined => { return typeof value === "boolean" ? value : undefined } -export const reasoningEffort = (request: LLMRequest): ReasoningEffort | undefined => { +export const reasoningEffort = (request: LLMRequest): string | undefined => { const value = options(request)?.reasoningEffort - return isAnyReasoningEffort(value) ? value : undefined + return typeof value === "string" ? value : undefined } export const reasoningSummary = (request: LLMRequest): "auto" | undefined => diff --git a/packages/llm/src/schema/ids.ts b/packages/llm/src/schema/ids.ts index 7eb7409802..279a3097e1 100644 --- a/packages/llm/src/schema/ids.ts +++ b/packages/llm/src/schema/ids.ts @@ -27,7 +27,7 @@ export const ToolCallID = Schema.String export type ToolCallID = Schema.Schema.Type export const ReasoningEfforts = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] as const -export const ReasoningEffort = Schema.Literals(ReasoningEfforts) +export const ReasoningEffort = Schema.String export type ReasoningEffort = Schema.Schema.Type export const TextVerbosity = Schema.Literals(["low", "medium", "high"]) diff --git a/packages/llm/test/provider/anthropic-messages.test.ts b/packages/llm/test/provider/anthropic-messages.test.ts index 8989312958..14a35a4fa7 100644 --- a/packages/llm/test/provider/anthropic-messages.test.ts +++ b/packages/llm/test/provider/anthropic-messages.test.ts @@ -57,6 +57,23 @@ describe("Anthropic Messages route", () => { }), ) + it.effect("lowers adaptive thinking settings with effort", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.updateRequest(request, { + providerOptions: { + anthropic: { thinking: { type: "adaptive", display: "summarized" }, effort: "low" }, + }, + }), + ) + + expect(prepared.body).toMatchObject({ + thinking: { type: "adaptive", display: "summarized" }, + output_config: { effort: "low" }, + }) + }), + ) + it.effect("lowers chronological system updates natively for Claude Opus 4.8 with cache hints", () => Effect.gen(function* () { const prepared = yield* LLMClient.prepare( diff --git a/packages/llm/test/provider/openai-chat.test.ts b/packages/llm/test/provider/openai-chat.test.ts index b736dc9dd3..63ae09bdd8 100644 --- a/packages/llm/test/provider/openai-chat.test.ts +++ b/packages/llm/test/provider/openai-chat.test.ts @@ -98,12 +98,26 @@ describe("OpenAI Chat route", () => { LLM.request({ model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).chat("gpt-4o-mini"), prompt: "think", - providerOptions: { openai: { reasoningEffort: "low" } }, + providerOptions: { openai: { reasoningEffort: "max" } }, }), ) expect(prepared.body.store).toBe(false) - expect(prepared.body.reasoning_effort).toBe("low") + expect(prepared.body.reasoning_effort).toBe("max") + }), + ) + + it.effect("passes through custom OpenAI-compatible reasoning effort strings", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + prompt: "think", + providerOptions: { openai: { reasoningEffort: "experimental" } }, + }), + ) + + expect(prepared.body.reasoning_effort).toBe("experimental") }), ) diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts index cd8bad51af..fbc5f2a864 100644 --- a/packages/llm/test/provider/openai-responses.test.ts +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -69,6 +69,16 @@ describe("OpenAI Responses route", () => { }), ) + it.effect("passes through custom OpenAI reasoning effort strings", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.updateRequest(request, { providerOptions: { openai: { reasoningEffort: "experimental" } } }), + ) + + expect(prepared.body.reasoning).toEqual({ effort: "experimental" }) + }), + ) + it.effect("omits unsupported semantic service tiers", () => Effect.gen(function* () { const prepared = yield* LLMClient.prepare( diff --git a/packages/opencode/specs/simulation/simulation-phases.md b/packages/opencode/specs/simulation/simulation-phases.md new file mode 100644 index 0000000000..ef7aa41e3f --- /dev/null +++ b/packages/opencode/specs/simulation/simulation-phases.md @@ -0,0 +1,170 @@ +# Simulation Implementation Phases + +Status: implementation plan for `specs/simulation/simulation.md`. + +The full simulation architecture is intentionally broad. This document breaks it into phases that can be implemented and reviewed incrementally. + +## Phase 1: Control Surface And Observability + +Goal: start the normal app in simulation mode and inspect/drive the TUI through an external WebSocket driver. + +This phase proves the core shape without swapping every foundational layer yet. + +Implementation checklist: + +- [x] Add `OPENCODE_SIMULATION=1` activation in V1/full-TUI startup. +- [x] Add simulation trace service with in-memory append-only records. +- [x] Add OpenTUI UI state extraction for screen, focus, elements, and generated actions. +- [x] Add OpenTUI UI action execution for typing, keys, enter, arrows, focus, and click. +- [x] Add reusable JSON-RPC WebSocket server on `127.0.0.1:40900+`. +- [x] Expose `ui.state`, `ui.action`, `ui.render`. +- [x] Expose `trace.list`, `trace.clear`, `trace.export`. +- [x] Wire visible V1/full-TUI renderer path through the same action protocol. +- [ ] Verify a local driver can inspect state and execute a real TUI input. + +Scope: + +- Add `OPENCODE_SIMULATION=1` activation. +- Start a TUI-owned JSON-RPC WebSocket server on `127.0.0.1:40900+`. +- Expose `ui.state`, `ui.action`, `ui.render`. +- Use the old simulation action model: type text, press keys, press enter, arrows, focus, click. +- Support fake OpenTUI renderer and visible renderer through the same action protocol. +- Add in-memory append-only trace with `trace.list`, `trace.clear`, `trace.export`. +- Record UI observations, generated actions, executed actions, errors, and render/stabilization events. + +Done when: + +- `OPENCODE_SIMULATION=1 bun run dev` starts the normal app. +- A local driver can connect to the WebSocket. +- The driver can inspect current screen/elements/actions. +- The driver can execute real TUI inputs. +- The trace shows observations and actions. + +Out of scope: + +- Backend layer replacement. +- Model-based runner. +- Generated plugin config. +- Deterministic replay tests. + +## Phase 2: Foundational Simulation Layers + +Goal: make the app safe and controlled by swapping the lowest layers, not app logic. + +Scope: + +- Wire simulation replacements through `AppNodeBuilder.build(...)` and `AppNodeBuilderV1.build(...)`. +- Create a real, empty anchor directory (`mkdtemp`) and `process.chdir` into it before any command resolves its working directory; skip creation when the runner already spawned the app inside an anchor. +- Root the in-memory filesystem at `process.cwd()` (the anchor). No cwd monkey-patching: cwd, `$PWD`, and `path.resolve()` stay truthful. +- Add snapshot loading from `OPENCODE_SIMULATION_STATE`: read the snapshot directory once at startup and seed the in-memory filesystem (snapshot `project/` paths joined onto the anchor root), config, env, and optional LLM/network state from it. +- Route config/data/state/cache/temp paths into the simulated space using existing env seams (`OPENCODE_CONFIG_DIR`, `OPENCODE_TEST_HOME`, `OPENCODE_DB=:memory:`), set before `packages/core/src/global.ts` import-time path setup runs. +- Deny host filesystem escapes loudly (paths outside the anchor root fail with typed simulation errors). +- Assert the anchor directory on the host is still empty at the end of the run; anything written there means a code path bypassed the simulated filesystem. +- Add simulated network registry and deny unknown external network by default. +- Add scriptable LLM boundary. +- Add simulated process registry: + - shell through `just-bash` against the simulated filesystem. + - minimal fake `git` support for discovery/status paths. + - deny unsupported process spawns. +- Add simulation-gated backend control routes, proxied only through the frontend WebSocket. +- Expose backend methods through the frontend server: filesystem seed/write, network register, LLM enqueue, backend snapshot. +- Trace filesystem, network, LLM, process, and backend control activity. + +Done when: + +- Unknown network fails with a simulation error. +- Host filesystem escape fails with a simulation error. +- The anchor directory on the host is empty after a run. +- The app boots from a snapshot directory via `OPENCODE_SIMULATION_STATE` and observes the seeded project files, config, and env through normal app paths. +- A driver can seed a project filesystem. +- A driver can enqueue an LLM script and submit a prompt through the TUI. +- The real session/tool path consumes the scripted LLM behavior. +- Shell commands use `just-bash`; unsupported process spawns fail. +- Trace contains backend activity and snapshots. + +Out of scope: + +- Model-based generation. +- Generated plugin config state. +- Shrinking. + +## Phase 3: Generated Config And Model-Based Runner + +Goal: explore different app states using generated commands and plugin-provided config state. + +Scope: + +- Add generated simulation plugins as the primary config-state generation mechanism. +- Support generated plugin domains for: + - agents and defaults. + - provider/model availability. + - tool definitions and scripted tool behavior. + - MCP-like capabilities or endpoints. + - permission policies. + - instructions/system-context-like inputs where supported. + - workspace/project adapters where supported. +- Add runner commands to generate, enable, disable, and inspect generated plugin state. +- Build a custom external model-based runner, not `fast-check` yet. +- Runner command shape: precondition, execute, model update, postcondition. +- Runner model tracks only high-level observational state: screen category, prompt availability, sessions, files, queued LLM scripts, generated plugins, backend status, idle expectation. +- Generate valid command sequences from model state and current `ui.state.actions`. +- Record seed, command distribution, precondition rejections, generated plugin/config domain coverage, UI action coverage, and backend event coverage. + +Done when: + +- A seeded runner can generate a short valid exploration. +- The runner can generate plugin-provided config state without generating large arbitrary config files. +- The app loads and observes generated plugin state through normal plugin/config paths. +- The runner can type and submit prompts through the TUI using generated actions. +- Basic properties run after commands: no crash, no unknown network, no host FS escape, coherent stabilized state. +- Trace export includes enough state to replay the generated run later. + +Out of scope: + +- Shrinking. +- Coverage-guided mutation corpus. +- Differential testing. +- CI randomized runs. + +## Phase 4: Replay, Promotion, And Campaigns + +Goal: turn exploratory simulation into durable tests and prepare for larger campaigns. + +Scope: + +- Add replay from exported trace. +- Add deterministic replay test generation from successful or failing traces. +- Add stronger trace schema validation. +- Add property families beyond no-crash: + - durable prompt admission is not lost. + - no duplicated visible message IDs. + - no orphan tool results. + - queue/steer semantics hold at stabilization boundaries. + - interrupt/resume does not duplicate promoted inputs. +- Add corpus storage for interesting traces. +- Add simple coverage/novelty scoring over UI states, backend event types, tool outcomes, generated config domains, and errors. +- Add long-running campaign mode outside normal CI. + +Done when: + +- A trace from Phase 3 can be replayed deterministically. +- A trace can be promoted to a normal test fixture. +- Campaign runs can collect interesting traces without committing randomized tests to CI. +- Failures produce a compact reproduction command and trace export. + +Out of scope: + +- Full shrinking. +- Deterministic scheduler/clock control. +- Parallel campaigns. +- Differential testing across app versions. + +## Later Work + +- Shrinking failed traces. +- Coverage-guided mutation of structured traces. +- `fast-check` integration if the custom runner becomes too limited. +- Differential testing across versions, renderers, storage modes, or scheduler policies. +- Deterministic clock/random/scheduler control. +- Parallel isolated workers. +- Model-generated properties with validity/soundness/coverage scoring. diff --git a/packages/opencode/specs/simulation/simulation.md b/packages/opencode/specs/simulation/simulation.md new file mode 100644 index 0000000000..5478796e78 --- /dev/null +++ b/packages/opencode/specs/simulation/simulation.md @@ -0,0 +1,489 @@ +# Opencode Simulation Architecture + +Status: first milestone architecture draft. + +## Goal + +Build a simulation environment for exploring opencode through the real app, primarily through the TUI, while replacing only the lowest foundational layers needed to make runs controlled, observable, and safe. + +The first milestone is an interactive exploration and model-based testing environment. It should be enough to start opencode normally, put the app into generated states, drive real user-level TUI actions, observe what happened, and record an in-memory trace that can later be exported into deterministic replay tests. + +This is not intended to be a custom simulated app or a separate `simulate` command. The normal app should run, with simulation enabled by one required flag: + +```sh +OPENCODE_SIMULATION=1 bun run dev +``` + +## Non-Goals + +- Do not reimplement the app. +- Do not replace mid-level services like session processing, tool registry, provider orchestration, route trees, or TUI components unless a foundational seam proves impossible. +- Do not build shrinking in the first milestone. +- Do not make generated randomized runs part of CI yet. +- Do not build differential testing in the first milestone. +- Do not expose simulation controls when `OPENCODE_SIMULATION` is not set. + +## Design Principles + +- Run the real app through normal commands. +- Drive the TUI using real user-level input: typing, keypresses, focus, click, and mouse actions. +- Keep simulation code isolated under a simulation/testing area. +- Touch production app code only at narrow activation points: builders, TUI startup, foundational layers, and simulation-gated backend routes. +- Swap foundational layers, not app logic. +- Make observations rich enough for humans and models. +- Treat traces as first-class artifacts. +- Use a lightweight model of expected high-level behavior, not a clone of opencode internals. +- Generate valid commands from current observed state rather than blindly fuzzing impossible actions. + +## Activation + +`OPENCODE_SIMULATION=1` is the only required flag. + +Initial state is provided through an optional snapshot directory: + +```sh +OPENCODE_SIMULATION=1 OPENCODE_SIMULATION_STATE=/path/to/snapshot bun run dev +``` + +Optional flags can be added later, but should stay minimal. Reasonable optional parameters later include renderer mode, trace output path, seed, or port override. + +All simulation parameters are environment variables, not CLI flags. This is a hard requirement: `packages/core/src/global.ts` computes and creates XDG paths at module import time, so anything that redirects paths must be in place before the first import. Environment variables set by the parent process (or read at the very top of startup) satisfy this; CLI flags parsed after imports do not. + +When enabled: + +- The app creates and changes into a real, empty anchor directory (see Filesystem). +- The app reads the snapshot directory, if provided, and seeds all simulated state from it. +- The app builds with simulation layer replacements. +- The TUI process starts a loopback WebSocket control server. +- Simulation-gated backend control routes become available only to the frontend/control path. +- In-memory trace recording starts automatically. + +Path seams reuse existing environment variables where they already exist: `OPENCODE_CONFIG_DIR` for global config, `OPENCODE_TEST_HOME` for home, and `OPENCODE_DB=:memory:` for the database. Simulation mode should set these before foundational modules load rather than inventing parallel mechanisms. + +## Control Server + +The external control surface lives in the TUI/frontend process, not the backend API server. + +This is important because the frontend has direct access to the renderer, screen state, focus state, interactable elements, and user input APIs. The backend remains the normal backend, with only simulation-gated control routes used internally by the frontend when needed. + +Protocol: + +- JSON-RPC 2.0 over WebSocket. +- Loopback only. +- Start at `127.0.0.1:40900`. +- If occupied, scan upward and report the actual URL. +- External drivers connect only to this frontend WebSocket. + +The app should not send JSON-RPC requests back to the driver in the first milestone. The driver sends requests; the app responds and emits notifications/events as useful. + +Initial method groups: + +- `ui.state`: return screen, elements, focus, and generated possible actions. +- `ui.action`: execute one real user-level action. +- `ui.render`: force or wait for a render and return state. +- `backend.filesystem.seed`: seed project files. +- `backend.filesystem.write`: write one file. +- `backend.network.register`: register a fake network response. +- `backend.llm.enqueue`: queue scripted LLM behavior. +- `backend.snapshot`: return backend simulation state. +- `trace.list`: return trace records. +- `trace.clear`: clear in-memory trace. +- `trace.export`: export trace JSON for replay/test generation. +- `run.stabilize`: wait for frontend/backend quiescence and return observations. + +## TUI Actions + +The old simulation branch had the right basic shape: observe OpenTUI renderables, derive executable actions, and execute those actions through OpenTUI input/mouse APIs. + +The first action vocabulary should stay close to that work: + +```ts +type UIAction = + | { type: "typeText"; text: string } + | { type: "pressKey"; key: string; modifiers?: KeyModifiers } + | { type: "pressEnter" } + | { type: "pressArrow"; direction: "up" | "down" | "left" | "right" } + | { type: "focus"; target: number } + | { type: "click"; target: number; x: number; y: number } +``` + +`ui.state` should return: + +- Current screen text. +- Focused renderable/editor state. +- Interactable elements. +- Generated actions valid for the current UI state. + +Elements should include stable-enough semantic data where available: + +- Renderable ID and numeric target. +- Position and dimensions. +- Focusable/clickable/editor flags. +- Focused flag. +- Text or label when available. +- Role/capability when available. + +Both fake OpenTUI renderer and visible terminal renderer should share this protocol. The architecture should support both; the default can be decided later. + +## Backend Control + +The backend server should be exactly the normal backend server. + +Simulation-only backend routes may exist, but only when `OPENCODE_SIMULATION=1`. They are private implementation details for the frontend simulation server to proxy commands like filesystem seeding, LLM scripting, network registration, and snapshots. + +External drivers should not use backend simulation routes directly. + +## Foundational Layer Replacement + +Current `origin/dev` has the right seam: `AppNodeBuilder.build(...)` and `AppNodeBuilderV1.build(...)` accept replacements over `LayerNode`s. Simulation should use those seams instead of adding large alternate app assemblies. + +First milestone replacements: + +- Filesystem. +- Network / HTTP client. +- LLM boundary. +- Process spawner. + +First milestone generated state surfaces: + +- Filesystem/project state. +- Network responses. +- LLM scripts. +- Process registry behavior. +- Plugin-generated config state. + +Likely later replacements: + +- Clock/random. +- Database path/isolation. +- Global paths/temp paths. + +The goal is to swap things at the bottom of the app. Everything above these foundational services should behave as production code. + +## Filesystem + +The filesystem simulation is in-memory, anchored at a real empty directory. + +On startup in simulation mode: + +1. Create a real, empty anchor directory with `mkdtemp` (for example `$TMPDIR/opencode-sim-XXXXXX`). +2. `process.chdir(anchor)` before any command resolves its working directory. +3. Use `process.cwd()` — now the anchor — as the root of the in-memory filesystem. +4. Seed the in-memory filesystem from the snapshot directory, joining snapshot-relative paths onto the anchor root. + +The anchor directory on the host stays empty for the entire run. All file content lives only in the in-memory filesystem. + +Rationale for the real anchor: + +- `process.cwd()`, `$PWD`, and `path.resolve()` are all genuinely correct with zero patching. The previous simulation branch used a virtual root (`/opencode`) that existed nowhere on the host, which forced monkey-patching `process.cwd` and `$PWD` and left raw `fs` relative-path resolution silently disagreeing with the faked cwd. +- The codebase reads `process.cwd()` at process edges (CLI entry points, TUI frontend, request-fallback in workspace routing) and converts it into an explicit `directory` value early; core never reads it directly. A truthful cwd at startup means every downstream consumer inherits the virtual root without touching those call sites. +- Leak detection is free: the anchor must be empty at the end of the run. Any file that appears there means some code path bypassed the simulated filesystem. This is an assertable invariant. +- Host filesystem bypasses read an empty directory instead of the developer's real project. Bypassed reads fail loudly instead of returning wrong-but-plausible data. + +Rationale for in-memory content: + +- The run is hermetic: no host writes, no cleanup dependencies, no cross-run contamination. +- Snapshots load and reset quickly, which matters for model-based runs that reset state often. +- The containment check (path must be inside the anchor root) doubles as the host-escape guard with a truthful boundary. + +The in-memory filesystem is still controlled and isolated: + +- Each run gets its own anchor root. +- Project files, config, data, state, cache, and temp paths should resolve inside that root (via `OPENCODE_CONFIG_DIR`, `OPENCODE_TEST_HOME`, and `OPENCODE_DB=:memory:`). +- Paths outside the anchor root fail loudly with a typed simulation error. +- Trace should record seeded files and file diffs/observations needed for replay. + +The anchor may be created by the app itself at activation, or by an external runner that spawns the app with the anchor as its working directory. Both should work: the app creates and enters an anchor only when its current directory is not already a designated anchor. + +## Initial State Snapshot + +`OPENCODE_SIMULATION_STATE` points at a directory containing one complete initial state. On startup the app slurps this directory once and constructs all simulated state from it. The snapshot is never written back to; it is a pure input. + +Proposed layout: + +```text +snapshot/ + project/... # workspace files, seeded into the in-memory FS under the anchor root + config/opencode.json # global config; the directory backs OPENCODE_CONFIG_DIR + env.json # extra environment values to apply + llm/... # scripted LLM behavior to pre-enqueue (optional) + network/... # network response registrations (optional) +``` + +Rules: + +- Paths inside `project/` are snapshot-relative. The loader joins them onto the anchor root, so absolute virtual paths look like real host paths under the anchor. +- Anything the config references (skills, instructions, reference paths) must exist inside `project/`. A snapshot that references missing files is invalid. +- The snapshot directory format is the contract between external state generators and the app. Generators (such as the opencode-probe project) produce snapshot directories plus a derived expected model; the app consumes only the snapshot. +- Seeding through the control server (`backend.filesystem.seed` and friends) remains available for incremental changes during a run; the snapshot covers initial state. + +## Configuration Via Generated Plugins + +Generated configuration is a core first-milestone feature. + +Much of opencode behavior is driven by config. The simulation runner needs to put the app into many different config-shaped states: different agents, tools, providers, MCP servers, permissions, modes, instructions, formatting settings, feature flags, and other config-dependent behavior. + +The runner should not primarily generate arbitrary config files. Instead, the simulation should express config-shaped state as generated plugins. + +Rationale: + +- Plugins are already a normal extension surface for opencode behavior. +- Generated plugins can produce app states without making the simulation depend on config-file syntax and file layout details. +- Plugin-generated state keeps setup closer to runtime behavior: the app reads config, loads plugins, and observes plugin-provided behavior through normal app paths. +- Plugins are a better unit for model-based generation because they can be named, versioned, traced, reused, and minimized independently. + +The first implementation should support generated simulation plugins that can contribute or affect config-equivalent domains such as: + +- Agents and agent defaults. +- Provider/model availability. +- Tool definitions and tool behavior. +- MCP-like capabilities or endpoints. +- Permission defaults and policies. +- Instructions/system-context-like inputs where supported. +- Formatting/project behavior where supported. +- Workspace/project adapters where supported. + +The simulation can still write the minimal bootstrap state needed for opencode to discover generated plugins, but the interesting generated state should live in plugin definitions rather than large generated `opencode.json` files. + +Trace should record: + +- Generated plugin IDs. +- Plugin-provided config/state fragments. +- Plugin hooks registered. +- Any plugin load/config errors. +- Which generated plugin state was active for each run. + +The model-based runner should include commands for generating and enabling plugin state. These commands should have normal preconditions and postconditions just like UI actions or backend setup commands. + +Example command families: + +- Generate a provider/model plugin. +- Generate an agent configuration plugin. +- Generate a tool plugin with scripted behavior. +- Generate permission policy state. +- Generate MCP-like tool/resource state. +- Enable or disable a generated plugin for the next app run. + +This is the main mechanism for exploring app states driven by configuration. + +## Network + +Unknown external network should fail loudly by default. + +The simulation network should support explicit response registration: + +- JSON response. +- Text response. +- Bytes response later if needed. +- Status-only response. +- Handler-style response later if needed. + +Loopback traffic needed by the app/frontend/backend may be allowed explicitly. + +All network calls should be traceable: + +- Method. +- URL. +- Request headers/body where safe. +- Matched simulation route. +- Status. +- Response summary. +- Error if denied. + +## LLM + +The LLM boundary should be scriptable. + +The driver can enqueue scripts that describe model behavior: + +- Text chunks. +- Thinking/reasoning chunks if relevant. +- Tool calls. +- Errors. +- Finish reason. + +The real session and tool pipeline should consume this behavior through the normal app path. The simulation should not bypass `SessionPrompt`, `SessionProcessor`, or tool execution. + +Missing scripted LLM behavior should fail with a clear simulation error unless a default response is explicitly configured. + +## Process Spawning + +External process spawning should be denied by default. + +The first milestone should provide a simulated process registry. This should be inspired by the old branch: + +- Shell commands can run through `just-bash` against the simulated filesystem. +- A small fake `git` command set can support project discovery/status paths needed by the app. +- Unsupported process spawns fail loudly. + +This preserves the rule that simulation does not spawn arbitrary external programs while still allowing useful shell/tool flows. + +## Trace + +Trace recording is always on in simulation mode, in memory for the first milestone. + +Trace entries should be append-only JSON-compatible records. They do not need to be written to disk initially, but `trace.export` should return a structure suitable for later replay and test generation. + +Trace should include: + +- Run metadata: seed, app version, renderer mode, WebSocket URL. +- Initial world setup. +- UI observations. +- Generated UI actions. +- Executed UI actions. +- Backend control requests. +- Backend snapshots. +- Network requests and matches/denials. +- LLM scripts enqueued and consumed. +- Tool calls and results. +- Permission decisions. +- Filesystem seed/write/diff summaries. +- Generated plugin/config state and load results. +- Stabilization boundaries. +- Errors and crashes. +- Model command execution and postcondition results. + +The trace is the bridge between exploratory simulation and deterministic tests. + +## Model-Based Runner + +The first runner is an external driver connecting to the frontend WebSocket. + +Use a custom runner for now, not `fast-check`. It should still follow the core shape used by property/model-based testing libraries: + +```ts +interface Command { + readonly name: string + check(model: Model): boolean + run(model: Model, app: SimulationClient): Promise +} +``` + +Basic runner responsibilities: + +- Keep a lightweight model of high-level expected state. +- Generate commands whose preconditions match the model and current app observations. +- Execute commands through the WebSocket. +- Update the model. +- Check postconditions/invariants. +- Record all steps in the trace. +- Support seed/replay. +- Track simple distribution stats. + +The model should track high-level, observational state only, such as: + +- Current screen/route category. +- Whether prompt editor is available. +- Known sessions. +- Known files and expected file contents/diffs. +- Queued LLM scripts. +- Recent backend/session status. +- Whether app is expected to be idle. + +The model must not track implementation internals like fibers, exact runner loop state, cache internals, or database implementation details. + +Initial command families: + +- Seed filesystem. +- Generate and enable plugin config state. +- Register network response. +- Enqueue LLM script. +- Observe UI state. +- Execute one generated UI action. +- Type prompt text. +- Press enter. +- Stabilize. +- Assert no crash. +- Assert visible response or file effect. +- Export trace. + +## Generators + +The first milestone should include generation, but not shrinking. + +Generation should be model-based and state-aware: + +- Generate from currently valid `ui.state.actions`. +- Generate backend setup commands from scenario/model state. +- Generate plugin-provided config state. +- Generate LLM scripts that match likely user prompts and tool flows. +- Generate short command sequences using preconditions. +- Use a seed so runs can be replayed. +- Use simple weights to avoid degenerate action selection. + +The generator should not attempt to produce arbitrary full app states upfront. It should build state by executing commands through the real app and observing the result. + +Important stats to record: + +- Seed. +- Command counts. +- Action type distribution. +- Generated plugin/config domain distribution. +- Rejected command/precondition counts. +- UI element/action coverage. +- Backend event type coverage where available. +- Errors and stabilization failures. + +## Properties + +First milestone properties should be simple and high-signal: + +- App does not crash. +- Backend does not crash. +- Unknown network is denied. +- Host filesystem escape is denied. +- Prompt submission can reach a scripted LLM response. +- Stabilization eventually reaches a coherent idle state for the demo flow. +- File effects from scripted tool behavior are observable in the simulated filesystem. +- Trace contains enough information to replay the run. + +More advanced model/refinement, metamorphic, and differential properties are future work. + +## First Demo Flow + +The first major demo should show this system as a real environment for exploring the app in controlled states: + +1. Start opencode normally with `OPENCODE_SIMULATION=1`. +2. TUI starts and exposes the simulation WebSocket on `127.0.0.1:40900+`. +3. External runner connects. +4. Runner provides a snapshot directory (or seeds the in-memory project filesystem through the control server). +5. Runner generates and enables plugin-provided config state. +6. Runner queues a scripted LLM response. +7. Runner observes `ui.state` and generated actions. +8. Runner drives real TUI input to type and submit a prompt. +9. App processes the prompt through the real backend/session/tool path. +10. Scripted LLM response appears or executes a file-affecting tool flow. +11. Runner stabilizes the app. +12. Runner inspects trace, backend snapshot, UI state, generated plugin state, and filesystem state. +13. Runner exports a deterministic replay trace. + +## Done-When Checklist + +- `OPENCODE_SIMULATION=1` starts the normal app with simulation wiring. +- Simulation code is isolated under a dedicated simulation/testing area. +- App changes outside simulation are limited to activation hooks, builder replacements, TUI startup, and gated backend routes. +- TUI exposes JSON-RPC WebSocket on `127.0.0.1:40900+`. +- Driver can call `ui.state`. +- Driver can execute generated UI actions. +- Fake and visible renderer paths use the same action protocol. +- Driver can seed filesystem state. +- Driver can generate and enable plugin-provided config state. +- Driver can register network responses and observe denied unknown network. +- Driver can enqueue LLM scripts. +- External process spawning is denied by default, with shell via `just-bash` and minimal fake process registry support. +- Driver can run a basic model-based generated command sequence. +- In-memory trace records observations/actions/backend interactions. +- Driver can list, clear, and export trace. +- Demo flow succeeds end-to-end. + +## Future Directions + +- Shrinking failed traces. +- Promote minimized traces into normal committed tests. +- Coverage-guided corpus and structured trace mutation. +- Richer semantic UI grounding for model-driven exploration. +- LLM-generated property proposals with validity/soundness checks. +- Differential testing across app versions, renderers, or storage modes. +- Deterministic scheduler/clock/random control. +- Parallel campaigns with isolated workers. +- File-backed trace persistence and replay CLI. diff --git a/packages/opencode/src/cli/cmd/attach.ts b/packages/opencode/src/cli/cmd/attach.ts index 09f227c259..51bafef779 100644 --- a/packages/opencode/src/cli/cmd/attach.ts +++ b/packages/opencode/src/cli/cmd/attach.ts @@ -43,32 +43,8 @@ export const AttachCommand = cmd({ alias: ["u"], type: "string", describe: "basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')", - }) - .option("mini", { - type: "boolean", - describe: "start the minimal interactive interface", - default: false, - }) - .option("replay", { - type: "boolean", - hidden: true, - }) - .option("no-replay", { - type: "boolean", - describe: "disable mini session history replay on resume and after resize", - }) - .option("replay-limit", { - type: "number", - describe: "cap visible mini replay to the newest N messages", }), handler: async (args) => { - if (args.replay === true) { - UI.error("--replay is not supported; replay is enabled by default") - process.exitCode = 1 - return - } - const noReplay = args.replay === false || args.noReplay === true - const directory = (() => { if (!args.dir) return undefined try { @@ -80,32 +56,6 @@ export const AttachCommand = cmd({ } })() - if (args.mini) { - const { runMini } = await import("./run") - await runMini({ - attach: args.url, - directory, - password: args.password, - username: args.username, - continue: args.continue, - session: args.session, - fork: args.fork, - replay: noReplay ? false : undefined, - replayLimit: args.replayLimit, - }) - return - } - - const unsupported = [ - ["--no-replay", noReplay], - ["--replay-limit", args.replayLimit !== undefined], - ].find((entry) => entry[1])?.[0] - if (unsupported) { - UI.error(`${unsupported} requires --mini`) - process.exitCode = 1 - return - } - const { TuiConfig } = await import("@/config/tui") if (args.fork && !args.continue && !args.session) { UI.error("--fork requires --continue or --session") diff --git a/packages/opencode/src/cli/cmd/mini.ts b/packages/opencode/src/cli/cmd/mini.ts new file mode 100644 index 0000000000..96faa2e88e --- /dev/null +++ b/packages/opencode/src/cli/cmd/mini.ts @@ -0,0 +1,172 @@ +import type { Argv } from "yargs" +import { cmd } from "./cmd" +import { UI } from "@/cli/ui" +import { resolveThreadDirectory } from "./tui" + +type ReplayArgs = { + replay?: boolean + noReplay?: boolean +} + +type MiniArgs = ReplayArgs & { + continue?: boolean + session?: string + fork?: boolean + replayLimit?: number +} + +type MiniLocalArgs = MiniArgs & { + project?: string + model?: string + agent?: string + prompt?: string + demo?: boolean +} + +type MiniAttachArgs = MiniArgs & { + url: string + dir?: string + password?: string + username?: string +} + +function replay(args: ReplayArgs) { + if (args.replay === true) { + UI.error("--replay is not supported; replay is enabled by default") + process.exitCode = 1 + return "invalid" as const + } + return args.replay === false || args.noReplay === true ? false : undefined +} + +function miniOptions(yargs: Argv) { + return yargs + .option("continue", { + alias: ["c"], + describe: "continue the last session", + type: "boolean", + }) + .option("session", { + alias: ["s"], + describe: "session id to continue", + type: "string", + }) + .option("fork", { + type: "boolean", + describe: "fork the session when continuing (use with --continue or --session)", + }) + .option("replay", { + type: "boolean", + hidden: true, + }) + .option("no-replay", { + type: "boolean", + describe: "disable session history replay on resume and after resize", + }) + .option("replay-limit", { + type: "number", + describe: "cap visible replay to the newest N messages", + }) +} + +/** @internal Exported for CLI parser tests. */ +export const MiniLocalCommand = cmd<{}, MiniLocalArgs>({ + command: "$0 [project]", + describe: "start the minimal interactive interface", + builder: (yargs) => + miniOptions( + yargs + .positional("project", { + type: "string", + describe: "path to start opencode in", + }) + .option("model", { + type: "string", + alias: ["m"], + describe: "model to use in the format of provider/model", + }) + .option("agent", { + type: "string", + describe: "agent to use", + }) + .option("prompt", { + type: "string", + describe: "prompt to use", + }) + .option("demo", { + type: "boolean", + hidden: true, + }), + ), + handler: async (args) => { + const shouldReplay = replay(args) + if (shouldReplay === "invalid") return + + const { runMini } = await import("./run") + await runMini({ + directory: resolveThreadDirectory(args.project), + continue: args.continue, + session: args.session, + fork: args.fork, + model: args.model, + agent: args.agent, + prompt: args.prompt, + replay: shouldReplay, + replayLimit: args.replayLimit, + demo: args.demo, + }) + }, +}) + +/** @internal Exported for CLI parser tests. */ +export const MiniAttachCommand = cmd<{}, MiniAttachArgs>({ + command: "attach ", + describe: "attach to a running opencode server with the minimal interface", + builder: (yargs) => + miniOptions( + yargs + .positional("url", { + type: "string", + describe: "http://localhost:4096", + demandOption: true, + }) + .option("dir", { + type: "string", + describe: "directory on the remote server", + }) + .option("password", { + alias: ["p"], + type: "string", + describe: "basic auth password (defaults to OPENCODE_SERVER_PASSWORD)", + }) + .option("username", { + alias: ["u"], + type: "string", + describe: "basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')", + }), + ), + handler: async (args) => { + const shouldReplay = replay(args) + if (shouldReplay === "invalid") return + + const { runMini } = await import("./run") + await runMini({ + attach: args.url, + directory: args.dir, + password: args.password, + username: args.username, + continue: args.continue, + session: args.session, + fork: args.fork, + replay: shouldReplay, + replayLimit: args.replayLimit, + }) + }, +}) + +export const MiniCommand = cmd({ + command: "mini", + describe: "start the minimal interactive interface", + builder: (yargs) => yargs.command(MiniLocalCommand).command(MiniAttachCommand).demandCommand(), + handler: async () => {}, +}) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 3927f615a0..235dce64be 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -1,13 +1,13 @@ import type { PermissionV1 } from "@opencode-ai/core/v1/permission" import { FSUtil } from "@opencode-ai/core/fs-util" -// CLI entry point for `opencode run` and `opencode --mini`. +// CLI entry point for `opencode run` and `opencode mini`. // // Handles three modes: // 1. Non-interactive (default): sends a single prompt, streams events to // stdout, and exits when the session goes idle. -// 2. Interactive local (`opencode --mini`): boots the split-footer direct mode +// 2. Interactive local (`opencode mini`): boots the split-footer direct mode // with an in-process server (no external HTTP). -// 3. Interactive attach (`opencode --mini --attach`): connects to a running +// 3. Interactive attach (`opencode mini attach`): connects to a running // opencode server and runs interactive mode against it. // // Also supports `--command` for slash-command execution, `--format json` for @@ -25,6 +25,8 @@ import { Filesystem } from "@/util/filesystem" import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2" import { FormatError, FormatUnknownError } from "../error" import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./run/runtime.stdin" +import { isImageAttachment, isPdfAttachment } from "@/util/media" +import { loadRunAgents } from "./run/catalog.shared" type ModelInput = Parameters[0]["model"] @@ -49,6 +51,14 @@ function resolveRunInput(value?: string, piped?: string): string | undefined { return value + "\n" + piped } +function isBinaryContent(bytes: Uint8Array) { + if (bytes.length === 0) return false + if (bytes.includes(0)) return true + return ( + bytes.reduce((count, byte) => count + Number(byte < 9 || (byte > 13 && byte < 32)), 0) / bytes.length > 0.3 + ) +} + type FilePart = { type: "file" url: string @@ -68,6 +78,7 @@ type SessionInfo = { id: string title?: string directory?: string + current?: boolean } function inline(info: Inline) { @@ -158,10 +169,6 @@ export const RunCommand = effectCmd({ describe: "fork the session before continuing (requires --continue or --session)", type: "boolean", }) - .option("share", { - type: "boolean", - describe: "share the session", - }) .option("model", { type: "string", alias: ["m"], @@ -217,11 +224,6 @@ export const RunCommand = effectCmd({ type: "boolean", describe: "show thinking blocks", }) - .option("mini", { - type: "boolean", - hidden: true, - default: false, - }) .option("replay", { type: "boolean", default: true, @@ -270,7 +272,7 @@ export const RunCommand = effectCmd({ const localInstance = yield* InstanceRef yield* Effect.promise(async () => { const rawMessage = [...args.message, ...(args["--"] || [])].join(" ") - const interactive = args.mini + const interactive = (args as typeof args & { mini?: boolean }).mini === true const auto = args.auto || args.yolo || args["dangerously-skip-permissions"] const thinking = interactive ? (args.thinking ?? true) : (args.thinking ?? false) const die = (message: string): never => { @@ -290,23 +292,23 @@ export const RunCommand = effectCmd({ .join(" ") if (interactive && args.command) { - die("--mini cannot be used with --command") + die("opencode mini cannot be used with --command") } if (interactive && args._?.[0] !== "mini") { - die("--mini must be used without the run subcommand") + die("opencode mini must be run with the mini command") } if (args.demo && !interactive) { - die("--demo requires --mini") + die("--demo requires opencode mini") } if (interactive && args.format === "json") { - die("--mini cannot be used with --format json") + die("opencode mini cannot be used with --format json") } if (args["replay-limit"] !== undefined && !interactive) { - die("--replay-limit requires --mini") + die("--replay-limit requires opencode mini") } if ( @@ -317,7 +319,7 @@ export const RunCommand = effectCmd({ } if (interactive && !process.stdout.isTTY) { - die("--mini requires a TTY stdout") + die("opencode mini requires a TTY stdout") } if (interactive) { @@ -355,6 +357,12 @@ export const RunCommand = effectCmd({ } const files: FilePart[] = [] + const fileInputs: Array<{ + filePath: string + resolvedPath: string + stat: ReturnType + isDirectory: boolean + }> = [] if (args.file) { const list = Array.isArray(args.file) ? args.file : [args.file] @@ -371,45 +379,7 @@ export const RunCommand = effectCmd({ UI.error(`Cannot attach local directory without a shared filesystem: ${filePath}`) process.exit(1) } - - const content = await (async () => { - if (!args.attach) return - const handle = await open(resolvedPath, "r") - try { - const opened = await handle.stat() - if (!opened.isFile() || Number(opened.size) > ATTACH_FILE_MAX_BYTES) { - UI.error(`Cannot attach local file larger than 10 MiB or a special file: ${filePath}`) - process.exit(1) - } - if (opened.size === 0) return Buffer.alloc(0) - const buffer = Buffer.alloc(Number(opened.size)) - let offset = 0 - while (offset < buffer.length) { - const read = await handle.read(buffer, offset, buffer.length - offset, offset) - if (read.bytesRead === 0) break - offset += read.bytesRead - } - return buffer.subarray(0, offset) - } finally { - await handle.close() - } - })() - const detected = FSUtil.mimeType(resolvedPath) - const text = content?.toString("utf8") - const mime = !args.attach - ? isDirectory - ? "application/x-directory" - : "text/plain" - : content && text !== undefined && Buffer.from(text, "utf8").equals(content) - ? "text/plain" - : detected - - files.push({ - type: "file", - url: content ? `data:${mime};base64,${content.toString("base64")}` : pathToFileURL(resolvedPath).href, - filename: path.basename(resolvedPath), - mime, - }) + fileInputs.push({ filePath, resolvedPath, stat, isDirectory }) } } @@ -446,6 +416,53 @@ export const RunCommand = effectCmd({ pattern: "*", }, ] + const currentPrompt = !interactive && !args.command && fileInputs.every((file) => !file.isDirectory) + + const inlineFiles = interactive || currentPrompt + for (const file of fileInputs) { + const content = await (async () => { + if (file.isDirectory || !inlineFiles) return + if (!file.stat?.isFile() || file.stat.size > ATTACH_FILE_MAX_BYTES) { + UI.error(`Cannot attach local file larger than 10 MiB or a special file: ${file.filePath}`) + process.exit(1) + } + const handle = await open(file.resolvedPath, "r") + try { + const opened = await handle.stat() + if (!opened.isFile() || Number(opened.size) > ATTACH_FILE_MAX_BYTES) { + UI.error(`Cannot attach local file larger than 10 MiB or a special file: ${file.filePath}`) + process.exit(1) + } + if (opened.size === 0) return Buffer.alloc(0) + const buffer = Buffer.alloc(Number(opened.size)) + let offset = 0 + while (offset < buffer.length) { + const read = await handle.read(buffer, offset, buffer.length - offset, offset) + if (read.bytesRead === 0) break + offset += read.bytesRead + } + return buffer.subarray(0, offset) + } finally { + await handle.close() + } + })() + const detected = FSUtil.mimeType(file.resolvedPath) + const text = content?.toString("utf8") + const mime = file.isDirectory + ? "application/x-directory" + : isImageAttachment(detected) || isPdfAttachment(detected) + ? detected + : content && !isBinaryContent(content) && text !== undefined && Buffer.from(text, "utf8").equals(content) + ? "text/plain" + : detected + + files.push({ + type: "file", + url: content ? `data:${mime};base64,${content.toString("base64")}` : pathToFileURL(file.resolvedPath).href, + filename: path.basename(file.resolvedPath), + mime, + }) + } function title() { if (args.title === undefined) return @@ -453,58 +470,122 @@ export const RunCommand = effectCmd({ return message.slice(0, 50) + (message.length > 50 ? "..." : "") } - async function session(sdk: OpencodeClient): Promise { - if (args.session) { - const current = await sdk.session - .get({ - sessionID: args.session, - }) - .catch(() => undefined) + async function currentSession(sdk: OpencodeClient, sessionID: string): Promise { + const listed = await sdk.v2.session + .list({ + directory: await current(sdk), + limit: 50, + order: "desc", + }) + .then((result) => result.data?.data.find((item) => item.id === sessionID)) + .catch(() => undefined) + const selected = + listed ?? + (await sdk.v2.session + .get({ sessionID }) + .then((result) => result.data?.data) + .catch(() => undefined)) + const legacy = + selected ?? + (await sdk.session + .get({ sessionID }) + .then((result) => result.data) + .catch(() => undefined)) + const transcript = await transcriptKind(sdk, legacy?.id ?? sessionID) + if (!legacy && transcript === "empty") { + return + } + if (interactive && transcript === "legacy") { + throw new Error("Mini cannot resume a legacy Session transcript") + } - if (!current?.data) { - UI.error("Session not found") - process.exit(1) - } - - if (args.fork) { - const forked = await sdk.session.fork({ - sessionID: args.session, - }) - const id = forked.data?.id - if (!id) { - return - } - - return { - id, - title: forked.data?.title ?? current.data.title, - directory: forked.data?.directory ?? current.data.directory, - } - } + return { + id: legacy?.id ?? sessionID, + title: legacy?.title, + directory: legacy ? ("location" in legacy ? legacy.location.directory : legacy.directory) : await current(sdk), + current: transcript !== "legacy", + } + } + async function forkSession(sdk: OpencodeClient, session: SessionInfo): Promise { + if (session.current !== false) { + const forked = await sdk.v2.session.fork( + { sessionID: session.id, messageID: undefined }, + { throwOnError: true }, + ) + await waitForFork(sdk, session.id, forked.data.data.id) return { - id: current.data.id, - title: current.data.title, - directory: current.data.directory, + id: forked.data.data.id, + title: forked.data.data.title, + directory: forked.data.data.location.directory, + current: true, } } - const base = args.continue ? (await sdk.session.list()).data?.find((item) => !item.parentID) : undefined + const forked = await sdk.session.fork({ + sessionID: session.id, + }) + const id = forked.data?.id + if (!id) { + return + } - if (base && args.fork) { - const forked = await sdk.session.fork({ - sessionID: base.id, - }) - const id = forked.data?.id - if (!id) { + return { + id, + title: forked.data?.title ?? session.title, + directory: forked.data?.directory ?? session.directory, + current: false, + } + } + + async function waitForFork(sdk: OpencodeClient, parentID: string, sessionID: string) { + const parentHasMessages = await sdk.v2.session + .messages({ sessionID: parentID, limit: 1 }) + .then((result) => (result.data?.data.length ?? 0) > 0) + .catch(() => false) + if (!parentHasMessages) { + return + } + + const deadline = Date.now() + 3000 + while (Date.now() < deadline) { + const forkedHasMessages = await sdk.v2.session + .messages({ sessionID, limit: 1 }) + .then((result) => (result.data?.data.length ?? 0) > 0) + .catch(() => false) + if (forkedHasMessages) { return } - return { - id, - title: forked.data?.title ?? base.title, - directory: forked.data?.directory ?? base.directory, + await Bun.sleep(25) + } + } + + async function session(sdk: OpencodeClient): Promise { + if (args.session) { + const current = await currentSession(sdk, args.session) + if (!current) { + UI.error("Session not found") + process.exit(1) } + if (!interactive && !currentPrompt && current.current !== false) { + throw new Error("This operation is not available for a current Session transcript") + } + + if (args.fork) { + return forkSession(sdk, current) + } + + return current + } + + const base = args.continue ? await currentRootSession(sdk) : undefined + if (base && !interactive && !currentPrompt && base.current !== false) { + throw new Error("This operation is not available for a current Session transcript") + } + + if (base && args.fork) { + return forkSession(sdk, base) } if (base) { @@ -512,6 +593,23 @@ export const RunCommand = effectCmd({ id: base.id, title: base.title, directory: base.directory, + current: "current" in base ? base.current : false, + } + } + + if (interactive || currentPrompt) { + const name = title() + const result = await sdk.v2.session.create({ + location: { directory: await current(sdk) }, + }) + const created = result.data?.data + if (!created) return + if (name) await sdk.v2.session.rename({ sessionID: created.id, title: name }) + return { + id: created.id, + title: name ?? created.title, + directory: created.location.directory, + current: true, } } @@ -529,30 +627,45 @@ export const RunCommand = effectCmd({ id, title: result.data?.title ?? name, directory: result.data?.directory, + current: false, } } - async function share(sdk: OpencodeClient, sessionID: string) { - const cfg = await sdk.config.get() - if (!cfg.data) return - if (cfg.data.share !== "auto" && !flags.autoShare && !args.share) return - const res = await sdk.session.share({ sessionID }).catch((error) => { - if (error instanceof Error && error.message.includes("disabled")) { - UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message) - } - return { error } + async function currentRootSession(sdk: OpencodeClient): Promise { + const response = await sdk.v2.session.list({ + directory: await current(sdk), + limit: 50, + order: "desc", }) - if (!res.error && "data" in res && res.data?.share?.url) { - UI.println(UI.Style.TEXT_INFO_BOLD + "~ " + res.data.share.url) + const root = (response.data?.data ?? []) + .filter((session) => !session.parentID) + .toSorted((a, b) => b.time.updated - a.time.updated)[0] + if (!root) return + return currentSession(sdk, root.id) + } + + async function transcriptKind(sdk: OpencodeClient, sessionID: string) { + const current = await sdk.v2.session.messages({ sessionID, limit: 1 }).then((result) => (result.data?.data.length ?? 0) > 0) + // Ordinary prompt flows assume a transcript with current messages is + // current-owned; only legacy-only modes (--command, directory + // attachments) still probe legacy history for mixed transcripts. + if (current && (interactive || currentPrompt)) return "current" as const + + const legacy = await sdk.session.messages({ sessionID, limit: 1 }).then((result) => (result.data?.length ?? 0) > 0) + if (current) { + if (legacy) throw new Error("Session contains mixed legacy and current transcripts") + return "current" as const } + if (legacy) return "legacy" as const + return "empty" as const } async function createFreshSession( sdk: OpencodeClient, input: { agent: string | undefined; model: ModelInput | undefined; variant: string | undefined }, ): Promise { - const result = await sdk.session.create({ - title: args.title !== undefined && args.title !== "" ? args.title : undefined, + const name = args.title !== undefined && args.title !== "" ? args.title : undefined + const result = await sdk.v2.session.create({ agent: input.agent, model: input.model ? { @@ -561,17 +674,18 @@ export const RunCommand = effectCmd({ variant: input.variant, } : undefined, - permission: [...rules], + location: { directory: await current(sdk) }, }) - const id = result.data?.id + const created = result.data?.data + const id = created?.id if (!id) { throw new Error("Failed to create session") } + if (name) await sdk.v2.session.rename({ sessionID: id, title: name }) - void share(sdk, id).catch(() => {}) return { id, - title: result.data?.title, + title: name ?? created.title, } } @@ -580,8 +694,8 @@ export const RunCommand = effectCmd({ return directory ?? root } - const next = await sdk.path - .get() + const next = await sdk.v2.location + .get(undefined, { throwOnError: true }) .then((x) => x.data?.directory) .catch(() => undefined) if (next) { @@ -622,10 +736,7 @@ export const RunCommand = effectCmd({ if (!args.agent) return undefined const name = args.agent - const modes = await sdk.app - .agents(undefined, { throwOnError: true }) - .then((x) => x.data ?? []) - .catch(() => undefined) + const modes = await loadRunAgents(sdk, await current(sdk)).catch(() => undefined) if (!modes) { UI.println( @@ -636,7 +747,7 @@ export const RunCommand = effectCmd({ return undefined } - const agent = modes.find((a) => a.name === name) + const agent = modes.find((item) => item.name === name) if (!agent) { UI.println( UI.Style.TEXT_WARNING_BOLD + "!", @@ -823,9 +934,33 @@ export const RunCommand = effectCmd({ // Validate agent if specified const agent = await pickAgent(client) - await share(client, sessionID) - if (!interactive) { + if (currentPrompt && sess.current !== false) { + const model = pick(args.model) + const { runNonInteractivePrompt } = await import("./run/noninteractive") + try { + await runNonInteractivePrompt({ + client, + sessionID, + message, + files, + agent, + model, + variant: args.variant, + thinking, + format: args.format === "json" ? "json" : "default", + dangerouslySkipPermissions: args["dangerously-skip-permissions"], + renderTool: tool, + renderToolError: toolError, + }) + } catch (error) { + const output = error instanceof Error ? { type: "unknown", message: error.message } : error + if (!emit("error", { error: output })) UI.error(formatRunError(error)) + process.exitCode = 1 + } + return + } + const events = await client.event.subscribe() const completed = loop(client, events).catch((e) => { console.error(e) @@ -880,7 +1015,7 @@ export const RunCommand = effectCmd({ directory: cwd, sessionID, sessionTitle: sess.title, - resume: Boolean(args.session || args.continue) && !args.fork, + resume: Boolean(args.session || args.continue), replay, replayLimit: args["replay-limit"], agent, @@ -917,7 +1052,6 @@ export const RunCommand = effectCmd({ fetch: fetchFn, resolveAgent: localAgent, session, - share, createSession: createFreshSession, agent: args.agent, model, @@ -984,7 +1118,6 @@ export async function runMini(input: MiniCommandInput) { continue: input.continue, session: input.session, fork: input.fork, - share: undefined, model: input.model, agent: input.agent, format: "default", @@ -1007,5 +1140,5 @@ export async function runMini(input: MiniCommandInput) { "dangerously-skip-permissions": false, dangerouslySkipPermissions: false, demo: input.demo ?? false, - }) + } as Parameters>[0] & { mini: boolean }) } diff --git a/packages/opencode/src/cli/cmd/run/catalog.shared.ts b/packages/opencode/src/cli/cmd/run/catalog.shared.ts new file mode 100644 index 0000000000..de3e719821 --- /dev/null +++ b/packages/opencode/src/cli/cmd/run/catalog.shared.ts @@ -0,0 +1,114 @@ +import type { OpencodeClient } from "@opencode-ai/sdk/v2" +import type { RunAgent, RunCommand, RunProvider, RunReference } from "./types" + +type CurrentAgent = NonNullable>["data"]>["data"][number] +type CurrentCommand = NonNullable>["data"]>["data"][number] +type CurrentSkill = NonNullable>["data"]>["data"][number] +type CurrentProvider = NonNullable>["data"]>["data"][number] +type CurrentModel = NonNullable>["data"]>["data"][number] + +function location(directory: string) { + return { + location: { + directory, + }, + } +} + +function defaultCost(model: CurrentModel) { + const picked = model.cost.find((cost) => cost.tier === undefined) ?? model.cost[0] + if (!picked) { + return undefined + } + + return { + ...picked, + input: model.cost.every((cost) => cost.input === 0) ? 0 : picked.input, + } +} + +export function runAgent(input: CurrentAgent): RunAgent { + return { + name: input.id, + description: input.description, + mode: input.mode, + hidden: input.hidden, + } +} + +export function runCommand(input: CurrentCommand): RunCommand { + return { + name: input.name, + description: input.description, + } +} + +export function runSkill(input: CurrentSkill): RunCommand { + return { + name: input.name, + description: input.description, + source: "skill", + } +} + +export function runProviders(providers: CurrentProvider[], models: CurrentModel[]): RunProvider[] { + const grouped = new Map() + + for (const provider of providers) { + grouped.set(provider.id, { + id: provider.id, + name: provider.name, + models: {}, + }) + } + + for (const model of models) { + const provider = grouped.get(model.providerID) ?? { + id: model.providerID, + name: model.providerID, + models: {}, + } + provider.models[model.id] = { + id: model.id, + providerID: model.providerID, + name: model.name, + capabilities: model.capabilities, + cost: defaultCost(model), + limit: model.limit, + status: model.status, + variants: Object.fromEntries(model.variants.map((variant) => [variant.id, {}])), + } + grouped.set(provider.id, provider) + } + + return [...grouped.values()] +} + +export async function loadRunAgents(sdk: OpencodeClient, directory: string): Promise { + const result = await sdk.v2.agent.list(location(directory), { throwOnError: true }) + return (result.data?.data ?? []).map(runAgent) +} + +export async function loadRunCommands(sdk: OpencodeClient, directory: string): Promise { + const [commands, skills] = await Promise.all([ + sdk.v2.command.list(location(directory), { throwOnError: true }), + sdk.v2.skill.list(location(directory), { throwOnError: true }), + ]) + return [ + ...(commands.data?.data ?? []).map(runCommand), + ...(skills.data?.data ?? []).filter((skill) => skill.slash !== false).map(runSkill), + ] +} + +export async function loadRunReferences(sdk: OpencodeClient, directory: string): Promise { + const result = await sdk.v2.reference.list(location(directory), { throwOnError: true }) + return (result.data?.data ?? []).filter((reference) => !reference.hidden) +} + +export async function loadRunProviders(sdk: OpencodeClient, directory: string): Promise { + const [providers, models] = await Promise.all([ + sdk.v2.provider.list(location(directory), { throwOnError: true }), + sdk.v2.model.list(location(directory), { throwOnError: true }), + ]) + return runProviders(providers.data?.data ?? [], models.data?.data ?? []) +} diff --git a/packages/opencode/src/cli/cmd/run/footer.permission.tsx b/packages/opencode/src/cli/cmd/run/footer.permission.tsx index 70cc2064fc..f513b4ab62 100644 --- a/packages/opencode/src/cli/cmd/run/footer.permission.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.permission.tsx @@ -141,7 +141,9 @@ export function RunPermissionBody(props: { const info = createMemo(() => permissionInfo(props.request)) const ft = createMemo(() => toolFiletype(info().file)) const narrow = createMemo(() => footerWidthPolicy(dims().width).dialog.narrow) - const opts = createMemo(() => permissionOptions(state().stage)) + const opts = createMemo(() => + permissionOptions(state().stage).filter((option) => option !== "always" || props.request.always.length > 0), + ) const busy = createMemo(() => state().submitting) const title = createMemo(() => { if (state().stage === "always") { @@ -165,7 +167,7 @@ export function RunPermissionBody(props: { }) const shift = (dir: -1 | 1) => { - setState((prev) => permissionShift(prev, dir)) + setState((prev) => permissionShift(prev, dir, opts())) } const submit = async (next: PermissionReply) => { diff --git a/packages/opencode/src/cli/cmd/run/footer.prompt.tsx b/packages/opencode/src/cli/cmd/run/footer.prompt.tsx index 0280982d50..90efdc5695 100644 --- a/packages/opencode/src/cli/cmd/run/footer.prompt.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.prompt.tsx @@ -1,7 +1,7 @@ // Prompt composer and its state machine for direct interactive mode. // // createPromptState() wires keymap command layers, history navigation, and -// `@` autocomplete for files, subagents, and MCP resources. +// `@` autocomplete for files, subagents, and project references. // It produces a PromptState that RunPromptBody renders as a slim single-line // composer while the footer view renders any active menus below it. /** @jsxImportSource @opentui/solid */ @@ -27,7 +27,7 @@ import { OPENCODE_BASE_MODE, useBindings } from "@opencode-ai/tui/keymap" import { realignEditorPromptParts, resolveEditorSlashValue } from "./prompt.editor" import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu" import type { RunFooterTheme } from "./theme" -import type { FooterState, RunAgent, RunCommand, RunPrompt, RunPromptPart, RunResource, RunTuiConfig } from "./types" +import type { FooterState, RunAgent, RunCommand, RunPrompt, RunPromptPart, RunReference, RunTuiConfig } from "./types" const AUTOCOMPLETE_ROWS = FOOTER_MENU_ROWS const AUTOCOMPLETE_BOTTOM_ROWS = 1 @@ -59,7 +59,7 @@ type PromptInput = { directory: string findFiles: (query: string) => Promise agents: Accessor - resources: Accessor + references: Accessor commands: Accessor tuiConfig: RunTuiConfig state: Accessor @@ -333,21 +333,20 @@ export function createPromptState(input: PromptInput): PromptState { }, })) }) - const resources = createMemo(() => { - return input.resources().map((item) => ({ + const references = createMemo(() => { + return input.references().map((item) => ({ kind: "mention", - display: Locale.truncateMiddle(`@${item.name} (${item.uri})`, width()), + display: Locale.truncateMiddle("@" + item.name, width()), value: item.name, - description: item.description, + description: item.description ?? (item.source.type === "git" ? item.source.repository : item.source.path), part: { type: "file", - mime: item.mimeType ?? "text/plain", + mime: "application/x-directory", filename: item.name, - url: item.uri, + url: pathToFileURL(item.path).href, source: { - type: "resource", - clientName: item.client, - uri: item.uri, + type: "file", + path: item.name, text: { start: 0, end: 0, @@ -402,7 +401,7 @@ export function createPromptState(input: PromptInput): PromptState { }, { initialValue: [] as Auto[] }, ) - const mentionOptions = createMemo(() => [...agents(), ...files(), ...resources()]) + const mentionOptions = createMemo(() => [...agents(), ...files(), ...references()]) const skillCommands = createMemo(() => (input.commands() ?? []).filter((item) => item.source === "skill")) const hasSkillsCommand = createMemo(() => (input.commands() ?? []).some((item) => item.source !== "skill" && item.name === "skills"), @@ -462,7 +461,7 @@ export function createPromptState(input: PromptInput): PromptState { return [ ...fuzzysort.go(next, agents(), { keys: ["value", "display", "description"] }).map((item) => item.obj), ...files(), - ...fuzzysort.go(next, resources(), { keys: ["value", "display", "description"] }).map((item) => item.obj), + ...fuzzysort.go(next, references(), { keys: ["value", "display", "description"] }).map((item) => item.obj), ] } diff --git a/packages/opencode/src/cli/cmd/run/footer.subagent.tsx b/packages/opencode/src/cli/cmd/run/footer.subagent.tsx index eb43b4b9f5..20cc6d8c7d 100644 --- a/packages/opencode/src/cli/cmd/run/footer.subagent.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.subagent.tsx @@ -53,6 +53,9 @@ export function RunFooterSubagentBody(props: { diffStyle?: RunDiffStyle onCycle: (dir: -1 | 1) => void onClose: () => void + // Formatted interrupt shortcut from the registered keymap binding; the + // command itself is dispatched through the keymap in footer.view. + interrupt?: () => string | undefined }) { const theme = createMemo(() => props.theme()) const footer = createMemo(() => theme().footer) @@ -89,6 +92,11 @@ export function RunFooterSubagentBody(props: { )) let scroll: ScrollBoxRenderable | undefined + const interruptHint = createMemo(() => { + if (tab()?.status !== "running") return undefined + return props.interrupt?.() + }) + useKeyboard((event) => { if (!props.active()) { return @@ -139,6 +147,13 @@ export function RunFooterSubagentBody(props: { {" " + subtitle()} + + {(hint) => ( + + {hint()} interrupt + + )} + 1 && props.index() > 0}> {props.index()} of {props.total()} diff --git a/packages/opencode/src/cli/cmd/run/footer.ts b/packages/opencode/src/cli/cmd/run/footer.ts index 0d9da6f297..6a4c03e797 100644 --- a/packages/opencode/src/cli/cmd/run/footer.ts +++ b/packages/opencode/src/cli/cmd/run/footer.ts @@ -55,7 +55,7 @@ import type { RunInput, RunPrompt, RunProvider, - RunResource, + RunReference, RunTuiConfig, StreamCommit, } from "./types" @@ -71,7 +71,7 @@ type RunFooterOptions = { directory: string findFiles: (query: string) => Promise agents: RunAgent[] - resources: RunResource[] + references: RunReference[] commands?: RunCommand[] wrote?: boolean sessionID: () => string | undefined @@ -97,6 +97,7 @@ type RunFooterOptions = { onEditorOpen: (input: { value: string }) => Promise onExit?: () => void onSubagentSelect?: (sessionID: string | undefined) => void + onSubagentInterrupt?: (sessionID: string) => void treeSitterClient?: TreeSitterClient } @@ -180,8 +181,8 @@ export class RunFooter implements FooterApi { private rows = TEXTAREA_MIN_ROWS private agents: Accessor private setAgents: Setter - private resources: Accessor - private setResources: Setter + private references: Accessor + private setReferences: Setter private commands: Accessor private setCommands: Setter private providers: Accessor @@ -255,9 +256,9 @@ export class RunFooter implements FooterApi { const [agents, setAgents] = createSignal(options.agents) this.agents = agents this.setAgents = setAgents - const [resources, setResources] = createSignal(options.resources) - this.resources = resources - this.setResources = setResources + const [references, setReferences] = createSignal(options.references) + this.references = references + this.setReferences = setReferences const [commands, setCommands] = createSignal(options.commands) this.commands = commands this.setCommands = setCommands @@ -311,7 +312,7 @@ export class RunFooter implements FooterApi { queuedPrompts: footer.queuedPrompts, findFiles: options.findFiles, agents: footer.agents, - resources: footer.resources, + references: footer.references, commands: footer.commands, providers: footer.providers, currentModel: footer.currentModel, @@ -341,6 +342,7 @@ export class RunFooter implements FooterApi { onLayout: footer.syncLayout, onStatus: footer.setStatus, onSubagentSelect: options.onSubagentSelect, + onSubagentInterrupt: options.onSubagentInterrupt, onQueuedRemove: footer.handleQueuedRemove, }) }, @@ -411,7 +413,7 @@ export class RunFooter implements FooterApi { } this.setAgents(next.agents) - this.setResources(next.resources) + this.setReferences(next.references) if (next.commands !== undefined) { this.setCommands(next.commands) } diff --git a/packages/opencode/src/cli/cmd/run/footer.view.tsx b/packages/opencode/src/cli/cmd/run/footer.view.tsx index ecddc50e49..245a24816d 100644 --- a/packages/opencode/src/cli/cmd/run/footer.view.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.view.tsx @@ -50,7 +50,7 @@ import type { RunInput, RunPrompt, RunProvider, - RunResource, + RunReference, RunTuiConfig, } from "./types" import type { RunTheme } from "./theme" @@ -74,7 +74,7 @@ type RunFooterViewProps = { directory: string findFiles: (query: string) => Promise agents: () => RunAgent[] - resources: () => RunResource[] + references: () => RunReference[] commands: () => RunCommand[] | undefined providers: () => RunProvider[] | undefined currentModel: () => RunInput["model"] @@ -108,6 +108,7 @@ type RunFooterViewProps = { onLayout: (input: { route: FooterPromptRoute; autocomplete: boolean; subagentRows: number }) => void onStatus: (text: string) => void onSubagentSelect?: (sessionID: string | undefined) => void + onSubagentInterrupt?: (sessionID: string) => void onQueuedRemove: (messageID: string) => Promise } @@ -213,6 +214,15 @@ export function RunFooterView(props: RunFooterViewProps) { props.tuiConfig, ) ?? "", ) + const subagentInterruptShortcut = useKeymapSelector( + (keymap: OpenTuiKeymap) => + formatKeySequence( + keymap + .getCommandBindings({ visibility: "registered", commands: ["subagent.interrupt"] }) + .get("subagent.interrupt")?.[0]?.sequence, + props.tuiConfig, + ) ?? "", + ) const interrupt = useKeymapSelector( (keymap: OpenTuiKeymap) => formatKeySequence( @@ -358,7 +368,7 @@ export function RunFooterView(props: RunFooterViewProps) { directory: props.directory, findFiles: props.findFiles, agents: props.agents, - resources: props.resources, + references: props.references, commands: props.commands, tuiConfig: props.tuiConfig, state: props.state, @@ -520,7 +530,7 @@ export function RunFooterView(props: RunFooterViewProps) { useBindings(() => ({ mode: OPENCODE_BASE_MODE, - enabled: active().type === "prompt" && route().type === "composer" && foregroundSubagents(), + enabled: active().type === "prompt" && route().type === "composer" && foregroundSubagents() && !!props.onBackground, priority: 1, commands: [ { @@ -561,6 +571,32 @@ export function RunFooterView(props: RunFooterViewProps) { bindings: props.tuiConfig.keybinds.get("session.queued_prompts"), })) + useBindings(() => ({ + mode: OPENCODE_BASE_MODE, + enabled: + active().type === "prompt" && + route().type === "subagent" && + selectedTab()?.status === "running" && + !!props.onSubagentInterrupt, + priority: 1, + commands: [ + { + name: "subagent.interrupt", + title: "Interrupt subagent", + category: "Session", + run: () => { + const current = selectedTab() + if (current?.status !== "running") { + return + } + + props.onSubagentInterrupt?.(current.sessionID) + }, + }, + ], + bindings: [{ key: "ctrl+d", desc: "Interrupt subagent", group: "Subagents", cmd: "subagent.interrupt" }], + })) + createEffect(() => { const current = route() if (current.type !== "subagent") { @@ -935,6 +971,7 @@ export function RunFooterView(props: RunFooterViewProps) { diffStyle={props.diffStyle} onCycle={cycleTab} onClose={closeTab} + interrupt={() => subagentInterruptShortcut() || undefined} /> diff --git a/packages/opencode/src/cli/cmd/run/noninteractive.ts b/packages/opencode/src/cli/cmd/run/noninteractive.ts new file mode 100644 index 0000000000..440436c704 --- /dev/null +++ b/packages/opencode/src/cli/cmd/run/noninteractive.ts @@ -0,0 +1,459 @@ +import type { + OpencodeClient, + ReasoningPart, + StepFinishPart, + StepStartPart, + TextPart, + ToolPart, + V2Event, +} from "@opencode-ai/sdk/v2" +import { EOL } from "node:os" +import { MessageID } from "@/session/schema" +import { UI } from "../../ui" + +type Model = { + providerID: string + modelID: string +} + +type File = { + url: string + filename: string + mime: string +} + +type Input = { + client: OpencodeClient + sessionID: string + message: string + files: File[] + agent?: string + model?: Model + variant?: string + thinking: boolean + format: "default" | "json" + dangerouslySkipPermissions: boolean + renderTool: (part: ToolPart) => Promise + renderToolError: (part: ToolPart) => Promise +} + +type StartedPart = { + id: string + timestamp: number +} + +type ToolState = StartedPart & { + assistantMessageID: string + tool: string + input: Record + raw?: string + provider?: unknown +} + +export async function runNonInteractivePrompt(input: Input) { + const controller = new AbortController() + const events = await input.client.v2.event.subscribe({ + signal: controller.signal, + sseMaxRetryAttempts: 0, + throwOnError: true, + }) + const stream = events.stream[Symbol.asyncIterator]() as AsyncGenerator + const connected = await stream.next() + if (connected.done) throw new Error("Event stream disconnected before prompt admission") + + const messageID = MessageID.ascending() + const starts = new Map() + const tools = new Map() + let submitted = false + let promoted = false + let emittedError = false + let questionRejected = false + let permissionRejected = false + let interrupted = false + let admission: AbortController | undefined + + const emit = (type: string, timestamp: number, data: Record) => { + if (input.format !== "json") return false + process.stdout.write(JSON.stringify({ type, timestamp, sessionID: input.sessionID, ...data }) + EOL) + return true + } + + const writeText = (part: TextPart, timestamp: number) => { + if (emit("text", timestamp, { part })) return + const text = part.text.trim() + if (!text) return + if (!process.stdout.isTTY) { + process.stdout.write(text + EOL) + return + } + UI.empty() + UI.println(text) + UI.empty() + } + + const replyPermission = async (request: { id: string; action: string; resources: string[] }) => { + if (!input.dangerouslySkipPermissions) { + permissionRejected = true + UI.println( + UI.Style.TEXT_WARNING_BOLD + "!", + UI.Style.TEXT_NORMAL + + `permission requested: ${request.action} (${request.resources.join(", ")}); auto-rejecting`, + ) + } + await input.client.v2.session.permission + .reply({ + sessionID: input.sessionID, + requestID: request.id, + reply: input.dangerouslySkipPermissions ? "once" : "reject", + }) + .catch(() => {}) + if (!input.dangerouslySkipPermissions) { + await input.client.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {}) + } + } + + const rejectQuestion = async (request: { id: string }) => { + questionRejected = true + await input.client.v2.session.question.reject({ sessionID: input.sessionID, requestID: request.id }).catch(() => {}) + } + + const consume = async () => { + while (!controller.signal.aborted) { + const next = await stream.next() + if (next.done) throw new Error("Event stream disconnected during prompt execution") + const event = next.value + + if (event.type === "permission.v2.asked" && submitted && event.data.sessionID === input.sessionID) { + await replyPermission(event.data) + continue + } + if (event.type === "question.v2.asked" && submitted && event.data.sessionID === input.sessionID) { + await rejectQuestion(event.data) + continue + } + if (!("sessionID" in event.data) || event.data.sessionID !== input.sessionID) continue + const time = "timestamp" in event.data ? toMillis(event.data.timestamp) : Date.now() + + if (event.type === "session.next.prompted") { + if (event.data.messageID === messageID) { + promoted = true + continue + } + if (promoted && event.data.delivery === "queue") return + } + if ( + event.type === "session.next.execution.settled" && + event.data.outcome === "interrupted" && + (interrupted || permissionRejected || questionRejected) + ) { + return + } + if (!promoted) continue + + if (event.type === "session.next.step.started") { + const part: StepStartPart = { + id: partID(event.id), + sessionID: input.sessionID, + messageID: event.data.assistantMessageID, + type: "step-start", + snapshot: event.data.snapshot, + } + if (!emit("step_start", time, { part }) && input.format !== "json") { + UI.empty() + UI.println(`> ${event.data.agent} · ${event.data.model.id}`) + UI.empty() + } + continue + } + + if (event.type === "session.next.text.started") { + starts.set(event.data.textID, { id: partID(event.id), timestamp: time }) + continue + } + if (event.type === "session.next.text.ended") { + const started = starts.get(event.data.textID) + const part: TextPart = { + id: started?.id ?? partID(event.id), + sessionID: input.sessionID, + messageID: event.data.assistantMessageID, + type: "text", + text: event.data.text, + time: { start: started?.timestamp ?? time, end: time }, + } + writeText(part, time) + continue + } + + if (event.type === "session.next.reasoning.started") { + starts.set(event.data.reasoningID, { id: partID(event.id), timestamp: time }) + continue + } + if (event.type === "session.next.reasoning.ended" && input.thinking) { + const started = starts.get(event.data.reasoningID) + const part: ReasoningPart = { + id: started?.id ?? partID(event.id), + sessionID: input.sessionID, + messageID: event.data.assistantMessageID, + type: "reasoning", + text: event.data.text, + metadata: event.data.providerMetadata, + time: { start: started?.timestamp ?? time, end: time }, + } + if (emit("reasoning", time, { part })) continue + const text = part.text.trim() + if (!text) continue + const line = `Thinking: ${text}` + if (!process.stdout.isTTY) { + process.stdout.write(line + EOL) + continue + } + UI.empty() + UI.println(`${UI.Style.TEXT_DIM}\u001b[3m${line}\u001b[0m${UI.Style.TEXT_NORMAL}`) + UI.empty() + continue + } + + if (event.type === "session.next.tool.input.started") { + tools.set(event.data.callID, { + id: partID(event.id), + timestamp: time, + assistantMessageID: event.data.assistantMessageID, + tool: event.data.name, + input: {}, + }) + continue + } + if (event.type === "session.next.tool.input.ended") { + const current = tools.get(event.data.callID) + if (current) current.raw = event.data.text + continue + } + if (event.type === "session.next.tool.called") { + const current = tools.get(event.data.callID) + tools.set(event.data.callID, { + id: current?.id ?? partID(event.id), + timestamp: current?.timestamp ?? time, + assistantMessageID: event.data.assistantMessageID, + tool: event.data.tool, + input: event.data.input, + raw: current?.raw, + provider: event.data.provider, + }) + continue + } + if (event.type === "session.next.tool.success") { + const current = tools.get(event.data.callID) ?? fallbackTool(event) + const part: ToolPart = { + id: current.id, + sessionID: input.sessionID, + messageID: event.data.assistantMessageID, + type: "tool", + callID: event.data.callID, + tool: current.tool, + state: { + status: "completed", + input: current.input, + output: event.data.content + .filter((item) => item.type === "text") + .map((item) => item.text) + .join("\n"), + title: current.tool, + metadata: { + structured: event.data.structured, + content: event.data.content, + outputPaths: event.data.outputPaths, + result: event.data.result, + providerCall: current.provider, + providerResult: event.data.provider, + rawInput: current.raw, + }, + time: { start: current.timestamp, end: time }, + }, + } + tools.delete(event.data.callID) + if (!emit("tool_use", time, { part })) await input.renderTool(part) + continue + } + if (event.type === "session.next.tool.failed") { + const current = tools.get(event.data.callID) ?? fallbackTool(event) + const error = event.data.error.message + const part: ToolPart = { + id: current.id, + sessionID: input.sessionID, + messageID: event.data.assistantMessageID, + type: "tool", + callID: event.data.callID, + tool: current.tool, + state: { + status: "error", + input: current.input, + error, + metadata: { + result: event.data.result, + providerCall: current.provider, + providerResult: event.data.provider, + rawInput: current.raw, + }, + time: { start: current.timestamp, end: time }, + }, + } + tools.delete(event.data.callID) + if (!emit("tool_use", time, { part })) { + await input.renderToolError(part) + UI.error(error) + } + continue + } + + if (event.type === "session.next.step.ended") { + const part: StepFinishPart = { + id: partID(event.id), + sessionID: input.sessionID, + messageID: event.data.assistantMessageID, + type: "step-finish", + reason: event.data.finish, + snapshot: event.data.snapshot, + cost: event.data.cost, + tokens: event.data.tokens, + } + emit("step_finish", time, { part }) + continue + } + if (event.type === "session.next.step.failed") { + if (interrupted || permissionRejected || questionRejected) continue + emittedError = true + process.exitCode = 1 + if (!emit("error", time, { error: event.data.error })) UI.error(event.data.error.message) + continue + } + if (event.type === "session.next.execution.settled") { + if (event.data.outcome === "failure" && !emittedError && !questionRejected) { + emittedError = true + process.exitCode = 1 + const error = event.data.error ?? { type: "unknown", message: "Session execution failed" } + if (!emit("error", toMillis(event.data.timestamp), { error })) UI.error(error.message) + } + if (event.data.outcome === "interrupted" && interrupted) process.exitCode = 130 + return + } + } + } + + const interrupt = () => { + if (interrupted) process.exit(130) + interrupted = true + process.exitCode = 130 + admission?.abort() + void input.client.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {}) + } + process.on("SIGINT", interrupt) + + let completed: Promise | undefined + try { + if (input.agent) { + await input.client.v2.session.switchAgent( + { sessionID: input.sessionID, agent: input.agent }, + { throwOnError: true }, + ) + } + const selected = input.model + ? { providerID: input.model.providerID, id: input.model.modelID, variant: input.variant } + : input.variant + ? await input.client.v2.session + .get({ sessionID: input.sessionID }, { throwOnError: true }) + .then((result) => result.data.data.model) + .then(async (model) => { + if (model) return { ...model, variant: input.variant } + const result = await input.client.v2.model.default(undefined, { throwOnError: true }) + const fallback = result.data.data + return fallback ? { providerID: fallback.providerID, id: fallback.id, variant: input.variant } : undefined + }) + : undefined + if (input.variant && !selected) throw new Error("Cannot select a variant before selecting a model") + if (selected) { + await input.client.v2.session.switchModel({ sessionID: input.sessionID, model: selected }, { throwOnError: true }) + } + + const prepared = await Promise.all(input.files.map(prepareFile)) + if (interrupted) return + submitted = true + completed = consume() + admission = new AbortController() + const response = await input.client.v2.session + .prompt( + { + sessionID: input.sessionID, + id: messageID, + prompt: { + text: [input.message, ...prepared.flatMap((file) => (file.text ? [file.text] : []))].join("\n\n"), + files: prepared.flatMap((file) => (file.attachment ? [file.attachment] : [])), + }, + delivery: "steer", + }, + { throwOnError: true, signal: admission.signal }, + ) + .catch(async (error) => { + if (interrupted) { + await input.client.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {}) + } + controller.abort() + await completed?.catch(() => {}) + if (interrupted) return undefined + throw error + }) + admission = undefined + if (!response) return + if (!response.data.data) throw new Error("Prompt was not admitted") + if (interrupted) await input.client.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {}) + + const [permissions, questions] = await Promise.all([ + input.client.v2.session.permission.list({ sessionID: input.sessionID }).catch(() => undefined), + input.client.v2.session.question.list({ sessionID: input.sessionID }).catch(() => undefined), + ]) + await Promise.all([ + ...(permissions?.data?.data ?? []).map(replyPermission), + ...(questions?.data?.data ?? []).map(rejectQuestion), + ]) + await completed + } finally { + process.off("SIGINT", interrupt) + controller.abort() + await stream.return?.(undefined).catch(() => {}) + } +} + +function partID(eventID: string) { + return `prt_${eventID.replace(/^evt_/, "")}` +} + +function fallbackTool(event: { + id: string + data: { timestamp: number; assistantMessageID: string; callID: string } +}): ToolState { + return { + id: partID(event.id), + timestamp: toMillis(event.data.timestamp), + assistantMessageID: event.data.assistantMessageID, + tool: "tool", + input: {}, + } +} + +function toMillis(value: unknown) { + if (typeof value === "number") return value + if (typeof value === "string") return new Date(value).getTime() + return Date.now() +} + +async function prepareFile(file: File) { + if (file.mime !== "text/plain") { + const uri = file.url.startsWith("data:") + ? file.url + : `data:${file.mime};base64,${Buffer.from(await Bun.file(new URL(file.url)).arrayBuffer()).toString("base64")}` + return { attachment: { uri, mime: file.mime, name: file.filename } } + } + const content = file.url.startsWith("data:") + ? Buffer.from(file.url.slice(file.url.indexOf(",") + 1), "base64").toString("utf8") + : await Bun.file(new URL(file.url)).text() + return { text: `\n${content}\n` } +} diff --git a/packages/opencode/src/cli/cmd/run/permission.shared.ts b/packages/opencode/src/cli/cmd/run/permission.shared.ts index 6ebdbd090c..09cbf36df2 100644 --- a/packages/opencode/src/cli/cmd/run/permission.shared.ts +++ b/packages/opencode/src/cli/cmd/run/permission.shared.ts @@ -150,8 +150,11 @@ export function permissionReply(requestID: string, reply: PermissionReply["reply } } -export function permissionShift(state: PermissionBodyState, dir: -1 | 1): PermissionBodyState { - const list = permissionOptions(state.stage) +export function permissionShift( + state: PermissionBodyState, + dir: -1 | 1, + list = permissionOptions(state.stage), +): PermissionBodyState { if (list.length === 0) { return state } diff --git a/packages/opencode/src/cli/cmd/run/runtime.boot.ts b/packages/opencode/src/cli/cmd/run/runtime.boot.ts index b1f6217846..4753adaae2 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.boot.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.boot.ts @@ -8,9 +8,12 @@ import { Context, Effect, Layer } from "effect" import { resolve } from "@opencode-ai/tui/config" import { TuiConfig } from "@/config/tui" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { makeGlobalNode } from "@opencode-ai/core/effect/app-node" import { makeRuntime } from "@/effect/run-service" +import { loadRunProviders } from "./catalog.shared" import { reusePendingTask } from "./runtime.shared" -import { resolveSession, sessionHistory } from "./session.shared" +import { resolveCurrentSession, sessionHistory } from "./session.shared" import type { RunDiffStyle, RunInput, RunPrompt, RunProvider, RunTuiConfig } from "./types" import { pickVariant } from "./variant.shared" @@ -95,20 +98,7 @@ const layer = Layer.effect( directory: string, model: RunInput["model"], ) { - const connected = yield* Effect.promise(() => - sdk.config - .providers({ directory }) - .then((item) => item.data?.providers) - .catch(() => undefined), - ) - const providers = yield* Effect.promise(() => - connected - ? Promise.resolve(connected) - : sdk.provider - .list() - .then((item) => item.data?.all ?? []) - .catch(() => []), - ) + const providers = yield* Effect.promise(() => loadRunProviders(sdk, directory)) const limits = Object.fromEntries( providers.flatMap((provider) => Object.entries(provider.models ?? {}).flatMap(([modelID, info]) => { @@ -143,7 +133,7 @@ const layer = Layer.effect( sessionID: string, model: RunInput["model"], ) { - const session = yield* Effect.promise(() => resolveSession(sdk, sessionID).catch(() => undefined)) + const session = yield* Effect.promise(() => resolveCurrentSession(sdk, sessionID).catch(() => undefined)) if (!session) { return emptySessionInfo() } @@ -172,7 +162,8 @@ const layer = Layer.effect( }), ) -const runtime = makeRuntime(Service, layer) +const node = makeGlobalNode({ service: Service, layer, deps: [] }) +const runtime = makeRuntime(Service, AppNodeBuilder.build(node)) // Fetches available variants and context limits for every provider/model pair. export async function resolveModelInfo( diff --git a/packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts b/packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts index 4644d3d036..a25b293883 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts @@ -27,7 +27,7 @@ import type { RunAgent, RunInput, RunPrompt, - RunResource, + RunReference, RunTuiConfig, } from "./types" import { formatModelLabel } from "./variant.shared" @@ -55,7 +55,7 @@ export type LifecycleInput = { directory: string findFiles: (query: string) => Promise agents: RunAgent[] - resources: RunResource[] + references: RunReference[] sessionID: string sessionTitle?: string getSessionID?: () => string | undefined @@ -75,6 +75,7 @@ export type LifecycleInput = { onInterrupt?: () => void onBackground?: () => void onSubagentSelect?: (sessionID: string | undefined) => void + onSubagentInterrupt?: (sessionID: string) => void } export type Lifecycle = { @@ -233,7 +234,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise input.sessionID), ...labels, model: input.model, @@ -276,6 +277,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise { diff --git a/packages/opencode/src/cli/cmd/run/runtime.stdin.ts b/packages/opencode/src/cli/cmd/run/runtime.stdin.ts index d236fb02c2..fcb1a40a8e 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.stdin.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.stdin.ts @@ -1,7 +1,7 @@ import fs from "fs" import * as tty from "node:tty" -export const INTERACTIVE_INPUT_ERROR = "--mini requires a controlling terminal for input" +export const INTERACTIVE_INPUT_ERROR = "opencode mini requires a controlling terminal for input" type InteractiveStdin = { stdin: NodeJS.ReadStream diff --git a/packages/opencode/src/cli/cmd/run/runtime.ts b/packages/opencode/src/cli/cmd/run/runtime.ts index 90cddffa22..8f3704fd41 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.ts @@ -1,4 +1,4 @@ -// Top-level orchestrator for `opencode --mini`. +// Top-level orchestrator for `opencode mini`. // // Wires the boot sequence, lifecycle (renderer + footer), stream transport, // and prompt queue together into a single session loop. Two entry points: @@ -15,6 +15,7 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { Flag } from "@opencode-ai/core/flag/flag" import { MessageID } from "@/session/schema" +import { loadRunAgents, loadRunCommands, loadRunReferences } from "./catalog.shared" import { createRunDemo } from "./demo" import { resolveModelInfo, resolveRunTuiConfig, resolveSessionInfo } from "./runtime.boot" import { createRuntimeLifecycle } from "./runtime.lifecycle" @@ -62,7 +63,6 @@ type RunLocalInput = { fetch: typeof globalThis.fetch resolveAgent: () => Promise session: (sdk: RunInput["sdk"]) => Promise<{ id: string; title?: string } | undefined> - share: (sdk: RunInput["sdk"], sessionID: string) => Promise createSession?: CreateSession agent: RunInput["agent"] model: RunInput["model"] @@ -77,7 +77,7 @@ type RunLocalInput = { } type StreamTransportModule = Pick< - Awaited, + Awaited, "createSessionTransport" | "formatUnknownError" > @@ -164,11 +164,9 @@ async function resolveExitTitle( return undefined } - return ctx.sdk.session - .get({ - sessionID: state.sessionID, - }) - .then((x) => x.data?.title) + return ctx.sdk.v2.session + .get({ sessionID: state.sessionID }) + .then((x) => x.data?.data.title) .catch(() => undefined) } @@ -233,7 +231,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep .then((x) => x.data ?? []) .catch(() => []), agents: [], - resources: [], + references: [], sessionID: state.sessionID, sessionTitle: state.sessionTitle, getSessionID: () => state.sessionID, @@ -250,21 +248,25 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep } log?.write("send.permission.reply", next) - await ctx.sdk.permission.reply(next) + await ctx.sdk.v2.session.permission.reply({ sessionID: state.sessionID, ...next }) }, onQuestionReply: async (next) => { if (state.demo?.questionReply(next)) { return } - await ctx.sdk.question.reply(next) + await ctx.sdk.v2.session.question.reply({ + sessionID: state.sessionID, + requestID: next.requestID, + questionV2Reply: { answers: next.answers ?? [] }, + }) }, onQuestionReject: async (next) => { if (state.demo?.questionReject(next)) { return } - await ctx.sdk.question.reject(next) + await ctx.sdk.v2.session.question.reject({ sessionID: state.sessionID, ...next }) }, onCycleVariant: () => { if (!state.model || state.variants.length === 0) { @@ -339,22 +341,30 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep }, onInterrupt: () => { if (!hasSession(input, state) || state.aborting) { - return + return false } state.aborting = true - void ctx.sdk.session - .abort({ - sessionID: state.sessionID, - }) + void (state.stream + ? state.stream.then((item) => item.handle.interruptActiveTurn()) + : ctx.sdk.v2.session.interrupt({ sessionID: state.sessionID })) .catch(() => {}) .finally(() => { state.aborting = false }) + return true }, onBackground: () => { - if (!hasSession(input, state)) return - void ctx.sdk.experimental.session.background({ sessionID: state.sessionID }).catch(() => {}) + if (!hasSession(input, state)) { + return + } + + log?.write("send.background", { sessionID: state.sessionID }) + void ctx.sdk.v2.session.background({ sessionID: state.sessionID }).catch(() => {}) + }, + onSubagentInterrupt: (sessionID) => { + log?.write("send.subagent.interrupt", { sessionID }) + void ctx.sdk.v2.session.interrupt({ sessionID }).catch(() => {}) }, onSubagentSelect: (sessionID) => { state.selectSubagent?.(sessionID) @@ -373,19 +383,10 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep return } - const [agents, resources, commands] = await Promise.all([ - ctx.sdk.app - .agents({ directory: ctx.directory }) - .then((x) => x.data ?? []) - .catch(() => []), - ctx.sdk.experimental.resource - .list({ directory: ctx.directory }) - .then((x) => Object.values(x.data ?? {})) - .catch(() => []), - ctx.sdk.command - .list({ directory: ctx.directory }) - .then((x) => x.data ?? []) - .catch(() => []), + const [agents, references, commands] = await Promise.all([ + loadRunAgents(ctx.sdk, ctx.directory).catch(() => []), + loadRunReferences(ctx.sdk, ctx.directory).catch(() => []), + loadRunCommands(ctx.sdk, ctx.directory).catch(() => []), ]) if (footer.isClosed) { return @@ -394,7 +395,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep footer.event({ type: "catalog", agents, - resources, + references, commands, }) } @@ -453,7 +454,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep }) }) - const streamTask = deps.streamTransport ?? import("./stream.transport") + const streamTask = deps.streamTransport ?? import("./stream-v2.transport") const ensureStream = () => { if (state.stream) { return state.stream @@ -758,7 +759,6 @@ export async function runInteractiveLocalMode(input: RunLocalInput): Promise {}) return { sessionID: next.id, sessionTitle: next.title, diff --git a/packages/opencode/src/cli/cmd/run/session-data.ts b/packages/opencode/src/cli/cmd/run/session-data.ts index 03951ec4c9..05daa8b423 100644 --- a/packages/opencode/src/cli/cmd/run/session-data.ts +++ b/packages/opencode/src/cli/cmd/run/session-data.ts @@ -5,9 +5,9 @@ // - FooterOutput: status bar patches and view transitions (permission, question) // // The reducer mutates SessionData in place for performance but has no -// external side effects -- no IO, no footer calls. The caller -// (stream.transport.ts) feeds events in and forwards output to the footer -// through stream.ts. +// external side effects -- no IO, no footer calls. The demo runtime +// (demo.ts) feeds events in and forwards output to the footer through +// stream.ts; the current transport reuses the blocker helpers below. // // Key design decisions: // @@ -24,7 +24,7 @@ // `data.questions`. The footer shows whichever is first. When a reply // event arrives, the queue entry is removed and the footer falls back // to the next pending request or to the prompt view. -import type { Event, Part, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2" +import type { Event, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2" import * as Locale from "@/util/locale" import { toolView } from "./tool" import type { FooterOutput, FooterPatch, FooterView, StreamCommit } from "./types" @@ -280,33 +280,6 @@ function remove(list: Array<{ id: string }>, id: string): boolean { return true } -export function bootstrapSessionData(input: { - data: SessionData - messages: Array<{ - parts: Part[] - }> - permissions: PermissionRequest[] - questions: QuestionRequest[] -}) { - for (const message of input.messages) { - for (const part of message.parts) { - if (part.type !== "tool") { - continue - } - - input.data.call.set(key(part.messageID, part.callID), part.state.input) - } - } - - for (const request of input.permissions.slice().sort((a, b) => a.id.localeCompare(b.id))) { - upsert(input.data.permissions, enrichPermission(input.data, request)) - } - - for (const request of input.questions.slice().sort((a, b) => a.id.localeCompare(b.id))) { - upsert(input.data.questions, request) - } -} - function key(msg: string, call: string): string { return `${msg}:${call}` } @@ -740,26 +713,6 @@ function failTool(part: ToolPart, text: string): SessionCommit { }) } -// Emits "interrupted" final entries for all in-flight parts. Called when a turn is aborted. -export function flushInterrupted(data: SessionData, commits: SessionCommit[]) { - for (const partID of data.part.keys()) { - if (data.ids.has(partID)) { - continue - } - - const msg = data.msg.get(partID) - if (msg && data.role.get(msg) === "user" && !data.includeUserText) { - data.ids.add(partID) - drop(data, partID) - continue - } - - flushPart(data, commits, partID, true) - data.ids.add(partID) - drop(data, partID) - } -} - // The main reducer. Takes one SDK event and returns scrollback commits and // footer updates. Called once per event from the stream transport's watch loop. // diff --git a/packages/opencode/src/cli/cmd/run/session-replay.ts b/packages/opencode/src/cli/cmd/run/session-replay.ts deleted file mode 100644 index 69a24f2719..0000000000 --- a/packages/opencode/src/cli/cmd/run/session-replay.ts +++ /dev/null @@ -1,374 +0,0 @@ -import type { Event, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2" -import { bootstrapSessionData, createSessionData, reduceSessionData, type SessionData } from "./session-data" -import { messagePrompt, type SessionMessages } from "./session.shared" -import { messageTurnSummaryCommit } from "./turn-summary" -import type { FooterPatch, LocalReplayRow, RunProvider, StreamCommit } from "./types" - -type ReplayInput = { - messages: SessionMessages - permissions: PermissionRequest[] - questions: QuestionRequest[] - thinking: boolean - limits: Record - providers?: RunProvider[] -} - -type ReplayConfig = { - limits: Record - providers?: RunProvider[] - summaries: ReadonlySet -} - -export type SessionReplay = { - data: SessionData - commits: StreamCommit[] - patch?: FooterPatch -} - -type ReplayMessage = { - commits: StreamCommit[] - patch?: FooterPatch -} - -const SHELL_SYNTHETIC_USER_TEXT = "The following tool was executed by the user" - -function apply(data: SessionData, event: Event, sessionID: string, thinking: boolean, limits: Record) { - return reduceSessionData({ - data, - event, - sessionID, - thinking, - limits, - }) -} - -function mergePatch(left: FooterPatch | undefined, right: FooterPatch | undefined) { - if (!left) { - return right - } - - if (!right) { - return left - } - - return { - ...left, - ...right, - } -} - -function active(data: SessionData) { - return data.part.size > 0 || data.tools.size > 0 -} - -function replayPatch(data: SessionData, patch: FooterPatch | undefined) { - if (active(data)) { - if (!patch) { - return { - phase: "running", - } satisfies FooterPatch - } - - return { - ...patch, - phase: "running", - } satisfies FooterPatch - } - - if (data.permissions.length > 0 || data.questions.length > 0) { - if (!patch) { - return { - phase: "idle", - } satisfies FooterPatch - } - - return { - ...patch, - phase: "idle", - } satisfies FooterPatch - } - - if (!patch) { - return undefined - } - - return { - ...patch, - phase: "idle", - status: "", - } satisfies FooterPatch -} - -function isShellSyntheticUser(message: SessionMessages[number]) { - if (message.info.role !== "user") { - return false - } - - const prompt = messagePrompt(message) - return ( - !prompt.text.trim() && - prompt.parts.length === 0 && - message.parts.some((part) => part.type === "text" && part.synthetic && part.text === SHELL_SYNTHETIC_USER_TEXT) - ) -} - -function isShellSyntheticAssistant(message: SessionMessages[number], shellParents: ReadonlySet) { - return ( - message.info.role === "assistant" && - shellParents.has(message.info.parentID) && - message.parts.some((part) => part.type === "tool" && part.tool === "bash") - ) -} - -function summaryMessageIDs(messages: SessionMessages): ReadonlySet { - const shellParents = new Set(messages.filter(isShellSyntheticUser).map((message) => message.info.id)) - const parents = new Set() - const summaries = new Set() - - for (let idx = messages.length - 1; idx >= 0; idx -= 1) { - const message = messages[idx] - if (!message || message.info.role !== "assistant") { - continue - } - - if (isShellSyntheticAssistant(message, shellParents)) { - continue - } - - if (parents.has(message.info.parentID)) { - continue - } - - parents.add(message.info.parentID) - - const completed = message.info.time.completed - if (typeof completed === "number" && completed > message.info.time.created) { - summaries.add(message.info.id) - } - } - - return summaries -} - -function replayMessage( - data: SessionData, - message: SessionMessages[number], - thinking: boolean, - config: ReplayConfig, -): ReplayMessage { - if (message.info.role === "user") { - const prompt = messagePrompt(message) - if (!prompt.text.trim()) { - return { - commits: [], - } - } - - return { - commits: [ - { - kind: "user", - text: prompt.text, - phase: "start", - source: "system", - messageID: message.info.id, - }, - ], - } - } - - const commits: StreamCommit[] = [] - let patch: FooterPatch | undefined - - const info = apply( - data, - { - id: `bootstrap:message:${message.info.id}`, - type: "message.updated", - properties: { - sessionID: message.info.sessionID, - info: message.info, - }, - }, - message.info.sessionID, - thinking, - config.limits, - ) - commits.push(...info.commits) - patch = mergePatch(patch, info.footer?.patch) - - for (const part of message.parts) { - const next = apply( - data, - { - id: `bootstrap:part:${part.id}`, - type: "message.part.updated", - properties: { - sessionID: part.sessionID, - part, - time: 0, - }, - }, - message.info.sessionID, - thinking, - config.limits, - ) - patch = mergePatch(patch, next.footer?.patch) - commits.push(...next.commits) - } - - const summary = config.summaries.has(message.info.id) - ? messageTurnSummaryCommit(message, config.providers) - : undefined - if (summary) { - commits.push(summary) - } - - return { - commits, - patch, - } -} - -export function replaySession(input: ReplayInput): SessionReplay { - const data = createSessionData() - const commits: StreamCommit[] = [] - let patch: FooterPatch | undefined - const summaries = summaryMessageIDs(input.messages) - - bootstrapSessionData({ - data, - messages: input.messages, - permissions: input.permissions, - questions: input.questions, - }) - - for (const message of input.messages) { - const next = replayMessage(data, message, input.thinking, { - limits: input.limits, - providers: input.providers, - summaries, - }) - commits.push(...next.commits) - patch = mergePatch(patch, next.patch) - } - - return { - data, - commits, - patch: replayPatch(data, patch), - } -} - -export function replayLocalRows( - messages: SessionMessages, - commits: StreamCommit[], - rows: LocalReplayRow[], -): StreamCommit[] { - const persisted = new Set(messages.map((message) => message.info.id)) - return rows.reduce((out, local) => { - const row = local.commit - if (row.kind === "user" && row.messageID && persisted.has(row.messageID)) { - return out - } - - if (!row.messageID) { - return [...out, row] - } - - const exact = local.after - ? out.findIndex( - (commit) => - commit.kind === local.after?.kind && - commit.text === local.after.text && - commit.phase === local.after.phase && - commit.toolState === local.after.toolState && - (local.after.partID ? commit.partID === local.after.partID : commit.messageID === local.after.messageID), - ) - : -1 - const anchored = - exact !== -1 - ? exact - : local.after - ? out.findLastIndex((commit) => - local.after?.partID - ? commit.partID === local.after.partID - : commit.kind === local.after?.kind && commit.messageID === local.after.messageID, - ) - : -1 - if (anchored !== -1) { - const commit = out[anchored] - const visible = local.after?.visible - if (commit && visible && commit.text.startsWith(visible) && commit.text.length > visible.length) { - return [ - ...out.slice(0, anchored), - { ...commit, text: visible }, - row, - { ...commit, text: commit.text.slice(visible.length) }, - ...out.slice(anchored + 1), - ] - } - - return [...out.slice(0, anchored + 1), row, ...out.slice(anchored + 1)] - } - - const after = out.findIndex((commit) => commit.kind === "user" && commit.messageID === row.messageID) - if (after !== -1) { - return [...out.slice(0, after + 1), row, ...out.slice(after + 1)] - } - - const before = out.findIndex((commit) => commit.messageID && row.messageID! < commit.messageID) - if (before === -1) { - return [...out, row] - } - - return [...out.slice(0, before), row, ...out.slice(before)] - }, commits) -} - -export function replayActiveText(data: SessionData, current: SessionData): StreamCommit[] { - return [...current.part.entries()].flatMap(([partID, kind]) => { - if (kind === "user" || current.end.has(partID) || data.ids.has(partID)) { - return [] - } - - const text = current.text.get(partID) ?? "" - const existing = data.text.get(partID) ?? "" - const sent = current.sent.get(partID) ?? 0 - const existingSent = data.sent.get(partID) ?? 0 - const visible = current.visible.get(partID) ?? "" - const existingVisible = data.visible.get(partID) ?? "" - if (!text.startsWith(existing) || existingSent > sent || !visible.startsWith(existingVisible)) { - return [] - } - - data.part.set(partID, kind) - data.text.set(partID, text) - data.sent.set(partID, sent) - data.visible.set(partID, visible) - const messageID = current.msg.get(partID) - if (messageID) { - data.msg.set(partID, messageID) - const role = current.role.get(messageID) - if (role) { - data.role.set(messageID, role) - } - } - - const chunk = visible.slice(existingVisible.length) - if (!chunk) { - return [] - } - - return [ - { - kind, - text: chunk, - phase: "progress", - source: kind, - ...(messageID ? { messageID } : {}), - partID, - }, - ] satisfies StreamCommit[] - }) -} diff --git a/packages/opencode/src/cli/cmd/run/session.shared.ts b/packages/opencode/src/cli/cmd/run/session.shared.ts index 7dbce26efd..49dece5a89 100644 --- a/packages/opencode/src/cli/cmd/run/session.shared.ts +++ b/packages/opencode/src/cli/cmd/run/session.shared.ts @@ -152,12 +152,52 @@ export function createSession(messages: SessionMessages): RunSession { } } -export async function resolveSession(sdk: RunInput["sdk"], sessionID: string, limit = LIMIT): Promise { - const response = await sdk.session.messages({ - sessionID, - limit, - }) - return createSession(response.data ?? []) +export async function resolveCurrentSession( + sdk: RunInput["sdk"], + sessionID: string, + limit = LIMIT, +): Promise { + const response = await sdk.v2.session.messages({ sessionID, limit, order: "desc" }, { throwOnError: true }) + const messages = response.data.data.toReversed() + const session = await sdk.v2.session.get({ sessionID }, { throwOnError: true }) + return { + first: messages.length === 0, + turns: messages.flatMap((message) => { + if (message.type !== "user") return [] + return [ + { + prompt: { + text: message.text, + parts: [ + ...(message.files ?? []).map((file) => ({ + type: "file" as const, + url: file.uri, + mime: file.mime, + filename: file.name, + source: file.source + ? { + type: "file" as const, + path: file.name ?? file.uri, + text: { start: file.source.start, end: file.source.end, value: file.source.text }, + } + : undefined, + })), + ...(message.agents ?? []).map((agent) => ({ + type: "agent" as const, + name: agent.name, + source: agent.source + ? { start: agent.source.start, end: agent.source.end, value: agent.source.text } + : undefined, + })), + ], + }, + provider: session.data.data.model?.providerID, + model: session.data.data.model?.id, + variant: session.data.data.model?.variant, + }, + ] + }), + } } export function sessionHistory(session: RunSession, limit = LIMIT): RunPrompt[] { diff --git a/packages/opencode/src/cli/cmd/run/splash.ts b/packages/opencode/src/cli/cmd/run/splash.ts index 141ff6fc55..9e9321cafd 100644 --- a/packages/opencode/src/cli/cmd/run/splash.ts +++ b/packages/opencode/src/cli/cmd/run/splash.ts @@ -234,7 +234,7 @@ function build(input: SplashWriterInput, kind: "entry" | "exit", ctx: Scrollback lines, body_left + label.length, top + 1, - `opencode --mini -s ${meta.session_id}`, + `opencode mini -s ${meta.session_id}`, right, undefined, TextAttributes.BOLD, diff --git a/packages/opencode/src/cli/cmd/run/stream-v2.subagent.ts b/packages/opencode/src/cli/cmd/run/stream-v2.subagent.ts new file mode 100644 index 0000000000..1988d18d89 --- /dev/null +++ b/packages/opencode/src/cli/cmd/run/stream-v2.subagent.ts @@ -0,0 +1,698 @@ +// Current-native subagent (child Session) tracking for the mini transport. +// +// Discovers child Sessions of the active parent from four current sources: +// 1. projected subagent tool output (`structured.sessionID`) during hydration +// 2. the current session list filtered by `parentID` during hydration +// 3. the process-local active-session map during hydration +// 4. live events from unknown sessions whose `parentID` matches the parent +// +// Tracks one footer tab per child and a detail transcript for the selected +// child, reduced from the same current live event stream the parent uses. +// Detail transcripts rebuild from projected messages on discovery, selection, +// and reconnect, then continue from live deltas using the same +// projected-prefix dedup the parent transport uses. +// +// Per-child interruption uses `v2.session.interrupt(childID)`. Per-child +// backgrounding is intentionally absent: subagent jobs block the parent +// session, so only whole-session `v2.session.background(parentID)` exists. +import type { + OpencodeClient, + SessionMessage, + SessionMessageAssistantTool, + ToolPart, + V2Event, +} from "@opencode-ai/sdk/v2" +import { Locale } from "@/util/locale" +import type { FooterSubagentDetail, FooterSubagentState, FooterSubagentTab, StreamCommit } from "./types" + +const CHILD_MESSAGE_LIMIT = 80 +const CHILD_FRAME_LIMIT = 80 +const DISCOVERY_BUFFER_LIMIT = 64 +const FAMILY_LIST_LIMIT = 100 +const FALLBACK_LABEL = "Subagent" + +export function outputText(content: ReadonlyArray<{ type: string; text?: string }>) { + return content.flatMap((item) => (item.type === "text" && item.text ? [item.text] : [])).join("\n") +} + +export function legacyTool(input: { + sessionID: string + messageID: string + callID: string + name: string + state: SessionMessageAssistantTool["state"] + time: SessionMessageAssistantTool["time"] + provider?: SessionMessageAssistantTool["provider"] +}): ToolPart { + const base = { + id: `prt_${input.callID}`, + sessionID: input.sessionID, + messageID: input.messageID, + type: "tool" as const, + callID: input.callID, + tool: input.name, + } + if (input.state.status === "pending") { + return { + ...base, + state: { status: "pending", input: {}, raw: input.state.input }, + } + } + if (input.state.status === "running") { + return { + ...base, + state: { + status: "running", + input: input.state.input, + title: input.name, + metadata: { structured: input.state.structured, content: input.state.content, providerCall: input.provider }, + time: { start: input.time.ran ?? input.time.created }, + }, + } + } + if (input.state.status === "completed") { + return { + ...base, + state: { + status: "completed", + input: input.state.input, + output: outputText(input.state.content), + title: input.name, + metadata: { + structured: input.state.structured, + content: input.state.content, + outputPaths: input.state.outputPaths, + result: input.state.result, + providerCall: input.provider, + }, + time: { start: input.time.ran ?? input.time.created, end: input.time.completed ?? input.time.created }, + }, + } + } + return { + ...base, + state: { + status: "error", + input: input.state.input, + error: input.state.error.message, + metadata: { + structured: input.state.structured, + content: input.state.content, + result: input.state.result, + providerCall: input.provider, + }, + time: { start: input.time.ran ?? input.time.created, end: input.time.completed ?? input.time.created }, + }, + } +} + +export function toolCommit(part: ToolPart, phase: "start" | "progress" | "final"): StreamCommit { + const status = part.state.status + const text = + status === "running" + ? part.tool === "task" + ? "running task" + : `running ${part.tool}` + : status === "completed" + ? part.state.output + : status === "error" + ? part.state.error + : "" + return { + kind: "tool", + source: "tool", + text, + phase, + messageID: part.messageID, + partID: part.id, + tool: part.tool, + part, + toolState: status === "error" ? "error" : status === "completed" ? "completed" : "running", + toolError: status === "error" ? part.state.error : undefined, + } +} + +type Frame = { + key: string + commit: StreamCommit +} + +type ToolTrack = { + name: string + input: Record + started: number +} + +type ChildState = { + sessionID: string + label: string + description: string + status: FooterSubagentTab["status"] + background: boolean + title?: string + callIDs: Set + lastUpdatedAt: number + frames: Frame[] + text: Map + projectedText: Map + reasoning: Map + projectedReasoning: Map + tools: Map + finishedTools: Set + messageIDs: Set + hydrated: boolean +} + +export type SubagentTrackerInput = { + sdk: OpencodeClient + sessionID: string + thinking: boolean + emit: () => void +} + +export type SubagentTracker = { + main(event: V2Event): void + foreign(sessionID: string, event: V2Event): void + hydrate(next: { messages: SessionMessage[]; active: Record }): Promise + select(sessionID: string | undefined): void + snapshot(): FooterSubagentState +} + +function record(value: unknown): Record | undefined { + if (typeof value === "object" && value !== null && !Array.isArray(value)) return value as Record + return undefined +} + +function text(value: unknown): string | undefined { + if (typeof value !== "string") return undefined + const next = value.trim() + return next || undefined +} + +function childSessionID(structured: Record | undefined) { + const sessionID = text(structured?.sessionID) + if (!sessionID || !sessionID.startsWith("ses")) return undefined + const status = structured?.status + if (status !== "running" && status !== "completed") return undefined + return { sessionID, running: status === "running" } +} + +function tab(child: ChildState): FooterSubagentTab { + return { + sessionID: child.sessionID, + partID: `subagent:${child.sessionID}`, + callID: `subagent:${child.sessionID}`, + label: child.label, + description: child.description || child.title || "", + status: child.status, + background: child.background ? true : undefined, + title: child.title, + toolCalls: child.callIDs.size > 0 ? child.callIDs.size : undefined, + lastUpdatedAt: child.lastUpdatedAt, + } +} + +export function createSubagentTracker(input: SubagentTrackerInput): SubagentTracker { + const children = new Map() + // Live subagent tool calls in the parent, so tool.success structured output + // can be joined with the call's input metadata. + const pendingCalls = new Map>() + // Foreign sessions already resolved through session.get. Non-children stay + // cached so unrelated concurrent sessions are checked at most once. + const checked = new Set() + // Foreign events buffered while a session.get discovery is in flight, so a + // fast child (including its settled event) is not lost mid-discovery. + const pendingEvents = new Map() + const hydrations = new Map>() + let selected: string | undefined + + const ensureChild = (sessionID: string): ChildState => { + const existing = children.get(sessionID) + const child: ChildState = existing ?? { + sessionID, + label: FALLBACK_LABEL, + description: "", + status: "running", + background: false, + callIDs: new Set(), + lastUpdatedAt: Date.now(), + frames: [], + text: new Map(), + projectedText: new Map(), + reasoning: new Map(), + projectedReasoning: new Map(), + tools: new Map(), + finishedTools: new Set(), + messageIDs: new Set(), + hydrated: false, + } + if (!existing) children.set(sessionID, child) + // Adopting a child while its session.get discovery is still in flight: + // drain the buffered events now. They arrived before whatever the caller + // applies next, so replaying them first preserves bus order, and the + // resolved discovery can no longer replay stale events (e.g. step.started) + // after a terminal settled event was applied directly. + const buffered = pendingEvents.get(sessionID) + if (buffered) { + pendingEvents.delete(sessionID) + for (const event of buffered) reduce(child, event) + } + return child + } + + const touch = (child: ChildState, timestamp?: number) => { + child.lastUpdatedAt = Math.max(child.lastUpdatedAt, timestamp ?? Date.now()) + } + + const notifyDetail = (child: ChildState) => { + if (child.sessionID === selected) input.emit() + } + + const setFrame = (child: ChildState, key: string, commit: StreamCommit) => { + const index = child.frames.findIndex((item) => item.key === key) + if (index === -1) { + child.frames.push({ key, commit }) + if (child.frames.length > CHILD_FRAME_LIMIT) child.frames.splice(0, child.frames.length - CHILD_FRAME_LIMIT) + return + } + child.frames[index] = { key, commit } + } + + const applyMeta = (child: ChildState, meta: Record | undefined) => { + if (!meta) return + const agent = text(meta.agent) + if (agent) child.label = Locale.titlecase(agent) + const description = text(meta.description) + if (description) child.description = description + if (meta.background === true) child.background = true + } + + const userFrame = (child: ChildState, messageID: string, value: string) => { + if (child.messageIDs.has(messageID)) return false + child.messageIDs.add(messageID) + setFrame(child, `user:${messageID}`, { + kind: "user", + source: "system", + text: value, + phase: "start", + messageID, + }) + return true + } + + const childTool = (child: ChildState, item: SessionMessageAssistantTool, messageID: string) => { + const part = legacyTool({ + sessionID: child.sessionID, + messageID, + callID: item.id, + name: item.name, + state: item.state, + time: item.time, + provider: item.provider, + }) + if (item.state.status === "pending") return + child.callIDs.add(item.id) + if (item.state.status === "running") { + setFrame(child, `tool:${item.id}`, toolCommit(part, "start")) + return + } + child.finishedTools.add(item.id) + child.tools.delete(item.id) + setFrame(child, `tool:${item.id}`, toolCommit(part, "final")) + } + + const rebuild = (child: ChildState, messages: SessionMessage[]) => { + child.frames = [] + child.text.clear() + child.projectedText.clear() + child.reasoning.clear() + child.projectedReasoning.clear() + child.finishedTools.clear() + child.messageIDs.clear() + child.callIDs.clear() + for (const message of messages) { + if (message.type === "user") { + userFrame(child, message.id, message.text) + continue + } + if (message.type !== "assistant") continue + child.messageIDs.add(message.id) + for (const item of message.content) { + if (item.type === "text") { + child.text.set(item.id, item.text) + child.projectedText.set(item.id, item.text) + setFrame(child, `text:${item.id}`, { + kind: "assistant", + source: "assistant", + text: item.text, + phase: "progress", + messageID: message.id, + partID: item.id, + }) + continue + } + if (item.type === "reasoning") { + child.reasoning.set(item.id, item.text) + child.projectedReasoning.set(item.id, item.text) + if (input.thinking) + setFrame(child, `reasoning:${item.id}`, { + kind: "reasoning", + source: "reasoning", + text: `Thinking: ${item.text}`, + phase: "progress", + messageID: message.id, + partID: item.id, + }) + continue + } + childTool(child, item, message.id) + } + if (message.error) { + setFrame(child, `error:${message.id}`, { + kind: "error", + source: "system", + text: message.error.message, + phase: "start", + messageID: message.id, + }) + } + } + } + + const hydrateChild = (child: ChildState): Promise => { + const existing = hydrations.get(child.sessionID) + if (existing) return existing + const task = input.sdk.v2.session + .messages({ sessionID: child.sessionID, limit: CHILD_MESSAGE_LIMIT, order: "desc" }, { throwOnError: true }) + .then((response) => { + rebuild(child, response.data.data.toReversed()) + child.hydrated = true + notifyDetail(child) + }) + .catch(() => {}) + .finally(() => { + hydrations.delete(child.sessionID) + }) + hydrations.set(child.sessionID, task) + return task + } + + const discover = (sessionID: string) => { + if (checked.has(sessionID) || children.has(sessionID) || sessionID === input.sessionID) return + checked.add(sessionID) + if (!pendingEvents.has(sessionID)) pendingEvents.set(sessionID, []) + void input.sdk.v2.session + .get({ sessionID }, { throwOnError: true }) + .then((response) => { + const session = response.data.data + const buffered = pendingEvents.get(sessionID) ?? [] + pendingEvents.delete(sessionID) + if (session.parentID !== input.sessionID) return + const child = ensureChild(sessionID) + if (session.agent) child.label = Locale.titlecase(session.agent) + child.title = session.title + for (const event of buffered) reduce(child, event) + touch(child) + input.emit() + void hydrateChild(child) + }) + .catch(() => { + // Allow a later event to retry discovery after transient failures. + pendingEvents.delete(sessionID) + checked.delete(sessionID) + }) + } + + const reduce = (child: ChildState, event: V2Event) => { + if (event.type === "session.next.prompted") { + if (userFrame(child, event.data.messageID, event.data.prompt.text)) { + touch(child, event.data.timestamp) + notifyDetail(child) + } + return + } + if (event.type === "session.next.step.started") { + touch(child, event.data.timestamp) + if (child.label === FALLBACK_LABEL && event.data.agent) child.label = Locale.titlecase(event.data.agent) + if (child.status !== "running") child.status = "running" + input.emit() + return + } + if (event.type === "session.next.text.delta") { + const projected = child.projectedText.get(event.data.textID) + const covered = projected?.indexOf(event.data.delta) ?? -1 + if (projected && covered >= 0) { + child.projectedText.set(event.data.textID, projected.slice(covered + event.data.delta.length)) + return + } + const next = (child.text.get(event.data.textID) ?? "") + event.data.delta + child.text.set(event.data.textID, next) + setFrame(child, `text:${event.data.textID}`, { + kind: "assistant", + source: "assistant", + text: next, + phase: "progress", + messageID: event.data.assistantMessageID, + partID: event.data.textID, + }) + touch(child, event.data.timestamp) + notifyDetail(child) + return + } + if (event.type === "session.next.text.ended") { + child.text.set(event.data.textID, event.data.text) + child.projectedText.delete(event.data.textID) + setFrame(child, `text:${event.data.textID}`, { + kind: "assistant", + source: "assistant", + text: event.data.text, + phase: "progress", + messageID: event.data.assistantMessageID, + partID: event.data.textID, + }) + touch(child, event.data.timestamp) + notifyDetail(child) + return + } + if (event.type === "session.next.reasoning.delta") { + const projected = child.projectedReasoning.get(event.data.reasoningID) + const covered = projected?.indexOf(event.data.delta) ?? -1 + if (projected && covered >= 0) { + child.projectedReasoning.set(event.data.reasoningID, projected.slice(covered + event.data.delta.length)) + return + } + const next = (child.reasoning.get(event.data.reasoningID) ?? "") + event.data.delta + child.reasoning.set(event.data.reasoningID, next) + if (!input.thinking) return + setFrame(child, `reasoning:${event.data.reasoningID}`, { + kind: "reasoning", + source: "reasoning", + text: `Thinking: ${next}`, + phase: "progress", + messageID: event.data.assistantMessageID, + partID: event.data.reasoningID, + }) + notifyDetail(child) + return + } + if (event.type === "session.next.reasoning.ended") { + child.reasoning.set(event.data.reasoningID, event.data.text) + child.projectedReasoning.delete(event.data.reasoningID) + if (!input.thinking) return + setFrame(child, `reasoning:${event.data.reasoningID}`, { + kind: "reasoning", + source: "reasoning", + text: `Thinking: ${event.data.text}`, + phase: "progress", + messageID: event.data.assistantMessageID, + partID: event.data.reasoningID, + }) + notifyDetail(child) + return + } + if (event.type === "session.next.tool.input.started") { + child.tools.set(event.data.callID, { name: event.data.name, input: {}, started: event.data.timestamp }) + return + } + if (event.type === "session.next.tool.called") { + const current = child.tools.get(event.data.callID) + child.tools.set(event.data.callID, { + name: event.data.tool, + input: event.data.input, + started: current?.started ?? event.data.timestamp, + }) + childTool( + child, + { + type: "tool", + id: event.data.callID, + name: event.data.tool, + provider: event.data.provider, + state: { status: "running", input: event.data.input, structured: {}, content: [] }, + time: { created: current?.started ?? event.data.timestamp, ran: event.data.timestamp }, + }, + event.data.assistantMessageID, + ) + touch(child, event.data.timestamp) + notifyDetail(child) + return + } + if (event.type === "session.next.tool.success" || event.type === "session.next.tool.failed") { + if (child.finishedTools.has(event.data.callID)) return + const current = child.tools.get(event.data.callID) + const failed = event.type === "session.next.tool.failed" + childTool( + child, + { + type: "tool", + id: event.data.callID, + name: current?.name ?? "tool", + provider: event.data.provider, + state: failed + ? { + status: "error", + input: current?.input ?? {}, + structured: {}, + content: [], + error: event.data.error, + result: event.data.result, + } + : { + status: "completed", + input: current?.input ?? {}, + structured: event.data.structured, + content: event.data.content, + outputPaths: event.data.outputPaths, + result: event.data.result, + }, + time: { + created: current?.started ?? event.data.timestamp, + ran: current?.started, + completed: event.data.timestamp, + }, + }, + event.data.assistantMessageID, + ) + touch(child, event.data.timestamp) + notifyDetail(child) + return + } + if (event.type === "session.next.step.failed") { + setFrame(child, `error:step:${event.data.assistantMessageID}`, { + kind: "error", + source: "system", + text: event.data.error.message, + phase: "start", + messageID: event.data.assistantMessageID, + }) + touch(child, event.data.timestamp) + notifyDetail(child) + return + } + if (event.type === "session.next.execution.settled") { + child.status = + event.data.outcome === "success" ? "completed" : event.data.outcome === "interrupted" ? "cancelled" : "error" + touch(child, event.data.timestamp) + input.emit() + } + } + + const mainTool = (item: SessionMessageAssistantTool, active?: Record) => { + if (item.name !== "subagent" || item.state.status !== "completed") return + const found = childSessionID(record(item.state.structured)) + if (!found) return + const child = ensureChild(found.sessionID) + applyMeta(child, record(item.state.input)) + if (found.running) child.background = true + if (child.status === "running") { + const running = found.running && (!active || found.sessionID in active) + child.status = running ? "running" : "completed" + } + touch(child, item.time.completed ?? item.time.created) + } + + return { + main(event) { + if (event.type === "session.next.tool.called") { + if (event.data.tool === "subagent") pendingCalls.set(event.data.callID, event.data.input) + return + } + if (event.type === "session.next.tool.failed") { + pendingCalls.delete(event.data.callID) + return + } + if (event.type !== "session.next.tool.success") return + const pending = pendingCalls.get(event.data.callID) + pendingCalls.delete(event.data.callID) + const found = childSessionID(record(event.data.structured)) + if (!found) return + const child = ensureChild(found.sessionID) + applyMeta(child, pending) + if (found.running) { + child.background = true + child.status = "running" + } + if (!found.running && child.status === "running") child.status = "completed" + touch(child, event.data.timestamp) + input.emit() + if (!child.hydrated) void hydrateChild(child) + }, + foreign(sessionID, event) { + const child = children.get(sessionID) + if (child) { + reduce(child, event) + return + } + discover(sessionID) + const buffered = pendingEvents.get(sessionID) + if (buffered && buffered.length < DISCOVERY_BUFFER_LIMIT) buffered.push(event) + }, + async hydrate(next) { + for (const message of next.messages) { + if (message.type !== "assistant") continue + for (const item of message.content) { + if (item.type === "tool") mainTool(item, next.active) + } + } + // Family index: adopt children directly from the current session list so + // historical subagents beyond the projected message window still get tabs. + const family = await input.sdk.v2.session + .list({ limit: FAMILY_LIST_LIMIT, order: "desc" }, { throwOnError: true }) + .then((response) => response.data.data.filter((session) => session.parentID === input.sessionID)) + .catch(() => []) + for (const session of family) { + const child = ensureChild(session.id) + if (session.agent && child.label === FALLBACK_LABEL) child.label = Locale.titlecase(session.agent) + if (!child.title) child.title = session.title + touch(child, session.time.updated) + } + for (const sessionID of Object.keys(next.active)) discover(sessionID) + for (const child of children.values()) { + // Reconnect can miss a child's settled event; the active map is the + // authoritative live signal for still-running children. + if (child.status === "running" && !(child.sessionID in next.active)) child.status = "completed" + } + const current = selected ? children.get(selected) : undefined + if (current) await hydrateChild(current) + if (children.size > 0) input.emit() + }, + select(sessionID) { + selected = sessionID + const child = sessionID ? children.get(sessionID) : undefined + if (child && !child.hydrated) void hydrateChild(child) + input.emit() + }, + snapshot() { + const tabs = [...children.values()].map(tab).toSorted((a, b) => { + const active = Number(b.status === "running") - Number(a.status === "running") + if (active !== 0) return active + return b.lastUpdatedAt - a.lastUpdatedAt + }) + const child = selected ? children.get(selected) : undefined + const details: Record = child + ? { [child.sessionID]: { sessionID: child.sessionID, commits: child.frames.map((item) => item.commit) } } + : {} + return { tabs, details, permissions: [], questions: [] } + }, + } +} diff --git a/packages/opencode/src/cli/cmd/run/stream-v2.transport.ts b/packages/opencode/src/cli/cmd/run/stream-v2.transport.ts new file mode 100644 index 0000000000..7700ddd024 --- /dev/null +++ b/packages/opencode/src/cli/cmd/run/stream-v2.transport.ts @@ -0,0 +1,798 @@ +import type { + OpencodeClient, + PermissionRequest, + PermissionV2Request, + QuestionRequest, + QuestionV2Request, + SessionMessage, + SessionMessageAssistant, + SessionMessageAssistantTool, + V2Event, +} from "@opencode-ai/sdk/v2" +import { blockerStatus, pickBlockerView } from "./session-data" +import { writeSessionOutput } from "./stream" +import { createSubagentTracker, legacyTool, toolCommit } from "./stream-v2.subagent" +import type { + FooterApi, + FooterView, + LocalReplayAnchor, + LocalReplayRow, + RunFilePart, + RunInput, + RunPrompt, + RunPromptPart, + RunProvider, + StreamCommit, +} from "./types" + +type Trace = { + write(type: string, data?: unknown): void +} + +type StreamInput = { + sdk: OpencodeClient + directory?: string + sessionID: string + thinking: boolean + replay?: boolean + replayLimit?: number + limits: () => Record + providers?: () => RunProvider[] + footer: FooterApi + trace?: Trace + signal?: AbortSignal +} + +export type SessionTurnInput = { + agent: string | undefined + model: RunInput["model"] + variant: string | undefined + prompt: RunPrompt + files: RunFilePart[] + includeFiles: boolean + onVisibleOutput?: (anchor: LocalReplayAnchor) => void + signal?: AbortSignal +} + +export type SessionResizeReplayInput = { + localRows: () => LocalReplayRow[] + reset: () => Promise +} + +export type SessionTransport = { + runPromptTurn(input: SessionTurnInput): Promise + interruptActiveTurn(): Promise + selectSubagent(sessionID: string | undefined): void + replayOnResize(input: SessionResizeReplayInput): Promise + close(): Promise +} + +type Wait = { + messageID: string + promoted: boolean + interrupted: boolean + failureRendered: boolean + resolve: () => void + reject: (error: unknown) => void + onVisibleOutput?: (anchor: LocalReplayAnchor) => void +} + +type RunV2Event = V2Event +type PromptFilePart = Extract + +type ToolState = { + messageID: string + name: string + input: Record + started: number + running: boolean +} + +type State = { + permissions: PermissionRequest[] + questions: QuestionRequest[] + view: FooterView + messageIDs: Set + text: Map + projectedText: Map + reasoning: Map + projectedReasoning: Map + tools: Map + finishedTools: Set + wait?: Wait + connected: boolean + closed: boolean + initial: boolean + buffered?: RunV2Event[] + errors: Set +} + +const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }) + +export function formatUnknownError(error: unknown): string { + if (typeof error === "string") return error + if (error instanceof Error) return error.message || error.name + if (error && typeof error === "object") { + const message = Reflect.get(error, "message") + if (typeof message === "string" && message.trim()) return message + const tag = Reflect.get(error, "_tag") + if (typeof tag === "string" && tag.trim()) return tag + } + return "unknown error" +} + +function permission(request: PermissionV2Request): PermissionRequest { + return { + id: request.id, + sessionID: request.sessionID, + permission: request.action, + patterns: request.resources, + metadata: request.metadata ?? {}, + always: request.save ?? [], + tool: request.source?.type === "tool" ? request.source : undefined, + } +} + +function question(request: QuestionV2Request): QuestionRequest { + return { + id: request.id, + sessionID: request.sessionID, + questions: request.questions, + tool: request.tool, + } +} + +function sessionID(event: RunV2Event) { + return "sessionID" in event.data && typeof event.data.sessionID === "string" ? event.data.sessionID : undefined +} + +function errorMessage(error: { message?: string; _tag?: string }) { + return error.message || error._tag || "Session execution failed" +} + +function wait(delay: number, signal: AbortSignal) { + return new Promise((resolve) => { + const timer = setTimeout(done, delay) + signal.addEventListener("abort", done, { once: true }) + function done() { + clearTimeout(timer) + signal.removeEventListener("abort", done) + resolve() + } + }) +} + +async function prepareFile(file: RunFilePart) { + if (file.mime !== "text/plain") return { attachment: { uri: file.url, mime: file.mime, name: file.filename } } + const content = file.url.startsWith("data:") + ? Buffer.from(file.url.slice(file.url.indexOf(",") + 1), "base64").toString("utf8") + : await Bun.file(new URL(file.url)).text() + return { text: `\n${content}\n` } +} + +function promptFileSource(part: PromptFilePart) { + if (!part.source?.text) return + return { + start: part.source.text.start, + end: part.source.text.end, + text: part.source.text.value, + } +} + +function streamPartKey(messageID: string, partID: string) { + return `${messageID}\u0000${partID}` +} + +async function resolveSelectedModel(input: StreamInput, next: Pick) { + if (next.model) return { providerID: next.model.providerID, id: next.model.modelID, variant: next.variant } + if (!next.variant) return + + const session = await input.sdk.v2.session + .get({ sessionID: input.sessionID }, { throwOnError: true, signal: next.signal }) + .then((response) => response.data.data.model) + if (session) return { ...session, variant: next.variant } + + const fallback = await input.sdk.v2.model + .default(undefined, { throwOnError: true, signal: next.signal }) + .then((response) => response.data.data) + if (!fallback) return + return { providerID: fallback.providerID, id: fallback.id, variant: next.variant } +} + +export async function createSessionTransport(input: StreamInput): Promise { + const controller = new AbortController() + input.signal?.addEventListener("abort", () => controller.abort(), { once: true }) + const state: State = { + permissions: [], + questions: [], + view: { type: "prompt" }, + messageIDs: new Set(), + text: new Map(), + projectedText: new Map(), + reasoning: new Map(), + projectedReasoning: new Map(), + tools: new Map(), + finishedTools: new Set(), + connected: false, + closed: false, + initial: true, + errors: new Set(), + } + let readyResolve!: () => void + let readyReject!: (error: unknown) => void + const ready = new Promise((resolve, reject) => { + readyResolve = resolve + readyReject = reject + }) + const abortReady = () => readyReject(new Error("Mini closed before the event stream connected")) + controller.signal.addEventListener("abort", abortReady, { once: true }) + const offFooterClose = input.footer.onClose(() => controller.abort()) + + const subagents = createSubagentTracker({ + sdk: input.sdk, + sessionID: input.sessionID, + thinking: input.thinking, + emit: () => { + if (state.closed || input.footer.isClosed) return + writeSessionOutput( + { footer: input.footer, trace: input.trace }, + { commits: [], footer: { subagent: subagents.snapshot() } }, + ) + }, + }) + + const write = (commits: StreamCommit[], patch?: { phase?: "idle" | "running"; status?: string; usage?: string }) => { + const visible = commits.at(-1) + if (visible) { + state.wait?.onVisibleOutput?.({ + kind: visible.kind, + text: visible.text, + phase: visible.phase, + messageID: visible.messageID, + partID: visible.partID, + toolState: visible.toolState, + }) + } + writeSessionOutput({ footer: input.footer, trace: input.trace }, { commits, footer: patch ? { patch } : undefined }) + } + + const syncBlockers = () => { + const next = pickBlockerView({ permission: state.permissions[0], question: state.questions[0] }) + if (next.type === "prompt" && state.view.type === "prompt") return + if (next.type !== "prompt" && state.view.type === next.type && next.request.id === state.view.request.id) return + state.view = next + writeSessionOutput( + { footer: input.footer, trace: input.trace }, + { commits: [], footer: { view: next, patch: { status: blockerStatus(next) } } }, + ) + } + + const renderTool = (messageID: string, item: SessionMessageAssistantTool) => { + const part = legacyTool({ + sessionID: input.sessionID, + messageID, + callID: item.id, + name: item.name, + state: item.state, + time: item.time, + provider: item.provider, + }) + if (item.state.status === "pending") return + if (item.state.status === "running") { + if (state.tools.get(item.id)?.running) return + state.tools.set(item.id, { + messageID, + name: item.name, + input: item.state.input, + started: item.time.ran ?? item.time.created, + running: true, + }) + write([toolCommit(part, "start")], { phase: "running", status: `running ${item.name}` }) + return + } + if (state.finishedTools.has(item.id)) return + if (!state.tools.get(item.id)?.running) write([toolCommit(part, "start")]) + state.finishedTools.add(item.id) + state.tools.delete(item.id) + write([toolCommit(part, item.state.status === "completed" && part.state.status === "completed" && part.state.output ? "progress" : "final")]) + } + + const renderMessage = (message: SessionMessage, render: boolean, reuseVisibleWait: boolean) => { + if (message.type === "user") { + const waiting = state.wait?.messageID === message.id + if (waiting && state.wait) state.wait.promoted = true + if (!render || state.messageIDs.has(message.id)) return + state.messageIDs.add(message.id) + if (reuseVisibleWait && waiting) return + write([{ kind: "user", source: "system", text: message.text, phase: "start", messageID: message.id }]) + return + } + if (message.type !== "assistant") return + state.messageIDs.add(message.id) + for (const item of message.content) { + if (item.type === "text") { + const key = streamPartKey(message.id, item.id) + const sent = state.text.get(key)?.length ?? 0 + state.text.set(key, item.text) + if (render) state.projectedText.set(key, item.text) + if (render && item.text.length > sent) + write([ + { + kind: "assistant", + source: "assistant", + text: item.text.slice(sent), + phase: "progress", + messageID: message.id, + partID: item.id, + }, + ]) + continue + } + if (item.type === "reasoning") { + const key = streamPartKey(message.id, item.id) + const sent = state.reasoning.get(key)?.length ?? 0 + state.reasoning.set(key, item.text) + if (render) state.projectedReasoning.set(key, item.text) + if (render && input.thinking && item.text.length > sent) + write([ + { + kind: "reasoning", + source: "reasoning", + text: sent === 0 ? `Thinking: ${item.text}` : item.text.slice(sent), + phase: "progress", + messageID: message.id, + partID: item.id, + }, + ]) + continue + } + if (render) renderTool(message.id, item) + } + if (render && message.error && !state.errors.has(message.id)) { + state.errors.add(message.id) + write([ + { + kind: "error", + source: "system", + text: errorMessage(message.error), + phase: "start", + messageID: message.id, + }, + ]) + } + } + + const hydrate = async (next: { render: boolean; reuseVisibleWait: boolean }) => { + const [messages, permissions, questions, active] = await Promise.all([ + input.sdk.v2.session.messages( + { sessionID: input.sessionID, limit: input.replayLimit ?? 200, order: "desc" }, + { throwOnError: true }, + ), + input.sdk.v2.session.permission.list({ sessionID: input.sessionID }, { throwOnError: true }), + input.sdk.v2.session.question.list({ sessionID: input.sessionID }, { throwOnError: true }), + input.sdk.v2.session.active({ throwOnError: true }), + ]) + const projected = messages.data.data.toReversed() + for (const message of projected) renderMessage(message, next.render, next.reuseVisibleWait) + state.permissions = permissions.data.data.map(permission) + state.questions = questions.data.data.map(question) + syncBlockers() + await subagents.hydrate({ messages: projected, active: active.data.data }) + const running = input.sessionID in active.data.data + write([], { phase: running ? "running" : "idle", status: running ? "assistant responding" : "" }) + if (!running && state.wait && (state.wait.promoted || state.wait.interrupted)) { + const current = state.wait + state.wait = undefined + current.resolve() + } + } + + const apply = (event: RunV2Event) => { + const source = sessionID(event) + if (source !== input.sessionID) { + if (source) subagents.foreign(source, event) + return + } + input.trace?.write("recv.event", event) + subagents.main(event) + if (event.type === "session.next.prompted") { + if (state.wait?.messageID === event.data.messageID) state.wait.promoted = true + state.messageIDs.add(event.data.messageID) + write([], { phase: "running", status: "waiting for assistant" }) + return + } + if (event.type === "session.next.step.started") { + write([], { phase: "running", status: "assistant responding" }) + return + } + if (event.type === "session.next.text.delta") { + const key = streamPartKey(event.data.assistantMessageID, event.data.textID) + const projected = state.projectedText.get(key) + const covered = projected?.indexOf(event.data.delta) ?? -1 + if (projected && covered >= 0) { + state.projectedText.set(key, projected.slice(covered + event.data.delta.length)) + return + } + const previous = state.text.get(key) ?? "" + state.text.set(key, previous + event.data.delta) + write([ + { + kind: "assistant", + source: "assistant", + text: event.data.delta, + phase: "progress", + messageID: event.data.assistantMessageID, + partID: event.data.textID, + }, + ]) + return + } + if (event.type === "session.next.text.ended") { + const key = streamPartKey(event.data.assistantMessageID, event.data.textID) + const previous = state.text.get(key) ?? "" + if (event.data.text.length > previous.length) + write([ + { + kind: "assistant", + source: "assistant", + text: event.data.text.slice(previous.length), + phase: "progress", + messageID: event.data.assistantMessageID, + partID: event.data.textID, + }, + ]) + state.text.set(key, event.data.text) + state.projectedText.delete(key) + return + } + if (event.type === "session.next.reasoning.delta") { + const key = streamPartKey(event.data.assistantMessageID, event.data.reasoningID) + const projected = state.projectedReasoning.get(key) + const covered = projected?.indexOf(event.data.delta) ?? -1 + if (projected && covered >= 0) { + state.projectedReasoning.set(key, projected.slice(covered + event.data.delta.length)) + return + } + const previous = state.reasoning.get(key) ?? "" + state.reasoning.set(key, previous + event.data.delta) + if (input.thinking) + write([ + { + kind: "reasoning", + source: "reasoning", + text: previous ? event.data.delta : `Thinking: ${event.data.delta}`, + phase: "progress", + messageID: event.data.assistantMessageID, + partID: event.data.reasoningID, + }, + ]) + return + } + if (event.type === "session.next.reasoning.ended") { + const key = streamPartKey(event.data.assistantMessageID, event.data.reasoningID) + const previous = state.reasoning.get(key) ?? "" + if (input.thinking && event.data.text.length > previous.length) + write([ + { + kind: "reasoning", + source: "reasoning", + text: previous ? event.data.text.slice(previous.length) : `Thinking: ${event.data.text}`, + phase: "progress", + messageID: event.data.assistantMessageID, + partID: event.data.reasoningID, + }, + ]) + state.reasoning.set(key, event.data.text) + state.projectedReasoning.delete(key) + return + } + if (event.type === "session.next.tool.input.started") { + state.tools.set(event.data.callID, { + messageID: event.data.assistantMessageID, + name: event.data.name, + input: {}, + started: event.data.timestamp, + running: false, + }) + return + } + if (event.type === "session.next.tool.called") { + if (state.finishedTools.has(event.data.callID)) return + const current = state.tools.get(event.data.callID) + const item: SessionMessageAssistantTool = { + type: "tool", + id: event.data.callID, + name: event.data.tool, + provider: event.data.provider, + state: { status: "running", input: event.data.input, structured: {}, content: [] }, + time: { created: current?.started ?? event.data.timestamp, ran: event.data.timestamp }, + } + renderTool(event.data.assistantMessageID, item) + return + } + if (event.type === "session.next.tool.progress") return + if (event.type === "session.next.tool.success" || event.type === "session.next.tool.failed") { + const current = state.tools.get(event.data.callID) + const failed = event.type === "session.next.tool.failed" + const item: SessionMessageAssistantTool = { + type: "tool", + id: event.data.callID, + name: current?.name ?? "tool", + provider: event.data.provider, + state: failed + ? { status: "error", input: current?.input ?? {}, structured: {}, content: [], error: event.data.error, result: event.data.result } + : { + status: "completed", + input: current?.input ?? {}, + structured: event.data.structured, + content: event.data.content, + outputPaths: event.data.outputPaths, + result: event.data.result, + }, + time: { created: current?.started ?? event.data.timestamp, ran: current?.started, completed: event.data.timestamp }, + } + renderTool(event.data.assistantMessageID, item) + return + } + if (event.type === "permission.v2.asked") { + if (!state.permissions.some((item) => item.id === event.data.id)) state.permissions.push(permission(event.data)) + syncBlockers() + return + } + if (event.type === "permission.v2.replied") { + state.permissions = state.permissions.filter((item) => item.id !== event.data.requestID) + syncBlockers() + return + } + if (event.type === "question.v2.asked") { + if (!state.questions.some((item) => item.id === event.data.id)) state.questions.push(question(event.data)) + syncBlockers() + return + } + if (event.type === "question.v2.replied" || event.type === "question.v2.rejected") { + state.questions = state.questions.filter((item) => item.id !== event.data.requestID) + syncBlockers() + return + } + if (event.type === "session.next.step.ended") { + const total = + event.data.tokens.input + + event.data.tokens.output + + event.data.tokens.reasoning + + event.data.tokens.cache.read + + event.data.tokens.cache.write + const usage = total > 0 ? total.toLocaleString() : "" + write([], { phase: event.data.finish === "tool-calls" ? "running" : "idle", usage: event.data.cost ? `${usage} · ${money.format(event.data.cost)}` : usage }) + return + } + if (event.type === "session.next.step.failed") { + state.errors.add(event.data.assistantMessageID) + if (state.wait) state.wait.failureRendered = true + write([{ kind: "error", source: "system", text: errorMessage(event.data.error), phase: "start" }]) + return + } + if (event.type === "session.next.execution.settled") { + write([], { phase: "idle", status: "" }) + const current = state.wait + if (!current || (!current.promoted && !current.interrupted)) return + state.wait = undefined + if (current.interrupted) { + current.resolve() + return + } + if (event.data.outcome === "failure") { + if (current.failureRendered) { + current.resolve() + return + } + current.reject(new Error(event.data.error ? errorMessage(event.data.error) : "Session execution failed")) + return + } + current.resolve() + } + } + + const receive = (event: RunV2Event) => { + if (state.buffered) { + state.buffered.push(event) + return + } + apply(event) + } + + const connect = async () => { + while (!controller.signal.aborted && !input.footer.isClosed) { + const error = await (async () => { + const connection = new AbortController() + const abortConnection = () => connection.abort() + controller.signal.addEventListener("abort", abortConnection, { once: true }) + const response = await input.sdk.v2.event.subscribe({ + signal: connection.signal, + sseMaxRetryAttempts: 0, + throwOnError: true, + }) + const stream = response.stream[Symbol.asyncIterator]() as AsyncGenerator + try { + const first = await stream.next() + if (first.done || first.value.type !== "server.connected") throw new Error("Event stream disconnected") + const buffered: RunV2Event[] = [] + let booting = true + const consume = (async () => { + while (!connection.signal.aborted) { + const next = await stream.next() + if (next.done) throw new Error("Event stream disconnected") + if (booting) buffered.push(next.value) + else receive(next.value) + } + })() + void consume.catch(() => {}) + await hydrate({ render: state.initial ? input.replay === true : true, reuseVisibleWait: !state.initial }) + state.initial = false + booting = false + for (const event of buffered.splice(0)) apply(event) + state.connected = true + readyResolve() + await consume + } finally { + controller.signal.removeEventListener("abort", abortConnection) + connection.abort() + void stream.return?.(undefined).catch(() => {}) + } + })().catch((error) => error) + state.connected = false + if (controller.signal.aborted || input.footer.isClosed) return + input.trace?.write("recv.reconnect", { error: formatUnknownError(error) }) + write([], { phase: "running", status: "reconnecting" }) + await wait(250, controller.signal) + } + } + const connection = connect() + try { + await ready + } catch (error) { + offFooterClose() + throw error + } finally { + controller.signal.removeEventListener("abort", abortReady) + } + + return { + async runPromptTurn(next) { + if (next.prompt.mode === "shell") throw new Error("Shell is not yet available for current Session transcripts") + if (next.prompt.command) throw new Error("Commands are not yet available for current Session transcripts") + if (state.wait) throw new Error("prompt already running") + if (!state.connected) throw new Error("Event stream is reconnecting") + + if (next.agent) { + await input.sdk.v2.session.switchAgent( + { sessionID: input.sessionID, agent: next.agent }, + { throwOnError: true, signal: next.signal }, + ) + } + const selected = await resolveSelectedModel(input, next) + if (next.variant && !selected) throw new Error("Cannot select a variant before selecting a model") + if (selected) + await input.sdk.v2.session.switchModel( + { sessionID: input.sessionID, model: selected }, + { throwOnError: true, signal: next.signal }, + ) + + const prepared = await Promise.all((next.includeFiles ? next.files : []).map(prepareFile)) + const promptFiles = next.prompt.parts.flatMap((part) => + part.type === "file" + ? [ + { + uri: part.url, + name: part.filename, + source: promptFileSource(part), + }, + ] + : [], + ) + const attachments = [ + ...prepared.flatMap((file) => (file.attachment ? [file.attachment] : [])), + ...promptFiles, + ] + const agents = next.prompt.parts.flatMap((part) => + part.type === "agent" + ? [ + { + name: part.name, + source: part.source + ? { start: part.source.start, end: part.source.end, text: part.source.value } + : undefined, + }, + ] + : [], + ) + const messageID = next.prompt.messageID + if (!messageID) throw new Error("Prompt message ID is required") + let resolve!: () => void + let reject!: (error: unknown) => void + const done = new Promise((done, fail) => { + resolve = done + reject = fail + }) + const active: Wait = { + messageID, + promoted: false, + interrupted: false, + failureRendered: false, + resolve, + reject, + onVisibleOutput: next.onVisibleOutput, + } + state.wait = active + const interrupt = () => { + active.interrupted = true + void input.sdk.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {}) + } + next.signal?.addEventListener("abort", interrupt, { once: true }) + try { + input.trace?.write("send.prompt", { sessionID: input.sessionID, messageID }) + await input.sdk.v2.session.prompt( + { + sessionID: input.sessionID, + id: messageID, + prompt: { + text: [ + next.prompt.text, + ...prepared.flatMap((file) => (file.text ? [file.text] : [])), + ].join("\n\n"), + files: attachments.length ? attachments : undefined, + agents: agents.length ? agents : undefined, + }, + delivery: "steer", + }, + { throwOnError: true, signal: next.signal }, + ) + await done + } catch (error) { + if (state.wait === active) state.wait = undefined + if (next.signal?.aborted) return + throw error + } finally { + next.signal?.removeEventListener("abort", interrupt) + } + }, + async interruptActiveTurn() { + if (state.wait) state.wait.interrupted = true + await input.sdk.v2.session.interrupt({ sessionID: input.sessionID }).catch(() => {}) + }, + selectSubagent(sessionID) { + subagents.select(sessionID) + }, + async replayOnResize(next) { + if (!input.replay || state.closed || input.footer.isClosed) return false + const buffered: RunV2Event[] = [] + state.buffered = buffered + try { + await input.footer.idle() + await next.reset() + state.messageIDs.clear() + state.text.clear() + state.projectedText.clear() + state.reasoning.clear() + state.projectedReasoning.clear() + state.tools.clear() + state.finishedTools.clear() + state.errors.clear() + await hydrate({ render: true, reuseVisibleWait: false }) + } finally { + state.buffered = undefined + } + for (const event of buffered) apply(event) + for (const row of next.localRows()) { + if (row.commit.messageID && state.messageIDs.has(row.commit.messageID)) continue + input.footer.append(row.commit) + } + return true + }, + async close() { + state.closed = true + offFooterClose() + controller.abort() + void connection.catch(() => {}) + }, + } +} diff --git a/packages/opencode/src/cli/cmd/run/stream.transport.ts b/packages/opencode/src/cli/cmd/run/stream.transport.ts deleted file mode 100644 index e4817f514d..0000000000 --- a/packages/opencode/src/cli/cmd/run/stream.transport.ts +++ /dev/null @@ -1,1462 +0,0 @@ -// Global event subscription and prompt turn coordination. -// -// Creates a long-lived global event stream subscription and feeds relevant -// events for the current session tree through the reducers. The reducers -// produce scrollback commits and footer patches, which get forwarded to the -// footer through stream.ts. -// -// Prompt turns are one-at-a-time: runPromptTurn() sends the prompt, arms a -// deferred Wait, and resolves when the session becomes idle. -// Prefer session.status idle events, but also poll session.status because some -// transports can miss status events while still delivering message events. If -// the turn is aborted (user interrupt), it flushes any in-progress parts as -// interrupted entries. -// -// The tick counter prevents stale idle events from resolving the wrong turn. -// We also re-check live session status before resolving an idle event so a -// delayed idle from an older turn cannot complete a newer busy turn. -import type { Event, GlobalEvent, OpencodeClient } from "@opencode-ai/sdk/v2" -import { Context, Deferred, Effect, Exit, Layer, Scope, Stream } from "effect" -import { makeRuntime } from "@/effect/run-service" -import { - blockerStatus, - bootstrapSessionData, - createSessionData, - flushInterrupted, - pickBlockerView, - reduceSessionData, - type SessionData, -} from "./session-data" -import { replayActiveText, replayLocalRows, replaySession } from "./session-replay" -import { - bootstrapSubagentCalls, - bootstrapSubagentData, - createSubagentData, - listSubagentPermissions, - listSubagentQuestions, - listSubagentTabs, - reduceSubagentData, - sameSubagentTab, - snapshotSelectedSubagentData, - SUBAGENT_BOOTSTRAP_LIMIT, - SUBAGENT_CALL_BOOTSTRAP_LIMIT, - type SubagentData, -} from "./subagent-data" -import { traceFooterOutput, writeSessionOutput } from "./stream" -import type { - FooterApi, - FooterOutput, - FooterPatch, - FooterSubagentState, - FooterSubagentTab, - FooterView, - LocalReplayAnchor, - LocalReplayRow, - RunFilePart, - RunInput, - RunPrompt, - RunPromptPart, - RunProvider, - StreamCommit, -} from "./types" - -type Trace = { - write(type: string, data?: unknown): void -} - -const StreamClosed = undefined as never - -type StreamInput = { - sdk: OpencodeClient - directory?: string - sessionID: string - thinking: boolean - replay?: boolean - replayLimit?: number - limits: () => Record - providers?: () => RunProvider[] - footer: FooterApi - trace?: Trace - signal?: AbortSignal -} - -type Wait = { - tick: number - armed: boolean - live: boolean - onVisibleOutput?: (anchor: LocalReplayAnchor) => void - done: Deferred.Deferred -} - -export type SessionTurnInput = { - agent: string | undefined - model: RunInput["model"] - variant: string | undefined - prompt: RunPrompt - files: RunFilePart[] - includeFiles: boolean - onVisibleOutput?: (anchor: LocalReplayAnchor) => void - signal?: AbortSignal -} - -export type SessionTransport = { - runPromptTurn(input: SessionTurnInput): Promise - selectSubagent(sessionID: string | undefined): void - replayOnResize(input: SessionResizeReplayInput): Promise - close(): Promise -} - -export type SessionResizeReplayInput = { - localRows: () => LocalReplayRow[] - reset: () => Promise -} - -type State = { - data: SessionData - subagent: SubagentData - wait?: Wait - tick: number - fault?: unknown - footerView: FooterView - blockerTick: number - selectedSubagent?: string - blockers: Map -} - -type TransportService = { - readonly runPromptTurn: (input: SessionTurnInput) => Effect.Effect - readonly selectSubagent: (sessionID: string | undefined) => Effect.Effect - readonly replayOnResize: (input: SessionResizeReplayInput) => Effect.Effect - readonly close: () => Effect.Effect -} - -class Service extends Context.Service()("@opencode/RunStreamTransport") {} - -function sid(event: Event): string | undefined { - if (event.type === "message.updated") { - return event.properties.sessionID - } - - if (event.type === "message.part.delta") { - return event.properties.sessionID - } - - if (event.type === "message.part.updated") { - return event.properties.part.sessionID - } - - if ( - event.type === "session.next.shell.started" || - event.type === "session.next.shell.ended" || - event.type === "permission.asked" || - event.type === "permission.replied" || - event.type === "question.asked" || - event.type === "question.replied" || - event.type === "question.rejected" || - event.type === "session.error" || - event.type === "session.status" - ) { - return event.properties.sessionID - } - - return undefined -} - -function isEvent(value: unknown): value is Event { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return false - } - - const type = Reflect.get(value, "type") - const properties = Reflect.get(value, "properties") - return typeof type === "string" && !!properties && typeof properties === "object" -} - -function isGlobalEvent(value: unknown): value is GlobalEvent { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return false - } - - const payload = Reflect.get(value, "payload") - return !!payload && typeof payload === "object" -} - -function globalPayloadEvent(value: unknown): Event | undefined { - if (!isGlobalEvent(value)) { - return undefined - } - - const payload = value.payload - if (payload.type === "sync") { - return undefined - } - - return isEvent(payload) ? payload : undefined -} - -function isMatchingDisposeEvent(value: unknown, directory: string | undefined): boolean { - if (!directory || !isGlobalEvent(value)) { - return false - } - - if (value.directory !== directory) { - return false - } - - return value.payload.type === "server.instance.disposed" -} - -function active(event: Event, sessionID: string): boolean { - if (sid(event) !== sessionID) { - return false - } - - if (event.type === "message.updated") { - return event.properties.info.role === "assistant" - } - - if (event.type === "message.part.delta" || event.type === "message.part.updated") { - return false - } - - if (event.type !== "session.status") { - return true - } - - return event.properties.status.type !== "idle" -} - -// Races the turn's deferred completion against an abort signal. -function waitTurn(done: Wait["done"], signal: AbortSignal) { - return Effect.raceAll([ - Deferred.await(done).pipe(Effect.as("idle" as const), Effect.exit), - Effect.callback<"abort">((resume) => { - if (signal.aborted) { - resume(Effect.succeed("abort")) - return Effect.void - } - - const onAbort = () => { - signal.removeEventListener("abort", onAbort) - resume(Effect.succeed("abort")) - } - - signal.addEventListener("abort", onAbort, { once: true }) - return Effect.sync(() => signal.removeEventListener("abort", onAbort)) - }).pipe(Effect.exit), - ]).pipe(Effect.flatMap((exit) => (Exit.isFailure(exit) ? Effect.failCause(exit.cause) : Effect.succeed(exit.value)))) -} - -export function formatUnknownError(error: unknown): string { - if (typeof error === "string") { - return error - } - - if (error instanceof Error) { - return error.message || error.name - } - - if (error && typeof error === "object") { - const value = error as { message?: unknown; name?: unknown } - if (typeof value.message === "string" && value.message.trim()) { - return value.message - } - - if (typeof value.name === "string" && value.name.trim()) { - return value.name - } - } - - return "unknown error" -} - -function sameView(a: FooterView, b: FooterView) { - if (a.type !== b.type) { - return false - } - - if (a.type === "prompt" && b.type === "prompt") { - return true - } - - if (a.type === "prompt" || b.type === "prompt") { - return false - } - - return a.request === b.request -} - -function blockerOrder(order: Map, id: string) { - return order.get(id) ?? Number.MAX_SAFE_INTEGER -} - -function firstByOrder(left: T[], right: T[], order: Map) { - return [...left, ...right].sort((a, b) => { - const next = blockerOrder(order, a.id) - blockerOrder(order, b.id) - if (next !== 0) { - return next - } - - return a.id.localeCompare(b.id) - })[0] -} - -function pickView(data: SessionData, subagent: SubagentData, order: Map): FooterView { - return pickBlockerView({ - permission: firstByOrder(data.permissions, listSubagentPermissions(subagent), order), - question: firstByOrder(data.questions, listSubagentQuestions(subagent), order), - }) -} - -function composeFooter(input: { - patch?: FooterPatch - subagent?: FooterSubagentState - current: FooterView - previous: FooterView -}) { - let footer: FooterOutput | undefined - - if (input.subagent) { - footer = { - ...footer, - subagent: input.subagent, - } - } - - if (!sameView(input.previous, input.current)) { - footer = { - ...footer, - view: input.current, - } - } - - if (input.current.type !== "prompt") { - footer = { - ...footer, - patch: { - ...input.patch, - status: blockerStatus(input.current), - }, - } - return footer - } - - if (input.patch) { - footer = { - ...footer, - patch: input.patch, - } - return footer - } - - if (input.previous.type !== "prompt") { - footer = { - ...footer, - patch: { - status: "", - }, - } - } - - return footer -} - -function traceTabs(trace: Trace | undefined, prev: FooterSubagentTab[], next: FooterSubagentTab[]) { - const before = new Map(prev.map((item) => [item.sessionID, item])) - const after = new Map(next.map((item) => [item.sessionID, item])) - - for (const [sessionID, tab] of after) { - if (sameSubagentTab(before.get(sessionID), tab)) { - continue - } - - trace?.write("subagent.tab", { - sessionID, - tab, - }) - } - - for (const sessionID of before.keys()) { - if (after.has(sessionID)) { - continue - } - - trace?.write("subagent.tab", { - sessionID, - cleared: true, - }) - } -} - -function createLayer(input: StreamInput) { - return Layer.fresh( - Layer.effect( - Service, - Effect.gen(function* () { - const scope = yield* Scope.make() - const abort = yield* Scope.provide(scope)( - Effect.acquireRelease( - Effect.sync(() => new AbortController()), - (abort) => Effect.sync(() => abort.abort()), - ), - ) - let closed = false - let closeStream = () => {} - const halt = () => { - abort.abort() - } - const stop = () => { - input.signal?.removeEventListener("abort", halt) - abort.abort() - closeStream() - } - const closeScope = () => { - if (closed) { - return Effect.void - } - - closed = true - stop() - return Scope.close(scope, Exit.void) - } - - input.signal?.addEventListener("abort", halt, { once: true }) - yield* Effect.addFinalizer(() => closeScope()) - - const events = yield* Scope.provide(scope)( - Effect.acquireRelease( - Effect.promise(() => - input.sdk.global.event({ - signal: abort.signal, - }), - ), - (events) => - Effect.sync(() => { - void events.stream.return(StreamClosed).catch(() => {}) - }), - ), - ) - closeStream = () => { - void events.stream.return(StreamClosed).catch(() => {}) - } - input.trace?.write("recv.subscribe", { - sessionID: input.sessionID, - }) - - const state: State = { - data: createSessionData(), - subagent: createSubagentData(), - tick: 0, - footerView: { type: "prompt" }, - blockerTick: 0, - blockers: new Map(), - } - let booting = true - let replaying = false - let replayDisabled = false - let replayPending: SessionResizeReplayInput | undefined - const buffered: Event[] = [] - const replayedParts = new Set() - const recovering = new Set() - const tracked = (sessionID: string | undefined) => - sessionID === input.sessionID || (!!sessionID && state.subagent.tabs.has(sessionID)) - const currentSubagentState = () => { - if (state.selectedSubagent && !state.subagent.tabs.has(state.selectedSubagent)) { - state.selectedSubagent = undefined - } - - return snapshotSelectedSubagentData(state.subagent, state.selectedSubagent) - } - - const seedBlocker = (id: string) => { - if (state.blockers.has(id)) { - return - } - - state.blockerTick += 1 - state.blockers.set(id, state.blockerTick) - } - - const trackBlocker = (event: Event) => { - if (event.type !== "permission.asked" && event.type !== "question.asked") { - return - } - - if (event.properties.sessionID !== input.sessionID && !state.subagent.tabs.has(event.properties.sessionID)) { - return - } - - seedBlocker(event.properties.id) - } - - const releaseBlocker = (event: Event) => { - if ( - event.type !== "permission.replied" && - event.type !== "question.replied" && - event.type !== "question.rejected" - ) { - return - } - - state.blockers.delete(event.properties.requestID) - } - - const syncFooter = (commits: StreamCommit[], patch?: FooterPatch, nextSubagent?: FooterSubagentState) => { - const current = pickView(state.data, state.subagent, state.blockers) - const footer = composeFooter({ - patch, - subagent: nextSubagent, - current, - previous: state.footerView, - }) - - if (commits.length === 0 && !footer) { - state.footerView = current - return - } - - input.trace?.write("reduce.output", { - commits, - footer: traceFooterOutput(footer), - }) - writeSessionOutput( - { - footer: input.footer, - trace: input.trace, - }, - { - commits, - footer, - }, - ) - state.footerView = current - } - - const resolveShellAgent = Effect.fn("RunStreamTransport.resolveShellAgent")(function* ( - agent: string | undefined, - ) { - if (agent) { - return agent - } - - const list = yield* Effect.promise(() => - input.sdk.app.agents(input.directory ? { directory: input.directory } : undefined, { throwOnError: true }), - ).pipe( - Effect.map((item) => item.data ?? []), - Effect.orElseSucceed(() => []), - ) - const next = list.find((item) => item.mode !== "subagent" && item.hidden !== true)?.name - if (next) { - return next - } - - return yield* Effect.fail(new Error("no primary agent available for shell mode")) - }) - - const recoverQuestion = Effect.fn("RunStreamTransport.recoverQuestion")(function* (partID: string) { - if (recovering.has(partID)) { - return - } - - recovering.add(partID) - try { - while (!closed && !abort.signal.aborted && !input.footer.isClosed) { - if (state.data.questions.length > 0 || !state.data.tools.has(partID)) { - return - } - - const questions = yield* Effect.promise(() => input.sdk.question.list()).pipe( - Effect.map((item) => (item.data ?? []).filter((request) => request.sessionID === input.sessionID)), - Effect.orElseSucceed(() => []), - ) - if (state.data.questions.length > 0 || !state.data.tools.has(partID)) { - return - } - - if (questions.length > 0) { - bootstrapSessionData({ - data: state.data, - messages: [], - permissions: [], - questions, - }) - for (const request of questions) { - seedBlocker(request.id) - } - input.trace?.write("question.recover", { - sessionID: input.sessionID, - requests: questions.map((request) => request.id), - }) - syncFooter([]) - return - } - - yield* Effect.sleep("250 millis") - } - } finally { - recovering.delete(partID) - } - }) - - const messages = (sessionID: string, limit?: number) => - Effect.promise(() => - input.sdk.session.messages({ - sessionID, - ...(typeof limit === "number" ? { limit } : {}), - }), - ).pipe( - Effect.map((item) => item.data ?? []), - Effect.orElseSucceed(() => []), - ) - - const replayMessages = () => - Effect.promise(() => - input.sdk.session.messages({ - sessionID: input.sessionID, - ...(input.replayLimit === undefined - ? {} - : { limit: Math.max(input.replayLimit, SUBAGENT_BOOTSTRAP_LIMIT) }), - }), - ).pipe(Effect.flatMap((item) => (item.error ? Effect.fail(item.error) : Effect.succeed(item.data ?? [])))) - - const replayRequests = () => - Effect.all( - [ - Effect.promise(() => input.sdk.permission.list()).pipe( - Effect.flatMap((item) => (item.error ? Effect.fail(item.error) : Effect.succeed(item.data ?? []))), - ), - Effect.promise(() => input.sdk.question.list()).pipe( - Effect.flatMap((item) => (item.error ? Effect.fail(item.error) : Effect.succeed(item.data ?? []))), - ), - ], - { concurrency: "unbounded" }, - ) - - const markReplayedParts = (data: SessionData) => { - replayedParts.clear() - for (const [partID] of data.text) { - if (data.part.has(partID)) { - replayedParts.add(partID) - } - } - } - - const bootstrapSubagentHistory = Effect.fn("RunStreamTransport.bootstrapSubagentHistory")(function* ( - sessions: string[], - ) { - yield* Effect.forEach( - sessions, - (sessionID) => - messages(sessionID, SUBAGENT_CALL_BOOTSTRAP_LIMIT).pipe( - Effect.tap((messagesList) => - Effect.sync(() => { - if ( - !bootstrapSubagentCalls({ - data: state.subagent, - sessionID, - messages: messagesList, - thinking: input.thinking, - limits: input.limits(), - }) - ) { - return - } - - syncFooter([], undefined, currentSubagentState()) - }), - ), - ), - { - concurrency: 4, - discard: true, - }, - ) - }) - - const bootstrap = Effect.fn("RunStreamTransport.bootstrap")(function* () { - const [messagesList, children, permissions, questions] = yield* Effect.all( - [ - messages( - input.sessionID, - input.replay - ? input.replayLimit === undefined - ? undefined - : Math.max(input.replayLimit, SUBAGENT_BOOTSTRAP_LIMIT) - : SUBAGENT_BOOTSTRAP_LIMIT, - ), - Effect.promise(() => - input.sdk.session.children({ - sessionID: input.sessionID, - }), - ).pipe( - Effect.map((item) => item.data ?? []), - Effect.orElseSucceed(() => []), - ), - Effect.promise(() => input.sdk.permission.list()).pipe( - Effect.map((item) => item.data ?? []), - Effect.orElseSucceed(() => []), - ), - Effect.promise(() => input.sdk.question.list()).pipe( - Effect.map((item) => item.data ?? []), - Effect.orElseSucceed(() => []), - ), - ], - { - concurrency: "unbounded", - }, - ) - - const sessionPermissions = permissions.filter((item) => item.sessionID === input.sessionID) - const sessionQuestions = questions.filter((item) => item.sessionID === input.sessionID) - const history = input.replay - ? replaySession({ - messages: messagesList, - permissions: sessionPermissions, - questions: sessionQuestions, - thinking: input.thinking, - limits: input.limits(), - providers: input.providers?.(), - }) - : undefined - const replay = - history && input.replayLimit !== undefined && messagesList.length > input.replayLimit - ? replaySession({ - messages: messagesList.slice(-input.replayLimit), - permissions: sessionPermissions, - questions: sessionQuestions, - thinking: input.thinking, - limits: input.limits(), - providers: input.providers?.(), - }) - : history - - if (history) { - state.data = history.data - } - - if (!history) { - bootstrapSessionData({ - data: state.data, - messages: messagesList, - permissions: sessionPermissions, - questions: sessionQuestions, - }) - } - - if (history) { - markReplayedParts(history.data) - } - - bootstrapSubagentData({ - data: state.subagent, - messages: messagesList, - children, - permissions, - questions, - }) - - for (const request of [ - ...state.data.permissions, - ...listSubagentPermissions(state.subagent), - ...state.data.questions, - ...listSubagentQuestions(state.subagent), - ].sort((a, b) => a.id.localeCompare(b.id))) { - seedBlocker(request.id) - } - - if (replay) { - const activeCommitIDs = new Set([...state.data.part.keys(), ...state.data.tools]) - for (const commit of replay.commits) { - input.trace?.write("ui.commit", commit) - input.footer.append(commit) - - if (commit.partID && activeCommitIDs.has(commit.partID)) { - continue - } - - yield* Effect.promise(() => input.footer.idle()).pipe(Effect.orElseSucceed(() => undefined)) - } - } - - const snapshot = currentSubagentState() - traceTabs(input.trace, [], snapshot.tabs) - syncFooter([], replay?.patch, snapshot) - if (replay) { - yield* Effect.promise(() => input.footer.idle()).pipe(Effect.orElseSucceed(() => undefined)) - } - - booting = false - yield* drainBuffered() - - const sessions = [...state.subagent.tabs.keys()] - if (sessions.length === 0) { - return - } - - yield* bootstrapSubagentHistory(sessions).pipe( - Effect.forkIn(scope, { startImmediately: true }), - Effect.asVoid, - ) - }) - - const idle = Effect.fn("RunStreamTransport.idle")((fallback: boolean) => - Effect.promise(() => input.sdk.session.status()).pipe( - Effect.map((out) => { - const item = out.data?.[input.sessionID] - return !item || item.type === "idle" - }), - Effect.orElseSucceed(() => fallback), - ), - ) - - const fail = Effect.fn("RunStreamTransport.fail")(function* (error: unknown) { - if (state.fault) { - return - } - - state.fault = error - const next = state.wait - state.wait = undefined - if (!next) { - return - } - - yield* Deferred.fail(next.done, error).pipe(Effect.ignore) - }) - - const touch = (event: Event) => { - const next = state.wait - if (!next || !active(event, input.sessionID)) { - return - } - - next.live = true - } - - const complete = Effect.fn("RunStreamTransport.complete")(function* (next: Wait, fallback: boolean) { - if (state.wait !== next || !next.armed || !next.live) { - return - } - - if (!(yield* idle(fallback)) || state.wait !== next) { - return - } - - state.tick = next.tick + 1 - state.wait = undefined - yield* Deferred.succeed(next.done, undefined).pipe(Effect.ignore) - }) - - const mark = Effect.fn("RunStreamTransport.mark")(function* (event: Event) { - if ( - event.type !== "session.status" || - event.properties.sessionID !== input.sessionID || - event.properties.status.type !== "idle" - ) { - return - } - - const next = state.wait - if (!next) { - return - } - - yield* complete(next, true) - }) - - const poll = Effect.fn("RunStreamTransport.poll")(function* (next: Wait, signal: AbortSignal) { - while (state.wait === next && !signal.aborted && !input.footer.isClosed && !closed) { - yield* Effect.sleep("250 millis") - yield* complete(next, false) - } - }) - - const flush = (type: "turn.abort" | "turn.cancel") => { - const commits: StreamCommit[] = [] - flushInterrupted(state.data, commits) - syncFooter(commits) - input.trace?.write(type, { - sessionID: input.sessionID, - }) - } - - const applyEvent = Effect.fn("RunStreamTransport.applyEvent")(function* (event: Event) { - if (event.type === "message.part.delta" && event.properties.sessionID === input.sessionID) { - if (replayedParts.has(event.properties.partID)) { - const seen = state.data.text.get(event.properties.partID) ?? "" - if (seen.endsWith(event.properties.delta)) { - return - } - - replayedParts.delete(event.properties.partID) - } - } - - trackBlocker(event) - - const prev = event.type === "message.part.updated" ? listSubagentTabs(state.subagent) : undefined - const next = reduceSessionData({ - data: state.data, - event, - sessionID: input.sessionID, - thinking: input.thinking, - limits: input.limits(), - }) - state.data = next.data - const visible = next.commits.at(-1) - if (visible) { - state.wait?.onVisibleOutput?.({ - kind: visible.kind, - text: visible.text, - phase: visible.phase, - messageID: visible.messageID, - partID: visible.partID, - toolState: visible.toolState, - ...(visible.partID && state.data.visible.has(visible.partID) - ? { visible: state.data.visible.get(visible.partID) } - : {}), - }) - } - - if ( - event.type === "message.part.updated" && - event.properties.part.sessionID === input.sessionID && - event.properties.part.type === "tool" && - event.properties.part.tool === "question" && - event.properties.part.state.status === "running" && - state.data.questions.length === 0 - ) { - yield* recoverQuestion(event.properties.part.id).pipe( - Effect.forkIn(scope, { startImmediately: true }), - Effect.asVoid, - ) - } - - const changed = reduceSubagentData({ - data: state.subagent, - event, - sessionID: input.sessionID, - thinking: input.thinking, - limits: input.limits(), - }) - if (changed && prev) { - traceTabs(input.trace, prev, listSubagentTabs(state.subagent)) - } - releaseBlocker(event) - - syncFooter(next.commits, next.footer?.patch, changed ? currentSubagentState() : undefined) - - touch(event) - yield* mark(event) - }) - - const drainBuffered = Effect.fn("RunStreamTransport.drainBuffered")(function* () { - let pending = buffered.splice(0) - while (pending.length > 0) { - const next: Event[] = [] - let changed = false - for (const event of pending) { - if (!tracked(sid(event))) { - next.push(event) - continue - } - - changed = true - yield* applyEvent(event) - } - - const arrived = buffered.splice(0) - if (!changed && arrived.length === 0) { - buffered.push(...next) - return - } - - pending = [...next, ...arrived] - } - }) - - const replayOnResize: (next: SessionResizeReplayInput) => Effect.Effect = Effect.fn( - "RunStreamTransport.replayOnResize", - )(function* (next: SessionResizeReplayInput) { - if (!input.replay || replayDisabled || booting || closed || input.footer.isClosed) { - return false - } - - if (replaying) { - replayPending = next - return false - } - - const finish: () => Effect.Effect = Effect.fnUntraced(function* () { - yield* drainBuffered() - const pending = replayPending - replayPending = undefined - if (!pending || replayDisabled || closed || input.footer.isClosed) { - replaying = false - return - } - - replaying = false - yield* replayOnResize(pending).pipe(Effect.asVoid) - }) - - replayedParts.clear() - replaying = true - input.trace?.write("replay.resize.start", { - sessionID: input.sessionID, - }) - const source = yield* Effect.all([replayMessages(), replayRequests()], { concurrency: "unbounded" }).pipe( - Effect.exit, - ) - if (Exit.isFailure(source)) { - input.trace?.write("replay.resize.abort", { - sessionID: input.sessionID, - phase: "snapshot", - }) - yield* finish() - return false - } - - const [messagesList, [permissions, questions]] = source.value - const sessionPermissions = permissions.filter((item) => item.sessionID === input.sessionID) - const sessionQuestions = questions.filter((item) => item.sessionID === input.sessionID) - const snapshot = yield* Effect.try({ - try: () => { - const history = replaySession({ - messages: messagesList, - permissions: sessionPermissions, - questions: sessionQuestions, - thinking: input.thinking, - limits: input.limits(), - providers: input.providers?.(), - }) - const activeCommits = replayActiveText(history.data, state.data) - return { - history, - activeCommits, - patch: - history.data.part.size > 0 || history.data.tools.size > 0 - ? { ...history.patch, phase: "running" as const } - : history.patch, - visible: - input.replayLimit !== undefined && messagesList.length > input.replayLimit - ? replaySession({ - messages: messagesList.slice(-input.replayLimit), - permissions: sessionPermissions, - questions: sessionQuestions, - thinking: input.thinking, - limits: input.limits(), - providers: input.providers?.(), - }) - : history, - } - }, - catch: (error) => error, - }).pipe(Effect.exit) - if (Exit.isFailure(snapshot)) { - input.trace?.write("replay.resize.abort", { - sessionID: input.sessionID, - phase: "snapshot", - }) - yield* finish() - return false - } - - const idle = yield* Effect.promise(() => input.footer.idle()).pipe(Effect.exit) - if (Exit.isFailure(idle) || closed || input.footer.isClosed) { - yield* finish() - return false - } - - const reset = yield* Effect.promise(() => next.reset()).pipe(Effect.exit) - if (Exit.isFailure(reset)) { - replayDisabled = true - input.trace?.write("replay.resize.disable", { - sessionID: input.sessionID, - phase: "reset", - }) - input.footer.append({ - kind: "error", - text: "resize replay failed; disabled for this session", - phase: "start", - source: "system", - }) - yield* finish() - return false - } - - state.data = snapshot.value.history.data - for (const request of [...state.data.permissions, ...state.data.questions]) { - seedBlocker(request.id) - } - - for (const commit of replayLocalRows( - messagesList, - [...snapshot.value.visible.commits, ...snapshot.value.activeCommits], - next.localRows(), - )) { - input.trace?.write("ui.commit", commit) - input.footer.append(commit) - } - - syncFooter([], snapshot.value.patch, currentSubagentState()) - const rebuilt = yield* Effect.promise(() => input.footer.idle()).pipe(Effect.exit) - if (Exit.isFailure(rebuilt)) { - replayDisabled = true - input.trace?.write("replay.resize.disable", { - sessionID: input.sessionID, - phase: "rebuild", - }) - input.footer.append({ - kind: "error", - text: "resize replay failed; disabled for this session", - phase: "start", - source: "system", - }) - yield* finish() - return false - } - - input.trace?.write("replay.resize.complete", { - sessionID: input.sessionID, - }) - yield* finish() - return true - }) - - const watch = Effect.fn("RunStreamTransport.watch")(() => - Stream.fromAsyncIterable(events.stream, (error) => - error instanceof Error ? error : new Error(String(error)), - ).pipe( - Stream.takeUntil(() => input.footer.isClosed || abort.signal.aborted), - Stream.runForEach( - Effect.fn("RunStreamTransport.event")(function* (item: unknown) { - if (input.footer.isClosed) { - abort.abort() - return - } - - if (isMatchingDisposeEvent(item, input.directory)) { - yield* fail(new Error("instance disposed")) - yield* closeScope() - return - } - - const event = globalPayloadEvent(item) - if (!event) { - return - } - - const sessionID = sid(event) - if (booting || replaying) { - if (sessionID) { - input.trace?.write("recv.event", event) - buffered.push(event) - } - return - } - - if (!tracked(sessionID)) { - if (sessionID) { - input.trace?.write("recv.event", event) - buffered.push(event) - } - return - } - - input.trace?.write("recv.event", event) - yield* applyEvent(event) - yield* drainBuffered() - }), - ), - Effect.catch((error) => (abort.signal.aborted ? Effect.void : fail(error))), - Effect.ensuring( - Effect.gen(function* () { - if (!abort.signal.aborted && !state.fault) { - yield* fail(new Error("global event stream closed")) - } - closeStream() - }), - ), - ), - ) - - yield* Scope.provide(scope)(watch().pipe(Effect.forkScoped)) - yield* bootstrap() - - const runPromptTurn = Effect.fn("RunStreamTransport.runPromptTurn")(function* (next: SessionTurnInput) { - if (closed || next.signal?.aborted || input.footer.isClosed) { - return - } - - if (state.fault) { - yield* Effect.fail(state.fault) - return - } - - if (state.wait) { - yield* Effect.fail(new Error("prompt already running")) - return - } - - const item: Wait = { - tick: state.tick, - armed: false, - live: false, - onVisibleOutput: next.onVisibleOutput, - done: yield* Deferred.make(), - } - state.wait = item - state.data.announced = false - - const turn = new AbortController() - const stop = () => { - turn.abort() - } - next.signal?.addEventListener("abort", stop, { once: true }) - abort.signal.addEventListener("abort", stop, { once: true }) - yield* poll(item, turn.signal).pipe(Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid) - - const req = { - sessionID: input.sessionID, - messageID: next.prompt.messageID, - agent: next.agent, - model: next.model, - variant: next.variant, - parts: [ - ...(next.includeFiles ? next.files : []), - { type: "text" as const, text: next.prompt.text }, - ...next.prompt.parts, - ], - } - const command = next.prompt.command - const send = - next.prompt.mode === "shell" - ? Effect.sync(() => { - input.trace?.write("send.shell", { - sessionID: input.sessionID, - command: next.prompt.text, - }) - }).pipe( - Effect.andThen( - resolveShellAgent(next.agent) - .pipe( - Effect.flatMap((agent) => - Effect.promise(() => - input.sdk.session.shell( - { - sessionID: input.sessionID, - agent, - model: next.model, - command: next.prompt.text, - }, - { signal: turn.signal, throwOnError: true }, - ), - ), - ), - ) - .pipe( - Effect.tap(() => - Effect.sync(() => { - input.trace?.write("send.shell.ok", { - sessionID: input.sessionID, - }) - item.armed = true - item.live = true - }), - ), - Effect.flatMap(() => Deferred.succeed(item.done, undefined).pipe(Effect.ignore)), - Effect.catch((error) => Deferred.fail(item.done, error).pipe(Effect.ignore)), - Effect.forkIn(scope, { startImmediately: true }), - Effect.asVoid, - ), - ), - ) - : command - ? Effect.sync(() => { - input.trace?.write("send.command", { sessionID: input.sessionID, command: command.name }) - }).pipe( - Effect.andThen( - Effect.promise(() => - input.sdk.session.command( - { - sessionID: input.sessionID, - messageID: next.prompt.messageID, - agent: next.agent, - model: next.model ? `${next.model.providerID}/${next.model.modelID}` : undefined, - variant: next.variant, - command: command.name, - arguments: command.arguments, - parts: [ - ...(next.includeFiles ? next.files : []), - ...next.prompt.parts.filter( - (item): item is Extract => item.type === "file", - ), - ], - }, - { signal: turn.signal }, - ), - ).pipe( - Effect.tap(() => - Effect.sync(() => { - input.trace?.write("send.command.ok", { - sessionID: input.sessionID, - command: command.name, - }) - item.armed = true - item.live = true - }), - ), - Effect.flatMap(() => Deferred.succeed(item.done, undefined).pipe(Effect.ignore)), - Effect.catch((error) => Deferred.fail(item.done, error).pipe(Effect.ignore)), - Effect.forkIn(scope, { startImmediately: true }), - Effect.asVoid, - ), - ), - ) - : Effect.sync(() => { - input.trace?.write("send.prompt", req) - }).pipe( - Effect.andThen( - Effect.promise(() => - input.sdk.session.promptAsync(req, { - signal: turn.signal, - }), - ), - ), - Effect.tap(() => - Effect.sync(() => { - input.trace?.write("send.prompt.ok", { - sessionID: input.sessionID, - }) - item.armed = true - }), - ), - ) - - yield* send.pipe( - Effect.flatMap(() => { - if (turn.signal.aborted || next.signal?.aborted || input.footer.isClosed || closed) { - if (state.wait === item) { - state.wait = undefined - } - flush("turn.abort") - return Effect.void - } - - if (!input.footer.isClosed && !state.data.announced) { - input.trace?.write("ui.patch", { - phase: "running", - status: "waiting for assistant", - }) - input.footer.event({ - type: "turn.wait", - }) - } - - if (state.tick > item.tick) { - if (state.wait === item) { - state.wait = undefined - } - return Effect.void - } - - return waitTurn(item.done, turn.signal).pipe( - Effect.flatMap((status) => - Effect.sync(() => { - if (state.wait === item) { - state.wait = undefined - } - - if (status === "abort") { - flush("turn.abort") - } - }), - ), - ) - }), - Effect.catch((error) => { - if (state.wait === item) { - state.wait = undefined - } - - const canceled = turn.signal.aborted || next.signal?.aborted === true || input.footer.isClosed || closed - if (canceled) { - flush("turn.cancel") - return Effect.void - } - - if (error === state.fault) { - return Effect.fail(error) - } - - input.trace?.write("send.prompt.error", { - sessionID: input.sessionID, - error: formatUnknownError(error), - }) - return Effect.fail(error) - }), - Effect.ensuring( - Effect.sync(() => { - input.trace?.write("turn.end", { - sessionID: input.sessionID, - }) - next.signal?.removeEventListener("abort", stop) - abort.signal.removeEventListener("abort", stop) - }), - ), - ) - return - }) - - const selectSubagent = Effect.fn("RunStreamTransport.selectSubagent")((sessionID: string | undefined) => - Effect.sync(() => { - if (closed) { - return - } - - const next = sessionID && state.subagent.tabs.has(sessionID) ? sessionID : undefined - if (state.selectedSubagent === next) { - return - } - - state.selectedSubagent = next - syncFooter([], undefined, currentSubagentState()) - }), - ) - - const close = Effect.fn("RunStreamTransport.close")(function* () { - yield* closeScope() - }) - - return Service.of({ - runPromptTurn, - selectSubagent, - replayOnResize, - close, - }) - }), - ), - ) -} - -// Opens an SDK event subscription and returns a SessionTransport. -// -// The background `watch` loop consumes every SDK event, runs it through the -// reducer, and writes output to the footer. When a session.status idle -// event arrives, it resolves the current turn's Wait so runPromptTurn() -// can return. -// -// The transport is single-turn: only one runPromptTurn() call can be active -// at a time. The prompt queue enforces this from above. -export async function createSessionTransport(input: StreamInput): Promise { - const runtime = makeRuntime(Service, createLayer(input)) - await runtime.runPromise(() => Effect.void) - - return { - runPromptTurn: (next) => runtime.runPromise((svc) => svc.runPromptTurn(next)), - selectSubagent: (sessionID) => runtime.runSync((svc) => svc.selectSubagent(sessionID)), - replayOnResize: (next) => runtime.runPromise((svc) => svc.replayOnResize(next)), - close: () => runtime.runPromise((svc) => svc.close()), - } -} diff --git a/packages/opencode/src/cli/cmd/run/subagent-data.ts b/packages/opencode/src/cli/cmd/run/subagent-data.ts deleted file mode 100644 index 172741d3b3..0000000000 --- a/packages/opencode/src/cli/cmd/run/subagent-data.ts +++ /dev/null @@ -1,876 +0,0 @@ -import type { Event, Message, Part, PermissionRequest, QuestionRequest, ToolPart } from "@opencode-ai/sdk/v2" -import * as Locale from "@/util/locale" -import { - bootstrapSessionData, - createSessionData, - formatError, - reduceSessionData, - type SessionData, -} from "./session-data" -import type { FooterSubagentState, FooterSubagentTab, StreamCommit } from "./types" - -export const SUBAGENT_BOOTSTRAP_LIMIT = 200 -export const SUBAGENT_CALL_BOOTSTRAP_LIMIT = 80 - -const SUBAGENT_COMMIT_LIMIT = 80 -const SUBAGENT_CALL_LIMIT = 32 -const SUBAGENT_ROLE_LIMIT = 32 -const SUBAGENT_ERROR_LIMIT = 16 -const SUBAGENT_ECHO_LIMIT = 8 - -type SessionMessage = { - parts: Part[] -} - -type BootstrapChildMessage = SessionMessage & { - info: Message -} - -type Frame = { - key: string - commit: StreamCommit -} - -type DetailState = { - sessionID: string - data: SessionData - frames: Frame[] -} - -export type SubagentData = { - tabs: Map - details: Map -} - -export type BootstrapSubagentInput = { - data: SubagentData - messages: SessionMessage[] - children: Array<{ id: string; title?: string }> - permissions: PermissionRequest[] - questions: QuestionRequest[] -} - -function createDetail(sessionID: string): DetailState { - return { - sessionID, - data: createSessionData({ - includeUserText: true, - }), - frames: [], - } -} - -function ensureDetail(data: SubagentData, sessionID: string) { - const current = data.details.get(sessionID) - if (current) { - return current - } - - const next = createDetail(sessionID) - data.details.set(sessionID, next) - return next -} - -export function sameSubagentTab(a: FooterSubagentTab | undefined, b: FooterSubagentTab | undefined) { - if (!a || !b) { - return false - } - - return ( - a.sessionID === b.sessionID && - a.partID === b.partID && - a.callID === b.callID && - a.label === b.label && - a.description === b.description && - a.status === b.status && - a.background === b.background && - a.title === b.title && - a.toolCalls === b.toolCalls && - a.lastUpdatedAt === b.lastUpdatedAt - ) -} - -function sameQueue(left: T[], right: T[]) { - return ( - left.length === right.length && left.every((item, index) => item.id === right[index]?.id && item === right[index]) - ) -} - -function queueSnapshot(data: SessionData) { - return { - permissions: data.permissions.slice(), - questions: data.questions.slice(), - } -} - -function queueChanged(data: SessionData, before: ReturnType) { - return !sameQueue(before.permissions, data.permissions) || !sameQueue(before.questions, data.questions) -} - -function sameCommit(left: StreamCommit, right: StreamCommit) { - return ( - left.kind === right.kind && - left.text === right.text && - left.phase === right.phase && - left.source === right.source && - left.messageID === right.messageID && - left.partID === right.partID && - left.tool === right.tool && - left.interrupted === right.interrupted && - left.toolState === right.toolState && - left.toolError === right.toolError - ) -} - -function text(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined - } - - const next = value.trim() - return next || undefined -} - -function num(value: unknown): number | undefined { - if (typeof value === "number" && Number.isFinite(value)) { - return value - } - - return undefined -} - -function inputLabel(input: Record): string | undefined { - const description = text(input.description) - if (description) { - return description - } - - const command = text(input.command) - if (command) { - return command - } - - const filePath = text(input.filePath) ?? text(input.filepath) - if (filePath) { - return filePath - } - - const pattern = text(input.pattern) - if (pattern) { - return pattern - } - - const query = text(input.query) - if (query) { - return query - } - - const url = text(input.url) - if (url) { - return url - } - - const path = text(input.path) - if (path) { - return path - } - - const prompt = text(input.prompt) - if (prompt) { - return prompt - } - - return undefined -} - -function stateTitle(part: ToolPart) { - return text("title" in part.state ? part.state.title : undefined) -} - -function callKey(messageID: string | undefined, callID: string | undefined): string | undefined { - if (!messageID || !callID) { - return undefined - } - - return `${messageID}:${callID}` -} - -function compactToolState(part: ToolPart): ToolPart["state"] { - if (part.state.status === "pending") { - return { - status: "pending", - input: part.state.input, - raw: part.state.raw, - } - } - - if (part.state.status === "running") { - return { - status: "running", - input: part.state.input, - time: part.state.time, - ...(part.state.metadata ? { metadata: part.state.metadata } : {}), - ...(part.state.title ? { title: part.state.title } : {}), - } - } - - if (part.state.status === "completed") { - return { - status: "completed", - input: part.state.input, - output: part.state.output, - title: part.state.title, - metadata: part.state.metadata, - time: part.state.time, - } - } - - return { - status: "error", - input: part.state.input, - error: part.state.error, - time: part.state.time, - ...(part.state.metadata ? { metadata: part.state.metadata } : {}), - } -} - -function recent(input: Iterable, limit: number) { - const list = [...input] - return list.slice(Math.max(0, list.length - limit)) -} - -function copyMap(source: Map, keep: Set) { - const out = new Map() - for (const [key, value] of source) { - if (!keep.has(key)) { - continue - } - - out.set(key, value) - } - return out -} - -function compactToolPart(part: ToolPart): ToolPart { - return { - id: part.id, - type: "tool", - sessionID: part.sessionID, - messageID: part.messageID, - callID: part.callID, - tool: part.tool, - state: compactToolState(part), - ...(part.metadata ? { metadata: part.metadata } : {}), - } -} - -function compactCommit(commit: StreamCommit): StreamCommit { - if (!commit.part) { - return commit - } - - return { - ...commit, - part: compactToolPart(commit.part), - } -} - -function stateUpdatedAt(part: ToolPart) { - if (!("time" in part.state)) { - return Date.now() - } - - const time = part.state.time - if (!("end" in time)) { - return time.start ?? Date.now() - } - - return time.end ?? time.start ?? Date.now() -} - -function metadata(part: ToolPart, key: string) { - return ("metadata" in part.state ? part.state.metadata?.[key] : undefined) ?? part.metadata?.[key] -} - -function taskStatus(part: ToolPart): FooterSubagentTab["status"] { - if (part.state.status === "completed") { - return "completed" - } - - if (part.state.status === "error") { - if (metadata(part, "interrupted") === true || text(part.state.error) === "Tool execution aborted") { - return "cancelled" - } - - return "error" - } - - return "running" -} - -function taskTab(part: ToolPart, sessionID: string): FooterSubagentTab { - const label = Locale.titlecase(text(part.state.input.subagent_type) ?? "general") - const description = text(part.state.input.description) ?? stateTitle(part) ?? inputLabel(part.state.input) ?? "" - - return { - sessionID, - partID: part.id, - callID: part.callID, - label, - description, - status: taskStatus(part), - background: metadata(part, "background") === true, - title: stateTitle(part), - toolCalls: num(metadata(part, "toolcalls")) ?? num(metadata(part, "toolCalls")) ?? num(metadata(part, "calls")), - lastUpdatedAt: stateUpdatedAt(part), - } -} - -function taskSessionID(part: ToolPart) { - return text(metadata(part, "sessionId")) ?? text(metadata(part, "sessionID")) -} - -function syncTaskTab(data: SubagentData, part: ToolPart, children?: Set) { - if (part.tool !== "task") { - return false - } - - const sessionID = taskSessionID(part) - if (!sessionID) { - return false - } - - if (children && children.size > 0 && !children.has(sessionID)) { - return false - } - - const next = taskTab(part, sessionID) - if (sameSubagentTab(data.tabs.get(sessionID), next)) { - ensureDetail(data, sessionID) - return false - } - - data.tabs.set(sessionID, next) - ensureDetail(data, sessionID) - return true -} - -function frameKey(commit: StreamCommit) { - if (commit.partID) { - return `${commit.kind}:${commit.partID}:${commit.phase}` - } - - if (commit.messageID) { - return `${commit.kind}:${commit.messageID}:${commit.phase}` - } - - return `${commit.kind}:${commit.phase}:${commit.text}` -} - -function limitFrames(detail: DetailState) { - if (detail.frames.length <= SUBAGENT_COMMIT_LIMIT) { - return - } - - detail.frames.splice(0, detail.frames.length - SUBAGENT_COMMIT_LIMIT) -} - -function mergeLiveCommit(current: StreamCommit, next: StreamCommit) { - if (current.phase !== "progress" || next.phase !== "progress") { - if (sameCommit(current, next)) { - return current - } - - return next - } - - const merged = { - ...current, - ...next, - text: current.text + next.text, - } - - if (sameCommit(current, merged)) { - return current - } - - return merged -} - -function appendCommits(detail: DetailState, commits: StreamCommit[]) { - let changed = false - - for (const commit of commits.map(compactCommit)) { - const key = frameKey(commit) - const index = detail.frames.findIndex((item) => item.key === key) - if (index === -1) { - detail.frames.push({ - key, - commit, - }) - changed = true - continue - } - - const next = mergeLiveCommit(detail.frames[index].commit, commit) - if (sameCommit(detail.frames[index].commit, next)) { - continue - } - - detail.frames[index] = { - key, - commit: next, - } - changed = true - } - - if (changed) { - limitFrames(detail) - } - - return changed -} - -function ensureBlockerTab( - data: SubagentData, - sessionID: string, - title: string | undefined, - kind: "permission" | "question", -) { - const current = data.tabs.get(sessionID) - if (current) { - ensureDetail(data, sessionID) - if (current.status !== "running") { - return false - } - - const next = { - ...current, - description: kind === "permission" ? "Pending permission" : "Pending question", - status: "running" as const, - title: current.title ?? title, - lastUpdatedAt: Date.now(), - } - if (sameSubagentTab(current, next)) { - return false - } - - data.tabs.set(sessionID, next) - return true - } - - data.tabs.set(sessionID, { - sessionID, - partID: `bootstrap:${sessionID}`, - callID: `bootstrap:${sessionID}`, - label: text(title) ?? Locale.titlecase(kind), - description: kind === "permission" ? "Pending permission" : "Pending question", - status: "running", - lastUpdatedAt: Date.now(), - }) - ensureDetail(data, sessionID) - return true -} - -function isAbortedAssistantMessage(info: Message) { - return info.role === "assistant" && info.error?.name === "MessageAbortedError" -} - -function cancelSubagentTab(data: SubagentData, sessionID: string) { - const current = data.tabs.get(sessionID) - if (!current || current.status !== "running") { - return false - } - - const next = { - ...current, - status: "cancelled" as const, - lastUpdatedAt: Date.now(), - } - if (sameSubagentTab(current, next)) { - return false - } - - data.tabs.set(sessionID, next) - return true -} - -function compactCallMap(detail: DetailState) { - const keep = new Set(recent(detail.data.call.keys(), SUBAGENT_CALL_LIMIT)) - - for (const request of detail.data.permissions) { - const key = callKey(request.tool?.messageID, request.tool?.callID) - if (key) { - keep.add(key) - } - } - - for (const item of detail.frames) { - const key = callKey(item.commit.part?.messageID, item.commit.part?.callID) - if (key) { - keep.add(key) - } - } - - return copyMap(detail.data.call, keep) -} - -function compactEchoMap(data: SessionData, messageIDs: Set) { - const keys = new Set([...messageIDs, ...recent(data.echo.keys(), SUBAGENT_ECHO_LIMIT)]) - return copyMap(data.echo, keys) -} - -function compactIDs(detail: DetailState) { - return new Set(recent(detail.data.ids, SUBAGENT_COMMIT_LIMIT + SUBAGENT_ERROR_LIMIT)) -} - -function compactDetail(detail: DetailState) { - const next = createSessionData({ - includeUserText: true, - }) - const activePartIDs = new Set(detail.data.part.keys()) - const framePartIDs = new Set(detail.frames.flatMap((item) => (item.commit.partID ? [item.commit.partID] : []))) - const partIDs = new Set([...activePartIDs, ...framePartIDs, ...detail.data.tools]) - const messageIDs = new Set([ - ...[...activePartIDs] - .map((partID) => detail.data.msg.get(partID)) - .filter((item): item is string => typeof item === "string"), - ...recent(detail.data.role.keys(), SUBAGENT_ROLE_LIMIT), - ]) - - next.announced = detail.data.announced - next.permissions = detail.data.permissions - next.questions = detail.data.questions - next.ids = compactIDs(detail) - next.tools = new Set([...detail.data.tools].filter((item) => partIDs.has(item))) - next.call = compactCallMap(detail) - next.role = copyMap(detail.data.role, messageIDs) - next.msg = copyMap(detail.data.msg, activePartIDs) - next.part = copyMap(detail.data.part, activePartIDs) - next.text = copyMap(detail.data.text, activePartIDs) - next.sent = copyMap(detail.data.sent, activePartIDs) - next.end = new Set([...detail.data.end].filter((item) => activePartIDs.has(item))) - next.echo = compactEchoMap(detail.data, messageIDs) - detail.data = next -} - -function applyChildEvent(input: { - detail: DetailState - event: Event - thinking: boolean - limits: Record -}) { - const before = queueSnapshot(input.detail.data) - const out = reduceSessionData({ - data: input.detail.data, - event: input.event, - sessionID: input.detail.sessionID, - thinking: input.thinking, - limits: input.limits, - }) - const changed = appendCommits(input.detail, out.commits) - compactDetail(input.detail) - - return changed || queueChanged(input.detail.data, before) -} - -function bootstrapChildEvent(input: { - detail: DetailState - event: Event - thinking: boolean - limits: Record -}) { - const out = reduceSessionData({ - data: input.detail.data, - event: input.event, - sessionID: input.detail.sessionID, - thinking: input.thinking, - limits: input.limits, - }) - - return appendCommits(input.detail, out.commits) -} - -function bootstrapChildMessages(input: { - detail: DetailState - messages: BootstrapChildMessage[] - thinking: boolean - limits: Record -}) { - let changed = false - - for (const message of input.messages) { - changed = - bootstrapChildEvent({ - detail: input.detail, - event: { - id: `bootstrap:message:${message.info.id}`, - type: "message.updated", - properties: { - sessionID: input.detail.sessionID, - info: message.info, - }, - }, - thinking: input.thinking, - limits: input.limits, - }) || changed - - for (const part of message.parts) { - changed = - bootstrapChildEvent({ - detail: input.detail, - event: { - id: `bootstrap:part:${part.id}`, - type: "message.part.updated", - properties: { - sessionID: input.detail.sessionID, - part, - time: 0, - }, - }, - thinking: input.thinking, - limits: input.limits, - }) || changed - } - } - - compactDetail(input.detail) - return changed -} - -function knownSession(data: SubagentData, sessionID: string) { - return data.tabs.has(sessionID) -} - -export function listSubagentPermissions(data: SubagentData) { - return [...data.details.values()].flatMap((detail) => detail.data.permissions) -} - -export function listSubagentQuestions(data: SubagentData) { - return [...data.details.values()].flatMap((detail) => detail.data.questions) -} - -export function createSubagentData(): SubagentData { - return { - tabs: new Map(), - details: new Map(), - } -} - -function snapshotDetail(detail: DetailState) { - return { - sessionID: detail.sessionID, - commits: detail.frames.map((item) => item.commit), - } -} - -export function listSubagentTabs(data: SubagentData) { - return [...data.tabs.values()].sort((a, b) => { - const active = Number(b.status === "running") - Number(a.status === "running") - if (active !== 0) { - return active - } - - return b.lastUpdatedAt - a.lastUpdatedAt - }) -} - -function snapshotQueues(data: SubagentData) { - return { - permissions: listSubagentPermissions(data).sort((a, b) => a.id.localeCompare(b.id)), - questions: listSubagentQuestions(data).sort((a, b) => a.id.localeCompare(b.id)), - } -} - -function snapshotState(data: SubagentData, details: FooterSubagentState["details"]): FooterSubagentState { - return { - tabs: listSubagentTabs(data), - details, - ...snapshotQueues(data), - } -} - -export function snapshotSubagentData(data: SubagentData): FooterSubagentState { - return snapshotState( - data, - Object.fromEntries([...data.details.entries()].map(([sessionID, detail]) => [sessionID, snapshotDetail(detail)])), - ) -} - -export function snapshotSelectedSubagentData( - data: SubagentData, - selectedSessionID: string | undefined, -): FooterSubagentState { - const detail = selectedSessionID ? data.details.get(selectedSessionID) : undefined - - return snapshotState(data, detail ? { [detail.sessionID]: snapshotDetail(detail) } : {}) -} - -export function bootstrapSubagentData(input: BootstrapSubagentInput) { - const child = new Map(input.children.map((item) => [item.id, item])) - const children = new Set(child.keys()) - let changed = false - - for (const message of input.messages) { - for (const part of message.parts) { - if (part.type !== "tool") { - continue - } - - changed = syncTaskTab(input.data, part, children) || changed - } - } - - for (const item of input.permissions) { - if (!children.has(item.sessionID)) { - continue - } - - changed = ensureBlockerTab(input.data, item.sessionID, child.get(item.sessionID)?.title, "permission") || changed - } - - for (const item of input.questions) { - if (!children.has(item.sessionID)) { - continue - } - - changed = ensureBlockerTab(input.data, item.sessionID, child.get(item.sessionID)?.title, "question") || changed - } - - for (const sessionID of input.data.tabs.keys()) { - const detail = ensureDetail(input.data, sessionID) - const before = queueSnapshot(detail.data) - - bootstrapSessionData({ - data: detail.data, - messages: [], - permissions: input.permissions - .filter((item) => item.sessionID === sessionID) - .sort((a, b) => a.id.localeCompare(b.id)), - questions: input.questions - .filter((item) => item.sessionID === sessionID) - .sort((a, b) => a.id.localeCompare(b.id)), - }) - compactDetail(detail) - - changed = queueChanged(detail.data, before) || changed - } - - return changed -} - -export function bootstrapSubagentCalls(input: { - data: SubagentData - sessionID: string - messages: BootstrapChildMessage[] - thinking: boolean - limits: Record -}) { - if (!knownSession(input.data, input.sessionID) || input.messages.length === 0) { - return false - } - - const detail = ensureDetail(input.data, input.sessionID) - const before = queueSnapshot(detail.data) - const beforeCallCount = detail.data.call.size - bootstrapSessionData({ - data: detail.data, - messages: input.messages, - permissions: detail.data.permissions, - questions: detail.data.questions, - }) - const changed = bootstrapChildMessages({ - detail, - messages: input.messages, - thinking: input.thinking, - limits: input.limits, - }) - - return changed || beforeCallCount !== detail.data.call.size || queueChanged(detail.data, before) -} - -export function reduceSubagentData(input: { - data: SubagentData - event: Event - sessionID: string - thinking: boolean - limits: Record -}) { - const event = input.event - - if (event.type === "message.part.updated") { - const part = event.properties.part - if (part.sessionID === input.sessionID) { - if (part.type !== "tool") { - return false - } - - return syncTaskTab(input.data, part) - } - } - - const sessionID = - event.type === "message.updated" || - event.type === "message.part.delta" || - event.type === "permission.asked" || - event.type === "permission.replied" || - event.type === "question.asked" || - event.type === "question.replied" || - event.type === "question.rejected" || - event.type === "session.error" || - event.type === "session.status" - ? event.properties.sessionID - : event.type === "message.part.updated" - ? event.properties.part.sessionID - : undefined - - if (!sessionID || !knownSession(input.data, sessionID)) { - return false - } - - const detail = ensureDetail(input.data, sessionID) - const cancelled = - event.type === "message.updated" && isAbortedAssistantMessage(event.properties.info) - ? cancelSubagentTab(input.data, sessionID) - : false - if (event.type === "session.status") { - if (event.properties.status.type !== "retry") { - return cancelled - } - - return ( - appendCommits(detail, [ - { - kind: "error", - text: event.properties.status.message, - phase: "start", - source: "system", - messageID: `retry:${event.properties.status.attempt}`, - }, - ]) || cancelled - ) - } - - if (event.type === "session.error" && event.properties.error) { - return ( - appendCommits(detail, [ - { - kind: "error", - text: formatError(event.properties.error), - phase: "start", - source: "system", - messageID: `session.error:${event.properties.sessionID}:${formatError(event.properties.error)}`, - }, - ]) || cancelled - ) - } - - return ( - applyChildEvent({ - detail, - event, - thinking: input.thinking, - limits: input.limits, - }) || cancelled - ) -} diff --git a/packages/opencode/src/cli/cmd/run/turn-summary.ts b/packages/opencode/src/cli/cmd/run/turn-summary.ts index aadac67afc..aa63a8ea0a 100644 --- a/packages/opencode/src/cli/cmd/run/turn-summary.ts +++ b/packages/opencode/src/cli/cmd/run/turn-summary.ts @@ -1,6 +1,4 @@ -import * as Locale from "@/util/locale" -import type { SessionMessages } from "./session.shared" -import type { RunProvider, StreamCommit } from "./types" +import type { StreamCommit } from "./types" export function turnSummaryCommit(input: { agent: string @@ -21,27 +19,3 @@ export function turnSummaryCommit(input: { messageID: input.messageID, } } - -export function messageTurnSummaryCommit( - message: SessionMessages[number], - providers?: RunProvider[], -): StreamCommit | undefined { - const info = message.info - if (info.role !== "assistant") { - return - } - - const completed = info.time.completed - if (typeof completed !== "number" || completed <= info.time.created) { - return - } - - const model = providers?.find((item) => item.id === info.providerID)?.models[info.modelID]?.name - - return turnSummaryCommit({ - agent: Locale.titlecase(info.agent), - model: model ?? info.modelID, - duration: Locale.duration(completed - info.time.created), - messageID: info.id, - }) -} diff --git a/packages/opencode/src/cli/cmd/run/types.ts b/packages/opencode/src/cli/cmd/run/types.ts index a914922e48..27372596f1 100644 --- a/packages/opencode/src/cli/cmd/run/types.ts +++ b/packages/opencode/src/cli/cmd/run/types.ts @@ -1,4 +1,4 @@ -// Shared type vocabulary for the direct interactive mode (`opencode --mini`). +// Shared type vocabulary for the direct interactive mode (`opencode mini`). // // Direct mode uses a split-footer terminal layout: immutable scrollback for the // session transcript, and a mutable footer for prompt input, status, and @@ -26,9 +26,63 @@ type PromptInput = Parameters[0] export type RunPromptPart = NonNullable[number] -export type RunCommand = NonNullable>["data"]>[number] +export type RunCommand = { + name: string + description?: string + source?: string + template?: string + hints?: unknown[] + agent?: string + model?: { + [key: string]: unknown + } + subtask?: boolean +} -export type RunProvider = NonNullable>["data"]>["all"][number] +export type RunProviderModel = { + id: string + providerID: string + api?: { + [key: string]: unknown + } + name?: string + capabilities?: { + [key: string]: unknown + } + cost?: { + input: number + output?: number + cache?: { + read: number + write: number + } + } + limit?: { + context: number + input?: number + output?: number + } + status?: string + options?: { + [key: string]: unknown + } + headers?: { + [key: string]: string + } + release_date?: string + variants?: Record +} + +export type RunProvider = { + id: string + name: string + source?: string + env?: string[] + options?: { + [key: string]: unknown + } + models: Record +} export type RunPrompt = { messageID?: string @@ -48,11 +102,16 @@ export type FooterQueuedPrompt = { prompt: RunPrompt } -export type RunAgent = NonNullable>["data"]>[number] +export type RunAgent = { + name: string + description?: string + mode: "subagent" | "primary" | "all" + hidden: boolean +} -type RunResourceMap = NonNullable>["data"]> - -export type RunResource = RunResourceMap[string] +export type RunReference = NonNullable< + Awaited>["data"] +>["data"][number] export type RunInput = { sdk: OpencodeClient @@ -224,7 +283,7 @@ export type FooterEvent = | { type: "catalog" agents: RunAgent[] - resources: RunResource[] + references: RunReference[] commands?: RunCommand[] } | { diff --git a/packages/opencode/src/cli/cmd/run/variant.shared.ts b/packages/opencode/src/cli/cmd/run/variant.shared.ts index e685ceb028..fa10af0055 100644 --- a/packages/opencode/src/cli/cmd/run/variant.shared.ts +++ b/packages/opencode/src/cli/cmd/run/variant.shared.ts @@ -10,6 +10,8 @@ import path from "path" import { FSUtil } from "@opencode-ai/core/fs-util" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Context, Effect, Layer } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { makeGlobalNode } from "@opencode-ai/core/effect/app-node" import { makeRuntime } from "@/effect/run-service" import { Global } from "@opencode-ai/core/global" import { isRecord } from "@/util/record" @@ -136,69 +138,69 @@ function state(value: unknown): ModelState { } } -function createLayer(fs = AppNodeBuilder.build(FSUtil.node)) { - return Layer.fresh( - Layer.effect( - Service, - Effect.gen(function* () { - const file = yield* FSUtil.Service +const layer = Layer.fresh( + Layer.effect( + Service, + Effect.gen(function* () { + const file = yield* FSUtil.Service - const read = Effect.fn("RunVariant.read")(function* () { - return yield* file.readJson(MODEL_FILE).pipe( - Effect.map(state), - Effect.catchCause(() => Effect.succeed(state(undefined))), - ) - }) + const read = Effect.fn("RunVariant.read")(function* () { + return yield* file.readJson(MODEL_FILE).pipe( + Effect.map(state), + Effect.catchCause(() => Effect.succeed(state(undefined))), + ) + }) - const resolveSavedVariant = Effect.fn("RunVariant.resolveSavedVariant")(function* (model: RunInput["model"]) { - if (!model) { - return undefined - } + const resolveSavedVariant = Effect.fn("RunVariant.resolveSavedVariant")(function* (model: RunInput["model"]) { + if (!model) { + return undefined + } - return (yield* read()).variant?.[variantKey(model)] - }) + return (yield* read()).variant?.[variantKey(model)] + }) - const saveVariant = Effect.fn("RunVariant.saveVariant")(function* ( - model: RunInput["model"], - variant: string | undefined, - ) { - if (!model) { - return - } + const saveVariant = Effect.fn("RunVariant.saveVariant")(function* ( + model: RunInput["model"], + variant: string | undefined, + ) { + if (!model) { + return + } - const current = yield* read() - const next = { - ...current.variant, - } - const key = variantKey(model) - if (variant) { - next[key] = variant - } + const current = yield* read() + const next = { + ...current.variant, + } + const key = variantKey(model) + if (variant) { + next[key] = variant + } - if (!variant) { - delete next[key] - } + if (!variant) { + delete next[key] + } - yield* file - .writeJson(MODEL_FILE, { - ...current, - variant: next, - }) - .pipe(Effect.orElseSucceed(() => undefined)) - }) + yield* file + .writeJson(MODEL_FILE, { + ...current, + variant: next, + }) + .pipe(Effect.orElseSucceed(() => undefined)) + }) - return Service.of({ - resolveSavedVariant, - saveVariant, - }) - }), - ).pipe(Layer.provide(fs)), - ) -} + return Service.of({ + resolveSavedVariant, + saveVariant, + }) + }), + ), +) + +const node = makeGlobalNode({ service: Service, layer, deps: [FSUtil.node] }) /** @internal Exported for testing. */ -export function createVariantRuntime(fs = AppNodeBuilder.build(FSUtil.node)): VariantRuntime { - const runtime = makeRuntime(Service, createLayer(fs)) +export function createVariantRuntime(replacements?: readonly LayerNode.Replacement[]): VariantRuntime { + const runtime = makeRuntime(Service, AppNodeBuilder.build(node, replacements)) return { resolveSavedVariant: (model) => runtime.runPromise((svc) => svc.resolveSavedVariant(model)).catch(() => undefined), saveVariant: (model, variant) => runtime.runPromise((svc) => svc.saveVariant(model, variant)).catch(() => {}), diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index feedd51d8e..54a0079813 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -89,73 +89,8 @@ export const TuiThreadCommand = cmd({ type: "boolean", hidden: true, default: false, - }) - .option("mini", { - type: "boolean", - describe: "start the minimal interactive interface", - default: false, - }) - .option("replay", { - type: "boolean", - hidden: true, - }) - .option("no-replay", { - type: "boolean", - describe: "disable mini session history replay on resume and after resize", - }) - .option("replay-limit", { - type: "number", - describe: "cap visible mini replay to the newest N messages", - }) - .option("demo", { - type: "boolean", - hidden: true, }), handler: async (args) => { - if (args.replay === true) { - UI.error("--replay is not supported; replay is enabled by default") - process.exitCode = 1 - return - } - const noReplay = args.replay === false || args.noReplay === true - - if (args.mini) { - const network = ["--port", "--hostname", "--mdns", "--no-mdns", "--mdns-domain", "--cors"].find((option) => - process.argv.some((arg) => arg === option || arg.startsWith(option + "=")), - ) - if (network) { - UI.error(`${network} cannot be used with --mini`) - process.exitCode = 1 - return - } - - const { runMini } = await import("./run") - await runMini({ - directory: resolveThreadDirectory(args.project), - continue: args.continue, - session: args.session, - fork: args.fork, - model: args.model, - agent: args.agent, - prompt: args.prompt, - replay: noReplay ? false : undefined, - replayLimit: args.replayLimit, - demo: args.demo, - }) - return - } - - const unsupported = [ - ["--no-replay", noReplay], - ["--replay-limit", args.replayLimit !== undefined], - ["--demo", args.demo !== undefined], - ].find((entry) => entry[1])?.[0] - if (unsupported) { - UI.error(`${unsupported} requires --mini`) - process.exitCode = 1 - return - } - const unguard = win32InstallCtrlCGuard() try { const { TuiConfig } = await import("@/config/tui") diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 13540a73a3..f17904bd49 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -19,6 +19,7 @@ import { GithubCommand } from "./cli/cmd/github" import { ExportCommand } from "./cli/cmd/export" import { ImportCommand } from "./cli/cmd/import" import { AttachCommand } from "./cli/cmd/attach" +import { MiniCommand } from "./cli/cmd/mini" import { TuiThreadCommand } from "./cli/cmd/tui" import { AcpCommand } from "./cli/cmd/acp" import { EOL } from "os" @@ -80,6 +81,7 @@ const cli = yargs(args) .completion("completion", "generate shell completion script") .command(AcpCommand) .command(McpCommand) + .command(MiniCommand) .command(TuiThreadCommand) .command(AttachCommand) .command(RunCommand) diff --git a/packages/opencode/src/server/routes/instance/httpapi/public.ts b/packages/opencode/src/server/routes/instance/httpapi/public.ts index 2a7266c511..692c3d4591 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/public.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/public.ts @@ -34,6 +34,8 @@ type OpenApiSchema = { additionalProperties?: OpenApiSchema | boolean allOf?: OpenApiSchema[] anyOf?: OpenApiSchema[] + contentMediaType?: string + contentSchema?: OpenApiSchema description?: string enum?: Array items?: OpenApiSchema @@ -97,6 +99,7 @@ function matchLegacyOpenApi(input: Record) { } normalizeComponentNames(spec) collapseDuplicateComponents(spec) + normalizeV2EventSchemas(spec) applyLegacySchemaOverrides(spec) normalizeComponentDescriptions(spec) addLegacyErrorSchemas(spec) @@ -229,6 +232,16 @@ function collapseDuplicateComponents(spec: OpenApiSpec) { } } +function normalizeV2EventSchemas(spec: OpenApiSpec) { + const schemas = spec.components?.schemas + if (!schemas?.V2Event1?.anyOf || schemas.V2Event?.type !== "string") return + schemas.V2EventStream = schemas.V2Event + rewriteRefs(spec, "V2Event", "V2EventStream") + schemas.V2Event = schemas.V2Event1 + delete schemas.V2Event1 + rewriteRefs(spec, "V2Event1", "V2Event") +} + function normalizeComponentNames(spec: OpenApiSpec) { const schemas = spec.components?.schemas if (!schemas) return diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index db2ae41db4..1d4f31ae93 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -66,6 +66,7 @@ import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionV2 } from "@opencode-ai/core/session" import { SessionExecution } from "@opencode-ai/core/session/execution" import * as SessionExecutionLocal from "@opencode-ai/core/session/execution/local" +import { PluginRuntime } from "@opencode-ai/core/plugin/runtime" import { lazy } from "@/util/lazy" import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@opencode-ai/server/cors" import { serveUIEffect } from "@/server/shared/ui" @@ -297,8 +298,11 @@ export function createRoutes( Layer.provide(sessionLocationLayer), Layer.provide(locationLayer), Layer.provide(PtyEnvironment.layer), + // PluginRuntime.providerNode shares this build so plugin tools (subagent, + // shell jobs) capture the same SessionV2/Job instances the handlers use. + // Without it the plugin runtime cell stays empty and subagents cannot spawn. Layer.provide( - AppNodeBuilderV1.build(SessionV2.node, [ + AppNodeBuilderV1.build(LayerNode.group([SessionV2.node, PluginRuntime.providerNode]), [ [LocationServiceMap.node, locationServiceMapV2], [SessionExecution.node, SessionExecutionLocal.node], ]), diff --git a/packages/opencode/src/temporary.ts b/packages/opencode/src/temporary.ts index 95461f301b..b100ba9d10 100644 --- a/packages/opencode/src/temporary.ts +++ b/packages/opencode/src/temporary.ts @@ -1,4 +1,5 @@ import yargs from "yargs" +import { MiniCommand } from "./cli/cmd/mini" import { TuiThreadCommand } from "./cli/cmd/tui" import { InstallationVersion } from "@opencode-ai/core/installation/version" import { hideBin } from "yargs/helpers" @@ -27,5 +28,6 @@ const cli = yargs(hideBin(process.argv)) if (opts.printLogs) process.env.OPENCODE_PRINT_LOGS = "1" if (opts.logLevel) process.env.OPENCODE_LOG_LEVEL = opts.logLevel }) + .command(MiniCommand) .command(TuiThreadCommand) .parse() diff --git a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap index e9d3ad2338..bd5dc88e7e 100644 --- a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap +++ b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap @@ -41,6 +41,34 @@ Options: --pure run without external plugins [boolean]" `; +exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mini --help 1`] = ` +"opencode mini + +start the minimal interactive interface + +Commands: + opencode mini [project] start the minimal interactive interface [default] + opencode mini attach attach to a running opencode server with the minimal interface + +Positionals: + project path to start opencode in [string] + +Options: + -h, --help show help [boolean] + -v, --version show version number [boolean] + --print-logs print logs to stderr [boolean] + --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] + --pure run without external plugins [boolean] + -m, --model model to use in the format of provider/model [string] + --agent agent to use [string] + --prompt prompt to use [string] + -c, --continue continue the last session [boolean] + -s, --session session id to continue [string] + --fork fork the session when continuing (use with --continue or --session) [boolean] + --no-replay disable session history replay on resume and after resize [boolean] + --replay-limit cap visible replay to the newest N messages [number]" +`; + exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode attach --help 1`] = ` "opencode attach @@ -50,21 +78,17 @@ Positionals: url http://localhost:4096 [string] [required] Options: - -h, --help show help [boolean] - -v, --version show version number [boolean] - --print-logs print logs to stderr [boolean] - --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] - --pure run without external plugins [boolean] - --dir directory to run in [string] - -c, --continue continue the last session [boolean] - -s, --session session id to continue [string] - --fork fork the session when continuing (use with --continue or --session) [boolean] - -p, --password basic auth password (defaults to OPENCODE_SERVER_PASSWORD) [string] - -u, --username basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode') - [string] - --mini start the minimal interactive interface [boolean] [default: false] - --no-replay disable mini session history replay on resume and after resize [boolean] - --replay-limit cap visible mini replay to the newest N messages [number]" + -h, --help show help [boolean] + -v, --version show version number [boolean] + --print-logs print logs to stderr [boolean] + --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] + --pure run without external plugins [boolean] + --dir directory to run in [string] + -c, --continue continue the last session [boolean] + -s, --session session id to continue [string] + --fork fork the session when continuing (use with --continue or --session) [boolean] + -p, --password basic auth password (defaults to OPENCODE_SERVER_PASSWORD) [string] + -u, --username basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')[string]" `; exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode run --help 1`] = ` @@ -85,7 +109,6 @@ Options: -c, --continue continue the last session [boolean] -s, --session session id to continue [string] --fork fork the session before continuing (requires --continue or --session) [boolean] - --share share the session [boolean] -m, --model model to use in the format of provider/model [string] --agent agent to use [string] --format format: default (formatted) or json (raw JSON events) @@ -403,6 +426,31 @@ Options: --format Output format [string] [choices: "json", "tsv"] [default: "tsv"]" `; +exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mini attach --help 1`] = ` +"opencode mini attach + +attach to a running opencode server with the minimal interface + +Positionals: + url http://localhost:4096 [string] [required] + +Options: + -h, --help show help [boolean] + -v, --version show version number [boolean] + --print-logs print logs to stderr [boolean] + --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] + --pure run without external plugins [boolean] + --dir directory on the remote server [string] + -p, --password basic auth password (defaults to OPENCODE_SERVER_PASSWORD) [string] + -u, --username basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode') + [string] + -c, --continue continue the last session [boolean] + -s, --session session id to continue [string] + --fork fork the session when continuing (use with --continue or --session) [boolean] + --no-replay disable session history replay on resume and after resize [boolean] + --replay-limit cap visible replay to the newest N messages [number]" +`; + exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode mcp list --help 1`] = ` "opencode mcp list diff --git a/packages/opencode/test/cli/help/help-snapshots.test.ts b/packages/opencode/test/cli/help/help-snapshots.test.ts index 3a14d0d7ec..fc3dabeef3 100644 --- a/packages/opencode/test/cli/help/help-snapshots.test.ts +++ b/packages/opencode/test/cli/help/help-snapshots.test.ts @@ -13,16 +13,27 @@ // version (changes per release), so we'd snapshot a moving target. import { describe, expect } from "bun:test" import { Effect } from "effect" +import { fileURLToPath } from "node:url" import { cliIt } from "../../lib/cli-process" import { normalizeForSnapshot, PATH_SEP } from "../../lib/snapshot" +const PACKAGE_ROOT_PATTERN = new RegExp( + fileURLToPath(new URL("../../..", import.meta.url)) + .replace(/[/\\]$/, "") + .replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), + "g", +) + // Composes `normalizeForSnapshot` (CRLF + tmpdir) with two help-specific // rules: // // 1. The harness's `oc-cli-XXX` subdir under TMPDIR collapses to ``. // `PATH_SEP` matches `/` and `\\` so the rule works on POSIX + Windows. // -// 2. yargs wraps the `[string] [default: "..."]` clause based on the +// 2. Some command defaults use the package cwd when the harness spawns the +// CLI. Collapse that path too so snapshots do not depend on checkout path. +// +// 3. yargs wraps the `[string] [default: "..."]` clause based on the // pre-normalized default's character length, so different random home // path widths produce different leading-whitespace counts (or even // line-wraps onto a fresh line on Windows). `\s+` matches both forms. @@ -33,6 +44,7 @@ function normalize(text: string): string { // (the harness now uses FileSystem.makeTempDirectoryScoped under the // hood). A `[a-z0-9]+` regex would leave uppercase chars trailing. [new RegExp(`${PATH_SEP}oc-cli-[A-Za-z0-9]+`, "g"), ""], + [PACKAGE_ROOT_PATTERN, ""], [/\s+\[string\] \[default: ""\]/g, ' [string] [default: ""]'], ], }) @@ -45,6 +57,7 @@ function normalize(text: string): string { const TOP_LEVEL = [ "acp", "mcp", + "mini", "attach", "run", "debug", @@ -69,6 +82,7 @@ const TOP_LEVEL = [ // distinct argv shape, not every leaf. Add new entries when a subcommand // gains user-visible flags that we want to lock in. const SUBCOMMANDS = [ + ["mini", "attach"], ["mcp", "list"], ["mcp", "add"], ["mcp", "auth"], @@ -101,7 +115,8 @@ describe("opencode CLI help-text snapshots", () => { const topLevel = yield* opencode.spawn(["--help"], { env: SNAPSHOT_ENV }) expect(topLevel.exitCode).toBe(0) expect(topLevel.stderr.endsWith("\n")).toBe(true) - expect(topLevel.stderr).toContain("--mini") + expect(topLevel.stderr).toContain("opencode mini") + expect(topLevel.stderr).not.toContain("--mini") expect(topLevel.stderr).not.toContain("--thinking") expect(topLevel.stderr).not.toContain("--variant") expect(topLevel.stderr).not.toContain("--demo") diff --git a/packages/opencode/test/cli/run/catalog.shared.test.ts b/packages/opencode/test/cli/run/catalog.shared.test.ts new file mode 100644 index 0000000000..bdf57ef7d1 --- /dev/null +++ b/packages/opencode/test/cli/run/catalog.shared.test.ts @@ -0,0 +1,132 @@ +import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" +import { OpencodeClient } from "@opencode-ai/sdk/v2" +import { loadRunReferences, runProviders } from "@/cli/cmd/run/catalog.shared" + +afterEach(() => { + mock.restore() +}) + +describe("run catalog shared", () => { + test("loads visible project references from the current reference catalog", async () => { + const client = new OpencodeClient() + const list = spyOn(client.v2.reference, "list").mockImplementation( + () => + Promise.resolve({ + data: { + location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, + data: [ + { + name: "effect", + path: "/repos/effect", + description: "Effect v4 sources", + source: { type: "local", path: "/repos/effect" }, + }, + { + name: "secret", + path: "/repos/secret", + hidden: true, + source: { type: "local", path: "/repos/secret" }, + }, + ], + }, + error: undefined, + request: new Request("https://opencode.test"), + response: new Response(), + }) as never, + ) + + const references = await loadRunReferences(client, "/tmp") + + expect(list).toHaveBeenCalledWith({ location: { directory: "/tmp" } }, { throwOnError: true }) + expect(references).toMatchObject([{ name: "effect", path: "/repos/effect", description: "Effect v4 sources" }]) + }) + + test("merges current providers and models into the footer catalog shape", () => { + const providers = runProviders( + [ + { + id: "openai", + name: "OpenAI", + api: { type: "native", settings: {} }, + request: { settings: {}, headers: {}, body: {} }, + }, + ], + [ + { + id: "gpt-5", + providerID: "openai", + name: "Little Frank", + api: { id: "openai", type: "native", settings: {} }, + capabilities: { + tools: true, + input: ["text"], + output: ["text"], + }, + request: { + settings: {}, + headers: {}, + body: {}, + }, + variants: [ + { + id: "high", + settings: {}, + headers: {}, + body: {}, + }, + ], + time: { + released: 1, + }, + cost: [ + { + input: 0, + output: 0, + cache: { + read: 0, + write: 0, + }, + }, + ], + status: "active", + enabled: true, + limit: { + context: 128000, + output: 8192, + }, + }, + ], + ) + + expect(providers).toEqual([ + { + id: "openai", + name: "OpenAI", + models: { + "gpt-5": { + id: "gpt-5", + providerID: "openai", + name: "Little Frank", + capabilities: expect.objectContaining({ tools: true }), + cost: { + input: 0, + output: 0, + cache: { + read: 0, + write: 0, + }, + }, + limit: { + context: 128000, + output: 8192, + }, + status: "active", + variants: { + high: {}, + }, + }, + }, + }, + ]) + }) +}) diff --git a/packages/opencode/test/cli/run/footer.view.test.tsx b/packages/opencode/test/cli/run/footer.view.test.tsx index 2e9fd8ef17..83042040f2 100644 --- a/packages/opencode/test/cli/run/footer.view.test.tsx +++ b/packages/opencode/test/cli/run/footer.view.test.tsx @@ -187,7 +187,7 @@ async function renderFooter( directory="/tmp" findFiles={async () => []} agents={() => []} - resources={() => []} + references={() => []} commands={() => input.commands ?? []} providers={() => input.providers} currentModel={() => input.currentModel} @@ -934,7 +934,7 @@ test("direct footer shows editable prompts and additional queued work while runn directory="/tmp" findFiles={async () => []} agents={() => []} - resources={() => []} + references={() => []} commands={() => []} providers={() => undefined} currentModel={() => ({ diff --git a/packages/opencode/test/cli/run/run-process.test.ts b/packages/opencode/test/cli/run/run-process.test.ts index bd5847e272..30ae27b68d 100644 --- a/packages/opencode/test/cli/run/run-process.test.ts +++ b/packages/opencode/test/cli/run/run-process.test.ts @@ -2,11 +2,12 @@ // These exercise the real CLI binary against a TestLLMServer running in the // same process. See `test/lib/cli-process.ts` for the harness — each test uses // `opencode.run(message, opts?)` to spawn `bun src/index.ts run ...` with -// `OPENCODE_CONFIG_CONTENT` providing the test provider config inline. +// an isolated test provider config under the fixture's temp home. import { describe, expect } from "bun:test" import { Effect } from "effect" import { reply } from "../../lib/llm-server" import { cliIt } from "../../lib/cli-process" +import { testProviderConfig } from "../../lib/test-provider" describe("opencode run (non-interactive subprocess)", () => { // Happy path: prompt completes, output reaches stdout, process exits 0. @@ -28,7 +29,7 @@ describe("opencode run (non-interactive subprocess)", () => { ({ llm, opencode }) => Effect.gen(function* () { yield* llm.push( - reply().text(" before tool ").tool("bash", { + reply().text(" before tool ").tool("shell", { command: "printf tool-output", description: "Print deterministic output", }), @@ -89,7 +90,7 @@ describe("opencode run (non-interactive subprocess)", () => { ({ llm, opencode }) => Effect.gen(function* () { yield* llm.push( - reply().text("partial response").tool("bash", { + reply().text("partial response").tool("shell", { command: "printf tool", description: "Print deterministic output", }), @@ -168,7 +169,7 @@ describe("opencode run (non-interactive subprocess)", () => { ({ llm, opencode }) => Effect.gen(function* () { yield* llm.push( - reply().reason("reasoning").text("before").tool("bash", { + reply().reason("reasoning").text("before").tool("shell", { command: "printf tool", description: "Print deterministic output", }), @@ -198,7 +199,7 @@ describe("opencode run (non-interactive subprocess)", () => { expect(events.find((event) => event.type === "tool_use")?.part).toEqual( expect.objectContaining({ type: "tool", - tool: "bash", + tool: "shell", state: expect.objectContaining({ status: "completed" }), }), ) @@ -217,7 +218,7 @@ describe("opencode run (non-interactive subprocess)", () => { ({ llm, opencode }) => Effect.gen(function* () { yield* llm.push( - reply().text("partial json").tool("bash", { + reply().text("partial json").tool("shell", { command: "printf tool", description: "Print deterministic output", }), @@ -227,16 +228,9 @@ describe("opencode run (non-interactive subprocess)", () => { const events = opencode.parseJsonEvents(result.stdout) expect(result.exitCode).toBe(0) - expect(events.map((event) => event.type)).toEqual([ - "step_start", - "text", - "tool_use", - "step_finish", - "step_start", - "step_finish", - ]) + expect(events.map((event) => event.type)).toEqual(["step_start", "text", "tool_use", "step_finish"]) expect(events[1]?.part).toEqual(expect.objectContaining({ type: "text", text: "partial json" })) - expect(events.at(-1)?.part).toEqual(expect.objectContaining({ type: "step-finish", reason: "unknown" })) + expect(events.at(-1)?.part).toEqual(expect.objectContaining({ type: "step-finish" })) }), 60_000, ) @@ -245,29 +239,29 @@ describe("opencode run (non-interactive subprocess)", () => { "rejects requested permissions by default and allows them with the dangerous flag", ({ home, llm, opencode }) => Effect.gen(function* () { - yield* llm.tool("bash", { command: "rm -f denied-file", description: "Remove a test file" }) + yield* llm.tool("shell", { command: "rm -f denied-file", description: "Remove a test file" }) yield* llm.text("continued after rejection") - const denied = yield* opencode.run("request permission", { permission: { bash: "ask" } }) + const denied = yield* opencode.run("request permission", { permission: { shell: "ask" } }) opencode.expectExit(denied, 0) - expect(denied.stderr).toContain("permission requested: bash") + expect(denied.stderr).toContain("permission requested: shell") expect(denied.stdout).toBe("") yield* llm.reset - yield* llm.tool("bash", { command: "rm -f allowed-file", description: "Remove a test file" }) + yield* llm.tool("shell", { command: "rm -f allowed-file", description: "Remove a test file" }) yield* llm.text("continued after approval") const allowed = yield* opencode.run("request permission", { - permission: { bash: "ask" }, + permission: { shell: "ask" }, extraArgs: ["--dangerously-skip-permissions"], }) opencode.expectExit(allowed, 0) - expect(allowed.stderr).not.toContain("permission requested: bash") + expect(allowed.stderr).not.toContain("permission requested: shell") expect(allowed.stdout).toContain("continued after approval") yield* llm.reset - yield* llm.tool("bash", { command: "touch explicitly-denied", description: "Create a denied marker" }) + yield* llm.tool("shell", { command: "touch explicitly-denied", description: "Create a denied marker" }) yield* llm.text("continued after explicit denial") const explicitlyDenied = yield* opencode.run("request denied permission", { - permission: { bash: "deny" }, + permission: { shell: "deny" }, extraArgs: ["--dangerously-skip-permissions"], }) opencode.expectExit(explicitlyDenied, 0) @@ -277,6 +271,135 @@ describe("opencode run (non-interactive subprocess)", () => { 60_000, ) + cliIt.concurrent( + "rejects unattended questions without hanging", + ({ llm, opencode }) => + Effect.gen(function* () { + yield* llm.tool("question", { + questions: [ + { + question: "Continue?", + header: "Continue", + options: [{ label: "Yes", description: "Continue execution" }], + }, + ], + }) + const result = yield* opencode.run("ask a question") + + opencode.expectExit(result, 0) + expect(result.stdout).toBe("") + }), + 60_000, + ) + + cliIt.concurrent( + "continues a current session with projected history", + ({ home, llm, opencode }) => + Effect.gen(function* () { + const env = { OPENCODE_DB: `${home}/run-continue.sqlite` } + yield* llm.text("first response") + const first = yield* opencode.run("first prompt", { env }) + opencode.expectExit(first, 0) + + yield* llm.text("second response") + const second = yield* opencode.run("second prompt", { env, extraArgs: ["--continue"] }) + opencode.expectExit(second, 0) + expect(second.stdout).toBe("second response\n") + expect(JSON.stringify((yield* llm.inputs).at(-1))).toContain("first prompt") + }), + 60_000, + ) + + cliIt.concurrent( + "forks the latest current session for --continue", + ({ home, llm, opencode }) => + Effect.gen(function* () { + const env = { OPENCODE_DB: `${home}/run-fork-continue.sqlite` } + yield* llm.text("first response") + const first = yield* opencode.run("first prompt", { env, format: "json" }) + opencode.expectExit(first, 0) + const firstSessionID = opencode.parseJsonEvents(first.stdout)[0]?.sessionID + expect(typeof firstSessionID).toBe("string") + + yield* llm.text("forked response") + const second = yield* opencode.run("second prompt", { + env, + format: "json", + extraArgs: ["--continue", "--fork"], + }) + + opencode.expectExit(second, 0) + const secondSessionID = String(opencode.parseJsonEvents(second.stdout)[0]?.sessionID) + expect(secondSessionID).not.toBe(String(firstSessionID)) + expect(JSON.stringify((yield* llm.inputs).at(-1))).toContain("first prompt") + }), + 60_000, + ) + + cliIt.concurrent( + "forks a current session selected by --session", + ({ home, llm, opencode }) => + Effect.gen(function* () { + const env = { OPENCODE_DB: `${home}/run-fork-session.sqlite` } + yield* llm.text("first response") + const first = yield* opencode.run("first prompt", { env, format: "json" }) + opencode.expectExit(first, 0) + const firstSessionID = opencode.parseJsonEvents(first.stdout)[0]?.sessionID + expect(typeof firstSessionID).toBe("string") + + yield* llm.text("forked response") + const second = yield* opencode.run("second prompt", { + env, + format: "json", + extraArgs: ["--session", String(firstSessionID), "--fork"], + }) + + opencode.expectExit(second, 0) + const secondSessionID = String(opencode.parseJsonEvents(second.stdout)[0]?.sessionID) + expect(secondSessionID).not.toBe(String(firstSessionID)) + expect(JSON.stringify((yield* llm.inputs).at(-1))).toContain("first prompt") + }), + 60_000, + ) + + cliIt.concurrent( + "applies a variant to the configured default model", + ({ llm, opencode }) => + Effect.gen(function* () { + yield* llm.text("variant response") + const result = yield* opencode.spawn(["run", "--variant", "default", "use the default model"], { + config: { ...testProviderConfig(llm.url), model: "test/test-model" }, + }) + + opencode.expectExit(result, 0) + expect(result.stdout).toBe("variant response\n") + }), + 60_000, + ) + + cliIt.live( + "preserves local image files as media attachments", + ({ home, llm, opencode }) => + Effect.gen(function* () { + const source = `${home}/image.png` + yield* Effect.promise(() => Bun.write(source, Buffer.from("iVBORw0KGgo=", "base64"))) + yield* llm.text("attachment received") + const config = testProviderConfig(llm.url) + config.provider.test.models["test-model"].attachment = true + + const result = yield* opencode.run("read the attachment", { + extraArgs: [`--file=${source}`, "--"], + config, + }) + + opencode.expectExit(result, 0) + const input = JSON.stringify(yield* llm.inputs) + expect(input).toContain("image/png") + expect(input).not.toContain("") + }), + 60_000, + ) + cliIt.live( "attach mode sends client-local file contents without a shared path", ({ home, llm, opencode }) => @@ -328,4 +451,19 @@ describe("opencode run (non-interactive subprocess)", () => { }), 30_000, ) + + cliIt.live( + "SIGINT before admission prevents provider execution", + ({ llm, opencode }) => + Effect.gen(function* () { + yield* llm.hang + const run = yield* opencode.startRun("do not start") + run.interrupt() + const result = yield* run.result + + expect(result.exitCode).not.toBe(0) + expect(yield* llm.inputs).toHaveLength(0) + }), + 30_000, + ) }) diff --git a/packages/opencode/test/cli/run/runtime.boot.test.ts b/packages/opencode/test/cli/run/runtime.boot.test.ts index 9ba69a6c8e..59a0d8e7c5 100644 --- a/packages/opencode/test/cli/run/runtime.boot.test.ts +++ b/packages/opencode/test/cli/run/runtime.boot.test.ts @@ -1,58 +1,71 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" -import { OpencodeClient, type Provider } from "@opencode-ai/sdk/v2" +import { OpencodeClient } from "@opencode-ai/sdk/v2" import type { Resolved } from "@opencode-ai/tui/config" import { TuiConfig } from "@/config/tui" import { resolveDiffStyle, resolveModelInfo, resolveRunTuiConfig } from "@/cli/cmd/run/runtime.boot" import { createTuiResolvedConfig } from "../../fixture/tui-runtime" -function model(id: string, providerID: string, context: number, variants?: Record>) { +function ok(data: T) { + return Promise.resolve({ + data, + error: undefined, + request: new Request("https://opencode.test"), + response: new Response(), + }) +} + +function provider(id: string, name: string) { + return { + id, + name, + api: { type: "native" as const, settings: {} }, + request: { headers: {}, body: {} }, + } +} + +function model(id: string, providerID: string, context: number, variants: string[] = []) { return { id, providerID, api: { id: providerID, - url: `https://${providerID}.test`, - npm: `@ai-sdk/${providerID}`, + type: "native" as const, + settings: {}, }, name: id, capabilities: { - temperature: true, - reasoning: true, - attachment: true, - toolcall: true, - input: { - text: true, - audio: false, - image: false, - video: false, - pdf: false, - }, - output: { - text: true, - audio: false, - image: false, - video: false, - pdf: false, - }, - interleaved: false, + tools: true, + input: ["text"], + output: ["text"], }, - cost: { - input: 0, - output: 0, - cache: { - read: 0, - write: 0, - }, + request: { + headers: {}, + body: {}, }, + variants: variants.map((variant) => ({ + id: variant, + headers: {}, + body: {}, + })), + time: { + released: 1, + }, + cost: [ + { + input: 0, + output: 0, + cache: { + read: 0, + write: 0, + }, + }, + ], limit: { context, output: 8192, }, status: "active" as const, - options: {}, - headers: {}, - release_date: "2026-01-01", - variants, + enabled: true, } } @@ -160,119 +173,101 @@ describe("run runtime boot", () => { await expect(resolveDiffStyle()).resolves.toBe("auto") }) - test("prefers configured providers for model selector data", async () => { + test("loads v2 providers and models for model selector data", async () => { const sdk = new OpencodeClient() - const data: { - all: Provider[] - default: Record - connected: string[] - } = { - all: [ + const providers = [provider("openai", "OpenAI")] + const models = [model("gpt-5", "openai", 128000, ["high", "minimal"])] + // The generated methods have conditional return types for throwOnError; these mocks represent the successful branch. + // @ts-expect-error successful SDK response is valid for both modes at runtime + const providerList = spyOn(sdk.v2.provider, "list").mockImplementation(() => ok({ data: providers })) + // @ts-expect-error successful SDK response is valid for both modes at runtime + spyOn(sdk.v2.model, "list").mockImplementation(() => ok({ data: models })) + + await expect(resolveModelInfo(sdk, "/workspace", { providerID: "openai", modelID: "gpt-5" })).resolves.toEqual({ + providers: [ { id: "openai", name: "OpenAI", - source: "api", - env: [], - options: {}, models: { - "gpt-5": model("gpt-5", "openai", 128000, { - high: {}, - minimal: {}, - }), - }, - }, - { - id: "anthropic", - name: "Anthropic", - source: "api", - env: [], - options: {}, - models: { - sonnet: model("sonnet", "anthropic", 200000), + "gpt-5": { + id: "gpt-5", + providerID: "openai", + name: "gpt-5", + capabilities: { + tools: true, + input: ["text"], + output: ["text"], + }, + cost: { + input: 0, + output: 0, + cache: { + read: 0, + write: 0, + }, + }, + limit: { + context: 128000, + output: 8192, + }, + status: "active", + variants: { + high: {}, + minimal: {}, + }, + }, }, }, ], - default: {}, - connected: [], - } - const configured = { - providers: [data.all[0]!], - default: {}, - } - const list = spyOn(sdk.provider, "list").mockImplementation(() => - Promise.resolve({ - data, - error: undefined, - request: new Request("https://opencode.test"), - response: new Response(), - }), - ) - spyOn(sdk.config, "providers").mockImplementation(() => - Promise.resolve({ - data: configured, - error: undefined, - request: new Request("https://opencode.test"), - response: new Response(), - }), - ) - - await expect(resolveModelInfo(sdk, "/workspace", { providerID: "openai", modelID: "gpt-5" })).resolves.toEqual({ - providers: configured.providers, variants: ["high", "minimal"], limits: { "openai/gpt-5": 128000, }, }) - expect(list).not.toHaveBeenCalled() + expect(providerList).toHaveBeenCalledWith( + { + location: { + directory: "/workspace", + }, + }, + { throwOnError: true }, + ) }) - test("falls back to provider list when configured providers are unavailable", async () => { + test("loads context limits across v2 providers", async () => { const sdk = new OpencodeClient() - const data: { - all: Provider[] - default: Record - connected: string[] - } = { - all: [ - { - id: "openai", - name: "OpenAI", - source: "api", - env: [], - options: {}, - models: { - "gpt-5": model("gpt-5", "openai", 128000, { - high: {}, - minimal: {}, - }), - }, - }, - { - id: "anthropic", - name: "Anthropic", - source: "api", - env: [], - options: {}, - models: { - sonnet: model("sonnet", "anthropic", 200000), - }, - }, - ], - default: {}, - connected: [], - } - spyOn(sdk.config, "providers").mockRejectedValue(new Error("boom")) - spyOn(sdk.provider, "list").mockImplementation(() => - Promise.resolve({ - data, - error: undefined, - request: new Request("https://opencode.test"), - response: new Response(), - }), - ) + const providers = [provider("openai", "OpenAI"), provider("anthropic", "Anthropic")] + const models = [model("gpt-5", "openai", 128000, ["high", "minimal"]), model("sonnet", "anthropic", 200000)] + // The generated methods have conditional return types for throwOnError; these mocks represent the successful branch. + // @ts-expect-error successful SDK response is valid for both modes at runtime + spyOn(sdk.v2.provider, "list").mockImplementation(() => ok({ data: providers })) + // @ts-expect-error successful SDK response is valid for both modes at runtime + spyOn(sdk.v2.model, "list").mockImplementation(() => ok({ data: models })) await expect(resolveModelInfo(sdk, "/workspace", { providerID: "openai", modelID: "gpt-5" })).resolves.toEqual({ - providers: data.all, + providers: [ + expect.objectContaining({ + id: "openai", + name: "OpenAI", + models: expect.objectContaining({ + "gpt-5": expect.objectContaining({ + variants: { + high: {}, + minimal: {}, + }, + }), + }), + }), + expect.objectContaining({ + id: "anthropic", + name: "Anthropic", + models: expect.objectContaining({ + sonnet: expect.objectContaining({ + variants: {}, + }), + }), + }), + ], variants: ["high", "minimal"], limits: { "openai/gpt-5": 128000, diff --git a/packages/opencode/test/cli/run/runtime.test.ts b/packages/opencode/test/cli/run/runtime.test.ts index 2c9eb2bd3d..9ad7e003b2 100644 --- a/packages/opencode/test/cli/run/runtime.test.ts +++ b/packages/opencode/test/cli/run/runtime.test.ts @@ -3,44 +3,18 @@ import { OpencodeClient } from "@opencode-ai/sdk/v2" import { runInteractiveMode } from "@/cli/cmd/run/runtime" import type { FooterApi, RunProvider } from "@/cli/cmd/run/types" -type SessionMessage = NonNullable>["data"]>[number] - const provider: RunProvider = { id: "openai", name: "OpenAI", - source: "api", - env: [], - options: {}, models: { "gpt-5": { id: "gpt-5", providerID: "openai", - api: { - id: "openai", - url: "https://openai.test", - npm: "@ai-sdk/openai", - }, name: "Little Frank", capabilities: { - temperature: true, - reasoning: true, - attachment: true, - toolcall: true, - input: { - text: true, - audio: false, - image: false, - video: false, - pdf: false, - }, - output: { - text: true, - audio: false, - image: false, - video: false, - pdf: false, - }, - interleaved: false, + tools: true, + input: ["text"], + output: ["text"], }, cost: { input: 0, @@ -55,9 +29,7 @@ const provider: RunProvider = { output: 8192, }, status: "active", - options: {}, - headers: {}, - release_date: "2026-01-01", + variants: {}, }, }, } @@ -141,44 +113,129 @@ describe("run interactive runtime", () => { const providers = defer() const sdk = new OpencodeClient() - spyOn(sdk.config, "providers").mockImplementation(async () => { + const legacyProviders = spyOn(sdk.config, "providers").mockRejectedValue(new Error("legacy providers should stay unused")) + const legacyAgents = spyOn(sdk.app, "agents").mockRejectedValue(new Error("legacy agents should stay unused")) + const legacyCommands = spyOn(sdk.command, "list").mockRejectedValue(new Error("legacy commands should stay unused")) + spyOn(sdk.v2.provider, "list").mockImplementation(async () => { providersStarted.resolve() await providers.promise - return ok({ providers: [provider], default: {} }) + return ok({ + location: { + directory: "/tmp", + }, + data: [ + { + id: "openai", + name: "OpenAI", + api: { + type: "native", + settings: {}, + }, + request: { + headers: {}, + body: {}, + }, + }, + ], + }) as never }) - spyOn(sdk.session, "messages").mockImplementation(() => - ok([ - { - info: { + spyOn(sdk.v2.model, "list").mockImplementation(() => + ok({ + location: { + directory: "/tmp", + }, + data: [ + { + id: "gpt-5", + providerID: "openai", + name: "Little Frank", + api: { + id: "openai", + type: "native", + settings: {}, + }, + capabilities: { + tools: true, + input: ["text"], + output: ["text"], + }, + request: { + headers: {}, + body: {}, + }, + variants: [], + time: { + released: 1, + }, + cost: [ + { + input: 0, + output: 0, + cache: { + read: 0, + write: 0, + }, + }, + ], + status: "active", + enabled: true, + limit: { + context: 128000, + output: 8192, + }, + }, + ], + }) as never, + ) + spyOn(sdk.v2.session, "messages").mockImplementation(() => + ok({ + data: [ + { id: "msg-user-1", - sessionID: "ses-1", - role: "user", + type: "user", + text: "hello", time: { created: 1, }, - agent: "build", - model: { - providerID: "openai", - modelID: "gpt-5", - variant: undefined, + }, + ], + cursor: {}, + }), + ) + spyOn(sdk.v2.session, "get").mockImplementation(() => + ok({ + data: { + id: "ses-1", + projectID: "pro-1", + title: "Session", + cost: 0, + tokens: { + input: 0, + output: 0, + reasoning: 0, + cache: { + read: 0, + write: 0, }, }, - parts: [ - { - id: "part-user-1", - sessionID: "ses-1", - messageID: "msg-user-1", - type: "text", - text: "hello", - }, - ], - } satisfies SessionMessage, - ]), + time: { + created: 1, + updated: 1, + }, + location: { + directory: "/tmp", + }, + model: { + providerID: "openai", + id: "gpt-5", + }, + }, + }), ) - spyOn(sdk.session, "get").mockRejectedValue(new Error("not needed")) - spyOn(sdk.app, "agents").mockImplementation(() => ok([])) - spyOn(sdk.experimental.resource, "list").mockImplementation(() => ok({})) - spyOn(sdk.command, "list").mockImplementation(() => ok([])) + spyOn(sdk.v2.agent, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) + spyOn(sdk.v2.reference, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) + spyOn(sdk.v2.command, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) + spyOn(sdk.v2.skill, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never) const task = runInteractiveMode( { @@ -215,6 +272,7 @@ describe("run interactive runtime", () => { }, 0) return { runPromptTurn: async () => {}, + interruptActiveTurn: async () => {}, selectSubagent: () => {}, replayOnResize: async () => false, close: async () => {}, @@ -234,5 +292,8 @@ describe("run interactive runtime", () => { await task expect(transportProviders).toEqual([[provider]]) + expect(legacyProviders).not.toHaveBeenCalled() + expect(legacyAgents).not.toHaveBeenCalled() + expect(legacyCommands).not.toHaveBeenCalled() }) }) diff --git a/packages/opencode/test/cli/run/session-data.test.ts b/packages/opencode/test/cli/run/session-data.test.ts index b685fb679f..ec21cd007e 100644 --- a/packages/opencode/test/cli/run/session-data.test.ts +++ b/packages/opencode/test/cli/run/session-data.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import type { Event } from "@opencode-ai/sdk/v2" -import { createSessionData, flushInterrupted, reduceSessionData } from "@/cli/cmd/run/session-data" +import { createSessionData, reduceSessionData } from "@/cli/cmd/run/session-data" import type { StreamCommit } from "@/cli/cmd/run/types" function reduce(data: ReturnType, event: unknown, thinking = true) { @@ -547,28 +547,6 @@ describe("run session data", () => { ]) }) - test("flushInterrupted emits one interrupted final per live part", () => { - const data = reduce( - createSessionData(), - text({ - id: "txt-1", - messageID: "msg-1", - text: "unfinished", - }), - ).data - - const first: StreamCommit[] = [] - flushInterrupted(data, first) - expect(first).toEqual([ - expect.objectContaining({ kind: "assistant", text: "unfinished", phase: "progress" }), - expect.objectContaining({ kind: "assistant", phase: "final", interrupted: true }), - ]) - - const next: StreamCommit[] = [] - flushInterrupted(data, next) - expect(next).toEqual([]) - }) - test("surfaces session errors as error commits", () => { const out = reduce(createSessionData(), { type: "session.error", diff --git a/packages/opencode/test/cli/run/session-replay.test.ts b/packages/opencode/test/cli/run/session-replay.test.ts deleted file mode 100644 index 7f0f272d63..0000000000 --- a/packages/opencode/test/cli/run/session-replay.test.ts +++ /dev/null @@ -1,691 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { replayLocalRows, replaySession } from "@/cli/cmd/run/session-replay" -import type { SessionMessages } from "@/cli/cmd/run/session.shared" -import type { RunProvider } from "@/cli/cmd/run/types" - -function userMessage(id: string, text: string): SessionMessages[number] { - return { - info: { - id, - sessionID: "session-1", - role: "user", - time: { - created: 1, - }, - agent: "build", - model: { - providerID: "openai", - modelID: "gpt-5", - }, - }, - parts: [ - { - id: `${id}-text`, - sessionID: "session-1", - messageID: id, - type: "text", - text, - }, - ], - } -} - -function assistantInfo( - id: string, - input: { - parentID?: string - modelID?: string - providerID?: string - time?: { created: number; completed?: number } - } = {}, -) { - return { - id, - sessionID: "session-1", - role: "assistant" as const, - time: input.time ?? { created: 2 }, - parentID: input.parentID ?? "msg-user-1", - modelID: input.modelID ?? "gpt-5", - providerID: input.providerID ?? "openai", - mode: "chat", - agent: "build", - path: { - cwd: "/tmp", - root: "/tmp", - }, - cost: 0, - tokens: { - input: 1, - output: 1, - reasoning: 0, - cache: { - read: 0, - write: 0, - }, - }, - } -} - -function assistantMessage( - id: string, - text: string, - input: { - parentID?: string - modelID?: string - providerID?: string - time?: { created: number; completed?: number } - } = {}, -): SessionMessages[number] { - const time = input.time ?? { - created: 200, - completed: 3000, - } - - return { - info: assistantInfo(id, { - ...input, - time, - }), - parts: [ - { - id: `${id}-text`, - sessionID: "session-1", - messageID: id, - type: "text", - text, - time: { - start: time.created, - end: time.completed, - }, - }, - ], - } -} - -const provider = (name: string): RunProvider => ({ - id: "openai", - name: "OpenAI", - source: "api", - env: [], - options: {}, - models: { - "gpt-5": { - id: "gpt-5", - providerID: "openai", - api: { - id: "openai", - url: "https://openai.test", - npm: "@ai-sdk/openai", - }, - name, - capabilities: { - temperature: true, - reasoning: true, - attachment: true, - toolcall: true, - input: { - text: true, - audio: false, - image: false, - video: false, - pdf: false, - }, - output: { - text: true, - audio: false, - image: false, - video: false, - pdf: false, - }, - interleaved: false, - }, - cost: { - input: 0, - output: 0, - cache: { - read: 0, - write: 0, - }, - }, - limit: { - context: 128000, - output: 8192, - }, - status: "active", - options: {}, - headers: {}, - release_date: "2026-01-01", - }, - }, -}) - -function runningToolMessage(id: string): SessionMessages[number] { - return { - info: assistantInfo(id), - parts: [ - { - id: `${id}-tool`, - sessionID: "session-1", - messageID: id, - type: "tool", - callID: `${id}-call`, - tool: "bash", - state: { - status: "running", - input: { - command: "pwd", - }, - time: { - start: 2, - }, - }, - }, - ], - } -} - -function shellUserMessage(id: string): SessionMessages[number] { - return { - info: { - id, - sessionID: "session-1", - role: "user", - time: { - created: 1, - }, - agent: "build", - model: { - providerID: "openai", - modelID: "gpt-5", - }, - }, - parts: [ - { - id: `${id}-text`, - sessionID: "session-1", - messageID: id, - type: "text", - text: "The following tool was executed by the user", - synthetic: true, - }, - ], - } -} - -function shellAssistantMessage(id: string, parentID: string): SessionMessages[number] { - return { - info: assistantInfo(id, { - parentID, - time: { - created: 200, - completed: 3000, - }, - }), - parts: [ - { - id: `${id}-tool`, - sessionID: "session-1", - messageID: id, - type: "tool", - callID: `${id}-call`, - tool: "bash", - state: { - status: "completed", - input: { - command: "ls", - }, - output: "account.ts\n", - title: "", - metadata: { - output: "account.ts\n", - }, - time: { - start: 200, - end: 3000, - }, - }, - }, - ], - } -} - -describe("run session replay", () => { - test("replays persisted user, assistant, and turn summary history into scrollback commits", () => { - const out = replaySession({ - messages: [ - userMessage("msg-user-1", "Hello, whats the weather today?"), - assistantMessage("msg-1", "What city or ZIP code should I check?"), - ], - permissions: [], - questions: [], - thinking: true, - limits: {}, - }) - - expect(out.commits).toEqual([ - expect.objectContaining({ - kind: "user", - text: "Hello, whats the weather today?", - phase: "start", - source: "system", - messageID: "msg-user-1", - }), - expect.objectContaining({ - kind: "assistant", - text: "What city or ZIP code should I check?", - phase: "progress", - source: "assistant", - messageID: "msg-1", - }), - expect.objectContaining({ - kind: "system", - text: "Build · gpt-5 · 2.8s", - phase: "final", - source: "system", - messageID: "msg-1", - summary: { - agent: "Build", - model: "gpt-5", - duration: "2.8s", - }, - }), - ]) - expect(out.patch).toEqual( - expect.objectContaining({ - phase: "idle", - status: "", - }), - ) - }) - - test("uses provider model names for replayed turn summaries when available", () => { - const out = replaySession({ - messages: [ - userMessage("msg-user-1", "Hello, whats the weather today?"), - assistantMessage("msg-1", "What city or ZIP code should I check?"), - ], - permissions: [], - questions: [], - thinking: true, - limits: {}, - providers: [provider("Little Frank")], - }) - - expect(out.commits.at(-1)).toEqual( - expect.objectContaining({ - kind: "system", - text: "Build · Little Frank · 2.8s", - summary: { - agent: "Build", - model: "Little Frank", - duration: "2.8s", - }, - }), - ) - }) - - test("replays one turn summary for the final assistant in a multi-step turn", () => { - const out = replaySession({ - messages: [ - userMessage("msg-user-1", "Plan and then answer"), - assistantMessage("msg-step-1", "Working", { - parentID: "msg-user-1", - time: { created: 200, completed: 900 }, - }), - assistantMessage("msg-step-2", "Done", { - parentID: "msg-user-1", - time: { created: 1000, completed: 3000 }, - }), - ], - permissions: [], - questions: [], - thinking: true, - limits: {}, - }) - - expect(out.commits.filter((commit) => commit.summary)).toEqual([ - expect.objectContaining({ - kind: "system", - text: "Build · gpt-5 · 2.0s", - messageID: "msg-step-2", - }), - ]) - }) - - test("keeps the footer in a running state for resumed active tools", () => { - const out = replaySession({ - messages: [runningToolMessage("msg-1")], - permissions: [], - questions: [], - thinking: true, - limits: {}, - }) - - expect(out.patch).toEqual( - expect.objectContaining({ - phase: "running", - status: "running bash", - }), - ) - }) - - test("does not replay turn summaries for shell-mode commands", () => { - const out = replaySession({ - messages: [ - shellUserMessage("msg-shell-user-1"), - shellAssistantMessage("msg-shell-assistant-1", "msg-shell-user-1"), - ], - permissions: [], - questions: [], - thinking: true, - limits: {}, - }) - - expect(out.commits.some((commit) => commit.summary)).toBe(false) - expect(out.commits).toContainEqual( - expect.objectContaining({ - kind: "tool", - text: "account.ts\n", - tool: "bash", - toolState: "completed", - }), - ) - }) - - test("merges failed local rows ahead of later persisted prompts", () => { - const persisted = { - kind: "user", - text: "successful", - phase: "start", - source: "system", - messageID: "msg-user-2", - } as const - const failed = { - kind: "user", - text: "failed", - phase: "start", - source: "system", - messageID: "msg-user-1", - } as const - const error = { - kind: "error", - text: "network unavailable", - phase: "start", - source: "system", - messageID: "msg-user-1", - } as const - - expect( - replayLocalRows([userMessage("msg-user-2", "successful")], [persisted], [{ commit: failed }, { commit: error }]), - ).toEqual([failed, error, persisted]) - }) - - test("retains local errors but not duplicate local prompts once a prompt persists", () => { - const persisted = { - kind: "user", - text: "failed after persistence", - phase: "start", - source: "system", - messageID: "msg-user-1", - } as const - const error = { - kind: "error", - text: "connection closed", - phase: "start", - source: "system", - messageID: "msg-user-1", - } as const - - expect( - replayLocalRows( - [userMessage("msg-user-1", "failed after persistence")], - [persisted], - [{ commit: persisted }, { commit: error }], - ), - ).toEqual([persisted, error]) - }) - - test("keeps a local turn failure below assistant output already visible for that turn", () => { - const first = { - kind: "user", - text: "start", - phase: "start", - source: "system", - messageID: "msg-user-1", - } as const - const answer = { - kind: "assistant", - text: "partial answer", - phase: "progress", - source: "assistant", - messageID: "msg-assistant-1", - } as const - const error = { - kind: "error", - text: "stream failed", - phase: "start", - source: "system", - messageID: "msg-user-1", - } as const - const second = { - kind: "user", - text: "retry", - phase: "start", - source: "system", - messageID: "msg-user-2", - } as const - - expect( - replayLocalRows( - [userMessage("msg-user-1", "start"), userMessage("msg-user-2", "retry")], - [first, answer, second], - [ - { - commit: error, - after: { kind: "assistant", text: "partial answer", phase: "progress", messageID: "msg-assistant-1" }, - }, - ], - ), - ).toEqual([first, answer, error, second]) - }) - - test("keeps a local failure above assistant output received after the failure", () => { - const first = { - kind: "user", - text: "start", - phase: "start", - source: "system", - messageID: "msg-user-1", - } as const - const error = { - kind: "error", - text: "request failed", - phase: "start", - source: "system", - messageID: "msg-user-1", - } as const - const late = { - kind: "assistant", - text: "late answer", - phase: "progress", - source: "assistant", - messageID: "msg-assistant-1", - } as const - - expect(replayLocalRows([userMessage("msg-user-1", "start")], [first, late], [{ commit: error }])).toEqual([ - first, - error, - late, - ]) - }) - - test("inserts a local failure between persisted output chunks spanning that failure", () => { - const first = { - kind: "user", - text: "start", - phase: "start", - source: "system", - messageID: "msg-user-1", - } as const - const complete = { - kind: "assistant", - text: "before after", - phase: "progress", - source: "assistant", - messageID: "msg-assistant-1", - partID: "part-1", - } as const - const error = { - kind: "error", - text: "stream failed", - phase: "start", - source: "system", - messageID: "msg-user-1", - } as const - - expect( - replayLocalRows( - [userMessage("msg-user-1", "start")], - [first, complete], - [ - { - commit: error, - after: { - kind: "assistant", - text: "before ", - phase: "progress", - messageID: "msg-assistant-1", - partID: "part-1", - visible: "before ", - }, - }, - ], - ), - ).toEqual([first, { ...complete, text: "before " }, error, { ...complete, text: "after" }]) - }) - - test("places an unpersisted failed prompt before live output from that turn", () => { - const prompt = { - kind: "user", - text: "start", - phase: "start", - source: "system", - messageID: "msg-1", - } as const - const answer = { - kind: "assistant", - text: "partial answer", - phase: "progress", - source: "assistant", - messageID: "msg-2", - } as const - const error = { - kind: "error", - text: "stream failed", - phase: "start", - source: "system", - messageID: "msg-1", - } as const - - expect( - replayLocalRows( - [], - [answer], - [ - { commit: prompt }, - { - commit: error, - after: { kind: "assistant", text: "partial answer", phase: "progress", messageID: "msg-2" }, - }, - ], - ), - ).toEqual([prompt, answer, error]) - }) - - test("anchors a failure after the visible start of a tool that later completes", () => { - const prompt = { - kind: "user", - text: "run ls", - phase: "start", - source: "system", - messageID: "msg-user-1", - } as const - const running = { - kind: "tool", - text: "running bash", - phase: "start", - source: "tool", - messageID: "msg-assistant-1", - partID: "part-tool-1", - toolState: "running", - } as const - const completed = { - kind: "tool", - text: "file.txt", - phase: "final", - source: "tool", - messageID: "msg-assistant-1", - partID: "part-tool-1", - toolState: "completed", - } as const - const error = { - kind: "error", - text: "connection lost", - phase: "start", - source: "system", - messageID: "msg-user-1", - } as const - - expect( - replayLocalRows( - [userMessage("msg-user-1", "run ls")], - [prompt, running, completed], - [ - { - commit: error, - after: { - kind: "tool", - text: "running bash", - phase: "start", - messageID: "msg-assistant-1", - partID: "part-tool-1", - toolState: "running", - }, - }, - ], - ), - ).toEqual([prompt, running, error, completed]) - }) - - test("retains an unpersisted local diagnostic before later persisted prompts", () => { - const first = { - kind: "user", - text: "before", - phase: "start", - source: "system", - messageID: "msg-user-1", - } as const - const error = { - kind: "error", - text: "failed to start new session", - phase: "start", - source: "system", - messageID: "msg-user-2", - } as const - const second = { - kind: "user", - text: "after", - phase: "start", - source: "system", - messageID: "msg-user-3", - } as const - - expect( - replayLocalRows( - [userMessage("msg-user-1", "before"), userMessage("msg-user-3", "after")], - [first, second], - [{ commit: error }], - ), - ).toEqual([first, error, second]) - }) -}) diff --git a/packages/opencode/test/cli/run/session.shared.test.ts b/packages/opencode/test/cli/run/session.shared.test.ts index 5a7e1bff98..a470f51014 100644 --- a/packages/opencode/test/cli/run/session.shared.test.ts +++ b/packages/opencode/test/cli/run/session.shared.test.ts @@ -1,6 +1,8 @@ -import { describe, expect, test } from "bun:test" +import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" +import { OpencodeClient } from "@opencode-ai/sdk/v2" import { createSession, + resolveCurrentSession, sessionHistory, sessionVariant, type RunSession, @@ -18,6 +20,10 @@ const model = { modelID: "gpt-5", } +afterEach(() => { + mock.restore() +}) + function userMessage(id: string, parts: Message["parts"], variant = "high"): Message { return { info: { @@ -244,4 +250,74 @@ describe("run session shared", () => { expect(sessionVariant(session, model)).toBe("minimal") }) + + test("restores current prompt history from stored text and file references", async () => { + const client = new OpencodeClient() + spyOn(client.v2.session, "messages").mockImplementation(() => + Promise.resolve({ + data: { + data: [ + { + id: "msg_prompt", + type: "user", + text: "Review @note.ts", + files: [ + { + uri: "file:///tmp/note.ts", + mime: "text/plain", + name: "note.ts", + source: { start: 7, end: 15, text: "@note.ts" }, + }, + ], + agents: [], + time: { created: 1 }, + }, + ], + cursor: {}, + }, + error: undefined, + request: new Request("https://opencode.test"), + response: new Response(), + }), + ) + spyOn(client.v2.session, "get").mockImplementation(() => + Promise.resolve({ + data: { + data: { + id: "ses_1", + title: "Session", + version: "dev", + projectID: "proj_1", + location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, + time: { created: 1, updated: 1 }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + model: { providerID: "openai", id: "gpt-5", variant: "high" }, + }, + }, + error: undefined, + request: new Request("https://opencode.test"), + response: new Response(), + }), + ) + + const out = await resolveCurrentSession(client, "ses_1") + + expect(out.turns[0]?.prompt).toEqual({ + text: "Review @note.ts", + parts: [ + { + type: "file", + url: "file:///tmp/note.ts", + mime: "text/plain", + filename: "note.ts", + source: { + type: "file", + path: "note.ts", + text: { start: 7, end: 15, value: "@note.ts" }, + }, + }, + ], + }) + }) }) diff --git a/packages/opencode/test/cli/run/stream-v2.transport.test.ts b/packages/opencode/test/cli/run/stream-v2.transport.test.ts new file mode 100644 index 0000000000..e1b412c5f6 --- /dev/null +++ b/packages/opencode/test/cli/run/stream-v2.transport.test.ts @@ -0,0 +1,1302 @@ +import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { pathToFileURL } from "node:url" +import { OpencodeClient, type V2Event } from "@opencode-ai/sdk/v2" +import { createSessionTransport } from "@/cli/cmd/run/stream-v2.transport" +import type { FooterApi, FooterEvent, StreamCommit } from "@/cli/cmd/run/types" +import { tmpdir } from "../../fixture/fixture" + +type RunV2Event = V2Event + +function feed() { + const values: RunV2Event[] = [] + let closed = false + let wake: (() => void) | undefined + const stream = (async function* (): AsyncGenerator { + while (!closed || values.length > 0) { + if (values.length === 0) { + await new Promise((resolve) => { + wake = resolve + }) + continue + } + const value = values.shift() + if (value) yield value + } + })() + return { + stream, + push(value: RunV2Event) { + values.push(value) + wake?.() + wake = undefined + }, + close() { + closed = true + wake?.() + wake = undefined + }, + } +} + +function ok(data: T) { + return Promise.resolve({ + data, + error: undefined, + request: new Request("https://opencode.test"), + response: new Response(), + }) +} + +function connected(id = "evt_connected") { + return { id, type: "server.connected", data: {} } satisfies RunV2Event +} + +function footer() { + const commits: StreamCommit[] = [] + const events: FooterEvent[] = [] + let closed = false + const api: FooterApi = { + get isClosed() { + return closed + }, + onPrompt: () => () => {}, + onQueuedRemove: () => () => {}, + onClose: () => () => {}, + event(value) { + events.push(value) + }, + append(value) { + commits.push(value) + }, + idle: () => Promise.resolve(), + close() { + closed = true + }, + destroy() { + closed = true + }, + } + return { api, commits, events } +} + +type SessionMessages = NonNullable< + Awaited>["data"] +>["data"][number][] + +function sdk(input: { + streams: ReturnType[] + active?: () => Record + messages?: Record + sessions?: Array<{ id: string; parentID?: string; title?: string; agent?: string; time: { updated: number } }> +}) { + const client = new OpencodeClient() + let subscription = 0 + spyOn(client.v2.event, "subscribe").mockImplementation( + () => Promise.resolve({ stream: input.streams[subscription++]?.stream ?? feed().stream }) as ReturnType, + ) + spyOn(client.v2.session, "messages").mockImplementation((request) => + ok({ + data: input.messages?.[request.sessionID] ?? [ + { + id: "msg_old", + type: "user" as const, + text: "previous prompt", + files: [], + agents: [], + time: { created: 1 }, + }, + ], + cursor: {}, + }), + ) + spyOn(client.v2.session.permission, "list").mockImplementation(() => ok({ data: [] })) + spyOn(client.v2.session.question, "list").mockImplementation(() => ok({ data: [] })) + spyOn(client.v2.session, "active").mockImplementation(() => ok({ data: input.active?.() ?? {} })) + spyOn(client.v2.session, "switchAgent").mockImplementation(() => ok(undefined)) + spyOn(client.v2.session, "switchModel").mockImplementation(() => ok(undefined)) + // The generated methods have conditional return types for throwOnError; the + // minimal shapes below are enough for family discovery and model fallback. + spyOn(client.v2.session, "list").mockImplementation( + () => + ok({ + location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, + data: input.sessions ?? [], + }) as never, + ) + spyOn(client.v2.model, "default").mockImplementation( + () => + ok({ + location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, + data: undefined, + }) as never, + ) + return client +} + +afterEach(() => { + mock.restore() +}) + +describe("V2 mini transport", () => { + test("hydrates projection, reduces live output, and completes on settlement", async () => { + const events = feed() + events.push(connected()) + const client = sdk({ streams: [events] }) + const ui = footer() + const transport = await createSessionTransport({ + sdk: client, + sessionID: "ses_1", + thinking: true, + replay: true, + limits: () => ({}), + footer: ui.api, + }) + expect(ui.commits.map((item) => item.text)).toEqual(["previous prompt"]) + + let admitted = false + // The generated method has conditional return types for throwOnError; this mock represents the successful branch. + // @ts-expect-error successful SDK response is valid for both modes at runtime + spyOn(client.v2.session, "prompt").mockImplementation((request) => { + const messageID = request.id ?? "msg_prompt" + const prompt = request.prompt ?? { text: "" } + admitted = true + return ok({ + data: { + admittedSeq: 1, + id: messageID, + sessionID: "ses_1", + prompt, + delivery: "steer" as const, + timeCreated: 2, + }, + }) + }) + + const turn = transport.runPromptTurn({ + agent: undefined, + model: undefined, + variant: undefined, + prompt: { messageID: "msg_prompt", text: "hello", parts: [] }, + files: [], + includeFiles: true, + }) + while (!admitted) await Bun.sleep(0) + events.push({ + id: "evt_prompted", + type: "session.next.prompted", + data: { + timestamp: 2, + sessionID: "ses_1", + messageID: "msg_prompt", + prompt: { text: "hello" }, + delivery: "steer", + }, + }) + events.push({ + id: "evt_text", + type: "session.next.text.delta", + data: { + timestamp: 3, + sessionID: "ses_1", + assistantMessageID: "msg_assistant", + textID: "txt_1", + delta: "answer", + }, + }) + events.push({ + id: "evt_settled", + type: "session.next.execution.settled", + data: { timestamp: 4, sessionID: "ses_1", outcome: "success" }, + }) + await turn + + expect(ui.commits.map((item) => item.text)).toEqual(["previous prompt", "answer"]) + expect(ui.events).toContainEqual({ type: "stream.patch", patch: { phase: "idle", status: "" } }) + await transport.close() + }) + + test("sends local file and directory mentions as structured prompt files", async () => { + await using tmp = await tmpdir() + const filePath = path.join(tmp.path, "note.ts") + const directoryPath = path.join(tmp.path, "docs") + await Bun.write(filePath, "export const answer = 42\n") + await fs.mkdir(directoryPath) + await Bun.write(path.join(directoryPath, "README.md"), "# hello\n") + + const events = feed() + events.push(connected()) + const client = sdk({ streams: [events] }) + const ui = footer() + const transport = await createSessionTransport({ + sdk: client, + sessionID: "ses_1", + thinking: false, + limits: () => ({}), + footer: ui.api, + }) + let request: + | Parameters[0] + | undefined + // The generated method has conditional return types for throwOnError; this mock represents the successful branch. + // @ts-expect-error successful SDK response is valid for both modes at runtime + spyOn(client.v2.session, "prompt").mockImplementation((input) => { + request = input + queueMicrotask(() => { + events.push({ + id: "evt_prompted", + type: "session.next.prompted", + data: { + timestamp: 2, + sessionID: "ses_1", + messageID: "msg_prompt", + prompt: { text: input.prompt?.text ?? "" }, + delivery: "steer", + }, + }) + events.push({ + id: "evt_settled", + type: "session.next.execution.settled", + data: { timestamp: 3, sessionID: "ses_1", outcome: "success" }, + }) + }) + return ok({ + data: { + admittedSeq: 1, + id: input.id ?? "msg_prompt", + sessionID: "ses_1", + prompt: input.prompt ?? { text: "" }, + delivery: "steer" as const, + timeCreated: 2, + }, + }) + }) + + await transport.runPromptTurn({ + agent: undefined, + model: undefined, + variant: undefined, + prompt: { + messageID: "msg_prompt", + text: "Review @note.ts and @docs", + parts: [ + { + type: "file", + url: pathToFileURL(filePath).href, + mime: "text/plain", + filename: "note.ts", + source: { type: "file", path: "note.ts", text: { start: 7, end: 15, value: "@note.ts" } }, + }, + { + type: "file", + url: pathToFileURL(`${directoryPath}${path.sep}`).href, + mime: "application/x-directory", + filename: "docs", + source: { type: "file", path: "docs/", text: { start: 20, end: 25, value: "@docs" } }, + }, + ], + }, + files: [], + includeFiles: true, + }) + + expect(request?.prompt?.text).toBe("Review @note.ts and @docs") + expect(request?.prompt?.files).toEqual([ + { + uri: pathToFileURL(filePath).href, + name: "note.ts", + source: { start: 7, end: 15, text: "@note.ts" }, + }, + { + uri: pathToFileURL(`${directoryPath}${path.sep}`).href, + name: "docs", + source: { start: 20, end: 25, text: "@docs" }, + }, + ]) + await transport.close() + }) + + test("sends attached file mentions as structured prompt files without reading them", async () => { + const events = feed() + events.push(connected()) + const client = sdk({ streams: [events] }) + const ui = footer() + const remoteRead = spyOn(client.file, "read") + const remoteList = spyOn(client.file, "list") + const transport = await createSessionTransport({ + sdk: client, + directory: "/remote/project", + sessionID: "ses_1", + thinking: false, + limits: () => ({}), + footer: ui.api, + }) + let request: + | Parameters[0] + | undefined + // The generated method has conditional return types for throwOnError; this mock represents the successful branch. + // @ts-expect-error successful SDK response is valid for both modes at runtime + spyOn(client.v2.session, "prompt").mockImplementation((input) => { + request = input + queueMicrotask(() => { + events.push({ + id: "evt_prompted", + type: "session.next.prompted", + data: { + timestamp: 2, + sessionID: "ses_1", + messageID: "msg_prompt", + prompt: { text: input.prompt?.text ?? "" }, + delivery: "steer", + }, + }) + events.push({ + id: "evt_settled", + type: "session.next.execution.settled", + data: { timestamp: 3, sessionID: "ses_1", outcome: "success" }, + }) + }) + return ok({ + data: { + admittedSeq: 1, + id: input.id ?? "msg_prompt", + sessionID: "ses_1", + prompt: input.prompt ?? { text: "" }, + delivery: "steer" as const, + timeCreated: 2, + }, + }) + }) + + await transport.runPromptTurn({ + agent: undefined, + model: undefined, + variant: undefined, + prompt: { + messageID: "msg_prompt", + text: "Review @note.ts and @docs", + parts: [ + { + type: "file", + url: "file:///remote/project/note.ts", + mime: "text/plain", + filename: "note.ts", + source: { type: "file", path: "note.ts", text: { start: 7, end: 15, value: "@note.ts" } }, + }, + { + type: "file", + url: "file:///remote/project/docs", + mime: "application/x-directory", + filename: "docs", + source: { type: "file", path: "docs", text: { start: 20, end: 25, value: "@docs" } }, + }, + ], + }, + files: [], + includeFiles: true, + }) + + expect(remoteRead).not.toHaveBeenCalled() + expect(remoteList).not.toHaveBeenCalled() + expect(request?.prompt?.text).toBe("Review @note.ts and @docs") + expect(request?.prompt?.files).toEqual([ + { + uri: "file:///remote/project/note.ts", + name: "note.ts", + source: { start: 7, end: 15, text: "@note.ts" }, + }, + { + uri: "file:///remote/project/docs", + name: "docs", + source: { start: 20, end: 25, text: "@docs" }, + }, + ]) + await transport.close() + }) + + test("sends local media mentions as structured prompt files", async () => { + await using tmp = await tmpdir() + const filePath = path.join(tmp.path, "diagram.png") + await Bun.write(filePath, Uint8Array.of(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00)) + + const events = feed() + events.push(connected()) + const client = sdk({ streams: [events] }) + const ui = footer() + const transport = await createSessionTransport({ + sdk: client, + sessionID: "ses_1", + thinking: false, + limits: () => ({}), + footer: ui.api, + }) + let request: + | Parameters[0] + | undefined + // The generated method has conditional return types for throwOnError; this mock represents the successful branch. + // @ts-expect-error successful SDK response is valid for both modes at runtime + spyOn(client.v2.session, "prompt").mockImplementation((input) => { + request = input + queueMicrotask(() => { + events.push({ + id: "evt_prompted", + type: "session.next.prompted", + data: { + timestamp: 2, + sessionID: "ses_1", + messageID: "msg_prompt", + prompt: { text: input.prompt?.text ?? "" }, + delivery: "steer", + }, + }) + events.push({ + id: "evt_settled", + type: "session.next.execution.settled", + data: { timestamp: 3, sessionID: "ses_1", outcome: "success" }, + }) + }) + return ok({ + data: { + admittedSeq: 1, + id: input.id ?? "msg_prompt", + sessionID: "ses_1", + prompt: input.prompt ?? { text: "" }, + delivery: "steer" as const, + timeCreated: 2, + }, + }) + }) + + await transport.runPromptTurn({ + agent: undefined, + model: undefined, + variant: undefined, + prompt: { + messageID: "msg_prompt", + text: "Review @diagram.png", + parts: [ + { + type: "file", + url: pathToFileURL(filePath).href, + mime: "text/plain", + filename: "diagram.png", + source: { type: "file", path: "diagram.png", text: { start: 7, end: 19, value: "@diagram.png" } }, + }, + ], + }, + files: [], + includeFiles: true, + }) + + expect(request?.prompt?.text).toBe("Review @diagram.png") + expect(request?.prompt?.files).toEqual([ + { + name: "diagram.png", + uri: pathToFileURL(filePath).href, + source: { start: 7, end: 19, text: "@diagram.png" }, + }, + ]) + await transport.close() + }) + + test("shows V2 blockers and replies through the runtime-owned session API", async () => { + const events = feed() + events.push(connected()) + const client = sdk({ streams: [events] }) + const ui = footer() + const transport = await createSessionTransport({ + sdk: client, + sessionID: "ses_1", + thinking: false, + limits: () => ({}), + footer: ui.api, + }) + events.push({ + id: "evt_permission", + type: "permission.v2.asked", + data: { id: "per_1", sessionID: "ses_1", action: "read", resources: ["/tmp/file"] }, + }) + + await Bun.sleep(0) + expect(ui.events).toContainEqual({ + type: "stream.view", + view: { + type: "permission", + request: { + id: "per_1", + sessionID: "ses_1", + permission: "read", + patterns: ["/tmp/file"], + metadata: {}, + always: [], + tool: undefined, + }, + }, + }) + await transport.close() + }) + + test("rebootstraps after disconnect and completes a promoted turn from idle active state", async () => { + const first = feed() + const second = feed() + first.push(connected("evt_connected_1")) + second.push(connected("evt_connected_2")) + let running = true + const client = sdk({ + streams: [first, second], + active: () => { + const active: Record = {} + if (running) active.ses_1 = { type: "running" } + return active + }, + }) + let projected = false + spyOn(client.v2.session, "messages").mockImplementation(() => + ok({ + data: projected + ? [ + { + id: "msg_prompt", + type: "user", + text: "hello", + files: [], + agents: [], + time: { created: 2 }, + }, + ] + : [], + cursor: {}, + }), + ) + const ui = footer() + const transport = await createSessionTransport({ + sdk: client, + sessionID: "ses_1", + thinking: false, + limits: () => ({}), + footer: ui.api, + }) + let admitted = false + // The generated method has conditional return types for throwOnError; this mock represents the successful branch. + // @ts-expect-error successful SDK response is valid for both modes at runtime + spyOn(client.v2.session, "prompt").mockImplementation((request) => { + const messageID = request.id ?? "msg_prompt" + const prompt = request.prompt ?? { text: "" } + admitted = true + return ok({ data: { admittedSeq: 1, id: messageID, sessionID: "ses_1", prompt, delivery: "steer" as const, timeCreated: 2 } }) + }) + + const turn = transport.runPromptTurn({ + agent: undefined, + model: undefined, + variant: undefined, + prompt: { messageID: "msg_prompt", text: "hello", parts: [] }, + files: [], + includeFiles: true, + }) + while (!admitted) await Bun.sleep(0) + projected = true + running = false + first.close() + await turn + + expect(ui.events).toContainEqual({ type: "stream.patch", patch: { phase: "running", status: "reconnecting" } }) + expect(ui.events).toContainEqual({ type: "stream.patch", patch: { phase: "idle", status: "" } }) + await transport.close() + }) + + test("does not duplicate the optimistic user row when reconnect hydration recovers a missed prompt", async () => { + const first = feed() + const second = feed() + first.push(connected("evt_connected_1")) + second.push(connected("evt_connected_2")) + let running = true + let projected = false + const client = sdk({ + streams: [first, second], + active: () => { + const active: Record = {} + if (running) active.ses_1 = { type: "running" } + return active + }, + }) + spyOn(client.v2.session, "messages").mockImplementation(() => + ok({ + data: projected + ? [ + { + id: "msg_prompt", + type: "user", + text: "hello", + files: [], + agents: [], + time: { created: 2 }, + }, + ] + : [], + cursor: {}, + }), + ) + const ui = footer() + ui.commits.push({ kind: "user", source: "system", text: "hello", phase: "start", messageID: "msg_prompt" }) + const transport = await createSessionTransport({ + sdk: client, + sessionID: "ses_1", + thinking: false, + limits: () => ({}), + footer: ui.api, + }) + let admitted = false + // The generated method has conditional return types for throwOnError; this mock represents the successful branch. + // @ts-expect-error successful SDK response is valid for both modes at runtime + spyOn(client.v2.session, "prompt").mockImplementation((request) => { + const messageID = request.id ?? "msg_prompt" + const prompt = request.prompt ?? { text: "" } + admitted = true + return ok({ data: { admittedSeq: 1, id: messageID, sessionID: "ses_1", prompt, delivery: "steer" as const, timeCreated: 2 } }) + }) + + const turn = transport.runPromptTurn({ + agent: undefined, + model: undefined, + variant: undefined, + prompt: { messageID: "msg_prompt", text: "hello", parts: [] }, + files: [], + includeFiles: true, + }) + while (!admitted) await Bun.sleep(0) + projected = true + running = false + first.close() + await turn + + expect(ui.commits.filter((item) => item.kind === "user" && item.messageID === "msg_prompt")).toHaveLength(1) + await transport.close() + }) + + test("reconciles buffered deltas already present in a resize snapshot", async () => { + const events = feed() + events.push(connected()) + const client = sdk({ streams: [events] }) + const ui = footer() + const transport = await createSessionTransport({ + sdk: client, + sessionID: "ses_1", + thinking: false, + replay: true, + limits: () => ({}), + footer: ui.api, + }) + spyOn(client.v2.session, "messages").mockImplementation(() => + ok({ + data: [ + { + id: "msg_assistant", + type: "assistant", + agent: "build", + model: { providerID: "test", id: "model" }, + content: [{ type: "text", id: "txt_1", text: "the answer" }], + time: { created: 2, completed: 3 }, + }, + ], + cursor: {}, + }), + ) + let reset!: () => void + const resetting = new Promise((resolve) => { + reset = resolve + }) + const replay = transport.replayOnResize({ localRows: () => [], reset: () => resetting }) + events.push({ + id: "evt_text", + type: "session.next.text.delta", + data: { + timestamp: 3, + sessionID: "ses_1", + assistantMessageID: "msg_assistant", + textID: "txt_1", + delta: "answer", + }, + }) + await Bun.sleep(0) + reset() + await replay + + expect(ui.commits.filter((item) => item.text === "the answer")).toHaveLength(1) + expect(ui.commits.some((item) => item.text === "answer")).toBe(false) + await transport.close() + }) + + test("scopes repeated text and reasoning ids by assistant message", async () => { + const events = feed() + events.push(connected()) + const client = sdk({ streams: [events] }) + spyOn(client.v2.session, "messages").mockImplementation(() => + ok({ + data: [ + { + id: "msg_b", + type: "assistant", + agent: "build", + model: { providerID: "test", id: "model" }, + content: [ + { type: "reasoning", id: "reasoning-0", text: "second thought" }, + { type: "text", id: "text-0", text: "second answer" }, + ], + time: { created: 4, completed: 5 }, + }, + { + id: "msg_a", + type: "assistant", + agent: "build", + model: { providerID: "test", id: "model" }, + content: [ + { type: "reasoning", id: "reasoning-0", text: "first thought" }, + { type: "text", id: "text-0", text: "first answer" }, + ], + time: { created: 2, completed: 3 }, + }, + ], + cursor: {}, + }), + ) + + const ui = footer() + const transport = await createSessionTransport({ + sdk: client, + sessionID: "ses_1", + thinking: true, + replay: true, + limits: () => ({}), + footer: ui.api, + }) + + expect(ui.commits.map((item) => item.text)).toEqual([ + "Thinking: first thought", + "first answer", + "Thinking: second thought", + "second answer", + ]) + await transport.close() + }) + + test("renders full reasoning when only the ended event is observed", async () => { + const events = feed() + events.push(connected()) + const client = sdk({ streams: [events] }) + const ui = footer() + const transport = await createSessionTransport({ + sdk: client, + sessionID: "ses_1", + thinking: true, + limits: () => ({}), + footer: ui.api, + }) + events.push({ + id: "evt_reasoning", + type: "session.next.reasoning.ended", + data: { + timestamp: 3, + sessionID: "ses_1", + assistantMessageID: "msg_assistant", + reasoningID: "reasoning_1", + text: "considering", + }, + }) + await Bun.sleep(0) + + expect(ui.commits.at(-1)?.text).toBe("Thinking: considering") + await transport.close() + }) + + test("resolves an interrupted turn even when promotion never arrived", async () => { + const events = feed() + events.push(connected()) + const client = sdk({ + streams: [events], + active: () => ({ ses_1: { type: "running" } }), + }) + const ui = footer() + const transport = await createSessionTransport({ + sdk: client, + sessionID: "ses_1", + thinking: false, + limits: () => ({}), + footer: ui.api, + }) + let admitted = false + // The generated method has conditional return types for throwOnError; this mock represents the successful branch. + // @ts-expect-error successful SDK response is valid for both modes at runtime + spyOn(client.v2.session, "prompt").mockImplementation((request) => { + const messageID = request.id ?? "msg_prompt" + const prompt = request.prompt ?? { text: "" } + admitted = true + return ok({ data: { admittedSeq: 1, id: messageID, sessionID: "ses_1", prompt, delivery: "steer" as const, timeCreated: 2 } }) + }) + const interrupted = spyOn(client.v2.session, "interrupt").mockImplementation(() => ok(undefined)) + + const turn = transport.runPromptTurn({ + agent: undefined, + model: undefined, + variant: undefined, + prompt: { messageID: "msg_prompt", text: "hello", parts: [] }, + files: [], + includeFiles: true, + }) + while (!admitted) await Bun.sleep(0) + await transport.interruptActiveTurn() + events.push({ + id: "evt_settled", + type: "session.next.execution.settled", + data: { timestamp: 3, sessionID: "ses_1", outcome: "interrupted" }, + }) + await turn + + expect(interrupted).toHaveBeenCalledWith({ sessionID: "ses_1" }) + await transport.close() + }) + + test("falls back to the default model when selecting a variant on a fresh session", async () => { + const events = feed() + events.push(connected()) + const client = sdk({ streams: [events] }) + const ui = footer() + const transport = await createSessionTransport({ + sdk: client, + sessionID: "ses_1", + thinking: false, + limits: () => ({}), + footer: ui.api, + }) + // The generated method has conditional return types for throwOnError; the test only needs the nested model field. + // @ts-expect-error minimal session shape is enough for this lookup + spyOn(client.v2.session, "get").mockImplementation(() => ok({ data: { model: undefined } })) + spyOn(client.v2.model, "default").mockImplementation( + () => + ok({ + location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, + data: { id: "gpt-5", providerID: "openai" }, + }) as never, + ) + const switched = spyOn(client.v2.session, "switchModel").mockImplementation(() => ok(undefined)) + let admitted = false + // The generated method has conditional return types for throwOnError; this mock represents the successful branch. + // @ts-expect-error successful SDK response is valid for both modes at runtime + spyOn(client.v2.session, "prompt").mockImplementation((request) => { + const messageID = request.id ?? "msg_prompt" + const prompt = request.prompt ?? { text: "" } + admitted = true + return ok({ data: { admittedSeq: 1, id: messageID, sessionID: "ses_1", prompt, delivery: "steer" as const, timeCreated: 2 } }) + }) + + const turn = transport.runPromptTurn({ + agent: undefined, + model: undefined, + variant: "high", + prompt: { messageID: "msg_prompt", text: "hello", parts: [] }, + files: [], + includeFiles: true, + }) + while (!admitted) await Bun.sleep(0) + events.push({ + id: "evt_prompted", + type: "session.next.prompted", + data: { + timestamp: 2, + sessionID: "ses_1", + messageID: "msg_prompt", + prompt: { text: "hello" }, + delivery: "steer", + }, + }) + events.push({ + id: "evt_settled", + type: "session.next.execution.settled", + data: { timestamp: 3, sessionID: "ses_1", outcome: "success" }, + }) + await turn + + expect(switched).toHaveBeenCalledWith( + { sessionID: "ses_1", model: { providerID: "openai", id: "gpt-5", variant: "high" } }, + expect.objectContaining({ throwOnError: true }), + ) + await transport.close() + }) + + test("interrupts the current Session when an active turn is aborted", async () => { + const events = feed() + events.push(connected()) + const client = sdk({ streams: [events] }) + const ui = footer() + const transport = await createSessionTransport({ + sdk: client, + sessionID: "ses_1", + thinking: false, + limits: () => ({}), + footer: ui.api, + }) + let admitted = false + // The generated method has conditional return types for throwOnError; this mock represents the successful branch. + // @ts-expect-error successful SDK response is valid for both modes at runtime + spyOn(client.v2.session, "prompt").mockImplementation((request) => { + const messageID = request.id ?? "msg_prompt" + const prompt = request.prompt ?? { text: "" } + admitted = true + return ok({ data: { admittedSeq: 1, id: messageID, sessionID: "ses_1", prompt, delivery: "steer" as const, timeCreated: 2 } }) + }) + const interrupted = spyOn(client.v2.session, "interrupt").mockImplementation(() => ok(undefined)) + const controller = new AbortController() + const turn = transport.runPromptTurn({ + agent: undefined, + model: undefined, + variant: undefined, + prompt: { messageID: "msg_prompt", text: "hello", parts: [] }, + files: [], + includeFiles: true, + signal: controller.signal, + }) + while (!admitted) await Bun.sleep(0) + events.push({ + id: "evt_prompted", + type: "session.next.prompted", + data: { + timestamp: 2, + sessionID: "ses_1", + messageID: "msg_prompt", + prompt: { text: "hello" }, + delivery: "steer", + }, + }) + await Bun.sleep(0) + controller.abort() + events.push({ + id: "evt_settled", + type: "session.next.execution.settled", + data: { timestamp: 3, sessionID: "ses_1", outcome: "interrupted" }, + }) + await turn + + expect(interrupted).toHaveBeenCalledWith({ sessionID: "ses_1" }) + await transport.close() + }) + + test("discovers a live child session and tracks its tab and selected detail", async () => { + const events = feed() + events.push(connected()) + const client = sdk({ + streams: [events], + messages: { + ses_child: [ + { + id: "msg_task", + type: "user" as const, + text: "task prompt", + files: [], + agents: [], + time: { created: 1 }, + }, + ], + }, + }) + spyOn(client.v2.session, "get").mockImplementation(() => + ok({ + data: { + id: "ses_child", + parentID: "ses_1", + projectID: "proj_1", + agent: "explore", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1 }, + title: "Find files", + location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, + }, + }), + ) + const ui = footer() + const transport = await createSessionTransport({ + sdk: client, + sessionID: "ses_1", + thinking: false, + limits: () => ({}), + footer: ui.api, + }) + const states = () => + ui.events.flatMap((event) => (event.type === "stream.subagent" ? [event.state] : [])) + transport.selectSubagent("ses_child") + + events.push({ + id: "evt_child_step", + type: "session.next.step.started", + data: { + timestamp: 2, + sessionID: "ses_child", + assistantMessageID: "msg_child_a", + agent: "explore", + model: { providerID: "test", id: "model" }, + }, + }) + while (!states().some((state) => state.details.ses_child?.commits.some((item) => item.text === "task prompt"))) + await Bun.sleep(0) + expect(states().at(-1)?.tabs).toMatchObject([ + { sessionID: "ses_child", label: "Explore", title: "Find files", status: "running" }, + ]) + + events.push({ + id: "evt_child_text", + type: "session.next.text.delta", + data: { + timestamp: 3, + sessionID: "ses_child", + assistantMessageID: "msg_child_a", + textID: "txt_child", + delta: "child answer", + }, + }) + while (!states().some((state) => state.details.ses_child?.commits.some((item) => item.text === "child answer"))) + await Bun.sleep(0) + + events.push({ + id: "evt_child_settled", + type: "session.next.execution.settled", + data: { timestamp: 4, sessionID: "ses_child", outcome: "success" }, + }) + while (!states().some((state) => state.tabs.some((tab) => tab.status === "completed"))) await Bun.sleep(0) + await transport.close() + }) + + test("keeps child terminal state observed during discovery", async () => { + const events = feed() + events.push(connected()) + const client = sdk({ streams: [events] }) + let resolveGet: (() => void) | undefined + const gate = new Promise((resolve) => { + resolveGet = resolve + }) + spyOn(client.v2.session, "get").mockImplementation(async () => { + await gate + return ok({ + data: { + id: "ses_child", + parentID: "ses_1", + projectID: "proj_1", + agent: "explore", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1 }, + title: "Find files", + location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, + }, + }) + }) + const ui = footer() + const transport = await createSessionTransport({ + sdk: client, + sessionID: "ses_1", + thinking: false, + limits: () => ({}), + footer: ui.api, + }) + const states = () => + ui.events.flatMap((event) => (event.type === "stream.subagent" ? [event.state] : [])) + + // Both events arrive while session.get is still in flight. + events.push({ + id: "evt_child_step", + type: "session.next.step.started", + data: { + timestamp: 2, + sessionID: "ses_child", + assistantMessageID: "msg_child_a", + agent: "explore", + model: { providerID: "test", id: "model" }, + }, + }) + events.push({ + id: "evt_child_settled", + type: "session.next.execution.settled", + data: { timestamp: 3, sessionID: "ses_child", outcome: "interrupted" }, + }) + await Bun.sleep(0) + resolveGet?.() + while (!states().some((state) => state.tabs.some((tab) => tab.status === "cancelled"))) await Bun.sleep(0) + await transport.close() + }) + + test("does not resurrect a settled child from stale discovery buffer", async () => { + const events = feed() + events.push(connected()) + const client = sdk({ streams: [events] }) + let resolveGet: (() => void) | undefined + const gate = new Promise((resolve) => { + resolveGet = resolve + }) + spyOn(client.v2.session, "get").mockImplementation(async () => { + await gate + return ok({ + data: { + id: "ses_child", + parentID: "ses_1", + projectID: "proj_1", + agent: "explore", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1 }, + title: "Find files", + location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, + }, + }) + }) + const ui = footer() + const transport = await createSessionTransport({ + sdk: client, + sessionID: "ses_1", + thinking: false, + limits: () => ({}), + footer: ui.api, + }) + const states = () => + ui.events.flatMap((event) => (event.type === "stream.subagent" ? [event.state] : [])) + + // Child event arrives first and gets buffered behind the gated session.get. + events.push({ + id: "evt_child_step", + type: "session.next.step.started", + data: { + timestamp: 2, + sessionID: "ses_child", + assistantMessageID: "msg_child_a", + agent: "explore", + model: { providerID: "test", id: "model" }, + }, + }) + // Parent's background subagent tool.success adopts the child mid-discovery. + events.push({ + id: "evt_parent_call", + type: "session.next.tool.called", + data: { + timestamp: 3, + sessionID: "ses_1", + assistantMessageID: "msg_parent_a", + callID: "call_sub", + tool: "subagent", + input: { agent: "explore", description: "Find things", prompt: "go", background: true }, + provider: { executed: true }, + }, + }) + events.push({ + id: "evt_parent_success", + type: "session.next.tool.success", + data: { + timestamp: 4, + sessionID: "ses_1", + assistantMessageID: "msg_parent_a", + callID: "call_sub", + structured: { sessionID: "ses_child", status: "running", output: "" }, + content: [], + provider: { executed: true }, + }, + }) + // The settled event arrives after adoption, so it applies directly. + events.push({ + id: "evt_child_settled", + type: "session.next.execution.settled", + data: { timestamp: 5, sessionID: "ses_child", outcome: "interrupted" }, + }) + while (!states().some((state) => state.tabs.some((tab) => tab.status === "cancelled"))) await Bun.sleep(0) + + // Resolving discovery must not replay the buffered step.started over the + // terminal status. + const before = states().length + resolveGet?.() + while (states().length === before) await Bun.sleep(0) + await Bun.sleep(0) + await Bun.sleep(0) + expect(states().at(-1)?.tabs).toMatchObject([{ sessionID: "ses_child", status: "cancelled" }]) + await transport.close() + }) + + test("adopts historical children from the session family list", async () => { + const events = feed() + events.push(connected()) + const client = sdk({ + streams: [events], + sessions: [ + { id: "ses_child_old", parentID: "ses_1", title: "Earlier subagent", agent: "explore", time: { updated: 9 } }, + { id: "ses_unrelated", title: "Different session", time: { updated: 5 } }, + { id: "ses_sibling", parentID: "ses_2", title: "Someone else's child", time: { updated: 4 } }, + ], + }) + const ui = footer() + const transport = await createSessionTransport({ + sdk: client, + sessionID: "ses_1", + thinking: false, + limits: () => ({}), + footer: ui.api, + }) + const states = ui.events.flatMap((event) => (event.type === "stream.subagent" ? [event.state] : [])) + expect(states.at(-1)?.tabs).toMatchObject([ + { + sessionID: "ses_child_old", + label: "Explore", + title: "Earlier subagent", + status: "completed", + }, + ]) + await transport.close() + }) + + test("hydrates completed subagent children from projected tool output", async () => { + const events = feed() + events.push(connected()) + const client = sdk({ + streams: [events], + messages: { + ses_1: [ + { + id: "msg_parent", + type: "assistant" as const, + agent: "build", + model: { providerID: "test", id: "model" }, + time: { created: 1, completed: 3 }, + content: [ + { + type: "tool" as const, + id: "call_sub", + name: "subagent", + state: { + status: "completed" as const, + input: { agent: "explore", description: "Find things", prompt: "go" }, + content: [{ type: "text" as const, text: "done" }], + structured: { sessionID: "ses_child", status: "completed", output: "done" }, + }, + time: { created: 1, ran: 1, completed: 2 }, + }, + ], + }, + ], + }, + }) + const ui = footer() + const transport = await createSessionTransport({ + sdk: client, + sessionID: "ses_1", + thinking: false, + limits: () => ({}), + footer: ui.api, + }) + const states = ui.events.flatMap((event) => (event.type === "stream.subagent" ? [event.state] : [])) + expect(states.at(-1)?.tabs).toMatchObject([ + { + sessionID: "ses_child", + label: "Explore", + description: "Find things", + status: "completed", + toolCalls: undefined, + }, + ]) + await transport.close() + }) +}) diff --git a/packages/opencode/test/cli/run/stream.transport.test.ts b/packages/opencode/test/cli/run/stream.transport.test.ts deleted file mode 100644 index 5bb578447f..0000000000 --- a/packages/opencode/test/cli/run/stream.transport.test.ts +++ /dev/null @@ -1,2363 +0,0 @@ -import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" -import { OpencodeClient, type GlobalEvent } from "@opencode-ai/sdk/v2" -import { createSessionTransport } from "@/cli/cmd/run/stream.transport" -import type { FooterApi, FooterEvent, LocalReplayRow, RunFilePart, StreamCommit } from "@/cli/cmd/run/types" - -type EventStream = Awaited>["stream"] -type GlobalEventStream = Awaited>["stream"] -type SdkEvent = EventStream extends AsyncGenerator ? T : never -type SessionMessage = NonNullable>["data"]>[number] -type SessionChild = NonNullable>["data"]>[number] -type SessionToolPart = Extract -type SessionStatusMap = NonNullable>["data"]> -type TextPart = Extract -type ReasoningPart = Extract - -afterEach(() => { - mock.restore() -}) - -function defer() { - let resolve!: (value: T | PromiseLike) => void - let reject!: (error?: unknown) => void - const promise = new Promise((next, fail) => { - resolve = next - reject = fail - }) - - return { promise, resolve, reject } -} - -async function waitFor(check: () => T | undefined, timeout = 1_000): Promise { - const end = Date.now() + timeout - while (Date.now() < end) { - const value = check() - if (value !== undefined) { - return value - } - - await Bun.sleep(10) - } - - throw new Error("timed out waiting for value") -} - -function busy(sessionID = "session-1") { - return { - id: `evt-${sessionID}-busy`, - type: "session.status", - properties: { - sessionID, - status: { - type: "busy", - }, - }, - } satisfies SdkEvent -} - -function idle(sessionID = "session-1") { - return { - id: `evt-${sessionID}-idle`, - type: "session.status", - properties: { - sessionID, - status: { - type: "idle", - }, - }, - } satisfies SdkEvent -} - -function retry(sessionID: string, attempt: number, message: string) { - return { - id: `evt-${sessionID}-retry-${attempt}`, - type: "session.status", - properties: { - sessionID, - status: { - type: "retry", - attempt, - message, - next: 1, - }, - }, - } satisfies SdkEvent -} - -function assistant(id: string) { - return { - id: `evt-${id}`, - type: "message.updated", - properties: { - sessionID: "session-1", - info: assistantMessage({ - sessionID: "session-1", - id, - parts: [], - }).info, - }, - } satisfies SdkEvent -} - -const StreamClosed = undefined as never - -function feed(returnValue: R = StreamClosed) { - const list: T[] = [] - let done = false - let wake: (() => void) | undefined - - const wrapped = (async function* (): AsyncGenerator { - while (!done || list.length > 0) { - if (list.length === 0) { - await new Promise((resolve) => { - wake = resolve - }) - continue - } - - const next = list.shift() - if (!next) { - continue - } - - yield next - } - return returnValue as R - })() - - return { - stream: wrapped, - push(value: T) { - list.push(value) - wake?.() - wake = undefined - }, - close() { - done = true - wake?.() - wake = undefined - }, - } -} - -function eventFeed() { - return feed() -} - -function globalFeed() { - return feed() -} - -function emptyStream(): EventStream { - return (async function* (): AsyncGenerator {})() -} - -function ok(data: T) { - return Promise.resolve({ - data, - error: undefined, - request: new Request("https://opencode.test"), - response: new Response(), - }) -} - -function sse(stream: EventStream) { - return Promise.resolve({ stream }) -} - -function globalSse(stream: GlobalEventStream) { - return Promise.resolve({ stream }) -} - -function wrapGlobalStream(stream: EventStream): GlobalEventStream { - return (async function* (): GlobalEventStream { - for await (const event of stream) { - yield globalEvent(event) - } - return StreamClosed - })() -} - -function statusMap(busy: boolean): SessionStatusMap { - if (busy) { - return { "session-1": { type: "busy" } } - } - - return {} -} - -function assistantMessage(input: { sessionID: string; id: string; parts: SessionMessage["parts"] }): SessionMessage { - return { - info: { - id: input.id, - sessionID: input.sessionID, - role: "assistant", - time: { - created: 1, - }, - parentID: "msg-user-1", - modelID: "gpt-5", - providerID: "openai", - mode: "chat", - agent: "build", - path: { - cwd: "/tmp", - root: "/tmp", - }, - cost: 0, - tokens: { - input: 1, - output: 1, - reasoning: 0, - cache: { - read: 0, - write: 0, - }, - }, - }, - parts: input.parts, - } -} - -function runningTool(input: { - sessionID: string - messageID: string - id: string - callID: string - tool: string - body: Record - metadata?: Record -}): SessionToolPart { - return { - id: input.id, - sessionID: input.sessionID, - messageID: input.messageID, - type: "tool", - callID: input.callID, - tool: input.tool, - state: { - status: "running", - input: input.body, - ...(input.metadata ? { metadata: input.metadata } : {}), - time: { - start: 1, - }, - }, - } -} - -function completedTool(input: { - sessionID: string - messageID: string - id: string - callID: string - tool: string - body: Record - output?: string - metadata?: Record -}): SessionToolPart { - return { - id: input.id, - sessionID: input.sessionID, - messageID: input.messageID, - type: "tool", - callID: input.callID, - tool: input.tool, - state: { - status: "completed", - input: input.body, - output: input.output ?? "", - title: input.tool, - metadata: input.metadata ?? {}, - time: { - start: 1, - end: 2, - }, - }, - } -} - -function textPart(id: string, messageID: string, text: string, sessionID = "session-1"): TextPart { - return { - id, - sessionID, - messageID, - type: "text", - text, - } -} - -function textUpdated(part: TextPart): SdkEvent { - return { - id: `evt-${part.id}-updated`, - type: "message.part.updated", - properties: { - sessionID: part.sessionID, - part, - time: 1, - }, - } -} - -function reasoningPart(id: string, messageID: string, text: string): ReasoningPart { - return { - id, - sessionID: "session-1", - messageID, - type: "reasoning", - text, - time: { start: 1 }, - } -} - -function reasoningUpdated(part: ReasoningPart): SdkEvent { - return { - id: `evt-${part.id}-updated`, - type: "message.part.updated", - properties: { - sessionID: part.sessionID, - part, - time: 1, - }, - } -} - -function toolUpdated(part: SessionToolPart): SdkEvent { - return { - id: `evt-${part.id}-updated`, - type: "message.part.updated", - properties: { - sessionID: part.sessionID, - part, - time: 1, - }, - } -} - -function textDelta(messageID: string, partID: string, delta: string, sessionID = "session-1"): SdkEvent { - return { - id: `evt-${partID}-delta`, - type: "message.part.delta", - properties: { - sessionID, - messageID, - partID, - field: "text", - delta, - }, - } -} - -function child(id: string): SessionChild { - return { - id, - slug: id, - projectID: "project-1", - directory: "/tmp", - title: id, - version: "1", - time: { - created: 1, - updated: 1, - }, - } -} - -function globalEvent(payload: GlobalEvent["payload"]): GlobalEvent { - return { - directory: "/tmp", - project: "project-1", - payload, - } -} - -function footer(fn?: (commit: StreamCommit) => void) { - const commits: StreamCommit[] = [] - const events: FooterEvent[] = [] - let closed = false - let idleCalls = 0 - - const api: FooterApi = { - get isClosed() { - return closed - }, - onPrompt: () => () => {}, - onQueuedRemove: () => () => {}, - onClose: () => () => {}, - event(next) { - events.push(next) - }, - append(next) { - commits.push(next) - fn?.(next) - }, - idle() { - idleCalls += 1 - return Promise.resolve() - }, - close() { - closed = true - }, - destroy() { - closed = true - }, - } - - return { - api, - commits, - events, - get idleCalls() { - return idleCalls - }, - } -} - -function sdk( - input: { - stream?: EventStream - globalStream?: GlobalEventStream - subscribe?: OpencodeClient["event"]["subscribe"] - globalEvent?: OpencodeClient["global"]["event"] - promptAsync?: OpencodeClient["session"]["promptAsync"] - status?: OpencodeClient["session"]["status"] - messages?: OpencodeClient["session"]["messages"] - children?: OpencodeClient["session"]["children"] - permissions?: OpencodeClient["permission"]["list"] - questions?: OpencodeClient["question"]["list"] - } = {}, -) { - const client = new OpencodeClient() - - const subscribe: OpencodeClient["event"]["subscribe"] = input.subscribe ?? (() => sse(input.stream ?? emptyStream())) - const globalEvent: OpencodeClient["global"]["event"] = - input.globalEvent ?? (() => globalSse(input.globalStream ?? wrapGlobalStream(input.stream ?? emptyStream()))) - const promptAsync: OpencodeClient["session"]["promptAsync"] = input.promptAsync ?? (() => ok(undefined)) - const status: OpencodeClient["session"]["status"] = input.status ?? (() => ok({})) - const messages: OpencodeClient["session"]["messages"] = input.messages ?? (() => ok([])) - const children: OpencodeClient["session"]["children"] = input.children ?? (() => ok([])) - const permissions: OpencodeClient["permission"]["list"] = input.permissions ?? (() => ok([])) - const questions: OpencodeClient["question"]["list"] = input.questions ?? (() => ok([])) - - spyOn(client.event, "subscribe").mockImplementation(subscribe) - spyOn(client.global, "event").mockImplementation(globalEvent) - spyOn(client.session, "promptAsync").mockImplementation(promptAsync) - spyOn(client.session, "status").mockImplementation(status) - spyOn(client.session, "messages").mockImplementation(messages) - spyOn(client.session, "children").mockImplementation(children) - spyOn(client.permission, "list").mockImplementation(permissions) - spyOn(client.question, "list").mockImplementation(questions) - - return client -} - -describe("run stream transport", () => { - test("does not replay persisted main-session history during bootstrap by default", async () => { - const src = eventFeed() - const ui = footer() - const transport = await createSessionTransport({ - sdk: sdk({ - stream: src.stream, - messages: async ({ sessionID }) => - sessionID === "session-1" - ? ok([ - assistantMessage({ - sessionID: "session-1", - id: "msg-1", - parts: [ - { - ...textPart("text-1", "msg-1", "Hello."), - time: { - start: 1, - end: 2, - }, - }, - ], - }), - ]) - : ok([]), - }), - sessionID: "session-1", - thinking: true, - limits: () => ({}), - footer: ui.api, - }) - - try { - expect(ui.commits).toEqual([]) - expect(ui.idleCalls).toBe(0) - } finally { - src.close() - await transport.close() - } - }) - - test("replays persisted main-session history during bootstrap when enabled", async () => { - const src = eventFeed() - const ui = footer() - const transport = await createSessionTransport({ - sdk: sdk({ - stream: src.stream, - messages: async ({ sessionID }) => - sessionID === "session-1" - ? ok([ - assistantMessage({ - sessionID: "session-1", - id: "msg-1", - parts: [ - { - ...textPart("text-1", "msg-1", "Hello."), - time: { - start: 1, - end: 2, - }, - }, - ], - }), - ]) - : ok([]), - }), - sessionID: "session-1", - thinking: true, - replay: true, - limits: () => ({}), - footer: ui.api, - }) - - try { - await waitFor(() => ui.commits.find((item) => item.kind === "assistant" && item.text === "Hello.")) - expect(ui.idleCalls).toBeGreaterThan(0) - } finally { - src.close() - await transport.close() - } - }) - - test("caps replayed bootstrap history to the configured number of messages", async () => { - const src = eventFeed() - const ui = footer() - const transport = await createSessionTransport({ - sdk: sdk({ - stream: src.stream, - messages: async ({ sessionID }) => - ok( - sessionID === "session-1" - ? [ - assistantMessage({ - sessionID: "session-1", - id: "msg-1", - parts: [ - { - ...textPart("text-1", "msg-1", "Hello."), - time: { - start: 1, - end: 2, - }, - }, - ], - }), - assistantMessage({ - sessionID: "session-1", - id: "msg-2", - parts: [ - { - ...textPart("text-2", "msg-2", "World."), - time: { - start: 3, - end: 4, - }, - }, - ], - }), - ] - : [], - ), - }), - sessionID: "session-1", - thinking: true, - replay: true, - replayLimit: 1, - limits: () => ({}), - footer: ui.api, - }) - - try { - await waitFor(() => (ui.commits.length > 0 ? ui.commits : undefined)) - expect(ui.commits.filter((item) => item.kind === "assistant")).toEqual([ - expect.objectContaining({ - text: "World.", - }), - ]) - } finally { - src.close() - await transport.close() - } - }) - - test("skips buffered pre-bootstrap deltas already covered by replay history", async () => { - const src = eventFeed() - const ui = footer() - const gate = defer() - let transport: Awaited> | undefined - const task = createSessionTransport({ - sdk: sdk({ - stream: src.stream, - messages: async ({ sessionID }) => { - if (sessionID !== "session-1") { - return ok([]) - } - - await gate.promise - return ok([ - assistantMessage({ - sessionID: "session-1", - id: "msg-1", - parts: [textPart("text-1", "msg-1", "Hello")], - }), - ]) - }, - }), - sessionID: "session-1", - thinking: true, - replay: true, - limits: () => ({}), - footer: ui.api, - }) - - try { - await Promise.resolve() - src.push(textDelta("msg-1", "text-1", "lo")) - gate.resolve() - transport = await task - - await waitFor(() => (ui.commits.length > 0 ? ui.commits : undefined)) - await Bun.sleep(20) - expect(ui.commits.filter((item) => item.kind === "assistant")).toEqual([ - expect.objectContaining({ - text: "Hello", - }), - ]) - } finally { - src.close() - await transport?.close() - } - }) - - test("applies buffered pre-bootstrap deltas not yet persisted", async () => { - const src = eventFeed() - const ui = footer() - const gate = defer() - let transport: Awaited> | undefined - const task = createSessionTransport({ - sdk: sdk({ - stream: src.stream, - messages: async ({ sessionID }) => { - if (sessionID !== "session-1") { - return ok([]) - } - - await gate.promise - return ok([ - assistantMessage({ - sessionID: "session-1", - id: "msg-1", - parts: [textPart("text-1", "msg-1", "")], - }), - ]) - }, - }), - sessionID: "session-1", - thinking: true, - replay: true, - limits: () => ({}), - footer: ui.api, - }) - - try { - await Promise.resolve() - src.push(textDelta("msg-1", "text-1", "Hello")) - gate.resolve() - transport = await task - - await waitFor(() => (ui.commits.length > 0 ? ui.commits : undefined)) - await Bun.sleep(20) - expect(ui.commits.filter((item) => item.kind === "assistant")).toEqual([ - expect.objectContaining({ - text: "Hello", - }), - ]) - } finally { - src.close() - await transport?.close() - } - }) - - test("preserves running footer state for resumed active sessions", async () => { - const src = eventFeed() - const ui = footer() - const transport = await createSessionTransport({ - sdk: sdk({ - stream: src.stream, - messages: async ({ sessionID }) => - sessionID === "session-1" - ? ok([ - assistantMessage({ - sessionID: "session-1", - id: "msg-1", - parts: [ - runningTool({ - sessionID: "session-1", - messageID: "msg-1", - id: "bash-1", - callID: "call-1", - tool: "bash", - body: { - command: "pwd", - }, - }), - ], - }), - ]) - : ok([]), - }), - sessionID: "session-1", - thinking: true, - replay: true, - limits: () => ({}), - footer: ui.api, - }) - - try { - const patch = await waitFor(() => { - const item = ui.events.findLast((event) => event.type === "stream.patch") - return item?.type === "stream.patch" ? item.patch : undefined - }) - - expect(patch).toEqual( - expect.objectContaining({ - phase: "running", - status: "running bash", - }), - ) - } finally { - src.close() - await transport.close() - } - }) - - test("rebuilds session output on resize and continues live deltas from replayed state", async () => { - const src = eventFeed() - const ui = footer() - let calls = 0 - const transport = await createSessionTransport({ - sdk: sdk({ - stream: src.stream, - messages: async () => { - calls += 1 - if (calls === 1) { - return ok([]) - } - - return ok([ - assistantMessage({ - sessionID: "session-1", - id: "msg-1", - parts: [textPart("text-1", "msg-1", "Hello")], - }), - ]) - }, - }), - sessionID: "session-1", - thinking: true, - replay: true, - limits: () => ({}), - footer: ui.api, - }) - const localRows: LocalReplayRow[] = [ - { commit: { kind: "user", text: "pending prompt", phase: "start", source: "system", messageID: "msg-pending" } }, - ] - const reset = mock(() => { - localRows.push({ - commit: { - kind: "user", - text: "sent during reset", - phase: "start", - source: "system", - messageID: "msg-during-reset", - }, - }) - return Promise.resolve() - }) - - try { - expect( - await transport.replayOnResize({ - localRows: () => localRows, - reset, - }), - ).toBe(true) - expect(reset).toHaveBeenCalledTimes(1) - expect(ui.commits).toEqual( - expect.arrayContaining([ - expect.objectContaining({ kind: "assistant", text: "Hello" }), - expect.objectContaining({ kind: "user", text: "sent during reset", messageID: "msg-during-reset" }), - ]), - ) - - src.push(textUpdated(textPart("text-1", "msg-1", "Hello world"))) - await waitFor(() => ui.commits.find((commit) => commit.kind === "assistant" && commit.text === " world")) - expect(ui.commits.filter((commit) => commit.kind === "assistant").map((commit) => commit.text)).toEqual([ - "Hello", - " world", - ]) - } finally { - src.close() - await transport.close() - } - }) - - test("coalesces active resize requests into one trailing replay", async () => { - const src = eventFeed() - const ui = footer() - const firstReset = defer() - const resetA = mock(() => firstReset.promise) - const resetB = mock(() => Promise.resolve()) - const resetC = mock(() => Promise.resolve()) - const transport = await createSessionTransport({ - sdk: sdk({ stream: src.stream }), - sessionID: "session-1", - thinking: true, - replay: true, - limits: () => ({}), - footer: ui.api, - }) - - try { - const active = transport.replayOnResize({ localRows: () => [], reset: resetA }) - await waitFor(() => (resetA.mock.calls.length === 1 ? true : undefined)) - - expect(await transport.replayOnResize({ localRows: () => [], reset: resetB })).toBe(false) - expect(await transport.replayOnResize({ localRows: () => [], reset: resetC })).toBe(false) - expect(resetB).not.toHaveBeenCalled() - - firstReset.resolve() - expect(await active).toBe(true) - expect(resetA).toHaveBeenCalledTimes(1) - expect(resetB).not.toHaveBeenCalled() - expect(resetC).toHaveBeenCalledTimes(1) - } finally { - src.close() - await transport.close() - } - }) - - test("keeps coalescing resize requests while buffered events drain", async () => { - const src = eventFeed() - const ui = footer() - const firstReset = defer() - const statusGate = defer() - const statusStarted = defer() - let blockStatus = false - const trace = mock((_type: string, _data?: unknown) => {}) - const resetA = mock(() => firstReset.promise) - const resetB = mock(() => Promise.resolve()) - const resetC = mock(() => Promise.resolve()) - const transport = await createSessionTransport({ - sdk: sdk({ - stream: src.stream, - status: async () => { - if (blockStatus) { - statusStarted.resolve() - await statusGate.promise - } - return ok(statusMap(true)) - }, - }), - sessionID: "session-1", - thinking: true, - replay: true, - limits: () => ({}), - footer: ui.api, - trace: { write: trace }, - }) - const turn = transport.runPromptTurn({ - agent: undefined, - model: undefined, - variant: undefined, - prompt: { text: "active", parts: [] }, - files: [], - includeFiles: false, - }) - - try { - await waitFor(() => ui.events.find((event) => event.type === "turn.wait")) - const active = transport.replayOnResize({ localRows: () => [], reset: resetA }) - await waitFor(() => (resetA.mock.calls.length === 1 ? true : undefined)) - blockStatus = true - src.push(busy()) - src.push(idle()) - await waitFor(() => (trace.mock.calls.filter((call) => call[0] === "recv.event").length >= 2 ? true : undefined)) - - expect(await transport.replayOnResize({ localRows: () => [], reset: resetB })).toBe(false) - firstReset.resolve() - await Promise.race([ - statusStarted.promise, - Bun.sleep(1_000).then(() => { - throw new Error("timed out waiting for buffered status drain") - }), - ]) - - expect(await transport.replayOnResize({ localRows: () => [], reset: resetC })).toBe(false) - expect(resetC).not.toHaveBeenCalled() - blockStatus = false - statusGate.resolve() - - expect( - await Promise.race([ - active, - Bun.sleep(1_000).then(() => { - throw new Error("timed out waiting for trailing resize replay") - }), - ]), - ).toBe(true) - expect(resetB).not.toHaveBeenCalled() - expect(resetC).toHaveBeenCalledTimes(1) - } finally { - src.close() - await transport.close() - await turn - } - }) - - test("preserves assistant deltas not yet persisted when replaying during a live stream", async () => { - const src = eventFeed() - const ui = footer() - let calls = 0 - const transport = await createSessionTransport({ - sdk: sdk({ - stream: src.stream, - messages: async () => { - calls += 1 - if (calls === 1) { - return ok([]) - } - - return ok([ - assistantMessage({ - sessionID: "session-1", - id: "msg-live", - parts: [textPart("text-live", "msg-live", "")], - }), - ]) - }, - }), - sessionID: "session-1", - thinking: true, - replay: true, - limits: () => ({}), - footer: ui.api, - }) - - try { - src.push(assistant("msg-live")) - src.push(textUpdated(textPart("text-live", "msg-live", ""))) - src.push(textDelta("msg-live", "text-live", "Hello")) - await waitFor(() => ui.commits.find((commit) => commit.kind === "assistant" && commit.text === "Hello")) - ui.commits.length = 0 - - expect(await transport.replayOnResize({ localRows: () => [], reset: () => Promise.resolve() })).toBe(true) - src.push(textDelta("msg-live", "text-live", "Hello")) - src.push( - textUpdated({ - ...textPart("text-live", "msg-live", "HelloHello"), - time: { start: 1, end: 2 }, - }), - ) - - await waitFor(() => - ui.commits.filter((commit) => commit.kind === "assistant" && commit.text === "Hello").length === 2 - ? true - : undefined, - ) - expect( - ui.commits.filter((commit) => commit.kind === "assistant" && commit.text).map((commit) => commit.text), - ).toEqual(["Hello", "Hello"]) - } finally { - src.close() - await transport.close() - } - }) - - test("preserves the display prefix for active reasoning restored during replay", async () => { - const src = eventFeed() - const ui = footer() - let calls = 0 - const transport = await createSessionTransport({ - sdk: sdk({ - stream: src.stream, - messages: async () => { - calls += 1 - if (calls === 1) { - return ok([]) - } - - return ok([ - assistantMessage({ - sessionID: "session-1", - id: "msg-thinking", - parts: [reasoningPart("thinking-1", "msg-thinking", "")], - }), - ]) - }, - }), - sessionID: "session-1", - thinking: true, - replay: true, - limits: () => ({}), - footer: ui.api, - }) - - try { - src.push(assistant("msg-thinking")) - src.push(reasoningUpdated(reasoningPart("thinking-1", "msg-thinking", ""))) - src.push(textDelta("msg-thinking", "thinking-1", "plan")) - await waitFor(() => ui.commits.find((commit) => commit.kind === "reasoning" && commit.text === "Thinking: plan")) - ui.commits.length = 0 - - expect(await transport.replayOnResize({ localRows: () => [], reset: () => Promise.resolve() })).toBe(true) - expect(ui.commits.filter((commit) => commit.kind === "reasoning").map((commit) => commit.text)).toEqual([ - "Thinking: plan", - ]) - } finally { - src.close() - await transport.close() - } - }) - - test("does not overlay stale active text when persistence completes during replay", async () => { - const src = eventFeed() - const ui = footer() - let calls = 0 - const transport = await createSessionTransport({ - sdk: sdk({ - stream: src.stream, - messages: async () => { - calls += 1 - if (calls === 1) { - return ok([]) - } - - return ok([ - assistantMessage({ - sessionID: "session-1", - id: "msg-finished", - parts: [ - { - ...textPart("text-finished", "msg-finished", "Hello"), - time: { start: 1, end: 2 }, - }, - ], - }), - ]) - }, - }), - sessionID: "session-1", - thinking: true, - replay: true, - limits: () => ({}), - footer: ui.api, - }) - - try { - src.push(assistant("msg-finished")) - src.push(textUpdated(textPart("text-finished", "msg-finished", ""))) - src.push(textDelta("msg-finished", "text-finished", "Hello")) - await waitFor(() => ui.commits.find((commit) => commit.kind === "assistant" && commit.text === "Hello")) - ui.commits.length = 0 - - expect(await transport.replayOnResize({ localRows: () => [], reset: () => Promise.resolve() })).toBe(true) - expect( - ui.commits.filter((commit) => commit.kind === "assistant" && commit.text).map((commit) => commit.text), - ).toEqual(["Hello"]) - } finally { - src.close() - await transport.close() - } - }) - - test("does not clear the terminal when resize replay snapshot fetch fails", async () => { - const src = eventFeed() - const ui = footer() - let calls = 0 - const transport = await createSessionTransport({ - sdk: sdk({ - stream: src.stream, - messages: async () => { - calls += 1 - if (calls === 1) { - return ok([]) - } - - throw new Error("snapshot failed") - }, - }), - sessionID: "session-1", - thinking: true, - replay: true, - limits: () => ({}), - footer: ui.api, - }) - const reset = mock(() => Promise.resolve()) - - try { - expect(await transport.replayOnResize({ localRows: () => [], reset })).toBe(false) - expect(reset).not.toHaveBeenCalled() - expect(ui.commits).toEqual([]) - } finally { - src.close() - await transport.close() - } - }) - - test("disables resize replay for the session after terminal reset fails", async () => { - const src = eventFeed() - const ui = footer() - const transport = await createSessionTransport({ - sdk: sdk({ stream: src.stream }), - sessionID: "session-1", - thinking: true, - replay: true, - limits: () => ({}), - footer: ui.api, - }) - const reset = mock(() => Promise.reject(new Error("clear failed"))) - - try { - expect(await transport.replayOnResize({ localRows: () => [], reset })).toBe(false) - expect(await transport.replayOnResize({ localRows: () => [], reset })).toBe(false) - expect(reset).toHaveBeenCalledTimes(1) - expect(ui.commits).toContainEqual({ - kind: "error", - text: "resize replay failed; disabled for this session", - phase: "start", - source: "system", - }) - } finally { - src.close() - await transport.close() - } - }) - - test("disables resize replay when rebuilding scrollback fails after terminal reset", async () => { - const src = eventFeed() - const ui = footer() - let cleared = false - const idle = ui.api.idle - ui.api.idle = () => (cleared ? Promise.reject(new Error("render failed")) : idle()) - const transport = await createSessionTransport({ - sdk: sdk({ stream: src.stream }), - sessionID: "session-1", - thinking: true, - replay: true, - limits: () => ({}), - footer: ui.api, - }) - const reset = mock(() => { - cleared = true - return Promise.resolve() - }) - - try { - expect(await transport.replayOnResize({ localRows: () => [], reset })).toBe(false) - expect(await transport.replayOnResize({ localRows: () => [], reset })).toBe(false) - expect(reset).toHaveBeenCalledTimes(1) - expect(ui.commits).toContainEqual({ - kind: "error", - text: "resize replay failed; disabled for this session", - phase: "start", - source: "system", - }) - } finally { - src.close() - await transport.close() - } - }) - - test("keeps completed historical subagent tabs during bootstrap", async () => { - const src = eventFeed() - const ui = footer() - const transport = await createSessionTransport({ - sdk: sdk({ - stream: src.stream, - messages: async ({ sessionID }) => { - if (sessionID !== "session-1") { - return ok([]) - } - - return ok([ - assistantMessage({ - sessionID: "session-1", - id: "msg-1", - parts: [ - completedTool({ - sessionID: "session-1", - messageID: "msg-1", - id: "task-1", - callID: "call-1", - tool: "task", - body: { - description: "Explore run folder", - subagent_type: "explore", - }, - metadata: { - sessionId: "child-1", - }, - }), - ], - }), - ]) - }, - children: async () => ok([child("child-1")]), - }), - sessionID: "session-1", - thinking: true, - limits: () => ({}), - footer: ui.api, - }) - - try { - const state = await waitFor(() => { - const item = ui.events.findLast((event) => event.type === "stream.subagent") - return item?.type === "stream.subagent" ? item.state : undefined - }) - - expect(state.tabs).toEqual([expect.objectContaining({ sessionID: "child-1", status: "completed" })]) - expect(state.details).toEqual({}) - } finally { - src.close() - await transport.close() - } - }) - - test("bootstraps child tabs and resumed blocker input", async () => { - const src = eventFeed() - const ui = footer() - const transport = await createSessionTransport({ - sdk: sdk({ - stream: src.stream, - messages: async ({ sessionID }) => { - if (sessionID === "session-1") { - return ok([ - assistantMessage({ - sessionID: "session-1", - id: "msg-1", - parts: [ - runningTool({ - sessionID: "session-1", - messageID: "msg-1", - id: "task-1", - callID: "call-1", - tool: "task", - body: { - description: "Explore run folder", - subagent_type: "explore", - }, - metadata: { - sessionId: "child-1", - }, - }), - ], - }), - ]) - } - - return ok([ - assistantMessage({ - sessionID: "child-1", - id: "msg-child-1", - parts: [ - runningTool({ - sessionID: "child-1", - messageID: "msg-child-1", - id: "edit-1", - callID: "call-edit-1", - tool: "edit", - body: { - filePath: "src/run/subagent-data.ts", - diff: "@@ -1 +1 @@", - }, - }), - ], - }), - ]) - }, - children: async () => ok([child("child-1")]), - permissions: async () => - ok([ - { - id: "perm-1", - sessionID: "child-1", - permission: "edit", - patterns: ["src/run/subagent-data.ts"], - metadata: {}, - always: [], - tool: { - messageID: "msg-child-1", - callID: "call-edit-1", - }, - }, - ]), - }), - sessionID: "session-1", - thinking: true, - limits: () => ({}), - footer: ui.api, - }) - - try { - const boot = await waitFor(() => { - const item = ui.events.findLast((event) => event.type === "stream.subagent") - const state = item?.type === "stream.subagent" ? item.state : undefined - return state?.tabs.some((tab) => tab.sessionID === "child-1") && - state.permissions.some((req) => req.id === "perm-1") - ? state - : undefined - }) - - expect(boot.tabs).toEqual([ - expect.objectContaining({ - sessionID: "child-1", - label: "Explore", - description: "Pending permission", - status: "running", - }), - ]) - expect(boot.permissions).toEqual([ - expect.objectContaining({ - id: "perm-1", - sessionID: "child-1", - metadata: { - input: { - filePath: "src/run/subagent-data.ts", - diff: "@@ -1 +1 @@", - }, - }, - }), - ]) - - transport.selectSubagent("child-1") - - const selected = await waitFor(() => { - const item = ui.events.findLast((event) => event.type === "stream.subagent") - const state = item?.type === "stream.subagent" ? item.state : undefined - const detail = state?.details["child-1"] - return detail?.commits.some( - (commit) => commit.kind === "tool" && commit.tool === "edit" && commit.phase === "start", - ) - ? state - : undefined - }) - - expect(selected.details).toEqual({ - "child-1": { - sessionID: "child-1", - commits: [ - expect.objectContaining({ - kind: "tool", - tool: "edit", - phase: "start", - }), - ], - }, - }) - - expect( - await waitFor(() => { - const item = ui.events.findLast((event) => event.type === "stream.view") - return item?.type === "stream.view" && item.view.type === "permission" && item.view.request.id === "perm-1" - ? item - : undefined - }), - ).toEqual({ - type: "stream.view", - view: { - type: "permission", - request: expect.objectContaining({ - id: "perm-1", - metadata: { - input: { - filePath: "src/run/subagent-data.ts", - diff: "@@ -1 +1 @@", - }, - }, - }), - }, - }) - } finally { - src.close() - await transport.close() - } - }) - - test("bootstraps child session output before selection", async () => { - const ui = footer() - const transport = await createSessionTransport({ - sdk: sdk({ - messages: async ({ sessionID }) => { - if (sessionID === "session-1") { - return ok([ - assistantMessage({ - sessionID: "session-1", - id: "msg-1", - parts: [ - runningTool({ - sessionID: "session-1", - messageID: "msg-1", - id: "task-1", - callID: "call-1", - tool: "task", - body: { - description: "Explore run.ts", - subagent_type: "explore", - }, - metadata: { - sessionId: "child-1", - }, - }), - ], - }), - ]) - } - - return sessionID === "child-1" - ? ok([ - assistantMessage({ - sessionID: "child-1", - id: "msg-child-1", - parts: [textPart("txt-child-1", "msg-child-1", "subagent summary", "child-1")], - }), - ]) - : ok([]) - }, - children: async () => ok([child("child-1")]), - }), - sessionID: "session-1", - thinking: true, - limits: () => ({}), - footer: ui.api, - }) - - try { - await waitFor(() => { - const item = ui.events.findLast((event) => event.type === "stream.subagent") - return item?.type === "stream.subagent" && item.state.tabs.some((tab) => tab.sessionID === "child-1") - ? item - : undefined - }) - - transport.selectSubagent("child-1") - - expect( - await waitFor(() => { - const item = ui.events.findLast((event) => event.type === "stream.subagent") - const detail = item?.type === "stream.subagent" ? item.state.details["child-1"] : undefined - return detail?.commits.some((commit) => commit.kind === "assistant" && commit.text === "subagent summary") - ? detail - : undefined - }), - ).toEqual({ - sessionID: "child-1", - commits: [ - expect.objectContaining({ - kind: "assistant", - text: "subagent summary", - }), - ], - }) - } finally { - await transport.close() - } - }) - - test("does not block startup on child history bootstrap", async () => { - const pending = defer>>>() - const ui = footer() - let transport: Awaited> | undefined - - const task = createSessionTransport({ - sdk: sdk({ - messages: async ({ sessionID }) => { - if (sessionID === "session-1") { - return ok([ - assistantMessage({ - sessionID: "session-1", - id: "msg-1", - parts: [ - runningTool({ - sessionID: "session-1", - messageID: "msg-1", - id: "task-1", - callID: "call-1", - tool: "task", - body: { - description: "Explore run.ts", - subagent_type: "explore", - }, - metadata: { - sessionId: "child-1", - }, - }), - ], - }), - ]) - } - - if (sessionID === "child-1") { - return pending.promise - } - - return ok([]) - }, - children: async () => ok([child("child-1")]), - }), - sessionID: "session-1", - thinking: true, - limits: () => ({}), - footer: ui.api, - }).then((item) => { - transport = item - return item - }) - - try { - const state = await waitFor(() => { - const item = ui.events.findLast((event) => event.type === "stream.subagent") - return item?.type === "stream.subagent" && item.state.tabs.some((tab) => tab.sessionID === "child-1") - ? item.state - : undefined - }) - - await waitFor(() => transport) - - expect(state).toEqual({ - tabs: [expect.objectContaining({ sessionID: "child-1", status: "running" })], - details: {}, - permissions: [], - questions: [], - }) - } finally { - pending.resolve(ok([])) - await task - await transport?.close() - } - }) - - test("replays child events buffered during bootstrap once the tab is known", async () => { - const global = globalFeed() - const ui = footer() - const gate = defer() - let transport: Awaited> | undefined - const task = createSessionTransport({ - sdk: sdk({ - globalStream: global.stream, - messages: async ({ sessionID }) => { - if (sessionID !== "session-1") { - return ok([]) - } - - await gate.promise - return ok([]) - }, - children: async () => ok([]), - }), - sessionID: "session-1", - thinking: true, - limits: () => ({}), - footer: ui.api, - }) - - try { - await Promise.resolve() - global.push(globalEvent(retry("child-1", 1, "retry child"))) - global.push( - globalEvent({ - id: "evt-child-message", - type: "message.updated", - properties: { - sessionID: "child-1", - info: assistantMessage({ - sessionID: "child-1", - id: "msg-child-1", - parts: [], - }).info, - }, - }), - ) - global.push(globalEvent(textUpdated(textPart("txt-child-1", "msg-child-1", "", "child-1")))) - global.push(globalEvent(textDelta("msg-child-1", "txt-child-1", "Hello", "child-1"))) - global.push( - globalEvent( - toolUpdated( - runningTool({ - sessionID: "session-1", - messageID: "msg-1", - id: "task-1", - callID: "call-1", - tool: "task", - body: { - description: "Explore run.ts", - subagent_type: "explore", - }, - metadata: { - sessionId: "child-1", - }, - }), - ), - ), - ) - gate.resolve() - transport = await task - - await waitFor(() => { - const item = ui.events.findLast((event) => event.type === "stream.subagent") - return item?.type === "stream.subagent" && item.state.tabs.some((tab) => tab.sessionID === "child-1") - ? item - : undefined - }) - - transport.selectSubagent("child-1") - - const detail = await waitFor(() => { - const item = ui.events.findLast((event) => event.type === "stream.subagent") - const next = item?.type === "stream.subagent" ? item.state.details["child-1"] : undefined - return next?.commits.some((commit) => commit.kind === "error" && commit.text === "retry child") && - next.commits.some((commit) => commit.kind === "assistant" && commit.text === "Hello") - ? next - : undefined - }) - - expect(detail).toEqual({ - sessionID: "child-1", - commits: expect.arrayContaining([ - expect.objectContaining({ - kind: "error", - text: "retry child", - }), - expect.objectContaining({ - kind: "assistant", - text: "Hello", - }), - ]), - }) - } finally { - global.close() - await transport?.close() - } - }) - - test("streams selected subagent output from global events while it is running", async () => { - const global = globalFeed() - const ui = footer() - const transport = await createSessionTransport({ - sdk: sdk({ - globalStream: global.stream, - }), - sessionID: "session-1", - thinking: true, - limits: () => ({}), - footer: ui.api, - }) - - try { - global.push(globalEvent(assistant("msg-1"))) - global.push( - globalEvent( - toolUpdated( - runningTool({ - sessionID: "session-1", - messageID: "msg-1", - id: "task-1", - callID: "call-1", - tool: "task", - body: { - description: "Explore run.ts", - subagent_type: "explore", - }, - metadata: { - sessionId: "child-1", - }, - }), - ), - ), - ) - - await waitFor(() => { - const item = ui.events.findLast((event) => event.type === "stream.subagent") - return item?.type === "stream.subagent" && item.state.tabs.some((tab) => tab.sessionID === "child-1") - ? item - : undefined - }) - - transport.selectSubagent("child-1") - - global.push( - globalEvent({ - id: "evt-child-message", - type: "message.updated", - properties: { - sessionID: "child-1", - info: assistantMessage({ - sessionID: "child-1", - id: "msg-child-1", - parts: [], - }).info, - }, - }), - ) - global.push(globalEvent(textUpdated(textPart("txt-child-1", "msg-child-1", "hello", "child-1")))) - - expect( - await waitFor(() => { - const item = ui.events.findLast((event) => event.type === "stream.subagent") - const detail = item?.type === "stream.subagent" ? item.state.details["child-1"] : undefined - return detail?.commits.some((commit) => commit.kind === "assistant" && commit.text === "hello") - ? detail - : undefined - }), - ).toEqual({ - sessionID: "child-1", - commits: [ - expect.objectContaining({ - kind: "assistant", - text: "hello", - }), - ], - }) - - global.push(globalEvent(textUpdated(textPart("txt-child-1", "msg-child-1", "hello world", "child-1")))) - - expect( - await waitFor(() => { - const item = ui.events.findLast((event) => event.type === "stream.subagent") - const detail = item?.type === "stream.subagent" ? item.state.details["child-1"] : undefined - return detail?.commits.some((commit) => commit.kind === "assistant" && commit.text === "hello world") - ? detail - : undefined - }, 2_000), - ).toEqual({ - sessionID: "child-1", - commits: [ - expect.objectContaining({ - kind: "assistant", - text: "hello world", - }), - ], - }) - } finally { - global.close() - await transport.close() - } - }) - - test("recovers pending questions from question.list when question.asked is missed", async () => { - const src = eventFeed() - const ui = footer() - let questionCalls = 0 - const request = { - id: "question-1", - sessionID: "session-1", - questions: [ - { - question: "Which area should I inspect first?", - header: "Area", - options: [{ label: "CLI", description: "Look at the direct run flow." }], - multiple: false, - }, - ], - tool: { - messageID: "msg-1", - callID: "call-question-1", - }, - } - const transport = await createSessionTransport({ - sdk: sdk({ - stream: src.stream, - questions: async () => { - questionCalls += 1 - return ok(questionCalls > 1 ? [request] : []) - }, - promptAsync: async () => { - queueMicrotask(() => { - src.push(busy()) - src.push(assistant("msg-1")) - src.push( - toolUpdated( - runningTool({ - sessionID: "session-1", - messageID: "msg-1", - id: "question-tool-1", - callID: "call-question-1", - tool: "question", - body: { - questions: request.questions, - }, - }), - ), - ) - }) - return ok(undefined) - }, - }), - sessionID: "session-1", - thinking: true, - limits: () => ({}), - footer: ui.api, - }) - - const ctrl = new AbortController() - - try { - const run = transport.runPromptTurn({ - agent: undefined, - model: undefined, - variant: undefined, - prompt: { text: "hello", parts: [] }, - files: [], - includeFiles: false, - signal: ctrl.signal, - }) - - const view = await waitFor(() => { - const item = ui.events.findLast((event) => event.type === "stream.view") - return item?.type === "stream.view" && item.view.type === "question" ? item.view : undefined - }) - - expect(view).toEqual({ - type: "question", - request, - }) - - expect(ui.events).toContainEqual({ - type: "stream.patch", - patch: { - phase: "running", - status: "awaiting answer", - }, - }) - - src.push( - toolUpdated( - completedTool({ - sessionID: "session-1", - messageID: "msg-1", - id: "question-tool-1", - callID: "call-question-1", - tool: "question", - body: { - questions: request.questions, - }, - output: "User has answered your questions.", - metadata: { - answers: [["CLI"]], - }, - }), - ), - ) - - expect( - await waitFor(() => { - const item = ui.events.findLast((event) => event.type === "stream.view") - return item?.type === "stream.view" && item.view.type === "prompt" ? item : undefined - }), - ).toEqual({ - type: "stream.view", - view: { type: "prompt" }, - }) - - ctrl.abort() - await run - } finally { - src.close() - await transport.close() - } - }) - - test("does not resurrect questions if question.list resolves after tool completion", async () => { - const src = eventFeed() - const ui = footer() - const started = defer() - const request = { - id: "question-race-1", - sessionID: "session-1", - questions: [ - { - question: "Which area should I inspect first?", - header: "Area", - options: [{ label: "CLI", description: "Look at the direct run flow." }], - multiple: false, - }, - ], - tool: { - messageID: "msg-1", - callID: "call-question-race-1", - }, - } - const pending = defer>>>() - let questionCalls = 0 - const transport = await createSessionTransport({ - sdk: sdk({ - stream: src.stream, - questions: async () => { - questionCalls += 1 - if (questionCalls === 1) { - return ok([]) - } - - if (questionCalls === 2) { - started.resolve() - return pending.promise - } - - return ok([]) - }, - promptAsync: async () => { - queueMicrotask(() => { - src.push(busy()) - src.push(assistant("msg-1")) - src.push( - toolUpdated( - runningTool({ - sessionID: "session-1", - messageID: "msg-1", - id: "question-race-tool-1", - callID: "call-question-race-1", - tool: "question", - body: { - questions: request.questions, - }, - }), - ), - ) - }) - return ok(undefined) - }, - }), - sessionID: "session-1", - thinking: true, - limits: () => ({}), - footer: ui.api, - }) - - const ctrl = new AbortController() - - try { - const run = transport.runPromptTurn({ - agent: undefined, - model: undefined, - variant: undefined, - prompt: { text: "hello", parts: [] }, - files: [], - includeFiles: false, - signal: ctrl.signal, - }) - - await started.promise - src.push( - toolUpdated( - completedTool({ - sessionID: "session-1", - messageID: "msg-1", - id: "question-race-tool-1", - callID: "call-question-race-1", - tool: "question", - body: { - questions: request.questions, - }, - output: "User has answered your questions.", - metadata: { - answers: [["CLI"]], - }, - }), - ), - ) - await waitFor(() => { - const commit = ui.commits.findLast( - (item) => item.kind === "tool" && item.partID === "question-race-tool-1" && item.toolState === "completed", - ) - return commit ? true : undefined - }) - pending.resolve(ok([request])) - - await Bun.sleep(50) - - expect( - ui.events.some( - (event) => - event.type === "stream.view" && event.view.type === "question" && event.view.request.id === request.id, - ), - ).toBe(false) - - ctrl.abort() - await run - } finally { - src.close() - await transport.close() - } - }) - - test("respects the includeFiles flag when building prompt payloads", async () => { - const src = eventFeed() - const ui = footer() - const seen: unknown[] = [] - const file: RunFilePart = { - type: "file", - url: "file:///tmp/a.ts", - filename: "a.ts", - mime: "text/plain", - } - - const transport = await createSessionTransport({ - sdk: sdk({ - stream: src.stream, - promptAsync: async (input) => { - seen.push(input) - queueMicrotask(() => { - src.push(busy()) - src.push(idle()) - }) - return ok(undefined) - }, - }), - sessionID: "session-1", - thinking: true, - limits: () => ({}), - footer: ui.api, - }) - - try { - await transport.runPromptTurn({ - agent: undefined, - model: undefined, - variant: undefined, - prompt: { text: "hello", parts: [] }, - files: [file], - includeFiles: true, - }) - - await transport.runPromptTurn({ - agent: undefined, - model: undefined, - variant: undefined, - prompt: { text: "again", parts: [] }, - files: [file], - includeFiles: false, - }) - - expect(seen).toEqual([ - expect.objectContaining({ - parts: [file, { type: "text", text: "hello" }], - }), - expect.objectContaining({ - parts: [{ type: "text", text: "again" }], - }), - ]) - } finally { - src.close() - await transport.close() - } - }) - - test("falls back to session status polling when idle events are missing", async () => { - const src = eventFeed() - const ui = footer() - let busy = true - const transport = await createSessionTransport({ - sdk: sdk({ - stream: src.stream, - promptAsync: async () => { - queueMicrotask(() => { - src.push(assistant("msg-1")) - busy = false - }) - return ok(undefined) - }, - status: async () => ok(statusMap(busy)), - }), - sessionID: "session-1", - thinking: true, - limits: () => ({}), - footer: ui.api, - }) - - try { - await Promise.race([ - transport.runPromptTurn({ - agent: undefined, - model: undefined, - variant: undefined, - prompt: { text: "hello", parts: [] }, - files: [], - includeFiles: false, - }), - new Promise((_, reject) => setTimeout(() => reject(new Error("turn timed out")), 1_000)), - ]) - } finally { - src.close() - await transport.close() - } - }) - - test("flushes interrupted output when the active turn aborts", async () => { - const src = eventFeed() - const seen = defer() - const ui = footer((commit) => { - if (commit.kind === "assistant" && commit.phase === "progress") { - seen.resolve() - } - }) - const transport = await createSessionTransport({ - sdk: sdk({ - stream: src.stream, - promptAsync: async () => { - queueMicrotask(() => { - src.push(busy()) - src.push(assistant("msg-1")) - src.push(textUpdated(textPart("txt-1", "msg-1", ""))) - src.push(textDelta("msg-1", "txt-1", "unfinished")) - }) - return ok(undefined) - }, - }), - sessionID: "session-1", - thinking: true, - limits: () => ({}), - footer: ui.api, - }) - - const ctrl = new AbortController() - - try { - const task = transport.runPromptTurn({ - agent: undefined, - model: undefined, - variant: undefined, - prompt: { text: "hello", parts: [] }, - files: [], - includeFiles: false, - signal: ctrl.signal, - }) - - await seen.promise - ctrl.abort() - await task - - expect(ui.commits).toEqual([ - { - kind: "assistant", - text: "unfinished", - phase: "progress", - source: "assistant", - messageID: "msg-1", - partID: "txt-1", - }, - { - kind: "assistant", - text: "", - phase: "final", - source: "assistant", - messageID: "msg-1", - partID: "txt-1", - interrupted: true, - }, - ]) - } finally { - src.close() - await transport.close() - } - }) - - test("closes an active turn without rejecting it", async () => { - const src = eventFeed() - const ui = footer() - const ready = defer() - let aborted = false - - const transport = await createSessionTransport({ - sdk: sdk({ - stream: src.stream, - promptAsync: async (_input, opt) => { - ready.resolve() - await new Promise((resolve) => { - const onAbort = () => { - aborted = true - opt?.signal?.removeEventListener("abort", onAbort) - resolve() - } - - opt?.signal?.addEventListener("abort", onAbort, { once: true }) - }) - return ok(undefined) - }, - }), - sessionID: "session-1", - thinking: true, - limits: () => ({}), - footer: ui.api, - }) - - try { - const task = transport.runPromptTurn({ - agent: undefined, - model: undefined, - variant: undefined, - prompt: { text: "hello", parts: [] }, - files: [], - includeFiles: false, - }) - - await ready.promise - await transport.close() - await task - - expect(aborted).toBe(true) - } finally { - src.close() - await transport.close() - } - }) - - test("rejects the active turn when the event stream faults", async () => { - const ui = footer() - const ready = defer() - - const transport = await createSessionTransport({ - sdk: sdk({ - globalEvent: () => - globalSse( - (async function* (): AsyncGenerator { - await ready.promise - yield globalEvent(busy()) - throw new Error("boom") - })(), - ), - promptAsync: async () => { - ready.resolve() - return ok(undefined) - }, - status: async () => ok({ "session-1": { type: "busy" } }), - }), - sessionID: "session-1", - thinking: true, - limits: () => ({}), - footer: ui.api, - }) - - try { - await expect( - transport.runPromptTurn({ - agent: undefined, - model: undefined, - variant: undefined, - prompt: { text: "hello", parts: [] }, - files: [], - includeFiles: false, - }), - ).rejects.toThrow("boom") - } finally { - await transport.close() - } - }) - - test("rejects the active turn when the backing instance is disposed", async () => { - const ui = footer() - const ready = defer() - - const transport = await createSessionTransport({ - sdk: sdk({ - globalEvent: () => - globalSse( - (async function* (): AsyncGenerator { - await ready.promise - yield globalEvent({ - id: "evt-disposed", - type: "server.instance.disposed", - properties: { - directory: "/tmp", - }, - }) - })(), - ), - promptAsync: async () => { - ready.resolve() - return ok(undefined) - }, - status: async () => ok({}), - }), - directory: "/tmp", - sessionID: "session-1", - thinking: true, - limits: () => ({}), - footer: ui.api, - }) - - try { - await expect( - transport.runPromptTurn({ - agent: undefined, - model: undefined, - variant: undefined, - prompt: { text: "hello", parts: [] }, - files: [], - includeFiles: false, - }), - ).rejects.toThrow("instance disposed") - } finally { - await transport.close() - } - }) - - test("rejects concurrent turns", async () => { - const src = eventFeed() - const ui = footer() - const transport = await createSessionTransport({ - sdk: sdk({ - stream: src.stream, - }), - sessionID: "session-1", - thinking: true, - limits: () => ({}), - footer: ui.api, - }) - - const ctrl = new AbortController() - - try { - const task = transport.runPromptTurn({ - agent: undefined, - model: undefined, - variant: undefined, - prompt: { text: "one", parts: [] }, - files: [], - includeFiles: false, - signal: ctrl.signal, - }) - - await expect( - transport.runPromptTurn({ - agent: undefined, - model: undefined, - variant: undefined, - prompt: { text: "two", parts: [] }, - files: [], - includeFiles: false, - }), - ).rejects.toThrow("prompt already running") - - ctrl.abort() - await task - } finally { - src.close() - await transport.close() - } - }) -}) diff --git a/packages/opencode/test/cli/run/subagent-data.test.ts b/packages/opencode/test/cli/run/subagent-data.test.ts deleted file mode 100644 index 4dcbd09608..0000000000 --- a/packages/opencode/test/cli/run/subagent-data.test.ts +++ /dev/null @@ -1,547 +0,0 @@ -import { describe, expect, test } from "bun:test" -import type { Event } from "@opencode-ai/sdk/v2" -import { entryBody } from "@/cli/cmd/run/entry.body" -import { - bootstrapSubagentCalls, - bootstrapSubagentData, - createSubagentData, - reduceSubagentData, - snapshotSubagentData, -} from "@/cli/cmd/run/subagent-data" - -type SessionMessage = Parameters[0]["messages"][number] -type ChildMessage = Parameters[0]["messages"][number] - -function visible(commits: Array[0]>) { - return commits.flatMap((item) => { - const body = entryBody(item) - if (body.type === "none") { - return [] - } - - if (body.type === "structured") { - if (body.snapshot.kind === "code" || body.snapshot.kind === "task") { - return [body.snapshot.title] - } - - if (body.snapshot.kind === "diff") { - return body.snapshot.items.map((item) => item.title) - } - - if (body.snapshot.kind === "todo") { - return ["# Todos"] - } - - return ["# Questions"] - } - - return [body.content] - }) -} - -function reduce(data: ReturnType, event: unknown) { - return reduceSubagentData({ - data, - event: event as Event, - sessionID: "parent-1", - thinking: true, - limits: {}, - }) -} - -function taskMessage(sessionID: string, status: "running" | "completed" | "interrupted" = "completed"): SessionMessage { - if (status === "running") { - return { - parts: [ - { - id: `part-${sessionID}`, - sessionID: "parent-1", - messageID: `msg-${sessionID}`, - type: "tool", - callID: `call-${sessionID}`, - tool: "task", - state: { - status: "running", - input: { - description: "Scan reducer paths", - subagent_type: "explore", - }, - title: "Reducer touchpoints", - metadata: { - sessionId: sessionID, - toolcalls: 4, - }, - time: { start: 1 }, - }, - }, - ], - } - } - - if (status === "interrupted") { - return { - parts: [ - { - id: `part-${sessionID}`, - sessionID: "parent-1", - messageID: `msg-${sessionID}`, - type: "tool", - callID: `call-${sessionID}`, - tool: "task", - state: { - status: "error", - input: { - description: "Scan reducer paths", - subagent_type: "explore", - }, - error: "Tool execution aborted", - metadata: { - sessionId: sessionID, - toolcalls: 4, - interrupted: true, - }, - time: { start: 1, end: 2 }, - }, - }, - ], - } - } - - return { - parts: [ - { - id: `part-${sessionID}`, - sessionID: "parent-1", - messageID: `msg-${sessionID}`, - type: "tool", - callID: `call-${sessionID}`, - tool: "task", - state: { - status: "completed", - input: { - description: "Scan reducer paths", - subagent_type: "explore", - }, - output: "", - title: "Reducer touchpoints", - metadata: { - sessionId: sessionID, - toolcalls: 4, - }, - time: { start: 1, end: 2 }, - }, - }, - ], - } -} - -function question(id: string, sessionID: string) { - return { - id, - sessionID, - questions: [ - { - question: "Mode?", - header: "Mode", - options: [{ label: "Fast", description: "Quick pass" }], - multiple: false, - }, - ], - } -} - -function childMessage(input: { - messageID: string - sessionID: string - role: "user" | "assistant" - parts: ChildMessage["parts"] -}) { - if (input.role === "user") { - return { - info: { - id: input.messageID, - sessionID: input.sessionID, - role: "user", - time: { - created: 1, - }, - agent: "test", - model: { - providerID: "openai", - modelID: "gpt-5", - }, - }, - parts: input.parts, - } satisfies ChildMessage - } - - return { - info: { - id: input.messageID, - sessionID: input.sessionID, - role: "assistant", - time: { - created: 2, - completed: 3, - }, - parentID: "msg-user-1", - providerID: "openai", - modelID: "gpt-5", - mode: "default", - agent: "explore", - path: { - cwd: "/tmp", - root: "/tmp", - }, - cost: 0, - tokens: { - input: 1, - output: 1, - reasoning: 0, - cache: { - read: 0, - write: 0, - }, - }, - finish: "stop", - }, - parts: input.parts, - } satisfies ChildMessage -} - -describe("run subagent data", () => { - test("bootstraps tabs and child blockers from parent task parts", () => { - const data = createSubagentData() - - expect( - bootstrapSubagentData({ - data, - messages: [taskMessage("child-1")], - children: [{ id: "child-1" }, { id: "child-2" }], - permissions: [ - { - id: "perm-1", - sessionID: "child-1", - permission: "read", - patterns: ["src/**/*.ts"], - metadata: {}, - always: [], - }, - { - id: "perm-2", - sessionID: "other", - permission: "read", - patterns: ["src/**/*.ts"], - metadata: {}, - always: [], - }, - ], - questions: [question("question-1", "child-1"), question("question-2", "other")], - }), - ).toBe(true) - - const snapshot = snapshotSubagentData(data) - - expect(snapshot.tabs).toEqual([ - expect.objectContaining({ - sessionID: "child-1", - label: "Explore", - description: "Scan reducer paths", - title: "Reducer touchpoints", - status: "completed", - toolCalls: 4, - }), - ]) - expect(snapshot.details).toEqual({ - "child-1": { - sessionID: "child-1", - commits: [], - }, - }) - expect(snapshot.permissions.map((item) => item.id)).toEqual(["perm-1"]) - expect(snapshot.questions.map((item) => item.id)).toEqual(["question-1"]) - }) - - test("marks interrupted task tabs as cancelled during bootstrap", () => { - const data = createSubagentData() - - bootstrapSubagentData({ - data, - messages: [taskMessage("child-1", "interrupted")], - children: [{ id: "child-1" }], - permissions: [], - questions: [], - }) - - expect(snapshotSubagentData(data).tabs).toEqual([ - expect.objectContaining({ - sessionID: "child-1", - status: "cancelled", - }), - ]) - }) - - test("captures child activity and blocker metadata in the footer detail state", () => { - const data = createSubagentData() - - bootstrapSubagentData({ - data, - messages: [taskMessage("child-1", "running")], - children: [{ id: "child-1" }], - permissions: [], - questions: [], - }) - - reduce(data, { - type: "message.part.updated", - properties: { - part: { - id: "txt-user-1", - messageID: "msg-user-1", - sessionID: "child-1", - type: "text", - text: "Inspect footer tabs", - }, - }, - }) - reduce(data, { - type: "message.updated", - properties: { - sessionID: "child-1", - info: { - id: "msg-user-1", - role: "user", - }, - }, - }) - reduce(data, { - type: "message.updated", - properties: { - sessionID: "child-1", - info: { - id: "msg-assistant-1", - role: "assistant", - }, - }, - }) - reduce(data, { - type: "message.part.updated", - properties: { - part: { - id: "reason-1", - messageID: "msg-assistant-1", - sessionID: "child-1", - type: "reasoning", - text: "planning next steps", - time: { start: 1 }, - }, - }, - }) - reduce(data, { - type: "message.part.updated", - properties: { - part: { - id: "tool-1", - messageID: "msg-assistant-1", - sessionID: "child-1", - type: "tool", - callID: "call-1", - tool: "bash", - state: { - status: "running", - input: { - command: "git status --short", - }, - time: { start: 1 }, - }, - }, - }, - }) - reduce(data, { - type: "permission.asked", - properties: { - id: "perm-1", - sessionID: "child-1", - permission: "bash", - patterns: ["git status --short"], - metadata: {}, - always: [], - tool: { - messageID: "msg-assistant-1", - callID: "call-1", - }, - }, - }) - reduce(data, { - type: "message.part.updated", - properties: { - part: { - id: "txt-1", - messageID: "msg-assistant-1", - sessionID: "child-1", - type: "text", - text: "hello", - }, - }, - }) - reduce(data, { - type: "message.part.delta", - properties: { - sessionID: "child-1", - messageID: "msg-assistant-1", - partID: "txt-1", - field: "text", - delta: " world", - }, - }) - - const snapshot = snapshotSubagentData(data) - - expect(snapshot.tabs).toEqual([expect.objectContaining({ sessionID: "child-1", status: "running" })]) - expect(visible(snapshot.details["child-1"]?.commits ?? [])).toEqual([ - "› Inspect footer tabs", - "_Thinking:_ planning next steps", - "$ git status --short", - "hello world", - ]) - expect(snapshot.permissions).toEqual([ - expect.objectContaining({ - id: "perm-1", - metadata: { - input: { - command: "git status --short", - }, - }, - }), - ]) - expect(snapshot.questions).toEqual([]) - }) - - test("replays bootstrapped child session messages into inspector commits", () => { - const data = createSubagentData() - - bootstrapSubagentData({ - data, - messages: [taskMessage("child-1", "completed")], - children: [{ id: "child-1" }], - permissions: [], - questions: [], - }) - - expect( - bootstrapSubagentCalls({ - data, - sessionID: "child-1", - messages: [ - childMessage({ - messageID: "msg-user-1", - sessionID: "child-1", - role: "user", - parts: [ - { - id: "txt-user-1", - messageID: "msg-user-1", - sessionID: "child-1", - type: "text", - text: "Inspect footer tabs", - time: { start: 1, end: 1 }, - }, - ], - }), - childMessage({ - messageID: "msg-assistant-1", - sessionID: "child-1", - role: "assistant", - parts: [ - { - id: "reason-1", - messageID: "msg-assistant-1", - sessionID: "child-1", - type: "reasoning", - text: "planning next steps", - time: { start: 2, end: 2 }, - }, - { - id: "txt-1", - messageID: "msg-assistant-1", - sessionID: "child-1", - type: "text", - text: "hello world", - time: { start: 2, end: 3 }, - }, - ], - }), - ], - thinking: true, - limits: {}, - }), - ).toBe(true) - - expect(visible(snapshotSubagentData(data).details["child-1"]?.commits ?? [])).toEqual([ - "› Inspect footer tabs", - "_Thinking:_ planning next steps", - "hello world", - ]) - }) - - test("marks a running tab cancelled when the child session aborts", () => { - const data = createSubagentData() - - bootstrapSubagentData({ - data, - messages: [taskMessage("child-1", "running")], - children: [{ id: "child-1" }], - permissions: [], - questions: [], - }) - - reduce(data, { - type: "message.updated", - properties: { - sessionID: "child-1", - info: { - id: "msg-assistant-1", - sessionID: "child-1", - role: "assistant", - time: { - created: 1, - completed: 2, - }, - error: { - name: "MessageAbortedError", - data: { - message: "Aborted", - }, - }, - parentID: "msg-user-1", - providerID: "openai", - modelID: "gpt-5", - mode: "default", - agent: "explore", - path: { - cwd: "/tmp", - root: "/tmp", - }, - cost: 0, - tokens: { - input: 1, - output: 1, - reasoning: 0, - cache: { - read: 0, - write: 0, - }, - }, - finish: "error", - }, - }, - }) - - expect(snapshotSubagentData(data).tabs).toEqual([ - expect.objectContaining({ - sessionID: "child-1", - status: "cancelled", - }), - ]) - }) -}) diff --git a/packages/opencode/test/cli/run/variant.shared.test.ts b/packages/opencode/test/cli/run/variant.shared.test.ts index 3de324b5e4..e05c9888bb 100644 --- a/packages/opencode/test/cli/run/variant.shared.test.ts +++ b/packages/opencode/test/cli/run/variant.shared.test.ts @@ -1,9 +1,8 @@ import path from "path" -import { NodeFileSystem } from "@effect/platform-node" -import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { FSUtil } from "@opencode-ai/core/fs-util" import { describe, expect, test } from "bun:test" -import { Effect, FileSystem, Layer } from "effect" +import { Effect, Layer } from "effect" import { Global } from "@opencode-ai/core/global" import { createVariantRuntime, @@ -99,7 +98,7 @@ function userMessage( } } -const it = testEffect(Layer.mergeAll(LayerNode.compile(FSUtil.node), NodeFileSystem.layer)) +const it = testEffect(AppNodeBuilder.build(FSUtil.node)) function remap(root: string, file: string) { if (file === Global.Path.state) { @@ -124,7 +123,7 @@ function remappedFs(root: string) { writeJson: (file, data, mode) => fs.writeJson(remap(root, file), data, mode), }) }), - ).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) + ).pipe(Layer.provide(AppNodeBuilder.build(FSUtil.node))) } describe("run variant shared", () => { @@ -160,9 +159,8 @@ describe("run variant shared", () => { it.live("reads and writes saved variants through a runtime-backed app fs layer", () => Effect.gen(function* () { - const filesys = yield* FileSystem.FileSystem const fs = yield* FSUtil.Service - const root = yield* filesys.makeTempDirectoryScoped() + const root = yield* fs.makeTempDirectoryScoped() const file = path.join(root, "model.json") yield* fs.writeJson(file, { @@ -172,7 +170,7 @@ describe("run variant shared", () => { }, }) - const svc = createVariantRuntime(remappedFs(root)) + const svc = createVariantRuntime([[FSUtil.node, remappedFs(root)]]) yield* Effect.promise(() => svc.saveVariant(model, "high")) expect(yield* Effect.promise(() => svc.resolveSavedVariant(model))).toBe("high") @@ -197,14 +195,13 @@ describe("run variant shared", () => { it.live("repairs malformed saved variant state on the next write", () => Effect.gen(function* () { - const filesys = yield* FileSystem.FileSystem const fs = yield* FSUtil.Service - const root = yield* filesys.makeTempDirectoryScoped() + const root = yield* fs.makeTempDirectoryScoped() const file = path.join(root, "model.json") - yield* filesys.writeFileString(file, "{") + yield* fs.writeFileString(file, "{") - const svc = createVariantRuntime(remappedFs(root)) + const svc = createVariantRuntime([[FSUtil.node, remappedFs(root)]]) yield* Effect.promise(() => svc.saveVariant(model, "high")) expect(yield* Effect.promise(() => svc.resolveSavedVariant(model))).toBe("high") diff --git a/packages/opencode/test/cli/tui/thread.test.ts b/packages/opencode/test/cli/tui/thread.test.ts index 73f87d904b..a358eddb31 100644 --- a/packages/opencode/test/cli/tui/thread.test.ts +++ b/packages/opencode/test/cli/tui/thread.test.ts @@ -4,6 +4,7 @@ import fs from "fs/promises" import path from "path" import yargs from "yargs" import { tmpdir } from "../../fixture/fixture" +import { MiniLocalCommand } from "../../../src/cli/cmd/mini" import { TuiThreadCommand, resolveThreadDirectory } from "../../../src/cli/cmd/tui" import { cliIt } from "../../lib/cli-process" @@ -45,12 +46,12 @@ describe("tui thread", () => { expect(resolveThreadDirectory(undefined, pwd.path, cwd.path)).toBe(cwd.path) }) - test("parses supported --no-replay forms", async () => { + test("parses supported mini --no-replay forms", async () => { for (const option of ["--no-replay", "--no-replay=true", "--noReplay"]) { const args = await yargs([]) - .command({ ...TuiThreadCommand, handler: () => {} }) + .command({ ...MiniLocalCommand, handler: () => {} }) .exitProcess(false) - .parse(["--mini", option, "--replay-limit", "10"]) + .parse([option, "--replay-limit", "10"]) expect(args.replay === false || args.noReplay === true).toBe(true) expect(args.replayLimit).toBe(10) @@ -66,30 +67,48 @@ describe("tui thread", () => { expect(args.mdns).toBe(false) }) - cliIt.live("rejects mini-only options without --mini", ({ opencode }) => + cliIt.live("rejects removed top-level mini alias", ({ opencode }) => Effect.gen(function* () { - const result = yield* opencode.spawn(["--replay-limit", "10"]) + const result = yield* opencode.spawn(["--mini"]) opencode.expectExit(result, 1) - expect(result.stderr).toContain("--replay-limit requires --mini") + expect(result.stderr).not.toContain("opencode mini requires a TTY stdout") }), ) - cliIt.live("routes attached sessions to mini mode", ({ opencode }) => + cliIt.live("rejects removed run mini flag", ({ opencode }) => + Effect.gen(function* () { + const result = yield* opencode.spawn(["run", "--mini"]) + + opencode.expectExit(result, 1) + expect(result.stderr).not.toContain("opencode mini requires a TTY stdout") + }), + ) + + cliIt.live("routes local sessions through mini", ({ opencode }) => + Effect.gen(function* () { + const result = yield* opencode.spawn(["mini"]) + + opencode.expectExit(result, 1) + expect(result.stderr).toContain("opencode mini requires a TTY stdout") + }), + ) + + cliIt.live("routes attached sessions through mini attach", ({ opencode }) => + Effect.gen(function* () { + const result = yield* opencode.spawn(["mini", "attach", "http://127.0.0.1:1"]) + + opencode.expectExit(result, 1) + expect(result.stderr).toContain("opencode mini requires a TTY stdout") + }), + ) + + cliIt.live("rejects removed attach mini alias", ({ opencode }) => Effect.gen(function* () { const result = yield* opencode.spawn(["attach", "http://127.0.0.1:1", "--mini"]) opencode.expectExit(result, 1) - expect(result.stderr).toContain("--mini requires a TTY stdout") - }), - ) - - cliIt.live("rejects network options in mini mode", ({ opencode }) => - Effect.gen(function* () { - const result = yield* opencode.spawn(["--mini", "--port", "4096"]) - - opencode.expectExit(result, 1) - expect(result.stderr).toContain("--port cannot be used with --mini") + expect(result.stderr).not.toContain("opencode mini requires a TTY stdout") }), ) }) diff --git a/packages/opencode/test/lib/cli-process.ts b/packages/opencode/test/lib/cli-process.ts index 12e8d9c866..77684d5e4f 100644 --- a/packages/opencode/test/lib/cli-process.ts +++ b/packages/opencode/test/lib/cli-process.ts @@ -5,8 +5,7 @@ // argv parsing → server boot → SDK call → event consumption → exit code (like // the original /event race or #27371's invalid-model hang). // -// Configuration flows through opencode's built-in test affordances: -// - OPENCODE_CONFIG_CONTENT : provider config inline, no files to find +// Configuration flows through an isolated global opencode.json under the temp home: // - OPENCODE_TEST_HOME : pins os.homedir() → tmpdir // - OPENCODE_DISABLE_PROJECT_CONFIG : skip walking up for opencode.json // - OPENCODE_PURE : skip external plugin discovery + install @@ -59,15 +58,16 @@ function forkStderrDrain(stream: ReadableStream, into: string[]) { ) } -function isolatedEnv(home: string, configJson: string): Record { +function isolatedEnv(home: string): Record { return { OPENCODE_TEST_HOME: home, + PWD: home, HOME: home, XDG_CONFIG_HOME: path.join(home, ".config"), XDG_DATA_HOME: path.join(home, ".local/share"), XDG_STATE_HOME: path.join(home, ".local/state"), XDG_CACHE_HOME: path.join(home, ".cache"), - OPENCODE_CONFIG_CONTENT: configJson, + OPENCODE_CONFIG_DIR: path.join(home, ".opencode-config"), OPENCODE_DISABLE_PROJECT_CONFIG: "1", OPENCODE_PURE: "1", OPENCODE_DISABLE_AUTOUPDATE: "1", @@ -89,7 +89,11 @@ export type RunHandle = { readonly result: Effect.Effect } -export type SpawnOpts = { readonly timeoutMs?: number; readonly env?: Record } +export type SpawnOpts = { + readonly timeoutMs?: number + readonly env?: Record + readonly config?: ReturnType & Record +} // Typed equivalent of constructing argv for `opencode run`. New flags should // land here so tests stay grep-able and refactor-safe. @@ -201,10 +205,15 @@ export function withCliFixture( .pipe(Effect.retry(Schedule.spaced("50 millis").pipe(Schedule.both(Schedule.recurs(20)))), Effect.ignore), ) - const configJson = JSON.stringify(testProviderConfig(llm.url)) - const env = isolatedEnv(home, configJson) + const env = isolatedEnv(home) + const writeConfig = (config?: SpawnOpts["config"]) => + fs + .writeWithDirs(path.join(env.OPENCODE_CONFIG_DIR, "opencode.json"), JSON.stringify(config ?? testProviderConfig(llm.url))) + .pipe(Effect.orDie) + yield* writeConfig() const spawn = Effect.fn("opencode.spawn")(function* (args: string[], opts?: SpawnOpts) { + yield* writeConfig(opts?.config) const start = Date.now() const timeoutMs = opts?.timeoutMs ?? 30_000 // stdin: "ignore" so the child doesn't see a piped stdin and block @@ -212,7 +221,7 @@ export function withCliFixture( // consumed as the prompt). The old Process.run wrapper defaulted to // ignore; ChildProcess.make defaults to pipe, so we set it explicitly. const command = ChildProcess.make("bun", ["run", "--conditions=browser", cliEntry, ...args], { - cwd: home, + cwd: opencodeRoot, env: { ...env, ...opts?.env }, extendEnv: true, stdin: "ignore", @@ -250,6 +259,7 @@ export function withCliFixture( const runArgs = (message: string, opts?: RunOpts) => { const argv: string[] = ["run"] + if (!opts?.extraArgs?.includes("--attach")) argv.push("--dir", home) if (opts?.printLogs) argv.push("--print-logs") argv.push("--model", opts?.model ?? testModelID) if (opts?.agent) argv.push("--agent", opts.agent) @@ -264,13 +274,8 @@ export function withCliFixture( if (!opts?.permission) return opts return { ...opts, - env: { - ...opts.env, - OPENCODE_CONFIG_CONTENT: JSON.stringify({ - ...testProviderConfig(llm.url), - permission: opts.permission, - }), - }, + env: opts.env, + config: { ...testProviderConfig(llm.url), permission: opts.permission }, } } @@ -281,10 +286,11 @@ export function withCliFixture( const startRun = Effect.fn("opencode.startRun")(function* (message: string, opts?: RunOpts) { const start = Date.now() const options = runOpts(opts) + yield* writeConfig(options?.config) const proc = yield* Effect.acquireRelease( Effect.sync(() => Bun.spawn(["bun", "run", "--conditions=browser", cliEntry, ...runArgs(message, opts)], { - cwd: home, + cwd: opencodeRoot, env: { ...process.env, ...env, ...options?.env }, stdin: "ignore", stdout: "pipe", @@ -325,7 +331,7 @@ export function withCliFixture( const proc = yield* Effect.acquireRelease( Effect.sync(() => Bun.spawn(["bun", "run", "--conditions=browser", cliEntry, ...argv], { - cwd: home, + cwd: opencodeRoot, env: { ...process.env, ...env, ...opts?.env }, stdout: "pipe", stderr: "pipe", @@ -396,7 +402,7 @@ export function withCliFixture( const proc = yield* Effect.acquireRelease( Effect.sync(() => Bun.spawn(["bun", "run", "--conditions=browser", cliEntry, ...argv], { - cwd: opts?.cwd ?? home, + cwd: opencodeRoot, env: { ...process.env, ...env, ...opts?.env }, stdin: "pipe", stdout: "pipe", diff --git a/packages/opencode/test/lib/test-provider.ts b/packages/opencode/test/lib/test-provider.ts index cfb5a93e33..f886ea54b7 100644 --- a/packages/opencode/test/lib/test-provider.ts +++ b/packages/opencode/test/lib/test-provider.ts @@ -30,7 +30,7 @@ export function testProviderConfig(llmUrl: string) { options: {}, }, }, - options: { apiKey: "test-key", baseURL: llmUrl }, + options: { apiKey: "test-key", baseURL: llmUrl, body: { apiKey: "test-key" } }, }, }, } diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index b8368383d0..2b768bc1d5 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -665,6 +665,8 @@ const scenarios: Scenario[] = [ http.protected.get("/api/location", "v2.location.get").json(200, object), http.protected.get("/api/agent", "v2.agent.list").json(200, locationData(array)), http.protected.get("/api/model", "v2.model.list").json(200, locationData(array)), + // The default model may be undefined in the exercise environment, so only the location envelope is asserted. + http.protected.get("/api/model/default", "v2.model.default").json(200, object), http.protected.get("/api/provider", "v2.provider.list").json(200, locationData(array)), http.protected.get("/api/integration", "v2.integration.list").json(200, locationData(array)), http.protected diff --git a/packages/plugin/src/v2/effect/generated/api.ts b/packages/plugin/src/v2/effect/generated/api.ts index 74b1eb671d..13c5cb80f5 100644 --- a/packages/plugin/src/v2/effect/generated/api.ts +++ b/packages/plugin/src/v2/effect/generated/api.ts @@ -116,94 +116,140 @@ export type Endpoint4_8Input = { export type Endpoint4_8Output = EffectValue>["data"] export type SessionPromptOperation = (input: Endpoint4_8Input) => Effect.Effect -type Endpoint4_9Request = Parameters[0] +type Endpoint4_9Request = Parameters[0] export type Endpoint4_9Input = { readonly sessionID: Endpoint4_9Request["params"]["sessionID"] readonly id?: Endpoint4_9Request["payload"]["id"] - readonly skill: Endpoint4_9Request["payload"]["skill"] + readonly command: Endpoint4_9Request["payload"]["command"] + readonly arguments?: Endpoint4_9Request["payload"]["arguments"] + readonly agent?: Endpoint4_9Request["payload"]["agent"] + readonly model?: Endpoint4_9Request["payload"]["model"] + readonly files?: Endpoint4_9Request["payload"]["files"] + readonly agents?: Endpoint4_9Request["payload"]["agents"] + readonly delivery?: Endpoint4_9Request["payload"]["delivery"] readonly resume?: Endpoint4_9Request["payload"]["resume"] } -export type Endpoint4_9Output = EffectValue> -export type SessionSkillOperation = (input: Endpoint4_9Input) => Effect.Effect +export type Endpoint4_9Output = EffectValue>["data"] +export type SessionCommandOperation = (input: Endpoint4_9Input) => Effect.Effect -type Endpoint4_10Request = Parameters[0] +type Endpoint4_10Request = Parameters[0] export type Endpoint4_10Input = { readonly sessionID: Endpoint4_10Request["params"]["sessionID"] - readonly text: Endpoint4_10Request["payload"]["text"] - readonly description?: Endpoint4_10Request["payload"]["description"] - readonly metadata?: Endpoint4_10Request["payload"]["metadata"] + readonly id?: Endpoint4_10Request["payload"]["id"] + readonly skill: Endpoint4_10Request["payload"]["skill"] + readonly resume?: Endpoint4_10Request["payload"]["resume"] } -export type Endpoint4_10Output = EffectValue> -export type SessionSyntheticOperation = (input: Endpoint4_10Input) => Effect.Effect +export type Endpoint4_10Output = EffectValue> +export type SessionSkillOperation = (input: Endpoint4_10Input) => Effect.Effect -type Endpoint4_11Request = Parameters[0] -export type Endpoint4_11Input = { readonly sessionID: Endpoint4_11Request["params"]["sessionID"] } -export type Endpoint4_11Output = EffectValue> -export type SessionCompactOperation = (input: Endpoint4_11Input) => Effect.Effect +type Endpoint4_11Request = Parameters[0] +export type Endpoint4_11Input = { + readonly sessionID: Endpoint4_11Request["params"]["sessionID"] + readonly text: Endpoint4_11Request["payload"]["text"] + readonly description?: Endpoint4_11Request["payload"]["description"] + readonly metadata?: Endpoint4_11Request["payload"]["metadata"] +} +export type Endpoint4_11Output = EffectValue> +export type SessionSyntheticOperation = (input: Endpoint4_11Input) => Effect.Effect -type Endpoint4_12Request = Parameters[0] +type Endpoint4_12Request = Parameters[0] export type Endpoint4_12Input = { readonly sessionID: Endpoint4_12Request["params"]["sessionID"] } -export type Endpoint4_12Output = EffectValue> -export type SessionWaitOperation = (input: Endpoint4_12Input) => Effect.Effect +export type Endpoint4_12Output = EffectValue> +export type SessionCompactOperation = (input: Endpoint4_12Input) => Effect.Effect -type Endpoint4_13Request = Parameters[0] -export type Endpoint4_13Input = { - readonly sessionID: Endpoint4_13Request["params"]["sessionID"] - readonly messageID: Endpoint4_13Request["payload"]["messageID"] - readonly files?: Endpoint4_13Request["payload"]["files"] +type Endpoint4_13Request = Parameters[0] +export type Endpoint4_13Input = { readonly sessionID: Endpoint4_13Request["params"]["sessionID"] } +export type Endpoint4_13Output = EffectValue> +export type SessionWaitOperation = (input: Endpoint4_13Input) => Effect.Effect + +type Endpoint4_14Request = Parameters[0] +export type Endpoint4_14Input = { + readonly sessionID: Endpoint4_14Request["params"]["sessionID"] + readonly messageID: Endpoint4_14Request["payload"]["messageID"] + readonly files?: Endpoint4_14Request["payload"]["files"] } -export type Endpoint4_13Output = EffectValue>["data"] -export type SessionRevertStageOperation = (input: Endpoint4_13Input) => Effect.Effect +export type Endpoint4_14Output = EffectValue>["data"] +export type SessionRevertStageOperation = (input: Endpoint4_14Input) => Effect.Effect -type Endpoint4_14Request = Parameters[0] -export type Endpoint4_14Input = { readonly sessionID: Endpoint4_14Request["params"]["sessionID"] } -export type Endpoint4_14Output = EffectValue> -export type SessionRevertClearOperation = (input: Endpoint4_14Input) => Effect.Effect - -type Endpoint4_15Request = Parameters[0] +type Endpoint4_15Request = Parameters[0] export type Endpoint4_15Input = { readonly sessionID: Endpoint4_15Request["params"]["sessionID"] } -export type Endpoint4_15Output = EffectValue> -export type SessionRevertCommitOperation = (input: Endpoint4_15Input) => Effect.Effect +export type Endpoint4_15Output = EffectValue> +export type SessionRevertClearOperation = (input: Endpoint4_15Input) => Effect.Effect -type Endpoint4_16Request = Parameters[0] +type Endpoint4_16Request = Parameters[0] export type Endpoint4_16Input = { readonly sessionID: Endpoint4_16Request["params"]["sessionID"] } -export type Endpoint4_16Output = EffectValue>["data"] -export type SessionContextOperation = (input: Endpoint4_16Input) => Effect.Effect +export type Endpoint4_16Output = EffectValue> +export type SessionRevertCommitOperation = (input: Endpoint4_16Input) => Effect.Effect -type Endpoint4_17Request = Parameters[0] -export type Endpoint4_17Input = { - readonly sessionID: Endpoint4_17Request["params"]["sessionID"] - readonly limit?: Endpoint4_17Request["query"]["limit"] - readonly after?: Endpoint4_17Request["query"]["after"] +type Endpoint4_17Request = Parameters[0] +export type Endpoint4_17Input = { readonly sessionID: Endpoint4_17Request["params"]["sessionID"] } +export type Endpoint4_17Output = EffectValue>["data"] +export type SessionContextOperation = (input: Endpoint4_17Input) => Effect.Effect + +type Endpoint4_18Request = Parameters[0] +export type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["sessionID"] } +export type Endpoint4_18Output = EffectValue< + ReturnType +>["data"] +export type SessionListContextEntriesOperation = ( + input: Endpoint4_18Input, +) => Effect.Effect + +type Endpoint4_19Request = Parameters[0] +export type Endpoint4_19Input = { + readonly sessionID: Endpoint4_19Request["params"]["sessionID"] + readonly key: Endpoint4_19Request["params"]["key"] + readonly value: Endpoint4_19Request["payload"]["value"] } -export type Endpoint4_17Output = EffectValue> -export type SessionHistoryOperation = (input: Endpoint4_17Input) => Effect.Effect +export type Endpoint4_19Output = EffectValue> +export type SessionPutContextEntryOperation = ( + input: Endpoint4_19Input, +) => Effect.Effect -type Endpoint4_18Request = Parameters[0] -export type Endpoint4_18Input = { - readonly sessionID: Endpoint4_18Request["params"]["sessionID"] - readonly after?: Endpoint4_18Request["query"]["after"] +type Endpoint4_20Request = Parameters[0] +export type Endpoint4_20Input = { + readonly sessionID: Endpoint4_20Request["params"]["sessionID"] + readonly key: Endpoint4_20Request["params"]["key"] } -export type Endpoint4_18Output = StreamValue>> -export type SessionEventsOperation = (input: Endpoint4_18Input) => Stream.Stream +export type Endpoint4_20Output = EffectValue> +export type SessionRemoveContextEntryOperation = ( + input: Endpoint4_20Input, +) => Effect.Effect -type Endpoint4_19Request = Parameters[0] -export type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] } -export type Endpoint4_19Output = EffectValue> -export type SessionInterruptOperation = (input: Endpoint4_19Input) => Effect.Effect - -type Endpoint4_20Request = Parameters[0] -export type Endpoint4_20Input = { readonly sessionID: Endpoint4_20Request["params"]["sessionID"] } -export type Endpoint4_20Output = EffectValue> -export type SessionBackgroundOperation = (input: Endpoint4_20Input) => Effect.Effect - -type Endpoint4_21Request = Parameters[0] +type Endpoint4_21Request = Parameters[0] export type Endpoint4_21Input = { readonly sessionID: Endpoint4_21Request["params"]["sessionID"] - readonly messageID: Endpoint4_21Request["params"]["messageID"] + readonly limit?: Endpoint4_21Request["query"]["limit"] + readonly after?: Endpoint4_21Request["query"]["after"] } -export type Endpoint4_21Output = EffectValue>["data"] -export type SessionMessageOperation = (input: Endpoint4_21Input) => Effect.Effect +export type Endpoint4_21Output = EffectValue> +export type SessionHistoryOperation = (input: Endpoint4_21Input) => Effect.Effect + +type Endpoint4_22Request = Parameters[0] +export type Endpoint4_22Input = { + readonly sessionID: Endpoint4_22Request["params"]["sessionID"] + readonly after?: Endpoint4_22Request["query"]["after"] +} +export type Endpoint4_22Output = StreamValue>> +export type SessionEventsOperation = (input: Endpoint4_22Input) => Stream.Stream + +type Endpoint4_23Request = Parameters[0] +export type Endpoint4_23Input = { readonly sessionID: Endpoint4_23Request["params"]["sessionID"] } +export type Endpoint4_23Output = EffectValue> +export type SessionInterruptOperation = (input: Endpoint4_23Input) => Effect.Effect + +type Endpoint4_24Request = Parameters[0] +export type Endpoint4_24Input = { readonly sessionID: Endpoint4_24Request["params"]["sessionID"] } +export type Endpoint4_24Output = EffectValue> +export type SessionBackgroundOperation = (input: Endpoint4_24Input) => Effect.Effect + +type Endpoint4_25Request = Parameters[0] +export type Endpoint4_25Input = { + readonly sessionID: Endpoint4_25Request["params"]["sessionID"] + readonly messageID: Endpoint4_25Request["params"]["messageID"] +} +export type Endpoint4_25Output = EffectValue>["data"] +export type SessionMessageOperation = (input: Endpoint4_25Input) => Effect.Effect export interface SessionApi { readonly list: SessionListOperation @@ -215,6 +261,7 @@ export interface SessionApi { readonly switchModel: SessionSwitchModelOperation readonly rename: SessionRenameOperation readonly prompt: SessionPromptOperation + readonly command: SessionCommandOperation readonly skill: SessionSkillOperation readonly synthetic: SessionSyntheticOperation readonly compact: SessionCompactOperation @@ -223,6 +270,9 @@ export interface SessionApi { readonly revertClear: SessionRevertClearOperation readonly revertCommit: SessionRevertCommitOperation readonly context: SessionContextOperation + readonly listContextEntries: SessionListContextEntriesOperation + readonly putContextEntry: SessionPutContextEntryOperation + readonly removeContextEntry: SessionRemoveContextEntryOperation readonly history: SessionHistoryOperation readonly events: SessionEventsOperation readonly interrupt: SessionInterruptOperation @@ -249,8 +299,14 @@ export type Endpoint6_0Input = { readonly location?: Endpoint6_0Request["query"] export type Endpoint6_0Output = EffectValue> export type ModelListOperation = (input?: Endpoint6_0Input) => Effect.Effect +type Endpoint6_1Request = Parameters[0] +export type Endpoint6_1Input = { readonly location?: Endpoint6_1Request["query"]["location"] } +export type Endpoint6_1Output = EffectValue> +export type ModelDefaultOperation = (input?: Endpoint6_1Input) => Effect.Effect + export interface ModelApi { readonly list: ModelListOperation + readonly default: ModelDefaultOperation } type Endpoint7_0Request = Parameters[0] diff --git a/packages/plugin/src/v2/effect/runtime.ts b/packages/plugin/src/v2/effect/runtime.ts index 1907067320..ca2074a977 100644 --- a/packages/plugin/src/v2/effect/runtime.ts +++ b/packages/plugin/src/v2/effect/runtime.ts @@ -1,3 +1,3 @@ import type { SessionApi } from "./generated/api.js" -export type SessionDomain = Pick, "create" | "get" | "prompt" | "interrupt"> +export type SessionDomain = Pick, "create" | "get" | "prompt" | "command" | "interrupt"> diff --git a/packages/protocol/src/client.ts b/packages/protocol/src/client.ts index 30392887a3..0744b15e94 100644 --- a/packages/protocol/src/client.ts +++ b/packages/protocol/src/client.ts @@ -59,6 +59,9 @@ export const endpointNames = { "integration.attempt.status": "attemptStatus", "integration.attempt.complete": "attemptComplete", "integration.attempt.cancel": "attemptCancel", + "session.context.entry.list": "listContextEntries", + "session.context.entry.put": "putContextEntry", + "session.context.entry.remove": "removeContextEntry", "session.revert.stage": "revertStage", "session.revert.clear": "revertClear", "session.revert.commit": "revertCommit", diff --git a/packages/protocol/src/errors.ts b/packages/protocol/src/errors.ts index 7f6893bae0..fdd3083439 100644 --- a/packages/protocol/src/errors.ts +++ b/packages/protocol/src/errors.ts @@ -89,6 +89,24 @@ export class SkillNotFoundError extends Schema.TaggedErrorClass()( + "CommandNotFoundError", + { + command: Schema.String, + message: Schema.String, + }, + { httpApiStatus: 404 }, +) {} + +export class CommandEvaluationError extends Schema.TaggedErrorClass()( + "CommandEvaluationError", + { + command: Schema.String, + message: Schema.String, + }, + { httpApiStatus: 500 }, +) {} + export class InvalidCursorError extends Schema.TaggedErrorClass()( "InvalidCursorError", { message: Schema.String }, diff --git a/packages/protocol/src/groups/model.ts b/packages/protocol/src/groups/model.ts index c8333eafb5..071a1b4735 100644 --- a/packages/protocol/src/groups/model.ts +++ b/packages/protocol/src/groups/model.ts @@ -21,6 +21,21 @@ export const ModelGroup = HttpApiGroup.make("server.model") }), ), ) + .add( + HttpApiEndpoint.get("model.default", "/api/model/default", { + query: LocationQuery, + success: Location.response(Schema.UndefinedOr(Model.Info)), + error: ServiceUnavailableError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.model.default", + summary: "Get default model", + description: "Retrieve the model used when a session has no explicit model selection.", + }), + ), + ) .annotateMerge( OpenApi.annotations({ title: "models", diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index 643bf6f4a9..d7285a69c9 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -2,6 +2,7 @@ import { SessionMessage } from "@opencode-ai/schema/session-message" import { SessionInput } from "@opencode-ai/schema/session-input" import { PromptInput } from "@opencode-ai/schema/prompt-input" import { Session } from "@opencode-ai/schema/session" +import { SessionContextEntry } from "@opencode-ai/schema/session-context-entry" 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" @@ -9,6 +10,8 @@ import { Context, Effect, Encoding, Result, Schema, Struct } from "effect" import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { ConflictError, + CommandEvaluationError, + CommandNotFoundError, InvalidCursorError, InvalidRequestError, MessageNotFoundError, @@ -257,6 +260,32 @@ export const makeSessionGroup = (sessionLo }), ), ) + .add( + HttpApiEndpoint.post("session.command", "/api/session/:sessionID/command", { + params: { sessionID: Session.ID }, + payload: Schema.Struct({ + id: SessionMessage.ID.pipe(Schema.optional), + command: Schema.String, + arguments: Schema.String.pipe(Schema.optional), + agent: Schema.String.pipe(Schema.optional), + model: Model.Ref.pipe(Schema.optional), + files: PromptInput.Prompt.fields.files, + agents: PromptInput.Prompt.fields.agents, + delivery: SessionInput.Delivery.pipe(Schema.optional), + resume: Schema.Boolean.pipe(Schema.optional), + }), + success: Schema.Struct({ data: SessionInput.Admitted }), + error: [ConflictError, SessionNotFoundError, CommandNotFoundError, CommandEvaluationError], + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.command", + summary: "Run command", + description: "Resolve a slash command into prompt input, admit it durably, and schedule execution unless resume is false.", + }), + ), + ) .add( HttpApiEndpoint.post("session.skill", "/api/session/:sessionID/skill", { params: { sessionID: Session.ID }, @@ -378,6 +407,53 @@ export const makeSessionGroup = (sessionLo }), ), ) + .add( + HttpApiEndpoint.get("session.context.entry.list", "/api/session/:sessionID/context-entry", { + params: { sessionID: Session.ID }, + success: Schema.Struct({ data: Schema.Array(SessionContextEntry.Info) }), + error: SessionNotFoundError, + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.context.entry.list", + summary: "List context entries", + description: "List API-managed context entries attached to the session's system context.", + }), + ), + ) + .add( + HttpApiEndpoint.put("session.context.entry.put", "/api/session/:sessionID/context-entry/:key", { + params: { sessionID: Session.ID, key: SessionContextEntry.Key }, + payload: Schema.Struct({ value: Schema.Json }), + success: HttpApiSchema.NoContent, + error: SessionNotFoundError, + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.context.entry.put", + summary: "Put context entry", + description: + "Attach or replace one durable context entry. The value is rendered into the session's system context; changes announce as updates at the next turn boundary.", + }), + ), + ) + .add( + HttpApiEndpoint.delete("session.context.entry.remove", "/api/session/:sessionID/context-entry/:key", { + params: { sessionID: Session.ID, key: SessionContextEntry.Key }, + success: HttpApiSchema.NoContent, + error: SessionNotFoundError, + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.context.entry.remove", + summary: "Remove context entry", + description: "Remove one context entry; the removal is announced to the model at the next turn boundary.", + }), + ), + ) .add( HttpApiEndpoint.get("session.history", "/api/session/:sessionID/history", { params: { sessionID: Session.ID }, diff --git a/packages/schema/src/agent.ts b/packages/schema/src/agent.ts index f10cba2941..3d8068b89a 100644 --- a/packages/schema/src/agent.ts +++ b/packages/schema/src/agent.ts @@ -38,7 +38,7 @@ export const Info = Schema.Struct({ empty: (id: ID) => schema.make({ id, - request: { headers: {}, body: {} }, + request: { settings: {}, headers: {}, body: {} }, mode: "all", hidden: false, permissions: [ diff --git a/packages/schema/src/command.ts b/packages/schema/src/command.ts index 35f2a66e18..fb88087dbd 100644 --- a/packages/schema/src/command.ts +++ b/packages/schema/src/command.ts @@ -1,9 +1,12 @@ export * as Command from "./command.js" import { Schema } from "effect" +import { define, inventory } from "./event.js" import { optional } from "./schema.js" import { Model } from "./model.js" +const Updated = define({ type: "command.updated", schema: {} }) + export interface Info extends Schema.Schema.Type {} export const Info = Schema.Struct({ name: Schema.String, @@ -13,3 +16,8 @@ export const Info = Schema.Struct({ model: Model.Ref.pipe(optional), subtask: Schema.Boolean.pipe(optional), }).annotate({ identifier: "CommandV2.Info" }) + +export const Event = { + Updated, + Definitions: inventory(Updated), +} diff --git a/packages/schema/src/event-manifest.ts b/packages/schema/src/event-manifest.ts index 5e8a2871f3..0179547ca0 100644 --- a/packages/schema/src/event-manifest.ts +++ b/packages/schema/src/event-manifest.ts @@ -2,6 +2,7 @@ export * as EventManifest from "./event-manifest.js" import { Agent } from "./agent.js" import { Catalog } from "./catalog.js" +import { Command } from "./command.js" import { Durable } from "./durable-event-manifest.js" import { Event } from "./event.js" import { FileSystem } from "./filesystem.js" @@ -53,6 +54,7 @@ const featureDefinitions = Event.inventory( ...Permission.Event.Definitions, ...Plugin.Event.Definitions, ...ProjectDirectories.Event.Definitions, + ...Command.Event.Definitions, ...Skill.Event.Definitions, ...FileSystemWatcher.Event.Definitions, ...Pty.Event.Definitions, diff --git a/packages/schema/src/model.ts b/packages/schema/src/model.ts index b4c1337be1..19276b6d00 100644 --- a/packages/schema/src/model.ts +++ b/packages/schema/src/model.ts @@ -94,7 +94,7 @@ export const Info = Schema.Struct({ name: modelID, api: { id: modelID, type: "native", settings: {} }, capabilities: { tools: false, input: [], output: [] }, - request: { headers: {}, body: {} }, + request: { settings: {}, headers: {}, body: {} }, variants: [], time: { released: 0 }, cost: [], diff --git a/packages/schema/src/provider.ts b/packages/schema/src/provider.ts index 2369d8a08e..daa53f8430 100644 --- a/packages/schema/src/provider.ts +++ b/packages/schema/src/provider.ts @@ -1,6 +1,6 @@ export * as Provider from "./provider.js" -import { Schema } from "effect" +import { Effect, Schema } from "effect" import { optional } from "./schema.js" import { Integration } from "./integration.js" import { statics } from "./schema.js" @@ -43,8 +43,12 @@ export const Api = Schema.Union([AISDK, Native]) .annotate({ identifier: "Provider.Api" }) export type Api = typeof Api.Type +export const Settings = Schema.Record(Schema.String, Schema.Unknown).annotate({ identifier: "Provider.Settings" }) +export type Settings = typeof Settings.Type + export interface Request extends Schema.Schema.Type {} export const Request = Schema.Struct({ + settings: Settings.pipe(Schema.withConstructorDefault(Effect.succeed({}))), headers: Schema.Record(Schema.String, Schema.String), body: Schema.Record(Schema.String, Schema.Json), }).annotate({ identifier: "Provider.Request" }) @@ -66,7 +70,7 @@ export const Info = Schema.Struct({ id, name: id, api: { type: "native", settings: {} }, - request: { headers: {}, body: {} }, + request: { settings: {}, headers: {}, body: {} }, }), })), ) diff --git a/packages/schema/src/session-context-entry.ts b/packages/schema/src/session-context-entry.ts new file mode 100644 index 0000000000..fbaeb53062 --- /dev/null +++ b/packages/schema/src/session-context-entry.ts @@ -0,0 +1,20 @@ +export * as SessionContextEntry from "./session-context-entry.js" + +import { Schema } from "effect" + +/** + * Slash-free client-facing key for one API-managed context entry. The server + * derives the namespaced SystemContext key as `api/`, keeping the + * `api/*` namespace enforced by construction. + */ +export const Key = Schema.String.check(Schema.isPattern(/^[a-z0-9][a-z0-9._-]*$/)).annotate({ + identifier: "SessionContextEntry.Key", + description: "Context entry key (lowercase alphanumerics plus . _ -)", +}) +export type Key = typeof Key.Type + +export const Info = Schema.Struct({ + key: Key, + value: Schema.Json.annotate({ description: "JSON value attached to the session's system context" }), +}).annotate({ identifier: "SessionContextEntry.Info" }) +export interface Info extends Schema.Schema.Type {} diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index a5c9f95eff..94b50cab2a 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -133,6 +133,16 @@ export const PromptAdmitted = Event.define({ }) export type PromptAdmitted = typeof PromptAdmitted.Type +export const ExecutionSettled = Event.define({ + type: "session.next.execution.settled", + schema: { + ...Base, + outcome: Schema.Literals(["success", "failure", "interrupted"]), + error: UnknownError.pipe(optional), + }, +}) +export type ExecutionSettled = typeof ExecutionSettled.Type + export const ContextUpdated = Event.define({ type: "session.next.context.updated", ...options, @@ -538,6 +548,7 @@ export const Definitions = Event.inventory( Forked, Prompted, PromptAdmitted, + ExecutionSettled, ContextUpdated, Synthetic, Skill.Activated, diff --git a/packages/sdk-next/test/embedded.test.ts b/packages/sdk-next/test/embedded.test.ts index 8f19ce3abc..ae8b54d648 100644 --- a/packages/sdk-next/test/embedded.test.ts +++ b/packages/sdk-next/test/embedded.test.ts @@ -68,6 +68,11 @@ it.live( resume: false, }) const context = yield* opencode.sessions.context({ sessionID: id }) + yield* opencode.sessions.putContextEntry({ sessionID: id, key: "deploy-target", value: "production" }) + yield* opencode.sessions.putContextEntry({ sessionID: id, key: "flags", value: { beta: true } }) + const contextEntries = yield* opencode.sessions.listContextEntries({ sessionID: id }) + yield* opencode.sessions.removeContextEntry({ sessionID: id, key: "flags" }) + const remainingContextEntries = yield* opencode.sessions.listContextEntries({ sessionID: id }) const wake = yield* opencode.sessions.prompt({ sessionID: id, prompt: fixture.sdk.Prompt.make({ text: "Promote this input" }), @@ -94,6 +99,7 @@ it.live( opencode.sessions.events({ sessionID: missingSessionID }).pipe(Stream.runHead, Effect.flip), opencode.sessions.interrupt({ sessionID: missingSessionID }).pipe(Effect.flip), opencode.sessions.message({ sessionID: missingSessionID, messageID: modelMessage.id }).pipe(Effect.flip), + opencode.sessions.listContextEntries({ sessionID: missingSessionID }).pipe(Effect.flip), ], { concurrency: "unbounded" }, ) @@ -112,6 +118,11 @@ it.live( expect(admitted.sessionID).toBe(id) expect(prompted.type).toBe("session.next.prompted") expect(wakeContext).toContainEqual(expect.objectContaining({ id: wake.id, type: "user" })) + expect(contextEntries).toEqual([ + { key: "deploy-target", value: "production" }, + { key: "flags", value: { beta: true } }, + ]) + expect(remainingContextEntries).toEqual([{ key: "deploy-target", value: "production" }]) expect(context.some((message) => message.type === "model-switched")).toBe(true) expect(event).toMatchObject({ type: "session.next.model.switched", durable: { seq: 1 } }) expect(message).toEqual(modelMessage) @@ -119,6 +130,7 @@ it.live( "SessionNotFoundError", "SessionNotFoundError", "SessionNotFoundError", + "SessionNotFoundError", ]) expect(missingMessage._tag).toBe("MessageNotFoundError") }), diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 145ae005b3..244915bf4c 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -142,7 +142,9 @@ import type { ProjectListResponses, ProjectUpdateErrors, ProjectUpdateResponses, + PromptAgentAttachment, PromptInput, + PromptInputFileAttachment, ProviderAuthErrors, ProviderAuthResponses, ProviderListErrors, @@ -181,6 +183,7 @@ import type { SessionChildrenResponses, SessionCommandErrors, SessionCommandResponses, + SessionContextEntryKey, SessionCreateErrors, SessionCreateResponses, SessionDeleteErrors, @@ -301,6 +304,8 @@ import type { V2LocationGetResponses, V2McpListErrors, V2McpListResponses, + V2ModelDefaultErrors, + V2ModelDefaultResponses, V2ModelListErrors, V2ModelListResponses, V2PermissionRequestListErrors, @@ -347,8 +352,16 @@ import type { V2SessionActiveResponses, V2SessionBackgroundErrors, V2SessionBackgroundResponses, + V2SessionCommandErrors, + V2SessionCommandResponses, V2SessionCompactErrors, V2SessionCompactResponses, + V2SessionContextEntryListErrors, + V2SessionContextEntryListResponses, + V2SessionContextEntryPutErrors, + V2SessionContextEntryPutResponses, + V2SessionContextEntryRemoveErrors, + V2SessionContextEntryRemoveResponses, V2SessionContextErrors, V2SessionContextResponses, V2SessionCreateErrors, @@ -399,6 +412,8 @@ import type { V2SessionSwitchAgentResponses, V2SessionSwitchModelErrors, V2SessionSwitchModelResponses, + V2SessionSyntheticErrors, + V2SessionSyntheticResponses, V2SessionWaitErrors, V2SessionWaitResponses, V2ShellCreateErrors, @@ -5220,6 +5235,113 @@ export class Revert extends HeyApiClient { } } +export class Entry extends HeyApiClient { + /** + * List context entries + * + * List API-managed context entries attached to the session's system context. + */ + public list( + parameters: { + sessionID: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "sessionID" }] }]) + return (options?.client ?? this.client).get< + V2SessionContextEntryListResponses, + V2SessionContextEntryListErrors, + ThrowOnError + >({ + url: "/api/session/{sessionID}/context-entry", + ...options, + ...params, + }) + } + + /** + * Remove context entry + * + * Remove one context entry; the removal is announced to the model at the next turn boundary. + */ + public remove( + parameters: { + sessionID: string + key: SessionContextEntryKey + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "path", key: "key" }, + ], + }, + ], + ) + return (options?.client ?? this.client).delete< + V2SessionContextEntryRemoveResponses, + V2SessionContextEntryRemoveErrors, + ThrowOnError + >({ + url: "/api/session/{sessionID}/context-entry/{key}", + ...options, + ...params, + }) + } + + /** + * Put context entry + * + * Attach or replace one durable context entry. The value is rendered into the session's system context; changes announce as updates at the next turn boundary. + */ + public put( + parameters: { + sessionID: string + key: SessionContextEntryKey + value?: unknown + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "path", key: "key" }, + { in: "body", key: "value" }, + ], + }, + ], + ) + return (options?.client ?? this.client).put< + V2SessionContextEntryPutResponses, + V2SessionContextEntryPutErrors, + ThrowOnError + >({ + url: "/api/session/{sessionID}/context-entry/{key}", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + +export class Context extends HeyApiClient { + private _entry?: Entry + get entry(): Entry { + return (this._entry ??= new Entry({ client: this.client })) + } +} + export class Permission2 extends HeyApiClient { /** * List session permission requests @@ -5777,6 +5899,57 @@ export class Session3 extends HeyApiClient { }) } + /** + * Run command + * + * Resolve a slash command into prompt input, admit it durably, and schedule execution unless resume is false. + */ + public command( + parameters: { + sessionID: string + id?: string + command?: string + arguments?: string + agent?: string + model?: ModelRef + files?: Array + agents?: Array + delivery?: "steer" | "queue" + resume?: boolean + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "body", key: "id" }, + { in: "body", key: "command" }, + { in: "body", key: "arguments" }, + { in: "body", key: "agent" }, + { in: "body", key: "model" }, + { in: "body", key: "files" }, + { in: "body", key: "agents" }, + { in: "body", key: "delivery" }, + { in: "body", key: "resume" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/api/session/{sessionID}/command", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + /** * Activate skill * @@ -5816,6 +5989,47 @@ export class Session3 extends HeyApiClient { }) } + /** + * Add synthetic message + * + * Append a synthetic message to a session and resume execution. + */ + public synthetic( + parameters: { + sessionID: string + text?: string + description?: string + metadata?: { + [key: string]: unknown + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "body", key: "text" }, + { in: "body", key: "description" }, + { in: "body", key: "metadata" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/api/session/{sessionID}/synthetic", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + /** * Compact session * @@ -6044,6 +6258,11 @@ export class Session3 extends HeyApiClient { return (this._revert ??= new Revert({ client: this.client })) } + private _context?: Context + get context2(): Context { + return (this._context ??= new Context({ client: this.client })) + } + private _permission?: Permission2 get permission(): Permission2 { return (this._permission ??= new Permission2({ client: this.client })) @@ -6077,6 +6296,28 @@ export class Model extends HeyApiClient { ...params, }) } + + /** + * Get default model + * + * Retrieve the model used when a session has no explicit model selection. + */ + public default( + parameters?: { + location?: { + directory?: string + workspace?: string + } + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) + return (options?.client ?? this.client).get({ + url: "/api/model/default", + ...options, + ...params, + }) + } } export class Generate extends HeyApiClient { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 55eac01f48..c65b44c9c8 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -24,6 +24,7 @@ export type Event = | EventSessionNextForked | EventSessionNextPrompted | EventSessionNextPromptAdmitted + | EventSessionNextExecutionSettled | EventSessionNextContextUpdated | EventSessionNextSynthetic | EventSessionNextSkillActivated @@ -63,6 +64,7 @@ export type Event = | EventPermissionV2Replied | EventPluginAdded | EventProjectDirectoriesUpdated + | EventCommandUpdated | EventSkillUpdated | EventFileWatcherUpdated | EventPtyCreated @@ -923,6 +925,16 @@ export type GlobalEvent = { delivery: "steer" | "queue" } } + | { + id: string + type: "session.next.execution.settled" + properties: { + timestamp: number + sessionID: string + outcome: "success" | "failure" | "interrupted" + error?: SessionErrorUnknown + } + } | { id: string type: "session.next.context.updated" @@ -942,6 +954,9 @@ export type GlobalEvent = { messageID: string text: string description?: string + metadata?: { + [key: string]: unknown + } } } | { @@ -1356,6 +1371,13 @@ export type GlobalEvent = { projectID: string } } + | { + id: string + type: "command.updated" + properties: { + [key: string]: unknown + } + } | { id: string type: "skill.updated" @@ -2826,6 +2848,18 @@ export type ConflictError = { resource?: string } +export type CommandNotFoundError = { + _tag: "CommandNotFoundError" + command: string + message: string +} + +export type CommandEvaluationError = { + _tag: "CommandEvaluationError" + command: string + message: string +} + export type SkillNotFoundError = { _tag: "SkillNotFoundError" skill: string @@ -3007,6 +3041,7 @@ export type V2Event = | SessionNextForked | SessionNextPrompted | SessionNextPromptAdmitted + | SessionNextExecutionSettled | SessionNextContextUpdated | SessionNextSynthetic | SessionNextSkillActivated @@ -3046,6 +3081,7 @@ export type V2Event = | PermissionV2Replied | PluginAdded | ProjectDirectoriesUpdated + | CommandUpdated | SkillUpdated | FileWatcherUpdated | PtyCreated @@ -3618,6 +3654,9 @@ export type SyncEventSessionNextSynthetic = { messageID: string text: string description?: string + metadata?: { + [key: string]: unknown + } } } } @@ -4091,7 +4130,12 @@ export type LocationInfo = { } } +export type ProviderSettings = { + [key: string]: unknown +} + export type ProviderRequest = { + settings: ProviderSettings headers: { [key: string]: string } @@ -4404,6 +4448,13 @@ export type SessionMessage = | SessionMessageAssistant | SessionMessageCompaction +export type SessionContextEntryKey = string + +export type SessionContextEntryInfo = { + key: SessionContextEntryKey + value: unknown +} + export type SessionNextAgentSwitched = { id: string metadata?: { @@ -4583,6 +4634,9 @@ export type SessionNextSynthetic = { messageID: string text: string description?: string + metadata?: { + [key: string]: unknown + } } } @@ -5120,6 +5174,7 @@ export type ModelV2Info = { api: ModelApi capabilities: ModelCapabilities request: { + settings: ProviderSettings headers: { [key: string]: string } @@ -5130,6 +5185,7 @@ export type ModelV2Info = { } variants: Array<{ id: string + settings: ProviderSettings headers: { [key: string]: string } @@ -5581,6 +5637,26 @@ export type MessagePartRemoved = { } } +export type SessionNextExecutionSettled = { + id: string + metadata?: { + [key: string]: unknown + } + type: "session.next.execution.settled" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + timestamp: number + sessionID: string + outcome: "success" | "failure" | "interrupted" + error?: SessionErrorUnknown + } +} + export type SessionNextTextDelta = { id: string metadata?: { @@ -5875,6 +5951,23 @@ export type ProjectDirectoriesUpdated = { } } +export type CommandUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "command.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + [key: string]: unknown + } +} + export type SkillUpdated = { id: string metadata?: { @@ -6786,6 +6879,17 @@ export type EventSessionNextPromptAdmitted = { } } +export type EventSessionNextExecutionSettled = { + id: string + type: "session.next.execution.settled" + properties: { + timestamp: number + sessionID: string + outcome: "success" | "failure" | "interrupted" + error?: SessionErrorUnknown + } +} + export type EventSessionNextContextUpdated = { id: string type: "session.next.context.updated" @@ -6806,6 +6910,9 @@ export type EventSessionNextSynthetic = { messageID: string text: string description?: string + metadata?: { + [key: string]: unknown + } } } @@ -7258,6 +7365,14 @@ export type EventProjectDirectoriesUpdated = { } } +export type EventCommandUpdated = { + id: string + type: "command.updated" + properties: { + [key: string]: unknown + } +} + export type EventSkillUpdated = { id: string type: "skill.updated" @@ -12245,6 +12360,61 @@ export type V2SessionPromptResponses = { export type V2SessionPromptResponse = V2SessionPromptResponses[keyof V2SessionPromptResponses] +export type V2SessionCommandData = { + body: { + id?: string + command: string + arguments?: string + agent?: string + model?: ModelRef + files?: Array + agents?: Array + delivery?: "steer" | "queue" + resume?: boolean + } + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/command" +} + +export type V2SessionCommandErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError | CommandNotFoundError + */ + 404: CommandNotFoundError | SessionNotFoundError + /** + * ConflictError + */ + 409: ConflictError + /** + * CommandEvaluationError + */ + 500: CommandEvaluationError +} + +export type V2SessionCommandError = V2SessionCommandErrors[keyof V2SessionCommandErrors] + +export type V2SessionCommandResponses = { + /** + * Success + */ + 200: { + data: SessionInputAdmitted + } +} + +export type V2SessionCommandResponse = V2SessionCommandResponses[keyof V2SessionCommandResponses] + export type V2SessionSkillData = { body: { id?: string @@ -12284,6 +12454,47 @@ export type V2SessionSkillResponses = { export type V2SessionSkillResponse = V2SessionSkillResponses[keyof V2SessionSkillResponses] +export type V2SessionSyntheticData = { + body: { + text: string + description?: string + metadata?: { + [key: string]: unknown + } + } + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/synthetic" +} + +export type V2SessionSyntheticErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError +} + +export type V2SessionSyntheticError = V2SessionSyntheticErrors[keyof V2SessionSyntheticErrors] + +export type V2SessionSyntheticResponses = { + /** + * + */ + 204: void +} + +export type V2SessionSyntheticResponse = V2SessionSyntheticResponses[keyof V2SessionSyntheticResponses] + export type V2SessionCompactData = { body?: never path: { @@ -12541,6 +12752,121 @@ export type V2SessionContextResponses = { export type V2SessionContextResponse = V2SessionContextResponses[keyof V2SessionContextResponses] +export type V2SessionContextEntryListData = { + body?: never + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/context-entry" +} + +export type V2SessionContextEntryListErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError +} + +export type V2SessionContextEntryListError = V2SessionContextEntryListErrors[keyof V2SessionContextEntryListErrors] + +export type V2SessionContextEntryListResponses = { + /** + * Success + */ + 200: { + data: Array + } +} + +export type V2SessionContextEntryListResponse = + V2SessionContextEntryListResponses[keyof V2SessionContextEntryListResponses] + +export type V2SessionContextEntryRemoveData = { + body?: never + path: { + sessionID: string + key: SessionContextEntryKey + } + query?: never + url: "/api/session/{sessionID}/context-entry/{key}" +} + +export type V2SessionContextEntryRemoveErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError +} + +export type V2SessionContextEntryRemoveError = + V2SessionContextEntryRemoveErrors[keyof V2SessionContextEntryRemoveErrors] + +export type V2SessionContextEntryRemoveResponses = { + /** + * + */ + 204: void +} + +export type V2SessionContextEntryRemoveResponse = + V2SessionContextEntryRemoveResponses[keyof V2SessionContextEntryRemoveResponses] + +export type V2SessionContextEntryPutData = { + body: { + value: unknown + } + path: { + sessionID: string + key: SessionContextEntryKey + } + query?: never + url: "/api/session/{sessionID}/context-entry/{key}" +} + +export type V2SessionContextEntryPutErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError +} + +export type V2SessionContextEntryPutError = V2SessionContextEntryPutErrors[keyof V2SessionContextEntryPutErrors] + +export type V2SessionContextEntryPutResponses = { + /** + * + */ + 204: void +} + +export type V2SessionContextEntryPutResponse = + V2SessionContextEntryPutResponses[keyof V2SessionContextEntryPutResponses] + export type V2SessionHistoryData = { body?: never path: { @@ -12815,6 +13141,47 @@ export type V2ModelListResponses = { export type V2ModelListResponse = V2ModelListResponses[keyof V2ModelListResponses] +export type V2ModelDefaultData = { + body?: never + path?: never + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/model/default" +} + +export type V2ModelDefaultErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * ServiceUnavailableError + */ + 503: ServiceUnavailableError +} + +export type V2ModelDefaultError = V2ModelDefaultErrors[keyof V2ModelDefaultErrors] + +export type V2ModelDefaultResponses = { + /** + * Success + */ + 200: { + location: LocationInfo + data: ModelV2Info + } +} + +export type V2ModelDefaultResponse = V2ModelDefaultResponses[keyof V2ModelDefaultResponses] + export type V2GenerateTextData = { body: { prompt: string diff --git a/packages/server/src/handlers/model.ts b/packages/server/src/handlers/model.ts index 36639ae7b1..5059804354 100644 --- a/packages/server/src/handlers/model.ts +++ b/packages/server/src/handlers/model.ts @@ -6,12 +6,20 @@ import { response } from "../location" export const ModelHandler = HttpApiBuilder.group(Api, "server.model", (handlers) => Effect.gen(function* () { - return handlers.handle( - "model.list", - Effect.fn(function* () { - const catalog = yield* Catalog.Service - return yield* response(catalog.model.available()) - }), - ) + return handlers + .handle( + "model.list", + Effect.fn(function* () { + const catalog = yield* Catalog.Service + return yield* response(catalog.model.available()) + }), + ) + .handle( + "model.default", + Effect.fn(function* () { + const catalog = yield* Catalog.Service + return yield* response(catalog.model.default()) + }), + ) }), ) diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index a61571e9a5..d53574758e 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -1,10 +1,13 @@ import { SessionV2 } from "@opencode-ai/core/session" +import { SessionContextEntry } from "@opencode-ai/core/session/context-entry" import { DateTime, Effect, Stream } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { Api } from "../api" import { SessionsCursor } from "@opencode-ai/protocol/groups/session" import { ConflictError, + CommandEvaluationError, + CommandNotFoundError, InvalidCursorError, MessageNotFoundError, ServiceUnavailableError, @@ -215,48 +218,106 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl } }), ) + .handle( + "session.command", + Effect.fn(function* (ctx) { + return { + data: yield* session + .command({ + sessionID: ctx.params.sessionID, + id: ctx.payload.id, + command: ctx.payload.command, + arguments: ctx.payload.arguments, + agent: ctx.payload.agent, + model: ctx.payload.model, + files: ctx.payload.files, + agents: ctx.payload.agents, + delivery: ctx.payload.delivery, + resume: ctx.payload.resume, + }) + .pipe( + Effect.catchTag("Session.NotFoundError", (error) => + Effect.fail( + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ), + Effect.catchTag("Command.NotFoundError", (error) => + Effect.fail( + new CommandNotFoundError({ + command: error.command, + message: error.message, + }), + ), + ), + Effect.catchTag("Command.EvaluationError", (error) => + Effect.fail( + new CommandEvaluationError({ + command: error.command, + message: error.message, + }), + ), + ), + Effect.catchTag("Session.PromptConflictError", (error) => + Effect.fail( + new ConflictError({ + message: `Prompt message ID conflicts with an existing durable record: ${error.messageID}`, + resource: error.messageID, + }), + ), + ), + ), + } + }), + ) .handle( "session.skill", Effect.fn(function* (ctx) { - yield* session.skill({ - sessionID: ctx.params.sessionID, - id: ctx.payload.id, - skill: ctx.payload.skill, - resume: ctx.payload.resume, - }).pipe( - Effect.catchTag("Session.NotFoundError", (error) => - Effect.fail( - new SessionNotFoundError({ - sessionID: error.sessionID, - message: `Session not found: ${error.sessionID}`, - }), + yield* session + .skill({ + sessionID: ctx.params.sessionID, + id: ctx.payload.id, + skill: ctx.payload.skill, + resume: ctx.payload.resume, + }) + .pipe( + Effect.catchTag("Session.NotFoundError", (error) => + Effect.fail( + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), ), - ), - Effect.catchTag("Session.SkillNotFoundError", (error) => - Effect.fail(new SkillNotFoundError({ skill: error.skill, message: `Skill not found: ${error.skill}` })), - ), - ) + Effect.catchTag("Session.SkillNotFoundError", (error) => + Effect.fail(new SkillNotFoundError({ skill: error.skill, message: `Skill not found: ${error.skill}` })), + ), + ) return HttpApiSchema.NoContent.make() }), ) .handle( "session.synthetic", Effect.fn(function* (ctx) { - yield* session.synthetic({ - sessionID: ctx.params.sessionID, - text: ctx.payload.text, - description: ctx.payload.description, - metadata: ctx.payload.metadata, - }).pipe( - Effect.catchTag("Session.NotFoundError", (error) => - Effect.fail( - new SessionNotFoundError({ - sessionID: error.sessionID, - message: `Session not found: ${error.sessionID}`, - }), + yield* session + .synthetic({ + sessionID: ctx.params.sessionID, + text: ctx.payload.text, + description: ctx.payload.description, + metadata: ctx.payload.metadata, + }) + .pipe( + Effect.catchTag("Session.NotFoundError", (error) => + Effect.fail( + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), ), - ), - ) + ) return HttpApiSchema.NoContent.make() }), ) @@ -462,6 +523,29 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl } }), ) + .handle( + "session.context.entry.list", + Effect.fn(function* (ctx) { + const contextEntries = yield* SessionContextEntry.Service + return { data: yield* contextEntries.list(ctx.params.sessionID) } + }), + ) + .handle( + "session.context.entry.put", + Effect.fn(function* (ctx) { + const contextEntries = yield* SessionContextEntry.Service + yield* contextEntries.put({ sessionID: ctx.params.sessionID, key: ctx.params.key, value: ctx.payload.value }) + return HttpApiSchema.NoContent.make() + }), + ) + .handle( + "session.context.entry.remove", + Effect.fn(function* (ctx) { + const contextEntries = yield* SessionContextEntry.Service + yield* contextEntries.remove({ sessionID: ctx.params.sessionID, key: ctx.params.key }) + return HttpApiSchema.NoContent.make() + }), + ) .handle( "session.history", Effect.fn(function* (ctx) { diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 32c7d4a880..7aff0f8d79 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -8,7 +8,7 @@ import { ClipboardProvider, useClipboard } from "./context/clipboard" import { ExitProvider, useExit } from "./context/exit" import { EpilogueProvider } from "./context/epilogue" import * as Selection from "./util/selection" -import { createCliRenderer, MouseButton } from "@opentui/core" +import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core" import { RouteProvider, useRoute } from "./context/route" import { Switch, @@ -184,8 +184,8 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { Effect.gen(function* () { const renderer = yield* Effect.acquireRelease( Effect.tryPromise({ - try: () => - createCliRenderer({ + try: async () => { + const options = { externalOutputMode: "passthrough", targetFps: 60, gatherStats: false, @@ -197,7 +197,15 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { consoleOptions: { keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }], }, - }), + } satisfies CliRendererConfig + + if (process.env.OPENCODE_SIMULATION === "1" || process.env.OPENCODE_SIMULATION === "true") { + const { Simulation } = await import("./simulation/simulation") + return Simulation.createSimulation(options) + } + + return createCliRenderer(options) + }, catch: (error) => (error instanceof Error ? error : new Error(String(error))), }), (renderer) => @@ -1116,6 +1124,34 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi return render({ params: route.data.data }) }) + // Suppress the full-screen reconnecting overlay for transient disconnects (initial startup, host + // reload, sub-second event-stream blips). After the first successful connect, show it only once the + // connection has been lost for a full second; before the first connect give a longer grace period so + // startup never flashes it, but a server that dies before ever connecting still surfaces instead of + // leaving a silent empty app. Hide it immediately the moment status leaves "connecting". + const [showReconnecting, setShowReconnecting] = createSignal(false) + let reconnectTimer: ReturnType | undefined + createEffect(() => { + if (reconnectTimer) { + clearTimeout(reconnectTimer) + reconnectTimer = undefined + } + if (sdk.connection.status() !== "connecting") { + setShowReconnecting(false) + return + } + reconnectTimer = setTimeout( + () => { + reconnectTimer = undefined + setShowReconnecting(true) + }, + sdk.connection.connectedOnce() ? 1000 : 5000, + ).unref() + }) + onCleanup(() => { + if (reconnectTimer) clearTimeout(reconnectTimer) + }) + return ( Promise; pluginHost: TuiPlugi - + diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index e182d537ff..7b0724e5dc 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -1125,15 +1125,46 @@ export function Prompt(props: PromptProps) { const restOfInput = firstLineEnd === -1 ? "" : inputText.slice(firstLineEnd + 1) const args = firstLineArgs.join(" ") + (restOfInput ? "\n" + restOfInput : "") - void sdk.client.session.command({ - sessionID, - command: command.slice(1), - arguments: args, - agent: agent.id, - model: `${selectedModel.providerID}/${selectedModel.modelID}`, - variant, - parts: nonTextParts.filter((x) => x.type === "file"), - }) + void sdk.api.session + .command({ + sessionID, + command: command.slice(1), + arguments: args, + agent: agent.id, + model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant }, + files: nonTextParts.flatMap((part) => + part.type === "file" + ? [ + { + uri: part.url, + name: part.filename, + source: part.source + ? { + start: part.source.text.start, + end: part.source.text.end, + text: part.source.text.value, + } + : undefined, + }, + ] + : [], + ), + agents: nonTextParts.flatMap((part) => + part.type === "agent" + ? [ + { + name: part.name, + source: part.source + ? { start: part.source.start, end: part.source.end, text: part.source.value } + : undefined, + }, + ] + : [], + ), + }) + .catch((error) => { + toast.show({ title: "Failed to run command", message: errorMessage(error), variant: "error" }) + }) } else if ( inputText.startsWith("/") && (data.location.skill.list(currentLocation()) ?? []).some( diff --git a/packages/tui/src/component/use-connected.tsx b/packages/tui/src/component/use-connected.tsx index 4a54ae68e1..4fdbe4a57c 100644 --- a/packages/tui/src/component/use-connected.tsx +++ b/packages/tui/src/component/use-connected.tsx @@ -1,13 +1,8 @@ import { createMemo } from "solid-js" import { useData } from "../context/data" -import { useSync } from "../context/sync" +import { hasConnectedProvider } from "../util/connected-provider" export function useConnected() { const data = useData() - const sync = useSync() - return createMemo( - () => - (data.location.integration.list() ?? []).some((integration) => integration.connections.length > 0) || - sync.data.console_state.consoleManagedProviders.length > 0, - ) + return createMemo(() => hasConnectedProvider(data.location.integration.list() ?? [])) } diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 88e3649e63..869c18d1fd 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -208,6 +208,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ case "agent.updated": void result.location.agent.refresh(event.location) break + case "command.updated": + void result.location.command.refresh(event.location) + break case "skill.updated": void result.location.skill.refresh(event.location) break @@ -636,6 +639,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ error() { return sdk.connection.error() }, + connectedOnce() { + return sdk.connection.connectedOnce() + }, }, session: { list() { diff --git a/packages/tui/src/context/sdk.tsx b/packages/tui/src/context/sdk.tsx index 0d5cce8a8f..f8297dd80b 100644 --- a/packages/tui/src/context/sdk.tsx +++ b/packages/tui/src/context/sdk.tsx @@ -25,9 +25,11 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ status: SDKConnectionStatus attempt: number error?: string + connectedOnce: boolean }>({ status: "connecting", attempt: 0, + connectedOnce: false, }) let stream: AbortController | undefined let pending: Promise | undefined @@ -68,7 +70,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ clearTimeout(timeout) attempt = 0 events.emit(first.value.type, first.value) - setConnection({ status: "connected", attempt: 0, error: undefined }) + setConnection({ status: "connected", attempt: 0, error: undefined, connectedOnce: true }) connected() while (!abort.signal.aborted && !controller.signal.aborted) { const event = await iterator.next() @@ -140,6 +142,9 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ error() { return connection.error }, + connectedOnce() { + return connection.connectedOnce + }, }, reload, } diff --git a/packages/tui/src/feature-plugins/home/tips.tsx b/packages/tui/src/feature-plugins/home/tips.tsx index 9a853c371d..7b67d4aac5 100644 --- a/packages/tui/src/feature-plugins/home/tips.tsx +++ b/packages/tui/src/feature-plugins/home/tips.tsx @@ -3,6 +3,8 @@ import type { BuiltinTuiPlugin } from "../builtins" import { createMemo, Show } from "solid-js" import { Tips } from "./tips-view" import { useBindings } from "../../keymap" +import { useData } from "../../context/data" +import { hasConnectedProvider } from "../../util/connected-provider" const id = "internal:home-tips" @@ -37,13 +39,10 @@ const tui: TuiPlugin = async (api) => { order: 100, slots: { home_bottom() { + const data = useData() const hidden = createMemo(() => api.kv.get("tips_hidden", false)) const first = createMemo(() => api.state.session.count() === 0) - const connected = createMemo(() => - api.state.provider.some( - (item) => item.id !== "opencode" || Object.values(item.models).some((model) => model.cost?.input !== 0), - ), - ) + const connected = createMemo(() => hasConnectedProvider(data.location.integration.list() ?? [])) const show = createMemo(() => (!first() || !connected()) && !hidden()) return