mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-28 20:46:14 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be8f5a5242 | ||
|
|
ce50f77c20 | ||
|
|
4d57b1d0c9 | ||
|
|
c036a8b1b6 |
@@ -307,7 +307,6 @@ 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())
|
||||
|
||||
@@ -1530,70 +1529,20 @@ export function createData(config: CreateDataInput) {
|
||||
loading(sessionID: string) {
|
||||
return store.session.messageLoading[sessionID] ?? false
|
||||
},
|
||||
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
|
||||
}
|
||||
async loadMore(sessionID: string) {
|
||||
const cursor = store.session.messageCursor[sessionID]
|
||||
if (!cursor || signal?.aborted) return
|
||||
if (!cursor || store.session.messageLoading[sessionID]) return
|
||||
setStore("session", "messageLoading", sessionID, true)
|
||||
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
|
||||
})
|
||||
const response = await api()
|
||||
.message.list({ sessionID, limit: messagePageLimit, cursor })
|
||||
.finally(() => setStore("session", "messageLoading", sessionID, false))
|
||||
track(messageLoads, sessionID, request)
|
||||
await request
|
||||
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)
|
||||
},
|
||||
invalidate(sessionID: string) {
|
||||
sync.invalidate(`session.message:${sessionID}`)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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"
|
||||
@@ -415,120 +414,6 @@ 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>()
|
||||
|
||||
@@ -87,7 +87,7 @@ const layer = Layer.effect(
|
||||
draft.agents.delete(id)
|
||||
},
|
||||
}),
|
||||
finalize: () => bus.publish(Agent.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
notify: () => bus.publish(Agent.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
})
|
||||
const selectable = (agent: Info | undefined) =>
|
||||
agent && agent.mode !== "subagent" && !agent.hidden ? agent : undefined
|
||||
|
||||
@@ -482,7 +482,8 @@ 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
|
||||
media.push({ type: "file", mediaType: item.mime, data: fileData(item.uri), filename: item.name })
|
||||
const data = /^data:[^;,]+(?:;[^,]*)*;base64,(.*)$/s.exec(item.uri)?.[1] ?? item.uri
|
||||
media.push({ type: "file", mediaType: item.mime, data, filename: item.name })
|
||||
return false
|
||||
})
|
||||
return toolResultPart({
|
||||
@@ -506,7 +507,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: fileData(part.data), filename: part.filename }]
|
||||
return [{ type: "file", mediaType: part.mediaType, data: part.data, filename: part.filename }]
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -515,7 +516,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: fileData(part.data), filename: part.filename }]
|
||||
return [{ type: "file", mediaType: part.mediaType, data: part.data, filename: part.filename }]
|
||||
case "reasoning":
|
||||
return [{ type: "reasoning", text: part.text, providerOptions: metadataProviderOptions(part.providerMetadata) }]
|
||||
case "tool-call":
|
||||
@@ -534,15 +535,6 @@ 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 [
|
||||
|
||||
+154
-148
@@ -294,156 +294,162 @@ export function configured(options?: Options) {
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const durable = definition.durable
|
||||
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
|
||||
.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)
|
||||
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 },
|
||||
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}`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
return committed
|
||||
}),
|
||||
)
|
||||
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)
|
||||
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
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@ const layer = Layer.effect(
|
||||
}
|
||||
return result
|
||||
},
|
||||
finalize: Effect.fn("Catalog.finalize")(function* () {
|
||||
notify: Effect.fn("Catalog.notify")(function* () {
|
||||
yield* bus.publish(Catalog.Event.Updated, {})
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -60,7 +60,7 @@ export const layer = Layer.effect(
|
||||
draft: (draft) => ({
|
||||
add: (definition) => draft.set(definition.name, definition),
|
||||
}),
|
||||
finalize: () => bus.publish(Command.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
notify: () => bus.publish(Command.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
})
|
||||
const info = (definition: Definition) =>
|
||||
Info.make({
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
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 }]
|
||||
}
|
||||
@@ -20,13 +20,14 @@ 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
|
||||
draft.add(
|
||||
entries.set(
|
||||
name,
|
||||
local(entry)
|
||||
? Reference.LocalSource.make({
|
||||
@@ -47,6 +48,7 @@ export const Plugin = define({
|
||||
)
|
||||
}
|
||||
}
|
||||
for (const [name, source] of entries) draft.add(name, source)
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
# Effect Drizzle SQLite Adapter
|
||||
|
||||
This subtree is an upstream-derived Drizzle ORM fork adapted to run SQLite query
|
||||
builders over Effect's generic `SqlClient`. It is maintained source, not
|
||||
generated output.
|
||||
|
||||
## Provenance
|
||||
|
||||
The implementation is derived from Drizzle ORM's Effect SQLite driver/session,
|
||||
SQLite Effect query builders, and shared query-builder utilities. The
|
||||
corresponding upstream source families are `drizzle-orm/src/effect-sqlite`,
|
||||
`drizzle-orm/src/sqlite-core`, and `drizzle-orm/src/utils.ts`.
|
||||
|
||||
The exact upstream revision originally copied into this repository is unknown.
|
||||
The currently pinned `drizzle-orm` version is a compatibility dependency, not
|
||||
copy provenance.
|
||||
|
||||
## Local Boundary
|
||||
|
||||
The supported local entrypoint is `@opencode-ai/core/database/drizzle`, exposed
|
||||
as the `EffectDrizzleSqlite` namespace. OpenCode's database service consumes that
|
||||
facade from `database/database.ts`.
|
||||
|
||||
Material local adaptations include:
|
||||
|
||||
- a runtime-independent driver over Effect's generic `SqlClient`
|
||||
- local cache, mapping, and runtime-inspection helpers
|
||||
- suppressed statement tracing beneath the database operation boundary
|
||||
- explicit SQLite transactions and savepoints
|
||||
- native transaction delegation for Durable Object SQLite
|
||||
- deliberate query-builder variance annotations
|
||||
|
||||
Preserve these adaptations when comparing or synchronizing upstream code.
|
||||
Focused regression coverage is in `test/database-drizzle.test.ts` and
|
||||
`test/sqlite-workerd.test.ts`.
|
||||
@@ -36,14 +36,14 @@ export const DefaultServices = Layer.merge(EffectCache.Default, EffectLogger.Def
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { SqliteClient } from "@effect/sql-sqlite-node"
|
||||
* import { EffectDrizzleSqlite } from "@opencode-ai/core/database/drizzle"
|
||||
* import { Effect } from "effect"
|
||||
* import { SqliteClient } from '@effect/sql-sqlite-node';
|
||||
* import * as SQLiteDrizzle from 'drizzle-orm/effect-sqlite';
|
||||
* import * as Effect from 'effect/Effect';
|
||||
*
|
||||
* const db = yield* EffectDrizzleSqlite.make({ relations }).pipe(
|
||||
* Effect.provide(EffectDrizzleSqlite.DefaultServices),
|
||||
* Effect.provide(SqliteClient.layer({ filename: "sqlite.db" })),
|
||||
* )
|
||||
* const db = yield* SQLiteDrizzle.make({ relations }).pipe(
|
||||
* Effect.provide(SQLiteDrizzle.DefaultServices),
|
||||
* Effect.provide(SqliteClient.layer({ filename: 'sqlite.db' })),
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export const make = Effect.fn("SQLiteDrizzle.make")(function* <TRelations extends AnyRelations = EmptyRelations>(
|
||||
|
||||
@@ -279,7 +279,7 @@ export class SQLiteEffectUpdateBase<
|
||||
: undefined
|
||||
on = on(
|
||||
new Proxy(
|
||||
getTableColumnsRuntime(this.config.table),
|
||||
this.config.table._.columns,
|
||||
new SelectionProxyHandler({ sqlAliasedBehavior: "sql", sqlBehavior: "sql" }),
|
||||
) as any,
|
||||
from &&
|
||||
|
||||
@@ -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.layerFromClient.pipe(Layer.provide(sqliteLayer({ storage })))",
|
||||
"workerd sqlite cannot open a database from a path; use Database.layerWith(sqliteLayer({ storage }))",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -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 is SessionV1.CompactionPart => part.type === "compaction")
|
||||
if (compaction) {
|
||||
const compaction = owned.find((part) => part.type === "compaction")
|
||||
if (compaction?.type === "compaction") {
|
||||
const pairedSummary = messages.find(
|
||||
(candidate): candidate is (typeof messages)[number] & { value: SessionV1.Assistant } =>
|
||||
(candidate) =>
|
||||
candidate.value.role === "assistant" &&
|
||||
candidate.value.parentID === item.row.id &&
|
||||
candidate.value.summary === true,
|
||||
candidate.value.summary,
|
||||
)
|
||||
if (!pairedSummary) return []
|
||||
if (!pairedSummary || pairedSummary.value.role !== "assistant") 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 is SessionV1.TextPart => part.type === "text" && part.text.length > 0)
|
||||
.map((part) => part.text)
|
||||
.filter((part) => part.type === "text" && part.text.length > 0)
|
||||
.map((part) => (part.type === "text" ? part.text : ""))
|
||||
.join("\n\n")
|
||||
const tailIndex = compaction.tail_start_id
|
||||
? messages.findIndex((candidate) => candidate.row.id === compaction.tail_start_id)
|
||||
@@ -313,14 +313,16 @@ export function transformSession(input: TransformInput): TransformResult {
|
||||
]
|
||||
}
|
||||
const subtasks = owned.filter((part) => part.type === "subtask")
|
||||
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")
|
||||
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")
|
||||
if (subtasks.length > 0 && visible.length === 0 && files.length === 0 && agents.length === 0) return []
|
||||
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 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 text = owned
|
||||
.flatMap((part) => {
|
||||
if (part.type === "text" && !part.ignored && !part.synthetic) return [part.text]
|
||||
@@ -328,12 +330,16 @@ export function transformSession(input: TransformInput): TransformResult {
|
||||
return []
|
||||
})
|
||||
.join("\n\n")
|
||||
const agentAttachments = agents.map((part) => ({
|
||||
name: part.name,
|
||||
...(part.source
|
||||
? { mention: { text: part.source.value, start: part.source.start, end: part.source.end } }
|
||||
: {}),
|
||||
}))
|
||||
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: "" },
|
||||
)
|
||||
if (
|
||||
ordinary.length === 0 &&
|
||||
unavailable.length === 0 &&
|
||||
@@ -345,7 +351,7 @@ export function transformSession(input: TransformInput): TransformResult {
|
||||
row(item.row, {
|
||||
id: item.row.id,
|
||||
type: "synthetic",
|
||||
text: synthetic.map((part) => part.text).join("\n\n"),
|
||||
text: synthetic.map((part) => (part.type === "text" ? part.text : "")).join("\n\n"),
|
||||
time: { created: item.row.time_created },
|
||||
}),
|
||||
]
|
||||
@@ -363,7 +369,7 @@ export function transformSession(input: TransformInput): TransformResult {
|
||||
row(item.row, {
|
||||
id: syntheticID(item.row.id, used),
|
||||
type: "synthetic",
|
||||
text: synthetic.map((part) => part.text).join("\n\n"),
|
||||
text: synthetic.map((part) => (part.type === "text" ? part.text : "")).join("\n\n"),
|
||||
time: { created: item.row.time_created },
|
||||
}),
|
||||
]
|
||||
@@ -437,6 +443,7 @@ 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) => {
|
||||
@@ -481,7 +488,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(
|
||||
@@ -521,75 +528,76 @@ 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 now = Date.now()
|
||||
yield* db.run(sql`
|
||||
const migrate = Effect.gen(function* () {
|
||||
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()
|
||||
}),
|
||||
)
|
||||
.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),
|
||||
)
|
||||
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`
|
||||
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,
|
||||
@@ -604,79 +612,81 @@ 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)}`,
|
||||
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 },
|
||||
})
|
||||
.run(),
|
||||
)
|
||||
.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* () {
|
||||
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 })
|
||||
.insert(KVTable)
|
||||
.values({ key: MIGRATION_STATE_KEY, value: { phase: "completed" } })
|
||||
.onConflictDoUpdate({
|
||||
target: EventSequenceTable.aggregate_id,
|
||||
set: { seq: transformed.watermark, owner_id: null },
|
||||
target: KVTable.key,
|
||||
set: { value: { phase: "completed" }, time_updated: Date.now() },
|
||||
})
|
||||
.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* () {
|
||||
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 }
|
||||
return { status: "completed" as const }
|
||||
})
|
||||
return yield* migrate
|
||||
}).pipe(Effect.orDie),
|
||||
)
|
||||
}
|
||||
@@ -705,7 +715,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(
|
||||
|
||||
@@ -61,23 +61,21 @@ export const makeMemoryDriver = (): MemoryDriver => {
|
||||
}
|
||||
const failed = (value: string, cause: unknown) => new Failed({ path: value, cause })
|
||||
const overrides: FilesImpl = {
|
||||
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() }
|
||||
}),
|
||||
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() })
|
||||
},
|
||||
write: (value, bytes) =>
|
||||
Effect.try({
|
||||
try: () => {
|
||||
@@ -91,17 +89,17 @@ export const makeMemoryDriver = (): MemoryDriver => {
|
||||
},
|
||||
catch: (cause) => failed(value, cause),
|
||||
}),
|
||||
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))
|
||||
}),
|
||||
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)
|
||||
},
|
||||
remove: (value) =>
|
||||
Effect.sync(() => {
|
||||
const target = resolveKey(value, false) ?? key(value)
|
||||
@@ -109,33 +107,32 @@ export const makeMemoryDriver = (): MemoryDriver => {
|
||||
if (entry === target || entry.startsWith(`${target}/`)) nodes.delete(entry)
|
||||
}
|
||||
}),
|
||||
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),
|
||||
})
|
||||
}),
|
||||
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),
|
||||
})
|
||||
},
|
||||
mkdir: (value) => Effect.try({ try: () => mkdirSync(value), catch: (cause) => failed(value, cause) }),
|
||||
}
|
||||
|
||||
|
||||
@@ -62,8 +62,9 @@ export const syncTextBom = Effect.fn("FileMutation.syncTextBom")(function* (
|
||||
const transactionLocks = KeyedMutex.makeUnsafe<string>()
|
||||
|
||||
/**
|
||||
* Mutation locking is process-local and serializes cooperating OpenCode
|
||||
* changes; external writes can still race.
|
||||
* 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.
|
||||
*/
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
@@ -128,6 +129,7 @@ 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.
|
||||
|
||||
@@ -25,19 +25,15 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Lo
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
let current: readonly string[] = []
|
||||
const listeners = new Set<(ignore: readonly string[]) => Effect.Effect<void>>()
|
||||
const state = State.create<Data, Draft>({
|
||||
const state: State.Interface<Data, Draft> = State.create<Data, Draft>({
|
||||
name: "location-watcher-policy",
|
||||
initial: () => ({ ignore: [] }),
|
||||
draft: (draft) => ({
|
||||
add: (ignore) => draft.ignore.push(...ignore),
|
||||
list: () => draft.ignore,
|
||||
}),
|
||||
finalize: (draft) =>
|
||||
Effect.sync(() => {
|
||||
current = [...draft.list()]
|
||||
}).pipe(Effect.andThen(Effect.forEach(listeners, (listener) => listener(current), { discard: true }))),
|
||||
notify: () => Effect.forEach(listeners, (listener) => listener(state.get().ignore), { discard: true }),
|
||||
})
|
||||
const observe = Effect.fn("LocationWatcherPolicy.observe")(function* (
|
||||
listener: (ignore: readonly string[]) => Effect.Effect<void>,
|
||||
@@ -56,7 +52,7 @@ const layer = Layer.effect(
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
current: () => current,
|
||||
current: () => state.get().ignore,
|
||||
observe,
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -1,38 +1,5 @@
|
||||
# GitHub Copilot AI SDK Adapters
|
||||
This is a temporary package used primarily for GitHub Copilot compatibility.
|
||||
|
||||
This directory contains upstream-derived AI SDK implementations adapted for
|
||||
GitHub Copilot. It is not a generic OpenAI-compatible provider.
|
||||
These DO NOT apply for openai-compatible providers or majority of providers supporting completions/responses apis. THIS IS ONLY FOR GITHUB COPILOT!!!
|
||||
|
||||
## Provenance
|
||||
|
||||
- `chat/` is derived from the Vercel AI SDK
|
||||
`@ai-sdk/openai-compatible` chat implementation.
|
||||
- `responses/` is derived from the Vercel AI SDK `@ai-sdk/openai` Responses
|
||||
implementation.
|
||||
- The exact upstream revisions originally copied into this repository are
|
||||
unknown. Current dependency versions and the `VERSION` constant in
|
||||
`copilot-provider.ts` are not copy provenance.
|
||||
|
||||
## Ownership
|
||||
|
||||
Keep `chat/` and `responses/` structurally close to their upstream modules, but
|
||||
preserve the intentional Copilot adaptations: the `copilot` options and metadata
|
||||
namespace, `thinking_budget`, reasoning text and opaque reasoning, stateless
|
||||
Responses requests with encrypted reasoning, rotating response item IDs, and
|
||||
explicit function-tool strictness taking precedence over the global fallback.
|
||||
|
||||
`copilot-provider.ts` is the local adapter assembly entrypoint used by
|
||||
`plugin/provider/github-copilot.ts`. `models.ts` is OpenCode-owned catalog
|
||||
reconciliation, not vendored SDK code. Authentication, request headers, model
|
||||
routing, and integration lifecycle are also owned by the provider plugin.
|
||||
|
||||
When updating the upstream-shaped modules, compare against both source packages
|
||||
and reapply the documented Copilot adaptations. Focused regression coverage is
|
||||
in:
|
||||
|
||||
- `test/github-copilot/copilot-chat-model.test.ts`
|
||||
- `test/github-copilot/convert-to-copilot-messages.test.ts`
|
||||
- `test/github-copilot/openai-responses-language-model.test.ts`
|
||||
- `test/github-copilot/openai-responses-prepare-tools.test.ts`
|
||||
- `test/github-copilot/models.test.ts`
|
||||
- `test/plugin/provider-github-copilot.test.ts`
|
||||
Avoid making edits to these files
|
||||
|
||||
@@ -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, type Limits } from "../image.js"
|
||||
import { DecodeError, ResizerUnavailableError, SizeError } from "../image.js"
|
||||
|
||||
const JPEG_QUALITIES = [80, 85, 70, 55, 40]
|
||||
|
||||
@@ -33,7 +33,12 @@ export const make = Effect.gen(function* () {
|
||||
return Effect.fn("Image.Photon.normalize")(function* (
|
||||
resource: string,
|
||||
content: FileSystem.Content & { readonly encoding: "base64" },
|
||||
limits: Readonly<Limits>,
|
||||
limits: {
|
||||
readonly autoResize: boolean
|
||||
readonly maxWidth: number
|
||||
readonly maxHeight: number
|
||||
readonly maxBase64Bytes: number
|
||||
},
|
||||
) {
|
||||
const photon = yield* loadPhoton
|
||||
const decoded = yield* Effect.try({
|
||||
|
||||
@@ -74,7 +74,7 @@ export const layer = (options?: Options) =>
|
||||
draft.available = false
|
||||
},
|
||||
}),
|
||||
finalize: () => bus.publish(Event.Updated, {}).pipe(Effect.asVoid),
|
||||
notify: () => bus.publish(Event.Updated, {}).pipe(Effect.asVoid),
|
||||
})
|
||||
|
||||
const source = (value: ReadonlyArray<File> | Instructions.Unavailable | Instructions.Removed) =>
|
||||
|
||||
@@ -328,7 +328,7 @@ const layer = Layer.effect(
|
||||
},
|
||||
},
|
||||
}),
|
||||
finalize: () => bus.publish(Integration.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
notify: () => bus.publish(Integration.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
})
|
||||
|
||||
const createCredential = Effect.fnUntraced(function* (input: Parameters<Credential.Interface["create"]>[0]) {
|
||||
@@ -402,11 +402,13 @@ const layer = Layer.effect(
|
||||
}
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const implementation = state
|
||||
.get()
|
||||
.integrations.get(attempt.integrationID)
|
||||
?.implementations.get(attempt.methodID)
|
||||
const persistence = yield* Effect.sync(() => attempt.label ?? implementation?.label?.(exit.value)).pipe(
|
||||
const persistence = yield* Effect.sync(() => {
|
||||
const implementation = state
|
||||
.get()
|
||||
.integrations.get(attempt.integrationID)
|
||||
?.implementations.get(attempt.methodID)
|
||||
return attempt.label ?? implementation?.label?.(exit.value)
|
||||
}).pipe(
|
||||
Effect.flatMap((label) =>
|
||||
createCredential({
|
||||
integrationID: attempt.integrationID,
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
} from "@modelcontextprotocol/sdk/types.js"
|
||||
import { Cause, Effect, Exit, Schema } from "effect"
|
||||
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import { McpStdio } from "./stdio.js"
|
||||
|
||||
const DEFAULT_STARTUP_TIMEOUT = 30_000
|
||||
@@ -157,7 +156,6 @@ export interface Connection {
|
||||
readonly callTool: (input: {
|
||||
readonly name: string
|
||||
readonly args?: Record<string, unknown>
|
||||
readonly sessionID?: Session.ID
|
||||
}) => Effect.Effect<CallToolResult, Error>
|
||||
readonly onClose: (callback: () => void) => void
|
||||
/** Registers a callback fired when the server emits an MCP logging notification. */
|
||||
@@ -398,11 +396,7 @@ export const connect = Effect.fnUntraced(function* (
|
||||
Effect.tryPromise({
|
||||
try: (signal) =>
|
||||
client.callTool(
|
||||
{
|
||||
name: input.name,
|
||||
arguments: input.args ?? {},
|
||||
...(input.sessionID === undefined ? {} : { _meta: { sessionID: input.sessionID } }),
|
||||
},
|
||||
{ name: input.name, arguments: input.args ?? {} },
|
||||
CallToolResultSchema,
|
||||
// Keep progress tokens available while enforcing a hard wall-clock execution timeout.
|
||||
{ signal, timeout: executionTimeout, onprogress: () => {} },
|
||||
|
||||
@@ -3,10 +3,23 @@ export * as Mcp from "./index.js"
|
||||
import { Mcp } from "@opencode-ai/schema/mcp"
|
||||
import { McpEvent } from "@opencode-ai/schema/mcp-event"
|
||||
import { ephemeral } from "@opencode-ai/schema/event"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import { createHash } from "node:crypto"
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
import { Cause, Context, Effect, Exit, FiberSet, Latch, Layer, Schema, Scope, Stream, Types } from "effect"
|
||||
import {
|
||||
Cause,
|
||||
Context,
|
||||
Effect,
|
||||
Exit,
|
||||
Fiber,
|
||||
FiberSet,
|
||||
Latch,
|
||||
Layer,
|
||||
Schema,
|
||||
Scope,
|
||||
Semaphore,
|
||||
Stream,
|
||||
Types,
|
||||
} from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Credential } from "../credential.js"
|
||||
import { Bus } from "../bus.js"
|
||||
@@ -154,7 +167,6 @@ export interface Interface extends State.Transformable<Draft> {
|
||||
readonly server: ServerName | string
|
||||
readonly name: string
|
||||
readonly args?: Record<string, unknown>
|
||||
readonly sessionID?: Session.ID
|
||||
}) => Effect.Effect<ToolResult, NotFoundError | ToolCallError>
|
||||
readonly instructions: () => Effect.Effect<ServerInstructions[]>
|
||||
readonly prompts: () => Effect.Effect<Prompt[]>
|
||||
@@ -615,8 +627,9 @@ export const layer = (options?: Options) =>
|
||||
|
||||
let applied: Map<ServerName, Mcp.ServerConfig> | undefined
|
||||
const overrides = new Map<ServerName, Mcp.ServerConfig | false>()
|
||||
const reconcile = Effect.fnUntraced(function* (next: Draft) {
|
||||
const servers = new Map(next.list())
|
||||
const reconcileLock = Semaphore.makeUnsafe(1)
|
||||
const reconcile = Effect.fnUntraced(function* () {
|
||||
const servers = state.get().servers
|
||||
if (!applied && entries.size === 0) {
|
||||
for (const [name, server] of servers) {
|
||||
entries.set(name, {
|
||||
@@ -677,7 +690,7 @@ export const layer = (options?: Options) =>
|
||||
Stream.runForEach((event) => Effect.sync(() => fork(reconnect(event.data.integrationID)))),
|
||||
),
|
||||
)
|
||||
const state = State.create<Data, Draft>({
|
||||
const state: State.Interface<Data, Draft> = State.create<Data, Draft>({
|
||||
name: "mcp",
|
||||
initial: () => ({
|
||||
servers: new Map(
|
||||
@@ -702,7 +715,12 @@ export const layer = (options?: Options) =>
|
||||
},
|
||||
remove: (server) => draft.servers.delete(ServerName.make(server)),
|
||||
}),
|
||||
finalize: reconcile,
|
||||
notify: () =>
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* Fiber.await(fork(reconcileLock.withPermit(reconcile())))
|
||||
if (Exit.isFailure(exit) && root.state._tag === "Closed" && Cause.hasInterruptsOnly(exit.cause)) return
|
||||
yield* exit
|
||||
}),
|
||||
})
|
||||
|
||||
// Suspend so each await sees current entries; a bare Map iterator is exhausted after one run.
|
||||
@@ -764,7 +782,7 @@ export const layer = (options?: Options) =>
|
||||
message: "MCP server is not connected",
|
||||
})
|
||||
const result = yield* target.entry.client
|
||||
.callTool({ name: input.name, args: input.args, sessionID: input.sessionID })
|
||||
.callTool({ name: input.name, args: input.args })
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
(error) => new ToolCallError({ server: target.name, tool: input.name, message: error.message }),
|
||||
|
||||
@@ -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, PersistentPty, ReadLines, Removed, type ReadResult } from "@opencode-ai/schema/persistent-pty"
|
||||
import { Added, Handoff, 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,9 +26,19 @@ 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 = PersistentPty.Info
|
||||
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 Snapshot = PersistentPty.Snapshot
|
||||
export type Snapshot = {
|
||||
readonly info: Info
|
||||
readonly text: string
|
||||
readonly checkpoint: Uint8Array
|
||||
readonly cursor: { readonly x: number; readonly y: number }
|
||||
}
|
||||
|
||||
export type Attachment = {
|
||||
readonly info: Info
|
||||
@@ -151,7 +161,15 @@ export const configured = (options: Options = {}) =>
|
||||
|
||||
const create = Effect.fn("PersistentPty.create")(function* (
|
||||
sessionID: Session.ID,
|
||||
input: Parameters<Interface["create"]>[1],
|
||||
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
|
||||
},
|
||||
) {
|
||||
const response = yield* request(
|
||||
daemon,
|
||||
@@ -320,7 +338,14 @@ export const configured = (options: Options = {}) =>
|
||||
|
||||
const attach = Effect.fn("PersistentPty.attach")(function* (
|
||||
id: Pty.ID,
|
||||
input: Parameters<Interface["attach"]>[1],
|
||||
input: {
|
||||
readonly cursor: number
|
||||
readonly attachmentID: string
|
||||
readonly role: Role
|
||||
readonly takeover?: boolean
|
||||
readonly onEvent: (event: StreamEvent) => void
|
||||
readonly onEnd: () => void
|
||||
},
|
||||
) {
|
||||
yield* get(id)
|
||||
const attachment = yield* daemon
|
||||
|
||||
@@ -37,7 +37,7 @@ export const ModelsDevPlugin = define({
|
||||
})
|
||||
for (const model of provider.models) {
|
||||
if (model.status === "deprecated") continue
|
||||
catalog.model.update(provider.info.id, model.id, (draft) => Object.assign(draft, structuredClone(model)))
|
||||
catalog.model.update(provider.info.id, model.id, (draft) => Object.assign(draft, model))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -40,7 +40,7 @@ export const load = Effect.fn("PluginModule.load")(function* (
|
||||
const npm = yield* Npm.Service
|
||||
const entrypoint = path.isAbsolute(operation.target)
|
||||
? pathToFileURL(operation.target).href
|
||||
: (yield* npm.add(operation.target, { subpaths: ["server", ""] })).entrypoint
|
||||
: (yield* npm.add(operation.target, { subpaths: ["server", ""], refresh: true })).entrypoint
|
||||
if (!entrypoint) return yield* Effect.fail(new Error(`Plugin entrypoint not found: ${operation.target}`))
|
||||
// Bun currently ignores query parameters when caching file:// imports.
|
||||
const target = typeof Bun !== "undefined" ? operation.target.replaceAll("\\", "/") : entrypoint
|
||||
|
||||
@@ -76,13 +76,11 @@ to every project for that user. Project configuration can live in any directory
|
||||
as `opencode.json(c)` or `.opencode/opencode.json(c)`, including nested packages
|
||||
in a monorepo.
|
||||
|
||||
During ordinary project discovery, OpenCode searches the current Location
|
||||
directory and every ancestor through the filesystem root, including directories
|
||||
above the detected project or repository root. It merges direct
|
||||
`opencode.json(c)` files from the farthest ancestor to the current directory,
|
||||
When OpenCode starts, it searches from the current directory up to the project
|
||||
root. It merges direct `opencode.json(c)` files from root to current directory,
|
||||
then does the same for `.opencode/opencode.json(c)` files. This means every
|
||||
discovered `.opencode` config overrides every discovered direct config. Global
|
||||
filesystem configuration has lower precedence than these discovered documents.
|
||||
`.opencode` config overrides every direct config. Global configuration has the
|
||||
lowest precedence.
|
||||
|
||||
Common configuration fields include `model`, `default_agent`, `permissions`,
|
||||
`agents`, `commands`, `plugins`, `providers`, `mcp`, `skills`, `instructions`,
|
||||
|
||||
@@ -78,9 +78,6 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
|
||||
...post.filter((plugin) => enabled.has(plugin.id)),
|
||||
],
|
||||
failures: [...failures.values()],
|
||||
refreshes: [...packages.entries()].flatMap(([target, plugin]) =>
|
||||
!path.isAbsolute(target) && enabled.has(plugin.id) ? [target] : [],
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -92,7 +89,6 @@ export const layer = Layer.effect(
|
||||
const instance = yield* InstancePlugins.Service
|
||||
const sources = yield* ConfigPluginSource.Service
|
||||
const bus = yield* Bus.Service
|
||||
const npm = yield* Npm.Service
|
||||
const ready = yield* Latch.make()
|
||||
let observed = 0
|
||||
|
||||
@@ -117,18 +113,6 @@ export const layer = Layer.effect(
|
||||
const resolved = yield* resolve(pre, post, operations)
|
||||
// Replace the active generation in one scoped, batched activation.
|
||||
yield* registry.activate(resolved.plugins, resolved.failures)
|
||||
if (resolved.refreshes.length) {
|
||||
yield* Effect.forEach(
|
||||
resolved.refreshes,
|
||||
(target) =>
|
||||
npm
|
||||
.add(target, { subpaths: ["server", ""], refresh: true })
|
||||
.pipe(
|
||||
Effect.catchCause((cause) => Effect.logWarning("failed to refresh package plugin", { target, cause })),
|
||||
),
|
||||
{ concurrency: "unbounded", discard: true },
|
||||
).pipe(Effect.forkDetach)
|
||||
}
|
||||
})
|
||||
const updates = Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])).pipe(
|
||||
// Make accepted work visible to flush before coalescing the burst.
|
||||
|
||||
@@ -137,10 +137,11 @@ 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. 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 (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.
|
||||
for (const item of directories) {
|
||||
const key = item.projectID + "\u0000" + item.directory
|
||||
if (announcing.has(key)) continue
|
||||
|
||||
@@ -26,6 +26,7 @@ export type Info = Reference.Info
|
||||
|
||||
type Data = {
|
||||
sources: Map<string, Types.DeepMutable<Source>>
|
||||
materialized: Map<string, Info>
|
||||
}
|
||||
|
||||
type Draft = {
|
||||
@@ -47,61 +48,71 @@ const layer = Layer.effect(
|
||||
const bus = yield* Bus.Service
|
||||
const cache = yield* RepositoryCache.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const materialized = new Map<string, Info>()
|
||||
const state = State.create<Data, Draft>({
|
||||
const state: State.Interface<Data, Draft> = State.create<Data, Draft>({
|
||||
name: "reference",
|
||||
initial: () => ({ sources: new Map() }),
|
||||
initial: () => ({ sources: new Map(), materialized: new Map() }),
|
||||
draft: (draft) => ({
|
||||
add: (name, source) => draft.sources.set(name, source as Types.DeepMutable<Source>),
|
||||
remove: (name) => draft.sources.delete(name),
|
||||
list: () => Array.from(draft.sources.entries()) as [string, Source][],
|
||||
}),
|
||||
finalize: (draft) =>
|
||||
Effect.gen(function* () {
|
||||
materialized.clear()
|
||||
for (const [name, source] of draft.list()) {
|
||||
if (source.type === "local") {
|
||||
materialized.set(
|
||||
name,
|
||||
Info.make({
|
||||
name,
|
||||
path: source.path,
|
||||
...(source.description === undefined ? {} : { description: source.description }),
|
||||
...(source.hidden === undefined ? {} : { hidden: source.hidden }),
|
||||
source,
|
||||
}),
|
||||
)
|
||||
continue
|
||||
}
|
||||
const repository = Repository.parse(source.repository)
|
||||
if (!repository || !Repository.isRemote(repository)) continue
|
||||
if (source.branch) {
|
||||
try {
|
||||
Repository.validateBranch(source.branch)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
materialized.set(
|
||||
prepare: (data) => {
|
||||
for (const [name, source] of data.sources) {
|
||||
if (source.type === "local") {
|
||||
data.materialized.set(
|
||||
name,
|
||||
Info.make({
|
||||
name,
|
||||
path: AbsolutePath.make(Repository.cachePath(global.repos, repository, source.branch)),
|
||||
path: source.path,
|
||||
...(source.description === undefined ? {} : { description: source.description }),
|
||||
...(source.hidden === undefined ? {} : { hidden: source.hidden }),
|
||||
source,
|
||||
}),
|
||||
)
|
||||
yield* cache.ensure({ reference: repository, branch: source.branch, refresh: true }).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("failed to materialize reference", {
|
||||
name,
|
||||
repository: source.repository,
|
||||
cause,
|
||||
}),
|
||||
),
|
||||
Effect.forkIn(scope),
|
||||
)
|
||||
continue
|
||||
}
|
||||
const repository = Repository.parse(source.repository)
|
||||
if (!repository || !Repository.isRemote(repository)) continue
|
||||
if (source.branch) {
|
||||
try {
|
||||
Repository.validateBranch(source.branch)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
data.materialized.set(
|
||||
name,
|
||||
Info.make({
|
||||
name,
|
||||
path: AbsolutePath.make(Repository.cachePath(global.repos, repository, source.branch)),
|
||||
...(source.description === undefined ? {} : { description: source.description }),
|
||||
...(source.hidden === undefined ? {} : { hidden: source.hidden }),
|
||||
source,
|
||||
}),
|
||||
)
|
||||
}
|
||||
},
|
||||
notify: () =>
|
||||
Effect.gen(function* () {
|
||||
for (const info of state.get().materialized.values()) {
|
||||
const source = info.source
|
||||
if (source.type !== "git") continue
|
||||
yield* cache
|
||||
.ensure({
|
||||
reference: Repository.parseRemote(source.repository),
|
||||
branch: source.branch,
|
||||
refresh: true,
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("failed to materialize reference", {
|
||||
name: info.name,
|
||||
repository: source.repository,
|
||||
cause,
|
||||
}),
|
||||
),
|
||||
Effect.forkIn(scope),
|
||||
)
|
||||
}
|
||||
yield* bus.publish(Reference.Event.Updated, {})
|
||||
}),
|
||||
@@ -111,7 +122,7 @@ const layer = Layer.effect(
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
list: Effect.fn("Reference.list")(function* () {
|
||||
return Array.from(materialized.values())
|
||||
return Array.from(state.get().materialized.values())
|
||||
}),
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -393,27 +393,26 @@ export const layer = Layer.effect(
|
||||
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
|
||||
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,
|
||||
}),
|
||||
}),
|
||||
const resolved = yield* input.resolveModel(input.session).pipe(
|
||||
Effect.catch((cause) =>
|
||||
failed({
|
||||
sessionID: input.session.id,
|
||||
reason: "manual",
|
||||
error: toSessionError(cause),
|
||||
inputID: input.inputID,
|
||||
}),
|
||||
),
|
||||
)
|
||||
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,7 +131,11 @@ 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: Parameters<Interface["put"]>[0]) {
|
||||
const put = Effect.fn("InstructionEntry.put")(function* (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly key: Key
|
||||
readonly value: Schema.Json
|
||||
}) {
|
||||
const actualBytes = Buffer.byteLength(JSON.stringify(input.value), "utf8")
|
||||
if (actualBytes > MaxValueBytes)
|
||||
yield* new ValueTooLargeError({
|
||||
@@ -155,7 +159,10 @@ const layer = Layer.effect(
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const remove = Effect.fn("InstructionEntry.remove")(function* (input: Parameters<Interface["remove"]>[0]) {
|
||||
const remove = Effect.fn("InstructionEntry.remove")(function* (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly key: Key
|
||||
}) {
|
||||
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 because re-rendering it
|
||||
// later would require the original Location-scoped instruction sources.
|
||||
// The rendered text is frozen into the durable event: replaying it later would
|
||||
// require the Location-scoped registry that produced it.
|
||||
const text = observation.initial ? "" : yield* renderUpdateText(db, instructions, observation)
|
||||
yield* bus.publish(
|
||||
SessionEvent.InstructionsUpdated,
|
||||
|
||||
@@ -43,7 +43,10 @@ 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: Parameters<Interface["load"]>[0]) {
|
||||
const load = Effect.fn("SessionInstructions.load")(function* (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly paths: ReadonlyArray<string>
|
||||
}) {
|
||||
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))
|
||||
|
||||
@@ -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 Interface } from "./index.js"
|
||||
import { DrainResult, Service, type Continuation } 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,7 +44,12 @@ 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: Parameters<Interface["drain"]>[0]) {
|
||||
const drain = Effect.fn("SessionRunner.drain")(function* (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly force: boolean
|
||||
readonly continuation?: Continuation
|
||||
readonly promotable?: SessionInbox.Promotable
|
||||
}) {
|
||||
const sessionID = input.sessionID
|
||||
let force = input.force
|
||||
let continuing = input.continuation !== undefined
|
||||
|
||||
+49
-49
@@ -122,8 +122,8 @@ const layer = () =>
|
||||
const environments = yield* SessionEnvironment.Service
|
||||
const context = yield* Effect.context()
|
||||
const runFork = Effect.runForkWith(context)
|
||||
const commands = new Map<Shell.ID, Active>()
|
||||
const exitOrder: Shell.ID[] = []
|
||||
const sessions = new Map<string, Active>()
|
||||
const exitOrder: string[] = []
|
||||
|
||||
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 command of commands.values()) {
|
||||
if (command.timeoutFiber) yield* Fiber.interrupt(command.timeoutFiber)
|
||||
for (const session of sessions.values()) {
|
||||
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
|
||||
// Teardown interrupts pending commands; it is not a terminal command failure.
|
||||
yield* Deferred.interrupt(command.done)
|
||||
yield* Deferred.interrupt(session.done)
|
||||
}
|
||||
commands.clear()
|
||||
sessions.clear()
|
||||
exitOrder.length = 0
|
||||
}),
|
||||
)
|
||||
|
||||
const require = Effect.fnUntraced(function* (id: Shell.ID) {
|
||||
const command = commands.get(id)
|
||||
if (!command) return yield* new NotFoundError({ id })
|
||||
return command
|
||||
const session = sessions.get(id)
|
||||
if (!session) return yield* new NotFoundError({ id })
|
||||
return session
|
||||
})
|
||||
|
||||
const removeCommand = Effect.fnUntraced(function* (id: Shell.ID) {
|
||||
const command = commands.get(id)
|
||||
const removeSession = Effect.fnUntraced(function* (id: Shell.ID) {
|
||||
const session = sessions.get(id)
|
||||
const index = exitOrder.indexOf(id)
|
||||
if (index !== -1) exitOrder.splice(index, 1)
|
||||
if (!command) return
|
||||
commands.delete(id)
|
||||
if (command.timeoutFiber) yield* Fiber.interrupt(command.timeoutFiber)
|
||||
if (!session) return
|
||||
sessions.delete(id)
|
||||
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
|
||||
// Unblock any wait still pending when the command is removed before it terminated.
|
||||
yield* Deferred.fail(command.done, new NotFoundError({ id }))
|
||||
yield* Effect.promise(() => unlink(command.file).catch(() => {}))
|
||||
yield* Deferred.fail(session.done, new NotFoundError({ id }))
|
||||
yield* Effect.promise(() => unlink(session.file).catch(() => {}))
|
||||
yield* bus.publish(Shell.Event.Deleted, { id })
|
||||
})
|
||||
|
||||
const remove = Effect.fn("Shell.remove")(function* (id: Shell.ID) {
|
||||
yield* require(id)
|
||||
yield* removeCommand(id)
|
||||
yield* removeSession(id)
|
||||
})
|
||||
|
||||
const list = Effect.fn("Shell.list")(function* () {
|
||||
return Array.from(commands.values())
|
||||
.filter((command) => command.info.status === "running")
|
||||
.map((command) => command.info)
|
||||
return Array.from(sessions.values())
|
||||
.filter((session) => session.info.status === "running")
|
||||
.map((session) => session.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 command = yield* require(id)
|
||||
if (command.info.status !== "running" || !command.timeout) return command.info
|
||||
yield* command.timeout(duration)
|
||||
return command.info
|
||||
const session = yield* require(id)
|
||||
if (session.info.status !== "running" || !session.timeout) return session.info
|
||||
yield* session.timeout(duration)
|
||||
return session.info
|
||||
})
|
||||
|
||||
const output = Effect.fnUntraced(function* (id: Shell.ID, input?: Shell.OutputInput) {
|
||||
const command = yield* require(id)
|
||||
const session = yield* require(id)
|
||||
const cursor = input?.cursor ?? 0
|
||||
const limit = input?.limit ?? 65536
|
||||
if (cursor >= command.size) return { output: "", cursor: command.size, size: command.size, truncated: false }
|
||||
if (cursor >= session.size) return { output: "", cursor: session.size, size: session.size, truncated: false }
|
||||
const start = Math.max(0, cursor)
|
||||
const length = Math.min(limit, command.size - start)
|
||||
const length = Math.min(limit, session.size - start)
|
||||
const buffer = Buffer.alloc(length)
|
||||
const bytesRead = yield* Effect.promise(
|
||||
() =>
|
||||
new Promise<number>((resolve) => {
|
||||
const stream = createReadStream(command.file, { start, end: start + length - 1 })
|
||||
const stream = createReadStream(session.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: command.size,
|
||||
size: session.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 command.
|
||||
// end). `create` returns once `ready` resolves with the registered session.
|
||||
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 command: Active = {
|
||||
const session: Active = {
|
||||
info: produce(info, (draft) => {
|
||||
draft.pid = handle.pid
|
||||
}),
|
||||
@@ -283,7 +283,7 @@ const layer = () =>
|
||||
size: 0,
|
||||
done: Deferred.makeUnsafe<Info, NotFoundError>(),
|
||||
}
|
||||
commands.set(id, command)
|
||||
sessions.set(id, session)
|
||||
|
||||
const stream = createWriteStream(file)
|
||||
const outputDone = Latch.makeUnsafe()
|
||||
@@ -291,7 +291,7 @@ const layer = () =>
|
||||
Stream.runForEach((chunk: Uint8Array) =>
|
||||
Effect.sync(() => {
|
||||
stream.write(chunk)
|
||||
command.size += chunk.length
|
||||
session.size += chunk.length
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -317,8 +317,8 @@ const layer = () =>
|
||||
|
||||
const finish = (status: Info["status"], exit?: number, beforeWait = Effect.void) =>
|
||||
Effect.gen(function* () {
|
||||
if (command.info.status !== "running") return
|
||||
command.info = produce(command.info, (draft) => {
|
||||
if (session.info.status !== "running") return
|
||||
session.info = produce(session.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
|
||||
// command still reports success rather than the removal NotFoundError. This runs before
|
||||
// session 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(command.done, command.info)
|
||||
yield* Deferred.succeed(session.done, session.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* removeCommand(oldest)
|
||||
yield* removeSession(Shell.ID.make(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 (command.timeoutFiber) yield* Fiber.interrupt(command.timeoutFiber)
|
||||
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
|
||||
})
|
||||
|
||||
command.timeout = (duration) =>
|
||||
session.timeout = (duration) =>
|
||||
Effect.gen(function* () {
|
||||
if (command.timeoutFiber) yield* Fiber.interrupt(command.timeoutFiber)
|
||||
command.timeoutFiber = undefined
|
||||
if (duration === 0 || command.info.status !== "running") return
|
||||
command.timeoutFiber = runFork(
|
||||
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
|
||||
session.timeoutFiber = undefined
|
||||
if (duration === 0 || session.info.status !== "running") return
|
||||
session.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* command.timeout(invocation.timeout)
|
||||
yield* session.timeout(invocation.timeout)
|
||||
|
||||
runFork(
|
||||
handle.exitCode.pipe(
|
||||
@@ -370,16 +370,16 @@ const layer = () =>
|
||||
)
|
||||
|
||||
yield* bus.publish(Shell.Event.Created, { info })
|
||||
yield* Deferred.succeed(ready, command)
|
||||
yield* Deferred.succeed(ready, session)
|
||||
// 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(command.done).pipe(Effect.catch(() => Effect.void))
|
||||
yield* Deferred.await(session.done).pipe(Effect.catch(() => Effect.void))
|
||||
}),
|
||||
).pipe(Effect.catchTag("AppProcessError", (error) => Deferred.fail(ready, error))),
|
||||
)
|
||||
|
||||
const command = yield* Deferred.await(ready)
|
||||
return command.info
|
||||
const session = yield* Deferred.await(ready)
|
||||
return session.info
|
||||
})
|
||||
|
||||
return Service.of({ create, list, get, wait, timeout, output, remove })
|
||||
|
||||
@@ -109,7 +109,7 @@ const layer = Layer.effect(
|
||||
draft.skills.delete(ID.make(id))
|
||||
},
|
||||
}),
|
||||
finalize: () => bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
notify: () => bus.publish(Skill.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
|
||||
+88
-94
@@ -1,9 +1,9 @@
|
||||
export * as State from "./state.js"
|
||||
|
||||
import { Clock, Context, Deferred, Effect, Scope, Semaphore } from "effect"
|
||||
import { Clock, Context, Deferred, Effect, Exit, Scope } from "effect"
|
||||
|
||||
/**
|
||||
* A replayable transform applied to a draft during reload.
|
||||
* A replayable transform applied to a draft while deriving state.
|
||||
*
|
||||
* Domain drafts expose readable and writable state while preserving concise
|
||||
* plugin/config code. Transforms synchronously rebuild derived state.
|
||||
@@ -16,13 +16,14 @@ export interface Registration {
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers and applies a scoped transform. Closing the owning Scope removes
|
||||
* the transform and reloads the materialized state.
|
||||
* Registers a scoped transform and invalidates the derived state. Closing the
|
||||
* owning Scope removes the transform. Reads synchronously replay pending changes.
|
||||
*/
|
||||
export type Transform<DraftApi> = (
|
||||
transform: TransformCallback<DraftApi>,
|
||||
) => Effect.Effect<Registration, never, Scope.Scope>
|
||||
|
||||
/** Invalidates the snapshot after captured inputs change and coalesces notifications. */
|
||||
export type Reload = () => Effect.Effect<void>
|
||||
|
||||
export interface Transformable<DraftApi> {
|
||||
@@ -33,7 +34,7 @@ export interface Transformable<DraftApi> {
|
||||
type Batch = {
|
||||
active: boolean
|
||||
readonly flush: boolean
|
||||
readonly reloads: Set<Reload>
|
||||
readonly notifications: Set<Reload>
|
||||
}
|
||||
|
||||
const CurrentBatch = Context.Reference<Batch | undefined>("@opencode/State/CurrentBatch", {
|
||||
@@ -41,17 +42,24 @@ const CurrentBatch = Context.Reference<Batch | undefined>("@opencode/State/Curre
|
||||
})
|
||||
const reloadDebounce = 500
|
||||
|
||||
/** flush: false is terminal teardown: states whose transforms are removed stop rebuilding, including pending reloads. */
|
||||
/** Batches notifications, not read visibility. flush: false is terminal teardown. */
|
||||
export function batch<A, E, R>(effect: Effect.Effect<A, E, R>, options: { readonly flush?: boolean } = {}) {
|
||||
return Effect.gen(function* () {
|
||||
const current = yield* CurrentBatch
|
||||
if (current?.active && options.flush !== false) return yield* effect
|
||||
const batch: Batch = { active: true, flush: options.flush !== false, reloads: new Set() }
|
||||
const exit = yield* effect.pipe(Effect.provideService(CurrentBatch, batch), Effect.exit)
|
||||
batch.active = false
|
||||
if (batch.flush) yield* Effect.forEach(batch.reloads, (reload) => reload(), { discard: true })
|
||||
return yield* exit
|
||||
})
|
||||
return Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
const current = yield* CurrentBatch
|
||||
if (current?.active && options.flush !== false) return yield* restore(effect)
|
||||
const batch: Batch = { active: true, flush: options.flush !== false, notifications: new Set() }
|
||||
const exit = yield* restore(effect.pipe(Effect.provideService(CurrentBatch, batch))).pipe(Effect.exit)
|
||||
batch.active = false
|
||||
const notifications = batch.flush
|
||||
? yield* Effect.forEach(batch.notifications, (notify) => restore(notify()).pipe(Effect.exit))
|
||||
: []
|
||||
// Accepted writes are not rolled back: one failed observer must not hide
|
||||
// the other states' changes, or replace the batch body's failure.
|
||||
yield* Exit.asVoidAll([exit, ...notifications])
|
||||
return yield* exit
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export const inherit = Effect.fnUntraced(function* () {
|
||||
@@ -65,124 +73,110 @@ export interface Options<State, DraftApi> {
|
||||
readonly initial: () => State
|
||||
/** Wraps mutable state in a domain-specific draft API. */
|
||||
readonly draft: MakeDraft<State, DraftApi>
|
||||
/** Synchronously completes derived data after ordered transform replay. */
|
||||
readonly prepare?: (state: State) => void
|
||||
/**
|
||||
* Runs after the rebuilt state becomes visible. Update events published here
|
||||
* act as read barriers: subscribers refetching on the event observe the
|
||||
* committed state.
|
||||
* Observes accepted changes outside the read path. Batched writes notify at
|
||||
* batch completion; reloads debounce notifications. Reads never run this hook.
|
||||
* Resource reconciliation owns its execution scope and coordination.
|
||||
*/
|
||||
readonly finalize?: (draft: DraftApi) => Effect.Effect<void>
|
||||
readonly notify?: () => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export interface Interface<State, DraftApi> extends Transformable<DraftApi> {
|
||||
/** Returns the latest accepted state, replaying stale inputs synchronously. */
|
||||
readonly get: () => State
|
||||
}
|
||||
|
||||
export function create<State, DraftApi>(options: Options<State, DraftApi>): Interface<State, DraftApi> {
|
||||
let state = options.initial()
|
||||
let transforms: { run: TransformCallback<DraftApi> }[] = []
|
||||
let generation = 0
|
||||
const transforms = new Set<{ run: TransformCallback<DraftApi> }>()
|
||||
let dirty = false
|
||||
let requestedAt = 0
|
||||
let running = false
|
||||
let closed = false
|
||||
let waiters: { generation: number; done: Deferred.Deferred<void> }[] = []
|
||||
const semaphore = Semaphore.makeUnsafe(1)
|
||||
let pending: Deferred.Deferred<void> | undefined
|
||||
|
||||
const commit = Effect.fn("State.commit")(function* (next: State) {
|
||||
state = next
|
||||
if (options.finalize) yield* options.finalize(options.draft(next))
|
||||
})
|
||||
|
||||
const materialize = Effect.fnUntraced(function* () {
|
||||
if (closed) return
|
||||
const get = () => {
|
||||
if (!dirty || closed) return state
|
||||
const next = options.initial()
|
||||
const api = options.draft(next)
|
||||
for (const transform of transforms) {
|
||||
yield* Effect.sync(() => {
|
||||
transform.run(api)
|
||||
})
|
||||
}
|
||||
yield* commit(next)
|
||||
transforms.forEach((transform) => transform.run(api))
|
||||
options.prepare?.(next)
|
||||
state = next
|
||||
dirty = false
|
||||
return state
|
||||
}
|
||||
|
||||
const notify = Effect.fn("State.notify")(function* () {
|
||||
if (closed) return
|
||||
get()
|
||||
if (options.notify) yield* options.notify()
|
||||
})
|
||||
|
||||
const materializeReload = () => semaphore.withPermit(materialize())
|
||||
|
||||
const rebuild = (): Effect.Effect<void> =>
|
||||
const publish = (done: Deferred.Deferred<void>): Effect.Effect<void> =>
|
||||
Effect.gen(function* () {
|
||||
const clock = yield* Clock.Clock
|
||||
const remaining = requestedAt + reloadDebounce - clock.currentTimeMillisUnsafe()
|
||||
if (remaining > 0) yield* Effect.sleep(remaining)
|
||||
if (clock.currentTimeMillisUnsafe() < requestedAt + reloadDebounce) return yield* rebuild()
|
||||
if (clock.currentTimeMillisUnsafe() < requestedAt + reloadDebounce) return yield* publish(done)
|
||||
|
||||
const target = generation
|
||||
const exit = yield* materializeReload().pipe(Effect.exit)
|
||||
const completed = waiters.filter((waiter) => waiter.generation <= target)
|
||||
waiters = waiters.filter((waiter) => waiter.generation > target)
|
||||
yield* Effect.forEach(completed, (waiter) => Deferred.done(waiter.done, exit), {
|
||||
concurrency: "unbounded",
|
||||
discard: true,
|
||||
})
|
||||
if (generation > target) return yield* rebuild()
|
||||
running = false
|
||||
// Release scheduling ownership before observers run: an observer may
|
||||
// request and await another reload without joining this notification.
|
||||
pending = undefined
|
||||
yield* notify().pipe(Deferred.into(done))
|
||||
})
|
||||
|
||||
const reload = Effect.fnUntraced(function* () {
|
||||
if (closed) return
|
||||
const done = Deferred.makeUnsafe<void>()
|
||||
const clock = yield* Clock.Clock
|
||||
generation++
|
||||
requestedAt = clock.currentTimeMillisUnsafe()
|
||||
waiters.push({ generation, done })
|
||||
if (!running) {
|
||||
running = true
|
||||
yield* rebuild().pipe(Effect.forkDetach)
|
||||
}
|
||||
yield* Deferred.await(done)
|
||||
})
|
||||
const changed = (debounce: boolean) =>
|
||||
Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
if (closed) return
|
||||
if (debounce) dirty = true
|
||||
const batch = yield* CurrentBatch
|
||||
if (batch?.active) {
|
||||
if (!batch.flush) {
|
||||
closed = true
|
||||
return
|
||||
}
|
||||
batch.notifications.add(notify)
|
||||
return
|
||||
}
|
||||
if (!debounce) return yield* restore(notify())
|
||||
|
||||
const clock = yield* Clock.Clock
|
||||
requestedAt = clock.currentTimeMillisUnsafe()
|
||||
// No yields between choosing the burst's completion and claiming it.
|
||||
const done = pending ?? Deferred.makeUnsafe<void>()
|
||||
if (!pending) {
|
||||
pending = done
|
||||
yield* publish(done).pipe(Effect.forkDetach)
|
||||
}
|
||||
yield* restore(Deferred.await(done))
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
get: () => state,
|
||||
get,
|
||||
transform: Effect.fn("State.transform")(function* (update) {
|
||||
yield* Effect.annotateCurrentSpan("state", options.name ?? "anonymous")
|
||||
const scope = yield* Scope.Scope
|
||||
return yield* Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const transform = { run: update }
|
||||
let active = true
|
||||
const dispose = Effect.uninterruptible(
|
||||
semaphore.withPermit(
|
||||
Effect.suspend(() => {
|
||||
if (!active) return Effect.void
|
||||
active = false
|
||||
transforms = transforms.filter((item) => item !== transform)
|
||||
return Effect.gen(function* () {
|
||||
const batch = yield* CurrentBatch
|
||||
if (batch?.active) {
|
||||
// Detached debounced reloads must also stay quiet after teardown.
|
||||
if (!batch.flush) {
|
||||
closed = true
|
||||
return
|
||||
}
|
||||
batch.reloads.add(materializeReload)
|
||||
return
|
||||
}
|
||||
yield* materialize()
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
yield* semaphore.withPermit(
|
||||
Effect.sync(() => {
|
||||
transforms = [...transforms, transform]
|
||||
Effect.suspend(() => {
|
||||
if (!transforms.delete(transform)) return Effect.void
|
||||
dirty = true
|
||||
return changed(false)
|
||||
}),
|
||||
)
|
||||
transforms.add(transform)
|
||||
dirty = true
|
||||
yield* Scope.addFinalizer(scope, dispose)
|
||||
const batch = yield* CurrentBatch
|
||||
if (batch?.active) batch.reloads.add(materializeReload)
|
||||
else yield* materializeReload()
|
||||
yield* changed(false)
|
||||
return { dispose }
|
||||
}),
|
||||
)
|
||||
}),
|
||||
reload,
|
||||
reload: () => changed(true),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +185,7 @@ const layer = Layer.effect(
|
||||
draft.tools.delete(id)
|
||||
},
|
||||
}),
|
||||
finalize: () =>
|
||||
notify: () =>
|
||||
Effect.forEach(
|
||||
state.get().errors,
|
||||
({ tool, error }) =>
|
||||
|
||||
@@ -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 normalizes model content and images.
|
||||
- `src/tool.ts` stores canonical Location registrations, derives LLM definitions, executes tools, and applies generic output bounding.
|
||||
- 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. Generic output bounding is applied by the Session runner after execution.
|
||||
Built-ins return complete tool responses. `Tool.Snapshot.execute` is the local execution boundary.
|
||||
|
||||
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.
|
||||
Producer capture limits remain local to producers. For example, Bash keeps `AppProcess.maxOutputBytes` and accurately reports stdout/stderr capture loss.
|
||||
|
||||
## Current Gaps
|
||||
|
||||
|
||||
@@ -72,7 +72,6 @@ export const layer = Layer.effect(
|
||||
server: tool.server,
|
||||
name: tool.name,
|
||||
args: (input ?? {}) as Record<string, unknown>,
|
||||
sessionID: context.sessionID,
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchTags({
|
||||
|
||||
@@ -5,7 +5,9 @@ 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.name === name
|
||||
return (
|
||||
typeof error === "object" && error !== null && "name" in error && (error as Record<string, unknown>).name === name
|
||||
)
|
||||
}
|
||||
|
||||
static create<Name extends string, Fields extends Schema.Struct.Fields>(
|
||||
|
||||
+34
-10
@@ -1,7 +1,7 @@
|
||||
export * as Vcs from "./vcs.js"
|
||||
|
||||
import path from "path"
|
||||
import { Cause, Context, Effect, Layer, Schema, Stream } from "effect"
|
||||
import { Cause, Context, Effect, Exit, Fiber, FiberSet, Layer, Schema, Semaphore, Stream } from "effect"
|
||||
import type { VcsDefinition, VcsDraft } from "@opencode-ai/plugin/effect/vcs"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
@@ -47,8 +47,11 @@ const layer = Layer.effect(
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const bus = yield* Bus.Service
|
||||
const root = yield* Effect.scope
|
||||
const fork = yield* FiberSet.makeRuntime<never, void, never>()
|
||||
const vcs = location.vcs
|
||||
const current: { info: Info } = { info: { branch: {} } }
|
||||
const refreshLock = Semaphore.makeUnsafe(1)
|
||||
const scope = {
|
||||
directory: location.directory,
|
||||
worktree: location.project.directory,
|
||||
@@ -69,7 +72,12 @@ const layer = Layer.effect(
|
||||
set: (selection) => (draft.selection = selection),
|
||||
},
|
||||
}),
|
||||
finalize: () => refresh(),
|
||||
notify: () =>
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* Fiber.await(fork(refresh()))
|
||||
if (Exit.isFailure(exit) && root.state._tag === "Closed" && Cause.hasInterruptsOnly(exit.cause)) return
|
||||
yield* exit
|
||||
}),
|
||||
})
|
||||
const selected = () => {
|
||||
const value = state.get()
|
||||
@@ -87,13 +95,23 @@ const layer = Layer.effect(
|
||||
),
|
||||
)
|
||||
const refresh = Effect.fn("Vcs.refresh")(function* () {
|
||||
const provider = selected()
|
||||
const next: Info = provider
|
||||
? yield* protect(provider, "info", provider.info(scope).pipe(Effect.flatMap(decodeInfo)), { branch: {} })
|
||||
: { branch: {} }
|
||||
const changed = current.info.branch.current !== next.branch.current
|
||||
current.info = next
|
||||
if (changed) yield* bus.publish(VcsEvent.BranchUpdated, { branch: next.branch.current })
|
||||
const changed = yield* Effect.gen(function* () {
|
||||
const provider = selected()
|
||||
const next: Info = provider
|
||||
? yield* protect(provider, "info", provider.info(scope).pipe(Effect.flatMap(decodeInfo)), { branch: {} })
|
||||
: { branch: {} }
|
||||
const changed = current.info.branch.current !== next.branch.current
|
||||
current.info = next
|
||||
return changed
|
||||
}).pipe(refreshLock.withPermit)
|
||||
if (!changed) return
|
||||
// Legacy listeners can publish nested updates before streams and SSE receive
|
||||
// this event. Re-announce the latest branch if publication was overtaken.
|
||||
while (true) {
|
||||
const branch = current.info.branch.current
|
||||
yield* bus.publish(VcsEvent.BranchUpdated, { branch })
|
||||
if (branch === current.info.branch.current) return
|
||||
}
|
||||
})
|
||||
|
||||
if (vcs) {
|
||||
@@ -105,7 +123,13 @@ const layer = Layer.effect(
|
||||
yield* bus.subscribe(FileSystem.Event.Changed).pipe(
|
||||
Stream.filter((event) => isBranchMetadata(event.data.file)),
|
||||
Stream.runForEach((event) =>
|
||||
refresh().pipe(Effect.withSpan("Vcs.refreshBranch", { attributes: { file: event.data.file } })),
|
||||
refresh().pipe(
|
||||
Effect.catchCauseIf(
|
||||
(cause) => !Cause.hasInterrupts(cause),
|
||||
(cause) => Effect.logWarning("vcs refresh failed", { file: event.data.file, cause }),
|
||||
),
|
||||
Effect.withSpan("Vcs.refreshBranch", { attributes: { file: event.data.file } }),
|
||||
),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
|
||||
@@ -88,7 +88,7 @@ const layer = Layer.effect(
|
||||
set: (selection) => (draft.selection = selection),
|
||||
},
|
||||
}),
|
||||
finalize: () => bus.publish(WebSearch.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
notify: () => bus.publish(WebSearch.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
})
|
||||
|
||||
const requireProvider = (providers: Map<ID, ProviderImplementation>, providerID: ID) => {
|
||||
|
||||
@@ -375,100 +375,7 @@ it.effect("projects replay metadata onto AI SDK prompt parts", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
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", () =>
|
||||
it.effect("moves a tool image through the real Mistral provider as a user message", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
let body: { messages?: unknown[] } | undefined
|
||||
@@ -508,14 +415,7 @@ it.effect("normalizes user and tool media through the real Mistral provider", ()
|
||||
LLM.request({
|
||||
model: resolved,
|
||||
messages: [
|
||||
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.user("Inspect the screenshot."),
|
||||
Message.assistant({ type: "tool-call", id: "call_1", name: "screenshot", input: {} }),
|
||||
Message.tool({
|
||||
type: "tool-result",
|
||||
@@ -526,12 +426,6 @@ it.effect("normalizes user and tool media through the real Mistral provider", ()
|
||||
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",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
@@ -540,17 +434,7 @@ it.effect("normalizes user and tool media through the real Mistral provider", ()
|
||||
).pipe(Effect.provide(client))
|
||||
|
||||
expect(body?.messages).toEqual([
|
||||
{
|
||||
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: "user", content: [{ type: "text", text: "Inspect the screenshot." }] },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "",
|
||||
@@ -573,7 +457,6 @@ it.effect("normalizes user and tool media through the real Mistral provider", ()
|
||||
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" },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
@@ -85,8 +85,8 @@ describe("Bus Session routing", () => {
|
||||
projectID: Project.ID.global,
|
||||
})
|
||||
const done = yield* bus.publish(Done, {})
|
||||
expect(yield* Fiber.join(first)).toEqual([moved, done])
|
||||
expect(yield* Fiber.join(second)).toEqual([moved, after, same, done])
|
||||
expect(Array.from(yield* Fiber.join(first))).toEqual([moved, done])
|
||||
expect(Array.from(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((yield* Fiber.join(first)).map((event) => event.id)).toEqual([eventID, after.id, done.id])
|
||||
expect(yield* Fiber.join(second)).toEqual([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])
|
||||
}),
|
||||
)
|
||||
}),
|
||||
@@ -158,17 +158,19 @@ describe("Bus Session routing", () => {
|
||||
const explicit = yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID: id }, { location: b })
|
||||
const done = yield* bus.publish(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(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(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(
|
||||
history.filter((event): event is Event.Payload => !Bus.isSynced(event)).every((event) => !event.location),
|
||||
Array.from(history)
|
||||
.filter((event): event is Event.Payload => !Bus.isSynced(event))
|
||||
.every((event) => !event.location),
|
||||
).toBe(true)
|
||||
}),
|
||||
)
|
||||
@@ -195,8 +197,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(yield* Fiber.join(typed)).toEqual([expected])
|
||||
expect(yield* Fiber.join(multiple)).toEqual([expected, done])
|
||||
expect(Array.from(yield* Fiber.join(typed))).toEqual([expected])
|
||||
expect(Array.from(yield* Fiber.join(multiple))).toEqual([expected, done])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -224,8 +226,8 @@ describe("Bus Session routing", () => {
|
||||
const done = yield* bus.publish(Done, {})
|
||||
yield* Deferred.succeed(gate, undefined)
|
||||
|
||||
expect(yield* Fiber.join(first)).toEqual([created, before, moved, done])
|
||||
expect(yield* Fiber.join(second)).toEqual([moved, after, done])
|
||||
expect(Array.from(yield* Fiber.join(first))).toEqual([created, before, moved, done])
|
||||
expect(Array.from(yield* Fiber.join(second))).toEqual([moved, after, done])
|
||||
expect(moved).not.toHaveProperty("location")
|
||||
}),
|
||||
)
|
||||
@@ -243,9 +245,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(yield* Fiber.join(first)).toEqual([deleted, done])
|
||||
expect(yield* Fiber.join(second)).toEqual([done])
|
||||
expect(yield* Fiber.join(global)).toEqual([deleted, missing, done])
|
||||
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])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -264,8 +266,8 @@ describe("Bus Session routing", () => {
|
||||
])
|
||||
const done = yield* bus.publish(Done, {})
|
||||
yield* Deferred.succeed(gate, undefined)
|
||||
expect(yield* Fiber.join(first)).toEqual([events[0], events[1], done])
|
||||
expect(yield* Fiber.join(second)).toEqual([events[1], events[2], events[3], done])
|
||||
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])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -293,8 +295,8 @@ describe("Bus Session routing", () => {
|
||||
const done = yield* bus.publish(Done, {})
|
||||
expect(Exit.isFailure(single)).toBe(true)
|
||||
expect(Exit.isFailure(batch)).toBe(true)
|
||||
expect(yield* Fiber.join(first)).toEqual([before, after, done])
|
||||
expect(yield* Fiber.join(second)).toEqual([done])
|
||||
expect(Array.from(yield* Fiber.join(first))).toEqual([before, after, done])
|
||||
expect(Array.from(yield* Fiber.join(second))).toEqual([done])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -325,8 +327,8 @@ describe("Bus Session routing", () => {
|
||||
{ publish: true },
|
||||
)
|
||||
const done = yield* bus.publish(Done, {})
|
||||
expect(yield* Fiber.join(first)).toEqual([done])
|
||||
const received = yield* Fiber.join(second)
|
||||
expect(Array.from(yield* Fiber.join(first))).toEqual([done])
|
||||
const received = Array.from(yield* Fiber.join(second))
|
||||
expect(received.map((event) => event.id)).toEqual([after.id, replayID, done.id])
|
||||
expect(received[1]).not.toHaveProperty("location")
|
||||
}),
|
||||
|
||||
@@ -123,7 +123,7 @@ describe("Bus", () => {
|
||||
yield* bus.publish(Message, { text: "hello" })
|
||||
yield* bus.publish(CountMessage, { count: 2 })
|
||||
|
||||
const received = (yield* Fiber.join(fiber)).map((event) =>
|
||||
const received = Array.from(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 = yield* Fiber.join(fiber)
|
||||
const received = Array.from(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(yield* Fiber.join(typed)).toEqual([event])
|
||||
expect(yield* Fiber.join(wildcard)).toEqual([event])
|
||||
expect(Array.from(yield* Fiber.join(typed))).toEqual([event])
|
||||
expect(Array.from(yield* Fiber.join(wildcard))).toEqual([event])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -602,7 +602,7 @@ describe("Bus", () => {
|
||||
|
||||
yield* bus.publish(DurableMessage, durableData(aggregateID, "two"))
|
||||
|
||||
expect((yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
|
||||
expect(Array.from(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((yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
|
||||
expect(Array.from(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((yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
|
||||
expect(Array.from(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((yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual(
|
||||
expect(Array.from(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((yield* Fiber.join(fiber)).map((event) => event.type)).toEqual([DurableMessage.type])
|
||||
expect(Array.from(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 = yield* Stream.runCollect(bus.log({ aggregateID }))
|
||||
const items = Array.from(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 = yield* Stream.runCollect(bus.log({ aggregateID }))
|
||||
const empty = Array.from(yield* Stream.runCollect(bus.log({ aggregateID })))
|
||||
yield* bus.publish(DurableMessage, durableData(aggregateID, "zero"))
|
||||
const drained = yield* Stream.runCollect(bus.log({ aggregateID, after: 0 }))
|
||||
const drained = Array.from(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 = yield* Fiber.join(fiber)
|
||||
const items = Array.from(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 = yield* Stream.runCollect(bus.log({ aggregateID }))
|
||||
const items = Array.from(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 = yield* Fiber.join(fiber)
|
||||
const items = Array.from(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) },
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Location } from "@opencode-ai/core/location"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { State } from "@opencode-ai/core/state"
|
||||
import { location } from "./fixture/location"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
@@ -30,6 +31,57 @@ const catalogLayer = AppNodeBuilder.build(
|
||||
const it = testEffect(catalogLayer)
|
||||
|
||||
describe("Catalog", () => {
|
||||
it.effect("reads available and default models inside a batch before publishing", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const bus = yield* Bus.Service
|
||||
const observed: string[] = []
|
||||
const unsubscribe = yield* bus.listen((event) =>
|
||||
event.type === Catalog.Event.Updated.type
|
||||
? catalog.model.default().pipe(
|
||||
Effect.map((model) => {
|
||||
observed.push(model?.id ?? "none")
|
||||
}),
|
||||
)
|
||||
: Effect.void,
|
||||
)
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
const providerID = Provider.ID.make("test")
|
||||
const old = Model.ID.make("old")
|
||||
const newest = Model.ID.make("new")
|
||||
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* catalog.transform((draft) => {
|
||||
draft.provider.update(providerID, () => {})
|
||||
draft.model.update(providerID, old, (model) => {
|
||||
model.time.released = 1000
|
||||
})
|
||||
draft.model.update(providerID, newest, (model) => {
|
||||
model.time.released = 2000
|
||||
})
|
||||
draft.model.default.set(providerID, old)
|
||||
})
|
||||
expect((yield* catalog.model.available()).map((model) => model.id)).toEqual([newest, old])
|
||||
expect((yield* catalog.model.default())?.id).toBe(old)
|
||||
|
||||
const overlay = yield* catalog.transform((draft) =>
|
||||
draft.model.update(providerID, old, (model) => {
|
||||
model.enabled = false
|
||||
}),
|
||||
)
|
||||
expect((yield* catalog.model.available()).map((model) => model.id)).toEqual([newest])
|
||||
expect((yield* catalog.model.default())?.id).toBe(newest)
|
||||
yield* overlay.dispose
|
||||
expect((yield* catalog.model.default())?.id).toBe(old)
|
||||
expect(observed).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
expect(observed).toEqual([old])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("publishes an updated event after catalog changes", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
@@ -291,6 +343,7 @@ describe("Catalog", () => {
|
||||
|
||||
configured = false
|
||||
const reload = yield* catalog.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
expect((yield* catalog.model.default())?.id).toBe(newest)
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Fiber.join(reload)
|
||||
expect((yield* catalog.model.default())?.id).toBe(newest)
|
||||
|
||||
@@ -330,7 +330,10 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
||||
)
|
||||
|
||||
it.live("loads legacy file-based agents from config directories", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
@@ -404,7 +407,10 @@ Use native v2 fields.`,
|
||||
|
||||
for (const testCase of sourceCases()) {
|
||||
it.effect(`rebuilds agents when a source file is ${testCase.name}`, () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const directory = path.join(tmp.path, testCase.source)
|
||||
@@ -439,7 +445,10 @@ Use native v2 fields.`,
|
||||
}
|
||||
|
||||
it.effect("coalesces updates inside the debounce window into one rebuild", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const directory = path.join(tmp.path, "agents")
|
||||
@@ -476,7 +485,10 @@ Use native v2 fields.`,
|
||||
)
|
||||
|
||||
it.effect("ignores updates outside agent source directories", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const directory = path.join(tmp.path, "agents")
|
||||
|
||||
@@ -54,7 +54,10 @@ const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
describe("ConfigCommandPlugin.Plugin", () => {
|
||||
it.live("loads inline and file-based commands in config order", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
@@ -163,7 +166,10 @@ Review files`,
|
||||
|
||||
for (const testCase of sourceCases()) {
|
||||
it.effect(`rebuilds commands when a source file is ${testCase.name}`, () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const directory = path.join(tmp.path, "commands")
|
||||
@@ -206,7 +212,10 @@ Review files`,
|
||||
}
|
||||
|
||||
it.effect("coalesces updates inside the debounce window into one rebuild", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const directory = path.join(tmp.path, "commands")
|
||||
@@ -245,7 +254,10 @@ Review files`,
|
||||
)
|
||||
|
||||
it.effect("ignores updates outside command source directories", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const directory = path.join(tmp.path, "commands")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Fiber, Layer, Logger, Schema, Stream } from "effect"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Fiber, Layer, Logger, PubSub, Schema, Stream } from "effect"
|
||||
import { FastCheck } from "effect/testing"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { AgentsDirectory, Directory, Document, Event, Info } from "@opencode-ai/schema/config"
|
||||
@@ -77,7 +77,10 @@ const provider = {
|
||||
|
||||
describe("Config", () => {
|
||||
it.live("excludes home-level claude and agents directories when global is disabled", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) => {
|
||||
const global = path.join(tmp.path, "global")
|
||||
const home = path.join(global, "home")
|
||||
@@ -117,7 +120,10 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("excludes global config reached through the project walk when global is disabled", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) => {
|
||||
// The location sits BENEATH the global config dir, so the upward walk
|
||||
// reaches the global opencode.json as a direct file.
|
||||
@@ -150,7 +156,10 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("loads explicit file and content overrides in priority order", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) => {
|
||||
const global = path.join(tmp.path, "global")
|
||||
const project = path.join(tmp.path, "project")
|
||||
@@ -185,7 +194,10 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("skips project configuration when project discovery is disabled", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) => {
|
||||
const global = path.join(tmp.path, "global")
|
||||
const project = path.join(tmp.path, "project")
|
||||
@@ -213,7 +225,10 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("reloads external config and publishes directory updates", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const global = path.join(tmp.path, "global")
|
||||
@@ -246,7 +261,10 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("exposes filesystem updates under config roots through changes", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const global = path.join(tmp.path, "global")
|
||||
@@ -278,7 +296,10 @@ describe("Config", () => {
|
||||
// watch being torn down, making recreation invisible) only reproduces with
|
||||
// path-faithful event delivery.
|
||||
it.live("keeps watching a deleted config file so recreating it reloads", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const global = path.join(tmp.path, "global")
|
||||
@@ -348,24 +369,26 @@ describe("Config", () => {
|
||||
}).pipe(Effect.provide(Config.testLayer())),
|
||||
)
|
||||
|
||||
test("returns the latest defined scalar from priority-ordered documents", () => {
|
||||
const entries = [
|
||||
new Document({
|
||||
type: "document",
|
||||
info: new Info({ model: selection("openrouter/openai/gpt-5") }),
|
||||
}),
|
||||
new Directory({ type: "directory", path: AbsolutePath.make("/skills") }),
|
||||
new AgentsDirectory({ type: "agents", path: AbsolutePath.make("/agents") }),
|
||||
new Document({ type: "document", info: new Info({}) }),
|
||||
new Document({
|
||||
type: "document",
|
||||
info: new Info({ model: selection("openrouter/openai/gpt-5.5") }),
|
||||
}),
|
||||
]
|
||||
it.effect("returns the latest defined scalar from priority-ordered documents", () =>
|
||||
Effect.sync(() => {
|
||||
const entries = [
|
||||
new Document({
|
||||
type: "document",
|
||||
info: new Info({ model: selection("openrouter/openai/gpt-5") }),
|
||||
}),
|
||||
new Directory({ type: "directory", path: AbsolutePath.make("/skills") }),
|
||||
new AgentsDirectory({ type: "agents", path: AbsolutePath.make("/agents") }),
|
||||
new Document({ type: "document", info: new Info({}) }),
|
||||
new Document({
|
||||
type: "document",
|
||||
info: new Info({ model: selection("openrouter/openai/gpt-5.5") }),
|
||||
}),
|
||||
]
|
||||
|
||||
expect(Config.latest(entries, "model")).toEqual(selection("openrouter/openai/gpt-5.5"))
|
||||
expect(Config.latest(entries, "default_agent")).toBeUndefined()
|
||||
})
|
||||
expect(Config.latest(entries, "model")).toEqual(selection("openrouter/openai/gpt-5.5"))
|
||||
expect(Config.latest(entries, "default_agent")).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("tolerates unavailable authenticated wellknown config and reloads it later", () =>
|
||||
Effect.acquireUseRelease(
|
||||
@@ -557,241 +580,268 @@ describe("Config", () => {
|
||||
).pipe(Effect.provide(Logger.layer([logger])))
|
||||
})
|
||||
|
||||
test("migrates arbitrary v1 configuration into valid v2 configuration", () => {
|
||||
FastCheck.assert(
|
||||
FastCheck.property(Schema.toArbitrary(ConfigV1.Info)(FastCheck), (info) => {
|
||||
const parsed = Schema.decodeUnknownSync(ConfigV1.Info)(
|
||||
Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(
|
||||
Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(info),
|
||||
),
|
||||
)
|
||||
Schema.decodeUnknownSync(Info)(ConfigMigrateV1.migrate(parsed), { errors: "all" })
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
)
|
||||
}, 30_000)
|
||||
it.effect("migrates arbitrary v1 configuration into valid v2 configuration", () =>
|
||||
Effect.sync(() => {
|
||||
FastCheck.assert(
|
||||
FastCheck.property(Schema.toArbitrary(ConfigV1.Info)(FastCheck), (info) => {
|
||||
const parsed = Schema.decodeUnknownSync(ConfigV1.Info)(
|
||||
Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(
|
||||
Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(info),
|
||||
),
|
||||
)
|
||||
Schema.decodeUnknownSync(Info)(ConfigMigrateV1.migrate(parsed), { errors: "all" })
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
test("migrates the v1 experimental subagent depth", () => {
|
||||
expect(ConfigMigrateV1.migrate({ experimental: { subagent_depth: 2 } }).experimental?.subagent_depth).toBe(2)
|
||||
})
|
||||
it.effect("migrates the v1 experimental subagent depth", () =>
|
||||
Effect.sync(() => {
|
||||
expect(ConfigMigrateV1.migrate({ experimental: { subagent_depth: 2 } }).experimental?.subagent_depth).toBe(2)
|
||||
}),
|
||||
)
|
||||
|
||||
test("migrates the v1 small model to the title agent", () => {
|
||||
expect(
|
||||
ConfigMigrateV1.migrate({
|
||||
small_model: "anthropic/claude-haiku-4-5",
|
||||
agent: { title: { prompt: "Custom title prompt" } },
|
||||
}).agents?.title,
|
||||
).toEqual({
|
||||
model: { providerID: "anthropic", model: "claude-haiku-4-5" },
|
||||
system: "Custom title prompt",
|
||||
})
|
||||
})
|
||||
it.effect("migrates the v1 small model to the title agent", () =>
|
||||
Effect.sync(() => {
|
||||
expect(
|
||||
ConfigMigrateV1.migrate({
|
||||
small_model: "anthropic/claude-haiku-4-5",
|
||||
agent: { title: { prompt: "Custom title prompt" } },
|
||||
}).agents?.title,
|
||||
).toEqual({
|
||||
model: { providerID: "anthropic", model: "claude-haiku-4-5" },
|
||||
system: "Custom title prompt",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
test("migrates v1 provider lists to policies", () => {
|
||||
expect(
|
||||
ConfigMigrateV1.migrate({
|
||||
enabled_providers: ["anthropic", "openai"],
|
||||
disabled_providers: ["openai"],
|
||||
}).experimental?.policies,
|
||||
).toEqual([
|
||||
{ action: "provider.use", resource: "*", effect: "deny" },
|
||||
{ action: "provider.use", resource: "anthropic", effect: "allow" },
|
||||
{ action: "provider.use", resource: "openai", effect: "allow" },
|
||||
{ action: "provider.use", resource: "openai", effect: "deny" },
|
||||
])
|
||||
expect(ConfigMigrateV1.migrate({ enabled_providers: [] }).experimental?.policies).toEqual([
|
||||
{ action: "provider.use", resource: "*", effect: "deny" },
|
||||
])
|
||||
})
|
||||
it.effect("migrates v1 provider lists to policies", () =>
|
||||
Effect.sync(() => {
|
||||
expect(
|
||||
ConfigMigrateV1.migrate({
|
||||
enabled_providers: ["anthropic", "openai"],
|
||||
disabled_providers: ["openai"],
|
||||
}).experimental?.policies,
|
||||
).toEqual([
|
||||
{ action: "provider.use", resource: "*", effect: "deny" },
|
||||
{ action: "provider.use", resource: "anthropic", effect: "allow" },
|
||||
{ action: "provider.use", resource: "openai", effect: "allow" },
|
||||
{ action: "provider.use", resource: "openai", effect: "deny" },
|
||||
])
|
||||
expect(ConfigMigrateV1.migrate({ enabled_providers: [] }).experimental?.policies).toEqual([
|
||||
{ action: "provider.use", resource: "*", effect: "deny" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
test("migrates v1 provider setup options into AISDK settings", () => {
|
||||
const migrated = ConfigMigrateV1.migrate({
|
||||
provider: {
|
||||
bedrock: {
|
||||
npm: "@ai-sdk/amazon-bedrock",
|
||||
models: { claude: { provider: { npm: "@ai-sdk/anthropic" } } },
|
||||
options: {
|
||||
headers: { "x-test": "1" },
|
||||
body: { trace: true },
|
||||
region: "us-east-1",
|
||||
profile: "dev",
|
||||
it.effect("migrates v1 provider setup options into AISDK settings", () =>
|
||||
Effect.sync(() => {
|
||||
const migrated = ConfigMigrateV1.migrate({
|
||||
provider: {
|
||||
bedrock: {
|
||||
npm: "@ai-sdk/amazon-bedrock",
|
||||
models: { claude: { provider: { npm: "@ai-sdk/anthropic" } } },
|
||||
options: {
|
||||
headers: { "x-test": "1" },
|
||||
body: { trace: true },
|
||||
region: "us-east-1",
|
||||
profile: "dev",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
expect(migrated.providers?.bedrock).toMatchObject({
|
||||
package: Provider.aisdk("@ai-sdk/amazon-bedrock"),
|
||||
models: { claude: { package: Provider.aisdk("@ai-sdk/anthropic") } },
|
||||
settings: { region: "us-east-1", profile: "dev" },
|
||||
headers: { "x-test": "1" },
|
||||
body: { trace: true },
|
||||
})
|
||||
})
|
||||
expect(migrated.providers?.bedrock).toMatchObject({
|
||||
package: Provider.aisdk("@ai-sdk/amazon-bedrock"),
|
||||
models: { claude: { package: Provider.aisdk("@ai-sdk/anthropic") } },
|
||||
settings: { region: "us-east-1", profile: "dev" },
|
||||
headers: { "x-test": "1" },
|
||||
body: { trace: true },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
test("renames old provider IDs while migrating v1 configuration", () => {
|
||||
const migrated = ConfigMigrateV1.migrate({
|
||||
model: "azure-cognitive-services/deployment",
|
||||
enabled_providers: ["google-vertex-anthropic"],
|
||||
disabled_providers: ["azure-cognitive-services"],
|
||||
agent: {
|
||||
reviewer: { model: "google-vertex-anthropic/claude-sonnet" },
|
||||
},
|
||||
command: {
|
||||
review: { template: "Review", model: "azure-cognitive-services/deployment" },
|
||||
},
|
||||
provider: {
|
||||
"azure-cognitive-services": {
|
||||
npm: "@ai-sdk/azure",
|
||||
env: ["AZURE_COGNITIVE_SERVICES_RESOURCE_NAME", "AZURE_COGNITIVE_SERVICES_API_KEY"],
|
||||
models: { deployment: {} },
|
||||
it.effect("renames old provider IDs while migrating v1 configuration", () =>
|
||||
Effect.sync(() => {
|
||||
const migrated = ConfigMigrateV1.migrate({
|
||||
model: "azure-cognitive-services/deployment",
|
||||
enabled_providers: ["google-vertex-anthropic"],
|
||||
disabled_providers: ["azure-cognitive-services"],
|
||||
agent: {
|
||||
reviewer: { model: "google-vertex-anthropic/claude-sonnet" },
|
||||
},
|
||||
"google-vertex-anthropic": {
|
||||
npm: "@ai-sdk/google-vertex/anthropic",
|
||||
options: { project: "test-project", location: "us-central1" },
|
||||
models: { "claude-sonnet": {} },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(migrated.model).toEqual({ providerID: "azure", model: "deployment" })
|
||||
expect(migrated.agents?.reviewer?.model).toEqual({ providerID: "google-vertex", model: "claude-sonnet" })
|
||||
expect(migrated.commands?.review?.model).toEqual({ providerID: "azure", model: "deployment" })
|
||||
expect(migrated.experimental?.policies).toEqual([
|
||||
{ action: "provider.use", resource: "*", effect: "deny" },
|
||||
{ action: "provider.use", resource: "google-vertex", effect: "allow" },
|
||||
{ action: "provider.use", resource: "azure", effect: "deny" },
|
||||
])
|
||||
expect(migrated.providers?.azure).toMatchObject({
|
||||
env: ["AZURE_COGNITIVE_SERVICES_API_KEY"],
|
||||
package: Provider.aisdk("@ai-sdk/azure"),
|
||||
models: { deployment: {} },
|
||||
})
|
||||
expect(migrated.providers?.["azure-cognitive-services"]).toBeUndefined()
|
||||
expect(migrated.providers?.["google-vertex"]).toMatchObject({
|
||||
settings: { project: "test-project", location: "us-central1" },
|
||||
models: {
|
||||
"claude-sonnet": { package: Provider.aisdk("@ai-sdk/google-vertex/anthropic") },
|
||||
},
|
||||
})
|
||||
expect(migrated.providers?.["google-vertex"]).not.toHaveProperty("package")
|
||||
expect(migrated.providers?.["google-vertex-anthropic"]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("preserves the generated base URL for v1 Azure OpenAI-compatible providers", () => {
|
||||
const migrated = ConfigMigrateV1.migrate({
|
||||
provider: {
|
||||
"azure-cognitive-services": {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
env: ["AZURE_COGNITIVE_SERVICES_RESOURCE_NAME", "AZURE_COGNITIVE_SERVICES_API_KEY"],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(migrated.providers?.azure).toMatchObject({
|
||||
env: ["AZURE_COGNITIVE_SERVICES_API_KEY"],
|
||||
package: Provider.aisdk("@ai-sdk/openai-compatible"),
|
||||
settings: {
|
||||
baseURL: "https://${AZURE_COGNITIVE_SERVICES_RESOURCE_NAME}.cognitiveservices.azure.com/openai",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("ignores old provider IDs when the current provider ID is configured", () => {
|
||||
const migrated = ConfigMigrateV1.migrate({
|
||||
provider: {
|
||||
azure: { models: { current: {} } },
|
||||
"azure-cognitive-services": { models: { legacy: {} } },
|
||||
"google-vertex": { models: { gemini: {} } },
|
||||
"google-vertex-anthropic": { models: { claude: {} } },
|
||||
},
|
||||
})
|
||||
|
||||
expect(migrated.providers?.azure?.models).toEqual({ current: expect.anything() })
|
||||
expect(migrated.providers?.["google-vertex"]?.models).toEqual({ gemini: expect.anything() })
|
||||
})
|
||||
|
||||
test("preserves the built-in package for v1 Vertex Anthropic custom models", () => {
|
||||
const migrated = ConfigMigrateV1.migrate({
|
||||
provider: {
|
||||
"google-vertex-anthropic": {
|
||||
models: { claude: {} },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(migrated.providers?.["google-vertex"]?.package).toBeUndefined()
|
||||
expect(migrated.providers?.["google-vertex"]?.models?.claude?.package).toBe(
|
||||
Provider.aisdk("@ai-sdk/google-vertex/anthropic"),
|
||||
)
|
||||
})
|
||||
|
||||
test("migrates v1 interleaved fields to compatibility", () => {
|
||||
const migrated = ConfigMigrateV1.migrate({
|
||||
provider: {
|
||||
custom: {
|
||||
models: {
|
||||
object: { interleaved: { field: "vendor_reasoning" } },
|
||||
string: { interleaved: "reasoning_text" },
|
||||
boolean: { interleaved: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(migrated.providers?.custom?.models?.object?.compatibility).toEqual({
|
||||
reasoningField: "vendor_reasoning",
|
||||
})
|
||||
expect(migrated.providers?.custom?.models?.string?.compatibility).toEqual({ reasoningField: "reasoning_text" })
|
||||
expect(migrated.providers?.custom?.models?.boolean?.compatibility).toBeUndefined()
|
||||
})
|
||||
|
||||
test("migrates v1 command configuration", () => {
|
||||
expect(
|
||||
ConfigMigrateV1.migrate({
|
||||
command: {
|
||||
review: {
|
||||
template: "Review changes",
|
||||
description: "Review code",
|
||||
agent: "reviewer",
|
||||
model: "anthropic/claude",
|
||||
variant: "high",
|
||||
subtask: true,
|
||||
review: { template: "Review", model: "azure-cognitive-services/deployment" },
|
||||
},
|
||||
provider: {
|
||||
"azure-cognitive-services": {
|
||||
npm: "@ai-sdk/azure",
|
||||
env: ["AZURE_COGNITIVE_SERVICES_RESOURCE_NAME", "AZURE_COGNITIVE_SERVICES_API_KEY"],
|
||||
models: { deployment: {} },
|
||||
},
|
||||
"google-vertex-anthropic": {
|
||||
npm: "@ai-sdk/google-vertex/anthropic",
|
||||
options: { project: "test-project", location: "us-central1" },
|
||||
models: { "claude-sonnet": {} },
|
||||
},
|
||||
},
|
||||
}).commands,
|
||||
).toEqual({
|
||||
review: {
|
||||
template: "Review changes",
|
||||
description: "Review code",
|
||||
agent: "reviewer",
|
||||
model: { providerID: "anthropic", model: "claude", variant: "high" },
|
||||
subtask: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test("normalizes renamed permission actions when migrating v1 permissions", () => {
|
||||
expect(
|
||||
ConfigMigrateV1.migrate({
|
||||
permission: {
|
||||
task: "ask",
|
||||
bash: { "git status": "allow", "*": "deny" },
|
||||
write: "deny",
|
||||
read: "allow",
|
||||
expect(migrated.model).toEqual({ providerID: "azure", model: "deployment" })
|
||||
expect(migrated.agents?.reviewer?.model).toEqual({ providerID: "google-vertex", model: "claude-sonnet" })
|
||||
expect(migrated.commands?.review?.model).toEqual({ providerID: "azure", model: "deployment" })
|
||||
expect(migrated.experimental?.policies).toEqual([
|
||||
{ action: "provider.use", resource: "*", effect: "deny" },
|
||||
{ action: "provider.use", resource: "google-vertex", effect: "allow" },
|
||||
{ action: "provider.use", resource: "azure", effect: "deny" },
|
||||
])
|
||||
expect(migrated.providers?.azure).toMatchObject({
|
||||
env: ["AZURE_COGNITIVE_SERVICES_API_KEY"],
|
||||
package: Provider.aisdk("@ai-sdk/azure"),
|
||||
models: { deployment: {} },
|
||||
})
|
||||
expect(migrated.providers?.["azure-cognitive-services"]).toBeUndefined()
|
||||
expect(migrated.providers?.["google-vertex"]).toMatchObject({
|
||||
settings: { project: "test-project", location: "us-central1" },
|
||||
models: {
|
||||
"claude-sonnet": { package: Provider.aisdk("@ai-sdk/google-vertex/anthropic") },
|
||||
},
|
||||
}).permissions,
|
||||
).toEqual([
|
||||
{ action: "subagent", resource: "*", effect: "ask" },
|
||||
{ action: "shell", resource: "git status", effect: "allow" },
|
||||
{ action: "shell", resource: "*", effect: "deny" },
|
||||
{ action: "edit", resource: "*", effect: "deny" },
|
||||
{ action: "read", resource: "*", effect: "allow" },
|
||||
])
|
||||
})
|
||||
})
|
||||
expect(migrated.providers?.["google-vertex"]).not.toHaveProperty("package")
|
||||
expect(migrated.providers?.["google-vertex-anthropic"]).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves the generated base URL for v1 Azure OpenAI-compatible providers", () =>
|
||||
Effect.sync(() => {
|
||||
const migrated = ConfigMigrateV1.migrate({
|
||||
provider: {
|
||||
"azure-cognitive-services": {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
env: ["AZURE_COGNITIVE_SERVICES_RESOURCE_NAME", "AZURE_COGNITIVE_SERVICES_API_KEY"],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(migrated.providers?.azure).toMatchObject({
|
||||
env: ["AZURE_COGNITIVE_SERVICES_API_KEY"],
|
||||
package: Provider.aisdk("@ai-sdk/openai-compatible"),
|
||||
settings: {
|
||||
baseURL: "https://${AZURE_COGNITIVE_SERVICES_RESOURCE_NAME}.cognitiveservices.azure.com/openai",
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores old provider IDs when the current provider ID is configured", () =>
|
||||
Effect.sync(() => {
|
||||
const migrated = ConfigMigrateV1.migrate({
|
||||
provider: {
|
||||
azure: { models: { current: {} } },
|
||||
"azure-cognitive-services": { models: { legacy: {} } },
|
||||
"google-vertex": { models: { gemini: {} } },
|
||||
"google-vertex-anthropic": { models: { claude: {} } },
|
||||
},
|
||||
})
|
||||
|
||||
expect(migrated.providers?.azure?.models).toEqual({ current: expect.anything() })
|
||||
expect(migrated.providers?.["google-vertex"]?.models).toEqual({ gemini: expect.anything() })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves the built-in package for v1 Vertex Anthropic custom models", () =>
|
||||
Effect.sync(() => {
|
||||
const migrated = ConfigMigrateV1.migrate({
|
||||
provider: {
|
||||
"google-vertex-anthropic": {
|
||||
models: { claude: {} },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(migrated.providers?.["google-vertex"]?.package).toBeUndefined()
|
||||
expect(migrated.providers?.["google-vertex"]?.models?.claude?.package).toBe(
|
||||
Provider.aisdk("@ai-sdk/google-vertex/anthropic"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("migrates v1 interleaved fields to compatibility", () =>
|
||||
Effect.sync(() => {
|
||||
const migrated = ConfigMigrateV1.migrate({
|
||||
provider: {
|
||||
custom: {
|
||||
models: {
|
||||
object: { interleaved: { field: "vendor_reasoning" } },
|
||||
string: { interleaved: "reasoning_text" },
|
||||
boolean: { interleaved: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(migrated.providers?.custom?.models?.object?.compatibility).toEqual({
|
||||
reasoningField: "vendor_reasoning",
|
||||
})
|
||||
expect(migrated.providers?.custom?.models?.string?.compatibility).toEqual({ reasoningField: "reasoning_text" })
|
||||
expect(migrated.providers?.custom?.models?.boolean?.compatibility).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("migrates v1 command configuration", () =>
|
||||
Effect.sync(() => {
|
||||
expect(
|
||||
ConfigMigrateV1.migrate({
|
||||
command: {
|
||||
review: {
|
||||
template: "Review changes",
|
||||
description: "Review code",
|
||||
agent: "reviewer",
|
||||
model: "anthropic/claude",
|
||||
variant: "high",
|
||||
subtask: true,
|
||||
},
|
||||
},
|
||||
}).commands,
|
||||
).toEqual({
|
||||
review: {
|
||||
template: "Review changes",
|
||||
description: "Review code",
|
||||
agent: "reviewer",
|
||||
model: { providerID: "anthropic", model: "claude", variant: "high" },
|
||||
subtask: true,
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("normalizes renamed permission actions when migrating v1 permissions", () =>
|
||||
Effect.sync(() => {
|
||||
expect(
|
||||
ConfigMigrateV1.migrate({
|
||||
permission: {
|
||||
task: "ask",
|
||||
bash: { "git status": "allow", "*": "deny" },
|
||||
write: "deny",
|
||||
read: "allow",
|
||||
},
|
||||
}).permissions,
|
||||
).toEqual([
|
||||
{ action: "subagent", resource: "*", effect: "ask" },
|
||||
{ action: "shell", resource: "git status", effect: "allow" },
|
||||
{ action: "shell", resource: "*", effect: "deny" },
|
||||
{ action: "edit", resource: "*", effect: "deny" },
|
||||
{ action: "read", resource: "*", effect: "allow" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("returns an empty configuration when directory files do not exist", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
@@ -806,7 +856,10 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("deduplicates global ecosystem directories found during upward discovery", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const global = path.join(tmp.path, "global")
|
||||
@@ -835,7 +888,10 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("does not watch ecosystem config roots", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
@@ -863,7 +919,10 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("loads opencode JSON and JSONC files from lowest to highest priority", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
@@ -974,7 +1033,10 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("does not load legacy config.json files", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
@@ -993,7 +1055,10 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("accepts $schema metadata without writing it into config files", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const file = path.join(tmp.path, "opencode.json")
|
||||
@@ -1017,7 +1082,10 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("loads supported scalar and resource configuration", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
@@ -1203,7 +1271,10 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("migrates the deprecated reference key into references", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
@@ -1236,7 +1307,10 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("migrates v1 configuration when a v1-only key is present", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
@@ -1408,7 +1482,10 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("ignores an invalid file while loading valid config values", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
@@ -1434,7 +1511,10 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("loads global and ancestor configuration across the project boundary", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) => {
|
||||
const global = path.join(tmp.path, "global")
|
||||
const root = path.join(tmp.path, "repo")
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
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)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -2,15 +2,13 @@ import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { ConfigPluginSource } from "@opencode-ai/core/config/plugin/source"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
@@ -20,7 +18,7 @@ import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Effect, Fiber, Layer, Logger, Schedule, Stream } from "effect"
|
||||
import { Effect, Fiber, Logger, Stream } from "effect"
|
||||
import { Database } from "../../src/database/database"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import { tempGlobalLayer } from "../fixture/global"
|
||||
@@ -37,40 +35,6 @@ const staticIt = testEffect(
|
||||
[Global.node, tempGlobalLayer],
|
||||
]),
|
||||
)
|
||||
const refreshNpm = makeGlobalNode({
|
||||
service: Npm.Service,
|
||||
layer: Layer.effect(
|
||||
Npm.Service,
|
||||
Effect.gen(function* () {
|
||||
const global = yield* Global.Service
|
||||
const directory = path.join(global.tmp, "background-refresh-plugin")
|
||||
const installed = { directory, entrypoint: pathToFileURL(path.join(directory, "index.js")).href }
|
||||
return Npm.Service.of({
|
||||
add: (_pkg, options) =>
|
||||
options?.refresh
|
||||
? Effect.gen(function* () {
|
||||
yield* Effect.promise(() => Bun.write(path.join(directory, "refresh-requested"), ""))
|
||||
yield* waitForFile(path.join(directory, "refresh-release")).pipe(Effect.orDie)
|
||||
yield* Effect.promise(() => Bun.write(path.join(directory, "refresh-finished"), ""))
|
||||
return installed
|
||||
})
|
||||
: Effect.succeed(installed),
|
||||
resolve: () => Effect.succeed(installed),
|
||||
which: () => Effect.succeed(undefined),
|
||||
})
|
||||
}),
|
||||
),
|
||||
deps: [Global.node],
|
||||
})
|
||||
const refreshIt = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node, Global.node]),
|
||||
[
|
||||
[Global.node, tempGlobalLayer],
|
||||
[Npm.node, refreshNpm],
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
describe("PluginSupervisor config", () => {
|
||||
it.live("applies selectors in order", () =>
|
||||
@@ -87,6 +51,7 @@ describe("PluginSupervisor config", () => {
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("allows the built-in Plan agent to be disabled", () =>
|
||||
withLocation(
|
||||
{ agents: { plan: { disabled: true } } },
|
||||
@@ -305,7 +270,7 @@ describe("PluginSupervisor config", () => {
|
||||
staticIt.live("uses only internal and SDK plugins when the static source is wired", () =>
|
||||
Effect.gen(function* () {
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
yield* sdk.register(define({ id: "static-sdk", effect: () => Effect.void }))
|
||||
yield* sdk.register(EffectPlugin.define({ id: "static-sdk", effect: () => Effect.void }))
|
||||
yield* withLocation(
|
||||
{ plugins: ["-*", path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts")] },
|
||||
Effect.gen(function* () {
|
||||
@@ -415,7 +380,7 @@ describe("PluginSupervisor config", () => {
|
||||
it.live("loads user plugins before internal post plugins", () =>
|
||||
Effect.gen(function* () {
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
yield* sdk.register(define({ id: "sdk-order", effect: () => Effect.void }))
|
||||
yield* sdk.register(EffectPlugin.define({ id: "sdk-order", effect: () => Effect.void }))
|
||||
yield* withLocation(
|
||||
{
|
||||
plugins: [
|
||||
@@ -466,8 +431,8 @@ describe("PluginSupervisor config", () => {
|
||||
it.live("unblocks flush when plugin activation fails", () =>
|
||||
Effect.gen(function* () {
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
yield* sdk.register(define({ id: "duplicate-id", effect: () => Effect.void }))
|
||||
yield* sdk.register(define({ id: "duplicate-id", effect: () => Effect.void }))
|
||||
yield* sdk.register(EffectPlugin.define({ id: "duplicate-id", effect: () => Effect.void }))
|
||||
yield* sdk.register(EffectPlugin.define({ id: "duplicate-id", effect: () => Effect.void }))
|
||||
yield* withLocation(
|
||||
undefined,
|
||||
Effect.gen(function* () {
|
||||
@@ -476,47 +441,6 @@ describe("PluginSupervisor config", () => {
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
refreshIt.live("refreshes active package plugins after setup without blocking flush", () =>
|
||||
Effect.gen(function* () {
|
||||
const global = yield* Global.Service
|
||||
const directory = path.join(global.tmp, "background-refresh-plugin")
|
||||
const activated = path.join(directory, "activated")
|
||||
const release = path.join(directory, "release")
|
||||
const refreshed = path.join(directory, "refresh-requested")
|
||||
const refreshRelease = path.join(directory, "refresh-release")
|
||||
const refreshFinished = path.join(directory, "refresh-finished")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(directory, { recursive: true })
|
||||
await fs.writeFile(
|
||||
path.join(directory, "index.js"),
|
||||
`export default {
|
||||
id: "background-refresh-plugin",
|
||||
async setup() {
|
||||
await Bun.write(${JSON.stringify(activated)}, "")
|
||||
while (!(await Bun.file(${JSON.stringify(release)}).exists())) await Bun.sleep(10)
|
||||
},
|
||||
}`,
|
||||
)
|
||||
})
|
||||
|
||||
yield* withLocation(
|
||||
{ plugins: ["background-refresh-plugin"] },
|
||||
Effect.gen(function* () {
|
||||
yield* waitForFile(activated)
|
||||
yield* Effect.sleep("100 millis")
|
||||
expect(yield* Effect.promise(() => Bun.file(refreshed).exists())).toBeFalse()
|
||||
yield* Effect.promise(() => Bun.write(release, ""))
|
||||
yield* waitForFile(refreshed)
|
||||
yield* ready().pipe(Effect.timeout("2 seconds"))
|
||||
yield* Effect.promise(() => Bun.write(refreshRelease, ""))
|
||||
yield* waitForFile(refreshFinished)
|
||||
const plugins = yield* Plugin.Service
|
||||
expect((yield* plugins.list()).map((plugin) => String(plugin.id))).toContain("background-refresh-plugin")
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const ready = Effect.fnUntraced(function* () {
|
||||
@@ -524,20 +448,16 @@ const ready = Effect.fnUntraced(function* () {
|
||||
yield* supervisor.flush
|
||||
})
|
||||
|
||||
const waitForFile = (file: string) =>
|
||||
Effect.promise(() => Bun.file(file).exists()).pipe(
|
||||
Effect.filterOrFail((exists) => exists),
|
||||
Effect.retry({ times: 200, schedule: Schedule.spaced("10 millis") }),
|
||||
Effect.timeout("2 seconds"),
|
||||
)
|
||||
|
||||
function withLocation<A, E, R>(
|
||||
config: unknown,
|
||||
effect: Effect.Effect<A, E, R>,
|
||||
fixtures = false,
|
||||
prepare?: (directory: string) => Promise<void>,
|
||||
) {
|
||||
return Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
return Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.tap((tmp) =>
|
||||
Effect.promise(async () => {
|
||||
await prepare?.(tmp.path)
|
||||
|
||||
@@ -34,41 +34,6 @@ 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
|
||||
@@ -137,14 +102,6 @@ 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)
|
||||
}
|
||||
|
||||
@@ -15,14 +15,6 @@ const users = sqliteTable("users", {
|
||||
id: integer().primaryKey({ autoIncrement: true }),
|
||||
name: text().notNull(),
|
||||
})
|
||||
const teams = sqliteTable("teams", {
|
||||
id: integer().primaryKey(),
|
||||
name: text().notNull(),
|
||||
})
|
||||
const memberships = sqliteTable("memberships", {
|
||||
user_id: integer().notNull(),
|
||||
team_id: integer().notNull(),
|
||||
})
|
||||
|
||||
const run = <A, E>(effect: Effect.Effect<A, E, SqlClient>) =>
|
||||
Effect.runPromise(
|
||||
@@ -171,41 +163,3 @@ test("supports returning and rejects empty update sets", async () => {
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("supports function-valued update joins with runtime table columns", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
const query = db
|
||||
.update(users)
|
||||
.set({ name: "Grace" })
|
||||
.from(teams)
|
||||
.innerJoin(memberships, (update) => eq(update.id, memberships.user_id))
|
||||
.where(eq(teams.name, "Core"))
|
||||
|
||||
expect(query.toSQL()).toEqual({
|
||||
sql: 'update "users" set "name" = ? from "teams" inner join "memberships" on "users"."id" = "memberships"."user_id" where "teams"."name" = ?',
|
||||
params: ["Grace", "Core"],
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("supports SQL-valued update joins", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
const query = db
|
||||
.update(users)
|
||||
.set({ name: "Lin" })
|
||||
.from(teams)
|
||||
.innerJoin(memberships, eq(users.id, memberships.user_id))
|
||||
.where(eq(teams.name, "Core"))
|
||||
|
||||
expect(query.toSQL()).toEqual({
|
||||
sql: 'update "users" set "name" = ? from "teams" inner join "memberships" on "users"."id" = "memberships"."user_id" where "teams"."name" = ?',
|
||||
params: ["Lin", "Core"],
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -16,7 +16,18 @@ function js(code: string, opts?: ChildProcess.CommandOptions) {
|
||||
}
|
||||
|
||||
function decodeByteStream(stream: Stream.Stream<Uint8Array, PlatformError.PlatformError>) {
|
||||
return Stream.mkUint8Array(stream).pipe(Effect.map((bytes) => new TextDecoder("utf-8").decode(bytes).trim()))
|
||||
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()
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function alive(pid: number) {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Fiber, Scope } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LocationWatcherPolicy } from "@opencode-ai/core/filesystem/location-watcher-policy"
|
||||
import { State } from "@opencode-ai/core/state"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(LocationWatcherPolicy.node))
|
||||
|
||||
describe("LocationWatcherPolicy", () => {
|
||||
it.effect("reads batched registrations and disposals without notifying observers", () =>
|
||||
Effect.gen(function* () {
|
||||
const policy = yield* LocationWatcherPolicy.Service
|
||||
const observed: string[][] = []
|
||||
yield* policy.observe((ignore) =>
|
||||
Effect.sync(() => {
|
||||
observed.push([...ignore])
|
||||
}),
|
||||
)
|
||||
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* policy.transform((draft) => draft.add(["base"]))
|
||||
const overlay = yield* policy.transform((draft) => draft.add(["overlay"]))
|
||||
const snapshot = policy.current()
|
||||
expect(snapshot).toEqual(["base", "overlay"])
|
||||
expect(observed).toEqual([])
|
||||
|
||||
yield* overlay.dispose
|
||||
expect(policy.current()).toEqual(["base"])
|
||||
expect(snapshot).toEqual(["base", "overlay"])
|
||||
expect(observed).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
expect(observed).toEqual([["base"]])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reads reloaded patterns before debounced observer reconciliation", () =>
|
||||
Effect.gen(function* () {
|
||||
const policy = yield* LocationWatcherPolicy.Service
|
||||
const observed: string[][] = []
|
||||
let ignore = ["first"]
|
||||
yield* policy.observe((ignore) =>
|
||||
Effect.sync(() => {
|
||||
observed.push([...ignore])
|
||||
}),
|
||||
)
|
||||
yield* policy.transform((draft) => draft.add(ignore))
|
||||
const snapshot = policy.current()
|
||||
observed.length = 0
|
||||
|
||||
ignore = ["second"]
|
||||
const reload = yield* policy.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
expect(policy.current()).toEqual(["second"])
|
||||
expect(snapshot).toEqual(["first"])
|
||||
expect(observed).toEqual([])
|
||||
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Fiber.join(reload)
|
||||
expect(observed).toEqual([["second"]])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("passes the latest policy to later observers after a reentrant registration", () =>
|
||||
Effect.gen(function* () {
|
||||
const policy = yield* LocationWatcherPolicy.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const observed: string[][] = []
|
||||
let reentered = false
|
||||
yield* policy.observe(() =>
|
||||
Effect.gen(function* () {
|
||||
if (reentered) return
|
||||
reentered = true
|
||||
yield* policy.transform((draft) => draft.add(["inner"])).pipe(Scope.provide(scope))
|
||||
}),
|
||||
)
|
||||
yield* policy.observe((ignore) =>
|
||||
Effect.sync(() => {
|
||||
observed.push([...ignore])
|
||||
}),
|
||||
)
|
||||
|
||||
yield* policy.transform((draft) => draft.add(["outer"]))
|
||||
|
||||
expect(policy.current()).toEqual(["outer", "inner"])
|
||||
expect(observed).toEqual([
|
||||
["outer", "inner"],
|
||||
["outer", "inner"],
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("allows an observer to await a reload and keeps later observers current", () =>
|
||||
Effect.gen(function* () {
|
||||
const policy = yield* LocationWatcherPolicy.Service
|
||||
const observed: string[][] = []
|
||||
let ignore = ["first"]
|
||||
let reentered = false
|
||||
yield* policy.observe(() =>
|
||||
Effect.gen(function* () {
|
||||
if (reentered) return
|
||||
reentered = true
|
||||
ignore = ["second"]
|
||||
yield* policy.reload()
|
||||
}),
|
||||
)
|
||||
yield* policy.observe((ignore) =>
|
||||
Effect.sync(() => {
|
||||
observed.push([...ignore])
|
||||
}),
|
||||
)
|
||||
|
||||
const writer = yield* policy
|
||||
.transform((draft) => draft.add(ignore))
|
||||
.pipe(Effect.forkChild({ startImmediately: true }))
|
||||
expect(policy.current()).toEqual(["second"])
|
||||
expect(observed).toEqual([])
|
||||
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Fiber.join(writer)
|
||||
expect(observed).toEqual([["second"], ["second"]])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, spyOn } from "bun:test"
|
||||
import { describe, expect, spyOn, test } from "bun:test"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { Deferred, Effect, Layer } from "effect"
|
||||
@@ -14,8 +14,6 @@ import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||
import { Workspace } from "@opencode-ai/core/workspace"
|
||||
import { location } from "../fixture/location"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import { it } from "../lib/effect"
|
||||
|
||||
const ripgrepStub = (entry: string, onFind: (input: Ripgrep.FindInput) => void) =>
|
||||
Layer.succeed(
|
||||
@@ -34,13 +32,14 @@ const ripgrepStub = (entry: string, onFind: (input: Ripgrep.FindInput) => void)
|
||||
)
|
||||
|
||||
describe("FileSystemSearch", () => {
|
||||
it.live("honors wildcard directory rules from .gitignore", () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = (yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-fff-ignore-")))).path
|
||||
yield* Effect.promise(() => mkdir(path.join(directory, "rust/target/debug/deps"), { recursive: true }))
|
||||
yield* Effect.promise(() => Bun.write(path.join(directory, ".gitignore"), "**/target/\n"))
|
||||
yield* Effect.promise(() => Bun.write(path.join(directory, "rust/target/debug/deps/ignored.rs"), "ignored"))
|
||||
expect(Bun.spawnSync(["git", "init", "-q"], { cwd: directory }).exitCode).toBe(0)
|
||||
test("honors wildcard directory rules from .gitignore", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-fff-ignore-"))
|
||||
try {
|
||||
await mkdir(path.join(directory, "rust/target/debug/deps"), { recursive: true })
|
||||
await Bun.write(path.join(directory, ".gitignore"), "**/target/\n")
|
||||
await Bun.write(path.join(directory, "rust/target/debug/deps/ignored.rs"), "ignored")
|
||||
const git = Bun.spawnSync(["git", "init", "-q"], { cwd: directory })
|
||||
expect(git.exitCode).toBe(0)
|
||||
|
||||
const ref = Location.Ref.make({ directory: AbsolutePath.make(directory) })
|
||||
const layer = FileSystemSearch.fffLayer.pipe(
|
||||
@@ -55,22 +54,26 @@ describe("FileSystemSearch", () => {
|
||||
),
|
||||
),
|
||||
)
|
||||
yield* Effect.gen(function* () {
|
||||
const search = yield* FileSystemSearch.Service
|
||||
const entries = yield* search.find({ query: "target" })
|
||||
expect(entries.every((entry) => !entry.path.startsWith("rust/target/"))).toBe(true)
|
||||
}).pipe(Effect.provide(layer))
|
||||
}),
|
||||
)
|
||||
const entries = await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const search = yield* FileSystemSearch.Service
|
||||
return yield* search.find({ query: "target" })
|
||||
}).pipe(Effect.provide(layer), Effect.scoped),
|
||||
)
|
||||
|
||||
it.live("selects the ripgrep layer for workspace-backed locations even when vcs would pick fff", () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = (yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-search-workspace-"))))
|
||||
.path
|
||||
expect(entries.every((entry) => !entry.path.startsWith("rust/target/"))).toBe(true)
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("selects the ripgrep layer for workspace-backed locations even when vcs would pick fff", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-search-workspace-"))
|
||||
try {
|
||||
// A local file that only an fff index of the server directory could surface.
|
||||
// The fff-vs-ripgrep discrimination only bites where Fff.available() is
|
||||
// true; elsewhere the layer choice already falls back to ripgrep.
|
||||
yield* Effect.promise(() => Bun.write(path.join(directory, "server-local.ts"), "server local"))
|
||||
await Bun.write(path.join(directory, "server-local.ts"), "server local")
|
||||
let observed: Ripgrep.FindInput | undefined
|
||||
const ref = Location.Ref.make({
|
||||
directory: AbsolutePath.make(directory),
|
||||
@@ -89,35 +92,37 @@ describe("FileSystemSearch", () => {
|
||||
[Ripgrep.node, ripgrepStub("remote.ts", (input) => (observed = input))],
|
||||
])
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const search = yield* FileSystemSearch.Service
|
||||
const entries = yield* search.find({ query: "ts", type: "file" })
|
||||
expect(observed?.cwd).toBe(directory)
|
||||
expect(entries.map((entry) => entry.path)).toEqual([RelativePath.make("remote.ts")])
|
||||
}).pipe(Effect.provide(layer))
|
||||
}),
|
||||
)
|
||||
await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const search = yield* FileSystemSearch.Service
|
||||
const entries = yield* search.find({ query: "ts", type: "file" })
|
||||
expect(observed?.cwd).toBe(directory)
|
||||
expect(entries.map((entry) => entry.path)).toEqual([RelativePath.make("remote.ts")])
|
||||
}).pipe(Effect.provide(layer), Effect.scoped),
|
||||
)
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it.live("bounds a home scan even when home is detected as a repository", () =>
|
||||
Effect.gen(function* () {
|
||||
let observed: Ripgrep.FindInput | undefined
|
||||
const home = AbsolutePath.make(os.homedir())
|
||||
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location(
|
||||
{ directory: home },
|
||||
{ vcs: { type: "git", store: AbsolutePath.make(path.join(home, ".git")) } },
|
||||
),
|
||||
),
|
||||
test("bounds a home scan even when home is detected as a repository", async () => {
|
||||
let observed: Ripgrep.FindInput | undefined
|
||||
const home = AbsolutePath.make(os.homedir())
|
||||
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location({ directory: home }, { vcs: { type: "git", store: AbsolutePath.make(path.join(home, ".git")) } }),
|
||||
),
|
||||
],
|
||||
[Ripgrep.node, ripgrepStub("src/index.ts", (input) => (observed = input))],
|
||||
])
|
||||
yield* Effect.gen(function* () {
|
||||
),
|
||||
],
|
||||
[Ripgrep.node, ripgrepStub("src/index.ts", (input) => (observed = input))],
|
||||
])
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const search = yield* FileSystemSearch.Service
|
||||
yield* Effect.sleep("10 millis")
|
||||
expect(observed).toBeUndefined()
|
||||
@@ -127,52 +132,52 @@ describe("FileSystemSearch", () => {
|
||||
expect((yield* search.find({ query: "src", type: "directory" }))[0]?.path).toBe(
|
||||
RelativePath.make(`src${path.sep}`),
|
||||
)
|
||||
}).pipe(Effect.provide(layer))
|
||||
}),
|
||||
)
|
||||
}).pipe(Effect.provide(layer), Effect.scoped),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("refreshes a stale ripgrep index atomically without blocking search", () =>
|
||||
Effect.gen(function* () {
|
||||
let scans = 0
|
||||
const started = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location({ directory: AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-atomic")) }),
|
||||
),
|
||||
test("refreshes a stale ripgrep index atomically without blocking search", async () => {
|
||||
let scans = 0
|
||||
const started = Effect.runSync(Deferred.make<void>())
|
||||
const release = Effect.runSync(Deferred.make<void>())
|
||||
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location({ directory: AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-atomic")) }),
|
||||
),
|
||||
],
|
||||
[
|
||||
Ripgrep.node,
|
||||
Layer.succeed(
|
||||
Ripgrep.Service,
|
||||
Ripgrep.Service.of({
|
||||
find: (input) =>
|
||||
Effect.gen(function* () {
|
||||
scans++
|
||||
if (scans > 1) {
|
||||
yield* Deferred.succeed(started, undefined)
|
||||
yield* Deferred.await(release)
|
||||
}
|
||||
const entry = FileSystem.Entry.make({
|
||||
path: RelativePath.make(scans === 1 ? "src/old.ts" : "src/new.ts"),
|
||||
type: "file",
|
||||
})
|
||||
if (input.onEntry) yield* input.onEntry(entry)
|
||||
return [entry]
|
||||
}),
|
||||
glob: () => Effect.succeed([]),
|
||||
grep: () => Effect.succeed([]),
|
||||
}),
|
||||
),
|
||||
],
|
||||
])
|
||||
),
|
||||
],
|
||||
[
|
||||
Ripgrep.node,
|
||||
Layer.succeed(
|
||||
Ripgrep.Service,
|
||||
Ripgrep.Service.of({
|
||||
find: (input) =>
|
||||
Effect.gen(function* () {
|
||||
scans++
|
||||
if (scans > 1) {
|
||||
yield* Deferred.succeed(started, undefined)
|
||||
yield* Deferred.await(release)
|
||||
}
|
||||
const entry = FileSystem.Entry.make({
|
||||
path: RelativePath.make(scans === 1 ? "src/old.ts" : "src/new.ts"),
|
||||
type: "file",
|
||||
})
|
||||
if (input.onEntry) yield* input.onEntry(entry)
|
||||
return [entry]
|
||||
}),
|
||||
glob: () => Effect.succeed([]),
|
||||
grep: () => Effect.succeed([]),
|
||||
}),
|
||||
),
|
||||
],
|
||||
])
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const search = yield* FileSystemSearch.Service
|
||||
yield* search.find({ query: "old", type: "file" })
|
||||
expect((yield* search.find({ query: "old", type: "file" }))[0]?.path).toBe(RelativePath.make("src/old.ts"))
|
||||
@@ -191,53 +196,47 @@ describe("FileSystemSearch", () => {
|
||||
}).pipe(Effect.repeat({ until: (entries) => entries.length > 0 }))
|
||||
expect(refreshed[0]?.path).toBe(RelativePath.make("src/new.ts"))
|
||||
expect(scans).toBe(2)
|
||||
}).pipe(Effect.provide(layer))
|
||||
}),
|
||||
)
|
||||
}).pipe(Effect.provide(layer), Effect.provide(TestClock.layer()), Effect.scoped),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("reuses location-owned fuzzy targets across index refreshes", () =>
|
||||
Effect.gen(function* () {
|
||||
let scans = 0
|
||||
const second = yield* Deferred.make<void>()
|
||||
const prepare = yield* Effect.acquireRelease(
|
||||
Effect.sync(() => spyOn(fuzzysort, "prepare")),
|
||||
(value) => Effect.sync(() => value.mockRestore()),
|
||||
)
|
||||
const cleanup = yield* Effect.acquireRelease(
|
||||
Effect.sync(() => spyOn(fuzzysort, "cleanup")),
|
||||
(value) => Effect.sync(() => value.mockRestore()),
|
||||
)
|
||||
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location({ directory: AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-cache")) }),
|
||||
),
|
||||
test("reuses location-owned fuzzy targets across index refreshes", async () => {
|
||||
let scans = 0
|
||||
const second = Effect.runSync(Deferred.make<void>())
|
||||
const prepare = spyOn(fuzzysort, "prepare")
|
||||
const cleanup = spyOn(fuzzysort, "cleanup")
|
||||
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location({ directory: AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-cache")) }),
|
||||
),
|
||||
],
|
||||
[
|
||||
Ripgrep.node,
|
||||
Layer.succeed(
|
||||
Ripgrep.Service,
|
||||
Ripgrep.Service.of({
|
||||
find: (input) =>
|
||||
Effect.gen(function* () {
|
||||
scans++
|
||||
const entry = FileSystem.Entry.make({ path: RelativePath.make("src/index.ts"), type: "file" })
|
||||
if (input.onEntry) yield* input.onEntry(entry)
|
||||
if (scans > 1) yield* Deferred.succeed(second, undefined)
|
||||
return [entry]
|
||||
}),
|
||||
glob: () => Effect.succeed([]),
|
||||
grep: () => Effect.succeed([]),
|
||||
}),
|
||||
),
|
||||
],
|
||||
])
|
||||
),
|
||||
],
|
||||
[
|
||||
Ripgrep.node,
|
||||
Layer.succeed(
|
||||
Ripgrep.Service,
|
||||
Ripgrep.Service.of({
|
||||
find: (input) =>
|
||||
Effect.gen(function* () {
|
||||
scans++
|
||||
const entry = FileSystem.Entry.make({ path: RelativePath.make("src/index.ts"), type: "file" })
|
||||
if (input.onEntry) yield* input.onEntry(entry)
|
||||
if (scans > 1) yield* Deferred.succeed(second, undefined)
|
||||
return [entry]
|
||||
}),
|
||||
glob: () => Effect.succeed([]),
|
||||
grep: () => Effect.succeed([]),
|
||||
}),
|
||||
),
|
||||
],
|
||||
])
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const search = yield* FileSystemSearch.Service
|
||||
yield* search.find({ query: "index", type: "file" })
|
||||
yield* TestClock.adjust("10 seconds")
|
||||
@@ -247,7 +246,9 @@ describe("FileSystemSearch", () => {
|
||||
|
||||
expect(prepare).toHaveBeenCalledTimes(2)
|
||||
expect(cleanup).toHaveBeenCalledTimes(3)
|
||||
}).pipe(Effect.provide(layer))
|
||||
}),
|
||||
)
|
||||
}).pipe(Effect.provide(layer), Effect.provide(TestClock.layer()), Effect.scoped),
|
||||
)
|
||||
prepare.mockRestore()
|
||||
cleanup.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,6 +14,10 @@ export function location(ref: Location.Ref, input: { projectDirectory?: Absolute
|
||||
} satisfies Location.Interface
|
||||
}
|
||||
|
||||
export function locationLayer(ref: Location.Ref, input: { projectDirectory?: AbsolutePath; vcs?: Project.Vcs } = {}) {
|
||||
return Layer.succeed(Location.Service, Location.Service.of(location(ref, input)))
|
||||
}
|
||||
|
||||
export const tempLocationLayer = Layer.unwrap(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
@@ -21,7 +25,7 @@ export const tempLocationLayer = Layer.unwrap(
|
||||
).pipe(
|
||||
Effect.map((tmp) => {
|
||||
const ref = Location.Ref.make({ directory: AbsolutePath.make(tmp.path) })
|
||||
return Layer.succeed(Location.Service, Location.Service.of(location(ref)))
|
||||
return locationLayer(ref)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Cause, Effect, Exit, Fiber } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Integration.node, Credential.node])))
|
||||
|
||||
describe("Integration replay", () => {
|
||||
it.effect("fails and closes an OAuth attempt when fresh implementation replay throws", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const integrationID = Integration.ID.make("replay-test")
|
||||
const methodID = Integration.MethodID.make("code")
|
||||
const source = { fail: false, closed: false }
|
||||
const failure = new Error("integration transform replay failed")
|
||||
yield* integrations.transform((editor) => {
|
||||
if (source.fail) throw failure
|
||||
editor.method.update({
|
||||
integrationID,
|
||||
method: { id: methodID, type: "oauth", label: "Fixture" },
|
||||
authorize: () =>
|
||||
Effect.addFinalizer(() => Effect.sync(() => (source.closed = true))).pipe(
|
||||
Effect.as({
|
||||
mode: "code" as const,
|
||||
url: "https://example.com/authorize",
|
||||
instructions: "Enter the fixture code",
|
||||
callback: () =>
|
||||
Effect.succeed(
|
||||
Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID,
|
||||
access: "dummy-access",
|
||||
refresh: "dummy-refresh",
|
||||
expires: Number.MAX_SAFE_INTEGER,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
})
|
||||
})
|
||||
|
||||
const attempt = yield* integrations.oauth.connect({ integrationID, methodID, label: "Fixture" })
|
||||
source.fail = true
|
||||
const reload = yield* integrations.reload().pipe(Effect.exit, Effect.forkChild({ startImmediately: true }))
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.gen(function* () {
|
||||
source.fail = false
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Fiber.join(reload)
|
||||
}),
|
||||
)
|
||||
|
||||
const exit = yield* integrations.oauth
|
||||
.complete({ integrationID, attemptID: attempt.attemptID, code: "dummy-code" })
|
||||
.pipe(Effect.exit)
|
||||
|
||||
expect(exit).toMatchObject(Exit.die(failure))
|
||||
expect(Exit.isFailure(exit) && Cause.squash(exit.cause)).toBe(failure)
|
||||
expect(yield* integrations.oauth.status({ integrationID, attemptID: attempt.attemptID })).toEqual({
|
||||
status: "failed",
|
||||
message: failure.message,
|
||||
time: attempt.time,
|
||||
})
|
||||
expect(source.closed).toBe(true)
|
||||
expect(yield* credentials.list(integrationID)).toEqual([])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -7,6 +7,7 @@ import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { State } from "@opencode-ai/core/state"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Integration.node, Credential.node, Bus.node])))
|
||||
@@ -186,7 +187,7 @@ describe("Integration", () => {
|
||||
value: Credential.Key.make({ type: "key", key: "secret", configuration: { accountId: "account" } }),
|
||||
}),
|
||||
])
|
||||
expect((yield* Fiber.join(created)).map((event) => ({ type: event.type, data: event.data }))).toEqual([
|
||||
expect(Array.from(yield* Fiber.join(created), (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 } },
|
||||
])
|
||||
@@ -262,6 +263,102 @@ describe("Integration", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("resolves stored OAuth with refresh registrations made inside a batch", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const integrationID = Integration.ID.make("acme")
|
||||
const method = Integration.OAuthMethod.make({
|
||||
id: Integration.MethodID.make("browser"),
|
||||
type: "oauth",
|
||||
label: "Browser",
|
||||
})
|
||||
const expired = Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID: method.id,
|
||||
access: "expired",
|
||||
refresh: "refresh",
|
||||
expires: 0,
|
||||
})
|
||||
const fresh = Credential.OAuth.make({
|
||||
...expired,
|
||||
access: "fresh",
|
||||
refresh: "fresh-refresh",
|
||||
expires: (yield* Clock.currentTimeMillis) + Duration.toMillis(Duration.hours(1)),
|
||||
})
|
||||
const stored = yield* credentials.create({ integrationID, label: "Personal", value: expired })
|
||||
const connection = { type: "credential" as const, id: stored.id, label: stored.label }
|
||||
const calls: string[] = []
|
||||
const implementation = {
|
||||
integrationID,
|
||||
method,
|
||||
authorize: () => Effect.die("unexpected authorization"),
|
||||
refresh: (value: Credential.OAuth) =>
|
||||
Effect.sync(() => {
|
||||
expect(value).toEqual(expired)
|
||||
calls.push("original")
|
||||
return fresh
|
||||
}),
|
||||
}
|
||||
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* integrations.transform((editor) => editor.method.update(implementation))
|
||||
expect(yield* integrations.connection.resolve(connection)).toEqual(fresh)
|
||||
expect((yield* credentials.get(stored.id))?.value).toEqual(fresh)
|
||||
expect(calls).toEqual(["original"])
|
||||
|
||||
expect(yield* integrations.connection.resolve(connection)).toEqual(fresh)
|
||||
expect(calls).toEqual(["original"])
|
||||
|
||||
yield* credentials.update(stored.id, { value: expired })
|
||||
const overridden = Credential.OAuth.make({ ...fresh, access: "override" })
|
||||
const override = yield* integrations.transform((editor) =>
|
||||
editor.method.update({
|
||||
...implementation,
|
||||
refresh: (value) =>
|
||||
Effect.sync(() => {
|
||||
expect(value).toEqual(expired)
|
||||
calls.push("override")
|
||||
return overridden
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(yield* integrations.connection.resolve(connection)).toEqual(overridden)
|
||||
expect((yield* credentials.get(stored.id))?.value).toEqual(overridden)
|
||||
|
||||
yield* override.dispose
|
||||
yield* credentials.update(stored.id, { value: expired })
|
||||
expect(yield* integrations.connection.resolve(connection)).toEqual(fresh)
|
||||
expect(calls).toEqual(["original", "override", "original"])
|
||||
|
||||
yield* credentials.update(stored.id, { value: expired })
|
||||
const removal = yield* integrations.transform((editor) => editor.method.remove(integrationID, method))
|
||||
expect(yield* integrations.connection.resolve(connection)).toEqual(expired)
|
||||
expect((yield* credentials.get(stored.id))?.value).toEqual(expired)
|
||||
expect(calls).toEqual(["original", "override", "original"])
|
||||
|
||||
yield* removal.dispose
|
||||
expect(yield* integrations.connection.resolve(connection)).toEqual(fresh)
|
||||
yield* credentials.update(stored.id, { value: expired })
|
||||
yield* integrations.transform((editor) => editor.method.update({ ...implementation, refresh: undefined }))
|
||||
expect(yield* integrations.connection.resolve(connection)).toEqual(expired)
|
||||
expect((yield* credentials.get(stored.id))?.value).toEqual(expired)
|
||||
expect(calls).toEqual(["original", "override", "original", "original"])
|
||||
|
||||
const failure = new Error("refresh failed")
|
||||
yield* integrations.transform((editor) =>
|
||||
editor.method.update({ ...implementation, refresh: () => Effect.fail(failure) }),
|
||||
)
|
||||
expect(yield* integrations.connection.resolve(connection).pipe(Effect.flip)).toEqual(
|
||||
new Integration.AuthorizationError({ cause: failure }),
|
||||
)
|
||||
expect((yield* credentials.get(stored.id))?.value).toEqual(expired)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("completes code OAuth once and stores the credential", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
|
||||
@@ -49,29 +49,6 @@ 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`
|
||||
|
||||
+139
-108
@@ -33,23 +33,37 @@ import { McpStdio } from "@opencode-ai/core/mcp/stdio"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { State } from "@opencode-ai/core/state"
|
||||
import { McpTool } from "@opencode-ai/core/tool/mcp"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { Deferred, Effect, Exit, Fiber, Layer, PubSub, Ref, Schedule, Schema, Sink, Stream } from "effect"
|
||||
import {
|
||||
Context,
|
||||
Deferred,
|
||||
Effect,
|
||||
Exit,
|
||||
Fiber,
|
||||
Layer,
|
||||
PubSub,
|
||||
Ref,
|
||||
Schedule,
|
||||
Schema,
|
||||
Scope,
|
||||
Sink,
|
||||
Stream,
|
||||
} from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { ExitCode, makeHandle, ProcessId } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import { Image } from "@opencode-ai/core/image"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { imagePassthrough } from "./lib/image"
|
||||
import { location } from "./fixture/location"
|
||||
import { location, locationLayer } from "./fixture/location"
|
||||
import { hostEnvironmentLayer, recordingEnvironmentLayer } from "./fixture/environment"
|
||||
import { executeTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
|
||||
|
||||
let assertion: Deferred.Deferred<Permission.AssertInput> | undefined
|
||||
let decision: Effect.Effect<void, Permission.Error> = Effect.void
|
||||
let calls = 0
|
||||
let invocations: Array<Parameters<Mcp.Interface["callTool"]>[0]> = []
|
||||
|
||||
type ResourcePage = {
|
||||
items: Array<{ name: string; uri: string; description?: string; mimeType?: string }>
|
||||
@@ -67,7 +81,7 @@ function resourceServer(
|
||||
listChanged?: boolean
|
||||
emptyElicitation?: boolean
|
||||
urlElicitation?: boolean
|
||||
respond?: (request: Request) => Response | undefined
|
||||
respond?: (request: Request) => Response | undefined | Promise<Response | undefined>
|
||||
} = {},
|
||||
) {
|
||||
return Effect.acquireRelease(
|
||||
@@ -84,12 +98,6 @@ function resourceServer(
|
||||
resourceLists: 0,
|
||||
templateLists: 0,
|
||||
toolLists: 0,
|
||||
toolCalls: [] as Array<{
|
||||
name: string
|
||||
arguments: Record<string, unknown> | undefined
|
||||
sessionID: unknown
|
||||
progressToken: unknown
|
||||
}>,
|
||||
initializations: 0,
|
||||
urls: [] as string[],
|
||||
}
|
||||
@@ -139,17 +147,6 @@ function resourceServer(
|
||||
}
|
||||
})
|
||||
}
|
||||
if (!input.emptyElicitation && !input.urlElicitation) {
|
||||
protocol.setRequestHandler(CallToolRequestSchema, (request) => {
|
||||
state.toolCalls.push({
|
||||
name: request.params.name,
|
||||
arguments: request.params.arguments,
|
||||
sessionID: request.params._meta?.sessionID,
|
||||
progressToken: request.params._meta?.progressToken,
|
||||
})
|
||||
return Promise.resolve({ content: [] })
|
||||
})
|
||||
}
|
||||
if (input.resources !== false) {
|
||||
protocol.setRequestHandler(ListResourcesRequestSchema, (request) => {
|
||||
state.resourceLists += 1
|
||||
@@ -176,7 +173,7 @@ function resourceServer(
|
||||
if (typeof body === "object" && body !== null && "method" in body && body.method === "initialize") {
|
||||
state.initializations += 1
|
||||
}
|
||||
return input.respond?.(request) ?? transport.handleRequest(request)
|
||||
return (await input.respond?.(request)) ?? transport.handleRequest(request)
|
||||
},
|
||||
})
|
||||
return {
|
||||
@@ -331,7 +328,6 @@ const mcp = Layer.mock(Mcp.Service, {
|
||||
callTool: (input) =>
|
||||
Effect.sync(() => {
|
||||
calls += 1
|
||||
invocations.push(input)
|
||||
if (input.name === "fail")
|
||||
return new Mcp.ToolResult({
|
||||
server: Mcp.ServerName.make(input.server),
|
||||
@@ -399,43 +395,6 @@ test("MCP tool names match V1 sanitization", () => {
|
||||
expect(McpTool.name("context 7", "resolve.library/id")).toBe("context_7_resolve_library_id")
|
||||
})
|
||||
|
||||
test("passes session IDs as MCP request metadata", async () => {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const server = yield* resourceServer()
|
||||
const connection = yield* connect(
|
||||
"session-metadata",
|
||||
new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false }),
|
||||
import.meta.dir,
|
||||
)
|
||||
yield* connection.callTool({
|
||||
name: "echo",
|
||||
args: { text: "hello" },
|
||||
sessionID: Session.ID.make("ses_mcp_metadata"),
|
||||
})
|
||||
yield* connection.callTool({ name: "echo" })
|
||||
|
||||
expect(server.state.toolCalls).toEqual([
|
||||
{
|
||||
name: "echo",
|
||||
arguments: { text: "hello" },
|
||||
sessionID: "ses_mcp_metadata",
|
||||
progressToken: expect.any(Number),
|
||||
},
|
||||
{
|
||||
name: "echo",
|
||||
arguments: {},
|
||||
sessionID: undefined,
|
||||
progressToken: expect.any(Number),
|
||||
},
|
||||
])
|
||||
expect(server.state.toolCalls[0]?.progressToken).not.toBe(server.state.toolCalls[1]?.progressToken)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("preserves output schema validation across paginated tool discovery", async () => {
|
||||
const server = new Server({ name: "pagination", version: "1.0.0" }, { capabilities: { tools: {} } })
|
||||
server.setRequestHandler(ListToolsRequestSchema, ({ params }) =>
|
||||
@@ -1411,6 +1370,126 @@ test("reconciles only changed MCP server config", async () => {
|
||||
)
|
||||
})
|
||||
|
||||
testEffect(Layer.empty).live("serializes MCP config restoration behind an in-flight replacement", () =>
|
||||
Effect.gen(function* () {
|
||||
const started = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const accepted = yield* Deferred.make<void>()
|
||||
const server = yield* resourceServer({
|
||||
respond: (request) =>
|
||||
request.method !== "POST"
|
||||
? undefined
|
||||
: Effect.runPromise(
|
||||
Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(release)), Effect.as(undefined)),
|
||||
),
|
||||
})
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const service = yield* Mcp.Service
|
||||
expect((yield* service.servers())[0]?.status).toEqual({ status: "disabled" })
|
||||
const replacing = yield* service
|
||||
.transform((draft) => draft.update("resources", (config) => (config.disabled = false)))
|
||||
.pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Deferred.await(started)
|
||||
|
||||
const restoring = yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* service.transform((draft) => draft.update("resources", (config) => (config.disabled = true)))
|
||||
yield* Deferred.succeed(accepted, undefined)
|
||||
}),
|
||||
).pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Deferred.await(accepted)
|
||||
expect((yield* service.servers())[0]?.status).toEqual({ status: "pending" })
|
||||
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Fiber.join(replacing)
|
||||
yield* Fiber.join(restoring)
|
||||
expect((yield* service.servers())[0]?.status).toEqual({ status: "disabled" })
|
||||
expect(yield* service.tools()).toEqual([])
|
||||
expect(server.state.initializations).toBe(1)
|
||||
}).pipe(
|
||||
Effect.ensuring(Deferred.succeed(release, undefined)),
|
||||
Effect.provide(
|
||||
resourceMcpLayer(new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false, disabled: true })),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
const shutdownIt = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Bus.node, Integration.node, Credential.node, Form.node, Environment.node, Location.node]),
|
||||
[
|
||||
[Location.node, locationLayer({ directory: AbsolutePath.make(import.meta.dir) })],
|
||||
[Environment.node, hostEnvironmentLayer],
|
||||
],
|
||||
),
|
||||
)
|
||||
;["active", "queued"].forEach((phase) =>
|
||||
shutdownIt.effect(`discards ${phase} MCP notifications after its layer closes`, () =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const entered = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const root = yield* Scope.make()
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Deferred.succeed(release, undefined).pipe(
|
||||
Effect.andThen(State.batch(Scope.close(root, Exit.void), { flush: false })),
|
||||
Effect.andThen(TestClock.adjust("500 millis")),
|
||||
),
|
||||
)
|
||||
const context = yield* Layer.buildWithScope(Mcp.layer(), root)
|
||||
const service = Context.get(context, Mcp.Service)
|
||||
const observed: string[] = []
|
||||
let block = false
|
||||
const unsubscribe = yield* bus.listen((event) =>
|
||||
Effect.gen(function* () {
|
||||
if (event.type !== McpEvent.StatusChanged.type) return
|
||||
observed.push(Schema.decodeUnknownSync(McpEvent.StatusChanged.data)(event.data).server)
|
||||
if (!block) return
|
||||
block = false
|
||||
yield* Deferred.succeed(entered, undefined)
|
||||
yield* Deferred.await(release)
|
||||
}),
|
||||
)
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
const source = { url: "https://example.com/initial", added: false }
|
||||
yield* service
|
||||
.transform((draft) => {
|
||||
draft.set("fixture", { type: "remote", url: source.url, oauth: false, disabled: true })
|
||||
if (source.added) draft.set("queued", { type: "local", command: ["unused"], disabled: true })
|
||||
})
|
||||
.pipe(Scope.provide(root))
|
||||
|
||||
block = true
|
||||
source.url = "https://example.com/first"
|
||||
source.added = phase === "active"
|
||||
const first = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Deferred.await(entered)
|
||||
source.url = "https://example.com/second"
|
||||
source.added = true
|
||||
const second = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("500 millis")
|
||||
|
||||
const shutdown = yield* State.batch(Scope.close(root, Exit.void), { flush: false }).pipe(
|
||||
Effect.forkChild({ startImmediately: true }),
|
||||
)
|
||||
yield* TestClock.adjust("1 millis")
|
||||
expect(shutdown.pollUnsafe()).toBeDefined()
|
||||
expect(first.pollUnsafe()).toBeDefined()
|
||||
expect(second.pollUnsafe()).toBeDefined()
|
||||
expect(yield* Deferred.isDone(release)).toBe(false)
|
||||
yield* Fiber.join(shutdown)
|
||||
observed.length = 0
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Fiber.join(first)
|
||||
yield* Fiber.join(second)
|
||||
expect(observed).toEqual([])
|
||||
expect((yield* service.servers()).map((server) => server.name)).toEqual([Mcp.ServerName.make("fixture")])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
test("serializes concurrent MCP lifecycle operations", async () => {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
@@ -1666,54 +1745,6 @@ it.effect("advertises MCP output schemas to Code Mode", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("forwards the invoking session through direct and Code Mode MCP tools", () =>
|
||||
Effect.gen(function* () {
|
||||
assertion = yield* Deferred.make<Permission.AssertInput>()
|
||||
decision = Effect.void
|
||||
invocations = []
|
||||
const registry = yield* Tool.Service
|
||||
const registration = yield* McpTool.Service
|
||||
yield* registration.flush
|
||||
const toolSet = yield* registry.snapshot()
|
||||
|
||||
expect(toolSet.definitions.find((tool) => tool.name === "direct_lookup")?.inputSchema).not.toHaveProperty(
|
||||
"properties.sessionID",
|
||||
)
|
||||
expect(toolSet.codeModeCatalog?.find((tool) => tool.path === "demo.search")?.signature).not.toContain("sessionID")
|
||||
|
||||
const directSessionID = Session.ID.make("ses_mcp_direct")
|
||||
yield* toolSet.execute({
|
||||
sessionID: directSessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call_mcp_direct", name: "direct_lookup", input: {} },
|
||||
})
|
||||
expect(invocations[0]).toEqual({
|
||||
server: "direct",
|
||||
name: "lookup",
|
||||
args: {},
|
||||
sessionID: directSessionID,
|
||||
})
|
||||
|
||||
const codeModeSessionID = Session.ID.make("ses_mcp_codemode")
|
||||
yield* toolSet.execute({
|
||||
sessionID: codeModeSessionID,
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call_mcp_codemode",
|
||||
name: "execute",
|
||||
input: { code: "return await tools.demo.search({})" },
|
||||
},
|
||||
})
|
||||
expect(invocations[1]).toEqual({
|
||||
server: "demo",
|
||||
name: "search",
|
||||
args: {},
|
||||
sessionID: codeModeSessionID,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("returns content-only MCP results through Code Mode", () =>
|
||||
Effect.gen(function* () {
|
||||
assertion = yield* Deferred.make<Permission.AssertInput>()
|
||||
|
||||
@@ -234,7 +234,7 @@ describe("Npm.add", () => {
|
||||
|
||||
const first = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
const mutableEntry = yield* npm.add(mutable)
|
||||
const mutableEntry = yield* npm.add(mutable, { refresh: true })
|
||||
const pinnedEntry = yield* npm.add(pinned, { refresh: true })
|
||||
yield* Effect.promise(async () => {
|
||||
await Bun.write(path.join(fixture.repository, "index.js"), 'export default { root: "second" }\n')
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Context, Effect, Exit, Fiber, Schema, Stream } from "effect"
|
||||
import { Clock, Context, Duration, Effect, Exit, Fiber, Schema, Stream } from "effect"
|
||||
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
|
||||
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
||||
@@ -103,6 +105,64 @@ describe("Plugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("refreshes its own stored OAuth connection during plugin activation", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const integrationID = Integration.ID.make("acme")
|
||||
const methodID = Integration.MethodID.make("browser")
|
||||
const expired = Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID,
|
||||
access: "expired",
|
||||
refresh: "refresh",
|
||||
expires: 0,
|
||||
})
|
||||
const fresh = Credential.OAuth.make({
|
||||
...expired,
|
||||
access: "fresh",
|
||||
refresh: "fresh-refresh",
|
||||
expires: (yield* Clock.currentTimeMillis) + Duration.toMillis(Duration.hours(1)),
|
||||
})
|
||||
const stored = yield* credentials.create({ integrationID, label: "Personal", value: expired })
|
||||
const resolved: (Credential.Value | undefined)[] = []
|
||||
const refreshed: Credential.OAuth[] = []
|
||||
|
||||
yield* plugins.activate([
|
||||
versioned(
|
||||
EffectPlugin.define({
|
||||
id: "oauth-refresh",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* ctx.integration.transform((editor) =>
|
||||
editor.method.update({
|
||||
integrationID,
|
||||
method: { id: methodID, type: "oauth", label: "Browser" },
|
||||
authorize: () => Effect.die("unexpected authorization"),
|
||||
refresh: (value) =>
|
||||
Effect.sync(() => {
|
||||
refreshed.push(value)
|
||||
return fresh
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const connection = yield* ctx.integration.connection.active(integrationID)
|
||||
if (!connection) return yield* Effect.die("stored connection missing")
|
||||
resolved.push(yield* ctx.integration.connection.resolve(connection).pipe(Effect.orDie))
|
||||
}),
|
||||
}),
|
||||
),
|
||||
])
|
||||
|
||||
expect(resolved).toEqual([fresh])
|
||||
expect(refreshed).toEqual([expired])
|
||||
expect((yield* credentials.get(stored.id))?.value).toEqual(fresh)
|
||||
expect(yield* plugins.list()).toEqual([
|
||||
{ id: Plugin.ID.make("oauth-refresh"), source: { type: "builtin" }, status: "active", tui: false },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("exposes public events through the plugin context", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Effect, Exit, Layer, Scope } from "effect"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
@@ -11,8 +11,6 @@ import { Location } from "@opencode-ai/core/location"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { ModelsDev } from "@opencode-ai/core/models-dev"
|
||||
import { ModelsDevPlugin } from "@opencode-ai/core/plugin/models-dev"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
@@ -20,7 +18,6 @@ import { withEnv } from "../fixture/env"
|
||||
import { location } from "../fixture/location"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { catalogHost, host, integrationHost } from "./host"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
@@ -30,68 +27,10 @@ const layer = AppNodeBuilder.build(LayerNode.group([Catalog.node, Integration.no
|
||||
[Location.node, locationLayer],
|
||||
])
|
||||
const it = testEffect(layer)
|
||||
const real = testEffect(PluginTestLayer)
|
||||
const models = (file: string) =>
|
||||
AppNodeBuilder.build(ModelsDev.node, [[ModelsDev.node, ModelsDev.configured({ file, fetch: false })]])
|
||||
|
||||
describe("ModelsDevPlugin", () => {
|
||||
real.effect("keeps the retained model seed unchanged across catalog replay", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const plugins = yield* Plugin.Service
|
||||
const providerID = Provider.ID.make("acme")
|
||||
const modelID = Model.ID.make("model")
|
||||
const modelsDev = ModelsDev.Service.of({
|
||||
get: () =>
|
||||
Effect.succeed([
|
||||
{
|
||||
info: {
|
||||
id: providerID,
|
||||
name: "Acme",
|
||||
activation: "auto",
|
||||
package: Provider.aisdk("@ai-sdk/openai-compatible"),
|
||||
},
|
||||
environment: [],
|
||||
models: [
|
||||
{
|
||||
id: modelID,
|
||||
modelID,
|
||||
providerID,
|
||||
name: "Model",
|
||||
capabilities: { tools: true, input: [], output: [] },
|
||||
variants: [],
|
||||
time: { released: Date.parse("2026-01-01") },
|
||||
cost: [],
|
||||
status: "active",
|
||||
enabled: true,
|
||||
limit: { context: 128_000, output: 32_000 },
|
||||
},
|
||||
],
|
||||
},
|
||||
] satisfies readonly ModelsDev.Snapshot[]),
|
||||
refresh: () => Effect.void,
|
||||
})
|
||||
const pluginHost = yield* PluginHost.make(plugins)
|
||||
yield* ModelsDevPlugin.effect(pluginHost).pipe(Effect.provideService(ModelsDev.Service, modelsDev))
|
||||
|
||||
const scope = yield* Scope.make()
|
||||
yield* catalog
|
||||
.transform((draft) =>
|
||||
draft.model.update(providerID, modelID, (model) => {
|
||||
model.variants ??= []
|
||||
model.variants.push({ id: Model.VariantID.make("configured") })
|
||||
}),
|
||||
)
|
||||
.pipe(Scope.provide(scope))
|
||||
expect((yield* catalog.model.get(providerID, modelID))?.variants).toEqual([
|
||||
{ id: Model.VariantID.make("configured") },
|
||||
])
|
||||
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect((yield* catalog.model.get(providerID, modelID))?.variants).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("projects normalized models.dev snapshots into the catalog", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { expect, test } from "bun:test"
|
||||
import { PluginModule } from "@opencode-ai/core/plugin/module"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { Effect } from "effect"
|
||||
|
||||
test("loads cached plugin packages without requesting a refresh", async () => {
|
||||
const calls: unknown[] = []
|
||||
const entrypoint = path.join(import.meta.dir, "fixtures", "config-effect-plugin.ts")
|
||||
const plugin = await PluginModule.load({ type: "add", target: "fixture-plugin", options: {} }).pipe(
|
||||
Effect.provideService(
|
||||
Npm.Service,
|
||||
Npm.Service.of({
|
||||
add: (_pkg, options) =>
|
||||
Effect.sync(() => {
|
||||
calls.push(options)
|
||||
return { directory: path.dirname(entrypoint), entrypoint: pathToFileURL(entrypoint).href }
|
||||
}),
|
||||
resolve: () => Effect.die(new Error("Unexpected resolve")),
|
||||
which: () => Effect.die(new Error("Unexpected which")),
|
||||
}),
|
||||
),
|
||||
Effect.runPromise,
|
||||
)
|
||||
|
||||
expect(plugin.id).toBe("config-effect-plugin")
|
||||
expect(calls).toEqual([{ subpaths: ["server", ""] }])
|
||||
})
|
||||
@@ -3,6 +3,8 @@ import { Message, SystemPart } from "@opencode-ai/ai"
|
||||
import { DateTime, Effect, Schema } from "effect"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
@@ -110,6 +112,59 @@ describe("fromPromise", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("refreshes its own stored OAuth connection during plugin activation", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const integrationID = Integration.ID.make("acme")
|
||||
const methodID = Integration.MethodID.make("browser")
|
||||
const expired = Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID,
|
||||
access: "expired",
|
||||
refresh: "dummy",
|
||||
expires: 0,
|
||||
})
|
||||
const fresh = Credential.OAuth.make({ ...expired, access: "fresh", expires: Number.MAX_SAFE_INTEGER })
|
||||
const stored = yield* credentials.create({ integrationID, label: "Fixture", value: expired })
|
||||
const resolved: string[] = []
|
||||
const refreshed: string[] = []
|
||||
const adapted = PluginPromise.fromPromise(
|
||||
define({
|
||||
id: "promise-oauth-refresh",
|
||||
setup: async (ctx) => {
|
||||
await ctx.integration.transform((editor) =>
|
||||
editor.method.update({
|
||||
integrationID,
|
||||
method: { id: methodID, type: "oauth", label: "Browser" },
|
||||
authorize: async () => {
|
||||
throw new Error("unexpected authorization")
|
||||
},
|
||||
refresh: async (value) => {
|
||||
refreshed.push(value.access)
|
||||
return fresh
|
||||
},
|
||||
}),
|
||||
)
|
||||
const connection = await ctx.integration.connection.active(integrationID)
|
||||
if (!connection) throw new Error("stored connection missing")
|
||||
const value = await ctx.integration.connection.resolve(connection)
|
||||
resolved.push(value?.type === "oauth" ? value.access : "missing")
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
yield* plugins.activate([{ ...adapted, version: "1" }])
|
||||
|
||||
expect(resolved).toEqual(["fresh"])
|
||||
expect(refreshed).toEqual(["expired"])
|
||||
expect((yield* credentials.get(stored.id))?.value).toEqual(fresh)
|
||||
expect(yield* plugins.list()).toEqual([
|
||||
{ id: Plugin.ID.make("promise-oauth-refresh"), source: { type: "builtin" }, status: "active", tui: false },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("exposes the host location including workspace and project metadata", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
|
||||
@@ -70,12 +70,20 @@ describe("AppProcess", () => {
|
||||
"requireSuccess fails on non-zero exit",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const error = yield* svc
|
||||
.run(cmd("-e", "process.exit(1)"))
|
||||
.pipe(Effect.flatMap(AppProcess.requireSuccess), Effect.flip)
|
||||
expect(error).toBeInstanceOf(AppProcess.AppProcessError)
|
||||
expect(error.exitCode).toBe(1)
|
||||
expect(error.message).toContain("Command failed (exit 1)")
|
||||
const exit = yield* Effect.exit(
|
||||
svc.run(cmd("-e", "process.exit(1)")).pipe(Effect.flatMap(AppProcess.requireSuccess)),
|
||||
)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) {
|
||||
const reason = exit.cause.reasons[0]
|
||||
if (reason && reason._tag === "Fail") {
|
||||
expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError)
|
||||
expect((reason.error as AppProcess.AppProcessError).exitCode).toBe(1)
|
||||
expect((reason.error as AppProcess.AppProcessError).message).toContain("Command failed (exit 1)")
|
||||
} else {
|
||||
throw new Error("expected fail reason")
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -97,9 +105,15 @@ describe("AppProcess", () => {
|
||||
expect(okZero.exitCode).toBe(0)
|
||||
const okOne = yield* svc.run(cmd("-e", "process.exit(1)")).pipe(Effect.flatMap(requireZeroOrOne))
|
||||
expect(okOne.exitCode).toBe(1)
|
||||
const error = yield* svc.run(cmd("-e", "process.exit(2)")).pipe(Effect.flatMap(requireZeroOrOne), Effect.flip)
|
||||
expect(error).toBeInstanceOf(AppProcess.AppProcessError)
|
||||
expect(error.exitCode).toBe(2)
|
||||
const exit = yield* Effect.exit(svc.run(cmd("-e", "process.exit(2)")).pipe(Effect.flatMap(requireZeroOrOne)))
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) {
|
||||
const reason = exit.cause.reasons[0]
|
||||
if (reason && reason._tag === "Fail") {
|
||||
expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError)
|
||||
expect((reason.error as AppProcess.AppProcessError).exitCode).toBe(2)
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -299,10 +313,18 @@ describe("AppProcess", () => {
|
||||
.runStream(cmd("-e", "console.log('only'); process.exit(1)"), { okExitCodes: [0, 1] })
|
||||
.pipe(Stream.runCollect)
|
||||
expect(Array.from(allowed)).toEqual(["only"])
|
||||
const error = yield* svc
|
||||
.runStream(cmd("-e", "console.log('a'); process.exit(2)"), { okExitCodes: [0, 1] })
|
||||
.pipe(Stream.runCollect, Effect.flip)
|
||||
expect(error).toBeInstanceOf(AppProcess.AppProcessError)
|
||||
const exit = yield* Effect.exit(
|
||||
svc
|
||||
.runStream(cmd("-e", "console.log('a'); process.exit(2)"), { okExitCodes: [0, 1] })
|
||||
.pipe(Stream.runCollect),
|
||||
)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) {
|
||||
const reason = exit.cause.reasons[0]
|
||||
if (reason && reason._tag === "Fail") {
|
||||
expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError)
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,23 +1,147 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Exit, Layer, Scope } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { State } from "@opencode-ai/core/state"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Reference } from "@opencode-ai/core/reference"
|
||||
import { Repository } from "@opencode-ai/core/repository"
|
||||
import { RepositoryCache } from "@opencode-ai/core/repository-cache"
|
||||
import { it } from "./lib/effect"
|
||||
import { it, testEffect } from "./lib/effect"
|
||||
|
||||
const cache = Layer.mock(RepositoryCache.Service, {
|
||||
ensure: () => Effect.die("unexpected Git materialization"),
|
||||
})
|
||||
const referenceLayer = AppNodeBuilder.build(Reference.node, [[RepositoryCache.node, cache]])
|
||||
const referenceLayer = AppNodeBuilder.build(LayerNode.group([Reference.node, Bus.node]), [
|
||||
[RepositoryCache.node, cache],
|
||||
])
|
||||
const referenceIt = testEffect(referenceLayer)
|
||||
|
||||
describe("Reference", () => {
|
||||
it.effect("registers normalized sources for the owning scope", () =>
|
||||
it.effect("prepares batched references before cache work or update events", () => {
|
||||
const operations: RepositoryCache.EnsureInput[] = []
|
||||
const cache = Layer.mock(RepositoryCache.Service, {
|
||||
ensure: (input) =>
|
||||
Effect.sync(() => {
|
||||
operations.push(input)
|
||||
return {
|
||||
repository: input.reference.label,
|
||||
host: input.reference.host,
|
||||
remote: input.reference.remote,
|
||||
localPath: Repository.cachePath(Global.Path.repos, input.reference, input.branch),
|
||||
status: "cached",
|
||||
} satisfies RepositoryCache.Result
|
||||
}),
|
||||
})
|
||||
const referenceLayer = AppNodeBuilder.build(LayerNode.group([Reference.node, Bus.node]), [
|
||||
[RepositoryCache.node, cache],
|
||||
])
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const references = yield* Reference.Service
|
||||
const bus = yield* Bus.Service
|
||||
const observed: string[][] = []
|
||||
const unsubscribe = yield* bus.listen((event) =>
|
||||
event.type === Reference.Event.Updated.type
|
||||
? references.list().pipe(
|
||||
Effect.map((infos) => {
|
||||
observed.push(infos.map((info) => info.name))
|
||||
}),
|
||||
)
|
||||
: Effect.void,
|
||||
)
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* references.transform((draft) => {
|
||||
draft.add("docs", Reference.LocalSource.make({ type: "local", path: AbsolutePath.make("/docs") }))
|
||||
draft.add(
|
||||
"sdk",
|
||||
Reference.GitSource.make({
|
||||
type: "git",
|
||||
repository: "owner/repo",
|
||||
branch: "feature/docs",
|
||||
description: "SDK documentation",
|
||||
hidden: true,
|
||||
}),
|
||||
)
|
||||
draft.add("invalid", Reference.GitSource.make({ type: "git", repository: "invalid" }))
|
||||
draft.add(
|
||||
"invalid-branch",
|
||||
Reference.GitSource.make({ type: "git", repository: "owner/repo", branch: "../escape" }),
|
||||
)
|
||||
draft.add("file", Reference.GitSource.make({ type: "git", repository: "file:///docs" }))
|
||||
})
|
||||
const infos = yield* references.list()
|
||||
expect(infos.map((info) => info.name)).toEqual(["docs", "sdk"])
|
||||
expect(infos[1]).toMatchObject({
|
||||
path: Repository.cachePath(Global.Path.repos, Repository.parseRemote("owner/repo"), "feature/docs"),
|
||||
description: "SDK documentation",
|
||||
hidden: true,
|
||||
})
|
||||
expect(operations).toEqual([])
|
||||
expect(observed).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
expect(observed).toEqual([["docs", "sdk"]])
|
||||
yield* Effect.yieldNow
|
||||
expect(
|
||||
operations.map((input) => ({
|
||||
repository: input.reference.label,
|
||||
branch: input.branch,
|
||||
refresh: input.refresh,
|
||||
})),
|
||||
).toEqual([{ repository: "owner/repo", branch: "feature/docs", refresh: true }])
|
||||
}).pipe(Effect.scoped, Effect.provide(referenceLayer))
|
||||
})
|
||||
|
||||
referenceIt.effect("lets update listeners replace references and refetch the latest projection", () =>
|
||||
Effect.gen(function* () {
|
||||
const references = yield* Reference.Service
|
||||
const scope = yield* Scope.make()
|
||||
const bus = yield* Bus.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const observed: string[][] = []
|
||||
let reentered = false
|
||||
const first = yield* bus.listen((event) =>
|
||||
Effect.gen(function* () {
|
||||
if (event.type !== Reference.Event.Updated.type || reentered) return
|
||||
reentered = true
|
||||
yield* references
|
||||
.transform((draft) =>
|
||||
draft.add("docs", Reference.LocalSource.make({ type: "local", path: AbsolutePath.make("/new") })),
|
||||
)
|
||||
.pipe(Scope.provide(scope))
|
||||
}),
|
||||
)
|
||||
const second = yield* bus.listen((event) =>
|
||||
event.type === Reference.Event.Updated.type
|
||||
? references.list().pipe(
|
||||
Effect.map((infos) => {
|
||||
observed.push(infos.map((info) => info.path))
|
||||
}),
|
||||
)
|
||||
: Effect.void,
|
||||
)
|
||||
yield* Effect.addFinalizer(() => first.pipe(Effect.andThen(second)))
|
||||
|
||||
yield* references.transform((draft) =>
|
||||
draft.add("docs", Reference.LocalSource.make({ type: "local", path: AbsolutePath.make("/old") })),
|
||||
)
|
||||
|
||||
expect((yield* references.list()).map((info) => info.path)).toEqual([AbsolutePath.make("/new")])
|
||||
expect(observed).toEqual([["/new"], ["/new"]])
|
||||
}),
|
||||
)
|
||||
|
||||
referenceIt.effect("registers normalized sources for the owning scope", () =>
|
||||
Effect.gen(function* () {
|
||||
const references = yield* Reference.Service
|
||||
const parent = yield* Effect.scope
|
||||
const scope = yield* Scope.fork(parent)
|
||||
const path = AbsolutePath.make("/docs")
|
||||
const source = Reference.LocalSource.make({
|
||||
type: "local",
|
||||
@@ -33,10 +157,10 @@ describe("Reference", () => {
|
||||
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect(yield* references.list()).toEqual([])
|
||||
}).pipe(Effect.provide(referenceLayer)),
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("derives Git paths without exposing cache operations", () =>
|
||||
referenceIt.effect("derives Git paths without exposing cache operations", () =>
|
||||
Effect.gen(function* () {
|
||||
const references = yield* Reference.Service
|
||||
const repository = Repository.parseRemote("owner/repo")
|
||||
@@ -50,10 +174,10 @@ describe("Reference", () => {
|
||||
source,
|
||||
}),
|
||||
])
|
||||
}).pipe(Effect.scoped, Effect.provide(referenceLayer)),
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves configured Git descriptions", () =>
|
||||
referenceIt.effect("preserves configured Git descriptions", () =>
|
||||
Effect.gen(function* () {
|
||||
const references = yield* Reference.Service
|
||||
const repository = Repository.parseRemote("owner/repo")
|
||||
@@ -72,6 +196,6 @@ describe("Reference", () => {
|
||||
source,
|
||||
}),
|
||||
])
|
||||
}).pipe(Effect.scoped, Effect.provide(referenceLayer)),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -20,8 +20,6 @@ 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"
|
||||
@@ -303,54 +301,6 @@ 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 = []
|
||||
|
||||
@@ -67,17 +67,6 @@ const liveIt = testEffect(
|
||||
],
|
||||
),
|
||||
)
|
||||
const projectIt = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, Project.node, SessionProjector.node, SessionStore.node, Session.node]),
|
||||
[
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
// Project adoption needs plain-prompt admission, not live plugin/provider startup.
|
||||
[LocationServiceMap.node, promptLocationLayer],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
],
|
||||
),
|
||||
)
|
||||
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
|
||||
const id = Session.ID.create()
|
||||
|
||||
@@ -130,7 +119,7 @@ describe("Session.create", () => {
|
||||
),
|
||||
)
|
||||
|
||||
projectIt.live("follows the directory's project identity established after creation", () =>
|
||||
liveIt.live("follows the directory's project identity established after creation", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
|
||||
@@ -857,7 +857,8 @@ describe("SessionModelTransport", () => {
|
||||
test("records metadata-only lifecycle metrics", async () => {
|
||||
const fixture = automatic()
|
||||
|
||||
await Effect.runPromise(
|
||||
await run(
|
||||
fixture.connector,
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const executor = transport.bind(session)
|
||||
@@ -873,11 +874,7 @@ 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()),
|
||||
),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -60,32 +60,25 @@ 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* seedSession()
|
||||
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 bus = yield* Bus.Service
|
||||
const inputID = SessionMessage.ID.make("msg_manual_compaction")
|
||||
yield* SessionInbox.admitCompaction(db, bus, { id: inputID, sessionID, delivery: "queue" })
|
||||
@@ -102,7 +95,22 @@ describe("SessionProjector", () => {
|
||||
|
||||
it.effect("loads legacy revert storage into canonical state", () =>
|
||||
Effect.gen(function* () {
|
||||
const db = yield* seedSession()
|
||||
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 legacy = JSON.stringify({
|
||||
messageID: "msg_boundary",
|
||||
snapshot: "tree",
|
||||
@@ -123,14 +131,28 @@ describe("SessionProjector", () => {
|
||||
|
||||
it.effect("projects staged, cleared, and committed reverts", () =>
|
||||
Effect.gen(function* () {
|
||||
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 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 boundary = SessionMessage.ID.make("msg_boundary")
|
||||
const earlier = SessionMessage.ID.make("msg_earlier")
|
||||
yield* db
|
||||
@@ -205,7 +227,24 @@ describe("SessionProjector", () => {
|
||||
|
||||
it.effect("orders projected messages and context by durable aggregate sequence", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* seedSession()
|
||||
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 bus = yield* Bus.Service
|
||||
|
||||
yield* bus.publish(SessionEvent.InboxEnqueued, {
|
||||
@@ -261,7 +300,24 @@ describe("SessionProjector", () => {
|
||||
|
||||
it.effect("maps malformed persisted rows consistently while single-message lookup defects", () =>
|
||||
Effect.gen(function* () {
|
||||
const db = yield* seedSession()
|
||||
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 messageID = SessionMessage.ID.make("msg_malformed")
|
||||
yield* db
|
||||
.insert(SessionMessageTable)
|
||||
@@ -288,7 +344,24 @@ describe("SessionProjector", () => {
|
||||
|
||||
it.effect("consumes the pending row and projects the message at promotion", () =>
|
||||
Effect.gen(function* () {
|
||||
const db = yield* seedSession()
|
||||
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 bus = yield* Bus.Service
|
||||
const id = SessionMessage.ID.make("msg_admitted")
|
||||
const admitted = yield* SessionInbox.admit(db, bus, {
|
||||
@@ -314,7 +387,26 @@ describe("SessionProjector", () => {
|
||||
|
||||
it.effect("projects durable context messages supported by the updater", () =>
|
||||
Effect.gen(function* () {
|
||||
const db = yield* seedSession({ agent: "plan", model: previousModel })
|
||||
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 bus = yield* Bus.Service
|
||||
|
||||
yield* bus.publish(SessionEvent.AgentSelected, {
|
||||
@@ -440,7 +532,24 @@ describe("SessionProjector", () => {
|
||||
|
||||
it.effect("rejects distinct creator events that reuse one projected message ID", () =>
|
||||
Effect.gen(function* () {
|
||||
const db = yield* seedSession()
|
||||
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 bus = yield* Bus.Service
|
||||
const id = SessionMessage.ID.make("msg_creator_collision")
|
||||
const { id: _, type, ...data } = encodeMessage({ id, type: "synthetic", text: "existing", time: { created } })
|
||||
@@ -467,7 +576,24 @@ describe("SessionProjector", () => {
|
||||
|
||||
it.effect("projects retry state and clears it at the next step or execution terminal", () =>
|
||||
Effect.gen(function* () {
|
||||
const db = yield* seedSession()
|
||||
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 bus = yield* Bus.Service
|
||||
const first = SessionMessage.ID.make("msg_retry_first")
|
||||
const second = SessionMessage.ID.make("msg_retry_second")
|
||||
@@ -517,7 +643,24 @@ describe("SessionProjector", () => {
|
||||
|
||||
it.effect("does not infer restart continuation from lifecycle history", () =>
|
||||
Effect.gen(function* () {
|
||||
const db = yield* seedSession()
|
||||
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 bus = yield* Bus.Service
|
||||
const suspended = () =>
|
||||
db
|
||||
@@ -537,7 +680,24 @@ describe("SessionProjector", () => {
|
||||
|
||||
it.effect("updates only the newest incomplete assistant projection", () =>
|
||||
Effect.gen(function* () {
|
||||
const db = yield* seedSession()
|
||||
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* db
|
||||
.insert(SessionMessageTable)
|
||||
.values([
|
||||
@@ -597,7 +757,24 @@ describe("SessionProjector", () => {
|
||||
|
||||
it.effect("projects ended and failed step terminal state", () =>
|
||||
Effect.gen(function* () {
|
||||
const db = yield* seedSession()
|
||||
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 endedID = SessionMessage.ID.make("msg_ended")
|
||||
const failedID = SessionMessage.ID.make("msg_failed")
|
||||
yield* db
|
||||
@@ -667,7 +844,24 @@ describe("SessionProjector", () => {
|
||||
|
||||
it.effect("does not revive a stale incomplete assistant projection", () =>
|
||||
Effect.gen(function* () {
|
||||
const db = yield* seedSession()
|
||||
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* db
|
||||
.insert(SessionMessageTable)
|
||||
.values([
|
||||
|
||||
@@ -78,7 +78,8 @@ const locations = Layer.effect(
|
||||
Layer.mock(Snapshot.Service, {
|
||||
capture: () =>
|
||||
ready ? Effect.undefined : Effect.die(new Error("Snapshot used before plugins were ready")),
|
||||
restore: () => (ready ? Effect.void : Effect.die(new Error("Snapshot used before plugins were ready"))),
|
||||
restore: () =>
|
||||
ready ? Effect.void : Effect.die(new Error("Snapshot used before plugins were ready")),
|
||||
}),
|
||||
Layer.succeed(
|
||||
PluginSupervisor.Service,
|
||||
@@ -171,9 +172,8 @@ const assistantRow = (id: SessionMessage.ID, seq: number) => {
|
||||
describe("Session.prompt", () => {
|
||||
it.effect("exposes the execution registry", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
activeSessions.add(sessionID)
|
||||
expect(Array.from(yield* session.active)).toEqual([sessionID])
|
||||
expect(Array.from(yield* (yield* Session.Service).active)).toEqual([sessionID])
|
||||
}).pipe(Effect.ensuring(Effect.sync(() => activeSessions.clear()))),
|
||||
)
|
||||
|
||||
@@ -557,7 +557,7 @@ describe("Session.prompt", () => {
|
||||
yield* session.resume(sessionID)
|
||||
|
||||
expect(yield* session.messages({ sessionID })).toEqual([])
|
||||
expect((yield* session.inbox(sessionID)).map((item) => item.id)).toEqual([message.id])
|
||||
expect(yield* admitted(message.id)).not.toHaveProperty("promotedSeq")
|
||||
expect(executionCalls).toEqual([sessionID])
|
||||
expect(wakeCalls).toEqual([])
|
||||
}),
|
||||
|
||||
@@ -198,20 +198,18 @@ 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* () {
|
||||
yield* enableTitleAgent
|
||||
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."
|
||||
})
|
||||
})
|
||||
const sessionID = Session.ID.make("ses_title_generate")
|
||||
yield* insertSession(sessionID)
|
||||
yield* prompt(sessionID, "Help me debug the failing build")
|
||||
@@ -242,8 +240,17 @@ 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
|
||||
yield* enableTitleAgent
|
||||
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."
|
||||
})
|
||||
})
|
||||
const sessionID = Session.ID.make("ses_title_small_model")
|
||||
yield* insertSession(sessionID)
|
||||
yield* prompt(sessionID, "Use a small model for this title")
|
||||
@@ -260,12 +267,20 @@ 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
|
||||
yield* enableTitleAgent
|
||||
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."
|
||||
})
|
||||
})
|
||||
const sessionID = Session.ID.make("ses_title_small_fallback")
|
||||
yield* insertSession(
|
||||
sessionID,
|
||||
@@ -299,7 +314,16 @@ 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* () {
|
||||
yield* enableTitleAgent
|
||||
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."
|
||||
})
|
||||
})
|
||||
const sessionID = Session.ID.make("ses_title_second_message")
|
||||
yield* insertSession(sessionID)
|
||||
yield* prompt(sessionID, "First message")
|
||||
@@ -318,7 +342,16 @@ it.effect("generates from the first user message after later messages exist", ()
|
||||
|
||||
it.effect("retries a legacy persisted fallback title", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* enableTitleAgent
|
||||
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."
|
||||
})
|
||||
})
|
||||
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)
|
||||
@@ -335,7 +368,16 @@ it.effect("retries a legacy persisted fallback title", () =>
|
||||
|
||||
it.effect("generates a title for an explicitly requested child session", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* enableTitleAgent
|
||||
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."
|
||||
})
|
||||
})
|
||||
const sessionID = Session.ID.make("ses_title_child")
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
@@ -369,6 +411,8 @@ 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")
|
||||
@@ -385,7 +429,14 @@ it.effect("does not generate when the title agent is removed", () =>
|
||||
|
||||
it.effect("regenerates an existing title using the title agent", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* enableTitleAgent
|
||||
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."
|
||||
})
|
||||
})
|
||||
const sessionID = Session.ID.make("ses_title_regenerate")
|
||||
yield* insertSession(sessionID, "Original title")
|
||||
yield* prompt(sessionID, "Investigate the login failure")
|
||||
@@ -429,7 +480,14 @@ 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* () {
|
||||
yield* enableTitleAgent
|
||||
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."
|
||||
})
|
||||
})
|
||||
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`)
|
||||
@@ -451,7 +509,14 @@ it.effect("bounds regeneration context while preserving the original request and
|
||||
|
||||
it.effect("preserves the existing title when regeneration fails", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* enableTitleAgent
|
||||
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."
|
||||
})
|
||||
})
|
||||
const sessionID = Session.ID.make("ses_title_regenerate_failure")
|
||||
yield* insertSession(sessionID, "Original title")
|
||||
yield* prompt(sessionID, "Fail to regenerate this title")
|
||||
@@ -468,7 +533,15 @@ it.effect("preserves the existing title when regeneration fails", () =>
|
||||
|
||||
it.effect("retries after a failed title request", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* enableTitleAgent
|
||||
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."
|
||||
})
|
||||
})
|
||||
const sessionID = Session.ID.make("ses_title_retry")
|
||||
yield* insertSession(sessionID)
|
||||
yield* prompt(sessionID, "Retry this title")
|
||||
@@ -487,7 +560,14 @@ it.effect("retries after a failed title request", () =>
|
||||
|
||||
it.effect("does not rename after a failed title stream", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* enableTitleAgent
|
||||
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."
|
||||
})
|
||||
})
|
||||
const sessionID = Session.ID.make("ses_title_stream_failure")
|
||||
yield* insertSession(sessionID)
|
||||
yield* prompt(sessionID, "Fail this title stream")
|
||||
@@ -509,7 +589,16 @@ it.effect("does not rename after a failed title stream", () =>
|
||||
|
||||
it.effect("keeps session context hooks away from title requests", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* enableTitleAgent
|
||||
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."
|
||||
})
|
||||
})
|
||||
// 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
|
||||
@@ -532,7 +621,15 @@ 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* () {
|
||||
yield* enableTitleAgent
|
||||
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."
|
||||
})
|
||||
})
|
||||
const sessionID = Session.ID.make("ses_title_manual_rename")
|
||||
yield* insertSession(sessionID)
|
||||
yield* prompt(sessionID, "Generate this title")
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { State } from "@opencode-ai/core/state"
|
||||
import { Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect"
|
||||
import { Cause, Deferred, Effect, Exit, Fiber, Scheduler, Scope } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
function valuesState(
|
||||
hooks: Pick<State.Options<{ values: string[] }, { add: (item: string) => void }>, "prepare" | "notify"> = {},
|
||||
) {
|
||||
return State.create({
|
||||
initial: () => ({ values: new Array<string>() }),
|
||||
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
|
||||
...hooks,
|
||||
})
|
||||
}
|
||||
|
||||
describe("State", () => {
|
||||
it.effect("commits a transform atomically when its updater is interrupted", () =>
|
||||
@@ -12,17 +20,13 @@ describe("State", () => {
|
||||
const rebuilding = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
let block = true
|
||||
const state = State.create({
|
||||
initial: () => ({ values: [] as string[] }),
|
||||
draft: (draft) => ({ add: (value: string) => draft.values.push(value) }),
|
||||
finalize: () =>
|
||||
const state = valuesState({
|
||||
notify: () =>
|
||||
block ? Deferred.succeed(rebuilding, undefined).pipe(Effect.andThen(Deferred.await(release))) : Effect.void,
|
||||
})
|
||||
const scope = yield* Scope.make()
|
||||
const fiber = yield* state
|
||||
.transform((editor) => {
|
||||
editor.add("registered")
|
||||
})
|
||||
.transform((editor) => editor.add("registered"))
|
||||
.pipe(Scope.provide(scope), Effect.forkChild)
|
||||
yield* Deferred.await(rebuilding)
|
||||
const interruption = yield* Fiber.interrupt(fiber).pipe(Effect.forkChild)
|
||||
@@ -36,20 +40,16 @@ describe("State", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("commits rebuilt state before finalize runs", () =>
|
||||
it.effect("makes rebuilt state visible before notifying", () =>
|
||||
Effect.gen(function* () {
|
||||
const observed: string[][] = []
|
||||
const state: State.Interface<{ values: string[] }, { add: (item: string) => void }> = State.create({
|
||||
initial: () => ({ values: [] as string[] }),
|
||||
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
|
||||
finalize: () => Effect.sync(() => observed.push([...state.get().values])),
|
||||
const state: ReturnType<typeof valuesState> = valuesState({
|
||||
notify: () => Effect.sync(() => observed.push([...state.get().values])),
|
||||
})
|
||||
|
||||
yield* state.transform((draft) => {
|
||||
draft.add("value")
|
||||
})
|
||||
yield* state.transform((draft) => draft.add("value"))
|
||||
|
||||
// Update events publish from finalize, so consumers reading on the event
|
||||
// Update events publish from notify, so consumers reading on the event
|
||||
// must observe the rebuilt state, not the previous one.
|
||||
expect(observed).toEqual([["value"]])
|
||||
}),
|
||||
@@ -58,14 +58,9 @@ describe("State", () => {
|
||||
it.effect("runs transforms during every reload", () =>
|
||||
Effect.gen(function* () {
|
||||
let value = "first"
|
||||
const state = State.create({
|
||||
initial: () => ({ values: [] as string[] }),
|
||||
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
|
||||
})
|
||||
const state = valuesState()
|
||||
|
||||
yield* state.transform((editor) => {
|
||||
editor.add(value)
|
||||
})
|
||||
yield* state.transform((editor) => editor.add(value))
|
||||
expect(state.get().values).toEqual(["first"])
|
||||
|
||||
value = "second"
|
||||
@@ -76,18 +71,327 @@ describe("State", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reads registrations and disposals inside a batch without publishing", () =>
|
||||
Effect.gen(function* () {
|
||||
const observed: string[][] = []
|
||||
let replays = 0
|
||||
const state: ReturnType<typeof valuesState> = valuesState({
|
||||
notify: () => Effect.sync(() => observed.push([...state.get().values])),
|
||||
})
|
||||
const scope = yield* Scope.make()
|
||||
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* state
|
||||
.transform((draft) => {
|
||||
replays++
|
||||
draft.add("value")
|
||||
})
|
||||
.pipe(Scope.provide(scope))
|
||||
|
||||
const snapshot = state.get()
|
||||
expect(snapshot.values).toEqual(["value"])
|
||||
expect(state.get()).toBe(snapshot)
|
||||
expect(replays).toBe(1)
|
||||
expect(observed).toEqual([])
|
||||
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
expect(state.get().values).toEqual([])
|
||||
expect(snapshot.values).toEqual(["value"])
|
||||
expect(observed).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
expect(observed).toEqual([[]])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reads a requested reload without waiting for its notification debounce", () =>
|
||||
Effect.gen(function* () {
|
||||
let value = "first"
|
||||
let replays = 0
|
||||
const observed: string[][] = []
|
||||
const state: ReturnType<typeof valuesState> = valuesState({
|
||||
notify: () => Effect.sync(() => observed.push([...state.get().values])),
|
||||
})
|
||||
yield* state.transform((draft) => {
|
||||
replays++
|
||||
draft.add(value)
|
||||
})
|
||||
const snapshot = state.get()
|
||||
observed.length = 0
|
||||
|
||||
value = "second"
|
||||
const reload = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("50 millis")
|
||||
|
||||
expect(state.get().values).toEqual(["second"])
|
||||
expect(snapshot.values).toEqual(["first"])
|
||||
expect(replays).toBe(2)
|
||||
expect(observed).toEqual([])
|
||||
|
||||
yield* TestClock.adjust("450 millis")
|
||||
yield* Fiber.join(reload)
|
||||
expect(observed).toEqual([["second"]])
|
||||
expect(replays).toBe(2)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("can await reload inside a batch while deferring its notification", () =>
|
||||
Effect.gen(function* () {
|
||||
let value = "first"
|
||||
let notifications = 0
|
||||
const state = valuesState({ notify: () => Effect.sync(() => notifications++) })
|
||||
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* state.transform((draft) => draft.add(value))
|
||||
expect(state.get().values).toEqual(["first"])
|
||||
value = "second"
|
||||
yield* state.reload()
|
||||
expect(state.get().values).toEqual(["second"])
|
||||
expect(notifications).toBe(0)
|
||||
}),
|
||||
)
|
||||
|
||||
expect(notifications).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("prepares derived data during reads without running observers", () =>
|
||||
Effect.gen(function* () {
|
||||
let notifications = 0
|
||||
const state = State.create({
|
||||
initial: () => ({ values: [] as string[], joined: "" }),
|
||||
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
|
||||
prepare: (data) => {
|
||||
data.joined = data.values.join(",")
|
||||
},
|
||||
notify: () => Effect.sync(() => notifications++),
|
||||
})
|
||||
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* state.transform((draft) => draft.add("first"))
|
||||
yield* state.transform((draft) => draft.add("second"))
|
||||
expect(state.get().joined).toBe("first,second")
|
||||
expect(notifications).toBe(0)
|
||||
}),
|
||||
)
|
||||
|
||||
expect(notifications).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps replay failures observable without replacing the previous snapshot", () =>
|
||||
Effect.gen(function* () {
|
||||
let fail = false
|
||||
const state = valuesState({
|
||||
prepare: () => {
|
||||
if (fail) throw new Error("preparation failed")
|
||||
},
|
||||
})
|
||||
yield* state.transform((draft) => draft.add("first"))
|
||||
const snapshot = state.get()
|
||||
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* state.transform((draft) => draft.add("second"))
|
||||
fail = true
|
||||
expect(() => state.get()).toThrow("preparation failed")
|
||||
expect(() => state.get()).toThrow("preparation failed")
|
||||
expect(snapshot.values).toEqual(["first"])
|
||||
fail = false
|
||||
expect(state.get().values).toEqual(["first", "second"])
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("allows an observer to await a registration on the same state", () =>
|
||||
Effect.gen(function* () {
|
||||
const scope = yield* Scope.Scope
|
||||
let added = false
|
||||
const observed: string[][] = []
|
||||
const state: ReturnType<typeof valuesState> = valuesState({
|
||||
notify: () =>
|
||||
Effect.gen(function* () {
|
||||
observed.push([...state.get().values])
|
||||
if (added) return
|
||||
added = true
|
||||
yield* state.transform((draft) => draft.add("second")).pipe(Scope.provide(scope))
|
||||
}),
|
||||
})
|
||||
|
||||
yield* state.transform((draft) => draft.add("first"))
|
||||
expect(observed).toEqual([["first"], ["first", "second"]])
|
||||
expect(state.get().values).toEqual(["first", "second"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("allows a debounced observer to await another reload", () =>
|
||||
Effect.gen(function* () {
|
||||
let value = "first"
|
||||
let reloadAgain = false
|
||||
const observed: string[][] = []
|
||||
const state: ReturnType<typeof valuesState> = valuesState({
|
||||
notify: () =>
|
||||
Effect.gen(function* () {
|
||||
observed.push([...state.get().values])
|
||||
if (!reloadAgain) return
|
||||
reloadAgain = false
|
||||
value = "third"
|
||||
yield* state.reload()
|
||||
}),
|
||||
})
|
||||
yield* state.transform((draft) => draft.add(value))
|
||||
observed.length = 0
|
||||
|
||||
value = "second"
|
||||
reloadAgain = true
|
||||
const reload = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("1 second")
|
||||
yield* Fiber.join(reload)
|
||||
|
||||
expect(observed).toEqual([["second"], ["third"]])
|
||||
expect(state.get().values).toEqual(["third"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps reload waiters associated with their own notification results", () =>
|
||||
Effect.gen(function* () {
|
||||
const entered = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
let value = "first"
|
||||
let block = false
|
||||
const observed: string[][] = []
|
||||
const state: ReturnType<typeof valuesState> = valuesState({
|
||||
notify: () =>
|
||||
Effect.gen(function* () {
|
||||
observed.push([...state.get().values])
|
||||
if (!block) return
|
||||
block = false
|
||||
yield* Deferred.succeed(entered, undefined)
|
||||
yield* Deferred.await(release)
|
||||
return yield* Effect.die(new Error("first notification failed"))
|
||||
}),
|
||||
})
|
||||
yield* state.transform((draft) => draft.add(value))
|
||||
// Release the detached worker before the earlier registration finalizer runs.
|
||||
yield* Effect.addFinalizer(() => Deferred.succeed(release, undefined))
|
||||
observed.length = 0
|
||||
|
||||
value = "second"
|
||||
block = true
|
||||
const first = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Deferred.await(entered)
|
||||
|
||||
value = "third"
|
||||
const second = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
expect(state.get().values).toEqual(["third"])
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Fiber.join(second)
|
||||
expect(first.pollUnsafe()).toBeUndefined()
|
||||
expect(observed).toEqual([["second"], ["third"]])
|
||||
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
const exit = yield* Fiber.await(first)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("first notification failed")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps coalesced reload callers independent of cancellation and shares their notification failure", () =>
|
||||
Effect.gen(function* () {
|
||||
let fail = false
|
||||
let notifications = 0
|
||||
const failure = new Error("notification failed")
|
||||
const state = State.create({
|
||||
initial: () => ({}),
|
||||
draft: (draft) => draft,
|
||||
notify: () =>
|
||||
Effect.sync(() => {
|
||||
notifications++
|
||||
if (fail) throw failure
|
||||
}),
|
||||
})
|
||||
yield* state.transform(() => {})
|
||||
notifications = 0
|
||||
fail = true
|
||||
|
||||
const cancelled = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
const first = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
const second = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* Fiber.interrupt(cancelled)
|
||||
yield* TestClock.adjust("500 millis")
|
||||
const exits = yield* Fiber.awaitAll([first, second])
|
||||
fail = false
|
||||
|
||||
expect(Exit.hasInterrupts(yield* Fiber.await(cancelled))).toBe(true)
|
||||
expect(exits.map((exit) => Exit.isFailure(exit) && Cause.squash(exit.cause))).toEqual([failure, failure])
|
||||
expect(notifications).toBe(1)
|
||||
|
||||
const recovered = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Fiber.join(recovered)
|
||||
expect(notifications).toBe(2)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues publishing when a reload caller is cancelled while scheduling its worker", () =>
|
||||
Effect.gen(function* () {
|
||||
let value = "first"
|
||||
let notifications = 0
|
||||
let interrupted = false
|
||||
const state = valuesState({ notify: () => Effect.sync(() => notifications++) })
|
||||
yield* state.transform((draft) => draft.add(value))
|
||||
notifications = 0
|
||||
|
||||
value = "second"
|
||||
const cancelled = yield* Effect.withFiber((fiber) => {
|
||||
const base = new Scheduler.MixedScheduler("sync")
|
||||
const scheduler: Scheduler.Scheduler = {
|
||||
executionMode: base.executionMode,
|
||||
// Keep the first scheduled task at the detached worker handoff.
|
||||
shouldYield: () => false,
|
||||
makeDispatcher: () => {
|
||||
const dispatcher = base.makeDispatcher()
|
||||
return {
|
||||
scheduleTask: (task, priority) => {
|
||||
if (!interrupted) {
|
||||
interrupted = true
|
||||
fiber.interruptUnsafe()
|
||||
}
|
||||
dispatcher.scheduleTask(task, priority)
|
||||
},
|
||||
flush: () => dispatcher.flush(),
|
||||
}
|
||||
},
|
||||
}
|
||||
return state.reload().pipe(Effect.provideService(Scheduler.Scheduler, scheduler))
|
||||
}).pipe(Effect.forkChild({ startImmediately: true }))
|
||||
const exit = yield* Fiber.await(cancelled)
|
||||
expect(interrupted).toBe(true)
|
||||
expect(Exit.hasInterrupts(exit)).toBe(true)
|
||||
expect(state.get().values).toEqual(["second"])
|
||||
yield* TestClock.adjust("500 millis")
|
||||
expect(notifications).toBe(1)
|
||||
|
||||
value = "third"
|
||||
const reload = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Fiber.join(reload)
|
||||
expect(state.get().values).toEqual(["third"])
|
||||
expect(notifications).toBe(2)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("disposes a transform once and rebuilds remaining state", () =>
|
||||
Effect.gen(function* () {
|
||||
const state = State.create({
|
||||
initial: () => ({ values: [] as string[] }),
|
||||
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
|
||||
})
|
||||
yield* state.transform((editor) => {
|
||||
editor.add("first")
|
||||
})
|
||||
const registration = yield* state.transform((editor) => {
|
||||
editor.add("second")
|
||||
})
|
||||
const state = valuesState()
|
||||
yield* state.transform((editor) => editor.add("first"))
|
||||
const registration = yield* state.transform((editor) => editor.add("second"))
|
||||
expect(state.get().values).toEqual(["first", "second"])
|
||||
|
||||
yield* registration.dispose
|
||||
@@ -98,49 +402,137 @@ describe("State", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("batches automatic rebuilds", () =>
|
||||
it.effect("batches notifications", () =>
|
||||
Effect.gen(function* () {
|
||||
let finalized = 0
|
||||
const first = State.create({
|
||||
initial: () => ({ values: [] as string[] }),
|
||||
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
|
||||
finalize: () => Effect.sync(() => finalized++),
|
||||
})
|
||||
const second = State.create({
|
||||
initial: () => ({ values: [] as string[] }),
|
||||
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
|
||||
finalize: () => Effect.sync(() => finalized++),
|
||||
})
|
||||
let notifications = 0
|
||||
const first = valuesState({ notify: () => Effect.sync(() => notifications++) })
|
||||
const second = valuesState({ notify: () => Effect.sync(() => notifications++) })
|
||||
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* first.transform((draft) => {
|
||||
draft.add("first")
|
||||
})
|
||||
yield* first.transform((draft) => {
|
||||
draft.add("second")
|
||||
})
|
||||
yield* second.transform((draft) => {
|
||||
draft.add("third")
|
||||
})
|
||||
expect(finalized).toBe(0)
|
||||
yield* first.transform((draft) => draft.add("first"))
|
||||
yield* first.transform((draft) => draft.add("second"))
|
||||
yield* second.transform((draft) => draft.add("third"))
|
||||
expect(notifications).toBe(0)
|
||||
}),
|
||||
)
|
||||
|
||||
expect(first.get().values).toEqual(["first", "second"])
|
||||
expect(second.get().values).toEqual(["third"])
|
||||
expect(finalized).toBe(2)
|
||||
expect(notifications).toBe(2)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("closes a batched observer's owning scope without losing the body's failure", () =>
|
||||
Effect.gen(function* () {
|
||||
const entered = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const registrations = yield* Scope.make()
|
||||
const owner = yield* Scope.make()
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Deferred.succeed(release, undefined).pipe(
|
||||
Effect.andThen(Scope.close(owner, Exit.void)),
|
||||
Effect.andThen(State.batch(Scope.close(registrations, Exit.void), { flush: false })),
|
||||
),
|
||||
)
|
||||
const state = State.create({
|
||||
initial: () => ({}),
|
||||
draft: (draft) => draft,
|
||||
notify: () => Deferred.succeed(entered, undefined).pipe(Effect.andThen(Deferred.await(release))),
|
||||
})
|
||||
const writer = yield* State.batch(
|
||||
state.transform(() => {}).pipe(Scope.provide(registrations), Effect.andThen(Effect.fail("batch body failed"))),
|
||||
).pipe(Effect.forkIn(owner, { startImmediately: true }))
|
||||
yield* Deferred.await(entered)
|
||||
|
||||
const shutdown = yield* Scope.close(owner, Exit.void).pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("1 millis")
|
||||
expect(shutdown.pollUnsafe()).toBeDefined()
|
||||
expect(yield* Deferred.isDone(release)).toBe(false)
|
||||
const exit = yield* Fiber.await(writer)
|
||||
expect(Exit.hasInterrupts(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("batch body failed")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lets batch observers read the other states' accepted changes", () =>
|
||||
Effect.gen(function* () {
|
||||
const observed: string[][] = []
|
||||
const first = valuesState({
|
||||
notify: () => Effect.sync(() => observed.push([...second.get().values])),
|
||||
})
|
||||
const second = valuesState()
|
||||
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* first.transform((draft) => draft.add("first"))
|
||||
yield* second.transform((draft) => draft.add("second"))
|
||||
}),
|
||||
)
|
||||
|
||||
expect(observed).toEqual([["second"]])
|
||||
}),
|
||||
)
|
||||
;["replay", "notification"].forEach((failure) =>
|
||||
it.effect(`notifies the other states when a batch ${failure} fails`, () =>
|
||||
Effect.gen(function* () {
|
||||
let fail = true
|
||||
const observed: string[] = []
|
||||
const first = State.create({
|
||||
initial: () => ({}),
|
||||
draft: (draft) => draft,
|
||||
notify: () => Effect.sync(() => observed.push("first")),
|
||||
})
|
||||
const failing = State.create({
|
||||
initial: () => ({}),
|
||||
draft: (draft) => draft,
|
||||
prepare: () => {
|
||||
if (fail && failure === "replay") throw new Error("replay failed")
|
||||
},
|
||||
notify: () =>
|
||||
fail ? Effect.die(new Error("notification failed")) : Effect.sync(() => observed.push("failing")),
|
||||
})
|
||||
const last = State.create({
|
||||
initial: () => ({}),
|
||||
draft: (draft) => draft,
|
||||
notify: () => Effect.sync(() => observed.push("last")),
|
||||
})
|
||||
|
||||
const exit = yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* first.transform(() => {})
|
||||
yield* failing.transform(() => {})
|
||||
yield* last.transform(() => {})
|
||||
return yield* Effect.die(new Error("batch failed"))
|
||||
}),
|
||||
).pipe(Effect.exit)
|
||||
fail = false
|
||||
|
||||
expect(observed).toEqual(["first", "last"])
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) {
|
||||
expect(Cause.pretty(exit.cause)).toContain("batch failed")
|
||||
expect(Cause.pretty(exit.cause)).toContain(`${failure} failed`)
|
||||
}
|
||||
|
||||
const reload = yield* failing.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Fiber.join(reload)
|
||||
expect(observed).toEqual(["first", "last", "failing"])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("discards teardown rebuilds and pending reloads while still running cleanup", () =>
|
||||
Effect.gen(function* () {
|
||||
let finalized = 0
|
||||
let notifications = 0
|
||||
let prepared = 0
|
||||
let disposed = 0
|
||||
const state = State.create({
|
||||
initial: () => ({ values: [] as string[] }),
|
||||
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
|
||||
finalize: () => Effect.sync(() => finalized++),
|
||||
const state = valuesState({
|
||||
prepare: () => {
|
||||
prepared++
|
||||
},
|
||||
notify: () => Effect.sync(() => notifications++),
|
||||
})
|
||||
const scope = yield* Scope.make()
|
||||
yield* Scope.addFinalizer(
|
||||
@@ -148,38 +540,44 @@ describe("State", () => {
|
||||
Effect.sync(() => disposed++),
|
||||
)
|
||||
const registration = yield* state.transform((draft) => draft.add("value")).pipe(Scope.provide(scope))
|
||||
expect(finalized).toBe(1)
|
||||
const snapshot = state.get()
|
||||
expect(notifications).toBe(1)
|
||||
expect(prepared).toBe(1)
|
||||
|
||||
const pending = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("250 millis")
|
||||
yield* State.batch(Scope.close(scope, Exit.void), { flush: false })
|
||||
expect(disposed).toBe(1)
|
||||
expect(finalized).toBe(1)
|
||||
expect(notifications).toBe(1)
|
||||
expect(state.get()).toBe(snapshot)
|
||||
expect(prepared).toBe(1)
|
||||
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Fiber.join(pending)
|
||||
yield* registration.dispose
|
||||
yield* state.reload()
|
||||
expect(finalized).toBe(1)
|
||||
expect(notifications).toBe(1)
|
||||
expect(state.get()).toBe(snapshot)
|
||||
expect(prepared).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps teardown suppression separate from an enclosing live batch", () =>
|
||||
Effect.gen(function* () {
|
||||
const finalized: string[] = []
|
||||
const notifications: string[] = []
|
||||
const closing = State.create({
|
||||
initial: () => ({}),
|
||||
draft: (draft) => draft,
|
||||
finalize: () => Effect.sync(() => finalized.push("closing")),
|
||||
notify: () => Effect.sync(() => notifications.push("closing")),
|
||||
})
|
||||
const live = State.create({
|
||||
initial: () => ({}),
|
||||
draft: (draft) => draft,
|
||||
finalize: () => Effect.sync(() => finalized.push("live")),
|
||||
notify: () => Effect.sync(() => notifications.push("live")),
|
||||
})
|
||||
const scope = yield* Scope.make()
|
||||
yield* closing.transform(() => {}).pipe(Scope.provide(scope))
|
||||
finalized.length = 0
|
||||
notifications.length = 0
|
||||
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
@@ -187,33 +585,27 @@ describe("State", () => {
|
||||
yield* State.batch(Scope.close(scope, Exit.void), { flush: false })
|
||||
}),
|
||||
)
|
||||
expect(finalized).toEqual(["live"])
|
||||
expect(notifications).toEqual(["live"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("debounces reload bursts", () =>
|
||||
Effect.gen(function* () {
|
||||
let finalized = 0
|
||||
const state = State.create({
|
||||
initial: () => ({ values: [] as string[] }),
|
||||
draft: (draft) => ({ add: (item: string) => draft.values.push(item) }),
|
||||
finalize: () => Effect.sync(() => finalized++),
|
||||
})
|
||||
yield* state.transform((draft) => {
|
||||
draft.add("value")
|
||||
})
|
||||
finalized = 0
|
||||
let notifications = 0
|
||||
const state = valuesState({ notify: () => Effect.sync(() => notifications++) })
|
||||
yield* state.transform((draft) => draft.add("value"))
|
||||
notifications = 0
|
||||
|
||||
const first = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("250 millis")
|
||||
const second = yield* state.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("499 millis")
|
||||
expect(finalized).toBe(0)
|
||||
expect(notifications).toBe(0)
|
||||
yield* TestClock.adjust("1 millis")
|
||||
yield* Fiber.join(first)
|
||||
yield* Fiber.join(second)
|
||||
|
||||
expect(finalized).toBe(1)
|
||||
expect(notifications).toBe(1)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -105,9 +105,11 @@ test("foreign typed failures settle as Tool.Error at the untrusted boundary", as
|
||||
execute: () => new ForeignFailure({ message: "transport died" }) as never,
|
||||
}
|
||||
|
||||
const error = await Effect.runPromise(execute(lying, {}, context).pipe(Effect.flip))
|
||||
const exit = await Effect.runPromiseExit(execute(lying, {}, context))
|
||||
expect(exit._tag).toBe("Failure")
|
||||
const error = exit._tag === "Failure" ? exit.cause.reasons.find((reason) => "error" in reason)?.error : undefined
|
||||
expect(error).toBeInstanceOf(Tool.Error)
|
||||
expect(error.message).toBe("transport died")
|
||||
expect((error as Tool.Error).message).toBe("transport died")
|
||||
})
|
||||
|
||||
test("execute supports callable namespace tools", async () => {
|
||||
|
||||
@@ -311,7 +311,7 @@ describe("Tool", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replays empty sources on reload and keeps advertised snapshots", () =>
|
||||
it.effect("reads refreshed sources before notifications and keeps advertised snapshots", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
let source: Info[] = []
|
||||
@@ -321,24 +321,24 @@ describe("Tool", () => {
|
||||
const tool = { ...constant("first"), name: "echo", options: { codemode: false } }
|
||||
source = [tool]
|
||||
const first = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Fiber.join(first)
|
||||
const advertised = yield* service.snapshot()
|
||||
expect((yield* advertised.execute(call("echo"))).output).toEqual({ text: "first" })
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Fiber.join(first)
|
||||
|
||||
tool.execute = constant("second").execute
|
||||
expect((yield* advertised.execute(call("echo"))).output).toEqual({ text: "first" })
|
||||
const second = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
expect((yield* executeTool(service, call("echo"))).output).toEqual({ text: "second" })
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Fiber.join(second)
|
||||
expect((yield* executeTool(service, call("echo"))).output).toEqual({ text: "second" })
|
||||
expect((yield* advertised.execute(call("echo"))).output).toEqual({ text: "first" })
|
||||
|
||||
source = []
|
||||
const removed = yield* service.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Fiber.join(removed)
|
||||
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
|
||||
expect((yield* advertised.execute(call("echo"))).output).toEqual({ text: "first" })
|
||||
}),
|
||||
)
|
||||
@@ -370,7 +370,7 @@ describe("Tool", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("batches tool publication and suppresses terminal teardown replay", () =>
|
||||
it.effect("batches tool notifications with fresh snapshots and suppresses terminal teardown replay", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
const runs: string[] = []
|
||||
@@ -386,7 +386,8 @@ describe("Tool", () => {
|
||||
draft.add({ ...constant("overlay"), name: "echo", options: { codemode: false } })
|
||||
})
|
||||
expect(runs).toEqual([])
|
||||
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
|
||||
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["echo", "execute"])
|
||||
expect(runs).toEqual(["base", "overlay"])
|
||||
}).pipe(Scope.provide(scope)),
|
||||
)
|
||||
|
||||
@@ -545,23 +546,32 @@ describe("Tool", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("logs invalid tool definitions without dropping healthy tools", () => {
|
||||
it.effect("compiles healthy tools before notifying invalid definition diagnostics", () => {
|
||||
const output: unknown[] = []
|
||||
const logger = Logger.map(Logger.formatStructured, (entry) => {
|
||||
output.push(entry.message)
|
||||
})
|
||||
return Effect.gen(function* () {
|
||||
const service = yield* Tool.Service
|
||||
yield* service.transform((draft) => {
|
||||
draft.add({ ...make(), name: "healthy", options: { codemode: false } })
|
||||
draft.add({
|
||||
name: "phone_type",
|
||||
input: Schema.Struct({}),
|
||||
execute: () => Effect.succeed({ content: "ok" }),
|
||||
options: { codemode: false },
|
||||
} as unknown as Info)
|
||||
draft.add({ ...make(), name: "codemode" })
|
||||
})
|
||||
const snapshot = yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* service.transform((draft) => {
|
||||
draft.add({ ...make(), name: "healthy", options: { codemode: false } })
|
||||
draft.add({
|
||||
name: "phone_type",
|
||||
input: Schema.Struct({}),
|
||||
execute: () => Effect.succeed({ content: "ok" }),
|
||||
options: { codemode: false },
|
||||
} as unknown as Info)
|
||||
draft.add({ ...make(), name: "codemode" })
|
||||
})
|
||||
const snapshot = yield* service.snapshot()
|
||||
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["healthy", "execute"])
|
||||
expect(snapshot.codeModeCatalog?.map((tool) => tool.path)).toEqual(["codemode"])
|
||||
expect(output).toEqual([])
|
||||
return snapshot
|
||||
}),
|
||||
)
|
||||
|
||||
expect(output).toEqual([
|
||||
[
|
||||
@@ -573,9 +583,6 @@ describe("Tool", () => {
|
||||
},
|
||||
],
|
||||
])
|
||||
const snapshot = yield* service.snapshot()
|
||||
expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["healthy", "execute"])
|
||||
expect(snapshot.codeModeCatalog?.map((tool) => tool.path)).toEqual(["codemode"])
|
||||
expect((yield* snapshot.execute(call("phone_type")).pipe(Effect.flip)).message).toBe("Unknown tool: phone_type")
|
||||
}).pipe(Effect.provide(Logger.layer([logger])))
|
||||
})
|
||||
|
||||
+371
-90
@@ -2,35 +2,40 @@ import { $ } from "bun"
|
||||
import { describe, expect } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Cause, Effect, Exit, Fiber, Layer, Stream } from "effect"
|
||||
import { Cause, Context, Deferred, Effect, Exit, Fiber, Layer, Option, Schema, Scope, Stream } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { State } from "@opencode-ai/core/state"
|
||||
import { Vcs } from "@opencode-ai/core/vcs"
|
||||
import { VcsGitPlugin } from "@opencode-ai/core/plugin/vcs/git"
|
||||
import type { VcsDefinition, VcsDiffInput } from "@opencode-ai/plugin/effect/vcs"
|
||||
import { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
import { VcsEvent } from "@opencode-ai/schema/vcs-event"
|
||||
import { location } from "./fixture/location"
|
||||
import { locationLayer } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { it } from "./lib/effect"
|
||||
import { it, testEffect } from "./lib/effect"
|
||||
import { host } from "./plugin/host"
|
||||
|
||||
const Done = Bus.ephemeral({ type: "test.vcs.done", schema: {} })
|
||||
|
||||
const synthetic = testEffect(
|
||||
LayerNode.compile(LayerNode.group([Vcs.node, Bus.node, Location.node]), [
|
||||
[Location.node, locationLayer({ directory: AbsolutePath.make(import.meta.dir) })],
|
||||
]),
|
||||
)
|
||||
|
||||
const provide = (directory: string, input: { git?: boolean } = {}) =>
|
||||
Effect.provide(
|
||||
LayerNode.compile(LayerNode.group([Vcs.node, Bus.node, Location.node, AppProcess.node]), [
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location(
|
||||
{ directory: AbsolutePath.make(directory) },
|
||||
input.git ? { vcs: { type: "git", store: AbsolutePath.make(path.join(directory, ".git")) } } : {},
|
||||
),
|
||||
),
|
||||
locationLayer(
|
||||
{ directory: AbsolutePath.make(directory) },
|
||||
input.git ? { vcs: { type: "git", store: AbsolutePath.make(path.join(directory, ".git")) } } : {},
|
||||
),
|
||||
],
|
||||
]),
|
||||
@@ -84,40 +89,36 @@ const provider = (input: Partial<VcsDefinition> = {}) =>
|
||||
}) satisfies VcsDefinition
|
||||
|
||||
describe("Vcs", () => {
|
||||
it.live("returns empty results outside version control", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
expect(yield* vcs.info()).toEqual({ branch: {} })
|
||||
expect(yield* vcs.branches()).toEqual([])
|
||||
expect(yield* vcs.status()).toEqual([])
|
||||
expect(yield* vcs.diff("working")).toEqual([])
|
||||
expect(yield* vcs.diff("branch")).toEqual([])
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
synthetic.effect("returns empty results outside version control", () =>
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
expect(yield* vcs.info()).toEqual({ branch: {} })
|
||||
expect(yield* vcs.branches()).toEqual([])
|
||||
expect(yield* vcs.status()).toEqual([])
|
||||
expect(yield* vcs.diff("working")).toEqual([])
|
||||
expect(yield* vcs.diff("branch")).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("serves scoped providers and restores the fallback after disposal", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
const registration = yield* vcs.transform((draft) => {
|
||||
draft.add(provider())
|
||||
draft.default.set("custom")
|
||||
})
|
||||
synthetic.effect("serves scoped providers and restores the fallback after disposal", () =>
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
const registration = yield* vcs.transform((draft) => {
|
||||
draft.add(provider())
|
||||
draft.default.set("custom")
|
||||
})
|
||||
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "main" } })
|
||||
expect(yield* vcs.branches()).toEqual(["feature", "main"])
|
||||
expect(yield* vcs.status()).toEqual([{ file: "file.txt", additions: 1, deletions: 0, status: "added" }])
|
||||
expect(yield* vcs.diff("working")).toEqual([
|
||||
{ file: "file.txt", patch: "+hello", additions: 1, deletions: 0, status: "added" },
|
||||
])
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "main" } })
|
||||
expect(yield* vcs.branches()).toEqual(["feature", "main"])
|
||||
expect(yield* vcs.status()).toEqual([{ file: "file.txt", additions: 1, deletions: 0, status: "added" }])
|
||||
expect(yield* vcs.diff("working")).toEqual([
|
||||
{ file: "file.txt", patch: "+hello", additions: 1, deletions: 0, status: "added" },
|
||||
])
|
||||
|
||||
yield* registration.dispose
|
||||
expect(yield* vcs.info()).toEqual({ branch: {} })
|
||||
expect(yield* vcs.status()).toEqual([])
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
yield* registration.dispose
|
||||
expect(yield* vcs.info()).toEqual({ branch: {} })
|
||||
expect(yield* vcs.status()).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("automatically selects a provider matching the resolved repository", () =>
|
||||
@@ -133,80 +134,360 @@ describe("Vcs", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("passes location scope and bounded diff options to providers", () =>
|
||||
withTmp((directory) =>
|
||||
synthetic.effect("passes location scope and bounded diff options to providers", () =>
|
||||
Effect.gen(function* () {
|
||||
const observed: VcsDiffInput[] = []
|
||||
const vcs = yield* Vcs.Service
|
||||
const location = yield* Location.Service
|
||||
yield* vcs.transform((draft) => {
|
||||
draft.add(
|
||||
provider({
|
||||
diff: (input) =>
|
||||
Effect.sync(() => {
|
||||
observed.push(input)
|
||||
return [{ file: "file.txt", patch: "+hello", additions: 1, deletions: 0, status: "added" }]
|
||||
}),
|
||||
}),
|
||||
)
|
||||
draft.default.set("custom")
|
||||
})
|
||||
|
||||
yield* vcs.diff("branch", { context: 3 })
|
||||
expect(observed).toEqual([
|
||||
{
|
||||
directory: location.directory,
|
||||
worktree: location.directory,
|
||||
canonical: location.directory,
|
||||
mode: "branch",
|
||||
context: 3,
|
||||
maxOutputBytes: 10_000_000,
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
synthetic.effect("validates provider results and bounds oversized patches", () =>
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
yield* vcs.transform((draft) => {
|
||||
draft.add(
|
||||
provider({
|
||||
status: () => Effect.succeed([{ file: "file.txt", additions: -1, deletions: 0, status: "added" }]),
|
||||
diff: () =>
|
||||
Effect.succeed([
|
||||
{ file: "file.txt", patch: "x".repeat(10_000_001), additions: 1, deletions: 0, status: "added" },
|
||||
]),
|
||||
}),
|
||||
)
|
||||
draft.default.set("custom")
|
||||
})
|
||||
|
||||
expect(yield* vcs.status()).toEqual([])
|
||||
const rows = yield* vcs.diff("working")
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(Buffer.byteLength(rows[0].patch)).toBeLessThan(1000)
|
||||
expect(rows[0].additions).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
synthetic.effect("preserves provider interruption", () =>
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
let interrupt = false
|
||||
yield* vcs.transform((draft) => {
|
||||
draft.add(
|
||||
provider({
|
||||
info: () =>
|
||||
interrupt ? Effect.interrupt : Effect.succeed({ branch: { current: "feature", default: "main" } }),
|
||||
status: () => Effect.never,
|
||||
}),
|
||||
)
|
||||
draft.default.set("custom")
|
||||
})
|
||||
|
||||
const fiber = yield* Effect.forkChild(vcs.status())
|
||||
yield* Fiber.interrupt(fiber)
|
||||
const exit = yield* Fiber.await(fiber)
|
||||
expect(Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause)).toBeTrue()
|
||||
|
||||
interrupt = true
|
||||
const reload = yield* vcs.reload().pipe(Effect.timeout("1 second"), Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("1 second")
|
||||
const reloaded = yield* Fiber.await(reload)
|
||||
expect(Exit.isFailure(reloaded) && Cause.hasInterruptsOnly(reloaded.cause)).toBeTrue()
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("keeps watching HEAD changes after a transform replay failure", () =>
|
||||
withGit((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const observed: VcsDiffInput[] = []
|
||||
const vcs = yield* Vcs.Service
|
||||
yield* vcs.transform((draft) => {
|
||||
const bus = yield* Bus.Service
|
||||
const replayed = yield* Deferred.make<void>()
|
||||
const faulty = yield* Scope.make()
|
||||
yield* Effect.addFinalizer(() => Scope.close(faulty, Exit.void))
|
||||
let branch = "initial"
|
||||
yield* vcs.transform((draft) =>
|
||||
draft.add(provider({ id: "git", info: () => Effect.sync(() => ({ branch: { current: branch } })) })),
|
||||
)
|
||||
const failure = new Error("fixture replay failed")
|
||||
let replays = 0
|
||||
const failed = yield* vcs
|
||||
.transform(() => {
|
||||
if (++replays === 2) Deferred.doneUnsafe(replayed, Exit.void)
|
||||
throw failure
|
||||
})
|
||||
.pipe(Scope.provide(faulty), Effect.exit)
|
||||
expect(Exit.isFailure(failed) && Cause.squash(failed.cause)).toBe(failure)
|
||||
|
||||
yield* bus.publish(FileSystem.Event.Changed, { file: path.join(directory, ".git", "HEAD"), event: "change" })
|
||||
yield* Deferred.await(replayed).pipe(Effect.timeout("1 second"))
|
||||
yield* Effect.yieldNow
|
||||
const status = yield* vcs.status().pipe(Effect.exit)
|
||||
expect(Exit.isFailure(status) && Cause.squash(status.cause)).toBe(failure)
|
||||
expect((yield* vcs.info()).branch.current).toBe("initial")
|
||||
|
||||
branch = "recovered"
|
||||
yield* Scope.close(faulty, Exit.void)
|
||||
expect((yield* vcs.info()).branch.current).toBe("recovered")
|
||||
const updated = yield* bus
|
||||
.subscribe(VcsEvent.BranchUpdated)
|
||||
.pipe(Stream.runHead, Effect.timeout("1 second"), Effect.forkScoped({ startImmediately: true }))
|
||||
branch = "after-recovery"
|
||||
yield* bus.publish(FileSystem.Event.Changed, { file: path.join(directory, ".git", "HEAD"), event: "change" })
|
||||
const event = yield* Fiber.join(updated)
|
||||
expect((yield* vcs.info()).branch.current).toBe("after-recovery")
|
||||
expect(Option.getOrUndefined(event)).toMatchObject({ data: { branch: "after-recovery" } })
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("stops in-flight and queued reloads when its layer closes", () =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const entered = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const root = yield* Scope.make()
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Deferred.succeed(release, undefined).pipe(
|
||||
Effect.andThen(State.batch(Scope.close(root, Exit.void), { flush: false })),
|
||||
Effect.andThen(TestClock.adjust("500 millis")),
|
||||
),
|
||||
)
|
||||
const context = yield* Layer.buildWithScope(
|
||||
LayerNode.compile(Vcs.node, [
|
||||
[Bus.node, Layer.succeed(Bus.Service, bus)],
|
||||
[Location.node, locationLayer({ directory: AbsolutePath.make(import.meta.dir) })],
|
||||
]),
|
||||
root,
|
||||
)
|
||||
const vcs = Context.get(context, Vcs.Service)
|
||||
const reads: string[] = []
|
||||
const observed: (string | undefined)[] = []
|
||||
yield* Effect.acquireRelease(
|
||||
bus.listen((event) =>
|
||||
Effect.sync(() => {
|
||||
if (event.type !== VcsEvent.BranchUpdated.type) return
|
||||
observed.push(Schema.decodeUnknownSync(VcsEvent.BranchUpdated.data)(event.data).branch)
|
||||
}),
|
||||
),
|
||||
(unsubscribe) => unsubscribe,
|
||||
)
|
||||
let branch = "initial"
|
||||
let block = false
|
||||
yield* vcs
|
||||
.transform((draft) => {
|
||||
draft.add(
|
||||
provider({
|
||||
diff: (input) =>
|
||||
Effect.sync(() => {
|
||||
observed.push(input)
|
||||
return [{ file: "file.txt", patch: "+hello", additions: 1, deletions: 0, status: "added" }]
|
||||
info: () =>
|
||||
Effect.gen(function* () {
|
||||
const value = branch
|
||||
reads.push(value)
|
||||
if (block) {
|
||||
block = false
|
||||
yield* Deferred.succeed(entered, undefined)
|
||||
yield* Deferred.await(release)
|
||||
}
|
||||
return { branch: { current: value } }
|
||||
}),
|
||||
}),
|
||||
)
|
||||
draft.default.set("custom")
|
||||
})
|
||||
.pipe(Scope.provide(root))
|
||||
observed.length = 0
|
||||
|
||||
yield* vcs.diff("branch", { context: 3 })
|
||||
expect(observed).toEqual([
|
||||
{
|
||||
directory,
|
||||
worktree: directory,
|
||||
canonical: directory,
|
||||
mode: "branch",
|
||||
context: 3,
|
||||
maxOutputBytes: 10_000_000,
|
||||
},
|
||||
])
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
block = true
|
||||
const first = yield* vcs.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Deferred.await(entered).pipe(Effect.timeout("1 second"), TestClock.withLive)
|
||||
branch = "late"
|
||||
const second = yield* vcs.reload().pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("500 millis")
|
||||
yield* Effect.yieldNow
|
||||
expect(reads).toEqual(["initial", "initial"])
|
||||
expect(first.pollUnsafe()).toBeUndefined()
|
||||
expect(second.pollUnsafe()).toBeUndefined()
|
||||
const snapshot = yield* vcs.info()
|
||||
|
||||
const shutdown = yield* State.batch(Scope.close(root, Exit.void), { flush: false }).pipe(
|
||||
Effect.forkChild({ startImmediately: true }),
|
||||
)
|
||||
yield* TestClock.adjust("1 millis")
|
||||
expect(shutdown.pollUnsafe()).toBeDefined()
|
||||
expect(first.pollUnsafe()).toBeDefined()
|
||||
expect(second.pollUnsafe()).toBeDefined()
|
||||
expect(yield* Deferred.isDone(release)).toBe(false)
|
||||
yield* Fiber.join(shutdown)
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Fiber.join(first)
|
||||
yield* Fiber.join(second)
|
||||
expect(reads).toEqual(["initial", "initial"])
|
||||
expect(observed).toEqual([])
|
||||
expect(yield* vcs.info()).toBe(snapshot)
|
||||
}).pipe(Effect.provide(LayerNode.compile(Bus.node))),
|
||||
)
|
||||
|
||||
it.live("validates provider results and bounds oversized patches", () =>
|
||||
withTmp((directory) =>
|
||||
it.live("serializes filesystem and config refreshes while reading the latest desired provider", () =>
|
||||
withGit((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
yield* vcs.transform((draft) => {
|
||||
const bus = yield* Bus.Service
|
||||
const started = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const accepted = yield* Deferred.make<void>()
|
||||
const reads: string[] = []
|
||||
yield* vcs.transform((draft) =>
|
||||
draft.add(
|
||||
provider({
|
||||
status: () => Effect.succeed([{ file: "file.txt", additions: -1, deletions: 0, status: "added" }]),
|
||||
diff: () =>
|
||||
Effect.succeed([
|
||||
{ file: "file.txt", patch: "x".repeat(10_000_001), additions: 1, deletions: 0, status: "added" },
|
||||
]),
|
||||
id: "git",
|
||||
info: () =>
|
||||
Effect.gen(function* () {
|
||||
reads.push(reads.length === 0 ? "initial" : "filesystem")
|
||||
if (reads.length === 1) return { branch: { current: "initial" } }
|
||||
yield* Deferred.succeed(started, undefined)
|
||||
yield* Deferred.await(release)
|
||||
return { branch: { current: "filesystem" } }
|
||||
}),
|
||||
}),
|
||||
)
|
||||
draft.default.set("custom")
|
||||
})
|
||||
),
|
||||
)
|
||||
const updates = yield* bus
|
||||
.subscribe(VcsEvent.BranchUpdated)
|
||||
.pipe(Stream.take(2), Stream.runLast, Effect.forkScoped({ startImmediately: true }))
|
||||
|
||||
expect(yield* vcs.status()).toEqual([])
|
||||
const rows = yield* vcs.diff("working")
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(Buffer.byteLength(rows[0].patch)).toBeLessThan(1000)
|
||||
expect(rows[0].additions).toBe(1)
|
||||
}).pipe(provide(directory)),
|
||||
yield* Effect.gen(function* () {
|
||||
yield* bus.publish(FileSystem.Event.Changed, { file: path.join(directory, ".git", "HEAD"), event: "change" })
|
||||
yield* Deferred.await(started)
|
||||
const configured = yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* vcs.transform((draft) =>
|
||||
draft.add(
|
||||
provider({
|
||||
id: "git",
|
||||
info: () =>
|
||||
Effect.sync(() => {
|
||||
reads.push("config")
|
||||
return { branch: { current: "config" } }
|
||||
}),
|
||||
status: () => Effect.succeed([{ file: "config.txt", additions: 1, deletions: 0, status: "added" }]),
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect((yield* vcs.status())[0]?.file).toBe("config.txt")
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "initial" } })
|
||||
yield* Deferred.succeed(accepted, undefined)
|
||||
}),
|
||||
).pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Deferred.await(accepted)
|
||||
expect(reads).toEqual(["initial", "filesystem"])
|
||||
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Fiber.join(configured)
|
||||
expect(Option.getOrUndefined(yield* Fiber.join(updates))?.data.branch).toBe("config")
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "config" } })
|
||||
expect(reads).toEqual(["initial", "filesystem", "config"])
|
||||
}).pipe(Effect.ensuring(Deferred.succeed(release, undefined)))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("preserves provider interruption", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
synthetic.effect("keeps branch streams current when listeners change the selected provider", () =>
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
const bus = yield* Bus.Service
|
||||
const scope = yield* Effect.scope
|
||||
const updates = yield* bus.subscribe([VcsEvent.BranchUpdated, Done]).pipe(
|
||||
Stream.takeUntil((event) => event.type === Done.type),
|
||||
Stream.runCollect,
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
const unsubscribe = yield* bus.listen((event) => {
|
||||
if (
|
||||
event.type !== VcsEvent.BranchUpdated.type ||
|
||||
Schema.decodeUnknownSync(VcsEvent.BranchUpdated.data)(event.data).branch !== "feature"
|
||||
)
|
||||
return Effect.void
|
||||
return vcs
|
||||
.transform((draft) =>
|
||||
draft.add(provider({ info: () => Effect.succeed({ branch: { current: "listener" } }) })),
|
||||
)
|
||||
.pipe(Scope.provide(scope), Effect.asVoid)
|
||||
})
|
||||
yield* Effect.gen(function* () {
|
||||
yield* vcs.transform((draft) => {
|
||||
draft.add(provider({ status: () => Effect.never }))
|
||||
draft.add(provider())
|
||||
draft.default.set("custom")
|
||||
})
|
||||
yield* bus.publish(Done, {})
|
||||
const events = (yield* Fiber.join(updates)).filter((event) => event.type === VcsEvent.BranchUpdated.type)
|
||||
expect(yield* vcs.info()).toEqual({ branch: { current: "listener" } })
|
||||
expect(events.length).toBeGreaterThanOrEqual(2)
|
||||
expect(events.at(-1)?.data.branch).toBe((yield* vcs.info()).branch.current)
|
||||
}).pipe(Effect.ensuring(unsubscribe))
|
||||
}),
|
||||
)
|
||||
|
||||
const fiber = yield* Effect.forkChild(vcs.status())
|
||||
yield* Fiber.interrupt(fiber)
|
||||
const exit = yield* Fiber.await(fiber)
|
||||
expect(Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause)).toBeTrue()
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
synthetic.effect("does not roll back branch streams when an older listener finishes late", () =>
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
const bus = yield* Bus.Service
|
||||
const entered = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const updates = yield* bus.subscribe([VcsEvent.BranchUpdated, Done]).pipe(
|
||||
Stream.takeUntil((event) => event.type === Done.type),
|
||||
Stream.runCollect,
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
const unsubscribe = yield* bus.listen((event) =>
|
||||
event.type === VcsEvent.BranchUpdated.type &&
|
||||
Schema.decodeUnknownSync(VcsEvent.BranchUpdated.data)(event.data).branch === "older"
|
||||
? Deferred.succeed(entered, undefined).pipe(Effect.andThen(Deferred.await(release)))
|
||||
: Effect.void,
|
||||
)
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const older = yield* vcs
|
||||
.transform((draft) => {
|
||||
draft.add(provider({ info: () => Effect.succeed({ branch: { current: "older" } }) }))
|
||||
draft.default.set("custom")
|
||||
})
|
||||
.pipe(Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Deferred.await(entered)
|
||||
yield* vcs.transform((draft) =>
|
||||
draft.add(provider({ info: () => Effect.succeed({ branch: { current: "newer" } }) })),
|
||||
)
|
||||
expect(older.pollUnsafe()).toBeUndefined()
|
||||
expect((yield* vcs.info()).branch.current).toBe("newer")
|
||||
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Fiber.join(older)
|
||||
yield* bus.publish(Done, {})
|
||||
const events = (yield* Fiber.join(updates)).filter((event) => event.type === VcsEvent.BranchUpdated.type)
|
||||
expect(events.length).toBeGreaterThanOrEqual(2)
|
||||
expect(events.at(-1)?.data.branch).toBe((yield* vcs.info()).branch.current)
|
||||
}).pipe(Effect.ensuring(Deferred.succeed(release, undefined).pipe(Effect.andThen(unsubscribe))))
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("lists local branches by recent activity", () =>
|
||||
|
||||
@@ -171,7 +171,7 @@ describe("Worktree", () => {
|
||||
{ directory: created.directory, strategy: "git" },
|
||||
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
|
||||
)
|
||||
expect((yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
|
||||
expect(Array.from(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((yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
|
||||
expect(Array.from(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())
|
||||
|
||||
@@ -1,14 +1,27 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Vcs } from "@opencode-ai/core/vcs"
|
||||
import { Credential } from "@opencode-ai/schema/credential"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { IntegrationID } from "@opencode-ai/schema/integration-id"
|
||||
import { Deferred, Effect, Exit, Fiber, Option, Schema, Stream } from "effect"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { VcsEvent } from "@opencode-ai/schema/vcs-event"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Deferred, Effect, Exit, Fiber, Option, Schema, Scope, Stream } from "effect"
|
||||
import { locationLayer } from "../../core/test/fixture/location"
|
||||
import { it, testEffect } from "../../core/test/lib/effect"
|
||||
import { EventFeed } from "../src/event-feed"
|
||||
|
||||
const Internal = Bus.ephemeral({ type: "test.internal", schema: { value: Schema.String } })
|
||||
const vcsIt = testEffect(
|
||||
LayerNode.compile(LayerNode.group([Vcs.node, Bus.node]), [
|
||||
[Location.node, locationLayer({ directory: AbsolutePath.make(import.meta.dir) })],
|
||||
[Database.node, Database.configured({ path: ":memory:" })],
|
||||
]),
|
||||
)
|
||||
|
||||
const event = (id: string): Event.Payload<typeof Agent.Event.Updated> => ({
|
||||
id: Event.ID.make(`evt_${id}`),
|
||||
@@ -44,6 +57,55 @@ describe("EventFeed", () => {
|
||||
expect(EventFeed.frame(payload)).toBe(`data: ${JSON.stringify(payload)}\n\n`)
|
||||
})
|
||||
|
||||
vcsIt.effect("delivers the latest VCS branch after an earlier legacy listener reenters", () =>
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const vcs = yield* Vcs.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const provider = {
|
||||
id: "fixture",
|
||||
name: "Fixture",
|
||||
info: () => Effect.succeed({ branch: { current: "outer" } }),
|
||||
branches: () => Effect.succeed([]),
|
||||
status: () => Effect.succeed([]),
|
||||
diff: () => Effect.succeed([]),
|
||||
}
|
||||
const unsubscribe = yield* bus.listen((event) =>
|
||||
event.type === VcsEvent.BranchUpdated.type &&
|
||||
Schema.decodeUnknownSync(VcsEvent.BranchUpdated.data)(event.data).branch === "outer"
|
||||
? vcs
|
||||
.transform((draft) =>
|
||||
draft.add({ ...provider, info: () => Effect.succeed({ branch: { current: "inner" } }) }),
|
||||
)
|
||||
.pipe(Scope.provide(scope), Effect.asVoid)
|
||||
: Effect.void,
|
||||
)
|
||||
// Unsubscribe before registration teardown can restore "outer" and reenter the listener.
|
||||
yield* Effect.gen(function* () {
|
||||
const feed = yield* EventFeed.make(bus.listen, {
|
||||
encode: (event) => (event.type === VcsEvent.BranchUpdated.type ? (event.data.branch ?? "none") : event.type),
|
||||
})
|
||||
const stream = yield* feed.subscribe
|
||||
const received = yield* stream.pipe(
|
||||
Stream.takeUntil((frame) => frame === Agent.Event.Updated.type, { excludeLast: true }),
|
||||
Stream.runLast,
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
|
||||
yield* vcs.transform((draft) => {
|
||||
draft.add(provider)
|
||||
draft.default.set(provider.id)
|
||||
})
|
||||
yield* unsubscribe
|
||||
yield* bus.publish(Agent.Event.Updated, {})
|
||||
|
||||
const info = yield* vcs.info()
|
||||
expect(info.branch.current).toBe("inner")
|
||||
expect(Option.getOrUndefined(yield* Fiber.join(received))).toBe(info.branch.current)
|
||||
}).pipe(Effect.ensuring(unsubscribe))
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("encodes once and delivers the same frame to every subscriber", () =>
|
||||
Effect.gen(function* () {
|
||||
let encodes = 0
|
||||
|
||||
+10
-15
@@ -68,7 +68,7 @@ import { DialogThemeList } from "./component/dialog-theme-list"
|
||||
import { DialogHelp } from "./ui/dialog-help"
|
||||
import { DialogAgent } from "./component/dialog-agent"
|
||||
import { DialogSessionList } from "./component/dialog-session-list"
|
||||
import { DialogOpen, DialogOpenKey, moveOpenSession } from "./component/dialog-open"
|
||||
import { DialogOpen, DialogOpenKey, loadDialogOpen } from "./component/dialog-open"
|
||||
import { SessionTabs } from "./component/session-tabs"
|
||||
import { clampSessionTabsWidth, sessionTabsFitVertically, SESSION_SIDEBAR_WIDTH } from "./ui/layout"
|
||||
import { createPaneResize } from "./ui/pane-resize"
|
||||
@@ -507,7 +507,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
}).catch((error) => console.error("Failed to persist TUI layout", error))
|
||||
},
|
||||
})
|
||||
const [openSessions, setOpenSessions] = createSignal<SessionInfo[]>([])
|
||||
let openingOpen: Promise<SessionInfo[]> | undefined
|
||||
// Toast once when an MCP server enters a failed or needs-auth state so the user knows to act,
|
||||
// without having to open the status panel. Tracking the last alerted status avoids re-toasting
|
||||
// the same problem on every refresh while still re-alerting if the state changes.
|
||||
@@ -719,12 +719,14 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
title: "Open session or project",
|
||||
category: "Session",
|
||||
slash: { name: "open", aliases: ["projects", "project"] },
|
||||
run: () => {
|
||||
if (dialog.key === DialogOpenKey) return
|
||||
dialog.replace(() => <DialogOpen sessions={openSessions()} onLoad={setOpenSessions} />, undefined, {
|
||||
key: DialogOpenKey,
|
||||
size: "large",
|
||||
})
|
||||
run: async () => {
|
||||
if (dialog.key === DialogOpenKey || openingOpen) return
|
||||
const previous = dialog.stack.at(-1)
|
||||
openingOpen = loadDialogOpen(data, client)
|
||||
const sessions = await openingOpen
|
||||
openingOpen = undefined
|
||||
if (dialog.stack.at(-1) !== previous) return
|
||||
dialog.replace(() => <DialogOpen sessions={sessions} />, undefined, { key: DialogOpenKey, size: "large" })
|
||||
},
|
||||
},
|
||||
...Array.from({ length: 9 }, (_, i) => ({
|
||||
@@ -1211,14 +1213,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
})
|
||||
})
|
||||
|
||||
event.on("session.moved", (evt) => {
|
||||
setOpenSessions((sessions) =>
|
||||
sessions.map((session) => (session.id !== evt.data.sessionID ? session : moveOpenSession(session, evt))),
|
||||
)
|
||||
})
|
||||
|
||||
event.on("session.deleted", (evt) => {
|
||||
setOpenSessions((sessions) => sessions.filter((session) => session.id !== evt.data.sessionID))
|
||||
if (route.data.type === "session" && route.data.sessionID === evt.data.sessionID) {
|
||||
const title = active?.id === evt.data.sessionID ? active.title : undefined
|
||||
route.navigate({ type: "home" })
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createMemo, createResource, createSignal, onCleanup, Show } from "solid-js"
|
||||
import type { OpenCodeEvent, SessionInfo } from "@opencode-ai/client"
|
||||
import { createMemo, createResource, createSignal } from "solid-js"
|
||||
import type { SessionInfo } from "@opencode-ai/client"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import type { RGBA } from "@opentui/core"
|
||||
import { dialogWidth, useDialog } from "../ui/dialog"
|
||||
@@ -25,7 +25,18 @@ export const DialogOpenKey = Symbol("DialogOpen")
|
||||
|
||||
type OpenTarget = { type: "session"; sessionID: string } | { type: "project"; directory: string }
|
||||
|
||||
export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions: SessionInfo[]) => void }) {
|
||||
export async function loadDialogOpen(data: ReturnType<typeof useData>, client: ReturnType<typeof useClient>) {
|
||||
const [, sessions] = await Promise.all([
|
||||
data.project.sync().catch(() => {}),
|
||||
client.api.session
|
||||
.list({ limit: 50, order: "desc", parentID: null })
|
||||
.then((response) => response.data)
|
||||
.catch(() => [] as SessionInfo[]),
|
||||
])
|
||||
return sessions
|
||||
}
|
||||
|
||||
export function DialogOpen(props: { sessions: SessionInfo[] }) {
|
||||
const dialog = useDialog()
|
||||
const route = useRoute()
|
||||
const data = useData()
|
||||
@@ -40,41 +51,6 @@ export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions:
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
const [filter, setFilter] = createSignal("")
|
||||
const [selectionMoved, setSelectionMoved] = createSignal(false)
|
||||
let closed = false
|
||||
onCleanup(() => {
|
||||
closed = true
|
||||
})
|
||||
const [recent] = createResource(() => {
|
||||
// A late read must not overwrite deletion or placement facts observed in flight.
|
||||
const changed = new Map<string, Extract<OpenCodeEvent, { type: "session.deleted" | "session.moved" }>>()
|
||||
const unsubscribe = client.event.listen((message) => {
|
||||
const event = message.details
|
||||
if (event.type === "session.deleted" || event.type === "session.moved") changed.set(event.data.sessionID, event)
|
||||
})
|
||||
onCleanup(unsubscribe)
|
||||
return client.api.session
|
||||
.list({ limit: 50, order: "desc", parentID: null })
|
||||
.then((response) => {
|
||||
if (!closed)
|
||||
props.onLoad(
|
||||
response.data.flatMap((session) => {
|
||||
const event = changed.get(session.id)
|
||||
if (!event) return [session]
|
||||
if (event.type === "session.deleted") return []
|
||||
return [moveOpenSession(props.sessions.find((entry) => entry.id === session.id) ?? session, event)]
|
||||
}),
|
||||
)
|
||||
return true
|
||||
})
|
||||
.catch(() => false)
|
||||
.finally(unsubscribe)
|
||||
})
|
||||
const [projects] = createResource(() =>
|
||||
data.project.sync().then(
|
||||
() => true,
|
||||
() => false,
|
||||
),
|
||||
)
|
||||
|
||||
const [matched] = createResource(
|
||||
() => {
|
||||
@@ -178,34 +154,12 @@ export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions:
|
||||
preserveSelection={selectionMoved()}
|
||||
onMove={() => setSelectionMoved(true)}
|
||||
onFilter={setFilter}
|
||||
emptyView={
|
||||
<Show when={!recent.loading && !projects.loading}>
|
||||
<box paddingLeft={4} paddingRight={4}>
|
||||
<text fg={theme.text.subdued}>No recent sessions or projects</text>
|
||||
</box>
|
||||
</Show>
|
||||
}
|
||||
footer={
|
||||
<box>
|
||||
<Show when={recent.loading || projects.loading}>
|
||||
<Spinner color={theme.text.subdued}>Refreshing sessions and projects...</Spinner>
|
||||
</Show>
|
||||
<Show when={recent() === false || projects() === false}>
|
||||
<text fg={theme.text.feedback.error.default}>
|
||||
Could not refresh{" "}
|
||||
{recent() === false ? (projects() === false ? "sessions and projects" : "sessions") : "projects"}.
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
}
|
||||
noMatchView={
|
||||
<box paddingLeft={4} paddingRight={4}>
|
||||
<text fg={theme.text.subdued}>
|
||||
{recent.loading || projects.loading || matched.loading
|
||||
? "Searching sessions and projects..."
|
||||
: shortcuts.get("session.list")
|
||||
? `No matches · search all sessions with ${shortcuts.get("session.list")}`
|
||||
: "No matches"}
|
||||
{shortcuts.get("session.list")
|
||||
? `No matches · search all sessions with ${shortcuts.get("session.list")}`
|
||||
: "No matches"}
|
||||
</text>
|
||||
</box>
|
||||
}
|
||||
@@ -223,16 +177,6 @@ export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions:
|
||||
)
|
||||
}
|
||||
|
||||
export function moveOpenSession(session: SessionInfo, event: Extract<OpenCodeEvent, { type: "session.moved" }>) {
|
||||
return {
|
||||
...session,
|
||||
location: event.data.location,
|
||||
projectID: event.data.projectID ?? session.projectID,
|
||||
subpath: event.data.subpath,
|
||||
time: { ...session.time, updated: Math.max(session.time.updated, event.created) },
|
||||
}
|
||||
}
|
||||
|
||||
function timeAgo(timestamp: number) {
|
||||
const minutes = Math.floor((Date.now() - timestamp) / 60_000)
|
||||
if (minutes < 1) return "now"
|
||||
|
||||
@@ -41,7 +41,6 @@ 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()
|
||||
|
||||
@@ -97,7 +96,6 @@ 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
|
||||
@@ -650,7 +648,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 = () => (props.controller ? undefined : session()?.title) ?? tab.title ?? "Untitled session"
|
||||
const title = () => tab.title ?? "Untitled session"
|
||||
const scrolling = () => marquee.active() === tab.sessionID
|
||||
const visibleTitleParts = createMemo(() =>
|
||||
scrolling()
|
||||
@@ -909,21 +907,14 @@ function VerticalSessionTabs(props: {
|
||||
unreadMarker={props.unreadMarker}
|
||||
attributes={selected() ? TextAttributes.BOLD : undefined}
|
||||
/>
|
||||
<title_shimmer
|
||||
<text
|
||||
width={titleWidth()}
|
||||
height={1}
|
||||
fg={foreground()}
|
||||
rename={{ pending: status().renaming, title: title() }}
|
||||
enabled={animations()}
|
||||
backdrop={pulseBackground()}
|
||||
wrapMode="none"
|
||||
selectable={false}
|
||||
attributes={
|
||||
(status().renaming && !animations()
|
||||
? TextAttributes.DIM
|
||||
: selected()
|
||||
? TextAttributes.BOLD
|
||||
: 0) | (tabs.isPreview?.(tab.sessionID) ? TextAttributes.ITALIC : 0) || undefined
|
||||
(selected() ? TextAttributes.BOLD : 0) |
|
||||
(tabs.isPreview?.(tab.sessionID) ? TextAttributes.ITALIC : 0) || undefined
|
||||
}
|
||||
>
|
||||
<Show
|
||||
@@ -936,7 +927,7 @@ function VerticalSessionTabs(props: {
|
||||
)}
|
||||
</Index>
|
||||
</Show>
|
||||
</title_shimmer>
|
||||
</text>
|
||||
<text
|
||||
position="absolute"
|
||||
right={1}
|
||||
@@ -1084,7 +1075,6 @@ 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
|
||||
@@ -1374,7 +1364,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 = () => data?.session.get(tab.sessionID)?.title ?? tab.title ?? "Untitled session"
|
||||
const 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.
|
||||
@@ -1502,18 +1492,13 @@ function HorizontalSessionTabs(props: {
|
||||
unreadMarker={props.unreadMarker}
|
||||
attributes={bold()}
|
||||
/>
|
||||
<title_shimmer
|
||||
<text
|
||||
width={availableTitleWidth()}
|
||||
height={1}
|
||||
fg={foreground()}
|
||||
rename={{ pending: status().renaming, title: title() }}
|
||||
enabled={animations()}
|
||||
backdrop={background()}
|
||||
wrapMode="none"
|
||||
selectable={false}
|
||||
attributes={
|
||||
(status().renaming && !animations() ? TextAttributes.DIM : (bold() ?? 0)) |
|
||||
(tabs.isPreview?.(tab.sessionID) ? TextAttributes.ITALIC : 0) || undefined
|
||||
(bold() ?? 0) | (tabs.isPreview?.(tab.sessionID) ? TextAttributes.ITALIC : 0) || undefined
|
||||
}
|
||||
>
|
||||
<Show when={scrolling() || glows() || titleFades()} fallback={visibleTitle()}>
|
||||
@@ -1523,7 +1508,7 @@ function HorizontalSessionTabs(props: {
|
||||
)}
|
||||
</Index>
|
||||
</Show>
|
||||
</title_shimmer>
|
||||
</text>
|
||||
<text
|
||||
position="absolute"
|
||||
right={1}
|
||||
|
||||
@@ -30,7 +30,7 @@ type TabPulseOptions = RenderableOptions<TabPulseRenderable> & {
|
||||
}
|
||||
|
||||
const clamp = (value: number) => Math.max(0, Math.min(1, value))
|
||||
export const smootherstep = (value: number) => value * value * value * (value * (value * 6 - 15) + 10)
|
||||
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()
|
||||
export const intensityAt = (index: number, front: number, head: number, tail: number) => {
|
||||
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))
|
||||
}
|
||||
export const coast = (value: number) => {
|
||||
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))
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
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"
|
||||
|
||||
@@ -18,27 +17,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
directory: props.directory,
|
||||
})
|
||||
data satisfies Plugin.Context["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))
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
return data
|
||||
},
|
||||
})
|
||||
|
||||
@@ -173,7 +173,6 @@ 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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,55 +7,25 @@ export function createHistoryPrepend(input: {
|
||||
active: (sessionID: string) => boolean
|
||||
scrollBy: (amount: number) => void
|
||||
}) {
|
||||
let pending: { scrollBy: number; continuation?: () => void; after?: () => void } | undefined
|
||||
let 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 (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
|
||||
},
|
||||
after(continuation: () => void) {
|
||||
if (!pending) return continuation()
|
||||
// A jump supersedes deferred scrolling, but must wait for anchor compensation.
|
||||
pending.scrollBy = 0
|
||||
pending.after = continuation
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
batch,
|
||||
createContext,
|
||||
createEffect,
|
||||
createMemo,
|
||||
@@ -289,7 +288,6 @@ 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()
|
||||
@@ -302,9 +300,6 @@ export function Session(props: {
|
||||
|
||||
const clearMessageNavigation = () => {
|
||||
ensureAllRowsPending?.splice(0)
|
||||
prependHistory.cancel()
|
||||
firstJump()?.()
|
||||
setFirstJump(undefined)
|
||||
setNavigationSlack(0)
|
||||
setNavigationMessage(undefined)
|
||||
}
|
||||
@@ -375,8 +370,6 @@ 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()
|
||||
@@ -459,7 +452,6 @@ 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)
|
||||
@@ -477,13 +469,12 @@ export function Session(props: {
|
||||
}
|
||||
|
||||
function isAwayFromBottom() {
|
||||
if (revealingOlderRows || revealingNewerRows || ensureAllRowsPending || navigationMessage() || firstJump())
|
||||
return true
|
||||
if (revealingOlderRows || revealingNewerRows || ensureAllRowsPending || navigationMessage()) 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 || !!firstJump()
|
||||
const preserveWindow = revealingOlderRows || revealingNewerRows || !!ensureAllRowsPending
|
||||
if (isAwayFromBottom()) setHiddenRows((current) => current ?? hidden())
|
||||
if (awayTimer) clearTimeout(awayTimer)
|
||||
awayTimer = setTimeout(() => {
|
||||
@@ -668,7 +659,6 @@ 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) {
|
||||
@@ -769,65 +759,17 @@ export function Session(props: {
|
||||
group: "Session",
|
||||
palette: undefined,
|
||||
run: () => {
|
||||
if (firstJump()) return
|
||||
clearMessageNavigation()
|
||||
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)
|
||||
})
|
||||
const first = () => {
|
||||
if (data.session.message.more(route.sessionID)) {
|
||||
prependHistory(0, first)
|
||||
return
|
||||
}
|
||||
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())
|
||||
ensureAllRows(() => {
|
||||
scroll.scrollTo(0)
|
||||
})
|
||||
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()
|
||||
},
|
||||
)
|
||||
}
|
||||
prependHistory.after(start)
|
||||
first()
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -859,12 +801,9 @@ 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)
|
||||
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))
|
||||
void client.api.session
|
||||
.rename({ sessionID: route.sessionID, title: input.trim() })
|
||||
.catch((error) => toast.error(error))
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1386,7 +1325,6 @@ 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()
|
||||
@@ -1414,10 +1352,7 @@ export function Session(props: {
|
||||
</scrollbox>
|
||||
</box>
|
||||
<box height={1} flexShrink={0} flexDirection="row" justifyContent="flex-end">
|
||||
<Show when={firstJump()}>
|
||||
<text fg={theme.text.feedback.info.default}>Loading session history...</text>
|
||||
</Show>
|
||||
<Show when={!firstJump() && awayFromBottom()}>
|
||||
<Show when={awayFromBottom()}>
|
||||
<box
|
||||
id="session-jump-to-latest"
|
||||
paddingLeft={1}
|
||||
|
||||
@@ -4,8 +4,6 @@ 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"
|
||||
@@ -47,24 +45,11 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
>
|
||||
<box flexShrink={0} gap={1} paddingRight={1}>
|
||||
<box paddingRight={1}>
|
||||
<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>
|
||||
<text fg={theme.text.default}>
|
||||
<b>{withTimestampedFallback(session()!)}</b>
|
||||
</text>
|
||||
<Show when={session()!.location.workspaceID}>
|
||||
<text fg={theme.text.subdued}>{session()!.location.workspaceID}</text>
|
||||
</Show>
|
||||
</box>
|
||||
<Slot path="sidebar.content" input={{ sessionID: props.sessionID }} />
|
||||
|
||||
@@ -284,8 +284,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
selection = option
|
||||
if (!moved) return
|
||||
if (
|
||||
!props.preserveSelection &&
|
||||
(props.current === undefined || props.focusCurrent === false || store.filter.length > 0)
|
||||
(!props.preserveSelection && (props.current === undefined || props.focusCurrent === false)) ||
|
||||
store.filter.length > 0
|
||||
)
|
||||
return
|
||||
scrollAfterLayout(false, option.value)
|
||||
|
||||
@@ -8,241 +8,6 @@ import path from "node:path"
|
||||
import { createEventStream, createFetch, directory, json } from "./fixture/tui-client"
|
||||
import { tmpdir } from "./fixture/fixture"
|
||||
|
||||
test.each([100, 44])("Ctrl-O is immediate, dismissible, and prunes cached deletions at width %s", async (width) => {
|
||||
await using state = await tmpdir()
|
||||
const setup = await createTestRenderer({ width, height: 30, useThread: false, kittyKeyboard: true })
|
||||
setup.renderer.start()
|
||||
const ready = Promise.withResolvers<void>()
|
||||
const requested = Promise.withResolvers<void>()
|
||||
const response = Promise.withResolvers<Response>()
|
||||
const projects = Promise.withResolvers<Response>()
|
||||
const refresh = Promise.withResolvers<Response>()
|
||||
const events = createEventStream()
|
||||
const cachedSession = {
|
||||
id: "ses_cached",
|
||||
title: "Cached session",
|
||||
projectID: "proj_fixture",
|
||||
location: { directory: "/fixture" },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 2 },
|
||||
}
|
||||
let requests = 0
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === "/api/session") {
|
||||
requests++
|
||||
requested.resolve()
|
||||
if (requests === 1) return response.promise
|
||||
if (requests === 2 || requests === 4) return refresh.promise.then((response) => response.clone())
|
||||
if (requests > 4) return new Response("Unavailable", { status: 503 })
|
||||
return json({ data: [cachedSession], cursor: {} })
|
||||
}
|
||||
if (url.pathname === "/api/project") return projects.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 () => ({ animations: false }), update: async () => ({}) },
|
||||
packages: { resolve: async () => undefined },
|
||||
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
|
||||
args: {},
|
||||
log: () => {},
|
||||
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
|
||||
)
|
||||
await ready.promise
|
||||
await setup.waitForFrame((frame) => frame.includes("commands"))
|
||||
setup.mockInput.pressKey("o", { ctrl: true })
|
||||
await requested.promise
|
||||
await setup.renderOnce()
|
||||
expect(setup.captureCharFrame()).toContain("Search sessions")
|
||||
expect(setup.captureCharFrame()).toContain("Refreshing")
|
||||
projects.resolve(
|
||||
json([
|
||||
{
|
||||
id: "proj_fixture",
|
||||
canonical: "/fixture",
|
||||
name: "Fixture project",
|
||||
time: { created: 1, updated: 2 },
|
||||
sandboxes: [],
|
||||
},
|
||||
]),
|
||||
)
|
||||
await setup.waitForFrame((frame) => frame.includes("Fixture project"))
|
||||
setup.mockInput.pressKey("o", { ctrl: true })
|
||||
expect(requests).toBe(1)
|
||||
setup.mockInput.pressEscape()
|
||||
await setup.waitForFrame((frame) => !frame.includes("Fixture project"))
|
||||
response.resolve(json({ data: [{ ...cachedSession, id: "ses_disposed", title: "Disposed response" }], cursor: {} }))
|
||||
await setup.renderOnce()
|
||||
expect(setup.captureCharFrame()).not.toContain("Fixture project")
|
||||
setup.mockInput.pressKey("o", { ctrl: true })
|
||||
await setup.waitForFrame((frame) => frame.includes("Refreshing"))
|
||||
expect(setup.captureCharFrame()).not.toContain("Disposed")
|
||||
setup.mockInput.pressEscape()
|
||||
await setup.waitForFrame((frame) => !frame.includes("Fixture project"))
|
||||
setup.mockInput.pressKey("o", { ctrl: true })
|
||||
await setup.waitForFrame((frame) => frame.includes("Cached"))
|
||||
setup.mockInput.pressEscape()
|
||||
await setup.waitForFrame((frame) => !frame.includes("Fixture project"))
|
||||
setup.mockInput.pressKey("o", { ctrl: true })
|
||||
await setup.waitForFrame((frame) => frame.includes("Cached") && frame.includes("Refreshing"))
|
||||
events.emit({
|
||||
id: "evt_deleted",
|
||||
created: 1,
|
||||
type: "session.deleted",
|
||||
durable: { aggregateID: "ses_cached", seq: 1, version: 2 },
|
||||
data: { sessionID: "ses_cached" },
|
||||
})
|
||||
await setup.waitForFrame((frame) => !frame.includes("Cached"))
|
||||
refresh.resolve(json({ data: [cachedSession], cursor: {} }))
|
||||
await setup.waitForFrame((frame) => frame.includes("Fixture project") && !frame.includes("Refreshing"))
|
||||
expect(setup.captureCharFrame()).not.toContain("Cached")
|
||||
setup.mockInput.pressEscape()
|
||||
await setup.waitForFrame((frame) => !frame.includes("Fixture project"))
|
||||
setup.mockInput.pressKey("o", { ctrl: true })
|
||||
await setup.waitForFrame((frame) => frame.includes("Could not refresh sessions"))
|
||||
expect(setup.captureCharFrame()).not.toContain("Cached")
|
||||
setup.renderer.destroy()
|
||||
await task
|
||||
} finally {
|
||||
response.resolve(json({ data: [], cursor: {} }))
|
||||
projects.resolve(json([]))
|
||||
refresh.resolve(json({ data: [], cursor: {} }))
|
||||
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
||||
await server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test.each(["dismissed", "refreshing"])(
|
||||
"Ctrl-O retains committed movement of a cached-only session while %s",
|
||||
async (phase) => {
|
||||
await using state = await tmpdir()
|
||||
const setup = await createTestRenderer({ width: 100, height: 30, useThread: false, kittyKeyboard: true })
|
||||
setup.renderer.start()
|
||||
const ready = Promise.withResolvers<void>()
|
||||
const refresh = Promise.withResolvers<Response>()
|
||||
const metadata = Promise.withResolvers<Response>()
|
||||
const destinationRequested = Promise.withResolvers<void>()
|
||||
const events = createEventStream()
|
||||
const cached = {
|
||||
id: "ses_cached_move",
|
||||
title: "Cached movement",
|
||||
projectID: "proj_old",
|
||||
location: { directory: "/fixture/old" },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 2 },
|
||||
}
|
||||
let requests = 0
|
||||
const locations: string[] = []
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === "/api/session") {
|
||||
if (url.searchParams.has("parentID")) {
|
||||
const parent = url.searchParams.get("parentID")
|
||||
if (parent && parent !== "null") return json({ data: [], cursor: {} })
|
||||
}
|
||||
return requests++ === 0
|
||||
? json({ data: [cached], cursor: {} })
|
||||
: refresh.promise.then((response) => response.clone())
|
||||
}
|
||||
if (url.pathname === `/api/session/${cached.id}`) return metadata.promise
|
||||
if (url.pathname === `/api/session/${cached.id}/message`) return json({ data: [], cursor: {} })
|
||||
if (url.pathname === `/api/session/${cached.id}/inbox` || url.pathname === `/api/session/${cached.id}/permission`)
|
||||
return json({ data: [] })
|
||||
if (url.pathname === "/api/project")
|
||||
return json(
|
||||
["old", "new"].map((name) => ({
|
||||
id: `proj_${name}`,
|
||||
canonical: `/fixture/${name}`,
|
||||
name: name === "old" ? "Old" : "New",
|
||||
time: { created: 1, updated: 2 },
|
||||
sandboxes: [],
|
||||
})),
|
||||
)
|
||||
if (url.pathname === "/api/location") {
|
||||
const query = url.searchParams.get("location[directory]") ?? ""
|
||||
locations.push(query)
|
||||
if (query.includes("/fixture/new")) {
|
||||
destinationRequested.resolve()
|
||||
return json({
|
||||
directory: "/fixture/new",
|
||||
project: { id: "proj_new", directory: "/fixture/new", canonical: "/fixture/new" },
|
||||
})
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}, events)
|
||||
const server = Bun.serve({ port: 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 } }), update: async () => ({}) },
|
||||
packages: { resolve: async () => undefined },
|
||||
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
|
||||
args: {},
|
||||
log: () => {},
|
||||
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
|
||||
)
|
||||
try {
|
||||
await ready.promise
|
||||
await setup.waitForFrame((frame) => frame.includes("commands"))
|
||||
setup.mockInput.pressKey("o", { ctrl: true })
|
||||
await setup.waitForFrame((frame) => frame.includes(cached.title) && !frame.includes("Refreshing"))
|
||||
setup.mockInput.pressEscape()
|
||||
await setup.waitForFrame((frame) => !frame.includes("Search sessions"))
|
||||
if (phase === "refreshing") {
|
||||
setup.mockInput.pressKey("o", { ctrl: true })
|
||||
await setup.waitForFrame((frame) => frame.includes(cached.title) && frame.includes("Refreshing"))
|
||||
}
|
||||
events.emit({
|
||||
id: "evt_cached_moved",
|
||||
created: 3,
|
||||
type: "session.moved",
|
||||
durable: { aggregateID: cached.id, seq: 1, version: 1 },
|
||||
data: { sessionID: cached.id, location: { directory: "/fixture/new" }, projectID: "proj_new" },
|
||||
})
|
||||
if (phase === "dismissed") {
|
||||
setup.mockInput.pressKey("o", { ctrl: true })
|
||||
refresh.resolve(new Response("Unavailable", { status: 503 }))
|
||||
await setup.waitForFrame((frame) => frame.includes("Could not refresh sessions"))
|
||||
}
|
||||
if (phase === "refreshing") {
|
||||
await setup.waitForFrame((frame) =>
|
||||
frame.split("\n").some((line) => line.includes(cached.title) && line.includes("New")),
|
||||
)
|
||||
refresh.resolve(json({ data: [cached], cursor: {} }))
|
||||
await setup.waitForFrame((frame) => frame.includes(cached.title) && !frame.includes("Refreshing"))
|
||||
}
|
||||
await setup.waitForFrame((frame) =>
|
||||
frame.split("\n").some((line) => line.includes(cached.title) && line.includes("New")),
|
||||
)
|
||||
expect(
|
||||
setup
|
||||
.captureCharFrame()
|
||||
.split("\n")
|
||||
.find((line) => line.includes(cached.title)),
|
||||
).toContain("New")
|
||||
locations.length = 0
|
||||
setup.mockInput.pressEnter()
|
||||
await destinationRequested.promise
|
||||
expect(locations.some((query) => query.includes("/fixture/old"))).toBe(false)
|
||||
} finally {
|
||||
refresh.resolve(json({ data: [], cursor: {} }))
|
||||
metadata.resolve(json({ data: { ...cached, projectID: "proj_new", location: { directory: "/fixture/new" } } }))
|
||||
setup.renderer.destroy()
|
||||
await task
|
||||
await server.stop()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
test("SIGHUP clears title and disposes scoped resources once", async () => {
|
||||
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
|
||||
const titles: string[] = []
|
||||
@@ -458,87 +223,6 @@ 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()
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { expect, test } from "bun:test"
|
||||
import { once } from "node:events"
|
||||
import { CliRenderEvents, TextAttributes } from "@opentui/core"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { createSignal, onMount } from "solid-js"
|
||||
import type { SessionInfo } from "@opencode-ai/client"
|
||||
import { DialogOpen, DialogOpenKey } from "../../../src/component/dialog-open"
|
||||
import { onMount } from "solid-js"
|
||||
import { DialogOpen, DialogOpenKey, loadDialogOpen } from "../../../src/component/dialog-open"
|
||||
import { ConfigProvider } from "../../../src/config"
|
||||
import { ClientProvider } from "../../../src/context/client"
|
||||
import { ClientProvider, useClient } from "../../../src/context/client"
|
||||
import { DataProvider, useData } from "../../../src/context/data"
|
||||
import { Keymap } from "../../../src/context/keymap"
|
||||
import { LocationProvider, useLocation } from "../../../src/context/location"
|
||||
@@ -88,7 +85,7 @@ test("finds and opens an exact session ID outside the recent list", async () =>
|
||||
expect(fixture.route.data).toEqual({ type: "session", sessionID })
|
||||
expect(fixture.location.ref).toEqual(remote)
|
||||
} finally {
|
||||
await fixture.dispose()
|
||||
fixture.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -134,7 +131,7 @@ test("shows the current project and opens its root", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("shows projects while sessions refresh and preserves the selected project", async () => {
|
||||
test("waits for sessions before showing the populated picker", async () => {
|
||||
let resolveSessions!: (response: Response) => void
|
||||
const sessions = new Promise<Response>((resolve) => (resolveSessions = resolve))
|
||||
const fixture = await renderOpen((url) => {
|
||||
@@ -160,9 +157,8 @@ test("shows projects while sessions refresh and preserves the selected project",
|
||||
})
|
||||
|
||||
try {
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Second project") && frame.includes("Refreshing"))
|
||||
expect(fixture.app.captureCharFrame()).toContain("Search sessions and projects")
|
||||
fixture.app.mockInput.pressArrow("down")
|
||||
await fixture.app.renderOnce()
|
||||
expect(fixture.app.captureCharFrame()).not.toContain("Search sessions and projects")
|
||||
|
||||
resolveSessions(
|
||||
json({
|
||||
@@ -181,6 +177,8 @@ test("shows projects while sessions refresh and preserves the selected project",
|
||||
}),
|
||||
)
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Recent session") && frame.includes("Second project"))
|
||||
fixture.app.mockInput.pressArrow("down")
|
||||
fixture.app.mockInput.pressArrow("down")
|
||||
fixture.app.mockInput.pressEnter()
|
||||
await fixture.app.waitFor(() => fixture.route.data.type === "home")
|
||||
|
||||
@@ -190,240 +188,6 @@ test("shows projects while sessions refresh and preserves the selected project",
|
||||
}
|
||||
})
|
||||
|
||||
test.each([false, true])("keeps a filtered selection visible after refresh with query reset %s", async (reset) => {
|
||||
const sessions = Promise.withResolvers<Response>()
|
||||
const fixture = await renderOpen((url) => {
|
||||
if (url.pathname === "/api/session") return sessions.promise
|
||||
if (url.pathname === "/api/project")
|
||||
return json([
|
||||
{
|
||||
id: "proj_first",
|
||||
canonical: "/tmp/opencode/first",
|
||||
name: "First shared project",
|
||||
time: { created: 1, updated: 2 },
|
||||
sandboxes: [],
|
||||
},
|
||||
{
|
||||
id: "proj_second",
|
||||
canonical: "/tmp/opencode/second",
|
||||
name: "Second shared project",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [],
|
||||
},
|
||||
])
|
||||
return undefined
|
||||
})
|
||||
const selectedTitle = () =>
|
||||
fixture.app
|
||||
.captureSpans()
|
||||
.lines.flatMap((line) => line.spans)
|
||||
.filter((span) => span.attributes & TextAttributes.BOLD)
|
||||
.map((span) => span.text)
|
||||
.join("")
|
||||
try {
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Second shared project") && frame.includes("Refreshing"))
|
||||
await fixture.app.mockInput.typeText("shared")
|
||||
await fixture.app.waitForFrame(() => selectedTitle().includes("First shared project"))
|
||||
fixture.app.mockInput.pressArrow("down")
|
||||
await fixture.app.waitForFrame(() => selectedTitle().includes("Second shared project"))
|
||||
|
||||
sessions.resolve(
|
||||
json({
|
||||
data: Array.from({ length: 12 }, (_, index) => ({
|
||||
...recentSession,
|
||||
id: `ses_shared_${index}`,
|
||||
title: "shared",
|
||||
time: { created: 1, updated: index + 3 },
|
||||
})),
|
||||
cursor: {},
|
||||
}),
|
||||
)
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Open") && !frame.includes("Refreshing"))
|
||||
// Selection reveal runs on FRAME; the following paint must show the selected row.
|
||||
const frame = once(fixture.app.renderer, CliRenderEvents.FRAME)
|
||||
fixture.app.renderer.requestRender()
|
||||
await frame
|
||||
expect(fixture.app.captureCharFrame()).toContain("Second shared project")
|
||||
expect(selectedTitle()).toContain("Second shared project")
|
||||
|
||||
if (reset) {
|
||||
await fixture.app.mockInput.typeText(" project")
|
||||
await fixture.app.waitForFrame(() => selectedTitle().includes("First shared project"))
|
||||
expect(fixture.app.captureCharFrame()).toContain("Second shared project")
|
||||
expect(selectedTitle()).not.toContain("Second shared project")
|
||||
}
|
||||
fixture.app.mockInput.pressEnter()
|
||||
await fixture.app.waitFor(() => fixture.route.data.type === "home")
|
||||
expect(fixture.route.data).toEqual({
|
||||
type: "home",
|
||||
location: { directory: `/tmp/opencode/${reset ? "first" : "second"}` },
|
||||
})
|
||||
} finally {
|
||||
sessions.resolve(json({ data: [], cursor: {} }))
|
||||
await fixture.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
const recentSession = {
|
||||
id: "ses_recent",
|
||||
projectID: "proj_recent",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 2 },
|
||||
title: "Recent session",
|
||||
location: { directory: "/fixture" },
|
||||
}
|
||||
|
||||
test("sessions remain selectable while projects are still loading", async () => {
|
||||
const projects = Promise.withResolvers<Response>()
|
||||
const fixture = await renderOpen((url) => {
|
||||
if (url.pathname === "/api/session") return json({ data: [recentSession], cursor: {} })
|
||||
if (url.pathname === "/api/project") return projects.promise
|
||||
return undefined
|
||||
})
|
||||
try {
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Recent session") && frame.includes("Refreshing"))
|
||||
fixture.app.mockInput.pressEnter()
|
||||
await fixture.app.waitFor(() => fixture.route.data.type === "session")
|
||||
expect(fixture.route.data).toEqual({ type: "session", sessionID: recentSession.id })
|
||||
} finally {
|
||||
projects.resolve(json([]))
|
||||
await fixture.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("shows hydrated sessions immediately without waiting for either read", async () => {
|
||||
const sessions = Promise.withResolvers<Response>()
|
||||
const projects = Promise.withResolvers<Response>()
|
||||
const fixture = await renderOpen(
|
||||
(url) => {
|
||||
if (url.pathname === "/api/session") return sessions.promise
|
||||
if (url.pathname === "/api/project") return projects.promise
|
||||
return undefined
|
||||
},
|
||||
({ data }) => data.session.remember(recentSession),
|
||||
)
|
||||
try {
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Recent session") && frame.includes("Refreshing"))
|
||||
fixture.app.mockInput.pressEnter()
|
||||
await fixture.app.waitFor(() => fixture.route.data.type === "session")
|
||||
expect(fixture.route.data).toEqual({ type: "session", sessionID: recentSession.id })
|
||||
} finally {
|
||||
sessions.resolve(json({ data: [], cursor: {} }))
|
||||
projects.resolve(json([]))
|
||||
await fixture.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps an uncached moved session in the first successful refresh", async () => {
|
||||
const response = Promise.withResolvers<Response>()
|
||||
const destination = { directory: "/fixture/destination" }
|
||||
const fixture = await renderOpen((url) => (url.pathname === "/api/session" ? response.promise : undefined))
|
||||
try {
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Refreshing"))
|
||||
fixture.emit({
|
||||
id: "evt_uncached_move",
|
||||
created: 3,
|
||||
type: "session.moved",
|
||||
durable: { aggregateID: recentSession.id, seq: 1, version: 1 },
|
||||
data: { sessionID: recentSession.id, location: destination, projectID: "proj_destination" },
|
||||
})
|
||||
// The following event supplies an ordered-stream receipt barrier without hydrating metadata.
|
||||
fixture.emit({
|
||||
id: "evt_move_received",
|
||||
created: 4,
|
||||
type: "session.execution.started",
|
||||
durable: { aggregateID: recentSession.id, seq: 2, version: 1 },
|
||||
data: { sessionID: recentSession.id },
|
||||
})
|
||||
await fixture.app.waitFor(() => fixture.data.session.status(recentSession.id) === "running")
|
||||
expect(fixture.data.session.get(recentSession.id)).toBeUndefined()
|
||||
response.resolve(
|
||||
json({
|
||||
data: [
|
||||
{ ...recentSession, location: destination, projectID: "proj_destination", time: { created: 1, updated: 3 } },
|
||||
],
|
||||
cursor: {},
|
||||
}),
|
||||
)
|
||||
await fixture.app.waitForFrame((frame) => frame.includes(recentSession.title) && !frame.includes("Refreshing"))
|
||||
fixture.app.mockInput.pressEnter()
|
||||
await fixture.app.waitFor(() => fixture.route.data.type === "session")
|
||||
expect(fixture.route.data).toEqual({ type: "session", sessionID: recentSession.id })
|
||||
expect(fixture.location.ref).toEqual(destination)
|
||||
} finally {
|
||||
response.resolve(json({ data: [], cursor: {} }))
|
||||
await fixture.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps the previous recent list usable when reopening fails to refresh", async () => {
|
||||
let requests = 0
|
||||
const fixture = await renderOpen((url) => {
|
||||
if (url.pathname === "/api/session")
|
||||
return requests++ === 0
|
||||
? json({ data: [recentSession], cursor: {} })
|
||||
: new Response("Unavailable", { status: 503 })
|
||||
return undefined
|
||||
})
|
||||
try {
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Recent session") && !frame.includes("Refreshing"))
|
||||
fixture.app.mockInput.pressEscape()
|
||||
await fixture.app.waitForFrame((frame) => !frame.includes("Recent session"))
|
||||
fixture.open()
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Could not refresh sessions"))
|
||||
expect(fixture.app.captureCharFrame()).toContain("Recent session")
|
||||
fixture.app.mockInput.pressEnter()
|
||||
await fixture.app.waitFor(() => fixture.route.data.type === "session")
|
||||
expect(fixture.route.data).toEqual({ type: "session", sessionID: recentSession.id })
|
||||
} finally {
|
||||
await fixture.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("shows an initial loading shell instead of reporting an empty list", async () => {
|
||||
const sessions = Promise.withResolvers<Response>()
|
||||
const projects = Promise.withResolvers<Response>()
|
||||
const fixture = await renderOpen((url) => {
|
||||
if (url.pathname === "/api/session") return sessions.promise
|
||||
if (url.pathname === "/api/project") return projects.promise
|
||||
return undefined
|
||||
})
|
||||
try {
|
||||
await fixture.app.waitForFrame(
|
||||
(frame) => frame.includes("Search sessions and projects") && frame.includes("Refreshing"),
|
||||
)
|
||||
expect(fixture.app.captureCharFrame()).not.toContain("No items available")
|
||||
await fixture.app.mockInput.typeText("missing")
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Searching sessions and projects"))
|
||||
expect(fixture.app.captureCharFrame()).not.toContain("No matches")
|
||||
sessions.resolve(json({ data: [], cursor: {} }))
|
||||
projects.resolve(json([]))
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("No matches") && !frame.includes("Refreshing"))
|
||||
} finally {
|
||||
sessions.resolve(json({ data: [], cursor: {} }))
|
||||
projects.resolve(json([]))
|
||||
await fixture.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("reports both refresh failures while keeping hydrated sessions usable", async () => {
|
||||
const fixture = await renderOpen(
|
||||
(url) => {
|
||||
if (url.pathname === "/api/session" || url.pathname === "/api/project")
|
||||
return new Response("Unavailable", { status: 503 })
|
||||
return undefined
|
||||
},
|
||||
({ data }) => data.session.remember(recentSession),
|
||||
)
|
||||
try {
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Could not refresh sessions and projects"))
|
||||
expect(fixture.app.captureCharFrame()).toContain("Recent session")
|
||||
} finally {
|
||||
await fixture.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("option arrows jump between sections", async () => {
|
||||
const handler: FetchHandler = (url) => {
|
||||
if (url.pathname === "/api/session")
|
||||
@@ -527,21 +291,20 @@ async function renderOpen(
|
||||
let location!: ReturnType<typeof useLocation>
|
||||
let data!: ReturnType<typeof useData>
|
||||
let storage!: ReturnType<typeof useStorage>
|
||||
let open!: () => void
|
||||
|
||||
function Probe() {
|
||||
const dialog = useDialog()
|
||||
const [sessions, setSessions] = createSignal<SessionInfo[]>([])
|
||||
const client = useClient()
|
||||
route = useRoute()
|
||||
location = useLocation()
|
||||
data = useData()
|
||||
storage = useStorage()
|
||||
open = () =>
|
||||
dialog.replace(() => <DialogOpen sessions={sessions()} onLoad={setSessions} />, undefined, {
|
||||
key: DialogOpenKey,
|
||||
size: "large",
|
||||
})
|
||||
onMount(() => void Promise.resolve(beforeOpen?.({ data, location })).then(open))
|
||||
onMount(
|
||||
() =>
|
||||
void Promise.all([beforeOpen?.({ data, location }), loadDialogOpen(data, client)]).then(([, sessions]) =>
|
||||
dialog.replace(() => <DialogOpen sessions={sessions} />, undefined, { key: DialogOpenKey, size: "large" }),
|
||||
),
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -581,8 +344,6 @@ async function renderOpen(
|
||||
|
||||
return {
|
||||
app,
|
||||
emit: events.emit,
|
||||
open: () => open(),
|
||||
get route() {
|
||||
return route
|
||||
},
|
||||
|
||||
@@ -101,37 +101,3 @@ 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")
|
||||
})
|
||||
|
||||
@@ -32,6 +32,7 @@ for (const orientation of ["horizontal", "vertical"] as const) {
|
||||
await using temporary = await tmpdir()
|
||||
const [status, setStatus] = createSignal<SessionTabsStatus>(EMPTY_SESSION_TAB_STATUS)
|
||||
const [active, setActive] = createSignal("second")
|
||||
const [animations, setAnimations] = createSignal(false)
|
||||
const [newTab, setNewTab] = createSignal(false)
|
||||
const [preview, setPreview] = createSignal(false)
|
||||
const settings: Info = { tabs: { enabled: true } }
|
||||
@@ -90,7 +91,11 @@ for (const orientation of ["horizontal", "vertical"] as const) {
|
||||
<ToastProvider>
|
||||
<DialogProvider>
|
||||
<box width="100%" height="100%">
|
||||
<SessionTabs controller={controller} orientation={orientation} animations={false} />
|
||||
<SessionTabs
|
||||
controller={controller}
|
||||
orientation={orientation}
|
||||
animations={animations()}
|
||||
/>
|
||||
</box>
|
||||
</DialogProvider>
|
||||
</ToastProvider>
|
||||
@@ -136,6 +141,7 @@ for (const orientation of ["horizontal", "vertical"] as const) {
|
||||
}
|
||||
|
||||
for (const attention of ["question", "permission"] as const) {
|
||||
setAnimations(false)
|
||||
setActive("second")
|
||||
setStatus({ ...EMPTY_SESSION_TAB_STATUS, busy: true, attention })
|
||||
await app.renderOnce()
|
||||
@@ -165,39 +171,84 @@ for (const orientation of ["horizontal", "vertical"] as const) {
|
||||
const dim = glow()
|
||||
expect(dim).toBeGreaterThan(0)
|
||||
expect(dim).toBeLessThan(full)
|
||||
setActive("second")
|
||||
await app.renderOnce()
|
||||
setAnimations(true)
|
||||
await app.renderOnce()
|
||||
expect(app.renderer.root.liveCount).toBe(0)
|
||||
|
||||
setActive("first")
|
||||
await app.renderOnce()
|
||||
expect(app.renderer.root.liveCount).toBe(0)
|
||||
await app.waitForFrame(() => glow() > dim && glow() < full)
|
||||
await app.waitForFrame(() => glow() === dim, { maxPasses: 60 })
|
||||
setActive("second")
|
||||
await app.renderOnce()
|
||||
expect(app.renderer.root.liveCount).toBe(0)
|
||||
await app.waitForFrame(() => glow() > dim && glow() < full)
|
||||
await app.waitForFrame(() => glow() === full, { maxPasses: 60 })
|
||||
|
||||
setStatus(EMPTY_SESSION_TAB_STATUS)
|
||||
await app.renderOnce()
|
||||
expect(app.renderer.root.liveCount).toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
const glyph = "\u2022"
|
||||
for (const unread of ["activity", "error"] as const) {
|
||||
setAnimations(false)
|
||||
setActive("second")
|
||||
setStatus({ ...EMPTY_SESSION_TAB_STATUS, busy: true })
|
||||
await app.renderOnce()
|
||||
setAnimations(true)
|
||||
setStatus({ ...EMPTY_SESSION_TAB_STATUS, unread })
|
||||
await app.renderOnce()
|
||||
const color = app
|
||||
.captureSpans()
|
||||
.lines.flatMap((line) => line.spans)
|
||||
.find((span) => span.text.trim() === glyph)?.fg
|
||||
expect(color?.toInts()).toEqual(
|
||||
const color = () =>
|
||||
app
|
||||
.captureSpans()
|
||||
.lines.flatMap((line) => line.spans)
|
||||
.find((span) => span.text.trim() === glyph)?.fg
|
||||
expect(color()?.toInts()).toEqual(
|
||||
(unread === "error" ? theme.text.feedback.error.default : theme.text.status.unread).toInts(),
|
||||
)
|
||||
const brightness = () => {
|
||||
const value = color()
|
||||
return value ? value.r + value.g + value.b : undefined
|
||||
}
|
||||
const initial = brightness()!
|
||||
await app.mockMouse.click(1, orientation === "vertical" ? 1 : 0)
|
||||
await app.renderOnce()
|
||||
expect(active()).toBe("first")
|
||||
expect(status().unread).toBeUndefined()
|
||||
expect(app.captureCharFrame()).toContain(" First")
|
||||
expect(app.captureCharFrame()).not.toContain(`${glyph} First`)
|
||||
expect(app.captureCharFrame()).toContain(`${glyph} First`)
|
||||
await app.waitForFrame((frame) => frame.includes(`${glyph} First`) && (brightness() ?? -1) > initial)
|
||||
const peak = brightness()!
|
||||
await app.waitForFrame((frame) => frame.includes(`${glyph} First`) && (brightness() ?? Infinity) < peak)
|
||||
await app.waitForFrame((frame) => frame.includes(" First"), { maxPasses: 60 })
|
||||
}
|
||||
|
||||
setAnimations(false)
|
||||
setStatus({ ...EMPTY_SESSION_TAB_STATUS, unread: "activity" })
|
||||
await app.renderOnce()
|
||||
setAnimations(true)
|
||||
await app.mockMouse.click(1, orientation === "vertical" ? 1 : 0)
|
||||
setStatus({ ...EMPTY_SESSION_TAB_STATUS, busy: true })
|
||||
await app.waitForFrame((frame) => SPINNER_FRAMES.slice(1).some((glyph) => frame.includes(`${glyph} First`)))
|
||||
setStatus({ ...EMPTY_SESSION_TAB_STATUS, busy: true, attention: "question" })
|
||||
await app.waitForFrame((frame) => frame.includes("? First"))
|
||||
|
||||
await config.update((draft) => {
|
||||
draft.tabs.indicators = "numbers"
|
||||
})
|
||||
await app.waitForFrame((frame) => frame.includes("1 First") && frame.includes("2 Second"))
|
||||
setStatus({ ...EMPTY_SESSION_TAB_STATUS, busy: true })
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).toContain("1 First")
|
||||
await config.update((draft) => {
|
||||
draft.tabs.indicators = "status"
|
||||
})
|
||||
await app.waitForFrame((frame) => frame.includes(`${SPINNER_FRAMES[0]} First`))
|
||||
await app.waitForFrame((frame) => SPINNER_FRAMES.some((glyph) => frame.includes(`${glyph} First`)))
|
||||
|
||||
setAnimations(false)
|
||||
setStatus(EMPTY_SESSION_TAB_STATUS)
|
||||
setActive("second")
|
||||
await app.renderOnce()
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
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()
|
||||
}
|
||||
})
|
||||
@@ -1,389 +0,0 @@
|
||||
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))
|
||||
}
|
||||
})
|
||||
@@ -193,7 +193,6 @@ const layer = Layer.effect(
|
||||
}
|
||||
|
||||
const tree = yield* reify({ dir, add: [pkg] })
|
||||
if (isMutable(parsed)) refreshed.add(pkg)
|
||||
const first = tree.edgesOut.values().next().value?.to
|
||||
if (!first) {
|
||||
const installed = yield* installedName(pkg, dir, parsedName)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user