mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-11 03:59:54 -04:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f6b9f8ba75 | |||
| d7a7256bb6 | |||
| eec55fa5f8 | |||
| 7ff27ca8cf | |||
| b62dc4a636 | |||
| ceec9f5b66 | |||
| bbb1b5e7d0 | |||
| f0b8ad1242 | |||
| be53e3bd81 | |||
| c22942c1f3 | |||
| 6895728add | |||
| 16aad9e6ad |
@@ -185,15 +185,15 @@ const secretValues = (request: HttpClientRequest.HttpClientRequest) => {
|
||||
// Two passes: structural (redact `"name": "value"` and `name=value` patterns
|
||||
// for any field name that looks sensitive) plus literal (replace any actual
|
||||
// secret values we sent in the request, in case the response echoes one back).
|
||||
const redactBody = (body: string, request: HttpClientRequest.HttpClientRequest) =>
|
||||
Array.from(secretValues(request)).reduce(
|
||||
const redactBody = (body: string, secrets: ReadonlySet<string>) =>
|
||||
Array.from(secrets).reduce(
|
||||
(text, secret) => text.split(secret).join(REDACTED),
|
||||
body.replace(REDACT_JSON_FIELD, `$1"${REDACTED}"`).replace(REDACT_QUERY_FIELD, `$1${REDACTED}`),
|
||||
)
|
||||
|
||||
const responseBody = (body: string | void, request: HttpClientRequest.HttpClientRequest) => {
|
||||
const responseBody = (body: string | void, secrets: ReadonlySet<string>) => {
|
||||
if (body === undefined) return {}
|
||||
const redacted = redactBody(body, request)
|
||||
const redacted = redactBody(body, secrets)
|
||||
if (redacted.length <= BODY_LIMIT) return { body: redacted }
|
||||
return { body: redacted.slice(0, BODY_LIMIT), bodyTruncated: true }
|
||||
}
|
||||
@@ -240,7 +240,7 @@ const statusError =
|
||||
const headers = normalizedHeaders(response.headers)
|
||||
const retryAfter = retryAfterMs(headers)
|
||||
const rateLimit = rateLimitDetails(headers, retryAfter)
|
||||
const details = responseBody(body, request)
|
||||
const details = responseBody(body, secretValues(request))
|
||||
return yield* new AIError({
|
||||
module: "RequestExecutor",
|
||||
method: "execute",
|
||||
@@ -261,6 +261,42 @@ const statusError =
|
||||
})
|
||||
})
|
||||
|
||||
// Classifies an HTTP failure captured outside the executor (for example by the
|
||||
// AI SDK's own fetch) onto the same reason types and redacted HttpContext that
|
||||
// executor-driven requests produce. The originating request is not available on
|
||||
// that path, so the method is assumed (language model calls are always POST),
|
||||
// request headers are empty, and only structural body redaction applies.
|
||||
export const classifyHttpFailure = (input: {
|
||||
readonly message: string
|
||||
readonly url: string
|
||||
readonly status?: number | undefined
|
||||
readonly code?: string | undefined
|
||||
readonly responseHeaders?: Record<string, string> | undefined
|
||||
readonly responseBody?: string | undefined
|
||||
}) => {
|
||||
const headers = normalizedHeaders(Headers.fromInput(input.responseHeaders))
|
||||
const retryAfter = retryAfterMs(headers)
|
||||
const rateLimit = rateLimitDetails(headers, retryAfter)
|
||||
const details = responseBody(input.responseBody ?? undefined, new Set<string>())
|
||||
return classifyProviderFailure({
|
||||
message: input.message,
|
||||
status: input.status,
|
||||
code: input.code,
|
||||
retryAfterMs: retryAfter,
|
||||
rateLimit,
|
||||
http: new HttpContext({
|
||||
request: new HttpRequestDetails({ method: "POST", url: redactUrl(input.url), headers: {} }),
|
||||
response:
|
||||
input.status === undefined
|
||||
? undefined
|
||||
: new HttpResponseDetails({ status: input.status, headers: redactHeaders(Headers.fromInput(headers), []) }),
|
||||
...details,
|
||||
requestId: requestId(headers),
|
||||
rateLimit,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
const toHttpError = (redactedNames: ReadonlyArray<string | RegExp>) => (error: unknown) => {
|
||||
const transportError = (input: {
|
||||
readonly message: string
|
||||
|
||||
@@ -11,7 +11,9 @@ import { createAcpFixture, expectOk, initialize, newSession, selectConfigOption
|
||||
describe("acp lifecycle subprocess", () => {
|
||||
test("stdin EOF exits cleanly", async () => {
|
||||
await using fixture = await createAcpFixture()
|
||||
expect(await fixture.spawn().close()).toBe(0)
|
||||
const acp = fixture.spawn()
|
||||
await initialize(acp)
|
||||
expect(await acp.close()).toBe(0)
|
||||
}, 60_000)
|
||||
|
||||
test("close capability and close request", async () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * as AISDK from "./aisdk"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { APICallError } from "@ai-sdk/provider"
|
||||
import type {
|
||||
JSONSchema7,
|
||||
JSONValue,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
LanguageModel,
|
||||
ProviderID,
|
||||
ProviderMetadata,
|
||||
TransportReason,
|
||||
ToolResultValue,
|
||||
UnknownProviderReason,
|
||||
type ContentPart,
|
||||
@@ -29,7 +31,7 @@ import {
|
||||
type ToolDefinition,
|
||||
type UsageInput,
|
||||
} from "@opencode-ai/ai"
|
||||
import { Auth, Endpoint, type AnyRoute } from "@opencode-ai/ai/route"
|
||||
import { Auth, Endpoint, RequestExecutor, type AnyRoute } from "@opencode-ai/ai/route"
|
||||
import { ProviderShared } from "@opencode-ai/ai/protocols/shared"
|
||||
import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
|
||||
import type { ID, Info } from "./model"
|
||||
@@ -723,7 +725,9 @@ function llmError(method: string, error: unknown) {
|
||||
const reason =
|
||||
error instanceof AIError
|
||||
? new InvalidProviderOutputReason({ message: error.message })
|
||||
: new UnknownProviderReason({ message: error instanceof Error ? error.message : String(error) })
|
||||
: APICallError.isInstance(error)
|
||||
? apiCallErrorReason(error)
|
||||
: new UnknownProviderReason({ message: unknownErrorMessage(error) })
|
||||
return new AIError({
|
||||
module: "AISDK",
|
||||
method,
|
||||
@@ -731,4 +735,57 @@ function llmError(method: string, error: unknown) {
|
||||
})
|
||||
}
|
||||
|
||||
function apiCallErrorReason(error: APICallError) {
|
||||
const details = providerErrorDetails(error)
|
||||
const reason = RequestExecutor.classifyHttpFailure({
|
||||
message: details.message,
|
||||
url: error.url,
|
||||
status: error.statusCode,
|
||||
code: details.code,
|
||||
responseHeaders: error.responseHeaders,
|
||||
responseBody: error.responseBody,
|
||||
})
|
||||
if (error.statusCode !== undefined || !error.isRetryable) return reason
|
||||
return new TransportReason({
|
||||
message: reason.message,
|
||||
kind: error.name,
|
||||
url: error.url,
|
||||
http: "http" in reason ? reason.http : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const ProviderErrorCode = Schema.Union([Schema.String, Schema.Finite])
|
||||
const ProviderErrorDetail = Schema.Struct({
|
||||
message: Schema.optionalKey(Schema.String),
|
||||
code: Schema.optionalKey(ProviderErrorCode),
|
||||
})
|
||||
const ProviderErrorBody = Schema.Struct({
|
||||
...ProviderErrorDetail.fields,
|
||||
error: Schema.optionalKey(ProviderErrorDetail),
|
||||
})
|
||||
const decodeProviderError = Schema.decodeUnknownOption(
|
||||
Schema.Union([ProviderErrorBody, Schema.fromJsonString(ProviderErrorBody)]),
|
||||
)
|
||||
|
||||
function unknownErrorMessage(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return message.trim() === "" ? "Provider request failed" : message
|
||||
}
|
||||
|
||||
function providerErrorDetails(error: APICallError) {
|
||||
const data = Option.getOrUndefined(decodeProviderError(error.data))
|
||||
const body = Option.getOrUndefined(decodeProviderError(error.responseBody))
|
||||
const details = [data?.error, data, body?.error, body]
|
||||
const message = details.map((detail) => detail?.message).find((value) => value?.trim())
|
||||
const value = details.map((detail) => detail?.code).find((value) => value !== undefined)
|
||||
const code = value === undefined ? undefined : String(value)
|
||||
const prefix =
|
||||
error.statusCode === undefined ? "Provider request failed" : `Provider request failed with HTTP ${error.statusCode}`
|
||||
return {
|
||||
code,
|
||||
message:
|
||||
error.message.trim() !== "" ? error.message : (message ?? (code === undefined ? prefix : `${prefix}: ${code}`)),
|
||||
}
|
||||
}
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer: locationLayer, deps: [] })
|
||||
|
||||
@@ -151,7 +151,15 @@ export const layer = (options?: Options) =>
|
||||
)
|
||||
if (!credential || credential.value.type !== "key") return []
|
||||
const variables = { [auth.env]: credential.value.key }
|
||||
const configs = yield* wellknown.resolve(entry, variables).pipe(Effect.orDie)
|
||||
const configs = yield* wellknown
|
||||
.resolve(entry, variables)
|
||||
.pipe(
|
||||
Effect.catch(() =>
|
||||
Effect.logWarning("failed to load wellknown config", { source: entry.origin }).pipe(
|
||||
Effect.as([] as const),
|
||||
),
|
||||
),
|
||||
)
|
||||
return yield* Effect.forEach(configs, (config) =>
|
||||
ConfigVariable.substitute({
|
||||
type: "virtual",
|
||||
|
||||
@@ -47,6 +47,10 @@ export const layer = Layer.effect(
|
||||
input.model
|
||||
? new ModelSelectionError({ message: error.message })
|
||||
: new UnavailableError({ message: error.message, service: error.providerID }),
|
||||
"SessionRunnerModel.UnresolvedProviderVariablesError": (error) =>
|
||||
input.model
|
||||
? new ModelSelectionError({ message: error.message })
|
||||
: new UnavailableError({ message: error.message, service: error.providerID }),
|
||||
}),
|
||||
)
|
||||
if (!resolved)
|
||||
|
||||
@@ -46,7 +46,24 @@ export class UnsupportedPackageError extends Schema.TaggedErrorClass<Unsupported
|
||||
}
|
||||
}
|
||||
|
||||
export type Error = VariantUnavailableError | UnsupportedPackageError | Integration.AuthorizationError
|
||||
export class UnresolvedProviderVariablesError extends Schema.TaggedErrorClass<UnresolvedProviderVariablesError>()(
|
||||
"SessionRunnerModel.UnresolvedProviderVariablesError",
|
||||
{
|
||||
providerID: Provider.ID,
|
||||
modelID: ID,
|
||||
variables: Schema.Array(Schema.String),
|
||||
},
|
||||
) {
|
||||
override get message() {
|
||||
return `Cannot initialize ${this.providerID}/${this.modelID}: ${this.variables.join(", ")} ${this.variables.length === 1 ? "is" : "are"} required to resolve the provider endpoint`
|
||||
}
|
||||
}
|
||||
|
||||
export type Error =
|
||||
| VariantUnavailableError
|
||||
| UnsupportedPackageError
|
||||
| UnresolvedProviderVariablesError
|
||||
| Integration.AuthorizationError
|
||||
|
||||
export interface Resolved {
|
||||
/** Route-level model for provider requests; its id is the provider API model id, which may differ from the catalog id. */
|
||||
@@ -141,12 +158,17 @@ export const fromCatalogModel = (
|
||||
model: Info,
|
||||
credential?: Credential.Value,
|
||||
dependencies?: Dependencies,
|
||||
): Effect.Effect<LanguageModel, UnsupportedPackageError> => {
|
||||
const resolved = produce(model, (draft) => {
|
||||
if (draft.settings?.apiKey === "") delete draft.settings.apiKey
|
||||
if (credential?.type === "key" && credential.metadata !== undefined)
|
||||
draft.body = Provider.mergeOverlay(draft.body, credential.metadata)
|
||||
})
|
||||
): Effect.Effect<LanguageModel, UnsupportedPackageError | UnresolvedProviderVariablesError> => {
|
||||
const prepared = prepareRuntimeModel(model, credential)
|
||||
if (prepared.unresolved.length > 0)
|
||||
return Effect.fail(
|
||||
new UnresolvedProviderVariablesError({
|
||||
providerID: model.providerID,
|
||||
modelID: model.id,
|
||||
variables: prepared.unresolved,
|
||||
}),
|
||||
)
|
||||
const resolved = prepared.model
|
||||
const packageName = Provider.packageName(resolved.package)
|
||||
const key = apiKey(resolved, credential)
|
||||
const configuration = credential?.type === "key" ? credential.configuration : undefined
|
||||
@@ -226,6 +248,26 @@ export const fromCatalogModel = (
|
||||
})
|
||||
}
|
||||
|
||||
function prepareRuntimeModel(model: Info, credential: Credential.Value | undefined) {
|
||||
const prepared = produce(model, (draft) => {
|
||||
if (draft.settings?.apiKey === "") delete draft.settings.apiKey
|
||||
if (credential?.type === "key" && credential.metadata !== undefined)
|
||||
draft.body = Provider.mergeOverlay(draft.body, credential.metadata)
|
||||
if (typeof draft.settings?.baseURL !== "string") return
|
||||
draft.settings.baseURL = draft.settings.baseURL.replace(/\$\{([^}]+)\}/g, (placeholder, name: string) => {
|
||||
return process.env[name] ?? placeholder
|
||||
})
|
||||
})
|
||||
const baseURL = prepared.settings?.baseURL
|
||||
const unresolved =
|
||||
typeof baseURL === "string"
|
||||
? Array.from(baseURL.matchAll(/\$\{([^}]+)\}/g), (match) => match[1]).filter(
|
||||
(name, index, names) => names.indexOf(name) === index,
|
||||
)
|
||||
: []
|
||||
return { model: prepared, unresolved }
|
||||
}
|
||||
|
||||
const nativeCredentialSettings = (specifier: string, credential: Credential.Value | undefined) => {
|
||||
if (!credential) return {}
|
||||
if (credential.type === "key") return { apiKey: credential.key }
|
||||
|
||||
@@ -59,36 +59,8 @@ function environmentNames(provider: ModelsDev.Snapshot) {
|
||||
}
|
||||
|
||||
function snapshots(data: readonly ModelsDev.Snapshot[]) {
|
||||
return (
|
||||
structuredClone(data)
|
||||
// These deprecated aliases are replaced by the canonical Azure and Google Vertex providers.
|
||||
.filter(
|
||||
(provider) => provider.info.id !== "azure-cognitive-services" && provider.info.id !== "google-vertex-anthropic",
|
||||
)
|
||||
.map((provider) => {
|
||||
const environment = new Set(provider.environment)
|
||||
return {
|
||||
...provider,
|
||||
info: {
|
||||
...provider.info,
|
||||
...(provider.info.settings ? { settings: resolveEnvironment(provider.info.settings, environment) } : {}),
|
||||
},
|
||||
models: provider.models.map((model) => ({
|
||||
...model,
|
||||
...(model.settings ? { settings: resolveEnvironment(model.settings, environment) } : {}),
|
||||
})),
|
||||
}
|
||||
})
|
||||
return structuredClone(data).filter(
|
||||
// These deprecated aliases are replaced by the canonical Azure and Google Vertex providers.
|
||||
(provider) => provider.info.id !== "azure-cognitive-services" && provider.info.id !== "google-vertex-anthropic",
|
||||
)
|
||||
}
|
||||
|
||||
function resolveEnvironment(settings: Readonly<Record<string, unknown>>, environment: Set<string>) {
|
||||
if (typeof settings.baseURL !== "string") return settings
|
||||
return {
|
||||
...settings,
|
||||
baseURL: settings.baseURL.replace(/\$\{([^}]+)\}/g, (value, name: string) => {
|
||||
if (!environment.has(name)) return value
|
||||
return process.env[name] ?? value
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -769,22 +769,27 @@ const layer = Layer.effect(
|
||||
{ location: current.location },
|
||||
)
|
||||
}),
|
||||
compact: Effect.fn("Session.compact")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
const inputID = input.id ?? SessionMessage.ID.create()
|
||||
const admitted = yield* SessionPending.admitCompaction(db, bus, {
|
||||
id: inputID,
|
||||
sessionID: input.sessionID,
|
||||
}).pipe(
|
||||
Effect.catchDefect((defect) =>
|
||||
defect instanceof SessionPending.LifecycleConflict
|
||||
? new CompactionConflictError({ sessionID: input.sessionID, inputID })
|
||||
: Effect.die(defect),
|
||||
),
|
||||
)
|
||||
yield* execution.wake(input.sessionID)
|
||||
return admitted
|
||||
}),
|
||||
compact: Effect.fn("Session.compact")((input) =>
|
||||
Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
if (session.revert) yield* SessionRevert.commit(session).pipe(Effect.provideService(Bus.Service, bus))
|
||||
const inputID = input.id ?? SessionMessage.ID.create()
|
||||
const admitted = yield* SessionPending.admitCompaction(db, bus, {
|
||||
id: inputID,
|
||||
sessionID: input.sessionID,
|
||||
}).pipe(
|
||||
Effect.catchDefect((defect) =>
|
||||
defect instanceof SessionPending.LifecycleConflict
|
||||
? new CompactionConflictError({ sessionID: input.sessionID, inputID })
|
||||
: Effect.die(defect),
|
||||
),
|
||||
)
|
||||
yield* execution.wake(input.sessionID)
|
||||
return admitted
|
||||
}),
|
||||
),
|
||||
),
|
||||
wait: Effect.fn("Session.wait")(function* (sessionID) {
|
||||
yield* result.get(sessionID)
|
||||
yield* execution.awaitIdle(sessionID)
|
||||
|
||||
@@ -34,6 +34,8 @@ export const VariantUnavailableError = ModelResolver.VariantUnavailableError
|
||||
export type VariantUnavailableError = ModelResolver.VariantUnavailableError
|
||||
export const UnsupportedPackageError = ModelResolver.UnsupportedPackageError
|
||||
export type UnsupportedPackageError = ModelResolver.UnsupportedPackageError
|
||||
export const UnresolvedProviderVariablesError = ModelResolver.UnresolvedProviderVariablesError
|
||||
export type UnresolvedProviderVariablesError = ModelResolver.UnresolvedProviderVariablesError
|
||||
|
||||
export type Error = ModelNotSelectedError | ModelUnavailableError | ModelResolver.Error
|
||||
export type Resolved = ModelResolver.Resolved
|
||||
|
||||
@@ -52,7 +52,8 @@ export function toSessionError(cause: unknown): SessionError.Error {
|
||||
cause instanceof SessionRunnerModel.ModelNotSelectedError ||
|
||||
cause instanceof SessionRunnerModel.ModelUnavailableError ||
|
||||
cause instanceof SessionRunnerModel.VariantUnavailableError ||
|
||||
cause instanceof SessionRunnerModel.UnsupportedPackageError
|
||||
cause instanceof SessionRunnerModel.UnsupportedPackageError ||
|
||||
cause instanceof SessionRunnerModel.UnresolvedProviderVariablesError
|
||||
)
|
||||
return { type: "provider.no-route", message: cause.message }
|
||||
if (cause instanceof Integration.AuthorizationError) return { type: "provider.auth", message: cause.message }
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { APICallError } from "@ai-sdk/provider"
|
||||
import type { LanguageModelV3, LanguageModelV3StreamPart } from "@ai-sdk/provider"
|
||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { SessionRunnerRetry } from "@opencode-ai/core/session/runner/retry"
|
||||
import { toSessionError } from "@opencode-ai/core/session/to-session-error"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { LLM, AIError, LLMEvent, Message } from "@opencode-ai/ai"
|
||||
import { LLM, AIError, LLMEvent, Message, isContextOverflowFailure } from "@opencode-ai/ai"
|
||||
import { LLMClient, RequestExecutor } from "@opencode-ai/ai/route"
|
||||
import { compileRequest } from "@opencode-ai/ai/route/client"
|
||||
import { expect } from "bun:test"
|
||||
@@ -337,3 +340,170 @@ it.effect("keeps malformed provider-executed AI SDK input terminal", () =>
|
||||
expect(error.message).toContain("Invalid JSON input for aisdk tool call web_search")
|
||||
}),
|
||||
)
|
||||
|
||||
const failingModel = (failure: unknown): LanguageModelV3 => ({
|
||||
specificationVersion: "v3",
|
||||
provider: "test",
|
||||
modelId: "test",
|
||||
supportedUrls: {},
|
||||
doGenerate: () => Promise.reject(new Error("Unexpected non-streaming request")),
|
||||
doStream: () => Promise.reject(failure),
|
||||
})
|
||||
|
||||
const streamFailure = (failure: unknown) =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* aisdk.hook.sdk((event) => {
|
||||
event.sdk = { languageModel: () => failingModel(failure) }
|
||||
})
|
||||
const resolved = yield* aisdk.model(model("test-ai-sdk"))
|
||||
return yield* LLMClient.generate(LLM.request({ model: resolved, prompt: "Hello" })).pipe(
|
||||
Effect.provide(client),
|
||||
Effect.flip,
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("preserves non-empty AI SDK error messages", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(new Error("Bad Request"))
|
||||
expect(error).toBeInstanceOf(AIError)
|
||||
expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "Bad Request" })
|
||||
}),
|
||||
)
|
||||
|
||||
const apiCallError = (input: Partial<ConstructorParameters<typeof APICallError>[0]>) =>
|
||||
new APICallError({
|
||||
message: "",
|
||||
url: "https://api.example.com/chat",
|
||||
requestBodyValues: { messages: [{ role: "user", content: "private prompt" }] },
|
||||
responseHeaders: { authorization: "Bearer secret-token" },
|
||||
...input,
|
||||
})
|
||||
|
||||
it.effect("derives status and code when the AI SDK error message is empty", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(
|
||||
apiCallError({
|
||||
statusCode: 404,
|
||||
responseBody: '{"error":{"message":"","code":"not_found"}}',
|
||||
data: { error: { message: "", code: "not_found" } },
|
||||
}),
|
||||
)
|
||||
expect(error.reason.message).toBe("Provider request failed with HTTP 404: not_found")
|
||||
expect(error.reason.message).not.toContain("secret-token")
|
||||
expect(error.reason.message).not.toContain("private prompt")
|
||||
const projected = toSessionError(error)
|
||||
expect(projected.type).toBe("provider.invalid-request")
|
||||
expect(projected.status).toBe(404)
|
||||
expect(projected.message).not.toBe("")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves redacted HTTP context on AI SDK call errors", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(
|
||||
apiCallError({
|
||||
statusCode: 404,
|
||||
responseBody: '{"error":{"message":"","code":"not_found"}}',
|
||||
}),
|
||||
)
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
||||
const http = "http" in error.reason ? error.reason.http : undefined
|
||||
expect(http?.request.url).toBe("https://api.example.com/chat")
|
||||
expect(http?.response?.status).toBe(404)
|
||||
expect(http?.response?.headers["authorization"]).toBe("<redacted>")
|
||||
expect(http?.body).toBe('{"error":{"message":"","code":"not_found"}}')
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies retryable AI SDK failures with retry-after details", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(
|
||||
apiCallError({
|
||||
statusCode: 429,
|
||||
responseHeaders: { "retry-after": "7" },
|
||||
}),
|
||||
)
|
||||
expect(error.reason).toMatchObject({ _tag: "RateLimit", retryAfterMs: 7000 })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies data-only AI SDK provider codes", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(
|
||||
apiCallError({
|
||||
statusCode: 400,
|
||||
data: { error: { code: "api_error" } },
|
||||
}),
|
||||
)
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status: 400 })
|
||||
expect(SessionRunnerRetry.isRetryable(error)).toBeTrue()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies data-only AI SDK authentication errors", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(
|
||||
apiCallError({
|
||||
statusCode: 400,
|
||||
data: { error: { code: "authentication_error" } },
|
||||
}),
|
||||
)
|
||||
expect(error.reason).toMatchObject({ _tag: "Authentication", kind: "invalid" })
|
||||
expect(SessionRunnerRetry.isRetryable(error)).toBeFalse()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("detects context overflow from data-only AI SDK errors", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(
|
||||
apiCallError({
|
||||
statusCode: 400,
|
||||
data: { error: { code: "context_length_exceeded" } },
|
||||
}),
|
||||
)
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest", classification: "context-overflow" })
|
||||
expect(isContextOverflowFailure(error)).toBeTrue()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retries status-less AI SDK transport failures", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(
|
||||
apiCallError({
|
||||
message: "Cannot connect to API: connection refused",
|
||||
isRetryable: true,
|
||||
}),
|
||||
)
|
||||
expect(error.reason).toMatchObject({ _tag: "Transport", kind: "AI_APICallError" })
|
||||
expect(SessionRunnerRetry.isRetryable(error)).toBeTrue()
|
||||
expect("http" in error.reason ? error.reason.http?.request.url : undefined).toBe("https://api.example.com/chat")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("prefers a structured provider message over the code fallback", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(
|
||||
apiCallError({
|
||||
statusCode: 404,
|
||||
data: { error: { code: "not_found" } },
|
||||
responseBody: '{"message":"The requested model does not exist"}',
|
||||
}),
|
||||
)
|
||||
expect(error.reason.message).toBe("The requested model does not exist")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("falls back to the status alone for malformed response bodies", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(
|
||||
apiCallError({
|
||||
statusCode: 502,
|
||||
isRetryable: false,
|
||||
responseBody: "<html>Bad Gateway</html>",
|
||||
}),
|
||||
)
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status: 502 })
|
||||
expect(error.reason.message).toBe("Provider request failed with HTTP 502")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -465,6 +465,7 @@ Use native v2 fields.`,
|
||||
},
|
||||
}),
|
||||
)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "reviewer.md"), "Review once"))
|
||||
yield* configTest.emitChange({ type: "create", path: path.join(directory, "reviewer.md") })
|
||||
|
||||
@@ -185,6 +185,7 @@ Review files`,
|
||||
},
|
||||
}),
|
||||
)
|
||||
yield* Effect.yieldNow
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "review.md"), "Review once"))
|
||||
yield* configTest.emitChange({ type: "create", path: path.join(directory, "review.md") })
|
||||
yield* configTest.emitChange({ type: "update", path: path.join(directory, "review.md") })
|
||||
|
||||
@@ -307,7 +307,7 @@ describe("Config", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("loads authenticated wellknown config before user configuration", () =>
|
||||
it.live("tolerates unavailable authenticated wellknown config and reloads it later", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) =>
|
||||
@@ -322,6 +322,7 @@ describe("Config", () => {
|
||||
})
|
||||
|
||||
const integrationID = Integration.ID.make("https://example.com")
|
||||
let available = false
|
||||
let key = "secret"
|
||||
const credentialNode = makeGlobalNode({
|
||||
service: Credential.Service,
|
||||
@@ -361,7 +362,10 @@ describe("Config", () => {
|
||||
refresh: () => Effect.succeed(false),
|
||||
add: () => Effect.die("unused Wellknown.add"),
|
||||
remove: () => Effect.die("unused Wellknown.remove"),
|
||||
resolve: (_entry, variables) => Effect.succeed([{ shell: variables.TOKEN }]),
|
||||
resolve: (_entry, variables) =>
|
||||
available
|
||||
? Effect.succeed([{ shell: variables.TOKEN }])
|
||||
: Effect.fail(new Error("expired credential")),
|
||||
}),
|
||||
),
|
||||
deps: [],
|
||||
@@ -374,11 +378,12 @@ describe("Config", () => {
|
||||
expect(Config.latest(initial, "shell")).toBe("project")
|
||||
expect(
|
||||
initial.flatMap((entry) => (entry.type === "document" && entry.info.shell ? [entry.info.shell] : [])),
|
||||
).toEqual(["secret", "global", "project"])
|
||||
).toEqual(["global", "project"])
|
||||
const updated = yield* bus
|
||||
.subscribe(Event.Updated)
|
||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
available = true
|
||||
key = "next"
|
||||
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID })
|
||||
expect(yield* Fiber.join(updated)).toHaveLength(1)
|
||||
|
||||
@@ -42,6 +42,27 @@ const model = (packageName: string | undefined, options: ModelOptions = {}) =>
|
||||
limit: options.limit ?? { context: 100, output: 20 },
|
||||
})
|
||||
|
||||
function withEnv<A, E, R>(variables: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = Object.fromEntries(Object.keys(variables).map((key) => [key, process.env[key]]))
|
||||
Object.entries(variables).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
})
|
||||
return previous
|
||||
}),
|
||||
effect,
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
Object.entries(previous).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
describe("ModelResolver", () => {
|
||||
it.effect("constructs native Azure requests with deployment IDs and projected resource URLs", () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -230,6 +251,42 @@ describe("ModelResolver", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("resolves provider URLs from environment without mutating the catalog model", () =>
|
||||
withEnv({ ACME_HOST: "api.acme.test" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = model(Provider.aisdk("@ai-sdk/openai-compatible"), {
|
||||
settings: { baseURL: "https://${ACME_HOST}/v1" },
|
||||
})
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(catalog)
|
||||
|
||||
expect(resolved.route.endpoint.baseURL).toBe("https://api.acme.test/v1")
|
||||
expect(catalog.settings?.baseURL).toBe("https://${ACME_HOST}/v1")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("rejects unresolved provider URL variables before route construction", () =>
|
||||
withEnv({ REQUIRED_HOST: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
const failure = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/openai-compatible"), {
|
||||
settings: { baseURL: "https://${REQUIRED_HOST}/${REQUIRED_PATH}/v1" },
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(failure).toMatchObject({
|
||||
_tag: "SessionRunnerModel.UnresolvedProviderVariablesError",
|
||||
providerID: "test-provider",
|
||||
modelID: "test-model",
|
||||
variables: ["REQUIRED_HOST", "REQUIRED_PATH"],
|
||||
})
|
||||
expect(failure.message).toBe(
|
||||
"Cannot initialize test-provider/test-model: REQUIRED_HOST, REQUIRED_PATH are required to resolve the provider endpoint",
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("overlays selected OpenAI variant settings and bodies", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = model(Provider.aisdk("@ai-sdk/openai"), {
|
||||
|
||||
@@ -242,7 +242,7 @@ describe("ModelsDevPlugin", () => {
|
||||
}).pipe(Effect.provide(models(path.join(import.meta.dir, "fixtures", "models-dev.json")))),
|
||||
)
|
||||
|
||||
it.effect("resolves declared environment variables in provider and model URLs", () =>
|
||||
it.effect("preserves provider and model URL templates in the catalog", () =>
|
||||
withEnv(
|
||||
{
|
||||
ACME_HOST: "api.acme.test",
|
||||
@@ -298,10 +298,10 @@ describe("ModelsDevPlugin", () => {
|
||||
)
|
||||
|
||||
expect((yield* catalog.provider.get(providerID))?.settings?.baseURL).toBe(
|
||||
"https://api.acme.test/${UNDECLARED_HOST}/v1",
|
||||
"https://${ACME_HOST}/${UNDECLARED_HOST}/v1",
|
||||
)
|
||||
expect((yield* catalog.model.get(providerID, modelID))?.settings?.baseURL).toBe(
|
||||
"https://api.acme.test/${ACME_MODEL_PATH}/v1",
|
||||
"https://${ACME_HOST}/${ACME_MODEL_PATH}/v1",
|
||||
)
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -118,4 +118,39 @@ describe("Session.compact", () => {
|
||||
expect((yield* session.context(created.id)).find((message) => message.id === first.id)).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("commits a staged revert before admitting manual compaction", () =>
|
||||
Effect.gen(function* () {
|
||||
requests = []
|
||||
const session = yield* Session.Service
|
||||
const bus = yield* Bus.Service
|
||||
const created = yield* session.create({ location })
|
||||
const boundary = SessionMessage.ID.create()
|
||||
yield* bus.publish(SessionEvent.InputAdmitted, {
|
||||
sessionID: created.id,
|
||||
inputID: boundary,
|
||||
input: {
|
||||
type: "user",
|
||||
data: { text: "Discard this turn." },
|
||||
delivery: "steer",
|
||||
},
|
||||
})
|
||||
yield* bus.publish(SessionEvent.InputPromoted, {
|
||||
sessionID: created.id,
|
||||
inputID: boundary,
|
||||
})
|
||||
yield* bus.publish(SessionEvent.RevertEvent.Staged, {
|
||||
sessionID: created.id,
|
||||
revert: { messageID: boundary, files: [] },
|
||||
})
|
||||
|
||||
const compacted = yield* session.compact({ sessionID: created.id })
|
||||
|
||||
expect((yield* session.get(created.id)).revert).toBeUndefined()
|
||||
expect(yield* session.context(created.id)).toEqual([])
|
||||
expect(yield* SessionPending.compaction((yield* Database.Service).db, created.id)).toMatchObject({
|
||||
id: compacted.id,
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -19,6 +19,9 @@ import {
|
||||
HttpResponseDetails,
|
||||
} from "@opencode-ai/ai"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { ID } from "@opencode-ai/core/model"
|
||||
import { ModelResolver } from "@opencode-ai/core/model-resolver"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import { toSessionError } from "@opencode-ai/core/session/to-session-error"
|
||||
import { SessionRunnerRetry } from "@opencode-ai/core/session/runner/retry"
|
||||
@@ -91,6 +94,19 @@ describe("toSessionError", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("preserves unresolved provider endpoint errors", () => {
|
||||
const error = new ModelResolver.UnresolvedProviderVariablesError({
|
||||
providerID: Provider.ID.make("cloudflare-workers-ai"),
|
||||
modelID: ID.make("model"),
|
||||
variables: ["CLOUDFLARE_ACCOUNT_ID"],
|
||||
})
|
||||
expect(toSessionError(error)).toEqual({
|
||||
type: "provider.no-route",
|
||||
message:
|
||||
"Cannot initialize cloudflare-workers-ai/model: CLOUDFLARE_ACCOUNT_ID is required to resolve the provider endpoint",
|
||||
})
|
||||
})
|
||||
|
||||
test("retries only rate limits, provider-internal failures, and transport failures", () => {
|
||||
const eligible = [
|
||||
llm(new RateLimitReason({ message: "rate" })),
|
||||
|
||||
@@ -286,27 +286,30 @@ describe("ShellTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("permissions compound commands separately", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
return withSession(tmp.path, (registry) =>
|
||||
executeTool(registry, call({ command: "printf one && printf two" }, "call-compound")),
|
||||
).pipe(
|
||||
Effect.andThen(
|
||||
Effect.sync(() => {
|
||||
expect(assertions).toHaveLength(1)
|
||||
expect(assertions[0]).toMatchObject({
|
||||
resources: ["printf one", "printf two"],
|
||||
save: ["printf *", "printf *"],
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||
),
|
||||
it.live(
|
||||
"permissions compound commands separately",
|
||||
() =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
return withSession(tmp.path, (registry) =>
|
||||
executeTool(registry, call({ command: "printf one && printf two" }, "call-compound")),
|
||||
).pipe(
|
||||
Effect.andThen(
|
||||
Effect.sync(() => {
|
||||
expect(assertions).toHaveLength(1)
|
||||
expect(assertions[0]).toMatchObject({
|
||||
resources: ["printf one", "printf two"],
|
||||
save: ["printf *", "printf *"],
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||
),
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
|
||||
it.live(
|
||||
|
||||
@@ -12,7 +12,7 @@ export function generateSyntax(theme: ResolvedThemeTokens, mode: Mode) {
|
||||
rule(["prompt"], theme.hue.accent[step]),
|
||||
rule(["extmark.file"], feedback.warning.default, { bold: true }),
|
||||
rule(["extmark.agent"], theme.categorical[0][step], { bold: true }),
|
||||
rule(["extmark.skill"], theme.categorical[1][step], { bold: true }),
|
||||
rule(["extmark.skill"], (theme.categorical[1] ?? theme.categorical[0])[step], { bold: true }),
|
||||
// V1 migration preserves its selected/inverse foreground in this action state.
|
||||
rule(["extmark.paste"], theme.text.action.primary.focused, {
|
||||
background: feedback.warning.default,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core"
|
||||
import { For, Show, createComputed, createEffect, createMemo, createSignal, untrack } from "solid-js"
|
||||
import { For, Show, createComputed, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { useConfig } from "../config"
|
||||
import { useSessionTabs } from "../context/session-tabs"
|
||||
@@ -16,16 +16,19 @@ import {
|
||||
type SessionTab,
|
||||
type SessionTabUnread,
|
||||
} from "../context/session-tabs-model"
|
||||
import { createAnimatable, spring } from "../ui/animation"
|
||||
import { createAnimatable, spring, tween } from "../ui/animation"
|
||||
import { Locale } from "../util/locale"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
import { TabPulse, unreadGlowIntensity } from "./tab-pulse"
|
||||
import { tint } from "../theme/color"
|
||||
import { SESSION_SIDEBAR_WIDTH } from "../ui/layout"
|
||||
import { projectName } from "../util/project"
|
||||
import { marqueeText } from "../util/marquee"
|
||||
|
||||
// A long title fades out over its last cells instead of cutting hard.
|
||||
const FADE_WIDTH = 4
|
||||
const MARQUEE_DELAY = 600
|
||||
const MARQUEE_INTERVAL = 100
|
||||
|
||||
type ContextController = ReturnType<typeof useSessionTabs>
|
||||
export type SessionTabsStatus = Omit<ReturnType<ContextController["status"]>, "unread"> & {
|
||||
@@ -46,6 +49,41 @@ const NEW_SESSION_TAB: SessionTab = { sessionID: "new", title: NEW_SESSION_TAB_T
|
||||
const glowTextColor = (base: RGBA, glow: RGBA, index: number, width: number) =>
|
||||
tint(base, glow, 0.12 * unreadGlowIntensity(index, width))
|
||||
|
||||
function fadeTitleColor(color: RGBA, background: RGBA, index: number, length: number, leading: number) {
|
||||
const fade = (position: number) => (position <= 0 ? 0 : 0.2 + 0.72 * ((position - 1) / Math.max(1, FADE_WIDTH - 1)))
|
||||
const start = index < FADE_WIDTH ? FADE_WIDTH - index : 0
|
||||
const end = index - (length - FADE_WIDTH) + 1
|
||||
const opacity = Math.max(fade(start) * leading, fade(end))
|
||||
return opacity === 0 ? color : tint(color, background, opacity)
|
||||
}
|
||||
|
||||
function createMarquee(hovered: () => string | undefined, animations: () => boolean) {
|
||||
const [offset, setOffset] = createSignal(0)
|
||||
const leading = createAnimatable({ opacity: 0 }, { enabled: animations, transition: tween({ duration: 0.25 }) })
|
||||
|
||||
createEffect(() => {
|
||||
if (!hovered()) {
|
||||
setOffset(0)
|
||||
leading.jump({ opacity: 0 })
|
||||
return
|
||||
}
|
||||
setOffset(0)
|
||||
leading.jump({ opacity: 0 })
|
||||
let interval: ReturnType<typeof setInterval> | undefined
|
||||
const delay = setTimeout(() => {
|
||||
setOffset(1)
|
||||
leading.animate({ opacity: 1 })
|
||||
interval = setInterval(() => setOffset((value) => value + 1), MARQUEE_INTERVAL)
|
||||
}, MARQUEE_DELAY)
|
||||
onCleanup(() => {
|
||||
clearTimeout(delay)
|
||||
if (interval) clearInterval(interval)
|
||||
})
|
||||
})
|
||||
|
||||
return { offset, leading: () => leading.value().opacity }
|
||||
}
|
||||
|
||||
export function SessionTabs(
|
||||
props: { controller?: SessionTabsController; animations?: boolean; orientation?: "horizontal" | "vertical" } = {},
|
||||
) {
|
||||
@@ -69,6 +107,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
const separatorUpperPulseColor = createMemo(() => tint(theme.background.default, theme.text.default, 0.04))
|
||||
const separatorLowerPulseColor = createMemo(() => tint(theme.background.default, theme.text.default, 0.05))
|
||||
const [hovered, setHovered] = createSignal<string>()
|
||||
const marquee = createMarquee(hovered, animations)
|
||||
const [dragging, setDragging] = createSignal<string>()
|
||||
const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>()
|
||||
const newTab = () => tabs.newTab?.() ?? false
|
||||
@@ -144,7 +183,12 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
const numberWidth = () => 2
|
||||
const titleWidth = () => Math.max(1, width() - numberWidth() - 2 - (hovered() === tab.sessionID ? 1 : 0))
|
||||
const title = () => tab.title ?? "Untitled session"
|
||||
const visibleTitle = createMemo(() => Locale.takeWidth(title(), titleWidth()))
|
||||
const scrolling = () => hovered() === tab.sessionID && marquee.offset() > 0
|
||||
const visibleTitle = createMemo(() =>
|
||||
scrolling()
|
||||
? marqueeText(title(), titleWidth(), marquee.offset())
|
||||
: Locale.takeWidth(title(), titleWidth()),
|
||||
)
|
||||
const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle()))
|
||||
const titleFades = createMemo(() => stringWidth(title()) >= titleWidth() && titleWidth() > FADE_WIDTH)
|
||||
const detail = createMemo(() => {
|
||||
@@ -206,9 +250,15 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
const color = glows()
|
||||
? glowTextColor(foreground(), glowColor(), 1 + numberWidth() + index, width())
|
||||
: foreground()
|
||||
if (!titleFades() || index < visibleTitleParts().length - FADE_WIDTH) return color
|
||||
const position = index - (visibleTitleParts().length - FADE_WIDTH)
|
||||
return tint(color, pulseBackground(), 0.2 + 0.72 * (position / Math.max(1, FADE_WIDTH - 1)))
|
||||
return titleFades()
|
||||
? fadeTitleColor(
|
||||
color,
|
||||
pulseBackground(),
|
||||
index,
|
||||
visibleTitleParts().length,
|
||||
scrolling() ? marquee.leading() : 0,
|
||||
)
|
||||
: color
|
||||
}
|
||||
const release = () => {
|
||||
setDragging(undefined)
|
||||
@@ -225,7 +275,10 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
backgroundColor={background()}
|
||||
onMouseOver={() => setHovered(tab.sessionID)}
|
||||
onMouseOut={() => setHovered(undefined)}
|
||||
onMouseDown={() => setDragging(tab.sessionID)}
|
||||
onMouseDown={() => {
|
||||
setHovered(tab.sessionID)
|
||||
setDragging(tab.sessionID)
|
||||
}}
|
||||
onMouseUp={release}
|
||||
onMouseDrag={(event) => {
|
||||
if (!rail || tab === NEW_SESSION_TAB) return
|
||||
@@ -382,6 +435,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
const config = useConfig().data
|
||||
const animations = () => props.animations ?? config.animations ?? true
|
||||
const [hovered, setHovered] = createSignal<string>()
|
||||
const marquee = createMarquee(hovered, animations)
|
||||
const [dragging, setDragging] = createSignal<string>()
|
||||
// A drag reorders a local preview and persists one move on release instead of writing
|
||||
// per slot crossing; the preview holds after release until the store reflects the move,
|
||||
@@ -561,7 +615,12 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
// Hovering reveals the close mark, so the title's right bound shifts left of it.
|
||||
const availableTitleWidth = () =>
|
||||
Math.max(1, width() - 1 - numberWidth() - (hovered() === tab.sessionID ? 2 : 0))
|
||||
const visibleTitle = createMemo(() => Locale.takeWidth(title(), availableTitleWidth()))
|
||||
const scrolling = () => hovered() === tab.sessionID && marquee.offset() > 0
|
||||
const visibleTitle = createMemo(() =>
|
||||
scrolling()
|
||||
? marqueeText(title(), availableTitleWidth(), marquee.offset())
|
||||
: Locale.takeWidth(title(), availableTitleWidth()),
|
||||
)
|
||||
const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle()))
|
||||
const titleFades = createMemo(
|
||||
() => stringWidth(title()) >= availableTitleWidth() && availableTitleWidth() > FADE_WIDTH,
|
||||
@@ -575,9 +634,15 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
const characterColor = (index: number) => {
|
||||
const base = foreground()
|
||||
const color = glows() ? glowTextColor(base, glowColor(), 1 + numberWidth() + index, width()) : base
|
||||
if (!titleFades() || index < visibleTitleParts().length - FADE_WIDTH) return color
|
||||
const position = index - (visibleTitleParts().length - FADE_WIDTH)
|
||||
return tint(color, background(), 0.2 + 0.72 * (position / Math.max(1, FADE_WIDTH - 1)))
|
||||
return titleFades()
|
||||
? fadeTitleColor(
|
||||
color,
|
||||
background(),
|
||||
index,
|
||||
visibleTitleParts().length,
|
||||
scrolling() ? marquee.leading() : 0,
|
||||
)
|
||||
: color
|
||||
}
|
||||
// The running sweep's level under the number cell, reported by the pulse renderable.
|
||||
const [sweepLevel, setSweepLevel] = createSignal(0)
|
||||
@@ -611,7 +676,10 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
backgroundColor={background()}
|
||||
onMouseOver={() => setHovered(tab.sessionID)}
|
||||
onMouseOut={() => setHovered(undefined)}
|
||||
onMouseDown={() => setDragging(tab.sessionID)}
|
||||
onMouseDown={() => {
|
||||
setHovered(tab.sessionID)
|
||||
setDragging(tab.sessionID)
|
||||
}}
|
||||
onMouseUp={release}
|
||||
onMouseDrag={(event) => {
|
||||
if (tab === NEW_SESSION_TAB) return
|
||||
|
||||
@@ -437,7 +437,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
if (!m) return
|
||||
if (route.data.type === "session") {
|
||||
setSessionDraft(route.data.sessionID, { ...m, variant: normalizeModelVariant(value) })
|
||||
return
|
||||
}
|
||||
setPreferences("variant", modelPreferenceKey(m), normalizeModelVariant(value))
|
||||
savePreferences()
|
||||
|
||||
@@ -89,6 +89,7 @@ import {
|
||||
createSessionRows,
|
||||
messageBoundaryIDs,
|
||||
resolvePart,
|
||||
turnDuration,
|
||||
type CacheUsage,
|
||||
type PartRef,
|
||||
type SessionRow,
|
||||
@@ -102,11 +103,13 @@ import { useSessionTabs } from "../../context/session-tabs"
|
||||
import { createSingleFlight } from "../../util/single-flight"
|
||||
import type { SessionPending } from "@opencode-ai/schema/session-pending"
|
||||
import { generateThinkingSyntax } from "./thinking-syntax"
|
||||
import { createDelayedPresence } from "../../util/delayed-presence"
|
||||
|
||||
addDefaultParsers(parsers.parsers)
|
||||
|
||||
// Exclude temporary bottom space when measuring the real transcript height.
|
||||
const NAVIGATION_SLACK_ID = "session-navigation-slack"
|
||||
const BACKGROUND_TOOL_HINT_DELAY = 1_000
|
||||
|
||||
// Tail-first transcript mounting: rows mounted with the session, then backfill cadence.
|
||||
// The tail comfortably overfills a tall viewport; backfill drains a 200-message transcript
|
||||
@@ -1326,18 +1329,20 @@ function turnTokenToolSummary(tool: SessionMessageAssistantTool) {
|
||||
function BackgroundToolHint(props: { messages: SessionMessageInfo[] }) {
|
||||
const theme = useTheme()
|
||||
const shortcut = Keymap.useShortcut("session.background")
|
||||
const visible = createMemo(() => {
|
||||
const running = createMemo(() => {
|
||||
if (!shortcut()) return
|
||||
const current = props.messages.findLast(
|
||||
(message): message is SessionMessageAssistant => message.type === "assistant" && !message.time.completed,
|
||||
)
|
||||
return (
|
||||
current?.content.some((part) => {
|
||||
if (part.type !== "tool" || part.state.status !== "running") return false
|
||||
const display = toolDisplay(part.name)
|
||||
return display === "shell" || display === "subagent"
|
||||
}) ?? false
|
||||
)
|
||||
const part = current?.content.find((part): part is SessionMessageAssistantTool => {
|
||||
if (part.type !== "tool" || part.state.status !== "running") return false
|
||||
const name = canonicalToolName(part.name)
|
||||
return name === "shell" || name === "subagent"
|
||||
})
|
||||
if (!current || !part) return
|
||||
return `${current.id}:${part.id}`
|
||||
})
|
||||
const visible = createDelayedPresence(running, BACKGROUND_TOOL_HINT_DELAY)
|
||||
return (
|
||||
<Show when={visible() && shortcut()}>
|
||||
{(value) => (
|
||||
@@ -1597,6 +1602,7 @@ function SessionGroupView(props: {
|
||||
|
||||
function AssistantFooter(props: { message: SessionMessageAssistant }) {
|
||||
const ctx = use()
|
||||
const data = useData()
|
||||
const local = useLocal()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = useTheme("elevated")
|
||||
@@ -1607,9 +1613,7 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) {
|
||||
.find((model) => model.providerID === props.message.model.providerID && model.id === props.message.model.id)
|
||||
?.name ?? `${props.message.model.providerID}/${props.message.model.id}`,
|
||||
)
|
||||
const duration = createMemo(() =>
|
||||
props.message.time.completed ? props.message.time.completed - props.message.time.created : 0,
|
||||
)
|
||||
const duration = createMemo(() => turnDuration(props.message, data.session.message.list(ctx.sessionID)))
|
||||
const interrupted = createMemo(() => props.message.error?.message === "Step interrupted")
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -348,6 +348,15 @@ export function cacheReuseDrop(previous: CacheUsage | undefined, current: CacheU
|
||||
return drop > 0 ? drop : undefined
|
||||
}
|
||||
|
||||
export function turnDuration(message: SessionMessageAssistant, messages: SessionMessageInfo[]) {
|
||||
if (message.time.completed === undefined) return 0
|
||||
const index = messages.findIndex((item) => item.id === message.id)
|
||||
const input = messages
|
||||
.slice(0, index === -1 ? messages.length : index)
|
||||
.findLast((item) => item.type === "user" || item.type === "synthetic")
|
||||
return Math.max(0, message.time.completed - (input?.time.created ?? message.time.created))
|
||||
}
|
||||
|
||||
function hasTokenUsage(
|
||||
message: SessionMessageAssistant,
|
||||
): message is SessionMessageAssistant & { tokens: NonNullable<SessionMessageAssistant["tokens"]> } {
|
||||
|
||||
@@ -212,7 +212,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
const flatten = createMemo(() => props.flat && store.filter.length > 0)
|
||||
|
||||
const grouped = createMemo<[string, DialogSelectOption<T>[]][]>(() => {
|
||||
if (flatten()) return [["", filtered()]]
|
||||
if (flatten()) return filtered().length ? [["", filtered()]] : []
|
||||
const result = pipe(
|
||||
filtered(),
|
||||
groupBy((x) => x.category ?? ""),
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js"
|
||||
|
||||
export function createDelayedPresence<T>(source: Accessor<T | undefined>, delay: number) {
|
||||
const [visible, setVisible] = createSignal(false)
|
||||
|
||||
createEffect(() => {
|
||||
const value = source()
|
||||
setVisible(false)
|
||||
if (value === undefined) return
|
||||
|
||||
const timer = setTimeout(() => setVisible(true), delay)
|
||||
onCleanup(() => clearTimeout(timer))
|
||||
})
|
||||
|
||||
return visible
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Locale } from "./locale"
|
||||
import { stringWidth } from "./string-width"
|
||||
|
||||
const GAP = " "
|
||||
|
||||
export function marqueeText(value: string, width: number, offset: number) {
|
||||
if (width <= 0) return ""
|
||||
if (stringWidth(value) <= width || offset <= 0) return Locale.takeWidth(value, width)
|
||||
|
||||
const loop = value + GAP
|
||||
const cursor = offset % stringWidth(loop)
|
||||
const segments = Locale.graphemes(loop + loop)
|
||||
const start = segments.reduce(
|
||||
(state, segment, index) =>
|
||||
state.width >= cursor ? state : { index: index + 1, width: state.width + stringWidth(segment) },
|
||||
{ index: 0, width: 0 },
|
||||
).index
|
||||
return Locale.takeWidth(segments.slice(start).join(""), width)
|
||||
}
|
||||
@@ -87,6 +87,7 @@ async function mountSelect(
|
||||
initial: DialogSelectOption<string>[],
|
||||
current?: string,
|
||||
focusCurrent?: boolean,
|
||||
select?: { flat?: boolean },
|
||||
) {
|
||||
const state = path.join(root, "state")
|
||||
await mkdir(state, { recursive: true })
|
||||
@@ -124,6 +125,7 @@ async function mountSelect(
|
||||
options={options()}
|
||||
current={current}
|
||||
focusCurrent={focusCurrent}
|
||||
flat={select?.flat}
|
||||
onMove={(option) => moved.push(option.value)}
|
||||
onSelect={(option) => selected.push(option.value)}
|
||||
/>
|
||||
@@ -367,6 +369,34 @@ test("keeps the current option selected when options reorder", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("shows no-match and still closes after a flat filter goes empty", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const select = await mountSelect(
|
||||
tmp.path,
|
||||
[
|
||||
{ title: "models.dev", value: "models.dev", category: "Projects" },
|
||||
{ title: "opencode2", value: "opencode2", category: "Projects" },
|
||||
],
|
||||
undefined,
|
||||
undefined,
|
||||
{ flat: true },
|
||||
)
|
||||
|
||||
try {
|
||||
await select.app.waitForFrame((frame) => frame.includes("models.dev"))
|
||||
await select.app.mockInput.typeText("models")
|
||||
await select.app.waitForFrame((frame) => frame.includes("models.dev") && !frame.includes("opencode2"))
|
||||
await select.app.mockInput.typeText(" missing")
|
||||
await select.app.waitForFrame((frame) => frame.includes("No results found"))
|
||||
expect(select.app.captureCharFrame()).not.toContain("models.dev")
|
||||
|
||||
select.app.mockInput.pressEscape()
|
||||
await select.app.waitForFrame((frame) => !frame.includes("Mutable options") && !frame.includes("No results found"))
|
||||
} finally {
|
||||
select.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps the first row selected when current is only a marker", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const project = { title: "project", value: "project" }
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
|
||||
import { cacheReuseDrop, messageBoundaryIDs, reduceSessionRows } from "../../../src/routes/session/rows"
|
||||
import { cacheReuseDrop, messageBoundaryIDs, reduceSessionRows, turnDuration } from "../../../src/routes/session/rows"
|
||||
|
||||
test("measures turn duration from the user prompt across assistant steps", () => {
|
||||
const first = assistant("assistant-1", [])
|
||||
first.time = { created: 8_000, completed: 11_000 }
|
||||
const final = assistant("assistant-2", [])
|
||||
final.time = { created: 27_000, completed: 30_000 }
|
||||
const messages: SessionMessageInfo[] = [
|
||||
{ type: "user", id: "user-1", text: "Question", time: { created: 1_000 } },
|
||||
first,
|
||||
final,
|
||||
]
|
||||
|
||||
expect(turnDuration(final, messages)).toBe(29_000)
|
||||
})
|
||||
|
||||
test("filters OpenAI cache quantization from cache reuse drops", () => {
|
||||
const openai = { id: "gpt", providerID: "openai" }
|
||||
|
||||
@@ -2,6 +2,7 @@ import { expect, test } from "bun:test"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import {
|
||||
DEFAULT_THEME,
|
||||
generateSyntax,
|
||||
resolveTheme,
|
||||
resolveThemeDocument,
|
||||
selectTheme,
|
||||
@@ -42,6 +43,14 @@ test("validates and resolves categorical hues in configured order", () => {
|
||||
expect(() => resolveSource({ version: 2, light: { categorical: ["magenta"] } }, "light")).toThrow("Invalid theme")
|
||||
})
|
||||
|
||||
test("generates syntax with one categorical hue", () => {
|
||||
const theme = resolveSource({ version: 2, light: { categorical: ["red"] } }, "light")
|
||||
const syntax = generateSyntax(theme, "light")
|
||||
|
||||
expect(syntax.getStyleId("extmark.skill")).not.toBeNull()
|
||||
syntax.destroy()
|
||||
})
|
||||
|
||||
test("uses the default categorical order for direct definitions", () => {
|
||||
const theme = resolveTheme({ ...light, categorical: undefined })
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { expect, jest, test } from "bun:test"
|
||||
import { createRoot, createSignal } from "solid-js"
|
||||
import { createDelayedPresence } from "../../src/util/delayed-presence"
|
||||
|
||||
test("shows only after the same value remains present for the delay", async () => {
|
||||
jest.useFakeTimers()
|
||||
const scope = createRoot((dispose) => {
|
||||
const [value, setValue] = createSignal<string>()
|
||||
return { dispose, setValue, visible: createDelayedPresence(value, 1_000) }
|
||||
})
|
||||
|
||||
try {
|
||||
scope.setValue("first")
|
||||
await Promise.resolve()
|
||||
jest.advanceTimersByTime(500)
|
||||
expect(scope.visible()).toBe(false)
|
||||
|
||||
scope.setValue("second")
|
||||
await Promise.resolve()
|
||||
jest.advanceTimersByTime(999)
|
||||
expect(scope.visible()).toBe(false)
|
||||
jest.advanceTimersByTime(1)
|
||||
expect(scope.visible()).toBe(true)
|
||||
} finally {
|
||||
scope.dispose()
|
||||
jest.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
test("cancels the delay when the value disappears or the owner is disposed", async () => {
|
||||
jest.useFakeTimers()
|
||||
const scope = createRoot((dispose) => {
|
||||
const [value, setValue] = createSignal<string>()
|
||||
return { dispose, setValue, visible: createDelayedPresence(value, 1_000) }
|
||||
})
|
||||
|
||||
try {
|
||||
scope.setValue("running")
|
||||
await Promise.resolve()
|
||||
jest.advanceTimersByTime(500)
|
||||
scope.setValue(undefined)
|
||||
await Promise.resolve()
|
||||
jest.advanceTimersByTime(1_000)
|
||||
expect(scope.visible()).toBe(false)
|
||||
|
||||
scope.setValue("running")
|
||||
await Promise.resolve()
|
||||
scope.dispose()
|
||||
jest.advanceTimersByTime(1_000)
|
||||
expect(scope.visible()).toBe(false)
|
||||
} finally {
|
||||
scope.dispose()
|
||||
jest.useRealTimers()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { marqueeText } from "../../src/util/marquee"
|
||||
import { stringWidth } from "../../src/util/string-width"
|
||||
|
||||
describe("marquee text", () => {
|
||||
test("keeps short text stationary", () => {
|
||||
expect(marqueeText("Short", 10, 8)).toBe("Short")
|
||||
})
|
||||
|
||||
test("starts clipped and scrolls through a long title", () => {
|
||||
expect(marqueeText("A long session title", 8, 0)).toBe("A long s")
|
||||
expect(marqueeText("A long session title", 8, 2)).toBe("long ses")
|
||||
expect(marqueeText("A long session title", 8, 15)).toBe("title ")
|
||||
expect(marqueeText("A long session title", 8, 20)).toBe(" A lo")
|
||||
})
|
||||
|
||||
test("clips wide graphemes to terminal cells", () => {
|
||||
const frame = marqueeText("Plan 🧭 the release", 8, 5)
|
||||
expect(frame).toBe("🧭 the r")
|
||||
expect(stringWidth(frame)).toBeLessThanOrEqual(8)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user