mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-11 11:26:24 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37b2c91937 |
@@ -1,4 +1,4 @@
|
||||
import { Effect, Option, Schema, SchemaGetter } from "effect"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import type { Content } from "@opencode/schema/tool"
|
||||
import { HttpTransport } from "../route/transport/index.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
@@ -325,8 +325,9 @@ export const StreamItem = Schema.StructWithRest(
|
||||
export type StreamItem = Schema.Schema.Type<typeof StreamItem>
|
||||
export type OutputItem = StreamItem & { readonly id: string }
|
||||
|
||||
// Responses-compatible providers put streaming error details at the top level or
|
||||
// under `error`, and response failures under `response.error`. Accept all three shapes.
|
||||
// The Responses schema puts streaming error details at the top level and
|
||||
// response failures under `response.error`. WebSocket failures use an
|
||||
// event-level `error` envelope, so accept all three shapes here.
|
||||
// https://www.openresponses.org/specification
|
||||
const OpenResponsesErrorPayload = Schema.Struct({
|
||||
type: optionalNull(Schema.String),
|
||||
@@ -400,17 +401,6 @@ export const Event = Schema.StructWithRest(
|
||||
headers: Schema.optional(Schema.Unknown),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
).pipe(
|
||||
Schema.decode({
|
||||
decode: SchemaGetter.transform((event) => {
|
||||
if (event.type !== "error" || event.error != null) return event
|
||||
const { code, message, param, ...rest } = event
|
||||
if (code === undefined && message === undefined && param === undefined) return event
|
||||
// Flat errors (for example, Meta's) can also arrive through generic Responses endpoints.
|
||||
return { ...rest, error: { code, message, param } }
|
||||
}),
|
||||
encode: SchemaGetter.passthrough(),
|
||||
}),
|
||||
)
|
||||
export type Event = Schema.Schema.Type<typeof Event>
|
||||
export type NormalizedEvent = Event & { readonly item?: OutputItem | null }
|
||||
|
||||
@@ -6,7 +6,7 @@ import { AuthOptions, type AtLeastOne, type ProviderAuthOption } from "../route/
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Framing } from "../route/framing.js"
|
||||
import { ProviderConfigurationError, ProviderID, ToolDefinition, type ModelID } from "../schema/index.js"
|
||||
import { ProviderID, ToolDefinition, type ModelID } from "../schema/index.js"
|
||||
|
||||
export const id = ProviderID.make("alibaba")
|
||||
|
||||
@@ -82,13 +82,8 @@ export const configure = (input: Config) => {
|
||||
? hosts.get(region)
|
||||
: `${workspaceID}.${region}.maas.aliyuncs.com`
|
||||
if (baseURL === undefined) {
|
||||
if (region === undefined)
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Alibaba requires region or baseURL" })
|
||||
if (host === undefined)
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: `Alibaba region ${region} requires workspaceID or baseURL`,
|
||||
})
|
||||
if (region === undefined) throw new Error("Alibaba requires region or baseURL")
|
||||
if (host === undefined) throw new Error(`Alibaba region ${region} requires workspaceID or baseURL`)
|
||||
}
|
||||
const opts = { ...rest, auth: AuthOptions.bearer(input, ["DASHSCOPE_API_KEY", "ALIBABA_API_KEY"]) }
|
||||
const common = { ...opts, endpoint: { baseURL: baseURL ?? `https://${host}/compatible-mode/v1` } }
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { OpenResponses } from "../protocols/open-responses.js"
|
||||
import { BedrockAuth, type Credentials } from "../protocols/utils/bedrock-auth.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
export const id = ProviderID.make("amazon-bedrock")
|
||||
@@ -79,12 +79,9 @@ const defaults = (input: Config) => {
|
||||
|
||||
export const configure = (input: Config = {}) => {
|
||||
if (input.auth === "bearer" && input.apiKey === undefined && process.env.AWS_BEARER_TOKEN_BEDROCK === undefined)
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Amazon Bedrock Mantle bearer auth requires apiKey" })
|
||||
throw new Error("Amazon Bedrock Mantle bearer auth requires apiKey")
|
||||
if (input.auth === "sigv4" && input.apiKey !== undefined)
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "Amazon Bedrock Mantle SigV4 auth does not accept apiKey",
|
||||
})
|
||||
throw new Error("Amazon Bedrock Mantle SigV4 auth does not accept apiKey")
|
||||
const configuredResponsesRoute = configuredRoute(responsesRoute, input)
|
||||
const configuredChatRoute = configuredRoute(chatRoute, input)
|
||||
const modelDefaults = defaults(input)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { RouteDefaultsInput } from "../route/client.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import * as BedrockConverse from "../protocols/bedrock-converse.js"
|
||||
import type { BedrockCredentials } from "../protocols/bedrock-converse.js"
|
||||
import { BedrockAuth } from "../protocols/utils/bedrock-auth.js"
|
||||
@@ -39,9 +39,8 @@ const bedrockBaseURL = (region: string) => `https://bedrock-runtime.${region}.am
|
||||
const configuredRoute = (input: Config) => {
|
||||
const { apiKey, auth, credentials, profile, region, baseURL, ...rest } = input
|
||||
if (auth === "bearer" && apiKey === undefined && process.env.AWS_BEARER_TOKEN_BEDROCK === undefined)
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Amazon Bedrock bearer auth requires apiKey" })
|
||||
if (auth === "sigv4" && apiKey !== undefined)
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Amazon Bedrock SigV4 auth does not accept apiKey" })
|
||||
throw new Error("Amazon Bedrock bearer auth requires apiKey")
|
||||
if (auth === "sigv4" && apiKey !== undefined) throw new Error("Amazon Bedrock SigV4 auth does not accept apiKey")
|
||||
const resolvedRegion = BedrockAuth.resolveRegion(input)
|
||||
return BedrockConverse.route.with({
|
||||
...rest,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { AnthropicMessages } from "../protocols/anthropic-messages.js"
|
||||
import { Auth } from "../route/auth.js"
|
||||
import type { ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { RouteDefaultsInput } from "../route/client.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
|
||||
export type AnthropicOptionsInput = AnthropicMessages.OptionsInput
|
||||
export type AnthropicProviderOptionsInput = AnthropicMessages.ProviderOptionsInput
|
||||
@@ -36,12 +36,8 @@ const auth = (input: ProviderAuthOption<"optional">) => {
|
||||
}
|
||||
|
||||
export const configure = (input: Config) => {
|
||||
if (!input.baseURL) throw new Error("Anthropic-compatible providers require a baseURL")
|
||||
const provider = input.provider ?? "anthropic-compatible"
|
||||
if (!input.baseURL)
|
||||
throw new ProviderConfigurationError({
|
||||
provider: ProviderID.make(provider),
|
||||
message: "Anthropic-compatible providers require a baseURL",
|
||||
})
|
||||
const { provider: _, baseURL, apiKey: _apiKey, auth: _auth, ...rest } = input
|
||||
const route = AnthropicMessages.route.with({
|
||||
...rest,
|
||||
@@ -65,13 +61,8 @@ export const model: ProviderPackage.Definition<Settings, AnthropicMessages.Provi
|
||||
modelID,
|
||||
settings,
|
||||
) => {
|
||||
// Read before the exclusivity check narrows a conflicting settings object to `never`.
|
||||
const provider = ProviderID.make(settings.provider ?? id)
|
||||
if (settings.apiKey !== undefined && settings.authToken !== undefined)
|
||||
throw new ProviderConfigurationError({
|
||||
provider,
|
||||
message: "Anthropic-compatible apiKey cannot be combined with authToken",
|
||||
})
|
||||
throw new Error("Anthropic-compatible apiKey cannot be combined with authToken")
|
||||
return configure({
|
||||
...(settings.authToken === undefined ? { apiKey: settings.apiKey } : { auth: Auth.bearer(settings.authToken) }),
|
||||
baseURL: settings.baseURL,
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { RouteDefaultsInput } from "../route/client.js"
|
||||
import { Auth } from "../route/auth.js"
|
||||
import type { ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { AnthropicMessages } from "../protocols/anthropic-messages.js"
|
||||
import { AnthropicCompatible } from "./anthropic-compatible.js"
|
||||
|
||||
@@ -57,10 +57,7 @@ export const model: ProviderPackage.Definition<Settings, AnthropicMessages.Provi
|
||||
settings,
|
||||
) => {
|
||||
if (settings.apiKey !== undefined && settings.authToken !== undefined)
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "Anthropic apiKey cannot be combined with authToken",
|
||||
})
|
||||
throw new Error("Anthropic apiKey cannot be combined with authToken")
|
||||
return configure({
|
||||
...(settings.authToken === undefined ? { apiKey: settings.apiKey } : { auth: Auth.bearer(settings.authToken) }),
|
||||
baseURL: settings.baseURL,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Auth } from "../route/auth.js"
|
||||
import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { Route, RouteDefaultsInput, CompactionOperations } from "../route/client.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import * as OpenAIChat from "../protocols/openai-chat.js"
|
||||
import * as OpenAIResponses from "../protocols/openai-responses.js"
|
||||
import { ProviderShared } from "../protocols/shared.js"
|
||||
@@ -163,7 +163,7 @@ const config = (settings: Settings): Config => {
|
||||
}
|
||||
if (settings.baseURL !== undefined) return { ...common, baseURL: settings.baseURL }
|
||||
if (settings.resourceName !== undefined) return { ...common, resourceName: settings.resourceName }
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Azure requires resourceName or baseURL" })
|
||||
throw new Error("Azure requires resourceName or baseURL")
|
||||
}
|
||||
|
||||
export const responsesModel: ProviderPackage.Definition<
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Auth } from "../route/auth.js"
|
||||
import type { AtLeastOne, ProviderAuthOption } from "../route/auth-options.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
export const id = ProviderID.make("cloudflare-ai-gateway")
|
||||
@@ -35,11 +35,7 @@ export type Settings = ProviderPackage.Settings &
|
||||
|
||||
export const baseURL = (input: GatewayURL) => {
|
||||
if (input.baseURL) return input.baseURL
|
||||
if (!input.accountId)
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "CloudflareAIGateway.configure requires accountId unless baseURL is supplied",
|
||||
})
|
||||
if (!input.accountId) throw new Error("CloudflareAIGateway.configure requires accountId unless baseURL is supplied")
|
||||
return `https://gateway.ai.cloudflare.com/v1/${encodeURIComponent(input.accountId)}/${encodeURIComponent(input.gatewayId?.trim() || "default")}/compat`
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { AuthOptions, type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
export const id = ProviderID.make("cloudflare-workers-ai")
|
||||
@@ -28,11 +28,7 @@ export type Settings = ProviderPackage.Settings &
|
||||
|
||||
export const baseURL = (input: WorkersAIURL) => {
|
||||
if (input.baseURL) return input.baseURL
|
||||
if (!input.accountId)
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "CloudflareWorkersAI.configure requires accountId unless baseURL is supplied",
|
||||
})
|
||||
if (!input.accountId) throw new Error("CloudflareWorkersAI.configure requires accountId unless baseURL is supplied")
|
||||
return `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(input.accountId)}/ai/v1`
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { GoogleVertexShared } from "./google-vertex-shared.js"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
@@ -37,8 +37,7 @@ const route = Route.make({
|
||||
export const routes = [route]
|
||||
|
||||
const configuredRoute = (input: Config) => {
|
||||
if ("apiKey" in input && input.apiKey !== undefined)
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Chat does not support API keys" })
|
||||
if ("apiKey" in input && input.apiKey !== undefined) throw new Error("Google Vertex Chat does not support API keys")
|
||||
const {
|
||||
accessToken: _accessToken,
|
||||
auth: _auth,
|
||||
@@ -75,8 +74,7 @@ export const provider = {
|
||||
}
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
|
||||
if (settings.apiKey !== undefined)
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Chat does not support API keys" })
|
||||
if (settings.apiKey !== undefined) throw new Error("Google Vertex Chat does not support API keys")
|
||||
return configure({
|
||||
accessToken: settings.accessToken,
|
||||
baseURL: settings.baseURL,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Auth } from "../route/auth.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { GoogleVertexShared } from "./google-vertex-shared.js"
|
||||
|
||||
export type AnthropicOptionsInput = AnthropicMessages.OptionsInput
|
||||
@@ -67,7 +67,7 @@ export const routes = [route]
|
||||
|
||||
const configuredRoute = (input: Config) => {
|
||||
if ("apiKey" in input && input.apiKey !== undefined)
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Messages does not support API keys" })
|
||||
throw new Error("Google Vertex Messages does not support API keys")
|
||||
const {
|
||||
accessToken: _accessToken,
|
||||
auth: _auth,
|
||||
@@ -107,8 +107,7 @@ export const model: ProviderPackage.Definition<Settings, AnthropicMessages.Provi
|
||||
modelID,
|
||||
settings,
|
||||
) => {
|
||||
if (settings.apiKey !== undefined)
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Messages does not support API keys" })
|
||||
if (settings.apiKey !== undefined) throw new Error("Google Vertex Messages does not support API keys")
|
||||
return configure({
|
||||
accessToken: settings.accessToken,
|
||||
baseURL: settings.baseURL,
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenResponses } from "../protocols/open-responses.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { GoogleVertexShared } from "./google-vertex-shared.js"
|
||||
import type { OpenResponsesProviderOptionsInput } from "./open-responses-options.js"
|
||||
|
||||
@@ -39,7 +39,7 @@ export const routes = [route]
|
||||
|
||||
const configuredRoute = (input: Config) => {
|
||||
if ("apiKey" in input && input.apiKey !== undefined)
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Responses does not support API keys" })
|
||||
throw new Error("Google Vertex Responses does not support API keys")
|
||||
const {
|
||||
accessToken: _accessToken,
|
||||
auth: _auth,
|
||||
@@ -79,8 +79,7 @@ export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOp
|
||||
modelID,
|
||||
settings,
|
||||
) => {
|
||||
if (settings.apiKey !== undefined)
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Responses does not support API keys" })
|
||||
if (settings.apiKey !== undefined) throw new Error("Google Vertex Responses does not support API keys")
|
||||
return configure({
|
||||
accessToken: settings.accessToken,
|
||||
baseURL: settings.baseURL,
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import type { AnyAuthClient } from "google-auth-library"
|
||||
import { Effect, Redacted } from "effect"
|
||||
import { Auth, MissingCredentialError } from "../route/auth.js"
|
||||
import { ProviderConfigurationError, ProviderID } from "../schema/index.js"
|
||||
|
||||
const SCOPE = "https://www.googleapis.com/auth/cloud-platform"
|
||||
const id = ProviderID.make("google-vertex")
|
||||
|
||||
export type OAuthOptions =
|
||||
| { readonly accessToken?: string; readonly auth?: never }
|
||||
@@ -37,18 +35,12 @@ export const host = (location: string) => {
|
||||
|
||||
export const requireProject = (value: string | undefined) => {
|
||||
if (value) return value
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "Google Vertex requires a project when baseURL is not configured",
|
||||
})
|
||||
throw new Error("Google Vertex requires a project when baseURL is not configured")
|
||||
}
|
||||
|
||||
export const apiKey = (input: ApiKeyOptions) => {
|
||||
if (input.apiKey !== undefined && (input.accessToken !== undefined || input.auth !== undefined))
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "Google Vertex apiKey cannot be combined with accessToken or auth",
|
||||
})
|
||||
throw new Error("Google Vertex apiKey cannot be combined with accessToken or auth")
|
||||
if (input.accessToken !== undefined || input.auth !== undefined) return undefined
|
||||
return input.apiKey ?? process.env.GOOGLE_VERTEX_API_KEY
|
||||
}
|
||||
@@ -76,10 +68,7 @@ const adc = (project?: string) => {
|
||||
|
||||
export const oauth = (input: OAuthOptions, project?: string) => {
|
||||
if (input.accessToken !== undefined && input.auth !== undefined)
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "Google Vertex accessToken cannot be combined with auth",
|
||||
})
|
||||
throw new Error("Google Vertex accessToken cannot be combined with auth")
|
||||
if (input.auth) return input.auth
|
||||
if (input.accessToken !== undefined) return Auth.bearer(input.accessToken)
|
||||
return adc(project)
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Auth } from "../route/auth.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Framing } from "../route/framing.js"
|
||||
import { ProviderConfigurationError, ProviderID, type LLMRequest, type ModelID } from "../schema/index.js"
|
||||
import { ProviderID, type LLMRequest, type ModelID } from "../schema/index.js"
|
||||
import { GoogleVertexShared } from "./google-vertex-shared.js"
|
||||
|
||||
export interface GeminiOptionsInput extends Gemini.OptionsInput {
|
||||
@@ -93,10 +93,7 @@ const configuredRoute = (input: Config, modelID: string | ModelID) => {
|
||||
const apiKey = GoogleVertexShared.apiKey(input)
|
||||
const endpointModel = String(modelID).startsWith("endpoints/")
|
||||
if (apiKey !== undefined && endpointModel)
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "Google Vertex tuned models do not support Express Mode API keys",
|
||||
})
|
||||
throw new Error("Google Vertex tuned models do not support Express Mode API keys")
|
||||
const location = GoogleVertexShared.location(inputLocation, "us-central1")
|
||||
const project = GoogleVertexShared.project(inputProject)
|
||||
const endpoint =
|
||||
@@ -126,10 +123,7 @@ export const provider = {
|
||||
}
|
||||
export const model: ProviderPackage.Definition<Settings, GeminiProviderOptionsInput>["model"] = (modelID, settings) => {
|
||||
if (settings.apiKey !== undefined && settings.accessToken !== undefined)
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "Google Vertex apiKey cannot be combined with accessToken or auth",
|
||||
})
|
||||
throw new Error("Google Vertex apiKey cannot be combined with accessToken or auth")
|
||||
return configure({
|
||||
...(settings.apiKey === undefined ? { accessToken: settings.accessToken } : { apiKey: settings.apiKey }),
|
||||
baseURL: settings.baseURL,
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
LanguageModel,
|
||||
LLMEvent,
|
||||
InvalidProviderOutputError,
|
||||
ProviderConfigurationError,
|
||||
ProviderID,
|
||||
mergeGenerationOptions,
|
||||
mergeHttpOptions,
|
||||
@@ -129,10 +128,7 @@ const makeRouteLanguageModel = <Options extends ProviderOptions, Compact extends
|
||||
const provider = route.provider ?? ("provider" in mapped ? mapped.provider : undefined)
|
||||
if (!provider) throw new Error(`Route.model(${route.id}) requires a provider`)
|
||||
if (!endpointBaseURL(route.endpoint))
|
||||
throw new ProviderConfigurationError({
|
||||
provider: ProviderID.make(provider),
|
||||
message: `Route.model(${route.id}) requires an endpoint baseURL — configure it on the route first`,
|
||||
})
|
||||
throw new Error(`Route.model(${route.id}) requires an endpoint baseURL — configure it on the route first`)
|
||||
return LanguageModel.make<Options, Compact>({
|
||||
...mapped,
|
||||
provider,
|
||||
|
||||
@@ -50,19 +50,6 @@ export class UnsupportedOperationError extends Schema.TaggedError<UnsupportedOpe
|
||||
route: Schema.optional(RouteID),
|
||||
}) {}
|
||||
|
||||
/**
|
||||
* Provider settings that are missing, conflicting, or unsupported, such as
|
||||
* Azure without `resourceName` or `baseURL`. Thrown synchronously while a
|
||||
* provider facade or package entrypoint configures a model, before any
|
||||
* request exists, so it is not an `AIError` reason.
|
||||
*/
|
||||
export class ProviderConfigurationError extends Schema.TaggedError<ProviderConfigurationError>(
|
||||
"AI.Error.ProviderConfiguration",
|
||||
)("ProviderConfiguration", {
|
||||
provider: ProviderID,
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class NoRouteError extends Schema.TaggedError<NoRouteError>("AI.Error.NoRoute")("NoRoute", {
|
||||
...ReasonFields,
|
||||
route: RouteID,
|
||||
|
||||
@@ -3,9 +3,6 @@ import { model } from "@opencode/ai/providers/openai"
|
||||
import { LLM } from "../src/index.js"
|
||||
import { Endpoint } from "../src/route/endpoint.js"
|
||||
|
||||
const configuration = (provider: string, message: string) =>
|
||||
expect.objectContaining({ _tag: "ProviderConfiguration", provider, message })
|
||||
|
||||
describe("provider package entrypoints", () => {
|
||||
test("semantic API aliases expose the same contract", async () => {
|
||||
const modules = await Promise.all([
|
||||
@@ -325,7 +322,7 @@ describe("provider package entrypoints", () => {
|
||||
const AnthropicCompatible = await import("@opencode/ai/providers/anthropic-compatible")
|
||||
expect(() =>
|
||||
Reflect.apply(AnthropicCompatible.model, undefined, ["compatible-model", { apiKey: "fixture" }]),
|
||||
).toThrow(configuration("anthropic-compatible", "Anthropic-compatible providers require a baseURL"))
|
||||
).toThrow("Anthropic-compatible providers require a baseURL")
|
||||
})
|
||||
|
||||
test("rejects conflicting Anthropic-compatible auth settings at runtime", async () => {
|
||||
@@ -340,10 +337,10 @@ describe("provider package entrypoints", () => {
|
||||
baseURL: "https://messages.example.test/v1",
|
||||
},
|
||||
]),
|
||||
).toThrow(configuration("anthropic-compatible", "Anthropic-compatible apiKey cannot be combined with authToken"))
|
||||
).toThrow("Anthropic-compatible apiKey cannot be combined with authToken")
|
||||
expect(() =>
|
||||
Reflect.apply(Anthropic.model, undefined, ["claude-sonnet-4-6", { apiKey: "fixture", authToken: "token" }]),
|
||||
).toThrow(configuration("anthropic", "Anthropic apiKey cannot be combined with authToken"))
|
||||
).toThrow("Anthropic apiKey cannot be combined with authToken")
|
||||
})
|
||||
|
||||
test("maps legacy OpenAI organization and project settings to headers", () => {
|
||||
@@ -493,45 +490,43 @@ describe("provider package entrypoints", () => {
|
||||
"gemini-3.5-flash",
|
||||
{ accessToken: "token", apiKey: "fixture", project: "vertex-project" },
|
||||
]),
|
||||
).toThrow(configuration("google-vertex", "Google Vertex apiKey cannot be combined with accessToken or auth"))
|
||||
).toThrow("Google Vertex apiKey cannot be combined with accessToken or auth")
|
||||
const configured = Reflect.apply(GoogleVertex.configure, undefined, [
|
||||
{ accessToken: "token", auth: {}, project: "vertex-project" },
|
||||
])
|
||||
expect(() => configured.model("gemini-3.5-flash")).toThrow(
|
||||
configuration("google-vertex", "Google Vertex accessToken cannot be combined with auth"),
|
||||
)
|
||||
expect(() => configured.model("gemini-3.5-flash")).toThrow("Google Vertex accessToken cannot be combined with auth")
|
||||
expect(() =>
|
||||
Reflect.apply(GoogleVertexMessages.model, undefined, [
|
||||
"claude-sonnet-4-6",
|
||||
{ apiKey: "fixture", project: "vertex-project" },
|
||||
]),
|
||||
).toThrow(configuration("google-vertex", "Google Vertex Messages does not support API keys"))
|
||||
).toThrow("Google Vertex Messages does not support API keys")
|
||||
expect(() =>
|
||||
Reflect.apply(Providers.GoogleVertexMessages.configure, undefined, [
|
||||
{ apiKey: "fixture", project: "vertex-project" },
|
||||
]),
|
||||
).toThrow(configuration("google-vertex", "Google Vertex Messages does not support API keys"))
|
||||
).toThrow("Google Vertex Messages does not support API keys")
|
||||
expect(() =>
|
||||
Reflect.apply(GoogleVertexChat.model, undefined, [
|
||||
"deepseek-ai/deepseek-v3.2-maas",
|
||||
{ apiKey: "fixture", project: "vertex-project" },
|
||||
]),
|
||||
).toThrow(configuration("google-vertex", "Google Vertex Chat does not support API keys"))
|
||||
).toThrow("Google Vertex Chat does not support API keys")
|
||||
expect(() =>
|
||||
Reflect.apply(Providers.GoogleVertexChat.configure, undefined, [
|
||||
{ apiKey: "fixture", project: "vertex-project" },
|
||||
]),
|
||||
).toThrow(configuration("google-vertex", "Google Vertex Chat does not support API keys"))
|
||||
).toThrow("Google Vertex Chat does not support API keys")
|
||||
expect(() =>
|
||||
Reflect.apply(GoogleVertexResponses.model, undefined, [
|
||||
"xai/grok-4.20-reasoning",
|
||||
{ apiKey: "fixture", project: "vertex-project" },
|
||||
]),
|
||||
).toThrow(configuration("google-vertex", "Google Vertex Responses does not support API keys"))
|
||||
).toThrow("Google Vertex Responses does not support API keys")
|
||||
expect(() =>
|
||||
Reflect.apply(Providers.GoogleVertexResponses.configure, undefined, [
|
||||
{ apiKey: "fixture", project: "vertex-project" },
|
||||
]),
|
||||
).toThrow(configuration("google-vertex", "Google Vertex Responses does not support API keys"))
|
||||
).toThrow("Google Vertex Responses does not support API keys")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -76,13 +76,7 @@ it.effect("Alibaba owns regional shared and workspace-specific endpoints", () =>
|
||||
|
||||
test("Alibaba requires explicit placement and supports complete base URL overrides", () => {
|
||||
for (const region of ["eu-central-1", "ap-northeast-1", "future-region"])
|
||||
expect(() => Alibaba.configure({ region })).toThrow(
|
||||
expect.objectContaining({
|
||||
_tag: "ProviderConfiguration",
|
||||
provider: "alibaba",
|
||||
message: `Alibaba region ${region} requires workspaceID or baseURL`,
|
||||
}),
|
||||
)
|
||||
expect(() => Alibaba.configure({ region })).toThrow("requires workspaceID or baseURL")
|
||||
for (const config of [
|
||||
{ baseURL: "https://gateway.example/prefix" },
|
||||
{ region: "future-region", workspaceID: "ignored", baseURL: "https://gateway.example/prefix" },
|
||||
|
||||
@@ -1458,13 +1458,7 @@ describe("Bedrock Converse route", () => {
|
||||
expect(headers.get("authorization")).toContain("Credential=AKIACHAINEXAMPLE/")
|
||||
expect(headers.get("authorization")).toContain("/ap-southeast-2/bedrock/aws4_request")
|
||||
}
|
||||
expect(() => AmazonBedrock.configure({ auth: "sigv4", apiKey: "k" })).toThrow(
|
||||
expect.objectContaining({
|
||||
_tag: "ProviderConfiguration",
|
||||
provider: "amazon-bedrock",
|
||||
message: "Amazon Bedrock SigV4 auth does not accept apiKey",
|
||||
}),
|
||||
)
|
||||
expect(() => AmazonBedrock.configure({ auth: "sigv4", apiKey: "k" })).toThrow("does not accept apiKey")
|
||||
}).pipe(
|
||||
withProcessEnv({
|
||||
...noAmbientAWS,
|
||||
|
||||
@@ -378,11 +378,7 @@ describe("Google Vertex providers", () => {
|
||||
|
||||
test("rejects tuned Gemini models in express mode", () => {
|
||||
expect(() => GoogleVertex.configure({ apiKey: "fixture" }).model("endpoints/1234567890")).toThrow(
|
||||
expect.objectContaining({
|
||||
_tag: "ProviderConfiguration",
|
||||
provider: "google-vertex",
|
||||
message: "Google Vertex tuned models do not support Express Mode API keys",
|
||||
}),
|
||||
"Google Vertex tuned models do not support Express Mode API keys",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { LLM, LLMClient } from "../../src/index.js"
|
||||
import { OpenResponses } from "../../src/protocols/open-responses.js"
|
||||
import { Meta } from "../../src/providers/index.js"
|
||||
import { configure } from "../../src/providers/openai-compatible-responses.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { fixedResponse } from "../lib/http.js"
|
||||
import { sseEvents } from "../lib/sse.js"
|
||||
|
||||
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
|
||||
|
||||
it.effect("normalizes flat errors in shared SSE and WebSocket decoding", () =>
|
||||
Effect.gen(function* () {
|
||||
const frame = {
|
||||
type: "error",
|
||||
sequence_number: 4,
|
||||
code: "server_shutting_down",
|
||||
message: "Server is shutting down. Please retry your request.",
|
||||
param: null,
|
||||
}
|
||||
for (const decode of [decodeEvent, OpenResponses.decodeChannelEvent]) {
|
||||
const event = yield* decode(JSON.stringify(frame))
|
||||
expect(event).toEqual({
|
||||
type: "error",
|
||||
sequence_number: 4,
|
||||
error: { code: frame.code, message: frame.message, param: null },
|
||||
})
|
||||
|
||||
for (const unchanged of [
|
||||
event,
|
||||
{ type: "error" },
|
||||
{
|
||||
type: "response.failed",
|
||||
response: { id: "resp_failed", error: { code: "server_error", message: "Internal server error" } },
|
||||
},
|
||||
{ type: "response.output_text.delta", item_id: "msg_text", delta: "Hello" },
|
||||
]) {
|
||||
expect(yield* decode(JSON.stringify(unchanged))).toEqual(unchanged)
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues to normalize untyped xAI WebSocket errors", () =>
|
||||
Effect.gen(function* () {
|
||||
const frame = { error: { type: "api_error", message: "gRPC error: Response with id=resp_missing not found" } }
|
||||
expect(yield* OpenResponses.decodeChannelEvent(JSON.stringify(frame))).toEqual({ ...frame, type: "error" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retains classification and original error bodies through Meta and generic Responses routes", () =>
|
||||
Effect.gen(function* () {
|
||||
const raw = `{
|
||||
"type": "error",
|
||||
"sequence_number": 4,
|
||||
"code": "server_shutting_down",
|
||||
"message": "Server is shutting down. Please retry your request.",
|
||||
"param": null,
|
||||
"diagnostic": "retain-original-frame"
|
||||
}`
|
||||
for (const model of [
|
||||
Meta.configure({ apiKey: "fixture" }).responses("muse-spark-1.3"),
|
||||
configure({ apiKey: "fixture", provider: "gateway", baseURL: "https://responses.example.test/v1" }).model(
|
||||
"example-model",
|
||||
),
|
||||
]) {
|
||||
const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" })).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents(raw.replaceAll("\n", "\ndata: ")))),
|
||||
Effect.flip,
|
||||
)
|
||||
expect(error.reason._tag).toBe("ProviderInternal")
|
||||
expect(error.message).toBe("server_shutting_down: Server is shutting down. Please retry your request.")
|
||||
expect(error.reason.body).toBe(raw)
|
||||
expect(error.reason.http?.status).toBe(200)
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -19,13 +19,11 @@ story("cancelling a version mismatch permits reconnecting again", async ({ mount
|
||||
const component = await mount("app-dialog-ssh--incompatible-session")
|
||||
await component.getByRole("button", { name: "Reconnect", exact: true }).click()
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog.getByRole("status")).toContainText("Server update required")
|
||||
await expect(dialog.getByRole("textbox")).toHaveCount(0)
|
||||
await expect(dialog.getByRole("alert")).toBeVisible()
|
||||
await dialog.getByRole("button", { name: "Cancel", exact: true }).click()
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await component.getByRole("button", { name: "Reconnect", exact: true }).click()
|
||||
await expect(dialog.getByRole("status")).toContainText("Server update required")
|
||||
await expect(dialog.getByRole("textbox")).toHaveCount(0)
|
||||
await expect(dialog.getByRole("alert")).toBeVisible()
|
||||
await dialog.getByRole("button", { name: "Cancel", exact: true }).click()
|
||||
await expect(dialog).toHaveCount(0)
|
||||
})
|
||||
@@ -45,17 +43,6 @@ story("adding a server keeps all SSH challenges in the original connection dialo
|
||||
await expect(dialog).toHaveCount(0)
|
||||
})
|
||||
|
||||
story("adding an incompatible server advances to a dedicated update step", async ({ mount, page }) => {
|
||||
await mount("app-dialog-ssh--incompatible-host")
|
||||
const dialog = page.getByRole("dialog")
|
||||
await dialog.getByRole("textbox", { name: "Host or SSH command" }).fill("ssh devbox")
|
||||
await dialog.getByRole("button", { name: "Add server", exact: true }).click()
|
||||
await expect(dialog.getByRole("status")).toContainText("Server update required")
|
||||
await expect(dialog.getByRole("textbox")).toHaveCount(0)
|
||||
await expect(dialog.getByRole("alert")).toHaveCount(0)
|
||||
await expect(dialog.getByRole("button", { name: "Update and reconnect", exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
story("updating an incompatible connection continues authentication in the same dialog", async ({ mount, page }) => {
|
||||
const component = await mount("app-dialog-ssh--incompatible-session")
|
||||
await component.getByRole("button", { name: "Reconnect", exact: true }).click()
|
||||
|
||||
@@ -115,7 +115,7 @@ test("combines follow-up patches into one three-file stack inside Used", async (
|
||||
})
|
||||
const group = page.locator('[data-component="collapsed-tool-group"]')
|
||||
await group.getByRole("button", { name: "Used 2 Shell, Patch", exact: true }).click()
|
||||
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts"])
|
||||
await expect(group.getByText("2 files", { exact: true })).toBeVisible()
|
||||
await timeline.send(
|
||||
partUpdated(
|
||||
toolPart(
|
||||
@@ -134,6 +134,7 @@ test("combines follow-up patches into one three-file stack inside Used", async (
|
||||
"true",
|
||||
)
|
||||
await expect(group.locator('[data-component="apply-patch-tool"]')).toHaveCount(1)
|
||||
await expect(group.getByText("3 files", { exact: true })).toBeVisible()
|
||||
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "c.ts"])
|
||||
})
|
||||
|
||||
|
||||
@@ -54,12 +54,8 @@ export function createWebPlatform(version: string) {
|
||||
|
||||
function getCurrentServerUrl() {
|
||||
if (import.meta.env.VITE_OPENCODE_SERVER_MODE === "none") return undefined
|
||||
if (import.meta.env.DEV) {
|
||||
const loopback =
|
||||
location.hostname === "localhost" || location.hostname === "[::1]" || location.hostname.startsWith("127.")
|
||||
const host = import.meta.env.VITE_OPENCODE_SERVER_HOST ?? (loopback ? location.hostname : "localhost")
|
||||
return `http://${host}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
|
||||
}
|
||||
if (import.meta.env.DEV)
|
||||
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
|
||||
return location.origin
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import { createData } from "@opencode/client/solid"
|
||||
import type { ServerScope } from "@/runtime/server/scope"
|
||||
import { createPermissionAutoApprover } from "@/session/requests/auto-approve"
|
||||
import { createServerNotificationState } from "@/shell/notifications/notification"
|
||||
import { createNotificationCoordinator } from "@/shell/notifications/coordinator"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import { createDesktopData } from "./data"
|
||||
import { ModelState } from "./persistence"
|
||||
@@ -34,7 +33,6 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
|
||||
},
|
||||
})
|
||||
const models = createGlobalModels()
|
||||
const notificationCoordinator = createNotificationCoordinator()
|
||||
|
||||
const settingsServer = createMemo(() => {
|
||||
const list = server.list
|
||||
@@ -59,7 +57,7 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
|
||||
if (existing) return existing
|
||||
const serverCtx = createRoot((dispose) => {
|
||||
serverCtxDisposers.set(key, dispose)
|
||||
return createServerController(conn, server.scope(key), server.projects.forServer(key), notificationCoordinator)
|
||||
return createServerController(conn, server.scope(key), server.projects.forServer(key))
|
||||
}, owner)
|
||||
serverCtxs.set(key, serverCtx)
|
||||
return serverCtx
|
||||
@@ -133,7 +131,6 @@ function createServerController(
|
||||
conn: ServerConnection.Any,
|
||||
scope: ServerScope,
|
||||
projects: ReturnType<typeof createServerProjects>,
|
||||
notificationCoordinator: ReturnType<typeof createNotificationCoordinator>,
|
||||
) {
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
@@ -162,7 +159,7 @@ function createServerController(
|
||||
})
|
||||
const sync = createServerSyncContext(sdk, data)
|
||||
createPermissionAutoApprover({ sdk, data })
|
||||
const notification = createServerNotificationState({ sdk, data, key: connKey, coordinator: notificationCoordinator })
|
||||
const notification = createServerNotificationState({ sdk, data, key: connKey })
|
||||
|
||||
function enrich(project: { worktree: string; expanded: boolean }) {
|
||||
const [childStore] = sync.child(project.worktree, { bootstrap: false })
|
||||
|
||||
@@ -278,7 +278,6 @@ function Open(props: { initial?: string }) {
|
||||
export default { title: "App/Dialogs/SSH", id: "app-dialog-ssh" }
|
||||
export const AuthenticationRequired = { render: () => <Fixture initial="required" /> }
|
||||
export const SettingsReconnect = { render: () => <Fixture initial="required" settings connectionDelay={200} /> }
|
||||
export const IncompatibleHost = { render: () => <Fixture incompatible /> }
|
||||
export const IncompatibleSession = { render: () => <Fixture initial="required" session incompatible /> }
|
||||
export const InactiveSession = { render: () => <Fixture initial="required" session connectionDelay={3000} /> }
|
||||
export const KeyReconnect = { render: () => <Fixture initial="required" session keyOnly connectionDelay={3000} /> }
|
||||
|
||||
@@ -120,13 +120,7 @@ export function DialogSsh(props: {
|
||||
<Divider />
|
||||
<DialogBody class="flex w-full min-w-0 flex-1 flex-col px-4 pt-4 pb-2">
|
||||
<div class="flex w-full min-w-0 flex-col gap-6">
|
||||
<Show
|
||||
when={
|
||||
!props.promptOnly &&
|
||||
item()?.stage !== "incompatible" &&
|
||||
(!state.prompted || (!!error() && !prompt()))
|
||||
}
|
||||
>
|
||||
<Show when={!props.promptOnly && (!state.prompted || (!!error() && !prompt()))}>
|
||||
<div class="flex w-full min-w-0 flex-col gap-2">
|
||||
<label class="settings-server-dialog-label" for="ssh-target">
|
||||
{language.t("ssh.target")}
|
||||
@@ -166,12 +160,6 @@ export function DialogSsh(props: {
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={item()?.stage === "incompatible"}>
|
||||
<div class="flex w-full min-w-0 flex-col gap-2" role="status" aria-live="polite">
|
||||
<span class="text-14-medium text-v2-text-text-base">{language.t("ssh.stage.incompatible")}</span>
|
||||
<span class="text-13-regular text-v2-text-text-muted">{language.t("ssh.error.version")}</span>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={prompt()} keyed>
|
||||
{(prompt) => (
|
||||
<div class="flex w-full min-w-0 flex-col gap-2">
|
||||
@@ -207,7 +195,7 @@ export function DialogSsh(props: {
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={item()?.stage !== "incompatible" && error()}>
|
||||
<Show when={error()}>
|
||||
{(error) => (
|
||||
<span class="settings-server-dialog-error !leading-[var(--line-height-compact)]" role="alert">
|
||||
{error()}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createMemo, createUniqueId, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createQuery, keepPreviousData } from "@tanstack/solid-query"
|
||||
import { createQuery } from "@tanstack/solid-query"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { SessionFilePanelV2, SessionFilePanelV2Empty } from "@opencode/session-ui/v2/session-file-panel-v2"
|
||||
import { SessionReviewV2Sidebar } from "@opencode/session-ui/v2/session-review-v2"
|
||||
@@ -56,7 +56,6 @@ export function SessionFileBrowserTab(props: {
|
||||
queryKey: [serverSDK.scope, "session-open-file", workspaceKey(), value] as const,
|
||||
enabled: serverSDK.connection.status() === "connected" && value.length > 0,
|
||||
queryFn: ({ signal }) => file.searchFiles(value, { limit: 200, signal }),
|
||||
placeholderData: keepPreviousData,
|
||||
}
|
||||
})
|
||||
const files = createMemo(() => {
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { onCleanup } from "solid-js"
|
||||
|
||||
const FOCUS_LOCK = "opencode:notification-focus"
|
||||
const MAX_CLAIMED = 500
|
||||
|
||||
export function createNotificationCoordinator() {
|
||||
const locks = typeof navigator === "undefined" ? undefined : navigator.locks
|
||||
const claimed = new Set<string>()
|
||||
const focus = { pending: false, release: undefined as (() => void) | undefined }
|
||||
|
||||
const updateFocus = () => {
|
||||
if (typeof document === "undefined" || !document.hasFocus()) {
|
||||
focus.release?.()
|
||||
return
|
||||
}
|
||||
if (!locks || focus.pending || focus.release) return
|
||||
|
||||
focus.pending = true
|
||||
void locks
|
||||
.request(FOCUS_LOCK, { mode: "shared" }, async () => {
|
||||
focus.pending = false
|
||||
if (!document.hasFocus()) return
|
||||
await new Promise<void>((resolve) => {
|
||||
focus.release = resolve
|
||||
})
|
||||
focus.release = undefined
|
||||
})
|
||||
.catch(() => {
|
||||
focus.pending = false
|
||||
})
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("focus", updateFocus)
|
||||
window.addEventListener("blur", updateFocus)
|
||||
document.addEventListener("visibilitychange", updateFocus)
|
||||
updateFocus()
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("focus", updateFocus)
|
||||
window.removeEventListener("blur", updateFocus)
|
||||
document.removeEventListener("visibilitychange", updateFocus)
|
||||
focus.release?.()
|
||||
})
|
||||
}
|
||||
|
||||
const once = async (kind: "sound" | "system", eventID: string, run: () => Promise<unknown> | void) => {
|
||||
const key = `${kind}:${eventID}`
|
||||
const execute = async () => {
|
||||
if (!claim(kind, key, claimed)) return
|
||||
await run()
|
||||
}
|
||||
if (!locks) return execute()
|
||||
await locks.request(`opencode:notification:${key}`, execute)
|
||||
}
|
||||
|
||||
return {
|
||||
sound(eventID: string, run: () => Promise<unknown> | void) {
|
||||
return once("sound", eventID, run)
|
||||
},
|
||||
system(eventID: string, run: () => Promise<unknown> | void) {
|
||||
return once("system", eventID, async () => {
|
||||
if (typeof document !== "undefined" && document.hasFocus()) return
|
||||
if (!locks) return run()
|
||||
await locks.request(FOCUS_LOCK, { mode: "exclusive", ifAvailable: true }, async (lock) => {
|
||||
if (!lock) return
|
||||
await run()
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function claim(kind: "sound" | "system", eventID: string, claimed: Set<string>) {
|
||||
if (claimed.has(eventID)) return false
|
||||
|
||||
if (typeof localStorage !== "undefined") {
|
||||
try {
|
||||
const storageKey = `opencode:notification-${kind}`
|
||||
const value: unknown = JSON.parse(localStorage.getItem(storageKey) ?? "[]")
|
||||
const events = Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []
|
||||
if (events.includes(eventID)) {
|
||||
claimed.add(eventID)
|
||||
return false
|
||||
}
|
||||
localStorage.setItem(storageKey, JSON.stringify([...events, eventID].slice(-MAX_CLAIMED)))
|
||||
} catch {
|
||||
// The in-memory claim still prevents duplicates in this renderer when storage is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
claimed.add(eventID)
|
||||
return true
|
||||
}
|
||||
@@ -11,8 +11,7 @@ import { useSettings } from "@/settings/model"
|
||||
import { decode64 } from "@/runtime/persistence/base64"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { playSoundById } from "@/shell/notifications/sound"
|
||||
import type { createNotificationCoordinator } from "@/shell/notifications/coordinator"
|
||||
import { playSoundByIdOnce } from "@/shell/notifications/sound"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
import { ServerConnection, useServers } from "@/runtime/server/registry"
|
||||
import { sessionIDHasOpenTab, useTabs } from "@/shell/tabs/tabs"
|
||||
@@ -115,12 +114,7 @@ function buildNotificationIndex(list: Notification[]) {
|
||||
return index
|
||||
}
|
||||
|
||||
export function createServerNotificationState(input: {
|
||||
sdk: ServerSDK
|
||||
data: Data
|
||||
key: ServerConnection.Key
|
||||
coordinator: ReturnType<typeof createNotificationCoordinator>
|
||||
}) {
|
||||
export function createServerNotificationState(input: { sdk: ServerSDK; data: Data; key: ServerConnection.Key }) {
|
||||
const platform = usePlatform()
|
||||
const settings = useSettings()
|
||||
const language = useLanguage()
|
||||
@@ -229,7 +223,7 @@ export function createServerNotificationState(input: {
|
||||
if (session.parentID) return
|
||||
|
||||
if (sessionIDHasOpenTab(tabs.store, input.key, sessionID) && settings.sounds.agentEnabled()) {
|
||||
void input.coordinator.sound(`${input.key}\0${eventID}`, () => playSoundById(settings.sounds.agent()))
|
||||
void playSoundByIdOnce(settings.sounds.agent(), `${input.key}\0${eventID}`)
|
||||
}
|
||||
|
||||
append({
|
||||
@@ -241,10 +235,8 @@ export function createServerNotificationState(input: {
|
||||
})
|
||||
|
||||
if (settings.notifications.agent()) {
|
||||
void input.coordinator.system(`${input.key}\0${eventID}`, () =>
|
||||
platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, () =>
|
||||
openNotificationSession(tabs, input.key, sessionID),
|
||||
),
|
||||
void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, () =>
|
||||
openNotificationSession(tabs, input.key, sessionID),
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -256,7 +248,7 @@ export function createServerNotificationState(input: {
|
||||
if (session?.parentID) return
|
||||
|
||||
if (sessionIDHasOpenTab(tabs.store, input.key, sessionID) && settings.sounds.errorsEnabled()) {
|
||||
void input.coordinator.sound(`${input.key}\0${eventID}`, () => playSoundById(settings.sounds.errors()))
|
||||
void playSoundByIdOnce(settings.sounds.errors(), `${input.key}\0${eventID}`)
|
||||
}
|
||||
|
||||
append({
|
||||
@@ -271,10 +263,8 @@ export function createServerNotificationState(input: {
|
||||
session?.title ??
|
||||
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
|
||||
if (settings.notifications.errors()) {
|
||||
void input.coordinator.system(`${input.key}\0${eventID}`, () =>
|
||||
platform.notify(language.t("notification.session.error.title"), description, () =>
|
||||
openNotificationSession(tabs, input.key, sessionID),
|
||||
),
|
||||
void platform.notify(language.t("notification.session.error.title"), description, () =>
|
||||
openNotificationSession(tabs, input.key, sessionID),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -74,6 +74,9 @@ function getLoads() {
|
||||
}
|
||||
|
||||
const cache = new Map<SoundID, Promise<string | undefined>>()
|
||||
const claimed = new Set<string>()
|
||||
const CLAIMED_STORAGE_KEY = "opencode:notification-sounds"
|
||||
const MAX_CLAIMED = 500
|
||||
|
||||
export function soundSrc(id: string | undefined) {
|
||||
const loads = getLoads()
|
||||
@@ -100,3 +103,34 @@ export function playSound(src: string | undefined) {
|
||||
export function playSoundById(id: string | undefined) {
|
||||
return soundSrc(id).then((src) => playSound(src))
|
||||
}
|
||||
|
||||
export async function playSoundByIdOnce(id: string | undefined, eventID: string) {
|
||||
const play = async () => {
|
||||
if (!claim(eventID)) return
|
||||
await playSoundById(id)
|
||||
}
|
||||
|
||||
if (typeof navigator === "undefined" || !navigator.locks) return play()
|
||||
await navigator.locks.request(`${CLAIMED_STORAGE_KEY}:${eventID}`, play)
|
||||
}
|
||||
|
||||
function claim(eventID: string) {
|
||||
if (claimed.has(eventID)) return false
|
||||
|
||||
if (typeof localStorage !== "undefined") {
|
||||
try {
|
||||
const value: unknown = JSON.parse(localStorage.getItem(CLAIMED_STORAGE_KEY) ?? "[]")
|
||||
const events = Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []
|
||||
if (events.includes(eventID)) {
|
||||
claimed.add(eventID)
|
||||
return false
|
||||
}
|
||||
localStorage.setItem(CLAIMED_STORAGE_KEY, JSON.stringify([...events, eventID].slice(-MAX_CLAIMED)))
|
||||
} catch {
|
||||
// The in-memory claim still prevents duplicates in this renderer when storage is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
claimed.add(eventID)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ const handler = Effect.fn("cli.session.list")(function* (
|
||||
null,
|
||||
2,
|
||||
)
|
||||
: formatList(page.data)) + EOL
|
||||
: formatTable(page.data)) + EOL
|
||||
const write = Effect.tryPromise(
|
||||
() =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
@@ -96,14 +96,18 @@ export default Runtime.handler(Commands.commands.session.commands.list, (input)
|
||||
),
|
||||
)
|
||||
|
||||
function formatList(sessions: ReadonlyArray<SessionInfo>) {
|
||||
return sessions
|
||||
.map((session) =>
|
||||
[
|
||||
session.id,
|
||||
(session.title ?? "Untitled session").replace(/[\r\n\t]/g, " "),
|
||||
new Date(session.time.updated).toLocaleString(),
|
||||
].join("\t"),
|
||||
)
|
||||
.join(EOL)
|
||||
function formatTable(sessions: ReadonlyArray<SessionInfo>) {
|
||||
const rows = sessions.map((session) => ({
|
||||
id: session.id,
|
||||
title: (session.title ?? "Untitled session").replace(/[\r\n\t]/g, " "),
|
||||
updated: new Date(session.time.updated).toLocaleString(),
|
||||
}))
|
||||
const idWidth = Math.max(20, ...rows.map((row) => row.id.length))
|
||||
const titleWidth = Math.max(25, ...rows.map((row) => row.title.length))
|
||||
const header = `${"Session ID".padEnd(idWidth)} ${"Title".padEnd(titleWidth)} Updated`
|
||||
return [
|
||||
header,
|
||||
"─".repeat(header.length),
|
||||
...rows.map((row) => `${row.id.padEnd(idWidth)} ${row.title.padEnd(titleWidth)} ${row.updated}`),
|
||||
].join(EOL)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import type { RelativePath } from "@opencode/schema/schema"
|
||||
import type { Brand } from "effect"
|
||||
import type { Model } from "@opencode/schema/model"
|
||||
import type { DateTime } from "effect"
|
||||
import type { Permission } from "@opencode/schema/permission"
|
||||
import type { SessionMessage } from "@opencode/schema/session-message"
|
||||
import type { SessionInbox } from "@opencode/schema/session-inbox"
|
||||
import type { PromptInput } from "@opencode/schema/prompt-input"
|
||||
@@ -27,6 +26,7 @@ import type { Integration } from "@opencode/schema/integration"
|
||||
import type { Form } from "@opencode/schema/form"
|
||||
import type { Mcp } from "@opencode/schema/mcp"
|
||||
import type { Credential } from "@opencode/schema/credential"
|
||||
import type { Permission } from "@opencode/schema/permission"
|
||||
import type { PermissionSaved } from "@opencode/schema/permission-saved"
|
||||
import type { FileSystem } from "@opencode/schema/filesystem"
|
||||
import type { Command } from "@opencode/schema/command"
|
||||
@@ -209,7 +209,6 @@ export type SessionCreateInput = {
|
||||
readonly model?: Model.Ref | undefined
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly metadata?: Session.Metadata | undefined
|
||||
readonly permissions?: Permission.Ruleset | undefined
|
||||
}
|
||||
export type SessionCreateOutput = Session.Info
|
||||
export type SessionCreateOperation<E = never> = (input?: SessionCreateInput) => Effect.Effect<SessionCreateOutput, E>
|
||||
@@ -438,7 +437,6 @@ export type SessionLogOutput =
|
||||
readonly agent?: Agent.ID | undefined
|
||||
readonly model?: Model.Ref | undefined
|
||||
readonly metadata?: Session.Metadata | undefined
|
||||
readonly permissions?: Permission.Ruleset | undefined
|
||||
readonly version: string
|
||||
}
|
||||
}
|
||||
@@ -491,15 +489,6 @@ export type SessionLogOutput =
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: { readonly sessionID: Session.ID; readonly title: string }
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||
readonly type: "session.permissions.updated"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: { readonly sessionID: Session.ID; readonly permissions: Permission.Ruleset }
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: number
|
||||
@@ -1596,12 +1585,6 @@ export type PermissionReplyOperation<E = never> = (
|
||||
input: PermissionReplyInput,
|
||||
) => Effect.Effect<PermissionReplyOutput, E>
|
||||
|
||||
export type PermissionRulesInput = { readonly sessionID: Session.ID; readonly permissions: Permission.Ruleset }
|
||||
export type PermissionRulesOutput = void
|
||||
export type PermissionRulesOperation<E = never> = (
|
||||
input: PermissionRulesInput,
|
||||
) => Effect.Effect<PermissionRulesOutput, E>
|
||||
|
||||
export interface PermissionApi<E = never> {
|
||||
readonly request: { readonly list: PermissionRequestListOperation<E> }
|
||||
readonly saved: { readonly list: PermissionSavedListOperation<E>; readonly remove: PermissionSavedRemoveOperation<E> }
|
||||
@@ -1609,7 +1592,6 @@ export interface PermissionApi<E = never> {
|
||||
readonly list: PermissionListOperation<E>
|
||||
readonly get: PermissionGetOperation<E>
|
||||
readonly reply: PermissionReplyOperation<E>
|
||||
readonly rules: PermissionRulesOperation<E>
|
||||
}
|
||||
|
||||
export type FileListInput = {
|
||||
|
||||
@@ -181,8 +181,6 @@ import type {
|
||||
PermissionGetOutput,
|
||||
PermissionReplyInput,
|
||||
PermissionReplyOutput,
|
||||
PermissionRulesInput,
|
||||
PermissionRulesOutput,
|
||||
FileListInput,
|
||||
FileListOutput,
|
||||
FileFindInput,
|
||||
@@ -397,7 +395,6 @@ const EndpointSessionCreate = (raw: RawClient["server.session"]) => (input?: Ses
|
||||
model: input?.["model"],
|
||||
location: input?.["location"],
|
||||
metadata: input?.["metadata"],
|
||||
permissions: input?.["permissions"],
|
||||
},
|
||||
}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
@@ -1148,14 +1145,6 @@ const EndpointPermissionReply = (raw: RawClient["server.permission"]) => (input:
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointPermissionRules = (raw: RawClient["server.permission"]) => (input: PermissionRulesInput) =>
|
||||
preserveEffect<PermissionRulesOutput>()(
|
||||
raw["session.permission.rules"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { permissions: input["permissions"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroupPermission = (raw: RawClient["server.permission"]) => ({
|
||||
request: { list: EndpointPermissionRequestList(raw) },
|
||||
saved: { list: EndpointPermissionSavedList(raw), remove: EndpointPermissionSavedRemove(raw) },
|
||||
@@ -1163,7 +1152,6 @@ const adaptGroupPermission = (raw: RawClient["server.permission"]) => ({
|
||||
list: EndpointPermissionList(raw),
|
||||
get: EndpointPermissionGet(raw),
|
||||
reply: EndpointPermissionReply(raw),
|
||||
rules: EndpointPermissionRules(raw),
|
||||
})
|
||||
|
||||
const EndpointFileList = (raw: RawClient["server.fs"]) => (input?: FileListInput) =>
|
||||
|
||||
@@ -175,8 +175,6 @@ import type {
|
||||
PermissionGetOutput,
|
||||
PermissionReplyInput,
|
||||
PermissionReplyOutput,
|
||||
PermissionRulesInput,
|
||||
PermissionRulesOutput,
|
||||
FileReadInput,
|
||||
FileReadOutput,
|
||||
FileListInput,
|
||||
@@ -567,7 +565,6 @@ export function make(options: ClientOptions) {
|
||||
model: input?.["model"],
|
||||
location: input?.["location"],
|
||||
metadata: input?.["metadata"],
|
||||
permissions: input?.["permissions"],
|
||||
},
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401],
|
||||
@@ -1569,18 +1566,6 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
rules: (input: PermissionRulesInput, requestOptions?: RequestOptions) =>
|
||||
request<PermissionRulesOutput>(
|
||||
{
|
||||
method: "PUT",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission/rules`,
|
||||
body: { permissions: input["permissions"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [400, 401, 404],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
file: {
|
||||
read: (input: FileReadInput, requestOptions?: RequestOptions) =>
|
||||
|
||||
@@ -551,6 +551,28 @@ export type InstructionEntryInfo = { key: InstructionEntryKey; value: JsonValue
|
||||
|
||||
export type InstructionEntrySnapshot = Array<{ key: InstructionEntryKey; value: JsonValue; removed: boolean }>
|
||||
|
||||
export type SessionCreated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.created"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: {
|
||||
sessionID: string
|
||||
projectID: string
|
||||
location: LocationRef
|
||||
subpath?: string
|
||||
parentID?: string
|
||||
slug: string
|
||||
title?: string
|
||||
agent?: string
|
||||
model?: ModelRef
|
||||
metadata?: SessionMetadata
|
||||
version: string
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionAgentSelected = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -1629,6 +1651,24 @@ export type SessionInboxMove = {
|
||||
delivery: SessionInboxDelivery
|
||||
}
|
||||
|
||||
export type SessionInfo = {
|
||||
id: string
|
||||
parentID?: string
|
||||
fork?: { sessionID: string; boundary: SessionForkBoundary }
|
||||
projectID: string
|
||||
agent?: string
|
||||
model?: ModelRef
|
||||
cost: MoneyUSD
|
||||
tokens: TokenUsageInfo
|
||||
outcome?: "succeeded" | "failed" | "interrupted"
|
||||
time: { created: number; updated: number; idle?: number; viewed?: number; archived?: number }
|
||||
title?: string
|
||||
location: LocationRef
|
||||
subpath?: string
|
||||
metadata?: SessionMetadata
|
||||
revert?: SessionRevert
|
||||
}
|
||||
|
||||
export type SessionRevertStaged = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -1872,58 +1912,6 @@ export type AgentInfo = {
|
||||
permissions: PermissionRuleset
|
||||
}
|
||||
|
||||
export type SessionPermissionsUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.permissions.updated"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; permissions: PermissionRuleset }
|
||||
}
|
||||
|
||||
export type SessionInfo = {
|
||||
id: string
|
||||
parentID?: string
|
||||
fork?: { sessionID: string; boundary: SessionForkBoundary }
|
||||
projectID: string
|
||||
agent?: string
|
||||
model?: ModelRef
|
||||
cost: MoneyUSD
|
||||
tokens: TokenUsageInfo
|
||||
outcome?: "succeeded" | "failed" | "interrupted"
|
||||
time: { created: number; updated: number; idle?: number; viewed?: number; archived?: number }
|
||||
title?: string
|
||||
location: LocationRef
|
||||
subpath?: string
|
||||
metadata?: SessionMetadata
|
||||
permissions?: PermissionRuleset
|
||||
revert?: SessionRevert
|
||||
}
|
||||
|
||||
export type SessionCreated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.created"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: {
|
||||
sessionID: string
|
||||
projectID: string
|
||||
location: LocationRef
|
||||
subpath?: string
|
||||
parentID?: string
|
||||
slug: string
|
||||
title?: string
|
||||
agent?: string
|
||||
model?: ModelRef
|
||||
metadata?: SessionMetadata
|
||||
permissions?: PermissionRuleset
|
||||
version: string
|
||||
}
|
||||
}
|
||||
|
||||
export type ConfigEntry =
|
||||
| {
|
||||
type: "document"
|
||||
@@ -2096,6 +2084,8 @@ export type ConfigEntry =
|
||||
| { type: "agents"; path: string }
|
||||
| { type: "claude"; path: string }
|
||||
|
||||
export type SessionsResponse = { data: Array<SessionInfo>; cursor: { previous?: string | null; next?: string | null } }
|
||||
|
||||
export type SessionInboxUser = {
|
||||
id: string
|
||||
sessionID: string
|
||||
@@ -2150,8 +2140,6 @@ export type FormFields = [FormField, ...Array<FormField>]
|
||||
|
||||
export type FormFields2 = [FormField1, ...Array<FormField1>]
|
||||
|
||||
export type SessionsResponse = { data: Array<SessionInfo>; cursor: { previous?: string | null; next?: string | null } }
|
||||
|
||||
export type SessionInboxInfo = SessionInboxUser | SessionInboxSynthetic | SessionInboxCompaction | SessionInboxMove
|
||||
|
||||
export type SessionInboxEnqueued = {
|
||||
@@ -2245,7 +2233,6 @@ export type SessionEventDurable =
|
||||
| SessionModelSelected
|
||||
| SessionMoved
|
||||
| SessionRenamed
|
||||
| SessionPermissionsUpdated
|
||||
| SessionViewed
|
||||
| SessionDeleted
|
||||
| SessionForked
|
||||
@@ -2305,7 +2292,6 @@ export type V2Event =
|
||||
| SessionModelSelected
|
||||
| SessionMoved
|
||||
| SessionRenamed
|
||||
| SessionPermissionsUpdated
|
||||
| SessionViewed
|
||||
| SessionUsageUpdated
|
||||
| SessionDeleted
|
||||
@@ -2818,11 +2804,6 @@ export type SessionCreateInput = {
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
readonly metadata?: { readonly [x: string]: JsonValue } | null
|
||||
readonly permissions?: ReadonlyArray<{
|
||||
readonly action: string
|
||||
readonly resource: string
|
||||
readonly effect: "allow" | "deny" | "ask"
|
||||
}> | null
|
||||
}["id"]
|
||||
readonly title?: {
|
||||
readonly id?: string | null
|
||||
@@ -2831,11 +2812,6 @@ export type SessionCreateInput = {
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
readonly metadata?: { readonly [x: string]: JsonValue } | null
|
||||
readonly permissions?: ReadonlyArray<{
|
||||
readonly action: string
|
||||
readonly resource: string
|
||||
readonly effect: "allow" | "deny" | "ask"
|
||||
}> | null
|
||||
}["title"]
|
||||
readonly agent?: {
|
||||
readonly id?: string | null
|
||||
@@ -2844,11 +2820,6 @@ export type SessionCreateInput = {
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
readonly metadata?: { readonly [x: string]: JsonValue } | null
|
||||
readonly permissions?: ReadonlyArray<{
|
||||
readonly action: string
|
||||
readonly resource: string
|
||||
readonly effect: "allow" | "deny" | "ask"
|
||||
}> | null
|
||||
}["agent"]
|
||||
readonly model?: {
|
||||
readonly id?: string | null
|
||||
@@ -2857,11 +2828,6 @@ export type SessionCreateInput = {
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
readonly metadata?: { readonly [x: string]: JsonValue } | null
|
||||
readonly permissions?: ReadonlyArray<{
|
||||
readonly action: string
|
||||
readonly resource: string
|
||||
readonly effect: "allow" | "deny" | "ask"
|
||||
}> | null
|
||||
}["model"]
|
||||
readonly location?: {
|
||||
readonly id?: string | null
|
||||
@@ -2870,11 +2836,6 @@ export type SessionCreateInput = {
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
readonly metadata?: { readonly [x: string]: JsonValue } | null
|
||||
readonly permissions?: ReadonlyArray<{
|
||||
readonly action: string
|
||||
readonly resource: string
|
||||
readonly effect: "allow" | "deny" | "ask"
|
||||
}> | null
|
||||
}["location"]
|
||||
readonly metadata?: {
|
||||
readonly id?: string | null
|
||||
@@ -2883,25 +2844,7 @@ export type SessionCreateInput = {
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
readonly metadata?: { readonly [x: string]: JsonValue } | null
|
||||
readonly permissions?: ReadonlyArray<{
|
||||
readonly action: string
|
||||
readonly resource: string
|
||||
readonly effect: "allow" | "deny" | "ask"
|
||||
}> | null
|
||||
}["metadata"]
|
||||
readonly permissions?: {
|
||||
readonly id?: string | null
|
||||
readonly title?: string | null
|
||||
readonly agent?: string | null
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
readonly metadata?: { readonly [x: string]: JsonValue } | null
|
||||
readonly permissions?: ReadonlyArray<{
|
||||
readonly action: string
|
||||
readonly resource: string
|
||||
readonly effect: "allow" | "deny" | "ask"
|
||||
}> | null
|
||||
}["permissions"]
|
||||
}
|
||||
|
||||
export type SessionCreateOutput = { data: SessionInfo }["data"]
|
||||
@@ -2939,11 +2882,6 @@ export type SessionImportInput = {
|
||||
readonly location: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly subpath?: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly permissions?: ReadonlyArray<{
|
||||
readonly action: string
|
||||
readonly resource: string
|
||||
readonly effect: "allow" | "deny" | "ask"
|
||||
}>
|
||||
readonly revert?: {
|
||||
readonly messageID: string
|
||||
readonly partID?: string
|
||||
@@ -3249,11 +3187,6 @@ export type SessionImportInput = {
|
||||
readonly location: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly subpath?: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly permissions?: ReadonlyArray<{
|
||||
readonly action: string
|
||||
readonly resource: string
|
||||
readonly effect: "allow" | "deny" | "ask"
|
||||
}>
|
||||
readonly revert?: {
|
||||
readonly messageID: string
|
||||
readonly partID?: string
|
||||
@@ -3559,11 +3492,6 @@ export type SessionImportInput = {
|
||||
readonly location: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly subpath?: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly permissions?: ReadonlyArray<{
|
||||
readonly action: string
|
||||
readonly resource: string
|
||||
readonly effect: "allow" | "deny" | "ask"
|
||||
}>
|
||||
readonly revert?: {
|
||||
readonly messageID: string
|
||||
readonly partID?: string
|
||||
@@ -5825,19 +5753,6 @@ export type PermissionReplyInput = {
|
||||
|
||||
export type PermissionReplyOutput = void
|
||||
|
||||
export type PermissionRulesInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly permissions: {
|
||||
readonly permissions: ReadonlyArray<{
|
||||
readonly action: string
|
||||
readonly resource: string
|
||||
readonly effect: "allow" | "deny" | "ask"
|
||||
}>
|
||||
}["permissions"]
|
||||
}
|
||||
|
||||
export type PermissionRulesOutput = void
|
||||
|
||||
export type FileReadInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
|
||||
@@ -695,10 +695,6 @@ export function createData(config: CreateDataInput) {
|
||||
})
|
||||
return
|
||||
}
|
||||
case "session.permissions.updated":
|
||||
if (store.session.info[event.data.sessionID])
|
||||
setStore("session", "info", event.data.sessionID, "permissions", event.data.permissions)
|
||||
return
|
||||
case "session.moved": {
|
||||
const current = store.session.info[event.data.sessionID]
|
||||
if (current) {
|
||||
|
||||
@@ -5,8 +5,9 @@ import { coerceToString } from "./value.js"
|
||||
// WebIDL DOMString conversion: a missing argument is a TypeError, anything else stringifies.
|
||||
const base64 = (name: "atob" | "btoa") =>
|
||||
sync(name, (args, node) => {
|
||||
if (args.length === 0)
|
||||
throw new InterpreterRuntimeError(`${name} requires 1 argument (a string)`, node).as("TypeError")
|
||||
if (args.length === 0) {
|
||||
throw new InterpreterRuntimeError(`${name} requires 1 argument, but only 0 were provided.`, node).as("TypeError")
|
||||
}
|
||||
const input = coerceToString(args[0])
|
||||
try {
|
||||
return name === "atob" ? atob(input) : btoa(input)
|
||||
|
||||
-2
@@ -45,7 +45,6 @@ import m42 from "./migration/20260812181746_session_inbox.js"
|
||||
import m43 from "./migration/20260812213948_worktree.js"
|
||||
import m44 from "./migration/20260819222447_session_viewed_state.js"
|
||||
import m45 from "./migration/20260823191254_nullable_workspace_binding.js"
|
||||
import m46 from "./migration/20260910120000_clear_v1_session_permission.js"
|
||||
|
||||
export const migrations = [
|
||||
m00,
|
||||
@@ -94,5 +93,4 @@ export const migrations = [
|
||||
m43,
|
||||
m44,
|
||||
m45,
|
||||
m46,
|
||||
] satisfies DatabaseMigration.Migration[]
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration.js"
|
||||
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260910120000_clear_v1_session_permission",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`UPDATE \`session_v2\` SET \`permission\` = NULL;`)
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export default migration
|
||||
@@ -600,7 +600,7 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
|
||||
id, ${projectID}, workspace_id, parent_id, slug, directory, path, title, version, share_url,
|
||||
summary_additions, summary_deletions, summary_files, summary_diffs, metadata, cost,
|
||||
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
|
||||
revert, NULL, agent, model, time_created, time_updated, time_compacting, time_archived
|
||||
revert, permission, agent, model, time_created, time_updated, time_compacting, time_archived
|
||||
FROM session
|
||||
WHERE id = ${nextID.id}
|
||||
`)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * as Generate from "./generate.js"
|
||||
|
||||
import { LLM, LLMClient, AIError } from "@opencode/ai"
|
||||
import { SessionID } from "@opencode/schema/session-id"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { makeLocationNode } from "@opencode/util/effect/app-node"
|
||||
import { llmClient } from "./effect/app-node-platform.js"
|
||||
@@ -41,8 +42,6 @@ export const layer = Layer.effect(
|
||||
[
|
||||
"SessionRunnerModel.VariantUnavailableError",
|
||||
"SessionRunnerModel.UnsupportedPackageError",
|
||||
"SessionRunnerModel.ModelConfigurationError",
|
||||
"SessionRunnerModel.ModelInitializationError",
|
||||
"SessionRunnerModel.UnresolvedProviderVariablesError",
|
||||
"SessionRunnerModel.UnsupportedCompactionError",
|
||||
],
|
||||
@@ -60,15 +59,24 @@ export const layer = Layer.effect(
|
||||
? `Model unavailable: ${input.model.providerID}/${input.model.id}`
|
||||
: "No model specified and no supported model is available",
|
||||
})
|
||||
const response = yield* llm.generate(LLM.request({ model: resolved.model, prompt: input.prompt })).pipe(
|
||||
Effect.mapError(
|
||||
(error: AIError) =>
|
||||
new UnavailableError({
|
||||
message: error.message,
|
||||
service: resolved.ref.providerID,
|
||||
}),
|
||||
),
|
||||
)
|
||||
const response = yield* llm
|
||||
.generate(
|
||||
LLM.request({
|
||||
model: resolved.model,
|
||||
prompt: input.prompt,
|
||||
// Gateways require session attribution even for a stateless call; no Session is stored.
|
||||
http: { headers: { "x-opencode-session": SessionID.create() } },
|
||||
}),
|
||||
)
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
(error: AIError) =>
|
||||
new UnavailableError({
|
||||
message: error.message,
|
||||
service: resolved.ref.providerID,
|
||||
}),
|
||||
),
|
||||
)
|
||||
return response.text
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as ModelResolver from "./model-resolver.js"
|
||||
|
||||
import { makeLocationNode } from "@opencode/util/effect/app-node"
|
||||
import { LanguageModel, ProviderConfigurationError } from "@opencode/ai"
|
||||
import { LanguageModel } from "@opencode/ai"
|
||||
import { Auth } from "@opencode/ai/route"
|
||||
import { Context, Effect, Layer, Schema, Struct } from "effect"
|
||||
import { AISDK } from "./aisdk.js"
|
||||
@@ -39,40 +39,6 @@ export class UnsupportedPackageError extends Schema.TaggedError<UnsupportedPacka
|
||||
}
|
||||
}
|
||||
|
||||
export const InitializationPhase = Schema.Literals(["load", "init", "construct"])
|
||||
export type InitializationPhase = typeof InitializationPhase.Type
|
||||
|
||||
/** Provider settings are missing, conflicting, or unsupported; the provider's own message tells the user what to fix. */
|
||||
export class ModelConfigurationError extends Schema.TaggedError<ModelConfigurationError>()(
|
||||
"SessionRunnerModel.ModelConfigurationError",
|
||||
{
|
||||
providerID: Provider.ID,
|
||||
modelID: ID,
|
||||
package: Schema.String,
|
||||
detail: Schema.String,
|
||||
},
|
||||
) {
|
||||
override get message() {
|
||||
return `Cannot initialize ${this.providerID}/${this.modelID}: ${this.detail}`
|
||||
}
|
||||
}
|
||||
|
||||
/** A supported package failed unexpectedly while loading or constructing the model. */
|
||||
export class ModelInitializationError extends Schema.TaggedError<ModelInitializationError>()(
|
||||
"SessionRunnerModel.ModelInitializationError",
|
||||
{
|
||||
providerID: Provider.ID,
|
||||
modelID: ID,
|
||||
package: Schema.String,
|
||||
phase: InitializationPhase,
|
||||
detail: Schema.String,
|
||||
},
|
||||
) {
|
||||
override get message() {
|
||||
return `Cannot initialize ${this.providerID}/${this.modelID}: ${this.detail}`
|
||||
}
|
||||
}
|
||||
|
||||
export class UnresolvedProviderVariablesError extends Schema.TaggedError<UnresolvedProviderVariablesError>()(
|
||||
"SessionRunnerModel.UnresolvedProviderVariablesError",
|
||||
{
|
||||
@@ -102,8 +68,6 @@ export class UnsupportedCompactionError extends Schema.TaggedError<UnsupportedCo
|
||||
export type Error =
|
||||
| VariantUnavailableError
|
||||
| UnsupportedPackageError
|
||||
| ModelConfigurationError
|
||||
| ModelInitializationError
|
||||
| UnresolvedProviderVariablesError
|
||||
| UnsupportedCompactionError
|
||||
| Integration.AuthorizationError
|
||||
@@ -171,11 +135,7 @@ export const fromCatalogModel = (
|
||||
dependencies?: Dependencies,
|
||||
): Effect.Effect<
|
||||
LanguageModel,
|
||||
| UnsupportedPackageError
|
||||
| ModelConfigurationError
|
||||
| ModelInitializationError
|
||||
| UnresolvedProviderVariablesError
|
||||
| UnsupportedCompactionError
|
||||
UnsupportedPackageError | UnresolvedProviderVariablesError | UnsupportedCompactionError
|
||||
> =>
|
||||
resolveCatalogModel(model, credential, dependencies).pipe(
|
||||
Effect.flatMap((resolved) => validateProviderVariables(model, resolved)),
|
||||
@@ -218,16 +178,14 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
|
||||
...configuration,
|
||||
}) ?? {},
|
||||
)
|
||||
return yield* loadAISDK({ ...resolved, settings }).pipe(
|
||||
Effect.mapError((error) => initialization(resolved, "init", error.cause)),
|
||||
)
|
||||
return yield* loadAISDK({ ...resolved, settings }).pipe(Effect.mapError(() => unsupported(resolved)))
|
||||
}
|
||||
if (!native) return yield* unsupported(resolved)
|
||||
|
||||
const specifier = native
|
||||
const mapped = yield* prepareProviderSettings(resolved, mapping?.settings ?? configured)
|
||||
const module = yield* (dependencies?.loadPackage ?? Provider.loadPackage)(specifier).pipe(
|
||||
Effect.mapError((error) => initialization(resolved, "load", error.cause)),
|
||||
Effect.mapError(() => unsupported(resolved)),
|
||||
)
|
||||
const settings = {
|
||||
...(credential ? Struct.omit(mapped, ["accessToken", "apiKey", "authToken"]) : mapped),
|
||||
@@ -246,15 +204,7 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
|
||||
: runtime.compatibility,
|
||||
})
|
||||
},
|
||||
catch: (cause) =>
|
||||
cause instanceof ProviderConfigurationError
|
||||
? new ModelConfigurationError({
|
||||
providerID: resolved.providerID,
|
||||
modelID: resolved.id,
|
||||
package: resolved.package ?? "unknown",
|
||||
detail: cause.message,
|
||||
})
|
||||
: initialization(resolved, "construct", cause),
|
||||
catch: () => unsupported(resolved),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -327,24 +277,6 @@ const unsupported = (model: Info) =>
|
||||
package: model.package ?? "unknown",
|
||||
})
|
||||
|
||||
const initialization = (model: Info, phase: InitializationPhase, cause: unknown) =>
|
||||
new ModelInitializationError({
|
||||
providerID: model.providerID,
|
||||
modelID: model.id,
|
||||
package: model.package ?? "unknown",
|
||||
phase,
|
||||
detail: causeMessage(cause) ?? `${phase} failed for ${model.package ?? "unknown"}`,
|
||||
})
|
||||
|
||||
// Unexpected throws still carry the most useful diagnosis in their message; a stack or an unknown value does not.
|
||||
const causeMessage = (cause: unknown): string | undefined => {
|
||||
if (typeof cause === "string") return cause.trim() || undefined
|
||||
if (!(cause instanceof globalThis.Error)) return undefined
|
||||
const message = cause.message.trim()
|
||||
if (message) return message
|
||||
return causeMessage(cause.cause)
|
||||
}
|
||||
|
||||
export const resolveModel = (
|
||||
model: Info,
|
||||
variant: VariantID | undefined,
|
||||
|
||||
@@ -154,7 +154,7 @@ const layer = Layer.effect(
|
||||
const session = yield* sessions.get(sessionID)
|
||||
if (!session) return yield* new SessionErrors.NotFoundError({ sessionID })
|
||||
const agent = yield* agents.resolve(agentID ?? session.agent)
|
||||
return merge(agent?.permissions ?? missingAgentPermissions, session.permissions ?? [])
|
||||
return agent?.permissions ?? missingAgentPermissions
|
||||
})
|
||||
|
||||
function denied(input: Pick<Request, "action" | "resources">, rules: Permission.Ruleset) {
|
||||
|
||||
@@ -404,7 +404,6 @@ export const make = Effect.fn("PluginHost.make")(function* (
|
||||
: Effect.fail(new Error(`Permission request not found: ${input.requestID}`)),
|
||||
),
|
||||
),
|
||||
rules: sessions.setPermissions,
|
||||
},
|
||||
plugin: {
|
||||
list: () => response(plugin.list()),
|
||||
@@ -510,8 +509,6 @@ export const make = Effect.fn("PluginHost.make")(function* (
|
||||
title: input?.title,
|
||||
agent: input?.agent,
|
||||
model: input?.model,
|
||||
metadata: input?.metadata,
|
||||
permissions: input?.permissions,
|
||||
location:
|
||||
input?.location ?? Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }),
|
||||
}),
|
||||
|
||||
@@ -11,7 +11,6 @@ import PROMPT_ASTRA from "./system-prompt/gpt-astra.txt"
|
||||
import PROMPT_KIMI from "./system-prompt/kimi.txt"
|
||||
import PROMPT_META from "./system-prompt/meta.txt"
|
||||
import PROMPT_TRINITY from "./system-prompt/trinity.txt"
|
||||
import PROMPT_ANTHROPIC from "./system-prompt/anthropic.txt"
|
||||
|
||||
export const OpenAIPlugin = make("opencode.prompt.openai", (model) => {
|
||||
const id = model.id.toLowerCase()
|
||||
@@ -19,19 +18,6 @@ export const OpenAIPlugin = make("opencode.prompt.openai", (model) => {
|
||||
return id.includes("gpt-6") ? PROMPT_ASTRA : PROMPT_GPT
|
||||
})
|
||||
|
||||
export const AnthropicPlugin = make(
|
||||
"opencode.prompt.anthropic",
|
||||
(model) => {
|
||||
const id = model.id.toLowerCase()
|
||||
if (!id.includes("claude")) return undefined
|
||||
return PROMPT_ANTHROPIC
|
||||
},
|
||||
"append",
|
||||
)
|
||||
|
||||
// Both OpenAIToolsPlugin and AnthropicToolsPlugin are disabled intentionally until we can figure out a good ux for displaying
|
||||
// heavy grep/glob usage done via shell or other mechanisms
|
||||
|
||||
export const OpenAIToolsPlugin = make("opencode.optimize.openai.tools", (model, tools) => {
|
||||
const ids = [model.id, model.modelID, model.family].join(" ").toLowerCase()
|
||||
if (!ids.includes("gpt")) return undefined
|
||||
@@ -59,12 +45,11 @@ export const MetaPlugin = make("opencode.prompt.meta", (model) => {
|
||||
return PROMPT_META.replaceAll("{{MODEL_NAME}}", model.name)
|
||||
})
|
||||
|
||||
export const Plugins = [OpenAIPlugin, AnthropicPlugin, KimiPlugin, ArceePlugin, MetaPlugin] as const
|
||||
export const Plugins = [OpenAIPlugin, KimiPlugin, ArceePlugin, MetaPlugin] as const
|
||||
|
||||
function make(
|
||||
id: string,
|
||||
optimize: (model: Model.Info, tools: SessionHooks["context"]["tools"]) => string | undefined,
|
||||
mode: "override" | "append" = "override",
|
||||
) {
|
||||
return define({
|
||||
id,
|
||||
@@ -81,9 +66,7 @@ function make(
|
||||
if ((yield* ctx.agent.get({ agentID: event.agent })).data.system) return
|
||||
const system = event.system[0]
|
||||
if (!system) return
|
||||
const rendered = SessionSystemPrompt.render(template, Object.keys(event.tools))
|
||||
const text = mode === "append" ? `${system.text}\n\n${rendered}` : rendered
|
||||
event.system[0] = { ...system, text }
|
||||
event.system[0] = { ...system, text: SessionSystemPrompt.render(template, Object.keys(event.tools)) }
|
||||
}).pipe(Effect.catch(() => Effect.void))
|
||||
yield* ctx.session.hook("context", hook)
|
||||
yield* ctx.session.hook("compaction", hook)
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
# Code comments
|
||||
By default, match the surrounding comment density: where the code has none, add none. Use comments sparingly, only where they are appropriate, such as for behavior that is not obvious from the code itself. Instructions from the user or the project take precedence over this guidance.
|
||||
@@ -18,7 +18,6 @@ import { SessionMessageTable } from "./session/sql.js"
|
||||
import { SessionSchema } from "./session/schema.js"
|
||||
import { RelativePath } from "./schema.js"
|
||||
import { Agent } from "@opencode/schema/agent"
|
||||
import type { Permission } from "@opencode/schema/permission"
|
||||
import { App } from "./app.js"
|
||||
import { Slug } from "./util/slug.js"
|
||||
import path from "path"
|
||||
@@ -82,7 +81,6 @@ type CreateBaseInput = {
|
||||
agent?: Agent.ID
|
||||
model?: Model.Ref
|
||||
metadata?: SessionSchema.Metadata
|
||||
permissions?: Permission.Ruleset
|
||||
}
|
||||
type CreateInput = CreateBaseInput &
|
||||
({ location: Location.Ref; parentID?: never } | { parentID: SessionSchema.ID; location?: never })
|
||||
@@ -159,10 +157,6 @@ export interface Interface {
|
||||
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: Agent.ID }) => Effect.Effect<void, NotFoundError>
|
||||
readonly switchModel: (input: { sessionID: SessionSchema.ID; model: Model.Ref }) => Effect.Effect<void, NotFoundError>
|
||||
readonly rename: (input: { sessionID: SessionSchema.ID; title: string }) => Effect.Effect<void, NotFoundError>
|
||||
readonly setPermissions: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
permissions: Permission.Ruleset
|
||||
}) => Effect.Effect<void, NotFoundError>
|
||||
readonly move: SessionMove.Interface["move"]
|
||||
readonly prompt: (
|
||||
input: Parameters<Session.Handle["prompt"]>[0] & { sessionID: SessionSchema.ID },
|
||||
@@ -254,10 +248,9 @@ const layer = Layer.effect(
|
||||
subpath: RelativePath.make(path.relative(project.directory, location.directory).replaceAll("\\", "/")),
|
||||
title: input.title,
|
||||
agent: input.agent,
|
||||
// Children inherit metadata and permissions the way they inherit
|
||||
// location, so host policies that read them treat the family uniformly.
|
||||
// Children inherit metadata the way they inherit location, so
|
||||
// host policies that read it treat the family uniformly.
|
||||
metadata: input.metadata ?? parent?.metadata,
|
||||
permissions: input.permissions ?? parent?.permissions,
|
||||
model: input.model
|
||||
? {
|
||||
id: Model.ID.make(input.model.id),
|
||||
@@ -394,7 +387,6 @@ const layer = Layer.effect(
|
||||
switchAgent: (input) => sessions.forSession(input.sessionID).switchAgent(input),
|
||||
switchModel: (input) => sessions.forSession(input.sessionID).switchModel(input),
|
||||
rename: (input) => sessions.forSession(input.sessionID).rename(input),
|
||||
setPermissions: (input) => sessions.forSession(input.sessionID).setPermissions(input),
|
||||
move: moves.move,
|
||||
compact: (input) => sessions.forSession(input.sessionID).compact(input),
|
||||
wait: (sessionID) => sessions.forSession(sessionID).wait(),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export * as SessionContext from "./context.js"
|
||||
|
||||
import { Model } from "@opencode/schema/model"
|
||||
import { Permission } from "../permission.js"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { Agent } from "../agent.js"
|
||||
import { Catalog } from "../catalog.js"
|
||||
@@ -130,7 +129,7 @@ const layer = Layer.effect(
|
||||
if (!agent.info) return yield* new AgentNotFoundError({ sessionID: session.id, agent: session.agent ?? agent.id })
|
||||
const loaded = yield* Effect.all(
|
||||
{
|
||||
tools: registry.snapshot(Permission.merge(agent.info.permissions, session.permissions ?? [])),
|
||||
tools: registry.snapshot(agent.info.permissions),
|
||||
builtins: builtins.load(sessionID),
|
||||
discovery: discovery.load(),
|
||||
skills: skillInstructions.load(agent),
|
||||
|
||||
@@ -50,7 +50,6 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In
|
||||
}),
|
||||
subpath: row.path ? RelativePath.make(row.path) : undefined,
|
||||
metadata: row.metadata ?? undefined,
|
||||
permissions: row.permission ?? undefined,
|
||||
revert: row.revert ? decodeRevert(row.revert) : undefined,
|
||||
outcome: row.idle_outcome ?? undefined,
|
||||
time: {
|
||||
|
||||
@@ -116,7 +116,6 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
)
|
||||
}),
|
||||
"session.renamed": () => Effect.void,
|
||||
"session.permissions.updated": () => Effect.void,
|
||||
"session.deleted": () => Effect.void,
|
||||
"session.forked": () => Effect.void,
|
||||
"session.inbox.delivered": () => Effect.void,
|
||||
|
||||
@@ -160,7 +160,6 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
||||
agent: parent.agent,
|
||||
model: parent.model,
|
||||
metadata: parent.metadata,
|
||||
permission: parent.permission,
|
||||
version: parent.version,
|
||||
cost: 0,
|
||||
tokens_input: 0,
|
||||
@@ -451,7 +450,6 @@ const layer = Layer.effectDiscard(
|
||||
agent: event.data.agent,
|
||||
model: event.data.model,
|
||||
metadata: event.data.metadata,
|
||||
permission: event.data.permissions,
|
||||
version: event.data.version,
|
||||
time_created: event.created,
|
||||
time_updated: event.created,
|
||||
@@ -573,14 +571,6 @@ const layer = Layer.effectDiscard(
|
||||
.run()
|
||||
.pipe(Effect.orDie),
|
||||
)
|
||||
yield* bus.project(SessionEvent.PermissionsUpdated, (event) =>
|
||||
db
|
||||
.update(SessionTable)
|
||||
.set({ permission: event.data.permissions, time_updated: event.created })
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie),
|
||||
)
|
||||
yield* bus.project(SessionEvent.Viewed, (event) => {
|
||||
const idle = event.data.idle
|
||||
return db
|
||||
|
||||
@@ -33,10 +33,6 @@ export const VariantUnavailableError = ModelResolver.VariantUnavailableError
|
||||
export type VariantUnavailableError = ModelResolver.VariantUnavailableError
|
||||
export const UnsupportedPackageError = ModelResolver.UnsupportedPackageError
|
||||
export type UnsupportedPackageError = ModelResolver.UnsupportedPackageError
|
||||
export const ModelConfigurationError = ModelResolver.ModelConfigurationError
|
||||
export type ModelConfigurationError = ModelResolver.ModelConfigurationError
|
||||
export const ModelInitializationError = ModelResolver.ModelInitializationError
|
||||
export type ModelInitializationError = ModelResolver.ModelInitializationError
|
||||
export const UnresolvedProviderVariablesError = ModelResolver.UnresolvedProviderVariablesError
|
||||
export type UnresolvedProviderVariablesError = ModelResolver.UnresolvedProviderVariablesError
|
||||
export const UnsupportedCompactionError = ModelResolver.UnsupportedCompactionError
|
||||
|
||||
@@ -3,7 +3,6 @@ export * as Session from "./session.js"
|
||||
import { DateTime, Effect, Fiber, Scope } from "effect"
|
||||
import type { Agent } from "@opencode/schema/agent"
|
||||
import type { Model } from "@opencode/schema/model"
|
||||
import type { Permission } from "@opencode/schema/permission"
|
||||
import { Event } from "@opencode/schema/event"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import { Bus } from "../bus.js"
|
||||
@@ -73,13 +72,6 @@ export const make = Effect.fn("Session.make")(function* () {
|
||||
yield* get(sessionID)
|
||||
yield* bus.publish(SessionEvent.Renamed, { sessionID, title: input.title })
|
||||
})
|
||||
const setPermissions = Effect.fn("Session.setPermissions")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
input: { permissions: Permission.Ruleset },
|
||||
) {
|
||||
yield* get(sessionID)
|
||||
yield* bus.publish(SessionEvent.PermissionsUpdated, { sessionID, permissions: input.permissions })
|
||||
})
|
||||
const switchAgent = Effect.fn("Session.switchAgent")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
input: { agent: Agent.ID },
|
||||
@@ -342,7 +334,6 @@ export const make = Effect.fn("Session.make")(function* () {
|
||||
message,
|
||||
view,
|
||||
rename,
|
||||
setPermissions,
|
||||
switchAgent,
|
||||
switchModel,
|
||||
inbox,
|
||||
@@ -365,7 +356,6 @@ export const make = Effect.fn("Session.make")(function* () {
|
||||
const message = operations.message.bind(undefined, sessionID)
|
||||
const view = operations.view.bind(undefined, sessionID)
|
||||
const rename = operations.rename.bind(undefined, sessionID)
|
||||
const setPermissions = operations.setPermissions.bind(undefined, sessionID)
|
||||
const switchAgent = operations.switchAgent.bind(undefined, sessionID)
|
||||
const switchModel = operations.switchModel.bind(undefined, sessionID)
|
||||
const inbox = operations.inbox.bind(undefined, sessionID)
|
||||
@@ -391,7 +381,6 @@ export const make = Effect.fn("Session.make")(function* () {
|
||||
message,
|
||||
view,
|
||||
rename,
|
||||
setPermissions,
|
||||
switchAgent,
|
||||
switchModel,
|
||||
inbox,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ProjectTable } from "../project/sql.js"
|
||||
import type { SessionMessage } from "./message.js"
|
||||
import type { SessionInbox } from "./inbox.js"
|
||||
import type { FileDiff } from "@opencode/schema/file-diff"
|
||||
import type { Permission } from "@opencode/schema/permission"
|
||||
import type { PermissionV1 } from "@opencode/schema/permission-v1"
|
||||
import type { Project } from "@opencode/schema/project"
|
||||
import type { SessionSchema } from "./schema.js"
|
||||
import type { Workspace } from "@opencode/schema/workspace"
|
||||
@@ -49,7 +49,7 @@ export const SessionTable = sqliteTable(
|
||||
tokens_cache_read: integer().notNull().default(0),
|
||||
tokens_cache_write: integer().notNull().default(0),
|
||||
revert: text({ mode: "json" }).$type<Session.Revert | RevertV1>(),
|
||||
permission: text({ mode: "json" }).$type<Permission.Ruleset>(),
|
||||
permission: text({ mode: "json" }).$type<PermissionV1.Ruleset>(),
|
||||
agent: text(),
|
||||
model: text({ mode: "json" }).$type<{
|
||||
id: string
|
||||
|
||||
@@ -55,8 +55,6 @@ export function toSessionError(cause: unknown): SessionError.Error {
|
||||
cause instanceof SessionRunnerModel.ModelUnavailableError ||
|
||||
cause instanceof SessionRunnerModel.VariantUnavailableError ||
|
||||
cause instanceof SessionRunnerModel.UnsupportedPackageError ||
|
||||
cause instanceof SessionRunnerModel.ModelConfigurationError ||
|
||||
cause instanceof SessionRunnerModel.ModelInitializationError ||
|
||||
cause instanceof SessionRunnerModel.UnresolvedProviderVariablesError
|
||||
)
|
||||
return { type: "provider.no-route", message: cause.message }
|
||||
|
||||
@@ -103,7 +103,6 @@ const layer = Layer.effect(
|
||||
agent: input.data.info.agent,
|
||||
model: input.data.info.model,
|
||||
metadata: input.data.info.metadata,
|
||||
permissions: input.data.info.permissions,
|
||||
},
|
||||
{
|
||||
location: input.location,
|
||||
|
||||
@@ -99,19 +99,8 @@ export const layer = Layer.effect(
|
||||
},
|
||||
)
|
||||
const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
|
||||
const output = () => {
|
||||
if (result.structured !== undefined) return result.structured
|
||||
if (text === "") return null
|
||||
// Agents assume JSON returned as text is already an object, so parse it when the server declares no schema.
|
||||
if (tool.outputSchema === undefined && (text.startsWith("{") || text.startsWith("["))) {
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {}
|
||||
}
|
||||
return text
|
||||
}
|
||||
return {
|
||||
output: output(),
|
||||
output: result.structured ?? (text === "" ? null : text),
|
||||
...(content.length === 0 ? {} : { content }),
|
||||
}
|
||||
}).pipe(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect } from "bun:test"
|
||||
import { LanguageModel } from "@opencode/ai"
|
||||
import { LanguageModel, LLMClient } from "@opencode/ai"
|
||||
import { RequestExecutor } from "@opencode/ai/route"
|
||||
import { OpenAIChat } from "@opencode/ai/protocols"
|
||||
import { TestLLM } from "@opencode/ai/testing"
|
||||
import { AISDK } from "@opencode/core/aisdk"
|
||||
@@ -11,6 +12,7 @@ import { ID, Info, Ref } from "@opencode/core/model"
|
||||
import { Provider } from "@opencode/core/provider"
|
||||
import { Npm } from "@opencode/util/npm"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const selected = Info.make({
|
||||
@@ -98,3 +100,56 @@ resolverIt.effect("resolves dynamic models with their catalog metadata", () =>
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
testEffect(Layer.empty).effect("attributes each stateless completion without creating a stored session", () =>
|
||||
Effect.gen(function* () {
|
||||
const sessions: string[] = []
|
||||
const http = Layer.succeed(
|
||||
HttpClient.HttpClient,
|
||||
HttpClient.make((request) =>
|
||||
Effect.sync(() => {
|
||||
const session = request.headers["x-opencode-session"]
|
||||
if (!session)
|
||||
return HttpClientResponse.fromWeb(
|
||||
request,
|
||||
Response.json(
|
||||
{
|
||||
error: { type: "MissingSessionID", message: "Session ID is required" },
|
||||
},
|
||||
{ status: 400 },
|
||||
),
|
||||
)
|
||||
sessions.push(session)
|
||||
return HttpClientResponse.fromWeb(
|
||||
request,
|
||||
new Response(
|
||||
`data: ${JSON.stringify({
|
||||
id: "completion",
|
||||
object: "chat.completion.chunk",
|
||||
created: 1,
|
||||
model: "gemini",
|
||||
choices: [{ index: 0, delta: { content: "OK" }, finish_reason: "stop" }],
|
||||
})}\n\ndata: [DONE]\n\n`,
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
const native = LLMClient.layer.pipe(Layer.provide(RequestExecutor.layer.pipe(Layer.provide(http))))
|
||||
yield* Effect.gen(function* () {
|
||||
const generate = yield* Generate.Service
|
||||
for (let index = 0; index < 2; index++) {
|
||||
expect(
|
||||
yield* generate.text({
|
||||
prompt: "Return exactly OK",
|
||||
model: Ref.make({ providerID: selected.providerID, id: selected.id }),
|
||||
}),
|
||||
).toBe("OK")
|
||||
}
|
||||
}).pipe(Effect.provide(Generate.layer.pipe(Layer.provide(Layer.merge(resolver, native)))))
|
||||
expect(sessions).toHaveLength(2)
|
||||
expect(sessions[0]).toStartWith("ses_")
|
||||
expect(sessions[1]).not.toBe(sessions[0])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -324,32 +324,6 @@ const mcp = Layer.mock(Mcp.Service, {
|
||||
description: "Status",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
}),
|
||||
new Mcp.Tool({
|
||||
server: Mcp.ServerName.make("demo"),
|
||||
name: "issues",
|
||||
description: "Returns JSON as text",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
}),
|
||||
new Mcp.Tool({
|
||||
server: Mcp.ServerName.make("demo"),
|
||||
name: "count",
|
||||
description: "Returns a number as text",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
}),
|
||||
new Mcp.Tool({
|
||||
server: Mcp.ServerName.make("demo"),
|
||||
name: "typed",
|
||||
description: "Declares a string output and returns JSON as text",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
outputSchema: { type: "string" },
|
||||
}),
|
||||
new Mcp.Tool({
|
||||
server: Mcp.ServerName.make("direct"),
|
||||
name: "issues",
|
||||
codemode: false,
|
||||
description: "Returns JSON as text",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
}),
|
||||
new Mcp.Tool({
|
||||
server: Mcp.ServerName.make("direct"),
|
||||
name: "lookup",
|
||||
@@ -400,20 +374,6 @@ const mcp = Layer.mock(Mcp.Service, {
|
||||
isError: false,
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
})
|
||||
if (input.name === "issues" || input.name === "typed")
|
||||
return new Mcp.ToolResult({
|
||||
server: Mcp.ServerName.make(input.server),
|
||||
tool: input.name,
|
||||
isError: false,
|
||||
content: [{ type: "text", text: '{"issues":[{"id":1}]}' }],
|
||||
})
|
||||
if (input.name === "count")
|
||||
return new Mcp.ToolResult({
|
||||
server: Mcp.ServerName.make(input.server),
|
||||
tool: input.name,
|
||||
isError: false,
|
||||
content: [{ type: "text", text: "42" }],
|
||||
})
|
||||
return new Mcp.ToolResult({
|
||||
server: Mcp.ServerName.make(input.server),
|
||||
tool: input.name,
|
||||
@@ -1983,7 +1943,6 @@ it.effect("advertises MCP output schemas to Code Mode", () =>
|
||||
|
||||
expect(toolSet.definitions.map((tool) => tool.name)).toEqual([
|
||||
"direct_fail",
|
||||
"direct_issues",
|
||||
"direct_lookup",
|
||||
"direct_media",
|
||||
"execute",
|
||||
@@ -2074,39 +2033,6 @@ it.effect("returns content-only MCP results through Code Mode", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("parses JSON text results from MCP tools without an output schema", () =>
|
||||
Effect.gen(function* () {
|
||||
assertion = yield* Deferred.make<Permission.AssertInput>()
|
||||
decision = Effect.void
|
||||
const registry = yield* Tool.Service
|
||||
const registration = yield* McpTool.Service
|
||||
yield* registration.flush
|
||||
const toolSet = yield* registry.snapshot()
|
||||
|
||||
const run = (code: string) =>
|
||||
toolSet
|
||||
.execute({
|
||||
sessionID: Session.ID.make("ses_mcp_json_text"),
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: `call_${code.length}`, name: "execute", input: { code } },
|
||||
})
|
||||
.pipe(Effect.map((execution) => execution.output.output))
|
||||
|
||||
expect(yield* run("return (await tools.demo.issues({})).issues[0].id")).toBe("1")
|
||||
expect(yield* run("return typeof (await tools.demo.count({}))")).toBe("string")
|
||||
expect(yield* run("return typeof (await tools.demo.typed({}))")).toBe("string")
|
||||
|
||||
// Outside Code Mode the content the model reads is the original text.
|
||||
expect(
|
||||
yield* toolSet.execute({
|
||||
sessionID: Session.ID.make("ses_mcp_json_text"),
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call_direct_issues", name: "direct_issues", input: {} },
|
||||
}),
|
||||
).toMatchObject({ output: { issues: [{ id: 1 }] }, content: [{ type: "text", text: '{"issues":[{"id":1}]}' }] })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("advertises MCP tools directly when Code Mode is disabled for the server", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* Tool.Service
|
||||
|
||||
@@ -1395,81 +1395,6 @@ describe("ModelResolver", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reports provider configuration errors from supported packages", () =>
|
||||
Effect.gen(function* () {
|
||||
const failure = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/azure"), {
|
||||
providerID: Provider.ID.azure,
|
||||
modelID: "gpt-5.4-nano",
|
||||
}),
|
||||
Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID: Integration.MethodID.make("oauth"),
|
||||
access: "oauth-token",
|
||||
refresh: "refresh",
|
||||
expires: Date.now() + 60_000,
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(failure).toMatchObject({
|
||||
_tag: "SessionRunnerModel.ModelConfigurationError",
|
||||
providerID: "azure",
|
||||
modelID: "test-model",
|
||||
package: "aisdk:@ai-sdk/azure",
|
||||
detail: "Azure requires resourceName or baseURL",
|
||||
})
|
||||
expect(failure.message).toBe("Cannot initialize azure/test-model: Azure requires resourceName or baseURL")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("distinguishes unexpected constructor failures from configuration errors", () =>
|
||||
Effect.gen(function* () {
|
||||
const failure = yield* ModelResolver.fromCatalogModel(model("@opencode/ai/providers/custom"), undefined, {
|
||||
loadPackage: () =>
|
||||
Effect.succeed({
|
||||
model: () => {
|
||||
throw new Error("custom provider crashed")
|
||||
},
|
||||
}),
|
||||
}).pipe(Effect.flip)
|
||||
|
||||
expect(failure).toMatchObject({
|
||||
_tag: "SessionRunnerModel.ModelInitializationError",
|
||||
phase: "construct",
|
||||
detail: "custom provider crashed",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reports package load and AISDK initialization failures with their causes", () =>
|
||||
Effect.gen(function* () {
|
||||
const load = yield* ModelResolver.fromCatalogModel(model("@opencode/ai/providers/custom"), undefined, {
|
||||
loadPackage: (specifier) =>
|
||||
Effect.fail(
|
||||
new Provider.LoadError({ package: specifier, cause: new Error(`Provider package ${specifier} is broken`) }),
|
||||
),
|
||||
}).pipe(Effect.flip)
|
||||
expect(load).toMatchObject({
|
||||
_tag: "SessionRunnerModel.ModelInitializationError",
|
||||
phase: "load",
|
||||
detail: "Provider package @opencode/ai/providers/custom is broken",
|
||||
})
|
||||
|
||||
const init = yield* ModelResolver.fromCatalogModel(model(Provider.aisdk("@ai-sdk/cohere")), undefined, {
|
||||
loadAISDK: (runtime) =>
|
||||
Effect.fail(
|
||||
new AISDK.InitError({ providerID: runtime.providerID, cause: new Error("Cohere plugin failed") }),
|
||||
),
|
||||
}).pipe(Effect.flip)
|
||||
expect(init).toMatchObject({
|
||||
_tag: "SessionRunnerModel.ModelInitializationError",
|
||||
phase: "init",
|
||||
detail: "Cohere plugin failed",
|
||||
})
|
||||
expect(init.message).toBe("Cannot initialize test-provider/test-model: Cohere plugin failed")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("drops an empty API key before loading an AISDK package", () =>
|
||||
Effect.gen(function* () {
|
||||
const native = yield* ModelResolver.fromCatalogModel(
|
||||
|
||||
@@ -224,34 +224,6 @@ describe("Permission", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("merges session rules after agent rules and before saved approvals", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup([{ action: "*", resource: "*", effect: "allow" }])
|
||||
const { db } = yield* Database.Service
|
||||
const service = yield* Permission.Service
|
||||
const setSession = (permission: Permission.Ruleset) =>
|
||||
db
|
||||
.update(SessionTable)
|
||||
.set({ permission })
|
||||
.where(eq(SessionTable.id, Session.ID.make("ses_test")))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
yield* setSession([{ action: "edit", resource: "/original/**", effect: "deny" }])
|
||||
expect(yield* service.ask(assertion({ action: "edit", resources: ["/original/src/index.ts"] }))).toMatchObject({
|
||||
effect: "deny",
|
||||
})
|
||||
|
||||
yield* setRules([])
|
||||
const saved = yield* PermissionSaved.Service
|
||||
yield* saved.add({ projectID: Project.ID.global, action: "bash", resources: ["pwd"] })
|
||||
yield* setSession([{ action: "bash", resource: "*", effect: "deny" }])
|
||||
expect(yield* service.ask(assertion({ action: "bash", resources: ["pwd"] }))).toMatchObject({ effect: "deny" })
|
||||
yield* setSession([{ action: "bash", resource: "*", effect: "ask" }])
|
||||
expect(yield* service.ask(assertion({ action: "bash", resources: ["pwd"] }))).toMatchObject({ effect: "allow" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses saved bash approvals while preserving configured deny precedence", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup()
|
||||
|
||||
@@ -108,7 +108,6 @@ export function host(overrides: Overrides = {}): Plugin.Context {
|
||||
list: () => Effect.die("unused permission.list"),
|
||||
get: () => Effect.die("unused permission.get"),
|
||||
reply: () => Effect.die("unused permission.reply"),
|
||||
rules: () => Effect.die("unused permission.rules"),
|
||||
},
|
||||
plugin: overrides.plugin ?? {
|
||||
list: () => Effect.die("unused plugin.list"),
|
||||
|
||||
@@ -19,11 +19,9 @@ import PROMPT_GPT from "../../src/plugin/system-prompt/gpt.txt"
|
||||
import PROMPT_ASTRA from "../../src/plugin/system-prompt/gpt-astra.txt"
|
||||
import PROMPT_KIMI from "../../src/plugin/system-prompt/kimi.txt"
|
||||
import PROMPT_TRINITY from "../../src/plugin/system-prompt/trinity.txt"
|
||||
import PROMPT_ANTHROPIC from "../../src/plugin/system-prompt/anthropic.txt"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
const fallback = SessionSystemPrompt.make([])
|
||||
const appended = `${fallback}\n\n${SessionSystemPrompt.render(PROMPT_ANTHROPIC, [])}`
|
||||
const makeHost = Effect.gen(function* () {
|
||||
const agents = yield* Agent.Service
|
||||
const plugins = yield* Plugin.Service
|
||||
@@ -50,7 +48,6 @@ describe("OptimizePlugin", () => {
|
||||
test("enables prompt plugins without model-specific tool optimization", () => {
|
||||
expect(OptimizePlugin.Plugins.map((plugin) => plugin.id)).toEqual([
|
||||
"opencode.prompt.openai",
|
||||
"opencode.prompt.anthropic",
|
||||
"opencode.prompt.kimi",
|
||||
"opencode.prompt.arcee",
|
||||
"opencode.prompt.meta",
|
||||
@@ -79,7 +76,7 @@ describe("OptimizePlugin", () => {
|
||||
["gpt-5-codex", PROMPT_GPT],
|
||||
["gpt-6-astra", PROMPT_ASTRA],
|
||||
["gemini-2.5-pro", fallback],
|
||||
["claude-sonnet-4", appended],
|
||||
["claude-sonnet-4", fallback],
|
||||
["kimi-k2", PROMPT_KIMI],
|
||||
["trinity", PROMPT_TRINITY],
|
||||
["meta/muse-spark-1.1", PROMPT_META.replaceAll("{{MODEL_NAME}}", "Muse Spark")],
|
||||
@@ -131,30 +128,6 @@ describe("OptimizePlugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("appends the Anthropic prompt to the baseline without changing tools or project instructions", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const pluginHost = yield* makeHost
|
||||
yield* catalog.transform((editor) =>
|
||||
editor.model.update(Provider.ID.make("test"), Model.ID.make("claude-sonnet-4"), () => {}),
|
||||
)
|
||||
yield* OptimizePlugin.AnthropicPlugin.effect(pluginHost)
|
||||
const event = context("claude-sonnet-4")
|
||||
event.system.push(SystemPart.make("Project instructions"))
|
||||
|
||||
yield* hooks.trigger("session", "context", event)
|
||||
|
||||
const baseline = SessionSystemPrompt.render(fallback, Object.keys(event.tools))
|
||||
expect(event.system.map((part) => part.text)).toEqual([
|
||||
`${baseline}\n\n${SessionSystemPrompt.render(PROMPT_ANTHROPIC, Object.keys(event.tools))}`,
|
||||
"Project instructions",
|
||||
])
|
||||
expect(event.system[0]?.text.startsWith(baseline)).toBe(true)
|
||||
expect(Object.keys(event.tools).sort()).toEqual(["edit", "glob", "grep", "patch", "read", "shell", "write"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("curates search tools across providers without changing editing tools", () =>
|
||||
Effect.gen(function* () {
|
||||
const hooks = yield* PluginHooks.Service
|
||||
@@ -325,7 +298,7 @@ describe("OptimizePlugin", () => {
|
||||
["codex-family-alias", "custom-deployment", "GPT-CODEX", fallback],
|
||||
["astra-api-alias", "gpt-6-astra", undefined, fallback],
|
||||
["astra-family-alias", "custom-deployment", "gpt-6", fallback],
|
||||
["claude-catalog-alias", "custom-model", undefined, appended],
|
||||
["claude-catalog-alias", "custom-model", undefined, fallback],
|
||||
["anthropic-api-alias", "Claude-Opus-4-8", undefined, fallback],
|
||||
["anthropic-family-alias", "custom-deployment", "CLAUDE-SONNET", fallback],
|
||||
] as const
|
||||
|
||||
@@ -388,32 +388,6 @@ describe("Session.create", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("stores permission rules, inherits them through children and forks, and replaces them", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const bus = yield* Bus.Service
|
||||
const { db } = yield* Database.Service
|
||||
const permissions = [{ action: "edit", resource: "/original/**", effect: "deny" as const }]
|
||||
|
||||
const created = yield* session.create({ location, permissions })
|
||||
expect(created.permissions).toEqual(permissions)
|
||||
expect((yield* session.create({ parentID: created.id })).permissions).toEqual(permissions)
|
||||
expect((yield* session.create({ parentID: created.id, permissions: [] })).permissions).toEqual([])
|
||||
|
||||
yield* session.prompt({ sessionID: created.id, text: "Fork context", resume: false })
|
||||
yield* SessionInbox.promote(db, bus, created.id, "steer")
|
||||
const forked = yield* session.fork({ sessionID: created.id, boundary: { type: "through" } })
|
||||
expect(forked.permissions).toEqual(permissions)
|
||||
|
||||
const replaced = [{ action: "shell", resource: "*", effect: "ask" as const }]
|
||||
yield* session.setPermissions({ sessionID: created.id, permissions: replaced })
|
||||
expect((yield* session.get(created.id)).permissions).toEqual(replaced)
|
||||
expect(
|
||||
yield* session.setPermissions({ sessionID: Session.ID.create(), permissions: replaced }).pipe(Effect.flip),
|
||||
).toBeInstanceOf(Session.NotFoundError)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("inherits location from an existing parent when omitted", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
@@ -1356,12 +1330,7 @@ describe("SessionTransfer", () => {
|
||||
const transfer = yield* SessionTransfer.Service
|
||||
const bus = yield* Bus.Service
|
||||
const { db } = yield* Database.Service
|
||||
const template = yield* session.create({
|
||||
location,
|
||||
title: "Exported",
|
||||
metadata: { channel: "C123" },
|
||||
permissions: [{ action: "edit", resource: "*", effect: "deny" }],
|
||||
})
|
||||
const template = yield* session.create({ location, title: "Exported", metadata: { channel: "C123" } })
|
||||
const sessionID = Session.ID.create()
|
||||
const sourceMessageID = SessionMessage.ID.create()
|
||||
const errorMessageID = SessionMessage.ID.create()
|
||||
@@ -1407,13 +1376,7 @@ describe("SessionTransfer", () => {
|
||||
})
|
||||
const messages = yield* session.messages({ sessionID, order: "asc" })
|
||||
|
||||
expect(imported).toMatchObject({
|
||||
id: sessionID,
|
||||
title: "Exported",
|
||||
location,
|
||||
metadata: { channel: "C123" },
|
||||
permissions: [{ action: "edit", resource: "*", effect: "deny" }],
|
||||
})
|
||||
expect(imported).toMatchObject({ id: sessionID, title: "Exported", location, metadata: { channel: "C123" } })
|
||||
expect(imported.time).toMatchObject({
|
||||
updated: DateTime.makeUnsafe(1_000),
|
||||
idle: DateTime.makeUnsafe(200),
|
||||
|
||||
@@ -140,30 +140,6 @@ describe("toSessionError", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("preserves provider configuration and initialization errors", () => {
|
||||
const configuration = new ModelResolver.ModelConfigurationError({
|
||||
providerID: Provider.ID.make("azure"),
|
||||
modelID: ID.make("gpt-5.4-nano"),
|
||||
package: "aisdk:@ai-sdk/azure",
|
||||
detail: "Azure requires resourceName or baseURL",
|
||||
})
|
||||
expect(toSessionError(configuration)).toEqual({
|
||||
type: "provider.no-route",
|
||||
message: "Cannot initialize azure/gpt-5.4-nano: Azure requires resourceName or baseURL",
|
||||
})
|
||||
const initialization = new ModelResolver.ModelInitializationError({
|
||||
providerID: Provider.ID.make("custom"),
|
||||
modelID: ID.make("model"),
|
||||
package: "@opencode/ai/providers/custom",
|
||||
phase: "load",
|
||||
detail: "Provider package @opencode/ai/providers/custom is broken",
|
||||
})
|
||||
expect(toSessionError(initialization)).toEqual({
|
||||
type: "provider.no-route",
|
||||
message: "Cannot initialize custom/model: Provider package @opencode/ai/providers/custom is broken",
|
||||
})
|
||||
})
|
||||
|
||||
test("retries rate limits, provider-internal, transport, and unrecognized failures", () => {
|
||||
const eligible = [
|
||||
llm(new RateLimitError({ message: "rate" })),
|
||||
|
||||
@@ -22,7 +22,7 @@ it.live(
|
||||
Effect.provideService(
|
||||
HttpClient.HttpClient,
|
||||
HttpClient.make((request) => {
|
||||
expect(request.url).toBe("https://registry.npmjs.org/@opencode%2fcli/beta")
|
||||
expect(request.url).toBe("https://registry.npmjs.org/@opencode-ai%2fcli/beta")
|
||||
return Effect.succeed(HttpClientResponse.fromWeb(request, response))
|
||||
}),
|
||||
),
|
||||
@@ -65,7 +65,7 @@ posix(
|
||||
|
||||
test("pins platform-specific artifacts and rejects unsafe inputs", () => {
|
||||
expect(RemoteCli.archiveUrl("linux-x64-baseline-musl", "2.0.0-beta.1")).toBe(
|
||||
"https://registry.npmjs.org/@opencode/cli-linux-x64-baseline-musl/-/cli-linux-x64-baseline-musl-2.0.0-beta.1.tgz",
|
||||
"https://registry.npmjs.org/@opencode-ai/cli-linux-x64-baseline-musl/-/cli-linux-x64-baseline-musl-2.0.0-beta.1.tgz",
|
||||
)
|
||||
expect(() => RemoteCli.installScript({ version: '2.0.0"; whoami', source: { type: "installer" } })).toThrow()
|
||||
expect(() => RemoteCli.archiveUrl("linux-x64;whoami", "2.0.0")).toThrow()
|
||||
|
||||
@@ -68,7 +68,7 @@ printf 'OPENCODE_REMOTE_TARGET=%s\\n' "$target"
|
||||
export function archiveUrl(target: string, version: string) {
|
||||
if (!/^(linux|darwin)-(x64-baseline|arm64)(-musl)?$/.test(target))
|
||||
throw new Failure({ code: "platform", detail: target })
|
||||
return `https://registry.npmjs.org/@opencode/cli-${target}/-/cli-${target}-${requireVersion(version)}.tgz`
|
||||
return `https://registry.npmjs.org/@opencode-ai/cli-${target}/-/cli-${target}-${requireVersion(version)}.tgz`
|
||||
}
|
||||
|
||||
type Source = { type: "download"; url: string } | { type: "archive" } | { type: "installer"; binary?: string }
|
||||
@@ -114,12 +114,12 @@ const Beta = Schema.Struct({ version: Schema.String.check(Schema.isPattern(/^0\.
|
||||
|
||||
export const latestBeta = Effect.fn("RemoteCli.latestBeta")(function* () {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const metadata = yield* http.get("https://registry.npmjs.org/@opencode%2fcli/beta").pipe(
|
||||
const metadata = yield* http.get("https://registry.npmjs.org/@opencode-ai%2fcli/beta").pipe(
|
||||
Effect.flatMap(HttpClientResponse.filterStatusOk),
|
||||
Effect.flatMap(HttpClientResponse.schemaBodyJson(Beta)),
|
||||
Effect.timeout("30 seconds"),
|
||||
Effect.mapError(
|
||||
() => new Failure({ code: "install", detail: "https://registry.npmjs.org/@opencode%2fcli/beta" }),
|
||||
() => new Failure({ code: "install", detail: "https://registry.npmjs.org/@opencode-ai%2fcli/beta" }),
|
||||
),
|
||||
)
|
||||
return metadata.version
|
||||
|
||||
@@ -9,7 +9,6 @@ export function createDesktopNotify(api: ElectronAPI): Platform["notify"] {
|
||||
const notification = new Notification(title, {
|
||||
body: description ?? "",
|
||||
icon: "https://opencode.ai/favicon-96x96-v3.png",
|
||||
silent: true,
|
||||
})
|
||||
notification.onclick = () => {
|
||||
void api.showWindow()
|
||||
|
||||
@@ -1280,100 +1280,6 @@ flowchart TD
|
||||
])
|
||||
})
|
||||
|
||||
test("expands & node groups into fan-in and fan-out edges", () => {
|
||||
const diagram = parseMermaidFlowchartDiagram(`flowchart LR
|
||||
N[Native] & M[Mapped] & O --> LM["LanguageModel"]
|
||||
LM -->|prepare| REQ & LOG`)
|
||||
|
||||
expect(diagram.nodes).toEqual([
|
||||
{ id: "N", label: "Native", shape: "box" },
|
||||
{ id: "M", label: "Mapped", shape: "box" },
|
||||
{ id: "O", label: "O", shape: "box" },
|
||||
{ id: "LM", label: "LanguageModel", shape: "box" },
|
||||
{ id: "REQ", label: "REQ", shape: "box" },
|
||||
{ id: "LOG", label: "LOG", shape: "box" },
|
||||
])
|
||||
expect(diagram.edges).toEqual([
|
||||
{ from: "N", to: "LM", label: "" },
|
||||
{ from: "M", to: "LM", label: "" },
|
||||
{ from: "O", to: "LM", label: "" },
|
||||
{ from: "LM", to: "REQ", label: "prepare" },
|
||||
{ from: "LM", to: "LOG", label: "prepare" },
|
||||
])
|
||||
})
|
||||
|
||||
test("expands & groups on both sides of an edge and through a chain", () => {
|
||||
const diagram = parseMermaidFlowchartDiagram(`flowchart LR
|
||||
A & B --> C & D --> E`)
|
||||
|
||||
expect(diagram.edges).toEqual([
|
||||
{ from: "A", to: "C", label: "" },
|
||||
{ from: "A", to: "D", label: "" },
|
||||
{ from: "B", to: "C", label: "" },
|
||||
{ from: "B", to: "D", label: "" },
|
||||
{ from: "C", to: "E", label: "" },
|
||||
{ from: "D", to: "E", label: "" },
|
||||
])
|
||||
})
|
||||
|
||||
test("declares every node of a bare & group inside the current subgraph", () => {
|
||||
const diagram = parseMermaidFlowchartDiagram(`flowchart TD
|
||||
subgraph Runtime
|
||||
A[Alpha] & B[Beta]:::focus
|
||||
end
|
||||
A --> B`)
|
||||
|
||||
expect(diagram.nodes).toEqual([
|
||||
{ id: "A", label: "Alpha", shape: "box" },
|
||||
{ id: "B", label: "Beta", shape: "box" },
|
||||
])
|
||||
expect(diagram.subgraphs?.[0]?.nodeIds).toEqual(["A", "B"])
|
||||
})
|
||||
|
||||
test("keeps & inside quoted or bracketed labels as label text", () => {
|
||||
const diagram = parseMermaidFlowchartDiagram(`flowchart LR
|
||||
A["Fetch & parse"] & B[R&D] --> C[Done & dusted]`)
|
||||
|
||||
expect(diagram.nodes).toEqual([
|
||||
{ id: "A", label: "Fetch & parse", shape: "box" },
|
||||
{ id: "B", label: "R&D", shape: "box" },
|
||||
{ id: "C", label: "Done & dusted", shape: "box" },
|
||||
])
|
||||
expect(diagram.edges).toEqual([
|
||||
{ from: "A", to: "C", label: "" },
|
||||
{ from: "B", to: "C", label: "" },
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps & inside edge labels as label text", () => {
|
||||
const diagram = parseMermaidFlowchartDiagram(`flowchart LR
|
||||
X[a & b] -->|x & y| Y`)
|
||||
|
||||
expect(diagram.nodes.find((node) => node.id === "X")?.label).toBe("a & b")
|
||||
expect(diagram.edges).toEqual([{ from: "X", to: "Y", label: "x & y" }])
|
||||
})
|
||||
|
||||
test("rejects empty & group members", () => {
|
||||
for (const statement of ["A & --> B", "& A --> B", "A --> B &", "A &"]) {
|
||||
expect(() => parseMermaidFlowchartDiagram(`flowchart LR\n ${statement}`)).toThrow(
|
||||
`Unsupported syntax in flowchart diagram at line 2: "${statement}"`,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test("renders a fan-in expressed with & the same as separate edge statements", () => {
|
||||
const grouped = renderFlowchartDiagram(`flowchart LR
|
||||
N & M & O --> LM[LanguageModel] --> REQ[LLMRequest]`)
|
||||
const separate = renderFlowchartDiagram(`flowchart LR
|
||||
N --> LM[LanguageModel]
|
||||
M --> LM
|
||||
O --> LM
|
||||
LM --> REQ[LLMRequest]`)
|
||||
|
||||
expect(grouped).toBe(separate)
|
||||
expect(grouped).toContain("LanguageModel")
|
||||
})
|
||||
|
||||
test("parses chained undirected solid edges", () => {
|
||||
const diagram = parseMermaidFlowchartDiagram(`flowchart LR
|
||||
A --- B --- C`)
|
||||
|
||||
@@ -127,41 +127,6 @@ function stripNodeToken(token: string): string {
|
||||
.trim()
|
||||
}
|
||||
|
||||
/** Split an `&`-joined node group, leaving `&` inside labels (brackets or quotes) untouched. */
|
||||
function splitNodeGroup(token: string): string[] {
|
||||
const groups: string[] = []
|
||||
const stack: string[] = []
|
||||
let quote: '"' | "'" | undefined
|
||||
let start = 0
|
||||
const closes: Record<string, string> = { "[": "]", "(": ")", "{": "}" }
|
||||
|
||||
for (let index = 0; index < token.length; index++) {
|
||||
const character = token[index]!
|
||||
if (quote) {
|
||||
if (character === quote && token[index - 1] !== "\\") quote = undefined
|
||||
continue
|
||||
}
|
||||
if (character === '"' || character === "'") {
|
||||
quote = character
|
||||
continue
|
||||
}
|
||||
if (character in closes) {
|
||||
stack.push(character)
|
||||
continue
|
||||
}
|
||||
if (stack.length > 0 && character === closes[stack.at(-1)!]) {
|
||||
stack.pop()
|
||||
continue
|
||||
}
|
||||
if (stack.length === 0 && character === "&") {
|
||||
groups.push(token.slice(start, index))
|
||||
start = index + 1
|
||||
}
|
||||
}
|
||||
groups.push(token.slice(start))
|
||||
return groups
|
||||
}
|
||||
|
||||
function edgeStyleFromArrow(...arrows: string[]): FlowchartEdgeStyle | undefined {
|
||||
if (arrows.some((arrow) => arrow.includes("=="))) return "thick"
|
||||
if (arrows.some((arrow) => arrow.includes("."))) return "dashed"
|
||||
@@ -340,57 +305,51 @@ export function parseMermaidFlowchartDiagram(content: string): FlowchartDiagram
|
||||
|
||||
const edgeOperators = parseEdgeOperators(line)
|
||||
if (edgeOperators.length > 0) {
|
||||
// Each chain position may be an `&` group (`A & B --> C`), so endpoints are lists of node tokens.
|
||||
const nodeGroups = [
|
||||
const nodeTokens = [
|
||||
line.slice(0, edgeOperators[0]!.index),
|
||||
...edgeOperators.map((operator, index) =>
|
||||
line.slice(operator.end, edgeOperators[index + 1]?.index ?? line.length),
|
||||
),
|
||||
].map((group) => splitNodeGroup(group).map(stripNodeToken))
|
||||
]
|
||||
|
||||
if (nodeGroups.every((group) => group.every((token) => token.length > 0))) {
|
||||
const unsupportedEndpoint = nodeGroups.find((group, index) => {
|
||||
if (nodeTokens.every((token) => stripNodeToken(token).length > 0)) {
|
||||
const unsupportedEndpoint = nodeTokens.find((token, index) => {
|
||||
const stripped = stripNodeToken(token)
|
||||
const orderOnlyEndpoint = edgeOperators[index - 1]?.orderOnly || edgeOperators[index]?.orderOnly
|
||||
return group.some(
|
||||
(stripped) =>
|
||||
!(orderOnlyEndpoint && subgraphs.some((subgraph) => subgraph.id === stripped)) &&
|
||||
!isSupportedNodeToken(stripped),
|
||||
return (
|
||||
!(orderOnlyEndpoint && subgraphs.some((subgraph) => subgraph.id === stripped)) &&
|
||||
!isSupportedNodeToken(stripped)
|
||||
)
|
||||
})
|
||||
if (unsupportedEndpoint) throw new MermaidSyntaxError("flowchart", source.lineNumber, line)
|
||||
const chainNodeIds = nodeGroups.map((group, index) => {
|
||||
const chainNodeIds = nodeTokens.map((token, index) => {
|
||||
const stripped = stripNodeToken(token)
|
||||
const orderOnlyEndpoint = edgeOperators[index - 1]?.orderOnly || edgeOperators[index]?.orderOnly
|
||||
return group.map((stripped) => {
|
||||
if (orderOnlyEndpoint && subgraphs.some((subgraph) => subgraph.id === stripped)) return stripped
|
||||
return ensureNode(nodes, stripped).id
|
||||
})
|
||||
if (orderOnlyEndpoint && subgraphs.some((subgraph) => subgraph.id === stripped)) return stripped
|
||||
return ensureNode(nodes, stripped).id
|
||||
})
|
||||
for (const nodeId of chainNodeIds.flat()) {
|
||||
for (const nodeId of chainNodeIds) {
|
||||
if (nodes.has(nodeId)) addNodeToSubgraph(currentSubgraph, nodeId)
|
||||
}
|
||||
for (let index = 0; index < edgeOperators.length; index++) {
|
||||
const operator = edgeOperators[index]!
|
||||
for (const from of chainNodeIds[index]!) {
|
||||
for (const to of chainNodeIds[index + 1]!) {
|
||||
const edge = createEdge(
|
||||
from,
|
||||
to,
|
||||
operator.label,
|
||||
operator.style,
|
||||
operator.arrowhead,
|
||||
operator.sourceArrowhead,
|
||||
)
|
||||
edges.push(operator.orderOnly ? { ...edge, orderOnly: true } : edge)
|
||||
}
|
||||
}
|
||||
const edge = createEdge(
|
||||
chainNodeIds[index]!,
|
||||
chainNodeIds[index + 1]!,
|
||||
operator.label,
|
||||
operator.style,
|
||||
operator.arrowhead,
|
||||
operator.sourceArrowhead,
|
||||
)
|
||||
edges.push(operator.orderOnly ? { ...edge, orderOnly: true } : edge)
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const nodeGroup = splitNodeGroup(line)
|
||||
if (nodeGroup.every(isSupportedNodeToken)) {
|
||||
for (const token of nodeGroup) addNodeToSubgraph(currentSubgraph, ensureNode(nodes, stripNodeToken(token)).id)
|
||||
if (isSupportedNodeToken(line)) {
|
||||
const node = ensureNode(nodes, line)
|
||||
addNodeToSubgraph(currentSubgraph, node.id)
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ describe("parser diagnostics", () => {
|
||||
})
|
||||
|
||||
test("does not partially parse unsupported flowchart syntax", () => {
|
||||
for (const statement of ["A & --> C", "A((Start)) --> B", "A-->B; B-->C"]) {
|
||||
for (const statement of ["A & B --> C", "A((Start)) --> B", "A-->B; B-->C"]) {
|
||||
expect(() => parseMermaidFlowchartDiagram(`flowchart LR\n ${statement}`)).toThrow(MermaidSyntaxError)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -19,6 +19,6 @@ export interface PermissionHooks {
|
||||
readonly evaluate: PermissionEvaluation
|
||||
}
|
||||
|
||||
export type PermissionDomain = Pick<PermissionApi<unknown>, "list" | "get" | "reply" | "rules"> & {
|
||||
export type PermissionDomain = Pick<PermissionApi<unknown>, "list" | "get" | "reply"> & {
|
||||
readonly hook: Hooks<PermissionHooks>
|
||||
}
|
||||
|
||||
@@ -438,7 +438,6 @@ export function fromPromise(plugin: Plugin) {
|
||||
list: adaptApiMethod(PermissionEndpoints["session.permission.list"], host.permission.list),
|
||||
get: adaptApiMethod(PermissionEndpoints["session.permission.get"], host.permission.get),
|
||||
reply: adaptApiMethod(PermissionEndpoints["session.permission.reply"], host.permission.reply),
|
||||
rules: adaptApiMethod(PermissionEndpoints["session.permission.rules"], host.permission.rules),
|
||||
},
|
||||
plugin: {
|
||||
list: adaptApiMethod(PluginEndpoints["plugin.list"], host.plugin.list),
|
||||
|
||||
@@ -19,6 +19,6 @@ export interface PermissionHooks {
|
||||
readonly evaluate: PermissionEvaluation
|
||||
}
|
||||
|
||||
export type PermissionDomain = Pick<PermissionApi, "list" | "get" | "reply" | "rules"> & {
|
||||
export type PermissionDomain = Pick<PermissionApi, "list" | "get" | "reply"> & {
|
||||
readonly hook: Hooks<PermissionHooks>
|
||||
}
|
||||
|
||||
@@ -132,21 +132,4 @@ export const makePermissionGroup = <
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.put("session.permission.rules", "/api/session/:sessionID/permission/rules", {
|
||||
params: { sessionID: Session.ID },
|
||||
payload: Schema.Struct({ permissions: Permission.Ruleset }),
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: SessionNotFoundError,
|
||||
})
|
||||
.middleware(sessionLocationMiddleware)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.permission.rules",
|
||||
summary: "Replace session permission rules",
|
||||
description:
|
||||
"Replace the session-scoped permission rules. Rules are evaluated after the agent's rules, and the last matching rule wins.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(OpenApi.annotations({ title: "permission", description: "Experimental permission routes." }))
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
import { Agent } from "@opencode/schema/agent"
|
||||
import { Skill } from "@opencode/schema/skill"
|
||||
import { Model } from "@opencode/schema/model"
|
||||
import { Permission } from "@opencode/schema/permission"
|
||||
import { Location } from "@opencode/schema/location"
|
||||
import { SessionEvent } from "@opencode/schema/session-event"
|
||||
import { EventLog } from "@opencode/schema/event-log"
|
||||
@@ -176,7 +175,6 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||
model: Model.Ref.pipe(Schema.optional),
|
||||
location: Location.Ref.pipe(Schema.optional),
|
||||
metadata: Session.Metadata.pipe(Schema.optional),
|
||||
permissions: Permission.Ruleset.pipe(Schema.optional),
|
||||
}),
|
||||
success: Schema.Struct({ data: Session.Info }),
|
||||
}).annotateMerge(
|
||||
|
||||
@@ -25,7 +25,6 @@ import { TokenUsage } from "./token-usage.js"
|
||||
import { SessionInbox } from "./session-inbox.js"
|
||||
import { Project } from "./project.js"
|
||||
import { SessionFork } from "./session-fork.js"
|
||||
import { Permission } from "./permission.js"
|
||||
|
||||
export { FileAttachment }
|
||||
|
||||
@@ -63,7 +62,6 @@ export const Created = Event.durable({
|
||||
model: Model.Ref.pipe(optional),
|
||||
/** Host-supplied annotations resolved at creation, including any inherited from a parent. */
|
||||
metadata: SessionMetadata.pipe(optional),
|
||||
permissions: Permission.Ruleset.pipe(optional),
|
||||
version: Schema.String,
|
||||
},
|
||||
})
|
||||
@@ -111,16 +109,6 @@ export const Renamed = Event.durable({
|
||||
})
|
||||
export type Renamed = typeof Renamed.Type
|
||||
|
||||
export const PermissionsUpdated = Event.durable({
|
||||
type: "session.permissions.updated",
|
||||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
permissions: Permission.Ruleset,
|
||||
},
|
||||
})
|
||||
export type PermissionsUpdated = typeof PermissionsUpdated.Type
|
||||
|
||||
export const Viewed = Event.durable({
|
||||
type: "session.viewed",
|
||||
...options,
|
||||
@@ -646,7 +634,6 @@ export const Definitions = Event.inventory(
|
||||
ModelSelected,
|
||||
Moved,
|
||||
Renamed,
|
||||
PermissionsUpdated,
|
||||
Viewed,
|
||||
UsageUpdated,
|
||||
Deleted,
|
||||
|
||||
@@ -10,7 +10,6 @@ import { SessionEvent } from "./session-event.js"
|
||||
import { SessionID } from "./session-id.js"
|
||||
import { SessionMetadata } from "./session-metadata.js"
|
||||
import { Money } from "./money.js"
|
||||
import { Permission } from "./permission.js"
|
||||
import { TokenUsage } from "./token-usage.js"
|
||||
import { Revert } from "./session-revert.js"
|
||||
import { SessionFork } from "./session-fork.js"
|
||||
@@ -55,8 +54,6 @@ export const Info = Schema.Struct({
|
||||
location: Location.Ref,
|
||||
subpath: RelativePath.pipe(optional),
|
||||
metadata: Metadata.pipe(optional),
|
||||
/** Evaluated after the agent's rules; the last matching rule wins. */
|
||||
permissions: Permission.Ruleset.pipe(optional),
|
||||
revert: Revert.pipe(optional),
|
||||
}).annotate({ identifier: "Session.Info" })
|
||||
|
||||
|
||||
@@ -115,7 +115,6 @@ describe("public event manifest", () => {
|
||||
"session.model.selected.1",
|
||||
"session.moved.1",
|
||||
"session.renamed.1",
|
||||
"session.permissions.updated.1",
|
||||
"session.viewed.1",
|
||||
"session.message.content.updated.1",
|
||||
"session.usage.recorded.1",
|
||||
|
||||
@@ -83,15 +83,6 @@ export const PermissionHandler = HttpApiBuilder.group(Api, "server.permission",
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.permission.rules",
|
||||
Effect.fn(function* (ctx) {
|
||||
yield* sessions
|
||||
.setPermissions({ sessionID: ctx.params.sessionID, permissions: ctx.payload.permissions })
|
||||
.pipe(Effect.catchTag("Session.NotFoundError", missingSession))
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"permission.saved.list",
|
||||
Effect.fn(function* (ctx) {
|
||||
|
||||
@@ -120,7 +120,6 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
|
||||
agent: ctx.payload.agent,
|
||||
model: ctx.payload.model,
|
||||
metadata: ctx.payload.metadata,
|
||||
permissions: ctx.payload.permissions,
|
||||
location: ctx.payload.location ?? { directory: AbsolutePath.make(process.cwd()) },
|
||||
})
|
||||
.pipe(Effect.orDie),
|
||||
|
||||
@@ -597,7 +597,7 @@ function groupContent(
|
||||
detail?: TimelineDetail,
|
||||
): PartGroup[] {
|
||||
const groups: PartGroup[] = []
|
||||
let adjacent: { type: "context" | "file"; refs: PartRef[]; tools: boolean } | undefined
|
||||
let adjacent: { type: "context" | "patch" | "edit"; refs: PartRef[]; tools: boolean } | undefined
|
||||
const flush = () => {
|
||||
const current = adjacent
|
||||
const first = current?.refs[0]
|
||||
@@ -665,7 +665,8 @@ function toolGroupType(
|
||||
const category = timelineCategory(content)!
|
||||
if (detail[category].placement === "grouped") return "context"
|
||||
if (currentToolFailed(content)) return undefined
|
||||
if (content.name === "patch" || content.name === "edit" || content.name === "write") return "file"
|
||||
if (content.name === "patch") return "patch"
|
||||
if (content.name === "edit") return "edit"
|
||||
return undefined
|
||||
}
|
||||
if (content.name === "question" || currentToolHasLoadedFiles(content)) return undefined
|
||||
@@ -683,7 +684,8 @@ function toolGroupType(
|
||||
)
|
||||
return undefined
|
||||
if (currentContentDefaultOpen(content, shellExpanded, editExpanded) !== true) return "context"
|
||||
if (content.name === "patch" || content.name === "edit" || content.name === "write") return "file"
|
||||
if (content.name === "patch") return "patch"
|
||||
if (content.name === "edit") return "edit"
|
||||
return undefined
|
||||
}
|
||||
|
||||
|
||||
@@ -691,13 +691,6 @@ describe("current session timeline rows", () => {
|
||||
state: { status: "running", input: {}, metadata: { files: [] } },
|
||||
time: { created: 10 },
|
||||
},
|
||||
{
|
||||
type: "tool",
|
||||
id: "tool_write_1",
|
||||
name: "write",
|
||||
state: { status: "running", input: {}, metadata: { files: [] } },
|
||||
time: { created: 11 },
|
||||
},
|
||||
],
|
||||
time: { created: 2, completed: 8 },
|
||||
},
|
||||
@@ -723,11 +716,14 @@ describe("current session timeline rows", () => {
|
||||
{
|
||||
type: "file",
|
||||
key: "part:msg_assistant:tool_patch_3",
|
||||
refs: [{ messageID: "msg_assistant", partID: "tool_patch_3" }],
|
||||
},
|
||||
{
|
||||
type: "file",
|
||||
key: "part:msg_assistant:tool_edit_1",
|
||||
refs: [
|
||||
{ messageID: "msg_assistant", partID: "tool_patch_3" },
|
||||
{ messageID: "msg_assistant", partID: "tool_edit_1" },
|
||||
{ messageID: "msg_assistant", partID: "tool_edit_2" },
|
||||
{ messageID: "msg_assistant", partID: "tool_write_1" },
|
||||
],
|
||||
},
|
||||
])
|
||||
@@ -794,8 +790,8 @@ describe("current session timeline rows", () => {
|
||||
test.each([
|
||||
{ shell: false, edit: false, types: ["context"] },
|
||||
{ shell: true, edit: false, types: ["part", "context"] },
|
||||
{ shell: false, edit: true, types: ["context", "file", "context"] },
|
||||
{ shell: true, edit: true, types: ["part", "file", "context"] },
|
||||
{ shell: false, edit: true, types: ["context", "file", "part", "file", "context"] },
|
||||
{ shell: true, edit: true, types: ["part", "file", "part", "file", "context"] },
|
||||
])("keeps tools expanded by settings outside collapsed groups ($shell, $edit)", ({ shell, edit, types }) => {
|
||||
const source = [
|
||||
{ id: "msg_user", type: "user", text: "work", time: { created: 1 } },
|
||||
|
||||
@@ -16,12 +16,12 @@ import {
|
||||
type JSX,
|
||||
} from "solid-js"
|
||||
import stripAnsi from "strip-ansi"
|
||||
import { createTwoFilesPatch } from "diff"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { type SessionSummary, useData } from "../context"
|
||||
import { useFileComponent } from "@opencode/ui/context/file"
|
||||
import { type UiI18n, useI18n } from "@opencode/ui/context/i18n"
|
||||
import { BasicTool, GenericTool } from "../components/basic-tool"
|
||||
import { Collapsible } from "@opencode/ui/collapsible"
|
||||
import { FileIcon } from "@opencode/ui/file-icon"
|
||||
import { Icon, type IconProps } from "@opencode/ui/icon"
|
||||
import { ToolErrorCard } from "../components/tool-error-card"
|
||||
@@ -837,24 +837,8 @@ export function CurrentFileToolGroup(props: {
|
||||
const files = createMemo((previous: { key: string; toolID: string; value: unknown }[]) => {
|
||||
const next = props.tools.flatMap((tool) => {
|
||||
const files = currentToolMetadata(tool).files
|
||||
if (Array.isArray(files) && files.length > 0)
|
||||
return files.map((value, index) => ({ key: `${tool.id}:${index}`, toolID: tool.id, value }))
|
||||
if (tool.name !== "write") return []
|
||||
const input = currentToolInput(tool)
|
||||
if (typeof input.path !== "string" || typeof input.content !== "string" || !input.content) return []
|
||||
return [
|
||||
{
|
||||
key: `${tool.id}:0`,
|
||||
toolID: tool.id,
|
||||
value: {
|
||||
file: input.path,
|
||||
patch: createTwoFilesPatch(input.path, input.path, "", input.content),
|
||||
additions: input.content.split("\n").length - Number(input.content.endsWith("\n")),
|
||||
deletions: 0,
|
||||
status: "modified",
|
||||
},
|
||||
},
|
||||
]
|
||||
if (!Array.isArray(files)) return []
|
||||
return files.map((value, index) => ({ key: `${tool.id}:${index}`, toolID: tool.id, value }))
|
||||
})
|
||||
const updates = new Map(next.map((entry) => [entry.key, entry.value]))
|
||||
const existing = new Set(previous.map((entry) => entry.key))
|
||||
@@ -880,10 +864,7 @@ export function CurrentFileToolGroup(props: {
|
||||
props.tools.some((tool) => tool.state.status === "streaming" || tool.state.status === "running"),
|
||||
)
|
||||
const render = ToolRegistry.render("patch") ?? GenericTool
|
||||
const tool = createMemo(() => {
|
||||
const name = props.tools[0]?.name
|
||||
return name === "edit" || name === "write" ? name : "patch"
|
||||
})
|
||||
const tool = createMemo(() => (props.tools[0]?.name === "edit" ? "edit" : "patch"))
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -998,7 +979,7 @@ export const ToolRegistry = {
|
||||
render: getTool,
|
||||
}
|
||||
|
||||
function FileTool(props: ToolProps & { title: string; count: number; children?: JSX.Element }) {
|
||||
function FileTool(props: ToolProps & { title: string; count: number; children: JSX.Element }) {
|
||||
const i18n = useI18n()
|
||||
return (
|
||||
<BasicTool
|
||||
@@ -1939,37 +1920,40 @@ ToolRegistry.register({
|
||||
ToolRegistry.register({
|
||||
name: "write",
|
||||
render(props) {
|
||||
const i18n = useI18n()
|
||||
const fileComponent = useFileComponent()
|
||||
const path = createMemo(() => (typeof props.input.path === "string" ? props.input.path : ""))
|
||||
const content = createMemo(() => (typeof props.input.content === "string" ? props.input.content : ""))
|
||||
const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, path()))
|
||||
return (
|
||||
<div data-component="write-tool">
|
||||
<Show when={content() && path()}>
|
||||
<ToolFileAccordion
|
||||
path={path()}
|
||||
defaultOpen={props.defaultOpen}
|
||||
open={props.open}
|
||||
onOpenChange={props.onOpenChange}
|
||||
forceOpen={props.forceOpen}
|
||||
defer={props.deferContent !== false}
|
||||
>
|
||||
<div data-component="write-content">
|
||||
<Dynamic
|
||||
component={fileComponent}
|
||||
mode="text"
|
||||
file={{
|
||||
name: path(),
|
||||
contents: content(),
|
||||
cacheKey: checksum(content()),
|
||||
}}
|
||||
overflow="scroll"
|
||||
onRendered={props.onContentRendered}
|
||||
/>
|
||||
</div>
|
||||
</ToolFileAccordion>
|
||||
</Show>
|
||||
<DiagnosticsDisplay diagnostics={diagnostics()} />
|
||||
<FileTool {...props} title={i18n.t("ui.messagePart.title.write")} count={path() ? 1 : 0}>
|
||||
<Show when={path()}>
|
||||
<ToolFileAccordion
|
||||
path={path()}
|
||||
defaultOpen={props.defaultOpen}
|
||||
open={props.open}
|
||||
onOpenChange={props.onOpenChange}
|
||||
forceOpen={props.forceOpen}
|
||||
defer={props.deferContent !== false}
|
||||
>
|
||||
<div data-component="write-content">
|
||||
<Dynamic
|
||||
component={fileComponent}
|
||||
mode="text"
|
||||
file={{
|
||||
name: path(),
|
||||
contents: content(),
|
||||
cacheKey: checksum(content()),
|
||||
}}
|
||||
overflow="scroll"
|
||||
onRendered={props.onContentRendered}
|
||||
/>
|
||||
</div>
|
||||
</ToolFileAccordion>
|
||||
</Show>
|
||||
<DiagnosticsDisplay diagnostics={diagnostics()} />
|
||||
</FileTool>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
@@ -1983,11 +1967,7 @@ ToolRegistry.register({
|
||||
const files = createMemo(() => patchFileGroups(props.metadata.files))
|
||||
const [expanded, setExpanded] = createSignal<string[]>([])
|
||||
const title = createMemo(() =>
|
||||
props.tool === "edit"
|
||||
? i18n.t("ui.messagePart.title.edit")
|
||||
: props.tool === "write"
|
||||
? i18n.t("ui.messagePart.title.write")
|
||||
: i18n.t("ui.tool.patch"),
|
||||
props.tool === "edit" ? i18n.t("ui.messagePart.title.edit") : i18n.t("ui.tool.patch"),
|
||||
)
|
||||
const open = createMemo(() => {
|
||||
if (!props.fileOpen) return expanded()
|
||||
@@ -2004,90 +1984,92 @@ ToolRegistry.register({
|
||||
|
||||
return (
|
||||
<div data-component="apply-patch-tool">
|
||||
<Show when={files().length > 0} fallback={<FileTool {...props} title={title()} count={0} />}>
|
||||
<FileAccordionGroup>
|
||||
<Index each={files()}>
|
||||
{(file) => {
|
||||
const value = () => file().path
|
||||
const active = createMemo(() => open().includes(value()))
|
||||
const [visible, setVisible] = createSignal(false)
|
||||
<FileTool {...props} title={title()} count={files().length}>
|
||||
<Show when={files().length > 0}>
|
||||
<FileAccordionGroup>
|
||||
<Index each={files()}>
|
||||
{(file) => {
|
||||
const value = () => file().path
|
||||
const active = createMemo(() => open().includes(value()))
|
||||
const [visible, setVisible] = createSignal(false)
|
||||
|
||||
createEffect(() => {
|
||||
if (!active()) {
|
||||
setVisible(false)
|
||||
return
|
||||
}
|
||||
createEffect(() => {
|
||||
if (!active()) {
|
||||
setVisible(false)
|
||||
return
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!active()) return
|
||||
setVisible(true)
|
||||
requestAnimationFrame(() => {
|
||||
if (!active()) return
|
||||
setVisible(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<FileAccordionItem
|
||||
open={active()}
|
||||
onOpenChange={(expanded) =>
|
||||
change(expanded ? [...open(), value()] : open().filter((path) => path !== value()))
|
||||
}
|
||||
type={file().type}
|
||||
header={
|
||||
<div data-slot="apply-patch-trigger-content">
|
||||
<div data-slot="apply-patch-file-info">
|
||||
<FileIcon node={{ path: file().path, type: "file" }} />
|
||||
<div data-slot="apply-patch-file-name-container">
|
||||
<Show when={file().path.includes("/")}>
|
||||
<span data-slot="apply-patch-directory">{`\u202A${displayDirectory(file().path)}\u202C`}</span>
|
||||
</Show>
|
||||
<span data-slot="apply-patch-filename">{getFilename(file().path)}</span>
|
||||
return (
|
||||
<FileAccordionItem
|
||||
open={active()}
|
||||
onOpenChange={(expanded) =>
|
||||
change(expanded ? [...open(), value()] : open().filter((path) => path !== value()))
|
||||
}
|
||||
type={file().type}
|
||||
header={
|
||||
<div data-slot="apply-patch-trigger-content">
|
||||
<div data-slot="apply-patch-file-info">
|
||||
<FileIcon node={{ path: file().path, type: "file" }} />
|
||||
<div data-slot="apply-patch-file-name-container">
|
||||
<Show when={file().path.includes("/")}>
|
||||
<span data-slot="apply-patch-directory">{`\u202A${displayDirectory(file().path)}\u202C`}</span>
|
||||
</Show>
|
||||
<span data-slot="apply-patch-filename">{getFilename(file().path)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div data-slot="apply-patch-trigger-actions">
|
||||
<Switch>
|
||||
<Match when={file().type === "add"}>
|
||||
<span data-slot="apply-patch-change" data-type="added">
|
||||
{i18n.t("ui.patch.action.created")}
|
||||
</span>
|
||||
</Match>
|
||||
<Match when={file().type === "delete"}>
|
||||
<span data-slot="apply-patch-change" data-type="removed">
|
||||
{i18n.t("ui.patch.action.deleted")}
|
||||
</span>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<DiffChanges
|
||||
appearance="standard"
|
||||
changes={{ additions: file().additions, deletions: file().deletions }}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
<Icon name="chevron-grabber-vertical" size="small" />
|
||||
</div>
|
||||
</div>
|
||||
<div data-slot="apply-patch-trigger-actions">
|
||||
<Switch>
|
||||
<Match when={file().type === "add"}>
|
||||
<span data-slot="apply-patch-change" data-type="added">
|
||||
{i18n.t("ui.patch.action.created")}
|
||||
</span>
|
||||
</Match>
|
||||
<Match when={file().type === "delete"}>
|
||||
<span data-slot="apply-patch-change" data-type="removed">
|
||||
{i18n.t("ui.patch.action.deleted")}
|
||||
</span>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<DiffChanges
|
||||
appearance="standard"
|
||||
changes={{ additions: file().additions, deletions: file().deletions }}
|
||||
}
|
||||
>
|
||||
<Show when={props.deferContent === false || visible()}>
|
||||
<For each={file().views}>
|
||||
{(view) => (
|
||||
<div data-component="apply-patch-file-diff">
|
||||
<Dynamic
|
||||
component={fileComponent}
|
||||
mode="diff"
|
||||
virtualize={props.virtualizeDiff}
|
||||
fileDiff={view.fileDiff}
|
||||
hunkSeparators={view.fileDiff.isPartial ? "simple" : "line-info-basic"}
|
||||
onRendered={props.onContentRendered}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
<Icon name="chevron-grabber-vertical" size="small" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show when={props.deferContent === false || visible()}>
|
||||
<For each={file().views}>
|
||||
{(view) => (
|
||||
<div data-component="apply-patch-file-diff">
|
||||
<Dynamic
|
||||
component={fileComponent}
|
||||
mode="diff"
|
||||
virtualize={props.virtualizeDiff}
|
||||
fileDiff={view.fileDiff}
|
||||
hunkSeparators={view.fileDiff.isPartial ? "simple" : "line-info-basic"}
|
||||
onRendered={props.onContentRendered}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</FileAccordionItem>
|
||||
)
|
||||
}}
|
||||
</Index>
|
||||
</FileAccordionGroup>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</FileAccordionItem>
|
||||
)
|
||||
}}
|
||||
</Index>
|
||||
</FileAccordionGroup>
|
||||
</Show>
|
||||
</FileTool>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -39,7 +39,6 @@ describe("inference stat normalization", () => {
|
||||
})
|
||||
|
||||
test("merges renamed models under their current name", () => {
|
||||
expect(statModel("deepseek-flash", "")).toBe("deepseek-v4.1-flash")
|
||||
expect(statModel("x-preview-f", "")).toBe("ox-alpha")
|
||||
expect(statModel("xiaomi/mimo-v2.5", "")).toBe("mimo-v2.5")
|
||||
expect(toModelAggregate(aggregate("x-preview-f", "openai"))).toMatchObject([
|
||||
|
||||
@@ -14,7 +14,6 @@ export const MODEL_AUTHOR_RULES = [
|
||||
] as const
|
||||
export const EXCLUDED_MODELS = new Set(["alpha-gpt-next"])
|
||||
export const MODEL_NAME_ALIASES: Record<string, string> = {
|
||||
"deepseek-flash": "deepseek-v4.1-flash",
|
||||
"x-preview-f": "ox-alpha",
|
||||
"xiaomi/mimo-v2.5": "mimo-v2.5",
|
||||
}
|
||||
|
||||
@@ -274,7 +274,6 @@ export const Definitions = {
|
||||
"permission.prompt.fullscreen": keybind("ctrl+f", "Toggle permission prompt fullscreen"),
|
||||
"plugins.toggle": keybind("return", "Toggle plugin"),
|
||||
"dialog.mcp.toggle": keybind("space", "Toggle MCP server"),
|
||||
"dialog.plugins.error": keybind("space", "View plugin error"),
|
||||
"dialog.plugins.install": keybind("shift+i", "Install plugin from plugin dialog"),
|
||||
"dialog.plugins.update": keybind("ctrl+u", "Update plugin from plugin dialog"),
|
||||
"dialog.plugins.check": keybind("ctrl+r", "Check for plugin updates from plugin dialog"),
|
||||
|
||||
@@ -223,15 +223,6 @@ export function PluginsDialog(props: {
|
||||
disabled: checking(),
|
||||
onTrigger: check,
|
||||
},
|
||||
{
|
||||
title: "view error",
|
||||
command: "dialog.plugins.error",
|
||||
hidden: !pluginError(focusedTui()),
|
||||
onTrigger: (option) => {
|
||||
const entry = entries().find((entry) => entry.key === option.value)
|
||||
if (pluginError(entry)) setDetail(entry)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: toggleTitle(),
|
||||
command: "plugins.toggle",
|
||||
@@ -248,7 +239,7 @@ export function PluginsDialog(props: {
|
||||
},
|
||||
]}
|
||||
footer={
|
||||
<Show when={pluginError(focusedEntry()) && !focusedTui()}>
|
||||
<Show when={pluginError(focusedEntry())}>
|
||||
<text>
|
||||
<span style={{ fg: props.context.theme.text.default }}>
|
||||
<b>enter</b>
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
import type { SessionMessageAssistant } from "@opencode/client"
|
||||
import { groupEntries, mergeGroups, splitGroups, type GroupNode } from "./tree"
|
||||
|
||||
export type PartRef = {
|
||||
messageID: string
|
||||
partID: string
|
||||
}
|
||||
|
||||
export type CacheUsage = {
|
||||
read: number
|
||||
model: SessionMessageAssistant["model"]
|
||||
}
|
||||
|
||||
export type SessionEntry =
|
||||
| { type: "message"; messageID: string }
|
||||
| { type: "compaction-queued"; inboxID: string }
|
||||
| { type: "part"; ref: PartRef }
|
||||
| { type: "assistant-footer"; messageID: string }
|
||||
| { type: "turn-usage"; messageIDs: string[]; previousCache?: CacheUsage }
|
||||
|
||||
type GroupKind = "reasoning" | "exploration"
|
||||
type SessionGroup = {
|
||||
type: "group"
|
||||
children: readonly GroupNode<SessionEntry, GroupKind>[]
|
||||
size: number
|
||||
completed: boolean
|
||||
} & ({ kind: "reasoning" } | { kind: "exploration"; pending: PartRef[] })
|
||||
|
||||
export type SessionRow = SessionEntry | SessionGroup
|
||||
|
||||
export type AppendPart =
|
||||
| { type: "text" }
|
||||
| { type: "reasoning"; time?: { completed?: number } }
|
||||
| { type: "tool"; name: string }
|
||||
|
||||
export type ProjectionEntry = {
|
||||
entry: SessionEntry
|
||||
part?: AppendPart
|
||||
closesPrevious?: boolean
|
||||
}
|
||||
|
||||
/** Hydrate a fresh history batch in one pass rather than merging one leaf at a time. */
|
||||
export function projectEntries(entries: ProjectionEntry[]): SessionRow[] {
|
||||
const nodes = groupEntries(entries, (item) => (item.part ? partPath(item.part) : []))
|
||||
return nodes.map((node, index) => {
|
||||
if (node.type === "entry") return node.entry.entry
|
||||
const next = nodes[index + 1]
|
||||
const completed =
|
||||
(next !== undefined && (next.type === "group" || next.entry.closesPrevious !== false)) ||
|
||||
(node.kind === "reasoning" &&
|
||||
node.children.every(
|
||||
(child) =>
|
||||
child.type === "entry" &&
|
||||
child.entry.part?.type === "reasoning" &&
|
||||
child.entry.part.time?.completed !== undefined,
|
||||
))
|
||||
const group = { ...node, children: node.children.map(unwrap), completed }
|
||||
return node.kind === "reasoning" ? { ...group, kind: "reasoning" } : { ...group, kind: "exploration", pending: [] }
|
||||
})
|
||||
}
|
||||
|
||||
function unwrap(node: GroupNode<ProjectionEntry, GroupKind>): GroupNode<SessionEntry, GroupKind> {
|
||||
if (node.type === "entry") return { ...node, entry: node.entry.entry }
|
||||
return { ...node, children: node.children.map(unwrap) }
|
||||
}
|
||||
|
||||
function partPath(part: AppendPart): readonly GroupKind[] {
|
||||
if (part.type === "reasoning") return ["reasoning"]
|
||||
if (part.type === "tool" && ["read", "glob", "grep"].includes(part.name.toLowerCase())) return ["exploration"]
|
||||
return []
|
||||
}
|
||||
|
||||
/** Production rules only: keep lifecycle/status decisions outside the tree engine. */
|
||||
export function append(rows: SessionRow[], ref: PartRef, part: AppendPart, index = rows.length) {
|
||||
const [node] = groupEntries<SessionEntry, GroupKind>([{ type: "part", ref }], () => partPath(part))
|
||||
if (node.type === "entry") {
|
||||
completePrevious(rows, index)
|
||||
rows.splice(index, 0, node.entry)
|
||||
return
|
||||
}
|
||||
const previous = rows[index - 1]
|
||||
if (previous?.type === "group" && previous.kind === node.kind) {
|
||||
// Permission-blocked tools remain at the end, just as the former refs/pending
|
||||
// partition did. Inserting a new ref must precede those blocked tools.
|
||||
const pending = previous.kind === "exploration" ? previous.pending.length : 0
|
||||
const [left, right] = splitGroups([previous], previous.size - pending)
|
||||
const [merged] = mergeGroups(mergeGroups(left, [node]), right)
|
||||
if (merged.type !== "group") throw new Error("Expected merged session group")
|
||||
previous.children = merged.children
|
||||
previous.size = merged.size
|
||||
if (part.type === "reasoning") previous.completed &&= part.time?.completed !== undefined
|
||||
return
|
||||
}
|
||||
completePrevious(rows, index)
|
||||
rows.splice(
|
||||
index,
|
||||
0,
|
||||
node.kind === "reasoning"
|
||||
? { ...node, kind: "reasoning", completed: part.type === "reasoning" && part.time?.completed !== undefined }
|
||||
: { ...node, kind: "exploration", pending: [], completed: false },
|
||||
)
|
||||
}
|
||||
|
||||
export function completePrevious(rows: SessionRow[], index = rows.length) {
|
||||
const previous = rows[index - 1]
|
||||
if (previous?.type === "group") previous.completed = true
|
||||
}
|
||||
|
||||
/** Part references for an existing production subgroup, not a flat timeline. */
|
||||
export function groupRefs(row: SessionGroup, includePending = false): PartRef[] {
|
||||
const pending = !includePending && row.kind === "exploration" ? row.pending : []
|
||||
const visit = (nodes: readonly GroupNode<SessionEntry, GroupKind>[]): PartRef[] =>
|
||||
nodes.flatMap((node) => {
|
||||
if (node.type === "group") return visit(node.children)
|
||||
if (node.entry.type !== "part") return []
|
||||
const ref = node.entry.ref
|
||||
if (pending.some((item) => item.messageID === ref.messageID && item.partID === ref.partID)) return []
|
||||
return [ref]
|
||||
})
|
||||
return visit(row.children)
|
||||
}
|
||||
|
||||
export function partitionPending(rows: SessionRow[], pending: Set<string>) {
|
||||
rows.forEach((row) => {
|
||||
if (row.type !== "group" || row.kind !== "exploration") return
|
||||
// The production exploration rule creates direct part children. Preserve the
|
||||
// existing stable partition order when permissions are admitted or dismissed.
|
||||
const blocked = (node: GroupNode<SessionEntry, GroupKind>) =>
|
||||
node.type === "entry" && node.entry.type === "part" && pending.has(node.entry.ref.partID)
|
||||
row.children = [...row.children.filter((node) => !blocked(node)), ...row.children.filter(blocked)]
|
||||
row.pending = groupRefs(row, true).filter((ref) => pending.has(ref.partID))
|
||||
})
|
||||
}
|
||||
|
||||
export function hasPart(rows: SessionRow[], ref: PartRef) {
|
||||
return rows.some((row) => {
|
||||
if (row.type === "part") return row.ref.messageID === ref.messageID && row.ref.partID === ref.partID
|
||||
if (row.type !== "group") return false
|
||||
return groupRefs(row, true).some((item) => item.messageID === ref.messageID && item.partID === ref.partID)
|
||||
})
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
export type GroupNode<Entry, Kind extends string> =
|
||||
| { readonly type: "entry"; readonly entry: Entry; readonly size: 1 }
|
||||
| {
|
||||
readonly type: "group"
|
||||
readonly kind: Kind
|
||||
readonly children: readonly GroupNode<Entry, Kind>[]
|
||||
/** Number of descendant leaves, independent of disclosure state. */
|
||||
readonly size: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Group adjacent entries by their configured nesting paths. For example, a read
|
||||
* can use ["exploration"] today or ["activity", "exploration"] in Low.
|
||||
* Entries are opaque: message/part identity, visibility and live state remain
|
||||
* owned by the session projection. A path of [] creates a standalone leaf.
|
||||
*/
|
||||
export function groupEntries<Entry, Kind extends string>(
|
||||
entries: readonly Entry[],
|
||||
path: (entry: Entry) => readonly Kind[],
|
||||
): readonly GroupNode<Entry, Kind>[] {
|
||||
const result: BuildingNode<Entry, Kind>[] = []
|
||||
entries.forEach((entry) => {
|
||||
appendEntry(result, entry, path(entry))
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
// Only freshly constructed nodes are writable; the published tree is readonly.
|
||||
type BuildingNode<Entry, Kind extends string> =
|
||||
| { type: "entry"; entry: Entry; size: 1 }
|
||||
| { type: "group"; kind: Kind; children: BuildingNode<Entry, Kind>[]; size: number }
|
||||
|
||||
function appendEntry<Entry, Kind extends string>(
|
||||
nodes: BuildingNode<Entry, Kind>[],
|
||||
entry: Entry,
|
||||
path: readonly Kind[],
|
||||
depth = 0,
|
||||
) {
|
||||
const kind = path[depth]
|
||||
if (kind === undefined) {
|
||||
nodes.push({ type: "entry", entry, size: 1 })
|
||||
return
|
||||
}
|
||||
const previous = nodes.at(-1)
|
||||
if (previous?.type === "group" && previous.kind === kind) {
|
||||
previous.size++
|
||||
appendEntry(previous.children, entry, path, depth + 1)
|
||||
return
|
||||
}
|
||||
const children: BuildingNode<Entry, Kind>[] = []
|
||||
appendEntry(children, entry, path, depth + 1)
|
||||
nodes.push({ type: "group", kind, children, size: 1 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Concatenate ordered, disjoint chunks, recursively merging compatible groups
|
||||
* at their seam. Untouched subtrees retain their object identity.
|
||||
*
|
||||
* This is concatenation, not ingestion: callers must reconcile overlapping
|
||||
* pages/replayed message IDs before merging. Equal payloads may be distinct
|
||||
* entries and must not be silently deduplicated here.
|
||||
*/
|
||||
export function mergeGroups<Entry, Kind extends string>(
|
||||
left: readonly GroupNode<Entry, Kind>[],
|
||||
right: readonly GroupNode<Entry, Kind>[],
|
||||
): readonly GroupNode<Entry, Kind>[] {
|
||||
if (!left.length) return right
|
||||
if (!right.length) return left
|
||||
const a = left[left.length - 1]
|
||||
const b = right[0]
|
||||
if (a.type !== "group" || b.type !== "group" || a.kind !== b.kind) return [...left, ...right]
|
||||
return [
|
||||
...left.slice(0, -1),
|
||||
{
|
||||
type: "group",
|
||||
kind: a.kind,
|
||||
size: a.size + b.size,
|
||||
children: mergeGroups(a.children, b.children),
|
||||
},
|
||||
...right.slice(1),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Split at a depth-first leaf offset. Group headers count as zero. Cached sizes
|
||||
* skip whole subtrees; only the ancestors crossing the cut are reconstructed.
|
||||
* The returned halves can be seam-merged again without changing their meaning.
|
||||
*/
|
||||
export function splitGroups<Entry, Kind extends string>(
|
||||
nodes: readonly GroupNode<Entry, Kind>[],
|
||||
count: number,
|
||||
): readonly [readonly GroupNode<Entry, Kind>[], readonly GroupNode<Entry, Kind>[]] {
|
||||
if (!Number.isInteger(count) || count < 0) throw new RangeError("Group split requires a non-negative integer")
|
||||
if (count === 0) return [[], nodes]
|
||||
let offset = 0
|
||||
for (const [index, node] of nodes.entries()) {
|
||||
const end = offset + node.size
|
||||
if (count === end) return [nodes.slice(0, index + 1), nodes.slice(index + 1)]
|
||||
if (count < end) {
|
||||
if (node.type !== "group") throw new RangeError("Cannot split inside an entry")
|
||||
const size = count - offset
|
||||
const [left, right] = splitGroups(node.children, size)
|
||||
return [
|
||||
[...nodes.slice(0, index), { ...node, children: left, size }],
|
||||
[{ ...node, children: right, size: node.size - size }, ...nodes.slice(index + 1)],
|
||||
]
|
||||
}
|
||||
offset = end
|
||||
}
|
||||
throw new RangeError("Group split exceeds entry count")
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
batch,
|
||||
createContext,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
onMount,
|
||||
Show,
|
||||
Switch,
|
||||
useContext,
|
||||
type Accessor,
|
||||
} from "solid-js"
|
||||
import path from "node:path"
|
||||
@@ -27,6 +29,7 @@ import { createSyntaxStyleMemo, ThemeContextProvider, useTheme, useThemes } from
|
||||
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA, MouseEvent } from "@opentui/core"
|
||||
import { Prompt, type PromptRef } from "../../component/prompt"
|
||||
import type {
|
||||
ModelInfo,
|
||||
SessionMessageInfo,
|
||||
SessionMessageAssistant,
|
||||
SessionMessageAssistantReasoning,
|
||||
@@ -108,10 +111,6 @@ import { createDelayedPresence } from "../../util/delayed-presence"
|
||||
import { SessionLocationMissing } from "./location-missing"
|
||||
import { isRecord } from "../../util/record"
|
||||
import { createHistoryPrepend } from "./history"
|
||||
import { context, use, type PendingAction } from "./render-context"
|
||||
import { INLINE_TOOL_ICON_WIDTH, InlineToolRow, ReasoningPart, reasoningContent, TextPart } from "./message-parts"
|
||||
import { groupRefs } from "./grouping/session"
|
||||
export { InlineToolRow } from "./message-parts"
|
||||
|
||||
addDefaultParsers(parsers.parsers)
|
||||
|
||||
@@ -122,6 +121,34 @@ const BACKGROUND_TOOL_HINT_DELAY = 3_000
|
||||
// The tail comfortably overfills a tall viewport; older rows mount as the reader approaches them.
|
||||
const TRANSCRIPT_TAIL_ROWS = 40
|
||||
const TRANSCRIPT_BACKFILL_CHUNK = 60
|
||||
type PendingAction = "steer" | "queue" | "cancel"
|
||||
|
||||
const context = createContext<{
|
||||
/** Content width: terminal width minus vertical tabs, sidebar, and padding. */
|
||||
width: number
|
||||
/**
|
||||
* Shared reactive terminal size. Transcript-row components must read this
|
||||
* instead of calling useTerminalDimensions(), which registers one renderer
|
||||
* resize listener per mounted component and grows with transcript length.
|
||||
*/
|
||||
terminal: { width: number; height: number }
|
||||
sessionID: string
|
||||
thinkingMode: () => ThinkingMode
|
||||
markdownMode: () => "source" | "rendered"
|
||||
groupExploration: () => boolean
|
||||
diffWrapMode: () => "word" | "none"
|
||||
models: () => ModelInfo[]
|
||||
messageIndex: (messageID: string) => number | undefined
|
||||
config: ReturnType<typeof useConfig>["data"]
|
||||
mutatePending: (action: PendingAction, inboxID: string) => Promise<boolean>
|
||||
pendingDelivery: (inboxID: string) => SessionInbox.Delivery | undefined
|
||||
}>()
|
||||
|
||||
function use() {
|
||||
const ctx = useContext(context)
|
||||
if (!ctx) throw new Error("useContext must be used within a Session component")
|
||||
return ctx
|
||||
}
|
||||
|
||||
export function Session(props: {
|
||||
scrollRef?: (scroll: ScrollBoxRenderable | undefined) => void
|
||||
@@ -1443,14 +1470,12 @@ function SessionRowView(props: SessionRowViewProps) {
|
||||
{(row) => <SessionPartView partRef={row().ref} message={props.message} />}
|
||||
</Match>
|
||||
<Match when={props.row.type === "group" && props.row.kind === "reasoning" ? props.row : undefined}>
|
||||
{(row) => (
|
||||
<SessionReasoningGroupView refs={groupRefs(row())} completed={row().completed} message={props.message} />
|
||||
)}
|
||||
{(row) => <SessionReasoningGroupView refs={row().refs} completed={row().completed} message={props.message} />}
|
||||
</Match>
|
||||
<Match when={props.row.type === "group" && props.row.kind === "exploration" ? props.row : undefined}>
|
||||
{(row) => (
|
||||
<SessionGroupView
|
||||
refs={groupRefs(row())}
|
||||
refs={row().refs}
|
||||
pending={row().pending}
|
||||
completed={row().completed}
|
||||
message={props.message}
|
||||
@@ -2437,6 +2462,160 @@ function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) {
|
||||
)
|
||||
}
|
||||
|
||||
const INLINE_TOOL_ICON_WIDTH = 2
|
||||
|
||||
function ReasoningPart(props: {
|
||||
last: boolean
|
||||
part: SessionMessageAssistantReasoning
|
||||
message: SessionMessageAssistant
|
||||
}) {
|
||||
const theme = useTheme()
|
||||
const { currentSyntax: syntax } = useThemes()
|
||||
const thinkingSyntax = createSyntaxStyleMemo(() => generateThinkingSyntax(syntax(), theme.text.subdued))
|
||||
const ctx = use()
|
||||
// Collapsed by default in hide mode: a single line throughout, so the
|
||||
// layout never shifts. Click to open the full markdown block, click to close.
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
|
||||
const content = createMemo(() => reasoningContent(props.part))
|
||||
const isDone = createMemo(
|
||||
() => props.part.time?.completed !== undefined || props.message.time.completed !== undefined,
|
||||
)
|
||||
const inMinimal = createMemo(() => ctx.thinkingMode() === "hide")
|
||||
const duration = createMemo(() => {
|
||||
const end = props.part.time?.completed ?? props.message.time.completed
|
||||
const start = props.part.time?.created ?? props.message.time.created
|
||||
return end === undefined ? 0 : Math.max(0, end - start)
|
||||
})
|
||||
const summary = createMemo(() => reasoningSummary(content()))
|
||||
const toggle = () => {
|
||||
if (!inMinimal()) return
|
||||
setExpanded((prev) => !prev)
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={content()}>
|
||||
<box paddingLeft={3} flexDirection="column" flexShrink={0}>
|
||||
<box
|
||||
border={!inMinimal() || expanded() ? ["left"] : undefined}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={theme.raise(theme.background.default)}
|
||||
paddingLeft={!inMinimal() || expanded() ? 1 : 0}
|
||||
>
|
||||
<box onMouseUp={toggle}>
|
||||
<ReasoningHeader
|
||||
toggleable={inMinimal()}
|
||||
open={!inMinimal() || expanded()}
|
||||
done={isDone()}
|
||||
title={inMinimal() && !expanded() ? summary().title : null}
|
||||
duration={isDone() ? Locale.duration(duration()) : undefined}
|
||||
/>
|
||||
</box>
|
||||
</box>
|
||||
<Show when={!inMinimal() || expanded()}>
|
||||
<box marginTop={1}>
|
||||
<box
|
||||
border={["left"]}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={theme.raise(theme.background.default)}
|
||||
paddingLeft={inMinimal() ? 3 : 1}
|
||||
>
|
||||
<code
|
||||
filetype="markdown"
|
||||
drawUnstyledText={false}
|
||||
streaming={true}
|
||||
syntaxStyle={thinkingSyntax()}
|
||||
content={content()}
|
||||
conceal={ctx.markdownMode() === "rendered"}
|
||||
fg={theme.text.subdued}
|
||||
/>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function reasoningContent(part: SessionMessageAssistantReasoning) {
|
||||
// OpenRouter encrypts some reasoning blocks; drop the placeholder.
|
||||
return part.text.replace("[REDACTED]", "").trim()
|
||||
}
|
||||
|
||||
function ReasoningHeader(props: {
|
||||
toggleable: boolean
|
||||
open: boolean
|
||||
done: boolean
|
||||
title: string | null
|
||||
duration?: string
|
||||
}) {
|
||||
const theme = useTheme()
|
||||
const fg = () =>
|
||||
props.open
|
||||
? RGBA.fromValues(
|
||||
theme.text.feedback.warning.default.r,
|
||||
theme.text.feedback.warning.default.g,
|
||||
theme.text.feedback.warning.default.b,
|
||||
0.6,
|
||||
)
|
||||
: theme.text.feedback.warning.default
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={!props.done}>
|
||||
<box flexDirection="row">
|
||||
<Spinner color={fg()}>{props.title ? "Thinking: " + props.title : "Thinking"}</Spinner>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<text fg={fg()} wrapMode="none">
|
||||
<Show when={props.toggleable}>
|
||||
<span>{props.open ? "- " : "+ "}</span>
|
||||
</Show>
|
||||
<span>Thought</span>
|
||||
<Show when={props.title || props.duration}>
|
||||
<span>: </span>
|
||||
</Show>
|
||||
<Show when={props.title}>
|
||||
<span>{props.title}</span>
|
||||
</Show>
|
||||
<Show when={props.duration}>
|
||||
<span>
|
||||
{props.title ? " · " : ""}
|
||||
{props.duration}
|
||||
</span>
|
||||
</Show>
|
||||
</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
function TextPart(props: { last: boolean; part: SessionMessageAssistantText; message: SessionMessageAssistant }) {
|
||||
const ctx = use()
|
||||
const theme = useTheme()
|
||||
const { currentSyntax: syntax } = useThemes()
|
||||
const plugins = usePlugin()
|
||||
return (
|
||||
<Show when={props.part.text.trim()}>
|
||||
<box paddingLeft={3} flexShrink={0}>
|
||||
{/* Configure custom nodes before parsing; apply content before streaming so completion keeps the final tokens. */}
|
||||
<markdown
|
||||
syntaxStyle={syntax()}
|
||||
renderNode={plugins.markdown()}
|
||||
content={props.part.text.trim()}
|
||||
streaming={props.message.time.completed === undefined}
|
||||
internalBlockMode="top-level"
|
||||
tableOptions={{ style: "grid", cellPaddingX: 1 }}
|
||||
conceal={ctx.markdownMode() === "rendered"}
|
||||
fg={theme.markdown.text}
|
||||
bg={theme.background.default}
|
||||
/>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
// Pending messages moved to individual tool pending functions
|
||||
|
||||
function ToolPart(props: { part: SessionMessageAssistantTool; images?: boolean }) {
|
||||
@@ -2739,6 +2918,101 @@ function InlineTool(props: {
|
||||
)
|
||||
}
|
||||
|
||||
export function InlineToolRow(props: {
|
||||
icon: string
|
||||
iconColor?: RGBA
|
||||
color?: RGBA
|
||||
errorColor?: RGBA
|
||||
failed?: boolean
|
||||
denied?: boolean
|
||||
error?: string
|
||||
errorExpanded?: boolean
|
||||
complete: unknown
|
||||
pending: string
|
||||
failure?: string
|
||||
spinner?: boolean
|
||||
status?: JSX.Element
|
||||
children: JSX.Element
|
||||
onMouseOver?: () => void
|
||||
onMouseOut?: () => void
|
||||
onMouseUp?: () => void
|
||||
}) {
|
||||
return (
|
||||
<box paddingLeft={3} onMouseOver={props.onMouseOver} onMouseOut={props.onMouseOut} onMouseUp={props.onMouseUp}>
|
||||
<Switch>
|
||||
<Match when={props.spinner}>
|
||||
<Show when={props.status} fallback={<Spinner color={props.color} children={props.children} />}>
|
||||
{(status) => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<Spinner color={props.color} />
|
||||
<InlineToolLabel color={props.color} status={status()}>
|
||||
{props.children}
|
||||
</InlineToolLabel>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<Show fallback={<Spinner color={props.color}>{props.pending}</Spinner>} when={props.complete || props.failed}>
|
||||
<box flexDirection="row">
|
||||
<text
|
||||
width={INLINE_TOOL_ICON_WIDTH}
|
||||
fg={props.failed ? props.errorColor : (props.iconColor ?? props.color)}
|
||||
attributes={props.denied ? TextAttributes.STRIKETHROUGH : undefined}
|
||||
>
|
||||
{props.icon}
|
||||
</text>
|
||||
<Show
|
||||
when={props.status}
|
||||
fallback={
|
||||
<text
|
||||
flexGrow={1}
|
||||
fg={props.failed ? props.errorColor : props.color}
|
||||
attributes={props.denied ? TextAttributes.STRIKETHROUGH : undefined}
|
||||
>
|
||||
{props.failed && !props.complete ? (props.failure ?? props.children) : props.children}
|
||||
</text>
|
||||
}
|
||||
>
|
||||
{(status) => (
|
||||
<InlineToolLabel
|
||||
color={props.failed ? props.errorColor : props.color}
|
||||
denied={props.denied}
|
||||
status={status()}
|
||||
>
|
||||
{props.failed && !props.complete ? (props.failure ?? props.children) : props.children}
|
||||
</InlineToolLabel>
|
||||
)}
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
</Match>
|
||||
</Switch>
|
||||
<Show when={props.failed && props.errorExpanded}>
|
||||
<box paddingLeft={INLINE_TOOL_ICON_WIDTH}>
|
||||
<text fg={props.errorColor}>{props.error}</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function InlineToolLabel(props: { color?: RGBA; denied?: boolean; status: JSX.Element; children: JSX.Element }) {
|
||||
return (
|
||||
<box flexDirection="row" flexWrap="wrap" columnGap={1} flexGrow={1}>
|
||||
<text
|
||||
maxWidth="100%"
|
||||
flexShrink={0}
|
||||
fg={props.color}
|
||||
attributes={props.denied ? TextAttributes.STRIKETHROUGH : undefined}
|
||||
>
|
||||
{props.children}
|
||||
</text>
|
||||
{props.status}
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBadge(props: { children: string }) {
|
||||
const theme = useTheme()
|
||||
return (
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
import { createMemo, createSignal, Match, Show, Switch } from "solid-js"
|
||||
import { RGBA, TextAttributes } from "@opentui/core"
|
||||
import type { JSX } from "@opentui/solid"
|
||||
import type {
|
||||
SessionMessageAssistant,
|
||||
SessionMessageAssistantReasoning,
|
||||
SessionMessageAssistantText,
|
||||
} from "@opencode/client"
|
||||
import { Spinner } from "../../component/spinner"
|
||||
import { createSyntaxStyleMemo, useTheme, useThemes } from "../../context/theme"
|
||||
import { reasoningSummary } from "../../context/thinking"
|
||||
import { usePlugin } from "../../plugin/context"
|
||||
import { SplitBorder } from "../../ui/border"
|
||||
import { Locale } from "../../util/locale"
|
||||
import { use } from "./render-context"
|
||||
import { generateThinkingSyntax } from "./thinking-syntax"
|
||||
|
||||
export const INLINE_TOOL_ICON_WIDTH = 2
|
||||
|
||||
export function ReasoningPart(props: {
|
||||
last: boolean
|
||||
part: SessionMessageAssistantReasoning
|
||||
message: SessionMessageAssistant
|
||||
}) {
|
||||
const theme = useTheme()
|
||||
const { currentSyntax: syntax } = useThemes()
|
||||
const thinkingSyntax = createSyntaxStyleMemo(() => generateThinkingSyntax(syntax(), theme.text.subdued))
|
||||
const ctx = use()
|
||||
// Collapsed by default in hide mode: a single line throughout, so the
|
||||
// layout never shifts. Click to open the full markdown block, click to close.
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
|
||||
const content = createMemo(() => reasoningContent(props.part))
|
||||
const isDone = createMemo(
|
||||
() => props.part.time?.completed !== undefined || props.message.time.completed !== undefined,
|
||||
)
|
||||
const inMinimal = createMemo(() => ctx.thinkingMode() === "hide")
|
||||
const duration = createMemo(() => {
|
||||
const end = props.part.time?.completed ?? props.message.time.completed
|
||||
const start = props.part.time?.created ?? props.message.time.created
|
||||
return end === undefined ? 0 : Math.max(0, end - start)
|
||||
})
|
||||
const summary = createMemo(() => reasoningSummary(content()))
|
||||
const toggle = () => {
|
||||
if (!inMinimal()) return
|
||||
setExpanded((prev) => !prev)
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={content()}>
|
||||
<box paddingLeft={3} flexDirection="column" flexShrink={0}>
|
||||
<box
|
||||
border={!inMinimal() || expanded() ? ["left"] : undefined}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={theme.raise(theme.background.default)}
|
||||
paddingLeft={!inMinimal() || expanded() ? 1 : 0}
|
||||
>
|
||||
<box onMouseUp={toggle}>
|
||||
<ReasoningHeader
|
||||
toggleable={inMinimal()}
|
||||
open={!inMinimal() || expanded()}
|
||||
done={isDone()}
|
||||
title={inMinimal() && !expanded() ? summary().title : null}
|
||||
duration={isDone() ? Locale.duration(duration()) : undefined}
|
||||
/>
|
||||
</box>
|
||||
</box>
|
||||
<Show when={!inMinimal() || expanded()}>
|
||||
<box marginTop={1}>
|
||||
<box
|
||||
border={["left"]}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={theme.raise(theme.background.default)}
|
||||
paddingLeft={inMinimal() ? 3 : 1}
|
||||
>
|
||||
<code
|
||||
filetype="markdown"
|
||||
drawUnstyledText={false}
|
||||
streaming={true}
|
||||
syntaxStyle={thinkingSyntax()}
|
||||
content={content()}
|
||||
conceal={ctx.markdownMode() === "rendered"}
|
||||
fg={theme.text.subdued}
|
||||
/>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export function reasoningContent(part: SessionMessageAssistantReasoning) {
|
||||
// OpenRouter encrypts some reasoning blocks; drop the placeholder.
|
||||
return part.text.replace("[REDACTED]", "").trim()
|
||||
}
|
||||
|
||||
function ReasoningHeader(props: {
|
||||
toggleable: boolean
|
||||
open: boolean
|
||||
done: boolean
|
||||
title: string | null
|
||||
duration?: string
|
||||
}) {
|
||||
const theme = useTheme()
|
||||
const fg = () =>
|
||||
props.open
|
||||
? RGBA.fromValues(
|
||||
theme.text.feedback.warning.default.r,
|
||||
theme.text.feedback.warning.default.g,
|
||||
theme.text.feedback.warning.default.b,
|
||||
0.6,
|
||||
)
|
||||
: theme.text.feedback.warning.default
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={!props.done}>
|
||||
<box flexDirection="row">
|
||||
<Spinner color={fg()}>{props.title ? "Thinking: " + props.title : "Thinking"}</Spinner>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<text fg={fg()} wrapMode="none">
|
||||
<Show when={props.toggleable}>
|
||||
<span>{props.open ? "- " : "+ "}</span>
|
||||
</Show>
|
||||
<span>Thought</span>
|
||||
<Show when={props.title || props.duration}>
|
||||
<span>: </span>
|
||||
</Show>
|
||||
<Show when={props.title}>
|
||||
<span>{props.title}</span>
|
||||
</Show>
|
||||
<Show when={props.duration}>
|
||||
<span>
|
||||
{props.title ? " · " : ""}
|
||||
{props.duration}
|
||||
</span>
|
||||
</Show>
|
||||
</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
export function TextPart(props: {
|
||||
last: boolean
|
||||
part: SessionMessageAssistantText
|
||||
message: SessionMessageAssistant
|
||||
}) {
|
||||
const ctx = use()
|
||||
const theme = useTheme()
|
||||
const { currentSyntax: syntax } = useThemes()
|
||||
const plugins = usePlugin()
|
||||
return (
|
||||
<Show when={props.part.text.trim()}>
|
||||
<box paddingLeft={3} flexShrink={0}>
|
||||
{/* Configure custom nodes before parsing; apply content before streaming so completion keeps the final tokens. */}
|
||||
<markdown
|
||||
syntaxStyle={syntax()}
|
||||
renderNode={plugins.markdown()}
|
||||
content={props.part.text.trim()}
|
||||
streaming={props.message.time.completed === undefined}
|
||||
internalBlockMode="top-level"
|
||||
tableOptions={{ style: "grid", cellPaddingX: 1 }}
|
||||
conceal={ctx.markdownMode() === "rendered"}
|
||||
fg={theme.markdown.text}
|
||||
bg={theme.background.default}
|
||||
/>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export function InlineToolRow(props: {
|
||||
icon: string
|
||||
iconColor?: RGBA
|
||||
color?: RGBA
|
||||
errorColor?: RGBA
|
||||
failed?: boolean
|
||||
denied?: boolean
|
||||
error?: string
|
||||
errorExpanded?: boolean
|
||||
complete: unknown
|
||||
pending: string
|
||||
failure?: string
|
||||
spinner?: boolean
|
||||
status?: JSX.Element
|
||||
children: JSX.Element
|
||||
onMouseOver?: () => void
|
||||
onMouseOut?: () => void
|
||||
onMouseUp?: () => void
|
||||
}) {
|
||||
return (
|
||||
<box paddingLeft={3} onMouseOver={props.onMouseOver} onMouseOut={props.onMouseOut} onMouseUp={props.onMouseUp}>
|
||||
<Switch>
|
||||
<Match when={props.spinner}>
|
||||
<Show when={props.status} fallback={<Spinner color={props.color} children={props.children} />}>
|
||||
{(status) => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<Spinner color={props.color} />
|
||||
<InlineToolLabel color={props.color} status={status()}>
|
||||
{props.children}
|
||||
</InlineToolLabel>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<Show fallback={<Spinner color={props.color}>{props.pending}</Spinner>} when={props.complete || props.failed}>
|
||||
<box flexDirection="row">
|
||||
<text
|
||||
width={INLINE_TOOL_ICON_WIDTH}
|
||||
fg={props.failed ? props.errorColor : (props.iconColor ?? props.color)}
|
||||
attributes={props.denied ? TextAttributes.STRIKETHROUGH : undefined}
|
||||
>
|
||||
{props.icon}
|
||||
</text>
|
||||
<Show
|
||||
when={props.status}
|
||||
fallback={
|
||||
<text
|
||||
flexGrow={1}
|
||||
fg={props.failed ? props.errorColor : props.color}
|
||||
attributes={props.denied ? TextAttributes.STRIKETHROUGH : undefined}
|
||||
>
|
||||
{props.failed && !props.complete ? (props.failure ?? props.children) : props.children}
|
||||
</text>
|
||||
}
|
||||
>
|
||||
{(status) => (
|
||||
<InlineToolLabel
|
||||
color={props.failed ? props.errorColor : props.color}
|
||||
denied={props.denied}
|
||||
status={status()}
|
||||
>
|
||||
{props.failed && !props.complete ? (props.failure ?? props.children) : props.children}
|
||||
</InlineToolLabel>
|
||||
)}
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
</Match>
|
||||
</Switch>
|
||||
<Show when={props.failed && props.errorExpanded}>
|
||||
<box paddingLeft={INLINE_TOOL_ICON_WIDTH}>
|
||||
<text fg={props.errorColor}>{props.error}</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function InlineToolLabel(props: { color?: RGBA; denied?: boolean; status: JSX.Element; children: JSX.Element }) {
|
||||
return (
|
||||
<box flexDirection="row" flexWrap="wrap" columnGap={1} flexGrow={1}>
|
||||
<text
|
||||
maxWidth="100%"
|
||||
flexShrink={0}
|
||||
fg={props.color}
|
||||
attributes={props.denied ? TextAttributes.STRIKETHROUGH : undefined}
|
||||
>
|
||||
{props.children}
|
||||
</text>
|
||||
{props.status}
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { createContext, useContext } from "solid-js"
|
||||
import type { ModelInfo } from "@opencode/client"
|
||||
import type { SessionInbox } from "@opencode/schema/session-inbox"
|
||||
import type { useConfig } from "../../config"
|
||||
import type { ThinkingMode } from "../../context/thinking"
|
||||
|
||||
export type PendingAction = "steer" | "queue" | "cancel"
|
||||
|
||||
export const context = createContext<{
|
||||
/** Content width: terminal width minus vertical tabs, sidebar, and padding. */
|
||||
width: number
|
||||
/**
|
||||
* Shared reactive terminal size. Transcript-row components must read this
|
||||
* instead of calling useTerminalDimensions(), which registers one renderer
|
||||
* resize listener per mounted component and grows with transcript length.
|
||||
*/
|
||||
terminal: { width: number; height: number }
|
||||
sessionID: string
|
||||
thinkingMode: () => ThinkingMode
|
||||
markdownMode: () => "source" | "rendered"
|
||||
groupExploration: () => boolean
|
||||
diffWrapMode: () => "word" | "none"
|
||||
models: () => ModelInfo[]
|
||||
messageIndex: (messageID: string) => number | undefined
|
||||
config: ReturnType<typeof useConfig>["data"]
|
||||
mutatePending: (action: PendingAction, inboxID: string) => Promise<boolean>
|
||||
pendingDelivery: (inboxID: string) => SessionInbox.Delivery | undefined
|
||||
}>()
|
||||
|
||||
export function use() {
|
||||
const ctx = useContext(context)
|
||||
if (!ctx) throw new Error("useContext must be used within a Session component")
|
||||
return ctx
|
||||
}
|
||||
@@ -4,20 +4,36 @@ import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { useConfig } from "../../config"
|
||||
import { useData } from "../../context/data"
|
||||
import { useClient } from "../../context/client"
|
||||
import {
|
||||
append,
|
||||
completePrevious,
|
||||
groupRefs,
|
||||
hasPart,
|
||||
partitionPending,
|
||||
projectEntries,
|
||||
type AppendPart,
|
||||
type CacheUsage,
|
||||
type PartRef,
|
||||
type ProjectionEntry,
|
||||
type SessionRow,
|
||||
} from "./grouping/session"
|
||||
export type { CacheUsage, PartRef, SessionRow } from "./grouping/session"
|
||||
|
||||
export type PartRef = {
|
||||
messageID: string
|
||||
partID: string
|
||||
}
|
||||
|
||||
export type CacheUsage = {
|
||||
read: number
|
||||
model: SessionMessageAssistant["model"]
|
||||
}
|
||||
|
||||
export type SessionRow =
|
||||
| { type: "message"; messageID: string }
|
||||
| { type: "compaction-queued"; inboxID: string }
|
||||
| { type: "part"; ref: PartRef }
|
||||
| {
|
||||
type: "group"
|
||||
kind: "reasoning"
|
||||
refs: PartRef[]
|
||||
completed: boolean
|
||||
}
|
||||
| {
|
||||
type: "group"
|
||||
kind: "exploration"
|
||||
refs: PartRef[]
|
||||
pending: PartRef[]
|
||||
completed: boolean
|
||||
}
|
||||
| { type: "assistant-footer"; messageID: string }
|
||||
| { type: "turn-usage"; messageIDs: string[]; previousCache?: CacheUsage }
|
||||
|
||||
export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessionID: string) => void) {
|
||||
const data = useData()
|
||||
@@ -164,7 +180,7 @@ export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessi
|
||||
(row) =>
|
||||
row.type === "group" &&
|
||||
row.kind === "reasoning" &&
|
||||
groupRefs(row).some((item) => item.messageID === ref.messageID && item.partID === ref.partID),
|
||||
row.refs.some((item) => item.messageID === ref.messageID && item.partID === ref.partID),
|
||||
)
|
||||
if (row?.type === "group" && row.kind === "reasoning") row.completed = true
|
||||
}),
|
||||
@@ -283,15 +299,16 @@ export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new S
|
||||
const usage = turnTokens
|
||||
? { steps: [] as SessionMessageAssistant[], previousTurnCache: undefined as CacheUsage | undefined }
|
||||
: undefined
|
||||
const entries = [
|
||||
return [
|
||||
...messages.filter((message) => !pending.has(message.id)),
|
||||
...pendingCompactions,
|
||||
...messages.filter(isInput),
|
||||
].reduce<ProjectionEntry[]>((rows, message) => {
|
||||
].reduce<SessionRow[]>((rows, message) => {
|
||||
if (message.type !== "assistant") {
|
||||
if (message.type === "synthetic" && !message.description?.trim()) return rows
|
||||
if (message.type === "compaction" && message.status === "completed" && usage) usage.previousTurnCache = undefined
|
||||
rows.push({ entry: { type: "message", messageID: message.id }, closesPrevious: !pending.has(message.id) })
|
||||
if (!pending.has(message.id)) completePrevious(rows)
|
||||
rows.push({ type: "message", messageID: message.id })
|
||||
return rows
|
||||
}
|
||||
usage?.steps.push(message)
|
||||
@@ -299,22 +316,21 @@ export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new S
|
||||
message.content.forEach((part) => {
|
||||
const partID = part.type === "tool" ? part.id : `${part.type}:${ordinals[part.type]++}`
|
||||
if ((part.type === "text" || part.type === "reasoning") && !part.text.trim()) return
|
||||
rows.push({ entry: { type: "part", ref: { messageID: message.id, partID } }, part })
|
||||
append(rows, { messageID: message.id, partID }, part)
|
||||
})
|
||||
const terminal = (message.finish && !["tool-calls", "unknown"].includes(message.finish)) || message.error
|
||||
if (terminal || message.retry) {
|
||||
rows.push({ entry: { type: "assistant-footer", messageID: message.id } })
|
||||
completePrevious(rows)
|
||||
rows.push({ type: "assistant-footer", messageID: message.id })
|
||||
}
|
||||
if (terminal && usage) {
|
||||
const stepsWithUsage = usage.steps.filter(hasTokenUsage)
|
||||
const last = stepsWithUsage.at(-1)
|
||||
if (last) {
|
||||
rows.push({
|
||||
entry: {
|
||||
type: "turn-usage",
|
||||
messageIDs: stepsWithUsage.map((step) => step.id),
|
||||
...(usage.previousTurnCache === undefined ? {} : { previousCache: usage.previousTurnCache }),
|
||||
},
|
||||
type: "turn-usage",
|
||||
messageIDs: stepsWithUsage.map((step) => step.id),
|
||||
...(usage.previousTurnCache === undefined ? {} : { previousCache: usage.previousTurnCache }),
|
||||
})
|
||||
usage.previousTurnCache = { read: last.tokens.cache.read, model: last.model }
|
||||
}
|
||||
@@ -322,7 +338,6 @@ export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new S
|
||||
}
|
||||
return rows
|
||||
}, [])
|
||||
return projectEntries(entries)
|
||||
}
|
||||
|
||||
export function cacheReuseDrop(previous: CacheUsage | undefined, current: CacheUsage) {
|
||||
@@ -412,7 +427,7 @@ function rowBoundaryMessageID(row: SessionRow, messages: Map<string, SessionMess
|
||||
row.type === "part"
|
||||
? row.ref.messageID
|
||||
: row.type === "group"
|
||||
? groupRefs(row)[0]?.messageID
|
||||
? row.refs[0]?.messageID
|
||||
: row.type === "assistant-footer"
|
||||
? row.messageID
|
||||
: row.type === "turn-usage"
|
||||
@@ -431,3 +446,66 @@ export function resolvePart(message: SessionMessageAssistant, partID: string) {
|
||||
const ordinal = Number(match[2])
|
||||
return message.content.filter((part) => part.type === match[1])[ordinal]
|
||||
}
|
||||
|
||||
type AppendPart =
|
||||
| { type: "text" }
|
||||
| { type: "reasoning"; time?: { completed?: number } }
|
||||
| { type: "tool"; name: string }
|
||||
|
||||
function append(rows: SessionRow[], ref: PartRef, part: AppendPart, index = rows.length) {
|
||||
if (part.type === "reasoning") {
|
||||
const previous = rows[index - 1]
|
||||
if (previous?.type === "group" && previous.kind === "reasoning") {
|
||||
previous.refs.push(ref)
|
||||
previous.completed &&= part.time?.completed !== undefined
|
||||
return
|
||||
}
|
||||
completePrevious(rows, index)
|
||||
rows.splice(index, 0, {
|
||||
type: "group",
|
||||
kind: "reasoning",
|
||||
refs: [ref],
|
||||
completed: part.time?.completed !== undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (part.type === "tool" && exploration(part.name)) {
|
||||
const previous = rows[index - 1]
|
||||
if (previous?.type === "group" && previous.kind === "exploration") {
|
||||
previous.refs.push(ref)
|
||||
return
|
||||
}
|
||||
completePrevious(rows, index)
|
||||
rows.splice(index, 0, { type: "group", kind: "exploration", refs: [ref], pending: [], completed: false })
|
||||
return
|
||||
}
|
||||
completePrevious(rows, index)
|
||||
rows.splice(index, 0, { type: "part", ref })
|
||||
}
|
||||
|
||||
function completePrevious(rows: SessionRow[], index = rows.length) {
|
||||
const previous = rows[index - 1]
|
||||
if (previous?.type === "group") previous.completed = true
|
||||
}
|
||||
|
||||
function partitionPending(rows: SessionRow[], pending: Set<string>) {
|
||||
rows.forEach((row) => {
|
||||
if (row.type !== "group" || row.kind !== "exploration") return
|
||||
const refs = [...row.refs, ...row.pending]
|
||||
row.refs = refs.filter((ref) => !pending.has(ref.partID))
|
||||
row.pending = refs.filter((ref) => pending.has(ref.partID))
|
||||
})
|
||||
}
|
||||
|
||||
function exploration(name: string) {
|
||||
return ["read", "glob", "grep"].includes(name.toLowerCase())
|
||||
}
|
||||
|
||||
function hasPart(rows: SessionRow[], ref: PartRef) {
|
||||
return rows.some((row) => {
|
||||
if (row.type === "part") return row.ref.messageID === ref.messageID && row.ref.partID === ref.partID
|
||||
if (row.type !== "group") return false
|
||||
const refs = row.kind === "exploration" ? [...row.refs, ...row.pending] : row.refs
|
||||
return refs.some((item) => item.messageID === ref.messageID && item.partID === ref.partID)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { groupEntries, mergeGroups, splitGroups } from "../../../src/routes/session/grouping/tree"
|
||||
|
||||
const path = (entry: { path: string[] }) => entry.path
|
||||
const read = { id: "read", path: ["activity", "exploration"] }
|
||||
const search = { id: "search", path: ["activity", "exploration"] }
|
||||
const thought = { id: "thought", path: ["activity", "reasoning"] }
|
||||
const text = { id: "text", path: [] }
|
||||
const leaf = (entry: typeof read) => ({ type: "entry" as const, entry, size: 1 as const })
|
||||
|
||||
test("groups adjacent entries by path and counts leaves, not wrappers", () => {
|
||||
expect(groupEntries([read, search, thought, text, read], path)).toEqual([
|
||||
{
|
||||
type: "group",
|
||||
kind: "activity",
|
||||
size: 3,
|
||||
children: [
|
||||
{ type: "group", kind: "exploration", size: 2, children: [leaf(read), leaf(search)] },
|
||||
{ type: "group", kind: "reasoning", size: 1, children: [leaf(thought)] },
|
||||
],
|
||||
},
|
||||
leaf(text),
|
||||
{
|
||||
type: "group",
|
||||
kind: "activity",
|
||||
size: 1,
|
||||
children: [{ type: "group", kind: "exploration", size: 1, children: [leaf(read)] }],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("a direct child breaks a subgroup without ending the outer group", () => {
|
||||
const shell = { id: "shell", path: ["activity"] }
|
||||
expect(groupEntries([read, shell, search], path)).toEqual([
|
||||
{
|
||||
type: "group",
|
||||
kind: "activity",
|
||||
size: 3,
|
||||
children: [
|
||||
{ type: "group", kind: "exploration", size: 1, children: [leaf(read)] },
|
||||
leaf(shell),
|
||||
{ type: "group", kind: "exploration", size: 1, children: [leaf(search)] },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("merges both grouping levels at a page seam without changing the inputs", () => {
|
||||
const left = groupEntries([text, read], path)
|
||||
const right = groupEntries([search, thought], path)
|
||||
const saved = structuredClone([left, right])
|
||||
const merged = mergeGroups(left, right)
|
||||
expect(merged).toEqual(groupEntries([text, read, search, thought], path))
|
||||
expect([left, right]).toEqual(saved)
|
||||
expect(merged[0]).toBe(left[0])
|
||||
})
|
||||
|
||||
test("splits at each leaf boundary and merges back to the original tree", () => {
|
||||
const entries = [read, search, thought, text]
|
||||
const tree = groupEntries(entries, path)
|
||||
for (let count = 0; count <= entries.length; count++) {
|
||||
const [left, right] = splitGroups(tree, count)
|
||||
expect(left).toEqual(groupEntries(entries.slice(0, count), path))
|
||||
expect(right).toEqual(groupEntries(entries.slice(count), path))
|
||||
expect(mergeGroups(left, right)).toEqual(tree)
|
||||
}
|
||||
})
|
||||
|
||||
test("handles empty chunks", () => {
|
||||
const tree = groupEntries([read], path)
|
||||
expect(groupEntries([], path)).toEqual([])
|
||||
expect(mergeGroups([], tree)).toBe(tree)
|
||||
expect(mergeGroups(tree, [])).toBe(tree)
|
||||
expect(splitGroups([], 0)).toEqual([[], []])
|
||||
})
|
||||
|
||||
test("rejects invalid split offsets", () => {
|
||||
const tree = groupEntries([read], path)
|
||||
for (const count of [-1, 0.5, 2, NaN]) {
|
||||
expect(() => splitGroups(tree, count)).toThrow(RangeError)
|
||||
}
|
||||
})
|
||||
@@ -13,10 +13,6 @@ import { ThemeProvider, useThemes } from "../../../src/context/theme"
|
||||
// the context back, so the context must load first exactly as it does in the app.
|
||||
import type { usePlugin } from "../../../src/plugin/context"
|
||||
import "../../../src/plugin/context"
|
||||
import { ClientProvider } from "../../../src/context/client"
|
||||
import { DataProvider } from "../../../src/context/data"
|
||||
import { LocationProvider } from "../../../src/context/location"
|
||||
import { RouteProvider } from "../../../src/context/route"
|
||||
import { PluginsDialog } from "../../../src/feature-plugins/system/plugins"
|
||||
import { DialogProvider } from "../../../src/ui/dialog"
|
||||
import { ToastProvider } from "../../../src/ui/toast"
|
||||
@@ -36,19 +32,11 @@ function packagePlugin(outdated: boolean): PluginInfo {
|
||||
}
|
||||
}
|
||||
|
||||
async function renderPlugins(
|
||||
root: string,
|
||||
inventory: { list: PluginInfo[]; check: PluginInfo[] },
|
||||
tui?: {
|
||||
registered: { id: string; source: "builtin" | "external"; active: boolean }[]
|
||||
list: { target: string; id?: string; status: "active" | "inactive" | "failed"; error?: string }[]
|
||||
},
|
||||
) {
|
||||
async function renderPlugins(root: string, inventory: { list: PluginInfo[]; check: PluginInfo[] }) {
|
||||
const state = path.join(root, "state")
|
||||
await mkdir(state, { recursive: true })
|
||||
const requests: { path: string; body: unknown }[] = []
|
||||
const toasts: ToastOptions[] = []
|
||||
const activations: string[] = []
|
||||
const location = { directory: root, project: { id: "proj_test", directory: root, canonical: root } }
|
||||
const transport = createFetch(async (url, request) => {
|
||||
if (url.pathname === "/api/plugin") return json({ location, data: inventory.list })
|
||||
@@ -61,14 +49,13 @@ async function renderPlugins(
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
})
|
||||
const api = createApi(transport.fetch)
|
||||
|
||||
function Harness() {
|
||||
function Content() {
|
||||
onCleanup(Keymap.use().mode.push("modal"))
|
||||
const theme = useThemes().currentTokens()
|
||||
const context = {
|
||||
client: api,
|
||||
client: createApi(transport.fetch),
|
||||
data: { location: { default: () => ({ directory: root }) }, on: () => () => {} },
|
||||
get theme() {
|
||||
return theme
|
||||
@@ -79,12 +66,9 @@ async function renderPlugins(
|
||||
},
|
||||
} as unknown as Context
|
||||
const plugins = {
|
||||
registered: () => tui?.registered ?? [],
|
||||
list: () => tui?.list ?? [],
|
||||
activate: async (id: string) => {
|
||||
activations.push(id)
|
||||
return true
|
||||
},
|
||||
registered: () => [],
|
||||
list: () => [],
|
||||
activate: async () => true,
|
||||
deactivate: async () => true,
|
||||
} as unknown as ReturnType<typeof usePlugin>
|
||||
return <PluginsDialog context={context} plugins={plugins} />
|
||||
@@ -93,23 +77,15 @@ async function renderPlugins(
|
||||
return (
|
||||
<TestTuiContexts directory={root} paths={{ home: root, state, worktree: root }}>
|
||||
<ConfigProvider config={createTuiResolvedConfig()}>
|
||||
<RouteProvider initialRoute={{ type: "home" }}>
|
||||
<ClientProvider api={api}>
|
||||
<DataProvider directory={root}>
|
||||
<LocationProvider>
|
||||
<Keymap.Provider>
|
||||
<ThemeProvider mode="dark" source={emptyThemeSource}>
|
||||
<ToastProvider>
|
||||
<DialogProvider>
|
||||
<Content />
|
||||
</DialogProvider>
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</Keymap.Provider>
|
||||
</LocationProvider>
|
||||
</DataProvider>
|
||||
</ClientProvider>
|
||||
</RouteProvider>
|
||||
<Keymap.Provider>
|
||||
<ThemeProvider mode="dark" source={emptyThemeSource}>
|
||||
<ToastProvider>
|
||||
<DialogProvider>
|
||||
<Content />
|
||||
</DialogProvider>
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</Keymap.Provider>
|
||||
</ConfigProvider>
|
||||
</TestTuiContexts>
|
||||
)
|
||||
@@ -117,46 +93,10 @@ async function renderPlugins(
|
||||
|
||||
const app = await testRender(() => <Harness />, { width: 80, height: 20, kittyKeyboard: true })
|
||||
app.renderer.start()
|
||||
const expected = tui?.list[0]?.id ?? inventory.list[0]?.id ?? "local.plugin"
|
||||
await app.waitForFrame((frame) => frame.includes(expected))
|
||||
return { app, requests, toasts, activations }
|
||||
await app.waitForFrame((frame) => frame.includes("team.plugins") || frame.includes("local.plugin"))
|
||||
return { app, requests, toasts }
|
||||
}
|
||||
|
||||
test("failed TUI plugins keep enter to enable and use space to show the error", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const fixture = await renderPlugins(
|
||||
tmp.path,
|
||||
{ list: [], check: [] },
|
||||
{
|
||||
registered: [{ id: "broken.plugin", source: "external", active: false }],
|
||||
list: [
|
||||
{
|
||||
target: "./broken.ts",
|
||||
id: "broken.plugin",
|
||||
status: "failed",
|
||||
error: "Plugin setup failed",
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
try {
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("broken.plugin") && frame.includes("view error"))
|
||||
expect(fixture.app.captureCharFrame()).toContain("enable")
|
||||
|
||||
fixture.app.mockInput.pressEnter()
|
||||
await fixture.app.waitFor(() => fixture.activations.length === 1)
|
||||
expect(fixture.activations).toEqual(["broken.plugin"])
|
||||
|
||||
fixture.app.mockInput.pressKey(" ")
|
||||
await fixture.app.waitForFrame(
|
||||
(frame) => frame.includes("TUI plugin error") && frame.includes("Plugin setup failed"),
|
||||
)
|
||||
} finally {
|
||||
fixture.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("checking for updates refreshes the inventory and reveals the update action", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const fixture = await renderPlugins(tmp.path, { list: [packagePlugin(false)], check: [packagePlugin(true)] })
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { append, groupRefs, partitionPending, type SessionRow } from "../../../src/routes/session/grouping/session"
|
||||
|
||||
test("a pending tool does not hide a later tool reusing its call ID in another message", () => {
|
||||
const rows: SessionRow[] = []
|
||||
const blocked = { messageID: "assistant-a", partID: "call-reused" }
|
||||
const later = { messageID: "assistant-b", partID: "call-reused" }
|
||||
append(rows, blocked, { type: "tool", name: "read" })
|
||||
partitionPending(rows, new Set([blocked.partID]))
|
||||
append(rows, later, { type: "tool", name: "read" })
|
||||
|
||||
const group = rows[0]
|
||||
if (group.type !== "group" || group.kind !== "exploration") throw new Error("Expected exploration group")
|
||||
expect(groupRefs(group)).toEqual([later])
|
||||
expect(group.pending).toEqual([blocked])
|
||||
expect(groupRefs(group, true)).toEqual([later, blocked])
|
||||
|
||||
partitionPending(rows, new Set())
|
||||
expect(groupRefs(group)).toEqual([later, blocked])
|
||||
expect(group.pending).toEqual([])
|
||||
})
|
||||
@@ -280,11 +280,10 @@ test("groups exploration parts across assistant messages until a delimiter", ()
|
||||
kind: "exploration",
|
||||
pending: [],
|
||||
completed: true,
|
||||
size: 3,
|
||||
children: [
|
||||
partChild("assistant-1", "read-1"),
|
||||
partChild("assistant-1", "glob-1"),
|
||||
partChild("assistant-2", "grep-1"),
|
||||
refs: [
|
||||
{ messageID: "assistant-1", partID: "read-1" },
|
||||
{ messageID: "assistant-1", partID: "glob-1" },
|
||||
{ messageID: "assistant-2", partID: "grep-1" },
|
||||
],
|
||||
},
|
||||
{ type: "part", ref: { messageID: "assistant-2", partID: "text:0" } },
|
||||
@@ -306,8 +305,7 @@ test("keeps non-exploration tools as individual part rows", () => {
|
||||
kind: "exploration",
|
||||
pending: [],
|
||||
completed: true,
|
||||
size: 1,
|
||||
children: [partChild("assistant-1", "read-1")],
|
||||
refs: [{ messageID: "assistant-1", partID: "read-1" }],
|
||||
},
|
||||
{ type: "part", ref: { messageID: "assistant-1", partID: "reasoning:0" } },
|
||||
{
|
||||
@@ -315,8 +313,7 @@ test("keeps non-exploration tools as individual part rows", () => {
|
||||
kind: "exploration",
|
||||
pending: [],
|
||||
completed: false,
|
||||
size: 1,
|
||||
children: [partChild("assistant-1", "grep-1")],
|
||||
refs: [{ messageID: "assistant-1", partID: "grep-1" }],
|
||||
},
|
||||
])
|
||||
})
|
||||
@@ -337,16 +334,14 @@ test("assigns stable kind ordinals within an assistant message", () => {
|
||||
type: "group",
|
||||
kind: "reasoning",
|
||||
completed: true,
|
||||
size: 1,
|
||||
children: [partChild("assistant-1", "reasoning:0")],
|
||||
refs: [{ messageID: "assistant-1", partID: "reasoning:0" }],
|
||||
},
|
||||
{ type: "part", ref: { messageID: "assistant-1", partID: "text:1" } },
|
||||
{
|
||||
type: "group",
|
||||
kind: "reasoning",
|
||||
completed: false,
|
||||
size: 1,
|
||||
children: [partChild("assistant-1", "reasoning:1")],
|
||||
refs: [{ messageID: "assistant-1", partID: "reasoning:1" }],
|
||||
},
|
||||
])
|
||||
})
|
||||
@@ -366,16 +361,17 @@ test("groups adjacent reasoning parts until a visible boundary", () => {
|
||||
type: "group",
|
||||
kind: "reasoning",
|
||||
completed: true,
|
||||
size: 2,
|
||||
children: [partChild("assistant-1", "reasoning:0"), partChild("assistant-1", "reasoning:1")],
|
||||
refs: [
|
||||
{ messageID: "assistant-1", partID: "reasoning:0" },
|
||||
{ messageID: "assistant-1", partID: "reasoning:1" },
|
||||
],
|
||||
},
|
||||
{ type: "part", ref: { messageID: "assistant-1", partID: "text:0" } },
|
||||
{
|
||||
type: "group",
|
||||
kind: "reasoning",
|
||||
completed: false,
|
||||
size: 1,
|
||||
children: [partChild("assistant-1", "reasoning:2")],
|
||||
refs: [{ messageID: "assistant-1", partID: "reasoning:2" }],
|
||||
},
|
||||
])
|
||||
})
|
||||
@@ -397,16 +393,17 @@ test("groups across empty assistant reasoning parts", () => {
|
||||
type: "group",
|
||||
kind: "reasoning",
|
||||
completed: true,
|
||||
size: 1,
|
||||
children: [partChild("assistant-1", "reasoning:0")],
|
||||
refs: [{ messageID: "assistant-1", partID: "reasoning:0" }],
|
||||
},
|
||||
{
|
||||
type: "group",
|
||||
kind: "exploration",
|
||||
pending: [],
|
||||
completed: false,
|
||||
size: 2,
|
||||
children: [partChild("assistant-1", "read-1"), partChild("assistant-2", "grep-1")],
|
||||
refs: [
|
||||
{ messageID: "assistant-1", partID: "read-1" },
|
||||
{ messageID: "assistant-2", partID: "grep-1" },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
@@ -428,8 +425,7 @@ test("completes exploration groups when another row follows", () => {
|
||||
kind: "exploration",
|
||||
pending: [],
|
||||
completed: true,
|
||||
size: 1,
|
||||
children: [partChild("assistant-1", "read-1")],
|
||||
refs: [{ messageID: "assistant-1", partID: "read-1" }],
|
||||
},
|
||||
{ type: "message", messageID: "user-1" },
|
||||
{
|
||||
@@ -437,8 +433,7 @@ test("completes exploration groups when another row follows", () => {
|
||||
kind: "exploration",
|
||||
pending: [],
|
||||
completed: true,
|
||||
size: 1,
|
||||
children: [partChild("assistant-2", "grep-1")],
|
||||
refs: [{ messageID: "assistant-2", partID: "grep-1" }],
|
||||
},
|
||||
{ type: "assistant-footer", messageID: "assistant-2" },
|
||||
])
|
||||
@@ -473,8 +468,10 @@ test("hides synthetic messages without descriptions", () => {
|
||||
kind: "exploration",
|
||||
pending: [],
|
||||
completed: false,
|
||||
size: 2,
|
||||
children: [partChild("assistant-1", "read-1"), partChild("assistant-2", "grep-1")],
|
||||
refs: [
|
||||
{ messageID: "assistant-1", partID: "read-1" },
|
||||
{ messageID: "assistant-2", partID: "grep-1" },
|
||||
],
|
||||
},
|
||||
])
|
||||
expect(reduceSessionRows(messages, new Set(["synthetic-1"]))).toEqual(rows)
|
||||
@@ -499,8 +496,7 @@ test("renders synthetic messages with descriptions", () => {
|
||||
kind: "exploration",
|
||||
pending: [],
|
||||
completed: true,
|
||||
size: 1,
|
||||
children: [partChild("assistant-1", "read-1")],
|
||||
refs: [{ messageID: "assistant-1", partID: "read-1" }],
|
||||
},
|
||||
{ type: "message", messageID: "synthetic-1" },
|
||||
{
|
||||
@@ -508,16 +504,11 @@ test("renders synthetic messages with descriptions", () => {
|
||||
kind: "exploration",
|
||||
pending: [],
|
||||
completed: false,
|
||||
size: 1,
|
||||
children: [partChild("assistant-2", "grep-1")],
|
||||
refs: [{ messageID: "assistant-2", partID: "grep-1" }],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
function partChild(messageID: string, partID: string) {
|
||||
return { type: "entry" as const, entry: { type: "part" as const, ref: { messageID, partID } }, size: 1 as const }
|
||||
}
|
||||
|
||||
test("renders a footer for a pre-output retry assistant after replay", () => {
|
||||
const message = assistant("assistant-retry", [])
|
||||
message.retry = {
|
||||
|
||||
@@ -2,13 +2,85 @@
|
||||
title: "Agents"
|
||||
---
|
||||
|
||||
Create a Markdown file to add a reusable agent. This example adds a read-only reviewer that the main agent can launch for code reviews:
|
||||
Agents combine a system prompt, model preference, tool permissions, and display
|
||||
metadata into a reusable assistant profile. OpenCode includes agents for common
|
||||
workflows, and you can override them or add your own in configuration or
|
||||
Markdown files.
|
||||
|
||||
## Built-in agents
|
||||
|
||||
| Agent | Mode | Purpose |
|
||||
| ----------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Build** (`build`) | `primary` | Default coding agent. Tools are allowed by default, sensitive environment-file reads ask for approval, and access outside the workspace asks for approval. |
|
||||
| **Plan** (`plan`) | `primary` | Planning agent. File edits are denied except for OpenCode plan files. Shell commands are not generally denied. |
|
||||
| **General** (`general`) | `subagent` | General-purpose research and multi-step work. It has broad tool access but cannot launch more subagents. |
|
||||
| **Explore** (`explore`) | `subagent` | Read-only code and web exploration using `read`, `glob`, `grep`, `webfetch`, and `websearch`. |
|
||||
|
||||
OpenCode also has hidden `compaction`, `title`, and `summary` system agents.
|
||||
They run internal maintenance tasks and are not available for direct use. There is no built-in
|
||||
`scout` agent in V2.
|
||||
|
||||
You can override a built-in agent with an entry of the same ID. Set
|
||||
`disabled: true` to remove one.
|
||||
|
||||
## Default agent
|
||||
|
||||
Set the primary agent used when a session has not selected one:
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"default_agent": "reviewer",
|
||||
}
|
||||
```
|
||||
|
||||
The configured agent must exist, must not have `mode: "subagent"`, and must not
|
||||
be hidden. If it is unavailable, OpenCode falls back to `build`, then to the
|
||||
first visible agent that can run as a primary agent. This selection does not
|
||||
rewrite the agent already stored on an existing session.
|
||||
|
||||
## Modes
|
||||
|
||||
An agent's `mode` controls where it can run:
|
||||
|
||||
| Mode | Behavior |
|
||||
| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `primary` | Can be selected as the main agent for a session. It cannot be launched as a subagent. This is the default for a custom agent when `mode` is omitted. |
|
||||
| `subagent` | Can run in a child session through the `subagent` tool, but cannot be selected as the main agent. |
|
||||
| `all` | Can be used either way. |
|
||||
|
||||
Subagents run in child sessions with fresh context. A primary agent can invoke
|
||||
one with the `subagent` tool, either in the foreground or in the background.
|
||||
|
||||
The parent agent's `subagent` permission controls which agents it may launch.
|
||||
The child currently uses its own configured permissions, not a restricted copy
|
||||
of the parent's permissions.
|
||||
|
||||
## Configure agents
|
||||
|
||||
### Markdown files
|
||||
|
||||
The recommended file locations are:
|
||||
|
||||
```text
|
||||
~/.config/opencode/agents/<name>.md
|
||||
.opencode/agents/<name>.md
|
||||
```
|
||||
|
||||
OpenCode discovers project `.opencode` directories from the current directory
|
||||
up to the project root. The path below `agents/` becomes the agent ID, so
|
||||
`.opencode/agents/team/reviewer.md` defines `team/reviewer`.
|
||||
|
||||
Frontmatter uses the same fields as an entry under `agents`. The Markdown body
|
||||
becomes `system`:
|
||||
|
||||
```md title=".opencode/agents/reviewer.md"
|
||||
---
|
||||
description: Reviews changes for correctness and regressions
|
||||
description: Reviews changes without modifying files
|
||||
mode: subagent
|
||||
model: anthropic/claude-sonnet-4-5#high
|
||||
color: "#ff6b6b"
|
||||
steps: 8
|
||||
permissions:
|
||||
- action: edit
|
||||
resource: "*"
|
||||
@@ -18,191 +90,70 @@ permissions:
|
||||
effect: deny
|
||||
---
|
||||
|
||||
Review the current changes. List findings in severity order with file and line references.
|
||||
Review for correctness, security, regressions, and missing tests.
|
||||
List findings in severity order with file and line references.
|
||||
```
|
||||
|
||||
Ask your primary agent to use it:
|
||||
### JSON or JSONC
|
||||
|
||||
```text
|
||||
Use the reviewer subagent to review my current changes.
|
||||
```
|
||||
|
||||
An agent combines a system prompt, model preference, permissions, and display details into a named assistant profile.
|
||||
|
||||
## Locations
|
||||
|
||||
Save Markdown agents globally for all projects or inside a project:
|
||||
|
||||
```text
|
||||
~/.config/opencode/agents/<name>.md
|
||||
.opencode/agents/<name>.md
|
||||
```
|
||||
|
||||
OpenCode discovers project `.opencode` directories from the current directory up to the project root. A nested path becomes part of the agent ID:
|
||||
|
||||
```text
|
||||
.opencode/agents/team/reviewer.md → team/reviewer
|
||||
```
|
||||
|
||||
## Formats
|
||||
|
||||
### Markdown
|
||||
|
||||
Frontmatter accepts the same fields as an `agents` configuration entry. The Markdown body becomes the agent's `system` prompt:
|
||||
|
||||
```md title=".opencode/agents/explainer.md"
|
||||
---
|
||||
description: Explains code without changing it
|
||||
mode: subagent
|
||||
---
|
||||
|
||||
Explain the relevant code with short examples. Do not edit files.
|
||||
```
|
||||
|
||||
### JSONC
|
||||
|
||||
Define agents under `agents` in any [OpenCode configuration file](/config):
|
||||
Use the `agents` field in any [OpenCode configuration file](/config):
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"default_agent": "reviewer",
|
||||
"agents": {
|
||||
"reviewer": {
|
||||
"description": "Reviews current changes",
|
||||
"mode": "subagent",
|
||||
"system": "Report findings in severity order.",
|
||||
"description": "Reviews changes for correctness, security, and missing tests",
|
||||
"mode": "all",
|
||||
"model": "anthropic/claude-sonnet-4-5#high",
|
||||
"system": "Review the current changes. Report findings before any summary.",
|
||||
"color": "#ff6b6b",
|
||||
"steps": 8,
|
||||
"permissions": [
|
||||
{ "action": "edit", "resource": "*", "effect": "deny" },
|
||||
{ "action": "shell", "resource": "*", "effect": "deny" },
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Selection
|
||||
|
||||
Set the primary agent used when a session has not selected one:
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"default_agent": "writer",
|
||||
"agents": {
|
||||
"writer": { "mode": "primary" },
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
The selected default must exist, be visible, and support primary use. Otherwise OpenCode uses `build`, then the first visible primary-capable agent. Changing this setting does not replace the agent stored on an existing session.
|
||||
|
||||
## Modes
|
||||
|
||||
Set `mode` according to where the agent should run:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"agents": {
|
||||
"reviewer": { "mode": "subagent" },
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
| Mode | Behavior |
|
||||
| --- | --- |
|
||||
| `primary` | Runs as the main agent for a session. This is the default for a new custom agent. |
|
||||
| `subagent` | Runs only in a child session through the `subagent` tool. |
|
||||
| `all` | Runs either as a primary agent or a subagent. |
|
||||
|
||||
Subagents run with fresh context in foreground or background child sessions. The parent agent's `subagent` permissions control which agents it may launch; the child uses its own configured permissions.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"agents": {
|
||||
"orchestrator": {
|
||||
"permissions": [
|
||||
{ "action": "subagent", "resource": "*", "effect": "deny" },
|
||||
{ "action": "subagent", "resource": "reviewer", "effect": "allow" },
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Builtins
|
||||
|
||||
OpenCode includes these visible agents:
|
||||
|
||||
| Agent | Mode | Purpose |
|
||||
| --- | --- | --- |
|
||||
| **Build** (`build`) | `primary` | Default coding agent. Tools are allowed by default; sensitive environment-file reads and access outside the workspace ask for approval. |
|
||||
| **Plan** (`plan`) | `primary` | Explores and plans without editing normal project files. It may write OpenCode plan files when asked, and shell commands remain permission-controlled. |
|
||||
| **General** (`general`) | `subagent` | Handles research and multi-step work with broad tool access, but cannot launch more subagents. |
|
||||
| **Explore** (`explore`) | `subagent` | Searches and reads code or web sources without editing files. |
|
||||
|
||||
Override a built-in by using the same ID:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"agents": {
|
||||
"build": {
|
||||
"permissions": [
|
||||
{ "action": "shell", "resource": "git push *", "effect": "ask" },
|
||||
],
|
||||
"permissions": [{ "action": "shell", "resource": "git push *", "effect": "ask" }],
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Hidden `compaction`, `title`, and `summary` agents perform maintenance and cannot be selected directly. V2 has no built-in `scout` agent.
|
||||
|
||||
## Merging
|
||||
|
||||
Agent definitions merge in configuration order. Later scalar values replace earlier values, request maps merge by key, and permission rules append:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"permissions": [
|
||||
{ "action": "shell", "resource": "*", "effect": "ask" },
|
||||
],
|
||||
"agents": {
|
||||
"build": {
|
||||
"permissions": [
|
||||
{ "action": "shell", "resource": "git status", "effect": "allow" },
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Global `permissions` apply before agent-specific rules, so later agent rules can refine them.
|
||||
Agent definitions merge in configuration order. Later scalar fields replace
|
||||
earlier values, request maps merge by key, and permission rules are appended.
|
||||
Global `permissions` are applied to every agent before its agent-specific rules,
|
||||
so a later agent rule can refine a global rule.
|
||||
|
||||
## Options
|
||||
|
||||
### Description
|
||||
### `description`
|
||||
|
||||
`description` explains the agent's purpose. Add it to subagents because OpenCode shows it to the model choosing which agent to launch:
|
||||
Explains the agent's purpose. It is optional, but strongly recommended for
|
||||
subagents because OpenCode includes it in the subagent catalog shown to the
|
||||
model.
|
||||
|
||||
```yaml
|
||||
description: Reviews database migrations for safety
|
||||
### `mode`
|
||||
|
||||
Accepts `primary`, `subagent`, or `all`. The default is `all`.
|
||||
|
||||
### `model`
|
||||
|
||||
Selects a model using `provider/model` with an optional `#variant`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"agents": {
|
||||
"reviewer": {
|
||||
"model": "anthropic/claude-sonnet-4-5#high",
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Mode
|
||||
|
||||
`mode` accepts `primary`, `subagent`, or `all`. When omitted on a new custom agent, it defaults to `primary`:
|
||||
|
||||
```yaml
|
||||
mode: all
|
||||
```
|
||||
|
||||
### Model
|
||||
|
||||
`model` uses `provider/model` with an optional `#variant`:
|
||||
|
||||
```yaml
|
||||
model: anthropic/claude-sonnet-4-5#high
|
||||
```
|
||||
|
||||
JSON configuration also accepts the expanded form:
|
||||
The equivalent expanded form is:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
@@ -218,108 +169,84 @@ JSON configuration also accepts the expanded form:
|
||||
}
|
||||
```
|
||||
|
||||
- A subagent uses its configured model, or inherits the parent session's model when none is configured.
|
||||
- A session stores its selected model separately. Selecting a primary agent by ID does not change that model.
|
||||
This is the preferred model when the agent is activated. A child session uses
|
||||
its subagent's configured model, or inherits the parent session's model when
|
||||
none is configured. The session's selected model is stored separately;
|
||||
creating or switching a primary session with only an agent ID does not itself
|
||||
change that session model.
|
||||
|
||||
### System
|
||||
### `system`
|
||||
|
||||
`system` sets the agent's system prompt. A non-empty value replaces the provider's base prompt for that agent:
|
||||
Sets the agent's system prompt. A non-empty value replaces OpenCode's
|
||||
provider-specific base prompt for that agent. Project instructions, skills,
|
||||
references, and other instruction sources are still added separately.
|
||||
|
||||
For a Markdown agent, use the document body instead of a `system` frontmatter
|
||||
field.
|
||||
|
||||
### `permissions`
|
||||
|
||||
Permissions are an ordered array of rules:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"agents": {
|
||||
"reviewer": { "system": "Review only. Do not modify files." },
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Project instructions, skills, references, and other instruction sources are still added. In a Markdown agent, put this text in the document body instead of a `system` frontmatter field.
|
||||
|
||||
### Permissions
|
||||
|
||||
`permissions` is an ordered list of matching rules:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"agents": {
|
||||
"reviewer": {
|
||||
"orchestrator": {
|
||||
"permissions": [
|
||||
{ "action": "*", "resource": "*", "effect": "deny" },
|
||||
{ "action": "read", "resource": "src/**", "effect": "allow" },
|
||||
{ "action": "subagent", "resource": "*", "effect": "deny" },
|
||||
{ "action": "subagent", "resource": "explore", "effect": "allow" },
|
||||
{ "action": "shell", "resource": "git *", "effect": "ask" },
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| `action` | Tool or permission action. Wildcards are supported. |
|
||||
| `resource` | Path, command, agent ID, or other value matched by the action. Wildcards are supported. |
|
||||
| `effect` | `allow`, `ask`, or `deny`. |
|
||||
Each rule has:
|
||||
|
||||
The last matching rule wins, so put broad rules before exceptions. Common actions include:
|
||||
| Field | Meaning |
|
||||
| ---------- | ---------------------------------------------------------------------------------------------- |
|
||||
| `action` | Tool or permission action, with `*` wildcards supported. |
|
||||
| `resource` | The path, command, agent ID, or other resource matched by the action. Wildcards are supported. |
|
||||
| `effect` | `allow`, `ask`, or `deny`. |
|
||||
|
||||
| Action | Covers |
|
||||
| --- | --- |
|
||||
| `shell` | Shell commands |
|
||||
| `edit` | Edit, write, and patch tools |
|
||||
| `subagent` | Child agents |
|
||||
| `read`, `glob`, `grep` | Local discovery tools |
|
||||
| `webfetch`, `websearch` | Web tools |
|
||||
| `skill` | Skill loading |
|
||||
The last matching rule wins. Important V2 action names include `shell` for
|
||||
shell commands, `edit` for all edit/write/patch tools, and `subagent` for child
|
||||
agents. Other tools generally use their tool name, such as `read`, `glob`,
|
||||
`grep`, `webfetch`, `websearch`, and `skill`.
|
||||
|
||||
For `read`, `edit`, and `external_directory` resources, OpenCode expands `~` and `$HOME`:
|
||||
<Callout type="tip">
|
||||
Put broad wildcard rules first and exceptions afterward. For example, deny all subagents first, then allow `explore`.
|
||||
</Callout>
|
||||
|
||||
```jsonc
|
||||
{ "action": "read", "resource": "~/notes/**", "effect": "allow" }
|
||||
```
|
||||
`~` and `$HOME` are expanded in filesystem resources for `read`, `edit`, and
|
||||
`external_directory`. Shell resources are raw command text and are not
|
||||
expanded.
|
||||
|
||||
Shell resources remain raw command text and do not expand those values.
|
||||
### `steps`
|
||||
|
||||
### Steps
|
||||
Sets a positive maximum number of model steps. On the final allowed step,
|
||||
OpenCode removes tools and asks the model to summarize its work in text. New
|
||||
user input resets the allowance.
|
||||
|
||||
`steps` sets a positive maximum number of model steps:
|
||||
### `hidden`
|
||||
|
||||
```yaml
|
||||
steps: 8
|
||||
```
|
||||
When `true`, removes the agent from normal agent listings, interactive
|
||||
discovery, and the subagent catalog advertised to models. It is a visibility
|
||||
setting, not a security boundary.
|
||||
|
||||
On the final step, OpenCode removes tools and asks the model to summarize in text. New user input resets the allowance.
|
||||
### `color`
|
||||
|
||||
### Hidden
|
||||
Sets the agent's UI color. Use a six-digit hex color such as `#ff6b6b`.
|
||||
|
||||
`hidden` removes an agent from normal listings, interactive discovery, and the subagent catalog:
|
||||
### `disabled`
|
||||
|
||||
```yaml
|
||||
hidden: true
|
||||
```
|
||||
When `true`, removes the agent definition at that point in configuration
|
||||
loading. This works for built-in and custom agents.
|
||||
|
||||
This controls visibility, not security. Use permissions to restrict behavior.
|
||||
### `request`
|
||||
|
||||
### Color
|
||||
|
||||
`color` sets the agent's UI color using a six-digit hex value:
|
||||
|
||||
```yaml
|
||||
color: "#ff6b6b"
|
||||
```
|
||||
|
||||
### Disabled
|
||||
|
||||
`disabled` removes a built-in or custom agent at that point in configuration loading:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"agents": {
|
||||
"plan": { "disabled": true },
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Request
|
||||
|
||||
`request` accepts per-agent header and JSON body overlays:
|
||||
The V2 schema accepts per-agent request `headers` and JSON `body` overlays:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
@@ -335,8 +262,8 @@ color: "#ff6b6b"
|
||||
```
|
||||
|
||||
<Callout type="warning">
|
||||
The V2 session runner preserves these values but does not yet send them with model requests. Configure active request
|
||||
settings on the provider, model, or model variant instead.
|
||||
The current V2 session runner preserves these overlays on the agent definition but does not yet apply them to model
|
||||
requests. Configure effective request settings on the provider, model, or model variant instead. Do not use legacy
|
||||
top-level agent fields such as `temperature`, `top_p`, `prompt`, `permission`, `tools`, `disable`, or `maxSteps` in
|
||||
new V2 configuration.
|
||||
</Callout>
|
||||
|
||||
Do not use legacy top-level fields such as `temperature`, `top_p`, `prompt`, `permission`, `tools`, `disable`, or `maxSteps` in new V2 agent configuration.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user