Compare commits

..
Author SHA1 Message Date
Ryan Vogelandopencode-agent[bot] 02eae8cde0 fix(cli): sign macOS preview binaries 2026-08-05 14:45:57 +00:00
28 changed files with 721 additions and 547 deletions
+56 -1
View File
@@ -124,12 +124,66 @@ jobs:
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: opencode-preview-cli
name: opencode-preview-cli-unsigned
path: packages/cli/dist/cli-*
outputs:
version: ${{ needs.version.outputs.version }}
sign-cli-macos:
needs: build-cli
runs-on: macos-26
if: github.repository == 'anomalyco/opencode'
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: apple-actions/import-codesign-certs@8f3fb608891dd2244cdab3d69cd68c0d37a7fe93 # v2.0.0
with:
keychain: build
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: opencode-preview-cli-unsigned
path: packages/cli/dist
- name: Sign macOS CLI binaries
run: |
identity=$(security find-identity -v -p codesigning build.keychain | sed -n 's/.*"\(Developer ID Application:.*\)"/\1/p' | head -n 1)
if [ -z "$identity" ]; then
echo "Developer ID Application identity not found"
exit 1
fi
found=0
for file in packages/cli/dist/cli-darwin-*/bin/opencode2; do
if [ ! -f "$file" ]; then
continue
fi
found=1
codesign \
--force \
--timestamp \
--options runtime \
--entitlements packages/cli/script/entitlements.plist \
--sign "$identity" \
"$file"
codesign --verify --deep --strict --verbose=4 "$file"
codesign --display --requirements - "$file"
done
if [ "$found" -eq 0 ]; then
echo "No macOS CLI binaries found"
exit 1
fi
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: opencode-preview-cli
path: packages/cli/dist/cli-*
if-no-files-found: error
build-node-cli:
needs: version
if: github.repository == 'anomalyco/opencode'
@@ -471,6 +525,7 @@ jobs:
needs:
- version
- build-cli
- sign-cli-macos
- build-node-cli
- sign-cli-windows
- build-electron
+5 -50
View File
@@ -211,43 +211,11 @@ export type StreamItem = Schema.Schema.Type<typeof StreamItem>
// event-level `error` envelope, so accept all three shapes here.
// https://www.openresponses.org/specification
const OpenResponsesErrorPayload = Schema.Struct({
type: optionalNull(Schema.String),
code: optionalNull(Schema.String),
message: 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(
Schema.Struct({
type: Schema.String,
@@ -272,9 +240,6 @@ export const Event = Schema.StructWithRest(
message: Schema.optional(Schema.String),
param: optionalNull(Schema.String),
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)],
)
@@ -667,9 +632,9 @@ export type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
const NO_EVENTS: StepResult["1"] = []
// `response.completed` / `response.incomplete` are clean finishes that emit a
// `finish` event; `response.failed` and `error` are hard failures. All four end
// the stream, so keep this set aligned with `step` and the protocol's terminal predicate.
const TERMINAL_TYPES = new Set(["error", "response.completed", "response.incomplete", "response.failed"])
// `finish` event; `response.failed` is a hard failure. All three end 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"])
export const terminal = (event: Event) => TERMINAL_TYPES.has(event.type)
const onOutputTextDelta = (state: ParserState, event: Event, id: string): StepResult => {
@@ -1004,16 +969,10 @@ const providerErrorMessage = (event: Event, fallback: string): string => {
const providerError = (state: ParserState, event: Event, fallback: string) => {
const code = event.code || event.error?.code || event.response?.error?.code || undefined
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({
module: state.id,
method: "stream",
reason: classifyProviderFailure({ message, code, status }),
reason: classifyProviderFailure({ message, code }),
})
}
@@ -1056,11 +1015,7 @@ export const step = (state: ParserState, event: Event) => {
if (event.type === "response.completed" || event.type === "response.incomplete")
return Effect.succeed(onResponseFinish(state, event))
if (event.type === "response.failed") return providerError(state, event, `${state.name} response failed`)
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`)),
)
if (event.type === "error") return providerError(state, event, `${state.name} stream error`)
return Effect.succeed<StepResult>([state, NO_EVENTS])
}
-1
View File
@@ -67,7 +67,6 @@ const SERVER_CODES = new Set([
"overloaded_error",
"server_error",
"server_is_overloaded",
"slow_down",
"serviceunavailableexception",
])
const INVALID_REQUEST_CODES = new Set(["invalid_prompt", "invalid_request_error", "validationexception"])
+9 -84
View File
@@ -29,45 +29,14 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/AI
const transportError = (
method: string,
message: string,
input: {
readonly url?: string
readonly kind?: string
readonly phase?: TransportReason["phase"]
readonly delivery?: TransportReason["delivery"]
} = {},
input: { readonly url?: string; readonly kind?: string } = {},
) =>
new AIError({
module: "WebSocketExecutor",
method,
reason: new TransportReason({
message,
url: input.url,
kind: input.kind,
phase: input.phase,
delivery: input.delivery,
}),
reason: new TransportReason({ message, url: input.url, kind: input.kind }),
})
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) => {
if ("message" in event && typeof event.message === "string") return event.message
return event.type
@@ -87,8 +56,6 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
transportError("open", `WebSocket closed before opening (state ${ws.readyState})`, {
url: input.url,
kind: "open",
phase: "connect",
delivery: "not-sent",
}),
)
}
@@ -112,12 +79,7 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
cleanup()
resume(
Effect.fail(
transportError("open", `Failed to open WebSocket: ${eventMessage(event)}`, {
url: input.url,
kind: "open",
phase: "connect",
delivery: "not-sent",
}),
transportError("open", `Failed to open WebSocket: ${eventMessage(event)}`, { url: input.url, kind: "open" }),
),
)
}
@@ -128,8 +90,6 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
transportError("open", `WebSocket closed before opening with code ${event.code}`, {
url: input.url,
kind: "open",
phase: "connect",
delivery: "not-sent",
}),
),
)
@@ -159,8 +119,6 @@ const webSocketUrl = (value: string) =>
transportError("prepare", error instanceof Error ? error.message : "Invalid WebSocket URL", {
url: value,
kind: "websocket",
phase: "prepare",
delivery: "not-sent",
}),
})
@@ -172,8 +130,6 @@ export const open = (input: WebSocketRequest) =>
transportError("open", error instanceof Error ? error.message : "Failed to construct WebSocket", {
url: input.url,
kind: "open",
phase: "connect",
delivery: "not-sent",
}),
}).pipe(Effect.flatMap((ws) => fromWebSocket(ws, input)))
@@ -194,11 +150,7 @@ export const fromWebSocket = (
Queue.failCauseUnsafe(
messages,
Cause.fail(
transportError("message", "Unsupported WebSocket message payload", {
url: input.url,
kind: "message",
phase: "receive",
}),
transportError("message", "Unsupported WebSocket message payload", { url: input.url, kind: "message" }),
),
)
}
@@ -206,23 +158,16 @@ export const fromWebSocket = (
Queue.failCauseUnsafe(
messages,
Cause.fail(
transportError("message", `WebSocket error: ${eventMessage(event)}`, {
url: input.url,
kind: "message",
phase: "receive",
}),
transportError("message", `WebSocket error: ${eventMessage(event)}`, { url: input.url, kind: "message" }),
),
)
}
const onClose = (event: CloseEvent) => {
if (event.code === 1000 || event.code === 1005) return Queue.endUnsafe(messages)
Queue.failCauseUnsafe(
messages,
Cause.fail(
transportError("message", `WebSocket closed with code ${event.code}`, {
url: input.url,
kind: "close",
phase: "close",
}),
transportError("message", `WebSocket closed with code ${event.code}`, { url: input.url, kind: "close" }),
),
)
}
@@ -244,8 +189,6 @@ export const fromWebSocket = (
transportError("sendText", error instanceof Error ? error.message : "Failed to send WebSocket message", {
url: input.url,
kind: "write",
phase: "send",
delivery: "not-sent",
}),
}),
messages: Stream.fromQueue(messages),
@@ -301,8 +244,6 @@ export const json = <Body, Message>(input: JsonInput<Body, Message>): JsonTransp
transportError("json", "WebSocket JSON transport requires WebSocketExecutor.Service", {
url: prepared.url,
kind: "websocket",
phase: "prepare",
delivery: "not-sent",
}),
)
}
@@ -310,27 +251,11 @@ export const json = <Body, Message>(input: JsonInput<Body, Message>): JsonTransp
return Stream.unwrap(
Effect.gen(function* () {
const connection = yield* Effect.acquireRelease(
webSocket
.open({ url: prepared.url, headers: prepared.headers })
.pipe(
Effect.mapError((error) => annotateTransportError(error, { phase: "connect", delivery: "not-sent" })),
),
webSocket.open({ url: prepared.url, headers: prepared.headers }),
(connection) => connection.close,
)
yield* connection.sendText(prepared.message)
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",
}),
),
)
return connection.messages.pipe(Stream.map((message) => messageText(message, decoder)))
}),
)
},
-7
View File
@@ -98,13 +98,6 @@ export class TransportReason extends Schema.Class<TransportReason>("AI.Error.Tra
kind: Schema.optional(Schema.String),
url: Schema.optional(Schema.String),
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>(
+2 -2
View File
@@ -69,10 +69,10 @@ describe("provider error classification", () => {
test("classifies V1 overloaded provider codes", () => {
expect(
['{"code":"resource_exhausted"}', '{"code":"service_unavailable"}', '{"code":"slow_down"}'].map(
['{"code":"resource_exhausted"}', '{"code":"service_unavailable"}'].map(
(message) => classifyProviderFailure({ message })._tag,
),
).toEqual(["ProviderInternal", "ProviderInternal", "ProviderInternal"])
).toEqual(["ProviderInternal", "ProviderInternal"])
})
test("classifies transient client statuses as provider internal", () => {
@@ -11,7 +11,6 @@ import {
ToolCallPart,
ToolDefinition,
ToolResultPart,
TransportReason,
Usage,
} from "../../src"
import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
@@ -289,114 +288,6 @@ 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", () =>
Effect.gen(function* () {
const error = yield* WebSocketExecutor.fromWebSocket(
@@ -406,7 +297,6 @@ describe("OpenAI Responses route", () => {
).pipe(Effect.flip)
expect(error.message).toContain("closed before opening")
expect(error.reason).toMatchObject({ _tag: "Transport", phase: "connect", delivery: "not-sent" })
}),
)
-19
View File
@@ -11,7 +11,6 @@ import {
LanguageModel,
ModelID,
ProviderID,
TransportReason,
Usage,
} from "../src/schema"
import { ProviderShared } from "../src/protocols/shared"
@@ -109,21 +108,3 @@ test("AI errors expose the shared runtime tag", async () => {
await Effect.runPromise(Effect.fail(error).pipe(Effect.catchTag("AI.Error", () => Effect.succeed("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)
})
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-executable-page-protection</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>
-1
View File
@@ -420,7 +420,6 @@ export type Endpoint5_26Output =
readonly data: {
readonly sessionID: Session.ID
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"
durable: { aggregateID: string; seq: number; version: 2 }
location?: LocationRef
data: { sessionID: string; delta: { [x: string]: string | "removed" }; text?: string }
data: { sessionID: string; delta: { [x: string]: string | "removed" } }
}
export type SessionSynthetic = {
+92 -2
View File
@@ -1,7 +1,8 @@
export * as FileMutation from "./file-mutation"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer } from "effect"
import { Context, Effect, Layer, Schema } from "effect"
import { dirname } from "path"
import { KeyedMutex } from "./effect/keyed-mutex"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Bom } from "@opencode-ai/util/bom"
@@ -21,6 +22,22 @@ export interface TextWriteInput {
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 {
readonly operation: "write"
readonly target: string
@@ -28,10 +45,24 @@ export interface WriteResult {
readonly existed: boolean
}
export interface RemoveResult {
readonly operation: "remove"
readonly target: string
readonly resource: string
readonly existed: boolean
}
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>
/** Write text while retaining an existing UTF-8 BOM and emitting at most one BOM. */
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") {}
@@ -58,6 +89,13 @@ const layer = Layer.effect(
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) =>
withTargetLock(input.target)(
Effect.gen(function* () {
@@ -84,10 +122,62 @@ const layer = Layer.effect(
),
)
return Service.of({ write, writeTextPreservingBom })
const create = Effect.fn("FileMutation.create")((input: WriteInput) =>
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] })
/**
+9 -5
View File
@@ -31,7 +31,10 @@ export const ripgrepLayer = Layer.effect(
const location = yield* Location.Service
const ripgrep = yield* Ripgrep.Service
const scope = yield* Scope.Scope
const files: string[] = []
const state = {
files: [] as string[],
directories: [] as string[],
}
const directories = new Set<string>()
yield* ripgrep
.find({
@@ -40,9 +43,10 @@ export const ripgrepLayer = Layer.effect(
limit: location.vcs ? Number.MAX_SAFE_INTEGER : 100_000,
onEntry: (entry) =>
Effect.sync(() => {
files.push(entry.path)
state.files.push(entry.path)
const parts = entry.path.split("/")
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))
@@ -102,10 +106,10 @@ export const ripgrepLayer = Layer.effect(
Effect.gen(function* () {
const items =
input.type === "file"
? files
? state.files
: input.type === "directory"
? Array.from(directories)
: [...files, ...directories]
? state.directories
: [...state.files, ...state.directories]
return fuzzysort.go(input.query, items, { limit: input.limit ?? 50 }).map((item) => {
const relative = item.target
const type = relative.endsWith(path.sep) ? ("directory" as const) : ("file" as const)
+4 -5
View File
@@ -410,7 +410,7 @@ const layer = Layer.effect(
fork: Effect.fn("Session.fork")(function* (input) {
const parent = yield* result.get(input.sessionID)
const boundary = yield* db
.select({ id: SessionMessageTable.id })
.select({ id: SessionMessageTable.id, seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(
and(
@@ -429,14 +429,13 @@ const layer = Layer.effect(
})
if (!boundary) return yield* new ForkEmptyError({ sessionID: input.sessionID })
const sessionID = SessionSchema.ID.create()
// The fork adopts the parent's newest instruction values rather than the
// values in effect at the boundary; copied history may contain frozen
// instruction-update text the initial baseline already reflects.
const instructionThrough =
input.boundary.type === "before" ? boundary.seq - 1 : yield* Bus.latestSequence(db, parent.id)
yield* bus.publish(SessionEvent.Forked, {
sessionID,
parentID: parent.id,
boundary: { ...input.boundary, messageID: boundary.id },
instructions: yield* InstructionState.current(db, parent.id),
instructions: yield* InstructionState.valuesAt(db, parent.id, instructionThrough),
})
return yield* result.get(sessionID).pipe(Effect.orDie)
}),
+5 -3
View File
@@ -80,9 +80,10 @@ export const entriesForRunner = Effect.fn("SessionHistory.entriesForRunner")(fun
.transaction(() =>
Effect.gen(function* () {
const messages = yield* messageEntries(db, sessionID)
const assembled = yield* InstructionState.assemble(db, sessionID, instructions)
return {
initial: yield* InstructionState.initial(db, sessionID, instructions),
entries: messages,
initial: assembled.initial,
entries: [...messages, ...assembled.updates].toSorted((a, b) => a.seq - b.seq),
}
}),
)
@@ -105,9 +106,10 @@ export const preview = Effect.fn("SessionHistory.preview")(function* (
)
const settled = unsettled === -1 ? messages : messages.slice(0, unsettled)
const assembled = yield* InstructionState.preview(db, sessionID, instructions, observed)
const entries = [...settled, ...assembled.updates].toSorted((a, b) => a.seq - b.seq)
return {
initial: assembled.initial,
messages: settled.map((entry) => entry.message),
messages: entries.map((entry) => entry.message),
instructionUpdate: assembled.update,
}
}),
+230 -56
View File
@@ -1,20 +1,25 @@
export * as InstructionState from "./instruction-state"
import { eq, inArray, sql } from "drizzle-orm"
import { Effect, Option, Schema } from "effect"
import { and, asc, desc, eq, gt, inArray, lte, sql } from "drizzle-orm"
import { DateTime, Effect, Option, Schema } from "effect"
import type { Database } from "../database/database"
import type { Bus } from "../bus"
import { Bus } from "../bus"
import { EventTable } from "../event/sql"
import { Instructions } from "../instructions/index"
import { SessionEvent } from "./event"
import { SessionMessage } from "./message"
import { Event } from "@opencode-ai/schema/event"
import { SessionSchema } from "./schema"
import { InstructionBlobTable, InstructionStateTable } from "./sql"
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 {
readonly sessionID: SessionSchema.ID
readonly initial: boolean
readonly previous: Instructions.Values
readonly current: Instructions.Values
}
@@ -23,14 +28,13 @@ export const observe = Effect.fn("InstructionState.observe")(function* (
instructions: Instructions.Instructions,
sessionID: SessionSchema.ID,
): Effect.fn.Return<Observation, Instructions.InitializationBlocked> {
const [observed, stored] = yield* Effect.all([Instructions.read(instructions), find(db, sessionID)], {
const [observed, stored] = yield* Effect.all([Instructions.read(instructions), ensure(db, sessionID)], {
concurrency: "unbounded",
})
const result = yield* observeAgainst(observed, stored?.current_values)
return {
sessionID,
initial: !stored,
previous: stored?.current_values ?? {},
...result,
}
})
@@ -38,20 +42,12 @@ export const observe = Effect.fn("InstructionState.observe")(function* (
export const commit = Effect.fn("InstructionState.commit")(function* (
db: DatabaseService,
bus: Bus.Interface,
instructions: Instructions.Instructions,
observation: Observation,
) {
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(
SessionEvent.InstructionsUpdated,
{
sessionID: observation.sessionID,
delta: observation.delta,
...(text.length > 0 ? { text } : {}),
},
{ sessionID: observation.sessionID, delta: observation.delta },
{
// Initial sync establishes the baseline; unlike later deltas it is not chronological history.
...(observation.initial ? { metadata: { instructions: { initial: true } } } : {}),
@@ -60,27 +56,13 @@ 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* (
db: DatabaseService,
bus: Bus.Interface,
instructions: Instructions.Instructions,
sessionID: SessionSchema.ID,
) {
yield* commit(db, bus, instructions, yield* observe(db, instructions, sessionID))
yield* commit(db, bus, yield* observe(db, instructions, sessionID))
})
export const apply = Effect.fn("InstructionState.apply")(function* (
@@ -158,24 +140,79 @@ export const reset = Effect.fn("InstructionState.reset")(function* (db: Database
.pipe(Effect.orDie)
})
/** Renders the epoch baseline shown at the start of every model request. */
export const initial = Effect.fn("InstructionState.initial")(function* (
export const rebuild = Effect.fn("InstructionState.rebuild")(function* (
db: DatabaseService,
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,
sessionID: SessionSchema.ID,
instructions: Instructions.Instructions,
) {
const state = yield* find(db, sessionID)
if (!state) return yield* Effect.die(new Error(`Instruction state not found during assembly: ${sessionID}`))
const blobs = yield* loadBlobs(db, Object.values(state.initial_values))
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
const assembled = yield* assembleState(db, sessionID, instructions, state)
return { initial: assembled.initial, updates: assembled.updates }
})
export const preview = Effect.fn("InstructionState.preview")(function* (
@@ -184,26 +221,20 @@ export const preview = Effect.fn("InstructionState.preview")(function* (
instructions: Instructions.Instructions,
observed: Instructions.ReadResult,
) {
const state = yield* find(db, sessionID)
const state = yield* readState(db, sessionID)
const result = yield* observeAgainst(observed, state?.current_values)
const observedBlobs = new Map<Instructions.Hash, Schema.Json>(
const blobs = new Map<Instructions.Hash, Schema.Json>(
Object.entries(result.blobs).map(([hash, value]) => [Instructions.Hash.make(hash), value]),
)
if (!state) {
const values = dereference(result.current, observedBlobs)
return { initial: Instructions.renderInitial(instructions, values), update: "" }
const values = dereference(result.current, blobs)
return { initial: Instructions.renderInitial(instructions, values), updates: [], update: "" }
}
const stored = yield* loadBlobs(db, [
...Object.values(state.initial_values),
...Object.values(state.current_values),
])
const assembled = yield* assembleState(db, sessionID, instructions, state)
return {
initial: Instructions.renderInitial(instructions, dereference(state.initial_values, stored)),
update: Instructions.renderUpdate(
instructions,
dereference(state.current_values, stored),
dereferenceDelta(result.delta, new Map([...stored, ...observedBlobs])),
),
initial: assembled.initial,
updates: assembled.updates,
update: Instructions.renderUpdate(instructions, assembled.current, dereferenceDelta(result.delta, blobs)),
}
})
@@ -224,6 +255,46 @@ const find = Effect.fnUntraced(function* (db: DatabaseService, sessionID: Sessio
.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 rows = Object.entries(blobs).map(([hash, value]) => ({ hash: Instructions.Hash.make(hash), value }))
if (rows.length === 0) return
@@ -268,3 +339,106 @@ function requireBlob(blobs: ReadonlyMap<Instructions.Hash, Schema.Json>, hash: I
if (value === undefined) throw new Error(`Instruction blob not found: ${hash}`)
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,
}
}
+1 -12
View File
@@ -179,18 +179,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
"session.execution.succeeded": () => clearCurrentRetry,
"session.execution.failed": () => clearCurrentRetry,
"session.execution.interrupted": () => clearCurrentRetry,
"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.instructions.updated": () => Effect.void,
"session.synthetic": (event) => {
return adapter.appendMessage(
SessionMessage.Synthetic.make({
+39 -22
View File
@@ -12,8 +12,10 @@ import {
User,
UserData,
} from "@opencode-ai/schema/session-pending"
import { Event } from "@opencode-ai/schema/event"
import type { Database } from "../database/database"
import { Bus } from "../bus"
import { EventTable } from "../event/sql"
import { KeyedMutex } from "../effect/keyed-mutex"
import { SessionEvent } from "./event"
import { SessionMessage } from "./message"
@@ -35,7 +37,11 @@ const decodeUser = Schema.decodeUnknownSync(UserData)
const encodeUser = Schema.encodeSync(UserData)
const decodeSynthetic = Schema.decodeUnknownSync(SyntheticData)
const encodeSynthetic = Schema.encodeSync(SyntheticData)
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
const decodeAdmittedEvent = Schema.decodeUnknownOption(SessionEvent.InputAdmitted.data)
const admittedEventType = Bus.versionedType(
SessionEvent.InputAdmitted.type,
SessionEvent.InputAdmitted.durable.version,
)
const inboxLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict>()(
@@ -97,35 +103,46 @@ export const compaction = Effect.fn("SessionPending.compaction")(function* (
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,
sessionID: SessionSchema.ID,
id: SessionMessage.ID,
delivery: Delivery,
) {
const row = yield* db
const message = yield* db
.select()
.from(SessionMessageTable)
.where(eq(SessionMessageTable.id, id))
.get()
.pipe(Effect.orDie)
if (row === undefined) return undefined
if (row.session_id !== sessionID || (row.type !== "user" && row.type !== "synthetic"))
if (message === undefined) return undefined
if (message.session_id !== sessionID || (message.type !== "user" && message.type !== "synthetic"))
return yield* Effect.die(new LifecycleConflict({ id }))
const message = decodeMessage({ ...row.data, id: row.id, type: row.type })
const base = { id, sessionID, timeCreated: message.time.created, delivery }
if (message.type === "user")
return User.make({
...base,
type: "user",
data: decodeUser(message),
})
if (message.type === "synthetic")
return Synthetic.make({
...base,
type: "synthetic",
data: decodeSynthetic(message),
})
const rows = yield* db
.select()
.from(EventTable)
.where(and(eq(EventTable.aggregate_id, sessionID), eq(EventTable.type, admittedEventType)))
.all()
.pipe(Effect.orDie)
for (const row of rows) {
const decoded = decodeAdmittedEvent(row.data)
if (decoded._tag !== "Some" || decoded.value.inputID !== id) continue
const base = {
id,
sessionID,
timeCreated: DateTime.makeUnsafe(row.created),
}
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 }))
})
@@ -143,7 +160,7 @@ export const admit = Effect.fn("SessionPending.admit")(function* (
if (existing.type === "compaction") return yield* Effect.die(new LifecycleConflict({ id: request.id }))
return existing
}
const promoted = yield* promotedFromMessage(db, request.sessionID, request.id, request.input.delivery)
const promoted = yield* promotedFromHistory(db, request.sessionID, request.id)
if (promoted !== undefined) return promoted
return yield* bus
.publish(SessionEvent.InputAdmitted, {
@@ -409,7 +426,7 @@ const publish = Effect.fn("SessionPending.publish")(function* (
.pipe(
Effect.catchDefect((defect) =>
defect instanceof LifecycleConflict
? promotedFromMessage(db, sessionID, entry.id, entry.delivery).pipe(
? promotedFromHistory(db, sessionID, entry.id).pipe(
Effect.flatMap((stored) => (stored !== undefined ? Effect.void : Effect.die(defect))),
)
: Effect.die(defect),
+59 -15
View File
@@ -1,6 +1,6 @@
export * as SessionProjector from "./projector"
import { and, asc, desc, eq, gt, gte, lt, lte, sql } from "drizzle-orm"
import { and, asc, desc, eq, gt, gte, inArray, lt, lte, sql } from "drizzle-orm"
import { DateTime, Effect, Layer, Schema, Stream } from "effect"
import { Database } from "../database/database"
import { Bus } from "../bus"
@@ -21,7 +21,10 @@ import { Money } from "@opencode-ai/schema/money"
type DatabaseService = Database.Interface["db"]
type CurrentDurableEvent = Extract<SessionEvent.Event, { readonly durable: object }>
type MessageEvent = Exclude<CurrentDurableEvent, typeof SessionEvent.Forked.Type | typeof SessionEvent.Deleted.Type>
type MessageEvent = Exclude<
CurrentDurableEvent,
typeof SessionEvent.Forked.Type | typeof SessionEvent.Deleted.Type | typeof SessionEvent.InstructionsUpdated.Type
>
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
@@ -252,22 +255,66 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
.pipe(Effect.orDie)
if (rows.length === 0) break
const idMap = new Map(rows.map((row) => [row.id, SessionMessage.ID.create()]))
yield* db
.insert(SessionMessageTable)
.values(
rows.map((row) => ({
id: SessionMessage.ID.create(),
session_id: event.data.sessionID,
type: row.type,
seq: row.seq,
time_created: row.time_created,
time_updated: row.time_updated,
data: row.data,
})),
rows.map((row) => {
const id = idMap.get(row.id)
if (!id) throw new Error(`Fork message ID mapping missing: ${row.id}`)
return {
id,
session_id: event.data.sessionID,
type: row.type,
seq: row.seq,
time_created: row.time_created,
time_updated: row.time_updated,
data: row.data,
}
}),
)
.run()
.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
}
if (copiedSeq !== undefined) yield* Bus.reserveSequence(db, event.data.sessionID, copiedSeq)
@@ -635,10 +682,7 @@ const layer = Layer.effectDiscard(
yield* bus.project(SessionEvent.Execution.Failed, (event) => run(db, event))
yield* bus.project(SessionEvent.Execution.Interrupted, (event) => run(db, event))
yield* bus.project(SessionEvent.InstructionsUpdated, (event) =>
Effect.gen(function* () {
yield* run(db, event)
yield* InstructionState.apply(db, event.data.sessionID, event.durable.seq, event.data.delta)
}),
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.Skill.Activated, (event) => run(db, event))
+1 -2
View File
@@ -18,9 +18,8 @@ export function isRetryable(error: AIError) {
switch (error.reason._tag) {
case "RateLimit":
case "ProviderInternal":
return true
case "Transport":
return error.reason.delivery === undefined || error.reason.delivery === "not-sent"
return true
case "InvalidProviderOutput":
return error.reason.classification === "incomplete-stream"
case "Authentication":
+162
View File
@@ -89,6 +89,68 @@ 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", () =>
withTmp((directory) =>
withTmp((outside) =>
@@ -109,6 +171,49 @@ 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", () =>
withTmp((directory) =>
Effect.gen(function* () {
@@ -152,6 +257,63 @@ 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", () =>
withTmp((directory) =>
Effect.gen(function* () {
+11 -55
View File
@@ -14,7 +14,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
import { InstructionState } from "@opencode-ai/core/session/instruction-state"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionSchema } from "@opencode-ai/core/session/schema"
import { InstructionBlobTable, InstructionStateTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
import { InstructionBlobTable, InstructionStateTable, SessionTable } from "@opencode-ai/core/session/sql"
import { testEffect } from "./lib/effect"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node])))
@@ -105,7 +105,6 @@ describe("InstructionState", () => {
expect(observation).toEqual({
sessionID,
initial: true,
previous: {},
current: {
"test/first": Instructions.hash("first"),
"test/second": Instructions.hash("second"),
@@ -157,7 +156,7 @@ describe("InstructionState", () => {
const initial = yield* InstructionState.observe(db, instructions, sessionID)
expect(reads).toBe(2)
yield* InstructionState.commit(db, events, instructions, initial)
yield* InstructionState.commit(db, events, initial)
expect(reads).toBe(2)
current = "changed"
@@ -167,10 +166,6 @@ describe("InstructionState", () => {
expect(changed).toMatchObject({
sessionID,
initial: false,
previous: {
"test/current": Instructions.hash("initial"),
"test/retired": Instructions.hash("retired"),
},
current: { "test/current": Instructions.hash("changed") },
delta: {
"test/current": Instructions.hash("changed"),
@@ -178,7 +173,7 @@ describe("InstructionState", () => {
},
blobs: { [Instructions.hash("changed")]: "changed" },
})
yield* InstructionState.commit(db, events, instructions, changed)
yield* InstructionState.commit(db, events, changed)
expect(reads).toBe(4)
yield* unsubscribe
@@ -195,11 +190,6 @@ describe("InstructionState", () => {
"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({
initial_values: {
"test/current": Instructions.hash("initial"),
@@ -232,19 +222,18 @@ describe("InstructionState", () => {
expect(observation).toEqual({
sessionID,
initial: false,
previous: { "test/context": Instructions.hash("unchanged") },
current: { "test/context": Instructions.hash("unchanged") },
delta: {},
blobs: {},
})
yield* InstructionState.commit(db, events, instructions, observation)
yield* InstructionState.commit(db, events, observation)
expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents)
expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual(beforeBlobs)
}),
)
it.effect("treats a missing state row as a fresh baseline without repairing it", () =>
it.effect("assembles a fresh private update without repairing a missing cache", () =>
Effect.gen(function* () {
const sessionID = SessionSchema.ID.make("ses_instruction_generate")
const { db, events } = yield* setup(sessionID)
@@ -265,7 +254,7 @@ describe("InstructionState", () => {
const assembled = yield* preview(db, sessionID, instructions)
expect(assembled).toEqual({ initial: "Changed context", update: "" })
expect(assembled).toEqual({ initial: "Initial context", updates: [], update: "Changed context" })
expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents)
expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual(beforeBlobs)
expect(
@@ -279,7 +268,7 @@ describe("InstructionState", () => {
}),
)
it.effect("trusts the projected state without consulting durable events", () =>
it.effect("reads through a stale cache without repairing it", () =>
Effect.gen(function* () {
const sessionID = SessionSchema.ID.make("ses_instruction_generate_stale")
const { db, events } = yield* setup(sessionID)
@@ -291,7 +280,6 @@ describe("InstructionState", () => {
yield* InstructionState.prepare(db, events, instructions, sessionID)
value = "Committed update"
yield* InstructionState.prepare(db, events, instructions, sessionID)
// Tamper with the projected state; the authoritative row wins over event history.
yield* db
.update(InstructionStateTable)
.set({ through_seq: 0, current_values: { "test/context": Instructions.hash("Initial context") } })
@@ -306,6 +294,7 @@ describe("InstructionState", () => {
const assembled = yield* preview(db, sessionID, instructions)
expect(assembled.initial).toBe("Initial context")
expect(assembled.updates.map((entry) => entry.message.text)).toEqual(["Committed update"])
expect(assembled.update).toBe("Private update")
expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents)
expect(yield* db.select().from(InstructionBlobTable).all().pipe(Effect.orDie)).toEqual(beforeBlobs)
@@ -313,41 +302,6 @@ 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", () =>
Effect.gen(function* () {
const sessionID = SessionSchema.ID.make("ses_instruction_generate_initial")
@@ -356,6 +310,7 @@ describe("InstructionState", () => {
expect(yield* preview(db, sessionID, instructions)).toEqual({
initial: "Initial context",
updates: [],
update: "",
})
expect(yield* instructionEvents(db, sessionID)).toEqual([])
@@ -381,6 +336,7 @@ describe("InstructionState", () => {
expect(yield* preview(db, sessionID, instructions)).toEqual({
initial: "Committed context",
updates: [],
update: "",
})
expect(yield* instructionEvents(db, sessionID)).toEqual(beforeEvents)
@@ -432,7 +388,7 @@ describe("InstructionState", () => {
for (const next of ["initial", "changed", "changed", Instructions.removed] as const) {
value = next
yield* InstructionState.observe(db, observedInstructions, observedSessionID).pipe(
Effect.flatMap((observation) => InstructionState.commit(db, events, observedInstructions, observation)),
Effect.flatMap((observation) => InstructionState.commit(db, events, observation)),
)
yield* InstructionState.prepare(db, events, preparedInstructions, preparedSessionID)
}
+6 -2
View File
@@ -284,9 +284,13 @@ describe("Session.create", () => {
})
expect(yield* SessionPending.find(db, forkContext[0].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(
yield* session.prompt({ id: forkContext[0].id, sessionID: forked.id, text: "First", resume: false }),
).toMatchObject({ id: forkContext[0].id, type: "user", data: { text: "First" } })
yield* session
.prompt({ id: forkContext[0].id, sessionID: forked.id, text: "First", resume: false })
.pipe(Effect.flip),
).toMatchObject({ _tag: "Session.PromptConflictError", messageID: forkContext[0].id })
yield* session.prompt({
sessionID: parent.id,
-22
View File
@@ -110,26 +110,4 @@ describe("toSessionError", () => {
expect(eligible.map(SessionRunnerRetry.isRetryable)).toEqual([true, true, true])
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])
})
})
-41
View File
@@ -553,47 +553,6 @@ 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", () =>
Effect.gen(function* () {
yield* setup
+12 -22
View File
@@ -1180,7 +1180,7 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("seeds a fork with the parent's newest instruction values", () =>
it.effect("forks instruction values at the selected message instead of the parent's latest state", () =>
Effect.gen(function* () {
const session = yield* setup
yield* runPrompt(session, "First")
@@ -1197,16 +1197,14 @@ describe("SessionRunnerLLM", () => {
.where(eq(InstructionStateTable.session_id, forked.id))
.get(),
).toMatchObject({
initial_values: { "test/context": Instructions.hash("Latest context") },
current_values: { "test/context": Instructions.hash("Latest context") },
initial_values: { "test/context": Instructions.hash("Changed context") },
current_values: { "test/context": Instructions.hash("Changed context") },
})
yield* session.prompt({ sessionID: forked.id, text: "Forked", resume: false })
yield* session.resume(forked.id)
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([defaultSystem, "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")
expect(requests.at(-1)?.system.map((part) => part.text)).toEqual([defaultSystem, "Changed context"])
expect(systemTexts(requests.at(-1)!)).toContain("Latest context")
const { db } = yield* Database.Service
const bus = yield* Bus.Service
@@ -1265,7 +1263,7 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("re-establishes a fresh baseline when instruction state is missing", () =>
it.effect("rebuilds a missing instruction cache without admitting another delta", () =>
Effect.gen(function* () {
const session = yield* setup
const { db } = yield* Database.Service
@@ -1279,15 +1277,13 @@ describe("SessionRunnerLLM", () => {
expect(requests).toHaveLength(1)
expect(requests[0]?.system.map((part) => part.text)).toEqual([defaultSystem, "Initial context"])
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(
yield* db
.select({ data: EventTable.data })
.select({ id: EventTable.id })
.from(EventTable)
.where(eq(EventTable.type, "session.instructions.updated.2"))
.all(),
).toHaveLength(2)
).toHaveLength(1)
expect(yield* db.select().from(InstructionStateTable).get()).toMatchObject({
initial_values: { "test/context": Instructions.hash("Initial context") },
current_values: { "test/context": Instructions.hash("Initial context") },
@@ -1314,10 +1310,7 @@ describe("SessionRunnerLLM", () => {
])
expect(messageRoles(requests[1])).toEqual(["user", "system", "user"])
expect(requests[1]?.messages.at(1)?.content).toEqual([{ type: "text", text: "Changed context" }])
// 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" })
expect(yield* session.messages({ sessionID })).toHaveLength(2)
const { db } = yield* Database.Service
const updates = yield* db
.select({ data: EventTable.data })
@@ -1334,10 +1327,9 @@ describe("SessionRunnerLLM", () => {
expect(updates[1]?.data).toEqual({
sessionID,
delta: { "test/context": Instructions.hash("Changed context") },
text: "Changed context",
})
yield* replaySessionProjection(sessionID)
expect(yield* session.messages({ sessionID })).toHaveLength(3)
expect(yield* session.messages({ sessionID })).toHaveLength(2)
}),
)
@@ -1604,7 +1596,7 @@ describe("SessionRunnerLLM", () => {
expect(requests[1]?.messages.at(1)?.content).toEqual([
{ type: "text", text: "System context source removed: test/context" },
])
expect(yield* session.messages({ sessionID })).toHaveLength(3)
expect(yield* session.messages({ sessionID })).toHaveLength(2)
}),
)
@@ -1716,14 +1708,12 @@ describe("SessionRunnerLLM", () => {
expect(requests[2]?.messages.filter((message) => message.role === "system")).toHaveLength(2)
expect((yield* session.context(sessionID)).map((message) => message.type)).toEqual([
"user",
"system",
"user",
"model-switched",
"system",
"user",
])
yield* replaySessionProjection(sessionID)
expect(yield* session.messages({ sessionID })).toHaveLength(6)
expect(yield* session.messages({ sessionID })).toHaveLength(4)
yield* runPrompt(session, "Fourth")
}),
)
-5
View File
@@ -186,11 +186,6 @@ export const InstructionsUpdated = Event.durable({
schema: {
...Base,
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
@@ -147,12 +147,12 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?:
const config = createTuiResolvedConfig()
const transport = createFetch((url) => {
if (url.pathname !== "/api/vcs/diff") return
if (fail) return json({ message: "boom" }, { status: 500 })
vcsDiffInput = {
location: { directory: url.searchParams.get("location[directory]") },
mode: url.searchParams.get("mode"),
context: url.searchParams.get("context"),
}
if (fail) return json({ message: "boom" }, { status: 500 })
return json({
location: { directory: "/repo/session", project: { id: "project-1", directory: "/repo/session" } },
data: vcsDiff,
@@ -238,7 +238,6 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?:
const app = await testRender(() => <Harness />, { width: 80, height })
await waitForCommand(app, commands, "diff.close")
await app.waitFor(() => vcsDiffInput !== undefined)
return {
app,
commands,