mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-11 20:19:53 -04:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1197209531 | |||
| 1c53c90d4e | |||
| edfd0bdb0b | |||
| 1b2e4750e1 | |||
| 59642a436f | |||
| 055eb78f06 | |||
| 722e0a04b2 |
@@ -1,4 +1,4 @@
|
|||||||
import { Cause, Context, Effect, Layer, Option, Schema, Stream } from "effect"
|
import { Cause, Context, Effect, Layer, Option, Schema } from "effect"
|
||||||
import {
|
import {
|
||||||
FetchHttpClient,
|
FetchHttpClient,
|
||||||
Headers,
|
Headers,
|
||||||
@@ -16,17 +16,12 @@ import {
|
|||||||
TransportReason,
|
TransportReason,
|
||||||
} from "../schema"
|
} from "../schema"
|
||||||
import { classifyProviderFailure } from "../provider-error"
|
import { classifyProviderFailure } from "../provider-error"
|
||||||
import { isRecord } from "../utils/record"
|
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly execute: (
|
readonly execute: (
|
||||||
request: HttpClientRequest.HttpClientRequest,
|
request: HttpClientRequest.HttpClientRequest,
|
||||||
middleware?: HttpMiddleware,
|
middleware?: HttpMiddleware,
|
||||||
) => Effect.Effect<HttpClientResponse.HttpClientResponse, AIError>
|
) => Effect.Effect<HttpClientResponse.HttpClientResponse, AIError>
|
||||||
readonly stream: (
|
|
||||||
request: HttpClientRequest.HttpClientRequest,
|
|
||||||
middleware?: HttpMiddleware,
|
|
||||||
) => Stream.Stream<Uint8Array, AIError>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type HttpHandler = (
|
export type HttpHandler = (
|
||||||
@@ -302,51 +297,41 @@ export const classifyHttpFailure = (input: {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
type HttpOperation = "request" | "read"
|
const toHttpError = (redactedNames: ReadonlyArray<string | RegExp>) => (error: unknown) => {
|
||||||
|
const transportError = (input: {
|
||||||
const httpError = (input: {
|
readonly message: string
|
||||||
readonly error: unknown
|
readonly kind?: string | undefined
|
||||||
readonly request: HttpClientRequest.HttpClientRequest
|
readonly request?: HttpClientRequest.HttpClientRequest | undefined
|
||||||
readonly operation: HttpOperation
|
}) =>
|
||||||
readonly redactedNames: ReadonlyArray<string | RegExp>
|
|
||||||
}) => {
|
|
||||||
const transportError = (failure: { readonly message: string; readonly code?: string | undefined }) =>
|
|
||||||
new AIError({
|
new AIError({
|
||||||
module: "RequestExecutor",
|
module: "RequestExecutor",
|
||||||
method: input.operation,
|
method: "execute",
|
||||||
reason: new TransportReason({
|
reason: new TransportReason({
|
||||||
message: failure.message,
|
message: input.message,
|
||||||
transport: "http",
|
kind: input.kind,
|
||||||
operation: input.operation,
|
url: input.request ? redactUrl(input.request.url) : undefined,
|
||||||
code: failure.code,
|
http: input.request ? new HttpContext({ request: requestDetails(input.request, redactedNames) }) : undefined,
|
||||||
url: redactUrl(input.request.url),
|
|
||||||
http: new HttpContext({ request: requestDetails(input.request, input.redactedNames) }),
|
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
const source =
|
if (Cause.isTimeoutError(error)) {
|
||||||
HttpClientError.isHttpClientError(input.error) && "cause" in input.error.reason
|
return transportError({ message: error.message, kind: "Timeout" })
|
||||||
? input.error.reason.cause
|
}
|
||||||
: input.error
|
if (!HttpClientError.isHttpClientError(error)) {
|
||||||
const code = isRecord(source) && typeof source.code === "string" ? source.code : undefined
|
return transportError({ message: error instanceof Error ? error.message : "HTTP transport failed" })
|
||||||
const request = HttpClientError.isHttpClientError(input.error) ? input.error.request : input.request
|
}
|
||||||
const raw = source instanceof Error ? source.message : input.error instanceof Error ? input.error.message : undefined
|
const request = "request" in error ? error.request : undefined
|
||||||
const detail = raw ? redactBody(raw, secretValues(request)) : undefined
|
if (error.reason._tag === "TransportError") {
|
||||||
const message = code && detail && !detail.includes(code) ? `${code}: ${detail}` : detail
|
|
||||||
|
|
||||||
if (Cause.isTimeoutError(input.error) || Cause.isTimeoutError(source))
|
|
||||||
return transportError({ message: message ?? "HTTP transport timed out", code: code ?? "Timeout" })
|
|
||||||
if (!HttpClientError.isHttpClientError(input.error))
|
|
||||||
return transportError({ message: message ?? "HTTP transport failed", code })
|
|
||||||
if (input.error.reason._tag === "TransportError") {
|
|
||||||
return transportError({
|
return transportError({
|
||||||
message: message ?? input.error.reason.description ?? "HTTP transport failed",
|
message: error.reason.description ?? "HTTP transport failed",
|
||||||
code: code ?? input.error.reason._tag,
|
kind: error.reason._tag,
|
||||||
|
request,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return transportError({
|
return transportError({
|
||||||
message: message ?? `HTTP transport failed: ${input.error.reason._tag}`,
|
message: `HTTP transport failed: ${error.reason._tag}`,
|
||||||
code: code ?? input.error.reason._tag,
|
kind: error.reason._tag,
|
||||||
|
request,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,41 +339,23 @@ 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 execute = (
|
const executeOnce = (request: HttpClientRequest.HttpClientRequest, middleware?: HttpMiddleware) =>
|
||||||
request: HttpClientRequest.HttpClientRequest,
|
|
||||||
middleware: HttpMiddleware | undefined,
|
|
||||||
redactedNames: ReadonlyArray<string | RegExp>,
|
|
||||||
) =>
|
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
const redactedNames = yield* Headers.CurrentRedactedNames
|
||||||
if (!middleware)
|
if (!middleware)
|
||||||
return yield* http.execute(request).pipe(
|
return yield* http
|
||||||
Effect.mapError((error) => httpError({ error, request, operation: "request", redactedNames })),
|
.execute(request)
|
||||||
Effect.flatMap(statusError(request, redactedNames)),
|
.pipe(Effect.mapError(toHttpError(redactedNames)), Effect.flatMap(statusError(request, redactedNames)))
|
||||||
)
|
|
||||||
|
|
||||||
const response = yield* middleware(request, (input) =>
|
const response = yield* middleware(request, (input) =>
|
||||||
http
|
http
|
||||||
.execute(input)
|
.execute(input)
|
||||||
.pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))),
|
.pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))),
|
||||||
).pipe(Effect.mapError((error) => httpError({ error, request, operation: "request", redactedNames })))
|
).pipe(Effect.mapError(toHttpError(redactedNames)))
|
||||||
return yield* statusError(response.request, redactedNames)(response)
|
return yield* statusError(response.request, redactedNames)(response)
|
||||||
})
|
})
|
||||||
const executeOnce = (request: HttpClientRequest.HttpClientRequest, middleware?: HttpMiddleware) =>
|
|
||||||
Effect.flatMap(Headers.CurrentRedactedNames, (redactedNames) => execute(request, middleware, redactedNames))
|
|
||||||
return Service.of({
|
return Service.of({
|
||||||
execute: executeOnce,
|
execute: executeOnce,
|
||||||
stream: (request, middleware) =>
|
|
||||||
Stream.unwrap(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const redactedNames = yield* Headers.CurrentRedactedNames
|
|
||||||
const response = yield* execute(request, middleware, redactedNames)
|
|
||||||
return response.stream.pipe(
|
|
||||||
Stream.mapError((error) =>
|
|
||||||
httpError({ error, request: response.request, operation: "read", redactedNames }),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Effect } from "effect"
|
import { Effect, Stream } from "effect"
|
||||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
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"
|
||||||
@@ -86,8 +86,26 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
|
|||||||
middleware: prepareInput.middleware,
|
middleware: prepareInput.middleware,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
frames: (prepared, _request, runtime) =>
|
frames: (prepared, request, runtime) =>
|
||||||
prepared.framing.frame(runtime.http.stream(prepared.request, prepared.middleware)),
|
Stream.unwrap(
|
||||||
|
runtime.http
|
||||||
|
.execute(prepared.request, prepared.middleware)
|
||||||
|
.pipe(
|
||||||
|
Effect.map((response) =>
|
||||||
|
prepared.framing.frame(
|
||||||
|
response.stream.pipe(
|
||||||
|
Stream.mapError((error) =>
|
||||||
|
ProviderShared.eventError(
|
||||||
|
`${request.model.provider}/${request.model.route.id}`,
|
||||||
|
`Failed to read ${request.model.provider}/${request.model.route.id} stream`,
|
||||||
|
ProviderShared.errorText(error),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const sseJson = {
|
export const sseJson = {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Cause, Context, Effect, Layer, Queue, Stream } from "effect"
|
import { Cause, Context, Effect, Layer, Queue, Stream } from "effect"
|
||||||
import { Headers } from "effect/unstable/http"
|
import { Headers } from "effect/unstable/http"
|
||||||
import { AIError, TransportReason, type TransportOperation } from "../../schema"
|
import { AIError, TransportReason } from "../../schema"
|
||||||
import * as HttpTransport from "./http"
|
import * as HttpTransport from "./http"
|
||||||
import type { Transport } from "./index"
|
import type { Transport } from "./index"
|
||||||
|
|
||||||
@@ -29,18 +29,12 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/AI
|
|||||||
const transportError = (
|
const transportError = (
|
||||||
method: string,
|
method: string,
|
||||||
message: string,
|
message: string,
|
||||||
input: { readonly operation: TransportOperation; readonly url?: string; readonly code?: string },
|
input: { readonly url?: string; readonly kind?: string } = {},
|
||||||
) =>
|
) =>
|
||||||
new AIError({
|
new AIError({
|
||||||
module: "WebSocketExecutor",
|
module: "WebSocketExecutor",
|
||||||
method,
|
method,
|
||||||
reason: new TransportReason({
|
reason: new TransportReason({ message, url: input.url, kind: input.kind }),
|
||||||
message,
|
|
||||||
transport: "websocket",
|
|
||||||
operation: input.operation,
|
|
||||||
url: input.url,
|
|
||||||
code: input.code,
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const eventMessage = (event: Event) => {
|
const eventMessage = (event: Event) => {
|
||||||
@@ -61,8 +55,7 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
|
|||||||
return Effect.fail(
|
return Effect.fail(
|
||||||
transportError("open", `WebSocket closed before opening (state ${ws.readyState})`, {
|
transportError("open", `WebSocket closed before opening (state ${ws.readyState})`, {
|
||||||
url: input.url,
|
url: input.url,
|
||||||
operation: "request",
|
kind: "open",
|
||||||
code: "closed",
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -86,10 +79,7 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
|
|||||||
cleanup()
|
cleanup()
|
||||||
resume(
|
resume(
|
||||||
Effect.fail(
|
Effect.fail(
|
||||||
transportError("open", `Failed to open WebSocket: ${eventMessage(event)}`, {
|
transportError("open", `Failed to open WebSocket: ${eventMessage(event)}`, { url: input.url, kind: "open" }),
|
||||||
url: input.url,
|
|
||||||
operation: "request",
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -99,8 +89,7 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
|
|||||||
Effect.fail(
|
Effect.fail(
|
||||||
transportError("open", `WebSocket closed before opening with code ${event.code}`, {
|
transportError("open", `WebSocket closed before opening with code ${event.code}`, {
|
||||||
url: input.url,
|
url: input.url,
|
||||||
operation: "request",
|
kind: "open",
|
||||||
code: String(event.code),
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -129,8 +118,7 @@ const webSocketUrl = (value: string) =>
|
|||||||
catch: (error) =>
|
catch: (error) =>
|
||||||
transportError("prepare", error instanceof Error ? error.message : "Invalid WebSocket URL", {
|
transportError("prepare", error instanceof Error ? error.message : "Invalid WebSocket URL", {
|
||||||
url: value,
|
url: value,
|
||||||
operation: "request",
|
kind: "websocket",
|
||||||
code: "invalid-url",
|
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -141,7 +129,7 @@ export const open = (input: WebSocketRequest) =>
|
|||||||
catch: (error) =>
|
catch: (error) =>
|
||||||
transportError("open", error instanceof Error ? error.message : "Failed to construct WebSocket", {
|
transportError("open", error instanceof Error ? error.message : "Failed to construct WebSocket", {
|
||||||
url: input.url,
|
url: input.url,
|
||||||
operation: "request",
|
kind: "open",
|
||||||
}),
|
}),
|
||||||
}).pipe(Effect.flatMap((ws) => fromWebSocket(ws, input)))
|
}).pipe(Effect.flatMap((ws) => fromWebSocket(ws, input)))
|
||||||
|
|
||||||
@@ -162,10 +150,7 @@ export const fromWebSocket = (
|
|||||||
Queue.failCauseUnsafe(
|
Queue.failCauseUnsafe(
|
||||||
messages,
|
messages,
|
||||||
Cause.fail(
|
Cause.fail(
|
||||||
transportError("message", "Unsupported WebSocket message payload", {
|
transportError("message", "Unsupported WebSocket message payload", { url: input.url, kind: "message" }),
|
||||||
url: input.url,
|
|
||||||
operation: "read",
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -173,10 +158,7 @@ export const fromWebSocket = (
|
|||||||
Queue.failCauseUnsafe(
|
Queue.failCauseUnsafe(
|
||||||
messages,
|
messages,
|
||||||
Cause.fail(
|
Cause.fail(
|
||||||
transportError("message", `WebSocket error: ${eventMessage(event)}`, {
|
transportError("message", `WebSocket error: ${eventMessage(event)}`, { url: input.url, kind: "message" }),
|
||||||
url: input.url,
|
|
||||||
operation: "read",
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -185,11 +167,7 @@ export const fromWebSocket = (
|
|||||||
Queue.failCauseUnsafe(
|
Queue.failCauseUnsafe(
|
||||||
messages,
|
messages,
|
||||||
Cause.fail(
|
Cause.fail(
|
||||||
transportError("message", `WebSocket closed with code ${event.code}`, {
|
transportError("message", `WebSocket closed with code ${event.code}`, { url: input.url, kind: "close" }),
|
||||||
url: input.url,
|
|
||||||
operation: "read",
|
|
||||||
code: String(event.code),
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -210,7 +188,7 @@ export const fromWebSocket = (
|
|||||||
catch: (error) =>
|
catch: (error) =>
|
||||||
transportError("sendText", error instanceof Error ? error.message : "Failed to send WebSocket message", {
|
transportError("sendText", error instanceof Error ? error.message : "Failed to send WebSocket message", {
|
||||||
url: input.url,
|
url: input.url,
|
||||||
operation: "write",
|
kind: "write",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
messages: Stream.fromQueue(messages),
|
messages: Stream.fromQueue(messages),
|
||||||
@@ -265,8 +243,7 @@ export const json = <Body, Message>(input: JsonInput<Body, Message>): JsonTransp
|
|||||||
return Stream.fail(
|
return Stream.fail(
|
||||||
transportError("json", "WebSocket JSON transport requires WebSocketExecutor.Service", {
|
transportError("json", "WebSocket JSON transport requires WebSocketExecutor.Service", {
|
||||||
url: prepared.url,
|
url: prepared.url,
|
||||||
operation: "request",
|
kind: "websocket",
|
||||||
code: "unavailable",
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,18 +92,10 @@ export class ProviderInternalReason extends Schema.Class<ProviderInternalReason>
|
|||||||
http: Schema.optional(HttpContext),
|
http: Schema.optional(HttpContext),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export const TransportType = Schema.Literals(["http", "websocket"])
|
|
||||||
export type TransportType = typeof TransportType.Type
|
|
||||||
|
|
||||||
export const TransportOperation = Schema.Literals(["request", "read", "write"])
|
|
||||||
export type TransportOperation = typeof TransportOperation.Type
|
|
||||||
|
|
||||||
export class TransportReason extends Schema.Class<TransportReason>("AI.Error.Transport")({
|
export class TransportReason extends Schema.Class<TransportReason>("AI.Error.Transport")({
|
||||||
_tag: Schema.tag("Transport"),
|
_tag: Schema.tag("Transport"),
|
||||||
message: Schema.String,
|
message: Schema.String,
|
||||||
transport: TransportType,
|
kind: Schema.optional(Schema.String),
|
||||||
operation: TransportOperation,
|
|
||||||
code: Schema.optional(Schema.String),
|
|
||||||
url: Schema.optional(Schema.String),
|
url: Schema.optional(Schema.String),
|
||||||
http: Schema.optional(HttpContext),
|
http: Schema.optional(HttpContext),
|
||||||
}) {}
|
}) {}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect, Layer, Ref, Stream } from "effect"
|
import { Effect, Layer, Ref } from "effect"
|
||||||
import { Headers, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
import { Headers, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||||
import { LLM, AIError } from "../src"
|
import { LLM, AIError } from "../src"
|
||||||
import { LLMClient, RequestExecutor } from "../src/route"
|
import { LLMClient, RequestExecutor } from "../src/route"
|
||||||
import * as OpenAIChat from "../src/protocols/openai-chat"
|
import * as OpenAIChat from "../src/protocols/openai-chat"
|
||||||
import { dynamicResponse, systemError } from "./lib/http"
|
import { dynamicResponse } from "./lib/http"
|
||||||
import { deltaChunk } from "./lib/openai-chunks"
|
import { deltaChunk } from "./lib/openai-chunks"
|
||||||
import { sseRaw } from "./lib/sse"
|
import { sseRaw } from "./lib/sse"
|
||||||
import { it } from "./lib/effect"
|
import { it } from "./lib/effect"
|
||||||
@@ -67,35 +67,6 @@ const expectAIError = (error: unknown) => {
|
|||||||
const errorHttp = (error: AIError) => ("http" in error.reason ? error.reason.http : undefined)
|
const errorHttp = (error: AIError) => ("http" in error.reason ? error.reason.http : undefined)
|
||||||
|
|
||||||
describe("RequestExecutor", () => {
|
describe("RequestExecutor", () => {
|
||||||
it.effect("parses response body failures at the executor seam", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const executor = yield* RequestExecutor.Service
|
|
||||||
const error = yield* executor.stream(secretRequest).pipe(Stream.runDrain, Effect.flip)
|
|
||||||
|
|
||||||
expectAIError(error)
|
|
||||||
expect(error.reason).toMatchObject({
|
|
||||||
_tag: "Transport",
|
|
||||||
message: "ECONNRESET: disconnected <redacted> <redacted>",
|
|
||||||
transport: "http",
|
|
||||||
operation: "read",
|
|
||||||
code: "ECONNRESET",
|
|
||||||
url: "https://provider.test/v1/chat?api_key=%3Credacted%3E&debug=1",
|
|
||||||
})
|
|
||||||
}).pipe(
|
|
||||||
Effect.provide(
|
|
||||||
responsesLayer([
|
|
||||||
new Response(
|
|
||||||
new ReadableStream({
|
|
||||||
start(controller) {
|
|
||||||
controller.error(systemError("ECONNRESET", "disconnected query-secret-123 header-secret-456"))
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("preserves middleware error messages", () =>
|
it.effect("preserves middleware error messages", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const executor = yield* RequestExecutor.Service
|
const executor = yield* RequestExecutor.Service
|
||||||
|
|||||||
@@ -34,12 +34,6 @@ const handlerLayer = (handler: Handler): Layer.Layer<HttpClient.HttpClient> =>
|
|||||||
|
|
||||||
export type RuntimeEnv = RequestExecutorService | WebSocketExecutorService | LLMClientService
|
export type RuntimeEnv = RequestExecutorService | WebSocketExecutorService | LLMClientService
|
||||||
|
|
||||||
export interface SystemError extends Error {
|
|
||||||
readonly code: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const systemError = (code: string, message: string): SystemError => Object.assign(new Error(message), { code })
|
|
||||||
|
|
||||||
export const runtimeLayer = (layer: Layer.Layer<HttpClient.HttpClient>): Layer.Layer<RuntimeEnv> => {
|
export const runtimeLayer = (layer: Layer.Layer<HttpClient.HttpClient>): Layer.Layer<RuntimeEnv> => {
|
||||||
const requestExecutorLayer = RequestExecutor.layer.pipe(Layer.provide(layer))
|
const requestExecutorLayer = RequestExecutor.layer.pipe(Layer.provide(layer))
|
||||||
const deps = Layer.mergeAll(requestExecutorLayer, WebSocketExecutor.layer)
|
const deps = Layer.mergeAll(requestExecutorLayer, WebSocketExecutor.layer)
|
||||||
@@ -69,14 +63,14 @@ export const dynamicResponse = (handler: Handler) => runtimeLayer(handlerLayer(h
|
|||||||
* Layer that emits the supplied SSE chunks and then aborts mid-stream. Used to
|
* Layer that emits the supplied SSE chunks and then aborts mid-stream. Used to
|
||||||
* exercise transport errors that surface during parsing.
|
* exercise transport errors that surface during parsing.
|
||||||
*/
|
*/
|
||||||
export const truncatedStream = (chunks: ReadonlyArray<string>, error: Error = new Error("connection reset")) =>
|
export const truncatedStream = (chunks: ReadonlyArray<string>) =>
|
||||||
dynamicResponse((input) =>
|
dynamicResponse((input) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
const encoder = new TextEncoder()
|
const encoder = new TextEncoder()
|
||||||
const stream = new ReadableStream({
|
const stream = new ReadableStream({
|
||||||
start(controller) {
|
start(controller) {
|
||||||
for (const chunk of chunks) controller.enqueue(encoder.encode(chunk))
|
for (const chunk of chunks) controller.enqueue(encoder.encode(chunk))
|
||||||
controller.error(error)
|
controller.error(new Error("connection reset"))
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return input.respond(stream, { headers: SSE_HEADERS })
|
return input.respond(stream, { headers: SSE_HEADERS })
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import { ProviderShared } from "../../src/protocols/shared"
|
|||||||
import { Auth, LLMClient } from "../../src/route"
|
import { Auth, LLMClient } from "../../src/route"
|
||||||
import { compileRequest } from "../../src/route/client"
|
import { compileRequest } from "../../src/route/client"
|
||||||
import { it } from "../lib/effect"
|
import { it } from "../lib/effect"
|
||||||
import { dynamicResponse, fixedResponse, systemError, truncatedStream } from "../lib/http"
|
import { dynamicResponse, fixedResponse, truncatedStream } from "../lib/http"
|
||||||
import { deltaChunk, usageChunk } from "../lib/openai-chunks"
|
import { deltaChunk, usageChunk } from "../lib/openai-chunks"
|
||||||
import { sseEvents } from "../lib/sse"
|
import { sseEvents } from "../lib/sse"
|
||||||
|
|
||||||
@@ -1221,20 +1221,12 @@ describe("OpenAI Chat route", () => {
|
|||||||
|
|
||||||
it.effect("surfaces transport errors that occur mid-stream", () =>
|
it.effect("surfaces transport errors that occur mid-stream", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const layer = truncatedStream(
|
const layer = truncatedStream([
|
||||||
[`data: ${JSON.stringify(deltaChunk({ role: "assistant", content: "Hello" }))}\n\n`],
|
`data: ${JSON.stringify(deltaChunk({ role: "assistant", content: "Hello" }))}\n\n`,
|
||||||
systemError("ECONNRESET", "socket closed unexpectedly"),
|
])
|
||||||
)
|
|
||||||
const error = yield* LLMClient.generate(request).pipe(Effect.provide(layer), Effect.flip)
|
const error = yield* LLMClient.generate(request).pipe(Effect.provide(layer), Effect.flip)
|
||||||
|
|
||||||
expect(error.reason).toMatchObject({
|
expect(error.message).toContain("Failed to read openai/openai-chat stream")
|
||||||
_tag: "Transport",
|
|
||||||
message: "ECONNRESET: socket closed unexpectedly",
|
|
||||||
transport: "http",
|
|
||||||
operation: "read",
|
|
||||||
code: "ECONNRESET",
|
|
||||||
url: "https://api.openai.test/v1/chat/completions",
|
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -243,7 +243,6 @@ describe("OpenAI Responses route", () => {
|
|||||||
RequestExecutor.Service,
|
RequestExecutor.Service,
|
||||||
RequestExecutor.Service.of({
|
RequestExecutor.Service.of({
|
||||||
execute: () => Effect.die("unexpected HTTP request"),
|
execute: () => Effect.die("unexpected HTTP request"),
|
||||||
stream: () => Stream.die("unexpected HTTP request"),
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
Layer.succeed(
|
Layer.succeed(
|
||||||
|
|||||||
@@ -69,63 +69,6 @@ describe("v2 session reducer", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("prefers durable selection predecessors and derives them for older events", () => {
|
|
||||||
const source: SessionMessageInfo[] = [
|
|
||||||
{ id: "msg_previous_agent", type: "agent-switched", agent: "build", time: { created: 1 } },
|
|
||||||
{
|
|
||||||
id: "msg_previous_model",
|
|
||||||
type: "model-switched",
|
|
||||||
model: { id: "old", providerID: "provider" },
|
|
||||||
time: { created: 1 },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
const reducer = createV2SessionReducer()
|
|
||||||
|
|
||||||
const agent = reducer.reduce(
|
|
||||||
source,
|
|
||||||
event({
|
|
||||||
...base,
|
|
||||||
id: "evt_agent",
|
|
||||||
type: "session.agent.selected",
|
|
||||||
data: { sessionID: "ses_1", agent: "plan", previous: "review" },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const model = reducer.reduce(
|
|
||||||
source,
|
|
||||||
event({
|
|
||||||
...base,
|
|
||||||
id: "evt_model",
|
|
||||||
type: "session.model.selected",
|
|
||||||
data: {
|
|
||||||
sessionID: "ses_1",
|
|
||||||
model: { id: "new", providerID: "provider" },
|
|
||||||
previous: { id: "durable", providerID: "provider" },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const legacyAgent = reducer.reduce(
|
|
||||||
source,
|
|
||||||
event({
|
|
||||||
...base,
|
|
||||||
id: "evt_legacy_agent",
|
|
||||||
type: "session.agent.selected",
|
|
||||||
data: { sessionID: "ses_1", agent: "plan" },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(agent?.messages.at(-1)).toMatchObject({ type: "agent-switched", agent: "plan", previous: "review" })
|
|
||||||
expect(model?.messages.at(-1)).toMatchObject({
|
|
||||||
type: "model-switched",
|
|
||||||
model: { id: "new" },
|
|
||||||
previous: { id: "durable" },
|
|
||||||
})
|
|
||||||
expect(legacyAgent?.messages.at(-1)).toMatchObject({
|
|
||||||
type: "agent-switched",
|
|
||||||
agent: "plan",
|
|
||||||
previous: "build",
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("folds tool, retry, and completion events", () => {
|
test("folds tool, retry, and completion events", () => {
|
||||||
const reducer = createV2SessionReducer()
|
const reducer = createV2SessionReducer()
|
||||||
let messages: SessionMessageInfo[] = []
|
let messages: SessionMessageInfo[] = []
|
||||||
|
|||||||
@@ -61,12 +61,6 @@ export function createV2SessionReducer() {
|
|||||||
type: "agent-switched",
|
type: "agent-switched",
|
||||||
metadata: event.metadata,
|
metadata: event.metadata,
|
||||||
agent: event.data.agent,
|
agent: event.data.agent,
|
||||||
previous:
|
|
||||||
event.data.previous ??
|
|
||||||
source.findLast(
|
|
||||||
(item): item is Extract<SessionMessageInfo, { type: "agent-switched" | "assistant" }> =>
|
|
||||||
item.type === "agent-switched" || item.type === "assistant",
|
|
||||||
)?.agent,
|
|
||||||
time: { created: event.created },
|
time: { created: event.created },
|
||||||
})
|
})
|
||||||
case "session.model.selected":
|
case "session.model.selected":
|
||||||
@@ -75,12 +69,10 @@ export function createV2SessionReducer() {
|
|||||||
type: "model-switched",
|
type: "model-switched",
|
||||||
metadata: event.metadata,
|
metadata: event.metadata,
|
||||||
model: event.data.model,
|
model: event.data.model,
|
||||||
previous:
|
previous: source.findLast(
|
||||||
event.data.previous ??
|
(item): item is Extract<SessionMessageInfo, { type: "model-switched" | "assistant" }> =>
|
||||||
source.findLast(
|
item.type === "model-switched" || item.type === "assistant",
|
||||||
(item): item is Extract<SessionMessageInfo, { type: "model-switched" | "assistant" }> =>
|
)?.model,
|
||||||
item.type === "model-switched" || item.type === "assistant",
|
|
||||||
)?.model,
|
|
||||||
time: { created: event.created },
|
time: { created: event.created },
|
||||||
})
|
})
|
||||||
case "session.synthetic":
|
case "session.synthetic":
|
||||||
|
|||||||
@@ -339,11 +339,7 @@ export type Endpoint5_31Output =
|
|||||||
readonly type: "session.agent.selected"
|
readonly type: "session.agent.selected"
|
||||||
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
|
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
|
||||||
readonly location?: Location.Ref | undefined
|
readonly location?: Location.Ref | undefined
|
||||||
readonly data: {
|
readonly data: { readonly sessionID: Session.ID; readonly agent: Agent.ID }
|
||||||
readonly sessionID: Session.ID
|
|
||||||
readonly agent: Agent.ID
|
|
||||||
readonly previous?: Agent.ID | undefined
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly id: Event.ID
|
readonly id: Event.ID
|
||||||
@@ -352,11 +348,7 @@ export type Endpoint5_31Output =
|
|||||||
readonly type: "session.model.selected"
|
readonly type: "session.model.selected"
|
||||||
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
|
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
|
||||||
readonly location?: Location.Ref | undefined
|
readonly location?: Location.Ref | undefined
|
||||||
readonly data: {
|
readonly data: { readonly sessionID: Session.ID; readonly model: Model.Ref }
|
||||||
readonly sessionID: Session.ID
|
|
||||||
readonly model: Model.Ref
|
|
||||||
readonly previous?: Model.Ref | undefined
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly id: Event.ID
|
readonly id: Event.ID
|
||||||
|
|||||||
@@ -436,7 +436,7 @@ export type SessionAgentSelected = {
|
|||||||
type: "session.agent.selected"
|
type: "session.agent.selected"
|
||||||
durable: { aggregateID: string; seq: number; version: 1 }
|
durable: { aggregateID: string; seq: number; version: 1 }
|
||||||
location?: LocationRef
|
location?: LocationRef
|
||||||
data: { sessionID: string; agent: string; previous?: string }
|
data: { sessionID: string; agent: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionModelSelected = {
|
export type SessionModelSelected = {
|
||||||
@@ -446,7 +446,7 @@ export type SessionModelSelected = {
|
|||||||
type: "session.model.selected"
|
type: "session.model.selected"
|
||||||
durable: { aggregateID: string; seq: number; version: 1 }
|
durable: { aggregateID: string; seq: number; version: 1 }
|
||||||
location?: LocationRef
|
location?: LocationRef
|
||||||
data: { sessionID: string; model: ModelRef; previous?: ModelRef }
|
data: { sessionID: string; model: ModelRef }
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionMoved = {
|
export type SessionMoved = {
|
||||||
|
|||||||
@@ -763,9 +763,7 @@ function apiCallErrorReason(error: APICallError) {
|
|||||||
if (error.statusCode !== undefined || !error.isRetryable) return reason
|
if (error.statusCode !== undefined || !error.isRetryable) return reason
|
||||||
return new TransportReason({
|
return new TransportReason({
|
||||||
message: reason.message,
|
message: reason.message,
|
||||||
transport: "http",
|
kind: error.name,
|
||||||
operation: "request",
|
|
||||||
code: error.name,
|
|
||||||
url: error.url,
|
url: error.url,
|
||||||
http: "http" in reason ? reason.http : undefined,
|
http: "http" in reason ? reason.http : undefined,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { SessionV1 } from "@opencode-ai/schema/session-v1"
|
|||||||
import { SessionMessage } from "../session/message"
|
import { SessionMessage } from "../session/message"
|
||||||
import { SessionSchema } from "../session/schema"
|
import { SessionSchema } from "../session/schema"
|
||||||
import { KVTable } from "../kv/sql"
|
import { KVTable } from "../kv/sql"
|
||||||
import { EventSequenceTable } from "../event/sql"
|
import { EventSequenceTable, EventTable } from "../event/sql"
|
||||||
import { eq, sql } from "drizzle-orm"
|
import { eq, sql } from "drizzle-orm"
|
||||||
import { Global } from "@opencode-ai/util/global"
|
import { Global } from "@opencode-ai/util/global"
|
||||||
import { existsSync } from "node:fs"
|
import { existsSync } from "node:fs"
|
||||||
@@ -161,7 +161,6 @@ type NextMessage = {
|
|||||||
|
|
||||||
const lock = Semaphore.makeUnsafe(1)
|
const lock = Semaphore.makeUnsafe(1)
|
||||||
const MIGRATION_STATE_KEY = "migration.v1-v2"
|
const MIGRATION_STATE_KEY = "migration.v1-v2"
|
||||||
const EVENT_DELETE_BATCH_SIZE = 1_000
|
|
||||||
const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
|
const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
|
||||||
const decodeMessage = Schema.decodeUnknownOption(SessionV1.Info)
|
const decodeMessage = Schema.decodeUnknownOption(SessionV1.Info)
|
||||||
const decodePart = Schema.decodeUnknownOption(SessionV1.Part)
|
const decodePart = Schema.decodeUnknownOption(SessionV1.Part)
|
||||||
@@ -486,15 +485,7 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
|
|||||||
yield* db
|
yield* db
|
||||||
.transaction((tx) =>
|
.transaction((tx) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
while (true) {
|
yield* tx.delete(EventTable).run()
|
||||||
yield* tx.run(sql`
|
|
||||||
DELETE FROM event
|
|
||||||
WHERE rowid IN (SELECT rowid FROM event LIMIT ${EVENT_DELETE_BATCH_SIZE})
|
|
||||||
`)
|
|
||||||
const deleted = (yield* tx.get<{ value: number }>(sql`SELECT changes() AS value`))?.value ?? 0
|
|
||||||
if (deleted < EVENT_DELETE_BATCH_SIZE) break
|
|
||||||
yield* Effect.yieldNow
|
|
||||||
}
|
|
||||||
yield* tx
|
yield* tx
|
||||||
.insert(KVTable)
|
.insert(KVTable)
|
||||||
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions" } })
|
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions" } })
|
||||||
|
|||||||
@@ -716,11 +716,10 @@ const layer = Layer.effect(
|
|||||||
.pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
|
.pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
|
||||||
}),
|
}),
|
||||||
switchAgent: Effect.fn("Session.switchAgent")(function* (input) {
|
switchAgent: Effect.fn("Session.switchAgent")(function* (input) {
|
||||||
const session = yield* result.get(input.sessionID)
|
yield* result.get(input.sessionID)
|
||||||
yield* bus.publish(SessionEvent.AgentSelected, {
|
yield* bus.publish(SessionEvent.AgentSelected, {
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
agent: input.agent,
|
agent: input.agent,
|
||||||
previous: session.agent,
|
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
switchModel: Effect.fn("Session.switchModel")(function* (input) {
|
switchModel: Effect.fn("Session.switchModel")(function* (input) {
|
||||||
@@ -734,7 +733,6 @@ const layer = Layer.effect(
|
|||||||
yield* bus.publish(SessionEvent.ModelSelected, {
|
yield* bus.publish(SessionEvent.ModelSelected, {
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
model: input.model,
|
model: input.model,
|
||||||
previous: session.model,
|
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
rename: Effect.fn("Session.rename")(function* (input) {
|
rename: Effect.fn("Session.rename")(function* (input) {
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
|||||||
"session.usage.recorded": () => Effect.void,
|
"session.usage.recorded": () => Effect.void,
|
||||||
"session.agent.selected": (event) => {
|
"session.agent.selected": (event) => {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const previous = event.data.previous ?? (yield* adapter.getAgent())
|
const previous = yield* adapter.getAgent()
|
||||||
yield* adapter.appendMessage(
|
yield* adapter.appendMessage(
|
||||||
SessionMessage.AgentSelected.make({
|
SessionMessage.AgentSelected.make({
|
||||||
id: SessionMessage.ID.fromEvent(event.id),
|
id: SessionMessage.ID.fromEvent(event.id),
|
||||||
@@ -76,7 +76,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
|||||||
},
|
},
|
||||||
"session.model.selected": (event) => {
|
"session.model.selected": (event) => {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const previous = event.data.previous ?? (yield* adapter.getModel())
|
const previous = yield* adapter.getModel()
|
||||||
yield* adapter.appendMessage(
|
yield* adapter.appendMessage(
|
||||||
SessionMessage.ModelSelected.make({
|
SessionMessage.ModelSelected.make({
|
||||||
id: SessionMessage.ID.fromEvent(event.id),
|
id: SessionMessage.ID.fromEvent(event.id),
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ Usage notes:
|
|||||||
- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label`
|
- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label`
|
||||||
|
|
||||||
export const Input = Schema.Struct({
|
export const Input = Schema.Struct({
|
||||||
questions: Schema.Array(Question.Prompt).check(Schema.isNonEmpty()).annotate({ description: "Questions to ask" }),
|
questions: Schema.NonEmptyArray(Question.Prompt).annotate({ description: "Questions to ask" }),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const Output = Schema.Struct({
|
export const Output = Schema.Struct({
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { LLM, AIError, LLMEvent, Message, isContextOverflowFailure } from "@open
|
|||||||
import { LLMClient, RequestExecutor } from "@opencode-ai/ai/route"
|
import { LLMClient, RequestExecutor } from "@opencode-ai/ai/route"
|
||||||
import { compileRequest } from "@opencode-ai/ai/route/client"
|
import { compileRequest } from "@opencode-ai/ai/route/client"
|
||||||
import { expect } from "bun:test"
|
import { expect } from "bun:test"
|
||||||
import { Effect, Layer, Stream } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
|
|
||||||
const it = testEffect(AISDK.locationLayer)
|
const it = testEffect(AISDK.locationLayer)
|
||||||
@@ -49,10 +49,7 @@ const client = LLMClient.layer.pipe(
|
|||||||
Layer.provide(
|
Layer.provide(
|
||||||
Layer.succeed(
|
Layer.succeed(
|
||||||
RequestExecutor.Service,
|
RequestExecutor.Service,
|
||||||
RequestExecutor.Service.of({
|
RequestExecutor.Service.of({ execute: () => Effect.die("Unexpected HTTP request") }),
|
||||||
execute: () => Effect.die("Unexpected HTTP request"),
|
|
||||||
stream: () => Stream.die("Unexpected HTTP request"),
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -545,12 +542,7 @@ it.effect("retries status-less AI SDK transport failures", () =>
|
|||||||
isRetryable: true,
|
isRetryable: true,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
expect(error.reason).toMatchObject({
|
expect(error.reason).toMatchObject({ _tag: "Transport", kind: "AI_APICallError" })
|
||||||
_tag: "Transport",
|
|
||||||
transport: "http",
|
|
||||||
operation: "request",
|
|
||||||
code: "AI_APICallError",
|
|
||||||
})
|
|
||||||
expect(SessionRunnerRetry.isRetryable(error)).toBeTrue()
|
expect(SessionRunnerRetry.isRetryable(error)).toBeTrue()
|
||||||
expect("http" in error.reason ? error.reason.http?.request.url : undefined).toBe("https://api.example.com/chat")
|
expect("http" in error.reason ? error.reason.http?.request.url : undefined).toBe("https://api.example.com/chat")
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -654,7 +654,7 @@ describe("Session.create", () => {
|
|||||||
expect(yield* session.get(created.id)).toMatchObject({ agent: "plan" })
|
expect(yield* session.get(created.id)).toMatchObject({ agent: "plan" })
|
||||||
expect(
|
expect(
|
||||||
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.drop(1), Stream.take(1), Stream.runCollect)),
|
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.drop(1), Stream.take(1), Stream.runCollect)),
|
||||||
).toMatchObject([{ type: "session.agent.selected", data: { agent: "plan", previous: "build" } }])
|
).toMatchObject([{ type: "session.agent.selected", data: { agent: "plan" } }])
|
||||||
expect(yield* session.messages({ sessionID: created.id, order: "asc" })).toMatchObject([
|
expect(yield* session.messages({ sessionID: created.id, order: "asc" })).toMatchObject([
|
||||||
{ type: "agent-switched", agent: "plan", previous: "build" },
|
{ type: "agent-switched", agent: "plan", previous: "build" },
|
||||||
])
|
])
|
||||||
@@ -678,12 +678,7 @@ describe("Session.create", () => {
|
|||||||
it.effect("switches the selected model through the durable Session event", () =>
|
it.effect("switches the selected model through the durable Session event", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const session = yield* Session.Service
|
const session = yield* Session.Service
|
||||||
const previous = Model.Ref.make({
|
const created = yield* session.create({ location })
|
||||||
id: Model.ID.make("haiku"),
|
|
||||||
providerID: Provider.ID.anthropic,
|
|
||||||
variant: Model.VariantID.make("default"),
|
|
||||||
})
|
|
||||||
const created = yield* session.create({ location, model: previous })
|
|
||||||
const model = Model.Ref.make({
|
const model = Model.Ref.make({
|
||||||
id: Model.ID.make("sonnet"),
|
id: Model.ID.make("sonnet"),
|
||||||
providerID: Provider.ID.anthropic,
|
providerID: Provider.ID.anthropic,
|
||||||
@@ -697,10 +692,7 @@ describe("Session.create", () => {
|
|||||||
yield* logEvents(session, created.id, true).pipe(Stream.drop(1), Stream.take(1), Stream.runCollect),
|
yield* logEvents(session, created.id, true).pipe(Stream.drop(1), Stream.take(1), Stream.runCollect),
|
||||||
)
|
)
|
||||||
expect(bus).toMatchObject([{ type: "session.model.selected" }])
|
expect(bus).toMatchObject([{ type: "session.model.selected" }])
|
||||||
expect(bus[0]?.data).toEqual({ sessionID: created.id, model, previous })
|
expect(bus[0]?.data).toEqual({ sessionID: created.id, model })
|
||||||
expect(yield* session.messages({ sessionID: created.id, order: "asc" })).toMatchObject([
|
|
||||||
{ type: "model-switched", model, previous },
|
|
||||||
])
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -39,9 +39,7 @@ describe("toSessionError", () => {
|
|||||||
)
|
)
|
||||||
expect(toSessionError(llm(new QuotaExceededReason({ message: "quota" }))).type).toBe("provider.quota")
|
expect(toSessionError(llm(new QuotaExceededReason({ message: "quota" }))).type).toBe("provider.quota")
|
||||||
expect(toSessionError(llm(new ContentPolicyReason({ message: "blocked" }))).type).toBe("provider.content-filter")
|
expect(toSessionError(llm(new ContentPolicyReason({ message: "blocked" }))).type).toBe("provider.content-filter")
|
||||||
expect(
|
expect(toSessionError(llm(new TransportReason({ message: "transport" }))).type).toBe("provider.transport")
|
||||||
toSessionError(llm(new TransportReason({ message: "transport", transport: "http", operation: "request" }))).type,
|
|
||||||
).toBe("provider.transport")
|
|
||||||
expect(toSessionError(llm(new ProviderInternalReason({ message: "internal", status: 500 }))).type).toBe(
|
expect(toSessionError(llm(new ProviderInternalReason({ message: "internal", status: 500 }))).type).toBe(
|
||||||
"provider.internal",
|
"provider.internal",
|
||||||
)
|
)
|
||||||
@@ -113,7 +111,7 @@ describe("toSessionError", () => {
|
|||||||
const eligible = [
|
const eligible = [
|
||||||
llm(new RateLimitReason({ message: "rate" })),
|
llm(new RateLimitReason({ message: "rate" })),
|
||||||
llm(new ProviderInternalReason({ message: "internal", status: 500 })),
|
llm(new ProviderInternalReason({ message: "internal", status: 500 })),
|
||||||
llm(new TransportReason({ message: "transport", transport: "http", operation: "request" })),
|
llm(new TransportReason({ message: "transport" })),
|
||||||
]
|
]
|
||||||
const ineligible = [
|
const ineligible = [
|
||||||
llm(new AuthenticationReason({ message: "auth", kind: "invalid" })),
|
llm(new AuthenticationReason({ message: "auth", kind: "invalid" })),
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ describe("SessionExecution lifecycle", () => {
|
|||||||
new AIError({
|
new AIError({
|
||||||
module: "test",
|
module: "test",
|
||||||
method: "stream",
|
method: "stream",
|
||||||
reason: new TransportReason({ message: "Disconnected", transport: "http", operation: "request" }),
|
reason: new TransportReason({ message: "Disconnected" }),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -515,11 +515,7 @@ const providerUnavailable = () =>
|
|||||||
new AIError({
|
new AIError({
|
||||||
module: "test",
|
module: "test",
|
||||||
method: "stream",
|
method: "stream",
|
||||||
reason: new TransportReason({
|
reason: new TransportReason({ message: "Provider unavailable" }),
|
||||||
message: "Provider unavailable",
|
|
||||||
transport: "http",
|
|
||||||
operation: "request",
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const incompleteStream = () =>
|
const incompleteStream = () =>
|
||||||
|
|||||||
@@ -89,30 +89,6 @@ const it = testEffect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
describe("QuestionTool", () => {
|
describe("QuestionTool", () => {
|
||||||
it.effect("emits one item schema for the nonempty questions array", () =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
captured = undefined
|
|
||||||
const registry = yield* Tool.Service
|
|
||||||
const definition = (yield* toolDefinitions(registry)).find((tool) => tool.name === QuestionTool.name)
|
|
||||||
|
|
||||||
expect(definition?.inputSchema).toHaveProperty("properties.questions.type", "array")
|
|
||||||
expect(definition?.inputSchema).toHaveProperty("properties.questions.minItems", 1)
|
|
||||||
expect(definition?.inputSchema).toHaveProperty("properties.questions.items")
|
|
||||||
expect(definition?.inputSchema).not.toHaveProperty("properties.questions.prefixItems")
|
|
||||||
expect(
|
|
||||||
yield* executeTool(registry, {
|
|
||||||
sessionID,
|
|
||||||
...toolIdentity,
|
|
||||||
call: { type: "tool-call", id: "call-question-empty", name: QuestionTool.name, input: { questions: [] } },
|
|
||||||
}),
|
|
||||||
).toMatchObject({
|
|
||||||
status: "error",
|
|
||||||
error: { type: "tool.execution", message: expect.stringContaining("Invalid tool input") },
|
|
||||||
})
|
|
||||||
expect(capturedInput()).toBeUndefined()
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("omits a catalog-denied question and enforces its leaf permission", () =>
|
it.effect("omits a catalog-denied question and enforces its leaf permission", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
captured = undefined
|
captured = undefined
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { Project } from "@opencode-ai/core/project"
|
|||||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
import { Global } from "@opencode-ai/util/global"
|
import { Global } from "@opencode-ai/util/global"
|
||||||
import { Effect, Fiber, Layer, Logger, Schedule, Schema, Scope } from "effect"
|
import { Effect, Layer, Logger, Schedule, Schema, Scope } from "effect"
|
||||||
import { eq, sql } from "drizzle-orm"
|
import { eq, sql } from "drizzle-orm"
|
||||||
import type { SqlClient } from "effect/unstable/sql/SqlClient"
|
import type { SqlClient } from "effect/unstable/sql/SqlClient"
|
||||||
import { tmpdir } from "./fixture/tmpdir"
|
import { tmpdir } from "./fixture/tmpdir"
|
||||||
@@ -798,35 +798,6 @@ describe("V1Migration database workflow", () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("yields while clearing stale events in batches", async () => {
|
|
||||||
await database(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const { db } = yield* Database.Service
|
|
||||||
yield* db.run(sql`INSERT INTO event_sequence (aggregate_id, seq) VALUES ('stale', 2500)`)
|
|
||||||
yield* db.run(sql`
|
|
||||||
WITH RECURSIVE rows(value) AS (
|
|
||||||
VALUES(1)
|
|
||||||
UNION ALL
|
|
||||||
SELECT value + 1 FROM rows WHERE value < 2500
|
|
||||||
)
|
|
||||||
INSERT INTO event (id, aggregate_id, seq, created, type, data)
|
|
||||||
SELECT printf('event_%04d', value), 'stale', value, 1, 'session.renamed.1', '{}'
|
|
||||||
FROM rows
|
|
||||||
`)
|
|
||||||
let yielded = false
|
|
||||||
const heartbeat = yield* Effect.yieldNow.pipe(
|
|
||||||
Effect.andThen(Effect.sync(() => (yielded = true))),
|
|
||||||
Effect.forkChild({ startImmediately: true }),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(yield* V1Migration.run()).toEqual({ status: "completed" })
|
|
||||||
expect(yielded).toBe(true)
|
|
||||||
yield* Fiber.join(heartbeat)
|
|
||||||
expect(yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM event`)).toEqual({ value: 0 })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("imports previous V2 sessions and messages as part of the migration", async () => {
|
test("imports previous V2 sessions and messages as part of the migration", async () => {
|
||||||
await using tmp = await tmpdir()
|
await using tmp = await tmpdir()
|
||||||
const filename = path.join(tmp.path, "opencode-next.db")
|
const filename = path.join(tmp.path, "opencode-next.db")
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
TextRenderable,
|
TextRenderable,
|
||||||
|
BoxRenderable,
|
||||||
RenderableEvents,
|
RenderableEvents,
|
||||||
createMarkdownCodeBlockRenderer,
|
createMarkdownCodeBlockRenderer,
|
||||||
parseColor,
|
parseColor,
|
||||||
@@ -33,6 +34,7 @@ interface PreparedDiagram {
|
|||||||
readonly source: string
|
readonly source: string
|
||||||
readonly text: StyledText
|
readonly text: StyledText
|
||||||
readonly height: number
|
readonly height: number
|
||||||
|
readonly width: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MermaidMarkdownRendererOptions {
|
export interface MermaidMarkdownRendererOptions {
|
||||||
@@ -55,16 +57,23 @@ function color(value: ColorInput | undefined): RGBA | undefined {
|
|||||||
return value === undefined ? undefined : parseColor(value)
|
return value === undefined ? undefined : parseColor(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
class StaticDiagramRenderable extends TextRenderable {
|
class StaticDiagramRenderable extends BoxRenderable {
|
||||||
constructor(ctx: RenderContext, prepared: PreparedDiagram) {
|
constructor(ctx: RenderContext, prepared: PreparedDiagram) {
|
||||||
super(ctx, {
|
super(ctx, {
|
||||||
content: prepared.text,
|
|
||||||
width: "100%",
|
width: "100%",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
flexShrink: 0,
|
||||||
|
marginTop: 1,
|
||||||
|
})
|
||||||
|
const diagram = new TextRenderable(ctx, {
|
||||||
|
content: prepared.text,
|
||||||
|
width: prepared.width,
|
||||||
|
maxWidth: "100%",
|
||||||
height: prepared.height,
|
height: prepared.height,
|
||||||
wrapMode: "none",
|
wrapMode: "none",
|
||||||
selectable: false,
|
selectable: false,
|
||||||
marginTop: 1,
|
|
||||||
})
|
})
|
||||||
|
this.add(diagram)
|
||||||
let dragX: number | undefined
|
let dragX: number | undefined
|
||||||
this.onMouseDown = (event: MouseEvent) => {
|
this.onMouseDown = (event: MouseEvent) => {
|
||||||
if (event.button !== 0) return
|
if (event.button !== 0) return
|
||||||
@@ -79,7 +88,7 @@ class StaticDiagramRenderable extends TextRenderable {
|
|||||||
if (dragX === undefined) return
|
if (dragX === undefined) return
|
||||||
const dx = event.x - dragX
|
const dx = event.x - dragX
|
||||||
dragX = event.x
|
dragX = event.x
|
||||||
if (dx) this.scrollX -= dx
|
if (dx) diagram.scrollX -= dx
|
||||||
}
|
}
|
||||||
this.onMouseDragEnd = (event: MouseEvent) => {
|
this.onMouseDragEnd = (event: MouseEvent) => {
|
||||||
dragX = undefined
|
dragX = undefined
|
||||||
@@ -121,6 +130,7 @@ function prepareDiagram(kind: DiagramKind, source: string, options: MermaidMarkd
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
height: size.height,
|
height: size.height,
|
||||||
|
width: size.width,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "sequence": {
|
case "sequence": {
|
||||||
@@ -144,6 +154,7 @@ function prepareDiagram(kind: DiagramKind, source: string, options: MermaidMarkd
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
height: size.height,
|
height: size.height,
|
||||||
|
width: size.width,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "state": {
|
case "state": {
|
||||||
@@ -168,6 +179,7 @@ function prepareDiagram(kind: DiagramKind, source: string, options: MermaidMarkd
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
height: size.height,
|
height: size.height,
|
||||||
|
width: size.width,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"
|
|||||||
import { mkdir } from "node:fs/promises"
|
import { mkdir } from "node:fs/promises"
|
||||||
import { tmpdir } from "node:os"
|
import { tmpdir } from "node:os"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import { CodeRenderable, MarkdownRenderable, RGBA, SyntaxStyle, TreeSitterClient } from "@opentui/core"
|
import { CodeRenderable, MarkdownRenderable, RGBA, SyntaxStyle, TextRenderable, TreeSitterClient } from "@opentui/core"
|
||||||
import { createTestRenderer } from "@opentui/core/testing"
|
import { createTestRenderer } from "@opentui/core/testing"
|
||||||
import { createMermaidMarkdownRenderer } from "../markdown.js"
|
import { createMermaidMarkdownRenderer } from "../markdown.js"
|
||||||
|
|
||||||
@@ -78,6 +78,31 @@ flowchart LR
|
|||||||
expect(markdown.getChildren()[0]?.marginTop).toBe(1)
|
expect(markdown.getChildren()[0]?.marginTop).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("leaves Mermaid alignment to its containing layout", async () => {
|
||||||
|
const testRenderer = await createTestRenderer({ width: 80, height: 14 })
|
||||||
|
renderer = testRenderer.renderer
|
||||||
|
const markdown = new MarkdownRenderable(renderer, {
|
||||||
|
id: "markdown-centered-mermaid",
|
||||||
|
content: `\`\`\`mermaid
|
||||||
|
flowchart LR
|
||||||
|
A[Start] --> B[Done]
|
||||||
|
\`\`\``,
|
||||||
|
syntaxStyle,
|
||||||
|
treeSitterClient,
|
||||||
|
renderNode: createMermaidMarkdownRenderer(renderer),
|
||||||
|
})
|
||||||
|
|
||||||
|
renderer.root.add(markdown)
|
||||||
|
await renderMarkdown(markdown, testRenderer.renderOnce)
|
||||||
|
|
||||||
|
const line = testRenderer
|
||||||
|
.captureCharFrame()
|
||||||
|
.split("\n")
|
||||||
|
.find((value) => value.includes("Start"))
|
||||||
|
if (!line) throw new Error("Expected the rendered diagram")
|
||||||
|
expect(line.indexOf("Start")).toBeLessThan(10)
|
||||||
|
})
|
||||||
|
|
||||||
test("recognizes normalized Mermaid fence info strings", async () => {
|
test("recognizes normalized Mermaid fence info strings", async () => {
|
||||||
const testRenderer = await createTestRenderer({ width: 80, height: 14 })
|
const testRenderer = await createTestRenderer({ width: 80, height: 14 })
|
||||||
renderer = testRenderer.renderer
|
renderer = testRenderer.renderer
|
||||||
@@ -235,17 +260,19 @@ sequenceDiagram
|
|||||||
renderer.root.add(markdown)
|
renderer.root.add(markdown)
|
||||||
await renderMarkdown(markdown, testRenderer.renderOnce)
|
await renderMarkdown(markdown, testRenderer.renderOnce)
|
||||||
|
|
||||||
const diagram = markdown.getChildren()[0] as CodeRenderable
|
const wrapper = markdown.getChildren()[0]
|
||||||
|
if (!wrapper) throw new Error("Expected the rendered diagram wrapper")
|
||||||
|
const diagram = wrapper.getChildren()[0] as TextRenderable
|
||||||
expect(diagram.scrollWidth).toBeGreaterThan(diagram.width)
|
expect(diagram.scrollWidth).toBeGreaterThan(diagram.width)
|
||||||
expect(diagram.scrollX).toBe(0)
|
expect(diagram.scrollX).toBe(0)
|
||||||
|
|
||||||
await testRenderer.mockMouse.drag(diagram.x + 20, diagram.y + 2, diagram.x + 5, diagram.y + 2)
|
await testRenderer.mockMouse.drag(wrapper.x + 20, wrapper.y + 2, wrapper.x + 5, wrapper.y + 2)
|
||||||
await testRenderer.renderOnce()
|
await testRenderer.renderOnce()
|
||||||
expect(diagram.scrollX).toBeGreaterThan(0)
|
expect(diagram.scrollX).toBeGreaterThan(0)
|
||||||
expect(diagram.hasSelection()).toBe(false)
|
expect(diagram.hasSelection()).toBe(false)
|
||||||
|
|
||||||
diagram.scrollX = 0
|
diagram.scrollX = 0
|
||||||
await testRenderer.mockMouse.scroll(diagram.x + 20, diagram.y + 2, "right")
|
await testRenderer.mockMouse.scroll(wrapper.x + 20, wrapper.y + 2, "right")
|
||||||
await testRenderer.renderOnce()
|
await testRenderer.renderOnce()
|
||||||
expect(diagram.scrollX).toBeGreaterThan(0)
|
expect(diagram.scrollX).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -14373,9 +14373,6 @@
|
|||||||
},
|
},
|
||||||
"agent": {
|
"agent": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
|
||||||
"previous": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["sessionID", "agent"],
|
"required": ["sessionID", "agent"],
|
||||||
@@ -14444,9 +14441,6 @@
|
|||||||
},
|
},
|
||||||
"model": {
|
"model": {
|
||||||
"$ref": "#/components/schemas/Model.Ref"
|
"$ref": "#/components/schemas/Model.Ref"
|
||||||
},
|
|
||||||
"previous": {
|
|
||||||
"$ref": "#/components/schemas/Model.Ref"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["sessionID", "model"],
|
"required": ["sessionID", "model"],
|
||||||
|
|||||||
@@ -69,7 +69,6 @@ export const AgentSelected = Event.durable({
|
|||||||
schema: {
|
schema: {
|
||||||
...Base,
|
...Base,
|
||||||
agent: Agent.ID,
|
agent: Agent.ID,
|
||||||
previous: Agent.ID.pipe(optional),
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export type AgentSelected = typeof AgentSelected.Type
|
export type AgentSelected = typeof AgentSelected.Type
|
||||||
@@ -80,7 +79,6 @@ export const ModelSelected = Event.durable({
|
|||||||
schema: {
|
schema: {
|
||||||
...Base,
|
...Base,
|
||||||
model: Model.Ref,
|
model: Model.Ref,
|
||||||
previous: Model.Ref.pipe(optional),
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export type ModelSelected = typeof ModelSelected.Type
|
export type ModelSelected = typeof ModelSelected.Type
|
||||||
|
|||||||
@@ -12,19 +12,6 @@ export const ModelHandler = HttpApiBuilder.group(Api, "server.model", (handlers)
|
|||||||
.handle(
|
.handle(
|
||||||
"model.list",
|
"model.list",
|
||||||
Effect.fn(function* () {
|
Effect.fn(function* () {
|
||||||
const plugins = yield* PluginSupervisor.Service
|
|
||||||
yield* plugins.flush.pipe(
|
|
||||||
Effect.timeoutOrElse({
|
|
||||||
duration: "5 seconds",
|
|
||||||
orElse: () =>
|
|
||||||
Effect.fail(
|
|
||||||
new ServiceUnavailableError({
|
|
||||||
message: "Model catalog initialization timed out",
|
|
||||||
service: "model.catalog",
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const catalog = yield* Catalog.Service
|
const catalog = yield* Catalog.Service
|
||||||
return yield* response(catalog.model.available())
|
return yield* response(catalog.model.available())
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
import fs from "node:fs/promises"
|
|
||||||
import path from "node:path"
|
|
||||||
import { expect } from "bun:test"
|
|
||||||
import { Effect } from "effect"
|
|
||||||
import { HttpServer } from "effect/unstable/http"
|
|
||||||
import { tmpdir } from "../../core/test/fixture/tmpdir"
|
|
||||||
import { it } from "../../core/test/lib/effect"
|
|
||||||
import { ServerProcess } from "../src/process"
|
|
||||||
|
|
||||||
it.live("waits for plugin initialization before listing models", () =>
|
|
||||||
Effect.acquireUseRelease(
|
|
||||||
Effect.promise(() => tmpdir("opencode-model-endpoint-")),
|
|
||||||
(tmp) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
yield* Effect.promise(() =>
|
|
||||||
fs.writeFile(
|
|
||||||
path.join(tmp.path, "opencode.json"),
|
|
||||||
JSON.stringify({
|
|
||||||
providers: {
|
|
||||||
custom: {
|
|
||||||
package: "aisdk:@ai-sdk/openai-compatible",
|
|
||||||
settings: { apiKey: "secret" },
|
|
||||||
models: { chat: {} },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const server = yield* ServerProcess.start<never, never>({
|
|
||||||
hostname: "127.0.0.1",
|
|
||||||
port: 0,
|
|
||||||
password: "secret",
|
|
||||||
app: { version: "test-version" },
|
|
||||||
database: { path: ":memory:" },
|
|
||||||
config: { directory: tmp.path },
|
|
||||||
fs: { filewatcher: false },
|
|
||||||
})
|
|
||||||
const url = new URL("/api/model", HttpServer.formatAddress(server.address))
|
|
||||||
url.searchParams.set("location[directory]", tmp.path)
|
|
||||||
const response = yield* Effect.promise(() =>
|
|
||||||
fetch(url, { headers: { authorization: `Basic ${btoa("opencode:secret")}` } }),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(response.status).toBe(200)
|
|
||||||
const body: unknown = yield* Effect.promise(() => response.json())
|
|
||||||
if (!isRecord(body) || !Array.isArray(body["data"])) throw new Error("Expected a model list response")
|
|
||||||
expect(
|
|
||||||
body["data"].some((model) => isRecord(model) && model["providerID"] === "custom" && model["id"] === "chat"),
|
|
||||||
).toBeTrue()
|
|
||||||
}),
|
|
||||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
||||||
}
|
|
||||||
@@ -60,6 +60,15 @@ export const settings: Setting[] = [
|
|||||||
labels: ["off", "on"],
|
labels: ["off", "on"],
|
||||||
keywords: ["scroll bar"],
|
keywords: ["scroll bar"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Reading width",
|
||||||
|
category: "Session",
|
||||||
|
path: ["session", "max_width"],
|
||||||
|
default: "auto",
|
||||||
|
values: ["auto", 66, 72, 80],
|
||||||
|
labels: ["auto", "66 columns", "72 columns", "80 columns"],
|
||||||
|
keywords: ["transcript", "composer", "centered", "max width", "prose"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Thinking",
|
title: "Thinking",
|
||||||
category: "Session",
|
category: "Session",
|
||||||
|
|||||||
@@ -125,6 +125,11 @@ export const Info = Schema.Struct({
|
|||||||
description: "Session sidebar visibility; 'auto' shows it when space permits",
|
description: "Session sidebar visibility; 'auto' shows it when space permits",
|
||||||
}),
|
}),
|
||||||
scrollbar: Schema.optional(Schema.Boolean).annotate({ description: "Show the session transcript scrollbar" }),
|
scrollbar: Schema.optional(Schema.Boolean).annotate({ description: "Show the session transcript scrollbar" }),
|
||||||
|
max_width: Schema.optional(
|
||||||
|
Schema.Union([Schema.Int.check(Schema.isGreaterThan(4)), Schema.Literal("auto")]),
|
||||||
|
).annotate({
|
||||||
|
description: "Session prose and composer max width, or 'auto' to use the available width",
|
||||||
|
}),
|
||||||
thinking: Schema.optional(Schema.Literals(["show", "hide"])).annotate({
|
thinking: Schema.optional(Schema.Literals(["show", "hide"])).annotate({
|
||||||
description: "Show or hide model reasoning by default",
|
description: "Show or hide model reasoning by default",
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ import { errorMessage } from "../../util/error"
|
|||||||
import { useToast } from "../../ui/toast"
|
import { useToast } from "../../ui/toast"
|
||||||
import stripAnsi from "strip-ansi"
|
import stripAnsi from "strip-ansi"
|
||||||
import { usePromptRef } from "../../context/prompt"
|
import { usePromptRef } from "../../context/prompt"
|
||||||
import { sessionTabsFitVertically, SESSION_SIDEBAR_WIDTH } from "../../ui/layout"
|
import { sessionLaneLayout, sessionTabsFitVertically, SESSION_SIDEBAR_WIDTH } from "../../ui/layout"
|
||||||
import { projectedPromptInput } from "../../prompt/codec"
|
import { projectedPromptInput } from "../../prompt/codec"
|
||||||
import { deduplicateVisibleImages } from "../../prompt/attachment"
|
import { deduplicateVisibleImages } from "../../prompt/attachment"
|
||||||
import { useEpilogue } from "../../context/epilogue"
|
import { useEpilogue } from "../../context/epilogue"
|
||||||
@@ -108,13 +108,13 @@ import { createSingleFlight } from "../../util/single-flight"
|
|||||||
import type { SessionPending } from "@opencode-ai/schema/session-pending"
|
import type { SessionPending } from "@opencode-ai/schema/session-pending"
|
||||||
import { generateThinkingSyntax } from "./thinking-syntax"
|
import { generateThinkingSyntax } from "./thinking-syntax"
|
||||||
import { createDelayedPresence } from "../../util/delayed-presence"
|
import { createDelayedPresence } from "../../util/delayed-presence"
|
||||||
|
import { markdownLaneMarginTop, markdownLanes } from "./markdown-lanes"
|
||||||
|
|
||||||
addDefaultParsers(parsers.parsers)
|
addDefaultParsers(parsers.parsers)
|
||||||
|
|
||||||
// Exclude temporary bottom space when measuring the real transcript height.
|
// Exclude temporary bottom space when measuring the real transcript height.
|
||||||
const NAVIGATION_SLACK_ID = "session-navigation-slack"
|
const NAVIGATION_SLACK_ID = "session-navigation-slack"
|
||||||
const BACKGROUND_TOOL_HINT_DELAY = 1_000
|
const BACKGROUND_TOOL_HINT_DELAY = 1_000
|
||||||
|
|
||||||
// Tail-first transcript mounting: rows mounted with the session, then backfill cadence.
|
// Tail-first transcript mounting: rows mounted with the session, then backfill cadence.
|
||||||
// The tail comfortably overfills a tall viewport; backfill drains a 200-message transcript
|
// The tail comfortably overfills a tall viewport; backfill drains a 200-message transcript
|
||||||
// in a few hundred milliseconds without a perceptible pause.
|
// in a few hundred milliseconds without a perceptible pause.
|
||||||
@@ -1084,55 +1084,57 @@ export function Session() {
|
|||||||
{(height) => <box id={NAVIGATION_SLACK_ID} height={height()} flexShrink={0} />}
|
{(height) => <box id={NAVIGATION_SLACK_ID} height={height()} flexShrink={0} />}
|
||||||
</Show>
|
</Show>
|
||||||
</scrollbox>
|
</scrollbox>
|
||||||
<box flexShrink={0}>
|
<SessionContentLane width="readable">
|
||||||
<Show when={!composer.open && !disabled() && queuedPrompts().length > 0}>
|
<box flexShrink={0}>
|
||||||
<QueuedPromptDock prompts={queuedPrompts()} onOpen={openQueuedPrompts} />
|
<Show when={!composer.open && !disabled() && queuedPrompts().length > 0}>
|
||||||
</Show>
|
<QueuedPromptDock prompts={queuedPrompts()} onOpen={openQueuedPrompts} />
|
||||||
<PluginSlot name="session.composer.top" input={{ sessionID: route.sessionID }} mode="all" />
|
</Show>
|
||||||
<Composer
|
<PluginSlot name="session.composer.top" input={{ sessionID: route.sessionID }} mode="all" />
|
||||||
sessionID={route.sessionID}
|
<Composer
|
||||||
open={composer.open || (!!session()?.parentID && forms().length === 0)}
|
sessionID={route.sessionID}
|
||||||
defaultTab={composer.tab ?? (session()?.parentID ? "subagents" : undefined)}
|
open={composer.open || (!!session()?.parentID && forms().length === 0)}
|
||||||
onClose={() => setComposer("open", false)}
|
defaultTab={composer.tab ?? (session()?.parentID ? "subagents" : undefined)}
|
||||||
/>
|
onClose={() => setComposer("open", false)}
|
||||||
<Switch>
|
/>
|
||||||
<Match when={composer.open || (!!session()?.parentID && forms().length === 0)}>{null}</Match>
|
<Switch>
|
||||||
<Match when={promptedPermissions().length > 0}>
|
<Match when={composer.open || (!!session()?.parentID && forms().length === 0)}>{null}</Match>
|
||||||
<Show when={promptedPermissions()[0]?.id} keyed>
|
<Match when={promptedPermissions().length > 0}>
|
||||||
{(_) => {
|
<Show when={promptedPermissions()[0]?.id} keyed>
|
||||||
const request = promptedPermissions()[0]
|
{(_) => {
|
||||||
return request ? (
|
const request = promptedPermissions()[0]
|
||||||
<PermissionPrompt request={request} directory={session()?.location.directory} />
|
return request ? (
|
||||||
) : null
|
<PermissionPrompt request={request} directory={session()?.location.directory} />
|
||||||
}}
|
) : null
|
||||||
</Show>
|
}}
|
||||||
</Match>
|
</Show>
|
||||||
<Match when={forms().length > 0}>
|
</Match>
|
||||||
<Show when={forms()[0]?.id} keyed>
|
<Match when={forms().length > 0}>
|
||||||
{(_) => {
|
<Show when={forms()[0]?.id} keyed>
|
||||||
const form = forms()[0]
|
{(_) => {
|
||||||
return form ? <FormPrompt form={form} /> : null
|
const form = forms()[0]
|
||||||
}}
|
return form ? <FormPrompt form={form} /> : null
|
||||||
</Show>
|
}}
|
||||||
</Match>
|
</Show>
|
||||||
<Match when={!disabled()}>
|
</Match>
|
||||||
<Prompt
|
<Match when={!disabled()}>
|
||||||
visible={true}
|
<Prompt
|
||||||
ref={bind}
|
visible={true}
|
||||||
disabled={false}
|
ref={bind}
|
||||||
onSubmit={() => {
|
disabled={false}
|
||||||
toBottom()
|
onSubmit={() => {
|
||||||
}}
|
toBottom()
|
||||||
onEmptySubmit={async () => {
|
}}
|
||||||
const next = queuedPrompts()[0]
|
onEmptySubmit={async () => {
|
||||||
if (!next) return false
|
const next = queuedPrompts()[0]
|
||||||
return mutatePending("steer", next.id)
|
if (!next) return false
|
||||||
}}
|
return mutatePending("steer", next.id)
|
||||||
sessionID={route.sessionID}
|
}}
|
||||||
/>
|
sessionID={route.sessionID}
|
||||||
</Match>
|
/>
|
||||||
</Switch>
|
</Match>
|
||||||
</box>
|
</Switch>
|
||||||
|
</box>
|
||||||
|
</SessionContentLane>
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
<Show when={sidebarVisible()}>
|
<Show when={sidebarVisible()}>
|
||||||
@@ -1176,22 +1178,30 @@ function SessionRowView(props: SessionRowViewProps) {
|
|||||||
)}
|
)}
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={props.row.type === "compaction-queued"}>
|
<Match when={props.row.type === "compaction-queued"}>
|
||||||
<CompactionQueued />
|
<SessionContentLane width="readable">
|
||||||
|
<CompactionQueued />
|
||||||
|
</SessionContentLane>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={props.row.type === "part" ? props.row : undefined}>
|
<Match when={props.row.type === "part" ? props.row : undefined}>
|
||||||
{(row) => <SessionPartView partRef={row().ref} message={props.message} />}
|
{(row) => <SessionPartView partRef={row().ref} message={props.message} />}
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={props.row.type === "group" && props.row.kind === "reasoning" ? props.row : undefined}>
|
<Match when={props.row.type === "group" && props.row.kind === "reasoning" ? props.row : undefined}>
|
||||||
{(row) => <SessionReasoningGroupView refs={row().refs} completed={row().completed} message={props.message} />}
|
{(row) => (
|
||||||
|
<SessionContentLane width="readable">
|
||||||
|
<SessionReasoningGroupView refs={row().refs} completed={row().completed} message={props.message} />
|
||||||
|
</SessionContentLane>
|
||||||
|
)}
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={props.row.type === "group" && props.row.kind === "exploration" ? props.row : undefined}>
|
<Match when={props.row.type === "group" && props.row.kind === "exploration" ? props.row : undefined}>
|
||||||
{(row) => (
|
{(row) => (
|
||||||
<SessionGroupView
|
<SessionContentLane width="readable">
|
||||||
refs={row().refs}
|
<SessionGroupView
|
||||||
pending={row().pending}
|
refs={row().refs}
|
||||||
completed={row().completed}
|
pending={row().pending}
|
||||||
message={props.message}
|
completed={row().completed}
|
||||||
/>
|
message={props.message}
|
||||||
|
/>
|
||||||
|
</SessionContentLane>
|
||||||
)}
|
)}
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={props.row.type === "assistant-footer" ? props.row : undefined}>
|
<Match when={props.row.type === "assistant-footer" ? props.row : undefined}>
|
||||||
@@ -1199,7 +1209,9 @@ function SessionRowView(props: SessionRowViewProps) {
|
|||||||
<Show when={props.message(row().messageID)}>
|
<Show when={props.message(row().messageID)}>
|
||||||
{(message) => (
|
{(message) => (
|
||||||
<Show when={message().type === "assistant"}>
|
<Show when={message().type === "assistant"}>
|
||||||
<AssistantFooter message={message() as SessionMessageAssistant} />
|
<SessionContentLane width="readable">
|
||||||
|
<AssistantFooter message={message() as SessionMessageAssistant} />
|
||||||
|
</SessionContentLane>
|
||||||
</Show>
|
</Show>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
@@ -1207,7 +1219,13 @@ function SessionRowView(props: SessionRowViewProps) {
|
|||||||
</Match>
|
</Match>
|
||||||
<Match when={props.row.type === "turn-usage" ? props.row : undefined}>
|
<Match when={props.row.type === "turn-usage" ? props.row : undefined}>
|
||||||
{(row) => (
|
{(row) => (
|
||||||
<TurnTokenUsage messageIDs={row().messageIDs} previousCache={row().previousCache} message={props.message} />
|
<SessionContentLane width="technical">
|
||||||
|
<TurnTokenUsage
|
||||||
|
messageIDs={row().messageIDs}
|
||||||
|
previousCache={row().previousCache}
|
||||||
|
message={props.message}
|
||||||
|
/>
|
||||||
|
</SessionContentLane>
|
||||||
)}
|
)}
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
@@ -1215,6 +1233,42 @@ function SessionRowView(props: SessionRowViewProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SessionContentLane(props: { children: JSX.Element; width: "readable" | "technical" }) {
|
||||||
|
const ctx = use()
|
||||||
|
const readable = () => ctx.config.session?.max_width ?? "auto"
|
||||||
|
const layout = createMemo(() => {
|
||||||
|
const width = readable()
|
||||||
|
return width === "auto" ? undefined : sessionLaneLayout(ctx.width, width)
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<Show when={layout()} fallback={props.children}>
|
||||||
|
{(value) => (
|
||||||
|
<box width="100%" paddingLeft={value().inset} flexShrink={0}>
|
||||||
|
<box width={value()[props.width]} flexShrink={0}>
|
||||||
|
{props.children}
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SessionBreakoutLane(props: { children: JSX.Element }) {
|
||||||
|
const ctx = use()
|
||||||
|
const readable = () => ctx.config.session?.max_width ?? "auto"
|
||||||
|
const inset = createMemo(() => {
|
||||||
|
const width = readable()
|
||||||
|
return width === "auto" ? undefined : sessionLaneLayout(ctx.width, width).inset
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<Show when={inset() !== undefined} fallback={props.children}>
|
||||||
|
<box width="100%" paddingLeft={inset()} flexShrink={0}>
|
||||||
|
{props.children}
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function TurnTokenUsage(props: {
|
function TurnTokenUsage(props: {
|
||||||
messageIDs: string[]
|
messageIDs: string[]
|
||||||
previousCache?: CacheUsage
|
previousCache?: CacheUsage
|
||||||
@@ -1377,20 +1431,35 @@ function SessionMessageView(props: { message: SessionMessageInfo }) {
|
|||||||
<UserMessage message={props.message as SessionMessageUser} />
|
<UserMessage message={props.message as SessionMessageUser} />
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={props.message.type === "shell"}>
|
<Match when={props.message.type === "shell"}>
|
||||||
<ShellMessage message={props.message as Extract<SessionMessageInfo, { type: "shell" }>} />
|
<SessionContentLane width="technical">
|
||||||
|
<ShellMessage message={props.message as Extract<SessionMessageInfo, { type: "shell" }>} />
|
||||||
|
</SessionContentLane>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={props.message.type === "agent-switched" || props.message.type === "model-switched"}>
|
<Match when={props.message.type === "agent-switched" || props.message.type === "model-switched"}>
|
||||||
<SessionSwitchMessageV2 message={props.message} />
|
<SessionContentLane width="readable">
|
||||||
|
<SessionSwitchMessageV2 message={props.message} />
|
||||||
|
</SessionContentLane>
|
||||||
</Match>
|
</Match>
|
||||||
<Match
|
<Match
|
||||||
when={props.message.type === "system" || props.message.type === "synthetic" || props.message.type === "skill"}
|
when={props.message.type === "system" || props.message.type === "synthetic" || props.message.type === "skill"}
|
||||||
>
|
>
|
||||||
<Show when={props.message.type === "skill"} fallback={<SessionNoticeMessageV2 message={props.message} />}>
|
<Show
|
||||||
<SessionSkillMessage message={props.message as Extract<SessionMessageInfo, { type: "skill" }>} />
|
when={props.message.type === "skill"}
|
||||||
|
fallback={
|
||||||
|
<SessionContentLane width="readable">
|
||||||
|
<SessionNoticeMessageV2 message={props.message} />
|
||||||
|
</SessionContentLane>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SessionContentLane width="readable">
|
||||||
|
<SessionSkillMessage message={props.message as Extract<SessionMessageInfo, { type: "skill" }>} />
|
||||||
|
</SessionContentLane>
|
||||||
</Show>
|
</Show>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={props.message.type === "compaction"}>
|
<Match when={props.message.type === "compaction"}>
|
||||||
<CompactionMessage message={props.message as Extract<SessionMessageInfo, { type: "compaction" }>} />
|
<SessionContentLane width="readable">
|
||||||
|
<CompactionMessage message={props.message as Extract<SessionMessageInfo, { type: "compaction" }>} />
|
||||||
|
</SessionContentLane>
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
)
|
)
|
||||||
@@ -1411,11 +1480,13 @@ function SessionPartView(props: { partRef: PartRef; message: (messageID: string)
|
|||||||
<TextPart part={item() as SessionMessageAssistantText} last={false} />
|
<TextPart part={item() as SessionMessageAssistantText} last={false} />
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={item().type === "reasoning"}>
|
<Match when={item().type === "reasoning"}>
|
||||||
<ReasoningPart
|
<SessionContentLane width="readable">
|
||||||
part={item() as SessionMessageAssistantReasoning}
|
<ReasoningPart
|
||||||
message={message() as SessionMessageAssistant}
|
part={item() as SessionMessageAssistantReasoning}
|
||||||
last={false}
|
message={message() as SessionMessageAssistant}
|
||||||
/>
|
last={false}
|
||||||
|
/>
|
||||||
|
</SessionContentLane>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={item().type === "tool"}>
|
<Match when={item().type === "tool"}>
|
||||||
<ToolPart part={item() as SessionMessageAssistantTool} />
|
<ToolPart part={item() as SessionMessageAssistantTool} />
|
||||||
@@ -1938,79 +2009,56 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={props.message.text.trim() || files().length || skills().length}>
|
<Show when={props.message.text.trim() || files().length || skills().length}>
|
||||||
<box
|
<SessionContentLane width="readable">
|
||||||
border={["left"]}
|
|
||||||
borderColor={delivery() ? theme.border.default : color()}
|
|
||||||
customBorderChars={SplitBorder.customBorderChars}
|
|
||||||
>
|
|
||||||
<SessionImages images={images()} paddingLeft={2} />
|
|
||||||
<box
|
<box
|
||||||
onMouseOver={() => {
|
border={["left"]}
|
||||||
setHover(true)
|
borderColor={delivery() ? theme.border.default : color()}
|
||||||
}}
|
customBorderChars={SplitBorder.customBorderChars}
|
||||||
onMouseOut={() => {
|
>
|
||||||
setHover(false)
|
<SessionImages images={images()} paddingLeft={2} />
|
||||||
}}
|
<box
|
||||||
onMouseUp={() => {
|
onMouseOver={() => {
|
||||||
if (renderer.getSelection()?.getSelectedText()) return
|
setHover(true)
|
||||||
if (delivery() === "steer") {
|
}}
|
||||||
|
onMouseOut={() => {
|
||||||
|
setHover(false)
|
||||||
|
}}
|
||||||
|
onMouseUp={() => {
|
||||||
|
if (renderer.getSelection()?.getSelectedText()) return
|
||||||
|
if (delivery() === "steer") {
|
||||||
|
dialog.replace(() => (
|
||||||
|
<DialogSelect
|
||||||
|
title="Pending steer"
|
||||||
|
options={[
|
||||||
|
{ title: "Move to queue", value: "queue" as const },
|
||||||
|
{ title: "Delete", value: "cancel" as const },
|
||||||
|
]}
|
||||||
|
onSelect={(option) => {
|
||||||
|
void updatePendingSteer(option.value)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
return
|
||||||
|
}
|
||||||
dialog.replace(() => (
|
dialog.replace(() => (
|
||||||
<DialogSelect
|
<DialogMessage
|
||||||
title="Pending steer"
|
messageID={props.message.id}
|
||||||
options={[
|
sessionID={ctx.sessionID}
|
||||||
{ title: "Move to queue", value: "queue" as const },
|
setPrompt={(value) => promptRef.current?.set(value)}
|
||||||
{ title: "Delete", value: "cancel" as const },
|
|
||||||
]}
|
|
||||||
onSelect={(option) => {
|
|
||||||
void updatePendingSteer(option.value)
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
return
|
}}
|
||||||
}
|
paddingTop={1}
|
||||||
dialog.replace(() => (
|
paddingBottom={1}
|
||||||
<DialogMessage
|
paddingLeft={2}
|
||||||
messageID={props.message.id}
|
backgroundColor={hover() ? theme.raise(theme.background.default) : theme.background.default}
|
||||||
sessionID={ctx.sessionID}
|
flexShrink={0}
|
||||||
setPrompt={(value) => promptRef.current?.set(value)}
|
>
|
||||||
/>
|
<text fg={theme.text.default}>{props.message.text}</text>
|
||||||
))
|
<Show when={skills().length}>
|
||||||
}}
|
<box flexDirection="row" paddingTop={1} gap={1} flexWrap="wrap">
|
||||||
paddingTop={1}
|
<For each={skills()}>
|
||||||
paddingBottom={1}
|
{(skill) => (
|
||||||
paddingLeft={2}
|
|
||||||
backgroundColor={hover() ? theme.raise(theme.background.default) : theme.background.default}
|
|
||||||
flexShrink={0}
|
|
||||||
>
|
|
||||||
<text fg={theme.text.default}>{props.message.text}</text>
|
|
||||||
<Show when={skills().length}>
|
|
||||||
<box flexDirection="row" paddingTop={1} gap={1} flexWrap="wrap">
|
|
||||||
<For each={skills()}>
|
|
||||||
{(skill) => (
|
|
||||||
<text fg={theme.text.default}>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
bg: theme.hue.accent[mode() === "light" ? 700 : 200],
|
|
||||||
fg: theme.background.default,
|
|
||||||
bold: true,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{" skill "}
|
|
||||||
</span>
|
|
||||||
<span style={{ bg: theme.raise(theme.background.default), fg: theme.text.subdued }}>
|
|
||||||
{` ${skill.name} `}
|
|
||||||
</span>
|
|
||||||
</text>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
<Show when={files().length}>
|
|
||||||
<box flexDirection="row" paddingTop={1} gap={1} flexWrap="wrap">
|
|
||||||
<For each={files()}>
|
|
||||||
{(file) => {
|
|
||||||
const label = file.mime === "application/x-directory" ? "dir" : "file"
|
|
||||||
return (
|
|
||||||
<text fg={theme.text.default}>
|
<text fg={theme.text.default}>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
@@ -2019,20 +2067,45 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
|||||||
bold: true,
|
bold: true,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{` ${label} `}
|
{" skill "}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ bg: theme.raise(theme.background.default), fg: theme.text.subdued }}>
|
<span style={{ bg: theme.raise(theme.background.default), fg: theme.text.subdued }}>
|
||||||
{" "}
|
{` ${skill.name} `}
|
||||||
{file.name ?? (file.source.type === "uri" ? file.source.uri : "attachment")}{" "}
|
|
||||||
</span>
|
</span>
|
||||||
</text>
|
</text>
|
||||||
)
|
)}
|
||||||
}}
|
</For>
|
||||||
</For>
|
</box>
|
||||||
</box>
|
</Show>
|
||||||
</Show>
|
<Show when={files().length}>
|
||||||
|
<box flexDirection="row" paddingTop={1} gap={1} flexWrap="wrap">
|
||||||
|
<For each={files()}>
|
||||||
|
{(file) => {
|
||||||
|
const label = file.mime === "application/x-directory" ? "dir" : "file"
|
||||||
|
return (
|
||||||
|
<text fg={theme.text.default}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
bg: theme.hue.accent[mode() === "light" ? 700 : 200],
|
||||||
|
fg: theme.background.default,
|
||||||
|
bold: true,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{` ${label} `}
|
||||||
|
</span>
|
||||||
|
<span style={{ bg: theme.raise(theme.background.default), fg: theme.text.subdued }}>
|
||||||
|
{" "}
|
||||||
|
{file.name ?? (file.source.type === "uri" ? file.source.uri : "attachment")}{" "}
|
||||||
|
</span>
|
||||||
|
</text>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</SessionContentLane>
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -2215,14 +2288,16 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText }) {
|
|||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const { currentSyntax: syntax } = useThemes()
|
const { currentSyntax: syntax } = useThemes()
|
||||||
const plugins = usePlugin()
|
const plugins = usePlugin()
|
||||||
return (
|
const constrained = () => (ctx.config.session?.max_width ?? "auto") !== "auto"
|
||||||
<Show when={props.part.text.trim()}>
|
|
||||||
|
function Content(input: { content: string }) {
|
||||||
|
return (
|
||||||
<box paddingLeft={3} flexShrink={0}>
|
<box paddingLeft={3} flexShrink={0}>
|
||||||
<markdown
|
<markdown
|
||||||
syntaxStyle={syntax()}
|
syntaxStyle={syntax()}
|
||||||
streaming={true}
|
streaming={true}
|
||||||
internalBlockMode="top-level"
|
internalBlockMode="top-level"
|
||||||
content={props.part.text.trim()}
|
content={input.content.trim()}
|
||||||
tableOptions={{ style: "grid" }}
|
tableOptions={{ style: "grid" }}
|
||||||
conceal={ctx.markdownMode() === "rendered"}
|
conceal={ctx.markdownMode() === "rendered"}
|
||||||
fg={theme.markdown.text}
|
fg={theme.markdown.text}
|
||||||
@@ -2230,6 +2305,38 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText }) {
|
|||||||
renderNode={plugins.markdown()}
|
renderNode={plugins.markdown()}
|
||||||
/>
|
/>
|
||||||
</box>
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={props.part.text.trim()}>
|
||||||
|
<Switch>
|
||||||
|
<Match when={!constrained()}>
|
||||||
|
<Content content={props.part.text} />
|
||||||
|
</Match>
|
||||||
|
<Match when={constrained()}>
|
||||||
|
<Index each={markdownLanes(props.part.text.trim())}>
|
||||||
|
{(segment, index) => {
|
||||||
|
const content = <Content content={segment().content} />
|
||||||
|
return (
|
||||||
|
<box width="100%" marginTop={markdownLaneMarginTop(index, segment().width)} flexShrink={0}>
|
||||||
|
<Switch>
|
||||||
|
<Match when={segment().width === "full"}>
|
||||||
|
<SessionBreakoutLane>{content}</SessionBreakoutLane>
|
||||||
|
</Match>
|
||||||
|
<Match when={segment().width === "technical"}>
|
||||||
|
<SessionContentLane width="technical">{content}</SessionContentLane>
|
||||||
|
</Match>
|
||||||
|
<Match when={segment().width === "readable"}>
|
||||||
|
<SessionContentLane width="readable">{content}</SessionContentLane>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Index>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -2238,6 +2345,7 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText }) {
|
|||||||
|
|
||||||
function ToolPart(props: { part: SessionMessageAssistantTool; images?: boolean }) {
|
function ToolPart(props: { part: SessionMessageAssistantTool; images?: boolean }) {
|
||||||
const display = createMemo(() => toolDisplay(props.part.name))
|
const display = createMemo(() => toolDisplay(props.part.name))
|
||||||
|
const width = createMemo(() => toolLane(props.part.name))
|
||||||
|
|
||||||
const toolprops = {
|
const toolprops = {
|
||||||
get metadata() {
|
get metadata() {
|
||||||
@@ -2307,10 +2415,12 @@ function ToolPart(props: { part: SessionMessageAssistantTool; images?: boolean }
|
|||||||
</Switch>
|
</Switch>
|
||||||
)
|
)
|
||||||
return [
|
return [
|
||||||
content,
|
<SessionContentLane width={width()}>{content}</SessionContentLane>,
|
||||||
<Show when={props.images !== false}>
|
<SessionContentLane width="readable">
|
||||||
<ToolImages parts={[props.part]} />
|
<Show when={props.images !== false}>
|
||||||
</Show>,
|
<ToolImages parts={[props.part]} />
|
||||||
|
</Show>
|
||||||
|
</SessionContentLane>,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3144,7 +3254,7 @@ function Edit(props: ToolProps) {
|
|||||||
const diffView = ctx.config.diffs?.view
|
const diffView = ctx.config.diffs?.view
|
||||||
if (diffView === "unified") return "unified"
|
if (diffView === "unified") return "unified"
|
||||||
if (diffView === "split") return "split"
|
if (diffView === "split") return "split"
|
||||||
// Default to "auto" behavior
|
if ((ctx.config.session?.max_width ?? "auto") !== "auto") return "unified"
|
||||||
return ctx.width > 120 ? "split" : "unified"
|
return ctx.width > 120 ? "split" : "unified"
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -3222,6 +3332,7 @@ function ApplyPatch(props: ToolProps) {
|
|||||||
const view = createMemo(() => {
|
const view = createMemo(() => {
|
||||||
if (ctx.config.diffs?.view === "unified") return "unified"
|
if (ctx.config.diffs?.view === "unified") return "unified"
|
||||||
if (ctx.config.diffs?.view === "split") return "split"
|
if (ctx.config.diffs?.view === "split") return "split"
|
||||||
|
if ((ctx.config.session?.max_width ?? "auto") !== "auto") return "unified"
|
||||||
return ctx.width > 120 ? "split" : "unified"
|
return ctx.width > 120 ? "split" : "unified"
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -3407,11 +3518,17 @@ const toolDisplays = new Set([
|
|||||||
"skill",
|
"skill",
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const technicalToolDisplays = new Set(["shell", "write", "edit", "execute", "patch", "generic"])
|
||||||
|
|
||||||
export function toolDisplay(tool: string) {
|
export function toolDisplay(tool: string) {
|
||||||
const normalized = canonicalToolName(tool)
|
const normalized = canonicalToolName(tool)
|
||||||
return toolDisplays.has(normalized) ? normalized : "generic"
|
return toolDisplays.has(normalized) ? normalized : "generic"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function toolLane(tool: string): "readable" | "technical" {
|
||||||
|
return technicalToolDisplays.has(toolDisplay(tool)) ? "technical" : "readable"
|
||||||
|
}
|
||||||
|
|
||||||
function recordValue(value: unknown): Record<string, unknown> | undefined {
|
function recordValue(value: unknown): Record<string, unknown> | undefined {
|
||||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return
|
||||||
return value as Record<string, unknown>
|
return value as Record<string, unknown>
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
export type MarkdownLane = {
|
||||||
|
content: string
|
||||||
|
width: "readable" | "technical" | "full"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markdownLanes(content: string): MarkdownLane[] {
|
||||||
|
const result: MarkdownLane[] = []
|
||||||
|
let fence: { marker: "`" | "~"; length: number } | undefined
|
||||||
|
let table = false
|
||||||
|
const lines = content.match(/[^\n]*(?:\n|$)/g)?.filter(Boolean) ?? []
|
||||||
|
|
||||||
|
for (const [index, line] of lines.entries()) {
|
||||||
|
const opening = fence ? undefined : line.match(/^ {0,3}(`{3,}|~{3,})([^\n]*)/)
|
||||||
|
const marker = opening?.[1]
|
||||||
|
const tableOpening = !opening && !fence && isTableRow(line) && isTableDelimiter(lines[index + 1])
|
||||||
|
if (marker) fence = { marker: marker.startsWith("`") ? "`" : "~", length: marker.length }
|
||||||
|
if (tableOpening) table = true
|
||||||
|
|
||||||
|
const width = opening
|
||||||
|
? opening[2]?.trim().split(/\s/, 1)[0]?.toLowerCase() === "mermaid"
|
||||||
|
? "full"
|
||||||
|
: "technical"
|
||||||
|
: fence
|
||||||
|
? (result.at(-1)?.width ?? "technical")
|
||||||
|
: table
|
||||||
|
? "technical"
|
||||||
|
: "readable"
|
||||||
|
const previous = result.at(-1)
|
||||||
|
if (previous?.width === width) previous.content += line
|
||||||
|
else result.push({ content: line, width })
|
||||||
|
|
||||||
|
if (!fence) {
|
||||||
|
if (table && !isTableRow(lines[index + 1])) table = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const currentFence = fence
|
||||||
|
const trimmed = line.trim()
|
||||||
|
if (
|
||||||
|
!opening &&
|
||||||
|
(line.match(/^ */)?.[0].length ?? 0) <= 3 &&
|
||||||
|
trimmed.length >= currentFence.length &&
|
||||||
|
[...trimmed].every((character) => character === currentFence.marker)
|
||||||
|
) {
|
||||||
|
fence = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTableRow(line: string | undefined) {
|
||||||
|
return Boolean(line?.trim() && line.includes("|"))
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTableDelimiter(line: string | undefined) {
|
||||||
|
if (!line) return false
|
||||||
|
const value = line.trim().replace(/^\||\|$/g, "")
|
||||||
|
const cells = value.split("|")
|
||||||
|
return cells.length > 1 && cells.every((cell) => /^:?-{3,}:?$/.test(cell.trim()))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markdownLaneMarginTop(index: number, width: MarkdownLane["width"]) {
|
||||||
|
if (index === 0 || width === "full") return 0
|
||||||
|
return 1
|
||||||
|
}
|
||||||
@@ -1,6 +1,19 @@
|
|||||||
export const SESSION_SIDEBAR_WIDTH = 42
|
export const SESSION_SIDEBAR_WIDTH = 42
|
||||||
|
export const SESSION_TECHNICAL_LANE_WIDTH = 88
|
||||||
const SESSION_CONTENT_MIN_WIDTH = 44
|
const SESSION_CONTENT_MIN_WIDTH = 44
|
||||||
|
|
||||||
export function sessionTabsFitVertically(total: number) {
|
export function sessionTabsFitVertically(total: number) {
|
||||||
return total >= SESSION_SIDEBAR_WIDTH + SESSION_CONTENT_MIN_WIDTH
|
return total >= SESSION_SIDEBAR_WIDTH + SESSION_CONTENT_MIN_WIDTH
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The shared spine centers the prose measure; the technical rail shares its
|
||||||
|
// leading edge and extends rightward, clamped so it still fits the canvas.
|
||||||
|
export function sessionLaneLayout(available: number, readable: number) {
|
||||||
|
const technical = Math.min(available, Math.max(readable, SESSION_TECHNICAL_LANE_WIDTH))
|
||||||
|
const centered = Math.floor((available - Math.min(readable, technical)) / 2)
|
||||||
|
return {
|
||||||
|
inset: Math.max(0, Math.min(centered, available - technical)),
|
||||||
|
readable: Math.min(readable, technical),
|
||||||
|
technical,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
parseQuestionAnswers,
|
parseQuestionAnswers,
|
||||||
parseQuestions,
|
parseQuestions,
|
||||||
toolDisplay,
|
toolDisplay,
|
||||||
|
toolLane,
|
||||||
} from "../../../src/routes/session"
|
} from "../../../src/routes/session"
|
||||||
|
|
||||||
let testSetup: Awaited<ReturnType<typeof testRender>> | undefined
|
let testSetup: Awaited<ReturnType<typeof testRender>> | undefined
|
||||||
@@ -131,6 +132,11 @@ describe("TUI inline tool wrapping", () => {
|
|||||||
expect(toolDisplay("apply_patch")).toBe("patch")
|
expect(toolDisplay("apply_patch")).toBe("patch")
|
||||||
expect(toolDisplay("patch")).toBe("patch")
|
expect(toolDisplay("patch")).toBe("patch")
|
||||||
expect(toolDisplay("plugin_tool")).toBe("generic")
|
expect(toolDisplay("plugin_tool")).toBe("generic")
|
||||||
|
expect(toolLane("glob")).toBe("readable")
|
||||||
|
expect(toolLane("webfetch")).toBe("readable")
|
||||||
|
expect(toolLane("shell")).toBe("technical")
|
||||||
|
expect(toolLane("apply_patch")).toBe("technical")
|
||||||
|
expect(toolLane("plugin_tool")).toBe("technical")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("replaces pending copy when a tool fails before completion", async () => {
|
test("replaces pending copy when a tool fails before completion", async () => {
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { expect, test } from "bun:test"
|
||||||
|
import { markdownLaneMarginTop, markdownLanes } from "../../../src/routes/session/markdown-lanes"
|
||||||
|
|
||||||
|
test("keeps prose in the readable lane", () => {
|
||||||
|
expect(markdownLanes("Before\n\nAfter")).toEqual([{ content: "Before\n\nAfter", width: "readable" }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("moves Mermaid fences into the full lane", () => {
|
||||||
|
expect(
|
||||||
|
markdownLanes(`Before
|
||||||
|
|
||||||
|
\`\`\`mermaid
|
||||||
|
flowchart LR
|
||||||
|
A --> B
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
After`),
|
||||||
|
).toEqual([
|
||||||
|
{ content: "Before\n\n", width: "readable" },
|
||||||
|
{ content: "```mermaid\nflowchart LR\n A --> B\n```\n", width: "full" },
|
||||||
|
{ content: "\nAfter", width: "readable" },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps an incomplete streaming Mermaid fence full width", () => {
|
||||||
|
expect(markdownLanes("Before\n```mermaid\nflowchart LR\n A -->")).toEqual([
|
||||||
|
{ content: "Before\n", width: "readable" },
|
||||||
|
{ content: "```mermaid\nflowchart LR\n A -->", width: "full" },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("supports tilde fences and longer closing fences", () => {
|
||||||
|
expect(markdownLanes("~~~ts\nconst value = 1\n~~~~\nAfter")).toEqual([
|
||||||
|
{ content: "~~~ts\nconst value = 1\n~~~~\n", width: "technical" },
|
||||||
|
{ content: "After", width: "readable" },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not close a fence indented as code", () => {
|
||||||
|
expect(markdownLanes("```ts\n ```\nstill code")).toEqual([
|
||||||
|
{ content: "```ts\n ```\nstill code", width: "technical" },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("gives ordinary fenced code an intermediate lane", () => {
|
||||||
|
expect(markdownLanes("```ts\nexport const value = true\n```")).toEqual([
|
||||||
|
{ content: "```ts\nexport const value = true\n```", width: "technical" },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("gives Markdown tables the technical lane", () => {
|
||||||
|
expect(markdownLanes("Before\n\n| Name | Value |\n| --- | ---: |\n| Width | 88 |\n\nAfter")).toEqual([
|
||||||
|
{ content: "Before\n\n", width: "readable" },
|
||||||
|
{ content: "| Name | Value |\n| --- | ---: |\n| Width | 88 |\n", width: "technical" },
|
||||||
|
{ content: "\nAfter", width: "readable" },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not treat ordinary pipe characters as a table", () => {
|
||||||
|
expect(markdownLanes("Use foo | bar in prose.")).toEqual([{ content: "Use foo | bar in prose.", width: "readable" }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("restores spacing between separately rendered blocks", () => {
|
||||||
|
expect(markdownLaneMarginTop(0, "readable")).toBe(0)
|
||||||
|
expect(markdownLaneMarginTop(1, "technical")).toBe(1)
|
||||||
|
expect(markdownLaneMarginTop(2, "readable")).toBe(1)
|
||||||
|
expect(markdownLaneMarginTop(1, "full")).toBe(0)
|
||||||
|
})
|
||||||
@@ -27,6 +27,15 @@ test("validates the session tabs setting", () => {
|
|||||||
expect(decode({ session: { image_preview: true } })).toEqual({ session: { image_preview: true } })
|
expect(decode({ session: { image_preview: true } })).toEqual({ session: { image_preview: true } })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("validates the session max width setting", () => {
|
||||||
|
const decode = Schema.decodeUnknownSync(Info)
|
||||||
|
|
||||||
|
expect(decode({ session: { max_width: "auto" } })).toEqual({ session: { max_width: "auto" } })
|
||||||
|
expect(decode({ session: { max_width: 100 } })).toEqual({ session: { max_width: 100 } })
|
||||||
|
expect(() => decode({ session: { max_width: 4 } })).toThrow()
|
||||||
|
expect(() => decode({ session: { max_width: 100.5 } })).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
test("resolves nested config and keybind defaults", () => {
|
test("resolves nested config and keybind defaults", () => {
|
||||||
const config = resolve(
|
const config = resolve(
|
||||||
{
|
{
|
||||||
@@ -53,6 +62,13 @@ test("shows resolved tab defaults in settings", () => {
|
|||||||
expect(settings.find((setting) => setting.path.join(".") === "tabs.layout")?.default).toBe("horizontal")
|
expect(settings.find((setting) => setting.path.join(".") === "tabs.layout")?.default).toBe("horizontal")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("shows session reading width presets in settings", () => {
|
||||||
|
const setting = settings.find((setting) => setting.path.join(".") === "session.max_width")
|
||||||
|
|
||||||
|
expect(setting?.default).toBe("auto")
|
||||||
|
expect(setting?.values).toEqual(["auto", 66, 72, 80])
|
||||||
|
})
|
||||||
|
|
||||||
test("provides config and its host interface", async () => {
|
test("provides config and its host interface", async () => {
|
||||||
const config = resolve({}, { terminalSuspend: true })
|
const config = resolve({}, { terminalSuspend: true })
|
||||||
let current = {}
|
let current = {}
|
||||||
|
|||||||
@@ -1,8 +1,23 @@
|
|||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import { sessionTabsFitVertically, SESSION_SIDEBAR_WIDTH } from "../../src/ui/layout"
|
import {
|
||||||
|
sessionLaneLayout,
|
||||||
|
sessionTabsFitVertically,
|
||||||
|
SESSION_SIDEBAR_WIDTH,
|
||||||
|
SESSION_TECHNICAL_LANE_WIDTH,
|
||||||
|
} from "../../src/ui/layout"
|
||||||
|
|
||||||
test("vertical tabs match the session sidebar and preserve compact content width", () => {
|
test("vertical tabs match the session sidebar and preserve compact content width", () => {
|
||||||
expect(SESSION_SIDEBAR_WIDTH).toBe(42)
|
expect(SESSION_SIDEBAR_WIDTH).toBe(42)
|
||||||
expect(sessionTabsFitVertically(86)).toBe(true)
|
expect(sessionTabsFitVertically(86)).toBe(true)
|
||||||
expect(sessionTabsFitVertically(85)).toBe(false)
|
expect(sessionTabsFitVertically(85)).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("session lanes center the prose measure on one leading edge", () => {
|
||||||
|
expect(SESSION_TECHNICAL_LANE_WIDTH).toBe(88)
|
||||||
|
// Wide canvas: prose is truly centered, technical extends rightward.
|
||||||
|
expect(sessionLaneLayout(156, 66)).toEqual({ inset: 45, readable: 66, technical: 88 })
|
||||||
|
// Centering the prose would push the technical rail past the canvas; clamp.
|
||||||
|
expect(sessionLaneLayout(100, 66)).toEqual({ inset: 12, readable: 66, technical: 88 })
|
||||||
|
expect(sessionLaneLayout(80, 66)).toEqual({ inset: 0, readable: 66, technical: 80 })
|
||||||
|
expect(sessionLaneLayout(60, 66)).toEqual({ inset: 0, readable: 60, technical: 60 })
|
||||||
|
})
|
||||||
|
|||||||
@@ -14373,9 +14373,6 @@
|
|||||||
},
|
},
|
||||||
"agent": {
|
"agent": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
|
||||||
"previous": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["sessionID", "agent"],
|
"required": ["sessionID", "agent"],
|
||||||
@@ -14444,9 +14441,6 @@
|
|||||||
},
|
},
|
||||||
"model": {
|
"model": {
|
||||||
"$ref": "#/components/schemas/Model.Ref"
|
"$ref": "#/components/schemas/Model.Ref"
|
||||||
},
|
|
||||||
"previous": {
|
|
||||||
"$ref": "#/components/schemas/Model.Ref"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["sessionID", "model"],
|
"required": ["sessionID", "model"],
|
||||||
|
|||||||
@@ -14373,9 +14373,6 @@
|
|||||||
},
|
},
|
||||||
"agent": {
|
"agent": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
|
||||||
"previous": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["sessionID", "agent"],
|
"required": ["sessionID", "agent"],
|
||||||
@@ -14444,9 +14441,6 @@
|
|||||||
},
|
},
|
||||||
"model": {
|
"model": {
|
||||||
"$ref": "#/components/schemas/Model.Ref"
|
"$ref": "#/components/schemas/Model.Ref"
|
||||||
},
|
|
||||||
"previous": {
|
|
||||||
"$ref": "#/components/schemas/Model.Ref"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["sessionID", "model"],
|
"required": ["sessionID", "model"],
|
||||||
|
|||||||
Reference in New Issue
Block a user