mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-03 00:36:20 -04:00
Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1eec3e640a | |||
| 4dc5ba66d8 | |||
| a21598901d | |||
| 3a7f507135 | |||
| b1f86ee72b | |||
| 003b22edda | |||
| d5f6c088f0 | |||
| e872bd3c8f | |||
| 85ea15e56d | |||
| 6a543791b6 | |||
| 47c6840752 | |||
| 14e4c3dfd7 | |||
| ee6577936d | |||
| 8285a18f18 | |||
| cfcd8de62a | |||
| c2db18c59b | |||
| 70393a1d18 | |||
| eab59ea021 | |||
| 4569659b03 | |||
| b5afaed053 | |||
| 97f5fb3399 | |||
| 18843682ad | |||
| 9eb9c1db7d | |||
| 8846336e11 | |||
| f6b5f6b6b3 | |||
| 02df1e9737 | |||
| 5b84e39f6d | |||
| 6d3f84b4ff | |||
| 255e9bbf06 | |||
| 25b6a179e2 | |||
| 80874c241f | |||
| f99469bdf8 | |||
| fe0eb4ea8f | |||
| a484f87680 | |||
| 3b6ab392ac | |||
| 6c567dc745 | |||
| 92567700f6 | |||
| 90c84639e3 |
@@ -88,7 +88,6 @@
|
|||||||
"@tanstack/solid-query": "5.91.4",
|
"@tanstack/solid-query": "5.91.4",
|
||||||
"@tanstack/solid-virtual": "catalog:",
|
"@tanstack/solid-virtual": "catalog:",
|
||||||
"@thisbeyond/solid-dnd": "0.7.5",
|
"@thisbeyond/solid-dnd": "0.7.5",
|
||||||
"diff": "catalog:",
|
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"fuzzysort": "catalog:",
|
"fuzzysort": "catalog:",
|
||||||
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
|
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
|
||||||
@@ -384,7 +383,6 @@
|
|||||||
"@opencode-ai/ai": "workspace:*",
|
"@opencode-ai/ai": "workspace:*",
|
||||||
"@opencode-ai/codemode": "workspace:*",
|
"@opencode-ai/codemode": "workspace:*",
|
||||||
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
|
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
|
||||||
"@opencode-ai/effect-sqlite-node": "workspace:*",
|
|
||||||
"@opencode-ai/plugin": "workspace:*",
|
"@opencode-ai/plugin": "workspace:*",
|
||||||
"@opencode-ai/schema": "workspace:*",
|
"@opencode-ai/schema": "workspace:*",
|
||||||
"@opencode-ai/util": "workspace:*",
|
"@opencode-ai/util": "workspace:*",
|
||||||
@@ -405,7 +403,6 @@
|
|||||||
"ignore": "7.0.5",
|
"ignore": "7.0.5",
|
||||||
"immer": "11.1.4",
|
"immer": "11.1.4",
|
||||||
"jsonc-parser": "3.3.1",
|
"jsonc-parser": "3.3.1",
|
||||||
"semver": "^7.6.3",
|
|
||||||
"tree-sitter-bash": "0.25.0",
|
"tree-sitter-bash": "0.25.0",
|
||||||
"tree-sitter-powershell": "0.25.10",
|
"tree-sitter-powershell": "0.25.10",
|
||||||
"turndown": "7.2.0",
|
"turndown": "7.2.0",
|
||||||
@@ -427,7 +424,6 @@
|
|||||||
"@tsconfig/bun": "catalog:",
|
"@tsconfig/bun": "catalog:",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"@types/semver": "catalog:",
|
|
||||||
"@types/turndown": "5.0.5",
|
"@types/turndown": "5.0.5",
|
||||||
"@types/which": "3.0.4",
|
"@types/which": "3.0.4",
|
||||||
"drizzle-kit": "catalog:",
|
"drizzle-kit": "catalog:",
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
|
|
||||||
const patterns = [
|
const patterns = [
|
||||||
/prompt is too long/i,
|
/prompt is too long/i,
|
||||||
/request_too_large/i,
|
|
||||||
/input is too long for requested model/i,
|
/input is too long for requested model/i,
|
||||||
/exceeds the context window/i,
|
/exceeds the context window/i,
|
||||||
/exceeds (?:the )?(?:model'?s )?maximum context length(?: of [\d,]+ tokens?|\s*\([\d,]+\))/i,
|
/exceeds (?:the )?(?:model'?s )?maximum context length(?: of [\d,]+ tokens?|\s*\([\d,]+\))/i,
|
||||||
@@ -33,7 +32,6 @@ const patterns = [
|
|||||||
/context window exceeds limit/i,
|
/context window exceeds limit/i,
|
||||||
/exceeded model token limit/i,
|
/exceeded model token limit/i,
|
||||||
/context[_ ]length[_ ]exceeded/i,
|
/context[_ ]length[_ ]exceeded/i,
|
||||||
/request entity too large/i,
|
|
||||||
/context length is only \d+ tokens/i,
|
/context length is only \d+ tokens/i,
|
||||||
/input length.*exceeds.*context length/i,
|
/input length.*exceeds.*context length/i,
|
||||||
/prompt too long; exceeded (?:max )?context length/i,
|
/prompt too long; exceeded (?:max )?context length/i,
|
||||||
@@ -44,11 +42,15 @@ const patterns = [
|
|||||||
/token limit exceeded/i,
|
/token limit exceeded/i,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const payloadPatterns = [/request_too_large/i, /request entity too large/i, /payload too large/i, /request too large/i]
|
||||||
|
|
||||||
const exclusions = [/^(throttling error|service unavailable):/i, /rate limit/i, /too many requests/i]
|
const exclusions = [/^(throttling error|service unavailable):/i, /rate limit/i, /too many requests/i]
|
||||||
|
|
||||||
export const isContextOverflow = (message: string) =>
|
export const isContextOverflow = (message: string) =>
|
||||||
!exclusions.some((pattern) => pattern.test(message)) &&
|
!exclusions.some((pattern) => pattern.test(message)) &&
|
||||||
(patterns.some((pattern) => pattern.test(message)) || /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message))
|
(patterns.some((pattern) => pattern.test(message)) || /^400\s*(status code)?\s*\(no body\)/i.test(message))
|
||||||
|
|
||||||
|
export const isPayloadTooLarge = (message: string) => payloadPatterns.some((pattern) => pattern.test(message))
|
||||||
|
|
||||||
export const isContextOverflowFailure = (failure: unknown) =>
|
export const isContextOverflowFailure = (failure: unknown) =>
|
||||||
failure instanceof LLMError
|
failure instanceof LLMError
|
||||||
@@ -100,6 +102,8 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
|
|||||||
isContextOverflow(text))
|
isContextOverflow(text))
|
||||||
)
|
)
|
||||||
return new InvalidRequestReason({ ...common, classification: "context-overflow" })
|
return new InvalidRequestReason({ ...common, classification: "context-overflow" })
|
||||||
|
if (input.status === 413 || isPayloadTooLarge(text))
|
||||||
|
return new InvalidRequestReason({ ...common, classification: "payload-too-large" })
|
||||||
if (CONTENT_POLICY_TEXT.test(text)) return new ContentPolicyReason(common)
|
if (CONTENT_POLICY_TEXT.test(text)) return new ContentPolicyReason(common)
|
||||||
if (codes.some((code) => QUOTA_CODES.has(code)) || (input.status === 429 && QUOTA_TEXT.test(text)))
|
if (codes.some((code) => QUOTA_CODES.has(code)) || (input.status === 429 && QUOTA_TEXT.test(text)))
|
||||||
return new QuotaExceededReason(common)
|
return new QuotaExceededReason(common)
|
||||||
@@ -142,12 +146,7 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
|
|||||||
retryAfterMs: input.retryAfterMs,
|
retryAfterMs: input.retryAfterMs,
|
||||||
})
|
})
|
||||||
if (codes.some((code) => INVALID_REQUEST_CODES.has(code))) return new InvalidRequestReason(common)
|
if (codes.some((code) => INVALID_REQUEST_CODES.has(code))) return new InvalidRequestReason(common)
|
||||||
if (
|
if (input.status === 400 || input.status === 404 || input.status === 413 || input.status === 422)
|
||||||
input.status === 400 ||
|
|
||||||
input.status === 404 ||
|
|
||||||
input.status === 413 ||
|
|
||||||
input.status === 422
|
|
||||||
)
|
|
||||||
return new InvalidRequestReason(common)
|
return new InvalidRequestReason(common)
|
||||||
return new UnknownProviderReason({ ...common, status: input.status })
|
return new UnknownProviderReason({ ...common, status: input.status })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Endpoint, type EndpointPatch } from "./endpoint"
|
|||||||
import { RequestExecutor } from "./executor"
|
import { RequestExecutor } from "./executor"
|
||||||
import { Framing } from "./framing"
|
import { Framing } from "./framing"
|
||||||
import { HttpTransport } from "./transport"
|
import { HttpTransport } from "./transport"
|
||||||
import type { HttpRequestTransform, Transport, TransportRuntime } from "./transport"
|
import type { HttpMiddleware, Transport, TransportRuntime } from "./transport"
|
||||||
import { WebSocketExecutor } from "./transport"
|
import { WebSocketExecutor } from "./transport"
|
||||||
import type { Protocol } from "./protocol"
|
import type { Protocol } from "./protocol"
|
||||||
import { applyCachePolicy } from "../cache-policy"
|
import { applyCachePolicy } from "../cache-policy"
|
||||||
@@ -96,7 +96,10 @@ export interface RoutePatch<Body, Prepared> extends RouteDefaultsInput {
|
|||||||
|
|
||||||
type RouteMappedModelInput = RouteModelInput | RouteRoutedModelInput
|
type RouteMappedModelInput = RouteModelInput | RouteRoutedModelInput
|
||||||
|
|
||||||
const makeRouteModel = <Options extends ProviderOptions = ProviderOptions>(route: AnyRoute, mapped: RouteMappedModelInput) => {
|
const makeRouteModel = <Options extends ProviderOptions = ProviderOptions>(
|
||||||
|
route: AnyRoute,
|
||||||
|
mapped: RouteMappedModelInput,
|
||||||
|
) => {
|
||||||
const provider = route.provider ?? ("provider" in mapped ? mapped.provider : undefined)
|
const provider = route.provider ?? ("provider" in mapped ? mapped.provider : undefined)
|
||||||
if (!provider) throw new Error(`Route.model(${route.id}) requires a provider`)
|
if (!provider) throw new Error(`Route.model(${route.id}) requires a provider`)
|
||||||
if (!endpointBaseURL(route.endpoint))
|
if (!endpointBaseURL(route.endpoint))
|
||||||
@@ -150,7 +153,7 @@ export interface Interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface StreamOptions {
|
export interface StreamOptions {
|
||||||
readonly transform?: HttpRequestTransform
|
readonly http?: HttpMiddleware
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StreamMethod {
|
export interface StreamMethod {
|
||||||
@@ -302,7 +305,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
|||||||
auth: routeInput.auth ?? Auth.none,
|
auth: routeInput.auth ?? Auth.none,
|
||||||
encodeBody,
|
encodeBody,
|
||||||
headers: routeInput.headers,
|
headers: routeInput.headers,
|
||||||
transform: options?.transform,
|
middleware: options?.http,
|
||||||
}),
|
}),
|
||||||
streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => {
|
streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => {
|
||||||
const route = `${request.model.provider}/${request.model.route.id}`
|
const route = `${request.model.provider}/${request.model.route.id}`
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Cause, Context, Effect, Layer } from "effect"
|
import { Cause, Context, Effect, Layer, Stream } from "effect"
|
||||||
import {
|
import {
|
||||||
FetchHttpClient,
|
FetchHttpClient,
|
||||||
Headers,
|
Headers,
|
||||||
@@ -20,9 +20,13 @@ import { classifyProviderFailure } from "../provider-error"
|
|||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly execute: (
|
readonly execute: (
|
||||||
request: HttpClientRequest.HttpClientRequest,
|
request: HttpClientRequest.HttpClientRequest,
|
||||||
|
middleware?: HttpMiddleware,
|
||||||
) => Effect.Effect<HttpClientResponse.HttpClientResponse, LLMError>
|
) => Effect.Effect<HttpClientResponse.HttpClientResponse, LLMError>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type HttpHandler = (request: Request) => Effect.Effect<Response, Error>
|
||||||
|
export type HttpMiddleware = (request: Request, handler: HttpHandler) => Effect.Effect<Response, Error>
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LLM/RequestExecutor") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/LLM/RequestExecutor") {}
|
||||||
|
|
||||||
const BODY_LIMIT = 16_384
|
const BODY_LIMIT = 16_384
|
||||||
@@ -282,12 +286,41 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient> = Layer.e
|
|||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const http = yield* HttpClient.HttpClient
|
const http = yield* HttpClient.HttpClient
|
||||||
const executeOnce = (request: HttpClientRequest.HttpClientRequest) =>
|
const executeOnce = (request: HttpClientRequest.HttpClientRequest, middleware?: HttpMiddleware) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const redactedNames = yield* Headers.CurrentRedactedNames
|
const redactedNames = yield* Headers.CurrentRedactedNames
|
||||||
return yield* http
|
if (!middleware)
|
||||||
.execute(request)
|
return yield* http
|
||||||
.pipe(Effect.mapError(toHttpError(redactedNames)), Effect.flatMap(statusError(request, redactedNames)))
|
.execute(request)
|
||||||
|
.pipe(Effect.mapError(toHttpError(redactedNames)), Effect.flatMap(statusError(request, redactedNames)))
|
||||||
|
|
||||||
|
let sent = request
|
||||||
|
const origins = new WeakMap<Response, HttpClientRequest.HttpClientRequest>()
|
||||||
|
const response = yield* HttpClientRequest.toWeb(request).pipe(
|
||||||
|
Effect.flatMap((web) =>
|
||||||
|
middleware(web, (input) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
sent = HttpClientRequest.fromWeb(input)
|
||||||
|
if (input.body)
|
||||||
|
sent = HttpClientRequest.bodyUint8Array(
|
||||||
|
sent,
|
||||||
|
new Uint8Array(yield* Effect.promise(() => input.arrayBuffer())),
|
||||||
|
input.headers.get("content-type") ?? undefined,
|
||||||
|
)
|
||||||
|
const response = yield* http
|
||||||
|
.execute(sent)
|
||||||
|
.pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause)))))
|
||||||
|
const body = yield* Stream.toReadableStreamEffect(response.stream)
|
||||||
|
const web = new Response(body, { status: response.status, headers: response.headers })
|
||||||
|
origins.set(web, sent)
|
||||||
|
return web
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Effect.mapError(toHttpError(redactedNames)),
|
||||||
|
)
|
||||||
|
const origin = origins.get(response) ?? sent
|
||||||
|
return yield* statusError(origin, redactedNames)(HttpClientResponse.fromWeb(origin, response))
|
||||||
})
|
})
|
||||||
return Service.of({
|
return Service.of({
|
||||||
execute: executeOnce,
|
execute: executeOnce,
|
||||||
|
|||||||
@@ -23,4 +23,4 @@ export type { ApiKeyMode, AuthOverride, ProviderAuthOption } from "./auth-option
|
|||||||
export type { Definition as EndpointFn, EndpointInput } from "./endpoint"
|
export type { Definition as EndpointFn, EndpointInput } from "./endpoint"
|
||||||
export type { Definition as FramingDef } from "./framing"
|
export type { Definition as FramingDef } from "./framing"
|
||||||
export type { Protocol as ProtocolDef } from "./protocol"
|
export type { Protocol as ProtocolDef } from "./protocol"
|
||||||
export type { HttpRequest, HttpRequestTransform, Transport as TransportDef, TransportRuntime } from "./transport"
|
export type { HttpHandler, HttpMiddleware, Transport as TransportDef, TransportRuntime } from "./transport"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Headers, HttpClientRequest } from "effect/unstable/http"
|
|||||||
import { Auth } from "../auth"
|
import { Auth } from "../auth"
|
||||||
import { render as renderEndpoint } from "../endpoint"
|
import { render as renderEndpoint } from "../endpoint"
|
||||||
import { Framing } from "../framing"
|
import { Framing } from "../framing"
|
||||||
import type { Transport, TransportPrepareInput } from "./index"
|
import type { HttpMiddleware, Transport, TransportPrepareInput } from "./index"
|
||||||
import * as ProviderShared from "../../protocols/shared"
|
import * as ProviderShared from "../../protocols/shared"
|
||||||
import { mergeJsonRecords, type LLMRequest } from "../../schema"
|
import { mergeJsonRecords, type LLMRequest } from "../../schema"
|
||||||
|
|
||||||
@@ -19,6 +19,7 @@ export interface JsonRequestParts<Body = unknown> {
|
|||||||
export interface HttpPrepared<Frame> {
|
export interface HttpPrepared<Frame> {
|
||||||
readonly request: HttpClientRequest.HttpClientRequest
|
readonly request: HttpClientRequest.HttpClientRequest
|
||||||
readonly framing: Framing.Definition<Frame>
|
readonly framing: Framing.Definition<Frame>
|
||||||
|
readonly middleware?: HttpMiddleware
|
||||||
}
|
}
|
||||||
|
|
||||||
const applyQuery = (url: string, query: Record<string, string> | undefined) => {
|
const applyQuery = (url: string, query: Record<string, string> | undefined) => {
|
||||||
@@ -74,21 +75,21 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
|
|||||||
prepare: (prepareInput) =>
|
prepare: (prepareInput) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const parts = yield* jsonRequestParts({ ...prepareInput })
|
const parts = yield* jsonRequestParts({ ...prepareInput })
|
||||||
const request = { url: parts.url, method: "POST", headers: { ...parts.headers }, body: parts.bodyText }
|
const request = ProviderShared.jsonPost({
|
||||||
yield* (prepareInput.transform?.(request) ?? Effect.void)
|
url: parts.url,
|
||||||
|
body: parts.bodyText,
|
||||||
|
headers: parts.headers,
|
||||||
|
})
|
||||||
return {
|
return {
|
||||||
request: ProviderShared.jsonPost({
|
request,
|
||||||
url: request.url,
|
|
||||||
body: request.body ?? "",
|
|
||||||
headers: Headers.fromInput(request.headers),
|
|
||||||
}),
|
|
||||||
framing: input.framing,
|
framing: input.framing,
|
||||||
|
middleware: prepareInput.middleware,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
frames: (prepared, request, runtime) =>
|
frames: (prepared, request, runtime) =>
|
||||||
Stream.unwrap(
|
Stream.unwrap(
|
||||||
runtime.http
|
runtime.http
|
||||||
.execute(prepared.request)
|
.execute(prepared.request, prepared.middleware)
|
||||||
.pipe(
|
.pipe(
|
||||||
Effect.map((response) =>
|
Effect.map((response) =>
|
||||||
prepared.framing.frame(
|
prepared.framing.frame(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Effect, Stream } from "effect"
|
import type { Effect, Stream } from "effect"
|
||||||
import { Endpoint } from "../endpoint"
|
import { Endpoint } from "../endpoint"
|
||||||
import { Auth } from "../auth"
|
import { Auth } from "../auth"
|
||||||
import type { Interface as RequestExecutorInterface } from "../executor"
|
import type { HttpMiddleware, Interface as RequestExecutorInterface } from "../executor"
|
||||||
import type { Interface as WebSocketExecutorInterface } from "./websocket"
|
import type { Interface as WebSocketExecutorInterface } from "./websocket"
|
||||||
import type { LLMError, LLMRequest } from "../../schema"
|
import type { LLMError, LLMRequest } from "../../schema"
|
||||||
|
|
||||||
@@ -10,15 +10,6 @@ export interface TransportRuntime {
|
|||||||
readonly webSocket?: WebSocketExecutorInterface
|
readonly webSocket?: WebSocketExecutorInterface
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HttpRequest {
|
|
||||||
url: string
|
|
||||||
readonly method: string
|
|
||||||
headers: Record<string, string>
|
|
||||||
body: string | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export type HttpRequestTransform = (request: HttpRequest) => Effect.Effect<void>
|
|
||||||
|
|
||||||
export interface Transport<Body, Prepared, Frame> {
|
export interface Transport<Body, Prepared, Frame> {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly prepare: (input: TransportPrepareInput<Body>) => Effect.Effect<Prepared, LLMError>
|
readonly prepare: (input: TransportPrepareInput<Body>) => Effect.Effect<Prepared, LLMError>
|
||||||
@@ -36,8 +27,9 @@ export interface TransportPrepareInput<Body> {
|
|||||||
readonly auth: Auth.Definition
|
readonly auth: Auth.Definition
|
||||||
readonly encodeBody: (body: Body) => string
|
readonly encodeBody: (body: Body) => string
|
||||||
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
|
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
|
||||||
readonly transform?: HttpRequestTransform
|
readonly middleware?: HttpMiddleware
|
||||||
}
|
}
|
||||||
|
|
||||||
export * as HttpTransport from "./http"
|
export * as HttpTransport from "./http"
|
||||||
|
export type { HttpHandler, HttpMiddleware } from "../executor"
|
||||||
export { WebSocketExecutor, WebSocketTransport } from "./websocket"
|
export { WebSocketExecutor, WebSocketTransport } from "./websocket"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Schema } from "effect"
|
|||||||
import { Tool } from "@opencode-ai/schema/tool"
|
import { Tool } from "@opencode-ai/schema/tool"
|
||||||
import { ModelID, ProviderID, ProviderMetadata, RouteID } from "./ids"
|
import { ModelID, ProviderID, ProviderMetadata, RouteID } from "./ids"
|
||||||
|
|
||||||
export const ProviderFailureClassification = Schema.Literal("context-overflow")
|
export const ProviderFailureClassification = Schema.Literals(["context-overflow", "payload-too-large"])
|
||||||
export type ProviderFailureClassification = typeof ProviderFailureClassification.Type
|
export type ProviderFailureClassification = typeof ProviderFailureClassification.Type
|
||||||
|
|
||||||
export class HttpRequestDetails extends Schema.Class<HttpRequestDetails>("LLM.HttpRequestDetails")({
|
export class HttpRequestDetails extends Schema.Class<HttpRequestDetails>("LLM.HttpRequestDetails")({
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { Effect, Schema } from "effect"
|
import { Effect, Ref, Schema } from "effect"
|
||||||
import { HttpClientRequest } from "effect/unstable/http"
|
import { HttpClientRequest } from "effect/unstable/http"
|
||||||
import { LLM, mergeProviderOptions } from "../src"
|
import { LLM, mergeProviderOptions } from "../src"
|
||||||
import { AnthropicMessages, OpenAIChat } from "../src/protocols"
|
import { AnthropicMessages, OpenAIChat } from "../src/protocols"
|
||||||
@@ -146,12 +146,19 @@ describe("request option precedence", () => {
|
|||||||
prompt: "Say hello.",
|
prompt: "Say hello.",
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
transform: (request) =>
|
http: (request, handler) =>
|
||||||
Effect.sync(() => {
|
Effect.gen(function* () {
|
||||||
expect(request.headers.authorization).toBe("Bearer fresh-key")
|
expect(request.headers.get("authorization")).toBe("Bearer fresh-key")
|
||||||
request.url = "https://proxy.test/v1/chat/completions"
|
const headers = new Headers(request.headers)
|
||||||
request.headers["x-plugin"] = "transformed"
|
headers.set("x-plugin", "transformed")
|
||||||
request.body = JSON.stringify({ transformed: true })
|
headers.set("content-type", "application/custom+json")
|
||||||
|
return yield* handler(
|
||||||
|
new Request("https://proxy.test/v1/chat/completions", {
|
||||||
|
method: "PUT",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ transformed: true }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
).pipe(
|
).pipe(
|
||||||
@@ -160,7 +167,9 @@ describe("request option precedence", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
||||||
expect(web.url).toBe("https://proxy.test/v1/chat/completions")
|
expect(web.url).toBe("https://proxy.test/v1/chat/completions")
|
||||||
|
expect(web.method).toBe("PUT")
|
||||||
expect(web.headers.get("x-plugin")).toBe("transformed")
|
expect(web.headers.get("x-plugin")).toBe("transformed")
|
||||||
|
expect(web.headers.get("content-type")).toBe("application/custom+json")
|
||||||
expect(decodeJson(input.text)).toEqual({ transformed: true })
|
expect(decodeJson(input.text)).toEqual({ transformed: true })
|
||||||
return input.respond(sseEvents(deltaChunk({}, "stop")), {
|
return input.respond(sseEvents(deltaChunk({}, "stop")), {
|
||||||
headers: { "content-type": "text/event-stream" },
|
headers: { "content-type": "text/event-stream" },
|
||||||
@@ -171,6 +180,83 @@ describe("request option precedence", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("transforms the HTTP response before protocol decoding", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* LLMClient.generate(
|
||||||
|
LLM.request({
|
||||||
|
model: OpenAIChat.route
|
||||||
|
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
|
||||||
|
.model({ id: "gpt-4o-mini" }),
|
||||||
|
prompt: "Say hello.",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
http: (request, handler) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* handler(request)
|
||||||
|
const body = yield* Effect.promise(() => response.text())
|
||||||
|
return new Response(body.replace("network", "hooked"), {
|
||||||
|
status: response.status,
|
||||||
|
headers: response.headers,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
dynamicResponse((input) =>
|
||||||
|
Effect.succeed(
|
||||||
|
input.respond(sseEvents(deltaChunk({ content: "network" }, "stop")), {
|
||||||
|
headers: { "content-type": "text/event-stream" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.text).toBe("hooked")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("can inspect an error response and retry the native request", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const attempts = yield* Ref.make(0)
|
||||||
|
const response = yield* LLMClient.generate(
|
||||||
|
LLM.request({
|
||||||
|
model: OpenAIChat.route
|
||||||
|
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("stale") })
|
||||||
|
.model({ id: "gpt-4o-mini" }),
|
||||||
|
prompt: "Say hello.",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
http: (request, handler) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const retry = request.clone()
|
||||||
|
const response = yield* handler(request)
|
||||||
|
expect(response.status).toBe(401)
|
||||||
|
const headers = new Headers(retry.headers)
|
||||||
|
headers.set("authorization", "Bearer refreshed")
|
||||||
|
return yield* handler(new Request(retry, { headers }))
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
dynamicResponse((input) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* Ref.update(attempts, (value) => value + 1)
|
||||||
|
if (input.request.headers.authorization !== "Bearer refreshed")
|
||||||
|
return input.respond("unauthorized", { status: 401 })
|
||||||
|
return input.respond(sseEvents(deltaChunk({ content: "retried" }, "stop")), {
|
||||||
|
headers: { "content-type": "text/event-stream" },
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.text).toBe("retried")
|
||||||
|
expect(yield* Ref.get(attempts)).toBe(2)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("applies raw body overlays after protocol lowering", () =>
|
it.effect("applies raw body overlays after protocol lowering", () =>
|
||||||
LLMClient.generate(
|
LLMClient.generate(
|
||||||
LLM.request({
|
LLM.request({
|
||||||
|
|||||||
@@ -85,14 +85,17 @@ describe("RequestExecutor", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("does not classify generic HTTP 413 payload errors as context overflow", () =>
|
it.effect("classifies generic HTTP 413 payload errors", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const executor = yield* RequestExecutor.Service
|
const executor = yield* RequestExecutor.Service
|
||||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||||
|
|
||||||
expectLLMError(error)
|
expectLLMError(error)
|
||||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
expect(error.reason).toMatchObject({
|
||||||
expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined()
|
_tag: "InvalidRequest",
|
||||||
|
classification: "payload-too-large",
|
||||||
|
http: { response: { status: 413 } },
|
||||||
|
})
|
||||||
}).pipe(Effect.provide(responsesLayer([new Response("request too large", { status: 413 })]))),
|
}).pipe(Effect.provide(responsesLayer([new Response("request too large", { status: 413 })]))),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ describe("provider error classification", () => {
|
|||||||
test("classifies provider token limit messages as context overflow", () => {
|
test("classifies provider token limit messages as context overflow", () => {
|
||||||
const messages = [
|
const messages = [
|
||||||
"tokens in request more than max tokens allowed",
|
"tokens in request more than max tokens allowed",
|
||||||
'{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}',
|
|
||||||
"Requested token count exceeds the model's maximum context length of 131072 tokens.",
|
"Requested token count exceeds the model's maximum context length of 131072 tokens.",
|
||||||
"Input length (265330) exceeds model's maximum context length (262144).",
|
"Input length (265330) exceeds model's maximum context length (262144).",
|
||||||
"Input length 131393 exceeds the maximum allowed input length of 131040 tokens.",
|
"Input length 131393 exceeds the maximum allowed input length of 131040 tokens.",
|
||||||
@@ -19,6 +18,24 @@ describe("provider error classification", () => {
|
|||||||
expect(messages.every(isContextOverflow)).toBe(true)
|
expect(messages.every(isContextOverflow)).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("classifies request size failures separately from context overflow", () => {
|
||||||
|
const failures = [
|
||||||
|
classifyProviderFailure({ message: "request too large", status: 413 }),
|
||||||
|
classifyProviderFailure({
|
||||||
|
message: '{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}',
|
||||||
|
status: 400,
|
||||||
|
}),
|
||||||
|
classifyProviderFailure({ message: "upstream request entity too large", status: 502 }),
|
||||||
|
]
|
||||||
|
|
||||||
|
expect(failures).toEqual(
|
||||||
|
failures.map((failure) =>
|
||||||
|
expect.objectContaining({ _tag: "InvalidRequest", classification: "payload-too-large" }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
expect(isContextOverflow("413 status code (no body)")).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
test("does not classify rate limits as context overflow", () => {
|
test("does not classify rate limits as context overflow", () => {
|
||||||
const messages = [
|
const messages = [
|
||||||
"Throttling error: Too many tokens, please wait before trying again.",
|
"Throttling error: Too many tokens, please wait before trying again.",
|
||||||
@@ -59,9 +76,10 @@ describe("provider error classification", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("classifies transient client statuses as provider internal", () => {
|
test("classifies transient client statuses as provider internal", () => {
|
||||||
expect(
|
expect([408, 409].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag)).toEqual([
|
||||||
[408, 409].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag),
|
"ProviderInternal",
|
||||||
).toEqual(["ProviderInternal", "ProviderInternal"])
|
"ProviderInternal",
|
||||||
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("classifies nested provider codes when a top-level code is also present", () => {
|
test("classifies nested provider codes when a top-level code is also present", () => {
|
||||||
|
|||||||
@@ -133,7 +133,6 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
|||||||
description: "Manage plugins",
|
description: "Manage plugins",
|
||||||
commands: [Spec.make("list", { description: "List active plugins" })],
|
commands: [Spec.make("list", { description: "List active plugins" })],
|
||||||
}),
|
}),
|
||||||
Spec.make("migrate", { description: "Migrate v1 data to v2" }),
|
|
||||||
Spec.make("mini", {
|
Spec.make("mini", {
|
||||||
description: "Start the minimal interactive interface",
|
description: "Start the minimal interactive interface",
|
||||||
params: {
|
params: {
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { Effect } from "effect"
|
|
||||||
import { Commands } from "../commands"
|
|
||||||
import { Runtime } from "../../framework/runtime"
|
|
||||||
|
|
||||||
export default Runtime.handler(Commands.commands.migrate, (_input) => Effect.log("No migrations to run."))
|
|
||||||
@@ -116,13 +116,6 @@ export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record<strin
|
|||||||
: { grouping: kv.exploration_grouping ? ("auto" as const) : ("none" as const) }),
|
: { grouping: kv.exploration_grouping ? ("auto" as const) : ("none" as const) }),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
...(kv.dismissed_getting_started === undefined
|
|
||||||
? {}
|
|
||||||
: {
|
|
||||||
hints: {
|
|
||||||
onboarding: !kv.dismissed_getting_started,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
...(kv.animations_enabled === undefined ? {} : { animations: kv.animations_enabled }),
|
...(kv.animations_enabled === undefined ? {} : { animations: kv.animations_enabled }),
|
||||||
...(legacy?.mouse === undefined ? {} : { mouse: legacy.mouse }),
|
...(legacy?.mouse === undefined ? {} : { mouse: legacy.mouse }),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ const Handlers = Runtime.handlers(Commands, {
|
|||||||
plugin: {
|
plugin: {
|
||||||
list: () => import("./commands/handlers/plugin/list"),
|
list: () => import("./commands/handlers/plugin/list"),
|
||||||
},
|
},
|
||||||
migrate: () => import("./commands/handlers/migrate"),
|
|
||||||
mini: () => import("./commands/handlers/mini"),
|
mini: () => import("./commands/handlers/mini"),
|
||||||
run: () => import("./commands/handlers/run"),
|
run: () => import("./commands/handlers/run"),
|
||||||
pair: () => import("./commands/handlers/pair"),
|
pair: () => import("./commands/handlers/pair"),
|
||||||
|
|||||||
@@ -235,10 +235,6 @@ async function renderToolError(part: SessionMessageAssistantTool, directory: str
|
|||||||
UI.println(UI.Style.TEXT_NORMAL + "✗", UI.Style.TEXT_NORMAL + `${info.title} failed`)
|
UI.println(UI.Style.TEXT_NORMAL + "✗", UI.Style.TEXT_NORMAL + `${info.title} failed`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function warning(message: string) {
|
|
||||||
UI.println(UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_NORMAL, message)
|
|
||||||
}
|
|
||||||
|
|
||||||
function errorMessage(error: unknown) {
|
function errorMessage(error: unknown) {
|
||||||
if (error instanceof Error) return error.message
|
if (error instanceof Error) return error.message
|
||||||
if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string")
|
if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string")
|
||||||
|
|||||||
@@ -74,12 +74,12 @@ test("migrates tui and kv config into cli.json", async () => {
|
|||||||
terminal: { title: false },
|
terminal: { title: false },
|
||||||
prompt: { editor: false, paste: "full" },
|
prompt: { editor: false, paste: "full" },
|
||||||
session: { sidebar: "hide", scrollbar: true, thinking: "show", grouping: "none" },
|
session: { sidebar: "hide", scrollbar: true, thinking: "show", grouping: "none" },
|
||||||
hints: { onboarding: false },
|
|
||||||
animations: false,
|
animations: false,
|
||||||
mouse: false,
|
mouse: false,
|
||||||
})
|
})
|
||||||
expect(config).not.toHaveProperty("skipped_version")
|
expect(config).not.toHaveProperty("skipped_version")
|
||||||
expect(config).not.toHaveProperty("which_key")
|
expect(config).not.toHaveProperty("which_key")
|
||||||
|
expect(config).not.toHaveProperty("hints")
|
||||||
expect((await Bun.file(path.join(directory, "cli.json")).json()).keybinds).toEqual({ leader: "ctrl+o" })
|
expect((await Bun.file(path.join(directory, "cli.json")).json()).keybinds).toEqual({ leader: "ctrl+o" })
|
||||||
expect(await Bun.file(path.join(directory, "cli.json")).exists()).toBe(true)
|
expect(await Bun.file(path.join(directory, "cli.json")).exists()).toBe(true)
|
||||||
expect(await Bun.file(path.join(directory, "tui.json")).exists()).toBe(true)
|
expect(await Bun.file(path.join(directory, "tui.json")).exists()).toBe(true)
|
||||||
|
|||||||
@@ -398,7 +398,7 @@ export type Endpoint5_26Output =
|
|||||||
readonly location?: Location.Ref | undefined
|
readonly location?: Location.Ref | undefined
|
||||||
readonly data: {
|
readonly data: {
|
||||||
readonly sessionID: Session.ID
|
readonly sessionID: Session.ID
|
||||||
readonly error: { readonly type: string; readonly message: string }
|
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
@@ -524,7 +524,7 @@ export type Endpoint5_26Output =
|
|||||||
readonly data: {
|
readonly data: {
|
||||||
readonly sessionID: Session.ID
|
readonly sessionID: Session.ID
|
||||||
readonly assistantMessageID: SessionMessage.ID
|
readonly assistantMessageID: SessionMessage.ID
|
||||||
readonly error: { readonly type: string; readonly message: string }
|
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
|
||||||
readonly cost?: (number & Brand.Brand<"Money.USD">) | undefined
|
readonly cost?: (number & Brand.Brand<"Money.USD">) | undefined
|
||||||
readonly tokens?:
|
readonly tokens?:
|
||||||
| {
|
| {
|
||||||
@@ -686,7 +686,7 @@ export type Endpoint5_26Output =
|
|||||||
readonly sessionID: Session.ID
|
readonly sessionID: Session.ID
|
||||||
readonly assistantMessageID: SessionMessage.ID
|
readonly assistantMessageID: SessionMessage.ID
|
||||||
readonly callID: string
|
readonly callID: string
|
||||||
readonly error: { readonly type: string; readonly message: string }
|
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
|
||||||
readonly content?:
|
readonly content?:
|
||||||
| readonly [
|
| readonly [
|
||||||
(
|
(
|
||||||
@@ -726,7 +726,7 @@ export type Endpoint5_26Output =
|
|||||||
readonly assistantMessageID: SessionMessage.ID
|
readonly assistantMessageID: SessionMessage.ID
|
||||||
readonly attempt: number
|
readonly attempt: number
|
||||||
readonly at: number
|
readonly at: number
|
||||||
readonly error: { readonly type: string; readonly message: string }
|
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
@@ -776,7 +776,7 @@ export type Endpoint5_26Output =
|
|||||||
readonly data: {
|
readonly data: {
|
||||||
readonly sessionID: Session.ID
|
readonly sessionID: Session.ID
|
||||||
readonly reason: "auto" | "manual"
|
readonly reason: "auto" | "manual"
|
||||||
readonly error: { readonly type: string; readonly message: string }
|
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
|
||||||
readonly inputID?: SessionMessage.ID | undefined
|
readonly inputID?: SessionMessage.ID | undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export type ToolTextContent = { type: "text"; text: string }
|
|||||||
|
|
||||||
export type ToolFileContent = { type: "file"; uri: string; mime: string; name?: string | null }
|
export type ToolFileContent = { type: "file"; uri: string; mime: string; name?: string | null }
|
||||||
|
|
||||||
export type SessionStructuredError = { type: string; message: string }
|
export type SessionStructuredError = { type: string; message: string; status?: number }
|
||||||
|
|
||||||
export type SessionMessageCompactionRunning = {
|
export type SessionMessageCompactionRunning = {
|
||||||
type: "compaction"
|
type: "compaction"
|
||||||
|
|||||||
@@ -56,7 +56,6 @@
|
|||||||
"@tsconfig/bun": "catalog:",
|
"@tsconfig/bun": "catalog:",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"@types/semver": "catalog:",
|
|
||||||
"@types/turndown": "5.0.5",
|
"@types/turndown": "5.0.5",
|
||||||
"@types/which": "3.0.4",
|
"@types/which": "3.0.4",
|
||||||
"@parcel/watcher-darwin-arm64": "2.5.1",
|
"@parcel/watcher-darwin-arm64": "2.5.1",
|
||||||
@@ -100,7 +99,6 @@
|
|||||||
"@ff-labs/fff-node": "0.10.1",
|
"@ff-labs/fff-node": "0.10.1",
|
||||||
"@opencode-ai/codemode": "workspace:*",
|
"@opencode-ai/codemode": "workspace:*",
|
||||||
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
|
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
|
||||||
"@opencode-ai/effect-sqlite-node": "workspace:*",
|
|
||||||
"@opencode-ai/ai": "workspace:*",
|
"@opencode-ai/ai": "workspace:*",
|
||||||
"@opencode-ai/schema": "workspace:*",
|
"@opencode-ai/schema": "workspace:*",
|
||||||
"@opencode-ai/plugin": "workspace:*",
|
"@opencode-ai/plugin": "workspace:*",
|
||||||
@@ -122,7 +120,6 @@
|
|||||||
"immer": "11.1.4",
|
"immer": "11.1.4",
|
||||||
"ignore": "7.0.5",
|
"ignore": "7.0.5",
|
||||||
"jsonc-parser": "3.3.1",
|
"jsonc-parser": "3.3.1",
|
||||||
"semver": "^7.6.3",
|
|
||||||
"turndown": "7.2.0",
|
"turndown": "7.2.0",
|
||||||
"tree-sitter-bash": "0.25.0",
|
"tree-sitter-bash": "0.25.0",
|
||||||
"tree-sitter-powershell": "0.25.10",
|
"tree-sitter-powershell": "0.25.10",
|
||||||
|
|||||||
@@ -1,181 +0,0 @@
|
|||||||
export * as MoveSession from "./move-session"
|
|
||||||
|
|
||||||
import { Context, DateTime, Effect, Layer, Schema } from "effect"
|
|
||||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
|
||||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
|
||||||
import { Git } from "../git"
|
|
||||||
import { Global } from "@opencode-ai/util/global"
|
|
||||||
import { Project } from "../project"
|
|
||||||
import { Session } from "../session"
|
|
||||||
import { SessionExecution } from "../session/execution"
|
|
||||||
import { SessionSchema } from "../session/schema"
|
|
||||||
import { SessionStore } from "../session/store"
|
|
||||||
import { AbsolutePath } from "../schema"
|
|
||||||
import path from "path"
|
|
||||||
|
|
||||||
export const Destination = Schema.Struct({
|
|
||||||
directory: AbsolutePath,
|
|
||||||
}).annotate({ identifier: "MoveSession.Destination" })
|
|
||||||
export type Destination = typeof Destination.Type
|
|
||||||
|
|
||||||
export const Input = Schema.Struct({
|
|
||||||
sessionID: SessionSchema.ID,
|
|
||||||
destination: Destination,
|
|
||||||
moveChanges: Schema.optional(Schema.Boolean),
|
|
||||||
}).annotate({ identifier: "MoveSession.Input" })
|
|
||||||
export type Input = typeof Input.Type
|
|
||||||
|
|
||||||
export class DestinationProjectMismatchError extends Schema.TaggedErrorClass<DestinationProjectMismatchError>()(
|
|
||||||
"MoveSession.DestinationProjectMismatchError",
|
|
||||||
{
|
|
||||||
expected: Project.ID,
|
|
||||||
actual: Project.ID,
|
|
||||||
},
|
|
||||||
) {}
|
|
||||||
|
|
||||||
export class DestinationNotFoundError extends Schema.TaggedErrorClass<DestinationNotFoundError>()(
|
|
||||||
"MoveSession.DestinationNotFoundError",
|
|
||||||
{ directory: AbsolutePath },
|
|
||||||
) {}
|
|
||||||
|
|
||||||
export class DestinationNotDirectoryError extends Schema.TaggedErrorClass<DestinationNotDirectoryError>()(
|
|
||||||
"MoveSession.DestinationNotDirectoryError",
|
|
||||||
{ directory: AbsolutePath },
|
|
||||||
) {}
|
|
||||||
|
|
||||||
export class ApplyChangesError extends Schema.TaggedErrorClass<ApplyChangesError>()("MoveSession.ApplyChangesError", {
|
|
||||||
message: Schema.String,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export class CaptureChangesError extends Schema.TaggedErrorClass<CaptureChangesError>()(
|
|
||||||
"MoveSession.CaptureChangesError",
|
|
||||||
{
|
|
||||||
message: Schema.String,
|
|
||||||
},
|
|
||||||
) {}
|
|
||||||
|
|
||||||
export class ResetSourceChangesError extends Schema.TaggedErrorClass<ResetSourceChangesError>()(
|
|
||||||
"MoveSession.ResetSourceChangesError",
|
|
||||||
{
|
|
||||||
directory: AbsolutePath,
|
|
||||||
message: Schema.String,
|
|
||||||
cause: Schema.optional(Schema.Defect()),
|
|
||||||
},
|
|
||||||
) {}
|
|
||||||
|
|
||||||
export type Error =
|
|
||||||
| Session.NotFoundError
|
|
||||||
| DestinationProjectMismatchError
|
|
||||||
| DestinationNotFoundError
|
|
||||||
| DestinationNotDirectoryError
|
|
||||||
| Session.DestinationNotFoundError
|
|
||||||
| Session.DestinationNotDirectoryError
|
|
||||||
| CaptureChangesError
|
|
||||||
| ApplyChangesError
|
|
||||||
| ResetSourceChangesError
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly moveSession: (input: Input) => Effect.Effect<void, Error>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ControlPlaneMoveSession") {}
|
|
||||||
|
|
||||||
const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const git = yield* Git.Service
|
|
||||||
const fs = yield* FSUtil.Service
|
|
||||||
const global = yield* Global.Service
|
|
||||||
const project = yield* Project.Service
|
|
||||||
const sessions = yield* SessionStore.Service
|
|
||||||
const session = yield* Session.Service
|
|
||||||
const execution = yield* SessionExecution.Service
|
|
||||||
|
|
||||||
const moveSession = Effect.fn("MoveSession.moveSession")(function* (input: Input) {
|
|
||||||
const current = yield* sessions.get(input.sessionID)
|
|
||||||
if (!current) return yield* new Session.NotFoundError({ sessionID: input.sessionID })
|
|
||||||
const value = input.destination.directory.trim()
|
|
||||||
const expanded = value === "~" ? global.home : value.startsWith("~/") ? path.join(global.home, value.slice(2)) : value
|
|
||||||
const directory = AbsolutePath.make(path.resolve(current.location.directory, expanded))
|
|
||||||
const destinationInfo = yield* fs.stat(directory).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
|
||||||
if (!destinationInfo) return yield* new DestinationNotFoundError({ directory })
|
|
||||||
if (destinationInfo.type !== "Directory") return yield* new DestinationNotDirectoryError({ directory })
|
|
||||||
if (current.location.directory === directory) return
|
|
||||||
|
|
||||||
const source = yield* project.resolve(current.location.directory)
|
|
||||||
const destination = yield* project.resolve(directory)
|
|
||||||
if (input.moveChanges && current.projectID !== destination.id) {
|
|
||||||
return yield* new DestinationProjectMismatchError({ expected: current.projectID, actual: destination.id })
|
|
||||||
}
|
|
||||||
// A move must not race active execution: a mid-drain relocation would let
|
|
||||||
// the source Location dispatch a request assembled under stale instructions
|
|
||||||
// and history. Serialize like removal does — stop the drain, then move.
|
|
||||||
yield* execution.interrupt(input.sessionID)
|
|
||||||
yield* execution.awaitIdle(input.sessionID)
|
|
||||||
|
|
||||||
const moveChanges = input.moveChanges && source.directory !== destination.directory
|
|
||||||
const sourceRepository = moveChanges ? yield* git.repo.discover(current.location.directory) : undefined
|
|
||||||
if (moveChanges && !sourceRepository)
|
|
||||||
return yield* new CaptureChangesError({ message: "Source is not a Git repository" })
|
|
||||||
const patch = sourceRepository
|
|
||||||
? yield* git.change
|
|
||||||
.capture({ repository: sourceRepository, path: current.location.directory })
|
|
||||||
.pipe(Effect.mapError((error) => new CaptureChangesError({ message: error.message })))
|
|
||||||
: Git.ChangeSet.make("")
|
|
||||||
if (patch) {
|
|
||||||
const repository = yield* git.repo.discover(directory)
|
|
||||||
if (!repository) return yield* new ApplyChangesError({ message: "Destination is not a Git repository" })
|
|
||||||
yield* git.change
|
|
||||||
.apply({ repository, path: directory, changes: patch })
|
|
||||||
.pipe(Effect.mapError((error) => new ApplyChangesError({ message: error.message })))
|
|
||||||
}
|
|
||||||
|
|
||||||
yield* session.move({
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
directory,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (patch) {
|
|
||||||
const repository = yield* git.repo.discover(current.location.directory)
|
|
||||||
if (!repository)
|
|
||||||
return yield* new ResetSourceChangesError({
|
|
||||||
directory: current.location.directory,
|
|
||||||
message: "Source is not a Git repository",
|
|
||||||
})
|
|
||||||
yield* git.change
|
|
||||||
.discard({
|
|
||||||
repository,
|
|
||||||
path: current.location.directory,
|
|
||||||
index: "preserve",
|
|
||||||
untracked: "remove",
|
|
||||||
})
|
|
||||||
.pipe(
|
|
||||||
Effect.mapError(
|
|
||||||
(error) =>
|
|
||||||
new ResetSourceChangesError({
|
|
||||||
directory: current.location.directory,
|
|
||||||
message: error.message,
|
|
||||||
cause: error.cause,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ moveSession })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const node = makeGlobalNode({
|
|
||||||
service: Service,
|
|
||||||
layer,
|
|
||||||
deps: [
|
|
||||||
FSUtil.node,
|
|
||||||
Git.node,
|
|
||||||
Global.node,
|
|
||||||
Project.node,
|
|
||||||
Session.node,
|
|
||||||
SessionStore.node,
|
|
||||||
SessionExecution.node,
|
|
||||||
],
|
|
||||||
})
|
|
||||||
@@ -148,6 +148,3 @@ export function buildLocationServiceMap(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// This is temporary for backwards compatibility
|
|
||||||
export const locationServiceMapLayer = buildLocationServiceMap()
|
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
export * as LayerMapExample from "./layer-map.example"
|
|
||||||
|
|
||||||
import { Context, Effect, Layer, LayerMap } from "effect"
|
|
||||||
import { Npm } from "@opencode-ai/util/npm"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tutorial: split global services from context-specific services.
|
|
||||||
*
|
|
||||||
* Use this pattern when part of the app should be constructed once at the app edge,
|
|
||||||
* while another part should be cached per request/project/workspace key.
|
|
||||||
*
|
|
||||||
* In this example:
|
|
||||||
* - Npm.Service is the global service. It is not keyed by request context and should
|
|
||||||
* be provided once by the application runtime.
|
|
||||||
* - ConfigService is context-specific. It is built from a RequestContext key and is
|
|
||||||
* cached by LayerMap for that key.
|
|
||||||
* - ConfigServiceMap.layer owns the cache. Provide it once globally, then each
|
|
||||||
* request can provide ConfigServiceMap.get(context) to select the right instance.
|
|
||||||
*
|
|
||||||
* Lifetime model:
|
|
||||||
* - ConfigServiceMap.layer has the app/global lifetime and depends on Npm.Service.
|
|
||||||
* - ConfigServiceMap.get(context) has the request/context lifetime and provides
|
|
||||||
* ConfigService for exactly that context key.
|
|
||||||
* - The cached ConfigService entry stays alive while something is using it. Once idle,
|
|
||||||
* it remains cached for idleTimeToLive, then its scope is finalized.
|
|
||||||
* - invalidate(context) removes the cache entry for future lookups. Active users keep
|
|
||||||
* running on the old instance; the next lookup can create a fresh instance.
|
|
||||||
*
|
|
||||||
* Key model:
|
|
||||||
* - Keys can be strings, structs, classes, arrays, etc.
|
|
||||||
* - Prefer primitive or immutable keys. Effect uses Hash / Equal semantics for cache
|
|
||||||
* lookup, so mutating an object after it has been used as a key is a bug.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type RequestContext = {
|
|
||||||
readonly directory: string
|
|
||||||
readonly workspace: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RequestContextRef extends Context.Service<RequestContextRef, RequestContext>()(
|
|
||||||
"@opencode/example/RequestContextRef",
|
|
||||||
) {}
|
|
||||||
|
|
||||||
export interface ConfigServiceShape {
|
|
||||||
readonly directory: string
|
|
||||||
readonly workspace: string
|
|
||||||
readonly nextUse: () => Effect.Effect<number>
|
|
||||||
readonly which: Npm.Interface["which"]
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ConfigService extends Context.Service<ConfigService, ConfigServiceShape>()(
|
|
||||||
"@opencode/example/ConfigService",
|
|
||||||
) {}
|
|
||||||
|
|
||||||
const configServiceLayer = Layer.effect(
|
|
||||||
ConfigService,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const context = yield* RequestContextRef
|
|
||||||
const npm = yield* Npm.Service
|
|
||||||
|
|
||||||
let useCount = 0
|
|
||||||
|
|
||||||
return ConfigService.of({
|
|
||||||
directory: context.directory,
|
|
||||||
workspace: context.workspace,
|
|
||||||
nextUse: () => Effect.succeed(++useCount),
|
|
||||||
which: npm.which,
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export class ConfigServiceMap extends LayerMap.Service<ConfigServiceMap>()("@opencode/example/ConfigServiceMap", {
|
|
||||||
lookup: (context: RequestContext) =>
|
|
||||||
configServiceLayer.pipe(Layer.provide(Layer.succeed(RequestContextRef, RequestContextRef.of(context)))),
|
|
||||||
idleTimeToLive: "5 minutes",
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export const appLayer = ConfigServiceMap.layer
|
|
||||||
|
|
||||||
export const readConfig = Effect.fn("LayerMapExample.readConfig")(function* () {
|
|
||||||
const config = yield* ConfigService
|
|
||||||
|
|
||||||
return {
|
|
||||||
directory: config.directory,
|
|
||||||
workspace: config.workspace,
|
|
||||||
useCount: yield* config.nextUse(),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
export const handleRequest = Effect.fn("LayerMapExample.handleRequest")(function* (context: RequestContext) {
|
|
||||||
return yield* readConfig().pipe(Effect.provide(ConfigServiceMap.get(context)))
|
|
||||||
})
|
|
||||||
|
|
||||||
export const invalidateContext = (context: RequestContext) => ConfigServiceMap.invalidate(context)
|
|
||||||
@@ -194,7 +194,9 @@ export function fromPromise(plugin: Plugin) {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
refresh:
|
refresh:
|
||||||
refresh === undefined ? undefined : (credential) => Effect.promise(() => refresh(credential)),
|
refresh === undefined
|
||||||
|
? undefined
|
||||||
|
: (credential) => Effect.promise(() => refresh(credential)),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
remove: draft.method.remove,
|
remove: draft.method.remove,
|
||||||
@@ -263,8 +265,35 @@ export function fromPromise(plugin: Plugin) {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
hook: (name, callback) =>
|
hook: (name, callback) => {
|
||||||
register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
|
if (name !== "http")
|
||||||
|
return register(
|
||||||
|
host.session.hook(name, (event) =>
|
||||||
|
Effect.promise(() => Promise.resolve(Reflect.apply(callback, undefined, [event]))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return register(
|
||||||
|
host.session.hook("http", (event) => {
|
||||||
|
const request = event.request
|
||||||
|
const output = {
|
||||||
|
...event,
|
||||||
|
request: (input: Request) =>
|
||||||
|
Effect.runPromiseWith(context)(request(input), { signal: input.signal }),
|
||||||
|
}
|
||||||
|
return Effect.promise(() => Promise.resolve(Reflect.apply(callback, undefined, [output]))).pipe(
|
||||||
|
Effect.tap(() =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
event.request = (input) =>
|
||||||
|
Effect.tryPromise({
|
||||||
|
try: (signal) => output.request(new Request(input, { signal })),
|
||||||
|
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
create: (input) =>
|
create: (input) =>
|
||||||
run(
|
run(
|
||||||
host.session.create(
|
host.session.create(
|
||||||
|
|||||||
@@ -225,15 +225,25 @@ export const OpenAIPlugin = define({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
yield* ctx.session.hook("request", (evt) =>
|
yield* ctx.session.hook("http", (evt) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return
|
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return
|
||||||
const url = new URL(evt.url)
|
const request = evt.request
|
||||||
if (url.origin === "https://api.openai.com") {
|
evt.request = (input) => {
|
||||||
evt.url = `${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`
|
const url = new URL(input.url)
|
||||||
|
const headers = new Headers(input.headers)
|
||||||
|
headers.set("originator", "opencode")
|
||||||
|
headers.set("session-id", evt.sessionID)
|
||||||
|
if (url.origin !== "https://api.openai.com") return request(new Request(input, { headers }))
|
||||||
|
return request(
|
||||||
|
new Request(`${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`, {
|
||||||
|
method: input.method,
|
||||||
|
headers,
|
||||||
|
body: input.body,
|
||||||
|
signal: input.signal,
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
evt.headers.originator = "opencode"
|
|
||||||
evt.headers["session-id"] = evt.sessionID
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -120,7 +120,6 @@ export const providerLayerWithCell = (cell: Cell) =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const layer = layerWithCell(defaultCell)
|
export const layer = layerWithCell(defaultCell)
|
||||||
export const providerLayer = providerLayerWithCell(defaultCell)
|
|
||||||
|
|
||||||
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
|
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
|
||||||
|
|
||||||
|
|||||||
@@ -220,24 +220,15 @@ export const layer = Layer.effect(
|
|||||||
toolChoice: stepLimitReached ? "none" : undefined,
|
toolChoice: stepLimitReached ? "none" : undefined,
|
||||||
})
|
})
|
||||||
const options: StreamOptions = {
|
const options: StreamOptions = {
|
||||||
transform: (request) =>
|
http: (request, handler) =>
|
||||||
hooks
|
hooks
|
||||||
.trigger("session", "request", {
|
.trigger("session", "http", {
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
agent: agent.id,
|
agent: agent.id,
|
||||||
model: resolved.ref,
|
model: resolved.ref,
|
||||||
...request,
|
request: handler,
|
||||||
})
|
})
|
||||||
.pipe(
|
.pipe(Effect.flatMap((event) => event.request(request))),
|
||||||
Effect.tap((event) =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
request.url = event.url
|
|
||||||
request.headers = event.headers
|
|
||||||
request.body = event.body
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
Effect.asVoid,
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
if (promptCacheSnapshots) {
|
if (promptCacheSnapshots) {
|
||||||
const current = PromptCacheDiagnostics.snapshot(request)
|
const current = PromptCacheDiagnostics.snapshot(request)
|
||||||
|
|||||||
@@ -11,25 +11,25 @@ export function toSessionError(cause: unknown): SessionError.Error {
|
|||||||
if (cause instanceof LLMError) {
|
if (cause instanceof LLMError) {
|
||||||
switch (cause.reason._tag) {
|
switch (cause.reason._tag) {
|
||||||
case "RateLimit":
|
case "RateLimit":
|
||||||
return { type: "provider.rate-limit", message: cause.reason.message }
|
return providerError("provider.rate-limit", cause.reason)
|
||||||
case "Authentication":
|
case "Authentication":
|
||||||
return { type: "provider.auth", message: cause.reason.message }
|
return providerError("provider.auth", cause.reason)
|
||||||
case "QuotaExceeded":
|
case "QuotaExceeded":
|
||||||
return { type: "provider.quota", message: cause.reason.message }
|
return providerError("provider.quota", cause.reason)
|
||||||
case "ContentPolicy":
|
case "ContentPolicy":
|
||||||
return { type: "provider.content-filter", message: cause.reason.message }
|
return providerError("provider.content-filter", cause.reason)
|
||||||
case "Transport":
|
case "Transport":
|
||||||
return { type: "provider.transport", message: cause.reason.message }
|
return providerError("provider.transport", cause.reason)
|
||||||
case "ProviderInternal":
|
case "ProviderInternal":
|
||||||
return { type: "provider.internal", message: cause.reason.message }
|
return providerError("provider.internal", cause.reason)
|
||||||
case "InvalidProviderOutput":
|
case "InvalidProviderOutput":
|
||||||
return { type: "provider.invalid-output", message: cause.reason.message }
|
return providerError("provider.invalid-output", cause.reason)
|
||||||
case "InvalidRequest":
|
case "InvalidRequest":
|
||||||
return { type: "provider.invalid-request", message: cause.reason.message }
|
return providerError("provider.invalid-request", cause.reason)
|
||||||
case "NoRoute":
|
case "NoRoute":
|
||||||
return { type: "provider.no-route", message: cause.reason.message }
|
return providerError("provider.no-route", cause.reason)
|
||||||
case "UnknownProvider":
|
case "UnknownProvider":
|
||||||
return { type: "provider.unknown", message: cause.reason.message }
|
return providerError("provider.unknown", cause.reason)
|
||||||
default: {
|
default: {
|
||||||
const exhaustive: never = cause.reason
|
const exhaustive: never = cause.reason
|
||||||
return exhaustive
|
return exhaustive
|
||||||
@@ -58,3 +58,9 @@ export function toSessionError(cause: unknown): SessionError.Error {
|
|||||||
if (cause instanceof Integration.AuthorizationError) return { type: "provider.auth", message: cause.message }
|
if (cause instanceof Integration.AuthorizationError) return { type: "provider.auth", message: cause.message }
|
||||||
return { type: "unknown", message: cause instanceof Error ? cause.message : String(cause) }
|
return { type: "unknown", message: cause instanceof Error ? cause.message : String(cause) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function providerError(type: string, reason: LLMError["reason"]): SessionError.Error {
|
||||||
|
const status =
|
||||||
|
("http" in reason ? reason.http?.response?.status : undefined) ?? ("status" in reason ? reason.status : undefined)
|
||||||
|
return { type, message: reason.message, ...(status === undefined ? {} : { status }) }
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
export * as ConfigConsoleStateV1 from "./console-state"
|
|
||||||
|
|
||||||
import { Schema } from "effect"
|
|
||||||
import { NonNegativeInt } from "../../schema"
|
|
||||||
|
|
||||||
export class ConsoleState extends Schema.Class<ConsoleState>("ConsoleState")({
|
|
||||||
consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
|
|
||||||
activeOrgName: Schema.optional(Schema.String),
|
|
||||||
switchableOrgCount: NonNegativeInt,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export const emptyConsoleState: ConsoleState = ConsoleState.make({
|
|
||||||
consoleManagedProviders: [],
|
|
||||||
activeOrgName: undefined,
|
|
||||||
switchableOrgCount: 0,
|
|
||||||
})
|
|
||||||
@@ -1,291 +0,0 @@
|
|||||||
import { describe, expect } from "bun:test"
|
|
||||||
import { $ } from "bun"
|
|
||||||
import fs from "fs/promises"
|
|
||||||
import path from "path"
|
|
||||||
import { eq } from "drizzle-orm"
|
|
||||||
import { Effect, Layer } from "effect"
|
|
||||||
import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
|
|
||||||
import { Database } from "@opencode-ai/core/database/database"
|
|
||||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
|
||||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
|
||||||
import { Bus } from "@opencode-ai/core/bus"
|
|
||||||
import { Job } from "@opencode-ai/core/job"
|
|
||||||
import { Project } from "@opencode-ai/core/project"
|
|
||||||
import { ProjectDirectories } from "@opencode-ai/core/project/directories"
|
|
||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
|
||||||
import { Session } from "@opencode-ai/core/session"
|
|
||||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
|
||||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
|
||||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
|
||||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
|
||||||
import { tmpdir } from "./fixture/tmpdir"
|
|
||||||
import { testEffect } from "./lib/effect"
|
|
||||||
|
|
||||||
// Records the execution serialization a move must perform before relocating.
|
|
||||||
const executionCalls: string[] = []
|
|
||||||
const recordingExecution = Layer.succeed(
|
|
||||||
SessionExecution.Service,
|
|
||||||
SessionExecution.Service.of({
|
|
||||||
active: Effect.succeed(new Set()),
|
|
||||||
resume: () => Effect.void,
|
|
||||||
wake: () => Effect.void,
|
|
||||||
interrupt: (sessionID) => Effect.sync(() => void executionCalls.push(`interrupt:${sessionID}`)),
|
|
||||||
awaitIdle: (sessionID) => Effect.sync(() => void executionCalls.push(`awaitIdle:${sessionID}`)),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const it = testEffect(
|
|
||||||
AppNodeBuilder.build(
|
|
||||||
LayerNode.group([
|
|
||||||
MoveSession.node,
|
|
||||||
Database.node,
|
|
||||||
Bus.node,
|
|
||||||
ProjectDirectories.node,
|
|
||||||
Project.node,
|
|
||||||
Session.node,
|
|
||||||
SessionProjector.node,
|
|
||||||
SessionStore.node,
|
|
||||||
]),
|
|
||||||
[[SessionExecution.node, recordingExecution]],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
function abs(input: string) {
|
|
||||||
return AbsolutePath.make(input)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function initRepo(directory: string) {
|
|
||||||
await $`git init`.cwd(directory).quiet()
|
|
||||||
await $`git config core.autocrlf false`.cwd(directory).quiet()
|
|
||||||
await $`git config core.fsmonitor false`.cwd(directory).quiet()
|
|
||||||
await $`git config commit.gpgsign false`.cwd(directory).quiet()
|
|
||||||
await $`git config user.email test@opencode.test`.cwd(directory).quiet()
|
|
||||||
await $`git config user.name Test`.cwd(directory).quiet()
|
|
||||||
await fs.writeFile(path.join(directory, "tracked.txt"), "initial\n")
|
|
||||||
await $`git add tracked.txt`.cwd(directory).quiet()
|
|
||||||
await $`git commit -m root`.cwd(directory).quiet()
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("MoveSession", () => {
|
|
||||||
it.live("moves session changes to another project directory", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const root = yield* Effect.acquireRelease(
|
|
||||||
Effect.promise(() => tmpdir()),
|
|
||||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
|
||||||
)
|
|
||||||
yield* Effect.promise(() => initRepo(root.path))
|
|
||||||
const source = abs(yield* Effect.promise(() => fs.realpath(root.path)))
|
|
||||||
const destination = abs(`${root.path}-move-destination`)
|
|
||||||
yield* Effect.addFinalizer(() =>
|
|
||||||
Effect.promise(() => fs.rm(destination, { recursive: true, force: true })).pipe(Effect.ignore),
|
|
||||||
)
|
|
||||||
yield* Effect.promise(() => $`git worktree add --detach ${destination} HEAD`.cwd(root.path).quiet())
|
|
||||||
const moved = abs(yield* Effect.promise(() => fs.realpath(destination)))
|
|
||||||
yield* Effect.promise(() => fs.writeFile(path.join(source, "tracked.txt"), "changed\n"))
|
|
||||||
yield* Effect.promise(() => fs.writeFile(path.join(source, "untracked.txt"), "new\n"))
|
|
||||||
|
|
||||||
const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id
|
|
||||||
const sessionID = Session.ID.make("ses_move")
|
|
||||||
const { db } = yield* Database.Service
|
|
||||||
yield* db
|
|
||||||
.insert(SessionTable)
|
|
||||||
.values({
|
|
||||||
id: sessionID,
|
|
||||||
project_id: projectID,
|
|
||||||
slug: "move",
|
|
||||||
directory: source,
|
|
||||||
title: "move",
|
|
||||||
version: "test",
|
|
||||||
time_created: 1,
|
|
||||||
time_updated: 1,
|
|
||||||
})
|
|
||||||
.run()
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
|
|
||||||
executionCalls.length = 0
|
|
||||||
yield* MoveSession.Service.use((service) =>
|
|
||||||
service.moveSession({ sessionID, destination: { directory: moved }, moveChanges: true }),
|
|
||||||
)
|
|
||||||
|
|
||||||
// The move stops active execution before any relocation side effect.
|
|
||||||
expect(executionCalls).toEqual([`interrupt:${sessionID}`, `awaitIdle:${sessionID}`])
|
|
||||||
expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "tracked.txt"), "utf8"))).toBe("changed\n")
|
|
||||||
expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "untracked.txt"), "utf8"))).toBe("new\n")
|
|
||||||
expect(yield* Effect.promise(() => fs.readFile(path.join(source, "tracked.txt"), "utf8"))).toBe("initial\n")
|
|
||||||
expect(yield* Effect.promise(() => Bun.file(path.join(source, "untracked.txt")).exists())).toBe(false)
|
|
||||||
expect(
|
|
||||||
yield* db
|
|
||||||
.select({ directory: SessionTable.directory, path: SessionTable.path })
|
|
||||||
.from(SessionTable)
|
|
||||||
.where(eq(SessionTable.id, sessionID))
|
|
||||||
.get(),
|
|
||||||
).toEqual({ directory: moved, path: "" })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("moves within a checkout without transferring existing changes", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const root = yield* Effect.acquireRelease(
|
|
||||||
Effect.promise(() => tmpdir()),
|
|
||||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
|
||||||
)
|
|
||||||
yield* Effect.promise(() => initRepo(root.path))
|
|
||||||
const source = abs(yield* Effect.promise(() => fs.realpath(root.path)))
|
|
||||||
const destination = abs(path.join(source, "packages"))
|
|
||||||
yield* Effect.promise(() => fs.writeFile(path.join(source, "tracked.txt"), "changed\n"))
|
|
||||||
yield* Effect.promise(() => fs.writeFile(path.join(source, "untracked.txt"), "new\n"))
|
|
||||||
|
|
||||||
const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id
|
|
||||||
const sessionID = Session.ID.make("ses_move_nested")
|
|
||||||
const { db } = yield* Database.Service
|
|
||||||
yield* db
|
|
||||||
.insert(SessionTable)
|
|
||||||
.values({
|
|
||||||
id: sessionID,
|
|
||||||
project_id: projectID,
|
|
||||||
slug: "move-nested",
|
|
||||||
directory: source,
|
|
||||||
title: "move nested",
|
|
||||||
version: "test",
|
|
||||||
time_created: 1,
|
|
||||||
time_updated: 1,
|
|
||||||
})
|
|
||||||
.run()
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
|
|
||||||
const missing = yield* Session.Service.use((service) =>
|
|
||||||
service.move({ sessionID, directory: abs("packages") }).pipe(Effect.flip),
|
|
||||||
)
|
|
||||||
expect(missing._tag).toBe("Session.DestinationNotFoundError")
|
|
||||||
yield* Effect.promise(() => fs.mkdir(destination))
|
|
||||||
|
|
||||||
yield* MoveSession.Service.use((service) =>
|
|
||||||
service.moveSession({ sessionID, destination: { directory: abs("packages") }, moveChanges: true }),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(yield* Effect.promise(() => fs.readFile(path.join(source, "tracked.txt"), "utf8"))).toBe("changed\n")
|
|
||||||
expect(yield* Effect.promise(() => fs.readFile(path.join(source, "untracked.txt"), "utf8"))).toBe("new\n")
|
|
||||||
expect(
|
|
||||||
yield* db
|
|
||||||
.select({ directory: SessionTable.directory, path: SessionTable.path })
|
|
||||||
.from(SessionTable)
|
|
||||||
.where(eq(SessionTable.id, sessionID))
|
|
||||||
.get(),
|
|
||||||
).toEqual({ directory: destination, path: "packages" })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("moves a session to another project", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const root = yield* Effect.acquireRelease(
|
|
||||||
Effect.promise(() => tmpdir()),
|
|
||||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
|
||||||
)
|
|
||||||
yield* Effect.promise(() => initRepo(root.path))
|
|
||||||
const source = abs(yield* Effect.promise(() => fs.realpath(root.path)))
|
|
||||||
const destination = abs(`${root.path}-other-project`)
|
|
||||||
yield* Effect.acquireRelease(
|
|
||||||
Effect.promise(() => fs.mkdir(destination, { recursive: true })),
|
|
||||||
() => Effect.promise(() => fs.rm(destination, { recursive: true, force: true })),
|
|
||||||
)
|
|
||||||
|
|
||||||
const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id
|
|
||||||
const destinationProjectID = (yield* Project.Service.use((service) => service.resolve(destination))).id
|
|
||||||
const sessionID = Session.ID.make("ses_move_project")
|
|
||||||
const { db } = yield* Database.Service
|
|
||||||
yield* db
|
|
||||||
.insert(SessionTable)
|
|
||||||
.values({
|
|
||||||
id: sessionID,
|
|
||||||
project_id: projectID,
|
|
||||||
slug: "move-project",
|
|
||||||
directory: source,
|
|
||||||
title: "move project",
|
|
||||||
version: "test",
|
|
||||||
time_created: 1,
|
|
||||||
time_updated: 1,
|
|
||||||
})
|
|
||||||
.run()
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
|
|
||||||
yield* Session.Service.use((service) =>
|
|
||||||
service.move({ sessionID, directory: destination }),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(
|
|
||||||
yield* db
|
|
||||||
.select({ projectID: SessionTable.project_id, directory: SessionTable.directory })
|
|
||||||
.from(SessionTable)
|
|
||||||
.where(eq(SessionTable.id, sessionID))
|
|
||||||
.get(),
|
|
||||||
).toEqual({ projectID: destinationProjectID, directory: destination })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("moves nested session changes without cleaning unrelated files", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const root = yield* Effect.acquireRelease(
|
|
||||||
Effect.promise(() => tmpdir()),
|
|
||||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
|
||||||
)
|
|
||||||
yield* Effect.promise(() => initRepo(root.path))
|
|
||||||
const source = abs(yield* Effect.promise(() => fs.realpath(root.path)))
|
|
||||||
const sourceDirectory = abs(path.join(source, "packages"))
|
|
||||||
yield* Effect.promise(() => fs.mkdir(sourceDirectory))
|
|
||||||
yield* Effect.promise(() => fs.writeFile(path.join(sourceDirectory, "tracked.txt"), "initial\n"))
|
|
||||||
yield* Effect.promise(() => fs.writeFile(path.join(sourceDirectory, "staged.txt"), "initial\n"))
|
|
||||||
yield* Effect.promise(() => $`git add packages/tracked.txt packages/staged.txt`.cwd(source).quiet())
|
|
||||||
yield* Effect.promise(() => $`git commit -m packages`.cwd(source).quiet())
|
|
||||||
const destination = abs(`${root.path}-move-nested-destination`)
|
|
||||||
yield* Effect.addFinalizer(() =>
|
|
||||||
Effect.promise(() => fs.rm(destination, { recursive: true, force: true })).pipe(Effect.ignore),
|
|
||||||
)
|
|
||||||
yield* Effect.promise(() => $`git worktree add --detach ${destination} HEAD`.cwd(source).quiet())
|
|
||||||
const moved = abs(path.join(yield* Effect.promise(() => fs.realpath(destination)), "packages"))
|
|
||||||
yield* Effect.promise(() => fs.writeFile(path.join(sourceDirectory, "tracked.txt"), "changed\n"))
|
|
||||||
yield* Effect.promise(() => fs.writeFile(path.join(sourceDirectory, "staged.txt"), "staged\n"))
|
|
||||||
yield* Effect.promise(() => $`git add packages/staged.txt`.cwd(source).quiet())
|
|
||||||
yield* Effect.promise(() => fs.writeFile(path.join(sourceDirectory, "untracked.txt"), "new\n"))
|
|
||||||
yield* Effect.promise(() => fs.writeFile(path.join(source, "tracked.txt"), "unrelated\n"))
|
|
||||||
yield* Effect.promise(() => fs.writeFile(path.join(source, "untracked.txt"), "unrelated\n"))
|
|
||||||
|
|
||||||
const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id
|
|
||||||
const sessionID = Session.ID.make("ses_move_nested_checkout")
|
|
||||||
const { db } = yield* Database.Service
|
|
||||||
yield* db
|
|
||||||
.insert(SessionTable)
|
|
||||||
.values({
|
|
||||||
id: sessionID,
|
|
||||||
project_id: projectID,
|
|
||||||
slug: "move-nested-checkout",
|
|
||||||
directory: sourceDirectory,
|
|
||||||
title: "move nested checkout",
|
|
||||||
version: "test",
|
|
||||||
time_created: 1,
|
|
||||||
time_updated: 1,
|
|
||||||
})
|
|
||||||
.run()
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
|
|
||||||
yield* MoveSession.Service.use((service) =>
|
|
||||||
service.moveSession({ sessionID, destination: { directory: moved }, moveChanges: true }),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "tracked.txt"), "utf8"))).toBe("changed\n")
|
|
||||||
expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "staged.txt"), "utf8"))).toBe("staged\n")
|
|
||||||
expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "untracked.txt"), "utf8"))).toBe("new\n")
|
|
||||||
expect(yield* Effect.promise(() => fs.readFile(path.join(sourceDirectory, "tracked.txt"), "utf8"))).toBe(
|
|
||||||
"initial\n",
|
|
||||||
)
|
|
||||||
expect(yield* Effect.promise(() => Bun.file(path.join(sourceDirectory, "untracked.txt")).exists())).toBe(false)
|
|
||||||
expect(yield* Effect.promise(() => fs.readFile(path.join(sourceDirectory, "staged.txt"), "utf8"))).toBe(
|
|
||||||
"staged\n",
|
|
||||||
)
|
|
||||||
expect(yield* Effect.promise(() => $`git status --porcelain -- packages/staged.txt`.cwd(source).text())).toBe(
|
|
||||||
"M packages/staged.txt\n",
|
|
||||||
)
|
|
||||||
expect(yield* Effect.promise(() => fs.readFile(path.join(source, "tracked.txt"), "utf8"))).toBe("unrelated\n")
|
|
||||||
expect(yield* Effect.promise(() => fs.readFile(path.join(source, "untracked.txt"), "utf8"))).toBe("unrelated\n")
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Message, SystemPart } from "@opencode-ai/ai"
|
import { Message, SystemPart } from "@opencode-ai/ai"
|
||||||
import { DateTime, Effect, Schema } from "effect"
|
import { DateTime, Deferred, Effect, Fiber, Schema } from "effect"
|
||||||
import { Agent } from "@opencode-ai/core/agent"
|
import { Agent } from "@opencode-ai/core/agent"
|
||||||
import { Catalog } from "@opencode-ai/core/catalog"
|
import { Catalog } from "@opencode-ai/core/catalog"
|
||||||
import { Model } from "@opencode-ai/core/model"
|
import { Model } from "@opencode-ai/core/model"
|
||||||
@@ -148,7 +148,9 @@ describe("fromPromise", () => {
|
|||||||
expect((await ctx.agent.get({ agentID: Agent.ID.make("reviewer") })).data).toMatchObject({
|
expect((await ctx.agent.get({ agentID: Agent.ID.make("reviewer") })).data).toMatchObject({
|
||||||
description: "Reviews code",
|
description: "Reviews code",
|
||||||
})
|
})
|
||||||
await expect(ctx.agent.get({ agentID: Agent.ID.make("missing") })).rejects.toThrow("Agent not found: missing")
|
await expect(ctx.agent.get({ agentID: Agent.ID.make("missing") })).rejects.toThrow(
|
||||||
|
"Agent not found: missing",
|
||||||
|
)
|
||||||
const models = (await ctx.catalog.model.list()).data
|
const models = (await ctx.catalog.model.list()).data
|
||||||
expect(models.find((model) => model.providerID === "test" && model.id === "alias")).toMatchObject({
|
expect(models.find((model) => model.providerID === "test" && model.id === "alias")).toMatchObject({
|
||||||
modelID: "gpt-5",
|
modelID: "gpt-5",
|
||||||
@@ -221,6 +223,77 @@ describe("fromPromise", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("adapts promise session HTTP hooks", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const plugin = yield* Plugin.Service
|
||||||
|
const hooks = yield* PluginHooks.Service
|
||||||
|
const host = yield* PluginHost.make(plugin)
|
||||||
|
yield* PluginPromise.fromPromise(
|
||||||
|
define({
|
||||||
|
id: "promise-session-http",
|
||||||
|
setup: async (ctx) => {
|
||||||
|
await ctx.session.hook("http", (event) => {
|
||||||
|
const request = event.request
|
||||||
|
event.request = async (input) => {
|
||||||
|
const response = await request(new Request(input, { headers: { "x-hook": "promise" } }))
|
||||||
|
return new Response(`${await response.text()}-response`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).effect(host)
|
||||||
|
const event: SessionHooks["http"] = {
|
||||||
|
sessionID: Session.ID.make("ses_promise_session_http"),
|
||||||
|
agent: Agent.ID.make("build"),
|
||||||
|
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
|
||||||
|
request: (input) => Effect.succeed(new Response(input.headers.get("x-hook") ?? "missing")),
|
||||||
|
}
|
||||||
|
|
||||||
|
yield* hooks.trigger("session", "http", event)
|
||||||
|
const response = yield* event.request(new Request("https://provider.test"))
|
||||||
|
|
||||||
|
expect(yield* Effect.promise(() => response.text())).toBe("promise-response")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("interrupts the Effect request through a promise session HTTP hook", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const plugin = yield* Plugin.Service
|
||||||
|
const hooks = yield* PluginHooks.Service
|
||||||
|
const host = yield* PluginHost.make(plugin)
|
||||||
|
yield* PluginPromise.fromPromise(
|
||||||
|
define({
|
||||||
|
id: "promise-session-http-interrupt",
|
||||||
|
setup: async (ctx) => {
|
||||||
|
await ctx.session.hook("http", (event) => {
|
||||||
|
const request = event.request
|
||||||
|
event.request = (input) => request(input)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).effect(host)
|
||||||
|
const started = yield* Deferred.make<void>()
|
||||||
|
const interrupted = yield* Deferred.make<void>()
|
||||||
|
const event: SessionHooks["http"] = {
|
||||||
|
sessionID: Session.ID.make("ses_promise_session_http_interrupt"),
|
||||||
|
agent: Agent.ID.make("build"),
|
||||||
|
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
|
||||||
|
request: () =>
|
||||||
|
Deferred.succeed(started, undefined).pipe(
|
||||||
|
Effect.andThen(Effect.never),
|
||||||
|
Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
yield* hooks.trigger("session", "http", event)
|
||||||
|
const fiber = yield* event.request(new Request("https://provider.test")).pipe(Effect.forkChild)
|
||||||
|
yield* Deferred.await(started)
|
||||||
|
yield* Fiber.interrupt(fiber)
|
||||||
|
|
||||||
|
expect(yield* Deferred.isDone(interrupted)).toBeTrue()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("disposes a hook registration on request", () =>
|
it.effect("disposes a hook registration on request", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const agents = yield* Agent.Service
|
const agents = yield* Agent.Service
|
||||||
@@ -315,19 +388,17 @@ describe("fromPromise", () => {
|
|||||||
id: "promise-tool",
|
id: "promise-tool",
|
||||||
setup: async (ctx) => {
|
setup: async (ctx) => {
|
||||||
await ctx.tool.transform((tools) => {
|
await ctx.tool.transform((tools) => {
|
||||||
tools.add(
|
tools.add({
|
||||||
{
|
name: "hello",
|
||||||
name: "hello",
|
options: { codemode: false },
|
||||||
options: { codemode: false },
|
description: "Hello",
|
||||||
description: "Hello",
|
input: Schema.Struct({ name: Schema.String }),
|
||||||
input: Schema.Struct({ name: Schema.String }),
|
output: Schema.String,
|
||||||
output: Schema.String,
|
execute: async ({ name }, context) => {
|
||||||
execute: async ({ name }, context) => {
|
await context.progress({ phase: "greeting" })
|
||||||
await context.progress({ phase: "greeting" })
|
return { output: `Hello, ${name}!` }
|
||||||
return { output: `Hello, ${name}!` }
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
)
|
})
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -29,6 +29,21 @@ function required<T>(value: T | undefined): T {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const http = Effect.fn(function* (providerID: Provider.ID, url: string) {
|
||||||
|
const event = yield* (yield* PluginHooks.Service).trigger("session", "http", {
|
||||||
|
sessionID: Session.ID.make("ses_test"),
|
||||||
|
agent: Agent.ID.make("build"),
|
||||||
|
model: Model.Ref.make({ providerID, id: Model.ID.make("gpt-5.5") }),
|
||||||
|
request: (input) => {
|
||||||
|
const headers = new Headers(input.headers)
|
||||||
|
headers.set("x-seen-url", input.url)
|
||||||
|
return Effect.succeed(new Response(null, { headers }))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const response = yield* event.request(new Request(url, { method: "POST", body: "{}" }))
|
||||||
|
return { url: response.headers.get("x-seen-url"), headers: Object.fromEntries(response.headers.entries()) }
|
||||||
|
})
|
||||||
|
|
||||||
describe("OpenAIPlugin", () => {
|
describe("OpenAIPlugin", () => {
|
||||||
it.effect("registers browser and headless ChatGPT OAuth methods", () =>
|
it.effect("registers browser and headless ChatGPT OAuth methods", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -100,33 +115,9 @@ describe("OpenAIPlugin", () => {
|
|||||||
})
|
})
|
||||||
yield* addPlugin()
|
yield* addPlugin()
|
||||||
|
|
||||||
const request = yield* (yield* PluginHooks.Service).trigger("session", "request", {
|
const request = yield* http(Provider.ID.openai, "https://api.openai.com/v1/responses")
|
||||||
sessionID: Session.ID.make("ses_test"),
|
const custom = yield* http(Provider.ID.make("custom-openai"), "https://custom.example/v1/responses")
|
||||||
agent: Agent.ID.make("build"),
|
const proxy = yield* http(Provider.ID.openai, "https://proxy.example/v1/responses?region=us")
|
||||||
model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }),
|
|
||||||
url: "https://api.openai.com/v1/responses",
|
|
||||||
method: "POST",
|
|
||||||
headers: {},
|
|
||||||
body: "{}",
|
|
||||||
})
|
|
||||||
const custom = yield* (yield* PluginHooks.Service).trigger("session", "request", {
|
|
||||||
sessionID: Session.ID.make("ses_test"),
|
|
||||||
agent: Agent.ID.make("build"),
|
|
||||||
model: Model.Ref.make({ providerID: Provider.ID.make("custom-openai"), id: Model.ID.make("gpt-5.5") }),
|
|
||||||
url: "https://custom.example/v1/responses",
|
|
||||||
method: "POST",
|
|
||||||
headers: {},
|
|
||||||
body: "{}",
|
|
||||||
})
|
|
||||||
const proxy = yield* (yield* PluginHooks.Service).trigger("session", "request", {
|
|
||||||
sessionID: Session.ID.make("ses_test"),
|
|
||||||
agent: Agent.ID.make("build"),
|
|
||||||
model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }),
|
|
||||||
url: "https://proxy.example/v1/responses?region=us",
|
|
||||||
method: "POST",
|
|
||||||
headers: {},
|
|
||||||
body: "{}",
|
|
||||||
})
|
|
||||||
|
|
||||||
const provider = required(yield* catalog.provider.get(Provider.ID.openai))
|
const provider = required(yield* catalog.provider.get(Provider.ID.openai))
|
||||||
expect(provider.package).toBe("@opencode-ai/ai/providers/openai")
|
expect(provider.package).toBe("@opencode-ai/ai/providers/openai")
|
||||||
@@ -134,7 +125,7 @@ describe("OpenAIPlugin", () => {
|
|||||||
expect(provider.headers).toMatchObject({ "chatgpt-account-id": "acct_123" })
|
expect(provider.headers).toMatchObject({ "chatgpt-account-id": "acct_123" })
|
||||||
expect(request.url).toBe("https://chatgpt.com/backend-api/codex/responses")
|
expect(request.url).toBe("https://chatgpt.com/backend-api/codex/responses")
|
||||||
expect(request.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
|
expect(request.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
|
||||||
expect(custom.headers).toEqual({})
|
expect(custom.headers).not.toHaveProperty("originator")
|
||||||
expect(proxy.url).toBe("https://proxy.example/v1/responses?region=us")
|
expect(proxy.url).toBe("https://proxy.example/v1/responses?region=us")
|
||||||
expect(proxy.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
|
expect(proxy.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
|
||||||
const eligible = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
|
const eligible = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
|
||||||
@@ -184,21 +175,13 @@ describe("OpenAIPlugin", () => {
|
|||||||
})
|
})
|
||||||
yield* addPlugin()
|
yield* addPlugin()
|
||||||
|
|
||||||
const request = yield* (yield* PluginHooks.Service).trigger("session", "request", {
|
const request = yield* http(Provider.ID.openai, "https://api.openai.com/v1/responses")
|
||||||
sessionID: Session.ID.make("ses_test"),
|
|
||||||
agent: Agent.ID.make("build"),
|
|
||||||
model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }),
|
|
||||||
url: "https://api.openai.com/v1/responses",
|
|
||||||
method: "POST",
|
|
||||||
headers: {},
|
|
||||||
body: "{}",
|
|
||||||
})
|
|
||||||
|
|
||||||
const model = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
|
const model = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
|
||||||
expect(model.package).toBe("@opencode-ai/ai/providers/openai")
|
expect(model.package).toBe("@opencode-ai/ai/providers/openai")
|
||||||
expect(model.enabled).toBe(true)
|
expect(model.enabled).toBe(true)
|
||||||
expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 })
|
expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 })
|
||||||
expect(request.headers).toEqual({})
|
expect(request.headers).not.toHaveProperty("originator")
|
||||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(true)
|
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(true)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ import {
|
|||||||
TransportReason,
|
TransportReason,
|
||||||
UnknownProviderReason,
|
UnknownProviderReason,
|
||||||
ToolFailure,
|
ToolFailure,
|
||||||
|
HttpContext,
|
||||||
|
HttpRequestDetails,
|
||||||
|
HttpResponseDetails,
|
||||||
} from "@opencode-ai/ai"
|
} from "@opencode-ai/ai"
|
||||||
import { Permission } from "@opencode-ai/core/permission"
|
import { Permission } from "@opencode-ai/core/permission"
|
||||||
import { Tool } from "@opencode-ai/schema/tool"
|
import { Tool } from "@opencode-ai/schema/tool"
|
||||||
@@ -71,6 +74,23 @@ describe("toSessionError", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("preserves provider HTTP status", () => {
|
||||||
|
const http = new HttpContext({
|
||||||
|
request: new HttpRequestDetails({ method: "POST", url: "https://example.com", headers: {} }),
|
||||||
|
response: new HttpResponseDetails({ status: 413, headers: {} }),
|
||||||
|
})
|
||||||
|
expect(toSessionError(llm(new InvalidRequestReason({ message: "too large", http })))).toEqual({
|
||||||
|
type: "provider.invalid-request",
|
||||||
|
message: "too large",
|
||||||
|
status: 413,
|
||||||
|
})
|
||||||
|
expect(toSessionError(llm(new ProviderInternalReason({ message: "bad gateway", status: 502 })))).toEqual({
|
||||||
|
type: "provider.internal",
|
||||||
|
message: "bad gateway",
|
||||||
|
status: 502,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test("retries only rate limits, provider-internal failures, and transport failures", () => {
|
test("retries only rate limits, provider-internal failures, and transport failures", () => {
|
||||||
const eligible = [
|
const eligible = [
|
||||||
llm(new RateLimitReason({ message: "rate" })),
|
llm(new RateLimitReason({ message: "rate" })),
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import type { SessionApi } from "@opencode-ai/client/effect/api"
|
import type { SessionApi } from "@opencode-ai/client/effect/api"
|
||||||
import type { Message, SystemPart } from "@opencode-ai/ai"
|
import type { Message, SystemPart } from "@opencode-ai/ai"
|
||||||
import type { HttpRequest } from "@opencode-ai/ai/route"
|
|
||||||
import type { Agent } from "@opencode-ai/schema/agent"
|
import type { Agent } from "@opencode-ai/schema/agent"
|
||||||
import type { Model } from "@opencode-ai/schema/model"
|
import type { Model } from "@opencode-ai/schema/model"
|
||||||
import type { Session } from "@opencode-ai/schema/session"
|
import type { Session } from "@opencode-ai/schema/session"
|
||||||
import type { JsonSchema } from "effect"
|
import type { Effect, JsonSchema } from "effect"
|
||||||
import type { Hooks } from "./registration.js"
|
import type { Hooks } from "./registration.js"
|
||||||
|
|
||||||
export interface SessionContext {
|
export interface SessionContext {
|
||||||
@@ -16,15 +15,16 @@ export interface SessionContext {
|
|||||||
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
|
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionRequest extends HttpRequest {
|
export interface SessionHttp {
|
||||||
readonly sessionID: Session.ID
|
readonly sessionID: Session.ID
|
||||||
readonly agent: Agent.ID
|
readonly agent: Agent.ID
|
||||||
readonly model: Model.Ref
|
readonly model: Model.Ref
|
||||||
|
request: (input: Request) => Effect.Effect<Response, Error>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionHooks {
|
export interface SessionHooks {
|
||||||
readonly context: SessionContext
|
readonly context: SessionContext
|
||||||
readonly request: SessionRequest
|
readonly http: SessionHttp
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionDomain = Pick<
|
export type SessionDomain = Pick<
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { SessionApi } from "@opencode-ai/client/promise/api"
|
import type { SessionApi } from "@opencode-ai/client/promise/api"
|
||||||
import type { Message, SystemPart } from "@opencode-ai/ai"
|
import type { Message, SystemPart } from "@opencode-ai/ai"
|
||||||
import type { HttpRequest } from "@opencode-ai/ai/route"
|
|
||||||
import type { Agent } from "@opencode-ai/schema/agent"
|
import type { Agent } from "@opencode-ai/schema/agent"
|
||||||
import type { Model } from "@opencode-ai/schema/model"
|
import type { Model } from "@opencode-ai/schema/model"
|
||||||
import type { Session } from "@opencode-ai/schema/session"
|
import type { Session } from "@opencode-ai/schema/session"
|
||||||
@@ -16,15 +15,16 @@ export interface SessionContext {
|
|||||||
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
|
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionRequest extends HttpRequest {
|
export interface SessionHttp {
|
||||||
readonly sessionID: Session.ID
|
readonly sessionID: Session.ID
|
||||||
readonly agent: Agent.ID
|
readonly agent: Agent.ID
|
||||||
readonly model: Model.Ref
|
readonly model: Model.Ref
|
||||||
|
request: (input: Request) => Promise<Response>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionHooks {
|
export interface SessionHooks {
|
||||||
readonly context: SessionContext
|
readonly context: SessionContext
|
||||||
readonly request: SessionRequest
|
readonly http: SessionHttp
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionDomain = Pick<
|
export type SessionDomain = Pick<
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
export * as SessionError from "./session-error.js"
|
export * as SessionError from "./session-error.js"
|
||||||
|
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
|
import { optional } from "./schema.js"
|
||||||
|
|
||||||
export interface Error extends Schema.Schema.Type<typeof Error> {}
|
export interface Error extends Schema.Schema.Type<typeof Error> {}
|
||||||
export const Error = Schema.Struct({
|
export const Error = Schema.Struct({
|
||||||
type: Schema.String,
|
type: Schema.String,
|
||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
|
status: Schema.Int.check(Schema.isBetween({ minimum: 100, maximum: 599 })).pipe(optional),
|
||||||
}).annotate({ identifier: "Session.StructuredError" })
|
}).annotate({ identifier: "Session.StructuredError" })
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export type {
|
|||||||
StatefulColor,
|
StatefulColor,
|
||||||
} from "./types.js"
|
} from "./types.js"
|
||||||
export { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults.js"
|
export { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults.js"
|
||||||
|
export { expandTheme } from "./expand.js"
|
||||||
export { migrateV1 } from "./v1-migrate.js"
|
export { migrateV1 } from "./v1-migrate.js"
|
||||||
export { resolveTheme, resolveThemeDocument, themeDecodeError } from "./resolve.js"
|
export { resolveTheme, resolveThemeDocument, themeDecodeError } from "./resolve.js"
|
||||||
export { selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "./select.js"
|
export { selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "./select.js"
|
||||||
|
|||||||
@@ -93,7 +93,6 @@
|
|||||||
"@opentui/solid": "catalog:",
|
"@opentui/solid": "catalog:",
|
||||||
"@solid-primitives/event-bus": "1.1.2",
|
"@solid-primitives/event-bus": "1.1.2",
|
||||||
"clipboardy": "4.0.0",
|
"clipboardy": "4.0.0",
|
||||||
"diff": "catalog:",
|
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"fuzzysort": "catalog:",
|
"fuzzysort": "catalog:",
|
||||||
"get-east-asian-width": "catalog:",
|
"get-east-asian-width": "catalog:",
|
||||||
|
|||||||
@@ -72,9 +72,9 @@ import { ThemeErrorToast } from "./component/theme-error-toast"
|
|||||||
import { ThemeProvider, useTheme, useThemes } from "./context/theme"
|
import { ThemeProvider, useTheme, useThemes } from "./context/theme"
|
||||||
import { Home } from "./routes/home"
|
import { Home } from "./routes/home"
|
||||||
import { Session } from "./routes/session"
|
import { Session } from "./routes/session"
|
||||||
import { PromptHistoryProvider } from "./component/prompt/history"
|
import { PromptHistoryProvider } from "./prompt/history"
|
||||||
import { FrecencyProvider } from "./component/prompt/frecency"
|
import { FrecencyProvider } from "./prompt/frecency"
|
||||||
import { PromptStashProvider } from "./component/prompt/stash"
|
import { PromptStashProvider } from "./prompt/stash"
|
||||||
import { Toast, ToastProvider, useToast } from "./ui/toast"
|
import { Toast, ToastProvider, useToast } from "./ui/toast"
|
||||||
import { isFallbackTitle } from "@opencode-ai/util/session-title-fallback"
|
import { isFallbackTitle } from "@opencode-ai/util/session-title-fallback"
|
||||||
import * as Model from "./util/model"
|
import * as Model from "./util/model"
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import type {
|
|||||||
TuiAttentionNotifyResult,
|
TuiAttentionNotifyResult,
|
||||||
TuiAttentionNotifySkipReason,
|
TuiAttentionNotifySkipReason,
|
||||||
TuiAttentionWhen,
|
TuiAttentionWhen,
|
||||||
TuiKV,
|
|
||||||
TuiAttentionSoundName,
|
TuiAttentionSoundName,
|
||||||
TuiAttentionSoundPack,
|
TuiAttentionSoundPack,
|
||||||
TuiAttentionSoundPackInfo,
|
TuiAttentionSoundPackInfo,
|
||||||
@@ -115,8 +114,6 @@ export function createTuiAttention(input: {
|
|||||||
renderer: AttentionRenderer
|
renderer: AttentionRenderer
|
||||||
config: Pick<Config.Resolved, "attention">
|
config: Pick<Config.Resolved, "attention">
|
||||||
update?: Config.Interface["update"]
|
update?: Config.Interface["update"]
|
||||||
/** @deprecated Ignored. Sound-pack persistence uses CLI config. */
|
|
||||||
kv?: TuiKV
|
|
||||||
audio?: Pick<typeof TuiAudio, "loadSoundFile" | "play">
|
audio?: Pick<typeof TuiAudio, "loadSoundFile" | "play">
|
||||||
}): TuiAttentionHost {
|
}): TuiAttentionHost {
|
||||||
let focus: FocusState = "unknown"
|
let focus: FocusState = "unknown"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Audio, type AudioErrorContext, type AudioPlayOptions, type AudioSound, type AudioVoice } from "@opentui/core"
|
import { Audio, type AudioErrorContext, type AudioPlayOptions, type AudioSound } from "@opentui/core"
|
||||||
import { readFile } from "node:fs/promises"
|
import { readFile } from "node:fs/promises"
|
||||||
|
|
||||||
let audio: Audio | null | undefined
|
let audio: Audio | null | undefined
|
||||||
@@ -42,10 +42,6 @@ export function play(sound: AudioSound, options?: AudioPlayOptions) {
|
|||||||
return current.play(sound, options)
|
return current.play(sound, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stopVoice(voice: AudioVoice) {
|
|
||||||
return audio?.stopVoice(voice) ?? false
|
|
||||||
}
|
|
||||||
|
|
||||||
export function dispose() {
|
export function dispose() {
|
||||||
audio?.dispose()
|
audio?.dispose()
|
||||||
audio = undefined
|
audio = undefined
|
||||||
|
|||||||
@@ -43,15 +43,6 @@ export const settings: Setting[] = [
|
|||||||
labels: ["off", "on"],
|
labels: ["off", "on"],
|
||||||
keywords: ["motion", "effects"],
|
keywords: ["motion", "effects"],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: "Onboarding",
|
|
||||||
category: "Appearance",
|
|
||||||
path: ["hints", "onboarding"],
|
|
||||||
default: true,
|
|
||||||
values: [false, true],
|
|
||||||
labels: ["off", "on"],
|
|
||||||
keywords: ["hints", "getting started", "guidance"],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: "Sidebar",
|
title: "Sidebar",
|
||||||
category: "Session",
|
category: "Session",
|
||||||
|
|||||||
@@ -63,15 +63,15 @@ export function DialogModel(props: { providerID?: string }) {
|
|||||||
.filter((model) => (props.providerID ? model.providerID === props.providerID : true))
|
.filter((model) => (props.providerID ? model.providerID === props.providerID : true))
|
||||||
.map((model) => {
|
.map((model) => {
|
||||||
const provider = providers().get(model.providerID)
|
const provider = providers().get(model.providerID)
|
||||||
|
const favorite = favorites.some((item) => item.providerID === model.providerID && item.modelID === model.id)
|
||||||
return {
|
return {
|
||||||
value: { providerID: model.providerID, modelID: model.id },
|
value: { providerID: model.providerID, modelID: model.id },
|
||||||
providerID: model.providerID,
|
providerID: model.providerID,
|
||||||
providerName: provider?.name ?? model.providerID,
|
providerName: provider?.name ?? model.providerID,
|
||||||
title: model.name,
|
title: model.name,
|
||||||
releaseDate: model.time.released,
|
releaseDate: model.time.released,
|
||||||
description: favorites.some((item) => item.providerID === model.providerID && item.modelID === model.id)
|
favorite,
|
||||||
? "(Favorite)"
|
description: favorite ? "(Favorite)" : undefined,
|
||||||
: undefined,
|
|
||||||
category: connected() ? (provider?.name ?? model.providerID) : undefined,
|
category: connected() ? (provider?.name ?? model.providerID) : undefined,
|
||||||
footer: free(model) ? "Free" : undefined,
|
footer: free(model) ? "Free" : undefined,
|
||||||
onSelect() {
|
onSelect() {
|
||||||
@@ -96,7 +96,9 @@ export function DialogModel(props: { providerID?: string }) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (needle) {
|
if (needle) {
|
||||||
return fuzzysort.go(needle, modelOptions, { keys: ["title", "category"] }).map((item) => item.obj)
|
return prioritizeFavorites(
|
||||||
|
fuzzysort.go(needle, modelOptions, { keys: ["title", "category"] }).map((item) => item.obj),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...favoriteOptions, ...recentOptions, ...modelOptions]
|
return [...favoriteOptions, ...recentOptions, ...modelOptions]
|
||||||
@@ -160,6 +162,10 @@ export function DialogModel(props: { providerID?: string }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function prioritizeFavorites<T extends { favorite: boolean }>(options: T[]) {
|
||||||
|
return options.toSorted((a, b) => Number(b.favorite) - Number(a.favorite))
|
||||||
|
}
|
||||||
|
|
||||||
export function sortModelOptions<
|
export function sortModelOptions<
|
||||||
T extends { providerID?: string; providerName?: string; releaseDate: string | number; title: string },
|
T extends { providerID?: string; providerName?: string; releaseDate: string | number; title: string },
|
||||||
>(options: T[]) {
|
>(options: T[]) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { createMemo, createSignal } from "solid-js"
|
|||||||
import { Locale } from "../util/locale"
|
import { Locale } from "../util/locale"
|
||||||
import { Keymap } from "../context/keymap"
|
import { Keymap } from "../context/keymap"
|
||||||
import { useTheme } from "../context/theme"
|
import { useTheme } from "../context/theme"
|
||||||
import { usePromptStash, type StashEntry } from "./prompt/stash"
|
import { usePromptStash, type StashEntry } from "../prompt/stash"
|
||||||
|
|
||||||
function getRelativeTime(timestamp: number): string {
|
function getRelativeTime(timestamp: number): string {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export * from "../../prompt/frecency"
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from "../../prompt/history"
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from "../../prompt/stash"
|
|
||||||
@@ -6,6 +6,7 @@ import { useSessionTabs } from "../context/session-tabs"
|
|||||||
import { useTheme, useThemes } from "../context/theme"
|
import { useTheme, useThemes } from "../context/theme"
|
||||||
import {
|
import {
|
||||||
adaptiveSessionTabLayout,
|
adaptiveSessionTabLayout,
|
||||||
|
moveSessionTab,
|
||||||
NEW_SESSION_TAB_TITLE,
|
NEW_SESSION_TAB_TITLE,
|
||||||
sessionTabComplete,
|
sessionTabComplete,
|
||||||
seedSessionTabMotion,
|
seedSessionTabMotion,
|
||||||
@@ -48,6 +49,10 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
|
|||||||
const animations = () => props.animations ?? config.animations ?? true
|
const animations = () => props.animations ?? config.animations ?? true
|
||||||
const [hovered, setHovered] = createSignal<string>()
|
const [hovered, setHovered] = createSignal<string>()
|
||||||
const [dragging, setDragging] = createSignal<string>()
|
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,
|
||||||
|
// so the strip never flashes the pre-drag order while the write is in flight.
|
||||||
|
const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>()
|
||||||
let strip: { screenX: number } | undefined
|
let strip: { screenX: number } | undefined
|
||||||
const hueStep = () => (mode() === "light" ? 800 : 200)
|
const hueStep = () => (mode() === "light" ? 800 : 200)
|
||||||
const accent = () => theme.hue.accent[hueStep()]
|
const accent = () => theme.hue.accent[hueStep()]
|
||||||
@@ -55,7 +60,18 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
|
|||||||
const idleNumber = () => tint(theme.text.subdued, theme.background.default, 0.35)
|
const idleNumber = () => tint(theme.text.subdued, theme.background.default, 0.35)
|
||||||
const newTab = () => tabs.newTab?.() ?? false
|
const newTab = () => tabs.newTab?.() ?? false
|
||||||
const activeID = createMemo(() => (newTab() ? NEW_SESSION_TAB.sessionID : tabs.current()))
|
const activeID = createMemo(() => (newTab() ? NEW_SESSION_TAB.sessionID : tabs.current()))
|
||||||
const items = createMemo(() => (newTab() ? [...tabs.tabs(), NEW_SESSION_TAB] : tabs.tabs()))
|
const ordered = createMemo(() => {
|
||||||
|
const pending = preview()
|
||||||
|
if (!pending) return tabs.tabs()
|
||||||
|
return moveSessionTab(tabs.tabs(), pending.sessionID, pending.index)
|
||||||
|
})
|
||||||
|
const items = createMemo(() => (newTab() ? [...ordered(), NEW_SESSION_TAB] : ordered()))
|
||||||
|
createEffect(() => {
|
||||||
|
const pending = preview()
|
||||||
|
if (!pending || dragging()) return
|
||||||
|
const index = tabs.tabs().findIndex((tab) => tab.sessionID === pending.sessionID)
|
||||||
|
if (index === -1 || index === Math.min(pending.index, tabs.tabs().length - 1)) setPreview(undefined)
|
||||||
|
})
|
||||||
const layout = createMemo((previous: ReturnType<typeof adaptiveSessionTabLayout> | undefined) =>
|
const layout = createMemo((previous: ReturnType<typeof adaptiveSessionTabLayout> | undefined) =>
|
||||||
adaptiveSessionTabLayout(items(), activeID(), dimensions().width, previous?.start),
|
adaptiveSessionTabLayout(items(), activeID(), dimensions().width, previous?.start),
|
||||||
)
|
)
|
||||||
@@ -279,6 +295,8 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
|
|||||||
// keeping sloppy clicks indistinguishable from clean ones.
|
// keeping sloppy clicks indistinguishable from clean ones.
|
||||||
const release = () => {
|
const release = () => {
|
||||||
setDragging(undefined)
|
setDragging(undefined)
|
||||||
|
const pending = preview()
|
||||||
|
if (pending?.sessionID === tab.sessionID) tabs.move(pending.sessionID, pending.index)
|
||||||
if (tab === NEW_SESSION_TAB) return
|
if (tab === NEW_SESSION_TAB) return
|
||||||
tabs.select(tab.sessionID)
|
tabs.select(tab.sessionID)
|
||||||
}
|
}
|
||||||
@@ -295,7 +313,8 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
|
|||||||
onMouseDrag={(event) => {
|
onMouseDrag={(event) => {
|
||||||
if (tab === NEW_SESSION_TAB) return
|
if (tab === NEW_SESSION_TAB) return
|
||||||
const slot = slotAt(event.x)
|
const slot = slotAt(event.x)
|
||||||
if (slot !== undefined && slot !== tabNumber() - 1) tabs.move(tab.sessionID, slot)
|
if (slot !== undefined && slot !== tabNumber() - 1)
|
||||||
|
setPreview({ sessionID: tab.sessionID, index: slot })
|
||||||
}}
|
}}
|
||||||
onMouseDragEnd={release}
|
onMouseDragEnd={release}
|
||||||
>
|
>
|
||||||
@@ -341,6 +360,9 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
|
|||||||
fg={closeColor()}
|
fg={closeColor()}
|
||||||
selectable={false}
|
selectable={false}
|
||||||
onMouseUp={(event) => {
|
onMouseUp={(event) => {
|
||||||
|
// The close mark only renders while hovered; without motion events a click can
|
||||||
|
// land here first, and must select the tab instead of closing it invisibly.
|
||||||
|
if (hovered() !== tab.sessionID) return
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
tabs.close(tab === NEW_SESSION_TAB ? undefined : tab.sessionID)
|
tabs.close(tab === NEW_SESSION_TAB ? undefined : tab.sessionID)
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -162,11 +162,6 @@ export const Info = Schema.Struct({
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
).annotate({ description: "Mini transcript presentation settings" }),
|
).annotate({ description: "Mini transcript presentation settings" }),
|
||||||
hints: Schema.optional(
|
|
||||||
Schema.Struct({
|
|
||||||
onboarding: Schema.optional(Schema.Boolean).annotate({ description: "Show getting-started guidance" }),
|
|
||||||
}),
|
|
||||||
).annotate({ description: "In-product guidance settings" }),
|
|
||||||
debug: Schema.optional(
|
debug: Schema.optional(
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
devtools: Schema.optional(Schema.Boolean).annotate({ description: "Show the DevTools debug bar" }),
|
devtools: Schema.optional(Schema.Boolean).annotate({ description: "Show the DevTools debug bar" }),
|
||||||
@@ -267,7 +262,3 @@ export function useConfig() {
|
|||||||
if (!value) throw new Error("ConfigProvider is missing")
|
if (!value) throw new Error("ConfigProvider is missing")
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useConfigOptional() {
|
|
||||||
return useContext(ConfigContext)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -105,8 +105,6 @@ export const Definitions = {
|
|||||||
session_compact: keybind("<leader>c", "Compact the session"),
|
session_compact: keybind("<leader>c", "Compact the session"),
|
||||||
session_queued_prompts: keybind("<leader>q", "View pending work"),
|
session_queued_prompts: keybind("<leader>q", "View pending work"),
|
||||||
session_child_first: keybind("down", "Toggle subagent picker"),
|
session_child_first: keybind("down", "Toggle subagent picker"),
|
||||||
session_child_cycle: keybind("right", "Go to next child session"),
|
|
||||||
session_child_cycle_reverse: keybind("left", "Go to previous child session"),
|
|
||||||
session_parent: keybind("up", "Go to parent session"),
|
session_parent: keybind("up", "Go to parent session"),
|
||||||
session_pin_toggle: keybind("ctrl+f", "Pin or unpin session in the session list"),
|
session_pin_toggle: keybind("ctrl+f", "Pin or unpin session in the session list"),
|
||||||
session_quick_switch_1: keybind("<leader>1", "Switch to session in quick slot 1"),
|
session_quick_switch_1: keybind("<leader>1", "Switch to session in quick slot 1"),
|
||||||
@@ -308,8 +306,6 @@ export const CommandMap = {
|
|||||||
session_compact: "session.compact",
|
session_compact: "session.compact",
|
||||||
session_queued_prompts: "session.queued_prompts",
|
session_queued_prompts: "session.queued_prompts",
|
||||||
session_child_first: "session.child.first",
|
session_child_first: "session.child.first",
|
||||||
session_child_cycle: "session.child.next",
|
|
||||||
session_child_cycle_reverse: "session.child.previous",
|
|
||||||
session_parent: "session.parent",
|
session_parent: "session.parent",
|
||||||
session_pin_toggle: "session.pin.toggle",
|
session_pin_toggle: "session.pin.toggle",
|
||||||
session_quick_switch_1: "session.quick_switch.1",
|
session_quick_switch_1: "session.quick_switch.1",
|
||||||
|
|||||||
@@ -29,9 +29,11 @@ export function openSessionTab(tabs: SessionTab[], tab: SessionTab): SessionTab[
|
|||||||
return tabs.map((item, position) => (position === index ? { ...item, title: tab.title } : item))
|
return tabs.map((item, position) => (position === index ? { ...item, title: tab.title } : item))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function closeSessionTab(tabs: readonly SessionTab[], sessionID: string) {
|
export function closeSessionTab(tabs: SessionTab[], sessionID: string) {
|
||||||
const index = tabs.findIndex((tab) => tab.sessionID === sessionID)
|
const index = tabs.findIndex((tab) => tab.sessionID === sessionID)
|
||||||
if (index === -1) return { tabs: [...tabs], next: undefined }
|
// Like openSessionTab and moveSessionTab, a no-op returns the same reference so callers can
|
||||||
|
// detect it by identity.
|
||||||
|
if (index === -1) return { tabs, next: undefined }
|
||||||
return {
|
return {
|
||||||
tabs: tabs.filter((tab) => tab.sessionID !== sessionID),
|
tabs: tabs.filter((tab) => tab.sessionID !== sessionID),
|
||||||
next: tabs[index + 1]?.sessionID ?? tabs[index - 1]?.sessionID,
|
next: tabs[index + 1]?.sessionID ?? tabs[index - 1]?.sessionID,
|
||||||
@@ -87,9 +89,13 @@ export function cycleSessionTab(tabs: readonly SessionTab[], active: string | un
|
|||||||
return tabs[(start + direction + tabs.length) % tabs.length]
|
return tabs[(start + direction + tabs.length) % tabs.length]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// In-memory navigation history is bounded so a long-lived TUI does not accumulate one entry per
|
||||||
|
// session switch forever; the oldest entries fall off first.
|
||||||
|
const SESSION_TAB_HISTORY_LIMIT = 100
|
||||||
|
|
||||||
export function recordSessionTabHistory(history: SessionTabHistory, sessionID: string): SessionTabHistory {
|
export function recordSessionTabHistory(history: SessionTabHistory, sessionID: string): SessionTabHistory {
|
||||||
if (history.entries[history.index] === sessionID) return history
|
if (history.entries[history.index] === sessionID) return history
|
||||||
const entries = [...history.entries.slice(0, history.index + 1), sessionID]
|
const entries = [...history.entries.slice(0, history.index + 1), sessionID].slice(-SESSION_TAB_HISTORY_LIMIT)
|
||||||
return { entries, index: entries.length - 1 }
|
return { entries, index: entries.length - 1 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,8 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
|||||||
function update(mutation: (draft: TabsState) => void) {
|
function update(mutation: (draft: TabsState) => void) {
|
||||||
const scope = config.tabs?.scope ?? "global"
|
const scope = config.tabs?.scope ?? "global"
|
||||||
void updateStore((draft) => mutation(scope === "cwd" ? (draft.cwd[paths.cwd] ??= empty()) : draft.global)).catch(
|
void updateStore((draft) => mutation(scope === "cwd" ? (draft.cwd[paths.cwd] ??= empty()) : draft.global)).catch(
|
||||||
() => {},
|
// Failed writes lose only tab layout, but silence would hide tabs resetting every launch.
|
||||||
|
(error) => console.error("Failed to persist session tabs", error),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,7 +223,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
|||||||
function remove(sessionID: string, navigate: boolean) {
|
function remove(sessionID: string, navigate: boolean) {
|
||||||
const target = root(sessionID)
|
const target = root(sessionID)
|
||||||
const closed = closeSessionTab(state().tabs, target)
|
const closed = closeSessionTab(state().tabs, target)
|
||||||
if (closed.tabs.length === state().tabs.length) return
|
if (closed.tabs === state().tabs) return
|
||||||
const selected = navigate && current() === target
|
const selected = navigate && current() === target
|
||||||
const previous = selected
|
const previous = selected
|
||||||
? moveSessionTabHistory(recordSessionTabHistory(history, target), closed.tabs, target, -1)
|
? moveSessionTabHistory(recordSessionTabHistory(history, target), closed.tabs, target, -1)
|
||||||
|
|||||||
@@ -194,10 +194,6 @@ export function resolveZedDbPath() {
|
|||||||
return candidates.find((item) => isFile(item))
|
return candidates.find((item) => isFile(item))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isZedTerminal() {
|
|
||||||
return process.env.ZED_TERM === "true" || process.env.TERM_PROGRAM?.toLowerCase() === "zed"
|
|
||||||
}
|
|
||||||
|
|
||||||
function isFile(item: string) {
|
function isFile(item: string) {
|
||||||
try {
|
try {
|
||||||
return statSync(item).isFile()
|
return statSync(item).isFile()
|
||||||
@@ -220,11 +216,6 @@ function zedWorkspacePaths(value: string | null) {
|
|||||||
return value.split(/\r?\n/).filter(Boolean)
|
return value.split(/\r?\n/).filter(Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function offsetToPosition(text: string, offset: number) {
|
|
||||||
const stringOffset = utf8ByteOffsetToStringIndex(text, offset)
|
|
||||||
return offsetsToSelection(text, stringOffset, stringOffset).start
|
|
||||||
}
|
|
||||||
|
|
||||||
function utf8ByteOffsetToStringIndex(text: string, byteOffset: number) {
|
function utf8ByteOffsetToStringIndex(text: string, byteOffset: number) {
|
||||||
if (byteOffset <= 0) return 0
|
if (byteOffset <= 0) return 0
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const money = new Intl.NumberFormat("en-US", {
|
|||||||
currency: "USD",
|
currency: "USD",
|
||||||
})
|
})
|
||||||
|
|
||||||
function View(props: { context: Plugin.Context; sessionID: string }) {
|
export function SidebarContext(props: { context: Plugin.Context; sessionID: string }) {
|
||||||
const theme = props.context.theme
|
const theme = props.context.theme
|
||||||
const msg = createMemo(() => props.context.data.session.message.list(props.sessionID))
|
const msg = createMemo(() => props.context.data.session.message.list(props.sessionID))
|
||||||
const session = createMemo(() => props.context.data.session.get(props.sessionID))
|
const session = createMemo(() => props.context.data.session.get(props.sessionID))
|
||||||
@@ -18,28 +18,32 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box>
|
<Show when={state() || cost() > 0}>
|
||||||
<text fg={theme.text.default}>
|
<box>
|
||||||
<b>Context</b>
|
<text fg={theme.text.default}>
|
||||||
</text>
|
<b>Context</b>
|
||||||
<Show when={state()} fallback={<text fg={theme.text.subdued}>Not measured</text>}>
|
</text>
|
||||||
{(value) => (
|
<Show when={state()}>
|
||||||
<>
|
{(value) => (
|
||||||
<text fg={theme.text.subdued}>{value().tokens.toLocaleString()} tokens</text>
|
<>
|
||||||
<Show when={value().percent !== undefined}>
|
<text fg={theme.text.subdued}>{value().tokens.toLocaleString()} tokens</text>
|
||||||
<text fg={theme.text.subdued}>{value().percent}% used</text>
|
<Show when={value().percent !== undefined}>
|
||||||
</Show>
|
<text fg={theme.text.subdued}>{value().percent}% used</text>
|
||||||
</>
|
</Show>
|
||||||
)}
|
</>
|
||||||
</Show>
|
)}
|
||||||
<text fg={theme.text.subdued}>{money.format(cost())} spent</text>
|
</Show>
|
||||||
</box>
|
<Show when={cost() > 0}>
|
||||||
|
<text fg={theme.text.subdued}>{money.format(cost())} spent</text>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Plugin.define({
|
export default Plugin.define({
|
||||||
id: "internal:sidebar-context",
|
id: "internal:sidebar-context",
|
||||||
setup(context) {
|
setup(context) {
|
||||||
context.ui.slot("sidebar.content", (props) => <View context={context} sessionID={props.sessionID} />)
|
context.ui.slot("sidebar.content", (props) => <SidebarContext context={context} sessionID={props.sessionID} />)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
import { Plugin, usePlugin } from "@opencode-ai/plugin/tui"
|
|
||||||
|
|
||||||
function View() {
|
|
||||||
const context = usePlugin()
|
|
||||||
const theme = context.theme
|
|
||||||
return (
|
|
||||||
<box>
|
|
||||||
<text fg={theme.text.default}>
|
|
||||||
<b>LSP</b>
|
|
||||||
</text>
|
|
||||||
<text fg={theme.text.subdued}>LSP status unavailable</text>
|
|
||||||
</box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Plugin.define({
|
|
||||||
id: "opencode.sidebar-lsp",
|
|
||||||
setup(context) {
|
|
||||||
context.ui.slot("sidebar.content", () => <View />)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -141,22 +141,6 @@ export function moveFileTreeSelectionToParent(rows: readonly FileTreeRow[], sele
|
|||||||
return rows.findLast((item, itemIndex) => itemIndex < index && item.depth < row.depth)?.id ?? selected
|
return rows.findLast((item, itemIndex) => itemIndex < index && item.depth < row.depth)?.id ?? selected
|
||||||
}
|
}
|
||||||
|
|
||||||
export function moveFileTreeSelectionToFile(
|
|
||||||
rows: readonly FileTreeRow[],
|
|
||||||
selected: number | undefined,
|
|
||||||
offset: number,
|
|
||||||
) {
|
|
||||||
const fileRows = rows.filter((row) => row.fileIndex !== undefined)
|
|
||||||
if (fileRows.length === 0) return undefined
|
|
||||||
const selectedIndex = selected === undefined ? -1 : rows.findIndex((row) => row.id === selected)
|
|
||||||
if (selectedIndex === -1) return offset < 0 ? fileRows[fileRows.length - 1]!.id : fileRows[0]!.id
|
|
||||||
const next =
|
|
||||||
offset < 0
|
|
||||||
? fileRows.findLast((row) => rows.findIndex((item) => item.id === row.id) < selectedIndex)
|
|
||||||
: fileRows.find((row) => rows.findIndex((item) => item.id === row.id) > selectedIndex)
|
|
||||||
return next?.id ?? (offset < 0 ? fileRows[0]!.id : fileRows[fileRows.length - 1]!.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fileTreeFileSelection(tree: FileTree, fileIndex: number) {
|
export function fileTreeFileSelection(tree: FileTree, fileIndex: number) {
|
||||||
const node = tree.nodes.find((item) => item.kind === "file" && item.fileIndex === fileIndex)
|
const node = tree.nodes.find((item) => item.kind === "file" && item.fileIndex === fileIndex)
|
||||||
if (!node) return undefined
|
if (!node) return undefined
|
||||||
|
|||||||
@@ -7,5 +7,3 @@ export const go = {
|
|||||||
left: [" ", "█▀▀▀", "█_^█", "▀▀▀▀"],
|
left: [" ", "█▀▀▀", "█_^█", "▀▀▀▀"],
|
||||||
right: [" ", "█▀▀█", "█__█", "▀▀▀▀"],
|
right: [" ", "█▀▀█", "█__█", "▀▀▀▀"],
|
||||||
}
|
}
|
||||||
|
|
||||||
export const marks = "_^~,"
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import HomeFooter from "../feature-plugins/home/footer"
|
|||||||
import PromptFooter from "../feature-plugins/prompt/footer"
|
import PromptFooter from "../feature-plugins/prompt/footer"
|
||||||
import SidebarContext from "../feature-plugins/sidebar/context"
|
import SidebarContext from "../feature-plugins/sidebar/context"
|
||||||
import SidebarFooter from "../feature-plugins/sidebar/footer"
|
import SidebarFooter from "../feature-plugins/sidebar/footer"
|
||||||
import SidebarLsp from "../feature-plugins/sidebar/lsp"
|
|
||||||
import SidebarMcp from "../feature-plugins/sidebar/mcp"
|
import SidebarMcp from "../feature-plugins/sidebar/mcp"
|
||||||
import DiffViewer from "../feature-plugins/system/diff-viewer"
|
import DiffViewer from "../feature-plugins/system/diff-viewer"
|
||||||
import Notifications from "../feature-plugins/system/notifications"
|
import Notifications from "../feature-plugins/system/notifications"
|
||||||
@@ -14,7 +13,6 @@ export const builtins = [
|
|||||||
PromptFooter,
|
PromptFooter,
|
||||||
SidebarContext,
|
SidebarContext,
|
||||||
SidebarMcp,
|
SidebarMcp,
|
||||||
SidebarLsp,
|
|
||||||
SidebarFooter,
|
SidebarFooter,
|
||||||
Notifications,
|
Notifications,
|
||||||
Plugins,
|
Plugins,
|
||||||
|
|||||||
@@ -887,22 +887,6 @@ export function Session() {
|
|||||||
dialog.clear()
|
dialog.clear()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: "Next subagent",
|
|
||||||
id: "session.child.next",
|
|
||||||
group: "Session",
|
|
||||||
palette: undefined,
|
|
||||||
enabled: !!session()?.parentID,
|
|
||||||
run: () => unavailable("Subagent navigation"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Previous subagent",
|
|
||||||
id: "session.child.previous",
|
|
||||||
group: "Session",
|
|
||||||
palette: undefined,
|
|
||||||
enabled: !!session()?.parentID,
|
|
||||||
run: () => unavailable("Subagent navigation"),
|
|
||||||
},
|
|
||||||
])
|
])
|
||||||
|
|
||||||
const commands = createMemo(() =>
|
const commands = createMemo(() =>
|
||||||
@@ -1925,122 +1909,6 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function AssistantMessage(props: { message: SessionMessageAssistant; last: boolean }) {
|
|
||||||
const ctx = use()
|
|
||||||
const local = useLocal()
|
|
||||||
const theme = useTheme("elevated")
|
|
||||||
const model = createMemo(
|
|
||||||
() =>
|
|
||||||
ctx
|
|
||||||
.models()
|
|
||||||
.find((model) => model.providerID === props.message.model.providerID && model.id === props.message.model.id)
|
|
||||||
?.name ?? `${props.message.model.providerID}/${props.message.model.id}`,
|
|
||||||
)
|
|
||||||
|
|
||||||
const final = createMemo(() => {
|
|
||||||
return props.message.finish && !["tool-calls", "unknown"].includes(props.message.finish)
|
|
||||||
})
|
|
||||||
|
|
||||||
const duration = createMemo(() => {
|
|
||||||
if (!final()) return 0
|
|
||||||
if (!props.message.time.completed) return 0
|
|
||||||
return props.message.time.completed - props.message.time.created
|
|
||||||
})
|
|
||||||
|
|
||||||
const exploration = createMemo(() => {
|
|
||||||
const grouped = new Map<string, { first: boolean; parts: SessionMessageAssistantTool[]; active: boolean }>()
|
|
||||||
if (!ctx.groupExploration()) return grouped
|
|
||||||
const runs = props.message.content
|
|
||||||
.map((part) =>
|
|
||||||
part.type === "tool" &&
|
|
||||||
["read", "glob", "grep"].includes(toolDisplay(part.name)) &&
|
|
||||||
part.state.status !== "streaming"
|
|
||||||
? part
|
|
||||||
: undefined,
|
|
||||||
)
|
|
||||||
.reduce<SessionMessageAssistantTool[][]>(
|
|
||||||
(runs, part) => {
|
|
||||||
if (part) runs[runs.length - 1].push(part)
|
|
||||||
if (!part && runs[runs.length - 1].length) runs.push([])
|
|
||||||
return runs
|
|
||||||
},
|
|
||||||
[[]],
|
|
||||||
)
|
|
||||||
.filter((run) => run.length > 0)
|
|
||||||
for (const run of runs) {
|
|
||||||
const summary = {
|
|
||||||
parts: run,
|
|
||||||
active: false,
|
|
||||||
}
|
|
||||||
run.forEach((part, index) => grouped.set(part.id, { ...summary, first: index === 0 }))
|
|
||||||
}
|
|
||||||
return grouped
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<For each={props.message.content}>
|
|
||||||
{(content, index) => (
|
|
||||||
<Switch>
|
|
||||||
<Match when={content.type === "text"}>
|
|
||||||
<TextPart
|
|
||||||
part={content as SessionMessageAssistantText}
|
|
||||||
last={index() === props.message.content.length - 1}
|
|
||||||
/>
|
|
||||||
</Match>
|
|
||||||
<Match when={content.type === "reasoning"}>
|
|
||||||
<ReasoningPart
|
|
||||||
part={content as SessionMessageAssistantReasoning}
|
|
||||||
message={props.message}
|
|
||||||
last={index() === props.message.content.length - 1}
|
|
||||||
/>
|
|
||||||
</Match>
|
|
||||||
<Match when={content.type === "tool"}>
|
|
||||||
<Show when={exploration().get((content as SessionMessageAssistantTool).id)?.first !== false}>
|
|
||||||
<Show
|
|
||||||
when={exploration().get((content as SessionMessageAssistantTool).id)}
|
|
||||||
fallback={<ToolPart part={content as SessionMessageAssistantTool} />}
|
|
||||||
>
|
|
||||||
{(summary) => <ExplorationSummary {...summary()} />}
|
|
||||||
</Show>
|
|
||||||
</Show>
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
<Show when={props.message.error}>
|
|
||||||
<box
|
|
||||||
border={["left"]}
|
|
||||||
paddingTop={1}
|
|
||||||
paddingBottom={1}
|
|
||||||
paddingLeft={2}
|
|
||||||
backgroundColor={theme.background.default}
|
|
||||||
customBorderChars={SplitBorder.customBorderChars}
|
|
||||||
borderColor={theme.text.feedback.error.default}
|
|
||||||
>
|
|
||||||
<text fg={theme.text.subdued}>{errorMessage(props.message.error)}</text>
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
<AssistantRetry retry={props.message.retry} />
|
|
||||||
<Switch>
|
|
||||||
<Match when={props.last || final() || props.message.error}>
|
|
||||||
<box paddingLeft={3}>
|
|
||||||
<text>
|
|
||||||
<span style={{ fg: props.message.error ? theme.text.subdued : local.agent.color(props.message.agent) }}>
|
|
||||||
{Locale.titlecase(props.message.agent)}
|
|
||||||
</span>
|
|
||||||
<span style={{ fg: theme.text.subdued }}> · {model()}</span>
|
|
||||||
<Show when={duration()}>
|
|
||||||
<span style={{ fg: theme.text.subdued }}> · {Locale.duration(duration())}</span>
|
|
||||||
</Show>
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) {
|
function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) {
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
return (
|
return (
|
||||||
@@ -2056,40 +1924,6 @@ function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ExplorationSummary(props: { parts: SessionMessageAssistantTool[]; active: boolean }) {
|
|
||||||
const theme = useTheme()
|
|
||||||
const pathFormatter = usePathFormatter()
|
|
||||||
const label = (part: SessionMessageAssistantTool) => {
|
|
||||||
const input = typeof part.state.input === "string" ? {} : part.state.input
|
|
||||||
const tool = toolDisplay(part.name)
|
|
||||||
if (tool === "read") return `Read ${pathFormatter.format(stringValue(input.path))}`
|
|
||||||
if (tool === "glob") return `Glob "${stringValue(input.pattern)}"`
|
|
||||||
return `Grep "${stringValue(input.pattern)}"`
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<box flexDirection="column">
|
|
||||||
<InlineToolRow
|
|
||||||
icon="✱"
|
|
||||||
color={theme.text.subdued}
|
|
||||||
complete={!props.active}
|
|
||||||
pending="Exploring"
|
|
||||||
spinner={props.active}
|
|
||||||
>
|
|
||||||
{props.active ? "Exploring" : "Explored"}
|
|
||||||
</InlineToolRow>
|
|
||||||
<For each={props.parts}>
|
|
||||||
{(part, index) => (
|
|
||||||
<box paddingLeft={5}>
|
|
||||||
<text fg={part.state.status === "error" ? theme.text.feedback.error.default : theme.text.subdued}>
|
|
||||||
{index() === props.parts.length - 1 ? "└" : "├"} {label(part)}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const INLINE_TOOL_ICON_WIDTH = 2
|
const INLINE_TOOL_ICON_WIDTH = 2
|
||||||
|
|
||||||
function ReasoningPart(props: {
|
function ReasoningPart(props: {
|
||||||
@@ -2954,10 +2788,6 @@ export function isBackgroundSubagent(
|
|||||||
return status === "completed" && metadata.status === "running"
|
return status === "completed" && metadata.status === "running"
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatSubagentRetry(attempt: number, message: string) {
|
|
||||||
return `Retrying (attempt ${attempt}) · ${message}`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ExecuteCall = { tool: string; status: "running" | "completed" | "error"; input?: Record<string, unknown> }
|
type ExecuteCall = { tool: string; status: "running" | "completed" | "error"; input?: Record<string, unknown> }
|
||||||
|
|
||||||
function executeCalls(value: unknown): ExecuteCall[] {
|
function executeCalls(value: unknown): ExecuteCall[] {
|
||||||
|
|||||||
@@ -27,9 +27,11 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
|||||||
position={props.overlay ? "absolute" : "relative"}
|
position={props.overlay ? "absolute" : "relative"}
|
||||||
>
|
>
|
||||||
<scrollbox
|
<scrollbox
|
||||||
|
ref={(scroll) => queueMicrotask(() => scroll.verticalScrollBar.resetVisibilityControl())}
|
||||||
flexGrow={1}
|
flexGrow={1}
|
||||||
scrollAcceleration={scrollAcceleration()}
|
scrollAcceleration={scrollAcceleration()}
|
||||||
verticalScrollbarOptions={{
|
verticalScrollbarOptions={{
|
||||||
|
visible: false,
|
||||||
trackOptions: {
|
trackOptions: {
|
||||||
backgroundColor: theme.background.default,
|
backgroundColor: theme.background.default,
|
||||||
foregroundColor: theme.scrollbar.default,
|
foregroundColor: theme.scrollbar.default,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { TextAttributes } from "@opentui/core"
|
import { TextAttributes } from "@opentui/core"
|
||||||
import { Keymap } from "../context/keymap"
|
import { Keymap } from "../context/keymap"
|
||||||
import { useTheme } from "../context/theme"
|
import { useTheme } from "../context/theme"
|
||||||
import { useDialog, type DialogContext } from "./dialog"
|
import { useDialog } from "./dialog"
|
||||||
|
|
||||||
export type DialogAlertProps = {
|
export type DialogAlertProps = {
|
||||||
title: string
|
title: string
|
||||||
@@ -56,12 +56,3 @@ export function DialogAlert(props: DialogAlertProps) {
|
|||||||
</box>
|
</box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
DialogAlert.show = (dialog: DialogContext, title: string, message: string) => {
|
|
||||||
return new Promise<void>((resolve) => {
|
|
||||||
dialog.replace(
|
|
||||||
() => <DialogAlert title={title} message={message} onConfirm={() => resolve()} />,
|
|
||||||
() => resolve(),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { TextAttributes } from "@opentui/core"
|
import { TextAttributes } from "@opentui/core"
|
||||||
import { Keymap } from "../context/keymap"
|
import { Keymap } from "../context/keymap"
|
||||||
import { useTheme } from "../context/theme"
|
import { useTheme } from "../context/theme"
|
||||||
import { useDialog, type DialogContext } from "./dialog"
|
import { useDialog } from "./dialog"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { For } from "solid-js"
|
import { For } from "solid-js"
|
||||||
import { Locale } from "../util/locale"
|
import { Locale } from "../util/locale"
|
||||||
@@ -17,8 +17,6 @@ export type DialogConfirmProps = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DialogConfirmResult = boolean | undefined
|
|
||||||
|
|
||||||
export function DialogConfirm(props: DialogConfirmProps) {
|
export function DialogConfirm(props: DialogConfirmProps) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const theme = useTheme("elevated")
|
const theme = useTheme("elevated")
|
||||||
@@ -93,20 +91,3 @@ export function DialogConfirm(props: DialogConfirmProps) {
|
|||||||
</box>
|
</box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
DialogConfirm.show = (dialog: DialogContext, title: string, message: string, label?: DialogConfirmProps["label"]) => {
|
|
||||||
return new Promise<DialogConfirmResult>((resolve) => {
|
|
||||||
dialog.replace(
|
|
||||||
() => (
|
|
||||||
<DialogConfirm
|
|
||||||
title={title}
|
|
||||||
message={message}
|
|
||||||
onConfirm={() => resolve(true)}
|
|
||||||
onCancel={() => resolve(false)}
|
|
||||||
label={label}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
() => resolve(undefined),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
import type { RGBA } from "@opentui/core"
|
|
||||||
import { createEffect, createMemo, createSignal, Show } from "solid-js"
|
|
||||||
import { useConfig } from "../config"
|
|
||||||
import { tint } from "../theme/color"
|
|
||||||
import { createAnimatable, tween } from "./animation"
|
|
||||||
import { FilePath } from "./file-path"
|
|
||||||
|
|
||||||
// FilePath that crossfades when its value changes: the old path fades to the
|
|
||||||
// background, the text swaps at the midpoint, and the new path fades back in.
|
|
||||||
// The initial value renders immediately; only subsequent changes animate.
|
|
||||||
export function FadeFilePath(props: {
|
|
||||||
value: string | undefined
|
|
||||||
maxWidth: number
|
|
||||||
fg: RGBA
|
|
||||||
bg: RGBA
|
|
||||||
basenameFg?: RGBA
|
|
||||||
}) {
|
|
||||||
const config = useConfig().data
|
|
||||||
const fade = createAnimatable(
|
|
||||||
{ front: 1 },
|
|
||||||
{
|
|
||||||
enabled: () => config.animations ?? true,
|
|
||||||
transition: tween({ duration: 0.3 }),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
const [text, setText] = createSignal(props.value)
|
|
||||||
const [previous, setPrevious] = createSignal<string>()
|
|
||||||
|
|
||||||
createEffect((current: string | undefined) => {
|
|
||||||
const next = props.value
|
|
||||||
if (next === undefined || next === current) return current
|
|
||||||
setText(next)
|
|
||||||
if (current === undefined) return next
|
|
||||||
setPrevious(current)
|
|
||||||
fade.jump({ front: 0 })
|
|
||||||
fade.animate({ front: 1 })
|
|
||||||
return next
|
|
||||||
}, props.value)
|
|
||||||
|
|
||||||
const display = createMemo(() => (previous() !== undefined && fade.value().front < 0.5 ? previous() : text()))
|
|
||||||
const fg = createMemo(() => {
|
|
||||||
if (previous() === undefined || fade.value().front >= 1) return props.fg
|
|
||||||
return tint(props.bg, props.fg, Math.abs(fade.value().front * 2 - 1))
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Show when={display() !== undefined}>
|
|
||||||
<FilePath
|
|
||||||
value={display() ?? ""}
|
|
||||||
maxWidth={props.maxWidth}
|
|
||||||
fg={fg()}
|
|
||||||
basenameFg={fade.value().front >= 1 ? props.basenameFg : undefined}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -143,40 +143,3 @@ export function errorMessage(error: unknown): string {
|
|||||||
if (formatted) return formatted
|
if (formatted) return formatted
|
||||||
return "unknown error"
|
return "unknown error"
|
||||||
}
|
}
|
||||||
|
|
||||||
export function errorData(error: unknown) {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
return {
|
|
||||||
type: error.name,
|
|
||||||
message: errorMessage(error),
|
|
||||||
stack: error.stack,
|
|
||||||
cause: error.cause === undefined ? undefined : errorFormat(error.cause),
|
|
||||||
formatted: errorFormat(error),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isRecord(error)) {
|
|
||||||
return {
|
|
||||||
type: typeof error,
|
|
||||||
message: errorMessage(error),
|
|
||||||
formatted: errorFormat(error),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = Object.getOwnPropertyNames(error).reduce<Record<string, unknown>>((acc, key) => {
|
|
||||||
const value = error[key]
|
|
||||||
if (value === undefined) return acc
|
|
||||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
||||||
acc[key] = value
|
|
||||||
return acc
|
|
||||||
}
|
|
||||||
// oxlint-disable-next-line no-base-to-string -- intentional coercion of arbitrary error properties
|
|
||||||
acc[key] = value instanceof Error ? value.message : String(value)
|
|
||||||
return acc
|
|
||||||
}, {})
|
|
||||||
|
|
||||||
if (typeof data.message !== "string") data.message = errorMessage(error)
|
|
||||||
if (typeof data.type !== "string") data.type = error.constructor?.name
|
|
||||||
data.formatted = errorFormat(error)
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
export function formatDuration(secs: number) {
|
|
||||||
if (secs <= 0) return ""
|
|
||||||
if (secs < 60) return `${secs}s`
|
|
||||||
if (secs < 3600) {
|
|
||||||
const mins = Math.floor(secs / 60)
|
|
||||||
const remaining = secs % 60
|
|
||||||
return remaining > 0 ? `${mins}m ${remaining}s` : `${mins}m`
|
|
||||||
}
|
|
||||||
if (secs < 86400) {
|
|
||||||
const hours = Math.floor(secs / 3600)
|
|
||||||
const remaining = Math.floor((secs % 3600) / 60)
|
|
||||||
return remaining > 0 ? `${hours}h ${remaining}m` : `${hours}h`
|
|
||||||
}
|
|
||||||
if (secs < 604800) {
|
|
||||||
const days = Math.floor(secs / 86400)
|
|
||||||
return days === 1 ? "~1 day" : `~${days} days`
|
|
||||||
}
|
|
||||||
const weeks = Math.floor(secs / 604800)
|
|
||||||
return weeks === 1 ? "~1 week" : `~${weeks} weeks`
|
|
||||||
}
|
|
||||||
@@ -16,19 +16,6 @@ export function datetime(input: number): string {
|
|||||||
return `${localTime} · ${localDate}`
|
return `${localTime} · ${localDate}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function todayTimeOrDateTime(input: number): string {
|
|
||||||
const date = new Date(input)
|
|
||||||
const now = new Date()
|
|
||||||
const isToday =
|
|
||||||
date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth() && date.getDate() === now.getDate()
|
|
||||||
|
|
||||||
if (isToday) {
|
|
||||||
return time(input)
|
|
||||||
} else {
|
|
||||||
return datetime(input)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function number(num: number): string {
|
export function number(num: number): string {
|
||||||
if (num >= 1000000) {
|
if (num >= 1000000) {
|
||||||
return (num / 1000000).toFixed(1) + "M"
|
return (num / 1000000).toFixed(1) + "M"
|
||||||
@@ -108,9 +95,4 @@ export function truncateMiddle(str: string, maxLength: number = 35): string {
|
|||||||
return str.slice(0, keepStart) + ellipsis + str.slice(-keepEnd)
|
return str.slice(0, keepStart) + ellipsis + str.slice(-keepEnd)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pluralize(count: number, singular: string, plural: string): string {
|
|
||||||
const template = count === 1 ? singular : plural
|
|
||||||
return template.replace("{}", count.toString())
|
|
||||||
}
|
|
||||||
|
|
||||||
export * as Locale from "./locale"
|
export * as Locale from "./locale"
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
const logo = {
|
import { logo } from "../logo"
|
||||||
left: [" ", "█▀▀█ █▀▀█ █▀▀█ █▀▀▄", "█__█ █__█ █^^^ █__█", "▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀~~▀"],
|
|
||||||
right: [" ▄ ", "█▀▀▀ █▀▀█ █▀▀█ █▀▀█", "█___ █__█ █__█ █^^^", "▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀"],
|
|
||||||
}
|
|
||||||
|
|
||||||
const reset = "\x1b[0m"
|
const reset = "\x1b[0m"
|
||||||
const bold = "\x1b[1m"
|
const bold = "\x1b[1m"
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
import { parsePatch } from "diff"
|
|
||||||
|
|
||||||
export function getRevertDiffFiles(diffText: string) {
|
|
||||||
if (!diffText) return []
|
|
||||||
|
|
||||||
try {
|
|
||||||
return parsePatch(diffText).map((patch) => {
|
|
||||||
const filename = [patch.newFileName, patch.oldFileName].find((item) => item && item !== "/dev/null") ?? "unknown"
|
|
||||||
return {
|
|
||||||
filename: filename.replace(/^[ab]\//, ""),
|
|
||||||
additions: patch.hunks.reduce((sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("+")).length, 0),
|
|
||||||
deletions: patch.hunks.reduce((sum, hunk) => sum + hunk.lines.filter((line) => line.startsWith("-")).length, 0),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,23 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { sortModelOptions } from "../../../../src/component/dialog-model"
|
import { prioritizeFavorites, sortModelOptions } from "../../../../src/component/dialog-model"
|
||||||
|
|
||||||
|
describe("prioritizeFavorites", () => {
|
||||||
|
test("moves favorites first while preserving fuzzy result order", () => {
|
||||||
|
const prioritized = prioritizeFavorites([
|
||||||
|
{ title: "Best match", favorite: false },
|
||||||
|
{ title: "Favorite match", favorite: true },
|
||||||
|
{ title: "Second best match", favorite: false },
|
||||||
|
{ title: "Second favorite match", favorite: true },
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(prioritized.map((model) => model.title)).toEqual([
|
||||||
|
"Favorite match",
|
||||||
|
"Second favorite match",
|
||||||
|
"Best match",
|
||||||
|
"Second best match",
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe("sortModelOptions", () => {
|
describe("sortModelOptions", () => {
|
||||||
test("orders opencode models before other providers", () => {
|
test("orders opencode models before other providers", () => {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { afterEach, describe, expect, test } from "bun:test"
|
|||||||
import { For } from "solid-js"
|
import { For } from "solid-js"
|
||||||
import { testRender, type JSX } from "@opentui/solid"
|
import { testRender, type JSX } from "@opentui/solid"
|
||||||
import {
|
import {
|
||||||
formatSubagentRetry,
|
|
||||||
InlineToolRow,
|
InlineToolRow,
|
||||||
isBackgroundSubagent,
|
isBackgroundSubagent,
|
||||||
parseApplyPatchFiles,
|
parseApplyPatchFiles,
|
||||||
@@ -198,10 +197,6 @@ describe("TUI inline tool wrapping", () => {
|
|||||||
).toEqual([{ message: "valid", range: { start: { line: 2, character: 3 } } }])
|
).toEqual([{ message: "valid", range: { start: { line: 2, character: 3 } } }])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("keeps retry status ahead of wrapping messages", () => {
|
|
||||||
expect(formatSubagentRetry(2, "Rate limited by provider")).toBe("Retrying (attempt 2) · Rate limited by provider")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("labels only detached or async subagents as background", () => {
|
test("labels only detached or async subagents as background", () => {
|
||||||
expect(isBackgroundSubagent({ status: "running" }, "running")).toBeFalse()
|
expect(isBackgroundSubagent({ status: "running" }, "running")).toBeFalse()
|
||||||
expect(isBackgroundSubagent({ status: "running" }, "completed")).toBeTrue()
|
expect(isBackgroundSubagent({ status: "running" }, "completed")).toBeTrue()
|
||||||
|
|||||||
@@ -80,6 +80,11 @@ describe("session tabs", () => {
|
|||||||
expect(closeSessionTab([{ sessionID: "a" }], "a").next).toBeUndefined()
|
expect(closeSessionTab([{ sessionID: "a" }], "a").next).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("closing an unknown session returns the same tabs reference", () => {
|
||||||
|
const tabs = [{ sessionID: "a" }, { sessionID: "b" }]
|
||||||
|
expect(closeSessionTab(tabs, "missing").tabs).toBe(tabs)
|
||||||
|
})
|
||||||
|
|
||||||
test("cycles through a filtered tab set in either direction", () => {
|
test("cycles through a filtered tab set in either direction", () => {
|
||||||
const tabs = ["a", "c", "e"].map((sessionID) => ({ sessionID }))
|
const tabs = ["a", "c", "e"].map((sessionID) => ({ sessionID }))
|
||||||
expect(cycleSessionTab(tabs, "c", 1)?.sessionID).toBe("e")
|
expect(cycleSessionTab(tabs, "c", 1)?.sessionID).toBe("e")
|
||||||
@@ -122,6 +127,15 @@ describe("session tabs", () => {
|
|||||||
expect(recordSessionTabHistory(history, "b")).toBe(history)
|
expect(recordSessionTabHistory(history, "b")).toBe(history)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("drops the oldest history entries beyond the limit", () => {
|
||||||
|
const sessions = Array.from({ length: 150 }, (_, index) => `session-${index}`)
|
||||||
|
const history = sessions.reduce(recordSessionTabHistory, { entries: [], index: -1 })
|
||||||
|
|
||||||
|
expect(history.entries.length).toBe(100)
|
||||||
|
expect(history.entries[0]).toBe("session-50")
|
||||||
|
expect(history.entries[history.index]).toBe("session-149")
|
||||||
|
})
|
||||||
|
|
||||||
test("returns to the latest history entry when no tab is active", () => {
|
test("returns to the latest history entry when no tab is active", () => {
|
||||||
const tabs = ["a", "b"].map((sessionID) => ({ sessionID }))
|
const tabs = ["a", "b"].map((sessionID) => ({ sessionID }))
|
||||||
const history = ["a", "b"].reduce(recordSessionTabHistory, { entries: [], index: -1 })
|
const history = ["a", "b"].reduce(recordSessionTabHistory, { entries: [], index: -1 })
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/** @jsxImportSource @opentui/solid */
|
/** @jsxImportSource @opentui/solid */
|
||||||
import { expect, test } from "bun:test"
|
import { afterAll, expect, test } from "bun:test"
|
||||||
import type { OpenCodeEvent } from "@opencode-ai/client"
|
import type { OpenCodeEvent } from "@opencode-ai/client"
|
||||||
import { testRender } from "@opentui/solid"
|
import { testRender } from "@opentui/solid"
|
||||||
import { mkdtempSync, rmSync, watch } from "fs"
|
import { mkdtempSync, readdirSync, rmSync, watch } from "fs"
|
||||||
import { tmpdir } from "os"
|
import { tmpdir } from "os"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { ConfigProvider } from "../../src/config"
|
import { ConfigProvider } from "../../src/config"
|
||||||
@@ -25,8 +25,32 @@ async function wait(fn: () => boolean | Promise<boolean>, timeout = 2_000) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// State directories are removed after the whole suite instead of per test: persistence writes are
|
||||||
|
// fire-and-forget behind a file lock, so a teardown-time removal races any still-queued write.
|
||||||
|
const stateDirs: string[] = []
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
for (const dir of stateDirs) {
|
||||||
|
// Drain any lock still held by a late write before deleting the tree beneath it.
|
||||||
|
await wait(() => {
|
||||||
|
try {
|
||||||
|
return readdirSync(path.join(dir, "test", "locks")).length === 0
|
||||||
|
} catch {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}).catch(() => undefined)
|
||||||
|
rmSync(dir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function stateDir(prefix: string) {
|
||||||
|
const dir = mkdtempSync(path.join(tmpdir(), prefix))
|
||||||
|
stateDirs.push(dir)
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
async function renderSessionTabs(initialSessionID: string, options?: { state?: string; title?: string }) {
|
async function renderSessionTabs(initialSessionID: string, options?: { state?: string; title?: string }) {
|
||||||
const state = options?.state ?? mkdtempSync(path.join(tmpdir(), "opencode-session-tabs-"))
|
const state = options?.state ?? stateDir("opencode-session-tabs-")
|
||||||
const events = createEventStream()
|
const events = createEventStream()
|
||||||
const calls = createFetch((url) => {
|
const calls = createFetch((url) => {
|
||||||
if (url.pathname !== `/api/session/${initialSessionID}`) return
|
if (url.pathname !== `/api/session/${initialSessionID}`) return
|
||||||
@@ -84,7 +108,6 @@ async function renderSessionTabs(initialSessionID: string, options?: { state?: s
|
|||||||
emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }),
|
emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }),
|
||||||
destroy() {
|
destroy() {
|
||||||
app.renderer.destroy()
|
app.renderer.destroy()
|
||||||
if (!options?.state) rmSync(state, { recursive: true, force: true })
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,7 +128,7 @@ test("stores session tabs globally by default", async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("concurrent TUIs do not alternate shared tab titles from divergent session caches", async () => {
|
test("concurrent TUIs do not alternate shared tab titles from divergent session caches", async () => {
|
||||||
const state = mkdtempSync(path.join(tmpdir(), "opencode-session-tabs-shared-"))
|
const state = stateDir("opencode-session-tabs-shared-")
|
||||||
let titled: Awaited<ReturnType<typeof renderSessionTabs>> | undefined
|
let titled: Awaited<ReturnType<typeof renderSessionTabs>> | undefined
|
||||||
let untitled: Awaited<ReturnType<typeof renderSessionTabs>> | undefined
|
let untitled: Awaited<ReturnType<typeof renderSessionTabs>> | undefined
|
||||||
|
|
||||||
@@ -144,7 +167,6 @@ test("concurrent TUIs do not alternate shared tab titles from divergent session
|
|||||||
} finally {
|
} finally {
|
||||||
titled?.destroy()
|
titled?.destroy()
|
||||||
untitled?.destroy()
|
untitled?.destroy()
|
||||||
rmSync(state, { recursive: true, force: true })
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
flattenFileTree,
|
flattenFileTree,
|
||||||
moveFileTreeSelection,
|
moveFileTreeSelection,
|
||||||
moveFileTreeSelectionToFirstChild,
|
moveFileTreeSelectionToFirstChild,
|
||||||
moveFileTreeSelectionToFile,
|
|
||||||
moveFileTreeSelectionToParent,
|
moveFileTreeSelectionToParent,
|
||||||
movePatchFileIndex,
|
movePatchFileIndex,
|
||||||
orderedPatchFileIndexes,
|
orderedPatchFileIndexes,
|
||||||
@@ -219,25 +218,6 @@ describe("diff viewer file tree utilities", () => {
|
|||||||
expect(moveFileTreeSelectionToParent(rows, undefined)).toBeUndefined()
|
expect(moveFileTreeSelectionToParent(rows, undefined)).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("moves file selection relative to the highlighted row", () => {
|
|
||||||
const rows = flattenFileTree(
|
|
||||||
buildFileTree([{ file: "src/config/tui.ts" }, { file: "src/session/index.ts" }, { file: "README.md" }]),
|
|
||||||
)
|
|
||||||
const config = rows.find((row) => row.kind === "directory" && row.name === "config")!
|
|
||||||
const session = rows.find((row) => row.kind === "directory" && row.name === "session")!
|
|
||||||
const tui = rows.find((row) => row.name === "tui.ts")!
|
|
||||||
const index = rows.find((row) => row.name === "index.ts")!
|
|
||||||
const readme = rows.find((row) => row.name === "README.md")!
|
|
||||||
|
|
||||||
expect(moveFileTreeSelectionToFile(rows, undefined, 1)).toBe(tui.id)
|
|
||||||
expect(moveFileTreeSelectionToFile(rows, undefined, -1)).toBe(readme.id)
|
|
||||||
expect(moveFileTreeSelectionToFile(rows, config.id, 1)).toBe(tui.id)
|
|
||||||
expect(moveFileTreeSelectionToFile(rows, session.id, -1)).toBe(tui.id)
|
|
||||||
expect(moveFileTreeSelectionToFile(rows, tui.id, 1)).toBe(index.id)
|
|
||||||
expect(moveFileTreeSelectionToFile(rows, index.id, -1)).toBe(tui.id)
|
|
||||||
expect(moveFileTreeSelectionToFile(rows, readme.id, 1)).toBe(readme.id)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("selects a file tree node and expands its parents for a patch file", () => {
|
test("selects a file tree node and expands its parents for a patch file", () => {
|
||||||
const tree = buildFileTree([{ file: "src/config/tui.ts" }, { file: "src/session/index.ts" }, { file: "README.md" }])
|
const tree = buildFileTree([{ file: "src/config/tui.ts" }, { file: "src/session/index.ts" }, { file: "README.md" }])
|
||||||
const selection = fileTreeFileSelection(tree, 1)
|
const selection = fileTreeFileSelection(tree, 1)
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/** @jsxImportSource @opentui/solid */
|
||||||
|
import { expect, test } from "bun:test"
|
||||||
|
import { RGBA } from "@opentui/core"
|
||||||
|
import { testRender } from "@opentui/solid"
|
||||||
|
import type { Context } from "@opencode-ai/plugin/tui/context"
|
||||||
|
import { SidebarContext } from "../../src/feature-plugins/sidebar/context"
|
||||||
|
|
||||||
|
function context(options?: { cost?: number; tokens?: number }) {
|
||||||
|
const color = RGBA.fromInts(200, 200, 200)
|
||||||
|
return {
|
||||||
|
theme: { text: { default: color, subdued: color } },
|
||||||
|
data: {
|
||||||
|
session: {
|
||||||
|
get: () => ({ location: { directory: "/workspace" } }),
|
||||||
|
cost: () => options?.cost ?? 0,
|
||||||
|
message: {
|
||||||
|
list: () =>
|
||||||
|
options?.tokens
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: "message",
|
||||||
|
type: "assistant",
|
||||||
|
model: { providerID: "provider", id: "model" },
|
||||||
|
tokens: {
|
||||||
|
input: options.tokens,
|
||||||
|
output: 0,
|
||||||
|
reasoning: 0,
|
||||||
|
cache: { read: 0, write: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
location: {
|
||||||
|
model: { list: () => [] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as Context
|
||||||
|
}
|
||||||
|
|
||||||
|
test("sidebar omits context before usage is available", async () => {
|
||||||
|
const app = await testRender(() => <SidebarContext context={context()} sessionID="session" />, {
|
||||||
|
width: 42,
|
||||||
|
height: 8,
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await app.renderOnce()
|
||||||
|
expect(app.captureCharFrame()).not.toContain("Context")
|
||||||
|
expect(app.captureCharFrame()).not.toContain("Not measured")
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("sidebar shows available context usage", async () => {
|
||||||
|
const app = await testRender(() => <SidebarContext context={context({ tokens: 1234 })} sessionID="session" />, {
|
||||||
|
width: 42,
|
||||||
|
height: 8,
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await app.renderOnce()
|
||||||
|
expect(app.captureCharFrame()).toContain("Context")
|
||||||
|
expect(app.captureCharFrame()).toContain("1,234 tokens")
|
||||||
|
} finally {
|
||||||
|
app.renderer.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -46,7 +46,7 @@ test("legacy page key aliases compile as page keys", async () => {
|
|||||||
|
|
||||||
test("formats navigation keys as arrows", async () => {
|
test("formats navigation keys as arrows", async () => {
|
||||||
let read = () => ({}) as Record<string, string>
|
let read = () => ({}) as Record<string, string>
|
||||||
const commands = ["session.parent", "session.child.first", "session.child.previous", "session.child.next"]
|
const commands = ["session.parent", "session.child.first"]
|
||||||
|
|
||||||
function Harness() {
|
function Harness() {
|
||||||
const shortcuts = Keymap.useShortcuts()
|
const shortcuts = Keymap.useShortcuts()
|
||||||
@@ -68,8 +68,6 @@ test("formats navigation keys as arrows", async () => {
|
|||||||
expect(read()).toEqual({
|
expect(read()).toEqual({
|
||||||
"session.parent": "↑",
|
"session.parent": "↑",
|
||||||
"session.child.first": "↓",
|
"session.child.first": "↓",
|
||||||
"session.child.previous": "←",
|
|
||||||
"session.child.next": "→",
|
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
app.renderer.destroy()
|
app.renderer.destroy()
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import type { FooterApi, FooterEvent, RunPrompt, StreamCommit } from "../../../s
|
|||||||
export function createFooterApiFixture(input: { events?: FooterEvent[]; commits?: StreamCommit[] } = {}) {
|
export function createFooterApiFixture(input: { events?: FooterEvent[]; commits?: StreamCommit[] } = {}) {
|
||||||
const prompts = new Set<(input: RunPrompt) => void>()
|
const prompts = new Set<(input: RunPrompt) => void>()
|
||||||
const closes = new Set<() => void>()
|
const closes = new Set<() => void>()
|
||||||
|
let ready!: () => void
|
||||||
|
const promptReady = new Promise<void>((resolve) => {
|
||||||
|
ready = resolve
|
||||||
|
})
|
||||||
const events = input.events ?? []
|
const events = input.events ?? []
|
||||||
const commits = input.commits ?? []
|
const commits = input.commits ?? []
|
||||||
const calls: Array<{ type: "event"; value: FooterEvent } | { type: "commit"; value: StreamCommit }> = []
|
const calls: Array<{ type: "event"; value: FooterEvent } | { type: "commit"; value: StreamCommit }> = []
|
||||||
@@ -14,6 +18,7 @@ export function createFooterApiFixture(input: { events?: FooterEvent[]; commits?
|
|||||||
},
|
},
|
||||||
onPrompt(fn) {
|
onPrompt(fn) {
|
||||||
prompts.add(fn)
|
prompts.add(fn)
|
||||||
|
ready()
|
||||||
return () => prompts.delete(fn)
|
return () => prompts.delete(fn)
|
||||||
},
|
},
|
||||||
onClose(fn) {
|
onClose(fn) {
|
||||||
@@ -50,9 +55,12 @@ export function createFooterApiFixture(input: { events?: FooterEvent[]; commits?
|
|||||||
events,
|
events,
|
||||||
commits,
|
commits,
|
||||||
calls,
|
calls,
|
||||||
|
promptReady,
|
||||||
submit(text: string, mode?: RunPrompt["mode"]) {
|
submit(text: string, mode?: RunPrompt["mode"]) {
|
||||||
|
if (prompts.size === 0) return false
|
||||||
const prompt: RunPrompt = mode ? { text, parts: [], mode } : { text, parts: [] }
|
const prompt: RunPrompt = mode ? { text, parts: [], mode } : { text, parts: [] }
|
||||||
for (const fn of [...prompts]) fn(prompt)
|
for (const fn of [...prompts]) fn(prompt)
|
||||||
|
return true
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ describe("run interactive runtime", () => {
|
|||||||
const api = ui.api
|
const api = ui.api
|
||||||
const selected = defer<Awaited<ReturnType<typeof sdk.model.default>>>()
|
const selected = defer<Awaited<ReturnType<typeof sdk.model.default>>>()
|
||||||
const catalogLoaded = defer<void>()
|
const catalogLoaded = defer<void>()
|
||||||
|
const defaultModelReloaded = defer<void>()
|
||||||
|
const modelShown = defer<void>()
|
||||||
|
const turnStarted = defer<void>()
|
||||||
const model = catalogModel({
|
const model = catalogModel({
|
||||||
id: "resolved",
|
id: "resolved",
|
||||||
providerID: "test",
|
providerID: "test",
|
||||||
@@ -69,7 +72,17 @@ describe("run interactive runtime", () => {
|
|||||||
providers: [catalogProvider("test", "Test Provider")],
|
providers: [catalogProvider("test", "Test Provider")],
|
||||||
models: [model],
|
models: [model],
|
||||||
})
|
})
|
||||||
const defaultModel = spyOn(sdk.model, "default").mockImplementation(() => selected.promise)
|
let defaultModelCalls = 0
|
||||||
|
const defaultModel = spyOn(sdk.model, "default").mockImplementation(() => {
|
||||||
|
defaultModelCalls++
|
||||||
|
if (defaultModelCalls === 2) defaultModelReloaded.resolve()
|
||||||
|
return selected.promise
|
||||||
|
})
|
||||||
|
const emit = api.event.bind(api)
|
||||||
|
api.event = (event) => {
|
||||||
|
emit(event)
|
||||||
|
if (event.type === "model") modelShown.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
const task = runInteractiveDeferredMode(
|
const task = runInteractiveDeferredMode(
|
||||||
{
|
{
|
||||||
@@ -110,6 +123,7 @@ describe("run interactive runtime", () => {
|
|||||||
runPromptTurn: async (input) => {
|
runPromptTurn: async (input) => {
|
||||||
turnAgent = input.agent
|
turnAgent = input.agent
|
||||||
turnModel = input.model
|
turnModel = input.model
|
||||||
|
turnStarted.resolve()
|
||||||
api.close()
|
api.close()
|
||||||
},
|
},
|
||||||
queuePromptTurn: async () => {},
|
queuePromptTurn: async () => {},
|
||||||
@@ -133,8 +147,8 @@ describe("run interactive runtime", () => {
|
|||||||
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
|
location: { directory: "/tmp", project: { id: "pro-1", directory: "/tmp", canonical: "/tmp" } },
|
||||||
data: model,
|
data: model,
|
||||||
})
|
})
|
||||||
while (defaultModel.mock.calls.length < 2) await Bun.sleep(0)
|
await defaultModelReloaded.promise
|
||||||
while (!events.some((event) => event.type === "model")) await Bun.sleep(0)
|
await modelShown.promise
|
||||||
expect(events).toContainEqual({
|
expect(events).toContainEqual({
|
||||||
type: "model",
|
type: "model",
|
||||||
model: "Resolved Model · Test Provider",
|
model: "Resolved Model · Test Provider",
|
||||||
@@ -142,8 +156,9 @@ describe("run interactive runtime", () => {
|
|||||||
})
|
})
|
||||||
expect(lifecycle.onCycleVariant?.()).toMatchObject({ status: "variant low", variant: "low" })
|
expect(lifecycle.onCycleVariant?.()).toMatchObject({ status: "variant low", variant: "low" })
|
||||||
lifecycle.onAgentSelect?.("review")
|
lifecycle.onAgentSelect?.("review")
|
||||||
ui.submit("hello")
|
await ui.promptReady
|
||||||
while (!turnModel) await Bun.sleep(0)
|
expect(ui.submit("hello")).toBe(true)
|
||||||
|
await turnStarted.promise
|
||||||
expect(turnAgent).toBe("review")
|
expect(turnAgent).toBe("review")
|
||||||
expect(turnModel).toEqual({ providerID: "test", modelID: "resolved" })
|
expect(turnModel).toEqual({ providerID: "test", modelID: "resolved" })
|
||||||
await task
|
await task
|
||||||
|
|||||||
@@ -1,25 +1,16 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { errorData, errorFormat, errorMessage } from "../../src/util/error"
|
import { errorFormat, errorMessage } from "../../src/util/error"
|
||||||
|
|
||||||
describe("util.error", () => {
|
describe("util.error", () => {
|
||||||
test("formats native Error instances", () => {
|
test("formats native Error instances", () => {
|
||||||
const err = new Error("boom")
|
const err = new Error("boom")
|
||||||
expect(errorMessage(err)).toBe("boom")
|
expect(errorMessage(err)).toBe("boom")
|
||||||
expect(errorFormat(err)).toContain("boom")
|
expect(errorFormat(err)).toContain("boom")
|
||||||
|
|
||||||
const data = errorData(err)
|
|
||||||
expect(data.type).toBe("Error")
|
|
||||||
expect(data.message).toBe("boom")
|
|
||||||
expect(String(data.formatted)).toContain("boom")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("extracts message from record-like values", () => {
|
test("extracts message from record-like values", () => {
|
||||||
const err = { message: "bad input", code: "E_BAD" }
|
const err = { message: "bad input", code: "E_BAD" }
|
||||||
expect(errorMessage(err)).toBe("bad input")
|
expect(errorMessage(err)).toBe("bad input")
|
||||||
|
|
||||||
const data = errorData(err)
|
|
||||||
expect(data.message).toBe("bad input")
|
|
||||||
expect(data.code).toBe("E_BAD")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("never returns bare {} for opaque object errors", () => {
|
test("never returns bare {} for opaque object errors", () => {
|
||||||
@@ -41,9 +32,5 @@ describe("util.error", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
expect(errorMessage(err)).toBe("ResolveMessage: Cannot resolve module")
|
expect(errorMessage(err)).toBe("ResolveMessage: Cannot resolve module")
|
||||||
|
|
||||||
const data = errorData(err)
|
|
||||||
expect(data.message).toBe("ResolveMessage: Cannot resolve module")
|
|
||||||
expect(String(data.formatted)).toContain("ResolveMessage")
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
import { formatDuration } from "../../src/util/format"
|
|
||||||
|
|
||||||
describe("util.format", () => {
|
|
||||||
describe("formatDuration", () => {
|
|
||||||
test("returns empty string for zero or negative values", () => {
|
|
||||||
expect(formatDuration(0)).toBe("")
|
|
||||||
expect(formatDuration(-1)).toBe("")
|
|
||||||
expect(formatDuration(-100)).toBe("")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("formats seconds under a minute", () => {
|
|
||||||
expect(formatDuration(1)).toBe("1s")
|
|
||||||
expect(formatDuration(30)).toBe("30s")
|
|
||||||
expect(formatDuration(59)).toBe("59s")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("formats minutes under an hour", () => {
|
|
||||||
expect(formatDuration(60)).toBe("1m")
|
|
||||||
expect(formatDuration(61)).toBe("1m 1s")
|
|
||||||
expect(formatDuration(90)).toBe("1m 30s")
|
|
||||||
expect(formatDuration(120)).toBe("2m")
|
|
||||||
expect(formatDuration(330)).toBe("5m 30s")
|
|
||||||
expect(formatDuration(3599)).toBe("59m 59s")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("formats hours under a day", () => {
|
|
||||||
expect(formatDuration(3600)).toBe("1h")
|
|
||||||
expect(formatDuration(3660)).toBe("1h 1m")
|
|
||||||
expect(formatDuration(7200)).toBe("2h")
|
|
||||||
expect(formatDuration(8100)).toBe("2h 15m")
|
|
||||||
expect(formatDuration(86399)).toBe("23h 59m")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("formats days under a week", () => {
|
|
||||||
expect(formatDuration(86400)).toBe("~1 day")
|
|
||||||
expect(formatDuration(172800)).toBe("~2 days")
|
|
||||||
expect(formatDuration(259200)).toBe("~3 days")
|
|
||||||
expect(formatDuration(604799)).toBe("~6 days")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("formats weeks", () => {
|
|
||||||
expect(formatDuration(604800)).toBe("~1 week")
|
|
||||||
expect(formatDuration(1209600)).toBe("~2 weeks")
|
|
||||||
expect(formatDuration(1609200)).toBe("~2 weeks")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("handles boundary values correctly", () => {
|
|
||||||
expect(formatDuration(59)).toBe("59s")
|
|
||||||
expect(formatDuration(60)).toBe("1m")
|
|
||||||
expect(formatDuration(3599)).toBe("59m 59s")
|
|
||||||
expect(formatDuration(3600)).toBe("1h")
|
|
||||||
expect(formatDuration(86399)).toBe("23h 59m")
|
|
||||||
expect(formatDuration(86400)).toBe("~1 day")
|
|
||||||
expect(formatDuration(604799)).toBe("~6 days")
|
|
||||||
expect(formatDuration(604800)).toBe("~1 week")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
import { getRevertDiffFiles } from "../../src/util/revert-diff"
|
|
||||||
|
|
||||||
describe("revert diff", () => {
|
|
||||||
test("prefers the actual file path over /dev/null for added and deleted files", () => {
|
|
||||||
const files = getRevertDiffFiles(`diff --git a/new.txt b/new.txt
|
|
||||||
new file mode 100644
|
|
||||||
index 0000000..3b18e51
|
|
||||||
--- /dev/null
|
|
||||||
+++ b/new.txt
|
|
||||||
@@ -0,0 +1 @@
|
|
||||||
+new content
|
|
||||||
diff --git a/old.txt b/old.txt
|
|
||||||
deleted file mode 100644
|
|
||||||
index 3b18e51..0000000
|
|
||||||
--- a/old.txt
|
|
||||||
+++ /dev/null
|
|
||||||
@@ -1 +0,0 @@
|
|
||||||
-old content
|
|
||||||
`)
|
|
||||||
|
|
||||||
expect(files).toEqual([
|
|
||||||
{
|
|
||||||
filename: "new.txt",
|
|
||||||
additions: 1,
|
|
||||||
deletions: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
filename: "old.txt",
|
|
||||||
additions: 0,
|
|
||||||
deletions: 1,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
+3
-2
@@ -246,7 +246,8 @@ mutable fields:
|
|||||||
| ------------------------------------------- | ------------------------------------------------------------------------------ |
|
| ------------------------------------------- | ------------------------------------------------------------------------------ |
|
||||||
| `ctx.aisdk.hook("sdk", callback)` | `sdk`, after inspecting `model`, `package`, and `options` |
|
| `ctx.aisdk.hook("sdk", callback)` | `sdk`, after inspecting `model`, `package`, and `options` |
|
||||||
| `ctx.aisdk.hook("language", callback)` | `language`, after inspecting `model`, `sdk`, and `options` |
|
| `ctx.aisdk.hook("language", callback)` | `language`, after inspecting `model`, `sdk`, and `options` |
|
||||||
| `ctx.session.hook("request", callback)` | `system`, `messages`, and the `tools` record immediately before model dispatch |
|
| `ctx.session.hook("context", callback)` | `system`, `messages`, and the `tools` record immediately before model dispatch |
|
||||||
|
| `ctx.session.hook("http", callback)` | `request`, wrapping the model's HTTP request and response |
|
||||||
| `ctx.tool.hook("execute.before", callback)` | `input`, before the selected tool executes |
|
| `ctx.tool.hook("execute.before", callback)` | `input`, before the selected tool executes |
|
||||||
| `ctx.tool.hook("execute.after", callback)` | Terminal `result` on success or `error` on failure |
|
| `ctx.tool.hook("execute.after", callback)` | Terminal `result` on success or `error` on failure |
|
||||||
|
|
||||||
@@ -259,7 +260,7 @@ import { Plugin } from "@opencode-ai/plugin"
|
|||||||
export default Plugin.define({
|
export default Plugin.define({
|
||||||
id: "acme.guards",
|
id: "acme.guards",
|
||||||
setup: async (ctx) => {
|
setup: async (ctx) => {
|
||||||
await ctx.session.hook("request", (event) => {
|
await ctx.session.hook("context", (event) => {
|
||||||
delete event.tools.write
|
delete event.tools.write
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user