Compare commits

...

3 Commits

Author SHA1 Message Date
Kit Langton 3f2aef16a1 fix(client): let complete moves resolve pending placement 2026-09-02 22:08:40 -04:00
Kit Langton 181283c0fd fix(client): compose move and repository adoption facts 2026-09-02 22:01:25 -04:00
Kit Langton 3223615646 fix(client): preserve live moves across metadata reads 2026-09-02 21:45:22 -04:00
2 changed files with 679 additions and 33 deletions
+72 -33
View File
@@ -52,6 +52,7 @@ import { batch, createEffect, createMemo, createSignal, onCleanup } from "solid-
export type DataSessionStatus = "idle" | "running"
type OpenCodeEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent, { type: Type }> }
type SessionPlacementEvent = OpenCodeEventMap["session.moved" | "worktree.resolved"]
export type CreateDataInput = {
readonly api: () => OpenCodeClient
@@ -213,6 +214,8 @@ export function createData(config: CreateDataInput) {
)
const messageIndex = new Map<string, Map<string, number>>()
const sync = createSync()
// Placement facts observed during a metadata read must compose in order, even for uncached family members.
const sessionPlacements = new Set<SessionPlacementEvent[]>()
let activeUpdates: Map<string, DataSessionStatus | undefined> | undefined
function setSessionActive(sessionID: string, status: DataSessionStatus) {
@@ -549,7 +552,20 @@ export function createData(config: CreateDataInput) {
)
}
function adoptionTarget(
info: Pick<SessionInfo, "projectID" | "location">,
event: OpenCodeEventMap["worktree.resolved"]["data"],
) {
const directory = event.adopted?.includes(info.projectID)
? store.project.info[info.projectID]?.canonical
: info.location.directory
if (!directory) return
return { projectID: info.projectID, directory, workspaceID: info.location.workspaceID }
}
function handleEvent(event: OpenCodeEvent) {
if (event.type === "session.moved" || event.type === "worktree.resolved")
sessionPlacements.forEach((events) => events.push(event))
switch (event.type) {
case "server.connected": {
const updates = new Map<string, DataSessionStatus | undefined>()
@@ -658,7 +674,7 @@ export function createData(config: CreateDataInput) {
projectID: current.projectID,
subpath: current.subpath,
}
setStore("session", "info", event.data.sessionID, "location", event.data.location)
setStore("session", "info", event.data.sessionID, "location", reconcile(event.data.location))
if (event.data.projectID) setStore("session", "info", event.data.sessionID, "projectID", event.data.projectID)
setStore("session", "info", event.data.sessionID, "subpath", event.data.subpath)
message.insert(event.data.sessionID, {
@@ -675,22 +691,14 @@ export function createData(config: CreateDataInput) {
}
case "worktree.resolved": {
for (const [sessionID, info] of Object.entries(store.session.info)) {
const explicit = event.data.adopted?.includes(info.projectID)
const directory = explicit ? store.project.info[info.projectID]?.canonical : info.location.directory
if (!directory) {
const target = adoptionTarget(info, event.data)
if (!target) {
if (info.location.workspaceID) continue
result.session.invalidate(sessionID)
void result.session.sync(sessionID)
continue
}
const adopted = Worktree.adopt(
{
projectID: info.projectID,
directory,
workspaceID: info.location.workspaceID,
},
event.data,
)
const adopted = Worktree.adopt(target, event.data)
if (!adopted) continue
setStore("session", "info", sessionID, "projectID", adopted.projectID)
setStore("session", "info", sessionID, "subpath", adopted.subpath)
@@ -1481,29 +1489,60 @@ export function createData(config: CreateDataInput) {
})
},
sync(sessionID: string, options?: { children?: boolean }) {
return sync.run(options?.children ? `session.family:${sessionID}` : `session:${sessionID}`, async () => {
const [info, children] = await Promise.all([
api().session.get({ sessionID }),
options?.children
? api()
.session.list({ parentID: sessionID, order: "desc" })
.then((response) => response.data)
: [],
])
const sessions = [info, ...children]
batch(() => {
setStore(
"session",
"info",
produce((draft) => {
for (const session of sessions) draft[session.id] = session
}),
return sync.run(options?.children ? `session.family:${sessionID}` : `session:${sessionID}`, () => {
const placements: SessionPlacementEvent[] = []
sessionPlacements.add(placements)
const load = async (): Promise<void> => {
const [info, children] = await Promise.all([
api().session.get({ sessionID }),
options?.children
? api()
.session.list({ parentID: sessionID, order: "desc" })
.then((response) => response.data)
: [],
])
const sessions = [info, ...children].map((session) =>
placements.reduce<SessionInfo | undefined>((info, event) => {
// A complete move supersedes even an earlier adoption whose placement was unresolved.
if (event.type === "session.moved")
return event.data.sessionID === session.id
? {
...session,
location: { ...event.data.location },
projectID: event.data.projectID,
subpath: event.data.subpath,
}
: info
if (!info) return
const target = adoptionTarget(info, event.data)
if (!target && !info.location.workspaceID) return
const adopted = target && Worktree.adopt(target, event.data)
return adopted ? { ...info, ...adopted } : info
}, session),
)
for (const session of sessions) {
sync.complete(`session:${session.id}`)
registerSession(session.id)
const resolved = sessions.filter((session) => session !== undefined)
if (resolved.length !== sessions.length) {
// Explicit adoption needs the directory project's canonical path to derive subpath.
// Without it, read after the observed facts instead of guessing or publishing an old identity.
placements.length = 0
return load()
}
})
batch(() => {
setStore(
"session",
"info",
produce((draft) => {
// Publish once: even a transient old location can redirect the UI.
for (const session of resolved) draft[session.id] = session
}),
)
for (const session of resolved) {
sync.complete(`session:${session.id}`)
registerSession(session.id)
}
})
}
return load().finally(() => sessionPlacements.delete(placements))
})
},
invalidate(sessionID: string) {
@@ -0,0 +1,607 @@
import { expect, test } from "bun:test"
import { createComputed, createRoot, createSignal } from "solid-js"
import { isServer } from "solid-js/web"
import { createData, type CreateDataInput } from "../src/solid"
import { OpenCode, type OpenCodeEvent, type SessionInfo } from "../src/promise"
const session = (id = "ses_move", parentID?: string): SessionInfo => ({
id,
parentID,
projectID: "project-original",
location: { directory: "/repo", workspaceID: "workspace-original" },
subpath: "original",
title: "Original title",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 1 },
})
for (const children of [false, true]) {
for (const cached of [false, true]) {
test(`stale ${children ? "family" : "session"} read cannot undo a move (${cached ? "cached" : "uncached"})`, async () => {
const original = session()
const setup = fixture([{ ...original, title: "Refreshed title", cost: 42, time: { created: 1, updated: 99 } }])
try {
if (cached) setup.data.session.remember(original)
setup.data.session.invalidate(original.id)
const read = setup.data.session.sync(original.id, { children })
await setup.requested.promise
setup.move(original.id, 2, {
projectID: "project-moved",
location: { directory: "/worktree" },
})
if (cached) expect(setup.data.session.get(original.id)?.location).toEqual({ directory: "/worktree" })
setup.release.resolve()
await read
expect(setup.data.session.get(original.id)).toMatchObject({
projectID: "project-moved",
location: { directory: "/worktree" },
title: "Refreshed title",
cost: 42,
time: { created: 1, updated: 99 },
})
expect(setup.data.session.get(original.id)?.location.workspaceID).toBeUndefined()
expect(setup.data.session.get(original.id)?.subpath).toBeUndefined()
} finally {
setup.release.resolve()
setup.dispose()
}
})
}
}
test.each([false, true])(
"later repository resolution supersedes an earlier move overlay (cached: %s)",
async (cached) => {
const resolved = {
...session(),
location: { directory: "/repo/app" },
projectID: "git-project",
subpath: "app",
}
// This GET is processed after resolution, not serialized before the move.
const setup = fixture([session()], async (_response, index) =>
Response.json(index === 0 ? { data: resolved } : { data: [], cursor: {} }),
)
try {
if (cached) setup.data.session.remember({ ...session(), location: { directory: "/original" } })
const read = setup.data.session.sync("ses_move", { children: true })
await setup.requested.promise
setup.move("ses_move", 2, { projectID: "directory-project", location: { directory: "/repo/app" } })
setup.emit({
id: "evt_resolved",
type: "worktree.resolved",
created: 3,
durable: { aggregateID: "git-project", seq: 1, version: 1 },
data: { projectID: "git-project", previous: "directory-project", directory: "/repo" },
})
if (cached) {
expect(setup.data.session.get("ses_move")?.projectID).toBe("git-project")
expect(setup.data.session.get("ses_move")?.subpath).toBe("app")
}
setup.release.resolve()
await read
expect(setup.data.session.get("ses_move")?.projectID).toBe("git-project")
expect(setup.data.session.get("ses_move")?.subpath).toBe("app")
} finally {
setup.release.resolve()
setup.dispose()
}
},
)
for (const cached of [false, true]) {
for (const order of ["adoption", "move-adoption", "adoption-move", "move-adoption-move"]) {
test(`family placement facts compose in order (${order}, cached: ${cached})`, async () => {
const sessions = [
{ ...session(), projectID: "directory-project", location: { directory: "/repo/app" } },
{ ...session("ses_child", "ses_move"), projectID: "directory-project", location: { directory: "/repo/ui" } },
session("ses_sibling", "ses_move"),
]
const setup = fixture(sessions.map((info) => ({ ...info, title: "Fresh title", cost: 42 })))
try {
if (cached) sessions.forEach(setup.data.session.remember)
const read = setup.data.session.sync("ses_move", { children: true })
await setup.requested.promise
if (order.startsWith("move")) {
setup.move("ses_move", 2, { projectID: "directory-project", location: { directory: "/repo/app" } })
setup.move("ses_child", 2, { projectID: "directory-project", location: { directory: "/repo/ui" } })
}
setup.emit({
id: "evt_resolved",
type: "worktree.resolved",
created: 3,
durable: { aggregateID: "git-project", seq: 1, version: 1 },
data: { projectID: "git-project", previous: "directory-project", directory: "/repo" },
})
if (order.endsWith("move")) {
setup.move("ses_move", 4, { projectID: "last-project", location: { directory: "/last" } })
setup.move("ses_child", 4, { projectID: "last-project", location: { directory: "/last/ui" }, subpath: "ui" })
}
setup.release.resolve()
await read
expect(setup.data.session.get("ses_move")).toMatchObject({
projectID: order.endsWith("move") ? "last-project" : "git-project",
location: { directory: order.endsWith("move") ? "/last" : "/repo/app" },
title: "Fresh title",
cost: 42,
})
expect(setup.data.session.get("ses_move")?.subpath).toBe(order.endsWith("move") ? undefined : "app")
expect(setup.data.session.get("ses_child")).toMatchObject({
projectID: order.endsWith("move") ? "last-project" : "git-project",
location: { directory: order.endsWith("move") ? "/last/ui" : "/repo/ui" },
subpath: "ui",
title: "Fresh title",
cost: 42,
})
expect(setup.data.session.get("ses_sibling")?.projectID).toBe("project-original")
expect(setup.data.session.family("ses_move").toSorted()).toEqual(["ses_child", "ses_move", "ses_sibling"])
expect(setup.requests).toHaveLength(2)
} finally {
setup.release.resolve()
setup.dispose()
}
})
}
}
test.each([false, true])(
"pending adoption respects canonical project paths and workspaces (cached: %s)",
async (cached) => {
const sessions = [session(), session("ses_local", "ses_move"), session("ses_remote", "ses_move")]
const setup = fixture(sessions)
try {
if (cached) sessions.forEach(setup.data.session.remember)
setup.emit({
id: "evt_project",
type: "project.updated",
created: 1,
data: { id: "directory-project", canonical: "/repo/ui", time: { created: 1, updated: 1 }, sandboxes: [] },
})
const read = setup.data.session.sync("ses_move", { children: true })
await setup.requested.promise
setup.move("ses_local", 2, { projectID: "directory-project", location: { directory: "/shortcut" } })
setup.move("ses_remote", 2, {
projectID: "directory-project",
location: { directory: "/shortcut", workspaceID: "remote-workspace" },
subpath: "remote-path",
})
setup.emit({
id: "evt_resolved",
type: "worktree.resolved",
created: 3,
durable: { aggregateID: "git-project", seq: 1, version: 1 },
data: {
projectID: "git-project",
previous: "unrelated-project",
directory: "/repo",
adopted: ["directory-project"],
},
})
setup.release.resolve()
await read
expect(setup.data.session.get("ses_local")).toMatchObject({
projectID: "git-project",
location: { directory: "/shortcut" },
subpath: "ui",
})
expect(setup.data.session.get("ses_remote")).toMatchObject({
projectID: "directory-project",
location: { directory: "/shortcut", workspaceID: "remote-workspace" },
subpath: "remote-path",
})
expect(setup.data.session.get("ses_move")?.projectID).toBe("project-original")
} finally {
setup.release.resolve()
setup.dispose()
}
},
)
test.each([false, true])(
"revalidates missing canonical paths only for local adoption (workspace: %s)",
async (workspace) => {
const setup = fixture([session()], async () =>
Response.json({
data: { ...session(), projectID: "git-project", location: { directory: "/shortcut" }, subpath: "ui" },
}),
)
try {
const read = setup.data.session.sync("ses_move")
await setup.requested.promise
setup.move("ses_move", 2, {
projectID: "directory-project",
location: { directory: "/shortcut", workspaceID: workspace ? "remote-workspace" : undefined },
})
setup.emit({
id: "evt_resolved",
type: "worktree.resolved",
created: 3,
durable: { aggregateID: "git-project", seq: 1, version: 1 },
data: {
projectID: "git-project",
previous: "unrelated-project",
directory: "/repo",
adopted: ["directory-project"],
},
})
setup.release.resolve()
await read
expect(setup.data.session.get("ses_move")?.projectID).toBe(workspace ? "directory-project" : "git-project")
expect(setup.data.session.get("ses_move")?.subpath).toBe(workspace ? undefined : "ui")
expect(setup.requests).toHaveLength(workspace ? 1 : 2)
} finally {
setup.release.resolve()
setup.dispose()
}
},
)
test.each([false, true])("a later complete move avoids re-reading unknown adoption (family: %s)", async (children) => {
const setup = fixture(
[{ ...session(), title: "Fresh title", cost: 40 }, session("ses_child", "ses_move")],
async (response, index) =>
index < (children ? 2 : 1) ? response : Response.json({ message: "network unavailable" }, { status: 503 }),
)
try {
const read = setup.data.session.sync("ses_move", { children })
await setup.requested.promise
setup.move("ses_move", 2, { projectID: "directory-project", location: { directory: "/shortcut" } })
setup.emit({
id: "evt_resolved",
type: "worktree.resolved",
created: 3,
durable: { aggregateID: "git-project", seq: 1, version: 1 },
data: { previous: "other-project", projectID: "git-project", directory: "/repo", adopted: ["directory-project"] },
})
setup.move("ses_move", 4, { projectID: "final-project", location: { directory: "/final/src" }, subpath: "src" })
setup.release.resolve()
await read
expect(setup.data.session.get("ses_move")).toMatchObject({
location: { directory: "/final/src" },
projectID: "final-project",
subpath: "src",
title: "Fresh title",
cost: 40,
})
expect(setup.data.session.get("ses_move")?.location.workspaceID).toBeUndefined()
expect(setup.requests).toHaveLength(children ? 2 : 1)
} finally {
setup.release.resolve()
setup.dispose()
}
})
test.each(["other-session", "known-adoption", "unknown-adoption"])(
"unknown placement is superseded only by a complete same-session move (%s)",
async (mode) => {
const parent = {
...session(),
projectID: "resolved-project",
location: { directory: mode === "other-session" ? "/shortcut" : "/final/src" },
subpath: "src",
}
const child = {
...session("ses_child", "ses_move"),
projectID: "final-project",
location: { directory: "/final/src" },
subpath: "src",
}
const setup = fixture([session(), session("ses_child", "ses_move")], async (response, index) => {
if (index < 2) return response
if (mode === "known-adoption") return Response.json({ message: "unnecessary request" }, { status: 503 })
return Response.json(index % 2 === 0 ? { data: parent } : { data: [child], cursor: {} })
})
try {
const read = setup.data.session.sync("ses_move", { children: true })
await setup.requested.promise
setup.move("ses_move", 2, { projectID: "directory-project", location: { directory: "/shortcut" } })
setup.emit({
id: "evt_resolved",
type: "worktree.resolved",
created: 3,
durable: { aggregateID: "resolved-project", seq: 1, version: 1 },
data: {
previous: "other-project",
projectID: "resolved-project",
directory: "/repo",
adopted: ["directory-project"],
},
})
setup.move(mode === "other-session" ? "ses_child" : "ses_move", 4, {
projectID: "final-project",
location: { directory: "/final/src" },
subpath: "src",
})
if (mode !== "other-session")
setup.emit({
id: "evt_final_resolved",
type: "worktree.resolved",
created: 5,
durable: { aggregateID: "resolved-project", seq: 2, version: 1 },
data: {
previous: "final-project",
projectID: "resolved-project",
directory: "/final",
adopted: mode === "unknown-adoption" ? ["final-project"] : undefined,
},
})
setup.release.resolve()
await read
expect(setup.data.session.get("ses_move")?.projectID).toBe("resolved-project")
expect(setup.data.session.get("ses_move")?.subpath).toBe("src")
expect(setup.data.session.get("ses_move")?.location).toEqual(parent.location)
if (mode === "other-session") expect(setup.data.session.get("ses_child")?.projectID).toBe("final-project")
expect(setup.requests).toHaveLength(mode === "known-adoption" ? 2 : 4)
} finally {
setup.release.resolve()
setup.dispose()
}
},
)
test.each([false, true])(
"a missing-canonical family refresh observes later moves and releases failed reads (failed: %s)",
async (failed) => {
const requested = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
const parent = { ...session(), projectID: "git-project", location: { directory: "/shortcut" }, subpath: "ui" }
const child = {
...session("ses_child", "ses_move"),
projectID: "git-project",
location: { directory: "/child" },
subpath: "child",
}
const setup = fixture([session(), session("ses_child", "ses_move")], async (response, index) => {
if (index < 2) return response
const refreshed = Response.json(index % 2 === 0 ? { data: parent } : { data: [child], cursor: {} })
if (index === 2) {
requested.resolve()
await release.promise
if (failed) return Response.json({ message: "offline" }, { status: 503 })
}
return refreshed
})
try {
const read = setup.data.session.sync("ses_move", { children: true })
await setup.requested.promise
setup.move("ses_move", 2, { projectID: "directory-project", location: { directory: "/shortcut" } })
setup.emit({
id: "evt_resolved",
type: "worktree.resolved",
created: 3,
durable: { aggregateID: "git-project", seq: 1, version: 1 },
data: {
projectID: "git-project",
previous: "unrelated-project",
directory: "/repo",
adopted: ["directory-project"],
},
})
setup.release.resolve()
await requested.promise
expect(setup.data.session.get("ses_move")).toBeUndefined()
setup.move("ses_child", 4, { projectID: "last-project", location: { directory: "/last" } })
release.resolve()
await (failed ? expect(read).rejects.toThrow() : read)
if (failed) await setup.data.session.sync("ses_move", { children: true })
expect(setup.data.session.get("ses_move")?.projectID).toBe("git-project")
expect(setup.data.session.get("ses_move")?.subpath).toBe("ui")
expect(setup.data.session.get("ses_child")?.location.directory).toBe(failed ? "/child" : "/last")
expect(setup.data.session.get("ses_child")?.projectID).toBe(failed ? "git-project" : "last-project")
expect(setup.requests).toHaveLength(failed ? 6 : 4)
} finally {
release.resolve()
setup.release.resolve()
setup.dispose()
}
},
)
test.each([false, true])("preserves each family member's latest move (cached: %s)", async (cached) => {
const sessions = [session(), session("ses_child", "ses_move"), session("ses_sibling", "ses_move")]
const setup = fixture(sessions)
try {
if (cached) sessions.forEach(setup.data.session.remember)
const read = setup.data.session.sync("ses_move", { children: true })
await setup.requested.promise
setup.move("ses_move", 2, { projectID: "project-parent", location: { directory: "/parent" }, subpath: "app" })
setup.move("ses_child", 2, {
projectID: "project-child",
location: { directory: "/child", workspaceID: "workspace-child" },
subpath: "src",
})
setup.move("ses_child", 3, { projectID: "project-final", location: { directory: "/final" } })
setup.release.resolve()
await read
expect(setup.data.session.get("ses_move")).toMatchObject({
projectID: "project-parent",
location: { directory: "/parent" },
subpath: "app",
})
expect(setup.data.session.get("ses_child")).toMatchObject({ projectID: "project-final" })
expect(setup.data.session.get("ses_child")?.location).toEqual({ directory: "/final" })
expect(setup.data.session.get("ses_child")?.subpath).toBeUndefined()
expect(setup.data.session.get("ses_sibling")).toEqual(sessions[2])
expect(setup.data.session.family("ses_move").toSorted()).toEqual(["ses_child", "ses_move", "ses_sibling"])
// Reconciliation updates metadata, not the transcript. Only observed moves on loaded sessions add rows.
expect(setup.data.session.message.list("ses_move")).toHaveLength(cached ? 1 : 0)
expect(setup.data.session.message.list("ses_child")).toHaveLength(cached ? 2 : 0)
} finally {
setup.release.resolve()
setup.dispose()
}
})
test.each([false, true])(
"overlapping session and family reads preserve moves (family finishes first: %s)",
async (familyFirst) => {
const gates = [Promise.withResolvers<void>(), Promise.withResolvers<void>(), Promise.withResolvers<void>()]
const started = gates.map(() => Promise.withResolvers<void>())
const setup = fixture([session()], async (response, index) => {
started[index]?.resolve()
await gates[index]?.promise
return response
})
try {
setup.data.session.remember(session())
setup.data.session.invalidate("ses_move")
const single = setup.data.session.sync("ses_move")
const family = setup.data.session.sync("ses_move", { children: true })
setup.release.resolve()
await Promise.all(started.map((gate) => gate.promise))
setup.move("ses_move", 2, { projectID: "project-first", location: { directory: "/first" } })
gates[familyFirst ? 1 : 0]?.resolve()
gates[2]?.resolve()
await (familyFirst ? family : single)
expect(setup.data.session.get("ses_move")?.location.directory).toBe("/first")
setup.move("ses_move", 3, {
projectID: "project-final",
location: { directory: "/final", workspaceID: "workspace-final" },
subpath: "final",
})
gates[familyFirst ? 0 : 1]?.resolve()
await Promise.all([single, family])
expect(setup.data.session.get("ses_move")).toMatchObject({
projectID: "project-final",
location: { directory: "/final", workspaceID: "workspace-final" },
subpath: "final",
})
expect(setup.data.session.message.list("ses_move")).toHaveLength(2)
setup.move("ses_move", 4, { projectID: "project-next", location: { directory: "/next" } })
expect(
setup.data.session.message
.list("ses_move")
.flatMap((item) => (item.type === "location-switched" ? [item.location.directory] : [])),
).toEqual(["/first", "/final", "/next"])
} finally {
gates.forEach((gate) => gate.resolve())
setup.release.resolve()
setup.dispose()
}
},
)
test.each([false, true])("later metadata reads stay authoritative after a move (failed: %s)", async (failed) => {
const sessions = [session()]
const setup = fixture(sessions, async (response, index) =>
failed && index === 0 ? Response.json({ message: "offline" }, { status: 503 }) : response,
)
try {
const read = setup.data.session.sync("ses_move")
await setup.requested.promise
setup.move("ses_move", 2, { projectID: "project-live", location: { directory: "/live" } })
setup.release.resolve()
await (failed ? expect(read).rejects.toThrow() : read)
sessions[0] = { ...session(), projectID: "project-later", location: { directory: "/later" } }
if (!failed) {
await setup.data.session.sync("ses_move")
expect(setup.requests).toHaveLength(1)
setup.data.session.invalidate("ses_move")
}
await setup.data.session.sync("ses_move")
expect(setup.data.session.get("ses_move")?.location).toEqual({ directory: "/later" })
expect(setup.data.session.get("ses_move")?.projectID).toBe("project-later")
expect(setup.requests).toHaveLength(2)
} finally {
setup.release.resolve()
setup.dispose()
}
})
// The package's default Bun condition uses Solid's server build. Exercise effects with --conditions=browser.
test.skipIf(isServer)("reconnect revalidates without publishing a stale move location", async () => {
const sessions = [session()]
const setup = fixture(sessions)
try {
setup.data.session.remember(session())
const read = setup.data.session.sync("ses_move", { children: true })
await setup.requested.promise
setup.move("ses_move", 2, { projectID: "project-live", location: { directory: "/live" } })
setup.locations.length = 0
setup.setStatus("reconnecting")
setup.setStatus("connected")
sessions[0] = { ...session(), projectID: "project-later", location: { directory: "/later" } }
const refreshed = setup.data.session.sync("ses_move", { children: true })
expect(setup.requests).toHaveLength(2)
setup.release.resolve()
await Promise.all([read, refreshed])
expect(setup.data.session.get("ses_move")?.location).toEqual({ directory: "/later" })
expect(setup.locations).not.toContain("/repo")
expect(setup.locations).toContain("/later")
expect(setup.requests).toHaveLength(4)
await setup.data.session.sync("ses_move", { children: true })
expect(setup.requests).toHaveLength(4)
} finally {
setup.release.resolve()
setup.dispose()
}
})
function fixture(sessions: SessionInfo[], respond?: (response: Response, index: number) => Promise<Response>) {
const requested = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const requests: URL[] = []
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)
const index = requests.length
requests.push(url)
const response =
url.pathname === "/api/session"
? Response.json({
data: sessions.filter((item) => item.parentID === url.searchParams.get("parentID")),
cursor: {},
})
: Response.json({ data: sessions.find((item) => url.pathname === `/api/session/${item.id}`) })
// Serialize before yielding: Solid may mutate the remembered fixture after the move.
requested.resolve()
await release.promise
return respond ? respond(response, index) : response
},
})
return createRoot((dispose) => {
const [status, setStatus] = createSignal<"connected" | "reconnecting">("connected")
const emit = (details: OpenCodeEvent) => listeners.forEach((listener) => listener({ name: details.type, details }))
const data = createData({
api: () => api,
directory: "/repo",
event: {
on: () => () => {},
listen(handler) {
listeners.add(handler)
return () => listeners.delete(handler)
},
},
connection: { status },
})
const locations: (string | undefined)[] = []
createComputed(() => locations.push(data.session.get("ses_move")?.location.directory))
return {
data,
emit,
locations,
move(
sessionID: string,
seq: number,
data: Omit<Extract<OpenCodeEvent, { type: "session.moved" }>["data"], "sessionID">,
) {
emit({
id: `evt_move_${sessionID}_${seq}`,
type: "session.moved",
created: seq,
durable: { aggregateID: sessionID, seq, version: 1 },
data: { sessionID, ...data },
})
},
setStatus,
requested,
release,
requests,
dispose,
}
})
}