mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-12 04:29:50 -04:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 01cbdcb8e4 | |||
| bf751a907d | |||
| 9d63ca8f90 | |||
| e3bd82013c | |||
| 90b6fa0eab | |||
| 5b7b1830d2 | |||
| a8fc664b6d | |||
| d853ff8848 | |||
| 94bc0fc6fa | |||
| d3eecf7ba2 | |||
| 99166f7c17 | |||
| 08dcf7d731 | |||
| caae28e0d4 | |||
| c83933d1d4 | |||
| c86f1c41ff | |||
| 5c0cc8e617 | |||
| 1b45061afb | |||
| 07bcd290c2 | |||
| 04ad06e2e3 | |||
| 93965df860 | |||
| f0333e0eea | |||
| 452335a78d | |||
| 9322f5d2c9 |
@@ -1,13 +0,0 @@
|
||||
import type { Context } from "../../../packages/plugin/src/tui/context"
|
||||
|
||||
export default {
|
||||
id: "test.tui-discovery-smoke",
|
||||
setup(_context: Context) {
|
||||
// context.ui.toast.show({
|
||||
// title: "TUI plugin discovery works",
|
||||
// message: "Loaded .opencode/plugins/tui/discovery-smoke.ts",
|
||||
// variant: "success",
|
||||
// duration: 30_000,
|
||||
// })
|
||||
},
|
||||
}
|
||||
@@ -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",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -658,7 +658,7 @@ function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () =
|
||||
|
||||
return (
|
||||
<>
|
||||
{["beta", "dev"].includes(channel) && (
|
||||
{["local", "beta", "dev"].includes(channel) && (
|
||||
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
|
||||
{channel.toUpperCase()}
|
||||
</div>
|
||||
|
||||
Vendored
+1
-1
@@ -1,7 +1,7 @@
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_OPENCODE_SERVER_HOST: string
|
||||
readonly VITE_OPENCODE_SERVER_PORT: string
|
||||
readonly VITE_OPENCODE_CHANNEL?: "dev" | "beta" | "prod"
|
||||
readonly VITE_OPENCODE_CHANNEL?: "local" | "dev" | "beta" | "prod"
|
||||
|
||||
readonly VITE_SENTRY_DSN?: string
|
||||
readonly VITE_SENTRY_ENVIRONMENT?: string
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { ServiceStatus } from "@opencode-ai/protocol/groups/health"
|
||||
import { Effect, FileSystem, Option, Schedule, Schema } from "effect"
|
||||
import { spawn, type ChildProcess } from "node:child_process"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import type { DiscoverOptions, Endpoint, EnsureOptions, StopOptions } from "../service.js"
|
||||
import {
|
||||
contenderFailure,
|
||||
contenderFinished,
|
||||
type ServiceContender,
|
||||
spawnServiceContender,
|
||||
} from "../service-contender.js"
|
||||
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
|
||||
|
||||
export * from "../service.js"
|
||||
@@ -18,11 +23,6 @@ export type Info = import("../service.js").Info
|
||||
// is all a client needs to connect. The daemon's own configuration (port,
|
||||
// persisted password) is CLI-owned and never read here.
|
||||
|
||||
type Contender = {
|
||||
readonly child: ChildProcess
|
||||
readonly error: () => Error | undefined
|
||||
}
|
||||
|
||||
// Read-only lookup: registration file plus health check and version gate.
|
||||
// Never spawns; escalation to ensure() is the caller's policy.
|
||||
/** Discover a healthy, compatible local service without starting one. */
|
||||
@@ -54,7 +54,7 @@ const discoverLocal = Effect.fnUntraced(function* (options: DiscoverOptions) {
|
||||
/** Ensure a healthy, compatible local service is running. */
|
||||
export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOptions = {}) {
|
||||
const timing = ensureTiming(options)
|
||||
const contenders = new Set<Contender>()
|
||||
const contenders = new Set<ServiceContender>()
|
||||
let timeouts: { readonly info: Info; readonly count: number } | undefined
|
||||
let announced = false
|
||||
let lastSpawn = 0
|
||||
@@ -70,13 +70,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
if (command === undefined) return yield* Effect.fail(new Error("Missing service command"))
|
||||
return yield* Effect.try({
|
||||
try: () => {
|
||||
const child = spawn(command, args, { detached: true, stdio: "ignore" })
|
||||
let error: Error | undefined
|
||||
child.once("error", (cause) => {
|
||||
error = new Error("Failed to start server", { cause })
|
||||
})
|
||||
child.unref()
|
||||
return { child, error: () => error }
|
||||
return spawnServiceContender(command, args)
|
||||
},
|
||||
catch: (cause) => new Error("Failed to start server", { cause }),
|
||||
})
|
||||
@@ -129,26 +123,13 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
until: Option.isSome,
|
||||
schedule: Schedule.max([Schedule.spaced(timing.pollInterval), Schedule.recurs(timing.attempts)]),
|
||||
}),
|
||||
Effect.ensuring(Effect.sync(() => contenders.forEach((contender) => contender.release()))),
|
||||
)
|
||||
if (Option.isNone(found))
|
||||
return yield* Effect.fail(new Error("Timed out waiting for the background service to start"))
|
||||
return found.value.endpoint
|
||||
})
|
||||
|
||||
function contenderFailure(contender: Contender) {
|
||||
const error = contender.error()
|
||||
if (error !== undefined) return error
|
||||
if (contender.child.exitCode !== null && contender.child.exitCode !== 0)
|
||||
return new Error(`Server process exited with code ${contender.child.exitCode}`)
|
||||
if (contender.child.signalCode !== null)
|
||||
return new Error(`Server process terminated by ${contender.child.signalCode}`)
|
||||
return undefined
|
||||
}
|
||||
|
||||
function contenderFinished(contender: Contender) {
|
||||
return contender.error() !== undefined || contender.child.exitCode !== null || contender.child.signalCode !== null
|
||||
}
|
||||
|
||||
/** Stop the registered local service. */
|
||||
export const stop = Effect.fn("service.stop")(function* (options: StopOptions = {}) {
|
||||
const existing = yield* find(options)
|
||||
|
||||
@@ -65,6 +65,7 @@ export type SessionMessageSystem = {
|
||||
time: { created: number }
|
||||
type: "system"
|
||||
text: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export type SessionMessageSkill = {
|
||||
@@ -408,6 +409,17 @@ export type ProviderRequest = {
|
||||
|
||||
export type PermissionRule = { action: string; resource: string; effect: PermissionEffect }
|
||||
|
||||
export type SessionMessageLocationSwitched = {
|
||||
id: string
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
time: { created: number }
|
||||
type: "location-switched"
|
||||
location: LocationRef
|
||||
projectID?: string
|
||||
subpath?: string
|
||||
previous?: { location: LocationRef; projectID?: string; subpath?: string }
|
||||
}
|
||||
|
||||
export type SessionCreated = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -1943,6 +1955,7 @@ export type SessionInputAdmitted = {
|
||||
export type SessionMessageInfo =
|
||||
| SessionMessageAgentSelected
|
||||
| SessionMessageModelSelected
|
||||
| SessionMessageLocationSwitched
|
||||
| SessionMessageUser
|
||||
| SessionMessageSynthetic
|
||||
| SessionMessageSystem
|
||||
@@ -2546,6 +2559,20 @@ export type SessionImportInput = {
|
||||
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||
readonly previous?: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "location-switched"
|
||||
readonly location: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly projectID?: string
|
||||
readonly subpath?: string
|
||||
readonly previous?: {
|
||||
readonly location: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly projectID?: string
|
||||
readonly subpath?: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
@@ -2585,6 +2612,7 @@ export type SessionImportInput = {
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "system"
|
||||
readonly text: string
|
||||
readonly description?: string
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
@@ -2798,6 +2826,20 @@ export type SessionImportInput = {
|
||||
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||
readonly previous?: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "location-switched"
|
||||
readonly location: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly projectID?: string
|
||||
readonly subpath?: string
|
||||
readonly previous?: {
|
||||
readonly location: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly projectID?: string
|
||||
readonly subpath?: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
@@ -2837,6 +2879,7 @@ export type SessionImportInput = {
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "system"
|
||||
readonly text: string
|
||||
readonly description?: string
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
@@ -3050,6 +3093,20 @@ export type SessionImportInput = {
|
||||
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||
readonly previous?: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "location-switched"
|
||||
readonly location: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly projectID?: string
|
||||
readonly subpath?: string
|
||||
readonly previous?: {
|
||||
readonly location: { readonly directory: string; readonly workspaceID?: string }
|
||||
readonly projectID?: string
|
||||
readonly subpath?: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
@@ -3089,6 +3146,7 @@ export type SessionImportInput = {
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "system"
|
||||
readonly text: string
|
||||
readonly description?: string
|
||||
}
|
||||
| {
|
||||
readonly id: string
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { readFile } from "node:fs/promises"
|
||||
import { spawn, type ChildProcess } from "node:child_process"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import type { DiscoverOptions, Endpoint, Info, EnsureOptions, StopOptions } from "../service.js"
|
||||
import {
|
||||
contenderFailure,
|
||||
contenderFinished,
|
||||
type ServiceContender,
|
||||
spawnServiceContender,
|
||||
} from "../service-contender.js"
|
||||
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
|
||||
import type { ServiceHealth, ServiceStopResponse } from "./generated/types.js"
|
||||
|
||||
@@ -14,11 +19,6 @@ export * from "../service.js"
|
||||
// intentionally implemented with Node APIs so Promise clients do not need
|
||||
// Effect or @effect/platform-node at runtime.
|
||||
|
||||
type Contender = {
|
||||
readonly child: ChildProcess
|
||||
readonly error: () => Error | undefined
|
||||
}
|
||||
|
||||
/** Discover a healthy, compatible local service without starting one. */
|
||||
export async function discover(options: DiscoverOptions = {}) {
|
||||
return (await discoverLocal(options))?.endpoint
|
||||
@@ -35,7 +35,7 @@ async function discoverLocal(options: DiscoverOptions) {
|
||||
export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
const timing = ensureTiming(options)
|
||||
const deadline = Date.now() + timing.promiseTimeout
|
||||
const contenders = new Set<Contender>()
|
||||
const contenders = new Set<ServiceContender>()
|
||||
let timeouts: { readonly info: Info; readonly count: number } | undefined
|
||||
let announced = false
|
||||
let lastSpawn = 0
|
||||
@@ -50,79 +50,63 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
const [command, ...args] = options.command ?? ["opencode", "serve", "--service"]
|
||||
if (command === undefined) throw new Error("Missing service command")
|
||||
try {
|
||||
const child = spawn(command, args, { detached: true, stdio: "ignore" })
|
||||
let error: Error | undefined
|
||||
child.once("error", (cause) => {
|
||||
error = new Error("Failed to start server", { cause })
|
||||
})
|
||||
child.unref()
|
||||
return { child, error: () => error }
|
||||
return spawnServiceContender(command, args)
|
||||
} catch (cause) {
|
||||
throw new Error("Failed to start server", { cause })
|
||||
}
|
||||
}
|
||||
|
||||
while (true) {
|
||||
if (Date.now() >= deadline) throw new Error("Timed out waiting for the background service to start")
|
||||
const registration = await registered(options.file, true, timing.requestTimeout)
|
||||
if (registration.timedOut && registration.info !== undefined) {
|
||||
timeouts = {
|
||||
info: registration.info,
|
||||
count: timeouts !== undefined && same(timeouts.info, registration.info) ? timeouts.count + 1 : 1,
|
||||
}
|
||||
if (timeouts.count >= 3) {
|
||||
announce("missing")
|
||||
await evict(registration.info, options, timing)
|
||||
timeouts = undefined
|
||||
lastSpawn = Date.now() - spawnDelay
|
||||
}
|
||||
} else timeouts = undefined
|
||||
try {
|
||||
while (true) {
|
||||
if (Date.now() >= deadline) throw new Error("Timed out waiting for the background service to start")
|
||||
const registration = await registered(options.file, true, timing.requestTimeout)
|
||||
if (registration.timedOut && registration.info !== undefined) {
|
||||
timeouts = {
|
||||
info: registration.info,
|
||||
count: timeouts !== undefined && same(timeouts.info, registration.info) ? timeouts.count + 1 : 1,
|
||||
}
|
||||
if (timeouts.count >= 3) {
|
||||
announce("missing")
|
||||
await evict(registration.info, options, timing)
|
||||
timeouts = undefined
|
||||
lastSpawn = Date.now() - spawnDelay
|
||||
}
|
||||
} else timeouts = undefined
|
||||
|
||||
if (registration.service !== undefined) {
|
||||
spawnDelay = timing.spawnDelay
|
||||
const service = registration.service
|
||||
const compatible = !service.legacy && (options.version === undefined || service.version === options.version)
|
||||
if (compatible && service.state === "ready") return service.endpoint
|
||||
if (compatible && service.state === "failed") throw new Error("Background service failed to start")
|
||||
if (!compatible) {
|
||||
announce("version-mismatch", service.version)
|
||||
await kill(service, options, timing).catch(() => undefined)
|
||||
lastSpawn = 0
|
||||
}
|
||||
} else {
|
||||
if (lastSpawn === 0 && registration.info !== undefined) lastSpawn = Date.now()
|
||||
const finished = [...contenders].filter(contenderFinished)
|
||||
const failure = finished.map(contenderFailure).find((error) => error !== undefined)
|
||||
if (finished.some((item) => item.child.exitCode === 0)) {
|
||||
spawnDelay = Math.min(spawnDelay * 2, timing.maxSpawnDelay)
|
||||
}
|
||||
finished.forEach((item) => contenders.delete(item))
|
||||
if (failure !== undefined && contenders.size === 0) throw failure
|
||||
// Keep one candidate plus one lock probe so a pre-lock stall cannot block recovery.
|
||||
if (contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) {
|
||||
announce("missing")
|
||||
contenders.add(spawnContender())
|
||||
lastSpawn = Date.now()
|
||||
if (registration.service !== undefined) {
|
||||
spawnDelay = timing.spawnDelay
|
||||
const service = registration.service
|
||||
const compatible = !service.legacy && (options.version === undefined || service.version === options.version)
|
||||
if (compatible && service.state === "ready") return service.endpoint
|
||||
if (compatible && service.state === "failed") throw new Error("Background service failed to start")
|
||||
if (!compatible) {
|
||||
announce("version-mismatch", service.version)
|
||||
await kill(service, options, timing).catch(() => undefined)
|
||||
lastSpawn = 0
|
||||
}
|
||||
} else {
|
||||
if (lastSpawn === 0 && registration.info !== undefined) lastSpawn = Date.now()
|
||||
const finished = [...contenders].filter(contenderFinished)
|
||||
const failure = finished.map(contenderFailure).find((error) => error !== undefined)
|
||||
if (finished.some((item) => item.child.exitCode === 0)) {
|
||||
spawnDelay = Math.min(spawnDelay * 2, timing.maxSpawnDelay)
|
||||
}
|
||||
finished.forEach((item) => contenders.delete(item))
|
||||
if (failure !== undefined && contenders.size === 0) throw failure
|
||||
// Keep one candidate plus one lock probe so a pre-lock stall cannot block recovery.
|
||||
if (contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) {
|
||||
announce("missing")
|
||||
contenders.add(spawnContender())
|
||||
lastSpawn = Date.now()
|
||||
}
|
||||
}
|
||||
await delay(timing.pollInterval)
|
||||
}
|
||||
await delay(timing.pollInterval)
|
||||
} finally {
|
||||
contenders.forEach((contender) => contender.release())
|
||||
}
|
||||
}
|
||||
|
||||
function contenderFailure(contender: Contender) {
|
||||
const error = contender.error()
|
||||
if (error !== undefined) return error
|
||||
if (contender.child.exitCode !== null && contender.child.exitCode !== 0)
|
||||
return new Error(`Server process exited with code ${contender.child.exitCode}`)
|
||||
if (contender.child.signalCode !== null)
|
||||
return new Error(`Server process terminated by ${contender.child.signalCode}`)
|
||||
return undefined
|
||||
}
|
||||
|
||||
function contenderFinished(contender: Contender) {
|
||||
return contender.error() !== undefined || contender.child.exitCode !== null || contender.child.signalCode !== null
|
||||
}
|
||||
|
||||
/** Stop the registered local service. */
|
||||
export async function stop(options: StopOptions = {}) {
|
||||
const existing = await find(options)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process"
|
||||
|
||||
export type ServiceContender = {
|
||||
readonly child: ChildProcess
|
||||
readonly error: () => Error | undefined
|
||||
readonly closed: () => boolean
|
||||
readonly stderr: () => string
|
||||
readonly release: () => void
|
||||
}
|
||||
|
||||
const stderrLimit = 8 * 1024
|
||||
|
||||
export function spawnServiceContender(command: string, args: ReadonlyArray<string>): ServiceContender {
|
||||
const child = spawn(command, args, { detached: true, stdio: ["ignore", "ignore", "pipe"] })
|
||||
let error: Error | undefined
|
||||
let closed = false
|
||||
let stderr = Buffer.alloc(0)
|
||||
const onStderr = (chunk: Buffer) => {
|
||||
const tail = chunk.subarray(-stderrLimit)
|
||||
stderr =
|
||||
tail.length === stderrLimit
|
||||
? Buffer.from(tail)
|
||||
: Buffer.concat([stderr.subarray(-(stderrLimit - tail.length)), tail])
|
||||
}
|
||||
child.stderr?.on("data", onStderr)
|
||||
if (child.stderr !== null && "unref" in child.stderr && typeof child.stderr.unref === "function") child.stderr.unref()
|
||||
child.once("error", (cause) => {
|
||||
error = new Error("Failed to start server", { cause })
|
||||
})
|
||||
child.once("close", () => {
|
||||
closed = true
|
||||
})
|
||||
child.unref()
|
||||
return {
|
||||
child,
|
||||
error: () => error,
|
||||
closed: () => closed,
|
||||
stderr: () => stderr.toString("utf8").trim(),
|
||||
release: () => {
|
||||
child.stderr?.off("data", onStderr)
|
||||
child.stderr?.resume()
|
||||
stderr = Buffer.alloc(0)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function contenderFailure(contender: ServiceContender) {
|
||||
const error = contender.error()
|
||||
if (error !== undefined) return error
|
||||
if (contender.child.exitCode !== null && contender.child.exitCode !== 0)
|
||||
return startupError(`Server process exited with code ${contender.child.exitCode}`, contender.stderr())
|
||||
if (contender.child.signalCode !== null)
|
||||
return startupError(`Server process terminated by ${contender.child.signalCode}`, contender.stderr())
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function contenderFinished(contender: ServiceContender) {
|
||||
return contender.error() !== undefined || contender.closed()
|
||||
}
|
||||
|
||||
function startupError(message: string, stderr: string) {
|
||||
return new Error(stderr ? `${message}\n${stderr}` : message)
|
||||
}
|
||||
@@ -3,6 +3,10 @@ import { appendFile, rename, writeFile } from "node:fs/promises"
|
||||
const [registration, mode, delay] = process.argv.slice(2)
|
||||
if (registration === undefined || mode === undefined) throw new Error("Missing service fixture arguments")
|
||||
if (mode === "failed") process.exit(1)
|
||||
if (mode === "stderr-failed") {
|
||||
process.stderr.write("x".repeat(16_384) + "\nactionable startup failure\n")
|
||||
process.exit(1)
|
||||
}
|
||||
if (mode === "record-start") {
|
||||
await writeFile(registration + ".started", "")
|
||||
process.exit(1)
|
||||
|
||||
@@ -72,6 +72,21 @@ test("reports a failed registered service", async () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("reports a bounded contender stderr tail with native promises", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
const error = await Service.ensure({
|
||||
file: registration,
|
||||
version: "test",
|
||||
command: [process.execPath, fixture, registration, "stderr-failed"],
|
||||
}).catch((error: unknown) => error)
|
||||
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
if (!(error instanceof Error)) throw error
|
||||
expect(error.message).toContain("actionable startup failure")
|
||||
expect(error.message.length).toBeLessThan(9_000)
|
||||
}, 10_000)
|
||||
|
||||
test("evicts an unresponsive registered service before starting its replacement", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
|
||||
@@ -201,6 +201,23 @@ test("reports a contender that fails to start", async () => {
|
||||
).rejects.toThrow("Server process exited with code 1")
|
||||
})
|
||||
|
||||
test("reports a bounded contender stderr tail", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
const error = await run(
|
||||
Service.ensure({
|
||||
file: registration,
|
||||
version: "test",
|
||||
command: [process.execPath, fixture, registration, "stderr-failed"],
|
||||
}),
|
||||
).catch((error: unknown) => error)
|
||||
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
if (!(error instanceof Error)) throw error
|
||||
expect(error.message).toContain("actionable startup failure")
|
||||
expect(error.message.length).toBeLessThan(9_000)
|
||||
}, 10_000)
|
||||
|
||||
test("reports a contender terminated by a signal", async () => {
|
||||
const directory = await temp()
|
||||
const registration = join(directory, "service.json")
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -136,6 +136,8 @@ const serialize = (message: SessionMessage.Info) => {
|
||||
const skills = message.skills?.map((skill) => `[Attached skill: ${skill.name}]\n${skill.text}`) ?? []
|
||||
return [`[User]: ${message.text}`, ...skills, ...files].join("\n")
|
||||
}
|
||||
if (message.type === "location-switched")
|
||||
return `[User]: The working directory has been changed to ${message.location.directory}.`
|
||||
if (message.type === "assistant") {
|
||||
return message.content
|
||||
.flatMap((part) => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Instructions } from "../instructions/index.js"
|
||||
import { InstructionBuiltIns } from "../instructions/builtins.js"
|
||||
import { Location } from "../location.js"
|
||||
import { McpInstructions } from "../mcp/instructions.js"
|
||||
import { McpTool } from "../tool/mcp.js"
|
||||
import { PluginSupervisor } from "../plugin/supervisor.js"
|
||||
import { ReferenceInstructions } from "../reference/instructions.js"
|
||||
import { SkillInstructions } from "../skill/instructions.js"
|
||||
@@ -64,6 +65,7 @@ const layer = Layer.effect(
|
||||
const entries = yield* InstructionEntry.Service
|
||||
const location = yield* Location.Service
|
||||
const mcpInstructions = yield* McpInstructions.Service
|
||||
const mcpTools = yield* McpTool.Service
|
||||
const models = yield* SessionRunnerModel.Service
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
const referenceInstructions = yield* ReferenceInstructions.Service
|
||||
@@ -78,6 +80,7 @@ const layer = Layer.effect(
|
||||
return yield* Effect.interrupt
|
||||
|
||||
yield* plugins.flush
|
||||
yield* mcpTools.flush
|
||||
const agent = yield* agents.select(session.agent)
|
||||
if (!agent.info) return yield* new AgentNotFoundError({ sessionID: session.id, agent: session.agent ?? agent.id })
|
||||
const loaded = yield* Effect.all(
|
||||
@@ -136,6 +139,7 @@ export const node = makeLocationNode({
|
||||
InstructionEntry.node,
|
||||
Location.node,
|
||||
McpInstructions.node,
|
||||
McpTool.node,
|
||||
PluginSupervisor.node,
|
||||
ReferenceInstructions.node,
|
||||
SessionRunnerModel.node,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { SessionMessage } from "./message.js"
|
||||
export interface Adapter {
|
||||
readonly getAgent: () => Effect.Effect<SessionMessage.AgentSelected["agent"] | undefined, never, never>
|
||||
readonly getModel: () => Effect.Effect<SessionMessage.ModelSelected["model"] | undefined, never, never>
|
||||
readonly getLocation: () => Effect.Effect<SessionMessage.LocationSwitched["previous"], never, never>
|
||||
readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined, never, never>
|
||||
readonly getAssistant: (
|
||||
messageID: SessionMessage.ID,
|
||||
@@ -89,7 +90,22 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
)
|
||||
})
|
||||
},
|
||||
"session.moved": () => Effect.void,
|
||||
"session.moved": (event) => {
|
||||
return Effect.gen(function* () {
|
||||
yield* adapter.appendMessage(
|
||||
SessionMessage.LocationSwitched.make({
|
||||
id: SessionMessage.ID.fromEvent(event.id),
|
||||
type: "location-switched",
|
||||
metadata: event.metadata,
|
||||
location: event.data.location,
|
||||
projectID: event.data.projectID,
|
||||
subpath: event.data.subpath,
|
||||
previous: yield* adapter.getLocation(),
|
||||
time: { created: event.created },
|
||||
}),
|
||||
)
|
||||
})
|
||||
},
|
||||
"session.renamed": () => Effect.void,
|
||||
"session.deleted": () => Effect.void,
|
||||
"session.forked": () => Effect.void,
|
||||
@@ -109,6 +125,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
id: SessionMessage.ID.fromEvent(event.id),
|
||||
type: "system",
|
||||
text: event.data.text,
|
||||
description: `Instructions updated: ${Object.keys(event.data.delta).join(", ")}`,
|
||||
metadata: event.metadata,
|
||||
time: { created: event.created },
|
||||
}),
|
||||
|
||||
@@ -16,6 +16,7 @@ import { InstructionState } from "./instruction-state.js"
|
||||
import { SessionPendingTable, SessionMessageTable, SessionTable } from "./sql.js"
|
||||
import { Slug } from "../util/slug.js"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { AbsolutePath, RelativePath } from "../schema.js"
|
||||
import type { SessionSchema } from "./schema.js"
|
||||
|
||||
type DatabaseService = Database.Interface["db"]
|
||||
@@ -253,6 +254,33 @@ function run(db: DatabaseService, event: MessageEvent) {
|
||||
Effect.map((row) => (row?.model ? Schema.decodeUnknownSync(Model.Ref)(row.model) : undefined)),
|
||||
)
|
||||
},
|
||||
getLocation() {
|
||||
return db
|
||||
.select({
|
||||
directory: SessionTable.directory,
|
||||
workspaceID: SessionTable.workspace_id,
|
||||
projectID: SessionTable.project_id,
|
||||
subpath: SessionTable.path,
|
||||
})
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.get()
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((row) =>
|
||||
row
|
||||
? {
|
||||
location: {
|
||||
directory: AbsolutePath.make(row.directory),
|
||||
workspaceID: row.workspaceID ? Workspace.ID.make(row.workspaceID) : undefined,
|
||||
},
|
||||
projectID: row.projectID,
|
||||
subpath: row.subpath === null ? undefined : RelativePath.make(row.subpath),
|
||||
}
|
||||
: undefined,
|
||||
),
|
||||
)
|
||||
},
|
||||
getCurrentAssistant() {
|
||||
return Effect.gen(function* () {
|
||||
// A newer step supersedes stale incomplete rows; never resume an older assistant projection.
|
||||
@@ -391,6 +419,7 @@ const layer = Layer.effectDiscard(
|
||||
)
|
||||
yield* bus.project(SessionEvent.Moved, (event) =>
|
||||
Effect.gen(function* () {
|
||||
yield* run(db, event)
|
||||
yield* db
|
||||
.update(SessionTable)
|
||||
.set({
|
||||
|
||||
@@ -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 []
|
||||
}
|
||||
|
||||
@@ -201,6 +216,15 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
|
||||
case "agent-switched":
|
||||
case "model-switched":
|
||||
return []
|
||||
case "location-switched":
|
||||
return [
|
||||
Message.make({
|
||||
id: message.id,
|
||||
role: "user",
|
||||
content: `The working directory has been changed to ${message.location.directory}.`,
|
||||
metadata: message.metadata,
|
||||
}),
|
||||
]
|
||||
case "user":
|
||||
const content = [
|
||||
...(message.skills ?? []).map((skill) => Message.text(skill.text)),
|
||||
|
||||
@@ -2,7 +2,7 @@ export * as McpTool from "./mcp.js"
|
||||
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { McpEvent } from "@opencode-ai/schema/mcp-event"
|
||||
import { Effect, Exit, type JsonSchema, Layer, Scope, Semaphore, Stream } from "effect"
|
||||
import { Context, Effect, Exit, Fiber, type JsonSchema, Layer, Scope, Semaphore, Stream } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Bus } from "../bus.js"
|
||||
|
||||
@@ -16,7 +16,15 @@ import { Tool } from "../tool.js"
|
||||
export const namespace = (server: string) => server.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||
export const name = (server: string, tool: string) => `${namespace(server)}_${tool.replace(/[^a-zA-Z0-9_-]/g, "_")}`
|
||||
|
||||
export const layer = Layer.effectDiscard(
|
||||
export interface Interface {
|
||||
/** Wait for the initial MCP tool registration to settle. */
|
||||
readonly flush: Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/McpTool") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const mcp = yield* MCP.Service
|
||||
const tools = yield* Tool.Service
|
||||
@@ -113,16 +121,17 @@ export const layer = Layer.effectDiscard(
|
||||
}),
|
||||
)
|
||||
|
||||
yield* reconcile.pipe(Effect.forkScoped)
|
||||
const initial = yield* reconcile.pipe(Effect.forkScoped)
|
||||
yield* bus.subscribe(McpEvent.ToolsChanged).pipe(
|
||||
Stream.runForEach(() => reconcile),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
return Service.of({ flush: Effect.asVoid(Fiber.await(initial)) })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
name: "mcp-tools",
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Tool.node, MCP.node, Bus.node, Permission.node],
|
||||
})
|
||||
|
||||
@@ -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" })),
|
||||
|
||||
@@ -32,7 +32,7 @@ describe("SessionExecution lifecycle", () => {
|
||||
new AIError({
|
||||
module: "test",
|
||||
method: "stream",
|
||||
reason: new TransportReason({ message: "Disconnected" }),
|
||||
reason: new TransportReason({ message: "Disconnected", transport: "http", operation: "request" }),
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -50,6 +50,23 @@ 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: "",
|
||||
}),
|
||||
])
|
||||
|
||||
yield* session.move({ sessionID: created.id, directory: destination })
|
||||
expect(yield* session.messages({ sessionID: created.id, order: "asc" })).toEqual(messages)
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -8,7 +8,11 @@ import { Skill } from "@opencode-ai/schema/skill"
|
||||
import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
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}`)
|
||||
@@ -67,6 +71,15 @@ describe("toLLMMessages", () => {
|
||||
model: { id: Model.ID.make("model"), providerID: Provider.ID.make("provider") },
|
||||
time: { created },
|
||||
}),
|
||||
SessionMessage.LocationSwitched.make({
|
||||
id: id("location"),
|
||||
type: "location-switched",
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/destination") }),
|
||||
previous: {
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/project") }),
|
||||
},
|
||||
time: { created },
|
||||
}),
|
||||
SessionMessage.System.make({
|
||||
id: id("system"),
|
||||
type: "system",
|
||||
@@ -110,9 +123,16 @@ describe("toLLMMessages", () => {
|
||||
model,
|
||||
)
|
||||
|
||||
expect(messages.map((message) => message.role)).toEqual(["system", "user", "user", "user", "user"])
|
||||
expect(messages[0]).toEqual(Message.system("Updated context\n\nOther context"))
|
||||
expect(messages[1]).toEqual(
|
||||
expect(messages.map((message) => message.role)).toEqual(["user", "system", "user", "user", "user", "user"])
|
||||
expect(messages[0]).toEqual(
|
||||
Message.make({
|
||||
id: id("location"),
|
||||
role: "user",
|
||||
content: "The working directory has been changed to /destination.",
|
||||
}),
|
||||
)
|
||||
expect(messages[1]).toEqual(Message.system("Updated context\n\nOther context"))
|
||||
expect(messages[2]).toEqual(
|
||||
Message.make({
|
||||
id: id("user"),
|
||||
role: "user",
|
||||
@@ -123,7 +143,7 @@ describe("toLLMMessages", () => {
|
||||
metadata: { agents: [{ name: "build" }] },
|
||||
}),
|
||||
)
|
||||
expect(messages.slice(2).map((message) => message.content)).toEqual([
|
||||
expect(messages.slice(3).map((message) => message.content)).toEqual([
|
||||
[{ type: "text", text: "Synthetic context" }],
|
||||
[
|
||||
{
|
||||
@@ -249,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(
|
||||
[
|
||||
@@ -277,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({
|
||||
@@ -295,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({
|
||||
@@ -314,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({
|
||||
@@ -330,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/",
|
||||
}),
|
||||
],
|
||||
@@ -341,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", () => {
|
||||
@@ -373,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(
|
||||
@@ -450,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)
|
||||
|
||||
@@ -134,6 +134,7 @@ test("Core reuses the canonical shared schemas", async () => {
|
||||
[coreSessionMessage.AssistantRetry, SessionMessage.AssistantRetry],
|
||||
[coreSessionMessage.AgentSelected, SessionMessage.AgentSelected],
|
||||
[coreSessionMessage.ModelSelected, SessionMessage.ModelSelected],
|
||||
[coreSessionMessage.LocationSwitched, SessionMessage.LocationSwitched],
|
||||
[coreSessionMessage.User, SessionMessage.User],
|
||||
[coreSessionMessage.Synthetic, SessionMessage.Synthetic],
|
||||
[coreSessionMessage.System, SessionMessage.System],
|
||||
|
||||
@@ -4,7 +4,7 @@ import appPlugin from "@opencode-ai/app/vite"
|
||||
|
||||
const channel = (() => {
|
||||
const raw = process.env.OPENCODE_CHANNEL
|
||||
if (raw === "dev" || raw === "beta" || raw === "prod") return raw
|
||||
if (raw === "local" || raw === "dev" || raw === "beta" || raw === "prod") return raw
|
||||
if (process.env.OPENCODE_CHANNEL === "latest") return "prod"
|
||||
return "dev"
|
||||
})()
|
||||
@@ -72,6 +72,10 @@ const require = __cjs_mod__.createRequire(import.meta.url);
|
||||
},
|
||||
},
|
||||
renderer: {
|
||||
define: {
|
||||
"import.meta.env.OPENCODE_VERSION": JSON.stringify(process.env.OPENCODE_VERSION),
|
||||
"import.meta.env.VITE_OPENCODE_CHANNEL": JSON.stringify(channel),
|
||||
},
|
||||
plugins: [appPlugin, sentry],
|
||||
publicDir: "../../../app/public",
|
||||
root: "src/renderer",
|
||||
|
||||
@@ -7,7 +7,11 @@ type ServerSource = { type: "build" } | { type: "download"; version: string }
|
||||
type DevOptions = { server: ServerSource; electron: string[] }
|
||||
|
||||
async function main() {
|
||||
process.env.OPENCODE_CHANNEL = "local"
|
||||
process.env.OPENCODE_VERSION = `2.0.0-local-${Date.now()}`
|
||||
process.env.OPENCODE_DISABLE_CHANNEL_DB = "0"
|
||||
const options = selectOptions()
|
||||
if (options.server.type === "build") process.env.OPENCODE_DESKTOP_SERVER_CHANNEL = "local"
|
||||
await prepareDesktop()
|
||||
await prepareServer(options.server)
|
||||
await startDesktop(options.electron)
|
||||
|
||||
@@ -93,7 +93,7 @@ export async function buildCliToResources(dest = windowsify("resources/opencode-
|
||||
await $`bun ${join(import.meta.dirname, "../../cli/script/build.ts")} --single --skip-install --skip-web-ui --outdir=${directory}`.env(
|
||||
{
|
||||
...process.env,
|
||||
OPENCODE_VERSION: `0.0.0-local-${Date.now()}`,
|
||||
OPENCODE_VERSION: process.env.OPENCODE_VERSION,
|
||||
},
|
||||
)
|
||||
if (stateHome && (await Bun.file(dest).exists())) {
|
||||
|
||||
@@ -25,6 +25,10 @@ export async function startBackgroundCli(logger: Logger) {
|
||||
const binary = app.isPackaged || isolated ? await installCli(bundled, version, logger) : bundled
|
||||
if (isolated) process.env.XDG_STATE_HOME = app.getPath("userData")
|
||||
const service = await Service.ensure({
|
||||
file:
|
||||
isolated && process.env.OPENCODE_DESKTOP_SERVER_CHANNEL === "local"
|
||||
? join(app.getPath("userData"), "opencode", "service-local.json")
|
||||
: undefined,
|
||||
version,
|
||||
command: [binary, "serve", "--service"],
|
||||
onStart: (reason, previousVersion) => logger.log("v2 CLI background service starting", { reason, previousVersion }),
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { app } from "electron"
|
||||
|
||||
type Channel = "dev" | "beta" | "prod"
|
||||
type Channel = "local" | "dev" | "beta" | "prod"
|
||||
const raw = import.meta.env.OPENCODE_CHANNEL
|
||||
export const CHANNEL: Channel = raw === "dev" || raw === "beta" || raw === "prod" ? raw : "dev"
|
||||
export const CHANNEL: Channel = raw === "local" || raw === "dev" || raw === "beta" || raw === "prod" ? raw : "dev"
|
||||
export const VERSION = app.isPackaged ? app.getVersion() : (process.env.OPENCODE_VERSION ?? app.getVersion())
|
||||
|
||||
export const UPDATER_ENABLED = app.isPackaged && CHANNEL !== "dev"
|
||||
|
||||
Vendored
+1
@@ -1,5 +1,6 @@
|
||||
interface ImportMetaEnv {
|
||||
readonly OPENCODE_CHANNEL: string
|
||||
readonly OPENCODE_VERSION?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
@@ -12,7 +12,7 @@ import contextMenu from "electron-context-menu"
|
||||
|
||||
import type { ServerReadyData } from "../preload/types"
|
||||
import { checkAppExists, resolveAppPath } from "./apps"
|
||||
import { CHANNEL } from "./constants"
|
||||
import { CHANNEL, VERSION } from "./constants"
|
||||
import { registerIpcHandlers, sendDeepLinks, sendMenuCommand } from "./ipc"
|
||||
import { forwardInitializationFailure } from "./initialization"
|
||||
import { exportDebugLogs, initCrashReporter, initLogging, startNetLog, write as writeLog } from "./logging"
|
||||
@@ -135,7 +135,7 @@ const main = Effect.gen(function* () {
|
||||
initCrashReporter()
|
||||
|
||||
const wslServers = createWslServersController(
|
||||
app.getVersion(),
|
||||
VERSION,
|
||||
async (distro) => {
|
||||
logger.log("spawning wsl sidecar", { distro })
|
||||
return spawnWslSidecar(distro, {
|
||||
@@ -165,7 +165,7 @@ const main = Effect.gen(function* () {
|
||||
}
|
||||
|
||||
logger.log("app starting", {
|
||||
version: app.getVersion(),
|
||||
version: VERSION,
|
||||
packaged: app.isPackaged,
|
||||
onboardingTest: Boolean(onboardingTestRoot),
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, wri
|
||||
import { ZipWriter, BlobWriter, BlobReader } from "@zip.js/zip.js"
|
||||
import { dirname, join } from "node:path"
|
||||
import { homedir } from "node:os"
|
||||
import { VERSION } from "./constants"
|
||||
|
||||
const MAX_LOG_AGE_DAYS = 7
|
||||
const TAIL_LINES = 1000
|
||||
@@ -133,7 +134,7 @@ function cleanup() {
|
||||
function manifest() {
|
||||
return {
|
||||
generated: new Date().toISOString(),
|
||||
version: app.getVersion(),
|
||||
version: VERSION,
|
||||
name: app.getName(),
|
||||
packaged: app.isPackaged,
|
||||
platform: process.platform,
|
||||
|
||||
@@ -33,6 +33,7 @@ import { Splash } from "@opencode-ai/ui/logo"
|
||||
import { useTheme } from "@opencode-ai/ui/theme/context"
|
||||
|
||||
const root = document.getElementById("root")
|
||||
const version = import.meta.env.OPENCODE_VERSION ?? pkg.version
|
||||
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
|
||||
throw new Error(t("desktop.error.dev.rootNotFound"))
|
||||
}
|
||||
@@ -41,7 +42,7 @@ if (import.meta.env.VITE_SENTRY_DSN) {
|
||||
Sentry.init({
|
||||
dsn: import.meta.env.VITE_SENTRY_DSN,
|
||||
environment: import.meta.env.VITE_SENTRY_ENVIRONMENT ?? import.meta.env.MODE,
|
||||
release: import.meta.env.VITE_SENTRY_RELEASE ?? `desktop@${pkg.version}`,
|
||||
release: import.meta.env.VITE_SENTRY_RELEASE ?? `desktop@${version}`,
|
||||
initialScope: {
|
||||
tags: {
|
||||
platform: "desktop",
|
||||
@@ -168,7 +169,7 @@ const createPlatform = (windowState: DesktopWindowState): Platform => {
|
||||
return {
|
||||
platform: "desktop",
|
||||
os,
|
||||
version: pkg.version,
|
||||
version,
|
||||
windowID: windowState.id,
|
||||
|
||||
async openDirectoryPickerDialog(opts) {
|
||||
|
||||
@@ -372,8 +372,6 @@ export interface KeymapCommand {
|
||||
readonly aliases?: string[]
|
||||
/** Keeps the slash command in the prompt and passes its raw input to run. */
|
||||
readonly arguments?: true
|
||||
/** Hides the command from slash completion until its exact name is typed. */
|
||||
readonly secret?: true
|
||||
}
|
||||
/** Promotes the command in discovery UI. */
|
||||
readonly suggested?: boolean | (() => boolean)
|
||||
|
||||
@@ -12795,6 +12795,63 @@
|
||||
"required": ["id", "time", "type", "model"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Session.Message.LocationSwitched": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"allOf": [
|
||||
{
|
||||
"pattern": "^msg_"
|
||||
}
|
||||
]
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
"time": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": ["created"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["location-switched"]
|
||||
},
|
||||
"location": {
|
||||
"$ref": "#/components/schemas/Location.Ref"
|
||||
},
|
||||
"projectID": {
|
||||
"type": "string"
|
||||
},
|
||||
"subpath": {
|
||||
"type": "string"
|
||||
},
|
||||
"previous": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"location": {
|
||||
"$ref": "#/components/schemas/Location.Ref"
|
||||
},
|
||||
"projectID": {
|
||||
"type": "string"
|
||||
},
|
||||
"subpath": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["location"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["id", "time", "type", "location"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Prompt.Base64": {
|
||||
"type": "string",
|
||||
"allOf": [
|
||||
@@ -13026,6 +13083,9 @@
|
||||
},
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["id", "time", "type", "text"],
|
||||
@@ -13719,6 +13779,9 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/Session.Message.ModelSelected"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Session.Message.LocationSwitched"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Session.Message.User"
|
||||
},
|
||||
|
||||
@@ -3,7 +3,9 @@ export * as SessionMessage from "./session-message.js"
|
||||
import { Schema } from "effect"
|
||||
import { optional } from "./schema.js"
|
||||
import { Content } from "./tool.js"
|
||||
import { Location } from "./location.js"
|
||||
import { Model } from "./model.js"
|
||||
import { Project } from "./project.js"
|
||||
import { Prompt } from "./prompt.js"
|
||||
import { DateTimeUtcFromMillis, PositiveInt, RelativePath, statics } from "./schema.js"
|
||||
import { ascending } from "./identifier.js"
|
||||
@@ -53,6 +55,20 @@ export const ModelSelected = Schema.Struct({
|
||||
previous: Model.Ref.pipe(optional),
|
||||
}).annotate({ identifier: "Session.Message.ModelSelected" })
|
||||
|
||||
export interface LocationSwitched extends Schema.Schema.Type<typeof LocationSwitched> {}
|
||||
export const LocationSwitched = Schema.Struct({
|
||||
...Base,
|
||||
type: Schema.tag("location-switched"),
|
||||
location: Location.Ref,
|
||||
projectID: Project.ID.pipe(optional),
|
||||
subpath: RelativePath.pipe(optional),
|
||||
previous: Schema.Struct({
|
||||
location: Location.Ref,
|
||||
projectID: Project.ID.pipe(optional),
|
||||
subpath: RelativePath.pipe(optional),
|
||||
}).pipe(optional),
|
||||
}).annotate({ identifier: "Session.Message.LocationSwitched" })
|
||||
|
||||
export interface User extends Schema.Schema.Type<typeof User> {}
|
||||
export const User = Schema.Struct({
|
||||
...Base,
|
||||
@@ -75,7 +91,10 @@ export interface System extends Schema.Schema.Type<typeof System> {}
|
||||
export const System = Schema.Struct({
|
||||
...Base,
|
||||
type: Schema.tag("system"),
|
||||
/** The model-facing update text, frozen at emit time. */
|
||||
text: Schema.String,
|
||||
/** A short human-readable summary for transcript display. */
|
||||
description: Schema.String.pipe(optional),
|
||||
}).annotate({ identifier: "Session.Message.System" })
|
||||
|
||||
export interface Skill extends Schema.Schema.Type<typeof Skill> {}
|
||||
@@ -243,6 +262,7 @@ export type Compaction = CompactionRunning | CompactionCompleted | CompactionFai
|
||||
export const Info = Schema.Union([
|
||||
AgentSelected,
|
||||
ModelSelected,
|
||||
LocationSwitched,
|
||||
User,
|
||||
Synthetic,
|
||||
System,
|
||||
@@ -251,5 +271,15 @@ export const Info = Schema.Union([
|
||||
Assistant,
|
||||
Compaction,
|
||||
]).annotate({ identifier: "Session.Message.Info" })
|
||||
export type Info = AgentSelected | ModelSelected | User | Synthetic | System | Skill | Shell | Assistant | Compaction
|
||||
export type Info =
|
||||
| AgentSelected
|
||||
| ModelSelected
|
||||
| LocationSwitched
|
||||
| User
|
||||
| Synthetic
|
||||
| System
|
||||
| Skill
|
||||
| Shell
|
||||
| Assistant
|
||||
| Compaction
|
||||
export type Type = Info["type"]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
batch,
|
||||
Show,
|
||||
} from "solid-js"
|
||||
import { createStore, unwrap } from "solid-js/store"
|
||||
import { createStore } from "solid-js/store"
|
||||
import {
|
||||
TuiLifecycleProvider,
|
||||
TuiAppProvider,
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
TuiStartupProvider,
|
||||
TuiTerminalEnvironmentProvider,
|
||||
useTuiApp,
|
||||
useTuiPaths,
|
||||
useTuiStartup,
|
||||
type TuiApp,
|
||||
} from "./context/runtime"
|
||||
@@ -62,7 +63,6 @@ import { useConnected } from "./component/use-connected"
|
||||
import { DialogMcp } from "./component/dialog-mcp"
|
||||
import { DialogStatus } from "./component/dialog-status"
|
||||
import { DialogConfig } from "./component/dialog-config"
|
||||
import { DialogExperiments } from "./component/dialog-experiments"
|
||||
import { DialogDebug } from "./component/dialog-debug"
|
||||
import { DialogPair, type DialogPairCredentials } from "./component/dialog-pair"
|
||||
import { DialogThemeList } from "./component/dialog-theme-list"
|
||||
@@ -86,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"
|
||||
@@ -454,6 +455,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()
|
||||
@@ -658,26 +660,15 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
category: "Session",
|
||||
slash: { name: "new", aliases: ["clear"] },
|
||||
run: () => {
|
||||
// With per-tab drafts, a new session is an explicit "this belongs
|
||||
// elsewhere" gesture: move the in-progress draft instead of leaving
|
||||
// a copy behind on the tab it came from.
|
||||
const carried = (() => {
|
||||
if (config.data.experimental?.tab_drafts !== true) return undefined
|
||||
const current = promptRef.current
|
||||
if (!current?.current.text) return undefined
|
||||
// Copy before reset: reset() merges an empty prompt into the same
|
||||
// underlying store object that unwrap exposes.
|
||||
const prompt = { ...unwrap(current.current) }
|
||||
current.reset()
|
||||
return prompt
|
||||
})()
|
||||
route.navigate({
|
||||
type: "home",
|
||||
prompt: carried,
|
||||
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()
|
||||
},
|
||||
@@ -885,19 +876,6 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
},
|
||||
category: "System",
|
||||
},
|
||||
{
|
||||
// Deliberately absent from the command palette; reachable only by the
|
||||
// secret /baldbeard incantation.
|
||||
name: "opencode.experiments",
|
||||
title: "Experiments",
|
||||
description: "look is my devrel meme face",
|
||||
palette: undefined,
|
||||
slash: { name: "baldbeard", secret: true as const },
|
||||
run: () => {
|
||||
dialog.replace(() => <DialogExperiments />)
|
||||
},
|
||||
category: "System",
|
||||
},
|
||||
{
|
||||
name: "opencode.status",
|
||||
title: "View status",
|
||||
|
||||
@@ -13,6 +13,8 @@ import { useRoute } from "../context/route"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useTheme, useThemes } from "../context/theme"
|
||||
import { DevTools } from "../devtools"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { DialogExperiments } from "./dialog-experiments"
|
||||
import { usePlugin } from "../plugin/context"
|
||||
import { errorMessage } from "../util/error"
|
||||
|
||||
@@ -27,6 +29,7 @@ export type RuntimeStatus = "normal" | "medium" | "high"
|
||||
export function DevToolsBar() {
|
||||
const client = useClient()
|
||||
const config = useConfig()
|
||||
const dialog = useDialog()
|
||||
const data = useData()
|
||||
const location = useLocation()
|
||||
const route = useRoute()
|
||||
@@ -381,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) => (
|
||||
@@ -405,6 +410,15 @@ export function DevToolsBar() {
|
||||
</PanelBox>
|
||||
</Show>
|
||||
</BarItem>
|
||||
<BarItem
|
||||
active={false}
|
||||
onClick={() => {
|
||||
close()
|
||||
dialog.replace(() => <DialogExperiments />)
|
||||
}}
|
||||
>
|
||||
<text fg={theme.text.subdued}>Experiments</text>
|
||||
</BarItem>
|
||||
<box flexGrow={1} minWidth={0}>
|
||||
<TimeToFirstDraw visible={timing()} width="100%" fg={theme.text.subdued} label="Time to first draw" />
|
||||
</box>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -16,13 +16,14 @@ export const experiments: Experiment[] = [
|
||||
{
|
||||
id: "tab_drafts",
|
||||
title: "Per-tab prompt drafts",
|
||||
description: "Keep unsent prompt drafts on the tab where they were written. New session moves the current draft.",
|
||||
description: "Keep unsent prompt drafts on the tab where they were written. New sessions start blank.",
|
||||
},
|
||||
]
|
||||
|
||||
export function DialogExperiments() {
|
||||
const config = useConfig()
|
||||
const toast = useToast()
|
||||
const [selected, setSelected] = createSignal(0)
|
||||
const [saving, setSaving] = createSignal(false)
|
||||
|
||||
const enabled = (experiment: Experiment) => config.data.experimental?.[experiment.id] === true
|
||||
@@ -30,14 +31,15 @@ export function DialogExperiments() {
|
||||
const options = createMemo(() =>
|
||||
experiments.map((experiment, index) => ({
|
||||
title: experiment.title,
|
||||
description: experiment.description,
|
||||
category: "Experiments",
|
||||
searchText: experiment.description,
|
||||
footer: enabled(experiment) ? "on" : "off",
|
||||
value: index,
|
||||
})),
|
||||
)
|
||||
|
||||
async function toggle(index: number) {
|
||||
// All experiments are booleans, so either direction toggles.
|
||||
async function change(index = selected()) {
|
||||
if (saving()) return
|
||||
const experiment = experiments[index]
|
||||
if (!experiment) return
|
||||
@@ -56,8 +58,23 @@ export function DialogExperiments() {
|
||||
<DialogSelect
|
||||
title="Experiments"
|
||||
options={options()}
|
||||
onSelect={(option) => void toggle(option.value)}
|
||||
footerHints={[{ title: "enter", label: "toggle" }]}
|
||||
onMove={(option) => setSelected(option.value)}
|
||||
onSelect={(option) => void change(option.value)}
|
||||
footerHints={[{ title: "←/→", label: "change" }]}
|
||||
bindings={[
|
||||
{
|
||||
bind: "left",
|
||||
title: "Previous value",
|
||||
group: "Experiments",
|
||||
run: () => void change(),
|
||||
},
|
||||
{
|
||||
bind: "right",
|
||||
title: "Next value",
|
||||
group: "Experiments",
|
||||
run: () => void change(),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -512,9 +512,6 @@ export function Autocomplete(props: {
|
||||
const results: AutocompleteOption[] = keymapCommands().flatMap((command) => {
|
||||
const slash = command.slash
|
||||
if (!slash) return []
|
||||
// Secret commands are incantations: absent from the "/" listing and from
|
||||
// fuzzy matching until the exact name is typed.
|
||||
if (slash.secret && search().toLowerCase() !== slash.name) return []
|
||||
return {
|
||||
display: `/${slash.name}`,
|
||||
description: command.description ?? command.title,
|
||||
|
||||
@@ -8,10 +8,6 @@ import { useToast } from "../../ui/toast"
|
||||
import { DialogMoveSession, type MoveSessionSelection } from "../dialog-move-session"
|
||||
import { useData } from "../../context/data"
|
||||
|
||||
function moveReminderText(directory: string) {
|
||||
return `<system-reminder>The user has changed the current working directory to "${directory}". This is still the same project but at a possibly new location; take this into account when working with any files from now on.</system-reminder>`
|
||||
}
|
||||
|
||||
export function usePromptMove(input: { projectID: () => string | undefined; sessionID: () => string | undefined }) {
|
||||
const dialog = useDialog()
|
||||
const client = useClient()
|
||||
@@ -103,9 +99,6 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
|
||||
setProgress("Moving session")
|
||||
try {
|
||||
await client.api.session.move({ sessionID, directory })
|
||||
await client.api.session
|
||||
.synthetic({ sessionID, text: moveReminderText(directory), resume: false })
|
||||
.catch(() => undefined)
|
||||
dialog.clear()
|
||||
} catch (error) {
|
||||
toast.error(error)
|
||||
|
||||
@@ -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"
|
||||
@@ -259,6 +265,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,
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -431,14 +431,32 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
setStore("session", "info", event.data.sessionID, "title", event.data.title)
|
||||
})
|
||||
break
|
||||
case "session.moved":
|
||||
if (store.session.info[event.data.sessionID]) {
|
||||
case "session.moved": {
|
||||
const current = store.session.info[event.data.sessionID]
|
||||
if (current) {
|
||||
const previous = {
|
||||
location: { ...current.location },
|
||||
projectID: current.projectID,
|
||||
subpath: current.subpath,
|
||||
}
|
||||
setStore("session", "info", event.data.sessionID, "location", event.data.location)
|
||||
if (event.data.projectID)
|
||||
setStore("session", "info", event.data.sessionID, "projectID", event.data.projectID)
|
||||
setStore("session", "info", event.data.sessionID, "subpath", event.data.subpath)
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(draft, index, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "location-switched",
|
||||
location: event.data.location,
|
||||
projectID: event.data.projectID,
|
||||
subpath: event.data.subpath,
|
||||
previous,
|
||||
time: { created: event.created },
|
||||
})
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
case "session.input.promoted": {
|
||||
const admitted = store.session.input[event.data.sessionID]?.includes(event.data.inputID) ?? false
|
||||
removePending(event.data.sessionID, event.data.inputID)
|
||||
@@ -505,19 +523,16 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
})
|
||||
break
|
||||
case "session.instructions.updated":
|
||||
const instructions = event.metadata?.instructions
|
||||
if (
|
||||
typeof instructions === "object" &&
|
||||
instructions !== null &&
|
||||
"initial" in instructions &&
|
||||
instructions.initial === true
|
||||
)
|
||||
break
|
||||
// Mirror the projector: the initial baseline and empty-rendering deltas carry no text
|
||||
// and produce no transcript message.
|
||||
const updateText = event.data.text
|
||||
if (updateText === undefined) break
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.append(draft, index, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "system",
|
||||
text: `Instructions updated: ${Object.keys(event.data.delta).join(", ")}`,
|
||||
text: updateText,
|
||||
description: `Instructions updated: ${Object.keys(event.data.delta).join(", ")}`,
|
||||
metadata: event.metadata,
|
||||
time: { created: event.created },
|
||||
})
|
||||
|
||||
@@ -24,7 +24,6 @@ declare module "@opentui/keymap" {
|
||||
name: string
|
||||
aliases?: string[]
|
||||
arguments?: true
|
||||
secret?: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -49,7 +49,11 @@ export function localSource(spec: string, directory: string) {
|
||||
// of hitting the ESM cache. Bun ignores query params when caching file:// URL
|
||||
// imports, so bust with a plain path there; Node keys its cache on the full
|
||||
// URL. Mirrors the core plugin supervisor's loader.
|
||||
// The mtime is truncated to whole milliseconds: a fractional mtimeMs puts a
|
||||
// dot in the query, and Bun's compiled binaries then skip runtime plugin
|
||||
// hooks for the import, breaking JSX/solid rewriting for external plugins.
|
||||
export function freshSpecifier(entrypoint: string, mtime: number) {
|
||||
if (typeof Bun !== "undefined") return `${fileURLToPath(entrypoint).replaceAll("\\", "/")}?mtime=${mtime}`
|
||||
return `${entrypoint}?mtime=${mtime}`
|
||||
const version = Math.trunc(mtime)
|
||||
if (typeof Bun !== "undefined") return `${fileURLToPath(entrypoint).replaceAll("\\", "/")}?mtime=${version}`
|
||||
return `${entrypoint}?mtime=${version}`
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
@@ -1379,7 +1413,13 @@ function SessionMessageView(props: { message: SessionMessageInfo }) {
|
||||
<Match when={props.message.type === "shell"}>
|
||||
<ShellMessage message={props.message as Extract<SessionMessageInfo, { type: "shell" }>} />
|
||||
</Match>
|
||||
<Match when={props.message.type === "agent-switched" || props.message.type === "model-switched"}>
|
||||
<Match
|
||||
when={
|
||||
props.message.type === "agent-switched" ||
|
||||
props.message.type === "model-switched" ||
|
||||
props.message.type === "location-switched"
|
||||
}
|
||||
>
|
||||
<SessionSwitchMessageV2 message={props.message} />
|
||||
</Match>
|
||||
<Match
|
||||
@@ -1670,6 +1710,7 @@ function SessionSwitchMessageV2(props: { message: SessionMessageInfo }) {
|
||||
}
|
||||
if (props.message.type === "model-switched")
|
||||
return switchLabel(props.message.model, ctx.models(), props.message.previous)
|
||||
if (props.message.type === "location-switched") return `Switched location to ${props.message.location.directory}`
|
||||
return ""
|
||||
}
|
||||
return (
|
||||
@@ -1688,7 +1729,7 @@ function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) {
|
||||
const state = () => stringValue(metadata()?.state)
|
||||
const actor = () => (source() === "shell" ? "Shell" : Locale.titlecase(stringValue(metadata()?.agent) ?? "Subagent"))
|
||||
const text = () => {
|
||||
if (props.message.type === "system") return props.message.text
|
||||
if (props.message.type === "system") return props.message.description ?? "Instructions updated"
|
||||
if (props.message.type === "synthetic") return props.message.description ?? ""
|
||||
return ""
|
||||
}
|
||||
@@ -1773,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}
|
||||
@@ -2223,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}
|
||||
@@ -2819,10 +2860,15 @@ function Shell(props: ToolProps) {
|
||||
})
|
||||
const maxLines = 10
|
||||
const maxChars = createMemo(() => maxLines * Math.max(20, ctx.width - 6))
|
||||
const prompt = createMemo(() => (workdir() && workdir() !== "." ? `${workdir()}$` : "$"))
|
||||
const input = createMemo(() => {
|
||||
if (!command()) return ""
|
||||
const prompt = workdir() && workdir() !== "." ? `${workdir()}$ ` : isRunning() ? "" : "$ "
|
||||
return `${prompt}${command()}`
|
||||
const cmd = command()
|
||||
if (!cmd) return ""
|
||||
// While running, the workdir prompt shares the spinner's text column; when
|
||||
// settled, the prompt renders as its own column so wrapped command lines
|
||||
// keep a stable hanging indent instead of jumping to the card inset.
|
||||
if (isRunning() && prompt() !== "$") return `${prompt()} ${cmd}`
|
||||
return cmd
|
||||
})
|
||||
const content = createMemo(() => [input(), output()].filter(Boolean).join("\n\n"))
|
||||
const collapsed = createMemo(() => collapseToolOutput(content(), maxLines, maxChars()))
|
||||
@@ -2830,6 +2876,8 @@ function Shell(props: ToolProps) {
|
||||
if (expanded() || !collapsed().overflow) return content()
|
||||
return collapsed().output
|
||||
})
|
||||
const limitedInput = createMemo(() => limited().slice(0, input().length))
|
||||
const limitedOutput = createMemo(() => limited().slice(Math.min(limited().length, input().length + 2)))
|
||||
const expandable = createMemo(() => Boolean(shellID()) || collapsed().overflow)
|
||||
const toggle = () => {
|
||||
const next = !expanded()
|
||||
@@ -2853,16 +2901,16 @@ function Shell(props: ToolProps) {
|
||||
<Show
|
||||
when={isRunning()}
|
||||
fallback={
|
||||
<text>
|
||||
<span style={{ fg: theme.text.default }}>{limited().slice(0, input().length)}</span>
|
||||
<span style={{ fg: theme.text.subdued }}>{limited().slice(input().length)}</span>
|
||||
</text>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={theme.text.default}>{prompt()}</text>
|
||||
<text fg={theme.text.default}>{limitedInput()}</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<Spinner color={color()}>
|
||||
<span style={{ fg: theme.text.default }}>{limited().slice(0, input().length)}</span>
|
||||
<span style={{ fg: theme.text.subdued }}>{limited().slice(input().length)}</span>
|
||||
</Spinner>
|
||||
<Spinner color={color()}>{limitedInput()}</Spinner>
|
||||
</Show>
|
||||
<Show when={limitedOutput()}>
|
||||
<text fg={theme.text.subdued}>{limitedOutput()}</text>
|
||||
</Show>
|
||||
</Show>
|
||||
<Show when={background()}>
|
||||
@@ -2883,7 +2931,7 @@ function Write(props: ToolProps) {
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.metadata.diagnostics !== undefined}>
|
||||
<Match when={props.part.state.status === "completed"}>
|
||||
<BlockTool
|
||||
path={{ label: "# Wrote", value: pathFormatter.format(stringValue(props.input.path)) }}
|
||||
part={props.part}
|
||||
|
||||
@@ -655,6 +655,18 @@ test("updates session location when moved", async () => {
|
||||
await wait(() => data.session.get("ses_test")?.location.directory === destination)
|
||||
expect(data.session.get("ses_test")?.projectID).toBe("project-moved")
|
||||
expect(data.session.get("ses_test")?.subpath).toBe("packages/cli")
|
||||
expect(data.session.message.list("ses_test")).toContainEqual({
|
||||
id: "msg_moved_1",
|
||||
type: "location-switched",
|
||||
location: { directory: destination },
|
||||
projectID: "project-moved",
|
||||
subpath: "packages/cli",
|
||||
previous: {
|
||||
location: { directory },
|
||||
projectID: "proj_test",
|
||||
},
|
||||
time: { created: 1 },
|
||||
})
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
@@ -2889,14 +2901,26 @@ test("skips initial instruction state and projects later updates with their mess
|
||||
delta: { "core/date": "1".repeat(64) },
|
||||
},
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_instructions_3",
|
||||
created: 2,
|
||||
type: "session.instructions.updated",
|
||||
durable: durable("session-1", 2, 2),
|
||||
data: {
|
||||
sessionID: "session-1",
|
||||
delta: { "core/date": "2".repeat(64) },
|
||||
text: "The current date has changed.",
|
||||
},
|
||||
})
|
||||
|
||||
await wait(() => sync.session.message.list("session-1")?.some((message) => message.time.created === 1))
|
||||
await wait(() => sync.session.message.list("session-1")?.some((message) => message.time.created === 2))
|
||||
expect(sync.session.message.list("session-1")).toHaveLength(1)
|
||||
expect(sync.session.message.list("session-1")?.[0]).toMatchObject({
|
||||
id: SessionMessage.ID.fromEvent(Event.ID.make("evt_instructions_2")),
|
||||
id: SessionMessage.ID.fromEvent(Event.ID.make("evt_instructions_3")),
|
||||
type: "system",
|
||||
text: "Instructions updated: core/date",
|
||||
time: { created: 1 },
|
||||
text: "The current date has changed.",
|
||||
description: "Instructions updated: core/date",
|
||||
time: { created: 2 },
|
||||
})
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
|
||||
@@ -25,6 +25,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 +47,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 +56,10 @@ 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("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()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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" })
|
||||
})
|
||||
@@ -1,7 +1,8 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { expect, test } from "bun:test"
|
||||
import { discoverTuiPlugins, tuiPluginDirectories } from "../src/plugin/discovery"
|
||||
import { discoverTuiPlugins, freshSpecifier, tuiPluginDirectories } from "../src/plugin/discovery"
|
||||
import { localProjectDirectory } from "../src/util/config-directories"
|
||||
import { tmpdir } from "./fixture/fixture"
|
||||
|
||||
@@ -67,6 +68,14 @@ test("uses an Hg root for a missing project plugin directory", async () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("truncates fractional mtimes in fresh specifiers", () => {
|
||||
// A dot in the query makes Bun's compiled binaries skip runtime plugin
|
||||
// hooks for the import, breaking JSX/solid rewriting for external plugins.
|
||||
const entrypoint = pathToFileURL(path.resolve("example.tsx")).href
|
||||
const specifier = freshSpecifier(entrypoint, 1786494961337.0317)
|
||||
expect(specifier.endsWith("example.tsx?mtime=1786494961337")).toBe(true)
|
||||
})
|
||||
|
||||
test("propagates non-missing filesystem errors", async () => {
|
||||
await expect(localProjectDirectory("\0")).rejects.toBeInstanceOf(Error)
|
||||
await expect(discoverTuiPlugins(["\0"])).rejects.toBeInstanceOf(Error)
|
||||
|
||||
@@ -12795,6 +12795,63 @@
|
||||
"required": ["id", "time", "type", "model"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Session.Message.LocationSwitched": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"allOf": [
|
||||
{
|
||||
"pattern": "^msg_"
|
||||
}
|
||||
]
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
"time": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": ["created"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["location-switched"]
|
||||
},
|
||||
"location": {
|
||||
"$ref": "#/components/schemas/Location.Ref"
|
||||
},
|
||||
"projectID": {
|
||||
"type": "string"
|
||||
},
|
||||
"subpath": {
|
||||
"type": "string"
|
||||
},
|
||||
"previous": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"location": {
|
||||
"$ref": "#/components/schemas/Location.Ref"
|
||||
},
|
||||
"projectID": {
|
||||
"type": "string"
|
||||
},
|
||||
"subpath": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["location"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["id", "time", "type", "location"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Prompt.Base64": {
|
||||
"type": "string",
|
||||
"allOf": [
|
||||
@@ -13026,6 +13083,9 @@
|
||||
},
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["id", "time", "type", "text"],
|
||||
@@ -13719,6 +13779,9 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/Session.Message.ModelSelected"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Session.Message.LocationSwitched"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Session.Message.User"
|
||||
},
|
||||
|
||||
@@ -12795,6 +12795,63 @@
|
||||
"required": ["id", "time", "type", "model"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Session.Message.LocationSwitched": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"allOf": [
|
||||
{
|
||||
"pattern": "^msg_"
|
||||
}
|
||||
]
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
"time": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": ["created"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["location-switched"]
|
||||
},
|
||||
"location": {
|
||||
"$ref": "#/components/schemas/Location.Ref"
|
||||
},
|
||||
"projectID": {
|
||||
"type": "string"
|
||||
},
|
||||
"subpath": {
|
||||
"type": "string"
|
||||
},
|
||||
"previous": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"location": {
|
||||
"$ref": "#/components/schemas/Location.Ref"
|
||||
},
|
||||
"projectID": {
|
||||
"type": "string"
|
||||
},
|
||||
"subpath": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["location"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["id", "time", "type", "location"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Prompt.Base64": {
|
||||
"type": "string",
|
||||
"allOf": [
|
||||
@@ -13026,6 +13083,9 @@
|
||||
},
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["id", "time", "type", "text"],
|
||||
@@ -13719,6 +13779,9 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/Session.Message.ModelSelected"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Session.Message.LocationSwitched"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Session.Message.User"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user