mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-08 09:37:47 +03:00
415 lines
15 KiB
TypeScript
415 lines
15 KiB
TypeScript
import { expect, test } from "bun:test"
|
|
import { createRoot } from "solid-js"
|
|
import { createData, settleMs, type CreateDataInput } from "../src/solid"
|
|
import { OpenCode, type OpenCodeEvent, type SessionInfo } from "../src/promise"
|
|
|
|
test("config reads and update refreshes are opt-in", async () => {
|
|
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
|
|
const requests: string[] = []
|
|
const location = { directory: "/project" }
|
|
let model = "provider/first"
|
|
const api = OpenCode.make({
|
|
baseUrl: "http://opencode.local",
|
|
fetch: async (input, init) => {
|
|
const request = input instanceof Request ? input : new Request(input, init)
|
|
const url = new URL(request.url)
|
|
requests.push(url.pathname)
|
|
if (url.pathname === "/api/location") return Response.json(location)
|
|
if (url.pathname === "/api/config") return Response.json([{ type: "document", info: { model } }])
|
|
if (url.pathname === "/api/mcp/resource")
|
|
return Response.json({ location, data: { resources: [], templates: [] } })
|
|
return Response.json({ location, data: [] })
|
|
},
|
|
})
|
|
const setup = createRoot((dispose) => ({
|
|
data: createData({
|
|
api: () => api,
|
|
directory: location.directory,
|
|
event: {
|
|
on: () => () => {},
|
|
listen(handler) {
|
|
listeners.add(handler)
|
|
return () => listeners.delete(handler)
|
|
},
|
|
},
|
|
}),
|
|
dispose,
|
|
}))
|
|
const event: OpenCodeEvent = { id: "evt_config", created: 1, type: "config.updated", location, data: {} }
|
|
try {
|
|
await setup.data.location.sync()
|
|
listeners.forEach((listener) => listener({ name: event.type, details: event }))
|
|
expect(requests).not.toContain("/api/config")
|
|
|
|
await setup.data.location.config.sync()
|
|
expect(requests.filter((path) => path === "/api/config")).toHaveLength(1)
|
|
model = "provider/second"
|
|
listeners.forEach((listener) => listener({ name: event.type, details: event }))
|
|
await setup.data.location.config.sync()
|
|
expect(requests.filter((path) => path === "/api/config")).toHaveLength(2)
|
|
expect(setup.data.location.config.list()).toEqual([{ type: "document", info: { model } }])
|
|
} finally {
|
|
setup.dispose()
|
|
}
|
|
})
|
|
|
|
test("event refreshes report failures, remain retryable, and preserve explicit read errors", async () => {
|
|
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
|
|
const reported = Promise.withResolvers<unknown>()
|
|
const errors: unknown[] = []
|
|
const state = { offline: true, requests: 0 }
|
|
const session: SessionInfo = {
|
|
id: "ses_refresh_failure",
|
|
projectID: "project",
|
|
cost: 0,
|
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
time: { created: 0, updated: 0, idle: 2 },
|
|
location: { directory: "/project" },
|
|
}
|
|
const api = OpenCode.make({
|
|
baseUrl: "http://opencode.local",
|
|
fetch: async () => {
|
|
state.requests++
|
|
if (state.offline) throw new TypeError("Failed to fetch")
|
|
return Response.json({ data: { ...session, title: "Recovered" } })
|
|
},
|
|
})
|
|
const setup = createRoot((dispose) => ({
|
|
data: createData({
|
|
api: () => api,
|
|
directory: "/project",
|
|
event: {
|
|
on: () => () => {},
|
|
listen(handler) {
|
|
listeners.add(handler)
|
|
return () => listeners.delete(handler)
|
|
},
|
|
},
|
|
onError(error) {
|
|
errors.push(error)
|
|
reported.resolve(error)
|
|
},
|
|
}),
|
|
dispose,
|
|
}))
|
|
const event: OpenCodeEvent = {
|
|
id: "evt_refresh_failure",
|
|
created: 2,
|
|
type: "session.viewed",
|
|
durable: { aggregateID: session.id, seq: 1, version: 1 },
|
|
data: { sessionID: session.id, idle: 2 },
|
|
}
|
|
try {
|
|
setup.data.session.remember(session)
|
|
setup.data.session.invalidate(session.id)
|
|
await expect(setup.data.session.sync(session.id)).rejects.toThrow("Transport")
|
|
expect(errors).toEqual([])
|
|
listeners.forEach((listener) => listener({ name: event.type, details: event }))
|
|
expect(String(await reported.promise)).toContain("Transport")
|
|
expect(errors).toHaveLength(1)
|
|
expect(setup.data.session.get(session.id)?.title).toBeUndefined()
|
|
state.offline = false
|
|
listeners.forEach((listener) => listener({ name: event.type, details: event }))
|
|
await setup.data.session.sync(session.id)
|
|
expect(setup.data.session.get(session.id)?.title).toBe("Recovered")
|
|
expect(state.requests).toBe(3)
|
|
} finally {
|
|
setup.dispose()
|
|
}
|
|
})
|
|
|
|
test.each(["reconnecting", "disposed"] as const)("background reads respect %s owners", async (mode) => {
|
|
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
|
|
const pending = Promise.withResolvers<Response>()
|
|
const errors: unknown[] = []
|
|
const state = { requests: 0, status: "connected" as "connected" | "reconnecting" }
|
|
const api = OpenCode.make({
|
|
baseUrl: "http://opencode.local",
|
|
fetch: () => {
|
|
if (state.requests++ === 0) return Promise.resolve(Response.json({ location: { directory: "/project" }, data: [] }))
|
|
return pending.promise
|
|
},
|
|
})
|
|
const setup = createRoot((dispose) => ({
|
|
data: createData({
|
|
api: () => api,
|
|
directory: "/project",
|
|
event: {
|
|
on: () => () => {},
|
|
listen(handler) {
|
|
listeners.add(handler)
|
|
return () => listeners.delete(handler)
|
|
},
|
|
},
|
|
connection: { status: () => state.status },
|
|
onError: (error) => errors.push(error),
|
|
}),
|
|
dispose,
|
|
}))
|
|
await setup.data.location.command.sync()
|
|
state.status = mode === "reconnecting" ? "reconnecting" : "connected"
|
|
const event: OpenCodeEvent = {
|
|
id: "evt_refresh_owner",
|
|
type: "command.updated",
|
|
location: { directory: "/project" },
|
|
data: {},
|
|
}
|
|
listeners.forEach((listener) => listener({ name: event.type, details: event }))
|
|
if (mode === "reconnecting") {
|
|
expect(state.requests).toBe(1)
|
|
setup.dispose()
|
|
return
|
|
}
|
|
const joined = setup.data.location.command.sync()
|
|
setup.dispose()
|
|
pending.reject(new TypeError("Failed to fetch"))
|
|
await expect(joined).rejects.toThrow("Transport")
|
|
expect(state.requests).toBe(2)
|
|
expect(errors).toEqual([])
|
|
})
|
|
|
|
test("a burst of mcp.status.changed events refetches the server list once it settles", async () => {
|
|
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
|
|
const requests: string[] = []
|
|
const location = { directory: "/project" }
|
|
const api = OpenCode.make({
|
|
baseUrl: "http://opencode.local",
|
|
fetch: async (input, init) => {
|
|
const request = input instanceof Request ? input : new Request(input, init)
|
|
requests.push(new URL(request.url).pathname)
|
|
return Response.json({ location, data: [] })
|
|
},
|
|
})
|
|
const setup = createRoot((dispose) => ({
|
|
data: createData({
|
|
api: () => api,
|
|
directory: location.directory,
|
|
event: {
|
|
on: () => () => {},
|
|
listen(handler) {
|
|
listeners.add(handler)
|
|
return () => listeners.delete(handler)
|
|
},
|
|
},
|
|
}),
|
|
dispose,
|
|
}))
|
|
try {
|
|
await setup.data.location.mcp.server.sync()
|
|
expect(requests.filter((path) => path === "/api/mcp")).toHaveLength(1)
|
|
for (const server of ["a", "b", "c", "d", "e"]) {
|
|
const event: OpenCodeEvent = { id: `evt_${server}`, created: 1, type: "mcp.status.changed", location, data: { server } }
|
|
listeners.forEach((listener) => listener({ name: event.type, details: event }))
|
|
}
|
|
expect(requests.filter((path) => path === "/api/mcp")).toHaveLength(1)
|
|
await new Promise((resolve) => setTimeout(resolve, settleMs * 2))
|
|
expect(requests.filter((path) => path === "/api/mcp")).toHaveLength(2)
|
|
} finally {
|
|
setup.dispose()
|
|
}
|
|
})
|
|
|
|
test("invalidations that land before a queued reload starts are absorbed by it", async () => {
|
|
const pending: Array<PromiseWithResolvers<Response>> = []
|
|
const location = { directory: "/project" }
|
|
const api = OpenCode.make({
|
|
baseUrl: "http://opencode.local",
|
|
fetch: () => {
|
|
const deferred = Promise.withResolvers<Response>()
|
|
pending.push(deferred)
|
|
return deferred.promise
|
|
},
|
|
})
|
|
const setup = createRoot((dispose) => ({
|
|
data: createData({
|
|
api: () => api,
|
|
directory: location.directory,
|
|
event: { on: () => () => {}, listen: () => () => {} },
|
|
}),
|
|
dispose,
|
|
}))
|
|
try {
|
|
const first = setup.data.location.agent.sync()
|
|
expect(pending).toHaveLength(1)
|
|
setup.data.location.agent.invalidate()
|
|
const second = setup.data.location.agent.sync()
|
|
setup.data.location.agent.invalidate()
|
|
const third = setup.data.location.agent.sync()
|
|
setup.data.location.agent.invalidate()
|
|
expect(pending).toHaveLength(1)
|
|
pending[0].resolve(Response.json({ location, data: [] }))
|
|
await first
|
|
await Promise.resolve()
|
|
expect(pending).toHaveLength(2)
|
|
pending[1].resolve(Response.json({ location, data: [{ name: "reviewer" }] }))
|
|
await Promise.all([second, third])
|
|
expect(pending).toHaveLength(2)
|
|
await setup.data.location.agent.sync()
|
|
expect(pending).toHaveLength(2)
|
|
} finally {
|
|
setup.dispose()
|
|
}
|
|
})
|
|
|
|
test("event-driven session reads evict missing sessions without reporting an error, while explicit reads still reject", async () => {
|
|
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
|
|
const errors: unknown[] = []
|
|
const session: SessionInfo = {
|
|
id: "ses_ephemeral",
|
|
projectID: "project",
|
|
cost: 0,
|
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
time: { created: 0, updated: 0, idle: 1 },
|
|
location: { directory: "/project" },
|
|
}
|
|
const api = OpenCode.make({
|
|
baseUrl: "http://opencode.local",
|
|
fetch: async () =>
|
|
Response.json(
|
|
{ _tag: "SessionNotFoundError", sessionID: session.id, message: `Session not found: ${session.id}` },
|
|
{ status: 404 },
|
|
),
|
|
})
|
|
const setup = createRoot((dispose) => ({
|
|
data: createData({
|
|
api: () => api,
|
|
directory: "/project",
|
|
event: {
|
|
on: () => () => {},
|
|
listen(handler) {
|
|
listeners.add(handler)
|
|
return () => listeners.delete(handler)
|
|
},
|
|
},
|
|
onError: (error) => errors.push(error),
|
|
}),
|
|
dispose,
|
|
}))
|
|
const viewed: OpenCodeEvent = {
|
|
id: "evt_ephemeral_viewed",
|
|
created: 2,
|
|
type: "session.viewed",
|
|
durable: { aggregateID: session.id, seq: 1, version: 1 },
|
|
data: { sessionID: session.id, idle: 2 },
|
|
}
|
|
try {
|
|
setup.data.session.remember(session)
|
|
expect(setup.data.session.get(session.id)).toBeDefined()
|
|
listeners.forEach((listener) => listener({ name: viewed.type, details: viewed }))
|
|
const deadline = Date.now() + 2_000
|
|
while (!setup.data.session.missing(session.id)) {
|
|
if (Date.now() > deadline) throw new Error("Timed out waiting for missing-session eviction")
|
|
await Bun.sleep(5)
|
|
}
|
|
expect(errors).toEqual([])
|
|
expect(setup.data.session.get(session.id)).toBeUndefined()
|
|
expect(setup.data.session.missing(session.id)).toBe(true)
|
|
|
|
await expect(setup.data.session.sync(session.id)).rejects.toMatchObject({
|
|
_tag: "SessionNotFoundError",
|
|
sessionID: session.id,
|
|
})
|
|
expect(errors).toEqual([])
|
|
} finally {
|
|
setup.dispose()
|
|
}
|
|
})
|
|
|
|
test("delayed metadata and family reads do not resurrect sessions deleted while in flight", async () => {
|
|
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
|
|
const makeSession = (id: string, parentID?: string): SessionInfo => ({
|
|
id,
|
|
projectID: "project",
|
|
...(parentID ? { parentID } : {}),
|
|
cost: 0,
|
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
time: { created: 0, updated: 0 },
|
|
location: { directory: "/project" },
|
|
})
|
|
const root = makeSession("ses_root")
|
|
const child = makeSession("ses_child", root.id)
|
|
const ownStarted = Promise.withResolvers<void>()
|
|
const ownRelease = Promise.withResolvers<void>()
|
|
const familyStarted = Promise.withResolvers<void>()
|
|
const familyRelease = Promise.withResolvers<void>()
|
|
const requests: string[] = []
|
|
const api = OpenCode.make({
|
|
baseUrl: "http://opencode.local",
|
|
fetch: async (input, init) => {
|
|
const url = new URL((input instanceof Request ? input : new Request(input, init)).url)
|
|
requests.push(`${url.pathname}${url.search}`)
|
|
if (url.pathname === `/api/session/${root.id}`) return Response.json({ data: root })
|
|
if (url.pathname === "/api/session" && url.searchParams.get("parentID") === root.id) {
|
|
familyStarted.resolve()
|
|
await familyRelease.promise
|
|
return Response.json({ data: [child], cursor: {} })
|
|
}
|
|
if (url.pathname === `/api/session/${child.id}` && requests.filter((item) => item === `/api/session/${child.id}`).length === 1) {
|
|
ownStarted.resolve()
|
|
await ownRelease.promise
|
|
return Response.json({ data: child })
|
|
}
|
|
return Response.json(
|
|
{ _tag: "SessionNotFoundError", sessionID: child.id, message: `Session not found: ${child.id}` },
|
|
{ status: 404 },
|
|
)
|
|
},
|
|
})
|
|
const setup = createRoot((dispose) => ({
|
|
data: createData({
|
|
api: () => api,
|
|
directory: "/project",
|
|
event: {
|
|
on: () => () => {},
|
|
listen(handler) {
|
|
listeners.add(handler)
|
|
return () => listeners.delete(handler)
|
|
},
|
|
},
|
|
}),
|
|
dispose,
|
|
}))
|
|
const emitDeleted = (sessionID: string, seq: number) => {
|
|
const event: OpenCodeEvent = {
|
|
id: `evt_del_${seq}`,
|
|
created: seq,
|
|
type: "session.deleted",
|
|
durable: { aggregateID: sessionID, seq, version: 1 },
|
|
data: { sessionID },
|
|
}
|
|
listeners.forEach((listener) => listener({ name: event.type, details: event }))
|
|
}
|
|
try {
|
|
// Own metadata read started before deletion must not republish.
|
|
setup.data.session.remember(child)
|
|
setup.data.session.invalidate(child.id)
|
|
const ownSync = setup.data.session.sync(child.id)
|
|
await ownStarted.promise
|
|
emitDeleted(child.id, 1)
|
|
expect(setup.data.session.get(child.id)).toBeUndefined()
|
|
ownRelease.resolve()
|
|
await ownSync
|
|
expect(setup.data.session.get(child.id)).toBeUndefined()
|
|
|
|
// Parent family read started before child deletion must not republish or mark child synced.
|
|
setup.data.session.remember(root)
|
|
setup.data.session.remember(child)
|
|
const familySync = setup.data.session.sync(root.id, { children: true })
|
|
await familyStarted.promise
|
|
emitDeleted(child.id, 2)
|
|
expect(setup.data.session.get(child.id)).toBeUndefined()
|
|
familyRelease.resolve()
|
|
await familySync
|
|
expect(setup.data.session.get(child.id)).toBeUndefined()
|
|
expect(setup.data.session.family(root.id)).toEqual([root.id])
|
|
await expect(setup.data.session.sync(child.id)).rejects.toMatchObject({
|
|
_tag: "SessionNotFoundError",
|
|
sessionID: child.id,
|
|
})
|
|
} finally {
|
|
ownRelease.resolve()
|
|
familyRelease.resolve()
|
|
setup.dispose()
|
|
}
|
|
})
|