From ed047bb18972815c84423abbeb6de9c1d309ad5f Mon Sep 17 00:00:00 2001 From: Stefan Kucurski Date: Mon, 19 Oct 2026 15:52:42 +0200 Subject: [PATCH] fix(client): reconcile session.sync results into the store --- packages/client/package.json | 4 +- packages/client/src/solid/data.ts | 8 +- packages/client/test/solid-data.test.ts | 100 +++++++++++++++++++++++- 3 files changed, 103 insertions(+), 9 deletions(-) diff --git a/packages/client/package.json b/packages/client/package.json index 0b4edd8e3bba..3551ffd00997 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -29,7 +29,9 @@ "build": "bun run script/build-package.ts", "generate": "bun run script/build.ts", "check:generated": "bun run generate && git diff --exit-code -- src/promise/generated src/effect/generated src/effect/api", - "test": "bun test --timeout 5000", + "test": "bun run test:unit && bun run test:reactivity", + "test:unit": "bun test --timeout 5000", + "test:reactivity": "bun test --conditions=browser --timeout 5000 ./test/solid-data.test.ts --test-name-pattern \"session sync reactivity\"", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/packages/client/src/solid/data.ts b/packages/client/src/solid/data.ts index 8d835fd12508..edbec395add6 100644 --- a/packages/client/src/solid/data.ts +++ b/packages/client/src/solid/data.ts @@ -1669,13 +1669,7 @@ export function createData(config: CreateDataInput) { ]) const sessions = [info, ...children] batch(() => { - setStore( - "session", - "info", - produce((draft) => { - for (const session of sessions) draft[session.id] = session - }), - ) + for (const session of sessions) setStore("session", "info", session.id, reconcile(session)) for (const session of sessions) { sync.complete(`session:${session.id}`) registerSession(session.id) diff --git a/packages/client/test/solid-data.test.ts b/packages/client/test/solid-data.test.ts index d2483e5536da..6ee2a8479dd4 100644 --- a/packages/client/test/solid-data.test.ts +++ b/packages/client/test/solid-data.test.ts @@ -1,6 +1,7 @@ import { expect, test } from "bun:test" import { getEventListeners } from "node:events" -import { createRoot } from "solid-js" +import { createEffect, createRoot } from "solid-js" +import { isServer } from "solid-js/web" import { createData, type CreateDataInput } from "../src/solid" import { OpenCode, type ModelInfo, type OpenCodeEvent, type Project, type SessionInfo } from "../src/promise" @@ -1245,3 +1246,100 @@ async function wait(check: () => boolean) { await Bun.sleep(10) } } + +function syncFixture(remote: Map) { + 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) + if (url.pathname === "/api/session") + return Response.json({ + data: [...remote.values()].filter((item) => item.parentID === url.searchParams.get("parentID")), + cursor: {}, + }) + return Response.json({ data: remote.get(decodeURIComponent(url.pathname.split("/").pop()!)) }) + }, + }) + return createRoot((dispose) => ({ + data: createData({ + api: () => api, + directory: "/project", + event: { on: () => () => {}, listen: () => () => {} }, + }), + dispose, + })) +} + +// The server build of solid-js has no effects, so this runs in the `test:reactivity` stage (browser condition). +test.skipIf(isServer)("session sync reactivity: an unchanged sync keeps the list and consumers quiet", async () => { + const remote = new Map() + const setup = syncFixture(remote) + const toSorted = Array.prototype.toSorted + let sorted = 0 + let runs = 0 + let disposeObserver = () => {} + try { + for (let index = 0; index < 50; index++) { + const info = { ...session(0), id: `ses_${index}`, time: { created: index, updated: index, idle: 2 } } + remote.set(info.id, info) + setup.data.session.remember(info) + } + createRoot((dispose) => { + disposeObserver = dispose + createEffect(() => { + setup.data.session.list() + runs++ + }) + }) + await Promise.resolve() + const before = setup.data.session.get("ses_10") + Array.prototype.toSorted = function (this: unknown[], compare?: (a: never, b: never) => number) { + sorted++ + return toSorted.call(this, compare) + } as typeof Array.prototype.toSorted + + for (let repeat = 0; repeat < 3; repeat++) { + setup.data.session.invalidate("ses_10") + await setup.data.session.sync("ses_10") + await Promise.resolve() + } + expect({ sorted, runs }).toEqual({ sorted: 0, runs: 1 }) + expect(setup.data.session.get("ses_10")).toBe(before) + + remote.set("ses_10", { ...remote.get("ses_10")!, time: { created: 10, updated: 100, idle: 2 } }) + setup.data.session.invalidate("ses_10") + await setup.data.session.sync("ses_10") + await Promise.resolve() + expect(runs).toBe(2) + expect(setup.data.session.list()[0]?.id).toBe("ses_10") + } finally { + Array.prototype.toSorted = toSorted + disposeObserver() + setup.dispose() + } +}) + +test("session sync semantics: replaces the stored session, drops omitted fields, never deletes children", async () => { + const remote = new Map([ + ["ses_root", { ...session(0), id: "ses_root", parentID: "ses_gone" }], + ["ses_child", { ...session(0), id: "ses_child", parentID: "ses_root" }], + ]) + const setup = syncFixture(remote) + try { + await setup.data.session.sync("ses_root", { children: true }) + expect(setup.data.session.get("ses_root")?.parentID).toBe("ses_gone") + + remote.set("ses_root", { ...session(0), id: "ses_root", cost: 3, location: { directory: "/other" } }) + remote.delete("ses_child") + setup.data.session.invalidate("ses_root") + await setup.data.session.sync("ses_root") + + const info = setup.data.session.get("ses_root")! + expect("parentID" in info).toBe(false) + expect(info.cost).toBe(3) + expect(info.location.directory).toBe("/other") + expect(setup.data.session.get("ses_child")).toBeDefined() + } finally { + setup.dispose() + } +})