mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-12 12:45:07 -04:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1036e82c6b | |||
| fcc8856f36 | |||
| 984e62502b | |||
| 5fd28cffc5 | |||
| c8dffb6893 | |||
| 01cbdcb8e4 | |||
| bf751a907d | |||
| 9d63ca8f90 | |||
| e3bd82013c | |||
| 90b6fa0eab | |||
| 5b7b1830d2 | |||
| a8fc664b6d | |||
| d853ff8848 | |||
| 94bc0fc6fa | |||
| d3eecf7ba2 | |||
| 99166f7c17 | |||
| 08dcf7d731 |
@@ -573,7 +573,10 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||
cache_control: cacheControl(breakpoints, part.cache),
|
||||
})
|
||||
}
|
||||
messages.push({ role: "user", content })
|
||||
const previous = messages.at(-1)
|
||||
if (previous?.role === "user" && previous.content.every((block) => block.type === "tool_result"))
|
||||
messages[messages.length - 1] = { role: "user", content: [...previous.content, ...content] }
|
||||
else messages.push({ role: "user", content })
|
||||
}
|
||||
|
||||
return messages
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Cause, Context, Effect, Layer, Option, Schema } from "effect"
|
||||
import { Cause, Context, Effect, Layer, Option, Schema, Stream } from "effect"
|
||||
import {
|
||||
FetchHttpClient,
|
||||
Headers,
|
||||
@@ -297,44 +297,86 @@ export const classifyHttpFailure = (input: {
|
||||
})
|
||||
}
|
||||
|
||||
const toHttpError = (redactedNames: ReadonlyArray<string | RegExp>) => (error: unknown) => {
|
||||
const transportError = (input: {
|
||||
readonly message: string
|
||||
readonly kind?: string | undefined
|
||||
readonly request?: HttpClientRequest.HttpClientRequest | undefined
|
||||
}) =>
|
||||
type HttpOperation = "request" | "read"
|
||||
|
||||
const NativeTransportFailure = Schema.Struct({
|
||||
message: Schema.String,
|
||||
code: Schema.optionalKey(Schema.String),
|
||||
cause: Schema.optionalKey(Schema.Unknown),
|
||||
})
|
||||
const decodeNativeTransportFailure = Schema.decodeUnknownOption(NativeTransportFailure)
|
||||
|
||||
const nativeTransportFailure = (error: unknown) => {
|
||||
const failure = Option.getOrUndefined(decodeNativeTransportFailure(error))
|
||||
if (!failure) return undefined
|
||||
if (failure.code !== undefined) return failure
|
||||
const cause = Option.getOrUndefined(decodeNativeTransportFailure(failure.cause))
|
||||
if (cause?.code !== undefined) return cause
|
||||
return failure
|
||||
}
|
||||
|
||||
const httpError = (input: {
|
||||
readonly error: unknown
|
||||
readonly request: HttpClientRequest.HttpClientRequest
|
||||
readonly operation: HttpOperation
|
||||
readonly redactedNames: ReadonlyArray<string | RegExp>
|
||||
}) => {
|
||||
const request = HttpClientError.isHttpClientError(input.error) ? input.error.request : input.request
|
||||
const transportError = (failure: { readonly message: string; readonly code?: string | undefined }) =>
|
||||
new AIError({
|
||||
module: "RequestExecutor",
|
||||
method: "execute",
|
||||
method: input.operation,
|
||||
reason: new TransportReason({
|
||||
message: input.message,
|
||||
kind: input.kind,
|
||||
url: input.request ? redactUrl(input.request.url) : undefined,
|
||||
http: input.request ? new HttpContext({ request: requestDetails(input.request, redactedNames) }) : undefined,
|
||||
message: failure.message,
|
||||
transport: "http",
|
||||
operation: input.operation,
|
||||
code: failure.code,
|
||||
url: redactUrl(request.url),
|
||||
http: new HttpContext({ request: requestDetails(request, input.redactedNames) }),
|
||||
}),
|
||||
})
|
||||
|
||||
if (Cause.isTimeoutError(error)) {
|
||||
return transportError({ message: error.message, kind: "Timeout" })
|
||||
}
|
||||
if (!HttpClientError.isHttpClientError(error)) {
|
||||
return transportError({ message: error instanceof Error ? error.message : "HTTP transport failed" })
|
||||
}
|
||||
const request = "request" in error ? error.request : undefined
|
||||
if (error.reason._tag === "TransportError") {
|
||||
const source =
|
||||
HttpClientError.isHttpClientError(input.error) && "cause" in input.error.reason
|
||||
? input.error.reason.cause
|
||||
: input.error
|
||||
const native = nativeTransportFailure(source)
|
||||
const code = native?.code
|
||||
const raw = native?.message ?? (input.error instanceof Error ? input.error.message : undefined)
|
||||
const detail = raw ? redactBody(raw, secretValues(request)) : undefined
|
||||
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({
|
||||
message: error.reason.description ?? "HTTP transport failed",
|
||||
kind: error.reason._tag,
|
||||
request,
|
||||
message: message ?? input.error.reason.description ?? "HTTP transport failed",
|
||||
code: code ?? input.error.reason._tag,
|
||||
})
|
||||
}
|
||||
return transportError({
|
||||
message: `HTTP transport failed: ${error.reason._tag}`,
|
||||
kind: error.reason._tag,
|
||||
request,
|
||||
message: message ?? `HTTP transport failed: ${input.error.reason._tag}`,
|
||||
code: code ?? input.error.reason._tag,
|
||||
})
|
||||
}
|
||||
|
||||
export const stream = (
|
||||
executor: Interface,
|
||||
request: HttpClientRequest.HttpClientRequest,
|
||||
middleware?: HttpMiddleware,
|
||||
): Stream.Stream<Uint8Array, AIError> =>
|
||||
Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const redactedNames = yield* Headers.CurrentRedactedNames
|
||||
const response = yield* executor.execute(request, middleware)
|
||||
return response.stream.pipe(
|
||||
Stream.mapError((error) => httpError({ error, request: response.request, operation: "read", redactedNames })),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
export const layer: Layer.Layer<Service, never, HttpClient.HttpClient> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
@@ -343,15 +385,16 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient> = Layer.e
|
||||
Effect.gen(function* () {
|
||||
const redactedNames = yield* Headers.CurrentRedactedNames
|
||||
if (!middleware)
|
||||
return yield* http
|
||||
.execute(request)
|
||||
.pipe(Effect.mapError(toHttpError(redactedNames)), Effect.flatMap(statusError(request, redactedNames)))
|
||||
return yield* http.execute(request).pipe(
|
||||
Effect.mapError((error) => httpError({ error, request, operation: "request", redactedNames })),
|
||||
Effect.flatMap(statusError(request, redactedNames)),
|
||||
)
|
||||
|
||||
const response = yield* middleware(request, (input) =>
|
||||
http
|
||||
.execute(input)
|
||||
.pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))),
|
||||
).pipe(Effect.mapError(toHttpError(redactedNames)))
|
||||
).pipe(Effect.mapError((error) => httpError({ error, request, operation: "request", redactedNames })))
|
||||
return yield* statusError(response.request, redactedNames)(response)
|
||||
})
|
||||
return Service.of({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Effect } from "effect"
|
||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||
import { Auth } from "../auth.js"
|
||||
import { render as renderEndpoint } from "../endpoint.js"
|
||||
@@ -6,6 +6,7 @@ import { Framing } from "../framing.js"
|
||||
import type { HttpMiddleware, Transport, TransportPrepareInput } from "./index.js"
|
||||
import * as ProviderShared from "../../protocols/shared.js"
|
||||
import { mergeJsonRecords, type LLMRequest } from "../../schema/index.js"
|
||||
import { RequestExecutor } from "../executor.js"
|
||||
|
||||
export type JsonRequestInput<Body> = TransportPrepareInput<Body>
|
||||
|
||||
@@ -86,26 +87,8 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
|
||||
middleware: prepareInput.middleware,
|
||||
}
|
||||
}),
|
||||
frames: (prepared, request, runtime) =>
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
frames: (prepared, _request, runtime) =>
|
||||
prepared.framing.frame(RequestExecutor.stream(runtime.http, prepared.request, prepared.middleware)),
|
||||
})
|
||||
|
||||
export const sseJson = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Cause, Context, Effect, Layer, Queue, Stream } from "effect"
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { AIError, TransportReason } from "../../schema/index.js"
|
||||
import { AIError, TransportReason, type TransportOperation } from "../../schema/index.js"
|
||||
import * as HttpTransport from "./http.js"
|
||||
import type { Transport } from "./index.js"
|
||||
|
||||
@@ -29,12 +29,18 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/AI
|
||||
const transportError = (
|
||||
method: string,
|
||||
message: string,
|
||||
input: { readonly url?: string; readonly kind?: string } = {},
|
||||
input: { readonly operation: TransportOperation; readonly url?: string; readonly code?: string },
|
||||
) =>
|
||||
new AIError({
|
||||
module: "WebSocketExecutor",
|
||||
method,
|
||||
reason: new TransportReason({ message, url: input.url, kind: input.kind }),
|
||||
reason: new TransportReason({
|
||||
message,
|
||||
transport: "websocket",
|
||||
operation: input.operation,
|
||||
url: input.url,
|
||||
code: input.code,
|
||||
}),
|
||||
})
|
||||
|
||||
const eventMessage = (event: Event) => {
|
||||
@@ -55,7 +61,8 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
|
||||
return Effect.fail(
|
||||
transportError("open", `WebSocket closed before opening (state ${ws.readyState})`, {
|
||||
url: input.url,
|
||||
kind: "open",
|
||||
operation: "request",
|
||||
code: "closed",
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -79,7 +86,10 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
|
||||
cleanup()
|
||||
resume(
|
||||
Effect.fail(
|
||||
transportError("open", `Failed to open WebSocket: ${eventMessage(event)}`, { url: input.url, kind: "open" }),
|
||||
transportError("open", `Failed to open WebSocket: ${eventMessage(event)}`, {
|
||||
url: input.url,
|
||||
operation: "request",
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -89,7 +99,8 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
|
||||
Effect.fail(
|
||||
transportError("open", `WebSocket closed before opening with code ${event.code}`, {
|
||||
url: input.url,
|
||||
kind: "open",
|
||||
operation: "request",
|
||||
code: String(event.code),
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -118,7 +129,8 @@ const webSocketUrl = (value: string) =>
|
||||
catch: (error) =>
|
||||
transportError("prepare", error instanceof Error ? error.message : "Invalid WebSocket URL", {
|
||||
url: value,
|
||||
kind: "websocket",
|
||||
operation: "request",
|
||||
code: "invalid-url",
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -129,7 +141,7 @@ export const open = (input: WebSocketRequest) =>
|
||||
catch: (error) =>
|
||||
transportError("open", error instanceof Error ? error.message : "Failed to construct WebSocket", {
|
||||
url: input.url,
|
||||
kind: "open",
|
||||
operation: "request",
|
||||
}),
|
||||
}).pipe(Effect.flatMap((ws) => fromWebSocket(ws, input)))
|
||||
|
||||
@@ -150,7 +162,10 @@ export const fromWebSocket = (
|
||||
Queue.failCauseUnsafe(
|
||||
messages,
|
||||
Cause.fail(
|
||||
transportError("message", "Unsupported WebSocket message payload", { url: input.url, kind: "message" }),
|
||||
transportError("message", "Unsupported WebSocket message payload", {
|
||||
url: input.url,
|
||||
operation: "read",
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -158,7 +173,10 @@ export const fromWebSocket = (
|
||||
Queue.failCauseUnsafe(
|
||||
messages,
|
||||
Cause.fail(
|
||||
transportError("message", `WebSocket error: ${eventMessage(event)}`, { url: input.url, kind: "message" }),
|
||||
transportError("message", `WebSocket error: ${eventMessage(event)}`, {
|
||||
url: input.url,
|
||||
operation: "read",
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -167,7 +185,11 @@ export const fromWebSocket = (
|
||||
Queue.failCauseUnsafe(
|
||||
messages,
|
||||
Cause.fail(
|
||||
transportError("message", `WebSocket closed with code ${event.code}`, { url: input.url, kind: "close" }),
|
||||
transportError("message", `WebSocket closed with code ${event.code}`, {
|
||||
url: input.url,
|
||||
operation: "read",
|
||||
code: String(event.code),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -188,7 +210,7 @@ export const fromWebSocket = (
|
||||
catch: (error) =>
|
||||
transportError("sendText", error instanceof Error ? error.message : "Failed to send WebSocket message", {
|
||||
url: input.url,
|
||||
kind: "write",
|
||||
operation: "write",
|
||||
}),
|
||||
}),
|
||||
messages: Stream.fromQueue(messages),
|
||||
@@ -243,7 +265,8 @@ export const json = <Body, Message>(input: JsonInput<Body, Message>): JsonTransp
|
||||
return Stream.fail(
|
||||
transportError("json", "WebSocket JSON transport requires WebSocketExecutor.Service", {
|
||||
url: prepared.url,
|
||||
kind: "websocket",
|
||||
operation: "request",
|
||||
code: "unavailable",
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -92,10 +92,18 @@ export class ProviderInternalReason extends Schema.Class<ProviderInternalReason>
|
||||
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")({
|
||||
_tag: Schema.tag("Transport"),
|
||||
message: Schema.String,
|
||||
kind: Schema.optional(Schema.String),
|
||||
transport: TransportType,
|
||||
operation: TransportOperation,
|
||||
code: Schema.optional(Schema.String),
|
||||
url: Schema.optional(Schema.String),
|
||||
http: Schema.optional(HttpContext),
|
||||
}) {}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer, Ref } from "effect"
|
||||
import { Headers, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { Effect, Layer, Ref, Stream } from "effect"
|
||||
import { Headers, HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { LLM, AIError } from "../src/index.js"
|
||||
import { LLMClient, RequestExecutor } from "../src/route.js"
|
||||
import * as OpenAIChat from "../src/protocols/openai-chat.js"
|
||||
import { dynamicResponse } from "./lib/http.js"
|
||||
import { dynamicResponse, systemError } from "./lib/http.js"
|
||||
import { deltaChunk } from "./lib/openai-chunks.js"
|
||||
import { sseRaw } from "./lib/sse.js"
|
||||
import { it } from "./lib/effect.js"
|
||||
@@ -67,6 +67,62 @@ const expectAIError = (error: unknown) => {
|
||||
const errorHttp = (error: AIError) => ("http" in error.reason ? error.reason.http : undefined)
|
||||
|
||||
describe("RequestExecutor", () => {
|
||||
it.effect("parses response body failures at the executor seam", () =>
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
const error = yield* RequestExecutor.stream(executor, 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("unwraps native transport failure causes", () =>
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
const error = yield* RequestExecutor.stream(executor, secretRequest).pipe(Stream.runDrain, Effect.flip)
|
||||
|
||||
expectAIError(error)
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "Transport",
|
||||
message: "ECONNRESET: socket closed",
|
||||
operation: "read",
|
||||
code: "ECONNRESET",
|
||||
})
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
responsesLayer([
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
pull(controller) {
|
||||
controller.error(new TypeError("fetch failed", { cause: systemError("ECONNRESET", "socket closed") }))
|
||||
},
|
||||
}),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("preserves middleware error messages", () =>
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
@@ -79,6 +135,48 @@ describe("RequestExecutor", () => {
|
||||
}).pipe(Effect.provide(responsesLayer([]))),
|
||||
)
|
||||
|
||||
it.effect("reports the request sent by middleware", () =>
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
const error = yield* executor
|
||||
.execute(request, (original, handler) =>
|
||||
handler(
|
||||
original.pipe(
|
||||
HttpClientRequest.setUrl("https://proxy.test/v1/chat?api_key=proxy-secret"),
|
||||
HttpClientRequest.setHeader("authorization", "Bearer proxy-secret"),
|
||||
),
|
||||
),
|
||||
)
|
||||
.pipe(Effect.flip)
|
||||
|
||||
expectAIError(error)
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "Transport",
|
||||
message: "ECONNRESET: proxy disconnected <redacted>",
|
||||
url: "https://proxy.test/v1/chat?api_key=%3Credacted%3E",
|
||||
http: {
|
||||
request: {
|
||||
url: "https://proxy.test/v1/chat?api_key=%3Credacted%3E",
|
||||
headers: { authorization: "<redacted>" },
|
||||
},
|
||||
},
|
||||
})
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.fail(
|
||||
new HttpClientError.HttpClientError({
|
||||
reason: new HttpClientError.TransportError({
|
||||
request: input.request,
|
||||
cause: systemError("ECONNRESET", "proxy disconnected proxy-secret"),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("classifies context overflow responses", () =>
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect, Layer, Ref } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route.js"
|
||||
import type { Service as LLMClientService } from "../../src/route/client.js"
|
||||
import type { Service as RequestExecutorService } from "../../src/route/executor.js"
|
||||
@@ -14,7 +14,9 @@ export type HandlerInput = {
|
||||
) => HttpClientResponse.HttpClientResponse
|
||||
}
|
||||
|
||||
export type Handler = (input: HandlerInput) => Effect.Effect<HttpClientResponse.HttpClientResponse>
|
||||
export type Handler = (
|
||||
input: HandlerInput,
|
||||
) => Effect.Effect<HttpClientResponse.HttpClientResponse, HttpClientError.HttpClientError>
|
||||
|
||||
const handlerLayer = (handler: Handler): Layer.Layer<HttpClient.HttpClient> =>
|
||||
Layer.succeed(
|
||||
@@ -34,6 +36,12 @@ const handlerLayer = (handler: Handler): Layer.Layer<HttpClient.HttpClient> =>
|
||||
|
||||
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> => {
|
||||
const requestExecutorLayer = RequestExecutor.layer.pipe(Layer.provide(layer))
|
||||
const deps = Layer.mergeAll(requestExecutorLayer, WebSocketExecutor.layer)
|
||||
@@ -63,14 +71,20 @@ export const dynamicResponse = (handler: Handler) => runtimeLayer(handlerLayer(h
|
||||
* Layer that emits the supplied SSE chunks and then aborts mid-stream. Used to
|
||||
* exercise transport errors that surface during parsing.
|
||||
*/
|
||||
export const truncatedStream = (chunks: ReadonlyArray<string>) =>
|
||||
export const truncatedStream = (chunks: ReadonlyArray<string>, error: Error = new Error("connection reset")) =>
|
||||
dynamicResponse((input) =>
|
||||
Effect.sync(() => {
|
||||
const encoder = new TextEncoder()
|
||||
let index = 0
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
for (const chunk of chunks) controller.enqueue(encoder.encode(chunk))
|
||||
controller.error(new Error("connection reset"))
|
||||
pull(controller) {
|
||||
const chunk = chunks[index]
|
||||
if (chunk !== undefined) {
|
||||
index++
|
||||
controller.enqueue(encoder.encode(chunk))
|
||||
return
|
||||
}
|
||||
controller.error(error)
|
||||
},
|
||||
})
|
||||
return input.respond(stream, { headers: SSE_HEADERS })
|
||||
|
||||
@@ -271,6 +271,47 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("batches parallel tool results into one Anthropic user message", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.user("Check both cities."),
|
||||
Message.assistant([
|
||||
{ type: "text", text: "I'll check both." },
|
||||
ToolCallPart.make({ id: "call_paris", name: "weather", input: { city: "Paris" } }),
|
||||
ToolCallPart.make({ id: "call_london", name: "weather", input: { city: "London" } }),
|
||||
]),
|
||||
Message.tool({ id: "call_paris", name: "weather", result: { temperature: 22 } }),
|
||||
Message.tool({ id: "call_london", name: "weather", result: { temperature: 18 } }),
|
||||
],
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toMatchObject([
|
||||
{ role: "user", content: [{ type: "text", text: "Check both cities." }] },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "I'll check both." },
|
||||
{ type: "tool_use", id: "call_paris", name: "weather", input: { city: "Paris" } },
|
||||
{ type: "tool_use", id: "call_london", name: "weather", input: { city: "London" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "tool_result", tool_use_id: "call_paris", content: '{"temperature":22}' },
|
||||
{ type: "tool_result", tool_use_id: "call_london", content: '{"temperature":18}' },
|
||||
],
|
||||
},
|
||||
])
|
||||
expect(prepared.body.messages).toHaveLength(3)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps tools and sends tool_choice none", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
@@ -915,6 +956,54 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("assembles and persists multiple tool calls from one Anthropic response", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "tool_use", id: "call_paris", name: "weather", input: {} },
|
||||
},
|
||||
{
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "input_json_delta", partial_json: '{"city":"Paris"}' },
|
||||
},
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 1,
|
||||
content_block: { type: "tool_use", id: "call_london", name: "weather", input: {} },
|
||||
},
|
||||
{
|
||||
type: "content_block_delta",
|
||||
index: 1,
|
||||
delta: { type: "input_json_delta", partial_json: '{"city":"London"}' },
|
||||
},
|
||||
{ type: "content_block_stop", index: 1 },
|
||||
{ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 2 } },
|
||||
{ type: "message_stop" },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.toolCalls).toMatchObject([
|
||||
{ id: "call_paris", name: "weather", input: { city: "Paris" } },
|
||||
{ id: "call_london", name: "weather", input: { city: "London" } },
|
||||
])
|
||||
expect(response.message.content).toMatchObject([
|
||||
{ type: "tool-call", id: "call_paris", name: "weather", input: { city: "Paris" } },
|
||||
{ type: "tool-call", id: "call_london", name: "weather", input: { city: "London" } },
|
||||
])
|
||||
expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: "tool_use" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps malformed server tool input terminal", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { Effect, Ref, Schema, Stream } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import {
|
||||
HttpOptions,
|
||||
@@ -22,7 +22,7 @@ import { ProviderShared } from "../../src/protocols/shared.js"
|
||||
import { Auth, LLMClient } from "../../src/route.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { dynamicResponse, fixedResponse, truncatedStream } from "../lib/http.js"
|
||||
import { dynamicResponse, fixedResponse, systemError, truncatedStream } from "../lib/http.js"
|
||||
import { deltaChunk, usageChunk } from "../lib/openai-chunks.js"
|
||||
import { sseEvents } from "../lib/sse.js"
|
||||
|
||||
@@ -1221,12 +1221,44 @@ describe("OpenAI Chat route", () => {
|
||||
|
||||
it.effect("surfaces transport errors that occur mid-stream", () =>
|
||||
Effect.gen(function* () {
|
||||
const layer = truncatedStream([
|
||||
`data: ${JSON.stringify(deltaChunk({ role: "assistant", content: "Hello" }))}\n\n`,
|
||||
])
|
||||
const error = yield* LLMClient.generate(request).pipe(Effect.provide(layer), Effect.flip)
|
||||
const layer = truncatedStream(
|
||||
[`data: ${JSON.stringify(deltaChunk({ role: "assistant", content: "Hello" }))}\n\n`],
|
||||
systemError("ECONNRESET", "socket closed unexpectedly"),
|
||||
)
|
||||
const events = yield* Ref.make<ReadonlyArray<LLMEvent>>([])
|
||||
const error = yield* LLMClient.stream(request).pipe(
|
||||
Stream.tap((event) => Ref.update(events, (current) => [...current, event])),
|
||||
Stream.runDrain,
|
||||
Effect.provide(layer),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.message).toContain("Failed to read openai/openai-chat stream")
|
||||
expect((yield* Ref.get(events)).some((event) => event.type === "text-delta")).toBeTrue()
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "Transport",
|
||||
message: "ECONNRESET: socket closed unexpectedly",
|
||||
transport: "http",
|
||||
operation: "read",
|
||||
code: "ECONNRESET",
|
||||
url: "https://api.openai.test/v1/chat/completions",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("surfaces transport errors before the first stream frame", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(truncatedStream([], systemError("ECONNRESET", "socket closed before output"))),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "Transport",
|
||||
message: "ECONNRESET: socket closed before output",
|
||||
transport: "http",
|
||||
operation: "read",
|
||||
code: "ECONNRESET",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -189,7 +189,12 @@ test.describe("smoke: session timeline", () => {
|
||||
.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')
|
||||
?.getBoundingClientRect()
|
||||
samples.push({ ids: visible, last: visible.includes(last), bottomError: bottom?.bottom - view.bottom })
|
||||
if (!firstPaint && visible.includes(last) && Math.abs((bottom?.bottom ?? Infinity) - view.bottom) <= 1) {
|
||||
if (
|
||||
!firstPaint &&
|
||||
visible.includes(last) &&
|
||||
Math.abs((bottom?.bottom ?? Infinity) - view.bottom) <= 1 &&
|
||||
!root.querySelector('[data-markdown-key="initial"]')
|
||||
) {
|
||||
firstPaint = true
|
||||
root.querySelectorAll<HTMLElement>("[data-timeline-key]").forEach((row) => {
|
||||
const rect = row.getBoundingClientRect()
|
||||
@@ -204,10 +209,16 @@ test.describe("smoke: session timeline", () => {
|
||||
}
|
||||
;(
|
||||
window as Window & {
|
||||
__sessionTabPaint?: { samples: typeof samples; removed: () => number; stop: () => void }
|
||||
__sessionTabPaint?: {
|
||||
samples: typeof samples
|
||||
painted: () => boolean
|
||||
removed: () => number
|
||||
stop: () => void
|
||||
}
|
||||
}
|
||||
).__sessionTabPaint = {
|
||||
samples,
|
||||
painted: () => firstPaint,
|
||||
removed: () => removedFirstPaintNodes,
|
||||
stop: () => {
|
||||
running = false
|
||||
@@ -219,17 +230,19 @@ test.describe("smoke: session timeline", () => {
|
||||
)
|
||||
|
||||
await switchTitlebarSession(page, fixture.targetID, fixture.expected.targetTitle)
|
||||
await page.waitForFunction(() =>
|
||||
(
|
||||
window as Window & { __sessionTabPaint?: { samples: Array<{ ids: string[] }> } }
|
||||
).__sessionTabPaint?.samples.some((sample) => sample.ids.length > 0),
|
||||
)
|
||||
await page.waitForFunction(() => {
|
||||
const probe = (
|
||||
window as Window & { __sessionTabPaint?: { samples: Array<{ ids: string[] }>; painted: () => boolean } }
|
||||
).__sessionTabPaint
|
||||
return probe?.painted() && probe.samples.some((sample) => sample.ids.length > 0)
|
||||
})
|
||||
await page.waitForTimeout(200)
|
||||
const first = await page.evaluate(() => {
|
||||
const probe = (
|
||||
window as Window & {
|
||||
__sessionTabPaint?: {
|
||||
samples: Array<{ ids: string[]; last: boolean; bottomError?: number }>
|
||||
painted: () => boolean
|
||||
removed: () => number
|
||||
stop: () => void
|
||||
}
|
||||
|
||||
@@ -15,8 +15,6 @@ export default Runtime.handler(Commands, (input) =>
|
||||
Effect.gen(function* () {
|
||||
const requestedDirectory = Option.getOrUndefined(input.directory)
|
||||
if (requestedDirectory !== undefined) process.chdir(requestedDirectory)
|
||||
const updater = yield* Updater.Service
|
||||
yield* updater.check().pipe(Effect.forkScoped)
|
||||
const preflight = UpdatePreflight.make()
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => preflight.close()))
|
||||
const server = yield* ServerConnection.resolve({
|
||||
@@ -36,6 +34,8 @@ export default Runtime.handler(Commands, (input) =>
|
||||
Effect.promise(() => preflight.fail("OpenCode update could not start the new background service")),
|
||||
),
|
||||
)
|
||||
const updater = yield* Updater.Service
|
||||
yield* updater.check().pipe(Effect.forkScoped)
|
||||
preflight.loading()
|
||||
const config = yield* Config.Service
|
||||
const npm = yield* Npm.Service
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
export * as Config from "./config"
|
||||
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Context, Effect, FileSystem, Layer, Option, Schema, Semaphore } from "effect"
|
||||
import { Flock } from "@opencode-ai/util/flock"
|
||||
import { Context, Effect, FileSystem, Layer, Option, Schema } from "effect"
|
||||
import { produce, type Draft } from "immer"
|
||||
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
|
||||
import path from "path"
|
||||
@@ -28,7 +29,6 @@ export const layer = Layer.effect(
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const global = yield* Global.Service
|
||||
const file = path.join(global.config, "cli.json")
|
||||
const lock = yield* Semaphore.make(1)
|
||||
|
||||
const readJson = Effect.fnUntraced(function* () {
|
||||
const text = yield* fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
@@ -49,38 +49,60 @@ export const layer = Layer.effect(
|
||||
const migrate = ConfigMigration.run({ file, config: global.config, state: global.state }).pipe(
|
||||
Effect.provideService(FileSystem.FileSystem, fs),
|
||||
)
|
||||
const withLock = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
Effect.scoped(
|
||||
Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
const lock = yield* restore(
|
||||
Effect.promise((signal) => Flock.acquire(file, { dir: path.join(global.state, "locks"), signal })),
|
||||
)
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => lock.release()))
|
||||
return yield* restore(effect)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const get = Effect.fn("cli.config.get")(function* () {
|
||||
yield* migrate.pipe(Effect.catchCause((cause) => Effect.logWarning("failed to migrate cli config", { cause })))
|
||||
return Option.getOrElse(decode(yield* readJson()), () => empty)
|
||||
})
|
||||
const get = Effect.fn("cli.config.get")(() =>
|
||||
withLock(
|
||||
Effect.gen(function* () {
|
||||
const migration = yield* migrate.pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("failed to migrate cli config", { cause }).pipe(Effect.as(undefined)),
|
||||
),
|
||||
)
|
||||
if (migration?.cause)
|
||||
yield* Effect.logWarning("failed to persist migrated cli config", { cause: migration.cause })
|
||||
if (migration?.info) return migration.info
|
||||
return Option.getOrElse(decode(yield* readJson()), () => empty)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const update = Effect.fn("cli.config.update")((update: (draft: Draft<Info>) => void) =>
|
||||
lock
|
||||
.withPermits(1)(
|
||||
Effect.gen(function* () {
|
||||
yield* migrate
|
||||
const current = Option.getOrElse(decode(yield* readJson()), () => empty)
|
||||
const next = produce(current, update)
|
||||
const edits = changes(current, next)
|
||||
if (!edits.length) return current
|
||||
const text = yield* fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("{}")))
|
||||
const updated = edits.reduce(
|
||||
(text, edit) =>
|
||||
applyEdits(
|
||||
text,
|
||||
modify(text, edit.path, edit.value, { formattingOptions: { tabSize: 2, insertSpaces: true } }),
|
||||
),
|
||||
text,
|
||||
)
|
||||
const errors: ParseError[] = []
|
||||
const config = Option.getOrUndefined(decode(parse(updated, errors, { allowTrailingComma: true })))
|
||||
if (errors.length || config === undefined) return yield* Effect.fail(new Error("Invalid CLI config update"))
|
||||
yield* write(updated.endsWith("\n") ? updated : updated + "\n")
|
||||
return config
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.mapError((cause) => new Error("Failed to update CLI config", { cause }))),
|
||||
withLock(
|
||||
Effect.gen(function* () {
|
||||
const migration = yield* migrate
|
||||
if (migration?.cause) return yield* Effect.failCause(migration.cause)
|
||||
const current = migration?.info ?? Option.getOrElse(decode(yield* readJson()), () => empty)
|
||||
const next = produce(current, update)
|
||||
const edits = changes(current, next)
|
||||
if (!edits.length) return current
|
||||
const text = yield* fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("{}")))
|
||||
const updated = edits.reduce(
|
||||
(text, edit) =>
|
||||
applyEdits(
|
||||
text,
|
||||
modify(text, edit.path, edit.value, { formattingOptions: { tabSize: 2, insertSpaces: true } }),
|
||||
),
|
||||
text,
|
||||
)
|
||||
const errors: ParseError[] = []
|
||||
const config = Option.getOrUndefined(decode(parse(updated, errors, { allowTrailingComma: true })))
|
||||
if (errors.length || config === undefined) return yield* Effect.fail(new Error("Invalid CLI config update"))
|
||||
yield* write(updated.endsWith("\n") ? updated : updated + "\n")
|
||||
return config
|
||||
}),
|
||||
).pipe(Effect.mapError((cause) => new Error("Failed to update CLI config", { cause }))),
|
||||
)
|
||||
|
||||
return Service.of({ path: file, get, update })
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
export * as ConfigMigration from "./migrate"
|
||||
|
||||
import { TuiConfigV1 } from "@opencode-ai/tui/config/v1"
|
||||
import { TuiKeybind } from "@opencode-ai/tui/config/v1/keybind"
|
||||
import { Definitions } from "@opencode-ai/tui/config/keybind"
|
||||
import { Effect, FileSystem, Option, Schema } from "effect"
|
||||
import { parse, type ParseError } from "jsonc-parser"
|
||||
import { randomUUID } from "crypto"
|
||||
import { createScanner, parse, parseTree, type Node, type ParseError } from "jsonc-parser"
|
||||
import path from "path"
|
||||
import type { Info } from "./schema"
|
||||
import { Info } from "./schema"
|
||||
|
||||
const decodeV1 = Schema.decodeUnknownOption(TuiConfigV1.Info)
|
||||
const decodeInfo = Schema.decodeUnknownOption(Info)
|
||||
const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Any))
|
||||
const LegacyKeybindTargets = new Set<string>(Object.values(TuiKeybind.CommandMap))
|
||||
|
||||
export const run = Effect.fn("cli.config.migrate")(function* (input: {
|
||||
readonly file: string
|
||||
@@ -15,7 +20,60 @@ export const run = Effect.fn("cli.config.migrate")(function* (input: {
|
||||
readonly state: string
|
||||
}) {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
if (yield* fs.exists(input.file).pipe(Effect.orElseSucceed(() => false))) return
|
||||
const persist = Effect.fnUntraced(function* (text: string, info: Info) {
|
||||
const temp = `${input.file}.${process.pid}.${randomUUID()}.tmp`
|
||||
const cause = yield* Effect.gen(function* () {
|
||||
yield* fs.makeDirectory(path.dirname(input.file), { recursive: true })
|
||||
yield* fs.writeFileString(temp, text, { mode: 0o600 })
|
||||
yield* fs.rename(temp, input.file)
|
||||
}).pipe(
|
||||
Effect.as(undefined),
|
||||
Effect.catchCause((cause) => Effect.succeed(cause)),
|
||||
Effect.ensuring(fs.remove(temp).pipe(Effect.ignore)),
|
||||
)
|
||||
return cause === undefined ? { info } : { info, cause }
|
||||
})
|
||||
|
||||
if (yield* fs.exists(input.file).pipe(Effect.orElseSucceed(() => false))) {
|
||||
const text = yield* fs.readFileString(input.file)
|
||||
const errors: ParseError[] = []
|
||||
const value: any = parse(text, errors, { allowTrailingComma: true })
|
||||
if (errors.length) return
|
||||
const config = Option.getOrUndefined(decodeRecord(value))
|
||||
if (config === undefined) return
|
||||
const keybinds = Option.getOrUndefined(decodeRecord(config.keybinds))
|
||||
if (keybinds === undefined) return
|
||||
const deduped = findKeybindObjects(text)
|
||||
.slice(0, -1)
|
||||
.reduce((text) => {
|
||||
const property = findKeybindObjects(text)[0]
|
||||
return property === undefined ? text : removeProperty(text, property)
|
||||
}, text)
|
||||
const updated = Object.keys(keybinds).reduce((text, name) => {
|
||||
const target =
|
||||
TuiKeybind.CommandMap[name as keyof typeof TuiKeybind.CommandMap] ??
|
||||
(name in Definitions || LegacyKeybindTargets.has(name) ? name : undefined)
|
||||
if (target === undefined) return text
|
||||
const properties = findKeybindProperties(text, name)
|
||||
if (!properties.length) return text
|
||||
const remove = !(target in Definitions) || (target !== name && target in keybinds)
|
||||
// The parser gives the final duplicate precedence, so remove earlier properties before renaming it.
|
||||
const updated = properties.slice(0, remove ? properties.length : -1).reduce((text) => {
|
||||
const property = findKeybindProperties(text, name)[0]
|
||||
return property === undefined ? text : removeProperty(text, property)
|
||||
}, text)
|
||||
if (remove) return updated
|
||||
if (target === name) return updated
|
||||
const key = findKeybindProperties(updated, name)[0]?.children?.[0]
|
||||
if (key === undefined) return text
|
||||
return updated.slice(0, key.offset) + JSON.stringify(target) + updated.slice(key.offset + key.length)
|
||||
}, deduped)
|
||||
if (updated === text) return
|
||||
const updatedErrors: ParseError[] = []
|
||||
const info = Option.getOrUndefined(decodeInfo(parse(updated, updatedErrors, { allowTrailingComma: true })))
|
||||
if (updatedErrors.length || info === undefined) return
|
||||
return yield* persist(updated, info)
|
||||
}
|
||||
|
||||
const legacyValue = yield* readJson(path.join(input.config, "tui.json"))
|
||||
const legacy = Option.getOrUndefined(decodeV1(legacyValue))
|
||||
@@ -23,19 +81,59 @@ export const run = Effect.fn("cli.config.migrate")(function* (input: {
|
||||
const migrated = migrateV1(legacy, kv ?? {})
|
||||
if (!Object.keys(migrated).length) return
|
||||
|
||||
const temp = input.file + ".tmp"
|
||||
yield* fs.makeDirectory(path.dirname(input.file), { recursive: true })
|
||||
yield* fs.writeFileString(temp, JSON.stringify(migrated, null, 2) + "\n", { mode: 0o600 })
|
||||
yield* fs.rename(temp, input.file)
|
||||
yield* Effect.logInfo("migrated cli config", {
|
||||
from: [
|
||||
legacyValue === undefined ? undefined : path.join(input.config, "tui.json"),
|
||||
kv === undefined ? undefined : path.join(input.state, "kv.json"),
|
||||
].filter(Boolean),
|
||||
to: input.file,
|
||||
})
|
||||
const result = yield* persist(JSON.stringify(migrated, null, 2) + "\n", migrated)
|
||||
if (result.cause === undefined)
|
||||
yield* Effect.logInfo("migrated cli config", {
|
||||
from: [
|
||||
legacyValue === undefined ? undefined : path.join(input.config, "tui.json"),
|
||||
kv === undefined ? undefined : path.join(input.state, "kv.json"),
|
||||
].filter(Boolean),
|
||||
to: input.file,
|
||||
})
|
||||
return result
|
||||
})
|
||||
|
||||
function findKeybindProperties(text: string, name: string) {
|
||||
const keybinds = findKeybindObjects(text).at(-1)?.children?.[1]
|
||||
return keybinds?.children?.filter((property) => property.children?.[0]?.value === name) ?? []
|
||||
}
|
||||
|
||||
function findKeybindObjects(text: string) {
|
||||
const tree = parseTree(text)
|
||||
if (tree === undefined) return []
|
||||
return tree.children?.filter((property) => property.children?.[0]?.value === "keybinds") ?? []
|
||||
}
|
||||
|
||||
function removeProperty(text: string, property: Node) {
|
||||
const properties = property.parent?.children ?? []
|
||||
const index = properties.indexOf(property)
|
||||
const end = property.offset + property.length
|
||||
const next = properties[index + 1]
|
||||
if (next) {
|
||||
const comma = findComma(text, end, next.offset)
|
||||
if (comma !== undefined) return text.slice(0, property.offset) + text.slice(end, comma) + text.slice(comma + 1)
|
||||
}
|
||||
const previous = properties[index - 1]
|
||||
if (previous) {
|
||||
const comma = findComma(text, previous.offset + previous.length, property.offset)
|
||||
if (comma !== undefined) return text.slice(0, comma) + text.slice(comma + 1, property.offset) + text.slice(end)
|
||||
}
|
||||
const comma = findComma(text, end, (property.parent?.offset ?? 0) + (property.parent?.length ?? 0))
|
||||
if (comma !== undefined) return text.slice(0, property.offset) + text.slice(end, comma) + text.slice(comma + 1)
|
||||
return text.slice(0, property.offset) + text.slice(end)
|
||||
}
|
||||
|
||||
function findComma(text: string, start: number, end: number) {
|
||||
const scanner = createScanner(text, false)
|
||||
scanner.setPosition(start)
|
||||
while (true) {
|
||||
scanner.scan()
|
||||
const offset = scanner.getTokenOffset()
|
||||
if (scanner.getTokenLength() === 0 || offset >= end) return
|
||||
if (text[offset] === ",") return offset
|
||||
}
|
||||
}
|
||||
|
||||
export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record<string, any>): Info {
|
||||
const plugins = [
|
||||
...(legacy?.plugin?.map((plugin) =>
|
||||
@@ -49,6 +147,16 @@ export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record<strin
|
||||
const diffView = kv.diff_viewer_view ?? (legacy?.diff_style === "stacked" ? "unified" : undefined)
|
||||
const thinking =
|
||||
kv.thinking_mode ?? (kv.thinking_visibility === undefined ? undefined : kv.thinking_visibility ? "show" : "hide")
|
||||
const keybinds =
|
||||
legacy?.keybinds === undefined
|
||||
? undefined
|
||||
: Object.fromEntries(
|
||||
Object.entries(legacy.keybinds).flatMap(([name, value]) => {
|
||||
const target = TuiKeybind.CommandMap[name as keyof typeof TuiKeybind.CommandMap] ?? name
|
||||
if (!(target in Definitions)) return []
|
||||
return [[target, value]]
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
...(themeName !== undefined || themeMode !== undefined
|
||||
@@ -59,7 +167,7 @@ export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record<strin
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(legacy?.keybinds === undefined ? {} : { keybinds: legacy.keybinds }),
|
||||
...(keybinds === undefined ? {} : { keybinds }),
|
||||
...(plugins.length ? { plugins } : {}),
|
||||
...(legacy?.leader_timeout === undefined ? {} : { leader: { timeout: legacy.leader_timeout } }),
|
||||
...(legacy?.scroll_speed === undefined && legacy?.scroll_acceleration?.enabled === undefined
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Flock } from "@opencode-ai/util/flock"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, FileSystem, Option } from "effect"
|
||||
import { expect, test } from "bun:test"
|
||||
import { parse } from "jsonc-parser"
|
||||
import path from "path"
|
||||
import { Config } from "../src/config"
|
||||
|
||||
@@ -21,7 +23,14 @@ test("migrates tui and kv config into cli.json", async () => {
|
||||
path.join(directory, "tui.json"),
|
||||
JSON.stringify({
|
||||
theme: "legacy",
|
||||
keybinds: { leader: "ctrl+o" },
|
||||
keybinds: {
|
||||
leader: "ctrl+o",
|
||||
app_exit: "ctrl+q",
|
||||
app_heap_snapshot: "ctrl+h",
|
||||
input_paste: { key: "ctrl+v", preventDefault: false },
|
||||
session_delete: false,
|
||||
"dialog.select.next": "ctrl+n",
|
||||
},
|
||||
plugin: [["example", { mode: "safe" }]],
|
||||
plugin_enabled: { disabled: false },
|
||||
leader_timeout: 500,
|
||||
@@ -65,7 +74,13 @@ test("migrates tui and kv config into cli.json", async () => {
|
||||
|
||||
expect(config).toMatchObject({
|
||||
theme: { name: "legacy", mode: "light" },
|
||||
keybinds: { leader: "ctrl+o" },
|
||||
keybinds: {
|
||||
leader: "ctrl+o",
|
||||
"app.exit": "ctrl+q",
|
||||
"prompt.paste": { key: "ctrl+v", preventDefault: false },
|
||||
"session.delete": false,
|
||||
"dialog.select.next": "ctrl+n",
|
||||
},
|
||||
plugins: [{ package: "example", options: { mode: "safe" } }, "-disabled"],
|
||||
leader: { timeout: 500 },
|
||||
scroll: { speed: 2, acceleration: true },
|
||||
@@ -80,7 +95,13 @@ test("migrates tui and kv config into cli.json", async () => {
|
||||
expect(config).not.toHaveProperty("skipped_version")
|
||||
expect(config).not.toHaveProperty("which_key")
|
||||
expect(config).not.toHaveProperty("hints")
|
||||
expect((await Bun.file(path.join(directory, "cli.json")).json()).keybinds).toEqual({ leader: "ctrl+o" })
|
||||
expect((await Bun.file(path.join(directory, "cli.json")).json()).keybinds).toEqual({
|
||||
leader: "ctrl+o",
|
||||
"app.exit": "ctrl+q",
|
||||
"prompt.paste": { key: "ctrl+v", preventDefault: false },
|
||||
"session.delete": false,
|
||||
"dialog.select.next": "ctrl+n",
|
||||
})
|
||||
expect(await Bun.file(path.join(directory, "cli.json")).exists()).toBe(true)
|
||||
expect(await Bun.file(path.join(directory, "tui.json")).exists()).toBe(true)
|
||||
expect(await Bun.file(path.join(directory, "kv.json")).exists()).toBe(true)
|
||||
@@ -141,6 +162,257 @@ test("preserves legacy cursor settings", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("migrates legacy keybind names in an existing cli.json", async () => {
|
||||
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
|
||||
const file = path.join(directory, "cli.json")
|
||||
await Bun.write(
|
||||
file,
|
||||
`{
|
||||
// Preserve this comment
|
||||
"keybinds": {
|
||||
// Session list shortcut
|
||||
"session_list": "ctrl+l",
|
||||
"app_heap_snapshot": "ctrl+h",
|
||||
// Legacy delete shortcut
|
||||
"session_delete": "ctrl+d",
|
||||
// Canonical delete shortcut
|
||||
"session.delete": "ctrl+x",
|
||||
"app.heap_snapshot": "ctrl+shift+h"
|
||||
}
|
||||
}
|
||||
`,
|
||||
)
|
||||
|
||||
try {
|
||||
const config = await run(
|
||||
directory,
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Config.Service
|
||||
return yield* service.get()
|
||||
}),
|
||||
)
|
||||
|
||||
expect(config.keybinds).toEqual({
|
||||
"session.list": "ctrl+l",
|
||||
"session.delete": "ctrl+x",
|
||||
})
|
||||
const text = await Bun.file(file).text()
|
||||
expect(text).toContain("// Preserve this comment")
|
||||
expect(text).toContain("// Session list shortcut")
|
||||
expect(text).toContain("// Legacy delete shortcut")
|
||||
expect(text).toContain("// Canonical delete shortcut")
|
||||
expect(parse(text).keybinds).toEqual({
|
||||
"session.list": "ctrl+l",
|
||||
"session.delete": "ctrl+x",
|
||||
})
|
||||
} finally {
|
||||
await Bun.$`rm -rf ${directory}`
|
||||
}
|
||||
})
|
||||
|
||||
test("uses migrated keybinds when persistence fails", async () => {
|
||||
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
|
||||
const file = path.join(directory, "cli.json")
|
||||
await Bun.write(file, `{"keybinds":{"session_list":"ctrl+l"}}`)
|
||||
const node = await Effect.runPromise(FileSystem.FileSystem.pipe(Effect.provide(NodeFileSystem.layer)))
|
||||
const fs = new Proxy(node, {
|
||||
get(target, property, receiver) {
|
||||
if (property === "rename") return () => Effect.die(new Error("read-only config"))
|
||||
return Reflect.get(target, property, receiver)
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const config = await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Config.Service
|
||||
return yield* service.get()
|
||||
}).pipe(
|
||||
Effect.provide(Config.layer),
|
||||
Effect.provide(Global.layerWith({ config: directory, state: directory })),
|
||||
Effect.provideService(FileSystem.FileSystem, fs),
|
||||
),
|
||||
)
|
||||
|
||||
expect(config.keybinds).toEqual({ "session.list": "ctrl+l" })
|
||||
expect(await Bun.file(file).json()).toEqual({ keybinds: { session_list: "ctrl+l" } })
|
||||
expect(await Array.fromAsync(new Bun.Glob("*.tmp").scan(directory))).toEqual([])
|
||||
} finally {
|
||||
await Bun.$`rm -rf ${directory}`
|
||||
}
|
||||
})
|
||||
|
||||
test("preserves the effective value when migrating duplicate legacy keybinds", async () => {
|
||||
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
|
||||
const file = path.join(directory, "cli.json")
|
||||
await Bun.write(file, `{"keybinds":{"session_delete":"ctrl+a","session_delete":"ctrl+b"}}`)
|
||||
|
||||
try {
|
||||
const config = await run(
|
||||
directory,
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Config.Service
|
||||
return yield* service.get()
|
||||
}),
|
||||
)
|
||||
|
||||
expect(config.keybinds).toEqual({ "session.delete": "ctrl+b" })
|
||||
expect(parse(await Bun.file(file).text()).keybinds).toEqual({ "session.delete": "ctrl+b" })
|
||||
} finally {
|
||||
await Bun.$`rm -rf ${directory}`
|
||||
}
|
||||
})
|
||||
|
||||
test("migrates and updates the effective duplicate top-level keybinds", async () => {
|
||||
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
|
||||
const file = path.join(directory, "cli.json")
|
||||
await Bun.write(file, `{"keybinds":{"session_delete":"first"},"keybinds":{"session_delete":"last"}}`)
|
||||
|
||||
try {
|
||||
const config = await run(
|
||||
directory,
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Config.Service
|
||||
expect((yield* service.get()).keybinds).toEqual({ "session.delete": "last" })
|
||||
return yield* service.update((draft) => {
|
||||
draft.keybinds = { ...draft.keybinds, "session.delete": "changed" }
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
expect(config.keybinds).toEqual({ "session.delete": "changed" })
|
||||
expect(parse(await Bun.file(file).text()).keybinds).toEqual({ "session.delete": "changed" })
|
||||
} finally {
|
||||
await Bun.$`rm -rf ${directory}`
|
||||
}
|
||||
})
|
||||
|
||||
test("serializes migration and updates across processes", async () => {
|
||||
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
|
||||
const file = path.join(directory, "cli.json")
|
||||
const started = path.join(directory, "started")
|
||||
const release = path.join(directory, "release")
|
||||
const migrateReady = path.join(directory, "migrate-ready")
|
||||
const updateReady = path.join(directory, "update-ready")
|
||||
await Bun.write(file, `{"keybinds":{"session_delete":"ctrl+d"}}`)
|
||||
const worker = path.join(import.meta.dir, "fixture/config-concurrency.ts")
|
||||
const migrate = Bun.spawn([process.execPath, worker, "migrate", directory, started, release, migrateReady], {
|
||||
stdout: "ignore",
|
||||
stderr: "pipe",
|
||||
})
|
||||
|
||||
try {
|
||||
await waitForFile(started, migrate.exited)
|
||||
const update = Bun.spawn([process.execPath, worker, "update", directory, started, release, updateReady], {
|
||||
stdout: "ignore",
|
||||
stderr: "pipe",
|
||||
})
|
||||
try {
|
||||
await waitForFile(updateReady, update.exited)
|
||||
expect(await Promise.race([update.exited.then(() => true), Bun.sleep(500).then(() => false)])).toBe(false)
|
||||
await Bun.write(release, "")
|
||||
const [migrateCode, updateCode] = await Promise.all([migrate.exited, update.exited])
|
||||
expect(await new Response(migrate.stderr).text()).toBe("")
|
||||
expect(await new Response(update.stderr).text()).toBe("")
|
||||
expect([migrateCode, updateCode]).toEqual([0, 0])
|
||||
expect(await Bun.file(file).json()).toEqual({ keybinds: { "session.delete": "ctrl+d" }, mouse: false })
|
||||
} finally {
|
||||
update.kill()
|
||||
await update.exited
|
||||
}
|
||||
} finally {
|
||||
await Bun.write(release, "")
|
||||
migrate.kill()
|
||||
await migrate.exited
|
||||
await Bun.$`rm -rf ${directory}`
|
||||
}
|
||||
})
|
||||
|
||||
test("config reads remain interruptible while waiting for the file lock", async () => {
|
||||
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
|
||||
const file = path.join(directory, "cli.json")
|
||||
const locks = path.join(directory, "locks")
|
||||
const held = await Flock.acquire(file, { dir: locks })
|
||||
|
||||
try {
|
||||
const service = await Effect.runPromise(
|
||||
Config.Service.pipe(
|
||||
Effect.provide(Config.layer),
|
||||
Effect.provide(Global.layerWith({ config: directory, state: directory })),
|
||||
Effect.provide(NodeFileSystem.layer),
|
||||
),
|
||||
)
|
||||
const result = Effect.runPromise(service.get().pipe(Effect.timeoutOption("50 millis")))
|
||||
expect(await Promise.race([result, Bun.sleep(250).then(() => "blocked" as const)])).toEqual(Option.none())
|
||||
} finally {
|
||||
await held.release()
|
||||
await Bun.$`rm -rf ${directory}`
|
||||
}
|
||||
})
|
||||
|
||||
test("updates effective duplicate canonical keybinds", async () => {
|
||||
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
|
||||
const file = path.join(directory, "cli.json")
|
||||
await Bun.write(
|
||||
file,
|
||||
`{"keybinds":{"session.delete":"first","session.delete":"last","permission.mode":"off","permission.mode":"on"}}`,
|
||||
)
|
||||
|
||||
try {
|
||||
const config = await run(
|
||||
directory,
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Config.Service
|
||||
expect((yield* service.get()).keybinds).toEqual({ "session.delete": "last", "permission.mode": "on" })
|
||||
return yield* service.update((draft) => {
|
||||
draft.keybinds = { ...draft.keybinds, "session.delete": "changed", "permission.mode": "changed" }
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
expect(config.keybinds).toEqual({ "session.delete": "changed", "permission.mode": "changed" })
|
||||
expect(parse(await Bun.file(file).text()).keybinds).toEqual({
|
||||
"session.delete": "changed",
|
||||
"permission.mode": "changed",
|
||||
})
|
||||
} finally {
|
||||
await Bun.$`rm -rf ${directory}`
|
||||
}
|
||||
})
|
||||
|
||||
test("removes orphaned keybinds without deleting trailing comments", async () => {
|
||||
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
|
||||
const file = path.join(directory, "cli.json")
|
||||
await Bun.write(
|
||||
file,
|
||||
`{
|
||||
"keybinds": {
|
||||
"app_heap_snapshot": "ctrl+h" /* Keep legacy explanation */,
|
||||
"app.heap_snapshot": "ctrl+shift+h" /* Keep canonical explanation */,
|
||||
},
|
||||
}
|
||||
`,
|
||||
)
|
||||
|
||||
try {
|
||||
const config = await run(
|
||||
directory,
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Config.Service
|
||||
return yield* service.get()
|
||||
}),
|
||||
)
|
||||
|
||||
expect(config.keybinds).toEqual({})
|
||||
const text = await Bun.file(file).text()
|
||||
expect(text).toContain("/* Keep legacy explanation */")
|
||||
expect(text).toContain("/* Keep canonical explanation */")
|
||||
expect(parse(text).keybinds).toEqual({})
|
||||
} finally {
|
||||
await Bun.$`rm -rf ${directory}`
|
||||
}
|
||||
})
|
||||
|
||||
test("updates a config draft while preserving JSONC comments", async () => {
|
||||
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
|
||||
await Bun.write(path.join(directory, "cli.json"), '{\n // Keep this comment\n "animations": true\n}\n')
|
||||
@@ -167,3 +439,15 @@ test("updates a config draft while preserving JSONC comments", async () => {
|
||||
await Bun.$`rm -rf ${directory}`
|
||||
}
|
||||
})
|
||||
|
||||
async function waitForFile(file: string, exited: Promise<number>) {
|
||||
const found = await Promise.race([
|
||||
(async () => {
|
||||
while (!(await Bun.file(file).exists())) await Bun.sleep(10)
|
||||
return true
|
||||
})(),
|
||||
exited.then(() => false),
|
||||
Bun.sleep(5000).then(() => false),
|
||||
])
|
||||
if (!found) throw new Error(`timed out waiting for ${file}`)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Effect, FileSystem } from "effect"
|
||||
import { Config } from "../../src/config"
|
||||
|
||||
const [mode, directory, started, release, ready] = process.argv.slice(2)
|
||||
if (!mode || !directory || !started || !release || !ready) throw new Error("missing config concurrency arguments")
|
||||
if (mode !== "migrate" && mode !== "update") throw new Error(`unknown mode: ${mode}`)
|
||||
|
||||
const node = await Effect.runPromise(FileSystem.FileSystem.pipe(Effect.provide(NodeFileSystem.layer)))
|
||||
const state = { writes: 0 }
|
||||
const writeFileString: FileSystem.FileSystem["writeFileString"] = (target, data, options) => {
|
||||
state.writes++
|
||||
if (mode !== "migrate" || state.writes !== 1) return node.writeFileString(target, data, options)
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.promise(() => Bun.write(started, ""))
|
||||
while (!(yield* Effect.promise(() => Bun.file(release).exists()))) yield* Effect.sleep("10 millis")
|
||||
yield* node.writeFileString(target, data, options)
|
||||
})
|
||||
}
|
||||
const fs = new Proxy(node, {
|
||||
get(target, property, receiver) {
|
||||
if (property === "writeFileString") return writeFileString
|
||||
return Reflect.get(target, property, receiver)
|
||||
},
|
||||
})
|
||||
const service = await Effect.runPromise(
|
||||
Config.Service.pipe(
|
||||
Effect.provide(Config.layer),
|
||||
Effect.provide(Global.layerWith({ config: directory, state: directory })),
|
||||
Effect.provideService(FileSystem.FileSystem, fs),
|
||||
),
|
||||
)
|
||||
|
||||
await Bun.write(ready, "")
|
||||
if (mode === "migrate") await Effect.runPromise(service.get())
|
||||
if (mode === "update")
|
||||
await Effect.runPromise(
|
||||
service.update((draft) => {
|
||||
draft.mouse = false
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,69 @@
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Effect, Option } from "effect"
|
||||
import { expect, mock, test } from "bun:test"
|
||||
import { mkdir, rm } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { Config } from "../src/config"
|
||||
import type { MiniCommandInput } from "../src/mini"
|
||||
import { OPENCODE_VERSION } from "../src/version"
|
||||
|
||||
test("mini handler passes resolved CLI keybinds to the runtime", async () => {
|
||||
const root = await Bun.$`mktemp -d`.text().then((value) => value.trim())
|
||||
const configDirectory = path.join(root, "config")
|
||||
const stateDirectory = path.join(root, "state")
|
||||
await mkdir(configDirectory, { recursive: true })
|
||||
await Bun.write(
|
||||
path.join(configDirectory, "cli.json"),
|
||||
JSON.stringify({
|
||||
keybinds: { "composer.subagent.interrupt": "ctrl+i" },
|
||||
leader: { timeout: 321 },
|
||||
}),
|
||||
)
|
||||
let received: MiniCommandInput["tuiConfig"]
|
||||
const mini = await import("../src/mini")
|
||||
mock.module("../src/mini", () => ({
|
||||
...mini,
|
||||
validateMiniTerminal() {},
|
||||
runMini(input: Pick<MiniCommandInput, "tuiConfig">) {
|
||||
received = input.tuiConfig
|
||||
return Promise.resolve()
|
||||
},
|
||||
}))
|
||||
const handler = (await import("../src/commands/handlers/mini")).default
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch: () => Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid }),
|
||||
})
|
||||
|
||||
try {
|
||||
await Effect.runPromise(
|
||||
handler({
|
||||
server: Option.some(server.url.toString()),
|
||||
standalone: false,
|
||||
continue: false,
|
||||
session: Option.none(),
|
||||
fork: false,
|
||||
replay: true as never,
|
||||
replayLimit: Option.none(),
|
||||
model: Option.none(),
|
||||
agent: Option.none(),
|
||||
prompt: Option.none(),
|
||||
demo: false,
|
||||
}).pipe(
|
||||
Effect.provide(Config.layer),
|
||||
Effect.provide(Global.layerWith({ config: configDirectory, state: stateDirectory })),
|
||||
Effect.provide(NodeFileSystem.layer),
|
||||
Effect.scoped,
|
||||
),
|
||||
)
|
||||
|
||||
const config = await received
|
||||
expect(config?.leader.timeout).toBe(321)
|
||||
expect(config?.keybinds.get("composer.subagent.interrupt")).toMatchObject([{ key: "ctrl+i" }])
|
||||
} finally {
|
||||
server.stop(true)
|
||||
mock.restore()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -358,6 +358,15 @@ export type Endpoint5_31Output =
|
||||
readonly previous?: Model.Ref | undefined
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: DateTime.Utc
|
||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||
readonly type: "session.move.admitted"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: { readonly sessionID: Session.ID; readonly move: SessionPending.MoveData }
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: DateTime.Utc
|
||||
@@ -367,6 +376,7 @@ export type Endpoint5_31Output =
|
||||
readonly location?: Location.Ref | undefined
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly moveID?: Event.ID | undefined
|
||||
readonly location: Location.Ref
|
||||
readonly projectID?: Project.ID | undefined
|
||||
readonly subpath?: RelativePath | undefined
|
||||
|
||||
@@ -420,6 +420,8 @@ export type SessionMessageLocationSwitched = {
|
||||
previous?: { location: LocationRef; projectID?: string; subpath?: string }
|
||||
}
|
||||
|
||||
export type SessionPendingMoveData = { location: LocationRef; projectID: string; subpath?: string }
|
||||
|
||||
export type SessionCreated = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -468,7 +470,7 @@ export type SessionMoved = {
|
||||
type: "session.moved"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; location: LocationRef; projectID?: string; subpath?: string }
|
||||
data: { sessionID: string; moveID?: string; location: LocationRef; projectID?: string; subpath?: string }
|
||||
}
|
||||
|
||||
export type SessionRenamed = {
|
||||
@@ -1527,6 +1529,24 @@ export type VcsInfo = { branch: VcsBranch }
|
||||
|
||||
export type PermissionRuleset = Array<PermissionRule>
|
||||
|
||||
export type SessionPendingMove = {
|
||||
id: string
|
||||
sessionID: string
|
||||
timeCreated: number
|
||||
type: "move"
|
||||
data: SessionPendingMoveData
|
||||
}
|
||||
|
||||
export type SessionMoveAdmitted = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.move.admitted"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; move: SessionPendingMoveData }
|
||||
}
|
||||
|
||||
export type SessionInfo = {
|
||||
id: string
|
||||
parentID?: string
|
||||
@@ -1914,7 +1934,11 @@ export type FormFields = [FormField, ...Array<FormField>]
|
||||
|
||||
export type FormFields3 = [FormField1, ...Array<FormField1>]
|
||||
|
||||
export type SessionPendingInfo = SessionPendingUser | SessionPendingSynthetic | SessionPendingCompaction
|
||||
export type SessionPendingInfo =
|
||||
| SessionPendingUser
|
||||
| SessionPendingSynthetic
|
||||
| SessionPendingCompaction
|
||||
| SessionPendingMove
|
||||
|
||||
export type SessionPendingMessage = SessionPendingUserMessage | SessionPendingSyntheticMessage
|
||||
|
||||
@@ -1983,6 +2007,7 @@ export type SessionEventDurable =
|
||||
| SessionCreated
|
||||
| SessionAgentSelected
|
||||
| SessionModelSelected
|
||||
| SessionMoveAdmitted
|
||||
| SessionMoved
|
||||
| SessionRenamed
|
||||
| SessionDeleted
|
||||
@@ -2046,6 +2071,7 @@ export type V2Event =
|
||||
| SessionCreated
|
||||
| SessionAgentSelected
|
||||
| SessionModelSelected
|
||||
| SessionMoveAdmitted
|
||||
| SessionMoved
|
||||
| SessionRenamed
|
||||
| SessionUsageUpdated
|
||||
|
||||
@@ -763,7 +763,9 @@ function apiCallErrorReason(error: APICallError) {
|
||||
if (error.statusCode !== undefined || !error.isRetryable) return reason
|
||||
return new TransportReason({
|
||||
message: reason.message,
|
||||
kind: error.name,
|
||||
transport: "http",
|
||||
operation: "request",
|
||||
code: error.name,
|
||||
url: error.url,
|
||||
http: "http" in reason ? reason.http : undefined,
|
||||
})
|
||||
|
||||
@@ -185,8 +185,8 @@ export interface Interface {
|
||||
) => Effect.Effect<SessionMessage.Info[], NotFoundError | MessageDecodeError>
|
||||
/**
|
||||
* Durable admitted session work not yet visible in projected history,
|
||||
* ordered by admission. Includes unpromoted user and synthetic inputs and
|
||||
* unhandled compaction barriers.
|
||||
* ordered by admission. Includes unpromoted user and synthetic inputs,
|
||||
* unhandled compaction barriers, and deferred moves.
|
||||
*/
|
||||
readonly pending: (sessionID: SessionSchema.ID) => Effect.Effect<SessionPending.Info[], NotFoundError>
|
||||
readonly cancelPending: (input: PendingInputRef) => Effect.Effect<void, NotFoundError | PendingInputConflictError>
|
||||
@@ -738,23 +738,21 @@ const layer = Layer.effect(
|
||||
const info = yield* fs.stat(directory).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (!info) return yield* new DestinationNotFoundError({ directory })
|
||||
if (info.type !== "Directory") return yield* new DestinationNotDirectoryError({ directory })
|
||||
if (current.location.directory === directory && current.location.workspaceID === input.workspaceID) return
|
||||
const pending = yield* SessionPending.move(db, input.sessionID)
|
||||
if (!pending && current.location.directory === directory && current.location.workspaceID === input.workspaceID)
|
||||
return
|
||||
const project = yield* projects.resolve(directory)
|
||||
yield* persistProject(project)
|
||||
if ((yield* execution.active).has(input.sessionID)) {
|
||||
yield* execution.interrupt(input.sessionID)
|
||||
yield* execution.awaitIdle(input.sessionID)
|
||||
}
|
||||
yield* bus.publish(
|
||||
SessionEvent.Moved,
|
||||
{
|
||||
sessionID: input.sessionID,
|
||||
yield* SessionPending.admitMove(db, bus, {
|
||||
sessionID: input.sessionID,
|
||||
source: current.location,
|
||||
data: {
|
||||
location: Location.Ref.make({ directory, workspaceID: input.workspaceID }),
|
||||
projectID: project.id,
|
||||
subpath: RelativePath.make(path.relative(project.directory, directory).replaceAll("\\", "/")),
|
||||
},
|
||||
{ location: current.location },
|
||||
)
|
||||
})
|
||||
yield* execution.wake(input.sessionID)
|
||||
}),
|
||||
compact: Effect.fn("Session.compact")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
|
||||
@@ -11,6 +11,8 @@ import { SessionSchema } from "./schema.js"
|
||||
import { SessionStore } from "./store.js"
|
||||
import { toSessionError } from "./to-session-error.js"
|
||||
import { UserInterruptedError } from "./error.js"
|
||||
import { Database } from "../database/database.js"
|
||||
import { SessionPending } from "./pending.js"
|
||||
|
||||
export interface Interface {
|
||||
/** Snapshots active execution owned by this process. */
|
||||
@@ -45,6 +47,7 @@ export const layer = Layer.effect(
|
||||
const store = yield* SessionStore.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const bus = yield* Bus.Service
|
||||
const db = (yield* Database.Service).db
|
||||
const reportLifecycle = <A>(sessionID: SessionSchema.ID, effect: Effect.Effect<A>) =>
|
||||
effect.pipe(
|
||||
Effect.tapCause((cause) =>
|
||||
@@ -54,7 +57,6 @@ export const layer = Layer.effect(
|
||||
Effect.annotateLogs({ sessionID }),
|
||||
),
|
||||
),
|
||||
Effect.asVoid,
|
||||
)
|
||||
// Write-ahead claim: starting records the durable intent that a turn is in flight, in the same
|
||||
// transaction as the started event. Terminals release it — except shutdown interruption, which
|
||||
@@ -72,7 +74,7 @@ export const layer = Layer.effect(
|
||||
reportLifecycle(
|
||||
sessionID,
|
||||
bus.publish(SessionEvent.Execution.Started, { sessionID }, claimOnCommit(sessionID)),
|
||||
),
|
||||
).pipe(Effect.asVoid),
|
||||
drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, force) {
|
||||
const session = yield* store.get(sessionID)
|
||||
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
|
||||
@@ -91,11 +93,9 @@ export const layer = Layer.effect(
|
||||
sessionID,
|
||||
Effect.gen(function* () {
|
||||
const outcome = terminal(exit, reason)
|
||||
if (outcome.type === "succeeded") {
|
||||
if (outcome.type === "succeeded")
|
||||
yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID }, releaseOnCommit(sessionID))
|
||||
return
|
||||
}
|
||||
if (outcome.type === "interrupted") {
|
||||
if (outcome.type === "interrupted")
|
||||
// A user cancel (or a superseding execution) releases the claim: the turn must not
|
||||
// resurrect at the next boot. Shutdown interruption keeps it for restart continuity.
|
||||
yield* bus.publish(
|
||||
@@ -103,16 +103,27 @@ export const layer = Layer.effect(
|
||||
{ sessionID, reason: outcome.reason },
|
||||
outcome.reason === "shutdown" ? undefined : releaseOnCommit(sessionID),
|
||||
)
|
||||
return
|
||||
}
|
||||
if (outcome.type === "failed")
|
||||
yield* bus.publish(
|
||||
SessionEvent.Execution.Failed,
|
||||
{
|
||||
sessionID,
|
||||
error: outcome.error,
|
||||
},
|
||||
releaseOnCommit(sessionID),
|
||||
)
|
||||
|
||||
if (outcome.type === "interrupted" && outcome.reason === "shutdown") return false
|
||||
const pending = yield* SessionPending.move(db, sessionID)
|
||||
if (!pending) return false
|
||||
const session = yield* store.get(sessionID)
|
||||
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
|
||||
yield* bus.publish(
|
||||
SessionEvent.Execution.Failed,
|
||||
{
|
||||
sessionID,
|
||||
error: outcome.error,
|
||||
},
|
||||
releaseOnCommit(sessionID),
|
||||
SessionEvent.Moved,
|
||||
{ sessionID, moveID: pending.id, ...pending.data },
|
||||
{ location: session.location },
|
||||
)
|
||||
return yield* SessionPending.has(db, sessionID, "any")
|
||||
}),
|
||||
),
|
||||
})
|
||||
@@ -130,7 +141,7 @@ export const layer = Layer.effect(
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [SessionStore.node, LocationServiceMap.node, Bus.node],
|
||||
deps: [SessionStore.node, LocationServiceMap.node, Bus.node, Database.node],
|
||||
})
|
||||
|
||||
/** Low-level compatibility layer for callers that only need durable Session recording. */
|
||||
|
||||
@@ -7,6 +7,8 @@ import { SessionEvent } from "../event.js"
|
||||
import { SessionExecution } from "../execution.js"
|
||||
import { SessionSchema } from "../schema.js"
|
||||
import { SessionStore } from "../store.js"
|
||||
import { Database } from "../../database/database.js"
|
||||
import { SessionPending } from "../pending.js"
|
||||
|
||||
const CONTINUE_AFTER_SERVER_RESTART =
|
||||
"The server restarted while you were working. Continue from where you left off without repeating completed work."
|
||||
@@ -62,6 +64,7 @@ export const layer = (options?: Options) =>
|
||||
const store = yield* SessionStore.Service
|
||||
const execution = yield* SessionExecution.Service
|
||||
const bus = yield* Bus.Service
|
||||
const db = (yield* Database.Service).db
|
||||
const scope = yield* Effect.scope
|
||||
const maxAttempts = options?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS
|
||||
|
||||
@@ -103,6 +106,14 @@ export const layer = (options?: Options) =>
|
||||
// them would only inject a stray continuation into a live turn.
|
||||
const orphaned = (yield* store.listSuspended()).filter((sessionID) => !active.has(sessionID))
|
||||
yield* Effect.forEach(orphaned, resumeOne, { concurrency: "unbounded", discard: true })
|
||||
const claimed = new Set(orphaned)
|
||||
yield* Effect.forEach(
|
||||
(yield* SessionPending.moveSessions(db)).filter(
|
||||
(sessionID) => !active.has(sessionID) && !claimed.has(sessionID),
|
||||
),
|
||||
execution.wake,
|
||||
{ concurrency: "unbounded", discard: true },
|
||||
)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
@@ -111,5 +122,5 @@ export const layer = (options?: Options) =>
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer: layer(),
|
||||
deps: [SessionStore.node, SessionExecution.node, Bus.node],
|
||||
deps: [SessionStore.node, SessionExecution.node, Bus.node, Database.node],
|
||||
})
|
||||
|
||||
@@ -106,6 +106,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
)
|
||||
})
|
||||
},
|
||||
"session.move.admitted": () => Effect.void,
|
||||
"session.renamed": () => Effect.void,
|
||||
"session.deleted": () => Effect.void,
|
||||
"session.forked": () => Effect.void,
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
Delivery,
|
||||
Info,
|
||||
Message,
|
||||
Move,
|
||||
MoveData,
|
||||
Synthetic,
|
||||
SyntheticData,
|
||||
User,
|
||||
@@ -19,10 +21,11 @@ import { SessionEvent } from "./event.js"
|
||||
import { SessionMessage } from "./message.js"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
import { SessionMessageTable, SessionPendingTable } from "./sql.js"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
|
||||
type DatabaseService = Database.Interface["db"]
|
||||
|
||||
export { Compaction, Delivery, Info, Message, Synthetic, SyntheticData, User, UserData }
|
||||
export { Compaction, Delivery, Info, Message, Move, MoveData, Synthetic, SyntheticData, User, UserData }
|
||||
|
||||
/**
|
||||
* Which pending input `promote` may consume: "steer" promotes steers only (a step
|
||||
@@ -35,6 +38,8 @@ const decodeUser = Schema.decodeUnknownSync(UserData)
|
||||
const encodeUser = Schema.encodeSync(UserData)
|
||||
const decodeSynthetic = Schema.decodeUnknownSync(SyntheticData)
|
||||
const encodeSynthetic = Schema.encodeSync(SyntheticData)
|
||||
const decodeMove = Schema.decodeUnknownSync(MoveData)
|
||||
const encodeMove = Schema.encodeSync(MoveData)
|
||||
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
|
||||
const inboxLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
|
||||
type PendingRef = { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID }
|
||||
@@ -42,21 +47,24 @@ type PendingRef = { readonly id: SessionMessage.ID; readonly sessionID: SessionS
|
||||
export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict>()(
|
||||
"SessionPending.LifecycleConflict",
|
||||
{
|
||||
id: SessionMessage.ID,
|
||||
id: Schema.Union([SessionMessage.ID, Event.ID]),
|
||||
},
|
||||
) {}
|
||||
|
||||
const fromRow = (row: typeof SessionPendingTable.$inferSelect): Info => {
|
||||
const base = {
|
||||
id: SessionMessage.ID.make(row.id),
|
||||
sessionID: SessionSchema.ID.make(row.session_id),
|
||||
timeCreated: DateTime.makeUnsafe(row.time_created),
|
||||
}
|
||||
if (row.type === "compaction") return Compaction.make({ ...base, type: "compaction" })
|
||||
if (!row.delivery) throw new LifecycleConflict({ id: base.id })
|
||||
if (row.type === "move")
|
||||
return Move.make({ ...base, id: Event.ID.make(row.id), type: "move", data: decodeMove(row.data) })
|
||||
const id = SessionMessage.ID.make(row.id)
|
||||
if (row.type === "compaction") return Compaction.make({ ...base, id, type: "compaction" })
|
||||
if (!row.delivery) throw new LifecycleConflict({ id })
|
||||
if (row.type === "user")
|
||||
return User.make({
|
||||
...base,
|
||||
id,
|
||||
type: "user",
|
||||
data: decodeUser(row.data),
|
||||
delivery: row.delivery,
|
||||
@@ -64,11 +72,12 @@ const fromRow = (row: typeof SessionPendingTable.$inferSelect): Info => {
|
||||
if (row.type === "synthetic")
|
||||
return Synthetic.make({
|
||||
...base,
|
||||
id,
|
||||
type: "synthetic",
|
||||
data: decodeSynthetic(row.data),
|
||||
delivery: row.delivery,
|
||||
})
|
||||
throw new LifecycleConflict({ id: base.id })
|
||||
throw new LifecycleConflict({ id })
|
||||
}
|
||||
|
||||
export const find = Effect.fn("SessionPending.find")(function* (db: DatabaseService, id: SessionMessage.ID) {
|
||||
@@ -98,6 +107,44 @@ export const compaction = Effect.fn("SessionPending.compaction")(function* (
|
||||
return entry.type === "compaction" ? entry : undefined
|
||||
})
|
||||
|
||||
export const move = Effect.fn("SessionPending.move")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
const row = yield* db
|
||||
.select()
|
||||
.from(SessionPendingTable)
|
||||
.where(and(eq(SessionPendingTable.session_id, sessionID), eq(SessionPendingTable.type, "move")))
|
||||
.orderBy(asc(SessionPendingTable.admitted_seq))
|
||||
.limit(1)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!row) return
|
||||
const entry = fromRow(row)
|
||||
return entry.type === "move" ? entry : undefined
|
||||
})
|
||||
|
||||
export const admitMove = Effect.fn("SessionPending.admitMove")(function* (
|
||||
db: DatabaseService,
|
||||
bus: Bus.Interface,
|
||||
input: { readonly sessionID: SessionSchema.ID; readonly data: MoveData; readonly source: MoveData["location"] },
|
||||
) {
|
||||
return yield* inboxLocks.withLock(input.sessionID)(
|
||||
Effect.gen(function* () {
|
||||
const pending = yield* move(db, input.sessionID)
|
||||
if (pending && JSON.stringify(encodeMove(pending.data)) === JSON.stringify(encodeMove(input.data))) return pending
|
||||
const event = yield* bus.publish(
|
||||
SessionEvent.MoveAdmitted,
|
||||
{
|
||||
sessionID: input.sessionID,
|
||||
move: input.data,
|
||||
},
|
||||
{ location: input.source },
|
||||
)
|
||||
const stored = yield* move(db, input.sessionID)
|
||||
if (stored) return stored
|
||||
return yield* Effect.die(new LifecycleConflict({ id: event.id }))
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const promotedFromMessage = Effect.fn("SessionPending.promotedFromMessage")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
@@ -288,6 +335,35 @@ export const projectCompactionAdmitted = Effect.fn("SessionPending.projectCompac
|
||||
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||
})
|
||||
|
||||
export const projectMoveAdmitted = Effect.fn("SessionPending.projectMoveAdmitted")(function* (
|
||||
db: DatabaseService,
|
||||
input: {
|
||||
readonly admittedSeq: number
|
||||
readonly id: Event.ID
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly data: MoveData
|
||||
readonly timeCreated: DateTime.Utc
|
||||
},
|
||||
) {
|
||||
yield* db
|
||||
.delete(SessionPendingTable)
|
||||
.where(and(eq(SessionPendingTable.session_id, input.sessionID), eq(SessionPendingTable.type, "move")))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(SessionPendingTable)
|
||||
.values({
|
||||
id: input.id,
|
||||
session_id: input.sessionID,
|
||||
type: "move",
|
||||
data: input.data,
|
||||
admitted_seq: input.admittedSeq,
|
||||
time_created: DateTime.toEpochMillis(input.timeCreated),
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
/**
|
||||
* Consume one pending row at promotion. The row's content feeds the projected
|
||||
* message insert inside the same event transaction; the deleted row is what
|
||||
@@ -297,7 +373,8 @@ export const projectPromoted = Effect.fn("SessionPending.projectPromoted")(funct
|
||||
db: DatabaseService,
|
||||
input: PendingRef,
|
||||
) {
|
||||
if (yield* compaction(db, input.sessionID)) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||
if ((yield* compaction(db, input.sessionID)) || (yield* move(db, input.sessionID)))
|
||||
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||
const deleted = yield* db
|
||||
.delete(SessionPendingTable)
|
||||
.where(and(eq(SessionPendingTable.id, input.id), eq(SessionPendingTable.session_id, input.sessionID)))
|
||||
@@ -306,7 +383,8 @@ export const projectPromoted = Effect.fn("SessionPending.projectPromoted")(funct
|
||||
.pipe(Effect.orDie)
|
||||
if (!deleted) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||
const stored = fromRow(deleted)
|
||||
if (stored.type === "compaction") return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||
if (stored.type === "compaction" || stored.type === "move")
|
||||
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
|
||||
return stored
|
||||
})
|
||||
|
||||
@@ -374,6 +452,33 @@ export const settleCompaction = Effect.fn("SessionPending.settleCompaction")(fun
|
||||
return undefined
|
||||
})
|
||||
|
||||
export const settleMove = Effect.fn("SessionPending.settleMove")(function* (
|
||||
db: DatabaseService,
|
||||
input: { readonly sessionID: SessionSchema.ID; readonly id: Event.ID },
|
||||
) {
|
||||
yield* db
|
||||
.delete(SessionPendingTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionPendingTable.id, input.id),
|
||||
eq(SessionPendingTable.session_id, input.sessionID),
|
||||
eq(SessionPendingTable.type, "move"),
|
||||
),
|
||||
)
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
export const moveSessions = Effect.fn("SessionPending.moveSessions")(function* (db: DatabaseService) {
|
||||
const rows = yield* db
|
||||
.select({ sessionID: SessionPendingTable.session_id })
|
||||
.from(SessionPendingTable)
|
||||
.where(eq(SessionPendingTable.type, "move"))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
return [...new Set(rows.map((row) => row.sessionID))]
|
||||
})
|
||||
|
||||
export const list = Effect.fn("SessionPending.list")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
const rows = yield* db
|
||||
.select()
|
||||
@@ -397,7 +502,7 @@ export const has = Effect.fn("SessionPending.has")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
scope: Scope,
|
||||
) {
|
||||
if (scope !== "any" && (yield* compaction(db, sessionID))) return false
|
||||
if (scope !== "any" && ((yield* compaction(db, sessionID)) || (yield* move(db, sessionID)))) return false
|
||||
const row = yield* db
|
||||
.select({ id: SessionPendingTable.id })
|
||||
.from(SessionPendingTable)
|
||||
@@ -473,12 +578,13 @@ const publish = Effect.fn("SessionPending.publish")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
rows: ReadonlyArray<typeof SessionPendingTable.$inferSelect>,
|
||||
) {
|
||||
if (yield* compaction(db, sessionID)) return 0
|
||||
if ((yield* compaction(db, sessionID)) || (yield* move(db, sessionID))) return 0
|
||||
yield* Effect.forEach(
|
||||
rows,
|
||||
(row) => {
|
||||
const entry = fromRow(row)
|
||||
if (entry.type === "compaction") return Effect.die(new LifecycleConflict({ id: entry.id }))
|
||||
if (entry.type === "compaction" || entry.type === "move")
|
||||
return Effect.die(new LifecycleConflict({ id: entry.id }))
|
||||
return bus
|
||||
.publish(SessionEvent.InputPromoted, {
|
||||
sessionID,
|
||||
@@ -512,7 +618,7 @@ export const promote = Effect.fn("SessionPending.promote")(function* (
|
||||
) {
|
||||
return yield* inboxLocks.withLock(sessionID)(
|
||||
Effect.gen(function* () {
|
||||
if (yield* compaction(db, sessionID)) return 0
|
||||
if ((yield* compaction(db, sessionID)) || (yield* move(db, sessionID))) return 0
|
||||
const steers = yield* db
|
||||
.select()
|
||||
.from(SessionPendingTable)
|
||||
|
||||
@@ -433,6 +433,8 @@ const layer = Layer.effectDiscard(
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* InstructionState.reset(db, event.data.sessionID)
|
||||
if (event.data.moveID)
|
||||
yield* SessionPending.settleMove(db, { sessionID: event.data.sessionID, id: event.data.moveID })
|
||||
}),
|
||||
)
|
||||
yield* bus.project(SessionEvent.Deleted, (event) =>
|
||||
@@ -522,6 +524,19 @@ const layer = Layer.effectDiscard(
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
yield* bus.project(SessionEvent.MoveAdmitted, (event) =>
|
||||
Effect.gen(function* () {
|
||||
if (event.durable === undefined)
|
||||
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
|
||||
yield* SessionPending.projectMoveAdmitted(db, {
|
||||
admittedSeq: event.durable.seq,
|
||||
id: event.id,
|
||||
sessionID: event.data.sessionID,
|
||||
data: event.data.move,
|
||||
timeCreated: event.created,
|
||||
})
|
||||
}),
|
||||
)
|
||||
yield* bus.project(SessionEvent.InputCancelled, (event) =>
|
||||
SessionPending.projectCancelled(db, {
|
||||
id: event.data.inputID,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as SessionRunCoordinator from "./run-coordinator.js"
|
||||
|
||||
import { Deferred, Effect, Exit, Fiber, FiberSet, Scope } from "effect"
|
||||
import { Cause, Deferred, Effect, Exit, Fiber, FiberSet, Scope } from "effect"
|
||||
|
||||
/** Serializes execution for each key while allowing different keys to run concurrently. */
|
||||
export interface Coordinator<Key, E, Reason = never> {
|
||||
@@ -50,11 +50,17 @@ export const make = <Key, E, Reason = never>(options: {
|
||||
* Runs in the execution fiber for every exit, including interruption, after the final
|
||||
* drain and before the execution settles (waiters resolve after it completes).
|
||||
*/
|
||||
readonly settled?: (key: Key, exit: Exit.Exit<void, E>, reason?: Reason) => Effect.Effect<void>
|
||||
readonly settled?: (key: Key, exit: Exit.Exit<void, E>, reason?: Reason) => Effect.Effect<boolean | void>
|
||||
}): Effect.Effect<Coordinator<Key, E, Reason>, never, Scope.Scope> =>
|
||||
Effect.gen(function* () {
|
||||
const executions = new Map<Key, Execution<E, Reason>>()
|
||||
const fork = yield* FiberSet.makeRuntime<never, void, never>()
|
||||
const closing = { value: false }
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
closing.value = true
|
||||
}),
|
||||
)
|
||||
|
||||
const loop = (key: Key, execution: Execution<E, Reason>, force: boolean): Effect.Effect<void, E> =>
|
||||
Effect.suspend(() => options.drain(key, force)).pipe(
|
||||
@@ -85,7 +91,22 @@ export const make = <Key, E, Reason = never>(options: {
|
||||
Effect.onExit((exit) =>
|
||||
Effect.sync(() => {
|
||||
execution.owner = undefined
|
||||
}).pipe(Effect.andThen(options.settled?.(key, exit, execution.interruptionReason) ?? Effect.void)),
|
||||
if (closing.value && Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause)) {
|
||||
execution.stopping = true
|
||||
execution.pendingWake = false
|
||||
}
|
||||
}).pipe(
|
||||
Effect.andThen(options.settled?.(key, exit, execution.interruptionReason) ?? Effect.void),
|
||||
Effect.map(Boolean),
|
||||
Effect.tap((restart) =>
|
||||
restart && !execution.stopping
|
||||
? Effect.sync(() => {
|
||||
execution.pendingWake = true
|
||||
})
|
||||
: Effect.void,
|
||||
),
|
||||
Effect.asVoid,
|
||||
),
|
||||
),
|
||||
Effect.onExit((exit) => Effect.sync(() => settle(key, execution, exit))),
|
||||
Effect.exit,
|
||||
|
||||
@@ -143,6 +143,7 @@ const layer = Layer.effect(
|
||||
let promotable: SessionPending.Promotable = "input"
|
||||
let step = 1
|
||||
while (true) {
|
||||
if (yield* SessionPending.move(db, sessionID)) return
|
||||
const result = yield* runStep(sessionID, promotable, step)
|
||||
if (step === 1) yield* startTitle(sessionID)
|
||||
yield* runPendingCompaction(sessionID)
|
||||
@@ -236,6 +237,8 @@ const layer = Layer.effect(
|
||||
// a blocked first step leaves pending inputs untouched.
|
||||
yield* InstructionState.prepare(db, bus, selected.instructions, selected.session.id)
|
||||
const promoted = promotable ? yield* SessionPending.promote(db, bus, selected.session.id, promotable) : 0
|
||||
if (promotable && promoted === 0 && (yield* SessionPending.move(db, sessionID)))
|
||||
return CallOutcome.Completed({ needsContinuation: false, step })
|
||||
// Promoted input opens a fresh step allowance.
|
||||
const currentStep = promoted > 0 ? 1 : step
|
||||
const loaded = yield* context.load(selected)
|
||||
@@ -482,6 +485,7 @@ const layer = Layer.effect(
|
||||
const runPendingCompaction = Effect.fn("SessionRunner.runPendingCompaction")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
if (yield* SessionPending.move(db, sessionID)) return
|
||||
const pending = yield* SessionPending.compaction(db, sessionID)
|
||||
if (!pending) return
|
||||
const session = yield* getSession(sessionID)
|
||||
|
||||
@@ -48,10 +48,12 @@ export const schedule = (
|
||||
assistantMessageID: () => SessionMessage.ID,
|
||||
) =>
|
||||
Schedule.max([Schedule.exponential("2 seconds"), Schedule.recurs(4)]).pipe(
|
||||
Schedule.jittered,
|
||||
Schedule.setInputType<RetryableFailure>(),
|
||||
Schedule.modifyDelay(({ input: failure, duration: delay }) => {
|
||||
const minimum = retryAfter(failure)
|
||||
return Effect.succeed(minimum === undefined ? delay : Duration.max(delay, Duration.millis(minimum)))
|
||||
const duration = minimum === undefined ? delay : Duration.max(delay, Duration.millis(minimum))
|
||||
return Effect.succeed(Duration.millis(Math.ceil(Duration.toMillis(duration))))
|
||||
}),
|
||||
Schedule.tap((metadata) =>
|
||||
bus.publish(SessionEvent.RetryScheduled, {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Message, ToolCallPart, ToolResultPart, type ContentPart, type ProviderMetadata } from "@opencode-ai/ai"
|
||||
import { Option, Schema } from "effect"
|
||||
import { fileURLToPath } from "url"
|
||||
import type { Model } from "../../model.js"
|
||||
import { SessionMessage } from "../message.js"
|
||||
import type { FileAttachment } from "@opencode-ai/schema/prompt"
|
||||
@@ -14,6 +15,17 @@ const media = (file: FileAttachment): ContentPart => ({
|
||||
metadata: file.description === undefined ? undefined : { description: file.description },
|
||||
})
|
||||
|
||||
const attachmentLocation = (file: FileAttachment) => {
|
||||
if (file.source.type !== "uri") return undefined
|
||||
const url = URL.parse(file.source.uri)
|
||||
if (url?.protocol !== "file:") return undefined
|
||||
try {
|
||||
return fileURLToPath(url)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
const textAttachment = (file: FileAttachment): ContentPart => ({
|
||||
type: "text",
|
||||
text: `\n\n${[
|
||||
@@ -36,7 +48,7 @@ const textAttachment = (file: FileAttachment): ContentPart => ({
|
||||
const directoryAttachment = (file: FileAttachment): ContentPart => ({
|
||||
type: "text",
|
||||
text: `\n\n${[
|
||||
`Attached directory: ${file.name ?? (file.source.type === "uri" ? file.source.uri : "directory")}`,
|
||||
`Attached directory: ${attachmentLocation(file) ?? file.name ?? (file.source.type === "uri" ? file.source.uri : "directory")}`,
|
||||
file.description === undefined ? undefined : `Description: ${file.description}`,
|
||||
file.data.length === 0 ? undefined : "",
|
||||
file.data.length === 0 ? undefined : Buffer.from(file.data, "base64").toString("utf8"),
|
||||
@@ -55,7 +67,10 @@ const directoryAttachment = (file: FileAttachment): ContentPart => ({
|
||||
const attachmentContent = (file: FileAttachment): ContentPart[] => {
|
||||
if (file.mime === "text/plain") return [textAttachment(file)]
|
||||
if (file.mime === "application/x-directory") return [directoryAttachment(file)]
|
||||
if (imageMimes.has(file.mime)) return [media(file)]
|
||||
if (imageMimes.has(file.mime)) {
|
||||
const location = attachmentLocation(file)
|
||||
return [...(location === undefined ? [] : [Message.text(`Attached file: ${location}`)]), media(file)]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
|
||||
@@ -96,13 +96,15 @@ export const SessionMessageTable = sqliteTable(
|
||||
export const SessionPendingTable = sqliteTable(
|
||||
"session_pending",
|
||||
{
|
||||
id: text().$type<SessionMessage.ID>().primaryKey(),
|
||||
id: text().$type<SessionPending.Info["id"]>().primaryKey(),
|
||||
session_id: text()
|
||||
.$type<SessionSchema.ID>()
|
||||
.notNull()
|
||||
.references(() => SessionTable.id, { onDelete: "cascade" }),
|
||||
type: text().$type<SessionPending.Info["type"]>().notNull(),
|
||||
data: text({ mode: "json" }).$type<UserData | SyntheticData | Record<string, never>>().notNull(),
|
||||
data: text({ mode: "json" })
|
||||
.$type<UserData | SyntheticData | SessionPending.MoveData | Record<string, never>>()
|
||||
.notNull(),
|
||||
delivery: text().$type<SessionPending.Delivery>(),
|
||||
admitted_seq: integer().notNull(),
|
||||
time_created: integer()
|
||||
|
||||
@@ -49,7 +49,9 @@ const client = LLMClient.layer.pipe(
|
||||
Layer.provide(
|
||||
Layer.succeed(
|
||||
RequestExecutor.Service,
|
||||
RequestExecutor.Service.of({ execute: () => Effect.die("Unexpected HTTP request") }),
|
||||
RequestExecutor.Service.of({
|
||||
execute: () => Effect.die("Unexpected HTTP request"),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -542,7 +544,12 @@ it.effect("retries status-less AI SDK transport failures", () =>
|
||||
isRetryable: true,
|
||||
}),
|
||||
)
|
||||
expect(error.reason).toMatchObject({ _tag: "Transport", kind: "AI_APICallError" })
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "Transport",
|
||||
transport: "http",
|
||||
operation: "request",
|
||||
code: "AI_APICallError",
|
||||
})
|
||||
expect(SessionRunnerRetry.isRetryable(error)).toBeTrue()
|
||||
expect("http" in error.reason ? error.reason.http?.request.url : undefined).toBe("https://api.example.com/chat")
|
||||
}),
|
||||
|
||||
@@ -39,7 +39,9 @@ describe("toSessionError", () => {
|
||||
)
|
||||
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 TransportReason({ message: "transport" }))).type).toBe("provider.transport")
|
||||
expect(
|
||||
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(
|
||||
"provider.internal",
|
||||
)
|
||||
@@ -111,7 +113,7 @@ describe("toSessionError", () => {
|
||||
const eligible = [
|
||||
llm(new RateLimitReason({ message: "rate" })),
|
||||
llm(new ProviderInternalReason({ message: "internal", status: 500 })),
|
||||
llm(new TransportReason({ message: "transport" })),
|
||||
llm(new TransportReason({ message: "transport", transport: "http", operation: "request" })),
|
||||
]
|
||||
const ineligible = [
|
||||
llm(new AuthenticationReason({ message: "auth", kind: "invalid" })),
|
||||
|
||||
@@ -8,7 +8,7 @@ import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import type { LocationServices } from "@opencode-ai/core/location-services"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
|
||||
@@ -17,11 +17,16 @@ import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionRunner } from "@opencode-ai/core/session/runner"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionPending } from "@opencode-ai/core/session/pending"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Context, Deferred, Effect, Exit, Fiber, Layer, LayerMap, Scope } from "effect"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionStore.node])))
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node])),
|
||||
)
|
||||
|
||||
describe("SessionExecution lifecycle", () => {
|
||||
test("classifies success and typed failure terminals", () => {
|
||||
@@ -32,7 +37,7 @@ describe("SessionExecution lifecycle", () => {
|
||||
new AIError({
|
||||
module: "test",
|
||||
method: "stream",
|
||||
reason: new TransportReason({ message: "Disconnected" }),
|
||||
reason: new TransportReason({ message: "Disconnected", transport: "http", operation: "request" }),
|
||||
}),
|
||||
),
|
||||
),
|
||||
@@ -133,6 +138,104 @@ describe("SessionExecution lifecycle", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("applies a deferred move only after the active execution settles", () =>
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const sessionID = Session.ID.make("ses_deferred_move")
|
||||
yield* seedSessions(database, [sessionID])
|
||||
|
||||
const draining = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const scope = yield* Scope.make()
|
||||
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
|
||||
const context = yield* buildExecution(scope, () =>
|
||||
Deferred.succeed(draining, undefined).pipe(Effect.andThen(Deferred.await(release))),
|
||||
)
|
||||
const execution = Context.get(context, SessionExecution.Service)
|
||||
yield* execution.resume(sessionID).pipe(Effect.forkIn(scope))
|
||||
yield* Deferred.await(draining)
|
||||
|
||||
yield* bus.publish(SessionEvent.MoveAdmitted, {
|
||||
sessionID,
|
||||
move: {
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/destination") }),
|
||||
projectID: Project.ID.global,
|
||||
subpath: RelativePath.make(""),
|
||||
},
|
||||
})
|
||||
expect((yield* store.get(sessionID))?.location.directory).toBe(AbsolutePath.make("/project"))
|
||||
expect((yield* SessionPending.move(database.db, sessionID))?.data.location.directory).toBe(
|
||||
AbsolutePath.make("/destination"),
|
||||
)
|
||||
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* execution.awaitIdle(sessionID)
|
||||
|
||||
expect((yield* store.get(sessionID))?.location.directory).toBe(AbsolutePath.make("/destination"))
|
||||
expect(yield* SessionPending.move(database.db, sessionID)).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("settling one move preserves a newer admitted destination", () =>
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const sessionID = Session.ID.make("ses_move_replacement")
|
||||
yield* seedSessions(database, [sessionID])
|
||||
const first = {
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/first") }),
|
||||
projectID: Project.ID.global,
|
||||
subpath: RelativePath.make("first"),
|
||||
}
|
||||
const second = {
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/second") }),
|
||||
projectID: Project.ID.global,
|
||||
subpath: RelativePath.make("second"),
|
||||
}
|
||||
|
||||
const admittedFirst = yield* bus.publish(SessionEvent.MoveAdmitted, { sessionID, move: first })
|
||||
const admittedSecond = yield* bus.publish(SessionEvent.MoveAdmitted, { sessionID, move: second })
|
||||
yield* bus.publish(SessionEvent.Moved, { sessionID, moveID: admittedFirst.id, ...first })
|
||||
|
||||
expect((yield* store.get(sessionID))?.location.directory).toBe(first.location.directory)
|
||||
expect((yield* SessionPending.move(database.db, sessionID))?.id).toBe(admittedSecond.id)
|
||||
|
||||
yield* bus.publish(SessionEvent.Moved, { sessionID, moveID: admittedSecond.id, ...second })
|
||||
expect((yield* store.get(sessionID))?.location.directory).toBe(second.location.directory)
|
||||
expect(yield* SessionPending.move(database.db, sessionID)).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("recovers an unclaimed deferred move on startup", () =>
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const sessionID = Session.ID.make("ses_move_recovery")
|
||||
yield* seedSessions(database, [sessionID])
|
||||
yield* bus.publish(SessionEvent.MoveAdmitted, {
|
||||
sessionID,
|
||||
move: {
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/recovered") }),
|
||||
projectID: Project.ID.global,
|
||||
},
|
||||
})
|
||||
|
||||
const scope = yield* Scope.make()
|
||||
yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void))
|
||||
const context = yield* buildExecution(scope, () => Effect.void)
|
||||
const execution = Context.get(context, SessionExecution.Service)
|
||||
yield* Context.get(context, SessionRestart.Service).resumeSuspendedSessions
|
||||
yield* execution.awaitIdle(sessionID)
|
||||
|
||||
expect((yield* store.get(sessionID))?.location.directory).toBe(AbsolutePath.make("/recovered"))
|
||||
expect(yield* SessionPending.move(database.db, sessionID)).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("starts every claimed execution without waiting for earlier drains to finish", () =>
|
||||
Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
@@ -34,7 +35,7 @@ const it = testEffect(
|
||||
)
|
||||
|
||||
describe("Session.move", () => {
|
||||
it.effect("moves a session whose source directory no longer exists", () =>
|
||||
it.effect("durably admits a move when the source directory no longer exists", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
@@ -49,24 +50,26 @@ describe("Session.move", () => {
|
||||
|
||||
yield* session.move({ sessionID: created.id, directory: destination })
|
||||
|
||||
expect((yield* session.get(created.id)).location.directory).toBe(destination)
|
||||
const messages = yield* session.messages({ sessionID: created.id, order: "asc" })
|
||||
expect(messages).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "location-switched",
|
||||
location: { directory: destination },
|
||||
projectID: Project.ID.global,
|
||||
previous: {
|
||||
location: { directory: path.join(tmp.path, "deleted") },
|
||||
projectID: Project.ID.global,
|
||||
subpath: "",
|
||||
},
|
||||
subpath: "",
|
||||
}),
|
||||
expect((yield* session.get(created.id)).location.directory).toBe(
|
||||
AbsolutePath.make(path.join(tmp.path, "deleted")),
|
||||
)
|
||||
expect(yield* session.pending(created.id)).toMatchObject([
|
||||
{
|
||||
type: "move",
|
||||
data: { location: { directory: destination }, projectID: Project.ID.global },
|
||||
},
|
||||
])
|
||||
|
||||
yield* session.move({ sessionID: created.id, directory: destination })
|
||||
expect(yield* session.messages({ sessionID: created.id, order: "asc" })).toEqual(messages)
|
||||
const replacement = AbsolutePath.make(path.join(tmp.path, "replacement"))
|
||||
yield* Effect.promise(() => fs.mkdir(replacement))
|
||||
yield* session.move({ sessionID: created.id, directory: replacement })
|
||||
|
||||
expect(yield* session.pending(created.id)).toMatchObject([
|
||||
{
|
||||
type: "move",
|
||||
data: { location: { directory: replacement }, projectID: Project.ID.global },
|
||||
},
|
||||
])
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -143,19 +143,26 @@ describe("SessionRunCoordinator", () => {
|
||||
it.effect("cleans active executions when its scope closes", () =>
|
||||
Effect.gen(function* () {
|
||||
const started = yield* Deferred.make<void>()
|
||||
let runs = 0
|
||||
const coordinator = yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const coordinator = yield* SessionRunCoordinator.make({
|
||||
drain: () => Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)),
|
||||
drain: () =>
|
||||
Effect.sync(() => runs++).pipe(
|
||||
Effect.andThen(Deferred.succeed(started, undefined)),
|
||||
Effect.andThen(Effect.never),
|
||||
),
|
||||
})
|
||||
yield* coordinator.wake("session")
|
||||
yield* Deferred.await(started)
|
||||
yield* coordinator.wake("session")
|
||||
expect(Array.from(yield* coordinator.active)).toEqual(["session"])
|
||||
return coordinator
|
||||
}),
|
||||
)
|
||||
|
||||
expect(Array.from(yield* coordinator.active)).toEqual([])
|
||||
expect(runs).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -517,6 +524,31 @@ describe("SessionRunCoordinator", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("starts one successor when settlement requests it", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const successor = yield* Deferred.make<void>()
|
||||
let drains = 0
|
||||
let settlements = 0
|
||||
const coordinator = yield* SessionRunCoordinator.make<string, never>({
|
||||
drain: () =>
|
||||
Effect.sync(() => {
|
||||
drains++
|
||||
if (drains === 2) Deferred.doneUnsafe(successor, Effect.void)
|
||||
}),
|
||||
settled: () => Effect.sync(() => ++settlements === 1),
|
||||
})
|
||||
|
||||
yield* coordinator.wake("session")
|
||||
yield* Deferred.await(successor)
|
||||
yield* coordinator.awaitIdle("session")
|
||||
|
||||
expect(drains).toBe(2)
|
||||
expect(settlements).toBe(2)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("trampolines synchronous self-waking execution", () =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -11,6 +11,8 @@ import { Shell } from "@opencode-ai/schema/shell"
|
||||
import { Location } from "@opencode-ai/schema/location"
|
||||
import { AbsolutePath } from "@opencode-ai/schema/schema"
|
||||
import { DateTime } from "effect"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
|
||||
const created = DateTime.makeUnsafe(0)
|
||||
const id = (value: string) => SessionMessage.ID.make(`msg_${value}`)
|
||||
@@ -267,12 +269,13 @@ Recent work
|
||||
])
|
||||
})
|
||||
|
||||
test("lowers directory attachments as directory context", () => {
|
||||
test("exposes admitted reference directory source paths in model context", () => {
|
||||
const location = path.resolve("/references/harness-engineering")
|
||||
const directory = FileAttachment.make({
|
||||
data: Base64.make(Buffer.from("lib/\nindex.ts").toString("base64")),
|
||||
mime: "application/x-directory",
|
||||
source: { type: "uri", uri: "file:///project/src" },
|
||||
name: "src/",
|
||||
source: { type: "uri", uri: pathToFileURL(location).href },
|
||||
name: "harness-engineering",
|
||||
})
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
@@ -295,14 +298,15 @@ Recent work
|
||||
{ type: "text", text: "Review this directory" },
|
||||
{
|
||||
type: "text",
|
||||
text: "\n\nAttached directory: src/\n\nlib/\nindex.ts",
|
||||
metadata: { attachment: { source: directory.source, name: "src/" } },
|
||||
text: `\n\nAttached directory: ${location}\n\nlib/\nindex.ts`,
|
||||
metadata: { attachment: { source: directory.source, name: "harness-engineering" } },
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("preserves attachment order after the prompt", () => {
|
||||
const directory = path.resolve("/project/src")
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
SessionMessage.User.make({
|
||||
@@ -313,7 +317,7 @@ Recent work
|
||||
FileAttachment.make({
|
||||
data: Base64.make(Buffer.from("index.ts").toString("base64")),
|
||||
mime: "application/x-directory",
|
||||
source: { type: "uri", uri: "file:///project/src" },
|
||||
source: { type: "uri", uri: pathToFileURL(directory).href },
|
||||
name: "src/",
|
||||
}),
|
||||
FileAttachment.make({
|
||||
@@ -332,12 +336,13 @@ Recent work
|
||||
expect(messages).toHaveLength(1)
|
||||
expect(messages[0]?.content.map((part) => (part.type === "text" ? part.text : part.type))).toEqual([
|
||||
"Review these attachments",
|
||||
"\n\nAttached directory: src/\n\nindex.ts",
|
||||
`\n\nAttached directory: ${directory}\n\nindex.ts`,
|
||||
"\n\nAttached file: main.ts\n\nexport const value = 1",
|
||||
])
|
||||
})
|
||||
|
||||
test("omits empty prompt text before an attachment", () => {
|
||||
const directory = path.resolve("/project/src")
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
SessionMessage.User.make({
|
||||
@@ -348,7 +353,7 @@ Recent work
|
||||
FileAttachment.make({
|
||||
data: Base64.make(Buffer.from("index.ts").toString("base64")),
|
||||
mime: "application/x-directory",
|
||||
source: { type: "uri", uri: "file:///project/src" },
|
||||
source: { type: "uri", uri: pathToFileURL(directory).href },
|
||||
name: "src/",
|
||||
}),
|
||||
],
|
||||
@@ -359,7 +364,9 @@ Recent work
|
||||
)
|
||||
|
||||
expect(messages).toHaveLength(1)
|
||||
expect(messages[0]?.content).toMatchObject([{ type: "text", text: "\n\nAttached directory: src/\n\nindex.ts" }])
|
||||
expect(messages[0]?.content).toMatchObject([
|
||||
{ type: "text", text: `\n\nAttached directory: ${directory}\n\nindex.ts` },
|
||||
])
|
||||
})
|
||||
|
||||
test("uses materialized image data as provider media and drops unsupported attachments", () => {
|
||||
@@ -391,6 +398,108 @@ Recent work
|
||||
])
|
||||
})
|
||||
|
||||
test("exposes admitted local image source paths before provider media", () => {
|
||||
const data = Base64.make("AAECAw==")
|
||||
const location = path.resolve("/project/IMG_3480.JPG")
|
||||
const image = FileAttachment.make({
|
||||
data,
|
||||
mime: "image/png",
|
||||
source: { type: "uri", uri: pathToFileURL(location).href },
|
||||
name: "IMG_3480.JPG",
|
||||
})
|
||||
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
SessionMessage.User.make({
|
||||
id: id("user-local-image-path"),
|
||||
type: "user",
|
||||
text: "Inspect this image",
|
||||
files: [image],
|
||||
time: { created },
|
||||
}),
|
||||
],
|
||||
model,
|
||||
)
|
||||
|
||||
expect(messages[0]?.content).toEqual([
|
||||
{ type: "text", text: "Inspect this image" },
|
||||
{ type: "text", text: `Attached file: ${location}` },
|
||||
{ type: "media", mediaType: "image/png", data, filename: "IMG_3480.JPG" },
|
||||
])
|
||||
})
|
||||
|
||||
test("falls back to attachment names for invalid local source paths", () => {
|
||||
const data = Base64.make("AAECAw==")
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
SessionMessage.User.make({
|
||||
id: id("user-invalid-local-paths"),
|
||||
type: "user",
|
||||
text: "Inspect these attachments",
|
||||
files: [
|
||||
FileAttachment.make({
|
||||
data: Base64.make(Buffer.from("index.ts").toString("base64")),
|
||||
mime: "application/x-directory",
|
||||
source: { type: "uri", uri: "file:///project/src%2Flib" },
|
||||
name: "src/",
|
||||
}),
|
||||
FileAttachment.make({
|
||||
data,
|
||||
mime: "image/png",
|
||||
source: { type: "uri", uri: "file:///project/image%2Fpreview.png" },
|
||||
name: "preview.png",
|
||||
}),
|
||||
],
|
||||
time: { created },
|
||||
}),
|
||||
],
|
||||
model,
|
||||
)
|
||||
|
||||
expect(messages[0]?.content).toEqual([
|
||||
{ type: "text", text: "Inspect these attachments" },
|
||||
{
|
||||
type: "text",
|
||||
text: "\n\nAttached directory: src/\n\nindex.ts",
|
||||
metadata: {
|
||||
attachment: {
|
||||
source: { type: "uri", uri: "file:///project/src%2Flib" },
|
||||
name: "src/",
|
||||
},
|
||||
},
|
||||
},
|
||||
{ type: "media", mediaType: "image/png", data, filename: "preview.png" },
|
||||
])
|
||||
})
|
||||
|
||||
test("does not add attachment location text for non-local provider media", () => {
|
||||
const data = Base64.make("AAECAw==")
|
||||
const messages = toLLMMessages(
|
||||
[
|
||||
SessionMessage.User.make({
|
||||
id: id("user-remote-image"),
|
||||
type: "user",
|
||||
text: "Inspect this image",
|
||||
files: [
|
||||
FileAttachment.make({
|
||||
data,
|
||||
mime: "image/png",
|
||||
source: { type: "uri", uri: "https://example.com/image.png" },
|
||||
name: "image.png",
|
||||
}),
|
||||
],
|
||||
time: { created },
|
||||
}),
|
||||
],
|
||||
model,
|
||||
)
|
||||
|
||||
expect(messages[0]?.content).toEqual([
|
||||
{ type: "text", text: "Inspect this image" },
|
||||
{ type: "media", mediaType: "image/png", data, filename: "image.png" },
|
||||
])
|
||||
})
|
||||
|
||||
test("deduplicates provider media while preserving durable attachment references", () => {
|
||||
const data = Base64.make("AAECAw==")
|
||||
const messages = toLLMMessages(
|
||||
@@ -468,7 +577,7 @@ Recent work
|
||||
FileAttachment.make({
|
||||
data,
|
||||
mime: "image/png",
|
||||
source: { type: "uri", uri: "file:///project/image.png" },
|
||||
source: { type: "uri", uri: pathToFileURL(path.resolve("/project/image.png")).href },
|
||||
name: "image.png",
|
||||
mention: { start: 0, end: 9, text: "[Image 1]" },
|
||||
}),
|
||||
|
||||
@@ -515,7 +515,11 @@ const providerUnavailable = () =>
|
||||
new AIError({
|
||||
module: "test",
|
||||
method: "stream",
|
||||
reason: new TransportReason({ message: "Provider unavailable" }),
|
||||
reason: new TransportReason({
|
||||
message: "Provider unavailable",
|
||||
transport: "http",
|
||||
operation: "request",
|
||||
}),
|
||||
})
|
||||
|
||||
const incompleteStream = () =>
|
||||
@@ -3947,7 +3951,7 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retries eligible pre-output failures after exponential backoff", () =>
|
||||
it.effect("bounds jittered exponential backoff for eligible pre-output failures", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* admit(session, "Retry transport")
|
||||
@@ -3956,9 +3960,9 @@ describe("SessionRunnerLLM", () => {
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* TestLLM.wait(1)
|
||||
yield* TestClock.adjust("1999 millis")
|
||||
yield* TestClock.adjust("1599 millis")
|
||||
expect(requests).toHaveLength(1)
|
||||
yield* TestClock.adjust("1 millis")
|
||||
yield* TestClock.adjust("801 millis")
|
||||
yield* Fiber.join(run)
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
@@ -3983,7 +3987,7 @@ describe("SessionRunnerLLM", () => {
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* TestLLM.wait(1)
|
||||
yield* TestClock.adjust("2 seconds")
|
||||
yield* TestClock.adjust("2400 millis")
|
||||
yield* Fiber.join(run)
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
@@ -4028,7 +4032,7 @@ describe("SessionRunnerLLM", () => {
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* TestLLM.wait(1)
|
||||
yield* TestClock.adjust("2 seconds")
|
||||
yield* TestClock.adjust("2400 millis")
|
||||
yield* Fiber.join(run)
|
||||
|
||||
expect(requests).toHaveLength(2)
|
||||
@@ -4085,7 +4089,7 @@ describe("SessionRunnerLLM", () => {
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* TestLLM.wait(1)
|
||||
yield* TestClock.adjust("2 seconds")
|
||||
yield* TestClock.adjust("2400 millis")
|
||||
yield* Fiber.join(run)
|
||||
|
||||
expect(requests[1]?.messages.at(-2)).toMatchObject({
|
||||
@@ -4126,7 +4130,7 @@ describe("SessionRunnerLLM", () => {
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* TestLLM.wait(1)
|
||||
while (!(yield* recordedEventTypes(sessionID)).includes("session.retry.scheduled.1")) yield* Effect.yieldNow
|
||||
yield* TestClock.adjust("2 seconds")
|
||||
yield* TestClock.adjust("2400 millis")
|
||||
yield* Fiber.join(run)
|
||||
|
||||
expect(executions).toEqual(["settled"])
|
||||
@@ -4165,7 +4169,7 @@ describe("SessionRunnerLLM", () => {
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* TestLLM.wait(1)
|
||||
while (!(yield* recordedEventTypes(sessionID)).includes("session.retry.scheduled.1")) yield* Effect.yieldNow
|
||||
yield* TestClock.adjust("2 seconds")
|
||||
yield* TestClock.adjust("2400 millis")
|
||||
yield* Fiber.join(run)
|
||||
|
||||
expect(messageRoles(requests[1])).toEqual(["user", "assistant", "tool", "user"])
|
||||
@@ -4203,7 +4207,7 @@ describe("SessionRunnerLLM", () => {
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* TestLLM.wait(1)
|
||||
for (const [index, delay] of [2_000, 4_000, 8_000, 16_000].entries()) {
|
||||
for (const [index, delay] of [2_400, 4_800, 9_600, 19_200].entries()) {
|
||||
yield* TestClock.adjust(delay)
|
||||
yield* TestLLM.wait(index + 2)
|
||||
}
|
||||
@@ -4224,7 +4228,7 @@ describe("SessionRunnerLLM", () => {
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* TestLLM.wait(1)
|
||||
for (const [index, delay] of [2_000, 4_000, 8_000, 16_000].entries()) {
|
||||
for (const [index, delay] of [2_400, 4_800, 9_600, 19_200].entries()) {
|
||||
yield* TestClock.adjust(delay)
|
||||
yield* TestLLM.wait(index + 2)
|
||||
}
|
||||
@@ -4239,12 +4243,15 @@ describe("SessionRunnerLLM", () => {
|
||||
.orderBy(asc(EventTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
expect(retries.map((event) => event.data)).toMatchObject([
|
||||
{ attempt: 2, at: 2_000 },
|
||||
{ attempt: 3, at: 6_000 },
|
||||
{ attempt: 4, at: 14_000 },
|
||||
{ attempt: 5, at: 30_000 },
|
||||
])
|
||||
for (const [index, range] of [
|
||||
[1_600, 2_400],
|
||||
[4_800, 7_200],
|
||||
[11_200, 16_800],
|
||||
[24_000, 36_000],
|
||||
].entries()) {
|
||||
expect(retries[index]?.data.at).toBeGreaterThanOrEqual(range[0]!)
|
||||
expect(retries[index]?.data.at).toBeLessThanOrEqual(range[1]!)
|
||||
}
|
||||
expect((yield* recordedEventTypes(sessionID)).filter((type) => type === "session.step.started.1")).toHaveLength(5)
|
||||
const assistant = requireAssistant(yield* session.context(sessionID))
|
||||
expect(yield* recordedStepSettlementEvents(sessionID, assistant.id)).toMatchObject([
|
||||
@@ -4274,7 +4281,7 @@ describe("SessionRunnerLLM", () => {
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* TestLLM.wait(1)
|
||||
yield* TestClock.adjust("2 seconds")
|
||||
yield* TestClock.adjust("2400 millis")
|
||||
yield* Fiber.join(run)
|
||||
|
||||
expect(requests).toHaveLength(3)
|
||||
|
||||
@@ -13083,6 +13083,9 @@
|
||||
},
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["id", "time", "type", "text"],
|
||||
|
||||
@@ -308,7 +308,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.move",
|
||||
summary: "Move session",
|
||||
description: "Move a session to another project directory, optionally transferring local changes.",
|
||||
description: "Move a session to another project directory after any active execution settles.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -90,6 +90,7 @@ export const Moved = Event.durable({
|
||||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
moveID: Event.ID.pipe(optional),
|
||||
location: Location.Ref,
|
||||
projectID: Project.ID.pipe(optional),
|
||||
subpath: RelativePath.pipe(optional),
|
||||
@@ -97,6 +98,16 @@ export const Moved = Event.durable({
|
||||
})
|
||||
export type Moved = typeof Moved.Type
|
||||
|
||||
export const MoveAdmitted = Event.durable({
|
||||
type: "session.move.admitted",
|
||||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
move: SessionPending.MoveData,
|
||||
},
|
||||
})
|
||||
export type MoveAdmitted = typeof MoveAdmitted.Type
|
||||
|
||||
export const Renamed = Event.durable({
|
||||
type: "session.renamed",
|
||||
...options,
|
||||
@@ -597,6 +608,7 @@ export const Definitions = Event.inventory(
|
||||
Created,
|
||||
AgentSelected,
|
||||
ModelSelected,
|
||||
MoveAdmitted,
|
||||
Moved,
|
||||
Renamed,
|
||||
UsageUpdated,
|
||||
|
||||
@@ -7,6 +7,10 @@ import { DateTimeUtcFromMillis } from "./schema.js"
|
||||
import { SessionDelivery } from "./session-delivery.js"
|
||||
import { SessionID } from "./session-id.js"
|
||||
import { SessionMessage } from "./session-message.js"
|
||||
import { Event } from "./event.js"
|
||||
import { Location } from "./location.js"
|
||||
import { Project } from "./project.js"
|
||||
import { RelativePath } from "./schema.js"
|
||||
|
||||
export const Delivery = SessionDelivery.Delivery
|
||||
export type Delivery = SessionDelivery.Delivery
|
||||
@@ -68,7 +72,23 @@ export const Compaction = Schema.Struct({
|
||||
type: Schema.tag("compaction"),
|
||||
}).annotate({ identifier: "SessionPending.Compaction" })
|
||||
|
||||
export const Info = Schema.Union([User, Synthetic, Compaction]).pipe(
|
||||
export interface MoveData extends Schema.Schema.Type<typeof MoveData> {}
|
||||
export const MoveData = Schema.Struct({
|
||||
location: Location.Ref,
|
||||
projectID: Project.ID,
|
||||
subpath: RelativePath.pipe(optional),
|
||||
}).annotate({ identifier: "SessionPending.MoveData" })
|
||||
|
||||
export interface Move extends Schema.Schema.Type<typeof Move> {}
|
||||
export const Move = Schema.Struct({
|
||||
id: Event.ID,
|
||||
sessionID: SessionID,
|
||||
timeCreated: DateTimeUtcFromMillis,
|
||||
type: Schema.tag("move"),
|
||||
data: MoveData,
|
||||
}).annotate({ identifier: "SessionPending.Move" })
|
||||
|
||||
export const Info = Schema.Union([User, Synthetic, Compaction, Move]).pipe(
|
||||
Schema.toTaggedUnion("type"),
|
||||
Schema.annotate({ identifier: "SessionPending.Info" }),
|
||||
)
|
||||
|
||||
@@ -90,6 +90,16 @@ describe("contract hygiene", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("pending moves omit absent placement details", () => {
|
||||
expect(
|
||||
Schema.encodeSync(SessionPending.MoveData)({
|
||||
location: { directory: AbsolutePath.make("/project"), workspaceID: undefined },
|
||||
projectID: Project.ID.global,
|
||||
subpath: undefined,
|
||||
}),
|
||||
).toEqual({ location: { directory: "/project" }, projectID: "global" })
|
||||
})
|
||||
|
||||
test("forms require at least one field", () => {
|
||||
expect(() =>
|
||||
Schema.decodeUnknownSync(Form.Info)({
|
||||
|
||||
@@ -78,6 +78,7 @@ describe("public event manifest", () => {
|
||||
"session.deleted.2",
|
||||
"session.agent.selected.1",
|
||||
"session.model.selected.1",
|
||||
"session.move.admitted.1",
|
||||
"session.moved.1",
|
||||
"session.renamed.1",
|
||||
"session.usage.recorded.1",
|
||||
|
||||
@@ -7,15 +7,6 @@ import { isAllowedCorsOrigin } from "./cors"
|
||||
import { createRoutes } from "./routes"
|
||||
import type { ServerOptions } from "./options"
|
||||
|
||||
export interface BootOptions {
|
||||
/**
|
||||
* Resumes execution-journaled Sessions once the application layer boots. Pair with
|
||||
* `SessionExecution.configured({ suspendOnStart: true })` on runtimes that can die without
|
||||
* teardown, so turns orphaned by a hard death replay on the next boot.
|
||||
*/
|
||||
readonly resumeSuspendedSessions?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a web-standard fetch handler — `(request: Request) => Promise<Response>` — serving the
|
||||
* same HttpApi routes as the Node server process without binding a port, owning a listener, or
|
||||
@@ -32,13 +23,17 @@ export interface BootOptions {
|
||||
* Auth follows `createRoutes` semantics: `options.password` enforces Basic auth; omitting it
|
||||
* serves unauthenticated, so an embedder without a password must front the handler with its own
|
||||
* access control.
|
||||
*
|
||||
* Sessions whose execution claim was never released resume once the layer is built, exactly as
|
||||
* the Node server process does: a runtime that dies without teardown — an evicted Durable
|
||||
* Object leaves the same durable signature as a killed process — replays orphaned turns on the
|
||||
* next boot, and the sweep is a no-op when nothing is suspended.
|
||||
*/
|
||||
export const make = Effect.fn("ServerFetch.make")(function* (options: ServerOptions = {}, boot: BootOptions = {}) {
|
||||
export const make = Effect.fn("ServerFetch.make")(function* (options: ServerOptions = {}) {
|
||||
const context = yield* Layer.build(createRoutes(options, () => []).pipe(Layer.provide(HttpServer.layerServices)))
|
||||
// Forked so the returned handler is never delayed; resumed drains are already
|
||||
// logged and durably recorded by the execution layer.
|
||||
if (boot.resumeSuspendedSessions)
|
||||
yield* Effect.forkDetach(Context.get(context, SessionRestart.Service).resumeSuspendedSessions)
|
||||
yield* Effect.forkDetach(Context.get(context, SessionRestart.Service).resumeSuspendedSessions)
|
||||
return Context.get(context, HttpRouter.HttpRouter)
|
||||
.asHttpEffect()
|
||||
.pipe(
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
TuiStartupProvider,
|
||||
TuiTerminalEnvironmentProvider,
|
||||
useTuiApp,
|
||||
useTuiPaths,
|
||||
useTuiStartup,
|
||||
type TuiApp,
|
||||
} from "./context/runtime"
|
||||
@@ -85,6 +86,7 @@ import { ArgsProvider, useArgs, type Args } from "./context/args"
|
||||
import open from "open"
|
||||
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
||||
import { Config, ConfigProvider, useConfig } from "./config"
|
||||
import { newSessionLocation } from "./config/new-session-location"
|
||||
import { PluginProvider, usePlugin, type PackageResolver } from "./plugin/context"
|
||||
import { tuiPluginDirectories } from "./plugin/discovery"
|
||||
import { PluginRoute, Slot } from "./plugin/render"
|
||||
@@ -148,6 +150,7 @@ const appBindingCommands = [
|
||||
"variant.cycle",
|
||||
"variant.list",
|
||||
"provider.connect",
|
||||
"opencode.settings",
|
||||
"opencode.status",
|
||||
"server.pair",
|
||||
"service.restart",
|
||||
@@ -166,6 +169,7 @@ const appBindingCommands = [
|
||||
"app.toggle.file_context",
|
||||
"app.toggle.diffwrap",
|
||||
"app.toggle.paste_summary",
|
||||
"permission.mode",
|
||||
] as const
|
||||
|
||||
export type TuiInput = {
|
||||
@@ -453,6 +457,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
const log = useLog({ component: "app" })
|
||||
const app = useTuiApp()
|
||||
const startup = useTuiStartup()
|
||||
const paths = useTuiPaths()
|
||||
const config = useConfig()
|
||||
const devtools = createMemo(() => config.data.debug?.devtools ?? app.channel === "local")
|
||||
const route = useRoute()
|
||||
@@ -659,10 +664,13 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
run: () => {
|
||||
route.navigate({
|
||||
type: "home",
|
||||
location:
|
||||
location: newSessionLocation(
|
||||
config.data.session.new_location,
|
||||
paths.cwd,
|
||||
route.data.type === "session"
|
||||
? (data.session.get(route.data.sessionID)?.location ?? location.ref)
|
||||
: undefined,
|
||||
),
|
||||
})
|
||||
dialog.clear()
|
||||
},
|
||||
|
||||
@@ -384,16 +384,18 @@ export function DevToolsBar() {
|
||||
>
|
||||
{turnTokens() ? "[x]" : "[ ]"} Turn token usage
|
||||
</Action>
|
||||
<Action
|
||||
onClick={() =>
|
||||
void config.update((draft) => {
|
||||
draft.debug = { ...draft.debug, turn_tokens: verboseTurnTokens() ? true : "verbose" }
|
||||
})
|
||||
}
|
||||
hoverBackground
|
||||
>
|
||||
{verboseTurnTokens() ? "[x]" : "[ ]"} Turn token usage (verbose)
|
||||
</Action>
|
||||
<Show when={Boolean(turnTokens())}>
|
||||
<Action
|
||||
onClick={() =>
|
||||
void config.update((draft) => {
|
||||
draft.debug = { ...draft.debug, turn_tokens: verboseTurnTokens() ? true : "verbose" }
|
||||
})
|
||||
}
|
||||
hoverBackground
|
||||
>
|
||||
{verboseTurnTokens() ? "[x]" : "[ ]"} Turn token usage (verbose)
|
||||
</Action>
|
||||
</Show>
|
||||
</box>
|
||||
<For each={groups()}>
|
||||
{(group) => (
|
||||
|
||||
@@ -93,6 +93,15 @@ export const settings: Setting[] = [
|
||||
labels: ["off", "on"],
|
||||
keywords: ["attachments", "images", "tool output"],
|
||||
},
|
||||
{
|
||||
title: "New session location",
|
||||
category: "Session",
|
||||
path: ["session", "new_location"],
|
||||
default: "launch",
|
||||
values: ["launch", "inherit"],
|
||||
labels: ["launch directory", "active session"],
|
||||
keywords: ["directory", "cwd", "inherit"],
|
||||
},
|
||||
{
|
||||
title: "Enabled",
|
||||
category: "Tabs",
|
||||
|
||||
@@ -27,6 +27,8 @@ import { marqueeText } from "../util/marquee"
|
||||
|
||||
// A long title fades out over its last cells instead of cutting hard.
|
||||
const FADE_WIDTH = 4
|
||||
// The add button renders as " + " at the end of the strip, so the tab layout leaves it room.
|
||||
const ADD_TAB_WIDTH = 3
|
||||
const MARQUEE_DELAY = 600
|
||||
const MARQUEE_INTERVAL = 100
|
||||
|
||||
@@ -42,6 +44,7 @@ export const EMPTY_SESSION_TAB_STATUS: SessionTabsStatus = {
|
||||
}
|
||||
export type SessionTabsController = Pick<ContextController, "tabs" | "current" | "select" | "close" | "move"> & {
|
||||
newTab?: () => boolean
|
||||
add?: () => void
|
||||
status(sessionID: string): SessionTabsStatus
|
||||
}
|
||||
|
||||
@@ -103,22 +106,23 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
const separatorUpperPulseColor = createMemo(() => tint(theme.background.default, theme.text.default, 0.04))
|
||||
const separatorLowerPulseColor = createMemo(() => tint(theme.background.default, theme.text.default, 0.05))
|
||||
const [hovered, setHovered] = createSignal<string>()
|
||||
const [addHovered, setAddHovered] = createSignal(false)
|
||||
const marquee = createMarquee(hovered, animations)
|
||||
const [dragging, setDragging] = createSignal<string>()
|
||||
const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>()
|
||||
const newTab = () => tabs.newTab?.() ?? false
|
||||
const activeID = createMemo(() => (newTab() ? NEW_SESSION_TAB.sessionID : tabs.current()))
|
||||
const activeID = createMemo(() => (newTab() ? undefined : tabs.current()))
|
||||
const ordered = createMemo(() => {
|
||||
const pending = preview()
|
||||
if (!pending) return tabs.tabs()
|
||||
return moveSessionTab(tabs.tabs(), pending.sessionID, pending.index)
|
||||
})
|
||||
const items = createMemo(() => (newTab() ? [...ordered(), NEW_SESSION_TAB] : ordered()))
|
||||
const items = ordered
|
||||
const statuses = createMemo(
|
||||
() =>
|
||||
new Map(
|
||||
items().map((tab) => {
|
||||
const status = tab === NEW_SESSION_TAB ? EMPTY_SESSION_TAB_STATUS : tabs.status(tab.sessionID)
|
||||
const status = tabs.status(tab.sessionID)
|
||||
return [
|
||||
tab.sessionID,
|
||||
{
|
||||
@@ -145,6 +149,8 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
|
||||
createEffect(() => {
|
||||
if (!scroll) return
|
||||
// The promoted new-session slot sits below the list, so bring the rail's bottom into view.
|
||||
if (newTab()) return scroll.scrollTo(Math.max(0, items().length * 3 + 1 - scroll.viewport.height))
|
||||
const index = items().findIndex((tab) => tab.sessionID === activeID())
|
||||
if (index === -1) return
|
||||
const top = index * 3
|
||||
@@ -171,7 +177,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
const selected = () => activeID() === tab.sessionID
|
||||
const status = createMemo(() => itemStatus(tab))
|
||||
const [sweepLevel, setSweepLevel] = createSignal(0)
|
||||
const session = createMemo(() => (tab === NEW_SESSION_TAB ? undefined : data.session.get(tab.sessionID)))
|
||||
const session = createMemo(() => data.session.get(tab.sessionID))
|
||||
const project = createMemo(() => {
|
||||
const value = session()
|
||||
return value ? data.project.get(value.projectID) : undefined
|
||||
@@ -188,7 +194,6 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle()))
|
||||
const titleFades = createMemo(() => stringWidth(title()) >= titleWidth() && titleWidth() > FADE_WIDTH)
|
||||
const detail = createMemo(() => {
|
||||
if (tab === NEW_SESSION_TAB) return Locale.takeWidth("Start a new session", titleWidth())
|
||||
const value = session()
|
||||
return Locale.takeWidth(projectName(project(), value?.location.directory) ?? "", titleWidth())
|
||||
})
|
||||
@@ -260,7 +265,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
setDragging(undefined)
|
||||
const pending = preview()
|
||||
if (pending?.sessionID === tab.sessionID) tabs.move(pending.sessionID, pending.index)
|
||||
if (tab !== NEW_SESSION_TAB) tabs.select(tab.sessionID)
|
||||
tabs.select(tab.sessionID)
|
||||
}
|
||||
return (
|
||||
<box
|
||||
@@ -277,7 +282,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
}}
|
||||
onMouseUp={release}
|
||||
onMouseDrag={(event) => {
|
||||
if (!rail || tab === NEW_SESSION_TAB) return
|
||||
if (!rail) return
|
||||
const target = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
@@ -386,7 +391,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
onMouseUp={(event) => {
|
||||
if (hovered() !== tab.sessionID) return
|
||||
event.stopPropagation()
|
||||
tabs.close(tab === NEW_SESSION_TAB ? undefined : tab.sessionID)
|
||||
tabs.close(tab.sessionID)
|
||||
}}
|
||||
>
|
||||
{hovered() === tab.sessionID ? "×" : ""}
|
||||
@@ -417,6 +422,63 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
{/* One slot with two states: a subdued affordance that promotes in place into the
|
||||
active new-session tab, instead of spawning a separate pseudo tab above itself. */}
|
||||
<Show when={tabs.add || newTab()}>
|
||||
<box
|
||||
height={1}
|
||||
width="100%"
|
||||
position="relative"
|
||||
flexDirection="row"
|
||||
paddingLeft={1}
|
||||
backgroundColor={
|
||||
newTab()
|
||||
? theme.background.action.primary.selected
|
||||
: addHovered()
|
||||
? theme.background.action.primary.hovered
|
||||
: theme.background.default
|
||||
}
|
||||
onMouseOver={() => setAddHovered(true)}
|
||||
onMouseOut={() => setAddHovered(false)}
|
||||
onMouseUp={() => {
|
||||
if (!newTab()) tabs.add?.()
|
||||
}}
|
||||
>
|
||||
<text
|
||||
width={2}
|
||||
fg={newTab() ? activeNumber() : addHovered() ? theme.text.default : idleNumber()}
|
||||
selectable={false}
|
||||
attributes={newTab() ? TextAttributes.BOLD : undefined}
|
||||
>
|
||||
+
|
||||
</text>
|
||||
<text
|
||||
fg={newTab() || addHovered() ? theme.text.default : theme.text.subdued}
|
||||
wrapMode="none"
|
||||
selectable={false}
|
||||
attributes={newTab() ? TextAttributes.BOLD : undefined}
|
||||
>
|
||||
{NEW_SESSION_TAB_TITLE}
|
||||
</text>
|
||||
<Show when={newTab()}>
|
||||
<text
|
||||
position="absolute"
|
||||
right={1}
|
||||
zIndex={2}
|
||||
width={1}
|
||||
fg={theme.text.subdued}
|
||||
selectable={false}
|
||||
onMouseUp={(event) => {
|
||||
if (!addHovered()) return
|
||||
event.stopPropagation()
|
||||
tabs.close()
|
||||
}}
|
||||
>
|
||||
{addHovered() ? "×" : ""}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</scrollbox>
|
||||
</box>
|
||||
@@ -431,6 +493,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
const config = useConfig().data
|
||||
const animations = () => props.animations ?? config.animations ?? true
|
||||
const [hovered, setHovered] = createSignal<string>()
|
||||
const [addHovered, setAddHovered] = createSignal(false)
|
||||
const marquee = createMarquee(hovered, animations)
|
||||
const [dragging, setDragging] = createSignal<string>()
|
||||
// A drag reorders a local preview and persists one move on release instead of writing
|
||||
@@ -449,7 +512,10 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
if (!pending) return tabs.tabs()
|
||||
return moveSessionTab(tabs.tabs(), pending.sessionID, pending.index)
|
||||
})
|
||||
// The promoted new-session slot joins the strip as the active tab; the idle plus affordance
|
||||
// and the promoted slot are mutually exclusive states of one control.
|
||||
const items = createMemo(() => (newTab() ? [...ordered(), NEW_SESSION_TAB] : ordered()))
|
||||
const showPlus = () => Boolean(tabs.add) && !newTab()
|
||||
createEffect(() => {
|
||||
const pending = preview()
|
||||
if (!pending || dragging()) return
|
||||
@@ -457,7 +523,12 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
if (index === -1 || index === Math.min(pending.index, tabs.tabs().length - 1)) setPreview(undefined)
|
||||
})
|
||||
const layout = createMemo((previous: ReturnType<typeof adaptiveSessionTabLayout> | undefined) =>
|
||||
adaptiveSessionTabLayout(items(), activeID(), dimensions().width, previous?.start),
|
||||
adaptiveSessionTabLayout(
|
||||
items(),
|
||||
activeID(),
|
||||
dimensions().width - (showPlus() ? ADD_TAB_WIDTH : 0),
|
||||
previous?.start,
|
||||
),
|
||||
)
|
||||
const statuses = createMemo(
|
||||
() =>
|
||||
@@ -704,7 +775,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
{" "}
|
||||
</text>
|
||||
<text width={numberWidth()} fg={numberColor()} selectable={false} attributes={bold()}>
|
||||
{sessionTabShortcutLabel(tabNumber() - 1)}
|
||||
{tab === NEW_SESSION_TAB ? "+" : sessionTabShortcutLabel(tabNumber() - 1)}
|
||||
</text>
|
||||
<text
|
||||
width={availableTitleWidth()}
|
||||
@@ -746,6 +817,19 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
{" " + layout().after}›
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={showPlus()}>
|
||||
<text
|
||||
width={ADD_TAB_WIDTH}
|
||||
fg={addHovered() ? theme.text.default : theme.text.subdued}
|
||||
bg={addHovered() ? theme.background.action.primary.hovered : undefined}
|
||||
selectable={false}
|
||||
onMouseOver={() => setAddHovered(true)}
|
||||
onMouseOut={() => setAddHovered(false)}
|
||||
onMouseUp={() => tabs.add?.()}
|
||||
>
|
||||
{" + "}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -137,6 +137,9 @@ export const Info = Schema.Struct({
|
||||
markdown: Schema.optional(Schema.Literals(["source", "rendered"])).annotate({
|
||||
description: "Show Markdown syntax markers or conceal them in rendered transcript content",
|
||||
}),
|
||||
new_location: Schema.optional(Schema.Literals(["launch", "inherit"])).annotate({
|
||||
description: "Start new sessions in the TUI launch directory or inherit the active session location",
|
||||
}),
|
||||
}),
|
||||
).annotate({ description: "Session transcript presentation settings" }),
|
||||
tabs: Schema.optional(
|
||||
@@ -202,7 +205,7 @@ export const Info = Schema.Struct({
|
||||
})
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
|
||||
export type Resolved = Omit<Info, "attention" | "cursor" | "keybinds" | "leader" | "mouse" | "tabs"> & {
|
||||
export type Resolved = Omit<Info, "attention" | "cursor" | "keybinds" | "leader" | "mouse" | "session" | "tabs"> & {
|
||||
attention: {
|
||||
enabled: boolean
|
||||
notifications: boolean
|
||||
@@ -218,6 +221,9 @@ export type Resolved = Omit<Info, "attention" | "cursor" | "keybinds" | "leader"
|
||||
style: "block" | "underline" | "line" | "default"
|
||||
blinking: boolean
|
||||
}
|
||||
session: Omit<NonNullable<Info["session"]>, "new_location"> & {
|
||||
new_location: "launch" | "inherit"
|
||||
}
|
||||
tabs: {
|
||||
enabled: boolean
|
||||
scope: "global" | "cwd"
|
||||
@@ -228,10 +234,10 @@ export type Resolved = Omit<Info, "attention" | "cursor" | "keybinds" | "leader"
|
||||
export function resolve(input: Info, options: { terminalSuspend: boolean }): Resolved {
|
||||
const keybinds: TuiKeybind.KeybindOverrides = { ...input.keybinds }
|
||||
if (!options.terminalSuspend) {
|
||||
keybinds.terminal_suspend = "none"
|
||||
if (keybinds.input_undo === undefined) {
|
||||
const inputUndo = TuiKeybind.defaultValue("input_undo")
|
||||
keybinds.input_undo = ["ctrl+z", ...(typeof inputUndo === "string" ? inputUndo.split(",") : [])]
|
||||
keybinds["terminal.suspend"] = "none"
|
||||
if (keybinds["input.undo"] === undefined) {
|
||||
const inputUndo = TuiKeybind.defaultValue("input.undo")
|
||||
keybinds["input.undo"] = ["ctrl+z", ...(typeof inputUndo === "string" ? inputUndo.split(",") : [])]
|
||||
.filter((value, index, values) => values.indexOf(value) === index)
|
||||
.join(",")
|
||||
}
|
||||
@@ -248,7 +254,6 @@ export function resolve(input: Info, options: { terminalSuspend: boolean }): Res
|
||||
sounds: input.attention?.sounds ?? {},
|
||||
},
|
||||
keybinds: createBindingLookup(TuiKeybind.toBindingConfig(TuiKeybind.parse(keybinds)), {
|
||||
commandMap: TuiKeybind.CommandMap,
|
||||
bindingDefaults: TuiKeybind.bindingDefaults(),
|
||||
}),
|
||||
leader: { timeout: input.leader?.timeout ?? 2000 },
|
||||
@@ -259,6 +264,10 @@ export function resolve(input: Info, options: { terminalSuspend: boolean }): Res
|
||||
blinking: input.cursor.blinking ?? true,
|
||||
}
|
||||
: undefined,
|
||||
session: {
|
||||
...input.session,
|
||||
new_location: input.session?.new_location ?? "launch",
|
||||
},
|
||||
tabs: {
|
||||
...input.tabs,
|
||||
enabled: input.tabs?.enabled ?? true,
|
||||
|
||||
@@ -1,2 +1,332 @@
|
||||
export * from "./v1/keybind"
|
||||
export * as TuiKeybind from "./v1/keybind"
|
||||
export * as TuiKeybind from "./keybind"
|
||||
|
||||
import type { KeyEvent, Renderable } from "@opentui/core"
|
||||
import type { Binding } from "@opentui/keymap"
|
||||
import type { BindingConfig, BindingDefaults } from "@opentui/keymap/extras"
|
||||
import { Schema } from "effect"
|
||||
|
||||
const KeyStroke = Schema.Struct({
|
||||
name: Schema.String,
|
||||
ctrl: Schema.optional(Schema.Boolean),
|
||||
shift: Schema.optional(Schema.Boolean),
|
||||
meta: Schema.optional(Schema.Boolean),
|
||||
super: Schema.optional(Schema.Boolean),
|
||||
hyper: Schema.optional(Schema.Boolean),
|
||||
})
|
||||
|
||||
const BindingObject = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
key: Schema.Union([Schema.String, KeyStroke]),
|
||||
event: Schema.optional(Schema.Literals(["press", "release"])),
|
||||
preventDefault: Schema.optional(Schema.Boolean),
|
||||
fallthrough: Schema.optional(Schema.Boolean),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
|
||||
const BindingItem = Schema.Union([Schema.String, KeyStroke, BindingObject])
|
||||
export const BindingValueSchema = Schema.Union([
|
||||
Schema.Literal(false),
|
||||
Schema.Literal("none"),
|
||||
BindingItem,
|
||||
Schema.Array(BindingItem),
|
||||
])
|
||||
export type BindingValueSchema = Schema.Schema.Type<typeof BindingValueSchema>
|
||||
|
||||
type Definition = {
|
||||
default: BindingValueSchema
|
||||
description: string
|
||||
}
|
||||
|
||||
export const LeaderDefault = "ctrl+x"
|
||||
|
||||
const keybind = (value: Definition["default"], description: string): Definition => ({ default: value, description })
|
||||
|
||||
export const Definitions = {
|
||||
leader: keybind(LeaderDefault, "Leader key for keybind combinations"),
|
||||
|
||||
"app.exit": keybind("ctrl+c,ctrl+d,<leader>q", "Exit the application"),
|
||||
"app.debug": keybind("none", "Toggle debug panel"),
|
||||
"app.console": keybind("none", "Toggle console"),
|
||||
"app.scrap": keybind("none", "Open scrap screen"),
|
||||
"app.toggle.animations": keybind("none", "Toggle animations"),
|
||||
"app.toggle.file_context": keybind("none", "Toggle file context"),
|
||||
"app.toggle.diffwrap": keybind("none", "Toggle diff wrapping"),
|
||||
"app.toggle.paste_summary": keybind("none", "Toggle paste summary"),
|
||||
"command.palette.show": keybind("ctrl+p", "List available commands"),
|
||||
"help.show": keybind("none", "Open help dialog"),
|
||||
"docs.open": keybind("none", "Open documentation"),
|
||||
"opencode.settings": keybind("none", "Open settings"),
|
||||
"server.pair": keybind("none", "Pair device"),
|
||||
"service.restart": keybind("none", "Restart service"),
|
||||
"permission.mode": keybind("none", "Toggle auto-approve permissions"),
|
||||
"diff.open": keybind("none", "Open diff viewer"),
|
||||
"diff.close": keybind("escape,q", "Close diff viewer"),
|
||||
"diff.down": keybind("j,down", "Move diff viewer down"),
|
||||
"diff.up": keybind("k,up", "Move diff viewer up"),
|
||||
"diff.page.down": keybind("pagedown,ctrl+f", "Page diff viewer down"),
|
||||
"diff.page.up": keybind("pageup,ctrl+b", "Page diff viewer up"),
|
||||
"diff.toggle": keybind("enter,space", "Toggle diff viewer item"),
|
||||
"diff.expand": keybind("right", "Expand diff viewer item"),
|
||||
"diff.expand_all": keybind("E", "Expand all diff viewer folders"),
|
||||
"diff.collapse": keybind("left", "Collapse diff viewer item"),
|
||||
"diff.switch_focus": keybind("tab", "Switch diff viewer focus"),
|
||||
"diff.next_hunk": keybind("]", "Jump to next diff hunk"),
|
||||
"diff.previous_hunk": keybind("[", "Jump to previous diff hunk"),
|
||||
"diff.next_file": keybind("n", "Jump to next diff file"),
|
||||
"diff.previous_file": keybind("p", "Jump to previous diff file"),
|
||||
"diff.toggle_file_tree": keybind("b", "Toggle diff viewer file tree"),
|
||||
"diff.single_patch": keybind("s", "Toggle single patch view"),
|
||||
"diff.switch_source": keybind("d", "Switch diff viewer source"),
|
||||
"diff.toggle_view": keybind("v", "Toggle diff viewer split or unified view"),
|
||||
"diff.mark_reviewed": keybind("m", "Toggle selected diff file reviewed"),
|
||||
"diff.help": keybind("?", "Show more diff viewer shortcuts"),
|
||||
|
||||
"prompt.editor": keybind("<leader>e", "Open external editor"),
|
||||
"theme.switch": keybind("<leader>t", "List available themes"),
|
||||
"theme.switch_mode": keybind("none", "Switch between light and dark theme mode"),
|
||||
"theme.mode.lock": keybind("none", "Lock or unlock theme mode"),
|
||||
"session.sidebar.toggle": keybind("<leader>b", "Toggle sidebar"),
|
||||
"session.toggle.scrollbar": keybind("none", "Toggle session scrollbar"),
|
||||
"opencode.status": keybind("<leader>s", "View status"),
|
||||
"opencode.debug": keybind("none", "View debug info"),
|
||||
|
||||
"session.export": keybind("<leader>x", "Export session to editor"),
|
||||
"session.copy": keybind("none", "Copy session transcript"),
|
||||
"session.move": keybind("none", "Move session"),
|
||||
"session.new": keybind("<leader>n", "Create a new session"),
|
||||
"session.list": keybind("<leader>l", "List all sessions"),
|
||||
"session.tab.next": keybind("ctrl+tab,<leader>right", "Switch to next open session tab"),
|
||||
"session.tab.previous": keybind("ctrl+shift+tab,<leader>left", "Switch to previous open session tab"),
|
||||
"session.tab.history.back": keybind("ctrl+o", "Go back in session tab history"),
|
||||
"session.tab.history.forward": keybind("ctrl+i", "Go forward in session tab history"),
|
||||
"session.tab.next_unread": keybind("<leader>down", "Switch to next unread session tab"),
|
||||
"session.tab.previous_unread": keybind("<leader>up", "Switch to previous unread session tab"),
|
||||
"session.tab.close": keybind("<leader>w", "Close current session tab"),
|
||||
"session.timeline": keybind("<leader>g", "Show session timeline"),
|
||||
"session.fork": keybind("none", "Fork session from message"),
|
||||
"session.rename": keybind("ctrl+r", "Rename session"),
|
||||
"session.delete": keybind("ctrl+d", "Delete session"),
|
||||
"session.share": keybind("none", "Share current session"),
|
||||
"session.unshare": keybind("none", "Unshare current session"),
|
||||
"session.interrupt": keybind("escape", "Interrupt current session"),
|
||||
"session.background": keybind("ctrl+b", "Background blocking session tools"),
|
||||
"session.compact": keybind("<leader>c", "Compact the session"),
|
||||
"session.cd": keybind("none", "Change working directory"),
|
||||
"session.queued_prompts": keybind("<leader>q", "Manage queued prompts"),
|
||||
"queued_prompt.delete": keybind("ctrl+d", "Delete queued prompt"),
|
||||
"session.toggle.exploration_grouping": keybind("none", "Toggle related tool call grouping"),
|
||||
"session.child.first": keybind("down", "Toggle subagent picker"),
|
||||
"session.child.next": keybind("right", "Go to next child session"),
|
||||
"session.child.previous": keybind("left", "Go to previous child session"),
|
||||
"session.parent": keybind("up", "Go to parent session"),
|
||||
"session.pin.toggle": keybind("ctrl+f", "Pin or unpin session in the session list"),
|
||||
"session.quick_switch.1": keybind("<leader>1", "Switch to session in quick slot 1"),
|
||||
"session.quick_switch.2": keybind("<leader>2", "Switch to session in quick slot 2"),
|
||||
"session.quick_switch.3": keybind("<leader>3", "Switch to session in quick slot 3"),
|
||||
"session.quick_switch.4": keybind("<leader>4", "Switch to session in quick slot 4"),
|
||||
"session.quick_switch.5": keybind("<leader>5", "Switch to session in quick slot 5"),
|
||||
"session.quick_switch.6": keybind("<leader>6", "Switch to session in quick slot 6"),
|
||||
"session.quick_switch.7": keybind("<leader>7", "Switch to session in quick slot 7"),
|
||||
"session.quick_switch.8": keybind("<leader>8", "Switch to session in quick slot 8"),
|
||||
"session.quick_switch.9": keybind("<leader>9", "Switch to session in quick slot 9"),
|
||||
"session.tab.select.1": keybind("<leader>1,ctrl+1", "Switch to session tab 1"),
|
||||
"session.tab.select.2": keybind("<leader>2,ctrl+2", "Switch to session tab 2"),
|
||||
"session.tab.select.3": keybind("<leader>3,ctrl+3", "Switch to session tab 3"),
|
||||
"session.tab.select.4": keybind("<leader>4,ctrl+4", "Switch to session tab 4"),
|
||||
"session.tab.select.5": keybind("<leader>5,ctrl+5", "Switch to session tab 5"),
|
||||
"session.tab.select.6": keybind("<leader>6,ctrl+6", "Switch to session tab 6"),
|
||||
"session.tab.select.7": keybind("<leader>7,ctrl+7", "Switch to session tab 7"),
|
||||
"session.tab.select.8": keybind("<leader>8,ctrl+8", "Switch to session tab 8"),
|
||||
"session.tab.select.9": keybind("<leader>9,ctrl+9", "Switch to session tab 9"),
|
||||
|
||||
"stash.delete": keybind("ctrl+d", "Delete stash entry"),
|
||||
"model.dialog.provider": keybind("ctrl+a", "Open provider list from model dialog"),
|
||||
"model.dialog.favorite": keybind("ctrl+f", "Toggle model favorite status"),
|
||||
"model.list": keybind("<leader>m", "List available models"),
|
||||
"model.cycle_recent": keybind("f2", "Next recently used model"),
|
||||
"model.cycle_recent_reverse": keybind("shift+f2", "Previous recently used model"),
|
||||
"model.cycle_favorite": keybind("none", "Next favorite model"),
|
||||
"model.cycle_favorite_reverse": keybind("none", "Previous favorite model"),
|
||||
"mcp.list": keybind("none", "List MCP servers"),
|
||||
"provider.connect": keybind("none", "Connect integration"),
|
||||
"agent.list": keybind("<leader>a", "List agents"),
|
||||
"agent.cycle": keybind("shift+tab", "Next agent"),
|
||||
"agent.cycle.reverse": keybind("none", "Previous agent"),
|
||||
"variant.cycle": keybind("ctrl+t", "Cycle model variants"),
|
||||
"variant.list": keybind("none", "List model variants"),
|
||||
|
||||
"session.page.up": keybind("pageup,ctrl+alt+b", "Scroll messages up by one page"),
|
||||
"session.page.down": keybind("pagedown,ctrl+alt+f", "Scroll messages down by one page"),
|
||||
"session.line.up": keybind("ctrl+alt+y", "Scroll messages up by one line"),
|
||||
"session.line.down": keybind("ctrl+alt+e", "Scroll messages down by one line"),
|
||||
"session.half.page.up": keybind("ctrl+alt+u", "Scroll messages up by half page"),
|
||||
"session.half.page.down": keybind("ctrl+alt+d", "Scroll messages down by half page"),
|
||||
"session.first": keybind("ctrl+g,home,alt+home", "Navigate to first message"),
|
||||
"session.last": keybind("ctrl+alt+g,end", "Navigate to last message"),
|
||||
"session.message.next": keybind("alt+down", "Navigate to next message"),
|
||||
"session.message.previous": keybind("alt+up", "Navigate to previous message"),
|
||||
"session.message.user.next": keybind("alt+shift+down", "Navigate to next user message"),
|
||||
"session.message.user.previous": keybind("alt+shift+up", "Navigate to previous user message"),
|
||||
"session.messages_last_user": keybind("alt+end", "Navigate to last user message"),
|
||||
"messages.copy": keybind("<leader>y", "Copy message"),
|
||||
"session.undo": keybind("<leader>u", "Undo message"),
|
||||
"session.redo": keybind("<leader>r", "Redo message"),
|
||||
"session.toggle.thinking": keybind("none", "Toggle thinking blocks visibility"),
|
||||
|
||||
"prompt.submit": keybind("none", "Submit prompt"),
|
||||
"prompt.queue": keybind("alt+return", "Queue prompt"),
|
||||
"prompt.editor_context.clear": keybind("none", "Clear editor context"),
|
||||
"prompt.skills": keybind("none", "Open skill selector"),
|
||||
"prompt.stash": keybind("none", "Stash prompt"),
|
||||
"prompt.stash.pop": keybind("none", "Pop stashed prompt"),
|
||||
"prompt.stash.list": keybind("none", "List stashed prompts"),
|
||||
|
||||
"prompt.clear": keybind("ctrl+c", "Clear input field"),
|
||||
"prompt.paste": keybind({ key: "ctrl+v", preventDefault: false }, "Paste from clipboard"),
|
||||
"input.submit": keybind("return", "Submit input"),
|
||||
"input.newline": keybind("shift+return,ctrl+return,ctrl+j", "Insert newline in input"),
|
||||
"input.move.left": keybind("left,ctrl+b", "Move cursor left in input"),
|
||||
"input.move.right": keybind("right,ctrl+f", "Move cursor right in input"),
|
||||
"input.move.up": keybind("up", "Move cursor up in input"),
|
||||
"input.move.down": keybind("down", "Move cursor down in input"),
|
||||
"input.select.left": keybind("shift+left", "Select left in input"),
|
||||
"input.select.right": keybind("shift+right", "Select right in input"),
|
||||
"input.select.up": keybind("shift+up", "Select up in input"),
|
||||
"input.select.down": keybind("shift+down", "Select down in input"),
|
||||
"input.line.home": keybind("ctrl+a", "Move to start of line in input"),
|
||||
"input.line.end": keybind("ctrl+e", "Move to end of line in input"),
|
||||
"input.select.line.home": keybind("ctrl+shift+a", "Select to start of line in input"),
|
||||
"input.select.line.end": keybind("ctrl+shift+e", "Select to end of line in input"),
|
||||
"input.visual.line.home": keybind("alt+a", "Move to start of visual line in input"),
|
||||
"input.visual.line.end": keybind("alt+e", "Move to end of visual line in input"),
|
||||
"input.select.visual.line.home": keybind("alt+shift+a", "Select to start of visual line in input"),
|
||||
"input.select.visual.line.end": keybind("alt+shift+e", "Select to end of visual line in input"),
|
||||
"input.buffer.home": keybind("home", "Move to start of buffer in input"),
|
||||
"input.buffer.end": keybind("end", "Move to end of buffer in input"),
|
||||
"input.select.buffer.home": keybind("shift+home", "Select to start of buffer in input"),
|
||||
"input.select.buffer.end": keybind("shift+end", "Select to end of buffer in input"),
|
||||
"input.delete.line": keybind("ctrl+shift+d", "Delete line in input"),
|
||||
"input.delete.to.line.end": keybind("ctrl+k", "Delete to end of line in input"),
|
||||
"input.delete.to.line.start": keybind("ctrl+u", "Delete to start of line in input"),
|
||||
"input.backspace": keybind("backspace,shift+backspace", "Backspace in input"),
|
||||
"input.delete": keybind("ctrl+d,delete,shift+delete", "Delete character in input"),
|
||||
"input.undo": keybind("ctrl+-,super+z", "Undo in input"),
|
||||
"input.redo": keybind("ctrl+.,super+shift+z", "Redo in input"),
|
||||
"input.word.forward": keybind("alt+f,alt+right,ctrl+right", "Move word forward in input"),
|
||||
"input.word.backward": keybind("alt+b,alt+left,ctrl+left", "Move word backward in input"),
|
||||
"input.select.word.forward": keybind("alt+shift+f,alt+shift+right", "Select word forward in input"),
|
||||
"input.select.word.backward": keybind("alt+shift+b,alt+shift+left", "Select word backward in input"),
|
||||
"input.delete.word.forward": keybind("alt+d,alt+delete,ctrl+delete", "Delete word forward in input"),
|
||||
"input.delete.word.backward": keybind("ctrl+w,ctrl+backspace,alt+backspace", "Delete word backward in input"),
|
||||
"input.select.all": keybind("super+a", "Select all in input"),
|
||||
"prompt.history.previous": keybind("up", "Previous history item"),
|
||||
"prompt.history.next": keybind("down", "Next history item"),
|
||||
|
||||
"composer.subagent.up": keybind("up", "Previous subagent"),
|
||||
"composer.subagent.down": keybind("down", "Next subagent"),
|
||||
"composer.subagent.select": keybind("return", "Navigate to subagent"),
|
||||
"composer.subagent.interrupt": keybind("ctrl+d", "Interrupt subagent"),
|
||||
"composer.shell.up": keybind("up", "Previous shell"),
|
||||
"composer.shell.down": keybind("down", "Next shell"),
|
||||
"composer.shell.kill": keybind("ctrl+d", "Kill shell command"),
|
||||
|
||||
"dialog.select.prev": keybind("up,ctrl+p", "Move to previous dialog item"),
|
||||
"dialog.select.next": keybind("down,ctrl+n", "Move to next dialog item"),
|
||||
"dialog.select.page_up": keybind("pageup", "Move up one page in dialog"),
|
||||
"dialog.select.page_down": keybind("pagedown", "Move down one page in dialog"),
|
||||
"dialog.select.home": keybind("home", "Move to first dialog item"),
|
||||
"dialog.select.end": keybind("end", "Move to last dialog item"),
|
||||
"dialog.select.submit": keybind("return", "Submit selected dialog item"),
|
||||
"dialog.prompt.submit": keybind("return", "Submit dialog prompt"),
|
||||
"dialog.project_copy.generate": keybind("tab", "Generate project copy name"),
|
||||
"dialog.move_session.new": keybind("ctrl+m", "New project copy"),
|
||||
"dialog.move_session.delete": keybind("ctrl+d", "Delete project copy"),
|
||||
"dialog.move_session.refresh": keybind("ctrl+r", "Refresh project copies"),
|
||||
"prompt.autocomplete.prev": keybind("up,ctrl+p", "Move to previous autocomplete item"),
|
||||
"prompt.autocomplete.next": keybind("down,ctrl+n", "Move to next autocomplete item"),
|
||||
"prompt.autocomplete.hide": keybind("escape", "Hide autocomplete"),
|
||||
"prompt.autocomplete.select": keybind("return", "Select autocomplete item"),
|
||||
"prompt.autocomplete.complete": keybind("tab", "Complete autocomplete item"),
|
||||
"permission.prompt.fullscreen": keybind("ctrl+f", "Toggle permission prompt fullscreen"),
|
||||
"plugins.toggle": keybind("space", "Toggle plugin"),
|
||||
"dialog.mcp.toggle": keybind("space", "Toggle MCP server"),
|
||||
"dialog.plugins.install": keybind("shift+i", "Install plugin from plugin dialog"),
|
||||
|
||||
"terminal.suspend": keybind("ctrl+z", "Suspend terminal"),
|
||||
"terminal.title.toggle": keybind("none", "Toggle terminal title"),
|
||||
"plugins.list": keybind("none", "Open plugin manager dialog"),
|
||||
"plugins.install": keybind("none", "Install plugin"),
|
||||
|
||||
"which-key.toggle": keybind("ctrl+alt+k", "Toggle which-key panel"),
|
||||
"which-key.layout.toggle": keybind("ctrl+alt+shift+k", "Switch which-key layout"),
|
||||
"which-key.pending.toggle": keybind("ctrl+alt+shift+p", "Toggle which-key pending preview"),
|
||||
"which-key.group.previous": keybind("ctrl+alt+left,ctrl+alt+[", "Previous which-key group"),
|
||||
"which-key.group.next": keybind("ctrl+alt+right,ctrl+alt+]", "Next which-key group"),
|
||||
"which-key.scroll.up": keybind("ctrl+alt+up,ctrl+alt+p", "Scroll which-key up"),
|
||||
"which-key.scroll.down": keybind("ctrl+alt+down,ctrl+alt+n", "Scroll which-key down"),
|
||||
"which-key.page.up": keybind("ctrl+alt+pageup", "Page which-key up"),
|
||||
"which-key.page.down": keybind("ctrl+alt+pagedown", "Page which-key down"),
|
||||
"which-key.home": keybind("ctrl+alt+home", "Jump to first which-key binding"),
|
||||
"which-key.end": keybind("ctrl+alt+end", "Jump to last which-key binding"),
|
||||
} satisfies Record<string, Definition>
|
||||
|
||||
type KeybindName = keyof typeof Definitions
|
||||
const KeybindNames = new Set<string>(Object.keys(Definitions))
|
||||
|
||||
export const KeybindOverrides = Schema.Struct(
|
||||
Object.fromEntries(
|
||||
Object.entries(Definitions).map(([name, item]) => [
|
||||
name,
|
||||
Schema.optional(BindingValueSchema).annotate({ description: item.description }),
|
||||
]),
|
||||
),
|
||||
).annotate({ description: "TUI keybinding overrides" })
|
||||
export const Descriptions = Object.fromEntries(
|
||||
Object.entries(Definitions).map(([name, item]) => [name, item.description]),
|
||||
) as Record<KeybindName, string>
|
||||
|
||||
export type Keybinds = { [K in KeybindName]: BindingValueSchema }
|
||||
export type KeybindOverrides = Partial<Keybinds>
|
||||
export type BindingLookupView = {
|
||||
readonly bindings: readonly Binding<Renderable, KeyEvent>[]
|
||||
get(command: string): readonly Binding<Renderable, KeyEvent>[]
|
||||
has(command: string): boolean
|
||||
gather(name: string, commands: readonly string[]): readonly Binding<Renderable, KeyEvent>[]
|
||||
pick(name: string, commands: readonly string[]): Binding<Renderable, KeyEvent>[]
|
||||
omit(name: string, commands: readonly string[]): Binding<Renderable, KeyEvent>[]
|
||||
}
|
||||
|
||||
export function toBindingConfig(keybinds: Keybinds): BindingConfig<Renderable, KeyEvent> {
|
||||
return Object.fromEntries(Object.entries(keybinds)) as BindingConfig<Renderable, KeyEvent>
|
||||
}
|
||||
|
||||
const decodeBindingValue = Schema.decodeUnknownSync(BindingValueSchema)
|
||||
|
||||
export function defaultValue(name: KeybindName) {
|
||||
return Definitions[name].default
|
||||
}
|
||||
|
||||
export function parse(keybinds: KeybindOverrides): Keybinds {
|
||||
const invalid = unknownKeys(keybinds)
|
||||
if (invalid.length) throw new Error(`Unrecognized keybind${invalid.length === 1 ? "" : "s"}: ${invalid.join(", ")}`)
|
||||
return Object.fromEntries(
|
||||
Object.entries(Definitions).map(([name, item]) => [
|
||||
name,
|
||||
decodeBindingValue(keybinds[name as KeybindName] ?? item.default),
|
||||
]),
|
||||
) as Keybinds
|
||||
}
|
||||
|
||||
export const Keybinds = { parse }
|
||||
|
||||
export function unknownKeys(input: object) {
|
||||
return Object.keys(input).filter((key) => !KeybindNames.has(key))
|
||||
}
|
||||
|
||||
export function bindingDefaults(): BindingDefaults<Renderable, KeyEvent> {
|
||||
return ({ command, binding }) => {
|
||||
if (binding.desc !== undefined) return
|
||||
return { desc: Descriptions[command as KeybindName] }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { LocationRef } from "@opencode-ai/client/promise"
|
||||
|
||||
export function newSessionLocation(
|
||||
mode: "launch" | "inherit",
|
||||
launchDirectory: string,
|
||||
current?: LocationRef,
|
||||
): LocationRef {
|
||||
if (mode === "inherit" && current) return current
|
||||
return { directory: launchDirectory }
|
||||
}
|
||||
@@ -201,7 +201,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
function updatePending(sessionID: string, inputID: string, delivery: SessionPending.Delivery) {
|
||||
const index = store.session.pending[sessionID]?.findIndex((item) => item.id === inputID) ?? -1
|
||||
const item = store.session.pending[sessionID]?.[index]
|
||||
if (index < 0 || !item || item.type === "compaction" || item.delivery === delivery) return
|
||||
if (index < 0 || !item || (item.type !== "user" && item.type !== "synthetic") || item.delivery === delivery)
|
||||
return
|
||||
setStore("session", "pending", sessionID, index, { ...item, delivery })
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallbac
|
||||
import { useEvent } from "./event"
|
||||
import { useRoute } from "./route"
|
||||
import { useConfig } from "../config"
|
||||
import { useLocation } from "./location"
|
||||
import { useStorage } from "./storage"
|
||||
import { useTuiPaths } from "./runtime"
|
||||
import {
|
||||
@@ -48,6 +49,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
const data = useData()
|
||||
const event = useEvent()
|
||||
const config = useConfig().data
|
||||
const location = useLocation()
|
||||
const paths = useTuiPaths()
|
||||
const enabled = () => config.tabs.enabled
|
||||
// Keyed reconcile keeps tab object identity across reorders, so strip rows move instead of
|
||||
@@ -249,6 +251,14 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
if (!enabled()) return
|
||||
route.navigate({ type: "session", sessionID: root(sessionID) })
|
||||
},
|
||||
add() {
|
||||
if (!enabled()) return
|
||||
const sessionID = current()
|
||||
route.navigate({
|
||||
type: "home",
|
||||
location: (sessionID ? data.session.get(sessionID)?.location : undefined) ?? location.ref,
|
||||
})
|
||||
},
|
||||
close(sessionID?: string) {
|
||||
if (!enabled()) return
|
||||
const target = sessionID ? root(sessionID) : current()
|
||||
|
||||
@@ -427,7 +427,6 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
||||
id: "diff.down",
|
||||
title: "Move diff viewer down",
|
||||
group: "VCS",
|
||||
bind: "j,down",
|
||||
run: focusRunner({
|
||||
files() {
|
||||
moveFileSelection(1)
|
||||
@@ -442,7 +441,6 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
||||
id: "diff.up",
|
||||
title: "Move diff viewer up",
|
||||
group: "VCS",
|
||||
bind: "k,up",
|
||||
run: focusRunner({
|
||||
files() {
|
||||
moveFileSelection(-1)
|
||||
@@ -457,7 +455,6 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
||||
id: "diff.page.down",
|
||||
title: "Page diff viewer down",
|
||||
group: "VCS",
|
||||
bind: "pagedown,ctrl+f",
|
||||
run: focusRunner({
|
||||
files() {
|
||||
moveFileSelection(8)
|
||||
@@ -472,7 +469,6 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
||||
id: "diff.page.up",
|
||||
title: "Page diff viewer up",
|
||||
group: "VCS",
|
||||
bind: "pageup,ctrl+b",
|
||||
run: focusRunner({
|
||||
files() {
|
||||
moveFileSelection(-8)
|
||||
@@ -578,7 +574,6 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
||||
id: "diff.mark_reviewed",
|
||||
title: "Toggle selected diff file reviewed",
|
||||
group: "VCS",
|
||||
bind: "m",
|
||||
run() {
|
||||
toggleSelectedFileReviewed()
|
||||
},
|
||||
|
||||
@@ -105,9 +105,21 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
|
||||
}
|
||||
}
|
||||
|
||||
const addTab = () => {
|
||||
const next = FIXTURE_TABS.find((fixture) => !tabs().some((tab) => tab.sessionID === fixture.sessionID))
|
||||
if (!next) {
|
||||
setLastEvent("all fixture tabs are open")
|
||||
return
|
||||
}
|
||||
setItems([...tabs().map((tab) => ({ ...tab })), { sessionID: next.sessionID }])
|
||||
select(next.sessionID)
|
||||
setLastEvent(`tab ${number(next.sessionID)} opened untitled; run it to earn its title`)
|
||||
}
|
||||
|
||||
const controller = {
|
||||
tabs,
|
||||
current: active,
|
||||
add: addTab,
|
||||
status(sessionID) {
|
||||
return statuses()[sessionID] ?? EMPTY_SESSION_TAB_STATUS
|
||||
},
|
||||
@@ -283,21 +295,7 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
|
||||
startRun(current)
|
||||
},
|
||||
},
|
||||
{
|
||||
bind: "t",
|
||||
title: "Add tab",
|
||||
group: "Storybook",
|
||||
run() {
|
||||
const next = FIXTURE_TABS.find((fixture) => !tabs().some((tab) => tab.sessionID === fixture.sessionID))
|
||||
if (!next) {
|
||||
setLastEvent("all fixture tabs are open")
|
||||
return
|
||||
}
|
||||
setItems([...tabs().map((tab) => ({ ...tab })), { sessionID: next.sessionID }])
|
||||
select(next.sessionID)
|
||||
setLastEvent(`tab ${number(next.sessionID)} opened untitled; run it to earn its title`)
|
||||
},
|
||||
},
|
||||
{ bind: "t", title: "Add tab", group: "Storybook", run: addTab },
|
||||
{ bind: "d", title: "Close tab", group: "Storybook", run: () => controller.close() },
|
||||
{
|
||||
bind: "r",
|
||||
|
||||
@@ -191,7 +191,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
const subagentShortcut = () => shortcut("session.child.first")
|
||||
const queuedShortcut = () => shortcut("session.queued_prompts")
|
||||
const backgroundShortcut = () => shortcut("session.background")
|
||||
const subagentInterruptShortcut = () => shortcut("subagent.interrupt")
|
||||
const subagentInterruptShortcut = () => shortcut("composer.subagent.interrupt")
|
||||
const interrupt = () => shortcut("session.interrupt")
|
||||
const variantCycle = () => monoShortcut(shortcuts.all("variant.cycle") ?? "", props.mono)
|
||||
const clearShortcut = () => shortcut("prompt.clear")
|
||||
@@ -610,10 +610,9 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
priority: 1,
|
||||
commands: [
|
||||
{
|
||||
id: "subagent.interrupt",
|
||||
id: "composer.subagent.interrupt",
|
||||
title: "Interrupt subagent",
|
||||
group: "Session",
|
||||
bind: "ctrl+d",
|
||||
run: () => {
|
||||
const current = selectedTab()
|
||||
if (current?.status !== "running") {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import type { LocationRef } from "@opencode-ai/client/promise"
|
||||
import type { Config } from "../config"
|
||||
import { newSessionLocation } from "../config/new-session-location"
|
||||
import { loadRunAgents, loadRunCommands, loadRunReferences } from "./catalog.shared"
|
||||
import {
|
||||
resolveMiniSettings,
|
||||
@@ -48,6 +49,7 @@ type Reconnect = (signal: AbortSignal) => Promise<RunInput["sdk"]>
|
||||
|
||||
type RunRuntimeInput = {
|
||||
host: MiniHost
|
||||
directory: string
|
||||
boot: () => Promise<BootContext>
|
||||
resolveSession: (sdk: RunInput["sdk"], signal: AbortSignal) => Promise<ResolvedSession>
|
||||
createSession?: CreateSession
|
||||
@@ -941,7 +943,11 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
||||
const created = await createSession(
|
||||
state.sdk,
|
||||
{
|
||||
location: state.location,
|
||||
location: newSessionLocation(
|
||||
(await tuiConfigTask).session.new_location,
|
||||
input.directory,
|
||||
state.location,
|
||||
),
|
||||
agent: state.agent,
|
||||
model: state.model,
|
||||
variant: state.activeVariant,
|
||||
@@ -1099,6 +1105,7 @@ export async function runInteractiveDeferredMode(input: RunDeferredInput, deps?:
|
||||
return runInteractiveRuntime(
|
||||
{
|
||||
host: input.host,
|
||||
directory: input.directory,
|
||||
files: input.files,
|
||||
initialInput: input.initialInput,
|
||||
thinking: input.thinking,
|
||||
|
||||
@@ -392,7 +392,7 @@ export type FormCancel = {
|
||||
location?: LocationRef
|
||||
}
|
||||
|
||||
export type RunTuiConfig = Pick<Config.Resolved, "keybinds" | "leader" | "theme" | "mini">
|
||||
export type RunTuiConfig = Pick<Config.Resolved, "keybinds" | "leader" | "theme" | "mini" | "session">
|
||||
|
||||
export type MiniSettings = {
|
||||
thinking: "show" | "hide"
|
||||
|
||||
@@ -55,7 +55,6 @@ export function ShellTab(props: { sessionID: string }) {
|
||||
id: "composer.shell.up",
|
||||
title: "Previous shell",
|
||||
group: "Composer",
|
||||
bind: "up",
|
||||
run() {
|
||||
if (store.selected === 0) {
|
||||
composer.close()
|
||||
@@ -68,7 +67,6 @@ export function ShellTab(props: { sessionID: string }) {
|
||||
id: "composer.shell.down",
|
||||
title: "Next shell",
|
||||
group: "Composer",
|
||||
bind: "down",
|
||||
run() {
|
||||
const list = entries()
|
||||
if (list.length === 0) return
|
||||
@@ -79,7 +77,6 @@ export function ShellTab(props: { sessionID: string }) {
|
||||
id: "composer.shell.kill",
|
||||
title: "Kill shell command",
|
||||
group: "Composer",
|
||||
bind: "ctrl+d",
|
||||
run() {
|
||||
const entry = selectedEntry()
|
||||
if (!entry) return
|
||||
|
||||
@@ -169,7 +169,6 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
id: "composer.subagent.up",
|
||||
title: "Previous subagent",
|
||||
group: "Composer",
|
||||
bind: "up",
|
||||
run() {
|
||||
if (store.selected === 0) {
|
||||
composer.close()
|
||||
@@ -182,7 +181,6 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
id: "composer.subagent.down",
|
||||
title: "Next subagent",
|
||||
group: "Composer",
|
||||
bind: "down",
|
||||
run() {
|
||||
const list = entries()
|
||||
if (list.length === 0) return
|
||||
@@ -193,7 +191,6 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
id: "composer.subagent.select",
|
||||
title: "Navigate to subagent",
|
||||
group: "Composer",
|
||||
bind: "return",
|
||||
run() {
|
||||
const entry = entries()[store.selected]
|
||||
if (entry) navigate({ type: "session", sessionID: entry.sessionID })
|
||||
@@ -213,7 +210,6 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
id: "composer.subagent.interrupt",
|
||||
title: "Interrupt subagent",
|
||||
group: "Composer",
|
||||
bind: "ctrl+d",
|
||||
run() {
|
||||
const entry = selectedEntry()
|
||||
if (!entry || entry.status !== "running") return
|
||||
|
||||
@@ -1222,6 +1222,11 @@ function TurnTokenUsage(props: {
|
||||
}) {
|
||||
const config = useConfig()
|
||||
const theme = useTheme()
|
||||
const renderer = useRenderer()
|
||||
// Collapsed by default: one summary line for the whole turn. Click to
|
||||
// open the full per-step table, click again to close.
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
const [hover, setHover] = createSignal(false)
|
||||
const verbose = () => config.data.debug?.turn_tokens === "verbose"
|
||||
const steps = createMemo(() => {
|
||||
let previousCache = props.previousCache
|
||||
@@ -1257,49 +1262,78 @@ function TurnTokenUsage(props: {
|
||||
cached: Math.max("Cached".length, ...steps().map((item) => item.cached.toLocaleString().length)),
|
||||
total: Math.max("Total".length, ...steps().map((item) => item.total.toLocaleString().length)),
|
||||
}))
|
||||
const summary = createMemo(() => {
|
||||
const items = steps()
|
||||
const last = items[items.length - 1]
|
||||
return {
|
||||
count: items.length,
|
||||
newTokens: items.reduce((sum, item) => sum + item.newTokens, 0),
|
||||
cached: last?.cached ?? 0,
|
||||
total: last?.total ?? 0,
|
||||
reuseDrops: items.filter((item) => item.reuseDrop !== undefined).length,
|
||||
}
|
||||
})
|
||||
return (
|
||||
<Show when={Boolean(config.data.debug?.turn_tokens) && steps().length > 0}>
|
||||
<box paddingLeft={3} flexDirection="column">
|
||||
<box flexDirection="row">
|
||||
<text width={INLINE_TOOL_ICON_WIDTH} fg={theme.text.subdued}>
|
||||
◈
|
||||
</text>
|
||||
<text fg={theme.text.subdued} attributes={TextAttributes.BOLD}>
|
||||
Tokens
|
||||
<box
|
||||
flexDirection="row"
|
||||
onMouseOver={() => setHover(true)}
|
||||
onMouseOut={() => setHover(false)}
|
||||
onMouseUp={() => {
|
||||
if (renderer.getSelection()?.getSelectedText()) return
|
||||
setExpanded((value) => !value)
|
||||
}}
|
||||
>
|
||||
<text fg={hover() ? theme.text.default : theme.text.subdued} wrapMode="none">
|
||||
<span>{expanded() ? "- " : "+ "}</span>
|
||||
<span style={{ attributes: TextAttributes.BOLD }}>Tokens</span>
|
||||
<span>
|
||||
: {summary().count} {summary().count === 1 ? "step" : "steps"} · {summary().newTokens.toLocaleString()}{" "}
|
||||
new · {summary().cached.toLocaleString()} cached · {summary().total.toLocaleString()} total
|
||||
</span>
|
||||
<Show when={summary().reuseDrops > 0}>
|
||||
<span style={{ fg: theme.text.feedback.warning.default }}>
|
||||
{" "}
|
||||
· ! {summary().reuseDrops} likely cache {summary().reuseDrops === 1 ? "bust" : "busts"}
|
||||
</span>
|
||||
</Show>
|
||||
</text>
|
||||
</box>
|
||||
<box paddingLeft={INLINE_TOOL_ICON_WIDTH}>
|
||||
<text fg={theme.text.subdued} attributes={TextAttributes.ITALIC}>
|
||||
{"Step".padEnd(columns().step + 2)}
|
||||
{"New".padStart(columns().newTokens)}
|
||||
{" "}
|
||||
{"Cached".padStart(columns().cached)}
|
||||
{" "}
|
||||
{"Total".padStart(columns().total)}
|
||||
</text>
|
||||
</box>
|
||||
<For each={steps()}>
|
||||
{(item) => (
|
||||
<box paddingLeft={INLINE_TOOL_ICON_WIDTH} flexDirection="column">
|
||||
<text fg={verbose() && item.finish === "tool-call" ? undefined : theme.text.subdued}>
|
||||
{item.finish.padEnd(columns().step + 2)}
|
||||
<span style={{ attributes: TextAttributes.BOLD }}>
|
||||
{item.newTokens.toLocaleString().padStart(columns().newTokens)}
|
||||
</span>
|
||||
{" "}
|
||||
{item.cached.toLocaleString().padStart(columns().cached)}
|
||||
{" "}
|
||||
{item.total.toLocaleString().padStart(columns().total)}
|
||||
</text>
|
||||
<TurnTokenToolCalls tools={item.tools} />
|
||||
<Show when={item.reuseDrop !== undefined}>
|
||||
<text fg={theme.text.feedback.warning.default}>
|
||||
! Likely cache bust: {item.reuseDrop?.toLocaleString()} fewer cached tokens than the previous step
|
||||
<Show when={expanded()}>
|
||||
<box paddingLeft={INLINE_TOOL_ICON_WIDTH}>
|
||||
<text fg={theme.text.subdued} attributes={TextAttributes.ITALIC}>
|
||||
{"Step".padEnd(columns().step + 2)}
|
||||
{"New".padStart(columns().newTokens)}
|
||||
{" "}
|
||||
{"Cached".padStart(columns().cached)}
|
||||
{" "}
|
||||
{"Total".padStart(columns().total)}
|
||||
</text>
|
||||
</box>
|
||||
<For each={steps()}>
|
||||
{(item) => (
|
||||
<box paddingLeft={INLINE_TOOL_ICON_WIDTH} flexDirection="column">
|
||||
<text fg={verbose() && item.finish === "tool-call" ? undefined : theme.text.subdued}>
|
||||
{item.finish.padEnd(columns().step + 2)}
|
||||
<span style={{ attributes: TextAttributes.BOLD }}>
|
||||
{item.newTokens.toLocaleString().padStart(columns().newTokens)}
|
||||
</span>
|
||||
{" "}
|
||||
{item.cached.toLocaleString().padStart(columns().cached)}
|
||||
{" "}
|
||||
{item.total.toLocaleString().padStart(columns().total)}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
<TurnTokenToolCalls tools={item.tools} />
|
||||
<Show when={item.reuseDrop !== undefined}>
|
||||
<text fg={theme.text.feedback.warning.default}>
|
||||
! Likely cache bust: {item.reuseDrop?.toLocaleString()} fewer cached tokens than the previous step
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
@@ -1780,7 +1814,7 @@ function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type:
|
||||
streaming={true}
|
||||
internalBlockMode="top-level"
|
||||
content={content()}
|
||||
tableOptions={{ style: "grid" }}
|
||||
tableOptions={{ style: "grid", cellPaddingX: 1 }}
|
||||
conceal={ctx.markdownMode() === "rendered"}
|
||||
fg={theme.markdown.text}
|
||||
bg={theme.background.default}
|
||||
@@ -2230,7 +2264,7 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText }) {
|
||||
streaming={true}
|
||||
internalBlockMode="top-level"
|
||||
content={props.part.text.trim()}
|
||||
tableOptions={{ style: "grid" }}
|
||||
tableOptions={{ style: "grid", cellPaddingX: 1 }}
|
||||
conceal={ctx.markdownMode() === "rendered"}
|
||||
fg={theme.markdown.text}
|
||||
bg={theme.background.default}
|
||||
|
||||
@@ -40,7 +40,6 @@ export function DialogPrompt(props: DialogPromptProps) {
|
||||
{
|
||||
id: "dialog.prompt.submit",
|
||||
title: "Submit dialog prompt",
|
||||
bind: "return",
|
||||
group: "Dialog",
|
||||
run: confirm,
|
||||
},
|
||||
|
||||
@@ -294,3 +294,63 @@ test("session startup prompt is submitted exactly once", async () => {
|
||||
await server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test("configured app bindings execute settings and permission commands", async () => {
|
||||
const setup = await createTestRenderer({ width: 100, height: 30, useThread: false, kittyKeyboard: true })
|
||||
setup.renderer.start()
|
||||
const ready = Promise.withResolvers<void>()
|
||||
const events = createEventStream()
|
||||
const calls = createFetch(undefined, events)
|
||||
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
|
||||
|
||||
try {
|
||||
const { run } = await import("../src/app")
|
||||
const task = Effect.runPromise(
|
||||
run({
|
||||
app: { name: "test", version: "test", channel: "test" },
|
||||
server: { endpoint: { url: server.url.toString() } },
|
||||
config: {
|
||||
get: async () => ({
|
||||
animations: false,
|
||||
keybinds: { "opencode.settings": "f6", "permission.mode": "f7" },
|
||||
}),
|
||||
update: async () => ({}),
|
||||
},
|
||||
packages: { resolve: async () => undefined },
|
||||
args: {},
|
||||
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
|
||||
log: () => {},
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
|
||||
)
|
||||
await ready.promise
|
||||
await setup.waitForFrame((frame) => frame.includes("commands"))
|
||||
|
||||
setup.mockInput.pressKey("F6")
|
||||
const settings = await setup.waitForFrame((frame) => frame.includes("Settings"))
|
||||
expect(settings).toContain("Color mode")
|
||||
expect(settings).toContain("Animations")
|
||||
|
||||
setup.mockInput.pressEscape()
|
||||
await setup.waitForFrame((frame) => !frame.includes("Settings"))
|
||||
setup.mockInput.pressKey("F7")
|
||||
await setup.renderOnce()
|
||||
setup.mockInput.pressKey("p", { ctrl: true })
|
||||
await setup.waitForFrame((frame) => frame.includes("Commands"))
|
||||
setup.mockInput.pressKey("END")
|
||||
const commands = await setup.waitForFrame(
|
||||
(frame) => {
|
||||
if (frame.includes("Disable auto-approve permissions")) return true
|
||||
setup.mockInput.pressArrow("up")
|
||||
return false
|
||||
},
|
||||
{ maxPasses: 100 },
|
||||
)
|
||||
expect(commands).not.toContain("Enable auto-approve permissions")
|
||||
|
||||
setup.renderer.destroy()
|
||||
await task
|
||||
} finally {
|
||||
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
||||
await server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { expect, test } from "bun:test"
|
||||
import { onMount } from "solid-js"
|
||||
import { ConfigProvider } from "../../../src/config"
|
||||
import type { TuiKeybind } from "../../../src/config/keybind"
|
||||
import { ClientProvider } from "../../../src/context/client"
|
||||
import { DataProvider, useData } from "../../../src/context/data"
|
||||
import { Keymap } from "../../../src/context/keymap"
|
||||
import { LocationProvider } from "../../../src/context/location"
|
||||
import { RouteProvider, useRoute } from "../../../src/context/route"
|
||||
import { ThemeProvider } from "../../../src/context/theme"
|
||||
import { Composer } from "../../../src/routes/session/composer"
|
||||
import { createApi, createEventStream, createFetch, directory, json } from "../../fixture/tui-client"
|
||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
|
||||
const sessions = {
|
||||
parent: session("parent", "Parent"),
|
||||
"child-a": session("child-a", "First", "parent"),
|
||||
"child-b": session("child-b", "Second", "parent"),
|
||||
}
|
||||
|
||||
const shells = [shell("sh-a", "bun test"), shell("sh-b", "bun dev")]
|
||||
|
||||
async function renderComposer(defaultTab: "subagents" | "shell", keybinds: Partial<TuiKeybind.Keybinds>) {
|
||||
const events = createEventStream()
|
||||
const interrupted: string[] = []
|
||||
const removed: string[] = []
|
||||
const ready = Promise.withResolvers<void>()
|
||||
let closed = 0
|
||||
let dispatch!: ReturnType<typeof Keymap.use>["dispatch"]
|
||||
let route!: ReturnType<typeof useRoute>
|
||||
const calls = createFetch((url, request) => {
|
||||
if (url.pathname === "/api/session/active")
|
||||
return json({ data: { "child-a": { type: "running" }, "child-b": { type: "running" } } })
|
||||
const sessionID = url.pathname.match(/^\/api\/session\/([^/]+)$/)?.[1]
|
||||
if (sessionID && sessionID in sessions) return json({ data: sessions[sessionID as keyof typeof sessions] })
|
||||
const interruptID = url.pathname.match(/^\/api\/session\/([^/]+)\/interrupt$/)?.[1]
|
||||
if (interruptID && request.method === "POST") {
|
||||
interrupted.push(interruptID)
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
if (url.pathname === "/api/shell" && request.method === "GET") {
|
||||
const requestDirectory = url.searchParams.get("location[directory]") ?? directory
|
||||
return json({
|
||||
location: { directory: requestDirectory, project: { id: "proj_test", directory: requestDirectory } },
|
||||
data: shells,
|
||||
})
|
||||
}
|
||||
const shellID = url.pathname.match(/^\/api\/shell\/([^/]+)$/)?.[1]
|
||||
if (shellID && request.method === "DELETE") {
|
||||
removed.push(shellID)
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
}, events)
|
||||
|
||||
function Content() {
|
||||
const data = useData()
|
||||
route = useRoute()
|
||||
dispatch = Keymap.use().dispatch
|
||||
onMount(() => {
|
||||
void Promise.all([
|
||||
data.session.sync("parent"),
|
||||
data.session.sync("child-a"),
|
||||
data.session.sync("child-b"),
|
||||
data.shell.sync(),
|
||||
])
|
||||
.then(() => wait(() => data.session.status("child-a") === "running"))
|
||||
.then(() => ready.resolve(), ready.reject)
|
||||
})
|
||||
return <Composer sessionID="parent" open={true} defaultTab={defaultTab} onClose={() => closed++} />
|
||||
}
|
||||
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<TestTuiContexts directory={directory}>
|
||||
<ConfigProvider config={createTuiResolvedConfig({ keybinds })}>
|
||||
<Keymap.Provider>
|
||||
<ClientProvider api={createApi(calls.fetch)}>
|
||||
<DataProvider>
|
||||
<LocationProvider>
|
||||
<RouteProvider initialRoute={{ type: "session", sessionID: "parent" }}>
|
||||
<ThemeProvider mode="dark" source={{ discover: async () => ({}) }}>
|
||||
<Content />
|
||||
</ThemeProvider>
|
||||
</RouteProvider>
|
||||
</LocationProvider>
|
||||
</DataProvider>
|
||||
</ClientProvider>
|
||||
</Keymap.Provider>
|
||||
</ConfigProvider>
|
||||
</TestTuiContexts>
|
||||
),
|
||||
{ width: 100, height: 20, kittyKeyboard: true },
|
||||
)
|
||||
await ready.promise
|
||||
await app.renderOnce()
|
||||
return {
|
||||
app,
|
||||
interrupted,
|
||||
removed,
|
||||
route: () => route.data,
|
||||
dispatch: (command: string) => dispatch(command),
|
||||
closed: () => closed,
|
||||
}
|
||||
}
|
||||
|
||||
test("disabled subagent bindings have no component fallbacks", async () => {
|
||||
const composer = await renderComposer("subagents", {
|
||||
"composer.subagent.up": "none",
|
||||
"composer.subagent.down": "none",
|
||||
"composer.subagent.select": "none",
|
||||
"composer.subagent.interrupt": "none",
|
||||
})
|
||||
try {
|
||||
expect(composer.app.captureCharFrame()).toContain("First")
|
||||
composer.app.mockInput.pressArrow("up")
|
||||
composer.app.mockInput.pressEnter()
|
||||
composer.app.mockInput.pressKey("d", { ctrl: true })
|
||||
await composer.app.renderOnce()
|
||||
expect(composer.closed()).toBe(0)
|
||||
expect(composer.route()).toMatchObject({ type: "session", sessionID: "parent" })
|
||||
expect(composer.interrupted).toEqual([])
|
||||
|
||||
composer.app.mockInput.pressArrow("down")
|
||||
composer.dispatch("composer.subagent.select")
|
||||
expect(composer.route()).toMatchObject({ type: "session", sessionID: "child-a" })
|
||||
} finally {
|
||||
composer.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("disabled shell bindings have no component fallbacks", async () => {
|
||||
const composer = await renderComposer("shell", {
|
||||
"composer.shell.up": "none",
|
||||
"composer.shell.down": "none",
|
||||
"composer.shell.kill": "none",
|
||||
})
|
||||
try {
|
||||
expect(composer.app.captureCharFrame()).toContain("bun test")
|
||||
composer.app.mockInput.pressArrow("up")
|
||||
composer.app.mockInput.pressKey("d", { ctrl: true })
|
||||
await composer.app.renderOnce()
|
||||
expect(composer.closed()).toBe(0)
|
||||
expect(composer.removed).toEqual([])
|
||||
|
||||
composer.app.mockInput.pressArrow("down")
|
||||
composer.dispatch("composer.shell.kill")
|
||||
await wait(() => composer.removed.length === 1)
|
||||
expect(composer.removed).toEqual(["sh-a"])
|
||||
} finally {
|
||||
composer.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
function session(id: string, title: string, parentID?: string) {
|
||||
return {
|
||||
id,
|
||||
projectID: "proj_test",
|
||||
title,
|
||||
agent: "build",
|
||||
location: { directory },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0, updated: 0 },
|
||||
...(parentID ? { parentID } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function shell(id: string, command: string) {
|
||||
return {
|
||||
id,
|
||||
status: "running" as const,
|
||||
command,
|
||||
cwd: directory,
|
||||
shell: "/bin/sh",
|
||||
file: `/tmp/${id}`,
|
||||
metadata: { sessionID: "parent" },
|
||||
time: { started: 1 },
|
||||
}
|
||||
}
|
||||
|
||||
async function wait(fn: () => boolean, timeout = 2000) {
|
||||
const start = Date.now()
|
||||
while (!fn()) {
|
||||
if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
|
||||
await Bun.sleep(10)
|
||||
}
|
||||
}
|
||||
@@ -982,7 +982,12 @@ test("updates and removes queued inputs from durable lifecycle events", async ()
|
||||
await wait(() =>
|
||||
data.session.pending
|
||||
.list(sessionID)
|
||||
.some((item) => item.id === "message-queued" && item.type !== "compaction" && item.delivery === "steer"),
|
||||
.some(
|
||||
(item) =>
|
||||
item.id === "message-queued" &&
|
||||
(item.type === "user" || item.type === "synthetic") &&
|
||||
item.delivery === "steer",
|
||||
),
|
||||
)
|
||||
expect(rows).toContainEqual({ type: "message", messageID: "message-queued" })
|
||||
|
||||
@@ -996,7 +1001,12 @@ test("updates and removes queued inputs from durable lifecycle events", async ()
|
||||
await wait(() =>
|
||||
data.session.pending
|
||||
.list(sessionID)
|
||||
.some((item) => item.id === "message-queued" && item.type !== "compaction" && item.delivery === "queue"),
|
||||
.some(
|
||||
(item) =>
|
||||
item.id === "message-queued" &&
|
||||
(item.type === "user" || item.type === "synthetic") &&
|
||||
item.delivery === "queue",
|
||||
),
|
||||
)
|
||||
expect(rows).not.toContainEqual({ type: "message", messageID: "message-queued" })
|
||||
|
||||
|
||||
@@ -87,8 +87,8 @@ test("dialog prompt submit wins when return is also input newline", async () =>
|
||||
const prompt = await mountPrompt({
|
||||
root: tmp.path,
|
||||
keybinds: {
|
||||
input_submit: "super+return",
|
||||
input_newline: "return,shift+return,alt+return,ctrl+j",
|
||||
"input.submit": "super+return",
|
||||
"input.newline": "return,shift+return,alt+return,ctrl+j",
|
||||
},
|
||||
onConfirm: (value) => confirmed.push(value),
|
||||
})
|
||||
@@ -113,7 +113,7 @@ test("dialog prompt submit can be rebound separately from input submit", async (
|
||||
const prompt = await mountPrompt({
|
||||
root: tmp.path,
|
||||
keybinds: {
|
||||
input_submit: "return",
|
||||
"input.submit": "return",
|
||||
"dialog.prompt.submit": "ctrl+y",
|
||||
},
|
||||
onConfirm: (value) => confirmed.push(value),
|
||||
@@ -135,3 +135,29 @@ test("dialog prompt submit can be rebound separately from input submit", async (
|
||||
await prompt.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test("dialog prompt submit can be disabled", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const confirmed: string[] = []
|
||||
const prompt = await mountPrompt({
|
||||
root: tmp.path,
|
||||
keybinds: {
|
||||
"input.submit": "return",
|
||||
"dialog.prompt.submit": "none",
|
||||
},
|
||||
onConfirm: (value) => confirmed.push(value),
|
||||
})
|
||||
|
||||
try {
|
||||
await wait(() => prompt.app.renderer.currentFocusedEditor instanceof TextareaRenderable)
|
||||
const textarea = prompt.app.renderer.currentFocusedEditor
|
||||
if (!(textarea instanceof TextareaRenderable)) throw new Error("expected focused dialog textarea")
|
||||
|
||||
prompt.app.mockInput.pressEnter()
|
||||
|
||||
expect(confirmed).toEqual([])
|
||||
expect(textarea.plainText).toBe("draft")
|
||||
} finally {
|
||||
await prompt.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -14,7 +14,7 @@ import type {
|
||||
import { ThemeProvider, useThemes } from "../../../src/context/theme"
|
||||
import { emptyThemeSource } from "../../fixture/fixture"
|
||||
import { ConfigProvider } from "../../../src/config"
|
||||
import { TuiKeybind } from "../../../src/config/keybind"
|
||||
import type { TuiKeybind } from "../../../src/config/keybind"
|
||||
import { Keymap } from "../../../src/context/keymap"
|
||||
import diffViewerPlugin from "../../../src/feature-plugins/system/diff-viewer"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
@@ -22,6 +22,7 @@ import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
import { createApi, createEventStream, createFetch, json } from "../../fixture/tui-client"
|
||||
import { DialogProvider } from "../../../src/ui/dialog"
|
||||
import { ToastProvider } from "../../../src/ui/toast"
|
||||
import { createSignal } from "solid-js"
|
||||
|
||||
test("closing the diff viewer returns to the route it opened from", async () => {
|
||||
const viewer = await renderDiffViewer([])
|
||||
@@ -49,7 +50,7 @@ test("closing the diff viewer returns to the route it opened from", async () =>
|
||||
})
|
||||
|
||||
test("shows an error instead of an empty diff when loading fails", async () => {
|
||||
const viewer = await renderDiffViewer([], 20, undefined, true)
|
||||
const viewer = await renderDiffViewer([], { fail: true })
|
||||
try {
|
||||
await viewer.app.waitForFrame((frame) => frame.includes("Could not load diff"))
|
||||
expect(viewer.app.captureCharFrame()).not.toContain("No changes to show")
|
||||
@@ -59,7 +60,7 @@ test("shows an error instead of an empty diff when loading fails", async () => {
|
||||
})
|
||||
|
||||
test("uses the active location when opened outside a session", async () => {
|
||||
const viewer = await renderDiffViewer([], 20, { type: "home" })
|
||||
const viewer = await renderDiffViewer([], { initialRoute: { type: "home" } })
|
||||
try {
|
||||
expect(viewer.vcsDiffInput()).toEqual({
|
||||
location: { directory: "/repo/default" },
|
||||
@@ -72,66 +73,35 @@ test("uses the active location when opened outside a session", async () => {
|
||||
})
|
||||
|
||||
test("brackets navigate diff hunks", async () => {
|
||||
const viewer = await renderDiffViewer(
|
||||
[
|
||||
{
|
||||
file: "src/file.ts",
|
||||
additions: 3,
|
||||
deletions: 3,
|
||||
status: "modified",
|
||||
patch: `--- a/src/file.ts
|
||||
+++ b/src/file.ts
|
||||
@@ -1,3 +1,3 @@
|
||||
const first = true
|
||||
-const oldFirst = true
|
||||
+const newFirst = true
|
||||
const afterFirst = true
|
||||
@@ -20,3 +20,3 @@
|
||||
const second = true
|
||||
-const oldSecond = true
|
||||
+const newSecond = true
|
||||
const afterSecond = true
|
||||
@@ -40,3 +40,3 @@
|
||||
const third = true
|
||||
-const oldThird = true
|
||||
+const newThird = true
|
||||
const afterThird = true`,
|
||||
},
|
||||
],
|
||||
12,
|
||||
)
|
||||
const viewer = await renderDiffViewer(hunkDiff, { height: 12 })
|
||||
try {
|
||||
await viewer.app.waitForFrame((frame) => frame.includes("const first"))
|
||||
await viewer.app.waitFor(() => Boolean(findScrollBox(viewer.app.renderer.root)))
|
||||
await viewer.app.flush()
|
||||
expect(viewer.app.captureCharFrame()).toContain("@@ -20,3 +20,3 @@")
|
||||
expect(countDiffs(viewer.app.renderer.root)).toBe(3)
|
||||
const scroll = findScrollBox(viewer.app.renderer.root)!
|
||||
const initial = scroll.scrollTop
|
||||
|
||||
expect(TuiKeybind.defaultValue("diff_next_hunk")).toBe("]")
|
||||
expect(TuiKeybind.defaultValue("diff_previous_hunk")).toBe("[")
|
||||
|
||||
viewer.commands.get("diff.next_hunk")!.run()
|
||||
viewer.app.mockInput.pressKey("]")
|
||||
await viewer.app.renderOnce()
|
||||
const first = scroll.scrollTop
|
||||
expect(first).toBeGreaterThan(initial)
|
||||
|
||||
viewer.commands.get("diff.next_hunk")!.run()
|
||||
viewer.app.mockInput.pressKey("]")
|
||||
await viewer.app.renderOnce()
|
||||
const second = scroll.scrollTop
|
||||
expect(second).toBeGreaterThan(first)
|
||||
|
||||
viewer.commands.get("diff.previous_hunk")!.run()
|
||||
viewer.app.mockInput.pressKey("[")
|
||||
await viewer.app.renderOnce()
|
||||
expect(scroll.scrollTop).toBe(first)
|
||||
|
||||
viewer.commands.get("diff.next_hunk")!.run()
|
||||
viewer.app.mockInput.pressKey("]")
|
||||
await viewer.app.renderOnce()
|
||||
expect(scroll.scrollTop).toBe(second)
|
||||
|
||||
scroll.scrollTo(initial)
|
||||
viewer.commands.get("diff.next_hunk")!.run()
|
||||
viewer.app.mockInput.pressKey("]")
|
||||
await viewer.app.renderOnce()
|
||||
expect(scroll.scrollTop).toBe(first)
|
||||
} finally {
|
||||
@@ -139,13 +109,49 @@ test("brackets navigate diff hunks", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?: Route, fail = false) {
|
||||
test("disabled diff keybinds have no component fallbacks", async () => {
|
||||
const viewer = await renderDiffViewer(hunkDiff, {
|
||||
height: 12,
|
||||
keybinds: disabledDiffKeybinds,
|
||||
})
|
||||
try {
|
||||
await viewer.app.waitForFrame((frame) => frame.includes("const first"))
|
||||
await viewer.app.waitFor(() => Boolean(findScrollBox(viewer.app.renderer.root)))
|
||||
await viewer.app.flush()
|
||||
const scroll = findScrollBox(viewer.app.renderer.root)!
|
||||
const initial = scroll.scrollTop
|
||||
|
||||
Object.keys(disabledDiffKeybinds).forEach((command) => expect(viewer.shortcut(command)).toBe(""))
|
||||
|
||||
viewer.app.mockInput.pressKey("j")
|
||||
await viewer.app.renderOnce()
|
||||
|
||||
expect(scroll.scrollTop).toBe(initial)
|
||||
} finally {
|
||||
viewer.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
async function renderDiffViewer(
|
||||
vcsDiff: unknown[],
|
||||
options: {
|
||||
height?: number
|
||||
initialRoute?: Route
|
||||
fail?: boolean
|
||||
keybinds?: TuiKeybind.KeybindOverrides
|
||||
} = {},
|
||||
) {
|
||||
const commands = new Map<string, KeymapCommand>()
|
||||
let current = initialRoute ?? startRoute
|
||||
const [current, setCurrent] = createSignal<Route>(options.initialRoute ?? startRoute)
|
||||
const currentData = () => {
|
||||
const route = current()
|
||||
return route.type === "plugin" ? route.data : undefined
|
||||
}
|
||||
let renderDiff: Page["render"] | undefined
|
||||
let renderCommands: SlotClaim<"app">["render"] | undefined
|
||||
let vcsDiffInput: unknown
|
||||
const config = createTuiResolvedConfig()
|
||||
let shortcut: (command: string) => string | undefined = () => undefined
|
||||
const config = createTuiResolvedConfig({ keybinds: options.keybinds })
|
||||
const transport = createFetch((url) => {
|
||||
if (url.pathname !== "/api/vcs/diff") return
|
||||
vcsDiffInput = {
|
||||
@@ -153,7 +159,7 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?:
|
||||
mode: url.searchParams.get("mode"),
|
||||
context: url.searchParams.get("context"),
|
||||
}
|
||||
if (fail) return json({ message: "boom" }, { status: 500 })
|
||||
if (options.fail) return json({ message: "boom" }, { status: 500 })
|
||||
return json({
|
||||
location: { directory: "/repo/session", project: { id: "project-1", directory: "/repo/session" } },
|
||||
data: vcsDiff,
|
||||
@@ -161,61 +167,65 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?:
|
||||
}, createEventStream())
|
||||
function Harness() {
|
||||
let theme: ReturnType<ReturnType<typeof useThemes>["currentTokens"]>
|
||||
const context = {
|
||||
options: {},
|
||||
client: createApi(transport.fetch),
|
||||
data: {
|
||||
session: { get: () => session },
|
||||
location: { default: () => ({ directory: "/repo/default" }) },
|
||||
},
|
||||
get theme() {
|
||||
return theme
|
||||
},
|
||||
keymap: {
|
||||
layer(input: () => KeymapLayer) {
|
||||
input().commands?.forEach((command) => {
|
||||
if (command.id) commands.set(command.id, command)
|
||||
})
|
||||
function Content() {
|
||||
const keymap = Keymap.use()
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
shortcut = shortcuts.get
|
||||
theme = useThemes().currentTokens()
|
||||
const context = {
|
||||
options: {},
|
||||
client: createApi(transport.fetch),
|
||||
data: {
|
||||
session: { get: () => session },
|
||||
location: { default: () => ({ directory: "/repo/default" }) },
|
||||
},
|
||||
dispatch() {},
|
||||
shortcuts: () => [],
|
||||
mode: { current: () => "base", push: () => () => {} },
|
||||
},
|
||||
ui: {
|
||||
dialog: {
|
||||
show: () => () => {},
|
||||
set() {},
|
||||
clear() {},
|
||||
get theme() {
|
||||
return theme
|
||||
},
|
||||
router: {
|
||||
register(page: Page) {
|
||||
if (page.name === "diff") renderDiff = page.render
|
||||
keymap: {
|
||||
layer(input: () => KeymapLayer) {
|
||||
input().commands?.forEach((command) => {
|
||||
if (command.id) commands.set(command.id, command)
|
||||
})
|
||||
Keymap.createLayer(input)
|
||||
},
|
||||
dispatch: keymap.dispatch,
|
||||
shortcuts: shortcuts.list,
|
||||
mode: keymap.mode,
|
||||
},
|
||||
ui: {
|
||||
dialog: {
|
||||
show: () => () => {},
|
||||
set() {},
|
||||
clear() {},
|
||||
},
|
||||
router: {
|
||||
register(page: Page) {
|
||||
if (page.name === "diff") renderDiff = page.render
|
||||
return () => {}
|
||||
},
|
||||
navigate(destination: Destination) {
|
||||
setCurrent(
|
||||
destination.type === "plugin" && !("id" in destination)
|
||||
? { ...destination, id: "diff-viewer" }
|
||||
: destination,
|
||||
)
|
||||
},
|
||||
current,
|
||||
},
|
||||
slot(claim: SlotClaim<"app">) {
|
||||
renderCommands = claim.render
|
||||
return () => {}
|
||||
},
|
||||
navigate(destination: Destination) {
|
||||
current =
|
||||
destination.type === "plugin" && !("id" in destination)
|
||||
? { ...destination, id: "diff-viewer" }
|
||||
: destination
|
||||
},
|
||||
current: () => current,
|
||||
},
|
||||
slot(claim: SlotClaim<"app">) {
|
||||
renderCommands = claim.render
|
||||
return () => {}
|
||||
},
|
||||
},
|
||||
} as unknown as Context
|
||||
} as unknown as Context
|
||||
|
||||
void diffViewerPlugin.setup(context)
|
||||
function Content() {
|
||||
theme = useThemes().currentTokens()
|
||||
void diffViewerPlugin.setup(context)
|
||||
const commandView = renderCommands?.({})
|
||||
if (current.type !== "plugin") commands.get("diff.open")?.run()
|
||||
return (
|
||||
<>
|
||||
{commandView}
|
||||
{renderDiff?.({ data: current.type === "plugin" ? current.data : undefined })}
|
||||
{renderDiff?.({ data: currentData() })}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -237,19 +247,60 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?:
|
||||
)
|
||||
}
|
||||
|
||||
const app = await testRender(() => <Harness />, { width: 80, height })
|
||||
await waitForCommand(app, commands, "diff.close")
|
||||
const app = await testRender(() => <Harness />, { width: 80, height: options.height ?? 20 })
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
await app.renderOnce()
|
||||
if (current().type !== "plugin") commands.get("diff.open")?.run()
|
||||
if (commands.has("diff.close")) break
|
||||
await Bun.sleep(25)
|
||||
}
|
||||
await app.waitFor(() => commands.has("diff.close"), { maxPasses: 1 })
|
||||
await app.waitFor(() => vcsDiffInput !== undefined)
|
||||
return {
|
||||
app,
|
||||
commands,
|
||||
current: () => current,
|
||||
current,
|
||||
shortcut: (command: string) => shortcut(command),
|
||||
vcsDiffInput: () => vcsDiffInput,
|
||||
}
|
||||
}
|
||||
|
||||
const startRoute: Route = { type: "session", sessionID: "session-1" }
|
||||
|
||||
const disabledDiffKeybinds = {
|
||||
"diff.down": "none",
|
||||
"diff.up": "none",
|
||||
"diff.page.down": "none",
|
||||
"diff.page.up": "none",
|
||||
"diff.mark_reviewed": "none",
|
||||
} satisfies TuiKeybind.KeybindOverrides
|
||||
|
||||
const hunkDiff = [
|
||||
{
|
||||
file: "src/file.txt",
|
||||
additions: 3,
|
||||
deletions: 3,
|
||||
status: "modified",
|
||||
patch: `--- a/src/file.txt
|
||||
+++ b/src/file.txt
|
||||
@@ -1,3 +1,3 @@
|
||||
const first = true
|
||||
-const oldFirst = true
|
||||
+const newFirst = true
|
||||
const afterFirst = true
|
||||
@@ -20,3 +20,3 @@
|
||||
const second = true
|
||||
-const oldSecond = true
|
||||
+const newSecond = true
|
||||
const afterSecond = true
|
||||
@@ -40,3 +40,3 @@
|
||||
const third = true
|
||||
-const oldThird = true
|
||||
+const newThird = true
|
||||
const afterThird = true`,
|
||||
},
|
||||
]
|
||||
|
||||
function findScrollBox(root: Renderable): ScrollBoxRenderable | undefined {
|
||||
if (root instanceof ScrollBoxRenderable && containsDiff(root)) return root
|
||||
return root.getChildren().map(findScrollBox).find(Boolean)
|
||||
@@ -280,11 +331,13 @@ const session = {
|
||||
}
|
||||
|
||||
test("branch diff source requests branch VCS diff", async () => {
|
||||
const viewer = await renderDiffViewer([], 20, {
|
||||
type: "plugin",
|
||||
id: "diff-viewer",
|
||||
name: "diff",
|
||||
data: { mode: "branch", sessionID: "session-1", returnRoute: startRoute },
|
||||
const viewer = await renderDiffViewer([], {
|
||||
initialRoute: {
|
||||
type: "plugin",
|
||||
id: "diff-viewer",
|
||||
name: "diff",
|
||||
data: { mode: "branch", sessionID: "session-1", returnRoute: startRoute },
|
||||
},
|
||||
})
|
||||
try {
|
||||
expect(viewer.current()).toEqual({
|
||||
@@ -302,15 +355,3 @@ test("branch diff source requests branch VCS diff", async () => {
|
||||
viewer.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
async function waitForCommand(
|
||||
app: Awaited<ReturnType<typeof testRender>>,
|
||||
commands: Map<string, unknown>,
|
||||
command: string,
|
||||
) {
|
||||
for (let attempt = 0; attempt < 10; attempt++) {
|
||||
await app.renderOnce()
|
||||
if (commands.has(command)) return
|
||||
await new Promise((resolve) => setTimeout(resolve, 25))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,15 +4,17 @@ import { expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { resolve, ConfigProvider, Info, useConfig, type Interface } from "../src/config"
|
||||
import { settings } from "../src/component/dialog-config"
|
||||
import { TuiKeybind } from "../src/config/keybind"
|
||||
import { CommandMap, Definitions } from "../src/config/v1/keybind"
|
||||
|
||||
const decodeInfo = Schema.decodeUnknownSync(Info)
|
||||
|
||||
test("validates mini replay settings", () => {
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
expect(decode({ mini: { replay: false, replay_limit: 50 } })).toEqual({
|
||||
expect(decodeInfo({ mini: { replay: false, replay_limit: 50 } })).toEqual({
|
||||
mini: { replay: false, replay_limit: 50 },
|
||||
})
|
||||
expect(() => decode({ mini: { replay_limit: 0 } })).toThrow()
|
||||
expect(() => decode({ mini: { replay_limit: 1.5 } })).toThrow()
|
||||
expect(() => decodeInfo({ mini: { replay_limit: 0 } })).toThrow()
|
||||
expect(() => decodeInfo({ mini: { replay_limit: 1.5 } })).toThrow()
|
||||
})
|
||||
|
||||
test("validates the session tabs setting", () => {
|
||||
@@ -25,6 +27,8 @@ test("validates the session tabs setting", () => {
|
||||
expect(() => decode({ tabs: { enabled: "on" } })).toThrow()
|
||||
expect(decode({ prompt: { image_preview: true } })).toEqual({ prompt: { image_preview: true } })
|
||||
expect(decode({ session: { image_preview: true } })).toEqual({ session: { image_preview: true } })
|
||||
expect(decode({ session: { new_location: "inherit" } })).toEqual({ session: { new_location: "inherit" } })
|
||||
expect(() => decode({ session: { new_location: "current" } })).toThrow()
|
||||
})
|
||||
|
||||
test("resolves nested config and keybind defaults", () => {
|
||||
@@ -45,6 +49,7 @@ test("resolves nested config and keybind defaults", () => {
|
||||
expect(config.diffs).toEqual({ view: "split" })
|
||||
expect(config.debug).toEqual({ devtools: true })
|
||||
expect(config.tabs).toEqual({ enabled: true, scope: "cwd", layout: "horizontal" })
|
||||
expect(config.session.new_location).toBe("launch")
|
||||
})
|
||||
|
||||
test("shows resolved tab defaults in settings", () => {
|
||||
@@ -53,6 +58,109 @@ test("shows resolved tab defaults in settings", () => {
|
||||
expect(settings.find((setting) => setting.path.join(".") === "tabs.layout")?.default).toBe("horizontal")
|
||||
})
|
||||
|
||||
test("shows the new session location default in settings", () => {
|
||||
expect(settings.find((setting) => setting.path.join(".") === "session.new_location")?.default).toBe("launch")
|
||||
})
|
||||
|
||||
test("uses command IDs as keybind keys", () => {
|
||||
const config = resolve({ keybinds: { "session.list": "ctrl+l" } }, { terminalSuspend: true })
|
||||
|
||||
expect(config.keybinds.get("session.list")).toMatchObject([{ key: "ctrl+l" }])
|
||||
expect(TuiKeybind.unknownKeys({ session_list: "ctrl+l" })).toEqual(["session_list"])
|
||||
expect(
|
||||
Object.keys(TuiKeybind.Definitions)
|
||||
.filter((key) => key !== "leader")
|
||||
.every((key) => key.includes(".")),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("preserves migrated v1 keybind defaults", () => {
|
||||
const pairs = [
|
||||
["app.exit", "app_exit"],
|
||||
["prompt.paste", "input_paste"],
|
||||
["session.delete", "session_delete"],
|
||||
["session.list", "session_list"],
|
||||
["agent.list", "agent_list"],
|
||||
] as const
|
||||
|
||||
pairs.forEach(([command, name]) => {
|
||||
expect(CommandMap[name]).toBe(command)
|
||||
expect(TuiKeybind.Definitions[command].default).toEqual(Definitions[name].default)
|
||||
})
|
||||
})
|
||||
|
||||
test("accepts every v2-only named command ID", () => {
|
||||
const commands = [
|
||||
"server.pair",
|
||||
"session.toggle.exploration_grouping",
|
||||
"composer.subagent.up",
|
||||
"composer.subagent.down",
|
||||
"composer.subagent.select",
|
||||
"composer.subagent.interrupt",
|
||||
"composer.shell.up",
|
||||
"composer.shell.down",
|
||||
"composer.shell.kill",
|
||||
"diff.down",
|
||||
"diff.up",
|
||||
"diff.page.down",
|
||||
"diff.page.up",
|
||||
"diff.mark_reviewed",
|
||||
"opencode.settings",
|
||||
"service.restart",
|
||||
"permission.mode",
|
||||
"session.cd",
|
||||
"app.scrap",
|
||||
]
|
||||
const config = resolve(
|
||||
decodeInfo({ keybinds: Object.fromEntries(commands.map((command) => [command, "ctrl+alt+z"])) }),
|
||||
{ terminalSuspend: true },
|
||||
)
|
||||
|
||||
commands.forEach((command) => expect(config.keybinds.get(command)).toMatchObject([{ key: "ctrl+alt+z" }]))
|
||||
})
|
||||
|
||||
test("centralizes named command defaults and resolves explicit none", () => {
|
||||
const defaults = {
|
||||
"composer.subagent.up": "up",
|
||||
"composer.subagent.down": "down",
|
||||
"composer.subagent.select": "return",
|
||||
"composer.subagent.interrupt": "ctrl+d",
|
||||
"composer.shell.up": "up",
|
||||
"composer.shell.down": "down",
|
||||
"composer.shell.kill": "ctrl+d",
|
||||
"diff.down": "j,down",
|
||||
"diff.up": "k,up",
|
||||
"diff.page.down": "pagedown,ctrl+f",
|
||||
"diff.page.up": "pageup,ctrl+b",
|
||||
"diff.mark_reviewed": "m",
|
||||
}
|
||||
const config = resolve({}, { terminalSuspend: true })
|
||||
Object.entries(defaults).forEach(([command, key]) => expect(config.keybinds.get(command)).toMatchObject([{ key }]))
|
||||
|
||||
const disabled = resolve(
|
||||
decodeInfo({ keybinds: Object.fromEntries(Object.keys(defaults).map((command) => [command, "none"])) }),
|
||||
{ terminalSuspend: true },
|
||||
)
|
||||
Object.keys(defaults).forEach((command) => expect(disabled.keybinds.get(command)).toEqual([]))
|
||||
})
|
||||
|
||||
test("rejects orphaned keybind definitions", () => {
|
||||
expect(decodeInfo({ keybinds: { "app.heap_snapshot": "ctrl+h" } })).toEqual({ keybinds: {} })
|
||||
})
|
||||
|
||||
test("uses ctrl+z for input undo when terminal suspend is unavailable", () => {
|
||||
const config = resolve({}, { terminalSuspend: false })
|
||||
expect(config.keybinds.has("terminal.suspend")).toBe(false)
|
||||
expect(config.keybinds.get("input.undo")).toMatchObject([{ key: "ctrl+z,ctrl+-,super+z" }])
|
||||
|
||||
const overridden = resolve(
|
||||
{ keybinds: { "terminal.suspend": "ctrl+s", "input.undo": "ctrl+u" } },
|
||||
{ terminalSuspend: false },
|
||||
)
|
||||
expect(overridden.keybinds.has("terminal.suspend")).toBe(false)
|
||||
expect(overridden.keybinds.get("input.undo")).toMatchObject([{ key: "ctrl+u" }])
|
||||
})
|
||||
|
||||
test("provides config and its host interface", async () => {
|
||||
const config = resolve({}, { terminalSuspend: true })
|
||||
let current = {}
|
||||
|
||||
@@ -7,6 +7,7 @@ import path from "path"
|
||||
import { ConfigProvider } from "../../src/config"
|
||||
import { ClientProvider, useClient } from "../../src/context/client"
|
||||
import { DataProvider, useData } from "../../src/context/data"
|
||||
import { LocationProvider } from "../../src/context/location"
|
||||
import { RouteProvider, useRoute } from "../../src/context/route"
|
||||
import { TuiAppProvider } from "../../src/context/runtime"
|
||||
import { SessionTabsProvider, useSessionTabs } from "../../src/context/session-tabs"
|
||||
@@ -86,9 +87,11 @@ async function renderSessionTabs(
|
||||
>
|
||||
<ClientProvider api={createApi(calls.fetch)}>
|
||||
<DataProvider>
|
||||
<SessionTabsProvider>
|
||||
<Probe />
|
||||
</SessionTabsProvider>
|
||||
<LocationProvider>
|
||||
<SessionTabsProvider>
|
||||
<Probe />
|
||||
</SessionTabsProvider>
|
||||
</LocationProvider>
|
||||
</DataProvider>
|
||||
</ClientProvider>
|
||||
</RouteProvider>
|
||||
@@ -272,3 +275,17 @@ test("tracks a temporary new session tab across close and creation", async () =>
|
||||
await setup.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("add opens the new session tab carrying the current session's location", async () => {
|
||||
const setup = await renderSessionTabs("first")
|
||||
|
||||
try {
|
||||
await wait(() => setup.tabs.current() === "first" && setup.data.session.get("first") !== undefined)
|
||||
setup.tabs.add()
|
||||
expect(setup.route.data).toEqual({ type: "home", location: { directory } })
|
||||
await wait(() => setup.tabs.newTab())
|
||||
expect(setup.tabs.tabs().map((tab) => tab.sessionID)).toEqual(["first"])
|
||||
} finally {
|
||||
await setup.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2,6 +2,6 @@ import { expect, test } from "bun:test"
|
||||
import { TuiKeybind } from "../src/config/keybind"
|
||||
|
||||
test("binds agent cycling only to shift+tab by default", () => {
|
||||
expect(TuiKeybind.Definitions.agent_cycle.default).toBe("shift+tab")
|
||||
expect(TuiKeybind.Definitions.agent_cycle_reverse.default).toBe("none")
|
||||
expect(TuiKeybind.Definitions["agent.cycle"].default).toBe("shift+tab")
|
||||
expect(TuiKeybind.Definitions["agent.cycle.reverse"].default).toBe("none")
|
||||
})
|
||||
|
||||
@@ -27,8 +27,8 @@ test("legacy page key aliases compile as page keys", async () => {
|
||||
<ConfigProvider
|
||||
config={createTuiResolvedConfig({
|
||||
keybinds: {
|
||||
messages_page_up: "pgup",
|
||||
messages_page_down: "pgdown",
|
||||
"session.page.up": "pgup",
|
||||
"session.page.down": "pgdown",
|
||||
},
|
||||
})}
|
||||
>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { RunFooterView } from "../../src/mini/footer.view"
|
||||
import { RUN_THEME_FALLBACK } from "../../src/mini/theme"
|
||||
import type { FooterState, FooterSubagentState, FooterView } from "../../src/mini/types"
|
||||
|
||||
test("down opens subagents from an empty prompt", async () => {
|
||||
async function renderSubagent(interrupt: "ctrl+i" | "none") {
|
||||
const [state] = createSignal<FooterState>({
|
||||
phase: "idle",
|
||||
status: "",
|
||||
@@ -34,9 +34,17 @@ test("down opens subagents from an empty prompt", async () => {
|
||||
forms: [],
|
||||
})
|
||||
const config = resolve(
|
||||
{ keybinds: { editor_open: "none", session_queued_prompts: "none" } },
|
||||
{
|
||||
keybinds: {
|
||||
"prompt.editor": "none",
|
||||
"session.queued_prompts": "none",
|
||||
"composer.subagent.interrupt": interrupt,
|
||||
},
|
||||
},
|
||||
{ terminalSuspend: true },
|
||||
)
|
||||
const interrupted: string[] = []
|
||||
|
||||
function Harness() {
|
||||
return (
|
||||
<Keymap.Provider config={config}>
|
||||
@@ -82,18 +90,47 @@ test("down opens subagents from an empty prompt", async () => {
|
||||
onLayout={() => {}}
|
||||
onStatus={() => {}}
|
||||
onMiniSettingChange={() => {}}
|
||||
onSubagentInterrupt={(sessionID) => interrupted.push(sessionID)}
|
||||
/>
|
||||
</Keymap.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const app = await testRender(() => <Harness />, { width: 100, height: 8, kittyKeyboard: true })
|
||||
return { app, interrupted }
|
||||
}
|
||||
|
||||
async function openSubagent(app: Awaited<ReturnType<typeof testRender>>) {
|
||||
await app.renderOnce()
|
||||
expect(app.renderer.currentFocusedEditor?.plainText).toBe("")
|
||||
app.mockInput.pressArrow("down")
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).toContain("Select subagent")
|
||||
app.mockInput.pressEnter()
|
||||
await app.renderOnce()
|
||||
}
|
||||
|
||||
test("configured subagent key updates its hint and action", async () => {
|
||||
const { app, interrupted } = await renderSubagent("ctrl+i")
|
||||
try {
|
||||
await app.renderOnce()
|
||||
expect(app.renderer.currentFocusedEditor?.plainText).toBe("")
|
||||
app.mockInput.pressArrow("down")
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).toContain("Select subagent")
|
||||
await openSubagent(app)
|
||||
expect(app.captureCharFrame()).toContain("ctrl+i")
|
||||
app.mockInput.pressKey("i", { ctrl: true })
|
||||
expect(interrupted).toEqual(["subagent-1"])
|
||||
} finally {
|
||||
app.renderer.currentFocusedRenderable?.blur()
|
||||
app.renderer.currentFocusedEditor?.blur()
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("disabled subagent interrupt has no component fallback", async () => {
|
||||
const { app, interrupted } = await renderSubagent("none")
|
||||
try {
|
||||
await openSubagent(app)
|
||||
expect(app.captureCharFrame()).not.toContain("ctrl+d")
|
||||
app.mockInput.pressKey("d", { ctrl: true })
|
||||
expect(interrupted).toEqual([])
|
||||
} finally {
|
||||
app.renderer.currentFocusedRenderable?.blur()
|
||||
app.renderer.currentFocusedEditor?.blur()
|
||||
|
||||
@@ -1072,7 +1072,7 @@ test.skip("direct footer recreates the frame across command panel transitions",
|
||||
test.skip("direct footer dispatches leader variant binding only when leader is registered", async () => {
|
||||
const calls: string[] = []
|
||||
const app = await renderFooter({
|
||||
tuiConfig: createTuiResolvedConfig({ keybinds: { leader: "ctrl+x", variant_cycle: "<leader>t" } }),
|
||||
tuiConfig: createTuiResolvedConfig({ keybinds: { leader: "ctrl+x", "variant.cycle": "<leader>t" } }),
|
||||
onCycle: () => calls.push("cycle"),
|
||||
})
|
||||
|
||||
@@ -1092,7 +1092,7 @@ test.skip("direct footer dispatches leader variant binding only when leader is r
|
||||
test("direct footer keeps leader variant binding inactive when leader is disabled", async () => {
|
||||
const calls: string[] = []
|
||||
const app = await renderFooter({
|
||||
tuiConfig: createTuiResolvedConfig({ keybinds: { leader: "none", variant_cycle: "<leader>t" } }),
|
||||
tuiConfig: createTuiResolvedConfig({ keybinds: { leader: "none", "variant.cycle": "<leader>t" } }),
|
||||
onCycle: () => calls.push("cycle"),
|
||||
})
|
||||
|
||||
@@ -1603,7 +1603,7 @@ test("direct footer keeps the command hint at its minimum width", async () => {
|
||||
|
||||
test("direct footer keeps complete status text ahead of the spinner", async () => {
|
||||
const app = await renderFooter({
|
||||
tuiConfig: createTuiResolvedConfig({ keybinds: { session_interrupt: "none" } }),
|
||||
tuiConfig: createTuiResolvedConfig({ keybinds: { "session.interrupt": "none" } }),
|
||||
state: { phase: "running" },
|
||||
width: 22,
|
||||
})
|
||||
@@ -1663,7 +1663,7 @@ test("direct footer hides the subagent hint when only completed subagents remain
|
||||
|
||||
test("direct footer omits interrupt key hint when interrupt is unbound", async () => {
|
||||
const app = await renderFooter({
|
||||
tuiConfig: createTuiResolvedConfig({ keybinds: { session_interrupt: "none", input_clear: "ctrl+l" } }),
|
||||
tuiConfig: createTuiResolvedConfig({ keybinds: { "session.interrupt": "none", "prompt.clear": "ctrl+l" } }),
|
||||
state: { phase: "running" },
|
||||
mono: true,
|
||||
})
|
||||
|
||||
@@ -1,75 +1,14 @@
|
||||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||
import { OpenCode } from "@opencode-ai/client/promise"
|
||||
import type { Resolved } from "../../src/config"
|
||||
import { resolveMiniSettings, resolveModelInfo, resolveRunTuiConfig } from "../../src/mini/runtime.boot"
|
||||
import { catalogModel, catalogProvider } from "./fixture/catalog"
|
||||
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
|
||||
|
||||
function config(input?: {
|
||||
leader?: string
|
||||
leaderTimeout?: number
|
||||
bindings?: Partial<{
|
||||
commandList: string[]
|
||||
variantCycle: string[]
|
||||
interrupt: string[]
|
||||
historyPrevious: string[]
|
||||
historyNext: string[]
|
||||
inputClear: string[]
|
||||
inputSubmit: string[]
|
||||
inputNewline: string[]
|
||||
}>
|
||||
}): Resolved {
|
||||
const bind = input?.bindings
|
||||
return createTuiResolvedConfig({
|
||||
leader: input?.leaderTimeout === undefined ? undefined : { timeout: input.leaderTimeout },
|
||||
keybinds: {
|
||||
...(input?.leader && { leader: input.leader }),
|
||||
...(bind?.commandList && { command_list: bind.commandList }),
|
||||
...(bind?.variantCycle && { variant_cycle: bind.variantCycle }),
|
||||
...(bind?.interrupt && { session_interrupt: bind.interrupt }),
|
||||
...(bind?.historyPrevious && { history_previous: bind.historyPrevious }),
|
||||
...(bind?.historyNext && { history_next: bind.historyNext }),
|
||||
...(bind?.inputClear && { input_clear: bind.inputClear }),
|
||||
...(bind?.inputSubmit && { input_submit: bind.inputSubmit }),
|
||||
...(bind?.inputNewline && { input_newline: bind.inputNewline }),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe("run runtime boot", () => {
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
test("reads footer keybinds from resolved keybind config", async () => {
|
||||
const input = config({
|
||||
leader: "ctrl+g",
|
||||
bindings: {
|
||||
commandList: ["ctrl+p"],
|
||||
variantCycle: ["ctrl+t", "alt+t"],
|
||||
interrupt: ["ctrl+c"],
|
||||
historyPrevious: ["k"],
|
||||
historyNext: ["j"],
|
||||
inputClear: ["ctrl+l"],
|
||||
inputSubmit: ["ctrl+s"],
|
||||
inputNewline: ["alt+return"],
|
||||
},
|
||||
})
|
||||
|
||||
const result = await resolveRunTuiConfig(input)
|
||||
|
||||
expect(result.keybinds.get("leader")?.[0]?.key).toBe("ctrl+g")
|
||||
expect(result.leader.timeout).toBe(2000)
|
||||
expect(result.keybinds.get("command.palette.show")?.[0]?.key).toBe("ctrl+p")
|
||||
expect(result.keybinds.get("variant.cycle").map((item) => item.key)).toEqual(["ctrl+t", "alt+t"])
|
||||
expect(result.keybinds.get("session.interrupt")?.[0]?.key).toBe("ctrl+c")
|
||||
expect(result.keybinds.get("prompt.history.previous")?.[0]?.key).toBe("k")
|
||||
expect(result.keybinds.get("prompt.history.next")?.[0]?.key).toBe("j")
|
||||
expect(result.keybinds.get("prompt.clear")?.[0]?.key).toBe("ctrl+l")
|
||||
expect(result.keybinds.get("input.submit")?.[0]?.key).toBe("ctrl+s")
|
||||
expect(result.keybinds.get("input.newline")?.[0]?.key).toBe("alt+return")
|
||||
})
|
||||
|
||||
test("falls back to default tui keymap config when config load fails", async () => {
|
||||
const result = await resolveRunTuiConfig(Promise.reject(new Error("boom")))
|
||||
|
||||
@@ -86,12 +25,6 @@ describe("run runtime boot", () => {
|
||||
expect(result.keybinds.get("prompt.queue")?.[0]?.key).toBe("alt+return")
|
||||
})
|
||||
|
||||
test("preserves disabled leader from resolved tui config", async () => {
|
||||
const result = await resolveRunTuiConfig(config({ leader: "none" }))
|
||||
|
||||
expect(result.keybinds.get("leader")).toEqual([])
|
||||
})
|
||||
|
||||
test("preserves shared config while resolving independent Mini defaults", async () => {
|
||||
const result = await resolveRunTuiConfig(
|
||||
createTuiResolvedConfig({
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { LifecycleInput } from "../../src/mini/runtime.lifecycle"
|
||||
import type { FooterEvent, MiniHost } from "../../src/mini/types"
|
||||
import { catalogModel, catalogProvider, stubCatalogLists } from "./fixture/catalog"
|
||||
import { createFooterApiFixture } from "./fixture/footer-api"
|
||||
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
|
||||
|
||||
function defer<T>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void
|
||||
@@ -488,7 +489,7 @@ describe("run interactive runtime", () => {
|
||||
expect(closedTitle).toBe("Cached title")
|
||||
})
|
||||
|
||||
test("adopts the deferred target location for catalogs, files, and runtime placement", async () => {
|
||||
test("adopts deferred target placement and supplied TUI config", async () => {
|
||||
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
|
||||
const lifecycleStarted = defer<void>()
|
||||
const painted = defer<void>()
|
||||
@@ -498,6 +499,8 @@ describe("run interactive runtime", () => {
|
||||
let getDirectory: (() => string) | undefined
|
||||
let findFiles: ((query: string) => Promise<string[]>) | undefined
|
||||
let transportLocation: unknown
|
||||
let runtimeConfig: LifecycleInput["tuiConfig"] | undefined
|
||||
const tuiConfig = createTuiResolvedConfig({ keybinds: { "variant.cycle": "ctrl+g" } })
|
||||
const catalogs = stubCatalogLists(sdk, {
|
||||
location: { directory: "/session", workspaceID: "work-1" },
|
||||
})
|
||||
@@ -534,11 +537,13 @@ describe("run interactive runtime", () => {
|
||||
model: undefined,
|
||||
variant: undefined,
|
||||
files: [],
|
||||
tuiConfig,
|
||||
},
|
||||
{
|
||||
createRuntimeLifecycle: async (input) => {
|
||||
getDirectory = input.getDirectory
|
||||
findFiles = input.findFiles
|
||||
runtimeConfig = input.tuiConfig
|
||||
lifecycleStarted.resolve()
|
||||
return {
|
||||
footer: api,
|
||||
@@ -577,6 +582,8 @@ describe("run interactive runtime", () => {
|
||||
|
||||
const query = { location: { directory: "/session", workspace: "work-1" } }
|
||||
expect(getDirectory?.()).toBe("/session")
|
||||
if (!runtimeConfig) throw new Error("runtime lifecycle did not receive TUI config")
|
||||
expect(await runtimeConfig).toBe(tuiConfig)
|
||||
expect(transportLocation).toMatchObject({ directory: "/session", workspaceID: "work-1" })
|
||||
expect(catalogs.provider).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
|
||||
expect(catalogs.model).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { newSessionLocation } from "../src/config/new-session-location"
|
||||
|
||||
test("uses the launch directory by default", () => {
|
||||
expect(newSessionLocation("launch", "/launch", { directory: "/session", workspaceID: "work-1" })).toEqual({
|
||||
directory: "/launch",
|
||||
})
|
||||
})
|
||||
|
||||
test("inherits the active session location when configured", () => {
|
||||
expect(newSessionLocation("inherit", "/launch", { directory: "/session", workspaceID: "work-1" })).toEqual({
|
||||
directory: "/session",
|
||||
workspaceID: "work-1",
|
||||
})
|
||||
})
|
||||
|
||||
test("falls back to the launch directory without an active session", () => {
|
||||
expect(newSessionLocation("inherit", "/launch")).toEqual({ directory: "/launch" })
|
||||
})
|
||||
@@ -13083,6 +13083,9 @@
|
||||
},
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["id", "time", "type", "text"],
|
||||
|
||||
@@ -13083,6 +13083,9 @@
|
||||
},
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["id", "time", "type", "text"],
|
||||
|
||||
Reference in New Issue
Block a user