mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-06 17:19:49 -04:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e7efffcb6 | |||
| 3e253c589e | |||
| 0a0fc09533 | |||
| 5aa0413fea | |||
| 6f4c199629 | |||
| ed8e1f4654 | |||
| 3b0195e045 | |||
| 143a776373 | |||
| 76b318e990 | |||
| c0ab35c3c2 |
@@ -211,11 +211,43 @@ export type StreamItem = Schema.Schema.Type<typeof StreamItem>
|
|||||||
// event-level `error` envelope, so accept all three shapes here.
|
// event-level `error` envelope, so accept all three shapes here.
|
||||||
// https://www.openresponses.org/specification
|
// https://www.openresponses.org/specification
|
||||||
const OpenResponsesErrorPayload = Schema.Struct({
|
const OpenResponsesErrorPayload = Schema.Struct({
|
||||||
|
type: optionalNull(Schema.String),
|
||||||
code: optionalNull(Schema.String),
|
code: optionalNull(Schema.String),
|
||||||
message: optionalNull(Schema.String),
|
message: optionalNull(Schema.String),
|
||||||
param: optionalNull(Schema.String),
|
param: optionalNull(Schema.String),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const WebSocketErrorHeader = Schema.Union([Schema.String, Schema.Number, Schema.Boolean])
|
||||||
|
export const WebSocketErrorEvent = Schema.StructWithRest(
|
||||||
|
Schema.Struct({
|
||||||
|
type: Schema.tag("error"),
|
||||||
|
status: Schema.optional(Schema.Number),
|
||||||
|
status_code: Schema.optional(Schema.Number),
|
||||||
|
code: optionalNull(Schema.String),
|
||||||
|
message: Schema.optional(Schema.String),
|
||||||
|
param: optionalNull(Schema.String),
|
||||||
|
error: optionalNull(OpenResponsesErrorPayload),
|
||||||
|
headers: Schema.optional(Schema.Record(Schema.String, WebSocketErrorHeader)),
|
||||||
|
}),
|
||||||
|
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||||
|
)
|
||||||
|
const decodeWebSocketErrorEvent = Schema.decodeUnknownEffect(WebSocketErrorEvent)
|
||||||
|
|
||||||
|
const decodeKnownErrorEvent = (event: Event) =>
|
||||||
|
decodeWebSocketErrorEvent({
|
||||||
|
...event,
|
||||||
|
status: typeof event.status === "number" ? event.status : undefined,
|
||||||
|
status_code: typeof event.status_code === "number" ? event.status_code : undefined,
|
||||||
|
headers: ProviderShared.isRecord(event.headers)
|
||||||
|
? Object.fromEntries(
|
||||||
|
Object.entries(event.headers).filter(
|
||||||
|
(entry): entry is [string, string | number | boolean] =>
|
||||||
|
typeof entry[1] === "string" || typeof entry[1] === "number" || typeof entry[1] === "boolean",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
export const Event = Schema.StructWithRest(
|
export const Event = Schema.StructWithRest(
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
type: Schema.String,
|
type: Schema.String,
|
||||||
@@ -240,6 +272,9 @@ export const Event = Schema.StructWithRest(
|
|||||||
message: Schema.optional(Schema.String),
|
message: Schema.optional(Schema.String),
|
||||||
param: optionalNull(Schema.String),
|
param: optionalNull(Schema.String),
|
||||||
error: optionalNull(OpenResponsesErrorPayload),
|
error: optionalNull(OpenResponsesErrorPayload),
|
||||||
|
status: Schema.optional(Schema.Unknown),
|
||||||
|
status_code: Schema.optional(Schema.Unknown),
|
||||||
|
headers: Schema.optional(Schema.Unknown),
|
||||||
}),
|
}),
|
||||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||||
)
|
)
|
||||||
@@ -632,9 +667,9 @@ export type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
|
|||||||
const NO_EVENTS: StepResult["1"] = []
|
const NO_EVENTS: StepResult["1"] = []
|
||||||
|
|
||||||
// `response.completed` / `response.incomplete` are clean finishes that emit a
|
// `response.completed` / `response.incomplete` are clean finishes that emit a
|
||||||
// `finish` event; `response.failed` is a hard failure. All three end the stream,
|
// `finish` event; `response.failed` and `error` are hard failures. All four end
|
||||||
// so keep this set aligned with `step` and the protocol's terminal predicate.
|
// the stream, so keep this set aligned with `step` and the protocol's terminal predicate.
|
||||||
const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "response.failed"])
|
const TERMINAL_TYPES = new Set(["error", "response.completed", "response.incomplete", "response.failed"])
|
||||||
export const terminal = (event: Event) => TERMINAL_TYPES.has(event.type)
|
export const terminal = (event: Event) => TERMINAL_TYPES.has(event.type)
|
||||||
|
|
||||||
const onOutputTextDelta = (state: ParserState, event: Event, id: string): StepResult => {
|
const onOutputTextDelta = (state: ParserState, event: Event, id: string): StepResult => {
|
||||||
@@ -969,10 +1004,16 @@ const providerErrorMessage = (event: Event, fallback: string): string => {
|
|||||||
const providerError = (state: ParserState, event: Event, fallback: string) => {
|
const providerError = (state: ParserState, event: Event, fallback: string) => {
|
||||||
const code = event.code || event.error?.code || event.response?.error?.code || undefined
|
const code = event.code || event.error?.code || event.response?.error?.code || undefined
|
||||||
const message = providerErrorMessage(event, fallback)
|
const message = providerErrorMessage(event, fallback)
|
||||||
|
const status =
|
||||||
|
typeof event.status === "number"
|
||||||
|
? event.status
|
||||||
|
: typeof event.status_code === "number"
|
||||||
|
? event.status_code
|
||||||
|
: undefined
|
||||||
return new AIError({
|
return new AIError({
|
||||||
module: state.id,
|
module: state.id,
|
||||||
method: "stream",
|
method: "stream",
|
||||||
reason: classifyProviderFailure({ message, code }),
|
reason: classifyProviderFailure({ message, code, status }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1015,7 +1056,11 @@ export const step = (state: ParserState, event: Event) => {
|
|||||||
if (event.type === "response.completed" || event.type === "response.incomplete")
|
if (event.type === "response.completed" || event.type === "response.incomplete")
|
||||||
return Effect.succeed(onResponseFinish(state, event))
|
return Effect.succeed(onResponseFinish(state, event))
|
||||||
if (event.type === "response.failed") return providerError(state, event, `${state.name} response failed`)
|
if (event.type === "response.failed") return providerError(state, event, `${state.name} response failed`)
|
||||||
if (event.type === "error") return providerError(state, event, `${state.name} stream error`)
|
if (event.type === "error")
|
||||||
|
return decodeKnownErrorEvent(event).pipe(
|
||||||
|
Effect.mapError(() => ProviderShared.eventError(state.id, `${state.name} returned a malformed error event`)),
|
||||||
|
Effect.flatMap(() => providerError(state, event, `${state.name} stream error`)),
|
||||||
|
)
|
||||||
return Effect.succeed<StepResult>([state, NO_EVENTS])
|
return Effect.succeed<StepResult>([state, NO_EVENTS])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ const SERVER_CODES = new Set([
|
|||||||
"overloaded_error",
|
"overloaded_error",
|
||||||
"server_error",
|
"server_error",
|
||||||
"server_is_overloaded",
|
"server_is_overloaded",
|
||||||
|
"slow_down",
|
||||||
"serviceunavailableexception",
|
"serviceunavailableexception",
|
||||||
])
|
])
|
||||||
const INVALID_REQUEST_CODES = new Set(["invalid_prompt", "invalid_request_error", "validationexception"])
|
const INVALID_REQUEST_CODES = new Set(["invalid_prompt", "invalid_request_error", "validationexception"])
|
||||||
|
|||||||
@@ -29,14 +29,45 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/AI
|
|||||||
const transportError = (
|
const transportError = (
|
||||||
method: string,
|
method: string,
|
||||||
message: string,
|
message: string,
|
||||||
input: { readonly url?: string; readonly kind?: string } = {},
|
input: {
|
||||||
|
readonly url?: string
|
||||||
|
readonly kind?: string
|
||||||
|
readonly phase?: TransportReason["phase"]
|
||||||
|
readonly delivery?: TransportReason["delivery"]
|
||||||
|
} = {},
|
||||||
) =>
|
) =>
|
||||||
new AIError({
|
new AIError({
|
||||||
module: "WebSocketExecutor",
|
module: "WebSocketExecutor",
|
||||||
method,
|
method,
|
||||||
reason: new TransportReason({ message, url: input.url, kind: input.kind }),
|
reason: new TransportReason({
|
||||||
|
message,
|
||||||
|
url: input.url,
|
||||||
|
kind: input.kind,
|
||||||
|
phase: input.phase,
|
||||||
|
delivery: input.delivery,
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const annotateTransportError = (
|
||||||
|
error: AIError,
|
||||||
|
input: { readonly phase: TransportReason["phase"]; readonly delivery: TransportReason["delivery"] },
|
||||||
|
) =>
|
||||||
|
error.reason._tag === "Transport"
|
||||||
|
? new AIError({
|
||||||
|
module: error.module,
|
||||||
|
method: error.method,
|
||||||
|
reason: new TransportReason({
|
||||||
|
message: error.reason.message,
|
||||||
|
kind: error.reason.kind,
|
||||||
|
url: error.reason.url,
|
||||||
|
http: error.reason.http,
|
||||||
|
phase: input.phase,
|
||||||
|
delivery: input.delivery,
|
||||||
|
recovery: error.reason.recovery,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
: error
|
||||||
|
|
||||||
const eventMessage = (event: Event) => {
|
const eventMessage = (event: Event) => {
|
||||||
if ("message" in event && typeof event.message === "string") return event.message
|
if ("message" in event && typeof event.message === "string") return event.message
|
||||||
return event.type
|
return event.type
|
||||||
@@ -56,6 +87,8 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
|
|||||||
transportError("open", `WebSocket closed before opening (state ${ws.readyState})`, {
|
transportError("open", `WebSocket closed before opening (state ${ws.readyState})`, {
|
||||||
url: input.url,
|
url: input.url,
|
||||||
kind: "open",
|
kind: "open",
|
||||||
|
phase: "connect",
|
||||||
|
delivery: "not-sent",
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -79,7 +112,12 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
|
|||||||
cleanup()
|
cleanup()
|
||||||
resume(
|
resume(
|
||||||
Effect.fail(
|
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,
|
||||||
|
kind: "open",
|
||||||
|
phase: "connect",
|
||||||
|
delivery: "not-sent",
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -90,6 +128,8 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
|
|||||||
transportError("open", `WebSocket closed before opening with code ${event.code}`, {
|
transportError("open", `WebSocket closed before opening with code ${event.code}`, {
|
||||||
url: input.url,
|
url: input.url,
|
||||||
kind: "open",
|
kind: "open",
|
||||||
|
phase: "connect",
|
||||||
|
delivery: "not-sent",
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -119,6 +159,8 @@ const webSocketUrl = (value: string) =>
|
|||||||
transportError("prepare", error instanceof Error ? error.message : "Invalid WebSocket URL", {
|
transportError("prepare", error instanceof Error ? error.message : "Invalid WebSocket URL", {
|
||||||
url: value,
|
url: value,
|
||||||
kind: "websocket",
|
kind: "websocket",
|
||||||
|
phase: "prepare",
|
||||||
|
delivery: "not-sent",
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -130,6 +172,8 @@ export const open = (input: WebSocketRequest) =>
|
|||||||
transportError("open", error instanceof Error ? error.message : "Failed to construct WebSocket", {
|
transportError("open", error instanceof Error ? error.message : "Failed to construct WebSocket", {
|
||||||
url: input.url,
|
url: input.url,
|
||||||
kind: "open",
|
kind: "open",
|
||||||
|
phase: "connect",
|
||||||
|
delivery: "not-sent",
|
||||||
}),
|
}),
|
||||||
}).pipe(Effect.flatMap((ws) => fromWebSocket(ws, input)))
|
}).pipe(Effect.flatMap((ws) => fromWebSocket(ws, input)))
|
||||||
|
|
||||||
@@ -150,7 +194,11 @@ export const fromWebSocket = (
|
|||||||
Queue.failCauseUnsafe(
|
Queue.failCauseUnsafe(
|
||||||
messages,
|
messages,
|
||||||
Cause.fail(
|
Cause.fail(
|
||||||
transportError("message", "Unsupported WebSocket message payload", { url: input.url, kind: "message" }),
|
transportError("message", "Unsupported WebSocket message payload", {
|
||||||
|
url: input.url,
|
||||||
|
kind: "message",
|
||||||
|
phase: "receive",
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -158,16 +206,23 @@ export const fromWebSocket = (
|
|||||||
Queue.failCauseUnsafe(
|
Queue.failCauseUnsafe(
|
||||||
messages,
|
messages,
|
||||||
Cause.fail(
|
Cause.fail(
|
||||||
transportError("message", `WebSocket error: ${eventMessage(event)}`, { url: input.url, kind: "message" }),
|
transportError("message", `WebSocket error: ${eventMessage(event)}`, {
|
||||||
|
url: input.url,
|
||||||
|
kind: "message",
|
||||||
|
phase: "receive",
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const onClose = (event: CloseEvent) => {
|
const onClose = (event: CloseEvent) => {
|
||||||
if (event.code === 1000 || event.code === 1005) return Queue.endUnsafe(messages)
|
|
||||||
Queue.failCauseUnsafe(
|
Queue.failCauseUnsafe(
|
||||||
messages,
|
messages,
|
||||||
Cause.fail(
|
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,
|
||||||
|
kind: "close",
|
||||||
|
phase: "close",
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -189,6 +244,8 @@ export const fromWebSocket = (
|
|||||||
transportError("sendText", error instanceof Error ? error.message : "Failed to send WebSocket message", {
|
transportError("sendText", error instanceof Error ? error.message : "Failed to send WebSocket message", {
|
||||||
url: input.url,
|
url: input.url,
|
||||||
kind: "write",
|
kind: "write",
|
||||||
|
phase: "send",
|
||||||
|
delivery: "not-sent",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
messages: Stream.fromQueue(messages),
|
messages: Stream.fromQueue(messages),
|
||||||
@@ -244,6 +301,8 @@ export const json = <Body, Message>(input: JsonInput<Body, Message>): JsonTransp
|
|||||||
transportError("json", "WebSocket JSON transport requires WebSocketExecutor.Service", {
|
transportError("json", "WebSocket JSON transport requires WebSocketExecutor.Service", {
|
||||||
url: prepared.url,
|
url: prepared.url,
|
||||||
kind: "websocket",
|
kind: "websocket",
|
||||||
|
phase: "prepare",
|
||||||
|
delivery: "not-sent",
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -251,11 +310,27 @@ export const json = <Body, Message>(input: JsonInput<Body, Message>): JsonTransp
|
|||||||
return Stream.unwrap(
|
return Stream.unwrap(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const connection = yield* Effect.acquireRelease(
|
const connection = yield* Effect.acquireRelease(
|
||||||
webSocket.open({ url: prepared.url, headers: prepared.headers }),
|
webSocket
|
||||||
|
.open({ url: prepared.url, headers: prepared.headers })
|
||||||
|
.pipe(
|
||||||
|
Effect.mapError((error) => annotateTransportError(error, { phase: "connect", delivery: "not-sent" })),
|
||||||
|
),
|
||||||
(connection) => connection.close,
|
(connection) => connection.close,
|
||||||
)
|
)
|
||||||
yield* connection.sendText(prepared.message)
|
yield* connection.sendText(prepared.message)
|
||||||
return connection.messages.pipe(Stream.map((message) => messageText(message, decoder)))
|
let observed = false
|
||||||
|
return connection.messages.pipe(
|
||||||
|
Stream.map((message) => {
|
||||||
|
observed = true
|
||||||
|
return messageText(message, decoder)
|
||||||
|
}),
|
||||||
|
Stream.mapError((error) =>
|
||||||
|
annotateTransportError(error, {
|
||||||
|
phase: error.reason._tag === "Transport" && error.reason.phase === "close" ? "close" : "receive",
|
||||||
|
delivery: observed ? "accepted" : "ambiguous",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -98,6 +98,13 @@ export class TransportReason extends Schema.Class<TransportReason>("AI.Error.Tra
|
|||||||
kind: Schema.optional(Schema.String),
|
kind: Schema.optional(Schema.String),
|
||||||
url: Schema.optional(Schema.String),
|
url: Schema.optional(Schema.String),
|
||||||
http: Schema.optional(HttpContext),
|
http: Schema.optional(HttpContext),
|
||||||
|
phase: Schema.optional(
|
||||||
|
Schema.Literals(["prepare", "queue", "connect", "send", "receive", "decode", "complete", "fallback", "close"]),
|
||||||
|
),
|
||||||
|
delivery: Schema.optional(Schema.Literals(["not-sent", "rejected", "ambiguous", "accepted"])),
|
||||||
|
recovery: Schema.optional(
|
||||||
|
Schema.Literals(["retry-connect", "retry-full", "rotate-and-retry-full", "fallback-http", "fail"]),
|
||||||
|
),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
export class InvalidProviderOutputReason extends Schema.Class<InvalidProviderOutputReason>(
|
export class InvalidProviderOutputReason extends Schema.Class<InvalidProviderOutputReason>(
|
||||||
|
|||||||
@@ -69,10 +69,10 @@ describe("provider error classification", () => {
|
|||||||
|
|
||||||
test("classifies V1 overloaded provider codes", () => {
|
test("classifies V1 overloaded provider codes", () => {
|
||||||
expect(
|
expect(
|
||||||
['{"code":"resource_exhausted"}', '{"code":"service_unavailable"}'].map(
|
['{"code":"resource_exhausted"}', '{"code":"service_unavailable"}', '{"code":"slow_down"}'].map(
|
||||||
(message) => classifyProviderFailure({ message })._tag,
|
(message) => classifyProviderFailure({ message })._tag,
|
||||||
),
|
),
|
||||||
).toEqual(["ProviderInternal", "ProviderInternal"])
|
).toEqual(["ProviderInternal", "ProviderInternal", "ProviderInternal"])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("classifies transient client statuses as provider internal", () => {
|
test("classifies transient client statuses as provider internal", () => {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
ToolCallPart,
|
ToolCallPart,
|
||||||
ToolDefinition,
|
ToolDefinition,
|
||||||
ToolResultPart,
|
ToolResultPart,
|
||||||
|
TransportReason,
|
||||||
Usage,
|
Usage,
|
||||||
} from "../../src"
|
} from "../../src"
|
||||||
import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
|
import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
|
||||||
@@ -288,6 +289,114 @@ describe("OpenAI Responses route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("terminates WebSocket control events without waiting for the socket to close", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const events = [
|
||||||
|
{ type: "error", error: { code: "slow_down", message: "Try later" } },
|
||||||
|
{
|
||||||
|
type: "error",
|
||||||
|
status_code: 429,
|
||||||
|
message: "Rate limited",
|
||||||
|
headers: { "retry-after": 1, "x-request-id": "request", cached: false, invalid: [] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.failed",
|
||||||
|
response: { error: { code: "server_error", message: "Unavailable" } },
|
||||||
|
},
|
||||||
|
{ type: "error", status: "not-a-status", message: "Malformed status" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const errors = yield* Effect.forEach(events, (event) =>
|
||||||
|
LLMClient.generate(
|
||||||
|
LLM.request({
|
||||||
|
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responsesWebSocket(
|
||||||
|
"gpt-4.1-mini",
|
||||||
|
),
|
||||||
|
prompt: "Say hello.",
|
||||||
|
}),
|
||||||
|
).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
LLMClient.layer.pipe(
|
||||||
|
Layer.provide(
|
||||||
|
Layer.mergeAll(
|
||||||
|
Layer.succeed(
|
||||||
|
RequestExecutor.Service,
|
||||||
|
RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
|
||||||
|
),
|
||||||
|
Layer.succeed(
|
||||||
|
WebSocketExecutor.Service,
|
||||||
|
WebSocketExecutor.Service.of({
|
||||||
|
open: () =>
|
||||||
|
Effect.succeed({
|
||||||
|
sendText: () => Effect.void,
|
||||||
|
messages: Stream.make(ProviderShared.encodeJson(event)).pipe(Stream.concat(Stream.never)),
|
||||||
|
close: Effect.void,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Effect.flip,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(errors.map((error) => error.reason._tag)).toEqual([
|
||||||
|
"ProviderInternal",
|
||||||
|
"RateLimit",
|
||||||
|
"ProviderInternal",
|
||||||
|
"UnknownProvider",
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("marks post-send WebSocket failures with delivery state", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const failure = new AIError({
|
||||||
|
module: "test",
|
||||||
|
method: "receive",
|
||||||
|
reason: new TransportReason({ message: "socket closed", phase: "close" }),
|
||||||
|
})
|
||||||
|
const streams = [
|
||||||
|
Stream.fail(failure),
|
||||||
|
Stream.make(ProviderShared.encodeJson({ type: "response.created" })).pipe(Stream.concat(Stream.fail(failure))),
|
||||||
|
]
|
||||||
|
const deps = Layer.mergeAll(
|
||||||
|
Layer.succeed(
|
||||||
|
RequestExecutor.Service,
|
||||||
|
RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
|
||||||
|
),
|
||||||
|
Layer.succeed(
|
||||||
|
WebSocketExecutor.Service,
|
||||||
|
WebSocketExecutor.Service.of({
|
||||||
|
open: () =>
|
||||||
|
Effect.succeed({
|
||||||
|
sendText: () => Effect.void,
|
||||||
|
messages: streams.shift() ?? Stream.die("unexpected WebSocket open"),
|
||||||
|
close: Effect.void,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const model = OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responsesWebSocket(
|
||||||
|
"gpt-4.1-mini",
|
||||||
|
)
|
||||||
|
|
||||||
|
const errors = yield* Effect.forEach(["first", "second"], (prompt) =>
|
||||||
|
LLMClient.generate(LLM.request({ model, prompt })).pipe(
|
||||||
|
Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))),
|
||||||
|
Effect.flip,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(errors.map((error) => error.reason)).toEqual([
|
||||||
|
expect.objectContaining({ _tag: "Transport", phase: "close", delivery: "ambiguous" }),
|
||||||
|
expect.objectContaining({ _tag: "Transport", phase: "close", delivery: "accepted" }),
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("fails immediately when WebSocket is already closed", () =>
|
it.effect("fails immediately when WebSocket is already closed", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const error = yield* WebSocketExecutor.fromWebSocket(
|
const error = yield* WebSocketExecutor.fromWebSocket(
|
||||||
@@ -297,6 +406,7 @@ describe("OpenAI Responses route", () => {
|
|||||||
).pipe(Effect.flip)
|
).pipe(Effect.flip)
|
||||||
|
|
||||||
expect(error.message).toContain("closed before opening")
|
expect(error.message).toContain("closed before opening")
|
||||||
|
expect(error.reason).toMatchObject({ _tag: "Transport", phase: "connect", delivery: "not-sent" })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
LanguageModel,
|
LanguageModel,
|
||||||
ModelID,
|
ModelID,
|
||||||
ProviderID,
|
ProviderID,
|
||||||
|
TransportReason,
|
||||||
Usage,
|
Usage,
|
||||||
} from "../src/schema"
|
} from "../src/schema"
|
||||||
import { ProviderShared } from "../src/protocols/shared"
|
import { ProviderShared } from "../src/protocols/shared"
|
||||||
@@ -108,3 +109,21 @@ test("AI errors expose the shared runtime tag", async () => {
|
|||||||
await Effect.runPromise(Effect.fail(error).pipe(Effect.catchTag("AI.Error", () => Effect.succeed("caught")))),
|
await Effect.runPromise(Effect.fail(error).pipe(Effect.catchTag("AI.Error", () => Effect.succeed("caught")))),
|
||||||
).toBe("caught")
|
).toBe("caught")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("transport errors serialize execution facts", () => {
|
||||||
|
const reason = new TransportReason({
|
||||||
|
message: "connection closed",
|
||||||
|
phase: "receive",
|
||||||
|
delivery: "ambiguous",
|
||||||
|
recovery: "fail",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(Schema.encodeSync(TransportReason)(reason)).toEqual({
|
||||||
|
_tag: "Transport",
|
||||||
|
message: "connection closed",
|
||||||
|
phase: "receive",
|
||||||
|
delivery: "ambiguous",
|
||||||
|
recovery: "fail",
|
||||||
|
})
|
||||||
|
expect(Schema.decodeUnknownSync(TransportReason)(Schema.encodeSync(TransportReason)(reason))).toEqual(reason)
|
||||||
|
})
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ const projectID = "proj_context_resize_regression"
|
|||||||
const sessionID = "ses_context_resize_regression"
|
const sessionID = "ses_context_resize_regression"
|
||||||
const title = "Context resize regression"
|
const title = "Context resize regression"
|
||||||
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
||||||
const contextIDs = ["ctx_0100_read", "ctx_0101_glob", "ctx_0102_grep", "ctx_0103_list"]
|
const contextIDs = ["prt_0100_read", "prt_0101_glob", "prt_0102_grep", "prt_0103_list"]
|
||||||
const followingTextID = `${id("msg_assistant", 10)}:text:0`
|
const followingTextID = "prt_0104_text"
|
||||||
|
|
||||||
type Message = {
|
type Message = {
|
||||||
info: Record<string, unknown> & { id: string; role: "user" | "assistant" }
|
info: Record<string, unknown> & { id: string; role: "user" | "assistant" }
|
||||||
@@ -263,7 +263,7 @@ function turn(index: number, target: boolean, status: "running" | "completed" =
|
|||||||
),
|
),
|
||||||
contextTool(contextIDs[3]!, assistantID, "list", { path: "src" }, status),
|
contextTool(contextIDs[3]!, assistantID, "list", { path: "src" }, status),
|
||||||
{
|
{
|
||||||
id: "prt_0104_text",
|
id: followingTextID,
|
||||||
sessionID,
|
sessionID,
|
||||||
messageID: assistantID,
|
messageID: assistantID,
|
||||||
type: "text",
|
type: "text",
|
||||||
@@ -295,7 +295,7 @@ function contextTool(
|
|||||||
sessionID,
|
sessionID,
|
||||||
messageID,
|
messageID,
|
||||||
type: "tool",
|
type: "tool",
|
||||||
callID: partID,
|
callID: `call_${partID}`,
|
||||||
tool,
|
tool,
|
||||||
state: {
|
state: {
|
||||||
status,
|
status,
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
|
|||||||
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
|
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
|
||||||
|
|
||||||
if (result.key) {
|
if (result.key) {
|
||||||
await serverSDK().client.auth.set({
|
await serverSDK().legacy.auth.set({
|
||||||
providerID: result.providerID,
|
providerID: result.providerID,
|
||||||
auth: {
|
auth: {
|
||||||
type: "api",
|
type: "api",
|
||||||
|
|||||||
@@ -71,11 +71,8 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
|||||||
() => (missingBase() ? true : undefined),
|
() => (missingBase() ? true : undefined),
|
||||||
async (): Promise<Path | undefined> => {
|
async (): Promise<Path | undefined> => {
|
||||||
if ((await sdk.protocol) === "v1")
|
if ((await sdk.protocol) === "v1")
|
||||||
return sdk.client.path
|
return sdk.legacy.path.get().catch(() => undefined)
|
||||||
.get()
|
return sdk.api.location
|
||||||
.then((result) => result.data)
|
|
||||||
.catch(() => undefined)
|
|
||||||
return sdk.currentApi.location
|
|
||||||
.get()
|
.get()
|
||||||
.then((location) => ({
|
.then((location) => ({
|
||||||
state: "",
|
state: "",
|
||||||
|
|||||||
@@ -62,11 +62,8 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
() => (missingBase() ? true : undefined),
|
() => (missingBase() ? true : undefined),
|
||||||
async (): Promise<Path | undefined> => {
|
async (): Promise<Path | undefined> => {
|
||||||
if ((await sdk.protocol) === "v1")
|
if ((await sdk.protocol) === "v1")
|
||||||
return sdk.client.path
|
return sdk.legacy.path.get().catch(() => undefined)
|
||||||
.get()
|
return sdk.api.location
|
||||||
.then((result) => result.data)
|
|
||||||
.catch(() => undefined)
|
|
||||||
return sdk.currentApi.location
|
|
||||||
.get()
|
.get()
|
||||||
.then((location) => ({
|
.then((location) => ({
|
||||||
state: "",
|
state: "",
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
|
|||||||
if (props.project.id && props.project.id !== "global") {
|
if (props.project.id && props.project.id !== "global") {
|
||||||
if ((await serverCtx().sdk.protocol) !== "v1") return
|
if ((await serverCtx().sdk.protocol) !== "v1") return
|
||||||
const project = await serverCtx()
|
const project = await serverCtx()
|
||||||
.sdk.client.project.update({
|
.sdk.legacy.project.update({
|
||||||
projectID: props.project.id,
|
projectID: props.project.id,
|
||||||
directory: props.project.worktree,
|
directory: props.project.worktree,
|
||||||
name,
|
name,
|
||||||
@@ -82,12 +82,6 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
|
|||||||
})
|
})
|
||||||
.then((result) => result.data)
|
.then((result) => result.data)
|
||||||
if (!project) return
|
if (!project) return
|
||||||
// const project = await serverCtx().sdk.api.project.update({
|
|
||||||
// projectID: props.project.id,
|
|
||||||
// name,
|
|
||||||
// icon: { color: store.color || "", override: store.iconOverride || "" },
|
|
||||||
// commands: { start },
|
|
||||||
// })
|
|
||||||
serverCtx().sync.set("project", (items) =>
|
serverCtx().sync.set("project", (items) =>
|
||||||
items.map((item) => (item.id === project.id ? normalizeProjectInfo(project) : item)),
|
items.map((item) => (item.id === project.id ? normalizeProjectInfo(project) : item)),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { useLanguage } from "@/context/language"
|
|||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
import { usePlatform, type DisplayBackend } from "@/context/platform"
|
import { usePlatform, type DisplayBackend } from "@/context/platform"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
|
||||||
import { useUpdaterAction } from "./updater-action"
|
import { useUpdaterAction } from "./updater-action"
|
||||||
import {
|
import {
|
||||||
monoDefault,
|
monoDefault,
|
||||||
@@ -125,16 +125,11 @@ export const SettingsGeneral: Component = () => {
|
|||||||
|
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const serverSdk = useServerSDK()
|
const serverSdk = useServerSDK()
|
||||||
|
const protocol = useServerProtocol()
|
||||||
|
|
||||||
const [shells] = createResource(
|
const [shells] = createResource(
|
||||||
async () => {
|
() => (protocol() === "v1" ? serverSdk() : undefined),
|
||||||
const sdk = serverSdk()
|
(sdk) => sdk.legacy.pty.shells().catch(() => [] as ShellOption[]),
|
||||||
if ((await sdk.protocol) === "v1") {
|
|
||||||
return (await sdk.client.pty.shells()).data ?? []
|
|
||||||
}
|
|
||||||
// return (await sdk.api.pty.shells()).data
|
|
||||||
return [] as ShellOption[]
|
|
||||||
},
|
|
||||||
{ initialValue: [] as ShellOption[] },
|
{ initialValue: [] as ShellOption[] },
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -325,10 +320,11 @@ export const SettingsGeneral: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
|
|
||||||
<SettingsRow
|
<Show when={protocol() === "v1"}>
|
||||||
title={language.t("settings.general.row.shell.title")}
|
<SettingsRow
|
||||||
description={language.t("settings.general.row.shell.description")}
|
title={language.t("settings.general.row.shell.title")}
|
||||||
>
|
description={language.t("settings.general.row.shell.description")}
|
||||||
|
>
|
||||||
<Select
|
<Select
|
||||||
data-action="settings-shell"
|
data-action="settings-shell"
|
||||||
options={shellOptions()}
|
options={shellOptions()}
|
||||||
@@ -345,7 +341,8 @@ export const SettingsGeneral: Component = () => {
|
|||||||
triggerVariant="settings"
|
triggerVariant="settings"
|
||||||
triggerStyle={{ "min-width": "180px" }}
|
triggerStyle={{ "min-width": "180px" }}
|
||||||
/>
|
/>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
title={language.t("settings.general.row.reasoningSummaries.title")}
|
title={language.t("settings.general.row.reasoningSummaries.title")}
|
||||||
|
|||||||
@@ -122,9 +122,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) =>
|
|||||||
|
|
||||||
const disconnect = async (providerID: string, name: string) => {
|
const disconnect = async (providerID: string, name: string) => {
|
||||||
if (isConfigCustom(providerID)) {
|
if (isConfigCustom(providerID)) {
|
||||||
await serverSDK()
|
await serverSDK().legacy.auth.remove({ providerID }).catch(() => undefined)
|
||||||
.client.auth.remove({ providerID })
|
|
||||||
.catch(() => undefined)
|
|
||||||
await disableProvider(providerID, name)
|
await disableProvider(providerID, name)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { useLanguage } from "@/context/language"
|
|||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
|
||||||
import { useUpdaterAction } from "../updater-action"
|
import { useUpdaterAction } from "../updater-action"
|
||||||
import {
|
import {
|
||||||
monoDefault,
|
monoDefault,
|
||||||
@@ -92,6 +92,7 @@ export const SettingsGeneralV2: Component<{
|
|||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const serverSdk = useServerSDK()
|
const serverSdk = useServerSDK()
|
||||||
|
const protocol = useServerProtocol()
|
||||||
const mobile = createMediaQuery("(max-width: 767px)")
|
const mobile = createMediaQuery("(max-width: 767px)")
|
||||||
|
|
||||||
const updater = useUpdaterAction()
|
const updater = useUpdaterAction()
|
||||||
@@ -122,14 +123,8 @@ export const SettingsGeneralV2: Component<{
|
|||||||
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
||||||
|
|
||||||
const [shells] = createResource(
|
const [shells] = createResource(
|
||||||
async () => {
|
() => (protocol() === "v1" ? serverSdk() : undefined),
|
||||||
const sdk = serverSdk()
|
(sdk) => sdk.legacy.pty.shells().catch(() => [] as ShellOption[]),
|
||||||
if ((await sdk.protocol) === "v1") {
|
|
||||||
return (await sdk.client.pty.shells()).data ?? []
|
|
||||||
}
|
|
||||||
// return (await sdk.api.pty.shells()).data
|
|
||||||
return [] as ShellOption[]
|
|
||||||
},
|
|
||||||
{ initialValue: [] as ShellOption[] },
|
{ initialValue: [] as ShellOption[] },
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -284,10 +279,11 @@ export const SettingsGeneralV2: Component<{
|
|||||||
</div>
|
</div>
|
||||||
</SettingsRowV2>
|
</SettingsRowV2>
|
||||||
|
|
||||||
<SettingsRowV2
|
<Show when={protocol() === "v1"}>
|
||||||
title={language.t("settings.general.row.shell.title")}
|
<SettingsRowV2
|
||||||
description={language.t("settings.general.row.shell.description")}
|
title={language.t("settings.general.row.shell.title")}
|
||||||
>
|
description={language.t("settings.general.row.shell.description")}
|
||||||
|
>
|
||||||
<SelectV2
|
<SelectV2
|
||||||
appearance="inline"
|
appearance="inline"
|
||||||
data-action="settings-shell"
|
data-action="settings-shell"
|
||||||
@@ -303,7 +299,8 @@ export const SettingsGeneralV2: Component<{
|
|||||||
serverSync().updateConfig({ shell: option.value })
|
serverSync().updateConfig({ shell: option.value })
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</SettingsRowV2>
|
</SettingsRowV2>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<SettingsRowV2
|
<SettingsRowV2
|
||||||
title={language.t("settings.general.row.reasoningSummaries.title")}
|
title={language.t("settings.general.row.reasoningSummaries.title")}
|
||||||
|
|||||||
@@ -119,9 +119,7 @@ export const SettingsProvidersV2: Component<{
|
|||||||
|
|
||||||
const disconnect = async (providerID: string, name: string) => {
|
const disconnect = async (providerID: string, name: string) => {
|
||||||
if (isConfigCustom(providerID)) {
|
if (isConfigCustom(providerID)) {
|
||||||
await serverSdk()
|
await serverSdk().legacy.auth.remove({ providerID }).catch(() => undefined)
|
||||||
.client.auth.remove({ providerID })
|
|
||||||
.catch(() => undefined)
|
|
||||||
await disableProvider(providerID, name)
|
await disableProvider(providerID, name)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -318,10 +318,12 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
|||||||
{mcpConnected() > 0 ? `${mcpConnected()} ` : ""}
|
{mcpConnected() > 0 ? `${mcpConnected()} ` : ""}
|
||||||
{language.t("status.popover.tab.mcp")}
|
{language.t("status.popover.tab.mcp")}
|
||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
<Tabs.Trigger value="lsp" data-slot="tab" class="text-12-regular">
|
<Show when={protocol() === "v1"}>
|
||||||
{lspCount() > 0 ? `${lspCount()} ` : ""}
|
<Tabs.Trigger value="lsp" data-slot="tab" class="text-12-regular">
|
||||||
{language.t("status.popover.tab.lsp")}
|
{lspCount() > 0 ? `${lspCount()} ` : ""}
|
||||||
</Tabs.Trigger>
|
{language.t("status.popover.tab.lsp")}
|
||||||
|
</Tabs.Trigger>
|
||||||
|
</Show>
|
||||||
<Show when={protocol() === "v1"}>
|
<Show when={protocol() === "v1"}>
|
||||||
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
|
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
|
||||||
{pluginCount() > 0 ? `${pluginCount()} ` : ""}
|
{pluginCount() > 0 ? `${pluginCount()} ` : ""}
|
||||||
@@ -459,7 +461,8 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
|||||||
</div>
|
</div>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
|
|
||||||
<Tabs.Content value="lsp">
|
<Show when={protocol() === "v1"}>
|
||||||
|
<Tabs.Content value="lsp">
|
||||||
<div class="flex flex-col px-2 pb-2">
|
<div class="flex flex-col px-2 pb-2">
|
||||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||||
<Show
|
<Show
|
||||||
@@ -485,7 +488,8 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<Show when={protocol() === "v1"}>
|
<Show when={protocol() === "v1"}>
|
||||||
<Tabs.Content value="plugins">
|
<Tabs.Content value="plugins">
|
||||||
|
|||||||
@@ -134,8 +134,7 @@ export const createDirSyncContext = (
|
|||||||
},
|
},
|
||||||
more: createMemo(() => current()[0].session.length >= current()[0].limit),
|
more: createMemo(() => current()[0].session.length >= current()[0].limit),
|
||||||
archive: async (sessionID: string) => {
|
archive: async (sessionID: string) => {
|
||||||
if ((await serverSDK.protocol) !== "v1") return
|
await serverSDK.legacy.session.archive(sessionID, directory)
|
||||||
await serverSDK.client.session.update({ sessionID, directory, time: { archived: Date.now() } })
|
|
||||||
current()[1](
|
current()[1](
|
||||||
"session",
|
"session",
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
|
|||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { QueryClient } from "@tanstack/solid-query"
|
import { QueryClient } from "@tanstack/solid-query"
|
||||||
import type { Config, Project } from "@/types"
|
import type { Config, Project } from "@/types"
|
||||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
import type { LegacyCapabilities } from "@/utils/server-compat"
|
||||||
import type { AgentApi, CatalogApi, CommandApi, ReferenceApi } from "@opencode-ai/client/promise"
|
import type { AgentApi, CatalogApi, CommandApi, ReferenceApi } from "@opencode-ai/client/promise"
|
||||||
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||||
import {
|
import {
|
||||||
@@ -111,36 +111,7 @@ describe("bootstrapDirectory", () => {
|
|||||||
project: [{ id: "project", worktree: "/project" } as Project],
|
project: [{ id: "project", worktree: "/project" } as Project],
|
||||||
provider,
|
provider,
|
||||||
},
|
},
|
||||||
sdk: {
|
legacy: { config: { directory: async () => ({}) } } as unknown as LegacyCapabilities,
|
||||||
app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
|
|
||||||
config: { get: async () => ({ data: {} }) },
|
|
||||||
session: { status: async () => ({ data: {} }) },
|
|
||||||
vcs: { get: async () => ({ data: undefined }) },
|
|
||||||
command: {
|
|
||||||
list: async () => {
|
|
||||||
mcpReads.push("command")
|
|
||||||
return { data: [] }
|
|
||||||
},
|
|
||||||
},
|
|
||||||
permission: { list: async () => ({ data: [] }) },
|
|
||||||
question: { list: async () => ({ data: [] }) },
|
|
||||||
v2: { reference: { list: async () => ({ data: { data: [] } }) } },
|
|
||||||
mcp: {
|
|
||||||
status: async () => {
|
|
||||||
mcpReads.push("status")
|
|
||||||
return { data: {} }
|
|
||||||
},
|
|
||||||
},
|
|
||||||
experimental: {
|
|
||||||
resource: {
|
|
||||||
list: async () => {
|
|
||||||
mcpReads.push("resource")
|
|
||||||
return { data: {} }
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
|
|
||||||
} as unknown as OpencodeClient,
|
|
||||||
api: currentApi,
|
api: currentApi,
|
||||||
store,
|
store,
|
||||||
setStore,
|
setStore,
|
||||||
@@ -163,16 +134,15 @@ describe("bootstrapDirectory", () => {
|
|||||||
describe("query keys", () => {
|
describe("query keys", () => {
|
||||||
test("partitions identical directories by server scope", () => {
|
test("partitions identical directories by server scope", () => {
|
||||||
const location = {} as Parameters<typeof loadPathQuery>[2]
|
const location = {} as Parameters<typeof loadPathQuery>[2]
|
||||||
const client = {} as Parameters<typeof loadPathQuery>[3]
|
|
||||||
const api = {} as CatalogApi
|
const api = {} as CatalogApi
|
||||||
const remote = "https://debian.example" as typeof ServerScope.local
|
const remote = "https://debian.example" as typeof ServerScope.local
|
||||||
|
|
||||||
expect([...loadPathQuery(ServerScope.local, "/repo", location, client).queryKey]).toEqual([
|
expect([...loadPathQuery(ServerScope.local, "/repo", location).queryKey]).toEqual([
|
||||||
"local",
|
"local",
|
||||||
"/repo",
|
"/repo",
|
||||||
"path",
|
"path",
|
||||||
])
|
])
|
||||||
expect([...loadPathQuery(remote, "/repo", location, client).queryKey]).toEqual([
|
expect([...loadPathQuery(remote, "/repo", location).queryKey]).toEqual([
|
||||||
"https://debian.example",
|
"https://debian.example",
|
||||||
"/repo",
|
"/repo",
|
||||||
"path",
|
"path",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type {
|
|||||||
ReferenceInfo,
|
ReferenceInfo,
|
||||||
Session,
|
Session,
|
||||||
} from "@/types"
|
} from "@/types"
|
||||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
import type { LegacyCapabilities } from "@/utils/server-compat"
|
||||||
import type {
|
import type {
|
||||||
AgentListInput,
|
AgentListInput,
|
||||||
AgentListOutput,
|
AgentListOutput,
|
||||||
@@ -107,10 +107,11 @@ function showErrors(input: {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const loadGlobalConfigQuery = (scope: ServerScope, sdk: OpencodeClient) =>
|
export const loadGlobalConfigQuery = (scope: ServerScope, legacy: LegacyCapabilities, enabled = true) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: [scope, "config"],
|
queryKey: [scope, "config"],
|
||||||
queryFn: () => retry(() => sdk.global.config.get().then((x) => x.data!)),
|
queryFn: () => retry(() => legacy.config.global()),
|
||||||
|
enabled,
|
||||||
})
|
})
|
||||||
|
|
||||||
type ProjectApi = {
|
type ProjectApi = {
|
||||||
@@ -141,7 +142,7 @@ export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) =>
|
|||||||
})
|
})
|
||||||
|
|
||||||
export async function bootstrapGlobal(input: {
|
export async function bootstrapGlobal(input: {
|
||||||
serverSDK: OpencodeClient
|
legacy: LegacyCapabilities
|
||||||
serverAPI: CatalogApi & { readonly location: LocationApi; readonly project: ProjectApi }
|
serverAPI: CatalogApi & { readonly location: LocationApi; readonly project: ProjectApi }
|
||||||
protocol?: Promise<ServerProtocol>
|
protocol?: Promise<ServerProtocol>
|
||||||
scope: ServerScope
|
scope: ServerScope
|
||||||
@@ -151,21 +152,22 @@ export async function bootstrapGlobal(input: {
|
|||||||
setGlobalStore: SetStoreFunction<GlobalStore>
|
setGlobalStore: SetStoreFunction<GlobalStore>
|
||||||
queryClient: QueryClient
|
queryClient: QueryClient
|
||||||
}) {
|
}) {
|
||||||
|
const protocol = await input.protocol
|
||||||
const slow = [
|
const slow = [
|
||||||
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK)),
|
protocol === "v1" && (() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.legacy))),
|
||||||
() =>
|
() =>
|
||||||
input.queryClient.fetchQuery(
|
input.queryClient.fetchQuery(
|
||||||
loadProvidersQuery(input.scope, null, input.serverAPI),
|
loadProvidersQuery(input.scope, null, input.serverAPI),
|
||||||
),
|
),
|
||||||
() =>
|
() =>
|
||||||
input.queryClient.fetchQuery(
|
input.queryClient.fetchQuery(
|
||||||
loadPathQuery(input.scope, null, input.serverAPI.location, input.serverSDK, input.protocol),
|
loadPathQuery(input.scope, null, input.serverAPI.location),
|
||||||
),
|
),
|
||||||
() =>
|
() =>
|
||||||
input.queryClient
|
input.queryClient
|
||||||
.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project))
|
.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project))
|
||||||
.then((data) => input.setGlobalStore("project", data)),
|
.then((data) => input.setGlobalStore("project", data)),
|
||||||
]
|
].filter(Boolean) as Array<() => Promise<unknown>>
|
||||||
await runAll(slow)
|
await runAll(slow)
|
||||||
// showErrors({
|
// showErrors({
|
||||||
// errors: errors(),
|
// errors: errors(),
|
||||||
@@ -273,22 +275,17 @@ export const loadPathQuery = (
|
|||||||
scope: ServerScope,
|
scope: ServerScope,
|
||||||
directory: string | null,
|
directory: string | null,
|
||||||
api: LocationApi,
|
api: LocationApi,
|
||||||
sdk: OpencodeClient,
|
|
||||||
protocol?: Promise<ServerProtocol>,
|
|
||||||
) =>
|
) =>
|
||||||
queryOptions<Path>({
|
queryOptions<Path>({
|
||||||
queryKey: [scope, directory, "path"],
|
queryKey: [scope, directory, "path"],
|
||||||
queryFn: async () => {
|
queryFn: () =>
|
||||||
if ((await protocol) === "v1")
|
retry(() => api.get(directory ? { location: { directory } } : undefined)).then((location) => ({
|
||||||
return retry(() => sdk.path.get({ directory: directory ?? undefined }).then((result) => result.data!))
|
|
||||||
return retry(() => api.get(directory ? { location: { directory } } : undefined)).then((location) => ({
|
|
||||||
state: "",
|
state: "",
|
||||||
config: "",
|
config: "",
|
||||||
worktree: location.project.directory,
|
worktree: location.project.directory,
|
||||||
directory: location.directory,
|
directory: location.directory,
|
||||||
home: "",
|
home: "",
|
||||||
}))
|
})),
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export const loadReferencesQuery = (
|
export const loadReferencesQuery = (
|
||||||
@@ -307,7 +304,7 @@ export async function bootstrapDirectory(input: {
|
|||||||
directory: string
|
directory: string
|
||||||
scope: ServerScope
|
scope: ServerScope
|
||||||
mcp: boolean
|
mcp: boolean
|
||||||
sdk: OpencodeClient
|
legacy: LegacyCapabilities
|
||||||
api: CatalogApi & {
|
api: CatalogApi & {
|
||||||
readonly agent: AgentListApi
|
readonly agent: AgentListApi
|
||||||
readonly command: CommandListApi
|
readonly command: CommandListApi
|
||||||
@@ -355,35 +352,13 @@ export async function bootstrapDirectory(input: {
|
|||||||
input.queryClient
|
input.queryClient
|
||||||
.ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.api.agent))
|
.ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.api.agent))
|
||||||
.then((data) => input.setStore("agent", data)),
|
.then((data) => input.setStore("agent", data)),
|
||||||
() =>
|
(await input.protocol) === "v1" &&
|
||||||
retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))),
|
(() =>
|
||||||
() =>
|
retry(() =>
|
||||||
retry(() =>
|
input.legacy.config
|
||||||
(async () => {
|
.directory(input.directory)
|
||||||
if ((await input.protocol) !== "v1") return
|
.then((config) => input.setStore("config", reconcile(config, { merge: false }))),
|
||||||
const x = await input.sdk.session.status()
|
)),
|
||||||
if (!input.session) {
|
|
||||||
input.setStore("session_status", x.data!)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const statuses = x.data ?? {}
|
|
||||||
input.session.set(
|
|
||||||
"session_status",
|
|
||||||
produce((draft) => {
|
|
||||||
for (const sessionID of Object.keys(draft)) {
|
|
||||||
if (statuses[sessionID]) continue
|
|
||||||
if (input.session?.get(sessionID)?.directory === input.directory) delete draft[sessionID]
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
for (const [sessionID, status] of Object.entries(statuses)) {
|
|
||||||
input.session.set("session_status", sessionID, reconcile(status))
|
|
||||||
}
|
|
||||||
await Promise.all(
|
|
||||||
Object.keys(statuses).map((sessionID) => input.session!.resolve(sessionID).catch(() => undefined)),
|
|
||||||
)
|
|
||||||
})(),
|
|
||||||
),
|
|
||||||
!seededProject &&
|
!seededProject &&
|
||||||
(() =>
|
(() =>
|
||||||
retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) =>
|
retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) =>
|
||||||
@@ -393,21 +368,12 @@ export async function bootstrapDirectory(input: {
|
|||||||
(() =>
|
(() =>
|
||||||
input.queryClient
|
input.queryClient
|
||||||
.ensureQueryData(
|
.ensureQueryData(
|
||||||
loadPathQuery(input.scope, input.directory, input.api.location, input.sdk, input.protocol),
|
loadPathQuery(input.scope, input.directory, input.api.location),
|
||||||
)
|
)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
const next = projectID(data.directory ?? input.directory, input.global.project)
|
const next = projectID(data.directory ?? input.directory, input.global.project)
|
||||||
if (next) input.setStore("project", next)
|
if (next) input.setStore("project", next)
|
||||||
})),
|
})),
|
||||||
() =>
|
|
||||||
retry(async () => {
|
|
||||||
if ((await input.protocol) !== "v1") return
|
|
||||||
return input.sdk.vcs.get().then((result) => {
|
|
||||||
const next = { branch: result.data?.branch, default_branch: result.data?.default_branch }
|
|
||||||
input.setStore("vcs", next)
|
|
||||||
if (next) input.vcsCache.setStore("value", next)
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
input.mcp &&
|
input.mcp &&
|
||||||
(() =>
|
(() =>
|
||||||
loadCommands(input.directory, input.api.command).then((commands) =>
|
loadCommands(input.directory, input.api.command).then((commands) =>
|
||||||
@@ -419,12 +385,10 @@ export async function bootstrapDirectory(input: {
|
|||||||
),
|
),
|
||||||
() =>
|
() =>
|
||||||
retry(() =>
|
retry(() =>
|
||||||
(async () => {
|
input.api.permission.request
|
||||||
if ((await input.protocol) === "v1") return (await input.sdk.permission.list()).data ?? []
|
.list({ location: { directory: input.directory } })
|
||||||
return input.api.permission.request
|
.then((result) => result.data.map(normalizePermissionRequest))
|
||||||
.list({ location: { directory: input.directory } })
|
.then((permissions) => {
|
||||||
.then((result) => result.data.map(normalizePermissionRequest))
|
|
||||||
})().then((permissions) => {
|
|
||||||
const ids = permissions.map((permission) => permission.sessionID)
|
const ids = permissions.map((permission) => permission.sessionID)
|
||||||
const grouped = groupBySession(
|
const grouped = groupBySession(
|
||||||
permissions.filter((permission) => !!permission.id && !!permission.sessionID),
|
permissions.filter((permission) => !!permission.id && !!permission.sessionID),
|
||||||
@@ -455,12 +419,10 @@ export async function bootstrapDirectory(input: {
|
|||||||
),
|
),
|
||||||
() =>
|
() =>
|
||||||
retry(() =>
|
retry(() =>
|
||||||
(async () => {
|
input.api.question.request
|
||||||
if ((await input.protocol) === "v1") return (await input.sdk.question.list()).data ?? []
|
.list({ location: { directory: input.directory } })
|
||||||
return input.api.question.request
|
.then((result) => result.data)
|
||||||
.list({ location: { directory: input.directory } })
|
.then((questions) => {
|
||||||
.then((result) => result.data)
|
|
||||||
})().then((questions) => {
|
|
||||||
const ids = questions.map((question) => question.sessionID)
|
const ids = questions.map((question) => question.sessionID)
|
||||||
const grouped = groupBySession(
|
const grouped = groupBySession(
|
||||||
questions.filter((question) => !!question.id && !!question.sessionID) as QuestionRequest[],
|
questions.filter((question) => !!question.id && !!question.sessionID) as QuestionRequest[],
|
||||||
|
|||||||
@@ -191,7 +191,10 @@ export function createChildStoreManager(input: {
|
|||||||
const pathQuery = useQuery(() => ({ ...input.queryOptions.path(key), enabled: instanceQueriesEnabled() }))
|
const pathQuery = useQuery(() => ({ ...input.queryOptions.path(key), enabled: instanceQueriesEnabled() }))
|
||||||
const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() }))
|
const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() }))
|
||||||
const mcpResourceQuery = useQuery(() => ({ ...input.queryOptions.mcpResources(key), enabled: mcpEnabled() }))
|
const mcpResourceQuery = useQuery(() => ({ ...input.queryOptions.mcpResources(key), enabled: mcpEnabled() }))
|
||||||
const lspQuery = useQuery(() => ({ ...input.queryOptions.lsp(key), enabled: instanceQueriesEnabled() }))
|
const lspQuery = useQuery(() => {
|
||||||
|
const options = input.queryOptions.lsp(key)
|
||||||
|
return { ...options, enabled: options.enabled !== false && instanceQueriesEnabled() }
|
||||||
|
})
|
||||||
const providerQuery = useQuery(() => ({
|
const providerQuery = useQuery(() => ({
|
||||||
...input.queryOptions.providers(key),
|
...input.queryOptions.providers(key),
|
||||||
enabled: instanceQueriesEnabled(),
|
enabled: instanceQueriesEnabled(),
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { SessionApi } from "@opencode-ai/client/promise"
|
import type { SessionApi } from "@opencode-ai/client/promise"
|
||||||
import { normalizeSessionInfo } from "@/utils/session"
|
import { normalizeSessionInfo } from "@/utils/session"
|
||||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
|
||||||
|
|
||||||
export async function loadRootSessions(input: { api: Pick<SessionApi, "list">; directory: string; limit: number }) {
|
export async function loadRootSessions(input: { api: Pick<SessionApi, "list">; directory: string; limit: number }) {
|
||||||
const result = await input.api.list({
|
const result = await input.api.list({
|
||||||
@@ -16,16 +15,6 @@ export async function loadRootSessions(input: { api: Pick<SessionApi, "list">; d
|
|||||||
} as const
|
} as const
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadRootSessionsV1(input: { client: OpencodeClient; directory: string; limit: number }) {
|
|
||||||
try {
|
|
||||||
const result = await input.client.session.list({ directory: input.directory, roots: true, limit: input.limit })
|
|
||||||
return { data: result.data, limit: input.limit, limited: true } as const
|
|
||||||
} catch {
|
|
||||||
const result = await input.client.session.list({ directory: input.directory, roots: true })
|
|
||||||
return { data: result.data, limit: input.limit, limited: false } as const
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function estimateRootSessionTotal(input: { count: number; limit: number; limited: boolean }) {
|
export function estimateRootSessionTotal(input: { count: number; limit: number; limited: boolean }) {
|
||||||
if (!input.limited) return input.count
|
if (!input.limited) return input.count
|
||||||
if (input.count < input.limit) return input.count
|
if (input.count < input.limit) return input.count
|
||||||
|
|||||||
@@ -574,7 +574,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
const sdk = serverSdk()
|
const sdk = serverSdk()
|
||||||
if ((await sdk.protocol) !== "v1") return
|
if ((await sdk.protocol) !== "v1") return
|
||||||
return sdk.client.project
|
return sdk.legacy.project
|
||||||
.update({ projectID, directory: worktree, icon: { color } })
|
.update({ projectID, directory: worktree, icon: { color } })
|
||||||
.then((response) => response.data)
|
.then((response) => response.data)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
|
|||||||
@@ -258,9 +258,6 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
|
|||||||
}
|
}
|
||||||
|
|
||||||
const list = async (directory: string) => {
|
const list = async (directory: string) => {
|
||||||
if ((await input.sdk.protocol) === "v1") {
|
|
||||||
return (await input.sdk.client.permission.list({ directory })).data ?? []
|
|
||||||
}
|
|
||||||
return input.sdk.api.permission.request
|
return input.sdk.api.permission.request
|
||||||
.list({ location: { directory } })
|
.list({ location: { directory } })
|
||||||
.then((result) => result.data.map(normalizePermissionRequest))
|
.then((result) => result.data.map(normalizePermissionRequest))
|
||||||
|
|||||||
@@ -12,7 +12,12 @@ import { createRefCountMap } from "@/utils/refcount"
|
|||||||
import { useGlobal } from "./global"
|
import { useGlobal } from "./global"
|
||||||
import { ServerScope } from "@/utils/server-scope"
|
import { ServerScope } from "@/utils/server-scope"
|
||||||
import { detectServerProtocol, type ServerProtocol } from "@/utils/server-protocol"
|
import { detectServerProtocol, type ServerProtocol } from "@/utils/server-protocol"
|
||||||
import { createCompatibleApi, type CompatibleApi } from "@/utils/server-compat"
|
import {
|
||||||
|
createCompatibleApi,
|
||||||
|
createLegacyCapabilities,
|
||||||
|
type CompatibleApi,
|
||||||
|
type LegacyCapabilities,
|
||||||
|
} from "@/utils/server-compat"
|
||||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
const isAbortError = (error: unknown) =>
|
const isAbortError = (error: unknown) =>
|
||||||
@@ -166,6 +171,7 @@ type ServerSDKBase = {
|
|||||||
url: string
|
url: string
|
||||||
client: ReturnType<typeof createSdkForServer>
|
client: ReturnType<typeof createSdkForServer>
|
||||||
api: CompatibleApi
|
api: CompatibleApi
|
||||||
|
legacy: LegacyCapabilities
|
||||||
currentApi: ServerApi
|
currentApi: ServerApi
|
||||||
event: {
|
event: {
|
||||||
on: ServerEventEmitter["on"]
|
on: ServerEventEmitter["on"]
|
||||||
@@ -329,6 +335,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
|||||||
directory,
|
directory,
|
||||||
})
|
})
|
||||||
const api = createCompatibleApi({ protocol, current: currentApi, legacy })
|
const api = createCompatibleApi({ protocol, current: currentApi, legacy })
|
||||||
|
const capabilities = createLegacyCapabilities({ protocol, current: currentApi, legacy })
|
||||||
|
|
||||||
return {
|
return {
|
||||||
server,
|
server,
|
||||||
@@ -338,6 +345,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
|||||||
url: server.http.url,
|
url: server.http.url,
|
||||||
client: sdk,
|
client: sdk,
|
||||||
api,
|
api,
|
||||||
|
legacy: capabilities,
|
||||||
currentApi,
|
currentApi,
|
||||||
event: {
|
event: {
|
||||||
on: emitter.on.bind(emitter),
|
on: emitter.on.bind(emitter),
|
||||||
@@ -365,6 +373,7 @@ export type DirectorySDK = {
|
|||||||
client: OpencodeClient
|
client: OpencodeClient
|
||||||
currentApi: ServerApi
|
currentApi: ServerApi
|
||||||
api: CompatibleApi
|
api: CompatibleApi
|
||||||
|
legacy: LegacyCapabilities
|
||||||
event: ReturnType<typeof createGlobalEmitter<SDKEventMap>>
|
event: ReturnType<typeof createGlobalEmitter<SDKEventMap>>
|
||||||
readonly url: string
|
readonly url: string
|
||||||
createClient: ServerSDKBase["createClient"]
|
createClient: ServerSDKBase["createClient"]
|
||||||
@@ -428,6 +437,12 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDKBase): Direc
|
|||||||
legacy: (next) => serverSDK.createClient({ directory: next ?? directory, throwOnError: true }),
|
legacy: (next) => serverSDK.createClient({ directory: next ?? directory, throwOnError: true }),
|
||||||
directory,
|
directory,
|
||||||
}),
|
}),
|
||||||
|
legacy: createLegacyCapabilities({
|
||||||
|
protocol: serverSDK.protocol,
|
||||||
|
current: serverSDK.currentApi,
|
||||||
|
legacy: (next) => serverSDK.createClient({ directory: next ?? directory, throwOnError: true }),
|
||||||
|
directory,
|
||||||
|
}),
|
||||||
event: emitter,
|
event: emitter,
|
||||||
get url() {
|
get url() {
|
||||||
return serverSDK.url
|
return serverSDK.url
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import type {
|
|||||||
SessionStatus,
|
SessionStatus,
|
||||||
Todo,
|
Todo,
|
||||||
} from "@/types"
|
} from "@/types"
|
||||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
import type { LegacyCapabilities } from "@/utils/server-compat"
|
||||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||||
import { batch } from "solid-js"
|
import { batch } from "solid-js"
|
||||||
import { createStore, produce, reconcile } from "solid-js/store"
|
import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
@@ -183,10 +183,14 @@ function reconcileFetched<T extends { id: string }>(
|
|||||||
return [...result.values()].sort((a, b) => cmp(a.id, b.id))
|
return [...result.values()].sort((a, b) => cmp(a.id, b.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
type ServerSessionOptions = { retry?: typeof retry; protocol?: Promise<"v1" | "v2"> }
|
type ServerSessionOptions = {
|
||||||
|
retry?: typeof retry
|
||||||
|
protocol?: Promise<"v1" | "v2">
|
||||||
|
legacy?: LegacyCapabilities
|
||||||
|
}
|
||||||
|
|
||||||
export function createServerSession(
|
export function createServerSession(
|
||||||
client: OpencodeClient,
|
client: { session: Pick<LegacyCapabilities["session"], "get" | "messages" | "message"> },
|
||||||
sessionApiOrOptions?: SessionApi | ServerSessionOptions,
|
sessionApiOrOptions?: SessionApi | ServerSessionOptions,
|
||||||
messageApi?: MessageApi,
|
messageApi?: MessageApi,
|
||||||
currentOptions?: ServerSessionOptions,
|
currentOptions?: ServerSessionOptions,
|
||||||
@@ -1389,14 +1393,16 @@ export function createServerSession(
|
|||||||
touch(sessionID)
|
touch(sessionID)
|
||||||
if (data.todo[sessionID] !== undefined && !request?.force) return
|
if (data.todo[sessionID] !== undefined && !request?.force) return
|
||||||
if ((await options?.protocol) === "v2") {
|
if ((await options?.protocol) === "v2") {
|
||||||
|
// TODO: Restore todos when the V2 API exposes a session todo snapshot.
|
||||||
setData("todo", sessionID, [])
|
setData("todo", sessionID, [])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
return runInflight(inflightTodo, sessionID, () => {
|
return runInflight(inflightTodo, sessionID, () => {
|
||||||
const active = generation(sessionID)
|
const active = generation(sessionID)
|
||||||
return (options?.retry ?? retry)(() => client.session.todo({ sessionID })).then((result) => {
|
if (!options?.legacy) return Promise.resolve()
|
||||||
|
return (options.retry ?? retry)(() => options.legacy!.session.todo(sessionID)).then((result) => {
|
||||||
if (generations.get(sessionID) !== active) return
|
if (generations.get(sessionID) !== active) return
|
||||||
setData("todo", sessionID, reconcile(result.data ?? [], { key: "id" }))
|
setData("todo", sessionID, reconcile(result, { key: "id" }))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import type {
|
|||||||
ProviderAuthResponse,
|
ProviderAuthResponse,
|
||||||
SessionStatus,
|
SessionStatus,
|
||||||
} from "@/types"
|
} from "@/types"
|
||||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js"
|
import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js"
|
||||||
@@ -60,6 +59,7 @@ import type {
|
|||||||
import { toggleMcp } from "./global-sync/mcp"
|
import { toggleMcp } from "./global-sync/mcp"
|
||||||
import { createServerSession, type ServerSession } from "./server-session"
|
import { createServerSession, type ServerSession } from "./server-session"
|
||||||
import { usePlatform } from "./platform"
|
import { usePlatform } from "./platform"
|
||||||
|
import type { LegacyCapabilities } from "@/utils/server-compat"
|
||||||
|
|
||||||
type GlobalStore = {
|
type GlobalStore = {
|
||||||
ready: boolean
|
ready: boolean
|
||||||
@@ -132,10 +132,11 @@ export const loadMcpResourcesQuery = (
|
|||||||
placeholderData: {},
|
placeholderData: {},
|
||||||
})
|
})
|
||||||
|
|
||||||
export const loadLspQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
|
export const loadLspQuery = (scope: ServerScope, directory: string, legacy: LegacyCapabilities, enabled = true) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: [scope, directory, "lsp"] as const,
|
queryKey: [scope, directory, "lsp"] as const,
|
||||||
queryFn: () => sdk.lsp.status().then((r) => r.data ?? []),
|
queryFn: () => legacy.lsp.status(directory),
|
||||||
|
enabled,
|
||||||
})
|
})
|
||||||
|
|
||||||
export const loadActiveSessionsQuery = (
|
export const loadActiveSessionsQuery = (
|
||||||
@@ -166,22 +167,20 @@ export function seedActiveSessionStatuses(
|
|||||||
|
|
||||||
function makeQueryOptionsApi(
|
function makeQueryOptionsApi(
|
||||||
scope: ServerScope,
|
scope: ServerScope,
|
||||||
serverSDK: () => OpencodeClient,
|
|
||||||
serverAPI: ServerApi,
|
serverAPI: ServerApi,
|
||||||
sdkFor: (dir: PathKey) => OpencodeClient,
|
protocolKind: Accessor<"v1" | "v2" | undefined>,
|
||||||
protocol: Promise<"v1" | "v2">,
|
legacy: LegacyCapabilities,
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
globalConfig: () => loadGlobalConfigQuery(scope, serverSDK()),
|
globalConfig: () => loadGlobalConfigQuery(scope, legacy, protocolKind() === "v1"),
|
||||||
projects: () => loadProjectsQuery(scope, serverAPI.project),
|
projects: () => loadProjectsQuery(scope, serverAPI.project),
|
||||||
providers: (directory: PathKey | null) => loadProvidersQuery(scope, directory, serverAPI),
|
providers: (directory: PathKey | null) => loadProvidersQuery(scope, directory, serverAPI),
|
||||||
path: (directory: PathKey | null) =>
|
path: (directory: PathKey | null) => loadPathQuery(scope, directory, serverAPI.location),
|
||||||
loadPathQuery(scope, directory, serverAPI.location, directory ? sdkFor(directory) : serverSDK(), protocol),
|
|
||||||
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, serverAPI.agent),
|
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, serverAPI.agent),
|
||||||
references: (directory: PathKey) => loadReferencesQuery(scope, directory, serverAPI.reference),
|
references: (directory: PathKey) => loadReferencesQuery(scope, directory, serverAPI.reference),
|
||||||
mcp: (directory: PathKey) => loadMcpQuery(scope, directory, serverAPI.mcp),
|
mcp: (directory: PathKey) => loadMcpQuery(scope, directory, serverAPI.mcp),
|
||||||
mcpResources: (directory: PathKey) => loadMcpResourcesQuery(scope, directory, serverAPI.mcp),
|
mcpResources: (directory: PathKey) => loadMcpResourcesQuery(scope, directory, serverAPI.mcp),
|
||||||
lsp: (directory: PathKey) => loadLspQuery(scope, directory, sdkFor(directory)),
|
lsp: (directory: PathKey) => loadLspQuery(scope, directory, legacy, protocolKind() === "v1"),
|
||||||
sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }),
|
sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -193,30 +192,24 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
const owner = getOwner()
|
const owner = getOwner()
|
||||||
if (!owner) throw new Error("ServerSync must be created within owner")
|
if (!owner) throw new Error("ServerSync must be created within owner")
|
||||||
|
|
||||||
const sdkCache = new Map<string, OpencodeClient>()
|
|
||||||
const booting = new Map<string, Promise<void>>()
|
const booting = new Map<string, Promise<void>>()
|
||||||
const sessionLoads = new Map<string, Promise<void>>()
|
const sessionLoads = new Map<string, Promise<void>>()
|
||||||
const sessionMeta = new Map<string, { limit: number }>()
|
const sessionMeta = new Map<string, { limit: number }>()
|
||||||
|
|
||||||
const sdkFor = (directory: string) => {
|
const session = createServerSession(
|
||||||
const key = directoryKey(directory)
|
{ session: serverSDK.legacy.session },
|
||||||
const cached = sdkCache.get(key)
|
serverSDK.currentApi.session,
|
||||||
if (cached) return cached
|
serverSDK.currentApi.message,
|
||||||
const sdk = serverSDK.createClient({
|
{
|
||||||
directory,
|
protocol: serverSDK.protocol,
|
||||||
throwOnError: true,
|
legacy: serverSDK.legacy,
|
||||||
})
|
},
|
||||||
sdkCache.set(key, sdk)
|
)
|
||||||
return sdk
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = createServerSession(serverSDK.client, serverSDK.currentApi.session, serverSDK.currentApi.message)
|
|
||||||
const queryOptionsApi = makeQueryOptionsApi(
|
const queryOptionsApi = makeQueryOptionsApi(
|
||||||
serverSDK.scope,
|
serverSDK.scope,
|
||||||
() => serverSDK.client,
|
|
||||||
serverSDK.currentApi,
|
serverSDK.currentApi,
|
||||||
sdkFor,
|
serverSDK.protocolKind,
|
||||||
serverSDK.protocol,
|
serverSDK.legacy,
|
||||||
)
|
)
|
||||||
|
|
||||||
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
|
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
|
||||||
@@ -293,7 +286,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
queryKey: [serverSDK.scope, "bootstrap"],
|
queryKey: [serverSDK.scope, "bootstrap"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
await bootstrapGlobal({
|
await bootstrapGlobal({
|
||||||
serverSDK: serverSDK.client,
|
legacy: serverSDK.legacy,
|
||||||
serverAPI: serverSDK.currentApi,
|
serverAPI: serverSDK.currentApi,
|
||||||
protocol: serverSDK.protocol,
|
protocol: serverSDK.protocol,
|
||||||
scope: serverSDK.scope,
|
scope: serverSDK.scope,
|
||||||
@@ -349,7 +342,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
const key = directoryKey(directory)
|
const key = directoryKey(directory)
|
||||||
queue.clear(key)
|
queue.clear(key)
|
||||||
sessionMeta.delete(key)
|
sessionMeta.delete(key)
|
||||||
sdkCache.delete(key)
|
|
||||||
clearProviderRev(serverSDK.scope, key)
|
clearProviderRev(serverSDK.scope, key)
|
||||||
},
|
},
|
||||||
translate: language.t,
|
translate: language.t,
|
||||||
@@ -446,7 +438,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
const child = children.ensureChild(directory)
|
const child = children.ensureChild(directory)
|
||||||
const cache = children.vcsCache.get(key)
|
const cache = children.vcsCache.get(key)
|
||||||
if (!cache) return
|
if (!cache) return
|
||||||
const sdk = sdkFor(directory)
|
|
||||||
await bootstrapDirectory({
|
await bootstrapDirectory({
|
||||||
directory,
|
directory,
|
||||||
scope: serverSDK.scope,
|
scope: serverSDK.scope,
|
||||||
@@ -457,7 +448,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
project: globalStore.project,
|
project: globalStore.project,
|
||||||
provider: globalStore.provider,
|
provider: globalStore.provider,
|
||||||
},
|
},
|
||||||
sdk,
|
legacy: serverSDK.legacy,
|
||||||
api: serverSDK.currentApi,
|
api: serverSDK.currentApi,
|
||||||
store: child[0],
|
store: child[0],
|
||||||
setStore: child[1],
|
setStore: child[1],
|
||||||
@@ -577,6 +568,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
permission: session.data.permission,
|
permission: session.data.permission,
|
||||||
vcsCache: children.vcsCache.get(key),
|
vcsCache: children.vcsCache.get(key),
|
||||||
loadLsp: () => {
|
loadLsp: () => {
|
||||||
|
if (serverSDK.protocolKind() !== "v1") return
|
||||||
if (!children.active(key)) return
|
if (!children.active(key)) return
|
||||||
void queryClient.fetchQuery(queryOptionsApi.lsp(key))
|
void queryClient.fetchQuery(queryOptionsApi.lsp(key))
|
||||||
},
|
},
|
||||||
@@ -625,7 +617,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updateConfigMutation = useMutation(() => ({
|
const updateConfigMutation = useMutation(() => ({
|
||||||
mutationFn: (config: Config) => serverSDK.client.global.config.update({ config }),
|
mutationFn: (config: Config) => serverSDK.legacy.config.update(config),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
bootstrap.refetch()
|
bootstrap.refetch()
|
||||||
// Invalidate all provider queries so newly configured custom providers
|
// Invalidate all provider queries so newly configured custom providers
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ export function createHomeProjectsController(home: HomeController) {
|
|||||||
home.server.context(conn).projects.move(worktree, index)
|
home.server.context(conn).projects.move(worktree, index)
|
||||||
},
|
},
|
||||||
canReveal: canRevealProject,
|
canReveal: canRevealProject,
|
||||||
|
canEdit: (conn: ServerConnection.Any) => home.server.context(conn).sdk.protocolKind() === "v1",
|
||||||
reveal: (conn: ServerConnection.Any, project: LocalProject) => {
|
reveal: (conn: ServerConnection.Any, project: LocalProject) => {
|
||||||
if (!platform.openPath || !canRevealProject(conn)) return
|
if (!platform.openPath || !canRevealProject(conn)) return
|
||||||
platform.openPath(project.worktree).catch((cause: unknown) =>
|
platform.openPath(project.worktree).catch((cause: unknown) =>
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export type HomeProjectsViewProps = {
|
|||||||
canDefaultServer: Accessor<boolean>
|
canDefaultServer: Accessor<boolean>
|
||||||
defaultServerKey: Accessor<ServerConnection.Key | null | undefined>
|
defaultServerKey: Accessor<ServerConnection.Key | null | undefined>
|
||||||
canRevealProject: (server: ServerConnection.Any) => boolean
|
canRevealProject: (server: ServerConnection.Any) => boolean
|
||||||
|
canEditProject: (server: ServerConnection.Any) => boolean
|
||||||
unseenCount: (server: ServerConnection.Any, project: LocalProject) => number
|
unseenCount: (server: ServerConnection.Any, project: LocalProject) => number
|
||||||
onWheel: (event: WheelEvent) => void
|
onWheel: (event: WheelEvent) => void
|
||||||
onChooseProject: (server: ServerConnection.Any) => void
|
onChooseProject: (server: ServerConnection.Any) => void
|
||||||
@@ -548,9 +549,11 @@ function HomeProjectRow(
|
|||||||
<MenuV2.Item onSelect={() => props.onOpenProjectNewSession(props.server, props.project.worktree)}>
|
<MenuV2.Item onSelect={() => props.onOpenProjectNewSession(props.server, props.project.worktree)}>
|
||||||
{props.language.t("command.session.new")}
|
{props.language.t("command.session.new")}
|
||||||
</MenuV2.Item>
|
</MenuV2.Item>
|
||||||
<MenuV2.Item onSelect={() => props.onEditProject(props.server, props.project)}>
|
<Show when={props.canEditProject(props.server)}>
|
||||||
{props.language.t("dialog.project.edit.title")}
|
<MenuV2.Item onSelect={() => props.onEditProject(props.server, props.project)}>
|
||||||
</MenuV2.Item>
|
{props.language.t("dialog.project.edit.title")}
|
||||||
|
</MenuV2.Item>
|
||||||
|
</Show>
|
||||||
<Show when={props.canRevealProject(props.server)}>
|
<Show when={props.canRevealProject(props.server)}>
|
||||||
<MenuV2.Item onSelect={() => props.onRevealProject(props.server, props.project)}>
|
<MenuV2.Item onSelect={() => props.onRevealProject(props.server, props.project)}>
|
||||||
{props.language.t(
|
{props.language.t(
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export function HomeProjects(props: { projects: HomeProjectsController; scroll:
|
|||||||
canDefaultServer={props.projects.server.canDefault}
|
canDefaultServer={props.projects.server.canDefault}
|
||||||
defaultServerKey={props.projects.server.defaultKey}
|
defaultServerKey={props.projects.server.defaultKey}
|
||||||
canRevealProject={props.projects.project.canReveal}
|
canRevealProject={props.projects.project.canReveal}
|
||||||
|
canEditProject={props.projects.project.canEdit}
|
||||||
unseenCount={props.projects.project.unseenCount}
|
unseenCount={props.projects.project.unseenCount}
|
||||||
onWheel={props.scroll.viewport.containWheel}
|
onWheel={props.scroll.viewport.containWheel}
|
||||||
onChooseProject={props.projects.project.choose}
|
onChooseProject={props.projects.project.choose}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Session } from "@/types"
|
import type { Session, V2SessionListResponse } from "@/types"
|
||||||
import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache"
|
import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { useMarked } from "@opencode-ai/ui/context/marked"
|
import { useMarked } from "@opencode-ai/ui/context/marked"
|
||||||
@@ -69,7 +69,10 @@ export function createHomeSessionsController(home: HomeController) {
|
|||||||
const cache = homeSessions()
|
const cache = homeSessions()
|
||||||
const eventSequence = cache.eventSequence()
|
const eventSequence = cache.eventSequence()
|
||||||
const index = await loadHomeSessionIndex(
|
const index = await loadHomeSessionIndex(
|
||||||
(input, options) => ctx.sdk.client.v2.session.list(input, options),
|
(input, options) =>
|
||||||
|
ctx.sdk.currentApi.session.list(input, options).then((data) => ({
|
||||||
|
data: data as unknown as V2SessionListResponse,
|
||||||
|
})),
|
||||||
eventSequence,
|
eventSequence,
|
||||||
signal,
|
signal,
|
||||||
)
|
)
|
||||||
@@ -179,6 +182,7 @@ export function createHomeSessionsController(home: HomeController) {
|
|||||||
showProjectName: () => !home.project.selected(),
|
showProjectName: () => !home.project.selected(),
|
||||||
server: () => home.selection.value().server,
|
server: () => home.selection.value().server,
|
||||||
canCreate: () => !!home.project.newSession(),
|
canCreate: () => !!home.project.newSession(),
|
||||||
|
canArchive: () => home.server.focusedContext()?.sdk.protocolKind() === "v1",
|
||||||
create: home.project.openNewSession,
|
create: home.project.openNewSession,
|
||||||
open: (session: Session, options?: OpenSessionOptions) => {
|
open: (session: Session, options?: OpenSessionOptions) => {
|
||||||
const directoryKey = pathKey(session.directory)
|
const directoryKey = pathKey(session.directory)
|
||||||
@@ -211,16 +215,10 @@ export function createHomeSessionsController(home: HomeController) {
|
|||||||
const ctx = home.server.focusedContext()
|
const ctx = home.server.focusedContext()
|
||||||
if (!conn || !ctx) return
|
if (!conn || !ctx) return
|
||||||
const [, setStore] = ctx.sync.child(session.directory)
|
const [, setStore] = ctx.sync.child(session.directory)
|
||||||
if ((await ctx.sdk.protocol) !== "v1") return
|
|
||||||
await archiveHomeSession({
|
await archiveHomeSession({
|
||||||
server: ServerConnection.key(conn),
|
server: ServerConnection.key(conn),
|
||||||
session,
|
session,
|
||||||
archive: (sessionID) =>
|
archive: (sessionID) => ctx.sdk.legacy.session.archive(sessionID, session.directory),
|
||||||
ctx.sdk.client.session.update({
|
|
||||||
sessionID,
|
|
||||||
directory: session.directory,
|
|
||||||
time: { archived: Date.now() },
|
|
||||||
}),
|
|
||||||
remove: () =>
|
remove: () =>
|
||||||
setStore(
|
setStore(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export type HomeSessionsViewProps = {
|
|||||||
showProjectName: Accessor<boolean>
|
showProjectName: Accessor<boolean>
|
||||||
server: Accessor<ServerConnection.Key>
|
server: Accessor<ServerConnection.Key>
|
||||||
canCreateSession: Accessor<boolean>
|
canCreateSession: Accessor<boolean>
|
||||||
|
canArchiveSession: Accessor<boolean>
|
||||||
searchValue: Accessor<string>
|
searchValue: Accessor<string>
|
||||||
searchPlaceholder: Accessor<string>
|
searchPlaceholder: Accessor<string>
|
||||||
searchOpen: Accessor<boolean>
|
searchOpen: Accessor<boolean>
|
||||||
@@ -460,7 +461,8 @@ function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionReco
|
|||||||
group-hover/session:opacity-100 focus-within:opacity-100
|
group-hover/session:opacity-100 focus-within:opacity-100
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={props.language.t("common.archive")}>
|
<Show when={props.canArchiveSession()}>
|
||||||
|
<TooltipV2 class="flex shrink-0 items-center" placement="bottom" value={props.language.t("common.archive")}>
|
||||||
<IconButtonV2
|
<IconButtonV2
|
||||||
data-action="home-session-archive"
|
data-action="home-session-archive"
|
||||||
variant="ghost-muted"
|
variant="ghost-muted"
|
||||||
@@ -473,7 +475,8 @@ function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionReco
|
|||||||
void props.onArchiveSession(props.record.session)
|
void props.onArchiveSession(props.record.session)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</TooltipV2>
|
</TooltipV2>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export function HomeSessions(props: {
|
|||||||
showProjectName={props.sessions.session.showProjectName}
|
showProjectName={props.sessions.session.showProjectName}
|
||||||
server={props.sessions.session.server}
|
server={props.sessions.session.server}
|
||||||
canCreateSession={props.sessions.session.canCreate}
|
canCreateSession={props.sessions.session.canCreate}
|
||||||
|
canArchiveSession={props.sessions.session.canArchive}
|
||||||
searchValue={props.search.query.value}
|
searchValue={props.search.query.value}
|
||||||
searchPlaceholder={props.search.query.placeholder}
|
searchPlaceholder={props.search.query.placeholder}
|
||||||
searchOpen={props.search.query.open}
|
searchOpen={props.search.query.open}
|
||||||
|
|||||||
@@ -872,17 +872,12 @@ export default function LegacyLayout(props: ParentProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function archiveSession(session: Session) {
|
async function archiveSession(session: Session) {
|
||||||
if ((await serverSDK().protocol) !== "v1") return
|
|
||||||
const [store, setStore] = serverSync().child(session.directory)
|
const [store, setStore] = serverSync().child(session.directory)
|
||||||
const sessions = store.session ?? []
|
const sessions = store.session ?? []
|
||||||
const index = sessions.findIndex((s) => s.id === session.id)
|
const index = sessions.findIndex((s) => s.id === session.id)
|
||||||
const nextSession = sessions[index + 1] ?? sessions[index - 1]
|
const nextSession = sessions[index + 1] ?? sessions[index - 1]
|
||||||
|
|
||||||
await serverSDK().client.session.update({
|
await serverSDK().legacy.session.archive(session.id, session.directory)
|
||||||
sessionID: session.id,
|
|
||||||
directory: session.directory,
|
|
||||||
time: { archived: Date.now() },
|
|
||||||
})
|
|
||||||
setStore(
|
setStore(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
const match = Binary.search(draft.session, session.id, (s) => s.id)
|
const match = Binary.search(draft.session, session.id, (s) => s.id)
|
||||||
@@ -980,6 +975,7 @@ export default function LegacyLayout(props: ParentProps) {
|
|||||||
title: language.t("command.session.archive"),
|
title: language.t("command.session.archive"),
|
||||||
category: language.t("command.category.session"),
|
category: language.t("command.category.session"),
|
||||||
keybind: "mod+shift+backspace",
|
keybind: "mod+shift+backspace",
|
||||||
|
hidden: serverSDK().protocolKind() !== "v1",
|
||||||
disabled: !params.dir || !params.id,
|
disabled: !params.dir || !params.id,
|
||||||
onSelect: () => {
|
onSelect: () => {
|
||||||
const session = currentSessions().find((s) => s.id === params.id)
|
const session = currentSessions().find((s) => s.id === params.id)
|
||||||
@@ -1304,13 +1300,10 @@ export default function LegacyLayout(props: ParentProps) {
|
|||||||
const name = next === getFilename(project.worktree) ? "" : next
|
const name = next === getFilename(project.worktree) ? "" : next
|
||||||
|
|
||||||
if (project.id && project.id !== "global") {
|
if (project.id && project.id !== "global") {
|
||||||
const sdk = serverSDK()
|
const result = await serverSDK().legacy.project
|
||||||
if ((await sdk.protocol) !== "v1") return
|
|
||||||
const result = await sdk.client.project
|
|
||||||
.update({ projectID: project.id, directory: project.worktree, name })
|
.update({ projectID: project.id, directory: project.worktree, name })
|
||||||
.then((response) => response.data)
|
.then((response) => response.data)
|
||||||
if (!result) return
|
if (!result) return
|
||||||
// const result = await serverSDK().api.project.update({ projectID: project.id, name })
|
|
||||||
serverSync().set("project", (items) =>
|
serverSync().set("project", (items) =>
|
||||||
items.map((item) => (item.id === result.id ? normalizeProjectInfo(result) : item)),
|
items.map((item) => (item.id === result.id ? normalizeProjectInfo(result) : item)),
|
||||||
)
|
)
|
||||||
@@ -1477,12 +1470,8 @@ export default function LegacyLayout(props: ParentProps) {
|
|||||||
platform,
|
platform,
|
||||||
serverSDK().scope,
|
serverSDK().scope,
|
||||||
)
|
)
|
||||||
await serverSDK()
|
|
||||||
.client.instance.dispose({ directory })
|
|
||||||
.catch(() => undefined)
|
|
||||||
|
|
||||||
const result = await serverSDK()
|
const result = await serverSDK()
|
||||||
.client.worktree.reset({ directory: root, worktreeResetInput: { directory } })
|
.legacy.workspace.reset(root, directory)
|
||||||
.then((x) => x.data)
|
.then((x) => x.data)
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
showToast({
|
showToast({
|
||||||
@@ -1504,11 +1493,7 @@ export default function LegacyLayout(props: ParentProps) {
|
|||||||
.filter((session) => session.time.archived === undefined)
|
.filter((session) => session.time.archived === undefined)
|
||||||
.map((session) =>
|
.map((session) =>
|
||||||
serverSDK()
|
serverSDK()
|
||||||
.client.session.update({
|
.legacy.session.archive(session.id, session.directory)
|
||||||
sessionID: session.id,
|
|
||||||
directory: session.directory,
|
|
||||||
time: { archived: Date.now() },
|
|
||||||
})
|
|
||||||
.catch(() => undefined),
|
.catch(() => undefined),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -1895,6 +1880,8 @@ export default function LegacyLayout(props: ParentProps) {
|
|||||||
clearHoverProjectSoon,
|
clearHoverProjectSoon,
|
||||||
prefetchSession,
|
prefetchSession,
|
||||||
archiveSession,
|
archiveSession,
|
||||||
|
canArchive: () => serverSDK().protocolKind() === "v1",
|
||||||
|
canResetWorkspace: () => serverSDK().protocolKind() === "v1",
|
||||||
workspaceName,
|
workspaceName,
|
||||||
renameWorkspace,
|
renameWorkspace,
|
||||||
editorOpen,
|
editorOpen,
|
||||||
@@ -1931,6 +1918,7 @@ export default function LegacyLayout(props: ParentProps) {
|
|||||||
openSidebar: () => layout.sidebar.open(),
|
openSidebar: () => layout.sidebar.open(),
|
||||||
closeProject,
|
closeProject,
|
||||||
showEditProjectDialog: (proj) => showEditProjectDialog(server.current!, proj),
|
showEditProjectDialog: (proj) => showEditProjectDialog(server.current!, proj),
|
||||||
|
canEditProject: () => serverSDK().protocolKind() === "v1",
|
||||||
toggleProjectWorkspaces,
|
toggleProjectWorkspaces,
|
||||||
workspacesEnabled: (project) => project.vcs === "git" && layout.sidebar.workspaces(project.worktree)(),
|
workspacesEnabled: (project) => project.vcs === "git" && layout.sidebar.workspaces(project.worktree)(),
|
||||||
workspaceIds,
|
workspaceIds,
|
||||||
@@ -1941,6 +1929,7 @@ export default function LegacyLayout(props: ParentProps) {
|
|||||||
clearHoverProjectSoon,
|
clearHoverProjectSoon,
|
||||||
prefetchSession,
|
prefetchSession,
|
||||||
archiveSession,
|
archiveSession,
|
||||||
|
canArchive: () => serverSDK().protocolKind() === "v1",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2031,16 +2020,19 @@ export default function LegacyLayout(props: ParentProps) {
|
|||||||
<div class="shrink-0 pl-1 py-1">
|
<div class="shrink-0 pl-1 py-1">
|
||||||
<div class="group/project flex items-start justify-between gap-2 py-2 pl-2 pr-0">
|
<div class="group/project flex items-start justify-between gap-2 py-2 pl-2 pr-0">
|
||||||
<div class="flex flex-col min-w-0">
|
<div class="flex flex-col min-w-0">
|
||||||
<InlineEditor
|
<Show
|
||||||
id={`project:${projectId()}`}
|
when={serverSDK().protocolKind() === "v1" || !project.id || project.id === "global"}
|
||||||
value={projectName}
|
fallback={<span class="text-14-medium text-text-strong truncate">{projectName()}</span>}
|
||||||
onSave={(next) => {
|
>
|
||||||
void renameProject(project, next)
|
<InlineEditor
|
||||||
}}
|
id={`project:${projectId()}`}
|
||||||
class="text-14-medium text-text-strong truncate"
|
value={projectName}
|
||||||
displayClass="text-14-medium text-text-strong truncate"
|
onSave={(next) => void renameProject(project, next)}
|
||||||
stopPropagation
|
class="text-14-medium text-text-strong truncate"
|
||||||
/>
|
displayClass="text-14-medium text-text-strong truncate"
|
||||||
|
stopPropagation
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<Tooltip
|
<Tooltip
|
||||||
placement="bottom"
|
placement="bottom"
|
||||||
@@ -2075,13 +2067,11 @@ export default function LegacyLayout(props: ParentProps) {
|
|||||||
/>
|
/>
|
||||||
<DropdownMenu.Portal>
|
<DropdownMenu.Portal>
|
||||||
<DropdownMenu.Content class="mt-1">
|
<DropdownMenu.Content class="mt-1">
|
||||||
<DropdownMenu.Item
|
<Show when={serverSDK().protocolKind() === "v1"}>
|
||||||
onSelect={() => {
|
<DropdownMenu.Item onSelect={() => showEditProjectDialog(server.current!, project)}>
|
||||||
showEditProjectDialog(server.current!, project)
|
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
|
||||||
}}
|
</DropdownMenu.Item>
|
||||||
>
|
</Show>
|
||||||
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
data-action="project-workspaces-toggle"
|
data-action="project-workspaces-toggle"
|
||||||
data-project={slug()}
|
data-project={slug()}
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ export type SessionItemProps = {
|
|||||||
clearHoverProjectSoon: () => void
|
clearHoverProjectSoon: () => void
|
||||||
prefetchSession: (session: Session, priority?: "high" | "low") => void
|
prefetchSession: (session: Session, priority?: "high" | "low") => void
|
||||||
archiveSession: (session: Session) => Promise<void>
|
archiveSession: (session: Session) => Promise<void>
|
||||||
|
canArchive: Accessor<boolean>
|
||||||
}
|
}
|
||||||
|
|
||||||
const SessionRow = (props: {
|
const SessionRow = (props: {
|
||||||
@@ -241,7 +242,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={!props.level}>
|
<Show when={!props.level && props.canArchive()}>
|
||||||
<div
|
<div
|
||||||
class="shrink-0 overflow-hidden transition-[width,opacity]"
|
class="shrink-0 overflow-hidden transition-[width,opacity]"
|
||||||
classList={{
|
classList={{
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export type ProjectSidebarContext = {
|
|||||||
openSidebar: () => void
|
openSidebar: () => void
|
||||||
closeProject: (directory: string) => void
|
closeProject: (directory: string) => void
|
||||||
showEditProjectDialog: (project: LocalProject) => void
|
showEditProjectDialog: (project: LocalProject) => void
|
||||||
|
canEditProject: Accessor<boolean>
|
||||||
toggleProjectWorkspaces: (project: LocalProject) => void
|
toggleProjectWorkspaces: (project: LocalProject) => void
|
||||||
workspacesEnabled: (project: LocalProject) => boolean
|
workspacesEnabled: (project: LocalProject) => boolean
|
||||||
workspaceIds: (project: LocalProject) => string[]
|
workspaceIds: (project: LocalProject) => string[]
|
||||||
@@ -65,6 +66,7 @@ const ProjectTile = (props: {
|
|||||||
onProjectFocus: (worktree: string) => void
|
onProjectFocus: (worktree: string) => void
|
||||||
navigateToProject: (directory: string) => void
|
navigateToProject: (directory: string) => void
|
||||||
showEditProjectDialog: (project: LocalProject) => void
|
showEditProjectDialog: (project: LocalProject) => void
|
||||||
|
canEditProject: Accessor<boolean>
|
||||||
toggleProjectWorkspaces: (project: LocalProject) => void
|
toggleProjectWorkspaces: (project: LocalProject) => void
|
||||||
workspacesEnabled: (project: LocalProject) => boolean
|
workspacesEnabled: (project: LocalProject) => boolean
|
||||||
closeProject: (directory: string) => void
|
closeProject: (directory: string) => void
|
||||||
@@ -148,9 +150,11 @@ const ProjectTile = (props: {
|
|||||||
</ContextMenu.Trigger>
|
</ContextMenu.Trigger>
|
||||||
<ContextMenu.Portal>
|
<ContextMenu.Portal>
|
||||||
<ContextMenu.Content>
|
<ContextMenu.Content>
|
||||||
<ContextMenu.Item onSelect={() => props.showEditProjectDialog(props.project)}>
|
<Show when={props.canEditProject()}>
|
||||||
<ContextMenu.ItemLabel>{props.language.t("common.edit")}</ContextMenu.ItemLabel>
|
<ContextMenu.Item onSelect={() => props.showEditProjectDialog(props.project)}>
|
||||||
</ContextMenu.Item>
|
<ContextMenu.ItemLabel>{props.language.t("common.edit")}</ContextMenu.ItemLabel>
|
||||||
|
</ContextMenu.Item>
|
||||||
|
</Show>
|
||||||
<ContextMenu.Item
|
<ContextMenu.Item
|
||||||
data-action="project-workspaces-toggle"
|
data-action="project-workspaces-toggle"
|
||||||
data-project={base64Encode(props.project.worktree)}
|
data-project={base64Encode(props.project.worktree)}
|
||||||
@@ -331,6 +335,7 @@ export const SortableProject = (props: {
|
|||||||
onProjectFocus={props.ctx.onProjectFocus}
|
onProjectFocus={props.ctx.onProjectFocus}
|
||||||
navigateToProject={props.ctx.navigateToProject}
|
navigateToProject={props.ctx.navigateToProject}
|
||||||
showEditProjectDialog={props.ctx.showEditProjectDialog}
|
showEditProjectDialog={props.ctx.showEditProjectDialog}
|
||||||
|
canEditProject={props.ctx.canEditProject}
|
||||||
toggleProjectWorkspaces={props.ctx.toggleProjectWorkspaces}
|
toggleProjectWorkspaces={props.ctx.toggleProjectWorkspaces}
|
||||||
workspacesEnabled={props.ctx.workspacesEnabled}
|
workspacesEnabled={props.ctx.workspacesEnabled}
|
||||||
closeProject={props.ctx.closeProject}
|
closeProject={props.ctx.closeProject}
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ export type WorkspaceSidebarContext = {
|
|||||||
clearHoverProjectSoon: () => void
|
clearHoverProjectSoon: () => void
|
||||||
prefetchSession: (session: Session, priority?: "high" | "low") => void
|
prefetchSession: (session: Session, priority?: "high" | "low") => void
|
||||||
archiveSession: (session: Session) => Promise<void>
|
archiveSession: (session: Session) => Promise<void>
|
||||||
|
canArchive: Accessor<boolean>
|
||||||
|
canResetWorkspace: Accessor<boolean>
|
||||||
workspaceName: (directory: string, projectId?: string, branch?: string) => string | undefined
|
workspaceName: (directory: string, projectId?: string, branch?: string) => string | undefined
|
||||||
renameWorkspace: (directory: string, next: string, projectId?: string, branch?: string) => void
|
renameWorkspace: (directory: string, next: string, projectId?: string, branch?: string) => void
|
||||||
editorOpen: (id: string) => boolean
|
editorOpen: (id: string) => boolean
|
||||||
@@ -151,6 +153,7 @@ const WorkspaceActions = (props: {
|
|||||||
workspaceValue: Accessor<string>
|
workspaceValue: Accessor<string>
|
||||||
openEditor: WorkspaceSidebarContext["openEditor"]
|
openEditor: WorkspaceSidebarContext["openEditor"]
|
||||||
showResetWorkspaceDialog: WorkspaceSidebarContext["showResetWorkspaceDialog"]
|
showResetWorkspaceDialog: WorkspaceSidebarContext["showResetWorkspaceDialog"]
|
||||||
|
canResetWorkspace: WorkspaceSidebarContext["canResetWorkspace"]
|
||||||
showDeleteWorkspaceDialog: WorkspaceSidebarContext["showDeleteWorkspaceDialog"]
|
showDeleteWorkspaceDialog: WorkspaceSidebarContext["showDeleteWorkspaceDialog"]
|
||||||
root: string
|
root: string
|
||||||
clearHoverProjectSoon: WorkspaceSidebarContext["clearHoverProjectSoon"]
|
clearHoverProjectSoon: WorkspaceSidebarContext["clearHoverProjectSoon"]
|
||||||
@@ -199,12 +202,14 @@ const WorkspaceActions = (props: {
|
|||||||
>
|
>
|
||||||
<DropdownMenu.ItemLabel>{props.language.t("common.rename")}</DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>{props.language.t("common.rename")}</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
<DropdownMenu.Item
|
<Show when={props.canResetWorkspace()}>
|
||||||
disabled={props.local() || props.busy()}
|
<DropdownMenu.Item
|
||||||
onSelect={() => props.showResetWorkspaceDialog(props.root, props.directory)}
|
disabled={props.local() || props.busy()}
|
||||||
>
|
onSelect={() => props.showResetWorkspaceDialog(props.root, props.directory)}
|
||||||
<DropdownMenu.ItemLabel>{props.language.t("common.reset")}</DropdownMenu.ItemLabel>
|
>
|
||||||
</DropdownMenu.Item>
|
<DropdownMenu.ItemLabel>{props.language.t("common.reset")}</DropdownMenu.ItemLabel>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</Show>
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
disabled={props.local() || props.busy()}
|
disabled={props.local() || props.busy()}
|
||||||
onSelect={() => props.showDeleteWorkspaceDialog(props.root, props.directory)}
|
onSelect={() => props.showDeleteWorkspaceDialog(props.root, props.directory)}
|
||||||
@@ -272,6 +277,7 @@ const WorkspaceSessionList = (props: {
|
|||||||
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
|
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
|
||||||
prefetchSession={props.ctx.prefetchSession}
|
prefetchSession={props.ctx.prefetchSession}
|
||||||
archiveSession={props.ctx.archiveSession}
|
archiveSession={props.ctx.archiveSession}
|
||||||
|
canArchive={props.ctx.canArchive}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
@@ -416,6 +422,7 @@ export const SortableWorkspace = (props: {
|
|||||||
workspaceValue={workspaceValue}
|
workspaceValue={workspaceValue}
|
||||||
openEditor={props.ctx.openEditor}
|
openEditor={props.ctx.openEditor}
|
||||||
showResetWorkspaceDialog={props.ctx.showResetWorkspaceDialog}
|
showResetWorkspaceDialog={props.ctx.showResetWorkspaceDialog}
|
||||||
|
canResetWorkspace={props.ctx.canResetWorkspace}
|
||||||
showDeleteWorkspaceDialog={props.ctx.showDeleteWorkspaceDialog}
|
showDeleteWorkspaceDialog={props.ctx.showDeleteWorkspaceDialog}
|
||||||
root={props.project.worktree}
|
root={props.project.worktree}
|
||||||
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
|
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ import { useNotification } from "@/context/notification"
|
|||||||
import { PromptProvider, usePrompt } from "@/context/prompt"
|
import { PromptProvider, usePrompt } from "@/context/prompt"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { SDKProvider, useSDK } from "@/context/sdk"
|
import { SDKProvider, useSDK } from "@/context/sdk"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
|
||||||
import { ServerConnection, serverName, useServer } from "@/context/server"
|
import { ServerConnection, serverName, useServer } from "@/context/server"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
@@ -361,6 +361,7 @@ export default function Page() {
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
|
const protocol = useServerProtocol()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const prompt = usePrompt()
|
const prompt = usePrompt()
|
||||||
@@ -847,7 +848,7 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const gitMutation = useMutation(() => ({
|
const gitMutation = useMutation(() => ({
|
||||||
mutationFn: () => sdk().client.project.initGit(),
|
mutationFn: () => sdk().legacy.project.initGit(sdk().directory),
|
||||||
onSuccess: (x) => {
|
onSuccess: (x) => {
|
||||||
if (!x.data) return
|
if (!x.data) return
|
||||||
upsert(x.data)
|
upsert(x.data)
|
||||||
@@ -896,17 +897,19 @@ export default function Page() {
|
|||||||
() => {
|
() => {
|
||||||
const id = params.id
|
const id = params.id
|
||||||
return [
|
return [
|
||||||
|
protocol(),
|
||||||
sdk().directory,
|
sdk().directory,
|
||||||
id,
|
id,
|
||||||
id ? (sync().data.session_status[id]?.type ?? "idle") : "idle",
|
id ? (sync().data.session_status[id]?.type ?? "idle") : "idle",
|
||||||
id ? composer.blocked() : false,
|
id ? composer.blocked() : false,
|
||||||
] as const
|
] as const
|
||||||
},
|
},
|
||||||
([dir, id, status, blocked]) => {
|
([serverProtocol, dir, id, status, blocked]) => {
|
||||||
if (todoFrame !== undefined) cancelAnimationFrame(todoFrame)
|
if (todoFrame !== undefined) cancelAnimationFrame(todoFrame)
|
||||||
if (todoTimer !== undefined) window.clearTimeout(todoTimer)
|
if (todoTimer !== undefined) window.clearTimeout(todoTimer)
|
||||||
todoFrame = undefined
|
todoFrame = undefined
|
||||||
todoTimer = undefined
|
todoTimer = undefined
|
||||||
|
if (serverProtocol !== "v1") return
|
||||||
if (!id) return
|
if (!id) return
|
||||||
if (status === "idle" && !blocked) return
|
if (status === "idle" && !blocked) return
|
||||||
const cached = untrack(() => sync().data.todo[id] !== undefined)
|
const cached = untrack(() => sync().data.todo[id] !== undefined)
|
||||||
@@ -1217,11 +1220,13 @@ export default function Page() {
|
|||||||
{language.t("session.review.noVcs.createGit.description")}
|
{language.t("session.review.noVcs.createGit.description")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button size="large" disabled={gitMutation.isPending} onClick={initGit}>
|
<Show when={protocol() === "v1"}>
|
||||||
{gitMutation.isPending
|
<Button size="large" disabled={gitMutation.isPending} onClick={initGit}>
|
||||||
? language.t("session.review.noVcs.createGit.actionLoading")
|
{gitMutation.isPending
|
||||||
: language.t("session.review.noVcs.createGit.action")}
|
? language.t("session.review.noVcs.createGit.actionLoading")
|
||||||
</Button>
|
: language.t("session.review.noVcs.createGit.action")}
|
||||||
|
</Button>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1254,7 +1259,8 @@ export default function Page() {
|
|||||||
return <div class="px-6 py-4 text-text-weak">{language.t("session.review.loadingChanges")}</div>
|
return <div class="px-6 py-4 text-text-weak">{language.t("session.review.loadingChanges")}</div>
|
||||||
}
|
}
|
||||||
if (reviewMode() === "turn" && nogit()) {
|
if (reviewMode() === "turn" && nogit()) {
|
||||||
return <SessionReviewEmptyNoGitV2 pending={gitMutation.isPending} onInitGit={initGit} />
|
if (protocol() === "v1") return <SessionReviewEmptyNoGitV2 pending={gitMutation.isPending} onInitGit={initGit} />
|
||||||
|
return empty(language.t("session.review.noVcs.createGit.description"))
|
||||||
}
|
}
|
||||||
return <SessionReviewEmptyChangesV2 />
|
return <SessionReviewEmptyChangesV2 />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ import { SessionContextUsage } from "@/components/session-context-usage"
|
|||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useSessionKey } from "@/pages/session/session-layout"
|
import { useSessionKey } from "@/pages/session/session-layout"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { useTabs } from "@/context/tabs"
|
import { useTabs } from "@/context/tabs"
|
||||||
@@ -302,7 +302,8 @@ export function MessageTimeline(props: {
|
|||||||
return displayLabel(session)
|
return displayLabel(session)
|
||||||
})
|
})
|
||||||
const shareUrl = createMemo(() => info()?.share?.url)
|
const shareUrl = createMemo(() => info()?.share?.url)
|
||||||
const shareEnabled = createMemo(() => sync().data.config.share !== "disabled")
|
const protocol = useServerProtocol()
|
||||||
|
const shareEnabled = createMemo(() => protocol() === "v1" && sync().data.config.share !== "disabled")
|
||||||
const parentID = createMemo(() => info()?.parentID)
|
const parentID = createMemo(() => info()?.parentID)
|
||||||
const parent = createMemo(() => {
|
const parent = createMemo(() => {
|
||||||
const id = parentID()
|
const id = parentID()
|
||||||
@@ -665,14 +666,14 @@ export function MessageTimeline(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const shareMutation = useMutation(() => ({
|
const shareMutation = useMutation(() => ({
|
||||||
mutationFn: (id: string) => serverSDK().client.session.share({ sessionID: id }),
|
mutationFn: (id: string) => serverSDK().legacy.session.share(id),
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
console.error("Failed to share session", err)
|
console.error("Failed to share session", err)
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const unshareMutation = useMutation(() => ({
|
const unshareMutation = useMutation(() => ({
|
||||||
mutationFn: (id: string) => serverSDK().client.session.unshare({ sessionID: id }),
|
mutationFn: (id: string) => serverSDK().legacy.session.unshare(id),
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
console.error("Failed to unshare session", err)
|
console.error("Failed to unshare session", err)
|
||||||
},
|
},
|
||||||
@@ -818,14 +819,12 @@ export function MessageTimeline(props: {
|
|||||||
const archiveSession = async (sessionID: string) => {
|
const archiveSession = async (sessionID: string) => {
|
||||||
const session = sync().session.get(sessionID)
|
const session = sync().session.get(sessionID)
|
||||||
if (!session) return
|
if (!session) return
|
||||||
if ((await sdk().protocol) !== "v1") return
|
|
||||||
|
|
||||||
const sessions = sync().data.session ?? []
|
const sessions = sync().data.session ?? []
|
||||||
const index = sessions.findIndex((s) => s.id === sessionID)
|
const index = sessions.findIndex((s) => s.id === sessionID)
|
||||||
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
|
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
|
||||||
|
|
||||||
await sdk()
|
await sdk()
|
||||||
.client.session.update({ sessionID, directory: sdk().directory, time: { archived: Date.now() } })
|
.legacy.session.archive(sessionID, sdk().directory)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
sync().set(
|
sync().set(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
@@ -1574,9 +1573,11 @@ export function MessageTimeline(props: {
|
|||||||
</DropdownMenu.ItemLabel>
|
</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</Show>
|
</Show>
|
||||||
<DropdownMenu.Item onSelect={() => void archiveSession(id)}>
|
<Show when={protocol() === "v1"}>
|
||||||
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
|
<DropdownMenu.Item onSelect={() => void archiveSession(id)}>
|
||||||
</DropdownMenu.Item>
|
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</Show>
|
||||||
<DropdownMenu.Separator />
|
<DropdownMenu.Separator />
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}
|
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}
|
||||||
@@ -1645,9 +1646,11 @@ export function MessageTimeline(props: {
|
|||||||
{language.t("session.share.action.share")}...
|
{language.t("session.share.action.share")}...
|
||||||
</MenuV2.Item>
|
</MenuV2.Item>
|
||||||
</Show>
|
</Show>
|
||||||
<MenuV2.Item onSelect={() => void archiveSession(id)}>
|
<Show when={protocol() === "v1"}>
|
||||||
{language.t("common.archive")}
|
<MenuV2.Item onSelect={() => void archiveSession(id)}>
|
||||||
</MenuV2.Item>
|
{language.t("common.archive")}
|
||||||
|
</MenuV2.Item>
|
||||||
|
</Show>
|
||||||
<MenuV2.Separator />
|
<MenuV2.Separator />
|
||||||
<MenuV2.Item onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}>
|
<MenuV2.Item onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}>
|
||||||
{language.t("common.delete")}...
|
{language.t("common.delete")}...
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import type { UserMessage } from "@/types"
|
|||||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
import { createSessionOwnership } from "./session-ownership"
|
import { createSessionOwnership } from "./session-ownership"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
|
import { useServerProtocol } from "@/context/server-sdk"
|
||||||
|
|
||||||
export type SessionCommandContext = {
|
export type SessionCommandContext = {
|
||||||
navigateMessageByOffset: (offset: number) => void
|
navigateMessageByOffset: (offset: number) => void
|
||||||
@@ -43,6 +44,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||||||
const permission = usePermission()
|
const permission = usePermission()
|
||||||
const prompt = usePrompt()
|
const prompt = usePrompt()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
|
const protocol = useServerProtocol()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const terminal = useTerminal()
|
const terminal = useTerminal()
|
||||||
@@ -194,7 +196,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const url = await sdk()
|
const url = await sdk()
|
||||||
.client.session.share({ sessionID })
|
.legacy.session.share(sessionID)
|
||||||
.then((res) => res.data?.share?.url)
|
.then((res) => res.data?.share?.url)
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
if (!url) {
|
if (!url) {
|
||||||
@@ -214,7 +216,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
|
||||||
await sdk()
|
await sdk()
|
||||||
.client.session.unshare({ sessionID })
|
.legacy.session.unshare(sessionID)
|
||||||
.then(() =>
|
.then(() =>
|
||||||
showToast({
|
showToast({
|
||||||
title: language.t("toast.session.unshare.success.title"),
|
title: language.t("toast.session.unshare.success.title"),
|
||||||
@@ -377,6 +379,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const shareCmds = () => {
|
const shareCmds = () => {
|
||||||
|
if (protocol() !== "v1") return []
|
||||||
if (sync().data.config.share === "disabled") return []
|
if (sync().data.config.share === "disabled") return []
|
||||||
return [
|
return [
|
||||||
sessionCommand({
|
sessionCommand({
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import { createCompatibleApi } from "./server-compat"
|
|||||||
|
|
||||||
function setup(
|
function setup(
|
||||||
protocol: "v1" | "v2" | Promise<"v1" | "v2">,
|
protocol: "v1" | "v2" | Promise<"v1" | "v2">,
|
||||||
responses?: { vcs?: { branch: string; default_branch: string } },
|
responses?: {
|
||||||
|
vcs?: { branch: string; default_branch: string }
|
||||||
|
question?: { id: string; sessionID: string; questions: never[]; tool?: { messageID: string; callID: string } }[]
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
const requests: Request[] = []
|
const requests: Request[] = []
|
||||||
const fetcher = Object.assign(
|
const fetcher = Object.assign(
|
||||||
@@ -36,6 +39,8 @@ function setup(
|
|||||||
}
|
}
|
||||||
if (request.method === "GET" && new URL(request.url).pathname === "/vcs")
|
if (request.method === "GET" && new URL(request.url).pathname === "/vcs")
|
||||||
return Response.json(responses?.vcs ?? {})
|
return Response.json(responses?.vcs ?? {})
|
||||||
|
if (request.method === "GET" && new URL(request.url).pathname === "/question")
|
||||||
|
return Response.json(responses?.question ?? [])
|
||||||
if (request.method === "GET") return Response.json([])
|
if (request.method === "GET") return Response.json([])
|
||||||
return new Response(undefined, { status: 204 })
|
return new Response(undefined, { status: 204 })
|
||||||
},
|
},
|
||||||
@@ -163,6 +168,21 @@ describe("createCompatibleApi", () => {
|
|||||||
expect(new URL(requests[0]!.url).pathname).toBe("/experimental/session")
|
expect(new URL(requests[0]!.url).pathname).toBe("/experimental/session")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("translates V1 question tool call IDs", async () => {
|
||||||
|
const { api } = setup("v1", {
|
||||||
|
question: [
|
||||||
|
{
|
||||||
|
id: "que_1",
|
||||||
|
sessionID: "ses_1",
|
||||||
|
questions: [],
|
||||||
|
tool: { messageID: "msg_1", callID: "call_1" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect((await api.question.request.list()).data[0]?.tool).toEqual({ messageID: "msg_1", id: "call_1" })
|
||||||
|
})
|
||||||
|
|
||||||
/*
|
/*
|
||||||
test("projects the V1 default branch", async () => {
|
test("projects the V1 default branch", async () => {
|
||||||
const { api } = setup("v1", { vcs: { branch: "feature", default_branch: "dev" } })
|
const { api } = setup("v1", { vcs: { branch: "feature", default_branch: "dev" } })
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import type { ServerProtocol } from "./server-protocol"
|
|||||||
import type { AgentPartInput, FilePartInput, Session, TextPartInput } from "@/types"
|
import type { AgentPartInput, FilePartInput, Session, TextPartInput } from "@/types"
|
||||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||||
import type {
|
import type {
|
||||||
Project,
|
|
||||||
ProjectCurrent,
|
ProjectCurrent,
|
||||||
SessionApi,
|
SessionApi,
|
||||||
SessionCommandInput,
|
SessionCommandInput,
|
||||||
@@ -28,7 +27,6 @@ type CompatibleSessionApi = Omit<
|
|||||||
shell: (input: SessionShellInput & LegacyPrompt) => Promise<SessionShellOutput>
|
shell: (input: SessionShellInput & LegacyPrompt) => Promise<SessionShellOutput>
|
||||||
compact: (input: SessionCompactInput & { model?: LegacyPrompt["model"] }) => Promise<SessionCompactOutput>
|
compact: (input: SessionCompactInput & { model?: LegacyPrompt["model"] }) => Promise<SessionCompactOutput>
|
||||||
rename: (input: Parameters<SessionApi["rename"]>[0] & LegacyLocation) => ReturnType<SessionApi["rename"]>
|
rename: (input: Parameters<SessionApi["rename"]>[0] & LegacyLocation) => ReturnType<SessionApi["rename"]>
|
||||||
// archive: (input: Parameters<SessionApi["archive"]>[0] & LegacyLocation) => ReturnType<SessionApi["archive"]>
|
|
||||||
remove: (input: Parameters<SessionApi["remove"]>[0] & LegacyLocation) => ReturnType<SessionApi["remove"]>
|
remove: (input: Parameters<SessionApi["remove"]>[0] & LegacyLocation) => ReturnType<SessionApi["remove"]>
|
||||||
}
|
}
|
||||||
type CompatiblePermissionApi = Omit<ServerApi["permission"], "reply"> & {
|
type CompatiblePermissionApi = Omit<ServerApi["permission"], "reply"> & {
|
||||||
@@ -54,6 +52,99 @@ type CompatibleInput = {
|
|||||||
directory?: string
|
directory?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createLegacyCapabilities(input: CompatibleInput) {
|
||||||
|
const directory = (value?: string) => value ?? input.directory
|
||||||
|
const client = (value?: string) => input.legacy(directory(value))
|
||||||
|
const requireV1 = async () => {
|
||||||
|
if ((await input.protocol) !== "v1") throw new Error("This capability is unavailable on V2 servers")
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
config: {
|
||||||
|
global: async () => {
|
||||||
|
await requireV1()
|
||||||
|
return (await client().global.config.get()).data ?? {}
|
||||||
|
},
|
||||||
|
directory: async (value?: string) => {
|
||||||
|
await requireV1()
|
||||||
|
return (await client(value).config.get()).data ?? {}
|
||||||
|
},
|
||||||
|
update: async (config: NonNullable<Parameters<LegacyClient["global"]["config"]["update"]>[0]>["config"]) => {
|
||||||
|
await requireV1()
|
||||||
|
return client().global.config.update({ config })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
set: async (value: Parameters<LegacyClient["auth"]["set"]>[0]) => {
|
||||||
|
await requireV1()
|
||||||
|
return client().auth.set(value)
|
||||||
|
},
|
||||||
|
remove: async (value: Parameters<LegacyClient["auth"]["remove"]>[0]) => {
|
||||||
|
await requireV1()
|
||||||
|
return client().auth.remove(value)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
get: (value: Parameters<LegacyClient["session"]["get"]>[0]) => client().session.get(value),
|
||||||
|
messages: (value: Parameters<LegacyClient["session"]["messages"]>[0]) => client().session.messages(value),
|
||||||
|
message: (value: Parameters<LegacyClient["session"]["message"]>[0]) => client().session.message(value),
|
||||||
|
share: async (sessionID: string) => {
|
||||||
|
await requireV1()
|
||||||
|
return client().session.share({ sessionID })
|
||||||
|
},
|
||||||
|
unshare: async (sessionID: string) => {
|
||||||
|
await requireV1()
|
||||||
|
return client().session.unshare({ sessionID })
|
||||||
|
},
|
||||||
|
archive: async (sessionID: string, value?: string) => {
|
||||||
|
await requireV1()
|
||||||
|
return client(value).session.update({ sessionID, time: { archived: Date.now() } })
|
||||||
|
},
|
||||||
|
todo: async (sessionID: string, value?: string) => {
|
||||||
|
await requireV1()
|
||||||
|
return (await client(value).session.todo({ sessionID })).data ?? []
|
||||||
|
},
|
||||||
|
},
|
||||||
|
project: {
|
||||||
|
update: async (value: Parameters<LegacyClient["project"]["update"]>[0]) => {
|
||||||
|
await requireV1()
|
||||||
|
return client(value.directory).project.update(value)
|
||||||
|
},
|
||||||
|
initGit: async (value?: string) => {
|
||||||
|
await requireV1()
|
||||||
|
return client(value).project.initGit()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
workspace: {
|
||||||
|
reset: async (root: string, value: string) => {
|
||||||
|
await requireV1()
|
||||||
|
await client(value).instance.dispose().catch(() => undefined)
|
||||||
|
return client(root).worktree.reset({ worktreeResetInput: { directory: value } })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pty: {
|
||||||
|
shells: async () => {
|
||||||
|
await requireV1()
|
||||||
|
return (await client().pty.shells()).data ?? []
|
||||||
|
},
|
||||||
|
},
|
||||||
|
path: {
|
||||||
|
get: async (value?: string) => {
|
||||||
|
await requireV1()
|
||||||
|
return (await client(value).path.get()).data
|
||||||
|
},
|
||||||
|
},
|
||||||
|
lsp: {
|
||||||
|
status: async (value: string) => {
|
||||||
|
await requireV1()
|
||||||
|
return (await client(value).lsp.status()).data ?? []
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LegacyCapabilities = ReturnType<typeof createLegacyCapabilities>
|
||||||
|
|
||||||
function mime(uri: string) {
|
function mime(uri: string) {
|
||||||
const match = /^data:([^;,]+)/.exec(uri)
|
const match = /^data:([^;,]+)/.exec(uri)
|
||||||
return match?.[1] ?? "application/octet-stream"
|
return match?.[1] ?? "application/octet-stream"
|
||||||
@@ -184,9 +275,6 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
|
|||||||
async rename(value: Parameters<ServerApi["session"]["rename"]>[0] & LegacyLocation) {
|
async rename(value: Parameters<ServerApi["session"]["rename"]>[0] & LegacyLocation) {
|
||||||
await legacy(value).session.update({ sessionID: value.sessionID, title: value.title })
|
await legacy(value).session.update({ sessionID: value.sessionID, title: value.title })
|
||||||
},
|
},
|
||||||
// async archive(value: Parameters<ServerApi["session"]["archive"]>[0] & LegacyLocation) {
|
|
||||||
// await legacy(value).session.update({ sessionID: value.sessionID, time: { archived: Date.now() } })
|
|
||||||
// },
|
|
||||||
async remove(value: Parameters<ServerApi["session"]["remove"]>[0] & LegacyLocation) {
|
async remove(value: Parameters<ServerApi["session"]["remove"]>[0] & LegacyLocation) {
|
||||||
await legacy(value).session.delete(value)
|
await legacy(value).session.delete(value)
|
||||||
},
|
},
|
||||||
@@ -313,34 +401,28 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
|
|||||||
canonical: result.data.worktree,
|
canonical: result.data.worktree,
|
||||||
} satisfies ProjectCurrent
|
} satisfies ProjectCurrent
|
||||||
},
|
},
|
||||||
// async update(value: Parameters<ServerApi["project"]["update"]>[0]) {
|
|
||||||
// const project = (await legacy().project.list()).data?.find((item) => item.id === value.projectID)
|
|
||||||
// const result = await legacy({ directory: project?.worktree }).project.update({
|
|
||||||
// ...value,
|
|
||||||
// directory: project?.worktree,
|
|
||||||
// })
|
|
||||||
// if (!result.data) throw new Error(`Project not found: ${value.projectID}`)
|
|
||||||
// return result.data as Project
|
|
||||||
// },
|
|
||||||
async directories(value: Parameters<ServerApi["project"]["directories"]>[0]) {
|
async directories(value: Parameters<ServerApi["project"]["directories"]>[0]) {
|
||||||
const result = await legacy(value.location).worktree.list()
|
const result = await legacy(value.location).worktree.list()
|
||||||
return (result.data ?? []).map((item) => ({ directory: item }))
|
return (result.data ?? []).map((item) => ({ directory: item }))
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// path: {
|
location: {
|
||||||
// ...input.current.path,
|
...input.current.location,
|
||||||
// async get(value?: Parameters<ServerApi["path"]["get"]>[0]) {
|
async get(value?: Parameters<ServerApi["location"]["get"]>[0]) {
|
||||||
// const result = await legacy(value?.location).path.get()
|
const result = await legacy(value?.location).path.get()
|
||||||
// if (!result.data) throw new Error("Path unavailable")
|
if (!result.data) throw new Error("Location unavailable")
|
||||||
// return result.data
|
return {
|
||||||
// },
|
directory: result.data.directory,
|
||||||
// },
|
project: {
|
||||||
|
id: "",
|
||||||
|
directory: result.data.worktree,
|
||||||
|
canonical: result.data.worktree,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
vcs: {
|
vcs: {
|
||||||
...input.current.vcs,
|
...input.current.vcs,
|
||||||
// async get(value?: Parameters<ServerApi["vcs"]["get"]>[0]) {
|
|
||||||
// const result = await legacy(value?.location).vcs.get()
|
|
||||||
// return located({ branch: result.data?.branch, defaultBranch: result.data?.default_branch }, value?.location)
|
|
||||||
// },
|
|
||||||
async status(value?: Parameters<ServerApi["vcs"]["status"]>[0]) {
|
async status(value?: Parameters<ServerApi["vcs"]["status"]>[0]) {
|
||||||
const result = await legacy(value?.location).vcs.status()
|
const result = await legacy(value?.location).vcs.status()
|
||||||
return located(result.data ?? [], value?.location)
|
return located(result.data ?? [], value?.location)
|
||||||
@@ -456,9 +538,6 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
|
|||||||
},
|
},
|
||||||
pty: {
|
pty: {
|
||||||
...input.current.pty,
|
...input.current.pty,
|
||||||
// async shells(value?: Parameters<ServerApi["pty"]["shells"]>[0]) {
|
|
||||||
// return located((await legacy(value?.location).pty.shells()).data ?? [], value?.location)
|
|
||||||
// },
|
|
||||||
async list(value?: Parameters<ServerApi["pty"]["list"]>[0]) {
|
async list(value?: Parameters<ServerApi["pty"]["list"]>[0]) {
|
||||||
return located((await legacy(value?.location).pty.list()).data ?? [], value?.location)
|
return located((await legacy(value?.location).pty.list()).data ?? [], value?.location)
|
||||||
},
|
},
|
||||||
@@ -490,14 +569,29 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
|
|||||||
async remove(value: Parameters<ServerApi["pty"]["remove"]>[0]) {
|
async remove(value: Parameters<ServerApi["pty"]["remove"]>[0]) {
|
||||||
await legacy(value.location).pty.remove({ ptyID: value.ptyID })
|
await legacy(value.location).pty.remove({ ptyID: value.ptyID })
|
||||||
},
|
},
|
||||||
// async connectToken(value: Parameters<ServerApi["pty"]["connectToken"]>[0]) {
|
|
||||||
// const result = await legacy(value.location).pty.connectToken({ ptyID: value.ptyID })
|
|
||||||
// if (!result.data) throw new Error(`Failed to connect terminal: ${value.ptyID}`)
|
|
||||||
// return located(result.data, value.location)
|
|
||||||
// },
|
|
||||||
},
|
},
|
||||||
permission: {
|
permission: {
|
||||||
...input.current.permission,
|
...input.current.permission,
|
||||||
|
request: {
|
||||||
|
...input.current.permission.request,
|
||||||
|
async list(value?: Parameters<ServerApi["permission"]["request"]["list"]>[0]) {
|
||||||
|
const result = await legacy(value?.location).permission.list()
|
||||||
|
return located(
|
||||||
|
(result.data ?? []).map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
sessionID: item.sessionID,
|
||||||
|
action: item.permission,
|
||||||
|
resources: item.patterns,
|
||||||
|
metadata: item.metadata,
|
||||||
|
save: item.always,
|
||||||
|
source: item.tool
|
||||||
|
? { type: "tool" as const, messageID: item.tool.messageID, callID: item.tool.callID }
|
||||||
|
: undefined,
|
||||||
|
})),
|
||||||
|
value?.location,
|
||||||
|
) as Awaited<ReturnType<ServerApi["permission"]["request"]["list"]>>
|
||||||
|
},
|
||||||
|
},
|
||||||
async reply(value: Parameters<ServerApi["permission"]["reply"]>[0] & { location?: { directory?: string } }) {
|
async reply(value: Parameters<ServerApi["permission"]["reply"]>[0] & { location?: { directory?: string } }) {
|
||||||
await legacy(value.location).permission.respond({
|
await legacy(value.location).permission.respond({
|
||||||
sessionID: value.sessionID,
|
sessionID: value.sessionID,
|
||||||
@@ -509,6 +603,18 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
|
|||||||
},
|
},
|
||||||
question: {
|
question: {
|
||||||
...input.current.question,
|
...input.current.question,
|
||||||
|
request: {
|
||||||
|
...input.current.question.request,
|
||||||
|
async list(value?: Parameters<ServerApi["question"]["request"]["list"]>[0]) {
|
||||||
|
return located(
|
||||||
|
((await legacy(value?.location).question.list()).data ?? []).map((request) => ({
|
||||||
|
...request,
|
||||||
|
tool: request.tool && { messageID: request.tool.messageID, id: request.tool.callID },
|
||||||
|
})),
|
||||||
|
value?.location,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
async reply(value: Parameters<ServerApi["question"]["reply"]>[0]) {
|
async reply(value: Parameters<ServerApi["question"]["reply"]>[0]) {
|
||||||
await legacy().question.reply({
|
await legacy().question.reply({
|
||||||
requestID: value.requestID,
|
requestID: value.requestID,
|
||||||
|
|||||||
+193
-38
@@ -1,4 +1,4 @@
|
|||||||
import type { AgentSideConnection, PromptResponse } from "@agentclientprotocol/sdk"
|
import type { AgentSideConnection, PromptResponse, SessionUpdate } from "@agentclientprotocol/sdk"
|
||||||
import type {
|
import type {
|
||||||
EventSubscribeOutput,
|
EventSubscribeOutput,
|
||||||
OpenCodeClient,
|
OpenCodeClient,
|
||||||
@@ -37,6 +37,34 @@ export type TurnStart =
|
|||||||
| { readonly type: "skill"; readonly id: string }
|
| { readonly type: "skill"; readonly id: string }
|
||||||
| { readonly type: "compaction"; readonly id: string }
|
| { readonly type: "compaction"; readonly id: string }
|
||||||
|
|
||||||
|
export const ChildSessionUpdatesCapability = "opencode/child-session-updates"
|
||||||
|
export const ChildSessionUpdateMethod = "opencode/session/child_update"
|
||||||
|
|
||||||
|
type ChildSessionUpdateBase = {
|
||||||
|
readonly rootSessionId: string
|
||||||
|
readonly childSessionId: string
|
||||||
|
readonly parentSessionId: string
|
||||||
|
readonly depth: number
|
||||||
|
readonly title?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChildSessionEvent =
|
||||||
|
| { readonly type: "update"; readonly update: SessionUpdate }
|
||||||
|
| {
|
||||||
|
readonly type: "status"
|
||||||
|
readonly status: "created" | "running" | "completed" | "failed" | "interrupted"
|
||||||
|
readonly error?: { readonly type: string; readonly message: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChildSessionUpdate = ChildSessionUpdateBase & ChildSessionEvent
|
||||||
|
|
||||||
|
type ChildSession = {
|
||||||
|
readonly id: string
|
||||||
|
readonly parentID: string
|
||||||
|
readonly depth: number
|
||||||
|
readonly title?: string
|
||||||
|
}
|
||||||
|
|
||||||
function emptyToolState(): ToolState {
|
function emptyToolState(): ToolState {
|
||||||
return { name: "tool", input: {}, metadata: {}, content: [] }
|
return { name: "tool", input: {}, metadata: {}, content: [] }
|
||||||
}
|
}
|
||||||
@@ -50,8 +78,13 @@ export async function streamTurn(input: {
|
|||||||
readonly writeTextFile: boolean
|
readonly writeTextFile: boolean
|
||||||
readonly submit: (signal: AbortSignal) => Promise<unknown>
|
readonly submit: (signal: AbortSignal) => Promise<unknown>
|
||||||
readonly control: TurnControl
|
readonly control: TurnControl
|
||||||
|
readonly childSessionUpdate?: (update: ChildSessionUpdate) => Promise<void>
|
||||||
|
readonly connectionSignal?: AbortSignal
|
||||||
|
readonly sessionSignal?: AbortSignal
|
||||||
}): Promise<PromptResponse> {
|
}): Promise<PromptResponse> {
|
||||||
const streamController = new AbortController()
|
const streamController = new AbortController()
|
||||||
|
const connectionAbort = () => streamController.abort()
|
||||||
|
input.connectionSignal?.addEventListener("abort", connectionAbort, { once: true })
|
||||||
const stream = input.client.event.subscribe({ signal: streamController.signal })[Symbol.asyncIterator]()
|
const stream = input.client.event.subscribe({ signal: streamController.signal })[Symbol.asyncIterator]()
|
||||||
const connected = await stream.next()
|
const connected = await stream.next()
|
||||||
if (connected.done) throw new Error("event stream disconnected before prompt admission")
|
if (connected.done) throw new Error("event stream disconnected before prompt admission")
|
||||||
@@ -62,47 +95,101 @@ export async function streamTurn(input: {
|
|||||||
let finish: SessionMessageAssistant["finish"]
|
let finish: SessionMessageAssistant["finish"]
|
||||||
let executionError: { readonly type: string; readonly message: string } | undefined
|
let executionError: { readonly type: string; readonly message: string } | undefined
|
||||||
const tools = new Map<string, ToolState>()
|
const tools = new Map<string, ToolState>()
|
||||||
|
const children = new Map<string, ChildSession>()
|
||||||
|
const openChildren = new Set<string>()
|
||||||
|
let handedOff = false
|
||||||
|
|
||||||
const update = (value: Parameters<Connection["sessionUpdate"]>[0]["update"]) =>
|
const notifyChild = async (child: ChildSession, value: ChildSessionEvent) => {
|
||||||
input.connection.sessionUpdate({ sessionId: input.sessionID, update: value })
|
if (!input.childSessionUpdate) return
|
||||||
|
await input
|
||||||
|
.childSessionUpdate({
|
||||||
|
rootSessionId: input.sessionID,
|
||||||
|
childSessionId: child.id,
|
||||||
|
parentSessionId: child.parentID,
|
||||||
|
depth: child.depth,
|
||||||
|
...(child.title ? { title: child.title } : {}),
|
||||||
|
...value,
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
const consume = async () => {
|
const updateSession = async (value: SessionUpdate, child: ChildSession | undefined, mode: "turn" | "background") => {
|
||||||
|
const projected = child ? projectChildUpdate(value, child) : value
|
||||||
|
if (mode === "turn" && (!child || !input.childSessionUpdate)) {
|
||||||
|
await input.connection.sessionUpdate({ sessionId: input.sessionID, update: projected })
|
||||||
|
}
|
||||||
|
if (child) await notifyChild(child, { type: "update", update: projected })
|
||||||
|
}
|
||||||
|
|
||||||
|
const consume = async (mode: "turn" | "background") => {
|
||||||
while (!streamController.signal.aborted) {
|
while (!streamController.signal.aborted) {
|
||||||
const next = await stream.next()
|
const next = await stream.next()
|
||||||
if (next.done) throw new Error("event stream disconnected during prompt execution")
|
if (next.done) throw new Error("event stream disconnected during prompt execution")
|
||||||
const event = next.value
|
const event = next.value
|
||||||
if (event.type === "permission.asked" && event.data.sessionID === input.sessionID) {
|
if (event.type === "session.created") {
|
||||||
const tool = event.data.source?.id ? tools.get(event.data.source.id) : undefined
|
const parentID = event.data.info.parentID
|
||||||
|
if (!parentID) continue
|
||||||
|
const parent = parentID === input.sessionID ? undefined : children.get(parentID)
|
||||||
|
if ((mode === "turn" && parentID === input.sessionID) || parent) {
|
||||||
|
const child = {
|
||||||
|
id: event.data.sessionID,
|
||||||
|
parentID,
|
||||||
|
depth: parent ? parent.depth + 1 : 1,
|
||||||
|
title: event.data.info.title,
|
||||||
|
}
|
||||||
|
children.set(child.id, child)
|
||||||
|
openChildren.add(child.id)
|
||||||
|
await notifyChild(child, { type: "status", status: "created" })
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventSessionID = sessionIDFromEvent(event)
|
||||||
|
const child = eventSessionID ? children.get(eventSessionID) : undefined
|
||||||
|
const send = (update: SessionUpdate) => updateSession(update, child, mode)
|
||||||
|
if (mode === "background" && !child) continue
|
||||||
|
|
||||||
|
if (event.type === "permission.asked" && (event.data.sessionID === input.sessionID || child)) {
|
||||||
|
const tool = event.data.source?.id ? tools.get(toolKey(event.data.sessionID, event.data.source.id)) : undefined
|
||||||
await replyPermission({
|
await replyPermission({
|
||||||
client: input.client,
|
client: input.client,
|
||||||
connection: input.connection,
|
connection: input.connection,
|
||||||
event,
|
event,
|
||||||
sessionID: input.sessionID,
|
sessionID: event.data.sessionID,
|
||||||
|
clientSessionID: input.sessionID,
|
||||||
cwd: input.cwd,
|
cwd: input.cwd,
|
||||||
tool,
|
tool,
|
||||||
|
...(child ? { toolCallPrefix: child.id, titlePrefix: child.title } : {}),
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (event.type === "form.created" && event.data.form.sessionID === input.sessionID) {
|
if (event.type === "form.created" && (event.data.form.sessionID === input.sessionID || child)) {
|
||||||
await input.client.form
|
await input.client.form
|
||||||
.cancel({ sessionID: input.sessionID, formID: event.data.form.id })
|
.cancel({ sessionID: event.data.form.sessionID, formID: event.data.form.id })
|
||||||
.catch(() => input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {}))
|
.catch(() => input.client.session.interrupt({ sessionID: event.data.form.sessionID }).catch(() => {}))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (!("sessionID" in event.data) || event.data.sessionID !== input.sessionID) continue
|
if (!eventSessionID || (eventSessionID !== input.sessionID && !child)) continue
|
||||||
if (matchesStart(event, input.start)) {
|
if (matchesStart(event, input.start)) {
|
||||||
started = true
|
started = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (!started) continue
|
if (!started) continue
|
||||||
|
|
||||||
|
if (event.type === "session.execution.started") {
|
||||||
|
if (child) {
|
||||||
|
await notifyChild(child, { type: "status", status: "running" })
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if (event.type === "session.step.started") {
|
if (event.type === "session.step.started") {
|
||||||
assistantMessageID = event.data.assistantMessageID
|
if (!child) assistantMessageID = event.data.assistantMessageID
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (event.type === "session.text.delta") {
|
if (event.type === "session.text.delta") {
|
||||||
assistantMessageID = event.data.assistantMessageID
|
if (!child) assistantMessageID = event.data.assistantMessageID
|
||||||
await update({
|
await send({
|
||||||
sessionUpdate: "agent_message_chunk",
|
sessionUpdate: "agent_message_chunk",
|
||||||
messageId: event.data.assistantMessageID,
|
messageId: event.data.assistantMessageID,
|
||||||
content: { type: "text", text: event.data.delta },
|
content: { type: "text", text: event.data.delta },
|
||||||
@@ -110,8 +197,8 @@ export async function streamTurn(input: {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (event.type === "session.reasoning.delta") {
|
if (event.type === "session.reasoning.delta") {
|
||||||
assistantMessageID = event.data.assistantMessageID
|
if (!child) assistantMessageID = event.data.assistantMessageID
|
||||||
await update({
|
await send({
|
||||||
sessionUpdate: "agent_thought_chunk",
|
sessionUpdate: "agent_thought_chunk",
|
||||||
messageId: event.data.assistantMessageID,
|
messageId: event.data.assistantMessageID,
|
||||||
content: { type: "text", text: event.data.delta },
|
content: { type: "text", text: event.data.delta },
|
||||||
@@ -119,9 +206,14 @@ export async function streamTurn(input: {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (event.type === "session.tool.input.started") {
|
if (event.type === "session.tool.input.started") {
|
||||||
assistantMessageID = event.data.assistantMessageID
|
if (!child) assistantMessageID = event.data.assistantMessageID
|
||||||
tools.set(event.data.id, { name: event.data.name, input: {}, metadata: {}, content: [] })
|
tools.set(toolKey(event.data.sessionID, event.data.id), {
|
||||||
await update({
|
name: event.data.name,
|
||||||
|
input: {},
|
||||||
|
metadata: {},
|
||||||
|
content: [],
|
||||||
|
})
|
||||||
|
await send({
|
||||||
sessionUpdate: "tool_call",
|
sessionUpdate: "tool_call",
|
||||||
...pendingToolCall({
|
...pendingToolCall({
|
||||||
toolCallId: event.data.id,
|
toolCallId: event.data.id,
|
||||||
@@ -133,11 +225,12 @@ export async function streamTurn(input: {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (event.type === "session.tool.called") {
|
if (event.type === "session.tool.called") {
|
||||||
assistantMessageID = event.data.assistantMessageID
|
if (!child) assistantMessageID = event.data.assistantMessageID
|
||||||
const current = tools.get(event.data.id) ?? emptyToolState()
|
const key = toolKey(event.data.sessionID, event.data.id)
|
||||||
|
const current = tools.get(key) ?? emptyToolState()
|
||||||
current.input = event.data.input
|
current.input = event.data.input
|
||||||
tools.set(event.data.id, current)
|
tools.set(key, current)
|
||||||
await update({
|
await send({
|
||||||
sessionUpdate: "tool_call_update",
|
sessionUpdate: "tool_call_update",
|
||||||
...runningToolUpdate({
|
...runningToolUpdate({
|
||||||
toolCallId: event.data.id,
|
toolCallId: event.data.id,
|
||||||
@@ -149,10 +242,10 @@ export async function streamTurn(input: {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (event.type === "session.tool.progress") {
|
if (event.type === "session.tool.progress") {
|
||||||
const current = tools.get(event.data.id)
|
const current = tools.get(toolKey(event.data.sessionID, event.data.id))
|
||||||
if (!current) continue
|
if (!current) continue
|
||||||
current.metadata = event.data.metadata
|
current.metadata = event.data.metadata
|
||||||
await update({
|
await send({
|
||||||
sessionUpdate: "tool_call_update",
|
sessionUpdate: "tool_call_update",
|
||||||
...runningToolUpdate({
|
...runningToolUpdate({
|
||||||
toolCallId: event.data.id,
|
toolCallId: event.data.id,
|
||||||
@@ -164,8 +257,9 @@ export async function streamTurn(input: {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (event.type === "session.tool.success") {
|
if (event.type === "session.tool.success") {
|
||||||
const current = tools.get(event.data.id) ?? emptyToolState()
|
const key = toolKey(event.data.sessionID, event.data.id)
|
||||||
tools.delete(event.data.id)
|
const current = tools.get(key) ?? emptyToolState()
|
||||||
|
tools.delete(key)
|
||||||
await syncEditedFiles({
|
await syncEditedFiles({
|
||||||
connection: input.connection,
|
connection: input.connection,
|
||||||
writeTextFile: input.writeTextFile,
|
writeTextFile: input.writeTextFile,
|
||||||
@@ -175,7 +269,7 @@ export async function streamTurn(input: {
|
|||||||
toolInput: current.input,
|
toolInput: current.input,
|
||||||
metadata: event.data.metadata ?? {},
|
metadata: event.data.metadata ?? {},
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
await update({
|
await send({
|
||||||
sessionUpdate: "tool_call_update",
|
sessionUpdate: "tool_call_update",
|
||||||
...completedToolUpdate({
|
...completedToolUpdate({
|
||||||
toolCallId: event.data.id,
|
toolCallId: event.data.id,
|
||||||
@@ -188,9 +282,10 @@ export async function streamTurn(input: {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (event.type === "session.tool.failed") {
|
if (event.type === "session.tool.failed") {
|
||||||
const current = tools.get(event.data.id) ?? emptyToolState()
|
const key = toolKey(event.data.sessionID, event.data.id)
|
||||||
tools.delete(event.data.id)
|
const current = tools.get(key) ?? emptyToolState()
|
||||||
await update({
|
tools.delete(key)
|
||||||
|
await send({
|
||||||
sessionUpdate: "tool_call_update",
|
sessionUpdate: "tool_call_update",
|
||||||
...errorToolUpdate({
|
...errorToolUpdate({
|
||||||
toolCallId: event.data.id,
|
toolCallId: event.data.id,
|
||||||
@@ -205,13 +300,33 @@ export async function streamTurn(input: {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (event.type === "session.step.ended") {
|
if (event.type === "session.step.ended") {
|
||||||
assistantMessageID = event.data.assistantMessageID
|
if (!child) {
|
||||||
finish = event.data.finish
|
assistantMessageID = event.data.assistantMessageID
|
||||||
|
finish = event.data.finish
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (event.type === "session.execution.succeeded") {
|
||||||
|
if (!child) return "succeeded" as const
|
||||||
|
openChildren.delete(child.id)
|
||||||
|
await notifyChild(child, { type: "status", status: "completed" })
|
||||||
|
if (mode === "background" && openChildren.size === 0) return "succeeded" as const
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (event.type === "session.execution.interrupted") {
|
||||||
|
if (!child) return "interrupted" as const
|
||||||
|
openChildren.delete(child.id)
|
||||||
|
await notifyChild(child, { type: "status", status: "interrupted" })
|
||||||
|
if (mode === "background" && openChildren.size === 0) return "interrupted" as const
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (event.type === "session.execution.succeeded") return "succeeded" as const
|
|
||||||
if (event.type === "session.execution.interrupted") return "interrupted" as const
|
|
||||||
if (event.type === "session.execution.failed") {
|
if (event.type === "session.execution.failed") {
|
||||||
|
if (child) {
|
||||||
|
openChildren.delete(child.id)
|
||||||
|
await notifyChild(child, { type: "status", status: "failed", error: event.data.error })
|
||||||
|
if (mode === "background" && openChildren.size === 0) return "failed" as const
|
||||||
|
continue
|
||||||
|
}
|
||||||
executionError = event.data.error
|
executionError = event.data.error
|
||||||
return "failed" as const
|
return "failed" as const
|
||||||
}
|
}
|
||||||
@@ -219,7 +334,13 @@ export async function streamTurn(input: {
|
|||||||
return "interrupted" as const
|
return "interrupted" as const
|
||||||
}
|
}
|
||||||
|
|
||||||
const completed = consume()
|
const completed = consume("turn")
|
||||||
|
const closeStream = async () => {
|
||||||
|
streamController.abort()
|
||||||
|
input.connectionSignal?.removeEventListener("abort", connectionAbort)
|
||||||
|
input.sessionSignal?.removeEventListener("abort", connectionAbort)
|
||||||
|
await stream.return?.(undefined).catch(() => {})
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await input.submit(control.admission.signal).catch((error) => {
|
await input.submit(control.admission.signal).catch((error) => {
|
||||||
if (!control.cancelled) throw error
|
if (!control.cancelled) throw error
|
||||||
@@ -233,6 +354,13 @@ export async function streamTurn(input: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const terminal = await completed
|
const terminal = await completed
|
||||||
|
if (input.childSessionUpdate && openChildren.size > 0 && !input.sessionSignal?.aborted) {
|
||||||
|
handedOff = true
|
||||||
|
input.sessionSignal?.addEventListener("abort", connectionAbort, { once: true })
|
||||||
|
void consume("background")
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(closeStream)
|
||||||
|
}
|
||||||
const assistant = assistantMessageID
|
const assistant = assistantMessageID
|
||||||
? await input.client.session
|
? await input.client.session
|
||||||
.message({ sessionID: input.sessionID, messageID: assistantMessageID })
|
.message({ sessionID: input.sessionID, messageID: assistantMessageID })
|
||||||
@@ -250,11 +378,38 @@ export async function streamTurn(input: {
|
|||||||
await completed.catch(() => {})
|
await completed.catch(() => {})
|
||||||
throw error
|
throw error
|
||||||
} finally {
|
} finally {
|
||||||
streamController.abort()
|
if (!handedOff) await closeStream()
|
||||||
await stream.return?.(undefined).catch(() => {})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sessionIDFromEvent(event: EventSubscribeOutput) {
|
||||||
|
if ("sessionID" in event.data && typeof event.data.sessionID === "string") return event.data.sessionID
|
||||||
|
if (event.type === "form.created") return event.data.form.sessionID
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolKey(sessionID: string, id: string) {
|
||||||
|
return `${sessionID}:${id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectChildUpdate(update: SessionUpdate, child: ChildSession) {
|
||||||
|
const projected = { ...update }
|
||||||
|
projected._meta = {
|
||||||
|
...projected._meta,
|
||||||
|
"opencode/child-session": {
|
||||||
|
id: child.id,
|
||||||
|
parentID: child.parentID,
|
||||||
|
depth: child.depth,
|
||||||
|
...(child.title ? { title: child.title } : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if (projected.sessionUpdate === "tool_call" || projected.sessionUpdate === "tool_call_update") {
|
||||||
|
projected.toolCallId = `${child.id}:${projected.toolCallId}`
|
||||||
|
if (projected.title && child.title) projected.title = `${child.title}: ${projected.title}`
|
||||||
|
}
|
||||||
|
return projected
|
||||||
|
}
|
||||||
|
|
||||||
export async function replayMessages(
|
export async function replayMessages(
|
||||||
connection: Pick<AgentSideConnection, "sessionUpdate">,
|
connection: Pick<AgentSideConnection, "sessionUpdate">,
|
||||||
sessionID: string,
|
sessionID: string,
|
||||||
|
|||||||
@@ -20,20 +20,28 @@ export async function replyPermission(input: {
|
|||||||
readonly connection: Connection
|
readonly connection: Connection
|
||||||
readonly event: PermissionEvent
|
readonly event: PermissionEvent
|
||||||
readonly sessionID: string
|
readonly sessionID: string
|
||||||
|
readonly clientSessionID?: string
|
||||||
readonly cwd: string
|
readonly cwd: string
|
||||||
readonly tool?: Tool
|
readonly tool?: Tool
|
||||||
|
readonly toolCallPrefix?: string
|
||||||
|
readonly titlePrefix?: string
|
||||||
}) {
|
}) {
|
||||||
const toolName = input.tool?.name ?? input.event.data.action
|
const toolName = input.tool?.name ?? input.event.data.action
|
||||||
const toolInput = { ...input.event.data.metadata, ...input.tool?.input }
|
const toolInput = { ...input.event.data.metadata, ...input.tool?.input }
|
||||||
const previews = await permissionPreviews(toolName, toolInput, input.cwd)
|
const previews = await permissionPreviews(toolName, toolInput, input.cwd)
|
||||||
|
const toolCallID = input.event.data.source?.id ?? input.event.data.id
|
||||||
|
const title = permissionTitle(toolName, toolInput, previews)
|
||||||
const result = await input.connection
|
const result = await input.connection
|
||||||
.requestPermission({
|
.requestPermission({
|
||||||
sessionId: input.sessionID,
|
sessionId: input.clientSessionID ?? input.sessionID,
|
||||||
toolCall: {
|
toolCall: {
|
||||||
...pendingToolCall({
|
...pendingToolCall({
|
||||||
toolCallId: input.event.data.source?.id ?? input.event.data.id,
|
toolCallId: input.toolCallPrefix ? `${input.toolCallPrefix}:${toolCallID}` : toolCallID,
|
||||||
toolName,
|
toolName,
|
||||||
state: { input: toolInput, title: permissionTitle(toolName, toolInput, previews) },
|
state: {
|
||||||
|
input: toolInput,
|
||||||
|
title: prefixedTitle(input.titlePrefix, title),
|
||||||
|
},
|
||||||
cwd: input.cwd,
|
cwd: input.cwd,
|
||||||
}),
|
}),
|
||||||
locations: permissionLocations(toolName, toolInput, input.event.data.resources, input.cwd, previews),
|
locations: permissionLocations(toolName, toolInput, input.event.data.resources, input.cwd, previews),
|
||||||
@@ -51,6 +59,12 @@ export async function replyPermission(input: {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function prefixedTitle(prefix: string | undefined, title: string | undefined) {
|
||||||
|
if (!prefix) return title
|
||||||
|
if (!title) return prefix
|
||||||
|
return `${prefix}: ${title}`
|
||||||
|
}
|
||||||
|
|
||||||
export async function syncEditedFiles(input: {
|
export async function syncEditedFiles(input: {
|
||||||
readonly connection: Partial<Pick<AgentSideConnection, "writeTextFile">>
|
readonly connection: Partial<Pick<AgentSideConnection, "writeTextFile">>
|
||||||
readonly writeTextFile: boolean
|
readonly writeTextFile: boolean
|
||||||
|
|||||||
@@ -43,13 +43,21 @@ import { OPENCODE_VERSION } from "../version"
|
|||||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||||
import { buildConfigOptions, parseModelSelection, type ConfigOptionProvider } from "./config-option"
|
import { buildConfigOptions, parseModelSelection, type ConfigOptionProvider } from "./config-option"
|
||||||
import { promptContentToParts } from "./content"
|
import { promptContentToParts } from "./content"
|
||||||
import { replayMessages, streamTurn, type TurnControl, type TurnStart } from "./event"
|
import {
|
||||||
|
ChildSessionUpdateMethod,
|
||||||
|
ChildSessionUpdatesCapability,
|
||||||
|
replayMessages,
|
||||||
|
streamTurn,
|
||||||
|
type ChildSessionUpdate,
|
||||||
|
type TurnControl,
|
||||||
|
type TurnStart,
|
||||||
|
} from "./event"
|
||||||
import { ACPError } from "./error"
|
import { ACPError } from "./error"
|
||||||
|
|
||||||
export const AuthMethodID = "opencode-login"
|
export const AuthMethodID = "opencode-login"
|
||||||
|
|
||||||
type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission"> &
|
type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission"> &
|
||||||
Partial<Pick<AgentSideConnection, "writeTextFile">>
|
Partial<Pick<AgentSideConnection, "writeTextFile" | "extNotification" | "signal">>
|
||||||
|
|
||||||
type Catalog = {
|
type Catalog = {
|
||||||
readonly providers: ConfigOptionProvider[]
|
readonly providers: ConfigOptionProvider[]
|
||||||
@@ -64,6 +72,7 @@ type Catalog = {
|
|||||||
type Attached = {
|
type Attached = {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly cwd: string
|
readonly cwd: string
|
||||||
|
readonly abort: AbortController
|
||||||
catalog: Catalog
|
catalog: Catalog
|
||||||
model: ModelRef
|
model: ModelRef
|
||||||
modeID: string
|
modeID: string
|
||||||
@@ -100,7 +109,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
|||||||
const catalogs = new Map<string, Promise<Catalog>>()
|
const catalogs = new Map<string, Promise<Catalog>>()
|
||||||
const registeredMcp = new Map<string, Set<string>>()
|
const registeredMcp = new Map<string, Set<string>>()
|
||||||
const active = new Map<string, TurnControl>()
|
const active = new Map<string, TurnControl>()
|
||||||
const capabilities = { writeTextFile: false }
|
const capabilities = { writeTextFile: false, childSessionUpdates: false }
|
||||||
|
|
||||||
const catalog = (cwd: string) => {
|
const catalog = (cwd: string) => {
|
||||||
const cached = catalogs.get(cwd)
|
const cached = catalogs.get(cwd)
|
||||||
@@ -119,11 +128,19 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
|||||||
throw new ACPError.SessionNotFoundError({ sessionId: sessionID })
|
throw new ACPError.SessionNotFoundError({ sessionId: sessionID })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const detach = (sessionID: string) => {
|
||||||
|
sessions.get(sessionID)?.abort.abort()
|
||||||
|
sessions.delete(sessionID)
|
||||||
|
registeredMcp.delete(sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
const attach = async (session: SessionInfo, cwd: string, mcpServers: readonly McpServer[]) => {
|
const attach = async (session: SessionInfo, cwd: string, mcpServers: readonly McpServer[]) => {
|
||||||
const currentCatalog = await catalog(cwd)
|
const currentCatalog = await catalog(cwd)
|
||||||
|
sessions.get(session.id)?.abort.abort()
|
||||||
const state: Attached = {
|
const state: Attached = {
|
||||||
id: session.id,
|
id: session.id,
|
||||||
cwd,
|
cwd,
|
||||||
|
abort: new AbortController(),
|
||||||
catalog: currentCatalog,
|
catalog: currentCatalog,
|
||||||
model: session.model ?? currentCatalog.defaultModel,
|
model: session.model ?? currentCatalog.defaultModel,
|
||||||
modeID: session.agent ?? currentCatalog.defaultModeID,
|
modeID: session.agent ?? currentCatalog.defaultModeID,
|
||||||
@@ -161,6 +178,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
|||||||
return {
|
return {
|
||||||
initialize: async (params) => {
|
initialize: async (params) => {
|
||||||
capabilities.writeTextFile = params.clientCapabilities?.fs?.writeTextFile === true
|
capabilities.writeTextFile = params.clientCapabilities?.fs?.writeTextFile === true
|
||||||
|
capabilities.childSessionUpdates = params.clientCapabilities?._meta?.[ChildSessionUpdatesCapability] === true
|
||||||
const authMethod: AuthMethod = {
|
const authMethod: AuthMethod = {
|
||||||
description: "Run `opencode auth login` in the terminal",
|
description: "Run `opencode auth login` in the terminal",
|
||||||
name: "Login with opencode",
|
name: "Login with opencode",
|
||||||
@@ -178,6 +196,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
|||||||
mcpCapabilities: { http: true, sse: false },
|
mcpCapabilities: { http: true, sse: false },
|
||||||
promptCapabilities: { embeddedContext: true, image: true },
|
promptCapabilities: { embeddedContext: true, image: true },
|
||||||
sessionCapabilities: { close: {}, delete: {}, fork: {}, list: {}, resume: {} },
|
sessionCapabilities: { close: {}, delete: {}, fork: {}, list: {}, resume: {} },
|
||||||
|
_meta: { [ChildSessionUpdatesCapability]: true },
|
||||||
},
|
},
|
||||||
authMethods: [authMethod],
|
authMethods: [authMethod],
|
||||||
agentInfo: { name: "OpenCode", version: OPENCODE_VERSION },
|
agentInfo: { name: "OpenCode", version: OPENCODE_VERSION },
|
||||||
@@ -224,8 +243,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
|||||||
await input.client.session.remove({ sessionID: params.sessionId }).catch((error) => {
|
await input.client.session.remove({ sessionID: params.sessionId }).catch((error) => {
|
||||||
if (!isSessionNotFoundError(error)) throw error
|
if (!isSessionNotFoundError(error)) throw error
|
||||||
})
|
})
|
||||||
sessions.delete(params.sessionId)
|
detach(params.sessionId)
|
||||||
registeredMcp.delete(params.sessionId)
|
|
||||||
return {}
|
return {}
|
||||||
},
|
},
|
||||||
resumeSession: async (params) => {
|
resumeSession: async (params) => {
|
||||||
@@ -234,8 +252,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
|||||||
return { configOptions: configOptions(state) }
|
return { configOptions: configOptions(state) }
|
||||||
},
|
},
|
||||||
closeSession: async (params) => {
|
closeSession: async (params) => {
|
||||||
sessions.delete(params.sessionId)
|
detach(params.sessionId)
|
||||||
registeredMcp.delete(params.sessionId)
|
|
||||||
const turn = active.get(params.sessionId)
|
const turn = active.get(params.sessionId)
|
||||||
if (turn) {
|
if (turn) {
|
||||||
turn.cancelled = true
|
turn.cancelled = true
|
||||||
@@ -296,6 +313,11 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
|||||||
const messageID = SessionMessage.ID.create()
|
const messageID = SessionMessage.ID.create()
|
||||||
const prepared = preparePrompt(state.catalog, params.prompt, messageID)
|
const prepared = preparePrompt(state.catalog, params.prompt, messageID)
|
||||||
const control: TurnControl = { cancelled: false, admission: new AbortController() }
|
const control: TurnControl = { cancelled: false, admission: new AbortController() }
|
||||||
|
const extNotification = input.connection.extNotification
|
||||||
|
const childSessionUpdate =
|
||||||
|
capabilities.childSessionUpdates && extNotification
|
||||||
|
? (update: ChildSessionUpdate) => extNotification(ChildSessionUpdateMethod, update).then(() => {})
|
||||||
|
: undefined
|
||||||
active.set(state.id, control)
|
active.set(state.id, control)
|
||||||
const response = await streamTurn({
|
const response = await streamTurn({
|
||||||
client: input.client,
|
client: input.client,
|
||||||
@@ -305,7 +327,10 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
|||||||
start: prepared.start,
|
start: prepared.start,
|
||||||
writeTextFile: capabilities.writeTextFile,
|
writeTextFile: capabilities.writeTextFile,
|
||||||
control,
|
control,
|
||||||
|
connectionSignal: input.connection.signal,
|
||||||
|
sessionSignal: state.abort.signal,
|
||||||
submit: (signal) => submitPrompt(input.client, state, prepared, signal),
|
submit: (signal) => submitPrompt(input.client, state, prepared, signal),
|
||||||
|
...(childSessionUpdate ? { childSessionUpdate } : {}),
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
if (active.get(state.id) === control) active.delete(state.id)
|
if (active.get(state.id) === control) active.delete(state.id)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
|
|||||||
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
|
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
|
||||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||||
import { resolve } from "node:path"
|
import { resolve } from "node:path"
|
||||||
import { replayMessages, streamTurn, type TurnControl } from "../../src/acp/event"
|
import { replayMessages, streamTurn, type ChildSessionUpdate, type TurnControl } from "../../src/acp/event"
|
||||||
import { createSseFixture, durableEvent, ephemeralEvent, withTimeout } from "./sse-fixture"
|
import { createSseFixture, durableEvent, ephemeralEvent, withTimeout } from "./sse-fixture"
|
||||||
|
|
||||||
type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
|
type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
|
||||||
@@ -191,6 +191,181 @@ describe("acp event behavior", () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("projects foreground child session updates onto the parent turn", async () => {
|
||||||
|
const updates: SessionUpdateParams[] = []
|
||||||
|
const fixture = createSseFixture({
|
||||||
|
onPrompt({ id, send }) {
|
||||||
|
send(durableEvent("session.input.promoted", { sessionID: "ses_parent", inputID: id }))
|
||||||
|
send(
|
||||||
|
durableEvent("session.created", {
|
||||||
|
sessionID: "ses_child",
|
||||||
|
info: childSession("ses_child", "ses_parent", "Explore code"),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
send(durableEvent("session.execution.started", { sessionID: "ses_child" }))
|
||||||
|
send(
|
||||||
|
durableEvent("session.tool.input.started", {
|
||||||
|
sessionID: "ses_child",
|
||||||
|
assistantMessageID: "msg_child",
|
||||||
|
id: "call_read",
|
||||||
|
name: "read",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
send(
|
||||||
|
durableEvent("session.tool.called", {
|
||||||
|
sessionID: "ses_child",
|
||||||
|
assistantMessageID: "msg_child",
|
||||||
|
id: "call_read",
|
||||||
|
input: { path: "/workspace/src/index.ts" },
|
||||||
|
executed: false,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
send(
|
||||||
|
durableEvent("session.tool.success", {
|
||||||
|
sessionID: "ses_child",
|
||||||
|
assistantMessageID: "msg_child",
|
||||||
|
id: "call_read",
|
||||||
|
metadata: {},
|
||||||
|
content: [{ type: "text", text: "source" }],
|
||||||
|
executed: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
send(durableEvent("session.execution.succeeded", { sessionID: "ses_child" }))
|
||||||
|
send(durableEvent("session.execution.succeeded", { sessionID: "ses_parent" }))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await turn({
|
||||||
|
fixture,
|
||||||
|
connection: recordingConnection(updates),
|
||||||
|
sessionID: "ses_parent",
|
||||||
|
inputID: "input_parent",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(updates.map((item) => [item.sessionId, item.update.sessionUpdate])).toEqual([
|
||||||
|
["ses_parent", "tool_call"],
|
||||||
|
["ses_parent", "tool_call_update"],
|
||||||
|
["ses_parent", "tool_call_update"],
|
||||||
|
])
|
||||||
|
expect(updates.map((item) => ("toolCallId" in item.update ? item.update.toolCallId : undefined))).toEqual([
|
||||||
|
"ses_child:call_read",
|
||||||
|
"ses_child:call_read",
|
||||||
|
"ses_child:call_read",
|
||||||
|
])
|
||||||
|
expect(updates[0]?.update).toMatchObject({
|
||||||
|
title: "Explore code: read",
|
||||||
|
_meta: {
|
||||||
|
"opencode/child-session": {
|
||||||
|
id: "ses_child",
|
||||||
|
parentID: "ses_parent",
|
||||||
|
depth: 1,
|
||||||
|
title: "Explore code",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(response.stopReason).toBe("end_turn")
|
||||||
|
} finally {
|
||||||
|
await fixture.stop()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("continues child extension updates after the parent turn ends", async () => {
|
||||||
|
const updates: SessionUpdateParams[] = []
|
||||||
|
const childUpdates: ChildSessionUpdate[] = []
|
||||||
|
const completed = Promise.withResolvers<void>()
|
||||||
|
const fixture = createSseFixture({
|
||||||
|
onPrompt({ id, send }) {
|
||||||
|
send(durableEvent("session.input.promoted", { sessionID: "ses_parent", inputID: id }))
|
||||||
|
send(
|
||||||
|
durableEvent("session.created", {
|
||||||
|
sessionID: "ses_background",
|
||||||
|
info: childSession("ses_background", "ses_parent", "Background research"),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
send(durableEvent("session.execution.succeeded", { sessionID: "ses_parent" }))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await turn({
|
||||||
|
fixture,
|
||||||
|
connection: recordingConnection(updates),
|
||||||
|
sessionID: "ses_parent",
|
||||||
|
inputID: "input_parent",
|
||||||
|
childSessionUpdate: async (update) => {
|
||||||
|
childUpdates.push(update)
|
||||||
|
if (update.type === "status" && update.status === "completed") completed.resolve()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(response.stopReason).toBe("end_turn")
|
||||||
|
|
||||||
|
fixture.send(
|
||||||
|
durableEvent("session.created", {
|
||||||
|
sessionID: "ses_future",
|
||||||
|
info: childSession("ses_future", "ses_parent", "Later turn child"),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
fixture.send(durableEvent("session.execution.started", { sessionID: "ses_future" }))
|
||||||
|
fixture.send(durableEvent("session.execution.started", { sessionID: "ses_background" }))
|
||||||
|
fixture.send(
|
||||||
|
durableEvent("session.tool.input.started", {
|
||||||
|
sessionID: "ses_background",
|
||||||
|
assistantMessageID: "msg_background",
|
||||||
|
id: "call_shell",
|
||||||
|
name: "shell",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
fixture.send(
|
||||||
|
durableEvent("session.tool.called", {
|
||||||
|
sessionID: "ses_background",
|
||||||
|
assistantMessageID: "msg_background",
|
||||||
|
id: "call_shell",
|
||||||
|
input: { command: "pwd" },
|
||||||
|
executed: false,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
fixture.send(
|
||||||
|
durableEvent("session.tool.success", {
|
||||||
|
sessionID: "ses_background",
|
||||||
|
assistantMessageID: "msg_background",
|
||||||
|
id: "call_shell",
|
||||||
|
metadata: { exit: 0 },
|
||||||
|
content: [{ type: "text", text: "/workspace" }],
|
||||||
|
executed: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
fixture.send(durableEvent("session.execution.succeeded", { sessionID: "ses_background" }))
|
||||||
|
await withTimeout(completed.promise, "background child completion was not delivered")
|
||||||
|
|
||||||
|
expect(updates).toEqual([])
|
||||||
|
expect(
|
||||||
|
childUpdates.map((update) =>
|
||||||
|
update.type === "status" ? [update.type, update.status] : [update.type, update.update.sessionUpdate],
|
||||||
|
),
|
||||||
|
).toEqual([
|
||||||
|
["status", "created"],
|
||||||
|
["status", "running"],
|
||||||
|
["update", "tool_call"],
|
||||||
|
["update", "tool_call_update"],
|
||||||
|
["update", "tool_call_update"],
|
||||||
|
["status", "completed"],
|
||||||
|
])
|
||||||
|
expect(childUpdates[2]).toMatchObject({
|
||||||
|
rootSessionId: "ses_parent",
|
||||||
|
childSessionId: "ses_background",
|
||||||
|
parentSessionId: "ses_parent",
|
||||||
|
depth: 1,
|
||||||
|
title: "Background research",
|
||||||
|
type: "update",
|
||||||
|
update: { toolCallId: "ses_background:call_shell" },
|
||||||
|
})
|
||||||
|
expect(childUpdates.some((update) => update.childSessionId === "ses_future")).toBe(false)
|
||||||
|
} finally {
|
||||||
|
await fixture.stop()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test("streams tool pending, progress, success, and failure updates", async () => {
|
test("streams tool pending, progress, success, and failure updates", async () => {
|
||||||
const updates: SessionUpdateParams[] = []
|
const updates: SessionUpdateParams[] = []
|
||||||
const fixture = createSseFixture({
|
const fixture = createSseFixture({
|
||||||
@@ -556,6 +731,7 @@ function turn(input: {
|
|||||||
readonly connection: Connection
|
readonly connection: Connection
|
||||||
readonly sessionID: string
|
readonly sessionID: string
|
||||||
readonly inputID: string
|
readonly inputID: string
|
||||||
|
readonly childSessionUpdate?: (update: ChildSessionUpdate) => Promise<void>
|
||||||
}) {
|
}) {
|
||||||
return streamTurn({
|
return streamTurn({
|
||||||
client: input.fixture.client,
|
client: input.fixture.client,
|
||||||
@@ -565,11 +741,25 @@ function turn(input: {
|
|||||||
start: { type: "input", id: input.inputID },
|
start: { type: "input", id: input.inputID },
|
||||||
writeTextFile: false,
|
writeTextFile: false,
|
||||||
control: { cancelled: false, admission: new AbortController() },
|
control: { cancelled: false, admission: new AbortController() },
|
||||||
|
childSessionUpdate: input.childSessionUpdate,
|
||||||
submit: (signal) =>
|
submit: (signal) =>
|
||||||
input.fixture.client.session.prompt({ sessionID: input.sessionID, id: input.inputID, text: "hello" }, { signal }),
|
input.fixture.client.session.prompt({ sessionID: input.sessionID, id: input.inputID, text: "hello" }, { signal }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function childSession(id: string, parentID: string, title: string) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
slug: id,
|
||||||
|
projectID: "project",
|
||||||
|
directory: "/workspace",
|
||||||
|
parentID,
|
||||||
|
title,
|
||||||
|
version: "test",
|
||||||
|
time: { created: 1, updated: 1 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function tokens() {
|
function tokens() {
|
||||||
return { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } }
|
return { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,6 +153,68 @@ describe("acp permission behavior", () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("routes foreground child permissions through the parent ACP session", async () => {
|
||||||
|
const permissionRequests: RequestPermissionRequest[] = []
|
||||||
|
const fixture = createSseFixture({
|
||||||
|
onPrompt({ id, send }) {
|
||||||
|
send(durableEvent("session.input.promoted", { sessionID: "ses_parent", inputID: id }))
|
||||||
|
send(
|
||||||
|
durableEvent("session.created", {
|
||||||
|
sessionID: "ses_child",
|
||||||
|
info: {
|
||||||
|
id: "ses_child",
|
||||||
|
slug: "ses_child",
|
||||||
|
projectID: "project",
|
||||||
|
directory: "/workspace",
|
||||||
|
parentID: "ses_parent",
|
||||||
|
title: "Review code",
|
||||||
|
version: "test",
|
||||||
|
time: { created: 1, updated: 1 },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
send(durableEvent("session.execution.started", { sessionID: "ses_child" }))
|
||||||
|
send(
|
||||||
|
permissionAsked("ses_child", "perm_child", {
|
||||||
|
action: "read",
|
||||||
|
metadata: { path: "/workspace/child.ts" },
|
||||||
|
source: { type: "tool", messageID: "msg_child", id: "call_child" },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
send(durableEvent("session.execution.succeeded", { sessionID: "ses_child" }))
|
||||||
|
send(durableEvent("session.execution.succeeded", { sessionID: "ses_parent" }))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const connection = {
|
||||||
|
sessionUpdate: async () => {},
|
||||||
|
requestPermission: async (request) => {
|
||||||
|
permissionRequests.push(request)
|
||||||
|
return { outcome: { outcome: "selected", optionId: "once" } } as const
|
||||||
|
},
|
||||||
|
} satisfies Connection
|
||||||
|
|
||||||
|
try {
|
||||||
|
await startTurn(fixture, connection, "ses_parent", "input_parent")
|
||||||
|
|
||||||
|
expect(permissionRequests).toHaveLength(1)
|
||||||
|
expect(permissionRequests[0]).toMatchObject({
|
||||||
|
sessionId: "ses_parent",
|
||||||
|
toolCall: {
|
||||||
|
toolCallId: "ses_child:call_child",
|
||||||
|
title: "Review code: /workspace/child.ts",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(fixture.requests).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/session/ses_child/permission/perm_child/reply",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
await fixture.stop()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test("previews edits during approval and syncs the completed file", async () => {
|
test("previews edits during approval and syncs the completed file", async () => {
|
||||||
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-acp-permission-"))
|
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-acp-permission-"))
|
||||||
const file = path.join(cwd, "file.ts")
|
const file = path.join(cwd, "file.ts")
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
|
|||||||
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
|
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
|
||||||
import { OpenCode } from "@opencode-ai/client/promise"
|
import { OpenCode } from "@opencode-ai/client/promise"
|
||||||
import { ACPService } from "../../src/acp/service"
|
import { ACPService } from "../../src/acp/service"
|
||||||
|
import { ChildSessionUpdatesCapability } from "../../src/acp/event"
|
||||||
|
|
||||||
describe("acp service", () => {
|
describe("acp service", () => {
|
||||||
test("creates a v2 session, registers mcp, and publishes commands", async () => {
|
test("creates a v2 session, registers mcp, and publishes commands", async () => {
|
||||||
@@ -39,11 +40,17 @@ describe("acp service", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const initialized = await service.initialize({
|
||||||
|
protocolVersion: 1,
|
||||||
|
clientCapabilities: { _meta: { [ChildSessionUpdatesCapability]: true } },
|
||||||
|
clientInfo: { name: "test", version: "1" },
|
||||||
|
})
|
||||||
const result = await service.newSession({
|
const result = await service.newSession({
|
||||||
cwd: "/workspace",
|
cwd: "/workspace",
|
||||||
mcpServers: [{ name: "docs", command: "bun", args: ["docs.ts"], env: [{ name: "TOKEN", value: "x" }] }],
|
mcpServers: [{ name: "docs", command: "bun", args: ["docs.ts"], env: [{ name: "TOKEN", value: "x" }] }],
|
||||||
})
|
})
|
||||||
expect(result.sessionId).toBe("ses_acp")
|
expect(result.sessionId).toBe("ses_acp")
|
||||||
|
expect(initialized.agentCapabilities?._meta).toEqual({ [ChildSessionUpdatesCapability]: true })
|
||||||
expect(result.configOptions?.map((option) => option.id)).toEqual(["model", "effort", "mode"])
|
expect(result.configOptions?.map((option) => option.id)).toEqual(["model", "effort", "mode"])
|
||||||
expect(requests).toContainEqual({
|
expect(requests).toContainEqual({
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
|
|||||||
@@ -420,6 +420,7 @@ export type Endpoint5_26Output =
|
|||||||
readonly data: {
|
readonly data: {
|
||||||
readonly sessionID: Session.ID
|
readonly sessionID: Session.ID
|
||||||
readonly delta: { readonly [x: string]: (string & Brand.Brand<"Instruction.Hash">) | "removed" }
|
readonly delta: { readonly [x: string]: (string & Brand.Brand<"Instruction.Hash">) | "removed" }
|
||||||
|
readonly text?: string | undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
|
|||||||
@@ -676,7 +676,7 @@ export type SessionInstructionsUpdated = {
|
|||||||
type: "session.instructions.updated"
|
type: "session.instructions.updated"
|
||||||
durable: { aggregateID: string; seq: number; version: 2 }
|
durable: { aggregateID: string; seq: number; version: 2 }
|
||||||
location?: LocationRef
|
location?: LocationRef
|
||||||
data: { sessionID: string; delta: { [x: string]: string | "removed" } }
|
data: { sessionID: string; delta: { [x: string]: string | "removed" }; text?: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionSynthetic = {
|
export type SessionSynthetic = {
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
export * as FileMutation from "./file-mutation"
|
export * as FileMutation from "./file-mutation"
|
||||||
|
|
||||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||||
import { Context, Effect, Layer, Schema } from "effect"
|
import { Context, Effect, Layer } from "effect"
|
||||||
import { dirname } from "path"
|
|
||||||
import { KeyedMutex } from "./effect/keyed-mutex"
|
import { KeyedMutex } from "./effect/keyed-mutex"
|
||||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||||
import { Bom } from "@opencode-ai/util/bom"
|
import { Bom } from "@opencode-ai/util/bom"
|
||||||
@@ -22,22 +21,6 @@ export interface TextWriteInput {
|
|||||||
readonly content: string
|
readonly content: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConditionalWriteInput extends WriteInput {
|
|
||||||
readonly expected: Uint8Array
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RemoveInput {
|
|
||||||
readonly target: Target
|
|
||||||
}
|
|
||||||
|
|
||||||
export class StaleContentError extends Schema.TaggedErrorClass<StaleContentError>()("FileMutation.StaleContentError", {
|
|
||||||
path: Schema.String,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export class TargetExistsError extends Schema.TaggedErrorClass<TargetExistsError>()("FileMutation.TargetExistsError", {
|
|
||||||
path: Schema.String,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export interface WriteResult {
|
export interface WriteResult {
|
||||||
readonly operation: "write"
|
readonly operation: "write"
|
||||||
readonly target: string
|
readonly target: string
|
||||||
@@ -45,24 +28,10 @@ export interface WriteResult {
|
|||||||
readonly existed: boolean
|
readonly existed: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RemoveResult {
|
|
||||||
readonly operation: "remove"
|
|
||||||
readonly target: string
|
|
||||||
readonly resource: string
|
|
||||||
readonly existed: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
/** Create without replacing an existing target. */
|
|
||||||
readonly create: (input: WriteInput) => Effect.Effect<WriteResult, TargetExistsError | FSUtil.Error>
|
|
||||||
readonly write: (input: WriteInput) => Effect.Effect<WriteResult, FSUtil.Error>
|
readonly write: (input: WriteInput) => Effect.Effect<WriteResult, FSUtil.Error>
|
||||||
/** Write text while retaining an existing UTF-8 BOM and emitting at most one BOM. */
|
/** Write text while retaining an existing UTF-8 BOM and emitting at most one BOM. */
|
||||||
readonly writeTextPreservingBom: (input: TextWriteInput) => Effect.Effect<WriteResult, FSUtil.Error>
|
readonly writeTextPreservingBom: (input: TextWriteInput) => Effect.Effect<WriteResult, FSUtil.Error>
|
||||||
/** Commit only if an existing target still has the expected bytes. */
|
|
||||||
readonly writeIfUnchanged: (
|
|
||||||
input: ConditionalWriteInput,
|
|
||||||
) => Effect.Effect<WriteResult, StaleContentError | FSUtil.Error>
|
|
||||||
readonly remove: (input: RemoveInput) => Effect.Effect<RemoveResult, FSUtil.Error>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/FileMutation") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/FileMutation") {}
|
||||||
@@ -89,13 +58,6 @@ const layer = Layer.effect(
|
|||||||
existed,
|
existed,
|
||||||
})
|
})
|
||||||
|
|
||||||
const removeResult = (target: Target, existed: boolean): RemoveResult => ({
|
|
||||||
operation: "remove",
|
|
||||||
target: target.canonical,
|
|
||||||
resource: target.resource,
|
|
||||||
existed,
|
|
||||||
})
|
|
||||||
|
|
||||||
const write = Effect.fn("FileMutation.write")((input: WriteInput) =>
|
const write = Effect.fn("FileMutation.write")((input: WriteInput) =>
|
||||||
withTargetLock(input.target)(
|
withTargetLock(input.target)(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -122,62 +84,10 @@ const layer = Layer.effect(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const create = Effect.fn("FileMutation.create")((input: WriteInput) =>
|
return Service.of({ write, writeTextPreservingBom })
|
||||||
withTargetLock(input.target)(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const write =
|
|
||||||
typeof input.content === "string"
|
|
||||||
? fs.writeFileString(input.target.canonical, input.content, { flag: "wx" })
|
|
||||||
: fs.writeFile(input.target.canonical, input.content, { flag: "wx" })
|
|
||||||
yield* write.pipe(
|
|
||||||
Effect.catchReason("PlatformError", "NotFound", () =>
|
|
||||||
fs.ensureDir(dirname(input.target.canonical)).pipe(Effect.andThen(write)),
|
|
||||||
),
|
|
||||||
Effect.catchReason("PlatformError", "AlreadyExists", () =>
|
|
||||||
Effect.fail(new TargetExistsError({ path: input.target.canonical })),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return writeResult(input.target, false)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const writeIfUnchanged = Effect.fn("FileMutation.writeIfUnchanged")((input: ConditionalWriteInput) =>
|
|
||||||
withTargetLock(input.target)(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const current = yield* fs.readFile(input.target.canonical)
|
|
||||||
if (!sameBytes(current, input.expected)) {
|
|
||||||
return yield* new StaleContentError({ path: input.target.canonical })
|
|
||||||
}
|
|
||||||
yield* typeof input.content === "string"
|
|
||||||
? fs.writeFileString(input.target.canonical, input.content)
|
|
||||||
: fs.writeFile(input.target.canonical, input.content)
|
|
||||||
return writeResult(input.target, true)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const remove = Effect.fn("FileMutation.remove")((input: RemoveInput) =>
|
|
||||||
withTargetLock(input.target)(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const existed = yield* fs.remove(input.target.canonical).pipe(
|
|
||||||
Effect.as(true),
|
|
||||||
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(false)),
|
|
||||||
)
|
|
||||||
return removeResult(input.target, existed)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
return Service.of({ create, write, writeTextPreservingBom, writeIfUnchanged, remove })
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
function sameBytes(left: Uint8Array, right: Uint8Array) {
|
|
||||||
if (left.length !== right.length) return false
|
|
||||||
return left.every((byte, index) => byte === right[index])
|
|
||||||
}
|
|
||||||
|
|
||||||
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node] })
|
export const node = makeLocationNode({ service: Service, layer, deps: [FSUtil.node] })
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -31,10 +31,7 @@ export const ripgrepLayer = Layer.effect(
|
|||||||
const location = yield* Location.Service
|
const location = yield* Location.Service
|
||||||
const ripgrep = yield* Ripgrep.Service
|
const ripgrep = yield* Ripgrep.Service
|
||||||
const scope = yield* Scope.Scope
|
const scope = yield* Scope.Scope
|
||||||
const state = {
|
const files: string[] = []
|
||||||
files: [] as string[],
|
|
||||||
directories: [] as string[],
|
|
||||||
}
|
|
||||||
const directories = new Set<string>()
|
const directories = new Set<string>()
|
||||||
yield* ripgrep
|
yield* ripgrep
|
||||||
.find({
|
.find({
|
||||||
@@ -43,10 +40,9 @@ export const ripgrepLayer = Layer.effect(
|
|||||||
limit: location.vcs ? Number.MAX_SAFE_INTEGER : 100_000,
|
limit: location.vcs ? Number.MAX_SAFE_INTEGER : 100_000,
|
||||||
onEntry: (entry) =>
|
onEntry: (entry) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
state.files.push(entry.path)
|
files.push(entry.path)
|
||||||
const parts = entry.path.split("/")
|
const parts = entry.path.split("/")
|
||||||
parts.slice(0, -1).forEach((_, index) => directories.add(parts.slice(0, index + 1).join("/") + path.sep))
|
parts.slice(0, -1).forEach((_, index) => directories.add(parts.slice(0, index + 1).join("/") + path.sep))
|
||||||
state.directories = Array.from(directories)
|
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
.pipe(Effect.orDie, Effect.asVoid, Effect.forkIn(scope))
|
.pipe(Effect.orDie, Effect.asVoid, Effect.forkIn(scope))
|
||||||
@@ -106,10 +102,10 @@ export const ripgrepLayer = Layer.effect(
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const items =
|
const items =
|
||||||
input.type === "file"
|
input.type === "file"
|
||||||
? state.files
|
? files
|
||||||
: input.type === "directory"
|
: input.type === "directory"
|
||||||
? state.directories
|
? Array.from(directories)
|
||||||
: [...state.files, ...state.directories]
|
: [...files, ...directories]
|
||||||
return fuzzysort.go(input.query, items, { limit: input.limit ?? 50 }).map((item) => {
|
return fuzzysort.go(input.query, items, { limit: input.limit ?? 50 }).map((item) => {
|
||||||
const relative = item.target
|
const relative = item.target
|
||||||
const type = relative.endsWith(path.sep) ? ("directory" as const) : ("file" as const)
|
const type = relative.endsWith(path.sep) ? ("directory" as const) : ("file" as const)
|
||||||
|
|||||||
@@ -410,7 +410,7 @@ const layer = Layer.effect(
|
|||||||
fork: Effect.fn("Session.fork")(function* (input) {
|
fork: Effect.fn("Session.fork")(function* (input) {
|
||||||
const parent = yield* result.get(input.sessionID)
|
const parent = yield* result.get(input.sessionID)
|
||||||
const boundary = yield* db
|
const boundary = yield* db
|
||||||
.select({ id: SessionMessageTable.id, seq: SessionMessageTable.seq })
|
.select({ id: SessionMessageTable.id })
|
||||||
.from(SessionMessageTable)
|
.from(SessionMessageTable)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
@@ -429,13 +429,14 @@ const layer = Layer.effect(
|
|||||||
})
|
})
|
||||||
if (!boundary) return yield* new ForkEmptyError({ sessionID: input.sessionID })
|
if (!boundary) return yield* new ForkEmptyError({ sessionID: input.sessionID })
|
||||||
const sessionID = SessionSchema.ID.create()
|
const sessionID = SessionSchema.ID.create()
|
||||||
const instructionThrough =
|
// The fork adopts the parent's newest instruction values rather than the
|
||||||
input.boundary.type === "before" ? boundary.seq - 1 : yield* Bus.latestSequence(db, parent.id)
|
// values in effect at the boundary; copied history may contain frozen
|
||||||
|
// instruction-update text the initial baseline already reflects.
|
||||||
yield* bus.publish(SessionEvent.Forked, {
|
yield* bus.publish(SessionEvent.Forked, {
|
||||||
sessionID,
|
sessionID,
|
||||||
parentID: parent.id,
|
parentID: parent.id,
|
||||||
boundary: { ...input.boundary, messageID: boundary.id },
|
boundary: { ...input.boundary, messageID: boundary.id },
|
||||||
instructions: yield* InstructionState.valuesAt(db, parent.id, instructionThrough),
|
instructions: yield* InstructionState.current(db, parent.id),
|
||||||
})
|
})
|
||||||
return yield* result.get(sessionID).pipe(Effect.orDie)
|
return yield* result.get(sessionID).pipe(Effect.orDie)
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -80,10 +80,9 @@ export const entriesForRunner = Effect.fn("SessionHistory.entriesForRunner")(fun
|
|||||||
.transaction(() =>
|
.transaction(() =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const messages = yield* messageEntries(db, sessionID)
|
const messages = yield* messageEntries(db, sessionID)
|
||||||
const assembled = yield* InstructionState.assemble(db, sessionID, instructions)
|
|
||||||
return {
|
return {
|
||||||
initial: assembled.initial,
|
initial: yield* InstructionState.initial(db, sessionID, instructions),
|
||||||
entries: [...messages, ...assembled.updates].toSorted((a, b) => a.seq - b.seq),
|
entries: messages,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -106,10 +105,9 @@ export const preview = Effect.fn("SessionHistory.preview")(function* (
|
|||||||
)
|
)
|
||||||
const settled = unsettled === -1 ? messages : messages.slice(0, unsettled)
|
const settled = unsettled === -1 ? messages : messages.slice(0, unsettled)
|
||||||
const assembled = yield* InstructionState.preview(db, sessionID, instructions, observed)
|
const assembled = yield* InstructionState.preview(db, sessionID, instructions, observed)
|
||||||
const entries = [...settled, ...assembled.updates].toSorted((a, b) => a.seq - b.seq)
|
|
||||||
return {
|
return {
|
||||||
initial: assembled.initial,
|
initial: assembled.initial,
|
||||||
messages: entries.map((entry) => entry.message),
|
messages: settled.map((entry) => entry.message),
|
||||||
instructionUpdate: assembled.update,
|
instructionUpdate: assembled.update,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,25 +1,20 @@
|
|||||||
export * as InstructionState from "./instruction-state"
|
export * as InstructionState from "./instruction-state"
|
||||||
|
|
||||||
import { and, asc, desc, eq, gt, inArray, lte, sql } from "drizzle-orm"
|
import { eq, inArray, sql } from "drizzle-orm"
|
||||||
import { DateTime, Effect, Option, Schema } from "effect"
|
import { Effect, Option, Schema } from "effect"
|
||||||
import type { Database } from "../database/database"
|
import type { Database } from "../database/database"
|
||||||
import { Bus } from "../bus"
|
import type { Bus } from "../bus"
|
||||||
import { EventTable } from "../event/sql"
|
|
||||||
import { Instructions } from "../instructions/index"
|
import { Instructions } from "../instructions/index"
|
||||||
import { SessionEvent } from "./event"
|
import { SessionEvent } from "./event"
|
||||||
import { SessionMessage } from "./message"
|
|
||||||
import { Event } from "@opencode-ai/schema/event"
|
|
||||||
import { SessionSchema } from "./schema"
|
import { SessionSchema } from "./schema"
|
||||||
import { InstructionBlobTable, InstructionStateTable } from "./sql"
|
import { InstructionBlobTable, InstructionStateTable } from "./sql"
|
||||||
|
|
||||||
type DatabaseService = Database.Interface["db"]
|
type DatabaseService = Database.Interface["db"]
|
||||||
|
|
||||||
const decodeInstructionsUpdated = Schema.decodeUnknownSync(SessionEvent.InstructionsUpdated.data)
|
|
||||||
const decodeForked = Schema.decodeUnknownSync(SessionEvent.Forked.data)
|
|
||||||
|
|
||||||
export interface Observation extends Instructions.Admission {
|
export interface Observation extends Instructions.Admission {
|
||||||
readonly sessionID: SessionSchema.ID
|
readonly sessionID: SessionSchema.ID
|
||||||
readonly initial: boolean
|
readonly initial: boolean
|
||||||
|
readonly previous: Instructions.Values
|
||||||
readonly current: Instructions.Values
|
readonly current: Instructions.Values
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,13 +23,14 @@ export const observe = Effect.fn("InstructionState.observe")(function* (
|
|||||||
instructions: Instructions.Instructions,
|
instructions: Instructions.Instructions,
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
): Effect.fn.Return<Observation, Instructions.InitializationBlocked> {
|
): Effect.fn.Return<Observation, Instructions.InitializationBlocked> {
|
||||||
const [observed, stored] = yield* Effect.all([Instructions.read(instructions), ensure(db, sessionID)], {
|
const [observed, stored] = yield* Effect.all([Instructions.read(instructions), find(db, sessionID)], {
|
||||||
concurrency: "unbounded",
|
concurrency: "unbounded",
|
||||||
})
|
})
|
||||||
const result = yield* observeAgainst(observed, stored?.current_values)
|
const result = yield* observeAgainst(observed, stored?.current_values)
|
||||||
return {
|
return {
|
||||||
sessionID,
|
sessionID,
|
||||||
initial: !stored,
|
initial: !stored,
|
||||||
|
previous: stored?.current_values ?? {},
|
||||||
...result,
|
...result,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -42,12 +38,20 @@ export const observe = Effect.fn("InstructionState.observe")(function* (
|
|||||||
export const commit = Effect.fn("InstructionState.commit")(function* (
|
export const commit = Effect.fn("InstructionState.commit")(function* (
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
bus: Bus.Interface,
|
bus: Bus.Interface,
|
||||||
|
instructions: Instructions.Instructions,
|
||||||
observation: Observation,
|
observation: Observation,
|
||||||
) {
|
) {
|
||||||
if (!observation.initial && Object.keys(observation.delta).length === 0) return
|
if (!observation.initial && Object.keys(observation.delta).length === 0) return
|
||||||
|
// The rendered text is frozen into the durable event: replaying it later would
|
||||||
|
// require the Location-scoped registry that produced it.
|
||||||
|
const text = observation.initial ? "" : yield* renderUpdateText(db, instructions, observation)
|
||||||
yield* bus.publish(
|
yield* bus.publish(
|
||||||
SessionEvent.InstructionsUpdated,
|
SessionEvent.InstructionsUpdated,
|
||||||
{ sessionID: observation.sessionID, delta: observation.delta },
|
{
|
||||||
|
sessionID: observation.sessionID,
|
||||||
|
delta: observation.delta,
|
||||||
|
...(text.length > 0 ? { text } : {}),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
// Initial sync establishes the baseline; unlike later deltas it is not chronological history.
|
// Initial sync establishes the baseline; unlike later deltas it is not chronological history.
|
||||||
...(observation.initial ? { metadata: { instructions: { initial: true } } } : {}),
|
...(observation.initial ? { metadata: { instructions: { initial: true } } } : {}),
|
||||||
@@ -56,13 +60,27 @@ export const commit = Effect.fn("InstructionState.commit")(function* (
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const renderUpdateText = Effect.fnUntraced(function* (
|
||||||
|
db: DatabaseService,
|
||||||
|
instructions: Instructions.Instructions,
|
||||||
|
observation: Observation,
|
||||||
|
) {
|
||||||
|
const replaced = Object.entries(observation.previous).filter(([key]) => Object.hasOwn(observation.delta, key))
|
||||||
|
const blobs = yield* loadBlobs(db, replaced.map(([, hash]) => hash))
|
||||||
|
const previous = Object.fromEntries(replaced.map(([key, hash]) => [key, requireBlob(blobs, hash)]))
|
||||||
|
const admitted = new Map(
|
||||||
|
Object.entries(observation.blobs).map(([hash, value]) => [Instructions.Hash.make(hash), value]),
|
||||||
|
)
|
||||||
|
return Instructions.renderUpdate(instructions, previous, dereferenceDelta(observation.delta, admitted))
|
||||||
|
})
|
||||||
|
|
||||||
export const prepare = Effect.fn("InstructionState.prepare")(function* (
|
export const prepare = Effect.fn("InstructionState.prepare")(function* (
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
bus: Bus.Interface,
|
bus: Bus.Interface,
|
||||||
instructions: Instructions.Instructions,
|
instructions: Instructions.Instructions,
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
) {
|
) {
|
||||||
yield* commit(db, bus, yield* observe(db, instructions, sessionID))
|
yield* commit(db, bus, instructions, yield* observe(db, instructions, sessionID))
|
||||||
})
|
})
|
||||||
|
|
||||||
export const apply = Effect.fn("InstructionState.apply")(function* (
|
export const apply = Effect.fn("InstructionState.apply")(function* (
|
||||||
@@ -140,79 +158,24 @@ export const reset = Effect.fn("InstructionState.reset")(function* (db: Database
|
|||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
})
|
})
|
||||||
|
|
||||||
export const rebuild = Effect.fn("InstructionState.rebuild")(function* (
|
/** Renders the epoch baseline shown at the start of every model request. */
|
||||||
db: DatabaseService,
|
export const initial = Effect.fn("InstructionState.initial")(function* (
|
||||||
sessionID: SessionSchema.ID,
|
|
||||||
) {
|
|
||||||
const state = yield* stateFromEvents(db, sessionID)
|
|
||||||
if (!state) {
|
|
||||||
yield* reset(db, sessionID)
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
yield* db
|
|
||||||
.insert(InstructionStateTable)
|
|
||||||
.values(state)
|
|
||||||
.onConflictDoUpdate({
|
|
||||||
target: InstructionStateTable.session_id,
|
|
||||||
set: {
|
|
||||||
epoch_start: state.epoch_start,
|
|
||||||
through_seq: state.through_seq,
|
|
||||||
initial_values: state.initial_values,
|
|
||||||
current_values: state.current_values,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.run()
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
return state
|
|
||||||
})
|
|
||||||
|
|
||||||
const assembleState = Effect.fnUntraced(function* (
|
|
||||||
db: DatabaseService,
|
|
||||||
sessionID: SessionSchema.ID,
|
|
||||||
instructions: Instructions.Instructions,
|
|
||||||
state: typeof InstructionStateTable.$inferSelect,
|
|
||||||
) {
|
|
||||||
const rows = yield* instructionUpdatesAfter(db, sessionID, state.epoch_start)
|
|
||||||
const updates = rows.map((row) => ({
|
|
||||||
row,
|
|
||||||
delta: decodeInstructionsUpdated(row.data).delta,
|
|
||||||
}))
|
|
||||||
const blobs = yield* loadBlobs(db, [
|
|
||||||
...Object.values(state.initial_values),
|
|
||||||
...updates.flatMap((update) =>
|
|
||||||
Object.values(update.delta).filter((hash): hash is Instructions.Hash => hash !== "removed"),
|
|
||||||
),
|
|
||||||
])
|
|
||||||
const valuesAtStart = dereference(state.initial_values, blobs)
|
|
||||||
let values = valuesAtStart
|
|
||||||
const result: Array<{ readonly seq: number; readonly message: SessionMessage.System }> = []
|
|
||||||
for (const update of updates) {
|
|
||||||
const delta = dereferenceDelta(update.delta, blobs)
|
|
||||||
const text = Instructions.renderUpdate(instructions, values, delta)
|
|
||||||
if (text.length > 0)
|
|
||||||
result.push({
|
|
||||||
seq: update.row.seq,
|
|
||||||
message: SessionMessage.System.make({
|
|
||||||
id: SessionMessage.ID.fromEvent(Event.ID.make(update.row.id)),
|
|
||||||
type: "system",
|
|
||||||
text,
|
|
||||||
time: { created: DateTime.makeUnsafe(update.row.created) },
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
values = Instructions.applyDelta(values, delta)
|
|
||||||
}
|
|
||||||
return { initial: Instructions.renderInitial(instructions, valuesAtStart), updates: result, current: values }
|
|
||||||
})
|
|
||||||
|
|
||||||
export const assemble = Effect.fn("InstructionState.assemble")(function* (
|
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
instructions: Instructions.Instructions,
|
instructions: Instructions.Instructions,
|
||||||
) {
|
) {
|
||||||
const state = yield* find(db, sessionID)
|
const state = yield* find(db, sessionID)
|
||||||
if (!state) return yield* Effect.die(new Error(`Instruction state not found during assembly: ${sessionID}`))
|
if (!state) return yield* Effect.die(new Error(`Instruction state not found during assembly: ${sessionID}`))
|
||||||
const assembled = yield* assembleState(db, sessionID, instructions, state)
|
const blobs = yield* loadBlobs(db, Object.values(state.initial_values))
|
||||||
return { initial: assembled.initial, updates: assembled.updates }
|
return Instructions.renderInitial(instructions, dereference(state.initial_values, blobs))
|
||||||
|
})
|
||||||
|
|
||||||
|
/** The current instruction values, used to seed a fork's baseline. */
|
||||||
|
export const current = Effect.fn("InstructionState.current")(function* (
|
||||||
|
db: DatabaseService,
|
||||||
|
sessionID: SessionSchema.ID,
|
||||||
|
) {
|
||||||
|
return (yield* find(db, sessionID))?.current_values
|
||||||
})
|
})
|
||||||
|
|
||||||
export const preview = Effect.fn("InstructionState.preview")(function* (
|
export const preview = Effect.fn("InstructionState.preview")(function* (
|
||||||
@@ -221,20 +184,26 @@ export const preview = Effect.fn("InstructionState.preview")(function* (
|
|||||||
instructions: Instructions.Instructions,
|
instructions: Instructions.Instructions,
|
||||||
observed: Instructions.ReadResult,
|
observed: Instructions.ReadResult,
|
||||||
) {
|
) {
|
||||||
const state = yield* readState(db, sessionID)
|
const state = yield* find(db, sessionID)
|
||||||
const result = yield* observeAgainst(observed, state?.current_values)
|
const result = yield* observeAgainst(observed, state?.current_values)
|
||||||
const blobs = new Map<Instructions.Hash, Schema.Json>(
|
const observedBlobs = new Map<Instructions.Hash, Schema.Json>(
|
||||||
Object.entries(result.blobs).map(([hash, value]) => [Instructions.Hash.make(hash), value]),
|
Object.entries(result.blobs).map(([hash, value]) => [Instructions.Hash.make(hash), value]),
|
||||||
)
|
)
|
||||||
if (!state) {
|
if (!state) {
|
||||||
const values = dereference(result.current, blobs)
|
const values = dereference(result.current, observedBlobs)
|
||||||
return { initial: Instructions.renderInitial(instructions, values), updates: [], update: "" }
|
return { initial: Instructions.renderInitial(instructions, values), update: "" }
|
||||||
}
|
}
|
||||||
const assembled = yield* assembleState(db, sessionID, instructions, state)
|
const stored = yield* loadBlobs(db, [
|
||||||
|
...Object.values(state.initial_values),
|
||||||
|
...Object.values(state.current_values),
|
||||||
|
])
|
||||||
return {
|
return {
|
||||||
initial: assembled.initial,
|
initial: Instructions.renderInitial(instructions, dereference(state.initial_values, stored)),
|
||||||
updates: assembled.updates,
|
update: Instructions.renderUpdate(
|
||||||
update: Instructions.renderUpdate(instructions, assembled.current, dereferenceDelta(result.delta, blobs)),
|
instructions,
|
||||||
|
dereference(state.current_values, stored),
|
||||||
|
dereferenceDelta(result.delta, new Map([...stored, ...observedBlobs])),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -255,46 +224,6 @@ const find = Effect.fnUntraced(function* (db: DatabaseService, sessionID: Sessio
|
|||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
})
|
})
|
||||||
|
|
||||||
const ensure = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
|
||||||
const stored = yield* find(db, sessionID)
|
|
||||||
if (!stored) return yield* rebuild(db, sessionID)
|
|
||||||
const latest = yield* latestRelevantSequence(db, sessionID)
|
|
||||||
if (!latest || latest.seq <= stored.through_seq) return stored
|
|
||||||
return yield* rebuild(db, sessionID)
|
|
||||||
})
|
|
||||||
|
|
||||||
const readState = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
|
||||||
const stored = yield* find(db, sessionID)
|
|
||||||
if (!stored) return yield* stateFromEvents(db, sessionID)
|
|
||||||
const latest = yield* latestRelevantSequence(db, sessionID)
|
|
||||||
if (!latest || latest.seq <= stored.through_seq) return stored
|
|
||||||
return yield* stateFromEvents(db, sessionID)
|
|
||||||
})
|
|
||||||
|
|
||||||
const stateFromEvents = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
|
||||||
const folded = fold(yield* instructionEvents(db, sessionID))
|
|
||||||
return folded ? foldedState(sessionID, folded) : undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
export const valuesAt = Effect.fn("InstructionState.valuesAt")(function* (
|
|
||||||
db: DatabaseService,
|
|
||||||
sessionID: SessionSchema.ID,
|
|
||||||
through: number,
|
|
||||||
) {
|
|
||||||
return fold(yield* instructionEvents(db, sessionID, through))?.current
|
|
||||||
})
|
|
||||||
|
|
||||||
const latestRelevantSequence = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
|
||||||
return yield* db
|
|
||||||
.select({ seq: EventTable.seq })
|
|
||||||
.from(EventTable)
|
|
||||||
.where(and(eq(EventTable.aggregate_id, sessionID), inArray(EventTable.type, relevantEventTypes)))
|
|
||||||
.orderBy(desc(EventTable.seq))
|
|
||||||
.limit(1)
|
|
||||||
.get()
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
})
|
|
||||||
|
|
||||||
const insertBlobs = Effect.fnUntraced(function* (db: DatabaseService, blobs: Readonly<Record<string, Schema.Json>>) {
|
const insertBlobs = Effect.fnUntraced(function* (db: DatabaseService, blobs: Readonly<Record<string, Schema.Json>>) {
|
||||||
const rows = Object.entries(blobs).map(([hash, value]) => ({ hash: Instructions.Hash.make(hash), value }))
|
const rows = Object.entries(blobs).map(([hash, value]) => ({ hash: Instructions.Hash.make(hash), value }))
|
||||||
if (rows.length === 0) return
|
if (rows.length === 0) return
|
||||||
@@ -339,106 +268,3 @@ function requireBlob(blobs: ReadonlyMap<Instructions.Hash, Schema.Json>, hash: I
|
|||||||
if (value === undefined) throw new Error(`Instruction blob not found: ${hash}`)
|
if (value === undefined) throw new Error(`Instruction blob not found: ${hash}`)
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
const instructionEventType = Bus.versionedType(
|
|
||||||
SessionEvent.InstructionsUpdated.type,
|
|
||||||
SessionEvent.InstructionsUpdated.durable.version,
|
|
||||||
)
|
|
||||||
const compactionEventType = Bus.versionedType(
|
|
||||||
SessionEvent.Compaction.Ended.type,
|
|
||||||
SessionEvent.Compaction.Ended.durable.version,
|
|
||||||
)
|
|
||||||
const movedEventType = Bus.versionedType(SessionEvent.Moved.type, SessionEvent.Moved.durable.version)
|
|
||||||
const revertedEventType = Bus.versionedType(
|
|
||||||
SessionEvent.RevertEvent.Committed.type,
|
|
||||||
SessionEvent.RevertEvent.Committed.durable.version,
|
|
||||||
)
|
|
||||||
const forkedEventType = Bus.versionedType(SessionEvent.Forked.type, SessionEvent.Forked.durable.version)
|
|
||||||
const relevantEventTypes = [
|
|
||||||
forkedEventType,
|
|
||||||
instructionEventType,
|
|
||||||
compactionEventType,
|
|
||||||
movedEventType,
|
|
||||||
revertedEventType,
|
|
||||||
]
|
|
||||||
|
|
||||||
type InstructionEventRow = typeof EventTable.$inferSelect
|
|
||||||
|
|
||||||
const instructionEvents = Effect.fnUntraced(function* (
|
|
||||||
db: DatabaseService,
|
|
||||||
sessionID: SessionSchema.ID,
|
|
||||||
through?: number,
|
|
||||||
): Effect.fn.Return<ReadonlyArray<InstructionEventRow>> {
|
|
||||||
return yield* eventRows(db, sessionID, relevantEventTypes, undefined, through)
|
|
||||||
})
|
|
||||||
|
|
||||||
const instructionUpdatesAfter = Effect.fnUntraced(function* (
|
|
||||||
db: DatabaseService,
|
|
||||||
sessionID: SessionSchema.ID,
|
|
||||||
after: number,
|
|
||||||
) {
|
|
||||||
return yield* eventRows(db, sessionID, [instructionEventType], after)
|
|
||||||
})
|
|
||||||
|
|
||||||
const eventRows = Effect.fnUntraced(function* (
|
|
||||||
db: DatabaseService,
|
|
||||||
sessionID: SessionSchema.ID,
|
|
||||||
types: ReadonlyArray<string>,
|
|
||||||
after?: number,
|
|
||||||
through?: number,
|
|
||||||
): Effect.fn.Return<ReadonlyArray<InstructionEventRow>> {
|
|
||||||
return yield* db
|
|
||||||
.select()
|
|
||||||
.from(EventTable)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(EventTable.aggregate_id, sessionID),
|
|
||||||
inArray(EventTable.type, types),
|
|
||||||
after === undefined ? undefined : gt(EventTable.seq, after),
|
|
||||||
through === undefined ? undefined : lte(EventTable.seq, through),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.orderBy(asc(EventTable.seq))
|
|
||||||
.all()
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
})
|
|
||||||
|
|
||||||
function fold(rows: ReadonlyArray<InstructionEventRow>) {
|
|
||||||
return rows.reduce<
|
|
||||||
| {
|
|
||||||
readonly epochStart: number
|
|
||||||
readonly throughSeq: number
|
|
||||||
readonly initial: Instructions.Values
|
|
||||||
readonly current: Instructions.Values
|
|
||||||
}
|
|
||||||
| undefined
|
|
||||||
>((state, row) => {
|
|
||||||
if (row.type === forkedEventType) {
|
|
||||||
const instructions = decodeForked(row.data).instructions
|
|
||||||
return instructions
|
|
||||||
? { epochStart: row.seq, throughSeq: row.seq, initial: instructions, current: instructions }
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
if (row.type === movedEventType || row.type === revertedEventType) return undefined
|
|
||||||
if (row.type === compactionEventType)
|
|
||||||
return state
|
|
||||||
? { epochStart: row.seq, throughSeq: row.seq, initial: state.current, current: state.current }
|
|
||||||
: undefined
|
|
||||||
if (row.type !== instructionEventType) return state
|
|
||||||
const delta = decodeInstructionsUpdated(row.data).delta
|
|
||||||
const current = Instructions.applyHashDelta(state?.current ?? {}, delta)
|
|
||||||
return state
|
|
||||||
? { ...state, throughSeq: row.seq, current }
|
|
||||||
: { epochStart: row.seq, throughSeq: row.seq, initial: current, current }
|
|
||||||
}, undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
function foldedState(sessionID: SessionSchema.ID, folded: NonNullable<ReturnType<typeof fold>>) {
|
|
||||||
return {
|
|
||||||
session_id: sessionID,
|
|
||||||
epoch_start: folded.epochStart,
|
|
||||||
through_seq: folded.throughSeq,
|
|
||||||
initial_values: folded.initial,
|
|
||||||
current_values: folded.current,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -179,7 +179,18 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||||||
"session.execution.succeeded": () => clearCurrentRetry,
|
"session.execution.succeeded": () => clearCurrentRetry,
|
||||||
"session.execution.failed": () => clearCurrentRetry,
|
"session.execution.failed": () => clearCurrentRetry,
|
||||||
"session.execution.interrupted": () => clearCurrentRetry,
|
"session.execution.interrupted": () => clearCurrentRetry,
|
||||||
"session.instructions.updated": () => Effect.void,
|
"session.instructions.updated": (event) => {
|
||||||
|
if (event.data.text === undefined) return Effect.void
|
||||||
|
return adapter.appendMessage(
|
||||||
|
SessionMessage.System.make({
|
||||||
|
id: SessionMessage.ID.fromEvent(event.id),
|
||||||
|
type: "system",
|
||||||
|
text: event.data.text,
|
||||||
|
metadata: event.metadata,
|
||||||
|
time: { created: event.created },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
"session.synthetic": (event) => {
|
"session.synthetic": (event) => {
|
||||||
return adapter.appendMessage(
|
return adapter.appendMessage(
|
||||||
SessionMessage.Synthetic.make({
|
SessionMessage.Synthetic.make({
|
||||||
|
|||||||
@@ -12,10 +12,8 @@ import {
|
|||||||
User,
|
User,
|
||||||
UserData,
|
UserData,
|
||||||
} from "@opencode-ai/schema/session-pending"
|
} from "@opencode-ai/schema/session-pending"
|
||||||
import { Event } from "@opencode-ai/schema/event"
|
|
||||||
import type { Database } from "../database/database"
|
import type { Database } from "../database/database"
|
||||||
import { Bus } from "../bus"
|
import { Bus } from "../bus"
|
||||||
import { EventTable } from "../event/sql"
|
|
||||||
import { KeyedMutex } from "../effect/keyed-mutex"
|
import { KeyedMutex } from "../effect/keyed-mutex"
|
||||||
import { SessionEvent } from "./event"
|
import { SessionEvent } from "./event"
|
||||||
import { SessionMessage } from "./message"
|
import { SessionMessage } from "./message"
|
||||||
@@ -37,11 +35,7 @@ const decodeUser = Schema.decodeUnknownSync(UserData)
|
|||||||
const encodeUser = Schema.encodeSync(UserData)
|
const encodeUser = Schema.encodeSync(UserData)
|
||||||
const decodeSynthetic = Schema.decodeUnknownSync(SyntheticData)
|
const decodeSynthetic = Schema.decodeUnknownSync(SyntheticData)
|
||||||
const encodeSynthetic = Schema.encodeSync(SyntheticData)
|
const encodeSynthetic = Schema.encodeSync(SyntheticData)
|
||||||
const decodeAdmittedEvent = Schema.decodeUnknownOption(SessionEvent.InputAdmitted.data)
|
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
|
||||||
const admittedEventType = Bus.versionedType(
|
|
||||||
SessionEvent.InputAdmitted.type,
|
|
||||||
SessionEvent.InputAdmitted.durable.version,
|
|
||||||
)
|
|
||||||
const inboxLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
|
const inboxLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
|
||||||
|
|
||||||
export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict>()(
|
export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict>()(
|
||||||
@@ -103,46 +97,35 @@ export const compaction = Effect.fn("SessionPending.compaction")(function* (
|
|||||||
return entry.type === "compaction" ? entry : undefined
|
return entry.type === "compaction" ? entry : undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
const promotedFromMessage = Effect.fn("SessionPending.promotedFromMessage")(function* (
|
||||||
* Reconstruct the admitted record for a pending row that was already consumed
|
|
||||||
* by promotion. The projected `session_message` row proves promotion happened;
|
|
||||||
* the durable `session.input.admitted` event retains the exact admitted
|
|
||||||
* message, including delivery.
|
|
||||||
*/
|
|
||||||
const promotedFromHistory = Effect.fn("SessionPending.promotedFromHistory")(function* (
|
|
||||||
db: DatabaseService,
|
db: DatabaseService,
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
id: SessionMessage.ID,
|
id: SessionMessage.ID,
|
||||||
|
delivery: Delivery,
|
||||||
) {
|
) {
|
||||||
const message = yield* db
|
const row = yield* db
|
||||||
.select()
|
.select()
|
||||||
.from(SessionMessageTable)
|
.from(SessionMessageTable)
|
||||||
.where(eq(SessionMessageTable.id, id))
|
.where(eq(SessionMessageTable.id, id))
|
||||||
.get()
|
.get()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
if (message === undefined) return undefined
|
if (row === undefined) return undefined
|
||||||
if (message.session_id !== sessionID || (message.type !== "user" && message.type !== "synthetic"))
|
if (row.session_id !== sessionID || (row.type !== "user" && row.type !== "synthetic"))
|
||||||
return yield* Effect.die(new LifecycleConflict({ id }))
|
return yield* Effect.die(new LifecycleConflict({ id }))
|
||||||
const rows = yield* db
|
const message = decodeMessage({ ...row.data, id: row.id, type: row.type })
|
||||||
.select()
|
const base = { id, sessionID, timeCreated: message.time.created, delivery }
|
||||||
.from(EventTable)
|
if (message.type === "user")
|
||||||
.where(and(eq(EventTable.aggregate_id, sessionID), eq(EventTable.type, admittedEventType)))
|
return User.make({
|
||||||
.all()
|
...base,
|
||||||
.pipe(Effect.orDie)
|
type: "user",
|
||||||
for (const row of rows) {
|
data: decodeUser(message),
|
||||||
const decoded = decodeAdmittedEvent(row.data)
|
})
|
||||||
if (decoded._tag !== "Some" || decoded.value.inputID !== id) continue
|
if (message.type === "synthetic")
|
||||||
const base = {
|
return Synthetic.make({
|
||||||
id,
|
...base,
|
||||||
sessionID,
|
type: "synthetic",
|
||||||
timeCreated: DateTime.makeUnsafe(row.created),
|
data: decodeSynthetic(message),
|
||||||
}
|
})
|
||||||
return decoded.value.input.type === "user"
|
|
||||||
? User.make({ ...base, ...decoded.value.input })
|
|
||||||
: Synthetic.make({ ...base, ...decoded.value.input })
|
|
||||||
}
|
|
||||||
// A projected message without an admitted event in this aggregate (for
|
|
||||||
// example fork-copied history) is not a retryable admission.
|
|
||||||
return yield* Effect.die(new LifecycleConflict({ id }))
|
return yield* Effect.die(new LifecycleConflict({ id }))
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -160,7 +143,7 @@ export const admit = Effect.fn("SessionPending.admit")(function* (
|
|||||||
if (existing.type === "compaction") return yield* Effect.die(new LifecycleConflict({ id: request.id }))
|
if (existing.type === "compaction") return yield* Effect.die(new LifecycleConflict({ id: request.id }))
|
||||||
return existing
|
return existing
|
||||||
}
|
}
|
||||||
const promoted = yield* promotedFromHistory(db, request.sessionID, request.id)
|
const promoted = yield* promotedFromMessage(db, request.sessionID, request.id, request.input.delivery)
|
||||||
if (promoted !== undefined) return promoted
|
if (promoted !== undefined) return promoted
|
||||||
return yield* bus
|
return yield* bus
|
||||||
.publish(SessionEvent.InputAdmitted, {
|
.publish(SessionEvent.InputAdmitted, {
|
||||||
@@ -426,7 +409,7 @@ const publish = Effect.fn("SessionPending.publish")(function* (
|
|||||||
.pipe(
|
.pipe(
|
||||||
Effect.catchDefect((defect) =>
|
Effect.catchDefect((defect) =>
|
||||||
defect instanceof LifecycleConflict
|
defect instanceof LifecycleConflict
|
||||||
? promotedFromHistory(db, sessionID, entry.id).pipe(
|
? promotedFromMessage(db, sessionID, entry.id, entry.delivery).pipe(
|
||||||
Effect.flatMap((stored) => (stored !== undefined ? Effect.void : Effect.die(defect))),
|
Effect.flatMap((stored) => (stored !== undefined ? Effect.void : Effect.die(defect))),
|
||||||
)
|
)
|
||||||
: Effect.die(defect),
|
: Effect.die(defect),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export * as SessionProjector from "./projector"
|
export * as SessionProjector from "./projector"
|
||||||
|
|
||||||
import { and, asc, desc, eq, gt, gte, inArray, lt, lte, sql } from "drizzle-orm"
|
import { and, asc, desc, eq, gt, gte, lt, lte, sql } from "drizzle-orm"
|
||||||
import { DateTime, Effect, Layer, Schema, Stream } from "effect"
|
import { DateTime, Effect, Layer, Schema, Stream } from "effect"
|
||||||
import { Database } from "../database/database"
|
import { Database } from "../database/database"
|
||||||
import { Bus } from "../bus"
|
import { Bus } from "../bus"
|
||||||
@@ -21,10 +21,7 @@ import { Money } from "@opencode-ai/schema/money"
|
|||||||
|
|
||||||
type DatabaseService = Database.Interface["db"]
|
type DatabaseService = Database.Interface["db"]
|
||||||
type CurrentDurableEvent = Extract<SessionEvent.Event, { readonly durable: object }>
|
type CurrentDurableEvent = Extract<SessionEvent.Event, { readonly durable: object }>
|
||||||
type MessageEvent = Exclude<
|
type MessageEvent = Exclude<CurrentDurableEvent, typeof SessionEvent.Forked.Type | typeof SessionEvent.Deleted.Type>
|
||||||
CurrentDurableEvent,
|
|
||||||
typeof SessionEvent.Forked.Type | typeof SessionEvent.Deleted.Type | typeof SessionEvent.InstructionsUpdated.Type
|
|
||||||
>
|
|
||||||
|
|
||||||
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
|
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
|
||||||
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
|
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
|
||||||
@@ -255,66 +252,22 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
|||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
if (rows.length === 0) break
|
if (rows.length === 0) break
|
||||||
|
|
||||||
const idMap = new Map(rows.map((row) => [row.id, SessionMessage.ID.create()]))
|
|
||||||
yield* db
|
yield* db
|
||||||
.insert(SessionMessageTable)
|
.insert(SessionMessageTable)
|
||||||
.values(
|
.values(
|
||||||
rows.map((row) => {
|
rows.map((row) => ({
|
||||||
const id = idMap.get(row.id)
|
id: SessionMessage.ID.create(),
|
||||||
if (!id) throw new Error(`Fork message ID mapping missing: ${row.id}`)
|
session_id: event.data.sessionID,
|
||||||
return {
|
type: row.type,
|
||||||
id,
|
seq: row.seq,
|
||||||
session_id: event.data.sessionID,
|
time_created: row.time_created,
|
||||||
type: row.type,
|
time_updated: row.time_updated,
|
||||||
seq: row.seq,
|
data: row.data,
|
||||||
time_created: row.time_created,
|
})),
|
||||||
time_updated: row.time_updated,
|
|
||||||
data: row.data,
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
.run()
|
.run()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
|
|
||||||
const pendingRows = yield* db
|
|
||||||
.select()
|
|
||||||
.from(SessionPendingTable)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(SessionPendingTable.session_id, event.data.parentID),
|
|
||||||
inArray(
|
|
||||||
SessionPendingTable.id,
|
|
||||||
rows.map((row) => row.id),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.all()
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
if (pendingRows.length > 0) {
|
|
||||||
yield* db
|
|
||||||
.insert(SessionPendingTable)
|
|
||||||
.values(
|
|
||||||
pendingRows.flatMap((row) => {
|
|
||||||
const id = idMap.get(row.id)
|
|
||||||
return id && row.type !== "compaction"
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
id,
|
|
||||||
session_id: event.data.sessionID,
|
|
||||||
type: row.type,
|
|
||||||
data: row.data,
|
|
||||||
delivery: row.delivery,
|
|
||||||
admitted_seq: row.admitted_seq,
|
|
||||||
time_created: row.time_created,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.run()
|
|
||||||
.pipe(Effect.orDie)
|
|
||||||
}
|
|
||||||
|
|
||||||
cursor = rows.at(-1)!.seq
|
cursor = rows.at(-1)!.seq
|
||||||
}
|
}
|
||||||
if (copiedSeq !== undefined) yield* Bus.reserveSequence(db, event.data.sessionID, copiedSeq)
|
if (copiedSeq !== undefined) yield* Bus.reserveSequence(db, event.data.sessionID, copiedSeq)
|
||||||
@@ -682,7 +635,10 @@ const layer = Layer.effectDiscard(
|
|||||||
yield* bus.project(SessionEvent.Execution.Failed, (event) => run(db, event))
|
yield* bus.project(SessionEvent.Execution.Failed, (event) => run(db, event))
|
||||||
yield* bus.project(SessionEvent.Execution.Interrupted, (event) => run(db, event))
|
yield* bus.project(SessionEvent.Execution.Interrupted, (event) => run(db, event))
|
||||||
yield* bus.project(SessionEvent.InstructionsUpdated, (event) =>
|
yield* bus.project(SessionEvent.InstructionsUpdated, (event) =>
|
||||||
InstructionState.apply(db, event.data.sessionID, event.durable.seq, event.data.delta),
|
Effect.gen(function* () {
|
||||||
|
yield* run(db, event)
|
||||||
|
yield* InstructionState.apply(db, event.data.sessionID, event.durable.seq, event.data.delta)
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
yield* bus.project(SessionEvent.Synthetic, (event) => run(db, event))
|
yield* bus.project(SessionEvent.Synthetic, (event) => run(db, event))
|
||||||
yield* bus.project(SessionEvent.Skill.Activated, (event) => run(db, event))
|
yield* bus.project(SessionEvent.Skill.Activated, (event) => run(db, event))
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ export function isRetryable(error: AIError) {
|
|||||||
switch (error.reason._tag) {
|
switch (error.reason._tag) {
|
||||||
case "RateLimit":
|
case "RateLimit":
|
||||||
case "ProviderInternal":
|
case "ProviderInternal":
|
||||||
case "Transport":
|
|
||||||
return true
|
return true
|
||||||
|
case "Transport":
|
||||||
|
return error.reason.delivery === undefined || error.reason.delivery === "not-sent"
|
||||||
case "InvalidProviderOutput":
|
case "InvalidProviderOutput":
|
||||||
return error.reason.classification === "incomplete-stream"
|
return error.reason.classification === "incomplete-stream"
|
||||||
case "Authentication":
|
case "Authentication":
|
||||||
|
|||||||
@@ -89,68 +89,6 @@ describe("FileMutation", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("rejects create when a prospective target appears after resolution", () =>
|
|
||||||
withTmp((directory) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const targetPath = path.join(directory, "appeared.txt")
|
|
||||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: "appeared.txt" })
|
|
||||||
yield* Effect.promise(() => fs.writeFile(targetPath, "winner"))
|
|
||||||
|
|
||||||
expect(
|
|
||||||
yield* (yield* FileMutation.Service).create({ target, content: "replacement" }).pipe(Effect.flip),
|
|
||||||
).toMatchObject({
|
|
||||||
_tag: "FileMutation.TargetExistsError",
|
|
||||||
})
|
|
||||||
expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("winner")
|
|
||||||
}).pipe(provide(directory)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("creates when an existing target disappears after resolution", () =>
|
|
||||||
withTmp((directory) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const targetPath = path.join(directory, "removed.txt")
|
|
||||||
yield* Effect.promise(() => fs.writeFile(targetPath, "before"))
|
|
||||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: "removed.txt" })
|
|
||||||
yield* Effect.promise(() => fs.rm(targetPath))
|
|
||||||
|
|
||||||
expect(yield* (yield* FileMutation.Service).create({ target, content: "after" })).toEqual({
|
|
||||||
operation: "write",
|
|
||||||
target: target.canonical,
|
|
||||||
resource: "removed.txt",
|
|
||||||
existed: false,
|
|
||||||
})
|
|
||||||
expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("after")
|
|
||||||
}).pipe(provide(directory)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("removes an existing internal file", () =>
|
|
||||||
withTmp((directory) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const targetPath = path.join(directory, "remove.txt")
|
|
||||||
yield* Effect.promise(() => fs.writeFile(targetPath, "remove"))
|
|
||||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: "remove.txt" })
|
|
||||||
const result = yield* (yield* FileMutation.Service).remove({ target })
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
operation: "remove",
|
|
||||||
target: target.canonical,
|
|
||||||
resource: "remove.txt",
|
|
||||||
existed: true,
|
|
||||||
})
|
|
||||||
expect(
|
|
||||||
yield* Effect.promise(() =>
|
|
||||||
fs.stat(targetPath).then(
|
|
||||||
() => true,
|
|
||||||
() => false,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
).toBe(false)
|
|
||||||
}).pipe(provide(directory)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("writes an explicitly resolved external target", () =>
|
it.live("writes an explicitly resolved external target", () =>
|
||||||
withTmp((directory) =>
|
withTmp((directory) =>
|
||||||
withTmp((outside) =>
|
withTmp((outside) =>
|
||||||
@@ -171,49 +109,6 @@ describe("FileMutation", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("removes an explicitly resolved external target", () =>
|
|
||||||
withTmp((directory) =>
|
|
||||||
withTmp((outside) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const targetPath = path.join(outside, "external.txt")
|
|
||||||
yield* Effect.promise(() => fs.writeFile(targetPath, "external"))
|
|
||||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
|
|
||||||
const result = yield* (yield* FileMutation.Service).remove({ target })
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
operation: "remove",
|
|
||||||
target: target.canonical,
|
|
||||||
resource: target.resource,
|
|
||||||
existed: true,
|
|
||||||
})
|
|
||||||
expect(
|
|
||||||
yield* Effect.promise(() =>
|
|
||||||
fs.stat(targetPath).then(
|
|
||||||
() => true,
|
|
||||||
() => false,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
).toBe(false)
|
|
||||||
}).pipe(provide(directory)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("reports a missing target as not removed without checking existence first", () =>
|
|
||||||
withTmp((directory) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: "missing.txt" })
|
|
||||||
|
|
||||||
expect(yield* (yield* FileMutation.Service).remove({ target })).toEqual({
|
|
||||||
operation: "remove",
|
|
||||||
target: target.canonical,
|
|
||||||
resource: "missing.txt",
|
|
||||||
existed: false,
|
|
||||||
})
|
|
||||||
}).pipe(provide(directory)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("serializes concurrent writes to the same canonical target", () =>
|
it.live("serializes concurrent writes to the same canonical target", () =>
|
||||||
withTmp((directory) =>
|
withTmp((directory) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -257,63 +152,6 @@ describe("FileMutation", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.live("allows only one concurrent conditional write based on the same bytes", () =>
|
|
||||||
withTmp((directory) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const targetPath = path.join(directory, "shared.txt")
|
|
||||||
yield* Effect.promise(() => fs.writeFile(targetPath, "initial"))
|
|
||||||
const firstStarted = yield* Deferred.make<void>()
|
|
||||||
const releaseFirst = yield* Deferred.make<void>()
|
|
||||||
let writes = 0
|
|
||||||
const filesystem = instrumentWrites((write) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
writes++
|
|
||||||
if (writes === 1) {
|
|
||||||
yield* Deferred.succeed(firstStarted, undefined)
|
|
||||||
yield* Deferred.await(releaseFirst)
|
|
||||||
}
|
|
||||||
yield* write
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
yield* Effect.gen(function* () {
|
|
||||||
const mutation = yield* LocationMutation.Service
|
|
||||||
const files = yield* FileMutation.Service
|
|
||||||
const target = yield* mutation.resolve({ path: "shared.txt" })
|
|
||||||
const expected = new TextEncoder().encode("initial")
|
|
||||||
const first = yield* files.writeIfUnchanged({ target, expected, content: "first" }).pipe(Effect.forkChild)
|
|
||||||
yield* Deferred.await(firstStarted)
|
|
||||||
const second = yield* files
|
|
||||||
.writeIfUnchanged({ target, expected, content: "second" })
|
|
||||||
.pipe(Effect.flip, Effect.forkChild)
|
|
||||||
|
|
||||||
yield* Deferred.succeed(releaseFirst, undefined)
|
|
||||||
yield* Fiber.join(first)
|
|
||||||
expect(yield* Fiber.join(second)).toMatchObject({ _tag: "FileMutation.StaleContentError" })
|
|
||||||
expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("first")
|
|
||||||
expect(writes).toBe(1)
|
|
||||||
}).pipe(provide(directory, filesystem))
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("rejects a conditional write when target content is already stale", () =>
|
|
||||||
withTmp((directory) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const targetPath = path.join(directory, "stale.txt")
|
|
||||||
yield* Effect.promise(() => fs.writeFile(targetPath, "current"))
|
|
||||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: "stale.txt" })
|
|
||||||
|
|
||||||
expect(
|
|
||||||
yield* (yield* FileMutation.Service)
|
|
||||||
.writeIfUnchanged({ target, expected: new TextEncoder().encode("older"), content: "replacement" })
|
|
||||||
.pipe(Effect.flip),
|
|
||||||
).toMatchObject({ _tag: "FileMutation.StaleContentError", path: target.canonical })
|
|
||||||
expect(yield* Effect.promise(() => fs.readFile(targetPath, "utf8"))).toBe("current")
|
|
||||||
}).pipe(provide(directory)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("allows distinct canonical targets to proceed independently", () =>
|
it.live("allows distinct canonical targets to proceed independently", () =>
|
||||||
withTmp((directory) =>
|
withTmp((directory) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
|
|||||||
import { InstructionState } from "@opencode-ai/core/session/instruction-state"
|
import { InstructionState } from "@opencode-ai/core/session/instruction-state"
|
||||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||||
import { SessionSchema } from "@opencode-ai/core/session/schema"
|
import { SessionSchema } from "@opencode-ai/core/session/schema"
|
||||||
import { InstructionBlobTable, InstructionStateTable, SessionTable } from "@opencode-ai/core/session/sql"
|
import { InstructionBlobTable, InstructionStateTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
|
||||||
import { testEffect } from "./lib/effect"
|
import { testEffect } from "./lib/effect"
|
||||||
|
|
||||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node])))
|
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node])))
|
||||||
@@ -105,6 +105,7 @@ describe("InstructionState", () => {
|
|||||||
expect(observation).toEqual({
|
expect(observation).toEqual({
|
||||||
sessionID,
|
sessionID,
|
||||||
initial: true,
|
initial: true,
|
||||||
|
previous: {},
|
||||||
current: {
|
current: {
|
||||||
"test/first": Instructions.hash("first"),
|
"test/first": Instructions.hash("first"),
|
||||||
"test/second": Instructions.hash("second"),
|
"test/second": Instructions.hash("second"),
|
||||||
@@ -156,7 +157,7 @@ describe("InstructionState", () => {
|
|||||||
|
|
||||||
const initial = yield* InstructionState.observe(db, instructions, sessionID)
|
const initial = yield* InstructionState.observe(db, instructions, sessionID)
|
||||||
expect(reads).toBe(2)
|
expect(reads).toBe(2)
|
||||||
yield* InstructionState.commit(db, events, initial)
|
yield* InstructionState.commit(db, events, instructions, initial)
|
||||||
expect(reads).toBe(2)
|
expect(reads).toBe(2)
|
||||||
|
|
||||||
current = "changed"
|
current = "changed"
|
||||||
@@ -166,6 +167,10 @@ describe("InstructionState", () => {
|
|||||||
expect(changed).toMatchObject({
|
expect(changed).toMatchObject({
|
||||||
sessionID,
|
sessionID,
|
||||||
initial: false,
|
initial: false,
|
||||||
|
previous: {
|
||||||
|
"test/current": Instructions.hash("initial"),
|
||||||
|
"test/retired": Instructions.hash("retired"),
|
||||||
|
},
|
||||||
current: { "test/current": Instructions.hash("changed") },
|
current: { "test/current": Instructions.hash("changed") },
|
||||||
delta: {
|
delta: {
|
||||||
"test/current": Instructions.hash("changed"),
|
"test/current": Instructions.hash("changed"),
|
||||||
@@ -173,7 +178,7 @@ describe("InstructionState", () => {
|
|||||||
},
|
},
|
||||||
blobs: { [Instructions.hash("changed")]: "changed" },
|
blobs: { [Instructions.hash("changed")]: "changed" },
|
||||||
})
|
})
|
||||||
yield* InstructionState.commit(db, events, changed)
|
yield* InstructionState.commit(db, events, instructions, changed)
|
||||||
expect(reads).toBe(4)
|
expect(reads).toBe(4)
|
||||||
yield* unsubscribe
|
yield* unsubscribe
|
||||||
|
|
||||||
@@ -190,6 +195,11 @@ describe("InstructionState", () => {
|
|||||||
"test/retired": "removed",
|
"test/retired": "removed",
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
// The chronological update text is frozen into the event; the baseline has none.
|
||||||
|
expect((yield* instructionEvents(db, sessionID)).map((event) => event.data.text)).toEqual([
|
||||||
|
undefined,
|
||||||
|
"changed\n\nRemoved retired",
|
||||||
|
])
|
||||||
expect(yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)).toMatchObject({
|
expect(yield* db.select().from(InstructionStateTable).get().pipe(Effect.orDie)).toMatchObject({
|
||||||
initial_values: {
|
initial_values: {
|
||||||
"test/current": Instructions.hash("initial"),
|
"test/current": Instructions.hash("initial"),
|
||||||
@@ -222,18 +232,19 @@ describe("InstructionState", () => {
|
|||||||
expect(observation).toEqual({
|
expect(observation).toEqual({
|
||||||
sessionID,
|
sessionID,
|
||||||
initial: false,
|
initial: false,
|
||||||
|
previous: { "test/context": Instructions.hash("unchanged") },
|
||||||
current: { "test/context": Instructions.hash("unchanged") },
|
current: { "test/context": Instructions.hash("unchanged") },
|
||||||
delta: {},
|
delta: {},
|
||||||
blobs: {},
|
blobs: {},
|
||||||
})
|
})
|
||||||
yield* InstructionState.commit(db, events, observation)
|
yield* InstructionState.commit(db, events, instructions, observation)
|
||||||
|
|
||||||
expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents)
|
expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents)
|
||||||
expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual(beforeBlobs)
|
expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual(beforeBlobs)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("assembles a fresh private update without repairing a missing cache", () =>
|
it.effect("treats a missing state row as a fresh baseline without repairing it", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const sessionID = SessionSchema.ID.make("ses_instruction_generate")
|
const sessionID = SessionSchema.ID.make("ses_instruction_generate")
|
||||||
const { db, events } = yield* setup(sessionID)
|
const { db, events } = yield* setup(sessionID)
|
||||||
@@ -254,7 +265,7 @@ describe("InstructionState", () => {
|
|||||||
|
|
||||||
const assembled = yield* preview(db, sessionID, instructions)
|
const assembled = yield* preview(db, sessionID, instructions)
|
||||||
|
|
||||||
expect(assembled).toEqual({ initial: "Initial context", updates: [], update: "Changed context" })
|
expect(assembled).toEqual({ initial: "Changed context", update: "" })
|
||||||
expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents)
|
expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents)
|
||||||
expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual(beforeBlobs)
|
expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual(beforeBlobs)
|
||||||
expect(
|
expect(
|
||||||
@@ -268,7 +279,7 @@ describe("InstructionState", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("reads through a stale cache without repairing it", () =>
|
it.effect("trusts the projected state without consulting durable events", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const sessionID = SessionSchema.ID.make("ses_instruction_generate_stale")
|
const sessionID = SessionSchema.ID.make("ses_instruction_generate_stale")
|
||||||
const { db, events } = yield* setup(sessionID)
|
const { db, events } = yield* setup(sessionID)
|
||||||
@@ -280,6 +291,7 @@ describe("InstructionState", () => {
|
|||||||
yield* InstructionState.prepare(db, events, instructions, sessionID)
|
yield* InstructionState.prepare(db, events, instructions, sessionID)
|
||||||
value = "Committed update"
|
value = "Committed update"
|
||||||
yield* InstructionState.prepare(db, events, instructions, sessionID)
|
yield* InstructionState.prepare(db, events, instructions, sessionID)
|
||||||
|
// Tamper with the projected state; the authoritative row wins over event history.
|
||||||
yield* db
|
yield* db
|
||||||
.update(InstructionStateTable)
|
.update(InstructionStateTable)
|
||||||
.set({ through_seq: 0, current_values: { "test/context": Instructions.hash("Initial context") } })
|
.set({ through_seq: 0, current_values: { "test/context": Instructions.hash("Initial context") } })
|
||||||
@@ -294,7 +306,6 @@ describe("InstructionState", () => {
|
|||||||
const assembled = yield* preview(db, sessionID, instructions)
|
const assembled = yield* preview(db, sessionID, instructions)
|
||||||
|
|
||||||
expect(assembled.initial).toBe("Initial context")
|
expect(assembled.initial).toBe("Initial context")
|
||||||
expect(assembled.updates.map((entry) => entry.message.text)).toEqual(["Committed update"])
|
|
||||||
expect(assembled.update).toBe("Private update")
|
expect(assembled.update).toBe("Private update")
|
||||||
expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents)
|
expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents)
|
||||||
expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual(beforeBlobs)
|
expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual(beforeBlobs)
|
||||||
@@ -302,6 +313,41 @@ describe("InstructionState", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("persists chronological updates as system messages", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const sessionID = SessionSchema.ID.make("ses_instruction_messages")
|
||||||
|
const { db, events } = yield* setup(sessionID)
|
||||||
|
let value = "Initial context"
|
||||||
|
const instructions = source(
|
||||||
|
"test/context",
|
||||||
|
Effect.sync(() => value),
|
||||||
|
)
|
||||||
|
const messages = () =>
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(SessionMessageTable)
|
||||||
|
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "system")))
|
||||||
|
.orderBy(asc(SessionMessageTable.seq))
|
||||||
|
.all()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
|
||||||
|
// The initial baseline is not chronological history and produces no message.
|
||||||
|
yield* InstructionState.prepare(db, events, instructions, sessionID)
|
||||||
|
expect(yield* messages()).toEqual([])
|
||||||
|
|
||||||
|
value = "Changed context"
|
||||||
|
yield* InstructionState.prepare(db, events, instructions, sessionID)
|
||||||
|
const rows = yield* messages()
|
||||||
|
expect(rows).toHaveLength(1)
|
||||||
|
expect(rows[0]?.data).toMatchObject({ text: "Changed context" })
|
||||||
|
expect(rows.map((row) => row.seq)).toEqual([(yield* instructionEvents(db, sessionID)).at(-1)!.seq])
|
||||||
|
|
||||||
|
// A no-op observation adds nothing.
|
||||||
|
yield* InstructionState.prepare(db, events, instructions, sessionID)
|
||||||
|
expect(yield* messages()).toHaveLength(1)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("assembles initial instructions without persisting a baseline", () =>
|
it.effect("assembles initial instructions without persisting a baseline", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const sessionID = SessionSchema.ID.make("ses_instruction_generate_initial")
|
const sessionID = SessionSchema.ID.make("ses_instruction_generate_initial")
|
||||||
@@ -310,7 +356,6 @@ describe("InstructionState", () => {
|
|||||||
|
|
||||||
expect(yield* preview(db, sessionID, instructions)).toEqual({
|
expect(yield* preview(db, sessionID, instructions)).toEqual({
|
||||||
initial: "Initial context",
|
initial: "Initial context",
|
||||||
updates: [],
|
|
||||||
update: "",
|
update: "",
|
||||||
})
|
})
|
||||||
expect(yield* instructionEvents(db, sessionID)).toEqual([])
|
expect(yield* instructionEvents(db, sessionID)).toEqual([])
|
||||||
@@ -336,7 +381,6 @@ describe("InstructionState", () => {
|
|||||||
|
|
||||||
expect(yield* preview(db, sessionID, instructions)).toEqual({
|
expect(yield* preview(db, sessionID, instructions)).toEqual({
|
||||||
initial: "Committed context",
|
initial: "Committed context",
|
||||||
updates: [],
|
|
||||||
update: "",
|
update: "",
|
||||||
})
|
})
|
||||||
expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents)
|
expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents)
|
||||||
@@ -388,7 +432,7 @@ describe("InstructionState", () => {
|
|||||||
for (const next of ["initial", "changed", "changed", Instructions.removed] as const) {
|
for (const next of ["initial", "changed", "changed", Instructions.removed] as const) {
|
||||||
value = next
|
value = next
|
||||||
yield* InstructionState.observe(db, observedInstructions, observedSessionID).pipe(
|
yield* InstructionState.observe(db, observedInstructions, observedSessionID).pipe(
|
||||||
Effect.flatMap((observation) => InstructionState.commit(db, events, observation)),
|
Effect.flatMap((observation) => InstructionState.commit(db, events, observedInstructions, observation)),
|
||||||
)
|
)
|
||||||
yield* InstructionState.prepare(db, events, preparedInstructions, preparedSessionID)
|
yield* InstructionState.prepare(db, events, preparedInstructions, preparedSessionID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -284,13 +284,9 @@ describe("Session.create", () => {
|
|||||||
})
|
})
|
||||||
expect(yield* SessionPending.find(db, forkContext[0].id)).toBeUndefined()
|
expect(yield* SessionPending.find(db, forkContext[0].id)).toBeUndefined()
|
||||||
expect(yield* SessionPending.find(db, forkContext[1].id)).toBeUndefined()
|
expect(yield* SessionPending.find(db, forkContext[1].id)).toBeUndefined()
|
||||||
// Fork-copied messages have no admitted event in the fork aggregate, so
|
|
||||||
// reusing their IDs as prompt IDs is conflicting reuse, not a retry.
|
|
||||||
expect(
|
expect(
|
||||||
yield* session
|
yield* session.prompt({ id: forkContext[0].id, sessionID: forked.id, text: "First", resume: false }),
|
||||||
.prompt({ id: forkContext[0].id, sessionID: forked.id, text: "First", resume: false })
|
).toMatchObject({ id: forkContext[0].id, type: "user", data: { text: "First" } })
|
||||||
.pipe(Effect.flip),
|
|
||||||
).toMatchObject({ _tag: "Session.PromptConflictError", messageID: forkContext[0].id })
|
|
||||||
|
|
||||||
yield* session.prompt({
|
yield* session.prompt({
|
||||||
sessionID: parent.id,
|
sessionID: parent.id,
|
||||||
|
|||||||
@@ -110,4 +110,26 @@ describe("toSessionError", () => {
|
|||||||
expect(eligible.map(SessionRunnerRetry.isRetryable)).toEqual([true, true, true])
|
expect(eligible.map(SessionRunnerRetry.isRetryable)).toEqual([true, true, true])
|
||||||
expect(ineligible.map(SessionRunnerRetry.isRetryable)).toEqual([false, false, false, false, false, false, false])
|
expect(ineligible.map(SessionRunnerRetry.isRetryable)).toEqual([false, false, false, false, false, false, false])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("retries transport failures only when delivery is absent or not sent", () => {
|
||||||
|
const retryable = [
|
||||||
|
llm(new TransportReason({ message: "http transport" })),
|
||||||
|
llm(new TransportReason({ message: "connect failed", delivery: "not-sent", phase: "connect" })),
|
||||||
|
]
|
||||||
|
const ineligible = [
|
||||||
|
llm(new TransportReason({ message: "send uncertain", delivery: "ambiguous", phase: "send" })),
|
||||||
|
llm(new TransportReason({ message: "response interrupted", delivery: "accepted", phase: "receive" })),
|
||||||
|
llm(
|
||||||
|
new TransportReason({
|
||||||
|
message: "continuation rejected",
|
||||||
|
delivery: "rejected",
|
||||||
|
recovery: "retry-full",
|
||||||
|
phase: "receive",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
expect(retryable.map(SessionRunnerRetry.isRetryable)).toEqual([true, true])
|
||||||
|
expect(ineligible.map(SessionRunnerRetry.isRetryable)).toEqual([false, false, false])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -553,6 +553,47 @@ describe("Session.prompt", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("reconciles an exact retry from the promoted message without admission history", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* setup
|
||||||
|
const session = yield* Session.Service
|
||||||
|
const bus = yield* Bus.Service
|
||||||
|
const { db } = yield* Database.Service
|
||||||
|
const input = { sessionID, id: messageID, text: "Fix the failing tests", resume: false }
|
||||||
|
const first = yield* session.prompt(input)
|
||||||
|
yield* SessionPending.promote(db, bus, sessionID, "steer")
|
||||||
|
yield* db
|
||||||
|
.delete(EventTable)
|
||||||
|
.where(eq(EventTable.aggregate_id, sessionID))
|
||||||
|
.run()
|
||||||
|
.pipe(Effect.orDie)
|
||||||
|
|
||||||
|
const retried = yield* session.prompt(input)
|
||||||
|
|
||||||
|
expect(retried).toMatchObject({ id: first.id, type: "user", data: { text: first.data.text } })
|
||||||
|
expect(yield* session.messages({ sessionID })).toMatchObject([
|
||||||
|
{ id: messageID, type: "user", text: "Fix the failing tests" },
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("ignores delivery when retrying a promoted message", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* setup
|
||||||
|
const session = yield* Session.Service
|
||||||
|
const bus = yield* Bus.Service
|
||||||
|
const { db } = yield* Database.Service
|
||||||
|
const input = { sessionID, id: messageID, text: "Fix the failing tests", resume: false }
|
||||||
|
yield* session.prompt(input)
|
||||||
|
yield* SessionPending.promote(db, bus, sessionID, "steer")
|
||||||
|
|
||||||
|
const retried = yield* session.prompt({ ...input, delivery: "queue" })
|
||||||
|
|
||||||
|
expect(retried).toMatchObject({ id: messageID, type: "user", data: { text: input.text } })
|
||||||
|
expect(yield* admitted(messageID)).toBeUndefined()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("wakes execution when an exact prompt retry recovers a committed message", () =>
|
it.effect("wakes execution when an exact prompt retry recovers a committed message", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* setup
|
yield* setup
|
||||||
|
|||||||
@@ -1180,7 +1180,7 @@ describe("SessionRunnerLLM", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("forks instruction values at the selected message instead of the parent's latest state", () =>
|
it.effect("seeds a fork with the parent's newest instruction values", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const session = yield* setup
|
const session = yield* setup
|
||||||
yield* runPrompt(session, "First")
|
yield* runPrompt(session, "First")
|
||||||
@@ -1197,14 +1197,16 @@ describe("SessionRunnerLLM", () => {
|
|||||||
.where(eq(InstructionStateTable.session_id, forked.id))
|
.where(eq(InstructionStateTable.session_id, forked.id))
|
||||||
.get(),
|
.get(),
|
||||||
).toMatchObject({
|
).toMatchObject({
|
||||||
initial_values: { "test/context": Instructions.hash("Changed context") },
|
initial_values: { "test/context": Instructions.hash("Latest context") },
|
||||||
current_values: { "test/context": Instructions.hash("Changed context") },
|
current_values: { "test/context": Instructions.hash("Latest context") },
|
||||||
})
|
})
|
||||||
yield* session.prompt({ sessionID: forked.id, text: "Forked", resume: false })
|
yield* session.prompt({ sessionID: forked.id, text: "Forked", resume: false })
|
||||||
yield* session.resume(forked.id)
|
yield* session.resume(forked.id)
|
||||||
|
|
||||||
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([defaultSystem, "Changed context"])
|
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([defaultSystem, "Latest context"])
|
||||||
expect(systemTexts(requests.at(-1)!)).toContain("Latest context")
|
// Copied history keeps the frozen chronological update; no new update is emitted.
|
||||||
|
expect(systemTexts(requests.at(-1)!)).toContain("Changed context")
|
||||||
|
expect(systemTexts(requests.at(-1)!)).not.toContain("Latest context")
|
||||||
|
|
||||||
const { db } = yield* Database.Service
|
const { db } = yield* Database.Service
|
||||||
const bus = yield* Bus.Service
|
const bus = yield* Bus.Service
|
||||||
@@ -1263,7 +1265,7 @@ describe("SessionRunnerLLM", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("rebuilds a missing instruction cache without admitting another delta", () =>
|
it.effect("re-establishes a fresh baseline when instruction state is missing", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const session = yield* setup
|
const session = yield* setup
|
||||||
const { db } = yield* Database.Service
|
const { db } = yield* Database.Service
|
||||||
@@ -1277,13 +1279,15 @@ describe("SessionRunnerLLM", () => {
|
|||||||
expect(requests).toHaveLength(1)
|
expect(requests).toHaveLength(1)
|
||||||
expect(requests[0]?.system.map((part) => part.text)).toEqual([defaultSystem, "Initial context"])
|
expect(requests[0]?.system.map((part) => part.text)).toEqual([defaultSystem, "Initial context"])
|
||||||
expect(messageRoles(requests[0])).toEqual(["user", "user"])
|
expect(messageRoles(requests[0])).toEqual(["user", "user"])
|
||||||
|
// The projected row is authoritative: a missing row admits a fresh baseline
|
||||||
|
// instead of rebuilding from durable events.
|
||||||
expect(
|
expect(
|
||||||
yield* db
|
yield* db
|
||||||
.select({ id: EventTable.id })
|
.select({ data: EventTable.data })
|
||||||
.from(EventTable)
|
.from(EventTable)
|
||||||
.where(eq(EventTable.type, "session.instructions.updated.2"))
|
.where(eq(EventTable.type, "session.instructions.updated.2"))
|
||||||
.all(),
|
.all(),
|
||||||
).toHaveLength(1)
|
).toHaveLength(2)
|
||||||
expect(yield* db.select().from(InstructionStateTable).get()).toMatchObject({
|
expect(yield* db.select().from(InstructionStateTable).get()).toMatchObject({
|
||||||
initial_values: { "test/context": Instructions.hash("Initial context") },
|
initial_values: { "test/context": Instructions.hash("Initial context") },
|
||||||
current_values: { "test/context": Instructions.hash("Initial context") },
|
current_values: { "test/context": Instructions.hash("Initial context") },
|
||||||
@@ -1310,7 +1314,10 @@ describe("SessionRunnerLLM", () => {
|
|||||||
])
|
])
|
||||||
expect(messageRoles(requests[1])).toEqual(["user", "system", "user"])
|
expect(messageRoles(requests[1])).toEqual(["user", "system", "user"])
|
||||||
expect(requests[1]?.messages.at(1)?.content).toEqual([{ type: "text", text: "Changed context" }])
|
expect(requests[1]?.messages.at(1)?.content).toEqual([{ type: "text", text: "Changed context" }])
|
||||||
expect(yield* session.messages({ sessionID })).toHaveLength(2)
|
// The chronological update is a durable client-visible system message.
|
||||||
|
const messages = yield* session.messages({ sessionID })
|
||||||
|
expect(messages).toHaveLength(3)
|
||||||
|
expect(messages[1]).toMatchObject({ type: "system", text: "Changed context" })
|
||||||
const { db } = yield* Database.Service
|
const { db } = yield* Database.Service
|
||||||
const updates = yield* db
|
const updates = yield* db
|
||||||
.select({ data: EventTable.data })
|
.select({ data: EventTable.data })
|
||||||
@@ -1327,9 +1334,10 @@ describe("SessionRunnerLLM", () => {
|
|||||||
expect(updates[1]?.data).toEqual({
|
expect(updates[1]?.data).toEqual({
|
||||||
sessionID,
|
sessionID,
|
||||||
delta: { "test/context": Instructions.hash("Changed context") },
|
delta: { "test/context": Instructions.hash("Changed context") },
|
||||||
|
text: "Changed context",
|
||||||
})
|
})
|
||||||
yield* replaySessionProjection(sessionID)
|
yield* replaySessionProjection(sessionID)
|
||||||
expect(yield* session.messages({ sessionID })).toHaveLength(2)
|
expect(yield* session.messages({ sessionID })).toHaveLength(3)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1596,7 +1604,7 @@ describe("SessionRunnerLLM", () => {
|
|||||||
expect(requests[1]?.messages.at(1)?.content).toEqual([
|
expect(requests[1]?.messages.at(1)?.content).toEqual([
|
||||||
{ type: "text", text: "System context source removed: test/context" },
|
{ type: "text", text: "System context source removed: test/context" },
|
||||||
])
|
])
|
||||||
expect(yield* session.messages({ sessionID })).toHaveLength(2)
|
expect(yield* session.messages({ sessionID })).toHaveLength(3)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1708,12 +1716,14 @@ describe("SessionRunnerLLM", () => {
|
|||||||
expect(requests[2]?.messages.filter((message) => message.role === "system")).toHaveLength(2)
|
expect(requests[2]?.messages.filter((message) => message.role === "system")).toHaveLength(2)
|
||||||
expect((yield* session.context(sessionID)).map((message) => message.type)).toEqual([
|
expect((yield* session.context(sessionID)).map((message) => message.type)).toEqual([
|
||||||
"user",
|
"user",
|
||||||
|
"system",
|
||||||
"user",
|
"user",
|
||||||
"model-switched",
|
"model-switched",
|
||||||
|
"system",
|
||||||
"user",
|
"user",
|
||||||
])
|
])
|
||||||
yield* replaySessionProjection(sessionID)
|
yield* replaySessionProjection(sessionID)
|
||||||
expect(yield* session.messages({ sessionID })).toHaveLength(4)
|
expect(yield* session.messages({ sessionID })).toHaveLength(6)
|
||||||
yield* runPrompt(session, "Fourth")
|
yield* runPrompt(session, "Fourth")
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -186,6 +186,11 @@ export const InstructionsUpdated = Event.durable({
|
|||||||
schema: {
|
schema: {
|
||||||
...Base,
|
...Base,
|
||||||
delta: Instruction.Delta,
|
delta: Instruction.Delta,
|
||||||
|
/**
|
||||||
|
* The rendered chronological update shown to the model, frozen at emit time.
|
||||||
|
* Absent for the initial baseline observation and for deltas that render empty.
|
||||||
|
*/
|
||||||
|
text: Schema.String.pipe(optional),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export type InstructionsUpdated = typeof InstructionsUpdated.Type
|
export type InstructionsUpdated = typeof InstructionsUpdated.Type
|
||||||
|
|||||||
@@ -1142,6 +1142,9 @@ export function ContextToolGroup(props: {
|
|||||||
const running = createMemo(
|
const running = createMemo(
|
||||||
() => partAccessor().state.status === "pending" || partAccessor().state.status === "running",
|
() => partAccessor().state.status === "pending" || partAccessor().state.status === "running",
|
||||||
)
|
)
|
||||||
|
const showDetails = createMemo(
|
||||||
|
() => !running() || partAccessor().tool === "glob" || partAccessor().tool === "grep",
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
<div data-slot="context-tool-group-item">
|
<div data-slot="context-tool-group-item">
|
||||||
<div data-component="tool-trigger">
|
<div data-component="tool-trigger">
|
||||||
@@ -1152,10 +1155,10 @@ export function ContextToolGroup(props: {
|
|||||||
<span data-slot="basic-tool-tool-title">
|
<span data-slot="basic-tool-tool-title">
|
||||||
<TextShimmer text={trigger().title} active={running()} />
|
<TextShimmer text={trigger().title} active={running()} />
|
||||||
</span>
|
</span>
|
||||||
<Show when={!running() && trigger().subtitle}>
|
<Show when={showDetails() && trigger().subtitle}>
|
||||||
<span data-slot="basic-tool-tool-subtitle">{trigger().subtitle}</span>
|
<span data-slot="basic-tool-tool-subtitle">{trigger().subtitle}</span>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={!running() && trigger().args?.length}>
|
<Show when={showDetails() && trigger().args?.length}>
|
||||||
<For each={trigger().args}>
|
<For each={trigger().args}>
|
||||||
{(arg) => <span data-slot="basic-tool-tool-arg">{arg}</span>}
|
{(arg) => <span data-slot="basic-tool-tool-arg">{arg}</span>}
|
||||||
</For>
|
</For>
|
||||||
|
|||||||
@@ -147,12 +147,12 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?:
|
|||||||
const config = createTuiResolvedConfig()
|
const config = createTuiResolvedConfig()
|
||||||
const transport = createFetch((url) => {
|
const transport = createFetch((url) => {
|
||||||
if (url.pathname !== "/api/vcs/diff") return
|
if (url.pathname !== "/api/vcs/diff") return
|
||||||
if (fail) return json({ message: "boom" }, { status: 500 })
|
|
||||||
vcsDiffInput = {
|
vcsDiffInput = {
|
||||||
location: { directory: url.searchParams.get("location[directory]") },
|
location: { directory: url.searchParams.get("location[directory]") },
|
||||||
mode: url.searchParams.get("mode"),
|
mode: url.searchParams.get("mode"),
|
||||||
context: url.searchParams.get("context"),
|
context: url.searchParams.get("context"),
|
||||||
}
|
}
|
||||||
|
if (fail) return json({ message: "boom" }, { status: 500 })
|
||||||
return json({
|
return json({
|
||||||
location: { directory: "/repo/session", project: { id: "project-1", directory: "/repo/session" } },
|
location: { directory: "/repo/session", project: { id: "project-1", directory: "/repo/session" } },
|
||||||
data: vcsDiff,
|
data: vcsDiff,
|
||||||
@@ -238,6 +238,7 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?:
|
|||||||
|
|
||||||
const app = await testRender(() => <Harness />, { width: 80, height })
|
const app = await testRender(() => <Harness />, { width: 80, height })
|
||||||
await waitForCommand(app, commands, "diff.close")
|
await waitForCommand(app, commands, "diff.close")
|
||||||
|
await app.waitFor(() => vcsDiffInput !== undefined)
|
||||||
return {
|
return {
|
||||||
app,
|
app,
|
||||||
commands,
|
commands,
|
||||||
|
|||||||
Reference in New Issue
Block a user