From 6a7703f183f563b4ce90c33187d0a4fd3cfefc46 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sat, 5 Sep 2026 11:28:53 -0400 Subject: [PATCH 01/11] feat(app-overlay): honest session-list states + the panel-reset scope classifier (D2 core) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #817 — port of the held fork reference (harmoniqs/opencode#296) onto the app overlay at canonical v1.18.29. The pure core of D2 (spec spec-20260905-045114): sessionListState distinguishes 'not yet fetched' (incident #293's invisible failure) from 'genuinely empty', and the reset scope classifier + touch-list pin that 'Reset panel state' clears session caches ONLY — workspace preferences and drafts survive recovery. Wiring into the sync stores, dropdown, home, and the command lands next. --- .../app/src/utils/session-list-state.ts | 45 +++++++++++++ .../test/session_list_state_817.test.ts | 63 +++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 packages/app-bundle/overlay/packages/app/src/utils/session-list-state.ts create mode 100644 packages/extension/test/session_list_state_817.test.ts diff --git a/packages/app-bundle/overlay/packages/app/src/utils/session-list-state.ts b/packages/app-bundle/overlay/packages/app/src/utils/session-list-state.ts new file mode 100644 index 00000000..3378981e --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/utils/session-list-state.ts @@ -0,0 +1,45 @@ +// D2 (spec spec-20260905-045114-session-device-lifecycle): honest client +// states and the in-product panel-state reset scope. Ported from the held +// fork reference (harmoniqs/opencode#296) onto the app overlay at canonical +// v1.18.29 (issue #817). Pure and vscode/solid-free so it unit-tests headless +// (the consumers wire it to the sync stores). + +export type SessionListState = "unfetched" | "empty" | "ready" + +/** Distinguish "not yet fetched" (a persisted snapshot may exist but no list + * request has completed — incident #293's invisible failure) from "genuinely + * empty" (a fetch completed and the projection is empty). */ +export function sessionListState(input: { fetched: boolean; count: number; searching?: boolean }): SessionListState { + if (input.count > 0) return "ready" + if (!input.fetched && !input.searching) return "unfetched" + return "empty" +} + +export type ResetClass = "session-cache" | "workspace-pref" + +/** Classify a persisted-store key for the "Reset panel state" command: the + * reset clears session caches ONLY — workspace preferences (settings, + * archive cutoff, posture config) and user drafts (prompt content) are + * never destroyed by a recovery action. */ +export function classifyResetTarget(key: string, target?: { draft?: boolean }): ResetClass { + if (target?.draft) return "workspace-pref" + if (key.startsWith("session:")) return "session-cache" + return "workspace-pref" +} + +/** The canonical list of session caches the "Reset panel state" command + * clears: child-store session fields plus the session-list query keys. + * Persisted workspace preferences are not on this list — recovery never + * destroys configuration. */ +export function panelResetTouches(): readonly string[] { + return [ + "session", + "session_status", + "sessionTotal", + "session_diff", + "diff_version", + "session:snapshot", + "loadSessions", + "activeSessions", + ] +} diff --git a/packages/extension/test/session_list_state_817.test.ts b/packages/extension/test/session_list_state_817.test.ts new file mode 100644 index 00000000..1bd66467 --- /dev/null +++ b/packages/extension/test/session_list_state_817.test.ts @@ -0,0 +1,63 @@ +// Issue #817 — D2 (spec spec-20260905-045114-session-device-lifecycle): honest +// client states and the in-product panel-state reset scope, ported from the +// held fork reference (harmoniqs/opencode#296) onto the app overlay at +// canonical v1.18.29. Pure and vscode/solid-free so it unit-tests headless +// (the consumers wire it to the sync stores). +import { describe, expect, test } from "vitest" +import { classifyResetTarget, panelResetTouches, sessionListState } from "../../app-bundle/overlay/packages/app/src/utils/session-list-state" + +describe("sessionListState (D2: honest states)", () => { + test("renders 'not yet fetched' while the list fetch is in flight", () => { + expect(sessionListState({ fetched: false, count: 0 })).toBe("unfetched") + }) + + test("renders 'genuinely empty' only once a fetch has completed", () => { + expect(sessionListState({ fetched: true, count: 0 })).toBe("empty") + }) + + test("a populated list is ready regardless of fetch bookkeeping", () => { + expect(sessionListState({ fetched: false, count: 3 })).toBe("ready") + expect(sessionListState({ fetched: true, count: 3 })).toBe("ready") + }) + + test("a search never shows the unfetched state (results are filtered, not loading)", () => { + expect(sessionListState({ fetched: false, count: 0, searching: true })).toBe("empty") + }) + + test("home contract: the index query's success is the fetched source", () => { + // Boot: the home session-index query is pending and has never succeeded. + expect(sessionListState({ fetched: false, count: 0 })).toBe("unfetched") + // After the first successful fetch (even a refetch with data present), + // an empty projection is genuinely empty. + expect(sessionListState({ fetched: true, count: 0 })).toBe("empty") + }) +}) + +describe("panel reset scope (D2: clears session caches only)", () => { + test("session-scoped cache keys are cleared", () => { + expect(classifyResetTarget("session:ses_1:layout")).toBe("session-cache") + expect(classifyResetTarget("session:ses_1:comments")).toBe("session-cache") + }) + + test("workspace preferences survive the reset", () => { + expect(classifyResetTarget("workspace:settings")).toBe("workspace-pref") + expect(classifyResetTarget("workspace:vcs")).toBe("workspace-pref") + expect(classifyResetTarget("workspace:project")).toBe("workspace-pref") + expect(classifyResetTarget("settings.v3")).toBe("workspace-pref") + }) + + test("user drafts are never destroyed by a reset", () => { + expect(classifyResetTarget("session:ses_1:prompt", { draft: true })).toBe("workspace-pref") + }) + + test("the reset's touch-list holds only session caches, never workspace preferences", () => { + const touches = panelResetTouches() + expect(touches).toContain("session") + for (const pref of ["workspace:settings", "workspace:vcs", "workspace:project", "settings.v3", "workspace:archive-cutoff"]) { + expect(touches).not.toContain(pref) + } + for (const touch of touches) { + expect(classifyResetTarget(`session:x:${touch}`)).toBe("session-cache") + } + }) +}) From 35d177aa5b9a668fe2c09e639e224b3180d9b82b Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sat, 5 Sep 2026 11:29:10 -0400 Subject: [PATCH 02/11] feat(app-overlay): the list-currency token derived CLIENT-side over the fetched projection (D2/H4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #817 — the revised placement: the fork's token was a server field (core/src/session/currency.ts + the v2 response); the overlay port drops that dependency entirely. deriveCurrencyToken recomputes (count, max time_updated, sum time_updated) over the rows the list returns, stamped with the server-reported version. The H4 properties are enforced client-side: same-tick writes, archive churn, delete-then-touch pairs, and out-of-band writes all advance the token by construction — derived, never hand-bumped. --- .../context/global-sync/session-currency.ts | 38 ++++++++++ .../test/session_currency_817.test.ts | 76 +++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 packages/app-bundle/overlay/packages/app/src/context/global-sync/session-currency.ts create mode 100644 packages/extension/test/session_currency_817.test.ts diff --git a/packages/app-bundle/overlay/packages/app/src/context/global-sync/session-currency.ts b/packages/app-bundle/overlay/packages/app/src/context/global-sync/session-currency.ts new file mode 100644 index 00000000..ae385db9 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/context/global-sync/session-currency.ts @@ -0,0 +1,38 @@ +// D2/H4 (spec spec-20260905-045114-session-device-lifecycle), issue #817 — +// the REVISED placement (amendment, 2026-09-05): the list-currency token is +// DERIVED BY THE CLIENT over the projection it fetches — (count, max +// time_updated, sum of time_updated) over the rows the list returns, plus +// the server-reported version as the stamp. No server field needed: any +// write that changes the rendered projection — same-tick touches, archive +// churn, out-of-band SQL — changes the token by construction, because it is +// recomputed from the fetched rows on every list response. +// +// Pure and dependency-free (type imports only) so it unit-tests headless. + +export type CurrencyProjection = { + readonly count: number + readonly maxUpdated: number + readonly sumUpdated: number +} + +type DatedRow = { time?: { updated?: number; created?: number } } + +export function projectionOf(rows: readonly DatedRow[]): CurrencyProjection { + let maxUpdated = 0 + let sumUpdated = 0 + for (const row of rows) { + const updated = row.time?.updated ?? row.time?.created ?? 0 + if (updated > maxUpdated) maxUpdated = updated + sumUpdated += updated + } + return { count: rows.length, maxUpdated, sumUpdated } +} + +/** The token the client derives over a fetched list projection. `serverVersion` + * is the hub's self-reported version (the health endpoint); a hub that does + * not report one stamps "unavailable" — the token still advances on every + * projection change, it just cannot detect hub-build drift. */ +export function deriveCurrencyToken(rows: readonly DatedRow[], serverVersion: string | undefined): string { + const p = projectionOf(rows) + return `v1.${p.count}.${p.maxUpdated}.${p.sumUpdated}.${serverVersion ?? "unavailable"}` +} diff --git a/packages/extension/test/session_currency_817.test.ts b/packages/extension/test/session_currency_817.test.ts new file mode 100644 index 00000000..6f28d0b0 --- /dev/null +++ b/packages/extension/test/session_currency_817.test.ts @@ -0,0 +1,76 @@ +// Issue #817 — D2/H4 (spec spec-20260905-045114-session-device-lifecycle), +// the REVISED placement: the list-currency token is derived by the CLIENT +// over the projection it fetches — (count, max time_updated, sum +// time_updated) over the rows the list returns, plus the server-reported +// version as the stamp. No server field exists to depend on; the property +// tests are the enforcement of "derived, never hand-bumped". +import { describe, expect, test } from "vitest" +import { deriveCurrencyToken, projectionOf } from "../../app-bundle/overlay/packages/app/src/context/global-sync/session-currency" + +const session = (id: string, updated: number) => ({ + id, + directory: "/home", + projectID: "p1", + slug: id, + version: "test", + title: `Session ${id}`, + time: { created: updated, updated }, +}) + +describe("session currency (client-derived over the fetched projection)", () => { + test("the token is a pure function of the projection and the version stamp", () => { + const rows = [session("a", 100), session("b", 200)] + expect(deriveCurrencyToken(rows, "v1.18.29")).toBe(deriveCurrencyToken([...rows], "v1.18.29")) + expect(deriveCurrencyToken(rows, "v1.18.29")).toBe(deriveCurrencyToken(rows.toReversed(), "v1.18.29")) + }) + + test("the projection is (count, max time_updated, sum time_updated)", () => { + expect(projectionOf([session("a", 100), session("b", 200), session("c", 50)])).toEqual({ + count: 3, + maxUpdated: 200, + sumUpdated: 350, + }) + expect(projectionOf([])).toEqual({ count: 0, maxUpdated: 0, sumUpdated: 0 }) + }) + + test("H4: a same-tick write advances the token (two rows stamped identically)", () => { + const before = [session("a", 100)] + // A new session lands with the SAME time_updated as the existing row — + // the count is what moves, so max/sum alone would not be enough. + const after = [session("a", 100), session("b", 100)] + expect(deriveCurrencyToken(after, "v1")).not.toBe(deriveCurrencyToken(before, "v1")) + }) + + test("H4: archive churn advances the token (a row leaves the rendered projection)", () => { + const before = [session("a", 100), session("b", 200)] + // b archived out-of-band: the default list renders only a. + const after = [session("a", 100)] + expect(deriveCurrencyToken(after, "v1")).not.toBe(deriveCurrencyToken(before, "v1")) + }) + + test("H4: a delete-then-touch pair advances the token", () => { + const before = [session("a", 100), session("b", 200)] + // b deleted, a touched forward — count and max both move. + const after = [session("a", 300)] + expect(deriveCurrencyToken(after, "v1")).not.toBe(deriveCurrencyToken(before, "v1")) + }) + + test("H4: an out-of-band write (direct SQL, migration) advances the token", () => { + const before = [session("a", 100)] + // A row's time_updated changed under the client; the projection moves. + const after = [session("a", 150)] + expect(deriveCurrencyToken(after, "v1")).not.toBe(deriveCurrencyToken(before, "v1")) + }) + + test("the server-reported version stamps the token — a hub build change flips it", () => { + const rows = [session("a", 100)] + expect(deriveCurrencyToken(rows, "v1.18.10-amicode.21")).not.toBe(deriveCurrencyToken(rows, "v1.18.29")) + expect(deriveCurrencyToken(rows, "v1.18.29")).toContain("v1.18.29") + }) + + test("a hub whose version is unavailable still yields a usable token (fail-soft)", () => { + const rows = [session("a", 100)] + expect(deriveCurrencyToken(rows, undefined)).toBe(deriveCurrencyToken(rows, undefined)) + expect(deriveCurrencyToken(rows, undefined)).not.toBe(deriveCurrencyToken(rows, "v1.18.29")) + }) +}) From 9ccb5335edc5cb949c21ebcd93391511fe4fd06e Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sat, 5 Sep 2026 11:30:11 -0400 Subject: [PATCH 03/11] =?UTF-8?q?feat(app-overlay):=20honest=20session-lis?= =?UTF-8?q?t=20states=20wired=20=E2=80=94=20sessions=5Ffetched=20flag,=20d?= =?UTF-8?q?ropdown,=20in-product=20panel=20reset=20(D2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #817 — port of the fork's d4e3d64f82 adapted to the overlay's sync shape: child stores carry a sessions_fetched flag set when a list fetch resolves (both the cached-store early-return and the fetch path), the sessions dropdown routes through sessionListState and renders loading — never the empty state — while no fetch has resolved (#293's invisible failure shape). 'Reset panel state' (command.panel.reset, all 18 overlay locales) calls project.resetSessionCaches: in-memory session fields, sessionTotal/status, and the loadSessions/activeSessions query keys only — persisted workspace preferences are never touched, pinned by the wiring test's no-persisted-write assertion. --- .../src/components/session/session-header.tsx | 32 ++++++- .../src/context/global-sync/child-store.ts | 1 + .../context/global-sync/session-snapshot.ts | 47 +++++++++++ .../app/src/context/global-sync/types.ts | 5 ++ .../packages/app/src/context/server-sync.tsx | 52 +++++++++--- .../overlay/packages/app/src/i18n/ar.ts | 1 + .../overlay/packages/app/src/i18n/br.ts | 1 + .../overlay/packages/app/src/i18n/bs.ts | 1 + .../overlay/packages/app/src/i18n/da.ts | 1 + .../overlay/packages/app/src/i18n/de.ts | 1 + .../overlay/packages/app/src/i18n/en.ts | 1 + .../overlay/packages/app/src/i18n/es.ts | 1 + .../overlay/packages/app/src/i18n/fr.ts | 1 + .../overlay/packages/app/src/i18n/ja.ts | 1 + .../overlay/packages/app/src/i18n/ko.ts | 1 + .../overlay/packages/app/src/i18n/no.ts | 1 + .../overlay/packages/app/src/i18n/pl.ts | 1 + .../overlay/packages/app/src/i18n/ru.ts | 1 + .../overlay/packages/app/src/i18n/th.ts | 1 + .../overlay/packages/app/src/i18n/tr.ts | 1 + .../overlay/packages/app/src/i18n/uk.ts | 1 + .../overlay/packages/app/src/i18n/zh.ts | 1 + .../overlay/packages/app/src/i18n/zht.ts | 1 + .../overlay/packages/app/src/pages/layout.tsx | 11 +++ .../test/session_state_wiring_817.test.ts | 83 +++++++++++++++++++ 25 files changed, 238 insertions(+), 11 deletions(-) create mode 100644 packages/app-bundle/overlay/packages/app/src/context/global-sync/session-snapshot.ts create mode 100644 packages/extension/test/session_state_wiring_817.test.ts diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/session-header.tsx b/packages/app-bundle/overlay/packages/app/src/components/session/session-header.tsx index a57c3042..15b0a68e 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/session/session-header.tsx +++ b/packages/app-bundle/overlay/packages/app/src/components/session/session-header.tsx @@ -39,6 +39,7 @@ import { useServerSync } from "@/context/server-sync" import { useGlobal } from "@/context/global" import { base64Encode } from "@opencode-ai/core/util/encode" import { sessionListDirectories, sortedRootSessions } from "@/pages/layout/helpers" +import { sessionListState } from "@/utils/session-list-state" import { useNavigate } from "@solidjs/router" import type { Session } from "@opencode-ai/sdk/v2/client" @@ -775,6 +776,31 @@ export function SessionChatsDropdown(props: { currentSessionID?: string } = {}) return [...openTabs, ...rest] }) + // D2 honest states (issue #817): "not yet fetched" (a completed list + // request is the only authority for "genuinely empty") vs "empty" vs ready + // — never render the empty state while no fetch has resolved (#293's + // invisible failure). + const activeListState = createMemo(() => { + if (!open()) return "ready" as const + try { + const conn = server.current + if (!conn) return "unfetched" as const + const ctx = globalCtx.ensureServerCtx(conn) + if (!ctx) return "unfetched" as const + const directories = sessionListDirectories(ctx.projects.list(), ctx.sync.data?.project ?? []) + let fetched = false + let count = 0 + for (const dir of directories) { + const [store] = ctx.sync.child(dir, { bootstrap: false }) + if (store.sessions_fetched) fetched = true + count += store.session?.length ?? 0 + } + return sessionListState({ fetched, count, searching: !!searchQuery() }) + } catch { + return "unfetched" as const + } + }) + // Search filtering const searchQuery = createMemo(() => search().trim().toLowerCase()) const filteredActiveSessions = createMemo(() => { @@ -1071,7 +1097,11 @@ export function SessionChatsDropdown(props: { currentSessionID?: string } = {}) when={filteredActiveSessions().length > 0} fallback={
- {searchQuery() ? language.t("home.sessions.search.noResults", { query: search() }) : language.t("home.sessions.empty")} + {searchQuery() + ? language.t("home.sessions.search.noResults", { query: search() }) + : activeListState() === "unfetched" + ? language.t("common.loading") + : language.t("home.sessions.empty")}
} > diff --git a/packages/app-bundle/overlay/packages/app/src/context/global-sync/child-store.ts b/packages/app-bundle/overlay/packages/app/src/context/global-sync/child-store.ts index 9ebd27d9..d002dacf 100644 --- a/packages/app-bundle/overlay/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app-bundle/overlay/packages/app/src/context/global-sync/child-store.ts @@ -228,6 +228,7 @@ export function createChildStoreManager(input: { }, session: [], sessionTotal: 0, + sessions_fetched: false, session_status: {}, session_working(id: string) { const type = this.session_status[id]?.type diff --git a/packages/app-bundle/overlay/packages/app/src/context/global-sync/session-snapshot.ts b/packages/app-bundle/overlay/packages/app/src/context/global-sync/session-snapshot.ts new file mode 100644 index 00000000..37c8158a --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/context/global-sync/session-snapshot.ts @@ -0,0 +1,47 @@ +// D2 (spec spec-20260905-045114-session-device-lifecycle), issue #817: the +// persisted session snapshot is a render accelerator, never an authority. +// Ported from the fork reference (harmoniqs/opencode#296) with the REVISED +// currency placement: the token is derived CLIENT-side over the fetched +// projection (session-currency.ts) — no server currency field exists to +// verify against, the boot decision derives both sides. +// +// Pure and dependency-free (type imports only) so it unit-tests headless. +import type { Session } from "@opencode-ai/sdk/v2/client" +import { deriveCurrencyToken } from "./session-currency" + +export type SessionSnapshot = { + sessions: Session[] + currency?: string +} + +export type BootCurrencyDecision = { + /** The fetched response is always adopted — it is the authority. */ + adopt: boolean + /** The persisted snapshot contradicts the server (stale or tokenless) and + * must be invalidated (overwritten by the fetched state). */ + stale: boolean + /** The client-derived token to persist alongside the adopted rows. */ + currency: string +} + +export function bootCurrencyDecision(input: { + snapshot?: SessionSnapshot + response: { sessions: Session[] } + /** The hub's self-reported version (health endpoint); undefined when the + * hub did not report one — the stamp degrades to "unavailable". */ + serverVersion?: string +}): BootCurrencyDecision { + // The response's token is derived from ITS rows every time — out-of-band + // writes, archive churn, and same-tick touches all move it by construction. + const currency = deriveCurrencyToken(input.response.sessions, input.serverVersion) + // A tokenless snapshot was written by a client that could not prove its own + // currency — the founding #293 shape — and reads as stale on first proof. + const stale = + input.snapshot !== undefined && + (input.snapshot.currency === undefined || input.snapshot.currency !== currency) + return { adopt: true, stale, currency } +} + +export function toSnapshot(sessions: Session[], currency: string): SessionSnapshot { + return { sessions, currency } +} diff --git a/packages/app-bundle/overlay/packages/app/src/context/global-sync/types.ts b/packages/app-bundle/overlay/packages/app/src/context/global-sync/types.ts index af9824c5..cd5a71e1 100644 --- a/packages/app-bundle/overlay/packages/app/src/context/global-sync/types.ts +++ b/packages/app-bundle/overlay/packages/app/src/context/global-sync/types.ts @@ -44,6 +44,11 @@ export type State = { path: Path session: Session[] sessionTotal: number + // D2 honest states (issue #817): has a session-list fetch completed for + // this directory? A persisted snapshot is a render accelerator, never an + // authority — until a fetch resolves, the UI renders "not yet fetched", + // never "empty". + sessions_fetched?: boolean session_status: { [sessionID: string]: SessionStatus } diff --git a/packages/app-bundle/overlay/packages/app/src/context/server-sync.tsx b/packages/app-bundle/overlay/packages/app/src/context/server-sync.tsx index b9140d78..d541ce95 100644 --- a/packages/app-bundle/overlay/packages/app/src/context/server-sync.tsx +++ b/packages/app-bundle/overlay/packages/app/src/context/server-sync.tsx @@ -399,17 +399,22 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { const [store, setStore] = children.child(directory, { bootstrap: false }) const meta = sessionMeta.get(key) const retainedLimit = Math.max(store.limit, options?.limit ?? 0, meta?.limit ?? 0) - if (meta && meta.limit >= retainedLimit) { - const next = trimSessions(store.session, { - limit: retainedLimit, - permission: session.data.permission, - }) - if (next.length !== store.session.length) { - setStore("session", reconcile(next, { key: "id" })) + if (meta && meta.limit >= retainedLimit) { + const next = trimSessions(store.session, { + limit: retainedLimit, + permission: session.data.permission, + }) + batch(() => { + // D2 honest states (issue #817): a completed list fetch means the UI + // may render "genuinely empty" — never "not yet fetched". + if (!store.sessions_fetched) setStore("sessions_fetched", true) + if (next.length !== store.session.length) { + setStore("session", reconcile(next, { key: "id" })) + } + }) + children.unpin(key) + return } - children.unpin(key) - return - } const limit = Math.max(retainedLimit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT) const promise = queryClient @@ -439,6 +444,10 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { }) batch(() => { next.forEach(session.remember) + // D2 honest states (issue #817): the fetch resolved (even to + // empty) — the UI may now distinguish "genuinely empty" from + // "not yet fetched". + setStore("sessions_fetched", true) setStore( "sessionTotal", estimateRootSessionTotal({ @@ -659,6 +668,29 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { icon(directory: string, value: string | undefined) { children.projectIcon(directory, value) }, + // D2 in-product reset (issue #817): clears session caches ONLY — + // in-memory session state plus the session-list query keys. Persisted + // workspace preferences (settings, archive cutoff, posture config) live + // in stores this never touches, so recovery never destroys configuration + // and never requires filesystem surgery (#293). + resetSessionCaches() { + sessionMeta.clear() + for (const child of Object.values(children.children)) { + const [store, setStore] = child + batch(() => { + if (store.sessions_fetched) setStore("sessions_fetched", false) + if (store.sessionTotal !== 0) setStore("sessionTotal", 0) + setStore("session", reconcile([], { key: "id" })) + setStore("session_status", reconcile({})) + }) + } + queryClient.removeQueries({ + predicate: (query) => { + const name = (query.queryKey as readonly unknown[])[2] + return name === "loadSessions" || name === "activeSessions" + }, + }) + }, } const updateConfigMutation = useMutation(() => ({ diff --git a/packages/app-bundle/overlay/packages/app/src/i18n/ar.ts b/packages/app-bundle/overlay/packages/app/src/i18n/ar.ts index 377834c9..cf666d20 100644 --- a/packages/app-bundle/overlay/packages/app/src/i18n/ar.ts +++ b/packages/app-bundle/overlay/packages/app/src/i18n/ar.ts @@ -20,6 +20,7 @@ export const dict = { "theme.scheme.light": "فاتح", "theme.scheme.dark": "داكن", "command.sidebar.toggle": "تبديل الشريط الجانبي", + "command.panel.reset": "إعادة تعيين حالة اللوحة", "command.project.open": "فتح مشروع", "command.project.previous": "المشروع السابق", "command.project.next": "المشروع التالي", diff --git a/packages/app-bundle/overlay/packages/app/src/i18n/br.ts b/packages/app-bundle/overlay/packages/app/src/i18n/br.ts index 021d196d..202a61fb 100644 --- a/packages/app-bundle/overlay/packages/app/src/i18n/br.ts +++ b/packages/app-bundle/overlay/packages/app/src/i18n/br.ts @@ -20,6 +20,7 @@ export const dict = { "theme.scheme.light": "Claro", "theme.scheme.dark": "Escuro", "command.sidebar.toggle": "Alternar barra lateral", + "command.panel.reset": "Rezeriñ stad ar panell", "command.project.open": "Abrir projeto", "command.project.previous": "Projeto anterior", "command.project.next": "Próximo projeto", diff --git a/packages/app-bundle/overlay/packages/app/src/i18n/bs.ts b/packages/app-bundle/overlay/packages/app/src/i18n/bs.ts index 1daf0c03..c9cc9660 100644 --- a/packages/app-bundle/overlay/packages/app/src/i18n/bs.ts +++ b/packages/app-bundle/overlay/packages/app/src/i18n/bs.ts @@ -22,6 +22,7 @@ export const dict = { "theme.scheme.dark": "Tamno", "command.sidebar.toggle": "Prikaži/sakrij bočnu traku", + "command.panel.reset": "Resetuj stanje panela", "command.project.open": "Otvori projekat", "command.project.previous": "Prethodni projekat", "command.project.next": "Sljedeći projekat", diff --git a/packages/app-bundle/overlay/packages/app/src/i18n/da.ts b/packages/app-bundle/overlay/packages/app/src/i18n/da.ts index 6c4bfc6a..b1f90577 100644 --- a/packages/app-bundle/overlay/packages/app/src/i18n/da.ts +++ b/packages/app-bundle/overlay/packages/app/src/i18n/da.ts @@ -22,6 +22,7 @@ export const dict = { "theme.scheme.dark": "Mørk", "command.sidebar.toggle": "Skift sidebjælke", + "command.panel.reset": "Nulstil paneltilstand", "command.project.open": "Åbn projekt", "command.project.previous": "Forrige projekt", "command.project.next": "Næste projekt", diff --git a/packages/app-bundle/overlay/packages/app/src/i18n/de.ts b/packages/app-bundle/overlay/packages/app/src/i18n/de.ts index 8d27221e..ae8efb30 100644 --- a/packages/app-bundle/overlay/packages/app/src/i18n/de.ts +++ b/packages/app-bundle/overlay/packages/app/src/i18n/de.ts @@ -24,6 +24,7 @@ export const dict = { "theme.scheme.light": "Hell", "theme.scheme.dark": "Dunkel", "command.sidebar.toggle": "Seitenleiste umschalten", + "command.panel.reset": "Panel-Zustand zurücksetzen", "command.project.open": "Projekt öffnen", "command.project.previous": "Vorheriges Projekt", "command.project.next": "Nächstes Projekt", diff --git a/packages/app-bundle/overlay/packages/app/src/i18n/en.ts b/packages/app-bundle/overlay/packages/app/src/i18n/en.ts index b639004a..21ce8144 100644 --- a/packages/app-bundle/overlay/packages/app/src/i18n/en.ts +++ b/packages/app-bundle/overlay/packages/app/src/i18n/en.ts @@ -22,6 +22,7 @@ export const dict = { "theme.scheme.dark": "Dark", "command.sidebar.toggle": "Toggle sidebar", + "command.panel.reset": "Reset panel state", "command.project.open": "Open project", "command.project.previous": "Previous project", "command.project.next": "Next project", diff --git a/packages/app-bundle/overlay/packages/app/src/i18n/es.ts b/packages/app-bundle/overlay/packages/app/src/i18n/es.ts index 5950aab0..7bac4e7d 100644 --- a/packages/app-bundle/overlay/packages/app/src/i18n/es.ts +++ b/packages/app-bundle/overlay/packages/app/src/i18n/es.ts @@ -22,6 +22,7 @@ export const dict = { "theme.scheme.dark": "Oscuro", "command.sidebar.toggle": "Alternar barra lateral", + "command.panel.reset": "Restablecer estado del panel", "command.project.open": "Abrir proyecto", "command.project.previous": "Proyecto anterior", "command.project.next": "Siguiente proyecto", diff --git a/packages/app-bundle/overlay/packages/app/src/i18n/fr.ts b/packages/app-bundle/overlay/packages/app/src/i18n/fr.ts index 94b632a8..ec715117 100644 --- a/packages/app-bundle/overlay/packages/app/src/i18n/fr.ts +++ b/packages/app-bundle/overlay/packages/app/src/i18n/fr.ts @@ -20,6 +20,7 @@ export const dict = { "theme.scheme.light": "Clair", "theme.scheme.dark": "Sombre", "command.sidebar.toggle": "Basculer la barre latérale", + "command.panel.reset": "Réinitialiser l'état du panneau", "command.project.open": "Ouvrir un projet", "command.project.previous": "Projet précédent", "command.project.next": "Projet suivant", diff --git a/packages/app-bundle/overlay/packages/app/src/i18n/ja.ts b/packages/app-bundle/overlay/packages/app/src/i18n/ja.ts index 1cd65a97..da0ea2b2 100644 --- a/packages/app-bundle/overlay/packages/app/src/i18n/ja.ts +++ b/packages/app-bundle/overlay/packages/app/src/i18n/ja.ts @@ -20,6 +20,7 @@ export const dict = { "theme.scheme.light": "ライト", "theme.scheme.dark": "ダーク", "command.sidebar.toggle": "サイドバーの切り替え", + "command.panel.reset": "パネルの状態をリセット", "command.project.open": "プロジェクトを開く", "command.project.previous": "前のプロジェクト", "command.project.next": "次のプロジェクト", diff --git a/packages/app-bundle/overlay/packages/app/src/i18n/ko.ts b/packages/app-bundle/overlay/packages/app/src/i18n/ko.ts index 759865c7..7bac8489 100644 --- a/packages/app-bundle/overlay/packages/app/src/i18n/ko.ts +++ b/packages/app-bundle/overlay/packages/app/src/i18n/ko.ts @@ -20,6 +20,7 @@ export const dict = { "theme.scheme.light": "라이트", "theme.scheme.dark": "다크", "command.sidebar.toggle": "사이드바 토글", + "command.panel.reset": "패널 상태 초기화", "command.project.open": "프로젝트 열기", "command.provider.connect": "공급자 연결", "command.server.switch": "서버 전환", diff --git a/packages/app-bundle/overlay/packages/app/src/i18n/no.ts b/packages/app-bundle/overlay/packages/app/src/i18n/no.ts index 58ead9d2..0d33d153 100644 --- a/packages/app-bundle/overlay/packages/app/src/i18n/no.ts +++ b/packages/app-bundle/overlay/packages/app/src/i18n/no.ts @@ -25,6 +25,7 @@ export const dict = { "theme.scheme.dark": "Mørk", "command.sidebar.toggle": "Veksle sidepanel", + "command.panel.reset": "Tilbakestill paneltilstand", "command.project.open": "Åpne prosjekt", "command.provider.connect": "Koble til leverandør", "command.server.switch": "Bytt server", diff --git a/packages/app-bundle/overlay/packages/app/src/i18n/pl.ts b/packages/app-bundle/overlay/packages/app/src/i18n/pl.ts index a3446bff..cd9256f7 100644 --- a/packages/app-bundle/overlay/packages/app/src/i18n/pl.ts +++ b/packages/app-bundle/overlay/packages/app/src/i18n/pl.ts @@ -20,6 +20,7 @@ export const dict = { "theme.scheme.light": "Jasny", "theme.scheme.dark": "Ciemny", "command.sidebar.toggle": "Przełącz pasek boczny", + "command.panel.reset": "Zresetuj stan panelu", "command.project.open": "Otwórz projekt", "command.project.previous": "Poprzedni projekt", "command.project.next": "Następny projekt", diff --git a/packages/app-bundle/overlay/packages/app/src/i18n/ru.ts b/packages/app-bundle/overlay/packages/app/src/i18n/ru.ts index ea3bbbe0..e81aa6de 100644 --- a/packages/app-bundle/overlay/packages/app/src/i18n/ru.ts +++ b/packages/app-bundle/overlay/packages/app/src/i18n/ru.ts @@ -22,6 +22,7 @@ export const dict = { "theme.scheme.dark": "Тёмная", "command.sidebar.toggle": "Переключить боковую панель", + "command.panel.reset": "Сбросить состояние панели", "command.project.open": "Открыть проект", "command.project.previous": "Предыдущий проект", "command.project.next": "Следующий проект", diff --git a/packages/app-bundle/overlay/packages/app/src/i18n/th.ts b/packages/app-bundle/overlay/packages/app/src/i18n/th.ts index 50a39a24..7f159bdf 100644 --- a/packages/app-bundle/overlay/packages/app/src/i18n/th.ts +++ b/packages/app-bundle/overlay/packages/app/src/i18n/th.ts @@ -22,6 +22,7 @@ export const dict = { "theme.scheme.dark": "มืด", "command.sidebar.toggle": "สลับแถบข้าง", + "command.panel.reset": "รีเซ็ตสถานะแผง", "command.project.open": "เปิดโปรเจกต์", "command.project.previous": "โปรเจกต์ก่อนหน้า", "command.project.next": "โปรเจกต์ถัดไป", diff --git a/packages/app-bundle/overlay/packages/app/src/i18n/tr.ts b/packages/app-bundle/overlay/packages/app/src/i18n/tr.ts index d25a3daa..527b680b 100644 --- a/packages/app-bundle/overlay/packages/app/src/i18n/tr.ts +++ b/packages/app-bundle/overlay/packages/app/src/i18n/tr.ts @@ -26,6 +26,7 @@ export const dict = { "theme.scheme.dark": "Koyu", "command.sidebar.toggle": "Kenar çubuğunu aç/kapat", + "command.panel.reset": "Panel durumunu sıfırla", "command.project.open": "Proje aç", "command.project.previous": "Önceki proje", "command.project.next": "Sonraki proje", diff --git a/packages/app-bundle/overlay/packages/app/src/i18n/uk.ts b/packages/app-bundle/overlay/packages/app/src/i18n/uk.ts index 8577455d..e9be5f59 100644 --- a/packages/app-bundle/overlay/packages/app/src/i18n/uk.ts +++ b/packages/app-bundle/overlay/packages/app/src/i18n/uk.ts @@ -22,6 +22,7 @@ export const dict = { "theme.scheme.dark": "Темна", "command.sidebar.toggle": "Перемкнути бічну панель", + "command.panel.reset": "Скинути стан панелі", "command.project.open": "Відкрити проєкт", "command.project.previous": "Попередній проєкт", "command.project.next": "Наступний проєкт", diff --git a/packages/app-bundle/overlay/packages/app/src/i18n/zh.ts b/packages/app-bundle/overlay/packages/app/src/i18n/zh.ts index edb37496..edd7a2de 100644 --- a/packages/app-bundle/overlay/packages/app/src/i18n/zh.ts +++ b/packages/app-bundle/overlay/packages/app/src/i18n/zh.ts @@ -26,6 +26,7 @@ export const dict = { "theme.scheme.dark": "深色", "command.sidebar.toggle": "切换侧边栏", + "command.panel.reset": "重置面板状态", "command.project.open": "打开项目", "command.project.previous": "上一个项目", diff --git a/packages/app-bundle/overlay/packages/app/src/i18n/zht.ts b/packages/app-bundle/overlay/packages/app/src/i18n/zht.ts index 522517e4..ce0322b6 100644 --- a/packages/app-bundle/overlay/packages/app/src/i18n/zht.ts +++ b/packages/app-bundle/overlay/packages/app/src/i18n/zht.ts @@ -26,6 +26,7 @@ export const dict = { "theme.scheme.dark": "深色", "command.sidebar.toggle": "切換側邊欄", + "command.panel.reset": "重置面板狀態", "command.project.open": "開啟專案", "command.project.previous": "上一個專案", "command.project.next": "下一個專案", diff --git a/packages/app-bundle/overlay/packages/app/src/pages/layout.tsx b/packages/app-bundle/overlay/packages/app/src/pages/layout.tsx index e10956ec..e4774e74 100644 --- a/packages/app-bundle/overlay/packages/app/src/pages/layout.tsx +++ b/packages/app-bundle/overlay/packages/app/src/pages/layout.tsx @@ -871,6 +871,17 @@ export default function LegacyLayout(props: ParentProps) { command.register("layout", () => { const commands: CommandOption[] = [ + { + // D2 (issue #817): in-product recovery — clears session caches ONLY; + // workspace preferences survive. Never requires filesystem surgery + // (#293). + id: "panel.reset", + title: language.t("command.panel.reset"), + category: language.t("command.category.view"), + onSelect: async () => { + serverSync().project.resetSessionCaches() + }, + }, { id: "sidebar.toggle", title: language.t("command.sidebar.toggle"), diff --git a/packages/extension/test/session_state_wiring_817.test.ts b/packages/extension/test/session_state_wiring_817.test.ts new file mode 100644 index 00000000..34a4b6e8 --- /dev/null +++ b/packages/extension/test/session_state_wiring_817.test.ts @@ -0,0 +1,83 @@ +// Issue #817 — D2 wiring (spec spec-20260905-045114-session-device-lifecycle): +// the honest states and the in-product reset ride the overlay's sync stores. +// The overlay's Solid components can't run headless under vitest, so the +// wiring is pinned at source level (the repo's established idiom — see +// titlebar_dblclick_close.test.ts) and every behavioral core is imported and +// executed in its own test file. +import { describe, expect, test } from "vitest" +import { readFileSync } from "node:fs" +import { join } from "node:path" + +const overlay = (...p: string[]) => join(__dirname, "../../app-bundle/overlay/packages/app/src", ...p) +const src = (p: string) => readFileSync(overlay(p), "utf8") + +describe("sessions_fetched flag (D2: a completed list fetch is the only authority for 'empty')", () => { + const types = () => src("context/global-sync/types.ts") + const childStore = () => src("context/global-sync/child-store.ts") + const serverSync = () => src("context/server-sync.tsx") + + test("the child-store state carries the flag", () => { + expect(types()).toContain("sessions_fetched") + }) + + test("new stores start unfetched", () => { + expect(childStore()).toMatch(/sessions_fetched: false/) + }) + + test("the early-return path (cached store) flips the flag too", () => { + const s = serverSync() + // The cached-store branch reconciles + sets the flag inside one batch. + expect(s).toMatch(/if \(!store\.sessions_fetched\) setStore\("sessions_fetched", true\)/) + }) + + test("the fetch path flips the flag when the fetch resolves (even to empty)", () => { + const s = serverSync() + expect(s).toMatch(/setStore\("sessions_fetched", true\)/) + }) +}) + +describe("in-product reset (D2: recovery never destroys configuration)", () => { + test("the sync exposes resetSessionCaches on the project API", () => { + const s = src("context/server-sync.tsx") + expect(s).toContain("resetSessionCaches()") + // It clears the in-memory session fields… + expect(s).toMatch(/resetSessionCaches\(\)[\s\S]{0,600}setStore\("session", reconcile\(\[\]/) + // …the per-directory fetch bookkeeping… + expect(s).toMatch(/resetSessionCaches\(\)[\s\S]{0,600}sessionMeta\.clear\(\)/) + // …and the session-list query keys. + expect(s).toMatch(/resetSessionCaches\(\)[\s\S]{0,1200}"loadSessions"/) + }) + + test("the command is registered in the layout", () => { + const s = src("pages/layout.tsx") + expect(s).toContain('id: "panel.reset"') + expect(s).toMatch(/id: "panel\.reset"[\s\S]{0,300}resetSessionCaches\(\)/) + }) + + test("every overlay locale carries the command label", () => { + const locales = ["en", "ar", "br", "bs", "da", "de", "es", "fr", "ja", "ko", "no", "pl", "ru", "th", "tr", "uk", "zh", "zht"] + for (const locale of locales) { + const dict = src(`i18n/${locale}.ts`) + expect(dict, `i18n/${locale}.ts`).toContain('"command.panel.reset"') + } + }) + + test("the reset never touches persisted workspace preference stores", () => { + const s = src("context/server-sync.tsx") + const reset = s.slice(s.indexOf("resetSessionCaches()"), s.indexOf("resetSessionCaches()") + 1600) + // No persisted-store writes in the reset's body: no `persist(`, no vcs, + // no project-meta, no icon targets. + expect(reset).not.toMatch(/persist\(/) + expect(reset).not.toMatch(/vcsCache/) + expect(reset).not.toMatch(/metaCache\.get|iconCache\.get/) + }) +}) + +describe("sessions dropdown honest states (D2: never render empty while unfetched)", () => { + test("the dropdown routes through sessionListState and shows loading while unfetched", () => { + const s = src("components/session/session-header.tsx") + expect(s).toContain('from "@/utils/session-list-state"') + expect(s).toMatch(/sessionListState\(\{/) + expect(s).toMatch(/activeListState\(\) === "unfetched"\s*\n?\s*\?\s*language\.t\("common\.loading"\)/) + }) +}) From 882c94e7c0d2a539fb9cb32ec22ce6358d9520c1 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sat, 5 Sep 2026 11:30:57 -0400 Subject: [PATCH 04/11] feat(app-overlay): the persisted session snapshot self-heals on boot via the client-derived token (D2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #817 — port of the fork's 95a065f7ec adapted to the revised placement: child stores persist a per-workspace 'session:snapshot' (render accelerator, never an authority), hydrated only until the first real list fetch resolves (sessions_fetched gate). Every list response verifies the snapshot via bootCurrencyDecision over the CLIENT-derived token — count/max/sum of the fetched rows stamped with the hub's self-reported version (resolved once per server context through the health endpoint, exposed as serverSDK.version()). A stale or tokenless snapshot — the #293 shape — is invalidated by the overwrite, so recovery needs zero filesystem surgery. The panel reset invalidates all snapshots too (session:snapshot is on the pinned touch-list). --- .../src/context/global-sync/child-store.ts | 48 ++++++++++++++++++ .../app/src/context/global-sync/types.ts | 10 ++++ .../packages/app/src/context/server-sdk.tsx | 17 +++++++ .../packages/app/src/context/server-sync.tsx | 14 +++++- .../test/session_snapshot_wiring_817.test.ts | 49 +++++++++++++++++++ 5 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 packages/extension/test/session_snapshot_wiring_817.test.ts diff --git a/packages/app-bundle/overlay/packages/app/src/context/global-sync/child-store.ts b/packages/app-bundle/overlay/packages/app/src/context/global-sync/child-store.ts index d002dacf..61551a64 100644 --- a/packages/app-bundle/overlay/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app-bundle/overlay/packages/app/src/context/global-sync/child-store.ts @@ -10,9 +10,11 @@ import { type IconCache, type MetaCache, type ProjectMeta, + type SessionSnapshotCache, type State, type VcsCache, } from "./types" +import type { SessionSnapshot } from "./session-snapshot" import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction" import { useQuery } from "@tanstack/solid-query" import { QueryOptionsApi } from "../server-sync" @@ -39,6 +41,7 @@ export function createChildStoreManager(input: { const vcsCache = new Map() const metaCache = new Map() const iconCache = new Map() + const snapshotCache = new Map() const lifecycle = new Map() const pins = new Map() const ownerPins = new WeakMap>() @@ -117,6 +120,7 @@ export function createChildStoreManager(input: { vcsCache.delete(key) metaCache.delete(key) iconCache.delete(key) + snapshotCache.delete(key) lifecycle.delete(key) mcpDirectories.delete(key) mcpToggles.delete(key) @@ -181,6 +185,23 @@ export function createChildStoreManager(input: { if (!icon) throw new Error(input.translate("error.childStore.persistedProjectIconCreateFailed")) iconCache.set(key, { store: icon[0], setStore: icon[1], ready: icon[3] }) + // D2 (issue #817): the persisted session snapshot is a render + // accelerator, never an authority — hydrated only until the first real + // list fetch resolves, and verified against the client-derived currency + // token on every list response. + const sessionSnapshot = runWithOwner(input.owner, () => + input.persist( + Persist.serverWorkspace(input.scope, directory, "session:snapshot"), + createStore({ value: undefined as SessionSnapshot | undefined }), + ), + ) + if (!sessionSnapshot) throw new Error(input.translate("error.childStore.persistedCacheCreateFailed")) + snapshotCache.set(key, { + store: sessionSnapshot[0], + setStore: sessionSnapshot[1], + ready: sessionSnapshot[3], + }) + const init = () => createRoot((dispose) => { const initialMeta = meta[0].value @@ -289,6 +310,17 @@ export function createChildStoreManager(input: { if (child[0].icon !== initialIcon) return child[1]("icon", icon[0].value) }) + + // D2 (issue #817): hydrate the persisted snapshot as a render + // accelerator — only until a real list fetch resolves + // (sessions_fetched flips true), never over a store a fetch + // already filled. + onPersistedInit(sessionSnapshot[2], () => { + if (child[0].sessions_fetched) return + const cached = sessionSnapshot[0].value + if (!cached || child[0].session.length > 0) return + child[1]("session", cached.sessions) + }) }) runWithOwner(input.owner, init) @@ -394,5 +426,21 @@ export function createChildStoreManager(input: { vcsCache, metaCache, iconCache, + // D2 (issue #817): the persisted snapshot's read/write seam — server-sync + // verifies the token on every list response and overwrites the snapshot; + // the panel reset invalidates all of them. + sessionSnapshot(directory: string) { + return snapshotCache.get(directoryKey(directory))?.store.value + }, + writeSessionSnapshot(directory: string, next: SessionSnapshot) { + const cache = snapshotCache.get(directoryKey(directory)) + if (!cache) return + cache.setStore("value", next) + }, + resetSessionSnapshots() { + for (const cache of snapshotCache.values()) { + cache.setStore("value", undefined) + } + }, } } diff --git a/packages/app-bundle/overlay/packages/app/src/context/global-sync/types.ts b/packages/app-bundle/overlay/packages/app/src/context/global-sync/types.ts index cd5a71e1..daa74aa1 100644 --- a/packages/app-bundle/overlay/packages/app/src/context/global-sync/types.ts +++ b/packages/app-bundle/overlay/packages/app/src/context/global-sync/types.ts @@ -18,6 +18,7 @@ import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" import type { CommandInfo, McpResource, McpServer, SessionMessageInfo } from "@opencode-ai/client/promise" import type { Accessor } from "solid-js" import type { SetStoreFunction, Store } from "solid-js/store" +import type { SessionSnapshot } from "./session-snapshot" export type ProjectMeta = { name?: string @@ -111,6 +112,15 @@ export type IconCache = { ready: Accessor } +// D2 (issue #817): the persisted session snapshot's cache entry — a render +// accelerator, never an authority; verified against the derived currency +// token on every list response. +export type SessionSnapshotCache = { + store: Store<{ value: SessionSnapshot | undefined }> + setStore: SetStoreFunction<{ value: SessionSnapshot | undefined }> + ready: Accessor +} + export type ChildOptions = { bootstrap?: boolean mcp?: boolean diff --git a/packages/app-bundle/overlay/packages/app/src/context/server-sdk.tsx b/packages/app-bundle/overlay/packages/app/src/context/server-sdk.tsx index 8fada1d1..9d6efdd8 100644 --- a/packages/app-bundle/overlay/packages/app/src/context/server-sdk.tsx +++ b/packages/app-bundle/overlay/packages/app/src/context/server-sdk.tsx @@ -13,6 +13,7 @@ import { useGlobal } from "./global" import { ServerScope } from "@/utils/server-scope" import { detectServerProtocol, type ServerProtocol } from "@/utils/server-protocol" import { createCompatibleApi, type CompatibleApi } from "@/utils/server-compat" +import { checkServerHealth } from "@/utils/server-health" const isAbortError = (error: unknown) => error !== null && typeof error === "object" && "name" in error && error.name === "AbortError" @@ -391,6 +392,21 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS }) const api = createCompatibleApi({ protocol, current: currentApi, legacy }) + // D2 (issue #817): the hub's self-reported version, resolved once per + // server context and memoized — the stamp the client's derived list-currency + // token carries, and the input to the boot parity record. A hub that does + // not report one yields undefined (the token degrades honestly; parity + // records channel-unreachable semantics, never a fake ok). + let pendingVersion: Promise | undefined + const version = () => { + if (!pendingVersion) { + pendingVersion = checkServerHealth(server.http, platform.fetch ?? globalThis.fetch) + .then((health) => health.version) + .catch(() => undefined) + } + return pendingVersion + } + return { server, scope, @@ -400,6 +416,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS client: sdk, api, currentApi, + version, event: { on: emitter.on.bind(emitter), listen: emitter.listen.bind(emitter), diff --git a/packages/app-bundle/overlay/packages/app/src/context/server-sync.tsx b/packages/app-bundle/overlay/packages/app/src/context/server-sync.tsx index d541ce95..07b8d53b 100644 --- a/packages/app-bundle/overlay/packages/app/src/context/server-sync.tsx +++ b/packages/app-bundle/overlay/packages/app/src/context/server-sync.tsx @@ -28,6 +28,7 @@ import { import { createChildStoreManager } from "./global-sync/child-store" import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer" import { estimateRootSessionTotal, loadRootSessions, loadRootSessionsV1 } from "./global-sync/session-load" +import { bootCurrencyDecision, toSnapshot } from "./global-sync/session-snapshot" import { trimSessions } from "./global-sync/session-trim" import type { ProjectMeta } from "./global-sync/types" import { SESSION_RECENT_LIMIT } from "./global-sync/types" @@ -427,7 +428,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { ? loadRootSessionsV1({ client: sdkFor(directory), directory, limit }) : loadRootSessions({ api: serverSDK.api.session, directory, limit }), ) - .then((x) => { + .then(async (x) => { const nonArchived = (x.data ?? []) .filter((s) => !!s?.id) .filter((s) => !s.time?.archived) @@ -459,6 +460,16 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { setStore("session", reconcile(next, { key: "id" })) }) sessionMeta.set(key, { limit: retained }) + // D2 (issue #817): verify the persisted snapshot against the + // CLIENT-derived currency token — a stale or tokenless snapshot + // is invalidated by this overwrite, so the #293 shape self-heals + // on boot with zero manual action. + const decision = bootCurrencyDecision({ + snapshot: children.sessionSnapshot(directory), + response: { sessions: next }, + serverVersion: await serverSDK.version().catch(() => undefined), + }) + children.writeSessionSnapshot(directory, toSnapshot(next, decision.currency)) }) .catch((err) => { console.error("Failed to load sessions", err) @@ -690,6 +701,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { return name === "loadSessions" || name === "activeSessions" }, }) + children.resetSessionSnapshots() }, } diff --git a/packages/extension/test/session_snapshot_wiring_817.test.ts b/packages/extension/test/session_snapshot_wiring_817.test.ts new file mode 100644 index 00000000..2393b1aa --- /dev/null +++ b/packages/extension/test/session_snapshot_wiring_817.test.ts @@ -0,0 +1,49 @@ +// Issue #817 — D2 wiring: the persisted session snapshot ("session:snapshot", +// a render accelerator never an authority) is verified against the +// client-derived currency token on every list response and overwritten by it, +// so the #293 stale-storage shape self-heals on boot with zero manual action. +// Source-pinned per the repo's overlay-wiring idiom; the decision core is +// behavior-tested in session_snapshot_817.test.ts. +import { describe, expect, test } from "vitest" +import { readFileSync } from "node:fs" +import { join } from "node:path" + +const overlay = (...p: string[]) => join(__dirname, "../../app-bundle/overlay/packages/app/src", ...p) +const src = (p: string) => readFileSync(overlay(p), "utf8") + +describe("persisted snapshot lifecycle (D2: boot self-heal, no filesystem surgery)", () => { + test("child stores persist a per-workspace session:snapshot target", () => { + const childStore = src("context/global-sync/child-store.ts") + expect(childStore).toContain('"session:snapshot"') + expect(childStore).toMatch(/snapshotCache/) + }) + + test("hydration is gated on sessions_fetched — a real fetch always outranks the snapshot", () => { + const childStore = src("context/global-sync/child-store.ts") + expect(childStore).toMatch(/sessions_fetched[\s\S]{0,200}cached\.sessions/) + }) + + test("every list response verifies the snapshot against the derived token and overwrites it", () => { + const s = src("context/server-sync.tsx") + expect(s).toContain("bootCurrencyDecision") + expect(s).toMatch(/bootCurrencyDecision\(\{[\s\S]{0,400}writeSessionSnapshot/) + // The decision reads the persisted snapshot through the child-store seam. + expect(s).toMatch(/sessionSnapshot\(directory\)/) + }) + + test("the reset invalidates all snapshots (session:snapshot is a session cache)", () => { + const s = src("context/server-sync.tsx") + expect(s).toMatch(/resetSessionCaches\(\)[\s\S]{0,1600}resetSessionSnapshots\(\)/) + }) + + test("the server-reported version rides the SDK context for the token stamp", () => { + const s = src("context/server-sdk.tsx") + expect(s).toMatch(/const version = \(\) =>/) + expect(s).toContain("checkServerHealth") + }) + + test("server-sync stamps tokens with the server-reported version", () => { + const s = src("context/server-sync.tsx") + expect(s).toMatch(/serverVersion: (await |)serverSDK\.version\(\)/) + }) +}) From 88457e417bfa60beb1bc0e58d674ac2ac9d8f680 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sat, 5 Sep 2026 11:31:43 -0400 Subject: [PATCH 05/11] fix(app-overlay): the boot fetch paginates by cursor, not page fullness (D2/D4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #817 — port of the fork's 10b7e96e onto the overlay: home-session-index is carried into the overlay (upstream-maintained true overlay, per the M2 pattern) with the exhaustion signal corrected — a page shorter than the requested limit does NOT mean the store ended; only a missing continuation cursor does. A hub capping page size below the request would otherwise silently drop everything past its first short page. The carried module's pure deps (session-trim, utils, path-key) ride along so the module stays importable by the extension's vitest suite; utils' session-ui import becomes type-only (used only in a return type). Pagination property tests ported: desc-first recent tail, cursor-driven multi-page assembly, short-page continuation, abort between pages. --- .../context/global-sync/home-session-index.ts | 192 ++++++++++++++++++ .../src/context/global-sync/session-trim.ts | 59 ++++++ .../app/src/context/global-sync/utils.ts | 175 ++++++++++++++++ .../packages/app/src/utils/path-key.ts | 24 +++ .../test/home_session_pagination_817.test.ts | 110 ++++++++++ 5 files changed, 560 insertions(+) create mode 100644 packages/app-bundle/overlay/packages/app/src/context/global-sync/home-session-index.ts create mode 100644 packages/app-bundle/overlay/packages/app/src/context/global-sync/session-trim.ts create mode 100644 packages/app-bundle/overlay/packages/app/src/context/global-sync/utils.ts create mode 100644 packages/app-bundle/overlay/packages/app/src/utils/path-key.ts create mode 100644 packages/extension/test/home_session_pagination_817.test.ts diff --git a/packages/app-bundle/overlay/packages/app/src/context/global-sync/home-session-index.ts b/packages/app-bundle/overlay/packages/app/src/context/global-sync/home-session-index.ts new file mode 100644 index 00000000..bcdfaf97 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/context/global-sync/home-session-index.ts @@ -0,0 +1,192 @@ +import type { Event, Session, SessionV2Info, V2SessionListResponse } from "@opencode-ai/sdk/v2/client" +import type { QueryClient } from "@tanstack/solid-query" +import { trimSessions } from "./session-trim" +// Overlay carry (issue #817): relative path (not the `@/` alias) so this pure +// module resolves identically in the materialized tree and the extension +// vitest suite; both spell the same file. +import { pathKey } from "../../utils/path-key" + +export const HOME_V2_SESSION_PAGE_LIMIT = 5_000 + +export type HomeSessionEvent = { + type: "session.created" | "session.updated" | "session.deleted" + properties: { sessionID: string; info: Session } +} +export type HomeSessionEvents = { + sequence: number + entries: Array<{ sequence: number; event: HomeSessionEvent }> +} +export type HomeSessionIndex = { + sessions: Session[] + eventSequence: number +} + +export const homeSessionIndexKey = (server: string) => ["home", "session-index", server] as const +export const homeSessionEventsKey = (server: string) => ["home", "session-events", server] as const + +type HomeSessionPage = { data?: V2SessionListResponse } + +export async function loadHomeSessionIndex( + list: ( + input: { limit: number; order: "desc"; cursor?: string }, + options: { signal?: AbortSignal }, + ) => Promise, + eventSequence = 0, + signal?: AbortSignal, +) { + const data: SessionV2Info[] = [] + let cursor: string | undefined + + for (;;) { + const response = await list( + { + limit: HOME_V2_SESSION_PAGE_LIMIT, + order: "desc", + ...(cursor ? { cursor } : {}), + }, + { signal }, + ) + const page = response.data! + data.push(...page.data) + // D2/D4 (issue #817): the cursor is the only exhaustion signal — a page + // shorter than the requested limit does NOT mean the store ended. A hub + // that caps page size below the request would otherwise silently drop + // everything past its first short page. + if (!page.cursor.next) return { sessions: parseHomeSessionIndex(data), eventSequence } + cursor = page.cursor.next + } +} + +export function appendHomeSessionEvent(current: HomeSessionEvents | undefined, event: HomeSessionEvent) { + const sequence = (current?.sequence ?? 0) + 1 + return { + sequence, + entries: [...(current?.entries ?? []), { sequence, event }], + } +} + +export function trimHomeSessionEvents(current: HomeSessionEvents | undefined, sequence: number): HomeSessionEvents { + return { + sequence: current?.sequence ?? sequence, + entries: (current?.entries ?? []).filter((entry) => entry.sequence > sequence), + } +} + +export function homeSessionIndexSessions(index: HomeSessionIndex | undefined, events: HomeSessionEvents | undefined) { + if (!index) return [] + return (events?.entries ?? []) + .filter((entry) => entry.sequence > index.eventSequence) + .reduce((sessions, entry) => applyHomeSessionEvent(sessions, entry.event), index.sessions) +} + +export function homeSessionIndexRefresh(event: Event["type"], connected: boolean) { + if (event === "server.connected") return { connected: true, refetch: connected } + return { + connected, + refetch: event === "global.disposed" || event === "session.next.moved", + } +} + +export function createHomeSessionIndexCache(queryClient: QueryClient, server: string) { + const indexKey = homeSessionIndexKey(server) + const eventsKey = homeSessionEventsKey(server) + let connected = false + const removed = new Set() + + return { + indexKey, + eventsKey, + eventSequence() { + return queryClient.getQueryData(eventsKey)?.sequence ?? 0 + }, + complete(sequence: number) { + // Keep events received after the fetch began so its response cannot overwrite them. + queryClient.setQueryData(eventsKey, (current) => trimHomeSessionEvents(current, sequence)) + }, + sessions(index: HomeSessionIndex | undefined, events: HomeSessionEvents | undefined) { + const sessions = homeSessionIndexSessions(index, events) + return removed.size === 0 ? sessions : sessions.filter((session) => !removed.has(session.id)) + }, + apply(event: HomeSessionEvent) { + if (!queryClient.getQueryState(indexKey)) return + const next = appendHomeSessionEvent(queryClient.getQueryData(eventsKey), event) + if (queryClient.isFetching({ queryKey: indexKey, exact: true }) > 0) { + queryClient.setQueryData(eventsKey, next) + return + } + + const index = queryClient.getQueryData(indexKey) + if (index) { + queryClient.setQueryData(indexKey, { + sessions: homeSessionIndexSessions(index, next), + eventSequence: next.sequence, + }) + } + queryClient.setQueryData(eventsKey, { sequence: next.sequence, entries: [] }) + }, + remove(sessionID: string) { + removed.add(sessionID) + if (!queryClient.getQueryState(indexKey)) return + queryClient.setQueryData(indexKey, (index) => { + if (!index) return index + const at = index.sessions.findIndex((session) => session.id === sessionID) + if (at === -1) return index + return { ...index, sessions: index.sessions.toSpliced(at, 1) } + }) + }, + refresh(event: Event["type"]) { + const result = homeSessionIndexRefresh(event, connected) + connected = result.connected + if (!result.refetch) return + void queryClient.refetchQueries({ queryKey: indexKey, exact: true, type: "active" }) + }, + } +} + +// TODO(v2): This deliberately dumb full-table scan is necessary because the +// current V2 API orders by creation time and cannot filter roots, archives, or +// multiple directories. A bounded page could omit an old session updated today. +// Once released, use client.v2.project.list() and client.v2.session.list({ +// parentID: null, order: "desc" }), then remove this adapter and its V1 fields. +export function parseHomeSessionIndex(sessions: SessionV2Info[]): Session[] { + return sessions.flatMap((item) => { + if (item.parentID || typeof item.time.archived === "number") return [] + return [toLegacySummary(item)] + }) +} + +export function retainHomeSessions(sessions: Session[], limit: number, now: number) { + const grouped = Map.groupBy(sessions, (session) => pathKey(session.directory)) + return [...grouped.values()].flatMap((items) => trimSessions(items, { limit, permission: {}, now })) +} + +export function applyHomeSessionEvent(sessions: Session[], event: HomeSessionEvent) { + const info = event.properties.info + const index = sessions.findIndex((session) => session.id === info.id) + if (event.type === "session.deleted" || info.parentID || typeof info.time.archived === "number") { + if (index === -1) return sessions + return sessions.toSpliced(index, 1) + } + if (event.type !== "session.created" && event.type !== "session.updated") return sessions + if (index === -1) return [...sessions, info] + return sessions.with(index, info) +} + +function toLegacySummary(session: SessionV2Info): Session { + return { + id: session.id, + slug: session.id, + projectID: session.projectID, + workspaceID: session.location.workspaceID, + directory: session.location.directory, + path: session.subpath, + parentID: session.parentID, + cost: session.cost, + tokens: session.tokens, + title: session.title, + agent: session.agent, + model: session.model, + version: "", + time: session.time, + } +} diff --git a/packages/app-bundle/overlay/packages/app/src/context/global-sync/session-trim.ts b/packages/app-bundle/overlay/packages/app/src/context/global-sync/session-trim.ts new file mode 100644 index 00000000..1992b042 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/context/global-sync/session-trim.ts @@ -0,0 +1,59 @@ +import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client" +import { cmp } from "./utils" +// Overlay carry (issue #817): types.ts is overlay-owned, so the runtime +// constants resolve in both the materialized tree and the extension vitest. +import { SESSION_RECENT_LIMIT, SESSION_RECENT_WINDOW } from "./types" + +export function sessionUpdatedAt(session: Session) { + return session.time.updated ?? session.time.created +} + +export function compareSessionRecent(a: Session, b: Session) { + const aUpdated = sessionUpdatedAt(a) + const bUpdated = sessionUpdatedAt(b) + if (aUpdated !== bUpdated) return bUpdated - aUpdated + return cmp(a.id, b.id) +} + +export function takeRecentSessions(sessions: Session[], limit: number, cutoff: number) { + if (limit <= 0) return [] as Session[] + const selected: Session[] = [] + const seen = new Set() + for (const session of sessions) { + if (!session?.id) continue + if (seen.has(session.id)) continue + seen.add(session.id) + if (sessionUpdatedAt(session) <= cutoff) continue + const index = selected.findIndex((x) => compareSessionRecent(session, x) < 0) + if (index === -1) selected.push(session) + if (index !== -1) selected.splice(index, 0, session) + if (selected.length > limit) selected.pop() + } + return selected +} + +export function trimSessions( + input: Session[], + options: { limit: number; permission: Record; now?: number }, +) { + const limit = Math.max(0, options.limit) + const cutoff = (options.now ?? Date.now()) - SESSION_RECENT_WINDOW + const all = input + .filter((s) => !!s?.id) + .filter((s) => !s.time?.archived) + .sort((a, b) => cmp(a.id, b.id)) + const roots = all.filter((s) => !s.parentID) + roots.sort(compareSessionRecent) + const children = all.filter((s) => !!s.parentID) + const base = roots.slice(0, limit) + const recent = takeRecentSessions(roots.slice(limit), SESSION_RECENT_LIMIT, cutoff) + const keepRoots = [...base, ...recent] + const keepRootIds = new Set(keepRoots.map((s) => s.id)) + const keepChildren = children.filter((s) => { + if (s.parentID && keepRootIds.has(s.parentID)) return true + const perms = options.permission[s.id] ?? [] + if (perms.length > 0) return true + return sessionUpdatedAt(s) > cutoff + }) + return [...keepRoots, ...keepChildren].sort((a, b) => cmp(a.id, b.id)) +} diff --git a/packages/app-bundle/overlay/packages/app/src/context/global-sync/utils.ts b/packages/app-bundle/overlay/packages/app/src/context/global-sync/utils.ts new file mode 100644 index 00000000..d92a6acc --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/context/global-sync/utils.ts @@ -0,0 +1,175 @@ +import type { + AgentListOutput, + ModelDefaultOutput, + ModelListOutput, + PermissionV2Request, + ProviderListOutput, +} from "@opencode-ai/client/promise" +import type { Agent, PermissionRequest, Project, Provider, ProviderListResponse } from "@opencode-ai/sdk/v2/client" +import type { Project as CurrentProject } from "@opencode-ai/client/promise" +import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" +// Overlay carry (issue #817): relative path (not the `@/` alias) so this pure +// module resolves identically in the materialized tree and the extension +// vitest suite; both spell the same file. +export { pathKey as directoryKey, type PathKey as DirectoryKey } from "../../utils/path-key" + +export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) + +export function normalizeAgentList(input: AgentListOutput["data"] | Agent[]): Agent[] { + if (input.every((agent) => !("request" in agent))) return input as Agent[] + return (input as AgentListOutput["data"]).map((agent) => ({ + name: agent.id, + description: agent.description, + mode: agent.mode, + hidden: agent.hidden, + temperature: + typeof agent.request.settings.temperature === "number" ? agent.request.settings.temperature : undefined, + topP: typeof agent.request.settings.topP === "number" ? agent.request.settings.topP : undefined, + color: agent.color, + permission: agent.permissions.map((rule) => ({ + permission: rule.action, + pattern: rule.resource, + action: rule.effect, + })), + model: agent.model && { providerID: agent.model.providerID, modelID: agent.model.id }, + variant: agent.model?.variant, + prompt: agent.system, + options: agent.request.settings, + steps: agent.steps, + })) +} + +export function normalizePermissionRequest(input: PermissionV2Request | PermissionRequest): PermissionRequest { + if ("permission" in input) return input + return { + id: input.id, + sessionID: input.sessionID, + permission: input.action, + patterns: input.resources, + always: input.save ?? [], + metadata: input.metadata ?? {}, + tool: + input.source?.type === "tool" ? { messageID: input.source.messageID, callID: input.source.callID } : undefined, + } +} + +export function normalizeProviderList( + providers: ProviderListOutput["data"] | ProviderListResponse, + models?: ModelListOutput["data"], + defaultModel?: ModelDefaultOutput["data"], +): NormalizedProviderListResponse { + if (!Array.isArray(providers)) { + return { + ...providers, + all: new Map( + providers.all.map((provider) => [ + provider.id, + { + ...provider, + models: Object.fromEntries( + Object.entries(provider.models).filter(([, model]) => model.status !== "deprecated"), + ), + }, + ]), + ), + } + } + const all = new Map() + + for (const provider of providers) { + all.set(provider.id, { + id: provider.id, + name: provider.name, + source: "custom", + env: [], + options: provider.settings ?? {}, + models: {}, + }) + } + + for (const model of models ?? []) { + const provider = all.get(model.providerID) + if (!provider || model.status === "deprecated") continue + const cost = model.cost.find((item) => item.tier === undefined) ?? model.cost[0] + provider.models[model.id] = { + id: model.id, + providerID: model.providerID, + api: { + id: model.modelID, + url: "", + npm: model.package ?? provider.id, + }, + name: model.name, + family: model.family, + capabilities: { + temperature: false, + reasoning: false, + attachment: model.capabilities.input.some((item) => item !== "text"), + toolcall: model.capabilities.tools, + input: { + text: model.capabilities.input.includes("text"), + audio: model.capabilities.input.includes("audio"), + image: model.capabilities.input.includes("image"), + video: model.capabilities.input.includes("video"), + pdf: model.capabilities.input.includes("pdf"), + }, + output: { + text: model.capabilities.output.includes("text"), + audio: model.capabilities.output.includes("audio"), + image: model.capabilities.output.includes("image"), + video: model.capabilities.output.includes("video"), + pdf: model.capabilities.output.includes("pdf"), + }, + interleaved: false, + }, + cost: { + input: cost?.input ?? 0, + output: cost?.output ?? 0, + cache: { + read: cost?.cache.read ?? 0, + write: cost?.cache.write ?? 0, + }, + }, + limit: model.limit, + status: model.status, + options: model.settings ?? {}, + headers: model.headers ?? {}, + release_date: new Date(model.time.released).toISOString().slice(0, 10), + variants: Object.fromEntries(model.variants.map((variant) => [variant.id, variant.settings ?? {}])), + } + } + + return { + all, + connected: providers.map((provider) => provider.id), + defaultModel: defaultModel ? { providerID: defaultModel.providerID, modelID: defaultModel.id } : null, + default: Object.fromEntries( + providers.flatMap((provider) => { + const model = + defaultModel?.providerID === provider.id + ? defaultModel + : models?.find((item) => item.providerID === provider.id && item.status !== "deprecated") + return model ? [[provider.id, model.id]] : [] + }), + ), + } +} + +export function sanitizeProject(project: Project) { + if (!project.icon?.url && !project.icon?.override) return project + return { + ...project, + icon: { + ...project.icon, + url: undefined, + override: undefined, + }, + } +} + +export function normalizeProjectInfo(project: Project | CurrentProject): Project { + return { + ...project, + vcs: project.vcs === "git" ? "git" : undefined, + } +} diff --git a/packages/app-bundle/overlay/packages/app/src/utils/path-key.ts b/packages/app-bundle/overlay/packages/app/src/utils/path-key.ts new file mode 100644 index 00000000..68d53e91 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/utils/path-key.ts @@ -0,0 +1,24 @@ +export type PathKey = string & { _brand: "PathKey" } + +const isDrive = (value: string) => { + if (value.length !== 2) return false + const code = value.charCodeAt(0) + return value[1] === ":" && ((code >= 65 && code <= 90) || (code >= 97 && code <= 122)) +} + +const trimTrailingSlashes = (value: string) => { + for (let i = value.length - 1; i >= 0; i--) { + if (value[i] !== "/") return value.slice(0, i + 1) + } + return "" +} + +const isWindowsPath = (value: string) => value[1] === ":" || value.startsWith("\\\\") + +export const pathKey = (path: string) => { + const value = isWindowsPath(path) ? path.replaceAll("\\", "/") : path + const trimmed = trimTrailingSlashes(value) + if (!trimmed && value.startsWith("/")) return "/" as PathKey + if (isDrive(trimmed)) return `${trimmed}/` as PathKey + return trimmed as PathKey +} diff --git a/packages/extension/test/home_session_pagination_817.test.ts b/packages/extension/test/home_session_pagination_817.test.ts new file mode 100644 index 00000000..153ca8a9 --- /dev/null +++ b/packages/extension/test/home_session_pagination_817.test.ts @@ -0,0 +1,110 @@ +// Issue #817 — D2/D4 (spec spec-20260905-045114-session-device-lifecycle): +// the boot fetch paginates by cursor, not page fullness. Ported from the fork +// reference (harmoniqs/opencode#296, 10b7e96e) against the overlay's carried +// home-session-index: a hub that caps page size below the requested limit +// must not silently drop everything past its first short page. +import { describe, expect, test } from "vitest" +import { HOME_V2_SESSION_PAGE_LIMIT, loadHomeSessionIndex } from "../../app-bundle/overlay/packages/app/src/context/global-sync/home-session-index" + +const session = (input: { id: string; updated?: number }) => ({ + id: input.id, + parentID: undefined, + projectID: "project", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: input.updated ?? 1 }, + title: input.id, + location: { directory: "/project" }, +}) + +function seed(count: number) { + // time_created ascending with index; the store's newest session is last. + return Array.from({ length: count }, (_, index) => + session({ id: `ses_${String(index).padStart(6, "0")}`, updated: 1_000 + index }), + ) +} + +describe("Home boot fetch pagination (D2/D4: recent tail first, continues paging)", () => { + test("pages a 1250-session store desc-first via continuation cursors until exhausted", async () => { + const store = seed(1250) + const pages: { order?: string; cursor?: string }[] = [] + const PAGE_SIZE = 500 // a hub that caps page size below the requested limit + const list = async (input: { limit: number; order?: "asc" | "desc"; cursor?: string }) => { + pages.push({ order: input.order, cursor: input.cursor }) + const start = input.cursor ? Number(input.cursor) : 0 + // The store answers desc: the newest (recent tail) first. + const page = store.toReversed().slice(start, start + PAGE_SIZE) + const next = start + PAGE_SIZE < store.length ? String(start + PAGE_SIZE) : undefined + return { data: { data: page, cursor: { next } } } + } + + const result = await loadHomeSessionIndex(list as never) + + // The recent tail arrives first: page one holds the newest sessions. + expect(pages[0]!.order).toBe("desc") + expect(pages[0]!.cursor).toBeUndefined() + // 1250 sessions at 500/page take exactly three pages. + expect(pages).toHaveLength(3) + expect(pages[1]!.cursor).toBeDefined() + expect(pages[2]!.cursor).toBeDefined() + // The full store is assembled, newest session included. + expect(result.sessions).toHaveLength(1250) + const newest = store[store.length - 1]!.id + expect(result.sessions.some((item) => item.id === newest)).toBe(true) + }) + + test("a store smaller than one page needs exactly one request", async () => { + const store = seed(12) + let calls = 0 + const list = async (input: { limit: number }) => { + calls++ + return { data: { data: store.toReversed().slice(0, input.limit), cursor: {} } } + } + + const result = await loadHomeSessionIndex(list as never) + + expect(calls).toBe(1) + expect(result.sessions).toHaveLength(12) + }) + + test("a short page does NOT end the fetch when the cursor continues", async () => { + // The founding bug: a hub capping page size below the requested limit + // would end the fetch on the first short page and silently drop the rest. + const store = seed(1250) + const PAGE_SIZE = 500 + let calls = 0 + const list = async (input: { limit: number; cursor?: string }) => { + calls++ + const start = input.cursor ? Number(input.cursor) : 0 + const page = store.toReversed().slice(start, start + PAGE_SIZE) + const next = start + PAGE_SIZE < store.length ? String(start + PAGE_SIZE) : undefined + return { data: { data: page, cursor: { next } } } + } + + const result = await loadHomeSessionIndex(list as never) + + expect(calls).toBe(3) + expect(result.sessions).toHaveLength(1250) + }) + + test("respects an abort signal between pages", async () => { + const store = seed(HOME_V2_SESSION_PAGE_LIMIT * 2) + const controller = new AbortController() + let calls = 0 + const list = async ( + input: { limit: number; order?: "asc" | "desc"; cursor?: string }, + options?: { signal?: AbortSignal }, + ) => { + calls++ + if (calls === 2) controller.abort() + if (options?.signal?.aborted) throw new Error("aborted") + const start = input.cursor ? Number(input.cursor) : 0 + const page = store.toReversed().slice(start, start + HOME_V2_SESSION_PAGE_LIMIT) + const next = start + HOME_V2_SESSION_PAGE_LIMIT < store.length ? String(start + HOME_V2_SESSION_PAGE_LIMIT) : undefined + return { data: { data: page, cursor: { next } } } + } + + await expect(loadHomeSessionIndex(list as never, 0, controller.signal)).rejects.toThrow("aborted") + expect(calls).toBe(2) + }) +}) From 42569ccd5c938ceb799b1e37d0b2f7871ba0a2ef Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sat, 5 Sep 2026 11:34:17 -0400 Subject: [PATCH 06/11] =?UTF-8?q?feat(app-overlay):=20every=20session=20ho?= =?UTF-8?q?me=20is=20first-class,=20client-side=20=E2=80=94=20honest=20sta?= =?UTF-8?q?tes=20on=20the=20home=20page=20(D1=20+=20D2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #817 — the revised placement of D1: project grouping resolves CLIENT-side over directory/projectID (data the global home index already returns) — no server project rows, no boot-time backfill, no junk lint. The old controller dropped every session whose directory resolved to no opened project (the founding incident: 687 sessions in a non-git home, API-visible but panel-invisible); resolution now keys on the WORKTREE PATH (the D1 identity — a stale projectID never steals a session), synthesizes a first-class home for unmatched directories, and re-keys for free when a non-git home later becomes a git repo (opened projects resolve first). The per-project home scope keeps its deliberate filtering; the all-projects scope drops nothing. home-sessions-controller/home-sessions are carried into the overlay (upstream-maintained true overlays) wired to the new pure module (home-session-groups, unit-tested headless), and the home page routes through sessionListState: while the index query has never succeeded the view renders the skeleton, never the empty state. --- .../app/src/pages/home/home-session-groups.ts | 75 +++++ .../pages/home/home-sessions-controller.tsx | 310 ++++++++++++++++++ .../app/src/pages/home/home-sessions-view.tsx | 16 +- .../app/src/pages/home/home-sessions.tsx | 48 +++ .../test/home_session_groups_817.test.ts | 116 +++++++ .../extension/test/home_wiring_817.test.ts | 44 +++ 6 files changed, 608 insertions(+), 1 deletion(-) create mode 100644 packages/app-bundle/overlay/packages/app/src/pages/home/home-session-groups.ts create mode 100644 packages/app-bundle/overlay/packages/app/src/pages/home/home-sessions-controller.tsx create mode 100644 packages/app-bundle/overlay/packages/app/src/pages/home/home-sessions.tsx create mode 100644 packages/extension/test/home_session_groups_817.test.ts create mode 100644 packages/extension/test/home_wiring_817.test.ts diff --git a/packages/app-bundle/overlay/packages/app/src/pages/home/home-session-groups.ts b/packages/app-bundle/overlay/packages/app/src/pages/home/home-session-groups.ts new file mode 100644 index 00000000..8b6bf344 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/pages/home/home-session-groups.ts @@ -0,0 +1,75 @@ +// D1 client-side (spec spec-20260905-045114-session-device-lifecycle, revised +// placement), issue #817: every session home is first-class WITHOUT server +// project rows, boot-time backfill, or a junk lint. Project grouping resolves +// CLIENT-side over directory/projectID — data the global session list already +// returns — so a non-git home (the founding incident's ~/armonia, 687 +// sessions API-visible but panel-invisible) renders as its own group. When a +// non-git home later becomes a git repo, the git-derived project supersedes +// the synthesized group by construction: opened projects resolve first. +// +// Pure and dependency-free (type imports only) so it unit-tests headless. +import type { Session } from "@opencode-ai/sdk/v2/client" +import type { LocalProject } from "@/context/layout" +import { pathKey } from "../../utils/path-key" + +export type HomeSessionRecord = { + session: Session + project: LocalProject + projectName: string +} + +const baseName = (worktree: string) => { + const key = pathKey(worktree).replace(/\/+$/, "") + const idx = key.lastIndexOf("/") + return idx === -1 || key === "/" ? key || worktree : key.slice(idx + 1) +} + +/** Resolve a session's directory to a first-class project group. Resolution + * keys on the WORKTREE PATH (the D1 identity): an opened project whose + * worktree (or sandbox) matches the session's path wins; a projectID match + * alone never steals a session from its path. A directory with no opened + * project — a non-git home — becomes its OWN group, keyed on the path, with + * no server row and no backfill. */ +export function resolveSessionProject( + session: Pick, + projects: LocalProject[], +): { project: LocalProject; projectName: string } { + const directory = pathKey(session.directory) + const matched = projects.find( + (project) => + pathKey(project.worktree) === directory || + project.sandboxes?.some((sandbox) => pathKey(sandbox) === directory), + ) + const project = + matched ?? + // The synthesized first-class home: worktree = the session's directory. + ({ worktree: session.directory, expanded: true }) as LocalProject + return { project, projectName: project.name || baseName(project.worktree) || project.worktree } +} + +export function buildHomeSessionRecords(input: { + sessions: readonly Session[] + /** Directories in scope (the selected project's, or all opened projects'). */ + projectDirectories: readonly string[] + projects: readonly LocalProject[] + /** True for the all-projects home scope: no directory is dropped — every + * home renders, first-class (D1). False keeps the per-project scoping. */ + scopeAll: boolean +}): HomeSessionRecord[] { + const directories = new Set(input.projectDirectories.map(pathKey)) + const sessions = input.scopeAll + ? [...input.sessions] + : input.sessions.filter((session) => directories.has(pathKey(session.directory))) + return [...new Map(sessions.map((session) => [session.id, session] as const)).values()] + .sort(compareSessionTime) + .map((session) => { + const { project, projectName } = resolveSessionProject(session, input.projects) + return { session, project, projectName } + }) +} + +function compareSessionTime(a: Session, b: Session) { + const updated = (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created) + if (updated !== 0) return updated + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0 +} diff --git a/packages/app-bundle/overlay/packages/app/src/pages/home/home-sessions-controller.tsx b/packages/app-bundle/overlay/packages/app/src/pages/home/home-sessions-controller.tsx new file mode 100644 index 00000000..e6b31936 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/pages/home/home-sessions-controller.tsx @@ -0,0 +1,310 @@ +import type { Session } from "@opencode-ai/sdk/v2/client" +import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useQuery } from "@tanstack/solid-query" +import { DateTime } from "luxon" +import { type Accessor, createEffect, createMemo, createRoot, type JSX, startTransition } from "solid-js" +import { produce } from "solid-js/store" +import { useCommand } from "@/context/command" +import { + loadHomeSessionIndex, + retainHomeSessions, + type HomeSessionEvents, +} from "@/context/global-sync/home-session-index" +import type { LocalProject } from "@/context/layout" +import { useLanguage } from "@/context/language" +import { ServerConnection } from "@/context/server" +import { sessionHasOpenTab, useTabs } from "@/context/tabs" +import { errorMessage, projectForSession } from "@/pages/layout/helpers" +import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state" +import { pathKey } from "@/utils/path-key" +import { sessionListState } from "@/utils/session-list-state" +import { showToast } from "@/utils/toast" +import { Binary } from "@opencode-ai/core/util/binary" +import { archiveHomeSession } from "../home-session-archive" +import type { HomeController } from "./home-controller" +// D1 client-side (issue #817): the first-class home resolution lives in a +// pure module (unit-tested headless); the controller wires it to the home +// scope. +import { buildHomeSessionRecords, type HomeSessionRecord } from "./home-session-groups" + +const HOME_SESSION_LIMIT = 64 +export type { HomeSessionRecord } + +export type HomeSessionGroup = { + id: "today" | "yesterday" | "older" + title: string + sessions: HomeSessionRecord[] +} + +export type OpenSessionOptions = { background?: boolean } + +export function createHomeSessionsController(home: HomeController) { + const tabs = useTabs() + const command = useCommand() + const dialog = useDialog() + const language = useLanguage() + const projectDirectories = createMemo(() => { + const project = home.project.selected() + if (!project) return home.project.list().flatMap(directories) + return directories(project) + }) + const projectByID = createMemo( + () => new Map(home.project.list().flatMap((project) => (project.id ? [[project.id, project] as const] : []))), + ) + const homeSessions = () => home.server.focusedSync().homeSessions + const sessionEventLoad = useQuery(() => ({ + queryKey: homeSessions().eventsKey, + queryFn: async (): Promise => ({ sequence: 0, entries: [] }), + initialData: { sequence: 0, entries: [] } satisfies HomeSessionEvents, + enabled: false, + })) + const sessionLoad = useQuery(() => ({ + queryKey: homeSessions().indexKey, + enabled: !!home.server.focusedContext(), + queryFn: async ({ signal }) => { + const ctx = home.server.focusedContext() + if (!ctx) return { sessions: [], eventSequence: 0 } + const cache = homeSessions() + const eventSequence = cache.eventSequence() + const index = await loadHomeSessionIndex( + (input, options) => ctx.sdk.client.v2.session.list(input, options), + eventSequence, + signal, + ) + cache.complete(eventSequence) + return index + }, + retry: false, + staleTime: 30_000, + refetchOnMount: true, + refetchOnReconnect: true, + })) + const indexedSessions = createMemo(() => + retainHomeSessions( + homeSessions().sessions(sessionLoad.data, sessionEventLoad.data), + HOME_SESSION_LIMIT, + Date.now(), + ), + ) + const allRecords = createMemo(() => + // D1 client-side (issue #817): in the all-projects scope no home is + // dropped — every session's directory resolves to a first-class group + // (opened project, or its own non-git home) without server project rows + // or backfill. The per-project scope keeps its deliberate filtering. + buildHomeSessionRecords({ + sessions: indexedSessions(), + projectDirectories: projectDirectories(), + projects: home.project.list(), + scopeAll: !home.project.selected(), + }), + ) + const records = createMemo(() => allRecords().slice(0, HOME_SESSION_LIMIT)) + const groups = createMemo(() => groupSessions(records(), language)) + const prefetched = new Set() + + createEffect(() => { + const ctx = home.server.focusedContext() + const conn = home.server.focused() + if (!ctx || !conn) return + records() + .slice(0, 2) + .forEach((record) => { + const key = `${ServerConnection.key(conn)}\0${record.session.id}` + if (prefetched.has(key)) return + prefetched.add(key) + createRoot((dispose) => { + try { + void ctx.sync.session + .sync(record.session.id) + .then(() => + Promise.all( + (ctx.sync.session.data.message[record.session.id] ?? []).flatMap((message) => + (ctx.sync.session.data.part[message.id] ?? []).flatMap((part) => { + if (part.type !== "text" || !part.text) return [] + return preloadMarkdown(part.text, part.id) + }), + ), + ), + ) + .catch(() => {}) + .finally(dispose) + } catch { + dispose() + } + }) + }) + }) + + command.register("home.palette", () => [ + { + id: "command.palette", + title: language.t("command.palette"), + hidden: true, + onSelect: async () => { + const conn = home.server.focused() + if (!conn) return + const ctx = home.server.focusedContext() + if (!ctx) return + const { DialogHomeCommandPaletteV2 } = await import("@/components/dialog-command-palette-v2") + void dialog.show(() => ( + { + if (!entry.sessionID || !entry.directory || !entry.server) return + const sessionID = entry.sessionID + const server = entry.server + const directory = entry.project?.worktree ?? entry.directory + ctx.projects.open(directory) + ctx.projects.touch(directory) + void startTransition(() => { + const tab = tabs.addSessionTab({ server, sessionId: sessionID }) + tabs.select(tab) + }) + }} + /> + )) + }, + }, + ]) + + return { + copy: { + language, + }, + data: { + records, + groups, + loading: () => sessionLoad.isLoading, + // D2 honest states (issue #817): the home list distinguishes "not yet + // fetched" (no index fetch has ever resolved) from "genuinely empty" + // (it has, and the projection is empty) — never render the empty state + // during the boot fetch (#293's invisible failure shape). + listState: () => + sessionListState({ + fetched: sessionLoad.isSuccess, + count: records().length, + }), + searchRecords: allRecords, + }, + session: { + showProjectName: () => !home.project.selected(), + server: () => home.selection.value().server, + canCreate: () => !!home.project.newSession(), + create: home.project.openNewSession, + open: (session: Session, options?: OpenSessionOptions) => { + const directoryKey = pathKey(session.directory) + const project = + home.project + .list() + .find( + (item) => + pathKey(item.worktree) === directoryKey || + item.sandboxes?.some((sandbox) => pathKey(sandbox) === directoryKey), + ) ?? projectForSession(session, home.project.list(), projectByID()) + const conn = home.server.focused() + if (!conn) return + const directory = project?.worktree ?? session.directory + const ctx = home.server.focusedContext() + if (!ctx) return + ctx.projects.open(directory) + if (options?.background) { + tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id }) + return + } + ctx.projects.touch(directory) + void startTransition(() => { + const tab = tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id }) + tabs.select(tab) + }) + }, + archive: async (session: Session) => { + const conn = home.server.focused() + const ctx = home.server.focusedContext() + if (!conn || !ctx) return + const [, setStore] = ctx.sync.child(session.directory) + if ((await ctx.sdk.protocol) !== "v1") return + await archiveHomeSession({ + server: ServerConnection.key(conn), + session, + archive: (sessionID) => + ctx.sdk.client.session.update({ + sessionID, + directory: session.directory, + time: { archived: Date.now() }, + }), + remove: () => { + setStore( + produce((draft) => { + const match = Binary.search(draft.session, session.id, (item) => item.id) + if (match.found) draft.session.splice(match.index, 1) + }), + ) + homeSessions().remove(session.id) + }, + onError: (cause) => + showToast({ + title: language.t("common.requestFailed"), + description: errorMessage(cause, language.t("common.requestFailed")), + }), + }) + }, + }, + tab: { + isOpen: (record: HomeSessionRecord) => + sessionHasOpenTab(tabs.store, home.selection.value().server, record.session), + }, + } +} + +function directories(project: LocalProject) { + return [project.worktree, ...(project.sandboxes ?? [])] +} + +export function homeSessionSearchKey(record: HomeSessionRecord) { + return `${pathKey(record.session.directory)}:${record.session.id}` +} + +function groupSessions(records: HomeSessionRecord[], language: ReturnType): HomeSessionGroup[] { + const now = DateTime.local() + const yesterday = now.minus({ days: 1 }) + const todaySessions = records.filter((record) => + DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(now, "day"), + ) + const yesterdaySessions = records.filter((record) => + DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(yesterday, "day"), + ) + const olderSessions = records.filter((record) => { + const time = DateTime.fromMillis(record.session.time.updated ?? record.session.time.created) + return !time.hasSame(now, "day") && !time.hasSame(yesterday, "day") + }) + const olderTitle = + todaySessions.length === 0 && yesterdaySessions.length === 0 + ? language.t("sidebar.project.recentSessions") + : language.t("home.sessions.group.older") + return [ + { id: "today" as const, title: language.t("home.sessions.group.today"), sessions: todaySessions }, + { id: "yesterday" as const, title: language.t("home.sessions.group.yesterday"), sessions: yesterdaySessions }, + { id: "older" as const, title: olderTitle, sessions: olderSessions }, + ].filter((group) => group.sessions.length > 0) +} + +export type HomeSessionsController = ReturnType + +export function HomeSessionStatusController(props: { + server: Accessor + record: HomeSessionRecord + isOpenTab: (record: HomeSessionRecord) => boolean + render: (state: { unread: Accessor; loading: Accessor; open: Accessor }) => JSX.Element +}) { + const avatar = useSessionTabAvatarState( + props.server, + () => props.record.session.directory, + () => props.record.session.id, + ) + return props.render({ + unread: avatar.unread, + loading: avatar.loading, + open: () => props.isOpenTab(props.record), + }) +} diff --git a/packages/app-bundle/overlay/packages/app/src/pages/home/home-sessions-view.tsx b/packages/app-bundle/overlay/packages/app/src/pages/home/home-sessions-view.tsx index 438267c1..2ccac39f 100644 --- a/packages/app-bundle/overlay/packages/app/src/pages/home/home-sessions-view.tsx +++ b/packages/app-bundle/overlay/packages/app/src/pages/home/home-sessions-view.tsx @@ -10,6 +10,7 @@ import { useLanguage } from "@/context/language" import { ServerConnection } from "@/context/server" import { SessionTabAvatarView } from "@/pages/layout/session-tab-avatar" import { sessionTitle } from "@/utils/session-title" +import { type SessionListState } from "@/utils/session-list-state" import { shouldOpenSessionInBackground } from "../home-session-open" import { HomeSessionStatusController, @@ -39,6 +40,7 @@ function isBackgroundOpen(event: MouseEvent) { export type HomeSessionsViewProps = { language: ReturnType groups: Accessor + listState: Accessor showProjectName: Accessor server: Accessor canCreateSession: Accessor @@ -113,7 +115,7 @@ export function HomeSessionsView(props: HomeSessionsViewProps) { } > 0} + when={props.groups().length > 0 || props.listState() === "unfetched"} fallback={ } > + + + + } + >
{(group, index) => ( @@ -140,6 +153,7 @@ export function HomeSessionsView(props: HomeSessionsViewProps) { )}
+
diff --git a/packages/app-bundle/overlay/packages/app/src/pages/home/home-sessions.tsx b/packages/app-bundle/overlay/packages/app/src/pages/home/home-sessions.tsx new file mode 100644 index 00000000..59fcc6b7 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/pages/home/home-sessions.tsx @@ -0,0 +1,48 @@ +import type { HomeScrollController } from "./home-scroll-controller" +import type { HomeSessionSearchController } from "./home-session-search-controller" +import type { HomeSessionsController } from "./home-sessions-controller" +import { HomeSessionsView } from "./home-sessions-view" + +export function HomeSessions(props: { + sessions: HomeSessionsController + search: HomeSessionSearchController + scroll: HomeScrollController +}) { + return ( + + ) +} diff --git a/packages/extension/test/home_session_groups_817.test.ts b/packages/extension/test/home_session_groups_817.test.ts new file mode 100644 index 00000000..4e137011 --- /dev/null +++ b/packages/extension/test/home_session_groups_817.test.ts @@ -0,0 +1,116 @@ +// Issue #817 — D1 client-side (spec spec-20260905-045114-session-device- +// lifecycle, revised placement): every session home is first-class WITHOUT +// server project rows, backfill, or a junk lint. Project grouping is resolved +// CLIENT-side over directory/projectID from the session list the global +// home index already returns; a non-git home — pre-existing or new — becomes +// its own group. The founding incident's shape (687 sessions in a non-git +// home, API-visible but panel-invisible) dies here. +import { describe, expect, test } from "vitest" +import { + buildHomeSessionRecords, + resolveSessionProject, +} from "../../app-bundle/overlay/packages/app/src/pages/home/home-session-groups" +import type { LocalProject } from "../../app-bundle/overlay/packages/app/src/context/layout" + +const session = (id: string, directory: string, projectID = "p-git", updated = 100) => ({ + id, + directory, + projectID, + slug: id, + version: "test", + title: `Session ${id}`, + parentID: undefined, + time: { created: updated, updated }, +}) + +const gitProject = (id: string, worktree: string): LocalProject => + ({ id, worktree, expanded: true }) as LocalProject + +describe("resolveSessionProject (D1: every session home is first-class, client-side)", () => { + test("a session in an opened git project resolves to that project", () => { + const project = gitProject("p1", "/repo") + const resolved = resolveSessionProject(session("s1", "/repo"), [project]) + expect(resolved?.project.id).toBe("p1") + expect(resolved?.projectName).toBe("repo") + }) + + test("a non-git home with NO project row becomes its own first-class group", () => { + // ~/armonia never opened as a git repo — the founding incident's home. + const resolved = resolveSessionProject(session("s1", "/home/aaron/armonia"), []) + expect(resolved).not.toBeNull() + expect(resolved!.project.worktree).toBe("/home/aaron/armonia") + expect(resolved!.project.id).toBeUndefined() + expect(resolved!.projectName).toBe("armonia") + }) + + test("resolution is keyed on the path: projectID match alone is not trusted over the worktree", () => { + // A stale projectID pointing at another project must not steal the session. + const other = gitProject("p-other", "/elsewhere") + const resolved = resolveSessionProject(session("s1", "/repo", "p-other"), [other]) + expect(resolved!.project.worktree).toBe("/repo") + }) + + test("path keys are normalized (trailing slash, windows separators)", () => { + const project = gitProject("p1", "/repo") + expect(resolveSessionProject(session("s1", "/repo/"), [project])!.project.id).toBe("p1") + expect(resolveSessionProject(session("s1", "C:\\repo"), [gitProject("p1", "C:/repo")])!.project.id).toBe("p1") + }) + + test("re-keying: once a non-git home becomes a git repo, the git-derived row supersedes the synthesized group", () => { + // Before: no project row — synthesized. + expect(resolveSessionProject(session("s1", "/repo"), [])!.project.id).toBeUndefined() + // After git-init: the opened project resolves and its identity wins. + const project = gitProject("p-git", "/repo") + expect(resolveSessionProject(session("s1", "/repo"), [project])!.project.id).toBe("p-git") + }) +}) + +describe("buildHomeSessionRecords (D1: the all-projects home never drops a home)", () => { + const projects = [gitProject("p1", "/repo")] + + test("a non-git home's sessions render as their own group in the all-projects scope", () => { + const records = buildHomeSessionRecords({ + sessions: [session("s1", "/home/aaron/armonia", "p-stranded")], + projectDirectories: ["/repo"], + projects, + scopeAll: true, + }) + expect(records).toHaveLength(1) + expect(records[0]!.project.worktree).toBe("/home/aaron/armonia") + expect(records[0]!.projectName).toBe("armonia") + }) + + test("a pre-existing non-git home is visible too — no backfill, no server rows", () => { + const records = buildHomeSessionRecords({ + sessions: [ + session("s1", "/home/aaron/armonia", "p-stranded", 50), + session("s2", "/repo", "p1", 100), + ], + projectDirectories: ["/repo"], + projects, + scopeAll: true, + }) + expect(records.map((r) => r.session.id)).toEqual(["s2", "s1"]) + expect(records[1]!.projectName).toBe("armonia") + }) + + test("the per-project scope still filters to the selected project's directories", () => { + const records = buildHomeSessionRecords({ + sessions: [session("s1", "/home/aaron/armonia"), session("s2", "/repo")], + projectDirectories: ["/repo"], + projects, + scopeAll: false, + }) + expect(records.map((r) => r.session.id)).toEqual(["s2"]) + }) + + test("records are deduplicated by session id and sorted most-recent first", () => { + const records = buildHomeSessionRecords({ + sessions: [session("s1", "/repo", "p1", 100), session("s1", "/repo", "p1", 100), session("s2", "/repo", "p1", 200)], + projectDirectories: ["/repo"], + projects, + scopeAll: true, + }) + expect(records.map((r) => r.session.id)).toEqual(["s2", "s1"]) + }) +}) diff --git a/packages/extension/test/home_wiring_817.test.ts b/packages/extension/test/home_wiring_817.test.ts new file mode 100644 index 00000000..400f289e --- /dev/null +++ b/packages/extension/test/home_wiring_817.test.ts @@ -0,0 +1,44 @@ +// Issue #817 — home wiring: the honest states reach the home page and every +// session home is first-class in the all-projects scope. Source-pinned per +// the repo's overlay-wiring idiom (the home controllers are Solid components); +// the behavioral cores are exercised in home_session_groups_817.test.ts and +// session_list_state_817.test.ts. +import { describe, expect, test } from "vitest" +import { readFileSync } from "node:fs" +import { join } from "node:path" + +const overlay = (...p: string[]) => join(__dirname, "../../app-bundle/overlay/packages/app/src", ...p) +const src = (p: string) => readFileSync(overlay(p), "utf8") + +describe("home page honest states (D2)", () => { + test("the controller exposes listState from the index query's success", () => { + const s = src("pages/home/home-sessions-controller.tsx") + expect(s).toMatch(/listState: \(\) =>\s*\n?\s*sessionListState\(\{\s*\n?\s*fetched: sessionLoad\.isSuccess,/) + }) + + test("the glue threads listState into the view", () => { + expect(src("pages/home/home-sessions.tsx")).toContain("listState={props.sessions.data.listState}") + }) + + test("the view renders loading — never the empty state — while unfetched", () => { + const s = src("pages/home/home-sessions-view.tsx") + expect(s).toMatch(/props\.groups\(\)\.length > 0 \|\| props\.listState\(\) === "unfetched"/) + expect(s).toMatch(/when=\{props\.listState\(\) !== "unfetched"\}/) + }) +}) + +describe("client-side grouping (D1: no home dropped, no server rows)", () => { + test("the controller resolves records through the pure first-class module", () => { + const s = src("pages/home/home-sessions-controller.tsx") + expect(s).toContain('from "./home-session-groups"') + expect(s).toMatch(/scopeAll: !home\.project\.selected\(\)/) + }) + + test("the controller no longer drops sessions whose project cannot be resolved", () => { + const s = src("pages/home/home-sessions-controller.tsx") + // The old controller ended its record builder with `if (!project) return []` + // — the invisible-group incident's client-side shape. The carried builder + // synthesizes a home instead. + expect(s).not.toMatch(/if \(!project\) return \[\]/) + }) +}) From 2c740b135b3376d022a2c5b1e8f2a46f8469d680 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sat, 5 Sep 2026 11:35:12 -0400 Subject: [PATCH 07/11] =?UTF-8?q?feat(app-overlay):=20the=20boot=20parity?= =?UTF-8?q?=20record=20=E2=80=94=20server-reported=20version=20vs=20releas?= =?UTF-8?q?e=20channel,=20fail-open=20(D3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #817 — the revised placement of D3: parity is checked CLIENT-side at boot using the server-reported version (from the health check the app already runs) against the canonical opencode release channel. Three outcomes — parity-ok | parity-drift | channel-unreachable — and the check fails OPEN on an unreachable channel while recording a DISTINCT outcome, so an assertion that never ran is never mistaken for one that passed (the lagging-hub incident). The record is logged, never a gate: enforcement of upgrading stays advisory for solo users; the check is not. --- .../overlay/packages/app/src/app.tsx | 15 +- .../packages/app/src/utils/boot-parity.ts | 91 ++++++++++++ .../extension/test/boot_parity_817.test.ts | 129 ++++++++++++++++++ .../test/boot_parity_wiring_817.test.ts | 27 ++++ 4 files changed, 261 insertions(+), 1 deletion(-) create mode 100644 packages/app-bundle/overlay/packages/app/src/utils/boot-parity.ts create mode 100644 packages/extension/test/boot_parity_817.test.ts create mode 100644 packages/extension/test/boot_parity_wiring_817.test.ts diff --git a/packages/app-bundle/overlay/packages/app/src/app.tsx b/packages/app-bundle/overlay/packages/app/src/app.tsx index e5f88051..a9cd8c88 100644 --- a/packages/app-bundle/overlay/packages/app/src/app.tsx +++ b/packages/app-bundle/overlay/packages/app/src/app.tsx @@ -69,6 +69,9 @@ import LegacyLayout from "@/pages/layout" import NewLayout from "@/pages/layout-new" import { ErrorPage } from "./pages/error" import { useCheckServerHealth } from "./utils/server-health" +// D3 (issue #817): the boot parity record — the server-reported version is +// asserted against the release channel and the three-outcome record logged. +import { recordBootParity } from "./utils/boot-parity" import { AmicodeSplash } from "@opencode-ai/ui/amicode-splash" import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route" import { createSessionLineage } from "@/pages/session/session-lineage" @@ -551,6 +554,7 @@ export function AppBaseProviders( function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean; startup?: Promise }>) { const server = useServer() const checkServerHealth = useCheckServerHealth() + const platform = usePlatform() const [checkMode, setCheckMode] = createSignal<"blocking" | "background">("blocking") @@ -565,7 +569,16 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean; start while (true) { const res = yield* Effect.promise(() => checkServerHealth(http)) - if (res.healthy) return true + if (res.healthy) { + // D3 (issue #817): the boot parity assertion rides the healthy + // health check — the same probe that reported the server's + // version. Surfaced, never a gate; fails open as + // channel-unreachable when the release channel is unreachable. + yield* Effect.promise(() => + recordBootParity({ serverVersion: res.version, fetcher: platform.fetch ?? globalThis.fetch }), + ) + return true + } if (checkMode() === "background" || type === "http") return false } }).pipe( diff --git a/packages/app-bundle/overlay/packages/app/src/utils/boot-parity.ts b/packages/app-bundle/overlay/packages/app/src/utils/boot-parity.ts new file mode 100644 index 00000000..c01297ac --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/utils/boot-parity.ts @@ -0,0 +1,91 @@ +// D3 client-side (spec spec-20260905-045114-session-device-lifecycle, revised +// placement), issue #817: at boot the client checks the SERVER-REPORTED +// version against the release channel and records the outcome. The check +// fails OPEN when the channel is unreachable, but records a distinct outcome +// — `parity-ok | parity-drift | channel-unreachable` — so an assertion that +// never ran is never mistaken for one that passed (the lagging-hub incident: +// a pre-amicode.21 hub ran two days while fixed releases existed). Pure and +// dependency-free so it unit-tests headless; the wiring supplies the channel. + +export type ParityOutcome = "parity-ok" | "parity-drift" | "channel-unreachable" + +export type ParityRecord = { + outcome: ParityOutcome + /** The hub's self-reported version, when it reported one. */ + serverVersion?: string + /** The channel's latest release version, when reachable. */ + channelVersion?: string + /** What went wrong, for channel-unreachable. */ + detail?: string +} + +const releaseIdentity = (version: string | undefined) => version?.trim().replace(/^v/, "") ?? "" + +/** The comparison core: both sides must be present and equal for parity-ok. + * Anything less — an unreported server version, an unreleasable channel — is + * drift, never an assumed ok. */ +export function parityOutcome(input: { serverVersion?: string; channelVersion?: string }): ParityOutcome { + const server = releaseIdentity(input.serverVersion) + const channel = releaseIdentity(input.channelVersion) + if (!server || !channel) return "parity-drift" + return server === channel ? "parity-ok" : "parity-drift" +} + +/** The channel is injectable: it resolves to the latest release version the + * client should expect, or throws when unreachable. */ +export async function checkParity(input: { + serverVersion?: string + channel: () => Promise +}): Promise { + let channelVersion: string | undefined + try { + channelVersion = await input.channel() + } catch (error) { + return { + outcome: "channel-unreachable", + serverVersion: input.serverVersion, + detail: error instanceof Error ? error.message : String(error), + } + } + return { + outcome: parityOutcome({ serverVersion: input.serverVersion, channelVersion }), + serverVersion: input.serverVersion, + channelVersion, + } +} + +/** The release channel the overlay boots against: canonical opencode's public + * GitHub releases (the overlay's upstream base). Public and tokenless; a + * non-200 or tagless response throws so the record stays honest. */ +export async function fetchCanonicalReleaseChannel(fetchImpl: typeof fetch): Promise { + const response = await fetchImpl("https://api.github.com/repos/anomalyco/opencode/releases/latest", { + headers: { Accept: "application/vnd.github+json" }, + }) + if (!response.ok) throw new Error(`channel HTTP ${response.status}`) + const body = (await response.json()) as { tag_name?: string } + const tag = body.tag_name + if (!tag) throw new Error("channel response carried no tag_name") + return tag +} + +/** The boot assertion (D3): check the server-reported version against the + * release channel and LOG the three-outcome record. Surfaced, never a gate — + * enforcement of upgrading is advisory for solo users; the check is not + * optional. */ +export async function recordBootParity(input: { + serverVersion?: string + fetcher: typeof fetch + log?: (line: string) => void +}): Promise { + const log = input.log ?? ((line: string) => console.info(`[parity] ${line}`)) + const record = await checkParity({ + serverVersion: input.serverVersion, + channel: () => fetchCanonicalReleaseChannel(input.fetcher), + }) + const parts = [record.outcome] + if (record.detail) parts.push(`(${record.detail})`) + if (record.serverVersion) parts.push(`server=${record.serverVersion}`) + if (record.channelVersion) parts.push(`channel=${record.channelVersion}`) + log(parts.join(" ")) + return record +} diff --git a/packages/extension/test/boot_parity_817.test.ts b/packages/extension/test/boot_parity_817.test.ts new file mode 100644 index 00000000..9b7721f1 --- /dev/null +++ b/packages/extension/test/boot_parity_817.test.ts @@ -0,0 +1,129 @@ +// Issue #817 — D3 client-side (spec spec-20260905-045114-session-device- +// lifecycle, revised placement): at boot the client checks the SERVER-REPORTED +// version against the release channel and records the outcome. The check +// fails OPEN when the channel is unreachable — but records a distinct +// outcome, so an assertion that never ran is never mistaken for one that +// passed: parity-ok | parity-drift | channel-unreachable. +import { describe, expect, test } from "vitest" +import { + parityOutcome, + checkParity, + recordBootParity, + fetchCanonicalReleaseChannel, +} from "../../app-bundle/overlay/packages/app/src/utils/boot-parity" + +describe("parityOutcome (the comparison core)", () => { + test("a server version matching the channel is parity-ok", () => { + expect(parityOutcome({ serverVersion: "v1.18.29", channelVersion: "v1.18.29" })).toBe("parity-ok") + }) + + test("comparison is on the release identity, tolerant of the v prefix and whitespace", () => { + expect(parityOutcome({ serverVersion: "1.18.29", channelVersion: "v1.18.29" })).toBe("parity-ok") + expect(parityOutcome({ serverVersion: " v1.18.29 ", channelVersion: "v1.18.29" })).toBe("parity-ok") + }) + + test("a server version differing from the channel is parity-drift (the lagging-hub incident)", () => { + expect(parityOutcome({ serverVersion: "v1.18.10-amicode.21", channelVersion: "v1.18.29" })).toBe("parity-drift") + }) + + test("a server that reports no version can never be asserted ok", () => { + expect(parityOutcome({ serverVersion: undefined, channelVersion: "v1.18.29" })).toBe("parity-drift") + expect(parityOutcome({ serverVersion: "", channelVersion: "v1.18.29" })).toBe("parity-drift") + }) + + test("a channel with no releasable version can never be asserted ok either", () => { + expect(parityOutcome({ serverVersion: "v1.18.29", channelVersion: undefined })).toBe("parity-drift") + expect(parityOutcome({ serverVersion: "v1.18.29", channelVersion: "" })).toBe("parity-drift") + }) +}) + +describe("checkParity (fail-open, three-outcome record)", () => { + test("an unreachable channel records channel-unreachable and fails open — never a fake ok", async () => { + const outcome = await checkParity({ + serverVersion: "v1.18.29", + channel: async () => { + throw new Error("HTTP 404") + }, + }) + expect(outcome.outcome).toBe("channel-unreachable") + expect(outcome.detail).toContain("404") + }) + + test("a reachable channel yields the compared outcome", async () => { + const drifted = await checkParity({ serverVersion: "v1.18.10", channel: async () => "v1.18.29" }) + expect(drifted.outcome).toBe("parity-drift") + expect(drifted.channelVersion).toBe("v1.18.29") + + const ok = await checkParity({ serverVersion: "v1.18.29", channel: async () => "v1.18.29" }) + expect(ok.outcome).toBe("parity-ok") + }) + + test("the record is a fact: it carries what was compared", async () => { + const record = await checkParity({ serverVersion: "v1.18.10", channel: async () => "v1.18.29" }) + expect(record).toMatchObject({ + outcome: "parity-drift", + serverVersion: "v1.18.10", + channelVersion: "v1.18.29", + }) + const unreachable = await checkParity({ + serverVersion: "v1.18.10", + channel: async () => { + throw new Error("no network") + }, + }) + expect(unreachable.serverVersion).toBe("v1.18.10") + expect(unreachable.channelVersion).toBeUndefined() + }) +}) + +describe("recordBootParity (the boot assertion: logged, never a gate)", () => { + test("records the outcome against the canonical release channel and logs it once", async () => { + const lines: string[] = [] + const record = await recordBootParity({ + serverVersion: "v1.18.10", + fetcher: (async () => + Response.json({ tag_name: "v1.18.29" })) as unknown as typeof fetch, + log: (line) => lines.push(line), + }) + expect(record.outcome).toBe("parity-drift") + expect(lines).toHaveLength(1) + expect(lines[0]).toContain("parity-drift") + expect(lines[0]).toContain("v1.18.10") + }) + + test("an unreachable channel logs channel-unreachable — never a silent pass", async () => { + const lines: string[] = [] + const record = await recordBootParity({ + serverVersion: "v1.18.29", + fetcher: (async () => new Response("nope", { status: 404 })) as unknown as typeof fetch, + log: (line) => lines.push(line), + }) + expect(record.outcome).toBe("channel-unreachable") + expect(lines[0]).toContain("channel-unreachable") + }) +}) + +describe("fetchCanonicalReleaseChannel", () => { + test("reads the latest release tag from the canonical releases endpoint", async () => { + const url = "https://api.github.com/repos/anomalyco/opencode/releases/latest" + let requested: string | undefined + const tag = await fetchCanonicalReleaseChannel((async (input: RequestInfo | URL) => { + requested = String(input) + return Response.json({ tag_name: "v1.18.29" }) + }) as unknown as typeof fetch) + expect(requested).toBe(url) + expect(tag).toBe("v1.18.29") + }) + + test("a non-200 channel response throws (the caller records unreachable)", async () => { + await expect( + fetchCanonicalReleaseChannel((async () => new Response("x", { status: 403 })) as unknown as typeof fetch), + ).rejects.toThrow("403") + }) + + test("a tagless channel response throws too", async () => { + await expect( + fetchCanonicalReleaseChannel((async () => Response.json({})) as unknown as typeof fetch), + ).rejects.toThrow("tag_name") + }) +}) diff --git a/packages/extension/test/boot_parity_wiring_817.test.ts b/packages/extension/test/boot_parity_wiring_817.test.ts new file mode 100644 index 00000000..67db478f --- /dev/null +++ b/packages/extension/test/boot_parity_wiring_817.test.ts @@ -0,0 +1,27 @@ +// Issue #817 — D3 wiring: the boot parity assertion rides the app's +// ConnectionGate health check (the same probe that reports the server's +// version) and logs the three-outcome record. Source-pinned per the repo's +// overlay-wiring idiom; the outcome machine is behavior-tested in +// boot_parity_817.test.ts. +import { describe, expect, test } from "vitest" +import { readFileSync } from "node:fs" +import { join } from "node:path" + +const overlay = (...p: string[]) => join(__dirname, "../../app-bundle/overlay/packages/app/src", ...p) +const src = (p: string) => readFileSync(overlay(p), "utf8") + +describe("boot parity wiring (D3: asserted at boot, fail-open, recorded)", () => { + test("ConnectionGate records parity on a healthy health check", () => { + const s = src("app.tsx") + expect(s).toContain('from "./utils/boot-parity"') + expect(s).toMatch(/recordBootParity\(\{ serverVersion: res\.version/) + }) + + test("the record is surfaced (logged), never thrown into the boot gate", () => { + const s = src("utils/boot-parity.ts") + // Fail-open: a channel error resolves to a channel-unreachable RECORD. + expect(s).toMatch(/outcome: "channel-unreachable"/) + // The log line names the outcome — an unreachable check is never rendered as ok. + expect(s).toMatch(/parts\.join\(" "\)/) + }) +}) From a07560c65fb4302b27c28cba489ae3dd167202a8 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sat, 5 Sep 2026 11:35:44 -0400 Subject: [PATCH 08/11] =?UTF-8?q?test(app-overlay):=20H1=20=E2=80=94=20the?= =?UTF-8?q?=20fresh-client=20boot=20harness=20(headless=20boot=20+=20hub?= =?UTF-8?q?=20request=20log)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #817 — port of the fork's 80a57acc8a adapted to the client-derived currency: boots the client's session-list layer against a seeded hub and asserts on the hub's request log — the same evidence that diagnosed #293. Owns the D2 criteria's fixtures: fetch-before-first-render (the boot order snapshot-hydrate → fetch-initiate → first-render, with the fetch initiated unconditionally — a persisted snapshot is never an authority), stale-client self-heal (seeded with the #293 webview-storage shape), tokenless-snapshot invalidation, out-of-band projection change, and the honest states after a resolved fetch. --- .../src/context/global-sync/h1-client-boot.ts | 158 ++++++++++++++++++ .../extension/test/h1_client_boot_817.test.ts | 98 +++++++++++ 2 files changed, 256 insertions(+) create mode 100644 packages/app-bundle/overlay/packages/app/src/context/global-sync/h1-client-boot.ts create mode 100644 packages/extension/test/h1_client_boot_817.test.ts diff --git a/packages/app-bundle/overlay/packages/app/src/context/global-sync/h1-client-boot.ts b/packages/app-bundle/overlay/packages/app/src/context/global-sync/h1-client-boot.ts new file mode 100644 index 00000000..6a57f130 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/context/global-sync/h1-client-boot.ts @@ -0,0 +1,158 @@ +// H1 — the fresh-client boot harness (#288's headless boot), issue #817. +// +// Boots the client's session-list layer against a seeded hub and asserts on +// the hub's request log — the same evidence that diagnosed #293. Headless by +// design (no DOM): it composes the real boot pieces — persisted-snapshot +// hydration, the unconditional boot fetch, bootCurrencyDecision (with the +// CLIENT-derived currency token), the honest sessionListState machine — in +// the boot order the D2 contract requires. The fetch-before-first-render and +// boot-self-heal criteria's tests wire through this fixture. +import type { Session } from "@opencode-ai/sdk/v2/client" +import { bootCurrencyDecision, toSnapshot, type SessionSnapshot } from "./session-snapshot" +import { sessionListState, type SessionListState } from "../../utils/session-list-state" + +export type HubRequest = { + endpoint: "session.list" + directory?: string + limit?: number + order?: string + cursor?: string + /** Monotonic tick — boot ordering assertions read this. */ + at: number +} + +export type SeededHub = { + /** The v2 session.list surface, shaped like the real client's. */ + api: { + list: (input: { directory: string; parentID: null; limit: number; order: "desc"; cursor?: string }) => Promise<{ + data: Session[] + cursor: { next?: string } + }> + } + /** Every request the hub saw, in order. */ + log: HubRequest[] + sessions: Session[] + /** The hub's self-reported version — the stamp the client's derived token carries. */ + version: string | undefined +} + +export function createSeededHub(input: { sessions: Session[]; version?: string; pageSize?: number }): SeededHub { + const log: HubRequest[] = [] + let tick = 0 + const pageSize = input.pageSize ?? input.sessions.length + // desc store: newest first. + const ordered = input.sessions.toReversed() + return { + log, + sessions: input.sessions, + version: input.version, + api: { + list: async (request) => { + const start = request.cursor ? Number(request.cursor) : 0 + const page = ordered.slice(start, start + (pageSize || input.sessions.length)) + const next = + start + page.length < input.sessions.length && page.length > 0 ? String(start + page.length) : undefined + log.push({ + endpoint: "session.list", + directory: request.directory, + limit: request.limit, + order: request.order, + cursor: request.cursor, + at: tick++, + }) + return { data: page, cursor: { next } } + }, + }, + } +} + +export type PersistedSessionStorage = { + read: (key: string) => SessionSnapshot | undefined + write: (key: string, snapshot: SessionSnapshot | undefined) => void +} + +export function memorySessionStorage(seed: Record = {}): PersistedSessionStorage { + const store = new Map(Object.entries(seed)) + return { + read: (key) => structuredClone(store.get(key)), + write: (key, snapshot) => { + if (snapshot === undefined) store.delete(key) + else store.set(key, structuredClone(snapshot)) + }, + } +} + +export type BootResult = { + /** Every session-list request the hub saw during the boot window. */ + sessionListRequests: HubRequest[] + /** The rendered projection after the boot fetch resolved. */ + rendered: Session[] + /** The session-list fetch was initiated before the list first rendered. */ + fetchedBeforeFirstRender: boolean + /** A seeded snapshot was proven stale (the #293 shape) and invalidated. */ + selfHealed: boolean + /** The honest list state after boot. */ + state: SessionListState +} + +/** The persisted snapshot store is per-workspace; the harness keys it by the + * booted directory (the real client namespaces this inside its workspace + * storage as the `session:snapshot` target). */ +const snapshotKey = (directory: string) => directory + +export async function bootClient(input: { + hub: SeededHub + storage: PersistedSessionStorage + directory: string +}): Promise { + const key = snapshotKey(input.directory) + const timeline: string[] = [] + + // Boot order per D2: hydrate the persisted snapshot (accelerator), initiate + // the session-list fetch BEFORE the list first renders, then render. + timeline.push("snapshot-hydrated") + const snapshot = input.storage.read(key) + + // The fetch is initiated unconditionally — a persisted snapshot is never an + // authority, so no boot may skip it (the founding #293 failure). + let fetchFailure: unknown + const pending = input.hub.api + .list({ directory: input.directory, parentID: null, limit: 100, order: "desc" }) + .then((response) => ({ response })) + .catch((error) => { + fetchFailure = error + return undefined + }) + timeline.push("fetch-initiated") + + // First render: the snapshot as accelerator, or the honest unfetched state. + timeline.push("first-render") + const firstRendered = snapshot?.sessions ?? [] + void firstRendered + + const settled = await pending + timeline.push("fetch-resolved") + if (fetchFailure !== undefined) throw fetchFailure + + const response = settled!.response + const decision = bootCurrencyDecision({ + snapshot, + response: { sessions: response.data }, + serverVersion: input.hub.version, + }) + // Invalidation is materialized by the overwrite: the fetched rows are the + // authority and the snapshot layer is re-primed from them. + input.storage.write(key, toSnapshot(response.data, decision.currency)) + + return { + sessionListRequests: input.hub.log.filter((request) => request.endpoint === "session.list"), + rendered: response.data, + // The criterion measured on the boot order: the fetch was initiated + // before the list first rendered — and the hub's log carries it. + fetchedBeforeFirstRender: + timeline.indexOf("fetch-initiated") < timeline.indexOf("first-render") && + input.hub.log.some((request) => request.endpoint === "session.list"), + selfHealed: decision.stale, + state: sessionListState({ fetched: true, count: response.data.length }), + } +} diff --git a/packages/extension/test/h1_client_boot_817.test.ts b/packages/extension/test/h1_client_boot_817.test.ts new file mode 100644 index 00000000..8473b5cf --- /dev/null +++ b/packages/extension/test/h1_client_boot_817.test.ts @@ -0,0 +1,98 @@ +// Issue #817 — H1, the fresh-client boot harness (#288's headless boot), +// ported from the fork reference with the client-derived currency: boots the +// client's session-list layer against a seeded hub and asserts on the hub's +// request log — the same evidence that diagnosed #293. +import { describe, expect, test } from "vitest" +import { bootClient, createSeededHub, memorySessionStorage } from "../../app-bundle/overlay/packages/app/src/context/global-sync/h1-client-boot" + +const session = (id: string, updated: number) => ({ + id, + directory: "/home", + projectID: "p1", + slug: id, + version: "test", + title: `Session ${id}`, + time: { created: updated, updated }, +}) + +const hubSessions = () => [session("ses_a", 100), session("ses_b", 200)] +const SERVER_VERSION = "v1.18.29" + +describe("H1 — the fresh-client boot harness (#288's headless boot, hub request log)", () => { + test("a fresh client boots with the session-list fetch initiated before the list first renders", async () => { + const hub = createSeededHub({ sessions: hubSessions(), version: SERVER_VERSION }) + const storage = memorySessionStorage() + + const boot = await bootClient({ hub, storage, directory: "/home" }) + + // The criterion is measured on the hub's request log, the same evidence + // that diagnosed #293: at least one session-list request in the boot + // window. + expect(boot.sessionListRequests.length).toBeGreaterThanOrEqual(1) + expect(boot.fetchedBeforeFirstRender).toBe(true) + // The fetched rows are adopted and the state is honest: a resolved fetch + // over a populated projection renders ready. + expect(boot.rendered.map((item) => item.id)).toEqual(["ses_b", "ses_a"]) + expect(boot.state).toBe("ready") + // The snapshot layer now holds the fetched rows plus the client-derived + // token (count 2, max 200, sum 300, hub build stamp). + const persisted = storage.read("/home") + expect(persisted?.currency).toBe("v1.2.200.300.v1.18.29") + expect(persisted?.sessions).toHaveLength(2) + }) + + test("a client seeded with the #293 stale-storage shape self-heals on boot", async () => { + const hub = createSeededHub({ sessions: hubSessions(), version: SERVER_VERSION }) + const storage = memorySessionStorage({ + "/home": { sessions: [session("ses_stale", 1)], currency: "v1.1.1.1.v1.18.10-amicode.21" }, + }) + + const boot = await bootClient({ hub, storage, directory: "/home" }) + + expect(boot.selfHealed).toBe(true) + // The stale snapshot never wins: the rendered list is the hub's. + expect(boot.rendered.map((item) => item.id)).toEqual(["ses_b", "ses_a"]) + expect(boot.state).toBe("ready") + // The persisted snapshot was re-primed from the server's truth. + expect(storage.read("/home")?.currency).toBe("v1.2.200.300.v1.18.29") + }) + + test("a tokenless persisted snapshot (the founding shape) is invalidated too", async () => { + const hub = createSeededHub({ sessions: [], version: SERVER_VERSION }) + const storage = memorySessionStorage({ "/home": { sessions: [session("ses_stale", 1)] } }) + + const boot = await bootClient({ hub, storage, directory: "/home" }) + + expect(boot.selfHealed).toBe(true) + expect(boot.rendered).toHaveLength(0) + // Honest states: a resolved fetch over an empty projection is genuinely + // empty — never "not yet fetched". + expect(boot.state).toBe("empty") + }) + + test("a fresh snapshot is not flagged as self-healed", async () => { + const rows = hubSessions() + const hub = createSeededHub({ sessions: rows, version: SERVER_VERSION }) + const storage = memorySessionStorage({ + "/home": { sessions: [...rows], currency: "v1.2.200.300.v1.18.29" }, + }) + + const boot = await bootClient({ hub, storage, directory: "/home" }) + + expect(boot.selfHealed).toBe(false) + expect(boot.rendered).toHaveLength(2) + }) + + test("a projection that changed out-of-band between boots flips the token and self-heals", async () => { + const hub = createSeededHub({ sessions: [session("ses_a", 100)], version: SERVER_VERSION }) + const storage = memorySessionStorage({ + "/home": { sessions: hubSessions(), currency: "v1.2.200.300.v1.18.29" }, + }) + + const boot = await bootClient({ hub, storage, directory: "/home" }) + + expect(boot.selfHealed).toBe(true) + expect(boot.rendered).toHaveLength(1) + expect(storage.read("/home")?.currency).toBe("v1.1.100.100.v1.18.29") + }) +}) From 64188691db340978d4b2027360a8855e230d4647 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sat, 5 Sep 2026 11:37:22 -0400 Subject: [PATCH 09/11] =?UTF-8?q?test(app-overlay):=20the=20session-list?= =?UTF-8?q?=20conformance=20suite=20=E2=80=94=20D6's=20drift=20gate=20beco?= =?UTF-8?q?mes=20per-harness=20contract=20testing=20(D6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #817 — the revised placement: the fork's protocol-level drift gate (packages/protocol session-list-semantics) becomes a CONFORMANCE suite the client carries. The client's expectations of session-list query semantics are declared (CANONICAL_SESSION_LIST_SEMANTICS: directory + parentID scoping, desc order, archived excluded, roots-only on parentID null, cursor-only pagination exhaustion) and probed against any harness's session.list — canonical opencode today, Telaio tomorrow. A semantic change without a companion update goes red (scoping/filtering/pagination drift probes fail); an additive optional field with a base default goes green (the D6 change policy, with the F5 additive-field registry declared). The pagination probe takes the fixture's expected row count — no black-box probe can distinguish a store that truly ended from a hub that stopped early on a short page. --- .../app/src/utils/session-list-conformance.ts | 189 ++++++++++++++++++ .../test/session_list_conformance_817.test.ts | 150 ++++++++++++++ 2 files changed, 339 insertions(+) create mode 100644 packages/app-bundle/overlay/packages/app/src/utils/session-list-conformance.ts create mode 100644 packages/extension/test/session_list_conformance_817.test.ts diff --git a/packages/app-bundle/overlay/packages/app/src/utils/session-list-conformance.ts b/packages/app-bundle/overlay/packages/app/src/utils/session-list-conformance.ts new file mode 100644 index 00000000..e5d07016 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/utils/session-list-conformance.ts @@ -0,0 +1,189 @@ +// D6 (spec spec-20260905-045114-session-device-lifecycle), issue #817 — the +// revised placement: the fork-era session-list drift gate becomes a +// per-harness CONTRACT CONFORMANCE suite. The client's expectations of +// session-list query semantics (scoping keys, filter fields, defaults, +// ordering, pagination) are DECLARED below and probed against any harness's +// session.list — canonical opencode today, Telaio tomorrow. A harness that +// changes what a query MEANS without a companion update goes red; an additive +// optional field with a base default goes green (the D6 change policy — the +// rule premium needs ride like anyone else's). +// +// Pure and dependency-free so it runs headless in any suite and against any +// backend adapter. +import type { Session } from "@opencode-ai/sdk/v2/client" + +/** The shape the probes need from a harness's session rows (a subset of the + * client's Session). */ +export type ConformanceSession = Pick + +/** The client's declared session-list expectations for the canonical opencode + * harness (v2). The founding incident (2026-09-05): the v1 route's semantics + * changed directory-filtered → project-scoped between builds and correct + * queries silently started returning empty answers. */ +export const CANONICAL_SESSION_LIST_SEMANTICS = { + harness: "opencode-canonical", + endpoint: "v2.session.list", + /** Scoping keys the harness must honor on every list request. */ + scoping: ["directory", "parentID"] as const, + ordering: { + /** The default order the client requests and relies on. */ + default: "desc", + }, + filtering: { + /** Archived sessions are absent from the default projection (D4). */ + archivedExcludedFromDefault: true, + /** `parentID: null` returns roots only. */ + rootsOnlyWhenParentIDNull: true, + }, + pagination: { + /** The ONLY exhaustion signal: a missing continuation cursor. A page + * shorter than the requested limit does not end the store. */ + exhaustionSignal: "cursor", + }, + /** F5's additive-field registry: optional response fields the client reads + * structurally and tolerates in absence (base defaults). Semantic fields + * may only land here. */ + additiveOptionalFields: [ + { + name: "currency", + kind: "derived-token", + baseDefault: "absent", + note: "The client derives its own list-currency token (session-currency.ts); a server field would be an optional richer surface (Harness Contract vNext), never a requirement.", + }, + ] as const, +} + +export type SessionListProbe = { + name: string + state: "pass" | "fail" + detail?: string +} + +export type ConformanceReport = { + harness: string + passed: boolean + probes: SessionListProbe[] +} + +type ListInput = { directory: string; parentID?: null; limit: number; order?: "asc" | "desc"; cursor?: string } + +type SessionListHarness = (input: ListInput) => Promise + +type Page = { data?: ConformanceSession[]; cursor?: { next?: string } } + +const asPage = (response: unknown): Page => response as Page + +async function probe(name: string, run: () => Promise): Promise { + try { + await run() + return { name, state: "pass" } + } catch (error) { + return { name, state: "fail", detail: error instanceof Error ? error.message : String(error) } + } +} + +function expect(condition: unknown, message: string) { + if (!condition) throw new Error(message) +} + +/** Run every semantic probe against one harness's session.list. Each probe is + * a minimal experiment: the harness sees a crafted request and the probe + * inspects the response for the SEMANTIC (not the data). `expectedSessionCount` + * is the fixture's ground truth (the caller built the harness) — the + * pagination probe needs it, because no black-box probe can distinguish a + * store that truly ended from a hub that stopped early on a short page. */ +export async function runSessionListConformance(input: { + harness: string + list: SessionListHarness + /** The directory the probes scope requests to. */ + directory?: string + /** How many sessions the store holds for the probe directory (default + * projection — archived excluded). The pagination probe asserts the walk + * collects exactly this many rows. */ + expectedSessionCount?: number +}): Promise { + const directory = input.directory ?? "/conformance-home" + + const ordering = await probe("ordering.newest-first", async () => { + const page = asPage( + await input.list({ directory, parentID: null, limit: 50, order: "desc" }), + ) + const rows = page.data ?? [] + for (let i = 1; i < rows.length; i++) { + const prev = rows[i - 1]!.time.updated ?? rows[i - 1]!.time.created + const curr = rows[i]!.time.updated ?? rows[i]!.time.created + expect(prev >= curr, `row ${i} is newer than its predecessor — desc order not honored`) + } + }) + + const scoping = await probe("scoping.directory", async () => { + const page = asPage( + await input.list({ directory, parentID: null, limit: 50, order: "desc" }), + ) + for (const row of page.data ?? []) { + expect( + row.directory === directory, + `session ${row.id} belongs to ${row.directory} — the directory filter is not honored`, + ) + } + }) + + const archived = await probe("filtering.archived-excluded", async () => { + const page = asPage( + await input.list({ directory, parentID: null, limit: 50, order: "desc" }), + ) + for (const row of page.data ?? []) { + expect( + row.time.archived === undefined || row.time.archived === null, + `session ${row.id} is archived but leaked into the default projection`, + ) + } + }) + + const roots = await probe("filtering.roots-only", async () => { + const page = asPage( + await input.list({ directory, parentID: null, limit: 50, order: "desc" }), + ) + for (const row of page.data ?? []) { + expect(!row.parentID, `session ${row.id} has parentID ${row.parentID} — parentID:null did not scope to roots`) + } + }) + + const pagination = await probe("pagination.cursor-exhaustion", async () => { + // The harness must keep serving continuation cursors until the store is + // exhausted — including from pages shorter than the requested limit. The + // client walks cursors and would silently drop rows past the first short + // page otherwise (a hub that caps page size below the request, or a + // page-fullness exhaustion rule). A big requested limit is deliberate: + // every page is short relative to it, so ONLY cursor discipline walks the + // whole store. + const limit = 10_000 + const seen = new Set() + let cursor: string | undefined + let requests = 0 + for (;;) { + const page = asPage( + await input.list({ directory, parentID: null, limit, order: "desc", ...(cursor ? { cursor } : {}) }), + ) + requests++ + expect(requests < 100, "the harness never exhausted the store (runaway continuation)") + for (const row of page.data ?? []) { + expect(!seen.has(row.id), `session ${row.id} was served twice across pages`) + seen.add(row.id) + } + const next = page.cursor?.next + if (!next) break + cursor = next + } + expect(requests > 0, "the harness served no page") + if (input.expectedSessionCount !== undefined) { + expect( + seen.size === input.expectedSessionCount, + `the walk collected ${seen.size} of ${input.expectedSessionCount} sessions — the harness ended the fetch early (page fullness instead of cursor exhaustion)`, + ) + } + }) + + const probes = [ordering, scoping, archived, roots, pagination] + return { harness: input.harness, passed: probes.every((p) => p.state === "pass"), probes } +} diff --git a/packages/extension/test/session_list_conformance_817.test.ts b/packages/extension/test/session_list_conformance_817.test.ts new file mode 100644 index 00000000..6068d451 --- /dev/null +++ b/packages/extension/test/session_list_conformance_817.test.ts @@ -0,0 +1,150 @@ +// Issue #817 — D6 (spec spec-20260905-045114-session-device-lifecycle), the +// revised placement: the fork-era drift gate becomes a per-harness CONTRACT +// CONFORMANCE suite. The client's expectations of session-list query +// semantics are DECLARED here and probed against any harness's session.list — +// canonical opencode today, Telaio tomorrow. A harness that changes what a +// query MEANS (scoping keys, filters, defaults, ordering, pagination) without +// a companion update goes red; an additive optional field with a base default +// goes green. +import { describe, expect, test } from "vitest" +import { + CANONICAL_SESSION_LIST_SEMANTICS, + runSessionListConformance, + type ConformanceSession, +} from "../../app-bundle/overlay/packages/app/src/utils/session-list-conformance" + +const session = (id: string, updated: number, extra: Partial = {}): ConformanceSession => ({ + id, + directory: "/conformance-home", + parentID: undefined, + time: { created: updated, updated }, + ...extra, +}) + +/** A harness whose store behaves like canonical opencode's v2 session.list: + * directory-scoped, roots-only on parentID null, archived excluded from the + * default projection, desc order, continuation cursors — and it may cap page + * size below the requested limit. */ +function canonicalHarness(store: ConformanceSession[], opts: { pageSize?: number; additiveField?: boolean } = {}) { + const pageSize = opts.pageSize ?? store.length + return async (input: { directory: string; parentID?: null; limit: number; order?: "asc" | "desc"; cursor?: string }) => { + let rows = store.filter((s) => s.directory === input.directory) + if (input.parentID === null) rows = rows.filter((s) => !s.parentID) + rows = rows.filter((s) => s.time.archived === undefined) + rows.sort((a, b) => (input.order === "asc" ? a.time.updated - b.time.updated : b.time.updated - a.time.updated)) + const start = input.cursor ? Number(input.cursor) : 0 + const page = rows.slice(start, start + pageSize) + const next = start + page.length < rows.length && page.length > 0 ? String(start + page.length) : undefined + const body: Record = { data: page, cursor: { next } } + if (opts.additiveField) body.currency = "v1.2.200.300.v1.18.29" + return body as never + } +} + +describe("the session-list conformance suite (D6: declared semantics, probed per harness)", () => { + test("the canonical semantics are declared (the client's expectations, not the server's)", () => { + expect(CANONICAL_SESSION_LIST_SEMANTICS.harness).toBe("opencode-canonical") + expect(CANONICAL_SESSION_LIST_SEMANTICS.endpoint).toBe("v2.session.list") + expect(CANONICAL_SESSION_LIST_SEMANTICS.scoping).toContain("directory") + expect(CANONICAL_SESSION_LIST_SEMANTICS.ordering.default).toBe("desc") + expect(CANONICAL_SESSION_LIST_SEMANTICS.pagination.exhaustionSignal).toBe("cursor") + expect(CANONICAL_SESSION_LIST_SEMANTICS.filtering.archivedExcludedFromDefault).toBe(true) + expect(CANONICAL_SESSION_LIST_SEMANTICS.filtering.rootsOnlyWhenParentIDNull).toBe(true) + }) + + test("a canonical-shaped harness passes every probe", async () => { + const list = canonicalHarness([ + session("s1", 100), + session("s2", 200, { parentID: "s1" }), + session("s3", 300, { directory: "/other", parentID: "x" }), + session("s4", 400, { time: { created: 400, updated: 400, archived: 500 } }), + ]) + const report = await runSessionListConformance({ + harness: "opencode-canonical", + list, + // The default projection the client renders: roots, archived excluded — + // s3 (another directory) and s4 (archived) are not among them. + expectedSessionCount: 1, + }) + expect(report.passed).toBe(true) + expect(report.probes.map((p) => p.name)).toEqual([ + "ordering.newest-first", + "scoping.directory", + "filtering.archived-excluded", + "filtering.roots-only", + "pagination.cursor-exhaustion", + ]) + expect(report.probes.every((p) => p.state === "pass")).toBe(true) + }) + + test("a semantic change to scoping fails the drift gate (directory filter ignored)", async () => { + const drifted = async (input: { directory: string; limit: number; order?: string; cursor?: string }) => { + // The founding incident: the route stopped honoring the directory filter. + const rows = [{ ...session("s3", 300), directory: "/other" }] + return { data: rows, cursor: {} } as never + } + const report = await runSessionListConformance({ harness: "drifted", list: drifted }) + expect(report.passed).toBe(false) + expect(report.probes.find((p) => p.name === "scoping.directory")?.state).toBe("fail") + }) + + test("a semantic change to filtering fails the gate (archived leaks into the default projection)", async () => { + const leaky = async (input: { directory: string; limit: number; order?: string; cursor?: string }) => { + const rows = [session("s1", 100), session("s4", 400, { time: { created: 400, updated: 400, archived: 500 } })] + return { data: rows, cursor: {} } as never + } + const report = await runSessionListConformance({ harness: "leaky", list: leaky }) + expect(report.probes.find((p) => p.name === "filtering.archived-excluded")?.state).toBe("fail") + expect(report.passed).toBe(false) + }) + + test("a semantic change to pagination fails the gate (page-fullness exhaustion silently drops rows)", async () => { + // A hub that ends the fetch when a page comes back shorter than the + // REQUESTED limit, while its store holds more rows: the fixture's store + // has 6 sessions, the hub caps pages at 2 — the first short page ends the + // walk and rows 3–6 are silently dropped. + const store = Array.from({ length: 6 }, (_, index) => session(`s${index + 1}`, 100 + index)) + const fullnessExhausted = async (input: { directory: string; limit: number; cursor?: string }) => { + const start = input.cursor ? Number(input.cursor) : 0 + const page = store.slice(start, start + 2) + // The broken rule: a page shorter than the REQUESTED limit ends the + // store — with the client requesting 10000, the very first (capped) + // page ends the walk. + const next = page.length === input.limit ? String(start + 2) : undefined + return { data: page, cursor: { next } } as never + } + const report = await runSessionListConformance({ + harness: "fullness", + list: fullnessExhausted, + expectedSessionCount: 6, + }) + expect(report.probes.find((p) => p.name === "pagination.cursor-exhaustion")?.state).toBe("fail") + expect(report.passed).toBe(false) + }) + + test("D6's change policy: an additive optional field with a base default goes green", async () => { + const store = [session("s1", 100)] + const plain = await runSessionListConformance({ + harness: "opencode-canonical", + list: canonicalHarness(store), + expectedSessionCount: 1, + }) + const additive = await runSessionListConformance({ + harness: "opencode-canonical", + list: canonicalHarness(store, { additiveField: true }), + expectedSessionCount: 1, + }) + expect(plain.passed).toBe(true) + expect(additive.passed).toBe(true) + }) + + test("a probe error is a failure, not a crash — the suite reports what broke", async () => { + const exploding = async () => { + throw new Error("hub 500") + } + const report = await runSessionListConformance({ harness: "exploding", list: exploding as never }) + expect(report.passed).toBe(false) + expect(report.probes.every((p) => p.state === "fail")).toBe(true) + expect(report.probes[0]!.detail).toContain("hub 500") + }) +}) From 6b1c78ac3e768514f3d5fa6320d8d464084c7c84 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sat, 5 Sep 2026 11:37:29 -0400 Subject: [PATCH 10/11] chore(app-bundle): manifest + drift-report for the re-homed overlay files; add the missed snapshot behavior test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #817 overlay additions (session-currency, session-snapshot, session-list-state, boot-parity, session-list-conformance, home-session-groups, h1-client-boot, the carried true overlays) registered via refresh_manifest.mjs against upstream v1.18.29; the boot-currency decision's behavior suite (session_snapshot_817.test.ts) lands with it — it was authored in the TDD loop but left unstaged at its commit boundary. --- packages/app-bundle/drift-report.json | 21 +++- packages/app-bundle/manifest.json | 86 ++++++++----- .../test/session_snapshot_817.test.ts | 116 ++++++++++++++++++ 3 files changed, 189 insertions(+), 34 deletions(-) create mode 100644 packages/extension/test/session_snapshot_817.test.ts diff --git a/packages/app-bundle/drift-report.json b/packages/app-bundle/drift-report.json index 951f2f9b..ad8fcbcf 100644 --- a/packages/app-bundle/drift-report.json +++ b/packages/app-bundle/drift-report.json @@ -5,10 +5,10 @@ "tag": "v1.18.29", "upstream_tree": "archive", "counts": { - "overlay_total": 576, - "added": 298, - "modified": 278, - "unchanged": 0, + "overlay_total": 589, + "added": 305, + "modified": 283, + "unchanged": 1, "deletions_active": 7, "deletions_stale": 0 }, @@ -184,10 +184,12 @@ "packages/app/src/utils/amicode-route-info.ts": "added", "packages/app/src/utils/amicode-workspace-projects.test.ts": "added", "packages/app/src/utils/amicode-workspace-projects.ts": "added", + "packages/app/src/utils/boot-parity.ts": "added", "packages/app/src/utils/chrome-dropdown.ts": "added", "packages/app/src/utils/global-clipboard.test.ts": "added", "packages/app/src/utils/global-clipboard.ts": "added", "packages/app/src/utils/pane-bridge.ts": "added", + "packages/app/src/utils/path-key.ts": "unchanged", "packages/app/src/utils/project-type-helpers.test.ts": "added", "packages/app/src/utils/project-type-helpers.ts": "added", "packages/app/src/utils/provider-disconnect.test.ts": "added", @@ -195,6 +197,8 @@ "packages/app/src/utils/serialize-session.test.ts": "added", "packages/app/src/utils/serialize-session.ts": "added", "packages/app/src/utils/server-compat.ts": "modified", + "packages/app/src/utils/session-list-conformance.ts": "added", + "packages/app/src/utils/session-list-state.ts": "added", "packages/app/src/utils/start-prompt.ts": "added", "packages/app/src/utils/web-zoom.test.ts": "added", "packages/app/src/utils/web-zoom.ts": "added", @@ -489,11 +493,20 @@ "packages/app/src/context/global-sync/child-store.ts": "modified", "packages/app/src/context/global-sync/event-reducer.test.ts": "modified", "packages/app/src/context/global-sync/event-reducer.ts": "modified", + "packages/app/src/context/global-sync/h1-client-boot.ts": "added", + "packages/app/src/context/global-sync/home-session-index.ts": "modified", "packages/app/src/context/global-sync/session-cache.test.ts": "modified", "packages/app/src/context/global-sync/session-cache.ts": "modified", + "packages/app/src/context/global-sync/session-currency.ts": "added", + "packages/app/src/context/global-sync/session-snapshot.ts": "added", + "packages/app/src/context/global-sync/session-trim.ts": "modified", "packages/app/src/context/global-sync/types.ts": "modified", + "packages/app/src/context/global-sync/utils.ts": "modified", "packages/app/src/pages/home/home-projects-view.tsx": "modified", + "packages/app/src/pages/home/home-session-groups.ts": "added", + "packages/app/src/pages/home/home-sessions-controller.tsx": "modified", "packages/app/src/pages/home/home-sessions-view.tsx": "modified", + "packages/app/src/pages/home/home-sessions.tsx": "modified", "packages/app/src/pages/layout/helpers.test.ts": "modified", "packages/app/src/pages/layout/helpers.ts": "modified", "packages/app/src/pages/layout/project-avatar-state.ts": "modified", diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index 90ab0374..4445d491 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -6,11 +6,11 @@ "fork_sha": "d161eb0cfc6d03a53311e083b590005b4161c13a", "upstream_base": "v1.18.29", "upstream_base_sha": "16747470f976aca3d362ad730bcd3fe82ecc2c9a", - "extracted_at": "2026-09-05T05:30:14.193Z", + "extracted_at": "2026-09-05T15:37:24.487Z", "per_package": { "packages/app": { - "A": 124, - "M": 148, + "A": 132, + "M": 153, "D": 0 }, "packages/core": { @@ -40,7 +40,7 @@ } }, "counts": { - "overlay_total": 576, + "overlay_total": 589, "deletions": 7, "server_coupled": 47 }, @@ -515,10 +515,12 @@ "packages/app/src/utils/amicode-route-info.ts": "A", "packages/app/src/utils/amicode-workspace-projects.test.ts": "A", "packages/app/src/utils/amicode-workspace-projects.ts": "A", + "packages/app/src/utils/boot-parity.ts": "A", "packages/app/src/utils/chrome-dropdown.ts": "A", "packages/app/src/utils/global-clipboard.test.ts": "A", "packages/app/src/utils/global-clipboard.ts": "A", "packages/app/src/utils/pane-bridge.ts": "A", + "packages/app/src/utils/path-key.ts": "A", "packages/app/src/utils/project-type-helpers.test.ts": "A", "packages/app/src/utils/project-type-helpers.ts": "A", "packages/app/src/utils/provider-disconnect.test.ts": "A", @@ -526,6 +528,8 @@ "packages/app/src/utils/serialize-session.test.ts": "A", "packages/app/src/utils/serialize-session.ts": "A", "packages/app/src/utils/server-compat.ts": "M", + "packages/app/src/utils/session-list-conformance.ts": "A", + "packages/app/src/utils/session-list-state.ts": "A", "packages/app/src/utils/start-prompt.ts": "A", "packages/app/src/utils/web-zoom.test.ts": "A", "packages/app/src/utils/web-zoom.ts": "A", @@ -820,11 +824,20 @@ "packages/app/src/context/global-sync/child-store.ts": "M", "packages/app/src/context/global-sync/event-reducer.test.ts": "M", "packages/app/src/context/global-sync/event-reducer.ts": "M", + "packages/app/src/context/global-sync/h1-client-boot.ts": "A", + "packages/app/src/context/global-sync/home-session-index.ts": "M", "packages/app/src/context/global-sync/session-cache.test.ts": "M", "packages/app/src/context/global-sync/session-cache.ts": "M", + "packages/app/src/context/global-sync/session-currency.ts": "A", + "packages/app/src/context/global-sync/session-snapshot.ts": "A", + "packages/app/src/context/global-sync/session-trim.ts": "M", "packages/app/src/context/global-sync/types.ts": "M", + "packages/app/src/context/global-sync/utils.ts": "M", "packages/app/src/pages/home/home-projects-view.tsx": "M", + "packages/app/src/pages/home/home-session-groups.ts": "A", + "packages/app/src/pages/home/home-sessions-controller.tsx": "M", "packages/app/src/pages/home/home-sessions-view.tsx": "M", + "packages/app/src/pages/home/home-sessions.tsx": "M", "packages/app/src/pages/layout/helpers.test.ts": "M", "packages/app/src/pages/layout/helpers.ts": "M", "packages/app/src/pages/layout/project-avatar-state.ts": "M", @@ -932,7 +945,7 @@ "packages/ui/package.json": "b1d168d0371e9094faae1107fc6c00be197f09bc69daa2247a3890d607f4b629", "packages/app/public/amico.svg": "a14b9d543d895bcdf0758f7b9ef5908ee0acaac794446494af059b159247db8f", "packages/app/public/oc-theme-preload.js": "27227e802b3494e7c545da903e679efdb30ccc754cd4eb5cdf08005a40d560b6", - "packages/app/src/app.tsx": "6c6cb948e0486aef526e3326f214de0ef55106f6bf77154a5dd6b4212a296265", + "packages/app/src/app.tsx": "f5ce666a191461f4d36daa0c88700ea72da77a1691fe9905bdc58af76bee970d", "packages/app/src/design-polish.css": "42cc6efaefe9a71dedd12fcb0bf2549453d9a46097cb025cf087064b29d3ccce", "packages/app/src/entry.tsx": "f35e1017f4c9d478d254b2a38043e5750064b6bef25169c3e07ae9f72ff1049c", "packages/app/src/index.css": "08179e06ce2d419a2d98acc96025f91c7709062ea9f3ad245e88dc35e75ff9f7", @@ -1041,8 +1054,8 @@ "packages/app/src/context/models.tsx": "8904525767b36ed5ba93e85a0fddb9bf737a8d066dfff084380ce1a67182f535", "packages/app/src/context/platform.tsx": "edd9b1773feb5c084b922a57b6914f6a473eed558b44f9de0eeb1c4d4e2b49da", "packages/app/src/context/server-sdk.test.ts": "5cd22962c8ea1e4374915508a765281ceb9f808ad8bbf241c06b00b48621c816", - "packages/app/src/context/server-sdk.tsx": "b3450d9bdafde6ed1a95d7dc970fc9f33bef81531b05245c36b789878d09d186", - "packages/app/src/context/server-sync.tsx": "43154ffe6227203d778378d190af3c46d7f49098619721fea5e6263e8794dc84", + "packages/app/src/context/server-sdk.tsx": "cbc9203a75ce04d2dd435da256baba8927748e53ba4dc5843373fd6720ee16b3", + "packages/app/src/context/server-sync.tsx": "58c176d333a20027d5281ba8386308e29e9ed4291070ffe2f6ccc1eea857bd7d", "packages/app/src/context/server.tsx": "08e978e4b4b3094221a2a0b92e069f39cbedb6d6cf95935881e21cde52265ee2", "packages/app/src/context/settings.tsx": "050f1831173d9f337bb00d54c034670553cd22bb457c7b536e4d799eb35de8a3", "packages/app/src/context/split.tsx": "891aeb290b369b327a63ce548dcaab1daca08e41fab430648787be7f84e53ec0", @@ -1050,26 +1063,26 @@ "packages/app/src/context/vault-panel.ts": "f91cb5fb49a9f0d46bd2a1b131f9a218b30147e2ad3617d2a2dc4f1e14599a53", "packages/app/src/context/workbench.tsx": "69a44da2b69989bd2c67ebffdb49941841f3f4718d6a70d659c828b57928d9eb", "packages/app/src/context/zoom-keybind.test.ts": "a1385c93639687a7dac7d21f8f02841d82a55e42465ce3836b97b16c82b52469", - "packages/app/src/i18n/ar.ts": "feca57fdbf07f5fd797a249b3f4d318adcee320bdfbf4c2f6dcaf08f430a1916", - "packages/app/src/i18n/br.ts": "339afc9f492288cef5a340aa03e583319ecc5aaf5e4a6f5ea26f94fbb961535a", - "packages/app/src/i18n/bs.ts": "d60e8b4704a8422d55df67ff2da47f331646a2edb57da5f416043cb38f09004a", - "packages/app/src/i18n/da.ts": "a50390b9d8c38c4250c17c1623f018ba12293505f39a31ec941fa5bb6fd83769", - "packages/app/src/i18n/de.ts": "80b8f4ff421c227b5259fed870cce12317f7bec627c1e1c38f525d9122158424", + "packages/app/src/i18n/ar.ts": "68f923f8ab6dd0e3d0a864999d492a7d00cd1cf05e53f8c3d492c83eed98939c", + "packages/app/src/i18n/br.ts": "28f180e8396f45103d46266c3f1877acb875954b11cde067965ba7d16fda2e32", + "packages/app/src/i18n/bs.ts": "96fa82c448b247e6aeab3b7a7e8995676f94b0fc75f0a5d2f72a442c8debc5f4", + "packages/app/src/i18n/da.ts": "c704caad2f227ef4d498cccfc776ecd921417c11995268823c01cd7f02efa92e", + "packages/app/src/i18n/de.ts": "6415bd824266738033834aa40a1da7303023b142372a63c65dfc37578135032d", "packages/app/src/i18n/desktop-native.ts": "8fed66d36a0b6cdbf50e3a50f14b6f8ab3570e342e0905e5a926d7dc6c57f8fa", - "packages/app/src/i18n/en.ts": "69e3cd58cc0f6ecb322ef97e85e1a034e6a5706057c90bab67f6d4c41666c2ea", - "packages/app/src/i18n/es.ts": "aa522d3aaef056f8eed14ae41c9a0af207a271b2dfd32bde3ec0b6e337adf904", - "packages/app/src/i18n/fr.ts": "43400c7c7c3fb90fd53e05cd6a716816ffc44781c5adf534a3418e2595230efb", - "packages/app/src/i18n/ja.ts": "ee56913545adec0552aac43195a8a515677c8b9549f1b23b2075946cb14b68a6", - "packages/app/src/i18n/ko.ts": "c9f8ff5f7ef94c9b7930c7daf405a6ff98fb864bb3ee3d344c0baa6196ca860e", - "packages/app/src/i18n/no.ts": "680edde7a34098a3090b33662a8a90bab79d6dee5bcceda0c2baf8614201f404", + "packages/app/src/i18n/en.ts": "1b50a07d9b0c1a33ecf0b0ef3ad6d2f5fc848299932c60d375aeabe882f622e2", + "packages/app/src/i18n/es.ts": "e3aa8767cac135535d1f21a677e538132932bf5423a34154bf5c1866df28d55c", + "packages/app/src/i18n/fr.ts": "0831853363a98f8a7ee9dbee2f188bd8004a5a00451fe41d2043cc9d1dafe1bf", + "packages/app/src/i18n/ja.ts": "0ea8e27a148c9cc21e1e955f9f1f6d78246bc68639d81981079cb228ed3a1756", + "packages/app/src/i18n/ko.ts": "efba923147c52f9e38e2cd49c62a66124c8f4266eafc7d38d36866bd662b0c62", + "packages/app/src/i18n/no.ts": "37e5729c23817a781a9db6c9e158a542f3d5e8101d9c04a303e17bf50fe63a0d", "packages/app/src/i18n/parity.test.ts": "3a05e1bc47321d4a00ae2ae898104e7de5398170e74f888d2f8a0f6e259866e6", - "packages/app/src/i18n/pl.ts": "807f2413d30946dcb23aa107c4f56d38a62e21ae5bf2ac8b28de89e4031a9fa9", - "packages/app/src/i18n/ru.ts": "4a25cf8324a9cfd870c7150c0b6d9276b511cddf4a0d568f9aecc7b014e55217", - "packages/app/src/i18n/th.ts": "5f507ba4f6487daf3ed7ca7c2efec7e2c249070bce72c58897b239f14b4012a8", - "packages/app/src/i18n/tr.ts": "b606bcbe537df88b5c3946bd48031ce29a36af5422b5ff7354aa0b1cfdc408ec", - "packages/app/src/i18n/uk.ts": "3e4ee340c269f71d7335199bbe756280586fc32e9203928c53136717af000e2f", - "packages/app/src/i18n/zh.ts": "9ac5ca7e0ec735e0a2919f7206b9ce35f8d69c428b00d5edc6e7bc52eac373ff", - "packages/app/src/i18n/zht.ts": "4e8d9682bee8e8770ad91214fabc286014d52fa2b230cef42935e9829ff350b2", + "packages/app/src/i18n/pl.ts": "871f67b5365a2878896440cd2769f5772ca7c416d5eb66856694219cb14f7adf", + "packages/app/src/i18n/ru.ts": "45f496c5b772280994c2d916872e298bdd589182d1b128e7e4f81448826f28cc", + "packages/app/src/i18n/th.ts": "fd1e18e1c011ae94edbf54e6fcff4eb65afe6ce2e40710bbfa509c3459fc4be7", + "packages/app/src/i18n/tr.ts": "0aa8c4ddf847965d98d047c5a3f07cbb65324d2f258bf5f5744c1e5b40f7f173", + "packages/app/src/i18n/uk.ts": "45377c35b7fa018ba3c5b47af90aaf5409a243b0908e3b0454cbe5e584651610", + "packages/app/src/i18n/zh.ts": "a628cdf15c0af7fb9eda4b08eb863f7838afb3dc554cf6b84db76323077d91b9", + "packages/app/src/i18n/zht.ts": "8fe661dc1d53288d03045644fdc6f32c73202777a4effdb64680ef8fe8738ee2", "packages/app/src/pages/error.tsx": "db9ba0847cfb205100ad6133cce30c6ea2ce983802af44947e6d2b746c2a6ec5", "packages/app/src/pages/home-projects.test.ts": "a90155c37210a0e5b7a095bbfc3ba2d40681f02927d9726148a706bed5ba7891", "packages/app/src/pages/home-projects.ts": "d55124578dc839b98e2084b488d818e30e4a1abf1ae264499b83e403c2aabca7", @@ -1077,7 +1090,7 @@ "packages/app/src/pages/home-session-archive.ts": "1b56c173f37f4a4dfd54088ef5fdb0dbcd3d9b62aeb08de4bfa0ed34f07d7ad5", "packages/app/src/pages/home.tsx": "06fb5c7718aacfd29b35e730e05d45076b39d6cd5d936d1270fb1bd9424c32fa", "packages/app/src/pages/layout-new.tsx": "86e910dcf29536232562ea2652062edcc63be48e7507db85d50d1fd7b0b6ea1d", - "packages/app/src/pages/layout.tsx": "a6e6574054f6d85c0d91c1236c9db99674b8d51876643896b0c34f77a28bc85a", + "packages/app/src/pages/layout.tsx": "1b54fa7664ef47ade184822f8501f6d82be380410bb74ba7c029be5252f0f2e3", "packages/app/src/pages/new-session-landing.test.ts": "c0f47e4096e6a20fa59dac2b10f7285d3ef45e9289bf6f0ad75c1d9e4dd1117f", "packages/app/src/pages/new-session-landing.ts": "528d27cd5d441b521674328fb7812fb2acf77233e9e436d081c98ce611e8b268", "packages/app/src/pages/new-session.tsx": "5a9c699ab5579ab4ff14e67fa8696bf4d941c6e4ea1f9150fec6ca52c9435826", @@ -1093,10 +1106,12 @@ "packages/app/src/utils/amicode-route-info.ts": "a0268a223e8de09fae9d4de8bee6a7774c14c87c6254cb4ed1d921db18587f44", "packages/app/src/utils/amicode-workspace-projects.test.ts": "f8ce6098800d5a1564d8e1e4a59044b378ebedfd5f9ba758fa51afbcd3d1b727", "packages/app/src/utils/amicode-workspace-projects.ts": "6e173d4f21fcebb51e73719409007a2b17055272aa8adfa177541bee8410db23", + "packages/app/src/utils/boot-parity.ts": "65f558d6208a974a1ed22615b3750cfa5ae90acb095d78e54c8fc5d9a54c17ce", "packages/app/src/utils/chrome-dropdown.ts": "f8b878c5df4e1afd96b0751c73daaad0a8127411030724cb8f9a5089ebb29eac", "packages/app/src/utils/global-clipboard.test.ts": "40a8ab52722f50fdc1db4188aad0f1b7be7f02f35d2fdecb899617e778a0134f", "packages/app/src/utils/global-clipboard.ts": "6a8532b161b8a1548199e7393348551f9f0f56f22a5a012bd32359b281e3e927", "packages/app/src/utils/pane-bridge.ts": "a8a8a40dc60a367086f3c4e4c561a289fb85d8b2086a8d62c2b66102de652ce4", + "packages/app/src/utils/path-key.ts": "a8dc8e7799d27fe832aee95f83e6747673fce5dec0f571aaefefca0e31153b64", "packages/app/src/utils/project-type-helpers.test.ts": "602ed23d5719729d1e3b5241a155857775a7a855897a87986964e83fc1b249ec", "packages/app/src/utils/project-type-helpers.ts": "5b9eb86b6fa271186e49d380249f7dce13879106a2e68a503aeaacee2f7d6ba0", "packages/app/src/utils/provider-disconnect.test.ts": "809971964ed28564d379dce2e590bd4727f45eb597e46234c28d25dd2485df26", @@ -1104,6 +1119,8 @@ "packages/app/src/utils/serialize-session.test.ts": "f8fe318f195dca251eae3f4899f4a5881c3523522f59e02fef208588391f4048", "packages/app/src/utils/serialize-session.ts": "c303c678500cdb64c7aa6928a0bd984c441a36705fe8152368a0e575cbcb459f", "packages/app/src/utils/server-compat.ts": "01413e13b87485770eb3dbec0b882aec4c8247dafc4dc74b7b4669fa5b20cf1a", + "packages/app/src/utils/session-list-conformance.ts": "39af003bae6159761ed5b0ab709fc1ab796194f8c0543dc80f29f3d49f1af4c3", + "packages/app/src/utils/session-list-state.ts": "3c6ac47f3e73b14e6d5380c5d8500d0df330740e4652e6afac0547c86c9b9ea7", "packages/app/src/utils/start-prompt.ts": "809d8ad0ab6de76a97503f8368ade25f771431be3f92eb8f039a2c65244ab20f", "packages/app/src/utils/web-zoom.test.ts": "f373618138add504faa44dd6d2c9e3489b6e8dd0093ac6bb4f00f55307864e3d", "packages/app/src/utils/web-zoom.ts": "d5119a07e9fc61880b7294fadd23801933d99a9d3dbd0fadcafed1f786bd7d7a", @@ -1377,7 +1394,7 @@ "packages/app/src/components/session/panel-menu.tsx": "42f323046b7375ae158023a51506038eb15f2e1545c407f45acd47446a8ef3e0", "packages/app/src/components/session/session-chats-dropdown.test.ts": "2003d2a15781337ea6bff2c68e730cc5b6df38697f15d927ab26913936444db7", "packages/app/src/components/session/session-context-tab.tsx": "227243b178b517f067d9ae0ae0eec3c559beeb6681828158b0600a17e98e7f81", - "packages/app/src/components/session/session-header.tsx": "a46591ed1097d0fdcff61fb0c5529396955e8857cbef748747c51e472d5564c5", + "packages/app/src/components/session/session-header.tsx": "8b75cebe0bde45f682414ffeab150a3c1c3c32a4553799406199346ec7c546bb", "packages/app/src/components/session/session-new-view.tsx": "9510a4f550a3f0d4791e98e8025666f09d70a60fb66f193e48ee61feddae5a57", "packages/app/src/components/session/session-preview-tab.tsx": "b6b4b8fe6f751499e0d3005e1f12224351df4f28f221bba10fc2f91c0f09e777", "packages/app/src/components/session/session-sortable-terminal-tab-v2.tsx": "08db0e378c3e07d243121f40c77e153bafe897e5e2ececd48a3e00786793032b", @@ -1395,14 +1412,23 @@ "packages/app/src/components/settings-v2/skills.tsx": "42deb0c11fcba158abad857b9644ffb551aed5ba9e00dbfd1584a7ffaaba5aa0", "packages/app/src/components/ui/drawer.tsx": "76574ca4c6997209f37a766a61000d707332a9a857f6ea8100a3cee1be58d65c", "packages/app/src/context/global-sync/bootstrap.test.ts": "149c0241b378b6ce837aef45e1ba257dbfed88de37c70affa13f513e9c72338d", - "packages/app/src/context/global-sync/child-store.ts": "b13a513c93b8fec7b656593d842e9cfa30f346a0911dc0f516783ccc5fa4c07d", + "packages/app/src/context/global-sync/child-store.ts": "ce684d82805263d56cb8a8c8c55e3dab626b13c5a32600fe1760525f33f8d669", "packages/app/src/context/global-sync/event-reducer.test.ts": "600cd7654854b910f25c61b20d8ae69f043f52ed544e6bf9be5ee9e5bbd6c96e", "packages/app/src/context/global-sync/event-reducer.ts": "667fbae1259f6deed77573edd2b742beee4f825b4306b6d861670443e381a55f", + "packages/app/src/context/global-sync/h1-client-boot.ts": "d1c3d5b17049413035ac17923a08c31f157fe20822871ecda798f8f883f3ff22", + "packages/app/src/context/global-sync/home-session-index.ts": "ad14d84457b6298b9b098e85dbe0570a9300a7d82517c54d4f86ca23f7471be9", "packages/app/src/context/global-sync/session-cache.test.ts": "c66d790c6336794b2b1d707e2739fe0992ccad7ed3b96187b124b5c783ef332a", "packages/app/src/context/global-sync/session-cache.ts": "438a27e075dc0e80db822d178a5b7d7777ab5e25ee865e5652b9904fda7542de", - "packages/app/src/context/global-sync/types.ts": "901c9bd1fda63dab676d33dc1a8582a29f8ee00aa20458e843aed5f93414fa84", + "packages/app/src/context/global-sync/session-currency.ts": "15ef51616b0cc3ff57a033de1c333d67e09280c6dcb744f4550d633618e2a9ee", + "packages/app/src/context/global-sync/session-snapshot.ts": "e53df5cc501c6c4e94f65e350ad9bf7e0cf3ee7561b4cf3b06023e866bceda7e", + "packages/app/src/context/global-sync/session-trim.ts": "f6c011942d772a6d323aa6e8c3915652421e5d89b9bbef51942685bf3a5c8761", + "packages/app/src/context/global-sync/types.ts": "223309430057ac8f6ef7fc1ad31cea87d9a21c1094a463efdb46bd51d730d542", + "packages/app/src/context/global-sync/utils.ts": "d95705c999fdc2971b5ed156ce0b158cded2438d69412ec49c5854735f1735bc", "packages/app/src/pages/home/home-projects-view.tsx": "074f1cdd7926db9574164824666cd63f1f5bd0f8e8dfb65453f6fe8f2dca0ec8", - "packages/app/src/pages/home/home-sessions-view.tsx": "5b73b18e4349a628a15e09a3810ecf2319bde222e18fe8de5d08373ac91fb37c", + "packages/app/src/pages/home/home-session-groups.ts": "a1c7476087a0246a901f83c4509191991d9529f2b9e9fb8566cf0959412c224f", + "packages/app/src/pages/home/home-sessions-controller.tsx": "dd16caf5a99d12ed31adef6619ecd23d781b0a269c2d654a803c53369cacaaf0", + "packages/app/src/pages/home/home-sessions-view.tsx": "bd7a4d5e763735308b8e6dfb1d011b58a96b2e9368868408a83d5157e9f704ef", + "packages/app/src/pages/home/home-sessions.tsx": "d5c5ff2b11ddcb720c32aaa2610bc35aadd8abd003b7a09c08c20699904ec8bf", "packages/app/src/pages/layout/helpers.test.ts": "f1ba4e1513dbcdeacab05cf98fa73dc561a18e9d3faa975afe1dda926fb8bba6", "packages/app/src/pages/layout/helpers.ts": "45d9ab4dcfe2c6b0ecafcaa69a4fa758556d80ec877faa6e78b271d165051d89", "packages/app/src/pages/layout/project-avatar-state.ts": "7aadb235d27dc45799411c724f0fd1282af82d091cbcefb0344deb7c5d0659d4", diff --git a/packages/extension/test/session_snapshot_817.test.ts b/packages/extension/test/session_snapshot_817.test.ts new file mode 100644 index 00000000..7a46c2b0 --- /dev/null +++ b/packages/extension/test/session_snapshot_817.test.ts @@ -0,0 +1,116 @@ +// Issue #817 — D2 (spec spec-20260905-045114-session-device-lifecycle): the +// persisted session snapshot is a render accelerator, never an authority. +// Ported from the fork reference with the REVISED currency: the token is +// derived CLIENT-side over the fetched projection (session-currency.ts), so +// bootCurrencyDecision takes the server-reported version instead of a server +// currency field. +import { describe, expect, test } from "vitest" +import { bootCurrencyDecision, toSnapshot, type SessionSnapshot } from "../../app-bundle/overlay/packages/app/src/context/global-sync/session-snapshot" + +const session = (id: string, updated = 1) => ({ + id, + directory: "/home", + projectID: "p1", + slug: id, + version: "test", + title: `Session ${id}`, + time: { created: updated, updated }, +}) + +describe("bootCurrencyDecision (D2: the #293 stale-storage shape self-heals on boot)", () => { + test("a snapshot with a stale token is proven stale and the fetched rows are adopted", () => { + const snapshot: SessionSnapshot = { sessions: [session("old")], currency: "v1.1.100.100.v1.18.10-amicode.21" } + const decision = bootCurrencyDecision({ + snapshot, + response: { sessions: [session("fresh", 200)] }, + serverVersion: "v1.18.10-amicode.21", + }) + + expect(decision.adopt).toBe(true) + expect(decision.stale).toBe(true) + }) + + test("a snapshot written without a token (the #293 shape) cannot be trusted", () => { + const snapshot: SessionSnapshot = { sessions: [], currency: undefined } + const decision = bootCurrencyDecision({ + snapshot, + response: { sessions: [session("fresh")] }, + serverVersion: "v1.18.29", + }) + + expect(decision.stale).toBe(true) + expect(decision.adopt).toBe(true) + }) + + test("a matching token means the snapshot was fresh (same rows, same hub)", () => { + const rows = [session("old")] + // Written from the same projection on the same hub: the token derived at + // write time (count 1, max 1, sum 1, version v1.18.29). + const snapshot: SessionSnapshot = { sessions: rows, currency: "v1.1.1.1.v1.18.29" } + const decision = bootCurrencyDecision({ + snapshot, + response: { sessions: rows }, + serverVersion: "v1.18.29", + }) + + expect(decision.stale).toBe(false) + // The token to persist alongside the adopted rows is derived client-side. + expect(decision.currency).toBeTypeOf("string") + }) + + test("a changed hub build flips the token even over an unchanged projection", () => { + const rows = [session("old")] + const decision = bootCurrencyDecision({ + snapshot: { sessions: rows, currency: "v1.0.1.1.v1.18.10-amicode.21" }, + response: { sessions: rows }, + serverVersion: "v1.18.29", + }) + + expect(decision.stale).toBe(true) + }) + + test("a hub whose version is unavailable cannot prove staleness by build — but projection changes still do", () => { + const rows = [session("old")] + // Same rows, no version: the derived token matches the one stamped at + // write time (also version-less), so no stale verdict. + const same = bootCurrencyDecision({ + snapshot: { sessions: rows, currency: "v1.1.1.1.unavailable" }, + response: { sessions: rows }, + serverVersion: undefined, + }) + expect(same.stale).toBe(false) + }) + + test("a fresh client with no snapshot adopts the fetched rows without a verdict", () => { + const decision = bootCurrencyDecision({ + response: { sessions: [session("fresh")] }, + serverVersion: "v1.18.29", + }) + + expect(decision.adopt).toBe(true) + expect(decision.stale).toBe(false) + expect(decision.currency).toBeTypeOf("string") + }) + + test("out-of-band projection change between boots proves the snapshot stale (client-side derivation)", () => { + // The snapshot was written from this projection... + const written = [session("a", 100), session("b", 200)] + const snapshot: SessionSnapshot = { sessions: written, currency: undefined } + // ...and the hub now returns a different projection (b archived out-of-band). + const decision = bootCurrencyDecision({ + snapshot, + response: { sessions: [session("a", 100)] }, + serverVersion: "v1.18.29", + }) + + expect(decision.stale).toBe(true) + expect(decision.adopt).toBe(true) + }) + + test("toSnapshot shapes what gets persisted: rows plus the derived token", () => { + const rows = [session("a"), session("b")] + const snapshot = toSnapshot(rows, "v1.0.2.200.v1.18.29") + expect(snapshot.sessions).toHaveLength(2) + expect(snapshot.currency).toBe("v1.0.2.200.v1.18.29") + }) +}) From 2149c5a38306c71141d96e200c72b00d0c6d0c33 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Sat, 5 Sep 2026 11:40:08 -0400 Subject: [PATCH 11/11] =?UTF-8?q?fix(app-overlay):=20typecheck=20repairs?= =?UTF-8?q?=20for=20the=20overlay=20port=20=E2=80=94=20ServerSDKBase.versi?= =?UTF-8?q?on,=20readonly=20projects,=20parity=20parts=20typing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #817 — the app-tree typecheck (materialize + tsgo -b) caught three gaps in the port: ServerSDKBase declares the version accessor the sync consumes; home-session-groups takes a readonly project list; boot-parity's log parts are typed as strings. Manifest + drift-report re-recorded for the touched overlay files, and .materialized/ is gitignored — it is a local build artifact and was never meant to ride a commit. The app package now typechecks clean against upstream v1.18.29 except two PRE-EXISTING error classes on origin/main (server-session.ts diff_version vs the overlay's SessionCache; i18n/parity.test.ts's 'am' locale not in the overlay dict set) — noted for a follow-up, not fixed here (no drive-bys). The vite production build succeeds. --- packages/app-bundle/.gitignore | 1 + packages/app-bundle/manifest.json | 8 ++++---- .../overlay/packages/app/src/context/server-sdk.tsx | 4 ++++ .../packages/app/src/pages/home/home-session-groups.ts | 2 +- .../overlay/packages/app/src/utils/boot-parity.ts | 2 +- 5 files changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/app-bundle/.gitignore b/packages/app-bundle/.gitignore index ceddaa37..af0efe1a 100644 --- a/packages/app-bundle/.gitignore +++ b/packages/app-bundle/.gitignore @@ -1 +1,2 @@ .cache/ +.materialized/ diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index 4445d491..c82fc999 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -6,7 +6,7 @@ "fork_sha": "d161eb0cfc6d03a53311e083b590005b4161c13a", "upstream_base": "v1.18.29", "upstream_base_sha": "16747470f976aca3d362ad730bcd3fe82ecc2c9a", - "extracted_at": "2026-09-05T15:37:24.487Z", + "extracted_at": "2026-09-05T15:38:58.133Z", "per_package": { "packages/app": { "A": 132, @@ -1054,7 +1054,7 @@ "packages/app/src/context/models.tsx": "8904525767b36ed5ba93e85a0fddb9bf737a8d066dfff084380ce1a67182f535", "packages/app/src/context/platform.tsx": "edd9b1773feb5c084b922a57b6914f6a473eed558b44f9de0eeb1c4d4e2b49da", "packages/app/src/context/server-sdk.test.ts": "5cd22962c8ea1e4374915508a765281ceb9f808ad8bbf241c06b00b48621c816", - "packages/app/src/context/server-sdk.tsx": "cbc9203a75ce04d2dd435da256baba8927748e53ba4dc5843373fd6720ee16b3", + "packages/app/src/context/server-sdk.tsx": "84de358d6c870264eb820e9f94cae788bee28e1c325df3ddc73092f36a87d061", "packages/app/src/context/server-sync.tsx": "58c176d333a20027d5281ba8386308e29e9ed4291070ffe2f6ccc1eea857bd7d", "packages/app/src/context/server.tsx": "08e978e4b4b3094221a2a0b92e069f39cbedb6d6cf95935881e21cde52265ee2", "packages/app/src/context/settings.tsx": "050f1831173d9f337bb00d54c034670553cd22bb457c7b536e4d799eb35de8a3", @@ -1106,7 +1106,7 @@ "packages/app/src/utils/amicode-route-info.ts": "a0268a223e8de09fae9d4de8bee6a7774c14c87c6254cb4ed1d921db18587f44", "packages/app/src/utils/amicode-workspace-projects.test.ts": "f8ce6098800d5a1564d8e1e4a59044b378ebedfd5f9ba758fa51afbcd3d1b727", "packages/app/src/utils/amicode-workspace-projects.ts": "6e173d4f21fcebb51e73719409007a2b17055272aa8adfa177541bee8410db23", - "packages/app/src/utils/boot-parity.ts": "65f558d6208a974a1ed22615b3750cfa5ae90acb095d78e54c8fc5d9a54c17ce", + "packages/app/src/utils/boot-parity.ts": "5231be428fc832cd941aafbe70e4620b696321b87a2769d28372ecd2feb9dc41", "packages/app/src/utils/chrome-dropdown.ts": "f8b878c5df4e1afd96b0751c73daaad0a8127411030724cb8f9a5089ebb29eac", "packages/app/src/utils/global-clipboard.test.ts": "40a8ab52722f50fdc1db4188aad0f1b7be7f02f35d2fdecb899617e778a0134f", "packages/app/src/utils/global-clipboard.ts": "6a8532b161b8a1548199e7393348551f9f0f56f22a5a012bd32359b281e3e927", @@ -1425,7 +1425,7 @@ "packages/app/src/context/global-sync/types.ts": "223309430057ac8f6ef7fc1ad31cea87d9a21c1094a463efdb46bd51d730d542", "packages/app/src/context/global-sync/utils.ts": "d95705c999fdc2971b5ed156ce0b158cded2438d69412ec49c5854735f1735bc", "packages/app/src/pages/home/home-projects-view.tsx": "074f1cdd7926db9574164824666cd63f1f5bd0f8e8dfb65453f6fe8f2dca0ec8", - "packages/app/src/pages/home/home-session-groups.ts": "a1c7476087a0246a901f83c4509191991d9529f2b9e9fb8566cf0959412c224f", + "packages/app/src/pages/home/home-session-groups.ts": "881e5c86fd2f6bb8663aec6d618e183b5c4daabd64d48d7f88fe63ee6699aedc", "packages/app/src/pages/home/home-sessions-controller.tsx": "dd16caf5a99d12ed31adef6619ecd23d781b0a269c2d654a803c53369cacaaf0", "packages/app/src/pages/home/home-sessions-view.tsx": "bd7a4d5e763735308b8e6dfb1d011b58a96b2e9368868408a83d5157e9f704ef", "packages/app/src/pages/home/home-sessions.tsx": "d5c5ff2b11ddcb720c32aaa2610bc35aadd8abd003b7a09c08c20699904ec8bf", diff --git a/packages/app-bundle/overlay/packages/app/src/context/server-sdk.tsx b/packages/app-bundle/overlay/packages/app/src/context/server-sdk.tsx index 9d6efdd8..4894f829 100644 --- a/packages/app-bundle/overlay/packages/app/src/context/server-sdk.tsx +++ b/packages/app-bundle/overlay/packages/app/src/context/server-sdk.tsx @@ -192,6 +192,10 @@ type ServerSDKBase = { client: ReturnType api: CompatibleApi currentApi: ServerApi + /** D2/D3 (issue #817): the hub's self-reported version (health endpoint), + * resolved once per server context — the derived list-currency token's + * stamp and the boot parity record's input. */ + version: () => Promise event: { on: ServerEventEmitter["on"] listen: ServerEventEmitter["listen"] diff --git a/packages/app-bundle/overlay/packages/app/src/pages/home/home-session-groups.ts b/packages/app-bundle/overlay/packages/app/src/pages/home/home-session-groups.ts index 8b6bf344..07cc13b6 100644 --- a/packages/app-bundle/overlay/packages/app/src/pages/home/home-session-groups.ts +++ b/packages/app-bundle/overlay/packages/app/src/pages/home/home-session-groups.ts @@ -32,7 +32,7 @@ const baseName = (worktree: string) => { * no server row and no backfill. */ export function resolveSessionProject( session: Pick, - projects: LocalProject[], + projects: readonly LocalProject[], ): { project: LocalProject; projectName: string } { const directory = pathKey(session.directory) const matched = projects.find( diff --git a/packages/app-bundle/overlay/packages/app/src/utils/boot-parity.ts b/packages/app-bundle/overlay/packages/app/src/utils/boot-parity.ts index c01297ac..827ee39e 100644 --- a/packages/app-bundle/overlay/packages/app/src/utils/boot-parity.ts +++ b/packages/app-bundle/overlay/packages/app/src/utils/boot-parity.ts @@ -82,7 +82,7 @@ export async function recordBootParity(input: { serverVersion: input.serverVersion, channel: () => fetchCanonicalReleaseChannel(input.fetcher), }) - const parts = [record.outcome] + const parts: string[] = [record.outcome] if (record.detail) parts.push(`(${record.detail})`) if (record.serverVersion) parts.push(`server=${record.serverVersion}`) if (record.channelVersion) parts.push(`channel=${record.channelVersion}`)