Compare commits

...
20 Commits
Author SHA1 Message Date
rekram1-node 094f19cd95 fix(core): preserve Merge model override settings 2026-08-28 20:34:10 +00:00
rekram1-node 5d318cd9d7 refactor(core): select native Merge provider in plugin 2026-08-28 20:32:09 +00:00
rekram1-node 9bb2db2f23 refactor(core): isolate Merge Gateway mapping 2026-08-28 20:20:18 +00:00
rekram1-node c59223c57b fix(core): route Merge Gateway through native chat 2026-08-28 19:23:43 +00:00
Kit Langton 3151660fbb refactor(core): name shell records as commands (#45693) 2026-08-28 15:00:37 -04:00
Kit Langton 0362ef48ff test(core): isolate transport metrics (#45688) 2026-08-28 15:00:33 -04:00
Kit Langton facd7ff452 refactor(core): simplify V1 migration effects (#45685) 2026-08-28 15:00:29 -04:00
Kit Langton 134cdda333 fix(core): normalize SDK file data (#45679) 2026-08-28 14:56:46 -04:00
Kit Langton 5634ef1bb6 refactor(core): simplify manual compaction (#45678) 2026-08-28 14:56:40 -04:00
Kit Langton 2379ab3d51 fix(core): defer memory filesystem observation (#45675) 2026-08-28 14:56:36 -04:00
Kit Langton 5c908ebba5 refactor(core): simplify reference config precedence (#45673) 2026-08-28 14:56:31 -04:00
Kit Langton ba0755d933 refactor(core): reuse platform contract types (#45666) 2026-08-28 14:56:27 -04:00
Kit Langton f7d6b00c1e test(core): use collected arrays directly (#45665) 2026-08-28 14:56:21 -04:00
Kit Langton 0bb6cf37be fix(tui): animate automatic session renames (#45957) 2026-08-28 18:43:53 +00:00
Kit Langton da57b27277 refactor(core): flatten durable commit validation (#45662) 2026-08-28 14:39:43 -04:00
Kit Langton 42a3fec594 docs(core): clarify runtime ownership (#45671) 2026-08-28 14:39:28 -04:00
Kit Langton d15034264b test(core): reuse session projection fixtures (#45661) 2026-08-28 14:37:33 -04:00
Kit Langton 1ce3c7e580 fix(tui): stop flashing when jumping to session start
Buffer bulk history privately and publish once with a bounded head window. Preserve pending anchor compensation while ensuring newer navigation cancels obsolete Home work. Add atomic-loading and real-App navigation regressions.
2026-08-28 14:35:31 -04:00
Kit Langton 07f27c4eca refactor(core): simplify migration narrowing (#45677) 2026-08-28 14:25:34 -04:00
Kit Langton 196893cfeb refactor(core): remove unused config editor (#45991) 2026-08-28 14:21:21 -04:00
49 changed files with 2360 additions and 1555 deletions
+62 -11
View File
@@ -307,6 +307,7 @@ export function createData(config: CreateDataInput) {
// submission order. Each waits for the previous POST to settle, so one
// failure does not block the next.
const sending = new Map<string, Promise<unknown>>()
const messageLoads = new Map<string, Promise<unknown>>()
const compacting = new Map<string, { id: string; observed: Set<string>; request: Promise<SessionInboxCompaction> }>()
onCleanup(() => compacting.clear())
@@ -1529,20 +1530,70 @@ export function createData(config: CreateDataInput) {
loading(sessionID: string) {
return store.session.messageLoading[sessionID] ?? false
},
async loadMore(sessionID: string) {
async loadMore(
sessionID: string,
options?: {
all?: boolean
signal?: AbortSignal
/** Runs synchronously inside the store-publication batch. */
beforePublish?: () => void
},
) {
const signal = options?.signal
if (signal?.aborted) return
while (messageLoads.has(sessionID)) {
const published = await (() => {
const pending = messageLoads.get(sessionID)
if (!signal) return pending
const aborted = Promise.withResolvers<void>()
const cancel = () => aborted.resolve()
signal.addEventListener("abort", cancel, { once: true })
return Promise.race([pending, aborted.promise])
.catch((error) => {
if (!signal.aborted) throw error
})
.finally(() => signal.removeEventListener("abort", cancel))
})()
if ((!options?.all && published) || signal?.aborted) return
}
const cursor = store.session.messageCursor[sessionID]
if (!cursor || store.session.messageLoading[sessionID]) return
if (!cursor || signal?.aborted) return
setStore("session", "messageLoading", sessionID, true)
const response = await api()
.message.list({ sessionID, limit: messagePageLimit, cursor })
const request = (async () => {
const fetched: SessionMessageInfo[] = []
let next: string | undefined = cursor
do {
const response = await api().message.list(
{
sessionID,
limit: options?.all ? 200 : messagePageLimit,
cursor: next,
},
{ signal },
)
if (signal?.aborted) return
fetched.push(...response.data)
next = response.cursor.next ?? undefined
if (!options?.all) break
} while (next)
// A jump through history publishes once, not once per page of offscreen messages.
const existing = store.session.message[sessionID] ?? []
const ids = new Set(existing.map((item) => item.id))
const messages = [...fetched.reverse().filter((item) => !ids.has(item.id)), ...existing]
batch(() => {
options?.beforePublish?.()
messageIndex.set(sessionID, new Map(messages.map((item, position) => [item.id, position])))
setStore("session", "message", sessionID, reconcile(messages))
setStore("session", "messageCursor", sessionID, next)
})
return true
})()
.catch((error) => {
if (!signal?.aborted) throw error
})
.finally(() => setStore("session", "messageLoading", sessionID, false))
const older = response.data.toReversed()
const existing = store.session.message[sessionID] ?? []
const ids = new Set(existing.map((item) => item.id))
const messages = [...older.filter((item) => !ids.has(item.id)), ...existing]
messageIndex.set(sessionID, new Map(messages.map((item, position) => [item.id, position])))
setStore("session", "message", sessionID, reconcile(messages))
setStore("session", "messageCursor", sessionID, response.cursor.next ?? undefined)
track(messageLoads, sessionID, request)
await request
},
invalidate(sessionID: string) {
sync.invalidate(`session.message:${sessionID}`)
+115
View File
@@ -1,4 +1,5 @@
import { expect, test } from "bun:test"
import { getEventListeners } from "node:events"
import { createRoot } from "solid-js"
import { createData, type CreateDataInput } from "../src/solid"
import { OpenCode, type OpenCodeEvent, type Project, type SessionInfo } from "../src/promise"
@@ -414,6 +415,120 @@ test("loads bounded message pages", async () => {
}
})
test.each(["success", "failure", "cancel", "cancel-retry", "cancel-page", "join-cancel", "join-failure"])(
"bulk history (%s)",
async (mode) => {
const messages = [1, 2, 3].map((index) => ({
id: `msg_${index}`,
type: "user",
text: `Message ${index}`,
time: { created: index },
}))
const release = Promise.withResolvers<void>()
const controller = new AbortController()
const requests: URL[] = []
const publications: string[][] = []
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async (input, init) => {
const url = new URL(input instanceof Request ? input.url : String(input))
requests.push(url)
const cursor = url.searchParams.get("cursor")
if (!cursor) return Response.json({ data: [messages[2]], cursor: { next: "recent" } })
if (cursor === "recent") {
if (mode.startsWith("join")) await release.promise
if (mode === "join-failure") return Response.json({ message: "offline" }, { status: 503 })
return Response.json({ data: [messages[2], messages[1]], cursor: { next: "oldest" } })
}
if (cursor === "oldest") return Response.json({ data: [messages[0]], cursor: { next: "empty" } })
expect(init?.signal).toBe(requests.length === 4 ? controller.signal : undefined)
await release.promise
if (mode === "failure") return Response.json({ message: "offline" }, { status: 503 })
return Response.json({ data: [], cursor: {} })
},
})
const setup = createRoot((dispose) => {
const data = createData({
api: () => api,
directory: "/project",
event: { on: () => () => {}, listen: () => () => {} },
})
return { data, dispose }
})
try {
await setup.data.session.message.sync("ses_refresh")
const newest = setup.data.session.message.get("ses_refresh", "msg_3")
const load = setup.data.session.message.loadMore(
"ses_refresh",
mode.startsWith("join")
? undefined
: {
all: true,
signal: controller.signal,
beforePublish: () => {
publications.push(setup.data.session.message.list("ses_refresh").map((message) => message.id))
expect(setup.data.session.message.get("ses_refresh", "msg_3")).toBe(newest)
},
},
)
const joined = setup.data.session.message.loadMore("ses_refresh", { all: true, signal: controller.signal })
const settled = Promise.allSettled([load, joined])
if (mode.startsWith("join")) {
await wait(() => requests.length === 2)
expect(getEventListeners(controller.signal, "abort")).toHaveLength(1)
controller.abort()
let cancelled = false
void joined.then(() => {
cancelled = true
})
await wait(() => cancelled)
expect(setup.data.session.message.loading("ses_refresh")).toBe(true)
expect(getEventListeners(controller.signal, "abort")).toHaveLength(0)
release.resolve()
expect((await settled).map((result) => result.status)).toEqual(
mode === "join-failure" ? ["rejected", "fulfilled"] : ["fulfilled", "fulfilled"],
)
expect(requests.at(-1)?.searchParams.get("limit")).toBe("20")
expect(requests).toHaveLength(2)
expect(setup.data.session.message.more("ses_refresh")).toBe(true)
expect(setup.data.session.message.list("ses_refresh").map((message) => message.id)).toEqual(
mode === "join-failure" ? ["msg_3"] : ["msg_2", "msg_3"],
)
return
}
await wait(() => requests.length === 4)
expect(setup.data.session.message.loading("ses_refresh")).toBe(true)
expect(setup.data.session.message.list("ses_refresh").map((message) => message.id)).toEqual(["msg_3"])
expect(requests.slice(1).map((url) => url.searchParams.get("limit"))).toEqual(["200", "200", "200"])
if (mode.startsWith("cancel")) controller.abort()
const retry =
mode === "cancel-retry" || mode === "cancel-page"
? setup.data.session.message.loadMore("ses_refresh", mode === "cancel-retry" ? { all: true } : undefined)
: undefined
release.resolve()
expect((await settled).map((result) => result.status)).toEqual(
mode === "failure" ? ["rejected", "rejected"] : ["fulfilled", "fulfilled"],
)
await retry
const success = mode === "success" || mode === "cancel-retry"
expect(setup.data.session.message.loading("ses_refresh")).toBe(false)
expect(setup.data.session.message.more("ses_refresh")).toBe(!success)
expect(setup.data.session.message.list("ses_refresh").map((message) => message.id)).toEqual(
success ? ["msg_1", "msg_2", "msg_3"] : mode === "cancel-page" ? ["msg_2", "msg_3"] : ["msg_3"],
)
expect(setup.data.session.message.get("ses_refresh", "msg_3")).toBe(newest)
expect(requests).toHaveLength(mode === "cancel-retry" ? 7 : mode === "cancel-page" ? 5 : 4)
if (mode === "cancel-page") expect(requests.at(-1)?.searchParams.get("limit")).toBe("20")
expect(publications).toEqual(mode === "success" ? [["msg_3"]] : [])
expect(getEventListeners(controller.signal, "abort")).toHaveLength(0)
} finally {
release.resolve()
setup.dispose()
}
},
)
test("preserves assistant content replacement events across an active message read", async () => {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const release = Promise.withResolvers<void>()
+12 -4
View File
@@ -482,8 +482,7 @@ function toolMessage(input: LLMRequest["messages"][number]) {
const value = part.result.value.filter((item) => {
if (item.type !== "file") return true
if (!item.mime.startsWith("image/") && item.mime !== "application/pdf") return true
const data = /^data:[^;,]+(?:;[^,]*)*;base64,(.*)$/s.exec(item.uri)?.[1] ?? item.uri
media.push({ type: "file", mediaType: item.mime, data, filename: item.name })
media.push({ type: "file", mediaType: item.mime, data: fileData(item.uri), filename: item.name })
return false
})
return toolResultPart({
@@ -507,7 +506,7 @@ function text(part: ContentPart) {
function userPart(part: ContentPart): UserContent {
if (part.type === "text") return [{ type: "text", text: part.text }]
if (part.type === "media")
return [{ type: "file", mediaType: part.mediaType, data: part.data, filename: part.filename }]
return [{ type: "file", mediaType: part.mediaType, data: fileData(part.data), filename: part.filename }]
return []
}
@@ -516,7 +515,7 @@ function assistantPart(part: ContentPart): AssistantContent {
case "text":
return [{ type: "text", text: part.text, providerOptions: metadataProviderOptions(part.providerMetadata) }]
case "media":
return [{ type: "file", mediaType: part.mediaType, data: part.data, filename: part.filename }]
return [{ type: "file", mediaType: part.mediaType, data: fileData(part.data), filename: part.filename }]
case "reasoning":
return [{ type: "reasoning", text: part.text, providerOptions: metadataProviderOptions(part.providerMetadata) }]
case "tool-call":
@@ -535,6 +534,15 @@ function assistantPart(part: ContentPart): AssistantContent {
}
}
function fileData(data: Extract<ContentPart, { type: "media" }>["data"]) {
if (typeof data !== "string") return data
const base64 = /^data:[^;,]+(?:;[^,]*)*;base64,(.*)$/s.exec(data)?.[1]
if (base64 !== undefined) return base64
if (!URL.canParse(data)) return data
const url = new URL(data)
return url.protocol === "http:" || url.protocol === "https:" ? url : data
}
function toolResultPart(part: ContentPart): ToolResultContent[] {
if (part.type !== "tool-result") return []
return [
+147 -153
View File
@@ -294,162 +294,156 @@ export function configured(options?: Options) {
) {
return Effect.gen(function* () {
const durable = definition.durable
if (durable) {
const aggregateID = (event.data as Record<string, unknown>)[durable.aggregate]
if (typeof aggregateID !== "string") {
yield* Effect.die(
new InvalidDurableEventError({
type: event.type,
message: `Expected string aggregate field ${durable.aggregate}`,
}),
)
} else {
if (input && input.aggregateID !== aggregateID) {
yield* Effect.die(
new InvalidDurableEventError({
type: event.type,
message: `Aggregate mismatch: expected ${input.aggregateID}, got ${aggregateID}`,
}),
)
}
const list = projectors.get(versionedType(definition.type, durable.version)) ?? []
return yield* Effect.uninterruptible(
Effect.gen(function* () {
const committed = yield* db
.transaction(
() =>
Effect.gen(function* () {
const row = yield* db
.select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
.from(EventSequenceTable)
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
.get()
.pipe(Effect.orDie)
const latest = row?.seq ?? -1
const encoded = Schema.encodeUnknownSync(definition.data)(event.data) as Record<
string,
unknown
>
if (input?.strictOwner && row?.ownerID && row.ownerID !== input.ownerID) {
yield* Effect.die(
new InvalidDurableEventError({
type: event.type,
message: `Replay owner mismatch for aggregate ${aggregateID}: expected ${row.ownerID}, got ${input.ownerID ?? "none"}`,
}),
)
}
if (input && input.seq <= latest) {
if (!persist) return
const stored = yield* db
.select()
.from(EventTable)
.where(and(eq(EventTable.aggregate_id, aggregateID), eq(EventTable.seq, input.seq)))
.get()
.pipe(Effect.orDie)
if (
stored?.id === event.id &&
stored.type === versionedType(definition.type, durable.version) &&
stored.created === (event.created ?? 0) &&
isDeepStrictEqual(stored.data, encoded)
) {
if (input.ownerID && row?.ownerID == null) {
yield* db
.update(EventSequenceTable)
.set({ owner_id: input.ownerID })
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
.run()
.pipe(Effect.orDie)
}
return
}
yield* Effect.die(
new InvalidDurableEventError({
type: event.type,
message: `Replay diverged at aggregate ${aggregateID} sequence ${input.seq}`,
}),
)
}
if (input && row?.ownerID && row.ownerID !== input.ownerID) {
return
}
const seq = input?.seq ?? latest + 1
if (input && seq !== latest + 1) {
yield* Effect.die(
new InvalidDurableEventError({
type: event.type,
message: `Sequence mismatch for aggregate ${aggregateID}: expected ${latest + 1}, got ${seq}`,
}),
)
}
if (persist) {
const stored = yield* db
.select({ aggregateID: EventTable.aggregate_id, seq: EventTable.seq })
.from(EventTable)
.where(eq(EventTable.id, event.id))
.get()
.pipe(Effect.orDie)
if (stored)
yield* Effect.die(
new InvalidDurableEventError({
type: event.type,
message: `Event ${event.id} already exists at aggregate ${stored.aggregateID} sequence ${stored.seq}`,
}),
)
}
const committed = {
...event,
durable: { aggregateID, seq, version: durable.version },
} as Event.Payload
const route = yield* prepareRoutes([committed])
for (const projector of list) {
yield* projector(committed)
}
if (commit) yield* commit(seq)
yield* db
.insert(EventSequenceTable)
.values([{ aggregate_id: aggregateID, seq, owner_id: input?.ownerID }])
.onConflictDoUpdate({
target: EventSequenceTable.aggregate_id,
set: {
seq: sql`max(${EventSequenceTable.seq}, ${seq})`,
...(input?.ownerID && row?.ownerID == null ? { owner_id: input.ownerID } : {}),
},
})
.run()
.pipe(Effect.orDie)
if (persist)
if (!durable) return yield* Effect.void
const aggregateID = (event.data as Record<string, unknown>)[durable.aggregate]
if (typeof aggregateID !== "string")
return yield* Effect.die(
new InvalidDurableEventError({
type: event.type,
message: `Expected string aggregate field ${durable.aggregate}`,
}),
)
if (input && input.aggregateID !== aggregateID) {
yield* Effect.die(
new InvalidDurableEventError({
type: event.type,
message: `Aggregate mismatch: expected ${input.aggregateID}, got ${aggregateID}`,
}),
)
}
const list = projectors.get(versionedType(definition.type, durable.version)) ?? []
return yield* Effect.uninterruptible(
Effect.gen(function* () {
const committed = yield* db
.transaction(
() =>
Effect.gen(function* () {
const row = yield* db
.select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
.from(EventSequenceTable)
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
.get()
.pipe(Effect.orDie)
const latest = row?.seq ?? -1
const encoded = Schema.encodeUnknownSync(definition.data)(event.data) as Record<string, unknown>
if (input?.strictOwner && row?.ownerID && row.ownerID !== input.ownerID) {
yield* Effect.die(
new InvalidDurableEventError({
type: event.type,
message: `Replay owner mismatch for aggregate ${aggregateID}: expected ${row.ownerID}, got ${input.ownerID ?? "none"}`,
}),
)
}
if (input && input.seq <= latest) {
if (!persist) return
const stored = yield* db
.select()
.from(EventTable)
.where(and(eq(EventTable.aggregate_id, aggregateID), eq(EventTable.seq, input.seq)))
.get()
.pipe(Effect.orDie)
if (
stored?.id === event.id &&
stored.type === versionedType(definition.type, durable.version) &&
stored.created === (event.created ?? 0) &&
isDeepStrictEqual(stored.data, encoded)
) {
if (input.ownerID && row?.ownerID == null) {
yield* db
.insert(EventTable)
.values([
{
id: event.id,
aggregate_id: aggregateID,
seq,
created: event.created ?? 0,
type: versionedType(definition.type, durable.version),
data: encoded,
},
])
.update(EventSequenceTable)
.set({ owner_id: input.ownerID })
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
.run()
.pipe(Effect.orDie)
return { aggregateID, seq, event: committed, route }
}),
{ behavior: "immediate" },
)
.pipe(Effect.orDie)
if (committed) {
committed.route()
yield* Effect.forEach(
pubsub.durable.get(committed.aggregateID) ?? [],
(wake) => PubSub.publish(wake, undefined),
{ discard: true },
)
}
return committed
}),
)
}
}
}
return
}
yield* Effect.die(
new InvalidDurableEventError({
type: event.type,
message: `Replay diverged at aggregate ${aggregateID} sequence ${input.seq}`,
}),
)
}
if (input && row?.ownerID && row.ownerID !== input.ownerID) {
return
}
const seq = input?.seq ?? latest + 1
if (input && seq !== latest + 1) {
yield* Effect.die(
new InvalidDurableEventError({
type: event.type,
message: `Sequence mismatch for aggregate ${aggregateID}: expected ${latest + 1}, got ${seq}`,
}),
)
}
if (persist) {
const stored = yield* db
.select({ aggregateID: EventTable.aggregate_id, seq: EventTable.seq })
.from(EventTable)
.where(eq(EventTable.id, event.id))
.get()
.pipe(Effect.orDie)
if (stored)
yield* Effect.die(
new InvalidDurableEventError({
type: event.type,
message: `Event ${event.id} already exists at aggregate ${stored.aggregateID} sequence ${stored.seq}`,
}),
)
}
const committed = {
...event,
durable: { aggregateID, seq, version: durable.version },
} as Event.Payload
const route = yield* prepareRoutes([committed])
for (const projector of list) {
yield* projector(committed)
}
if (commit) yield* commit(seq)
yield* db
.insert(EventSequenceTable)
.values([{ aggregate_id: aggregateID, seq, owner_id: input?.ownerID }])
.onConflictDoUpdate({
target: EventSequenceTable.aggregate_id,
set: {
seq: sql`max(${EventSequenceTable.seq}, ${seq})`,
...(input?.ownerID && row?.ownerID == null ? { owner_id: input.ownerID } : {}),
},
})
.run()
.pipe(Effect.orDie)
if (persist)
yield* db
.insert(EventTable)
.values([
{
id: event.id,
aggregate_id: aggregateID,
seq,
created: event.created ?? 0,
type: versionedType(definition.type, durable.version),
data: encoded,
},
])
.run()
.pipe(Effect.orDie)
return { aggregateID, seq, event: committed, route }
}),
{ behavior: "immediate" },
)
.pipe(Effect.orDie)
if (committed) {
committed.route()
yield* Effect.forEach(
pubsub.durable.get(committed.aggregateID) ?? [],
(wake) => PubSub.publish(wake, undefined),
{ discard: true },
)
}
return committed
}),
)
})
}
-150
View File
@@ -1,150 +0,0 @@
export * as ConfigFile from "./file.js"
import { isDeepStrictEqual } from "node:util"
import { isRecord } from "@opencode-ai/ai/utils/record"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Effect, Schema, Semaphore } from "effect"
import {
applyEdits,
createScanner,
findNodeAtLocation,
modify,
parseTree,
type Node,
type ParseError,
} from "jsonc-parser"
export class UpdateError extends Schema.TaggedError<UpdateError>()("ConfigFile.UpdateError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect()),
}) {}
const isJson = Schema.is(Schema.MutableJson)
const isDocument = (value: unknown): value is Schema.MutableJsonObject => isRecord(value) && isJson(value)
const lock = Semaphore.makeUnsafe(1)
/**
* Edits an existing JSON(C) file using raw source values, not resolved Config.Info.
* The synchronous callback mutates a source clone; its return value is ignored.
* Validates JSON only; normalization and substitution remain the reader's job.
* Does not discover files, start watchers, or refresh Config state.
* Read-modify-write calls are serialized within this process.
*/
export const update = Effect.fn("ConfigFile.update")(
function* (
filepath: string,
mutate: (draft: Schema.MutableJsonObject) => void,
): Effect.fn.Return<Schema.JsonObject, UpdateError, FSUtil.Service> {
const fs = yield* FSUtil.Service
const text = yield* fs
.readFileString(filepath)
.pipe(Effect.mapError((cause) => new UpdateError({ message: `Failed to read config: ${filepath}`, cause })))
const errors: ParseError[] = []
const current = parseSource(text, errors)
if (errors.length || !isDocument(current))
return yield* Effect.fail(new UpdateError({ message: `Invalid config file: ${filepath}` }))
const next = yield* Effect.try({
try: () => {
const draft = structuredClone(current)
mutate(draft)
return draft
},
catch: (cause) => new UpdateError({ message: "Config update failed", cause }),
})
if (!isDocument(next))
return yield* Effect.fail(new UpdateError({ message: `Config update must produce a JSON object: ${filepath}` }))
const edits = changes(current, next)
if (!edits.length) return next
const updated = yield* Effect.try({
try: () => edits.reduce(patch, text),
catch: (cause) => new UpdateError({ message: `Failed to patch config: ${filepath}`, cause }),
})
// Duplicate keys can make parse choose the last value while modify edits the first.
const written = parseSource(updated, errors)
if (errors.length || !isDeepStrictEqual(written, next))
return yield* Effect.fail(
new UpdateError({ message: `Config patch does not match the requested update: ${filepath}` }),
)
const temporary = filepath + ".tmp"
yield* fs.writeFileString(temporary, updated.endsWith("\n") ? updated : updated + "\n").pipe(
Effect.andThen(fs.rename(temporary, filepath)),
Effect.mapError((cause) => new UpdateError({ message: `Failed to write config: ${filepath}`, cause })),
)
return next
},
(effect) => lock.withPermit(effect),
)
type Edit = { readonly path: (string | number)[]; readonly value: unknown }
function parseSource(text: string, errors: ParseError[]) {
const root = parseTree(text, errors, { allowTrailingComma: true })
if (!root || errors.length) return undefined
// parse() assigns onto {}, invoking the __proto__ setter instead of retaining
// an own JSON key. Construct object entries from the AST without those setters.
const value = (node: Node): unknown => {
if (node.type === "array") return (node.children ?? []).map(value)
if (node.type === "object")
return Object.fromEntries(
(node.children ?? []).map((property) => {
const child = property.children?.[1]
return [property.children?.[0]?.value, child && value(child)]
}),
)
return node.value
}
return value(root)
}
function patch(text: string, edit: Edit) {
if (edit.value !== undefined)
return applyEdits(
text,
modify(text, edit.path, edit.value, { formattingOptions: { tabSize: 2, insertSpaces: true } }),
)
const tree = parseTree(text)
const node = tree && findNodeAtLocation(tree, edit.path)
if (!node) return text
// jsonc-parser removes adjacent comments along with the separator. Remove only
// the property/element itself and one comma, leaving surrounding comments intact.
const target = node.parent?.type === "property" ? node.parent : node
const siblings = target.parent?.children ?? []
const previous = siblings[siblings.indexOf(target) - 1]
const scanner = createScanner(text, true)
scanner.setPosition(target.offset + target.length)
scanner.scan()
const following = text[scanner.getTokenOffset()] === ","
if (!following && previous) {
scanner.setPosition(previous.offset + previous.length)
scanner.scan()
}
return applyEdits(text, [
{ offset: target.offset, length: target.length, content: "" },
...(following || previous ? [{ offset: scanner.getTokenOffset(), length: 1, content: "" }] : []),
])
}
function changes(before: unknown, after: unknown, path: (string | number)[] = []): Edit[] {
if (isDeepStrictEqual(before, after)) return []
if (Array.isArray(before) && Array.isArray(after)) {
return [
...after.flatMap((value, index) => changes(before[index], value, [...path, index])),
// Remove from the end so earlier deletions cannot shift later paths.
...before
.slice(after.length)
.map((_, index) => ({ path: [...path, after.length + index], value: undefined }))
.toReversed(),
]
}
if (isRecord(before) && isRecord(after)) {
return [...new Set([...Object.keys(before), ...Object.keys(after)])].flatMap((key) => {
if (!Object.hasOwn(after, key)) return [{ path: [...path, key], value: undefined }]
if (!Object.hasOwn(before, key)) return [{ path: [...path, key], value: after[key] }]
return changes(before[key], after[key], [...path, key])
})
}
return [{ path, value: after }]
}
+1 -3
View File
@@ -20,14 +20,13 @@ export const Plugin = define({
const global = yield* Global.Service
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, ctx.reference.reload())
yield* ctx.reference.transform((draft) => {
const entries = new Map<string, Reference.Source>()
for (const doc of loaded.entries.filter((entry): entry is Document => entry.type === "document")) {
const directory = doc.path ? path.dirname(doc.path) : location.directory
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
if (!validAlias(name)) continue
const description = typeof entry === "string" ? undefined : entry.description
const hidden = typeof entry === "string" ? undefined : entry.hidden
entries.set(
draft.add(
name,
local(entry)
? Reference.LocalSource.make({
@@ -48,7 +47,6 @@ export const Plugin = define({
)
}
}
for (const [name, source] of entries) draft.add(name, source)
})
}),
})
+1 -1
View File
@@ -212,7 +212,7 @@ const nativeLayer = (config: Config) =>
: Layer.effect(
Sqlite.Native,
Effect.die(
"workerd sqlite cannot open a database from a path; use Database.layerWith(sqliteLayer({ storage }))",
"workerd sqlite cannot open a database from a path; use Database.layerFromClient.pipe(Layer.provide(sqliteLayer({ storage })))",
),
)
+151 -161
View File
@@ -275,22 +275,22 @@ export function transformSession(input: TransformInput): TransformResult {
if (paired.has(item.row.id)) return []
const owned = byMessage.get(item.row.id)?.map((part) => part.value) ?? []
if (item.value.role === "user") {
const compaction = owned.find((part) => part.type === "compaction")
if (compaction?.type === "compaction") {
const compaction = owned.find((part): part is SessionV1.CompactionPart => part.type === "compaction")
if (compaction) {
const pairedSummary = messages.find(
(candidate) =>
(candidate): candidate is (typeof messages)[number] & { value: SessionV1.Assistant } =>
candidate.value.role === "assistant" &&
candidate.value.parentID === item.row.id &&
candidate.value.summary,
candidate.value.summary === true,
)
if (!pairedSummary || pairedSummary.value.role !== "assistant") return []
if (!pairedSummary) return []
paired.add(pairedSummary.row.id)
if (pairedSummary.value.error || pairedSummary.value.time.completed === undefined) return []
const summary = pairedSummary
const summaryText = (byMessage.get(summary.row.id) ?? [])
.map((part) => part.value)
.filter((part) => part.type === "text" && part.text.length > 0)
.map((part) => (part.type === "text" ? part.text : ""))
.filter((part): part is SessionV1.TextPart => part.type === "text" && part.text.length > 0)
.map((part) => part.text)
.join("\n\n")
const tailIndex = compaction.tail_start_id
? messages.findIndex((candidate) => candidate.row.id === compaction.tail_start_id)
@@ -313,16 +313,14 @@ export function transformSession(input: TransformInput): TransformResult {
]
}
const subtasks = owned.filter((part) => part.type === "subtask")
const visible = owned.filter((part) => part.type === "text" && !part.ignored)
const files = owned.filter((part) => part.type === "file")
const agents = owned.filter((part) => part.type === "agent")
const visible = owned.filter((part): part is SessionV1.TextPart => part.type === "text" && !part.ignored)
const files = owned.filter((part): part is SessionV1.FilePart => part.type === "file")
const agents = owned.filter((part): part is SessionV1.AgentPart => part.type === "agent")
if (subtasks.length > 0 && visible.length === 0 && files.length === 0 && agents.length === 0) return []
const ordinary = visible.filter((part) => part.type === "text" && !part.synthetic)
const synthetic = visible.filter((part) => part.type === "text" && part.synthetic)
const attachments = files.flatMap((part) => (part.type === "file" ? migrateFile(part) : []))
const unavailable = files.flatMap((part) =>
part.type === "file" && !part.url.startsWith("data:") ? [unavailableFile(part)] : [],
)
const ordinary = visible.filter((part) => !part.synthetic)
const synthetic = visible.filter((part) => part.synthetic)
const attachments = files.flatMap((part) => migrateFile(part))
const unavailable = files.flatMap((part) => (!part.url.startsWith("data:") ? [unavailableFile(part)] : []))
const text = owned
.flatMap((part) => {
if (part.type === "text" && !part.ignored && !part.synthetic) return [part.text]
@@ -330,16 +328,12 @@ export function transformSession(input: TransformInput): TransformResult {
return []
})
.join("\n\n")
const agentAttachments = agents.map((part) =>
part.type === "agent"
? {
name: part.name,
...(part.source
? { mention: { text: part.source.value, start: part.source.start, end: part.source.end } }
: {}),
}
: { name: "" },
)
const agentAttachments = agents.map((part) => ({
name: part.name,
...(part.source
? { mention: { text: part.source.value, start: part.source.start, end: part.source.end } }
: {}),
}))
if (
ordinary.length === 0 &&
unavailable.length === 0 &&
@@ -351,7 +345,7 @@ export function transformSession(input: TransformInput): TransformResult {
row(item.row, {
id: item.row.id,
type: "synthetic",
text: synthetic.map((part) => (part.type === "text" ? part.text : "")).join("\n\n"),
text: synthetic.map((part) => part.text).join("\n\n"),
time: { created: item.row.time_created },
}),
]
@@ -369,7 +363,7 @@ export function transformSession(input: TransformInput): TransformResult {
row(item.row, {
id: syntheticID(item.row.id, used),
type: "synthetic",
text: synthetic.map((part) => (part.type === "text" ? part.text : "")).join("\n\n"),
text: synthetic.map((part) => part.text).join("\n\n"),
time: { created: item.row.time_created },
}),
]
@@ -443,7 +437,6 @@ export function transformSession(input: TransformInput): TransformResult {
})
.map((item, seq) => ({ ...item, seq }))
const assistants = messages
.filter((item) => item.value.role === "assistant")
.map((item) => item.value)
.filter((item): item is SessionV1.Assistant => item.role === "assistant")
const latestUser = messages.findLast((item) => {
@@ -488,7 +481,7 @@ export function status(): Effect.Effect<Status, never, Database.Service> {
if (runtimeState.status === "error") return runtimeState
if (state?.phase === "completed") return { status: "completed" as const }
return { status: "required" as const }
}).pipe(Effect.orDie)
})
}
export const layer = Layer.effectDiscard(
@@ -528,76 +521,75 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
const state = yield* readState(db)
if (state?.phase === "completed") return { status: "completed" as const }
if (!(yield* hasLegacySessions(db))) return { status: "completed" as const }
const migrate = Effect.gen(function* () {
const now = Date.now()
yield* db.run(sql`
const now = Date.now()
yield* db.run(sql`
INSERT OR IGNORE INTO project (id, worktree, time_created, time_updated, sandboxes)
VALUES (${Project.ID.global}, ${path.parse(global.data).root}, ${now}, ${now}, '[]')
`)
if (state === undefined)
yield* db
.transaction((tx) =>
Effect.gen(function* () {
while (true) {
yield* tx.run(sql`
if (state === undefined)
yield* db
.transaction((tx) =>
Effect.gen(function* () {
while (true) {
yield* tx.run(sql`
DELETE FROM event
WHERE rowid IN (SELECT rowid FROM event LIMIT ${EVENT_DELETE_BATCH_SIZE})
`)
const deleted = (yield* tx.get<{ value: number }>(sql`SELECT changes() AS value`))?.value ?? 0
if (deleted < EVENT_DELETE_BATCH_SIZE) break
yield* Effect.yieldNow
}
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions" } })
.run()
}),
)
.pipe(Effect.orDie)
const sourceTotal = yield* countNextSessions(nextPath(options, global.data))
const legacyTotal = (yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session`))?.value ?? 0
const cursor = state?.phase === "sessions" ? state.cursor : undefined
const migrated =
cursor !== undefined
? ((yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session WHERE id >= ${cursor}`))
?.value ?? 0)
: 0
const denominator = sourceTotal + legacyTotal
updateProgress({ label: "Migrating sessions", numerator: migrated, denominator })
yield* importNextDatabase(db, nextPath(options, global.data), (completed) => {
updateProgress({ label: "Migrating sessions", numerator: migrated + completed, denominator })
})
updateProgress({ label: "Migrating sessions", numerator: migrated + sourceTotal, denominator })
const projects = new Set(
(yield* db.all<{ id: string }>(sql`SELECT id FROM project`)).map((project) => project.id),
)
while (true) {
const state = yield* readState(db)
const cursorValue = state?.phase === "sessions" ? state.cursor : undefined
const nextID = yield* db.get<{ id: string; project_id: string }>(
cursorValue === undefined
? sql`SELECT id, project_id FROM session ORDER BY id DESC LIMIT 1`
: sql`SELECT id, project_id FROM session WHERE id < ${cursorValue} ORDER BY id DESC LIMIT 1`,
const deleted = (yield* tx.get<{ value: number }>(sql`SELECT changes() AS value`))?.value ?? 0
if (deleted < EVENT_DELETE_BATCH_SIZE) break
yield* Effect.yieldNow
}
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions" } })
.run()
}),
)
if (!nextID) break
yield* db
.transaction((tx) =>
Effect.gen(function* () {
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions", cursor: nextID.id } })
.onConflictDoUpdate({
target: KVTable.key,
set: { value: { phase: "sessions", cursor: nextID.id }, time_updated: Date.now() },
})
.run()
const projectID = projects.has(nextID.project_id) ? nextID.project_id : Project.ID.global
if (projectID !== nextID.project_id)
yield* Effect.logWarning("Reassigned V1 session with missing project", {
sessionID: nextID.id,
projectID: nextID.project_id,
})
yield* tx.run(sql`
.pipe(Effect.orDie)
const sourceTotal = yield* countNextSessions(nextPath(options, global.data))
const legacyTotal = (yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session`))?.value ?? 0
const cursor = state?.phase === "sessions" ? state.cursor : undefined
const migrated =
cursor !== undefined
? ((yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session WHERE id >= ${cursor}`))
?.value ?? 0)
: 0
const denominator = sourceTotal + legacyTotal
updateProgress({ label: "Migrating sessions", numerator: migrated, denominator })
yield* importNextDatabase(db, nextPath(options, global.data), (completed) => {
updateProgress({ label: "Migrating sessions", numerator: migrated + completed, denominator })
})
updateProgress({ label: "Migrating sessions", numerator: migrated + sourceTotal, denominator })
const projects = new Set(
(yield* db.all<{ id: string }>(sql`SELECT id FROM project`)).map((project) => project.id),
)
while (true) {
const state = yield* readState(db)
const cursorValue = state?.phase === "sessions" ? state.cursor : undefined
const nextID = yield* db.get<{ id: string; project_id: string }>(
cursorValue === undefined
? sql`SELECT id, project_id FROM session ORDER BY id DESC LIMIT 1`
: sql`SELECT id, project_id FROM session WHERE id < ${cursorValue} ORDER BY id DESC LIMIT 1`,
)
if (!nextID) break
yield* db
.transaction((tx) =>
Effect.gen(function* () {
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions", cursor: nextID.id } })
.onConflictDoUpdate({
target: KVTable.key,
set: { value: { phase: "sessions", cursor: nextID.id }, time_updated: Date.now() },
})
.run()
const projectID = projects.has(nextID.project_id) ? nextID.project_id : Project.ID.global
if (projectID !== nextID.project_id)
yield* Effect.logWarning("Reassigned V1 session with missing project", {
sessionID: nextID.id,
projectID: nextID.project_id,
})
yield* tx.run(sql`
INSERT OR IGNORE INTO session_v2 (
id, project_id, workspace_id, parent_id, slug, directory, path, title, version, share_url,
summary_additions, summary_deletions, summary_files, summary_diffs, metadata, cost,
@@ -612,81 +604,79 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
FROM session
WHERE id = ${nextID.id}
`)
const next = yield* tx
.select()
.from(SessionTable)
.where(eq(SessionTable.id, SessionSchema.ID.make(nextID.id)))
.get()
if (!next) return yield* Effect.die(new Error(`Failed to copy V1 session ${nextID.id}`))
const sourceMessages = yield* tx.all<SourceMessage>(
sql`SELECT id, session_id, time_created, time_updated, data FROM message WHERE session_id = ${next.id}`,
)
const sourceParts = yield* tx.all<SourcePart>(
sql`SELECT id, message_id, session_id, time_created, time_updated, data FROM part WHERE session_id = ${next.id}`,
)
const transformed = transformSession({ session: next, messages: sourceMessages, parts: sourceParts })
yield* Effect.forEach(transformed.warnings, (warning) =>
Effect.logWarning("Skipped V1 migration row", warning),
)
yield* tx.delete(SessionMessageTable).where(eq(SessionMessageTable.session_id, next.id)).run()
yield* Effect.forEach(transformed.messages, (message) =>
tx
.insert(SessionMessageTable)
.values({
id: SessionMessage.ID.make(message.id),
session_id: SessionSchema.ID.make(message.session_id),
type: message.type,
seq: message.seq,
time_created: message.time_created,
time_updated: message.time_updated,
data: sql`${JSON.stringify(message.data)}`,
})
.run(),
)
yield* tx
.update(SessionTable)
.set({ ...transformed.session, time_updated: next.time_updated })
.where(eq(SessionTable.id, next.id))
.run()
yield* tx
.insert(EventSequenceTable)
.values({ aggregate_id: next.id, seq: transformed.watermark })
.onConflictDoUpdate({
target: EventSequenceTable.aggregate_id,
set: { seq: transformed.watermark, owner_id: null },
const next = yield* tx
.select()
.from(SessionTable)
.where(eq(SessionTable.id, SessionSchema.ID.make(nextID.id)))
.get()
if (!next) return yield* Effect.die(new Error(`Failed to copy V1 session ${nextID.id}`))
const sourceMessages = yield* tx.all<SourceMessage>(
sql`SELECT id, session_id, time_created, time_updated, data FROM message WHERE session_id = ${next.id}`,
)
const sourceParts = yield* tx.all<SourcePart>(
sql`SELECT id, message_id, session_id, time_created, time_updated, data FROM part WHERE session_id = ${next.id}`,
)
const transformed = transformSession({ session: next, messages: sourceMessages, parts: sourceParts })
yield* Effect.forEach(transformed.warnings, (warning) =>
Effect.logWarning("Skipped V1 migration row", warning),
)
yield* tx.delete(SessionMessageTable).where(eq(SessionMessageTable.session_id, next.id)).run()
yield* Effect.forEach(transformed.messages, (message) =>
tx
.insert(SessionMessageTable)
.values({
id: SessionMessage.ID.make(message.id),
session_id: SessionSchema.ID.make(message.session_id),
type: message.type,
seq: message.seq,
time_created: message.time_created,
time_updated: message.time_updated,
data: sql`${JSON.stringify(message.data)}`,
})
.run()
}),
)
.pipe(Effect.orDie)
if (runtimeState.status === "running")
runtimeState = {
status: "running",
progress: {
label: "Migrating sessions",
numerator: (runtimeState.progress.numerator ?? 0) + 1,
denominator,
},
}
yield* Effect.yieldNow
}
yield* db
.transaction((tx) =>
Effect.gen(function* () {
.run(),
)
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "completed" } })
.update(SessionTable)
.set({ ...transformed.session, time_updated: next.time_updated })
.where(eq(SessionTable.id, next.id))
.run()
yield* tx
.insert(EventSequenceTable)
.values({ aggregate_id: next.id, seq: transformed.watermark })
.onConflictDoUpdate({
target: KVTable.key,
set: { value: { phase: "completed" }, time_updated: Date.now() },
target: EventSequenceTable.aggregate_id,
set: { seq: transformed.watermark, owner_id: null },
})
.run()
}),
)
.pipe(Effect.orDie)
return { status: "completed" as const }
})
return yield* migrate
if (runtimeState.status === "running")
runtimeState = {
status: "running",
progress: {
label: "Migrating sessions",
numerator: (runtimeState.progress.numerator ?? 0) + 1,
denominator,
},
}
yield* Effect.yieldNow
}
yield* db
.transaction((tx) =>
Effect.gen(function* () {
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "completed" } })
.onConflictDoUpdate({
target: KVTable.key,
set: { value: { phase: "completed" }, time_updated: Date.now() },
})
.run()
}),
)
.pipe(Effect.orDie)
return { status: "completed" as const }
}).pipe(Effect.orDie),
)
}
@@ -715,7 +705,7 @@ function countNextSessions(sourcePath: string | undefined) {
if (!isNextDatabase(source)) return 0
return source.query<{ value: number }, []>("SELECT COUNT(*) AS value FROM session").get()?.value ?? 0
}),
).pipe(Effect.orElseSucceed(() => 0))
)
}
function importNextDatabase(
+55 -52
View File
@@ -61,21 +61,23 @@ export const makeMemoryDriver = (): MemoryDriver => {
}
const failed = (value: string, cause: unknown) => new Failed({ path: value, cause })
const overrides: FilesImpl = {
stat: (value) => {
const node = lookup(value)
return node ? Effect.succeed(info(node)) : Effect.fail(new NotFound({ path: value }))
},
read: (value, range) => {
const original = lookup(value)
if (!original) return Effect.fail(new NotFound({ path: value }))
if (original.type === "directory") return Effect.fail(new WrongKind({ path: value, actual: "directory" }))
const resolved = resolveKey(value, true)
const node = resolved === undefined ? undefined : nodes.get(resolved)
if (!node) return Effect.fail(new NotFound({ path: value }))
if (node.type !== "file") return Effect.fail(new WrongKind({ path: value, actual: node.type }))
const bytes = range === undefined ? node.bytes : node.bytes.subarray(range.offset, range.offset + range.length)
return Effect.succeed({ info: info(node), bytes: bytes.slice() })
},
stat: (value) =>
Effect.suspend(() => {
const node = lookup(value)
return node ? Effect.succeed(info(node)) : Effect.fail(new NotFound({ path: value }))
}),
read: (value, range) =>
Effect.gen(function* () {
const original = lookup(value)
if (!original) return yield* new NotFound({ path: value })
if (original.type === "directory") return yield* new WrongKind({ path: value, actual: "directory" })
const resolved = resolveKey(value, true)
const node = resolved === undefined ? undefined : nodes.get(resolved)
if (!node) return yield* new NotFound({ path: value })
if (node.type !== "file") return yield* new WrongKind({ path: value, actual: node.type })
const bytes = range === undefined ? node.bytes : node.bytes.subarray(range.offset, range.offset + range.length)
return { info: info(node), bytes: bytes.slice() }
}),
write: (value, bytes) =>
Effect.try({
try: () => {
@@ -89,17 +91,17 @@ export const makeMemoryDriver = (): MemoryDriver => {
},
catch: (cause) => failed(value, cause),
}),
list: (value) => {
const target = resolveKey(value, true) ?? key(value)
const node = nodes.get(target)
if (!node) return Effect.fail(new NotFound({ path: value }))
if (node.type !== "directory") return Effect.fail(new WrongKind({ path: value, actual: node.type }))
const entries = [...nodes.entries()]
.filter(([entry]) => entry !== target && path.posix.dirname(entry) === target)
.map(([entry, child]) => ({ name: path.posix.basename(entry), type: child.type satisfies FileType }))
.sort((a, b) => a.name.localeCompare(b.name))
return Effect.succeed(entries)
},
list: (value) =>
Effect.gen(function* () {
const target = resolveKey(value, true) ?? key(value)
const node = nodes.get(target)
if (!node) return yield* new NotFound({ path: value })
if (node.type !== "directory") return yield* new WrongKind({ path: value, actual: node.type })
return [...nodes.entries()]
.filter(([entry]) => entry !== target && path.posix.dirname(entry) === target)
.map(([entry, child]) => ({ name: path.posix.basename(entry), type: child.type satisfies FileType }))
.sort((a, b) => a.name.localeCompare(b.name))
}),
remove: (value) =>
Effect.sync(() => {
const target = resolveKey(value, false) ?? key(value)
@@ -107,32 +109,33 @@ export const makeMemoryDriver = (): MemoryDriver => {
if (entry === target || entry.startsWith(`${target}/`)) nodes.delete(entry)
}
}),
move: (from, to) => {
const source = resolveKey(from, false) ?? key(from)
const node = nodes.get(source)
if (!node) return Effect.fail(new NotFound({ path: from }))
return Effect.try({
try: () => {
const requested = resolveKey(to, false) ?? key(to)
const destination =
nodes.get(requested)?.type === "directory"
? path.posix.join(requested, path.posix.basename(source))
: requested
if (node.type === "directory" && destination.startsWith(`${source}/`)) {
throw new Error(`Cannot move a directory into itself: ${from}`)
}
const existing = nodes.get(destination)
if (node.type === "directory" && existing && existing.type !== "directory") {
throw new Error(`Cannot overwrite a non-directory with a directory: ${to}`)
}
requireParent(destination)
const moved = [...nodes.entries()].filter(([entry]) => entry === source || entry.startsWith(`${source}/`))
for (const [entry] of moved) nodes.delete(entry)
for (const [entry, child] of moved) nodes.set(`${destination}${entry.slice(source.length)}`, child)
},
catch: (cause) => failed(from, cause),
})
},
move: (from, to) =>
Effect.gen(function* () {
const source = resolveKey(from, false) ?? key(from)
const node = nodes.get(source)
if (!node) return yield* new NotFound({ path: from })
yield* Effect.try({
try: () => {
const requested = resolveKey(to, false) ?? key(to)
const destination =
nodes.get(requested)?.type === "directory"
? path.posix.join(requested, path.posix.basename(source))
: requested
if (node.type === "directory" && destination.startsWith(`${source}/`)) {
throw new Error(`Cannot move a directory into itself: ${from}`)
}
const existing = nodes.get(destination)
if (node.type === "directory" && existing && existing.type !== "directory") {
throw new Error(`Cannot overwrite a non-directory with a directory: ${to}`)
}
requireParent(destination)
const moved = [...nodes.entries()].filter(([entry]) => entry === source || entry.startsWith(`${source}/`))
for (const [entry] of moved) nodes.delete(entry)
for (const [entry, child] of moved) nodes.set(`${destination}${entry.slice(source.length)}`, child)
},
catch: (cause) => failed(from, cause),
})
}),
mkdir: (value) => Effect.try({ try: () => mkdirSync(value), catch: (cause) => failed(value, cause) }),
}
+2 -4
View File
@@ -62,9 +62,8 @@ export const syncTextBom = Effect.fn("FileMutation.syncTextBom")(function* (
const transactionLocks = KeyedMutex.makeUnsafe<string>()
/**
* Serialize file changes by absolute target. Conditional writes compare and
* write under the same process-local lock so cooperating OpenCode mutations do
* not overwrite changes made from the same stale content.
* Mutation locking is process-local and serializes cooperating OpenCode
* changes; external writes can still race.
*/
const layer = Layer.effect(
Service,
@@ -129,7 +128,6 @@ export const node = makeLocationNode({ service: Service, layer, deps: [Environme
/**
* Deferred until the corresponding integrations exist.
*/
// TODO: Add formatter integration after formatter runtime exists.
// TODO: Publish watcher/file-edit events after watcher integration exists.
// TODO: Add snapshots / undo after snapshot design exists.
// TODO: Notify LSP and collect diagnostics after LSP runtime exists.
+2 -7
View File
@@ -3,7 +3,7 @@ import { Effect } from "effect"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { FileSystem } from "../filesystem.js"
import { DecodeError, ResizerUnavailableError, SizeError } from "../image.js"
import { DecodeError, ResizerUnavailableError, SizeError, type Limits } from "../image.js"
const JPEG_QUALITIES = [80, 85, 70, 55, 40]
@@ -33,12 +33,7 @@ export const make = Effect.gen(function* () {
return Effect.fn("Image.Photon.normalize")(function* (
resource: string,
content: FileSystem.Content & { readonly encoding: "base64" },
limits: {
readonly autoResize: boolean
readonly maxWidth: number
readonly maxHeight: number
readonly maxBase64Bytes: number
},
limits: Readonly<Limits>,
) {
const photon = yield* loadPhoton
const decoded = yield* Effect.try({
+5 -30
View File
@@ -4,7 +4,7 @@ import os from "node:os"
import path from "node:path"
import { Context, Effect, Layer, Schema } from "effect"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Added, Handoff, ReadLines, Removed, type ReadResult } from "@opencode-ai/schema/persistent-pty"
import { Added, Handoff, PersistentPty, ReadLines, Removed, type ReadResult } from "@opencode-ai/schema/persistent-pty"
import { Session } from "@opencode-ai/schema/session"
import { Bus } from "../bus.js"
import { Pty } from "@opencode-ai/schema/pty"
@@ -26,19 +26,9 @@ export { Handoff } from "@opencode-ai/schema/persistent-pty"
export const Options = Schema.Struct({ handoff: Schema.optional(Handoff) })
export type Options = typeof Options.Type
export type Info = Pty.Info & {
readonly sessionID: Session.ID
readonly foregroundProcess: string | null
readonly size: { readonly cols: number; readonly rows: number }
readonly output: { readonly head: number; readonly tail: number }
}
export type Info = PersistentPty.Info
export type Snapshot = {
readonly info: Info
readonly text: string
readonly checkpoint: Uint8Array
readonly cursor: { readonly x: number; readonly y: number }
}
export type Snapshot = PersistentPty.Snapshot
export type Attachment = {
readonly info: Info
@@ -161,15 +151,7 @@ export const configured = (options: Options = {}) =>
const create = Effect.fn("PersistentPty.create")(function* (
sessionID: Session.ID,
input: {
readonly command?: string
readonly args: readonly string[]
readonly cwd?: string
readonly title: string
readonly env: Readonly<Record<string, string>>
readonly cols?: number
readonly rows?: number
},
input: Parameters<Interface["create"]>[1],
) {
const response = yield* request(
daemon,
@@ -338,14 +320,7 @@ export const configured = (options: Options = {}) =>
const attach = Effect.fn("PersistentPty.attach")(function* (
id: Pty.ID,
input: {
readonly cursor: number
readonly attachmentID: string
readonly role: Role
readonly takeover?: boolean
readonly onEvent: (event: StreamEvent) => void
readonly onEnd: () => void
},
input: Parameters<Interface["attach"]>[1],
) {
yield* get(id)
const attachment = yield* daemon
+2
View File
@@ -14,6 +14,7 @@ import { GoogleVertexPlugin } from "./provider/google-vertex.js"
import { KiloPlugin } from "./provider/kilo.js"
import { LLMGatewayPlugin } from "./provider/llmgateway.js"
import { LMStudioPlugin } from "./provider/lmstudio.js"
import { MergeGatewayPlugin } from "./provider/merge-gateway.js"
import { MistralPlugin } from "./provider/mistral.js"
import { NvidiaPlugin } from "./provider/nvidia.js"
import { OllamaPlugin } from "./provider/ollama.js"
@@ -47,6 +48,7 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
KiloPlugin,
LLMGatewayPlugin,
LMStudioPlugin,
MergeGatewayPlugin,
MistralPlugin,
NvidiaPlugin,
OllamaPlugin,
@@ -0,0 +1,56 @@
import { Effect } from "effect"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Provider } from "../../provider.js"
export const MergeGatewayPlugin = define({
id: "opencode.provider.merge-gateway",
effect: Effect.fn(function* (ctx) {
yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) {
const merge = Provider.packageName(item.provider.package) === "merge-gateway-ai-sdk-provider"
for (const model of item.models.values()) {
if (Provider.packageName(model.package ?? item.provider.package) !== "merge-gateway-ai-sdk-provider") {
if (merge)
evt.model.update(model.providerID, model.id, (model) => {
model.settings = Provider.mergeOverlay(item.provider.settings, model.settings)
})
continue
}
evt.model.update(model.providerID, model.id, (model) => {
if (model.package) model.package = "@opencode-ai/ai/providers/openai-compatible"
model.settings = {
...(!merge ? { baseURL: "https://api-gateway.merge.dev/v1/ai-sdk", provider: model.providerID } : {}),
...settings(merge ? model.settings : Provider.mergeOverlay(item.provider.settings, model.settings)),
}
// Merge uses `thinking` instead of the usual OpenAI-compatible reasoning fields.
// models.dev tracks upstream models, not these gateway-specific compatibility defaults.
model.compatibility = {
...model.compatibility,
reasoningField: "thinking",
maxTokensField: "max_tokens",
requireReasoning: false,
}
})
}
if (!merge) continue
evt.provider.update(item.provider.id, (provider) => {
provider.package = "@opencode-ai/ai/providers/openai-compatible"
provider.settings = {
baseURL: "https://api-gateway.merge.dev/v1/ai-sdk",
provider: provider.id,
...settings(provider.settings),
}
})
}
})
}),
})
function settings(input: Readonly<Record<string, unknown>> = {}) {
const options = Object.fromEntries(Object.entries(input).filter(([key]) => key !== "apiKey" && key !== "baseURL"))
return {
...(typeof input.apiKey === "string" ? { apiKey: input.apiKey } : {}),
...(typeof input.baseURL === "string" ? { baseURL: input.baseURL } : {}),
...(Object.keys(options).length ? { providerOptions: options } : {}),
}
}
+4 -5
View File
@@ -137,11 +137,10 @@ const layer = Layer.effect(
strategy: project.vcs.type === "git" ? "git" : undefined,
})
// A missing directory row means this directory's resolution is a new durable
// fact (copy.ts registers copy directories directly; those never strand
// sessions and never announce). The row insert commits atomically with the
// event, so a crash between checks retries on the next resolve instead of
// stranding the announcement. The in-flight set keeps concurrent resolves
// from publishing the same fact twice.
// fact. The row insert commits atomically with the event, so a crash between
// checks retries on the next resolve instead of stranding the announcement.
// The in-flight set keeps concurrent resolves from publishing the same fact
// twice.
for (const item of directories) {
const key = item.projectID + "\u0000" + item.directory
if (announcing.has(key)) continue
+20 -19
View File
@@ -393,26 +393,27 @@ export const layer = Layer.effect(
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
inputID: input.inputID,
})
const resolved = yield* input.resolveModel(input.session).pipe(
Effect.catch((cause) =>
failed({
sessionID: input.session.id,
reason: "manual",
error: toSessionError(cause),
inputID: input.inputID,
}),
),
return yield* input.resolveModel(input.session).pipe(
Effect.matchEffect({
onFailure: (cause) =>
failed({
sessionID: input.session.id,
reason: "manual",
error: toSessionError(cause),
inputID: input.inputID,
}),
onSuccess: (resolved) =>
execute({
session: input.session,
resolved,
prepare: input.prepare,
reason: "manual",
inputID: input.inputID,
started: input.started,
...content,
}),
}),
)
if ("status" in resolved) return resolved
return yield* execute({
session: input.session,
resolved,
prepare: input.prepare,
reason: "manual",
inputID: input.inputID,
started: input.started,
...content,
})
})
return Service.of({
transform: state.transform,
@@ -131,11 +131,7 @@ const layer = Layer.effect(
return (yield* rows(sessionID, false)).map((row) => ({ key: row.key, value: row.value }))
})
const put = Effect.fn("InstructionEntry.put")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly key: Key
readonly value: Schema.Json
}) {
const put = Effect.fn("InstructionEntry.put")(function* (input: Parameters<Interface["put"]>[0]) {
const actualBytes = Buffer.byteLength(JSON.stringify(input.value), "utf8")
if (actualBytes > MaxValueBytes)
yield* new ValueTooLargeError({
@@ -159,10 +155,7 @@ const layer = Layer.effect(
.pipe(Effect.orDie)
})
const remove = Effect.fn("InstructionEntry.remove")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly key: Key
}) {
const remove = Effect.fn("InstructionEntry.remove")(function* (input: Parameters<Interface["remove"]>[0]) {
yield* db
.update(InstructionEntryTable)
.set({ value: null, removed: true, time_updated: Date.now() })
@@ -42,8 +42,8 @@ export const commit = Effect.fn("InstructionState.commit")(function* (
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.
// The rendered text is frozen into the durable event because re-rendering it
// later would require the original Location-scoped instruction sources.
const text = observation.initial ? "" : yield* renderUpdateText(db, instructions, observation)
yield* bus.publish(
SessionEvent.InstructionsUpdated,
+1 -4
View File
@@ -43,10 +43,7 @@ const layer = Layer.effect(
// are re-discovered and re-injected instead of staying silently lost.
const inFlight = yield* Ref.make<Map<SessionSchema.ID, Set<string>>>(new Map())
const load = Effect.fn("SessionInstructions.load")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly paths: ReadonlyArray<string>
}) {
const load = Effect.fn("SessionInstructions.load")(function* (input: Parameters<Interface["load"]>[0]) {
const claimed = yield* Ref.modify(inFlight, (map) => {
const existing = map.get(input.sessionID) ?? new Set<string>()
const newlyClaimed = input.paths.filter((path) => !existing.has(path))
+2 -7
View File
@@ -15,7 +15,7 @@ import { SessionMessage } from "../message.js"
import { SessionSchema } from "../schema.js"
import { SessionStore } from "../store.js"
import { SessionTitle } from "../title.js"
import { DrainResult, Service, type Continuation } from "./index.js"
import { DrainResult, Service, type Interface } from "./index.js"
import { Snapshot } from "../../snapshot.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { llmClient } from "../../effect/app-node-platform.js"
@@ -44,12 +44,7 @@ const layer = Layer.effect(
// Title generation starts once input is visible and must not delay model execution.
const titles = yield* FiberMap.make<SessionSchema.ID, void, never>()
const drain = Effect.fn("SessionRunner.drain")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly force: boolean
readonly continuation?: Continuation
readonly promotable?: SessionInbox.Promotable
}) {
const drain = Effect.fn("SessionRunner.drain")(function* (input: Parameters<Interface["drain"]>[0]) {
const sessionID = input.sessionID
let force = input.force
let continuing = input.continuation !== undefined
+49 -49
View File
@@ -122,8 +122,8 @@ const layer = () =>
const environments = yield* SessionEnvironment.Service
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
const sessions = new Map<string, Active>()
const exitOrder: string[] = []
const commands = new Map<Shell.ID, Active>()
const exitOrder: Shell.ID[] = []
const outputDir = path.join(global.data, DIRECTORY, location.project.id)
const { mkdir, unlink } = yield* Effect.promise(() => import("fs/promises"))
@@ -132,44 +132,44 @@ const layer = () =>
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
for (const session of sessions.values()) {
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
for (const command of commands.values()) {
if (command.timeoutFiber) yield* Fiber.interrupt(command.timeoutFiber)
// Teardown interrupts pending commands; it is not a terminal command failure.
yield* Deferred.interrupt(session.done)
yield* Deferred.interrupt(command.done)
}
sessions.clear()
commands.clear()
exitOrder.length = 0
}),
)
const require = Effect.fnUntraced(function* (id: Shell.ID) {
const session = sessions.get(id)
if (!session) return yield* new NotFoundError({ id })
return session
const command = commands.get(id)
if (!command) return yield* new NotFoundError({ id })
return command
})
const removeSession = Effect.fnUntraced(function* (id: Shell.ID) {
const session = sessions.get(id)
const removeCommand = Effect.fnUntraced(function* (id: Shell.ID) {
const command = commands.get(id)
const index = exitOrder.indexOf(id)
if (index !== -1) exitOrder.splice(index, 1)
if (!session) return
sessions.delete(id)
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
if (!command) return
commands.delete(id)
if (command.timeoutFiber) yield* Fiber.interrupt(command.timeoutFiber)
// Unblock any wait still pending when the command is removed before it terminated.
yield* Deferred.fail(session.done, new NotFoundError({ id }))
yield* Effect.promise(() => unlink(session.file).catch(() => {}))
yield* Deferred.fail(command.done, new NotFoundError({ id }))
yield* Effect.promise(() => unlink(command.file).catch(() => {}))
yield* bus.publish(Shell.Event.Deleted, { id })
})
const remove = Effect.fn("Shell.remove")(function* (id: Shell.ID) {
yield* require(id)
yield* removeSession(id)
yield* removeCommand(id)
})
const list = Effect.fn("Shell.list")(function* () {
return Array.from(sessions.values())
.filter((session) => session.info.status === "running")
.map((session) => session.info)
return Array.from(commands.values())
.filter((command) => command.info.status === "running")
.map((command) => command.info)
})
const get = Effect.fn("Shell.get")(function* (id: Shell.ID) {
@@ -181,24 +181,24 @@ const layer = () =>
})
const timeout = Effect.fn("Shell.timeout")(function* (id: Shell.ID, duration: number) {
const session = yield* require(id)
if (session.info.status !== "running" || !session.timeout) return session.info
yield* session.timeout(duration)
return session.info
const command = yield* require(id)
if (command.info.status !== "running" || !command.timeout) return command.info
yield* command.timeout(duration)
return command.info
})
const output = Effect.fnUntraced(function* (id: Shell.ID, input?: Shell.OutputInput) {
const session = yield* require(id)
const command = yield* require(id)
const cursor = input?.cursor ?? 0
const limit = input?.limit ?? 65536
if (cursor >= session.size) return { output: "", cursor: session.size, size: session.size, truncated: false }
if (cursor >= command.size) return { output: "", cursor: command.size, size: command.size, truncated: false }
const start = Math.max(0, cursor)
const length = Math.min(limit, session.size - start)
const length = Math.min(limit, command.size - start)
const buffer = Buffer.alloc(length)
const bytesRead = yield* Effect.promise(
() =>
new Promise<number>((resolve) => {
const stream = createReadStream(session.file, { start, end: start + length - 1 })
const stream = createReadStream(command.file, { start, end: start + length - 1 })
let offset = 0
stream.on("data", (chunk: string | Buffer) => {
const bytes = Buffer.from(chunk)
@@ -212,7 +212,7 @@ const layer = () =>
return {
output: buffer.subarray(0, bytesRead).toString("utf8"),
cursor: start + bytesRead,
size: session.size,
size: command.size,
truncated: false,
}
})
@@ -257,7 +257,7 @@ const layer = () =>
// Spawn through the Environment and stream combined output to the file. The handle is scope-bound, so
// the managing fiber keeps its scope open until the command terminates (it awaits `done` at the
// end). `create` returns once `ready` resolves with the registered session.
// end). `create` returns once `ready` resolves with the registered command.
const ready = Deferred.makeUnsafe<Active, AppProcess.AppProcessError>()
runFork(
Effect.scoped(
@@ -275,7 +275,7 @@ const layer = () =>
.pipe(
Effect.mapError((cause) => new AppProcess.AppProcessError({ command: invocation.command, cause })),
)
const session: Active = {
const command: Active = {
info: produce(info, (draft) => {
draft.pid = handle.pid
}),
@@ -283,7 +283,7 @@ const layer = () =>
size: 0,
done: Deferred.makeUnsafe<Info, NotFoundError>(),
}
sessions.set(id, session)
commands.set(id, command)
const stream = createWriteStream(file)
const outputDone = Latch.makeUnsafe()
@@ -291,7 +291,7 @@ const layer = () =>
Stream.runForEach((chunk: Uint8Array) =>
Effect.sync(() => {
stream.write(chunk)
session.size += chunk.length
command.size += chunk.length
}),
),
)
@@ -317,8 +317,8 @@ const layer = () =>
const finish = (status: Info["status"], exit?: number, beforeWait = Effect.void) =>
Effect.gen(function* () {
if (session.info.status !== "running") return
session.info = produce(session.info, (draft) => {
if (command.info.status !== "running") return
command.info = produce(command.info, (draft) => {
draft.status = status
if (exit !== undefined) draft.exit = exit
draft.time.completed = Date.now()
@@ -326,10 +326,10 @@ const layer = () =>
yield* beforeWait
yield* outputDone.await
// Resolve waiters with the terminal Info before any retention eviction, so an evicted
// session still reports success rather than the removal NotFoundError. This runs before
// command still reports success rather than the removal NotFoundError. This runs before
// the timeout-fiber interrupt below, which on the timeout path would otherwise cancel
// this very fiber (finish is invoked by the timeout fiber) before waiters are resolved.
yield* Deferred.succeed(session.done, session.info)
yield* Deferred.succeed(command.done, command.info)
yield* bus.publish(Shell.Event.Exited, {
id,
...(exit !== undefined ? { exit } : {}),
@@ -339,19 +339,19 @@ const layer = () =>
while (exitOrder.length > EXITED_LIMIT) {
const oldest = exitOrder[0]
if (!oldest) break
yield* removeSession(Shell.ID.make(oldest))
yield* removeCommand(oldest)
}
// Cancel a pending timeout once the command exits on its own. Interrupting last avoids
// aborting finish when finish itself runs on the timeout fiber.
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
if (command.timeoutFiber) yield* Fiber.interrupt(command.timeoutFiber)
})
session.timeout = (duration) =>
command.timeout = (duration) =>
Effect.gen(function* () {
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
session.timeoutFiber = undefined
if (duration === 0 || session.info.status !== "running") return
session.timeoutFiber = runFork(
if (command.timeoutFiber) yield* Fiber.interrupt(command.timeoutFiber)
command.timeoutFiber = undefined
if (duration === 0 || command.info.status !== "running") return
command.timeoutFiber = runFork(
Effect.sleep(Duration.millis(duration)).pipe(
Effect.flatMap(() =>
finish("timeout", undefined, handle.kill().pipe(Effect.catch(() => Effect.void))),
@@ -360,7 +360,7 @@ const layer = () =>
)
})
yield* session.timeout(invocation.timeout)
yield* command.timeout(invocation.timeout)
runFork(
handle.exitCode.pipe(
@@ -370,16 +370,16 @@ const layer = () =>
)
yield* bus.publish(Shell.Event.Created, { info })
yield* Deferred.succeed(ready, session)
yield* Deferred.succeed(ready, command)
// Hold the handle's scope open until the command terminates; closing it earlier would
// release (kill) the process before its exit is observed.
yield* Deferred.await(session.done).pipe(Effect.catch(() => Effect.void))
yield* Deferred.await(command.done).pipe(Effect.catch(() => Effect.void))
}),
).pipe(Effect.catchTag("AppProcessError", (error) => Deferred.fail(ready, error))),
)
const session = yield* Deferred.await(ready)
return session.info
const command = yield* Deferred.await(ready)
return command.info
})
return Service.of({ create, list, get, wait, timeout, output, remove })
+3 -3
View File
@@ -7,7 +7,7 @@
- Plugin authors get schema-derived input types at the `ToolDraft.add` boundary through `Tool`.
- The heterogeneous Core registry deliberately erases registered definitions to `Tool.Info`. Use `any` at this internal boundary; do not replace it with `unknown`, JSON-value plumbing, casts, or compiled wrapper types solely to preserve type safety after registration.
- Executors return model content and metadata alongside declared machine output. Shipped built-ins and plugin tools use the same runtime shape after registration.
- `src/tool.ts` stores canonical Location registrations, derives LLM definitions, executes tools, and applies generic output bounding.
- `src/tool.ts` stores canonical Location registrations, derives LLM definitions, executes tools, and normalizes model content and images.
- Built-in tool plugins live in `tool/plugin`.
Do not add a second executable entry type, registry-owned executor, authorization callback, output-path callback, or legacy normalization path.
@@ -53,9 +53,9 @@ Tool filtering is catalog visibility, not execution authorization. A call still
## Output
Built-ins return complete tool responses. `Tool.Snapshot.execute` is the local execution boundary.
Built-ins return complete tool responses. `Tool.Snapshot.execute` is the local execution boundary. Generic output bounding is applied by the Session runner after execution.
Producer capture limits remain local to producers. For example, Bash keeps `AppProcess.maxOutputBytes` and accurately reports stdout/stderr capture loss.
Producer capture remains local to producers. Shell stores combined process output in its backing file and returns a bounded tail with the full-output path when truncated.
## Current Gaps
+1 -3
View File
@@ -5,9 +5,7 @@ export abstract class NamedError extends Error {
abstract toObject(): { name: string; data: unknown }
static hasName(error: unknown, name: string): boolean {
return (
typeof error === "object" && error !== null && "name" in error && (error as Record<string, unknown>).name === name
)
return typeof error === "object" && error !== null && "name" in error && error.name === name
}
static create<Name extends string, Fields extends Schema.Struct.Fields>(
+120 -3
View File
@@ -375,7 +375,100 @@ it.effect("projects replay metadata onto AI SDK prompt parts", () =>
}),
)
it.effect("moves a tool image through the real Mistral provider as a user message", () =>
it.effect("normalizes file data across AI SDK prompt parts", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
yield* aisdk.hook.sdk((event) => {
event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
})
const resolved = yield* aisdk.model(model("opaque-provider"))
const bytes = new Uint8Array([0, 1, 2, 3])
const prepared = yield* compileRequest(
LLM.request({
model: resolved,
messages: [
Message.user([
{ type: "media", mediaType: "image/png", data: bytes, filename: "bytes.png" },
{ type: "media", mediaType: "image/png", data: "AAAA", filename: "base64.png" },
{
type: "media",
mediaType: "image/png",
data: "data:image/png;charset=utf-8;base64,AQID",
filename: "inline.png",
},
{ type: "media", mediaType: "image/png", data: "https://example.com/image.png" },
{ type: "media", mediaType: "image/png", data: "s3://bucket/image.png" },
]),
Message.assistant({
type: "media",
mediaType: "application/pdf",
data: "http://example.com/document.pdf",
filename: "document.pdf",
}),
Message.tool({
id: "call_1",
name: "screenshot",
result: {
type: "content",
value: [{ type: "file", uri: "data:image/png;base64,BAUG", mime: "image/png", name: "tool.png" }],
},
}),
],
}),
)
expect(prepared.body.prompt).toEqual([
{
role: "user",
content: [
{ type: "file", mediaType: "image/png", data: bytes, filename: "bytes.png" },
{ type: "file", mediaType: "image/png", data: "AAAA", filename: "base64.png" },
{ type: "file", mediaType: "image/png", data: "AQID", filename: "inline.png" },
{
type: "file",
mediaType: "image/png",
data: new URL("https://example.com/image.png"),
filename: undefined,
},
{ type: "file", mediaType: "image/png", data: "s3://bucket/image.png", filename: undefined },
],
},
{
role: "assistant",
content: [
{
type: "file",
mediaType: "application/pdf",
data: new URL("http://example.com/document.pdf"),
filename: "document.pdf",
},
],
},
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call_1",
toolName: "screenshot",
output: { type: "text", value: "Media attached in the following user message." },
providerOptions: undefined,
},
],
},
{
role: "user",
content: [
{ type: "text", text: "Attached media from tool result:" },
{ type: "file", mediaType: "image/png", data: "BAUG", filename: "tool.png" },
],
},
])
}),
)
it.effect("normalizes user and tool media through the real Mistral provider", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
let body: { messages?: unknown[] } | undefined
@@ -415,7 +508,14 @@ it.effect("moves a tool image through the real Mistral provider as a user messag
LLM.request({
model: resolved,
messages: [
Message.user("Inspect the screenshot."),
Message.user([
{ type: "text", text: "Inspect the attachments." },
{ type: "media", mediaType: "image/png", data: new Uint8Array([0, 1, 2, 3]) },
{ type: "media", mediaType: "image/png", data: "AQID" },
{ type: "media", mediaType: "image/png", data: "data:image/png;base64,BAUG" },
{ type: "media", mediaType: "image/png", data: "http://example.com/image.png" },
{ type: "media", mediaType: "application/pdf", data: "https://example.com/document.pdf" },
]),
Message.assistant({ type: "tool-call", id: "call_1", name: "screenshot", input: {} }),
Message.tool({
type: "tool-result",
@@ -426,6 +526,12 @@ it.effect("moves a tool image through the real Mistral provider as a user messag
value: [
{ type: "text", text: "Screenshot captured" },
{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png", name: "screen.png" },
{
type: "file",
uri: "https://example.com/tool-document.pdf",
mime: "application/pdf",
name: "tool-document.pdf",
},
],
},
}),
@@ -434,7 +540,17 @@ it.effect("moves a tool image through the real Mistral provider as a user messag
).pipe(Effect.provide(client))
expect(body?.messages).toEqual([
{ role: "user", content: [{ type: "text", text: "Inspect the screenshot." }] },
{
role: "user",
content: [
{ type: "text", text: "Inspect the attachments." },
{ type: "image_url", image_url: "data:image/png;base64,AAECAw==" },
{ type: "image_url", image_url: "data:image/png;base64,AQID" },
{ type: "image_url", image_url: "data:image/png;base64,BAUG" },
{ type: "image_url", image_url: "http://example.com/image.png" },
{ type: "document_url", document_url: "https://example.com/document.pdf" },
],
},
{
role: "assistant",
content: "",
@@ -457,6 +573,7 @@ it.effect("moves a tool image through the real Mistral provider as a user messag
content: [
{ type: "text", text: "Attached media from tool result:" },
{ type: "image_url", image_url: "data:image/png;base64,AAAA" },
{ type: "document_url", document_url: "https://example.com/tool-document.pdf" },
],
},
])
+22 -24
View File
@@ -85,8 +85,8 @@ describe("Bus Session routing", () => {
projectID: Project.ID.global,
})
const done = yield* bus.publish(Done, {})
expect(Array.from(yield* Fiber.join(first))).toEqual([moved, done])
expect(Array.from(yield* Fiber.join(second))).toEqual([moved, after, same, done])
expect(yield* Fiber.join(first)).toEqual([moved, done])
expect(yield* Fiber.join(second)).toEqual([moved, after, same, done])
expect(moved.location).toEqual(a)
}),
)
@@ -130,8 +130,8 @@ describe("Bus Session routing", () => {
)
const after = yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID: child })
const done = yield* bus.publish(Done, {})
expect(Array.from(yield* Fiber.join(first)).map((event) => event.id)).toEqual([eventID, after.id, done.id])
expect(Array.from(yield* Fiber.join(second))).toEqual([done])
expect((yield* Fiber.join(first)).map((event) => event.id)).toEqual([eventID, after.id, done.id])
expect(yield* Fiber.join(second)).toEqual([done])
}),
)
}),
@@ -158,19 +158,17 @@ describe("Bus Session routing", () => {
const explicit = yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID: id }, { location: b })
const done = yield* bus.publish(Done, {})
expect(Array.from(yield* Fiber.join(first))).toEqual([renamed, text, broadcast, done])
expect(Array.from(yield* Fiber.join(second))).toEqual([broadcast, explicit, done])
expect(Array.from(yield* Fiber.join(workspace))).toEqual([broadcast, done])
expect(Array.from(yield* Fiber.join(global))).toEqual([renamed, text, broadcast, explicit, done])
expect(yield* Fiber.join(first)).toEqual([renamed, text, broadcast, done])
expect(yield* Fiber.join(second)).toEqual([broadcast, explicit, done])
expect(yield* Fiber.join(workspace)).toEqual([broadcast, done])
expect(yield* Fiber.join(global)).toEqual([renamed, text, broadcast, explicit, done])
expect(listened).toEqual([renamed, text, broadcast, explicit, done])
expect(renamed).not.toHaveProperty("location")
expect(text).not.toHaveProperty("location")
expect(JSON.parse(JSON.stringify(renamed))).not.toHaveProperty("location")
const history = yield* bus.log({ aggregateID: id }).pipe(Stream.runCollect)
expect(
Array.from(history)
.filter((event): event is Event.Payload => !Bus.isSynced(event))
.every((event) => !event.location),
history.filter((event): event is Event.Payload => !Bus.isSynced(event)).every((event) => !event.location),
).toBe(true)
}),
)
@@ -197,8 +195,8 @@ describe("Bus Session routing", () => {
yield* bus.publish(SessionEvent.Moved, { sessionID: id, location: b, projectID: Project.ID.global })
const expected = yield* bus.publish(SessionEvent.Renamed, { sessionID: id, title: "destination" })
const done = yield* bus.publish(Done, {})
expect(Array.from(yield* Fiber.join(typed))).toEqual([expected])
expect(Array.from(yield* Fiber.join(multiple))).toEqual([expected, done])
expect(yield* Fiber.join(typed)).toEqual([expected])
expect(yield* Fiber.join(multiple)).toEqual([expected, done])
}),
)
@@ -226,8 +224,8 @@ describe("Bus Session routing", () => {
const done = yield* bus.publish(Done, {})
yield* Deferred.succeed(gate, undefined)
expect(Array.from(yield* Fiber.join(first))).toEqual([created, before, moved, done])
expect(Array.from(yield* Fiber.join(second))).toEqual([moved, after, done])
expect(yield* Fiber.join(first)).toEqual([created, before, moved, done])
expect(yield* Fiber.join(second)).toEqual([moved, after, done])
expect(moved).not.toHaveProperty("location")
}),
)
@@ -245,9 +243,9 @@ describe("Bus Session routing", () => {
const database = yield* Database.Service
expect(yield* database.db.select().from(SessionTable).where(eq(SessionTable.id, id)).get()).toBeUndefined()
expect(Array.from(yield* Fiber.join(first))).toEqual([deleted, done])
expect(Array.from(yield* Fiber.join(second))).toEqual([done])
expect(Array.from(yield* Fiber.join(global))).toEqual([deleted, missing, done])
expect(yield* Fiber.join(first)).toEqual([deleted, done])
expect(yield* Fiber.join(second)).toEqual([done])
expect(yield* Fiber.join(global)).toEqual([deleted, missing, done])
}),
)
@@ -266,8 +264,8 @@ describe("Bus Session routing", () => {
])
const done = yield* bus.publish(Done, {})
yield* Deferred.succeed(gate, undefined)
expect(Array.from(yield* Fiber.join(first))).toEqual([events[0], events[1], done])
expect(Array.from(yield* Fiber.join(second))).toEqual([events[1], events[2], events[3], done])
expect(yield* Fiber.join(first)).toEqual([events[0], events[1], done])
expect(yield* Fiber.join(second)).toEqual([events[1], events[2], events[3], done])
}),
)
@@ -295,8 +293,8 @@ describe("Bus Session routing", () => {
const done = yield* bus.publish(Done, {})
expect(Exit.isFailure(single)).toBe(true)
expect(Exit.isFailure(batch)).toBe(true)
expect(Array.from(yield* Fiber.join(first))).toEqual([before, after, done])
expect(Array.from(yield* Fiber.join(second))).toEqual([done])
expect(yield* Fiber.join(first)).toEqual([before, after, done])
expect(yield* Fiber.join(second)).toEqual([done])
}),
)
@@ -327,8 +325,8 @@ describe("Bus Session routing", () => {
{ publish: true },
)
const done = yield* bus.publish(Done, {})
expect(Array.from(yield* Fiber.join(first))).toEqual([done])
const received = Array.from(yield* Fiber.join(second))
expect(yield* Fiber.join(first)).toEqual([done])
const received = yield* Fiber.join(second)
expect(received.map((event) => event.id)).toEqual([after.id, replayID, done.id])
expect(received[1]).not.toHaveProperty("location")
}),
+15 -15
View File
@@ -123,7 +123,7 @@ describe("Bus", () => {
yield* bus.publish(Message, { text: "hello" })
yield* bus.publish(CountMessage, { count: 2 })
const received = Array.from(yield* Fiber.join(fiber)).map((event) =>
const received = (yield* Fiber.join(fiber)).map((event) =>
event.type === "test.message" ? event.data.text : event.data.count,
)
expect(received).toEqual(["hello", 2])
@@ -136,7 +136,7 @@ describe("Bus", () => {
const fiber = yield* bus.subscribe(Message).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
const event = yield* bus.publish(Message, { text: "hello" })
const received = Array.from(yield* Fiber.join(fiber))
const received = yield* Fiber.join(fiber)
expect(received).toEqual([event])
expect(event.type).toBe("test.message")
@@ -212,8 +212,8 @@ describe("Bus", () => {
yield* Effect.yieldNow
const event = yield* bus.publish(Message, { text: "hello" })
expect(Array.from(yield* Fiber.join(typed))).toEqual([event])
expect(Array.from(yield* Fiber.join(wildcard))).toEqual([event])
expect(yield* Fiber.join(typed)).toEqual([event])
expect(yield* Fiber.join(wildcard)).toEqual([event])
}),
)
@@ -602,7 +602,7 @@ describe("Bus", () => {
yield* bus.publish(DurableMessage, durableData(aggregateID, "two"))
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
expect((yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
[1, durableData(aggregateID, "one")],
[2, durableData(aggregateID, "two")],
])
@@ -618,7 +618,7 @@ describe("Bus", () => {
yield* bus.publish(DurableMessage, durableData(aggregateID, "one"))
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
expect((yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
[0, durableData(aggregateID, "zero")],
[1, durableData(aggregateID, "one")],
])
@@ -653,7 +653,7 @@ describe("Bus", () => {
yield* bus.publish(DurableMessage, durableData(aggregateID, "during handoff"))
yield* Deferred.succeed(continueRead, undefined)
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
expect((yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
[0, durableData(aggregateID, "during handoff")],
])
}).pipe(Effect.provide(eventLayer))
@@ -672,7 +672,7 @@ describe("Bus", () => {
yield* bus.publish(DurableMessage, durableData(aggregateID, String(index)))
}
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual(
expect((yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual(
Array.from({ length: count }, (_, index) => [index, durableData(aggregateID, String(index))]),
)
}),
@@ -688,7 +688,7 @@ describe("Bus", () => {
yield* bus.publish(Message, { text: "live only" })
yield* bus.publish(DurableMessage, durableData(aggregateID, "durable"))
expect(Array.from(yield* Fiber.join(fiber)).map((event) => event.type)).toEqual([DurableMessage.type])
expect((yield* Fiber.join(fiber)).map((event) => event.type)).toEqual([DurableMessage.type])
}),
)
@@ -1268,7 +1268,7 @@ describe("Bus", () => {
yield* bus.publish(DurableMessage, durableData(aggregateID, "zero"))
yield* bus.publish(DurableMessage, durableData(aggregateID, "one"))
const items = Array.from(yield* Stream.runCollect(bus.log({ aggregateID })))
const items = yield* Stream.runCollect(bus.log({ aggregateID }))
expect(items.map((item) => (Bus.isSynced(item) ? item.type : item.durable?.seq))).toEqual([
Event.Seq.make(0),
@@ -1284,9 +1284,9 @@ describe("Bus", () => {
const bus = yield* Bus.Service
const aggregateID = Session.ID.create()
const empty = Array.from(yield* Stream.runCollect(bus.log({ aggregateID })))
const empty = yield* Stream.runCollect(bus.log({ aggregateID }))
yield* bus.publish(DurableMessage, durableData(aggregateID, "zero"))
const drained = Array.from(yield* Stream.runCollect(bus.log({ aggregateID, after: 0 })))
const drained = yield* Stream.runCollect(bus.log({ aggregateID, after: 0 }))
expect(empty).toEqual([{ type: "log.synced", aggregateID }])
expect(empty[0]).not.toHaveProperty("seq")
@@ -1306,7 +1306,7 @@ describe("Bus", () => {
yield* bus.publish(DurableMessage, durableData(aggregateID, "one"))
const items = Array.from(yield* Fiber.join(fiber))
const items = yield* Fiber.join(fiber)
expect(items.map((item) => (Bus.isSynced(item) ? item : item.durable?.seq))).toEqual([
Event.Seq.make(0),
{ type: "log.synced", aggregateID, seq: Event.Seq.make(0) },
@@ -1330,7 +1330,7 @@ describe("Bus", () => {
yield* bus.publish(DurableMessage, durableData(aggregateID, "three"))
yield* bus.publish(DurableMessage, durableData(aggregateID, "four"))
const items = Array.from(yield* Stream.runCollect(bus.log({ aggregateID })))
const items = yield* Stream.runCollect(bus.log({ aggregateID }))
expect(items.map((item) => (Bus.isSynced(item) ? item.type : item.durable?.seq))).toEqual([
Event.Seq.make(0),
@@ -1378,7 +1378,7 @@ describe("Bus", () => {
yield* bus.publish(DurableMessage, durableData(aggregateID, "one"))
yield* Deferred.succeed(releaseRead, undefined)
const items = Array.from(yield* Fiber.join(fiber))
const items = yield* Fiber.join(fiber)
expect(items.map((item) => (Bus.isSynced(item) ? item : item.durable?.seq))).toEqual([
Event.Seq.make(0),
{ type: "log.synced", aggregateID, seq: Event.Seq.make(0) },
-411
View File
@@ -1,411 +0,0 @@
import path from "path"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { parse } from "jsonc-parser"
import { isRecord } from "@opencode-ai/ai/utils/record"
import { ConfigFile } from "@opencode-ai/core/config/file"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { withTempDir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
// No Config, Location, Watcher, Credential, or WellKnown services are provided.
const it = testEffect(LayerNode.compile(FSUtil.node))
describe("ConfigFile", () => {
it.live("edits the explicit target and preserves comments and unrelated fields", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const global = path.join(tmp.path, "global", "opencode.jsonc")
const target = path.join(tmp.path, "project", "custom.jsonc")
const text = '{\n // Keep this comment.\n "shell": "project",\n "custom": { "value": 1 },\n}\n'
yield* fs.writeWithDirs(global, '{ "shell": "global" }')
yield* fs.writeWithDirs(target, text)
const updated = yield* ConfigFile.update(target, (draft) => {
draft.shell = "updated"
})
expect(updated).toEqual({ shell: "updated", custom: { value: 1 } })
expect(yield* fs.readFileString(target)).toBe(text.replace('"project"', '"updated"'))
expect(yield* fs.readFileString(global)).toBe('{ "shell": "global" }')
}),
),
)
it.live("leaves raw substitutions, model shorthand, and legacy shapes unresolved", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.jsonc")
const text = `{
"model": "{env:OPENCODE_TEST_CONFIG_MODEL}",
"shell": "{file:missing-shell.txt}",
"skills": { "paths": ["./skills"] },
"agent": { "review": { "model": "acme/reasoner" } },
"username": "before"
}
`
yield* fs.writeFileString(target, text)
yield* ConfigFile.update(target, (draft) => {
expect(draft.model).toBe("{env:OPENCODE_TEST_CONFIG_MODEL}")
expect(draft.shell).toBe("{file:missing-shell.txt}")
expect(draft.skills).toEqual({ paths: ["./skills"] })
draft.username = "after"
})
expect(yield* fs.readFileString(target)).toBe(text.replace('"before"', '"after"'))
}),
),
)
it.live("patches nested source fields and deletes legacy keys without migrating them", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.jsonc")
yield* fs.writeFileString(
target,
`{
"agent": {
"review": { "description": "before", "hidden": true },
// Keep the other definition.
"build": { "description": "unchanged" }
},
"snapshot": true
}
`,
)
const updated = yield* ConfigFile.update(target, (draft) => {
const agent: unknown = draft.agent
if (!isRecord(agent) || !isRecord(agent.review)) throw new Error("Missing fixture agent")
agent.review.description = "after"
agent.review.color = "blue"
delete agent.review.hidden
delete draft.snapshot
})
expect(updated).toEqual({
agent: { review: { description: "after", color: "blue" }, build: { description: "unchanged" } },
})
expect(parse(yield* fs.readFileString(target))).toEqual(updated)
expect(yield* fs.readFileString(target)).toContain("// Keep the other definition.")
}),
),
)
it.live("patches array elements without rewriting untouched comments", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.jsonc")
const text = `{
"plugins": [
// Keep the first plugin.
"first",
"second",
// Keep the third plugin.
"third",
"fourth"
]
}
`
yield* fs.writeFileString(target, text)
yield* ConfigFile.update(target, (draft) => {
if (!Array.isArray(draft.plugins)) throw new Error("Missing fixture plugins")
draft.plugins[1] = "updated"
})
expect(yield* fs.readFileString(target)).toBe(text.replace('"second"', '"updated"'))
const shortened = yield* ConfigFile.update(target, (draft) => {
if (!Array.isArray(draft.plugins)) throw new Error("Missing fixture plugins")
draft.plugins.splice(1, 3)
})
expect(shortened.plugins).toEqual(["first"])
expect(parse(yield* fs.readFileString(target))).toEqual(shortened)
const extended = yield* ConfigFile.update(target, (draft) => {
if (!Array.isArray(draft.plugins)) throw new Error("Missing fixture plugins")
draft.plugins.push("added", "last")
})
expect(extended.plugins).toEqual(["first", "added", "last"])
expect(parse(yield* fs.readFileString(target))).toEqual(extended)
expect(yield* fs.readFileString(target)).toContain("// Keep the first plugin.")
}),
),
)
it.live("preserves adjacent comments when deleting properties and array elements", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.jsonc")
yield* fs.writeFileString(
target,
`{
"shell": "remove",
// Keep the model explanation.
"model": "acme/reasoner",
"plugins": ["first", "second", /* Keep the plugin explanation. */ "third"],
"skills": [/* Keep the source explanation. */ "remove",],
}
`,
)
const updated = yield* ConfigFile.update(target, (draft) => {
delete draft.shell
if (!Array.isArray(draft.plugins)) throw new Error("Missing fixture plugins")
draft.plugins.splice(1, 1)
draft.skills = []
})
expect(parse(yield* fs.readFileString(target))).toEqual(updated)
expect(updated).toEqual({ model: "acme/reasoner", plugins: ["first", "third"], skills: [] })
expect(yield* fs.readFileString(target)).toContain("// Keep the model explanation.")
expect(yield* fs.readFileString(target)).toContain("/* Keep the plugin explanation. */")
expect(yield* fs.readFileString(target)).toContain("/* Keep the source explanation. */")
}),
),
)
it.live("deletes own JSON keys that also exist on Object.prototype", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.json")
yield* fs.writeFileString(
target,
'{ "\\u005f_proto__": "remove", "constructor": "remove", "toString": "remove", "shell": "keep" }',
)
const updated = yield* ConfigFile.update(target, (draft) => {
;["__proto__", "constructor", "toString"].forEach((key) => {
delete draft[key]
})
})
expect(updated).toEqual({ shell: "keep" })
expect(yield* fs.readJson(target)).toEqual(updated)
}),
),
)
it.live("preserves and edits object-valued __proto__ source keys", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.json")
yield* fs.writeFileString(target, '{ "__proto__": { "value": "before" }, "shell": "keep" }')
const updated = yield* ConfigFile.update(target, (draft) => {
expect(Object.hasOwn(draft, "__proto__")).toBe(true)
const entry: unknown = draft["__proto__"]
if (!isRecord(entry)) throw new Error("Missing fixture entry")
entry.value = "after"
})
expect(updated).toEqual({ ["__proto__"]: { value: "after" }, shell: "keep" })
expect(yield* fs.readJson(target)).toEqual(updated)
expect(Object.getPrototypeOf(updated)).toBe(Object.prototype)
}),
),
)
it.live("rejects a duplicate-key patch that would not change the effective value", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.json")
const text = '{ "shell": "first", "shell": "second" }'
yield* fs.writeFileString(target, text)
const error = yield* ConfigFile.update(target, (draft) => {
draft.shell = "after"
}).pipe(Effect.flip)
expect(error).toBeInstanceOf(ConfigFile.UpdateError)
expect(error.message).toBe(`Config patch does not match the requested update: ${target}`)
expect(yield* fs.readFileString(target)).toBe(text)
expect(yield* fs.exists(target + ".tmp")).toBe(false)
}),
),
)
it.live("rereads the selected file for consecutive edits without a watcher", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.json")
yield* fs.writeFileString(target, '{ "shell": "first" }')
yield* ConfigFile.update(target, (draft) => {
draft.shell = "second"
})
yield* ConfigFile.update(target, (draft) => {
expect(draft.shell).toBe("second")
draft.username = "added"
})
expect(yield* fs.readJson(target)).toEqual({ shell: "second", username: "added" })
yield* fs.writeFileString(target, '{ "shell": "external", "username": "added" }')
const updated = yield* ConfigFile.update(target, (draft) => {
expect(draft.shell).toBe("external")
draft.snapshots = false
})
expect(yield* fs.readJson(target)).toEqual(updated)
expect(updated).toEqual({ shell: "external", username: "added", snapshots: false })
}),
),
)
it.live("serializes concurrent read-modify-write calls", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.json")
yield* fs.writeFileString(target, '{ "count": 0 }')
const increment = ConfigFile.update(target, (draft) => {
if (typeof draft.count !== "number") throw new Error("Missing fixture count")
draft.count++
})
yield* Effect.all([increment, increment, increment], { concurrency: "unbounded" })
expect(yield* fs.readJson(target)).toEqual({ count: 3 })
}),
),
)
it.live("does not rewrite no-op or structurally equal edits", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.json")
const text = '{\r\n "plugins": ["first"]\r\n}'
yield* fs.writeFileString(target, text)
const before = yield* fs.stat(target)
yield* ConfigFile.update(target, () => {})
yield* ConfigFile.update(target, (draft) => {
draft.plugins = ["first"]
})
expect(yield* fs.readFileString(target)).toBe(text)
expect((yield* fs.stat(target)).ino).toEqual(before.ino)
expect((yield* fs.stat(target)).mtime).toEqual(before.mtime)
expect(yield* fs.exists(target + ".tmp")).toBe(false)
}),
),
)
it.live("leaves the file unchanged when a callback throws and permits a later edit", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.json")
const text = '{ "shell": "before" }'
yield* fs.writeFileString(target, text)
const cause = new Error("Rejected config update")
const error = yield* ConfigFile.update(target, (draft) => {
draft.shell = "discarded"
throw cause
}).pipe(Effect.flip)
expect(error).toBeInstanceOf(ConfigFile.UpdateError)
expect(error.message).toBe("Config update failed")
expect(error.cause).toBe(cause)
expect(yield* fs.readFileString(target)).toBe(text)
expect(yield* fs.exists(target + ".tmp")).toBe(false)
yield* ConfigFile.update(target, (draft) => {
draft.shell = "recovered"
})
expect(yield* fs.readJson(target)).toEqual({ shell: "recovered" })
}),
),
)
it.live("ignores callback return values instead of replacing the document", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.json")
yield* fs.writeFileString(target, "{}")
expect(yield* ConfigFile.update(target, () => new Date(0))).toEqual({})
expect(yield* fs.readFileString(target)).toBe("{}")
const updated = yield* ConfigFile.update(target, (draft) => (draft.shell = "updated"))
expect(updated).toEqual({ shell: "updated" })
expect(yield* fs.readJson(target)).toEqual(updated)
}),
),
)
it.live("rejects non-JSON mutations before writing", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.json")
const text = '{ "shell": "before" }'
yield* fs.writeFileString(target, text)
const error = yield* ConfigFile.update(target, (draft) => {
draft.invalid = Number.NaN
}).pipe(Effect.flip)
expect(error).toBeInstanceOf(ConfigFile.UpdateError)
expect(error.message).toBe(`Config update must produce a JSON object: ${target}`)
expect(yield* fs.readFileString(target)).toBe(text)
expect(yield* fs.exists(target + ".tmp")).toBe(false)
}),
),
)
;["", "{", "[]", "null"].forEach((text) => {
it.live(`rejects invalid or non-object source ${JSON.stringify(text)}`, () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.json")
yield* fs.writeFileString(target, text)
const error = yield* ConfigFile.update(target, () => {
throw new Error("Callback must not run")
}).pipe(Effect.flip)
expect(error).toBeInstanceOf(ConfigFile.UpdateError)
expect(error.message).toBe(`Invalid config file: ${target}`)
expect(yield* fs.readFileString(target)).toBe(text)
expect(yield* fs.exists(target + ".tmp")).toBe(false)
}),
),
)
})
it.live("reports a missing target without creating it", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "missing.json")
const error = yield* ConfigFile.update(target, () => {}).pipe(Effect.flip)
expect(error).toBeInstanceOf(ConfigFile.UpdateError)
expect(error.message).toBe(`Failed to read config: ${target}`)
expect(error.cause).toBeDefined()
expect(yield* fs.exists(target)).toBe(false)
}),
),
)
it.live("reports write failures without replacing the target", () =>
withTempDir((tmp) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const target = path.join(tmp.path, "opencode.json")
const text = '{ "shell": "before" }'
yield* fs.writeFileString(target, text)
yield* fs.makeDirectory(target + ".tmp")
const error = yield* ConfigFile.update(target, (draft) => {
draft.shell = "discarded"
}).pipe(Effect.flip)
expect(error).toBeInstanceOf(ConfigFile.UpdateError)
expect(error.message).toBe(`Failed to write config: ${target}`)
expect(error.cause).toBeDefined()
expect(yield* fs.readFileString(target)).toBe(text)
}),
),
)
})
+43
View File
@@ -34,6 +34,41 @@ const decode = Schema.decodeUnknownSync(Info)
const document = path.join(import.meta.dir, "opencode.json")
describe("config plugin reloads", () => {
it.effect("preserves reference precedence and insertion order across documents", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const references = yield* Reference.Service
const host = yield* PluginHost.make(plugins)
yield* references.transform((draft) =>
draft.add(
"external",
Reference.LocalSource.make({ type: "local", path: AbsolutePath.make("/references/external") }),
),
)
yield* ConfigReferencePlugin.Plugin.effect(host)
const result = yield* references.list()
expect(result.map((reference) => reference.name)).toEqual(["external", "shared", "first", "second"])
expect(result.find((reference) => reference.name === "shared")?.path).toBe(
AbsolutePath.make(path.resolve("/config/second/shared")),
)
}).pipe(
Effect.provide(
Config.testLayer([
referenceConfig("/config/first/opencode.json", {
shared: "./shared",
first: "./first",
}),
referenceConfig("/config/second/opencode.json", {
shared: "./shared",
second: "./second",
}),
]),
),
Effect.provideService(Global.Service, Global.Service.of(Global.make())),
),
)
it.live("reloads config-backed domains without reloading external plugins", () =>
Effect.gen(function* () {
const agents = yield* Agent.Service
@@ -102,6 +137,14 @@ function config(name: string) {
})
}
function referenceConfig(file: string, references: Record<string, string>) {
return new Document({
type: "document",
path: AbsolutePath.make(file),
info: decode({ references }),
})
}
function title(value: string) {
return value.charAt(0).toUpperCase() + value.slice(1)
}
@@ -16,18 +16,7 @@ function js(code: string, opts?: ChildProcess.CommandOptions) {
}
function decodeByteStream(stream: Stream.Stream<Uint8Array, PlatformError.PlatformError>) {
return Stream.runCollect(stream).pipe(
Effect.map((chunks) => {
const total = chunks.reduce((acc, x) => acc + x.length, 0)
const out = new Uint8Array(total)
let off = 0
for (const chunk of chunks) {
out.set(chunk, off)
off += chunk.length
}
return new TextDecoder("utf-8").decode(out).trim()
}),
)
return Stream.mkUint8Array(stream).pipe(Effect.map((bytes) => new TextDecoder("utf-8").decode(bytes).trim()))
}
function alive(pid: number) {
+1 -1
View File
@@ -186,7 +186,7 @@ describe("Integration", () => {
value: Credential.Key.make({ type: "key", key: "secret", configuration: { accountId: "account" } }),
}),
])
expect(Array.from(yield* Fiber.join(created), (event) => ({ type: event.type, data: event.data }))).toEqual([
expect((yield* Fiber.join(created)).map((event) => ({ type: event.type, data: event.data }))).toEqual([
{ type: Credential.Event.Updated.type, data: {} },
{ type: Credential.Event.Switched.type, data: { credentialID: stored[0]?.id, integrationID } },
])
@@ -49,6 +49,29 @@ export const environmentConformance = <E>(
}),
)
check("observes filesystem state when an operation executes", (harness) =>
Effect.gen(function* () {
const target = `${harness.root}/deferred.txt`
const source = `${harness.root}/source.txt`
const destination = `${harness.root}/destination.txt`
const read = harness.files.read(target)
const stat = harness.files.stat(target)
const list = harness.files.list(harness.root)
const move = harness.files.move(source, destination)
yield* harness.files.write(target, bytes("first"))
yield* harness.files.write(source, bytes("moved"))
expect(text((yield* read).bytes)).toBe("first")
expect((yield* stat).size).toBe(5)
expect(yield* list).toContainEqual({ name: "deferred.txt", type: "file" })
yield* move
expect(text((yield* harness.files.read(destination)).bytes)).toBe("moved")
yield* harness.files.write(target, bytes("second"))
expect(text((yield* read).bytes)).toBe("second")
}),
)
check("reports missing paths", (harness) =>
Effect.gen(function* () {
const target = `${harness.root}/missing`
@@ -0,0 +1,200 @@
import { describe, expect, test } from "bun:test"
import { Effect, Layer } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { LLM, Message } from "@opencode-ai/ai"
import { LLMClient, RequestExecutor } from "@opencode-ai/ai/route"
import { compileRequest } from "@opencode-ai/ai/route/client"
import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential"
import { Model } from "@opencode-ai/core/model"
import { ModelResolver } from "@opencode-ai/core/model-resolver"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { MergeGatewayPlugin } from "@opencode-ai/core/plugin/provider/merge-gateway"
import { Provider } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const providerID = Provider.ID.make("merge-gateway")
const modelID = Model.ID.make("zai/glm-5.3-flash")
const addPlugin = Effect.fn(function* () {
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin)
yield* MergeGatewayPlugin.effect(host)
})
describe("MergeGatewayPlugin", () => {
test("is registered as a built-in provider plugin", () => {
expect(ProviderPlugins).toContain(MergeGatewayPlugin)
})
it.effect("sets gateway defaults by effective package and leaves later overrides intact", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const other = Model.ID.make("other")
const custom = Provider.ID.make("custom")
yield* catalog.transform((draft) => {
draft.provider.update(providerID, (provider) => {
provider.package = Provider.aisdk("merge-gateway-ai-sdk-provider")
provider.settings = { baseURL: "https://custom.example/v1", apiKey: "configured-key", reasoningEffort: "low" }
})
draft.model.update(providerID, modelID, (model) => {
model.compatibility = { reasoningField: "reasoning_content", requireFinishReason: true }
model.settings = { reasoningEffort: "high" }
})
draft.model.update(providerID, other, (model) => {
model.package = Provider.aisdk("@ai-sdk/openai-compatible")
})
draft.provider.update(custom, (provider) => {
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
provider.settings = { baseURL: "https://inherited.example/v1", reasoningEffort: "high" }
})
draft.model.update(custom, modelID, (model) => {
model.package = Provider.aisdk("merge-gateway-ai-sdk-provider")
})
})
yield* addPlugin()
expect((yield* catalog.provider.get(providerID))?.package).toBe("@opencode-ai/ai/providers/openai-compatible")
expect((yield* catalog.model.get(providerID, modelID))?.settings).toEqual({
baseURL: "https://custom.example/v1",
apiKey: "configured-key",
provider: providerID,
providerOptions: { reasoningEffort: "high" },
})
expect((yield* catalog.model.get(providerID, modelID))?.compatibility).toEqual({
reasoningField: "thinking",
maxTokensField: "max_tokens",
requireReasoning: false,
requireFinishReason: true,
})
expect((yield* catalog.model.get(providerID, other))?.compatibility).toBeUndefined()
expect((yield* catalog.model.get(providerID, other))?.package).toBe(Provider.aisdk("@ai-sdk/openai-compatible"))
expect((yield* catalog.model.get(custom, modelID))?.compatibility?.reasoningField).toBe("thinking")
expect((yield* catalog.model.get(custom, modelID))?.package).toBe("@opencode-ai/ai/providers/openai-compatible")
expect((yield* catalog.model.get(custom, modelID))?.settings).toEqual({
baseURL: "https://inherited.example/v1",
reasoningEffort: "high",
provider: custom,
providerOptions: { reasoningEffort: "high" },
})
for (const [provider, id, effort, url] of [
[providerID, other, "low", "https://custom.example/v1"],
[custom, modelID, "high", "https://inherited.example/v1"],
] as const) {
const info = yield* catalog.model.get(provider, id)
if (!info) throw new Error("Missing model override")
const model = yield* ModelResolver.fromCatalogModel(info, Credential.Key.make({ type: "key", key: "test-key" }))
expect(model.route.endpoint.baseURL).toBe(url)
const request = yield* compileRequest(LLM.request({ model, prompt: "Hello" }))
expect(request.body.reasoning_effort).toBe(effort)
}
yield* catalog.transform((draft) => {
draft.provider.update(providerID, (provider) => {
provider.settings = { ...provider.settings, baseURL: "https://later.example/v1" }
})
draft.model.update(providerID, modelID, (model) => {
model.compatibility = { ...model.compatibility, reasoningField: "custom_thinking" }
})
})
expect((yield* catalog.model.get(providerID, modelID))?.compatibility?.reasoningField).toBe("custom_thinking")
expect((yield* catalog.model.get(providerID, modelID))?.settings?.baseURL).toBe("https://later.example/v1")
}),
)
it.effect("uses native HTTP for images, thinking, tool calls, and usage", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((draft) => {
draft.provider.update(providerID, (provider) => {
provider.package = Provider.aisdk("merge-gateway-ai-sdk-provider")
provider.headers = { "x-test": "header" }
})
draft.model.update(providerID, modelID, (model) => {
model.body = { tags: [{ key: "env", value: "test" }] }
})
})
yield* addPlugin()
const info = yield* catalog.model.get(providerID, modelID)
if (!info) throw new Error("Missing Merge model")
expect(info.package).toBe("@opencode-ai/ai/providers/openai-compatible")
const model = yield* ModelResolver.fromCatalogModel(info, Credential.Key.make({ type: "key", key: "test-key" }), {
loadAISDK: () => Effect.die("Merge Gateway must use the native provider"),
})
expect(model.route.id).toBe("openai-compatible-chat")
const transport = Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.sync(() => {
expect(request.url).toBe("https://api-gateway.merge.dev/v1/ai-sdk/chat/completions")
expect(request.headers.authorization).toBe("Bearer test-key")
expect(request.headers["x-test"]).toBe("header")
if (request.body._tag !== "Uint8Array") throw new Error("Expected JSON request")
const body = JSON.parse(new TextDecoder().decode(request.body.body))
expect(body.max_tokens).toBe(100)
expect(body).not.toHaveProperty("max_completion_tokens")
expect(body.tags).toEqual([{ key: "env", value: "test" }])
expect(body.messages[0]).toMatchObject({
role: "user",
content: [
{ type: "text", text: "[Image 1] describe this image" },
{ type: "image_url", image_url: { url: "data:image/png;base64,iVBORw0KGgo=" } },
{ type: "image_url", image_url: { url: "data:image/jpeg;base64,/9j/" } },
],
})
expect(body.messages[1]).not.toHaveProperty("thinking")
const chunks = [
{ choices: [{ index: 0, delta: { thinking: "Inspecting the image." }, finish_reason: null }] },
{ choices: [{ index: 0, delta: { content: "I see it." }, finish_reason: null }] },
{
choices: [
{
index: 0,
delta: {
tool_calls: [
{
index: 0,
id: "call_1",
type: "function",
function: { name: "read", arguments: '{"path":"image.png"}' },
},
],
},
finish_reason: "tool_calls",
},
],
},
{ choices: [], usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 } },
]
return HttpClientResponse.fromWeb(
request,
new Response(chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("") + "data: [DONE]\n\n", {
headers: { "content-type": "text/event-stream" },
}),
)
}),
),
)
const response = yield* LLMClient.generate(
LLM.request({
model,
generation: { maxTokens: 100 },
messages: [
Message.user([
Message.text("[Image 1] describe this image"),
{ type: "media", mediaType: "image/png", data: "iVBORw0KGgo=" },
{ type: "media", mediaType: "image/jpeg", data: "/9j/" },
]),
Message.assistant("Let me look."),
Message.user("Continue"),
],
}),
).pipe(Effect.provide(LLMClient.layer.pipe(Layer.provide(RequestExecutor.layer.pipe(Layer.provide(transport))))))
expect(response.text).toBe("I see it.")
expect(response.reasoning).toBe("Inspecting the image.")
expect(response.toolCalls).toMatchObject([{ name: "read", input: { path: "image.png" } }])
expect(response.usage).toMatchObject({ inputTokens: 10, outputTokens: 5 })
}),
)
})
@@ -20,6 +20,8 @@ import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { App } from "@opencode-ai/core/app"
import { Agent } from "@opencode-ai/core/agent"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -301,6 +303,54 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
}),
)
it.effect("manual compaction records model resolution failures without calling the model", () =>
Effect.gen(function* () {
requests = []
const compaction = yield* SessionCompaction.Service
const store = yield* SessionStore.Service
const sessionID = Session.ID.make("ses_manual_resolution_failure")
const session = yield* insertSession(sessionID)
const modelRequests = yield* SessionModelRequest.Service
const inputID = SessionMessage.ID.make("msg_manual_resolution_failure")
expect(
yield* compaction.compactManual({
session,
resolveModel: () =>
Effect.fail(
new SessionRunnerModel.ModelUnavailableError({
providerID: Provider.ID.make("test"),
modelID: Model.ID.make("missing"),
}),
),
prepare: modelRequests.prepare,
messages: [
{
id: SessionMessage.ID.create(),
type: "user",
text: "Summarize this conversation.",
time: { created: DateTime.makeUnsafe(0) },
},
],
inputID,
}),
).toEqual({
status: "failed",
error: { type: "provider.no-route", message: "Model unavailable: test/missing" },
})
expect(requests).toHaveLength(0)
expect(yield* store.context(sessionID)).toMatchObject([
{
id: inputID,
type: "compaction",
status: "failed",
reason: "manual",
error: { type: "provider.no-route", message: "Model unavailable: test/missing" },
},
])
}),
)
it.effect("forked session compaction reuses the fork root prompt cache key", () =>
Effect.gen(function* () {
requests = []
@@ -857,8 +857,7 @@ describe("SessionModelTransport", () => {
test("records metadata-only lifecycle metrics", async () => {
const fixture = automatic()
await run(
fixture.connector,
await Effect.runPromise(
Effect.gen(function* () {
const transport = yield* SessionModelTransport.Service
const executor = transport.bind(session)
@@ -874,7 +873,11 @@ describe("SessionModelTransport", () => {
)
expect(JSON.stringify(lifecycle)).not.toContain("secret-one")
expect(JSON.stringify(lifecycle)).not.toContain("secret-two")
}),
}).pipe(
Effect.provide(SessionModelTransport.makeLayer(fixture.connector)),
Effect.scoped,
Effect.provideService(Metric.MetricRegistry, new Map()),
),
)
})
})
+42 -236
View File
@@ -60,25 +60,32 @@ const assistantRow = (
return { id, session_id: sessionID, type, seq, time_created: DateTime.toEpochMillis(time.created), data }
}
const seedSession = (overrides?: Partial<typeof SessionTable.$inferInsert>) =>
Effect.gen(function* () {
const db = (yield* Database.Service).db
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
...overrides,
})
.run()
return db
})
describe("SessionProjector", () => {
it.effect("does not settle a pending manual compaction on an auto failure", () =>
Effect.gen(function* () {
const db = (yield* Database.Service).db
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
const db = yield* seedSession()
const bus = yield* Bus.Service
const inputID = SessionMessage.ID.make("msg_manual_compaction")
yield* SessionInbox.admitCompaction(db, bus, { id: inputID, sessionID, delivery: "queue" })
@@ -95,22 +102,7 @@ describe("SessionProjector", () => {
it.effect("loads legacy revert storage into canonical state", () =>
Effect.gen(function* () {
const db = (yield* Database.Service).db
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
const db = yield* seedSession()
const legacy = JSON.stringify({
messageID: "msg_boundary",
snapshot: "tree",
@@ -131,28 +123,14 @@ describe("SessionProjector", () => {
it.effect("projects staged, cleared, and committed reverts", () =>
Effect.gen(function* () {
const db = (yield* Database.Service).db
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
cost: 1.25,
tokens_input: 10,
tokens_output: 4,
tokens_reasoning: 2,
tokens_cache_read: 3,
tokens_cache_write: 1,
})
.run()
const db = yield* seedSession({
cost: 1.25,
tokens_input: 10,
tokens_output: 4,
tokens_reasoning: 2,
tokens_cache_read: 3,
tokens_cache_write: 1,
})
const boundary = SessionMessage.ID.make("msg_boundary")
const earlier = SessionMessage.ID.make("msg_earlier")
yield* db
@@ -227,24 +205,7 @@ describe("SessionProjector", () => {
it.effect("orders projected messages and context by durable aggregate sequence", () =>
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
.pipe(Effect.orDie)
yield* seedSession()
const bus = yield* Bus.Service
yield* bus.publish(SessionEvent.InboxEnqueued, {
@@ -300,24 +261,7 @@ describe("SessionProjector", () => {
it.effect("maps malformed persisted rows consistently while single-message lookup defects", () =>
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
.pipe(Effect.orDie)
const db = yield* seedSession()
const messageID = SessionMessage.ID.make("msg_malformed")
yield* db
.insert(SessionMessageTable)
@@ -344,24 +288,7 @@ describe("SessionProjector", () => {
it.effect("consumes the pending row and projects the message at promotion", () =>
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
.pipe(Effect.orDie)
const db = yield* seedSession()
const bus = yield* Bus.Service
const id = SessionMessage.ID.make("msg_admitted")
const admitted = yield* SessionInbox.admit(db, bus, {
@@ -387,26 +314,7 @@ describe("SessionProjector", () => {
it.effect("projects durable context messages supported by the updater", () =>
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
agent: "plan",
model: previousModel,
})
.run()
.pipe(Effect.orDie)
const db = yield* seedSession({ agent: "plan", model: previousModel })
const bus = yield* Bus.Service
yield* bus.publish(SessionEvent.AgentSelected, {
@@ -532,24 +440,7 @@ describe("SessionProjector", () => {
it.effect("rejects distinct creator events that reuse one projected message ID", () =>
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
.pipe(Effect.orDie)
const db = yield* seedSession()
const bus = yield* Bus.Service
const id = SessionMessage.ID.make("msg_creator_collision")
const { id: _, type, ...data } = encodeMessage({ id, type: "synthetic", text: "existing", time: { created } })
@@ -576,24 +467,7 @@ describe("SessionProjector", () => {
it.effect("projects retry state and clears it at the next step or execution terminal", () =>
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
.pipe(Effect.orDie)
const db = yield* seedSession()
const bus = yield* Bus.Service
const first = SessionMessage.ID.make("msg_retry_first")
const second = SessionMessage.ID.make("msg_retry_second")
@@ -643,24 +517,7 @@ describe("SessionProjector", () => {
it.effect("does not infer restart continuation from lifecycle history", () =>
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
.pipe(Effect.orDie)
const db = yield* seedSession()
const bus = yield* Bus.Service
const suspended = () =>
db
@@ -680,24 +537,7 @@ describe("SessionProjector", () => {
it.effect("updates only the newest incomplete assistant projection", () =>
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
.pipe(Effect.orDie)
const db = yield* seedSession()
yield* db
.insert(SessionMessageTable)
.values([
@@ -757,24 +597,7 @@ describe("SessionProjector", () => {
it.effect("projects ended and failed step terminal state", () =>
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
.pipe(Effect.orDie)
const db = yield* seedSession()
const endedID = SessionMessage.ID.make("msg_ended")
const failedID = SessionMessage.ID.make("msg_failed")
yield* db
@@ -844,24 +667,7 @@ describe("SessionProjector", () => {
it.effect("does not revive a stale incomplete assistant projection", () =>
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
.pipe(Effect.orDie)
const db = yield* seedSession()
yield* db
.insert(SessionMessageTable)
.values([
+24 -121
View File
@@ -198,18 +198,20 @@ beforeEach(() => {
titleStream = successfulTitle
})
const enableTitleAgent = Effect.gen(function* () {
const agents = yield* Agent.Service
yield* agents.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
})
it.effect("generates a title from the sole user message and renames the session", () =>
Effect.gen(function* () {
requests = []
titleStream = successfulTitle
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
yield* enableTitleAgent
const sessionID = Session.ID.make("ses_title_generate")
yield* insertSession(sessionID)
yield* prompt(sessionID, "Help me debug the failing build")
@@ -240,17 +242,8 @@ it.effect("generates a title from the sole user message and renames the session"
it.effect("uses a small model from the primary provider", () =>
Effect.gen(function* () {
requests = []
titleStream = successfulTitle
selectedSmall = small
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
yield* enableTitleAgent
const sessionID = Session.ID.make("ses_title_small_model")
yield* insertSession(sessionID)
yield* prompt(sessionID, "Use a small model for this title")
@@ -267,20 +260,12 @@ it.effect("uses a small model from the primary provider", () =>
it.effect("falls back to the primary model when the small model fails", () =>
Effect.gen(function* () {
requests = []
titleStream = () =>
requests.length === 1
? Stream.make(LLMEvent.providerError({ message: "Small model unavailable" }))
: successfulTitle()
selectedSmall = lowSmall
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
yield* enableTitleAgent
const sessionID = Session.ID.make("ses_title_small_fallback")
yield* insertSession(
sessionID,
@@ -314,16 +299,7 @@ it.effect("falls back to the primary model when the small model fails", () =>
it.effect("generates from the first user message after later messages exist", () =>
Effect.gen(function* () {
requests = []
titleStream = successfulTitle
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
yield* enableTitleAgent
const sessionID = Session.ID.make("ses_title_second_message")
yield* insertSession(sessionID)
yield* prompt(sessionID, "First message")
@@ -342,16 +318,7 @@ it.effect("generates from the first user message after later messages exist", ()
it.effect("retries a legacy persisted fallback title", () =>
Effect.gen(function* () {
requests = []
titleStream = successfulTitle
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
yield* enableTitleAgent
const sessionID = Session.ID.make("ses_title_legacy")
const created = Date.parse("2026-07-30T18:45:03.662Z")
yield* insertSession(sessionID, "New session - 2026-07-30T18:45:03.662Z", created)
@@ -368,16 +335,7 @@ it.effect("retries a legacy persisted fallback title", () =>
it.effect("generates a title for an explicitly requested child session", () =>
Effect.gen(function* () {
requests = []
titleStream = successfulTitle
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
yield* enableTitleAgent
const sessionID = Session.ID.make("ses_title_child")
const { db } = yield* Database.Service
yield* db
@@ -411,8 +369,6 @@ it.effect("generates a title for an explicitly requested child session", () =>
it.effect("does not generate when the title agent is removed", () =>
Effect.gen(function* () {
requests = []
titleStream = successfulTitle
const sessionID = Session.ID.make("ses_title_no_agent")
yield* insertSession(sessionID)
yield* prompt(sessionID, "Help me debug the failing build")
@@ -429,14 +385,7 @@ it.effect("does not generate when the title agent is removed", () =>
it.effect("regenerates an existing title using the title agent", () =>
Effect.gen(function* () {
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
yield* enableTitleAgent
const sessionID = Session.ID.make("ses_title_regenerate")
yield* insertSession(sessionID, "Original title")
yield* prompt(sessionID, "Investigate the login failure")
@@ -480,14 +429,7 @@ it.effect("regenerates an existing title using the title agent", () =>
it.effect("bounds regeneration context while preserving the original request and recent conversation", () =>
Effect.gen(function* () {
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
yield* enableTitleAgent
const sessionID = Session.ID.make("ses_title_regenerate_bounded")
yield* insertSession(sessionID, "Original title")
yield* prompt(sessionID, `ORIGINAL_GOAL ${"a".repeat(3_000)} OMITTED_ORIGINAL_END`)
@@ -509,14 +451,7 @@ it.effect("bounds regeneration context while preserving the original request and
it.effect("preserves the existing title when regeneration fails", () =>
Effect.gen(function* () {
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
yield* enableTitleAgent
const sessionID = Session.ID.make("ses_title_regenerate_failure")
yield* insertSession(sessionID, "Original title")
yield* prompt(sessionID, "Fail to regenerate this title")
@@ -533,15 +468,7 @@ it.effect("preserves the existing title when regeneration fails", () =>
it.effect("retries after a failed title request", () =>
Effect.gen(function* () {
requests = []
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
yield* enableTitleAgent
const sessionID = Session.ID.make("ses_title_retry")
yield* insertSession(sessionID)
yield* prompt(sessionID, "Retry this title")
@@ -560,14 +487,7 @@ it.effect("retries after a failed title request", () =>
it.effect("does not rename after a failed title stream", () =>
Effect.gen(function* () {
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
yield* enableTitleAgent
const sessionID = Session.ID.make("ses_title_stream_failure")
yield* insertSession(sessionID)
yield* prompt(sessionID, "Fail this title stream")
@@ -589,16 +509,7 @@ it.effect("does not rename after a failed title stream", () =>
it.effect("keeps session context hooks away from title requests", () =>
Effect.gen(function* () {
requests = []
titleStream = successfulTitle
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
yield* enableTitleAgent
// Context hooks shape the agent conversation; title generation is not part of
// it, so it opts out and the transcript passes through unchanged.
const hooks = yield* PluginHooks.Service
@@ -621,15 +532,7 @@ it.effect("keeps session context hooks away from title requests", () =>
it.effect("preserves a manual rename completed while generation is in flight", () =>
Effect.gen(function* () {
requests = []
const agentService = yield* Agent.Service
yield* agentService.transform((editor) => {
editor.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "You are a title generator."
})
})
yield* enableTitleAgent
const sessionID = Session.ID.make("ses_title_manual_rename")
yield* insertSession(sessionID)
yield* prompt(sessionID, "Generate this title")
+2 -2
View File
@@ -171,7 +171,7 @@ describe("Worktree", () => {
{ directory: created.directory, strategy: "git" },
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
)
expect(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
expect((yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: false })
@@ -550,7 +550,7 @@ describe("Worktree", () => {
{ directory: existing, strategy: "git" },
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
)
expect(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
expect((yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
yield* Effect.promise(() => $`git worktree remove --force ${target}`.cwd(input.root.path).quiet())
yield* Effect.promise(() => $`git worktree remove --force ${unchanged}`.cwd(input.root.path).quiet())
+24 -9
View File
@@ -41,6 +41,7 @@ import { DialogSessionRename } from "./dialog-session-rename"
import { Keymap } from "../context/keymap"
import { registerOpencodeSpinner } from "./register-spinner"
import { SPINNER_FRAMES } from "./spinner-frames"
import "./title-shimmer"
registerOpencodeSpinner()
@@ -96,6 +97,7 @@ export const EMPTY_SESSION_TAB_STATUS: SessionTabsStatus = {
promptPulse: 0,
attention: false,
busy: false,
renaming: false,
}
export type SessionTabsController = Pick<ContextController, "tabs" | "current" | "select" | "close" | "move"> & {
newTab?: () => boolean
@@ -648,7 +650,7 @@ function VerticalSessionTabs(props: {
const restingTitleWidth = () => Math.max(1, width() - numberWidth() - 2)
const hoveredTitleWidth = () => Math.max(1, restingTitleWidth() - 1)
const titleWidth = () => (hovered() === tab.sessionID ? hoveredTitleWidth() : restingTitleWidth())
const title = () => tab.title ?? "Untitled session"
const title = () => (props.controller ? undefined : session()?.title) ?? tab.title ?? "Untitled session"
const scrolling = () => marquee.active() === tab.sessionID
const visibleTitleParts = createMemo(() =>
scrolling()
@@ -907,14 +909,21 @@ function VerticalSessionTabs(props: {
unreadMarker={props.unreadMarker}
attributes={selected() ? TextAttributes.BOLD : undefined}
/>
<text
<title_shimmer
width={titleWidth()}
height={1}
fg={foreground()}
rename={{ pending: status().renaming, title: title() }}
enabled={animations()}
backdrop={pulseBackground()}
wrapMode="none"
selectable={false}
attributes={
(selected() ? TextAttributes.BOLD : 0) |
(tabs.isPreview?.(tab.sessionID) ? TextAttributes.ITALIC : 0) || undefined
(status().renaming && !animations()
? TextAttributes.DIM
: selected()
? TextAttributes.BOLD
: 0) | (tabs.isPreview?.(tab.sessionID) ? TextAttributes.ITALIC : 0) || undefined
}
>
<Show
@@ -927,7 +936,7 @@ function VerticalSessionTabs(props: {
)}
</Index>
</Show>
</text>
</title_shimmer>
<text
position="absolute"
right={1}
@@ -1075,6 +1084,7 @@ function HorizontalSessionTabs(props: {
numbers: boolean
}) {
const tabs = props.controller ?? useSessionTabs()
const data = props.controller ? undefined : useData()
const dimensions = useTerminalDimensions()
const theme = useTheme()
const config = useConfig().data
@@ -1364,7 +1374,7 @@ function HorizontalSessionTabs(props: {
const glowColor = createMemo(() => tint(background(), feedbackColor() ?? unreadColor(), glowLevel()))
const glows = () =>
Boolean(status().attention || (!selected() && !status().busy && status().unread !== undefined))
const title = () => tab.title ?? "Untitled session"
const title = () => data?.session.get(tab.sessionID)?.title ?? tab.title ?? "Untitled session"
const tabNumber = createMemo(() => items().findIndex((item) => item.sessionID === tab.sessionID) + 1)
const numberWidth = () => Math.max(2, String(items().length).length)
// Hovering reveals the close mark, so the title's right bound shifts left of it.
@@ -1492,13 +1502,18 @@ function HorizontalSessionTabs(props: {
unreadMarker={props.unreadMarker}
attributes={bold()}
/>
<text
<title_shimmer
width={availableTitleWidth()}
height={1}
fg={foreground()}
rename={{ pending: status().renaming, title: title() }}
enabled={animations()}
backdrop={background()}
wrapMode="none"
selectable={false}
attributes={
(bold() ?? 0) | (tabs.isPreview?.(tab.sessionID) ? TextAttributes.ITALIC : 0) || undefined
(status().renaming && !animations() ? TextAttributes.DIM : (bold() ?? 0)) |
(tabs.isPreview?.(tab.sessionID) ? TextAttributes.ITALIC : 0) || undefined
}
>
<Show when={scrolling() || glows() || titleFades()} fallback={visibleTitle()}>
@@ -1508,7 +1523,7 @@ function HorizontalSessionTabs(props: {
)}
</Index>
</Show>
</text>
</title_shimmer>
<text
position="absolute"
right={1}
+3 -3
View File
@@ -30,7 +30,7 @@ type TabPulseOptions = RenderableOptions<TabPulseRenderable> & {
}
const clamp = (value: number) => Math.max(0, Math.min(1, value))
const smootherstep = (value: number) => value * value * value * (value * (value * 6 - 15) + 10)
export const smootherstep = (value: number) => value * value * value * (value * (value * 6 - 15) + 10)
const RUN_DURATION = 2_800
const RUN_ATTACK = 450
const RUN_HEAD = 4
@@ -53,11 +53,11 @@ const GLOW_RELEASE_PEAK = 1.25
const GLOW_TAIL = 12
const GLOW_OPACITY = 0.16
const DEFAULT_FOREGROUND = RGBA.defaultForeground()
const intensityAt = (index: number, front: number, head: number, tail: number) => {
export const intensityAt = (index: number, front: number, head: number, tail: number) => {
const distance = front - index
return distance < 0 ? smootherstep(clamp(1 + distance / head)) : smootherstep(clamp(1 - distance / tail))
}
const coast = (value: number) => {
export const coast = (value: number) => {
const ramp = 0.2
if (value < ramp) return (value * value) / (2 * ramp * (1 - ramp))
if (value > 1 - ramp) return 1 - ((1 - value) * (1 - value)) / (2 * ramp * (1 - ramp))
@@ -0,0 +1,269 @@
import {
BoxRenderable,
OptimizedBuffer,
RGBA,
TargetChannel,
TextRenderable,
type RenderContext,
type TextOptions,
} from "@opentui/core"
import { extend } from "@opentui/solid"
import { coast, intensityAt, smootherstep } from "./tab-pulse"
type TitleShimmerOptions = TextOptions & {
rename?: { title: string; pending: boolean }
enabled?: boolean
backdrop?: RGBA
}
const SHIMMER_DURATION = 1200
const SHIMMER_FADE = 240
const ARRIVAL_DURATION = 450
const WIPE_FEATHER = 4
const TRANSPARENT = RGBA.fromValues(0, 0, 0, 0)
// Native text draws wide glyphs as a head followed by flagged continuation cells.
const CONTINUATION = 0xc0000000 | 0
export class TitleShimmerRenderable extends TextRenderable {
private _rename: TitleShimmerOptions["rename"]
private _enabled: boolean
private _backdrop: RGBA
private pendingTitle: string | undefined
private elapsed = 0
private blend = 0
private fresh = true
private arrival: number | undefined
private scratch: OptimizedBuffer | undefined
private previous: OptimizedBuffer | undefined
private mask = new Float32Array(0)
private matrix = new Float32Array(16)
constructor(ctx: RenderContext, options: TitleShimmerOptions) {
super(ctx, options)
this._rename = options.rename
this.pendingTitle = options.rename?.title
this._enabled = options.enabled ?? true
this._backdrop = options.backdrop ?? RGBA.defaultBackground()
this.matrix[15] = 1
this.updateBackdrop()
this.live = this.animating
}
private get animating() {
return this._enabled && (this.shimmering || this.arrival !== undefined || this.blend > 0)
}
private get shimmering() {
return this._rename?.pending && this._rename.title === this.pendingTitle
}
set rename(value: TitleShimmerOptions["rename"]) {
if (value?.title === this._rename?.title && value?.pending === this._rename?.pending) return
if (value?.pending && !this._rename?.pending) {
if (this.pendingTitle !== value.title) this.blend = 0
this.pendingTitle = value.title
if (this.blend === 0) this.elapsed = 0
this.arrival = undefined
this.previous?.destroy()
this.previous = undefined
}
// Only an automatic rename replaces the last painted title with a wipe.
if (value?.title !== this._rename?.title) {
this.arrival = value && this._rename?.pending && this._enabled && this.previous ? 0 : undefined
if (this.arrival === undefined) this.blend = 0
}
this._rename = value
this.changed()
}
set enabled(value: boolean) {
if (value === this._enabled) return
this._enabled = value
if (!value) {
this.arrival = undefined
this.blend = 0
}
this.changed()
}
set backdrop(value: RGBA) {
if (value.equals(this._backdrop)) return
this._backdrop = value
this.updateBackdrop()
this.requestRender()
}
private updateBackdrop() {
this.matrix[3] = this._backdrop.r
this.matrix[7] = this._backdrop.g
this.matrix[11] = this._backdrop.b
}
private changed() {
if (!this.live && this.animating) this.fresh = true
this.live = this.animating
if (!this.animating) {
this.previous?.destroy()
this.previous = undefined
}
this.requestRender()
}
override render(buffer: OptimizedBuffer, deltaTime: number) {
if (!this.visible || this.isDestroyed || !Number.isFinite(this.width) || this.width <= 0 || this.height <= 0) return
if (!this.animating) return super.render(buffer, deltaTime)
// A newly live title must not inherit time spent idle before its fade started.
const delta = this.fresh ? 0 : deltaTime
this.fresh = false
this.elapsed = (this.elapsed + delta) % SHIMMER_DURATION
if (this.arrival !== undefined) {
this.arrival += delta
if (this.arrival >= ARRIVAL_DURATION) {
this.arrival = undefined
this.blend = 0
}
}
this.blend = Math.max(
0,
Math.min(1, this.blend + (this.shimmering || this.arrival !== undefined ? delta : -delta) / SHIMMER_FADE),
)
this.live = this.animating
if (!this.animating) {
this.previous?.destroy()
this.previous = undefined
return super.render(buffer, deltaTime)
}
if (!this.scratch)
this.scratch = OptimizedBuffer.create(this.width, this.height, this._ctx.widthMethod, { respectAlpha: true })
if (this.scratch.width !== this.width || this.scratch.height !== this.height)
this.scratch.resize(this.width, this.height)
// Shade locally, then composite: colorMatrix itself does not respect ancestor scissors.
this.scratch.clear(TRANSPARENT)
// OpenTUI's framebuffer compositor can paint a cut wide glyph. Clip the native text draw first.
const clip = {
left: Math.max(0, -this.screenX),
top: Math.max(0, -this.screenY),
right: Math.min(this.width, buffer.width - this.screenX),
bottom: Math.min(this.height, buffer.height - this.screenY),
}
for (let parent = this.parent; parent; parent = parent.parent) {
if (parent.overflow === "visible" || parent.width <= 0 || parent.height <= 0) continue
const border = parent instanceof BoxRenderable ? parent.border : false
const left = Number(border === true || (Array.isArray(border) && border.includes("left")))
const top = Number(border === true || (Array.isArray(border) && border.includes("top")))
clip.left = Math.max(clip.left, parent.screenX - this.screenX + left)
clip.top = Math.max(clip.top, parent.screenY - this.screenY + top)
clip.right = Math.min(
clip.right,
parent.screenX -
this.screenX +
parent.width -
Number(border === true || (Array.isArray(border) && border.includes("right"))),
)
clip.bottom = Math.min(
clip.bottom,
parent.screenY -
this.screenY +
parent.height -
Number(border === true || (Array.isArray(border) && border.includes("bottom"))),
)
}
this.scratch.pushScissorRect(
clip.left,
clip.top,
Math.max(0, clip.right - clip.left),
Math.max(0, clip.bottom - clip.top),
)
this.scratch.drawTextBuffer(this.textBufferView, 0, 0)
const characters = this.scratch.buffers.char
let end = 0
for (let row = 0; row < this.height; row++) {
let column = this.width
while (
column > 0 &&
(characters[row * this.width + column - 1] === 32 || characters[row * this.width + column - 1] === 0)
)
column--
end = Math.max(end, column)
}
const wipeFront =
this.arrival !== undefined && this.previous
? -WIPE_FEATHER +
coast(this.arrival / ARRIVAL_DURATION) * (Math.max(end, this.previous.width) + WIPE_FEATHER * 2)
: undefined
const cut = Math.max(0, Math.min(this.width, Math.round(wipeFront ?? 0)))
if (wipeFront !== undefined && this.previous) {
this.scratch.clear(TRANSPARENT)
this.scratch.pushScissorRect(0, 0, cut, this.height)
this.scratch.drawTextBuffer(this.textBufferView, 0, 0)
this.scratch.popScissorRect()
// Snapshot slices must also end on whole glyphs; framebuffer clipping alone can split them.
for (let row = 0; row < Math.min(this.height, this.previous.height); row++) {
let left = Math.max(cut, clip.left)
let right = Math.min(this.previous.width, clip.right)
const offset = row * this.previous.width
while (left < right && (this.previous.buffers.char[offset + left] & CONTINUATION) === CONTINUATION) left++
while (
right > left &&
right < this.previous.width &&
(this.previous.buffers.char[offset + right] & CONTINUATION) === CONTINUATION
)
right--
if (right > left) this.scratch.drawFrameBuffer(left, row, this.previous, left, row, right - left, 1)
}
}
this.scratch.clearScissorRects()
if (this.mask.length !== this.width * this.height * 3) this.mask = new Float32Array(this.width * this.height * 3)
if (wipeFront === undefined) {
if (!this.previous)
this.previous = OptimizedBuffer.create(Math.max(1, end), this.height, this._ctx.widthMethod, {
respectAlpha: true,
})
if (this.previous.width !== Math.max(1, end) || this.previous.height !== this.height)
this.previous.resize(Math.max(1, end), this.height)
this.previous.clear(TRANSPARENT)
this.previous.drawFrameBuffer(0, 0, this.scratch)
}
const front = -4 + coast(this.elapsed / SHIMMER_DURATION) * ((this.previous?.width ?? end) + 4 + 18)
const level = smootherstep(this.blend)
let strength = 0
for (let cell = 0; cell < characters.length; cell++) {
const column = cell % this.width
if ((characters[cell] & CONTINUATION) !== CONTINUATION) {
const old = wipeFront === undefined || column >= cut
let visibility = old ? 1 - 0.6 * level * (1 - intensityAt(column, front, 4, 18)) : 1
if (wipeFront !== undefined) {
let width = 1
while (column + width < this.width && (characters[cell + width] & CONTINUATION) === CONTINUATION) width++
const distance = old ? column - wipeFront : wipeFront - (column + width)
visibility *= smootherstep(Math.max(0, Math.min(1, distance / WIPE_FEATHER)))
}
strength = 1 - visibility
}
this.mask[cell * 3] = column
this.mask[cell * 3 + 1] = Math.floor(cell / this.width)
this.mask[cell * 3 + 2] = strength
}
this.scratch.colorMatrix(this.matrix, this.mask, 1, TargetChannel.FG)
buffer.drawFrameBuffer(this.screenX, this.screenY, this.scratch)
this.markClean()
this._ctx.addToHitGrid(this.screenX, this.screenY, this.width, this.height, this.num)
}
override destroy() {
this.previous?.destroy()
this.previous = undefined
this.scratch?.destroy()
this.scratch = undefined
super.destroy()
}
}
extend({ title_shimmer: TitleShimmerRenderable })
declare module "@opentui/solid" {
interface OpenTUIComponents {
title_shimmer: typeof TitleShimmerRenderable
}
}
+23 -1
View File
@@ -1,5 +1,6 @@
import { createData } from "@opencode-ai/client/solid"
import type { Plugin } from "@opencode-ai/plugin/tui"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "./helper"
import { useClient } from "./client"
@@ -17,6 +18,27 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
directory: props.directory,
})
data satisfies Plugin.Context["data"]
return data
const [generatingTitles, setGeneratingTitles] = createStore<Record<string, boolean | undefined>>({})
return {
...data,
session: {
...data.session,
title: {
pending: (sessionID: string) => generatingTitles[sessionID] === true,
async generate(sessionID: string) {
if (generatingTitles[sessionID]) return
setGeneratingTitles(sessionID, true)
await client.api.session
.rename({ sessionID, title: "" })
.then(() => {
// The HTTP response can beat the renamed event. Keep pending until the new title is projected locally.
data.session.invalidate(sessionID)
return data.session.sync(sessionID)
})
.finally(() => setGeneratingTitles(sessionID, undefined))
},
},
},
}
},
})
@@ -173,6 +173,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
? ("question" as const)
: (false as const),
busy: members.some((id) => data.session.status(id) === "running" || data.session.pending.list(id).length > 0),
renaming: data.session.title.pending(session),
}
}
+49 -19
View File
@@ -7,25 +7,55 @@ export function createHistoryPrepend(input: {
active: (sessionID: string) => boolean
scrollBy: (amount: number) => void
}) {
let loading = false
let pending: { scrollBy: number; continuation?: () => void; after?: () => void } | undefined
return (scrollBy = 0, continuation?: () => void) => {
const sessionID = input.sessionID()
if (loading || !input.more(sessionID)) return false
loading = true
const before = input.height()
void input.loadMore(sessionID).then(
() =>
input.afterLayout(() => {
loading = false
if (!input.active(sessionID)) return
input.scrollBy(input.height() - before + scrollBy)
continuation?.()
}),
() => {
loading = false
return Object.assign(
(scrollBy = 0, continuation?: () => void) => {
const sessionID = input.sessionID()
if (pending) {
if (continuation || (!pending.scrollBy && scrollBy)) {
pending.scrollBy = scrollBy
pending.continuation = continuation
return true
}
return false
}
if (!input.more(sessionID)) return false
const current = { scrollBy, continuation }
pending = current
const before = input.height()
void input.loadMore(sessionID).then(
() =>
input.afterLayout(() => {
if (pending !== current) return
const after = pending.after
pending = undefined
if (!input.active(sessionID)) return
input.scrollBy(input.height() - before + current.scrollBy)
current.continuation?.()
after?.()
}),
() => {
if (pending !== current) return
const after = pending.after
pending = undefined
if (input.active(sessionID)) after?.()
},
)
return true
},
{
cancel() {
if (!pending) return
pending.continuation = undefined
pending.after = undefined
},
)
return true
}
after(continuation: () => void) {
if (!pending) return continuation()
// A jump supersedes deferred scrolling, but must wait for anchor compensation.
pending.scrollBy = 0
pending.after = continuation
},
},
)
}
+78 -13
View File
@@ -1,4 +1,5 @@
import {
batch,
createContext,
createEffect,
createMemo,
@@ -288,6 +289,7 @@ export function Session(props: {
const boundaryIDs = createMemo(() => new Set(boundaries().filter((id) => id !== undefined)))
const [navigationMessage, setNavigationMessage] = createSignal<string>()
const [navigationSlack, setNavigationSlack] = createSignal(0)
const [firstJump, setFirstJump] = createSignal<() => void>()
const [synced, setSynced] = createSignal(false)
const sessionTabs = useSessionTabs()
const terminals = useSessionTerminals()
@@ -300,6 +302,9 @@ export function Session(props: {
const clearMessageNavigation = () => {
ensureAllRowsPending?.splice(0)
prependHistory.cancel()
firstJump()?.()
setFirstJump(undefined)
setNavigationSlack(0)
setNavigationMessage(undefined)
}
@@ -370,6 +375,8 @@ export function Session(props: {
let awayTimer: ReturnType<typeof setTimeout> | undefined
onCleanup(() => {
if (awayTimer) clearTimeout(awayTimer)
prependHistory.cancel()
firstJump()?.()
if (!scroll || scroll.isDestroyed) return
scroll.verticalScrollBar.off("change", updateAwayFromBottom)
saveScrollAnchor()
@@ -452,6 +459,7 @@ export function Session(props: {
}
/** Message navigation needs the full transcript mounted before walking or jumping. */
const ensureAllRows = (continuation: () => void) => {
if (firstJump()) clearMessageNavigation()
if (!ensureAllRowsPending && hidden() === 0 && visibleEnd() === rows.length) return continuation()
if (ensureAllRowsPending) {
ensureAllRowsPending.push(continuation)
@@ -469,12 +477,13 @@ export function Session(props: {
}
function isAwayFromBottom() {
if (revealingOlderRows || revealingNewerRows || ensureAllRowsPending || navigationMessage()) return true
if (revealingOlderRows || revealingNewerRows || ensureAllRowsPending || navigationMessage() || firstJump())
return true
if (visibleEnd() < rows.length) return true
return scroll.scrollTop < Math.max(0, scroll.scrollHeight - scroll.viewport.height)
}
function updateAwayFromBottom() {
const preserveWindow = revealingOlderRows || revealingNewerRows || !!ensureAllRowsPending
const preserveWindow = revealingOlderRows || revealingNewerRows || !!ensureAllRowsPending || !!firstJump()
if (isAwayFromBottom()) setHiddenRows((current) => current ?? hidden())
if (awayTimer) clearTimeout(awayTimer)
awayTimer = setTimeout(() => {
@@ -659,6 +668,7 @@ export function Session(props: {
})
const jumpToBackgroundTool = (target: BackgroundToolTarget, beforeMessageID: string) => {
if (firstJump()) clearMessageNavigation()
const jump = () => {
const index = backgroundToolRowIndex(rows, messages(), target, beforeMessageID)
if (index === -1) {
@@ -759,17 +769,65 @@ export function Session(props: {
group: "Session",
palette: undefined,
run: () => {
if (firstJump()) return
clearMessageNavigation()
const first = () => {
if (data.session.message.more(route.sessionID)) {
prependHistory(0, first)
return
const request = new AbortController()
const cancel = () => request.abort()
setFirstJump(() => cancel)
const start = () => {
if (firstJump() !== cancel || scroll.isDestroyed) return
if (revealingOlderRows || revealingNewerRows || ensureAllRowsPending) return afterLayout(start)
const previous = { start: hiddenRows(), end: visibleRowsEnd() }
const restore = () => {
cancel()
batch(() => {
setHiddenRows(previous.start)
setVisibleRowsEnd(previous.end)
})
}
ensureAllRows(() => {
scroll.scrollTo(0)
const commit = () => {
if (firstJump() !== restore || scroll.isDestroyed) return
scroll.stickyScroll = false
batch(() => {
setHiddenRows(0)
setVisibleRowsEnd(TRANSCRIPT_BACKFILL_CHUNK)
setFirstJump(() => cancel)
})
}
// Pin both ends until the head budget commits in the same batch as history.
batch(() => {
setFirstJump(() => restore)
setHiddenRows(hidden())
setVisibleRowsEnd(visibleEnd())
})
void data.session.message
.loadMore(route.sessionID, {
all: true,
signal: request.signal,
beforePublish: commit,
})
.then(
() => {
commit()
if (firstJump() !== cancel || scroll.isDestroyed) return
if (rows.length <= TRANSCRIPT_BACKFILL_CHUNK) setVisibleRowsEnd(undefined)
scroll.scrollTo(0)
afterLayout(() => {
if (firstJump() !== cancel) return
scroll.scrollTo(0)
setFirstJump(undefined)
updateAwayFromBottom()
})
},
(error) => {
if (firstJump() !== restore || scroll.isDestroyed) return
clearMessageNavigation()
toast.error(error)
updateAwayFromBottom()
},
)
}
first()
prependHistory.after(start)
dialog.clear()
},
},
@@ -801,9 +859,12 @@ export function Session(props: {
slash: { name: "rename", arguments: true as const },
run: (input?: string) => {
if (input === undefined) return DialogSessionRename.show(dialog, route.sessionID, session()?.title)
void client.api.session
.rename({ sessionID: route.sessionID, title: input.trim() })
.catch((error) => toast.error(error))
const title = input.trim()
void (
title
? client.api.session.rename({ sessionID: route.sessionID, title })
: data.session.title.generate(route.sessionID)
).catch((error) => toast.error(error))
},
},
{
@@ -1325,6 +1386,7 @@ export function Session(props: {
flexGrow={1}
scrollAcceleration={scrollAcceleration()}
onMouseScroll={(event) => {
if (firstJump()) clearMessageNavigation()
if (event.scroll?.direction === "up" && revealOlderRows()) return
if (event.scroll?.direction === "down" && revealNewerRows()) return
updateAwayFromBottom()
@@ -1352,7 +1414,10 @@ export function Session(props: {
</scrollbox>
</box>
<box height={1} flexShrink={0} flexDirection="row" justifyContent="flex-end">
<Show when={awayFromBottom()}>
<Show when={firstJump()}>
<text fg={theme.text.feedback.info.default}>Loading session history...</text>
</Show>
<Show when={!firstJump() && awayFromBottom()}>
<box
id="session-jump-to-latest"
paddingLeft={1}
+20 -5
View File
@@ -4,6 +4,8 @@ import { useTheme } from "../../context/theme"
import { useConfig } from "../../config"
import { Slot } from "../../plugin/render"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import { TextAttributes } from "@opentui/core"
import "../../component/title-shimmer"
import { getScrollAcceleration } from "../../util/scroll"
import { SESSION_SIDEBAR_WIDTH } from "../../ui/layout"
@@ -45,11 +47,24 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
>
<box flexShrink={0} gap={1} paddingRight={1}>
<box paddingRight={1}>
<text fg={theme.text.default}>
<b>{withTimestampedFallback(session()!)}</b>
</text>
<Show when={session()!.location.workspaceID}>
<text fg={theme.text.subdued}>{session()!.location.workspaceID}</text>
<title_shimmer
fg={theme.text.default}
rename={{
pending: data.session.title.pending(props.sessionID),
title: withTimestampedFallback(session()),
}}
enabled={config.animations ?? true}
backdrop={theme.background.default}
attributes={
data.session.title.pending(props.sessionID) && config.animations === false
? TextAttributes.DIM
: TextAttributes.BOLD
}
>
{withTimestampedFallback(session())}
</title_shimmer>
<Show when={session().location.workspaceID}>
<text fg={theme.text.subdued}>{session().location.workspaceID}</text>
</Show>
</box>
<Slot path="sidebar.content" input={{ sessionID: props.sessionID }} />
+81
View File
@@ -223,6 +223,87 @@ test("session title generated while an untitled session is loading remains visib
}
})
test("automatic rename refreshes the displayed title before settling, even without a renamed event", async () => {
await using state = await tmpdir()
const setup = await createTestRenderer({ width: 90, height: 20, useThread: false, kittyKeyboard: true })
setup.renderer.start()
const events = createEventStream()
const response = Promise.withResolvers<Response>()
const bodies: unknown[] = []
const location = { directory, project: { id: "project", directory, canonical: directory } }
const session = {
id: "ses_rename",
title: "Compiler cleanup",
projectID: "project",
location: { directory },
agent: "build",
model: { providerID: "provider", id: "model" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
}
const calls = createFetch(async (url, request) => {
if (url.pathname === "/api/location") return json(location)
if (url.pathname === "/api/agent")
return json({ location, data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }] })
if (url.pathname === "/api/model")
return json({ location, data: [{ id: "model", providerID: "provider", name: "Model", variants: [] }] })
if (url.pathname === "/api/provider") return json({ location, data: [{ id: "provider", name: "Provider" }] })
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
if (url.pathname === "/api/session/ses_rename") return json({ data: session })
if (/^\/api\/session\/ses_rename\/(message|inbox|permission)$/.test(url.pathname))
return json({ data: [], cursor: {} })
if (url.pathname === "/api/session/ses_rename/rename") {
bodies.push(await request.json())
return response.promise
}
return undefined
}, events)
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
try {
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: {
get: async () => ({
tabs: { enabled: true, layout: "vertical" },
session: { sidebar: "hide" },
}),
update: async () => ({}),
},
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: session.id },
log: () => {},
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
)
await setup.waitForFrame((frame) => frame.includes(session.title) && frame.includes("Build · Model Provider"))
await setup.mockInput.typeText("/rename")
setup.mockInput.pressEscape()
setup.mockInput.pressEnter()
await setup.waitFor(() => bodies.length === 1)
await setup.renderOnce()
expect(bodies[0]).toEqual({ title: "" })
expect(setup.captureCharFrame()).toContain("Compiler cleanup")
session.title = "Simplify compiler parsing"
response.resolve(new Response(null, { status: 204 }))
await setup.waitForFrame((frame) => frame.includes(session.title), { maxPasses: 60 })
expect(setup.captureCharFrame()).not.toContain("Compiler cleanup")
setup.renderer.destroy()
await task
} finally {
response.resolve(new Response(null, { status: 204 }))
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await server.stop()
}
})
test("session startup prompt is submitted exactly once", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
const events = createEventStream()
@@ -101,3 +101,37 @@ test("continues navigation after the prepended page is laid out", async () => {
expect(events).toEqual(["loaded", "layout", "anchored", "continued"])
})
test.each(["success", "failure", "cancel", "takeover"])("settles superseded prepend navigation (%s)", async (mode) => {
const load = Promise.withResolvers<void>()
const layout: (() => void)[] = []
const events: (number | string)[] = []
let height = 100
const prepend = createHistoryPrepend({
sessionID: () => "session-1",
more: () => true,
loadMore: () => load.promise,
height: () => height,
afterLayout: (continuation) => layout.push(continuation),
active: () => true,
scrollBy: (amount) => events.push(amount),
})
prepend(-4, () => events.push("obsolete"))
prepend.cancel()
prepend.after(() => events.push("jump"))
if (mode === "cancel" || mode === "takeover") prepend.cancel()
if (mode === "takeover") expect(prepend(-8)).toBe(true)
if (mode === "failure") load.reject(new Error("offline"))
if (mode !== "failure") {
height = 160
load.resolve()
}
await Promise.resolve()
expect(events).toEqual(mode === "failure" ? ["jump"] : [])
layout.shift()?.()
expect(events).toEqual(
mode === "failure" ? ["jump"] : mode === "success" ? [60, "jump"] : mode === "takeover" ? [52] : [60],
)
prepend.after(() => events.push("idle"))
expect(events.at(-1)).toBe("idle")
})
@@ -0,0 +1,145 @@
import { expect, test } from "bun:test"
import { BoxRenderable, RGBA, TextAttributes, TextRenderable } from "@opentui/core"
import { createTestRenderer, ManualClock } from "@opentui/core/testing"
import { TitleShimmerRenderable } from "../../src/component/title-shimmer"
test("shimmer fades in from idle and fades out on unchanged completion", async () => {
const clock = new ManualClock()
const app = await createTestRenderer({ width: 24, height: 1, useThread: false, clock })
const title = new TitleShimmerRenderable(app.renderer, {
width: 24,
height: 1,
content: "Compiler cleanup",
fg: "#eeeeee",
backdrop: RGBA.fromHex("#111111"),
rename: { title: "Compiler cleanup", pending: false },
})
app.renderer.root.add(title)
try {
await app.renderOnce()
const frame = app.captureCharFrame()
const colors = app.captureSpans()
clock.advance(2000)
title.rename = { title: "Compiler cleanup", pending: true }
await app.renderOnce()
expect(app.captureSpans()).toEqual(colors)
clock.advance(120)
await app.renderOnce()
const middle = app.captureSpans().lines[0].spans.findLast((span) => span.text.trim())?.fg.r ?? 0
expect(middle).toBeLessThan(colors.lines[0].spans[0].fg.r)
clock.advance(120)
await app.renderOnce()
expect(app.captureSpans().lines[0].spans.findLast((span) => span.text.trim())?.fg.r ?? 0).toBeLessThan(middle)
expect(app.captureSpans()).not.toEqual(colors)
expect(app.captureCharFrame()).toBe(frame)
title.rename = { title: "Compiler cleanup", pending: false }
await app.renderOnce()
expect(app.renderer.root.liveCount).toBe(1)
clock.advance(240)
await app.renderOnce()
expect(app.renderer.root.liveCount).toBe(0)
expect(app.captureSpans()).toEqual(colors)
title.enabled = false
title.rename = { title: "Compiler cleanup", pending: true }
await app.renderOnce()
expect(app.renderer.root.liveCount).toBe(0)
} finally {
app.renderer.destroy()
}
})
test("a feathered wipe keeps the old shimmer moving without dimming the revealed new title", async () => {
const clock = new ManualClock()
const app = await createTestRenderer({ width: 16, height: 1, useThread: false, clock })
const title = new TitleShimmerRenderable(app.renderer, {
width: 16,
height: 1,
content: "ABCDEFGHIJKLMNOP",
fg: "#eeeeee",
attributes: TextAttributes.ITALIC,
backdrop: RGBA.fromHex("#111111"),
rename: { title: "ABCDEFGHIJKLMNOP", pending: true },
})
app.renderer.root.add(title)
try {
await app.renderOnce()
clock.advance(600)
await app.renderOnce()
const colors = app.captureSpans()
title.content = "abcdefghijklmnop"
title.rename = { title: "abcdefghijklmnop", pending: true }
await app.renderOnce()
expect(app.captureCharFrame().trim()).toBe("ABCDEFGHIJKLMNOP")
expect(app.captureSpans()).toEqual(colors)
clock.advance(225)
await app.renderOnce()
expect(app.captureCharFrame().trim()).toBe("abcdefghIJKLMNOP")
const spans = app.captureSpans().lines[0].spans
expect(spans[0].fg.equals(RGBA.fromHex("#eeeeee"))).toBe(true)
expect(spans.some((span) => span.fg.equals(RGBA.fromHex("#111111")))).toBe(true)
expect(spans.at(-1)?.fg.toInts()).not.toEqual(colors.lines[0].spans.at(-1)?.fg.toInts())
expect(spans.every((span) => Boolean(span.attributes & TextAttributes.ITALIC))).toBe(true)
clock.advance(225)
await app.renderOnce()
expect(app.captureCharFrame().trim()).toBe("abcdefghijklmnop")
expect(app.renderer.root.liveCount).toBe(0)
title.rename = { title: "abcdefghijklmnop", pending: false }
title.content = "Manual"
title.rename = { title: "Manual", pending: false }
await app.renderOnce()
expect(app.captureCharFrame().trim()).toBe("Manual")
expect(app.renderer.root.liveCount).toBe(0)
title.rename = { title: "Manual", pending: true }
await app.renderOnce()
title.content = "Next"
title.rename = { title: "Next", pending: false }
title.enabled = false
await app.renderOnce()
expect(app.captureCharFrame().trim()).toBe("Next")
title.enabled = true
expect(app.renderer.root.liveCount).toBe(0)
} finally {
app.renderer.destroy()
}
})
test("native Unicode clipping and shorter replacement leave no split glyphs or old tail", async () => {
const clock = new ManualClock()
const app = await createTestRenderer({ width: 24, height: 3, useThread: false, clock })
const content = "A\u65e5B \u{1f680} cafe\u0301"
const title = new TitleShimmerRenderable(app.renderer, {
width: 20,
height: 1,
content,
fg: "#eeeeee",
wrapMode: "none",
backdrop: RGBA.fromHex("#111111"),
rename: { title: content, pending: true },
})
const plain = new TextRenderable(app.renderer, { width: 20, height: 1, content, wrapMode: "none" })
const shadedBox = new BoxRenderable(app.renderer, { width: 6, height: 1, marginLeft: 2, overflow: "hidden" })
const plainBox = new BoxRenderable(app.renderer, { width: 6, height: 1, marginLeft: 2, overflow: "hidden" })
shadedBox.add(title)
plainBox.add(plain)
app.renderer.root.add(shadedBox)
app.renderer.root.add(plainBox)
app.renderer.root.add(new TextRenderable(app.renderer, { content: "untouched" }))
try {
await app.renderOnce()
expect(app.captureCharFrame().split("\n")[0]).toBe(app.captureCharFrame().split("\n")[1])
title.content = "Short"
title.rename = { title: "Short", pending: false }
clock.advance(200)
await app.renderOnce()
expect(app.captureCharFrame().split("\n")[0].trim()).toBe("Sh B")
expect(app.captureCharFrame().split("\n")[2]).toContain("untouched")
clock.advance(250)
await app.renderOnce()
expect(app.captureCharFrame().split("\n")[0].trim()).toBe("Short")
expect(app.renderer.root.liveCount).toBe(0)
} finally {
app.renderer.destroy()
}
})
+389
View File
@@ -0,0 +1,389 @@
import { expect, test } from "bun:test"
import { type Renderable, ScrollBoxRenderable } from "@opentui/core"
import { createTestRenderer } from "@opentui/core/testing"
import { Effect, FileSystem } from "effect"
import { Global } from "@opencode-ai/util/global"
import { createEventStream, createFetch, directory, json } from "./fixture/tui-client"
import { tmpdir } from "./fixture/fixture"
test.each([
"bottom",
"scrolled",
"cancel",
"scroll-cancel",
"up-cancel",
"mouse-cancel",
"page-cancel",
"failure",
"mixed",
"close",
"resize-cancel",
"settling",
"settling-scrolled",
"settling-reveal",
"prepend",
"prepend-navigation",
"prepend-failure",
])("Home loads a stable, bounded beginning (%s)", async (mode) => {
await using state = await tmpdir()
const setup = await createTestRenderer({ width: 100, height: 30, useThread: false, kittyKeyboard: true })
setup.renderer.start()
const session = {
id: "dummy",
title: "Long history",
projectID: "project",
location: { directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
}
const messages = Array.from({ length: 400 }, (_, index) =>
mode === "mixed" && index % 2
? {
id: `message-${index}`,
type: "assistant",
agent: "build",
model: { providerID: "demo", id: "demo-model" },
content: [{ type: "text", text: `History message ${String(index).padStart(4, "0")}` }],
finish: "stop",
time: { created: index, completed: index + 1 },
}
: {
id: `message-${index}`,
type: "user",
text: `History message ${String(index).padStart(4, "0")}`,
time: { created: index },
},
)
const pages: { end: number; limit: number }[] = []
const release = Promise.withResolvers<void>()
const finish = Promise.withResolvers<void>()
const prior = Promise.withResolvers<void>()
const aborted = Promise.withResolvers<void>()
const events = createEventStream()
const calls = createFetch(async (url, request) => {
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
if (url.pathname === "/api/session/dummy") return json({ data: session })
if (url.pathname === "/api/session/dummy/message") {
const end = Number(url.searchParams.get("cursor") ?? messages.length)
const limit = Number(url.searchParams.get("limit"))
const start = Math.max(0, end - limit)
pages.push({ end, limit })
if (end < messages.length) {
request.signal.addEventListener("abort", () => aborted.resolve(), { once: true })
await (mode.startsWith("prepend") && limit === 20 ? prior.promise : release.promise)
}
if (end === 0) await finish.promise
if (mode === "failure" && end === 0 && pages.filter((page) => page.end === 0).length === 1)
return json({ message: "offline" }, { status: 503 })
if (mode === "prepend-failure" && limit === 200) return json({ message: "offline" }, { status: 503 })
return json({ data: messages.slice(start, end).toReversed(), cursor: end ? { next: String(start) } : {} })
}
if (url.pathname === "/api/session/dummy/inbox") return json({ data: [] })
if (url.pathname === "/api/session/dummy/permission") return json({ data: [] })
return undefined
}, events)
const server = Bun.serve({ port: 0, idleTimeout: 0, fetch: (request) => calls.fetch(request) })
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: {
get: async () => ({
animations: false,
tabs: { enabled: false },
keybinds: {
"session.line.up": "f6",
"session.page.down": "f7",
"session.page.up": "f8",
"session.message.previous": "f9",
},
}),
update: async () => ({}),
},
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
args: { sessionID: "dummy" },
log: () => {},
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
)
try {
await setup.waitForFrame((frame) => frame.includes("History message 0399"))
const findScrollBox = (root: Renderable): ScrollBoxRenderable | undefined =>
root instanceof ScrollBoxRenderable && root.getRenderable("message-399")
? root
: root.getChildren().map(findScrollBox).find(Boolean)
const scroll = findScrollBox(setup.renderer.root)
if (!scroll) throw new Error("session transcript scrollbox was not found")
const mounted = () => scroll.getChildren().filter((child) => child.id?.startsWith("message-"))
const maximum = () => Math.max(0, scroll.scrollHeight - scroll.viewport.height)
if (mode === "scrolled" || mode === "cancel" || mode === "settling-scrolled") {
setup.mockInput.pressKey("F6")
await setup.waitForFrame((frame) => frame.includes("Jump to latest"))
}
if (mode === "page-cancel" || mode.startsWith("prepend")) {
scroll.scrollTo(0)
await setup.waitForFrame((frame) => frame.includes("History message 0380"))
}
await setup.waitForVisualIdle()
const visible = () =>
JSON.stringify(
setup
.captureCharFrame()
.split("\n")
.flatMap((line, y) => {
const message = line.match(/History message (\d{4})/)
// Markdown fills asynchronously; stable user rows are the viewport anchors.
if (mode === "mixed" && message && messages[Number(message[1])]?.type === "assistant") return []
return message ? [{ line: message[0], x: message.index, y }] : []
}),
)
const before = visible()
const frames = [before]
const mountCounts: number[] = []
let navigated = false
const capture = () => {
if (visible() !== frames.at(-1)) frames.push(visible())
if (mode !== "settling-reveal" || scroll.getRenderable("message-0")) mountCounts.push(mounted().length)
if (
mode.startsWith("settling") &&
!navigated &&
setup.captureCharFrame().includes("History message 0000") &&
setup.captureCharFrame().includes("Loading session history")
) {
navigated = true
setup.mockInput.pressKey("F7")
}
}
setup.renderer.on("frame", capture)
if (mode.startsWith("prepend")) {
setup.mockInput.pressKey(mode === "prepend-navigation" ? "F9" : "F8")
await setup.waitFor(() => pages.length === 2)
setup.mockInput.pressKey("HOME")
await setup.waitForFrame((frame) => frame.includes("Loading session history"))
prior.resolve()
await setup.waitFor(() => pages.some((page) => page.end === 360 && page.limit === 200))
await setup.waitForVisualIdle()
expect(visible()).toBe(before)
expect(setup.captureCharFrame()).toContain("Loading session history")
expect(pages).toEqual([
{ end: 400, limit: 20 },
{ end: 380, limit: 20 },
{ end: 360, limit: 200 },
])
release.resolve()
finish.resolve()
if (mode === "prepend-failure") {
await setup.waitForFrame((frame) => !frame.includes("Loading session history"))
expect(visible()).toBe(before)
expect(mounted()).toHaveLength(40)
return
}
await setup.waitForFrame(
(frame) => frame.includes("History message 0000") && !frame.includes("Loading session history"),
)
expect(mounted()).toHaveLength(60)
expect(pages).toHaveLength(5)
return
}
setup.mockInput.pressKey("HOME")
await setup.waitForFrame((frame) => frame.includes("Loading session history..."))
setup.mockInput.pressKey("HOME")
await setup.waitForVisualIdle()
expect(pages).toEqual([
{ end: 400, limit: 20 },
{ end: 380, limit: 200 },
])
expect(frames).toEqual([before])
if (mode === "close" || mode === "resize-cancel") {
if (mode === "close") setup.renderer.destroy()
if (mode === "resize-cancel") {
setup.resize(60, 22)
await setup.waitForFrame(
(frame) => frame.includes("History message 0399") && !frame.includes("Loading session history"),
)
}
await aborted.promise
release.resolve()
finish.resolve()
if (mode === "close") await task
if (mode === "resize-cancel") await setup.waitForVisualIdle({ quietFrames: 4 })
expect(pages).toHaveLength(2)
return
}
if (mode === "mixed") {
release.resolve()
finish.resolve()
await setup.waitForFrame(
(frame) => frame.includes("History message 0000") && !frame.includes("Loading session history"),
)
await setup.waitForVisualIdle()
expect(frames).toEqual([before, visible()])
expect(setup.captureCharFrame()).toContain("History message 0001")
expect(mounted().map((child) => child.id)).toEqual(messages.slice(0, 40).map((message) => message.id))
expect(scroll.scrollTop).toBe(0)
expect(Math.max(...mountCounts)).toBeLessThanOrEqual(60)
return
}
if (mode.startsWith("settling")) {
release.resolve()
finish.resolve()
await setup.waitFor(() => navigated)
await setup.waitForVisualIdle()
expect(mounted().map((child) => child.id)).toEqual(messages.slice(0, 60).map((message) => message.id))
expect(scroll.scrollTop).toBeGreaterThan(0)
setup.mockInput.pressKey("END")
await setup.waitForFrame((frame) => frame.includes("History message 0399") && !frame.includes("Jump to latest"))
await setup.waitFor(() => scroll.scrollTop === maximum())
if (mode === "settling-reveal") {
scroll.scrollTo(0)
await setup.waitForFrame((frame) => frame.includes("History message 0360"))
setup.mockInput.pressKey("F8")
}
navigated = false
setup.mockInput.pressKey("HOME")
await setup.waitFor(() => navigated)
await setup.waitForVisualIdle()
expect(mounted().map((child) => child.id)).toEqual(messages.slice(0, 60).map((message) => message.id))
expect(scroll.scrollTop).toBeGreaterThan(0)
expect(scroll.scrollTop).toBeLessThanOrEqual(scroll.height)
expect(Math.max(...mountCounts)).toBeLessThanOrEqual(60)
expect(pages).toHaveLength(4)
return
}
if (mode === "page-cancel") {
setup.mockInput.pressKey("F8")
await setup.waitForFrame((frame) => !frame.includes("Loading session history"))
release.resolve()
finish.resolve()
await setup.waitFor(() => Boolean(scroll.getRenderable("message-360")))
await setup.waitForVisualIdle()
expect(setup.captureCharFrame()).toContain("History message 0379")
expect(mounted()).toHaveLength(40)
expect(pages).toEqual([
{ end: 400, limit: 20 },
{ end: 380, limit: 200 },
{ end: 380, limit: 20 },
])
return
}
if (mode === "failure") {
release.resolve()
finish.resolve()
await setup.waitForFrame((frame) => !frame.includes("Loading session history"))
events.emit({
id: "evt_live",
created: 400,
type: "session.inbox.enqueued",
durable: { aggregateID: "dummy", seq: 1, version: 1 },
data: {
sessionID: "dummy",
inboxID: "message-live",
item: { type: "user", payload: { text: "Live message after failure" }, delivery: "steer" },
},
})
await setup.waitForFrame((frame) => frame.includes("Live message after failure"))
expect(mounted()).toHaveLength(21)
setup.mockInput.pressKey("HOME")
await setup.waitForFrame(
(frame) => frame.includes("History message 0000") && !frame.includes("Loading session history"),
)
expect(mounted()).toHaveLength(60)
return
}
if (mode === "up-cancel" || mode === "mouse-cancel") {
if (mode === "up-cancel") setup.mockInput.pressKey("F6")
if (mode === "mouse-cancel") await setup.mockMouse.scroll(scroll.viewport.x + 2, scroll.viewport.y + 2, "up")
await setup.waitForFrame(
(frame) => frame.includes("Jump to latest") && !frame.includes("Loading session history"),
)
await setup.waitForVisualIdle()
const cancelled = visible()
release.resolve()
finish.resolve()
await setup.waitForVisualIdle({ quietFrames: 4 })
expect(visible()).toBe(cancelled)
expect(mounted()).toHaveLength(20)
expect(pages).toHaveLength(2)
return
}
if (mode.endsWith("cancel")) {
setup.mockInput.pressKey(mode === "cancel" ? "END" : "F7")
await setup.waitForFrame(
(frame) =>
frame.includes("History message 0399") &&
!frame.includes("Loading session history") &&
!frame.includes("Jump to latest"),
)
await setup.waitFor(() => scroll.scrollTop === maximum())
expect(scroll.scrollTop).toBe(maximum())
release.resolve()
finish.resolve()
await setup.waitForVisualIdle({ quietFrames: 4 })
expect(scroll.scrollTop).toBe(maximum())
expect(mounted()).toHaveLength(20)
expect(pages).toHaveLength(2)
expect(setup.captureCharFrame()).not.toContain("History message 0000")
setup.renderer.off("frame", capture)
setup.mockInput.pressKey("HOME")
}
if (!mode.endsWith("cancel")) {
release.resolve()
await setup.waitFor(() => pages.some((page) => page.end === 0))
await setup.waitForVisualIdle()
expect(frames).toEqual([before])
finish.resolve()
}
await setup.waitForFrame(
(frame) => frame.includes("History message 0000") && !frame.includes("Loading session history"),
)
await setup.waitForVisualIdle()
if (!mode.endsWith("cancel")) expect(frames).toEqual([before, visible()])
setup.renderer.off("frame", capture)
expect(Math.max(...mountCounts)).toBeLessThanOrEqual(60)
expect(mounted().map((child) => child.id)).toEqual(messages.slice(0, 60).map((message) => message.id))
expect(scroll.scrollTop).toBe(0)
expect(pages).toEqual([
{ end: 400, limit: 20 },
...(mode.endsWith("cancel") ? [{ end: 380, limit: 200 }] : []),
{ end: 380, limit: 200 },
{ end: 180, limit: 200 },
{ end: 0, limit: 200 },
])
// Forward paging must reveal the cached middle, not stop at the bounded head.
scroll.scrollTo(scroll.scrollHeight)
await setup.waitForFrame((frame) => frame.includes("History message 0059"))
setup.mockInput.pressKey("F7")
await setup.waitForFrame((frame) => frame.includes("History message 0060"))
expect(mounted().map((child) => child.id)).toEqual(messages.slice(0, 120).map((message) => message.id))
setup.mockInput.pressKey("F8")
await setup.waitForFrame(
(frame) => frame.includes("History message 0059") && !frame.includes("History message 0060"),
)
setup.mockInput.pressKey("END")
await setup.waitForFrame((frame) => frame.includes("History message 0399") && !frame.includes("Jump to latest"))
await setup.waitFor(() => scroll.scrollTop === maximum())
expect(scroll.scrollTop).toBe(maximum())
setup.mockInput.pressKey("HOME")
await setup.waitForFrame(
(frame) => frame.includes("History message 0000") && !frame.includes("Loading session history"),
)
setup.mockInput.pressKey("HOME")
await setup.waitForVisualIdle()
expect(scroll.scrollTop).toBe(0)
expect(mounted()).toHaveLength(60)
expect(pages).toHaveLength(mode.endsWith("cancel") ? 5 : 4)
} finally {
prior.resolve()
release.resolve()
finish.resolve()
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await task.finally(() => server.stop(true))
}
})