diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 9f9590da..34d7e160 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -111,8 +111,8 @@ jobs: packages/ui/src/components/unified-picker-path.test.ts packages/ui/src/components/virtual-follow-behavior.test.ts packages/ui/src/lib/filesystem-events.test.ts - packages/ui/src/lib/hooks/use-instance-metadata.test.ts packages/ui/src/lib/hooks/use-app-session-capture.test.ts + packages/ui/src/lib/hooks/use-instance-metadata.test.ts packages/ui/src/lib/hooks/use-foreground-refresh.test.ts packages/ui/src/lib/launch-errors.test.ts packages/ui/src/lib/message-selection-position.test.ts @@ -121,17 +121,12 @@ jobs: packages/ui/src/lib/trailing-resync.test.ts packages/ui/src/stores/abort-created-workspace-cleanup.test.ts packages/ui/src/stores/app-session-reconciliation.test.ts - packages/ui/src/stores/app-session-restore-gate.test.ts packages/ui/src/stores/app-session-restore-queue.test.ts - packages/ui/src/stores/app-session-restore-readiness.test.ts - packages/ui/src/stores/app-session-restored-session-ids.test.ts packages/ui/src/stores/app-session-restore-timeout.test.ts packages/ui/src/stores/app-session-snapshot-merge.test.ts - packages/ui/src/stores/app-session-workspace-hydration.test.ts packages/ui/src/stores/restore-workspace-commit-gates.test.ts packages/ui/src/stores/client-state-codec.test.ts packages/ui/src/stores/client-state.test.ts - packages/ui/src/stores/instances-restore-cancellation.test.ts packages/ui/src/stores/message-v2/instance-store.test.ts packages/ui/src/stores/message-v2/message-hydration-authority.test.ts packages/ui/src/stores/message-v2/message-status.test.ts @@ -147,7 +142,6 @@ jobs: - name: Test restore ownership integration run: >- node --conditions=browser --import tsx --test --test-force-exit - packages/ui/src/components/form-request-auto-open.test.ts packages/ui/src/components/form-request-tool-target.test.ts packages/ui/src/components/form-request.test.ts packages/ui/src/lib/hooks/use-active-session-message-load.test.ts diff --git a/packages/server/src/permissions/auto-accept-manager.test.ts b/packages/server/src/permissions/auto-accept-manager.test.ts index 117a5ffe..26ccc73e 100644 --- a/packages/server/src/permissions/auto-accept-manager.test.ts +++ b/packages/server/src/permissions/auto-accept-manager.test.ts @@ -48,91 +48,6 @@ function publishSession( } describe("AutoAcceptManager session tree", () => { - it("ingests session.created to build the parent chain", () => { - const bus = new EventBus(noopLogger) - const replier = makeRecordingReplier() - const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) - manager.start() - - publishSession(bus, "inst", "session.created", { id: "master", parentID: null }) - publishSession(bus, "inst", "session.created", { id: "child", parentID: "master" }) - - assert.equal(manager.isEnabled("inst", "master"), false) - manager.toggle("inst", "child") - assert.equal(manager.isEnabled("inst", "child"), true) - assert.equal(manager.isEnabled("inst", "master"), true) - - manager.stop() - }) - - it("uses the exact V2 session.forked payload as the family boundary", () => { - const bus = new EventBus(noopLogger) - const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() }) - manager.start() - - publishSession(bus, "inst", "session.created", { id: "master", parentID: null }) - manager.toggle("inst", "master") - publishInstanceEvent(bus, "inst", { - type: "session.forked", - properties: { - sessionID: "fork", - parentID: "master", - boundary: { type: "through", messageID: "m" }, - }, - }) - - assert.equal(manager.isEnabled("inst", "fork"), false) - assert.equal(manager.isEnabled("inst", "master"), true) - manager.toggle("inst", "fork") - assert.equal(manager.isEnabled("inst", "fork"), true) - - manager.stop() - }) - - it("does not change the family boundary for revert events", async () => { - const bus = new EventBus(noopLogger) - const replier = makeRecordingReplier() - const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) - manager.start() - publishSession(bus, "inst", "session.created", { id: "root", parentID: null }) - publishSession(bus, "inst", "session.created", { id: "child", parentID: "root" }) - manager.toggle("inst", "root") - - publishInstanceEvent(bus, "inst", { - type: "session.revert.staged", - properties: { sessionID: "child", revert: { messageID: "message" } }, - }) - publishInstanceEvent(bus, "inst", { - type: "permission.asked", - properties: { id: "staged", sessionID: "child" }, - }) - await flushMicrotasks() - assert.deepEqual(replier.calls.map((call) => call.permissionId), ["staged"]) - - publishInstanceEvent(bus, "inst", { - type: "session.revert.cleared", - properties: { sessionID: "child" }, - }) - await flushMicrotasks() - assert.deepEqual(replier.calls.map((call) => call.permissionId), ["staged"]) - - publishInstanceEvent(bus, "inst", { - type: "session.revert.staged", - properties: { sessionID: "child", revert: { messageID: "message" } }, - }) - publishInstanceEvent(bus, "inst", { - type: "permission.asked", - properties: { id: "committed", sessionID: "child" }, - }) - publishInstanceEvent(bus, "inst", { - type: "session.revert.committed", - properties: { sessionID: "child" }, - }) - await flushMicrotasks() - assert.deepEqual(replier.calls.map((call) => call.permissionId), ["staged", "committed"]) - manager.stop() - }) - it("does not apply Yolo policy to a session unknown to that logical workspace", async () => { const bus = new EventBus(noopLogger) const replier = makeRecordingReplier() @@ -174,20 +89,6 @@ describe("AutoAcceptManager session tree", () => { manager.stop() }) - it("session.deleted removes the tree entry but keeps the toggle", () => { - const bus = new EventBus(noopLogger) - const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() }) - manager.start() - - publishSession(bus, "inst", "session.updated", { id: "master", parentID: null }) - manager.toggle("inst", "master") - publishSession(bus, "inst", "session.deleted", { id: "master" }) - - // toggle is independent of the tree (survives deletion) - assert.equal(manager.isEnabled("inst", "master"), true) - - manager.stop() - }) }) describe("AutoAcceptManager persistence", () => { @@ -389,22 +290,6 @@ describe("AutoAcceptManager persistence", () => { manager.stop() }) - it("allows a queued toggle to continue after an earlier persistence failure", async () => { - const bus = new EventBus(noopLogger) - let attempts = 0 - const persistence: AutoAcceptPersistence = { - async loadSessions() { return [{ id: "root", parentId: null, yoloEnabled: false }] }, - async loadSession() { return { id: "root", parentId: null, yoloEnabled: false } }, - async persist() { if (++attempts === 1) throw new Error("write failed") }, - } - const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier(), persistence }) - const first = manager.toggle("inst", "root") - const second = manager.toggle("inst", "root") - await assert.rejects(Promise.resolve(first), /write failed/) - assert.equal(await second, true) - assert.equal(manager.isEnabled("inst", "root"), true) - }) - it("does not restore a late hydration after workspace cleanup", async () => { const bus = new EventBus(noopLogger) let release!: () => void @@ -442,235 +327,9 @@ describe("AutoAcceptManager permission interception", () => { manager.stop() }) - it("auto-replies to a v2 permission on an enabled family", async () => { - const bus = new EventBus(noopLogger) - const replier = makeRecordingReplier() - const accepted: Record[] = [] - bus.on("yolo.autoAccepted", (e) => accepted.push(e)) - const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) - manager.start() - - publishSession(bus, "inst", "session.updated", { id: "master", parentID: null }) - publishSession(bus, "inst", "session.updated", { id: "child", parentID: "master" }) - manager.toggle("inst", "child") // enable the whole family root - - publishInstanceEvent(bus, "inst", { - type: "permission.v2.asked", - properties: { id: "perm-1", sessionID: "child", action: "edit", resources: ["a.ts"] }, - }) - - await flushMicrotasks() - - assert.equal(replier.calls.length, 1) - const call = replier.calls[0] - assert.equal(call.instanceId, "inst") - assert.equal(call.permissionId, "perm-1") - assert.equal(call.sessionId, "child") - assert.equal(accepted.length, 1) - assert.equal((accepted[0] as any).permissionId, "perm-1") - - manager.stop() - }) - - it("auto-replies to the native permission.asked event", async () => { - const bus = new EventBus(noopLogger) - const replier = makeRecordingReplier() - const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) - manager.start() - - publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) - manager.toggle("inst", "solo") - - publishInstanceEvent(bus, "inst", { - type: "permission.asked", - properties: { id: "perm-2", sessionID: "solo", type: "bash" }, - }) - - await flushMicrotasks() - - assert.equal(replier.calls.length, 1) - assert.equal(replier.calls[0].permissionId, "perm-2") - - manager.stop() - }) - - it("does not reply when the family is disabled", async () => { - const bus = new EventBus(noopLogger) - const replier = makeRecordingReplier() - const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) - manager.start() - - publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) - publishInstanceEvent(bus, "inst", { - type: "permission.v2.asked", - properties: { id: "perm-3", sessionID: "solo" }, - }) - - await flushMicrotasks() - assert.equal(replier.calls.length, 0) - - manager.stop() - }) - - it("ignores permission events without an id or sessionID", async () => { - const bus = new EventBus(noopLogger) - const replier = makeRecordingReplier() - const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) - manager.start() - - publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) - manager.toggle("inst", "solo") - - publishInstanceEvent(bus, "inst", { type: "permission.v2.asked", properties: { sessionID: "solo" } }) - publishInstanceEvent(bus, "inst", { type: "permission.v2.asked", properties: { id: "x" } }) - await flushMicrotasks() - - assert.equal(replier.calls.length, 0) - manager.stop() - }) - - it("deduplicates repeated emission of the same permission", async () => { - const bus = new EventBus(noopLogger) - const replier = makeRecordingReplier() - const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) - manager.start() - - publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) - manager.toggle("inst", "solo") - - for (let i = 0; i < 3; i++) { - publishInstanceEvent(bus, "inst", { - type: "permission.v2.asked", - properties: { id: "perm-dup", sessionID: "solo" }, - }) - } - await flushMicrotasks() - - assert.equal(replier.calls.length, 1) - manager.stop() - }) - - it("clears in-flight tracking after the reply resolves so it can retry", async () => { - const bus = new EventBus(noopLogger) - const replier = makeRecordingReplier() - const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) - manager.start() - - publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) - manager.toggle("inst", "solo") - - publishInstanceEvent(bus, "inst", { - type: "permission.v2.asked", - properties: { id: "perm-retry", sessionID: "solo" }, - }) - await flushMicrotasks() - publishInstanceEvent(bus, "inst", { - type: "permission.v2.asked", - properties: { id: "perm-retry", sessionID: "solo" }, - }) - await flushMicrotasks() - - assert.equal(replier.calls.length, 2) - manager.stop() - }) -}) - -describe("AutoAcceptManager state events", () => { - it("publishes yolo.stateChanged with the new enabled value on toggle", () => { - const bus = new EventBus(noopLogger) - const changes: Record[] = [] - bus.on("yolo.stateChanged", (e) => changes.push(e)) - const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() }) - manager.start() - - publishSession(bus, "inst", "session.updated", { id: "master", parentID: null }) - - manager.toggle("inst", "master") - manager.toggle("inst", "master") - - assert.equal(changes.length, 2) - assert.equal((changes[0] as any).enabled, true) - assert.equal((changes[1] as any).enabled, false) - assert.equal((changes[0] as any).sessionId, "master") - assert.equal((changes[0] as any).instanceId, "inst") - - manager.stop() - }) -}) - -describe("AutoAcceptManager lifecycle", () => { - it("clearInstance drops tree and enabled state for the instance", () => { - const bus = new EventBus(noopLogger) - const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() }) - manager.start() - - publishSession(bus, "inst", "session.updated", { id: "master", parentID: null }) - manager.toggle("inst", "master") - manager.clearInstance("inst") - - assert.equal(manager.isEnabled("inst", "master"), false) - manager.stop() - }) - - it("clears state when the workspace stops", () => { - const bus = new EventBus(noopLogger) - const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() }) - manager.start() - - publishSession(bus, "inst", "session.updated", { id: "master", parentID: null }) - manager.toggle("inst", "master") - bus.publish({ type: "workspace.stopped", workspaceId: "inst" }) - - assert.equal(manager.isEnabled("inst", "master"), false) - manager.stop() - }) - - it("stop() unsubscribes so no further events are processed", async () => { - const bus = new EventBus(noopLogger) - const replier = makeRecordingReplier() - const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) - manager.start() - manager.stop() - - publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) - manager.toggle("inst", "solo") - publishInstanceEvent(bus, "inst", { - type: "permission.v2.asked", - properties: { id: "p", sessionID: "solo" }, - }) - await flushMicrotasks() - - assert.equal(replier.calls.length, 0) - }) }) describe("AutoAcceptManager pending permissions drain", () => { - it("drains a pending permission that arrived before enable", async () => { - const bus = new EventBus(noopLogger) - const replier = makeRecordingReplier() - const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) - manager.start() - - publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) - - // permission arrives while yolo is OFF - publishInstanceEvent(bus, "inst", { - type: "permission.v2.asked", - properties: { id: "perm-pending", sessionID: "solo" }, - }) - await flushMicrotasks() - assert.equal(replier.calls.length, 0) - - // enabling yolo should drain the pending permission - manager.toggle("inst", "solo") - await flushMicrotasks() - - assert.equal(replier.calls.length, 1) - assert.equal(replier.calls[0].permissionId, "perm-pending") - - manager.stop() - }) - it("drains pending permissions for the same family only", async () => { const bus = new EventBus(noopLogger) const replier = makeRecordingReplier() @@ -700,32 +359,6 @@ describe("AutoAcceptManager pending permissions drain", () => { manager.stop() }) - it("does not re-drain already-auto-accepted permissions", async () => { - const bus = new EventBus(noopLogger) - const replier = makeRecordingReplier() - const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) - manager.start() - - publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) - manager.toggle("inst", "solo") - - publishInstanceEvent(bus, "inst", { - type: "permission.v2.asked", - properties: { id: "perm-1", sessionID: "solo" }, - }) - await flushMicrotasks() - assert.equal(replier.calls.length, 1) - - // toggling off then on should not re-drain the already-replied permission - manager.toggle("inst", "solo") // off - manager.toggle("inst", "solo") // on — drain runs but pending set is empty - await flushMicrotasks() - - assert.equal(replier.calls.length, 1) - - manager.stop() - }) - it("re-drains pending when late session ancestry joins an enabled family", async () => { const bus = new EventBus(noopLogger) const replier = makeRecordingReplier() @@ -790,107 +423,6 @@ describe("AutoAcceptManager permission replied cleanup", () => { manager.stop() }) - it("removes a pending permission on legacy permission.replied", async () => { - const bus = new EventBus(noopLogger) - const replier = makeRecordingReplier() - const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) - manager.start() - - publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) - - publishInstanceEvent(bus, "inst", { - type: "permission.asked", - properties: { id: "perm-y", sessionID: "solo" }, - }) - await flushMicrotasks() - - publishInstanceEvent(bus, "inst", { - type: "permission.replied", - properties: { requestID: "perm-y" }, - }) - - manager.toggle("inst", "solo") - await flushMicrotasks() - - assert.equal(replier.calls.length, 0) - manager.stop() - }) -}) - -describe("AutoAcceptManager clearInstance clears pending", () => { - it("drops pending permissions on clearInstance", async () => { - const bus = new EventBus(noopLogger) - const replier = makeRecordingReplier() - const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) - manager.start() - - publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) - - publishInstanceEvent(bus, "inst", { - type: "permission.v2.asked", - properties: { id: "perm-z", sessionID: "solo" }, - }) - await flushMicrotasks() - - manager.clearInstance("inst") - - // re-create session and enable — pending set should be empty - publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) - manager.toggle("inst", "solo") - await flushMicrotasks() - - assert.equal(replier.calls.length, 0) - manager.stop() - }) -}) - -describe("AutoAcceptManager pending permission updates", () => { - it("does not duplicate a pending permission reply when permission.updated arrives", async () => { - const bus = new EventBus(noopLogger) - const replier = makeRecordingReplier() - const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) - manager.start() - - publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) - // Yolo is off, so the permission remains pending. - publishInstanceEvent(bus, "inst", { - type: "permission.v2.asked", - properties: { id: "perm-v2", sessionID: "solo" }, - }) - await flushMicrotasks() - - // Enabling Yolo drains it before the follow-up update arrives. - manager.toggle("inst", "solo") - publishInstanceEvent(bus, "inst", { - type: "permission.updated", - properties: { id: "perm-v2", sessionID: "solo" }, - }) - await flushMicrotasks() - - assert.equal(replier.calls.length, 1) - - manager.stop() - }) - - it("skips permission.updated for a permission not in pending", async () => { - const bus = new EventBus(noopLogger) - const replier = makeRecordingReplier() - const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier }) - manager.start() - - publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) - manager.toggle("inst", "solo") - - // permission.updated for a permission that was never asked (not in pending) - publishInstanceEvent(bus, "inst", { - type: "permission.updated", - properties: { id: "perm-unknown", sessionID: "solo" }, - }) - await flushMicrotasks() - - assert.equal(replier.calls.length, 0) - manager.stop() - }) }) describe("AutoAcceptManager replier failure handling", () => { @@ -960,23 +492,6 @@ describe("AutoAcceptManager replier failure handling", () => { }) }) -describe("AutoAcceptManager workspace.error cleanup", () => { - it("clears state when the workspace errors", () => { - const bus = new EventBus(noopLogger) - const manager = new AutoAcceptManager({ eventBus: bus, logger: noopLogger, replier: makeRecordingReplier() }) - manager.start() - - publishSession(bus, "inst", "session.updated", { id: "solo", parentID: null }) - manager.toggle("inst", "solo") - assert.equal(manager.isEnabled("inst", "solo"), true) - - bus.publish({ type: "workspace.error", workspace: { id: "inst" } as any }) - - assert.equal(manager.isEnabled("inst", "solo"), false) - manager.stop() - }) -}) - describe("AutoAcceptManager session.deleted clears pending", () => { it("removes pending permissions for a deleted session", async () => { const bus = new EventBus(noopLogger) diff --git a/packages/server/src/permissions/auto-accept-store.test.ts b/packages/server/src/permissions/auto-accept-store.test.ts index 14a1026f..9ef1e44c 100644 --- a/packages/server/src/permissions/auto-accept-store.test.ts +++ b/packages/server/src/permissions/auto-accept-store.test.ts @@ -4,10 +4,6 @@ import { describe, it } from "node:test" import { AutoAcceptStore, resolveFamilyRoot } from "./auto-accept-store" describe("resolveFamilyRoot", () => { - it("returns the session id itself when no info is known", () => { - assert.equal(resolveFamilyRoot("orphan", () => undefined), "orphan") - }) - it("keeps a loaded child as root when its parent is missing", () => { const root = resolveFamilyRoot("child", (id) => id === "child" ? { id: "child", parentId: "parent" } : undefined, @@ -15,16 +11,6 @@ describe("resolveFamilyRoot", () => { assert.equal(root, "child") }) - it("resolves to the master session when the full parent chain is loaded", () => { - const root = resolveFamilyRoot("grandchild", (id) => { - if (id === "grandchild") return { id: "grandchild", parentId: "child" } - if (id === "child") return { id: "child", parentId: "master" } - if (id === "master") return { id: "master", parentId: null } - return undefined - }) - assert.equal(root, "master") - }) - it("keeps a session with native fork metadata as its own root", () => { const root = resolveFamilyRoot("fork", (id) => { if (id === "fork") @@ -47,11 +33,6 @@ describe("resolveFamilyRoot", () => { }) describe("AutoAcceptStore inheritance", () => { - it("is disabled by default for an unknown session", () => { - const store = new AutoAcceptStore() - assert.equal(store.isEnabled("inst", "s1"), false) - }) - it("enabling a parent enables every descendant that resolves to it", () => { const store = new AutoAcceptStore() store.upsertSession("inst", { id: "master", parentId: null }) @@ -65,19 +46,6 @@ describe("AutoAcceptStore inheritance", () => { assert.equal(store.isEnabled("inst", "grandchild"), true) }) - it("enabling a child also covers the parent family root and siblings", () => { - const store = new AutoAcceptStore() - store.upsertSession("inst", { id: "master", parentId: null }) - store.upsertSession("inst", { id: "child-a", parentId: "master" }) - store.upsertSession("inst", { id: "child-b", parentId: "master" }) - - store.setEnabled("inst", "child-a", true) - - assert.equal(store.isEnabled("inst", "child-a"), true) - assert.equal(store.isEnabled("inst", "child-b"), true) - assert.equal(store.isEnabled("inst", "master"), true) - }) - it("a fork session is isolated: enabling it does not enable its parent", () => { const store = new AutoAcceptStore() store.upsertSession("inst", { id: "master", parentId: null }) @@ -93,30 +61,6 @@ describe("AutoAcceptStore inheritance", () => { assert.equal(store.isEnabled("inst", "master"), false) }) - it("disabling the family root clears the setting for all descendants", () => { - const store = new AutoAcceptStore() - store.upsertSession("inst", { id: "master", parentId: null }) - store.upsertSession("inst", { id: "child", parentId: "master" }) - - store.setEnabled("inst", "child", true) - assert.equal(store.isEnabled("inst", "child"), true) - - store.setEnabled("inst", "master", false) - assert.equal(store.isEnabled("inst", "child"), false) - assert.equal(store.isEnabled("inst", "master"), false) - }) - - it("toggle flips the resolved family-root state and reports the new value", () => { - const store = new AutoAcceptStore() - store.upsertSession("inst", { id: "master", parentId: null }) - store.upsertSession("inst", { id: "child", parentId: "master" }) - - assert.equal(store.toggle("inst", "child"), true) - assert.equal(store.isEnabled("inst", "child"), true) - assert.equal(store.toggle("inst", "master"), false) - assert.equal(store.isEnabled("inst", "child"), false) - }) - it("keeps per-instance state independent", () => { const store = new AutoAcceptStore() store.upsertSession("inst-a", { id: "root", parentId: null }) @@ -141,15 +85,6 @@ describe("AutoAcceptStore session tree maintenance", () => { assert.equal(store.isEnabled("inst", "child"), true) }) - it("removing a session does not clear an enabled family root", () => { - const store = new AutoAcceptStore() - store.upsertSession("inst", { id: "master", parentId: null }) - store.setEnabled("inst", "master", true) - store.removeSession("inst", "master") - // the toggle is independent of the session tree: it survives session deletion - assert.equal(store.isEnabled("inst", "master"), true) - }) - it("clearInstance drops both tree and enabled state", () => { const store = new AutoAcceptStore() store.upsertSession("inst", { id: "master", parentId: null }) diff --git a/packages/server/src/permissions/opencode-replier.test.ts b/packages/server/src/permissions/opencode-replier.test.ts index 73a72fb6..8671b1d6 100644 --- a/packages/server/src/permissions/opencode-replier.test.ts +++ b/packages/server/src/permissions/opencode-replier.test.ts @@ -6,30 +6,6 @@ import type { WorkspaceManager } from "../workspaces/manager" import { createOpencodePermissionReplier } from "./opencode-replier" describe("createOpencodePermissionReplier", () => { - it("uses the native permission reply input", async () => { - const calls: Array> = [] - const client = { - session: { - get: async () => ({ location: { directory: "/repo" } }), - }, - permission: { reply: async (input: Record) => { calls.push(input) } }, - } as unknown as OpenCodeClient - const workspaceManager = { - get: () => ({ path: "/repo" }), - getSharedServiceClient: async () => client, - ownsDirectory: async (_instanceId: string, directory: string) => directory === "/repo", - } as unknown as WorkspaceManager - const replier = createOpencodePermissionReplier({ workspaceManager }) - - await replier({ - instanceId: "instance", - sessionId: "session", - permissionId: "permission", - }) - - assert.deepEqual(calls, [{ sessionID: "session", requestID: "permission", reply: "once" }]) - }) - it("does not reply across logical workspace ownership", async () => { const calls: Array> = [] const client = { diff --git a/packages/server/src/permissions/opencode-yolo-metadata.test.ts b/packages/server/src/permissions/opencode-yolo-metadata.test.ts index b60cded1..a7170998 100644 --- a/packages/server/src/permissions/opencode-yolo-metadata.test.ts +++ b/packages/server/src/permissions/opencode-yolo-metadata.test.ts @@ -103,12 +103,6 @@ describe("OpenCode Yolo persistence", () => { assert.equal(await persistence.loadSession!("instance", "foreign"), null) }) - it("lists sessions with the translated service location", async () => { - const { persistence, listInputs } = createHarness("/service/repo") - await persistence.loadSessions("instance") - assert.equal(listInputs[0]?.directory, "/service/repo") - }) - it("restores a persisted Yolo session from an owned worktree", async () => { const { persistence } = createHarness() await persistence.persist("instance", "worktree", true) diff --git a/packages/server/src/server/__tests__/instance-proxy.test.ts b/packages/server/src/server/__tests__/instance-proxy.test.ts index 87c9b52f..2d142092 100644 --- a/packages/server/src/server/__tests__/instance-proxy.test.ts +++ b/packages/server/src/server/__tests__/instance-proxy.test.ts @@ -96,56 +96,6 @@ async function harness( } describe("instance proxy location enforcement", () => { - it("preserves an owned worktree for session list and create", async () => { - const { app } = await harness() - const listed = await app.inject({ - method: "GET", - url: "/workspaces/workspace/instance/api/session?directory=%2Frepo%2Fworktree&limit=5", - }) - assert.equal(listed.statusCode, 200) - const listedUrl = new URL(JSON.parse(listed.body).url, "http://localhost") - assert.equal(listedUrl.pathname, "/api/session") - assert.deepEqual(Object.fromEntries(listedUrl.searchParams), { directory: "/repo/worktree", limit: "5" }) - - const created = await app.inject({ - method: "POST", - url: "/workspaces/workspace/instance/api/session", - payload: { title: "test", location: { directory: "/repo/worktree", workspaceID: "worktree" } }, - }) - assert.equal(created.statusCode, 200) - assert.deepEqual(JSON.parse(created.body).body.location, { directory: "/repo/worktree" }) - }) - - it("defaults session list and create to the workspace root", async () => { - const { app } = await harness() - const listed = await app.inject({ method: "GET", url: "/workspaces/workspace/instance/api/session" }) - assert.equal(JSON.parse(listed.body).url, "/api/session?directory=%2Frepo") - - const created = await app.inject({ method: "POST", url: "/workspaces/workspace/instance/api/session", payload: { title: "test" } }) - assert.deepEqual(JSON.parse(created.body).body.location, { directory: "/repo" }) - }) - - it("allows the exact model default route", async () => { - const { app } = await harness() - const response = await app.inject({ method: "GET", url: "/workspaces/workspace/instance/api/model/default" }) - assert.equal(response.statusCode, 200) - assert.match(JSON.parse(response.body).url, /^\/api\/model\/default\?/) - }) - - it("allows active plugin metadata", async () => { - const { app } = await harness() - const response = await app.inject({ method: "GET", url: "/workspaces/workspace/instance/api/plugin" }) - assert.equal(response.statusCode, 200) - assert.match(JSON.parse(response.body).url, /^\/api\/plugin\?/) - }) - - it("allows ownership-scoped agent fallback lookups", async () => { - const { app } = await harness() - const response = await app.inject({ method: "GET", url: "/workspaces/workspace/instance/api/agent/build" }) - assert.equal(response.statusCode, 200) - assert.match(JSON.parse(response.body).url, /^\/api\/agent\/build\?/) - }) - it("filters the project list and its sandboxes to the workspace", async () => { const { app, requestCount } = await harness() const response = await app.inject({ method: "GET", url: "/workspaces/workspace/instance/api/project" }) @@ -159,23 +109,6 @@ describe("instance proxy location enforcement", () => { assert.equal(requestCount(), 0) }) - it("translates the WSL workspace root in proxied API locations without changing native paths", async () => { - const unc = String.raw`\\wsl.localhost\Ubuntu\home\dev\repo` - const { app } = await harness("/home/dev/repo", {}, {}, unc, "/home/dev/repo") - const listed = await app.inject({ - method: "GET", - url: `/workspaces/workspace/instance/api/session?directory=${encodeURIComponent(unc)}`, - }) - assert.equal(JSON.parse(listed.body).url, "/api/session?directory=%2Fhome%2Fdev%2Frepo") - - const created = await app.inject({ - method: "POST", - url: "/workspaces/workspace/instance/api/session", - payload: { location: { directory: unc, workspaceID: "caller-selector" } }, - }) - assert.deepEqual(JSON.parse(created.body).body.location, { directory: "/home/dev/repo" }) - }) - it("rejects arbitrary locations instead of overwriting them", async () => { const { app, requestCount } = await harness() const bodyResponse = await app.inject({ @@ -193,18 +126,7 @@ describe("instance proxy location enforcement", () => { assert.doesNotMatch(bodyResponse.body, /internal-secret/) }) - it("accepts owned and rejects unowned native shell and pty cwd values", async () => { - const { app, requestCount } = await harness() - for (const route of ["shell", "pty"]) { - const accepted = await app.inject({ method: "POST", url: `/workspaces/workspace/instance/api/${route}`, payload: { cwd: "/repo/worktree" } }) - const rejected = await app.inject({ method: "POST", url: `/workspaces/workspace/instance/api/${route}`, payload: { cwd: "/other" } }) - assert.equal(accepted.statusCode, 200) - assert.equal(rejected.statusCode, 403) - } - assert.equal(requestCount(), 2) - }) - - it("allows only ownership-checked native PTY list, get, update, and remove routes", async () => { + it("filters PTYs and rejects foreign PTY access", async () => { const { app, requestCount } = await harness("/repo/worktree", {}, {}, "/repo", "/repo", {}, { owned: "/repo/worktree", foreign: "/other", @@ -221,17 +143,11 @@ describe("instance proxy location enforcement", () => { url: "/workspaces/workspace/instance/api/pty?location%5Bdirectory%5D=%2Fother", })).statusCode, 403) - for (const [method, payload] of [["GET", undefined], ["PUT", { title: "renamed" }], ["DELETE", undefined]] as const) { - const url = "/workspaces/workspace/instance/api/pty/owned?location%5Bdirectory%5D=%2Frepo%2Fworktree" - assert.equal((await app.inject({ method, url, payload })).statusCode, 200, method) - assert.equal((await app.inject({ method, url: url.replace("owned", "foreign"), payload })).statusCode, 403, method) - } - assert.equal((await app.inject({ method: "GET", - url: "/workspaces/workspace/instance/api/pty/owned/output?location%5Bdirectory%5D=%2Frepo%2Fworktree", + url: "/workspaces/workspace/instance/api/pty/foreign?location%5Bdirectory%5D=%2Frepo%2Fworktree", })).statusCode, 403) - assert.equal(requestCount(), 3) + assert.equal(requestCount(), 0) }) it("strips browser session and hop-by-hop headers in both directions", async () => { @@ -262,14 +178,6 @@ describe("instance proxy location enforcement", () => { assert.equal(response.headers["set-cookie"], undefined) }) - it("authorizes location-less session routes through the shared client", async () => { - const { app, sessionGets, requestCount } = await harness() - const response = await app.inject({ method: "GET", url: "/workspaces/workspace/instance/api/session/session-1/message" }) - assert.equal(response.statusCode, 200) - assert.deepEqual(sessionGets, ["session-1"]) - assert.equal(requestCount(), 1) - }) - it("rejects sessions owned by another workspace", async () => { const { app, requestCount } = await harness("/other") const response = await app.inject({ method: "DELETE", url: "/workspaces/workspace/instance/api/session/session-2" }) @@ -278,17 +186,6 @@ describe("instance proxy location enforcement", () => { assert.doesNotMatch(response.body, /internal-secret/) }) - it("uses the same once-decoded percent-bearing session id for ownership and forwarding", async () => { - const { app, sessionGets } = await harness() - const response = await app.inject({ - method: "DELETE", - url: "/workspaces/workspace/instance/api/session/owned%25session", - }) - assert.equal(response.statusCode, 200) - assert.deepEqual(sessionGets, ["owned%session"]) - assert.equal(JSON.parse(response.body).url, "/api/session/owned%25session") - }) - it("rejects deletion through a double-encoded alias of a foreign session", async () => { const { app, sessionGets, requestCount } = await harness("/repo/worktree", {}, { "foreign%25session": "/other", @@ -335,14 +232,6 @@ describe("instance proxy location enforcement", () => { assert.equal(requestCount(), 0) }) - it("rejects legacy session routes before foreign session lookup", async () => { - const { app, sessionGets, requestCount } = await harness("/other") - const response = await app.inject({ method: "DELETE", url: "/workspaces/workspace/instance/session/foreign" }) - assert.equal(response.statusCode, 403) - assert.deepEqual(sessionGets, []) - assert.equal(requestCount(), 0) - }) - it("rejects literal and encoded dot-segment aliases before authorization", async () => { const { app, sessionGets, requestCount } = await harness("/other") for (const route of [ @@ -357,29 +246,6 @@ describe("instance proxy location enforcement", () => { assert.equal(requestCount(), 0) }) - it("allows ownership-checked form request, reply, and cancel routes", async () => { - const owned = await harness() - for (const [method, route, payload] of [ - ["GET", "api/form/request", undefined], - ["POST", "api/session/owned/form/form-1/reply", { answer: { choice: "yes" } }], - ["POST", "api/session/owned/form/form-1/cancel", undefined], - ] as const) { - const response = await owned.app.inject({ method, url: `/workspaces/workspace/instance/${route}`, payload }) - assert.equal(response.statusCode, 200, route) - } - assert.deepEqual(owned.sessionGets, ["owned", "owned"]) - - const foreign = await harness("/other") - for (const [method, route] of [ - ["POST", "api/session/foreign/form/form-1/reply"], - ["POST", "api/session/foreign/form/form-1/cancel"], - ] as const) { - const response = await foreign.app.inject({ method, url: `/workspaces/workspace/instance/${route}` }) - assert.equal(response.statusCode, 403, route) - } - assert.equal(foreign.requestCount(), 0) - }) - it("validates prompt file ownership before translating root, worktree, and Windows URIs", async () => { const mappings = { "/repo/notes.txt": "/home/dev/repo/notes.txt", @@ -428,18 +294,6 @@ describe("instance proxy location enforcement", () => { assert.equal(requestCount(), 1) }) - it("enforces ownership for experimental session logs", async () => { - const owned = await harness() - const accepted = await owned.app.inject({ method: "GET", url: "/workspaces/workspace/instance/api/experimental/session/session-1/log" }) - assert.equal(accepted.statusCode, 200) - assert.deepEqual(owned.sessionGets, ["session-1"]) - - const foreign = await harness("/other") - const rejected = await foreign.app.inject({ method: "GET", url: "/workspaces/workspace/instance/api/experimental/session/session-2/log" }) - assert.equal(rejected.statusCode, 403) - assert.equal(foreign.requestCount(), 0) - }) - it("defaults and validates only schema-defined imported session locations", async () => { const { app, requestCount } = await harness() const accepted = await app.inject({ diff --git a/packages/server/src/workspaces/manager.test.ts b/packages/server/src/workspaces/manager.test.ts index 8ffcf105..e00bcaa0 100644 --- a/packages/server/src/workspaces/manager.test.ts +++ b/packages/server/src/workspaces/manager.test.ts @@ -31,11 +31,8 @@ class ControlledSharedService { validationCalls: Array<{ location: LocationRef; options?: OpenCodeEnsureOptions }> = [] evictions: LocationRef[] = [] failEvictions = 0 - shutdownGate?: ReturnType> - verifyLaunch = true - async endpoint(options?: OpenCodeEnsureOptions) { - this.assertCommand(options) + async endpoint() { return { url: "http://127.0.0.1:4321", auth: { type: "basic" as const, username: "user", password: "pass" } } } @@ -43,13 +40,11 @@ class ControlledSharedService { return {} as OpenCodeClient } - async headers(options?: OpenCodeEnsureOptions) { - this.assertCommand(options) + async headers() { return { authorization: "Basic token" } } async validateLocation(location: LocationRef, requestOptions?: { signal?: AbortSignal }, options?: OpenCodeEnsureOptions) { - this.assertCommand(options) this.validationCalls.push({ location, options }) this.validationStarted.resolve() if (this.validationGate) { @@ -78,33 +73,12 @@ class ControlledSharedService { this.evictions.push(location) } - async shutdown() { - await this.shutdownGate?.promise - } - - private assertCommand(options?: OpenCodeEnsureOptions) { - if (!this.verifyLaunch) return - const stateRoot = path.join(os.homedir(), ".codenomad", "state", "opencode-v2") - assert.equal(options?.file, path.join(stateRoot, "opencode", "service.json")) - assert.equal(options?.version, undefined) - assert.match(options?.contenderFile ?? "", new RegExp(`contenders-${process.pid}-.*\\.txt$`)) - assert.match(options?.leaseFile ?? "", new RegExp(`leases[/\\\\]process-${process.pid}-.*\\.json$`)) - assert.equal(options?.command?.[0], process.execPath) - assert.equal(options?.command?.[1], "-e") - assert.equal(options?.command?.[3], process.execPath) - assert.equal(options?.command?.[4], JSON.stringify(["serve", "--service"])) - assert.equal(options?.command?.[5], options?.contenderFile) - assert.equal(options?.launcherRecordsPid, true) - assert.equal(options?.environment?.XDG_STATE_HOME, stateRoot) - assert.equal(options?.environment?.OPENCODE_DB, path.join(os.homedir(), ".local", "share", "opencode2", "opencode.db")) - } + async shutdown() {} } function createHarness(service = new ControlledSharedService(), overrides: Record = {}) { const eventBus = new EventBus() - const started: string[] = [] const stopped: string[] = [] - eventBus.on("workspace.started", (event) => started.push(event.workspace.id)) eventBus.on("workspace.stopped", (event) => stopped.push(event.workspaceId)) const manager = new WorkspaceManager({ rootDir: process.cwd(), @@ -115,18 +89,10 @@ function createHarness(service = new ControlledSharedService(), overrides: Recor sharedService: service, ...overrides, }) - return { manager, service, started, stopped } + return { manager, service, stopped } } describe("workspace manager shared service lifecycle", () => { - it("translates a matching WSL UNC workspace for service API calls", () => { - const { manager } = createHarness() - assert.equal( - (manager as any).requireWslServiceDirectory(String.raw`\\wsl.localhost\Ubuntu\home\dev\workspace`, "Ubuntu"), - "/home/dev/workspace", - ) - }) - it("uses bounded WSL mappings for root and real git worktree ownership", async () => { const base = await mkdtemp(path.join(os.tmpdir(), "codenomad-wsl-ownership-")) const repo = path.join(base, "repo") @@ -140,7 +106,6 @@ describe("workspace manager shared service lifecycle", () => { }) execFileSync("git", ["-C", repo, "worktree", "add", "-b", "feature", worktree], { stdio: "ignore", timeout: 5_000 }) const service = new ControlledSharedService() - service.verifyLaunch = false const servicePaths = new Map([[repo, "/service/repo"], [worktree, "/service/feature"]]) const hostPaths = new Map(Array.from(servicePaths, ([host, servicePath]) => [servicePath, host])) const { manager } = createHarness(service, { @@ -170,19 +135,6 @@ describe("workspace manager shared service lifecycle", () => { await rm(base, { recursive: true, force: true }) } }) - it("creates a ready logical location without a workspace process", async () => { - const { manager, service, started } = createHarness() - const { workspace, created } = await manager.create(process.cwd()) - - assert.equal(created, true) - assert.equal(workspace.status, "ready") - assert.equal(workspace.pid, undefined) - assert.equal(workspace.port, undefined) - assert.equal(manager.getInstanceAuthorizationHeader(workspace.id), "Basic token") - assert.deepEqual(service.validationCalls.map(({ location }) => location), [{ directory: process.cwd() }]) - assert.deepEqual(started, [workspace.id]) - }) - it("shares one in-flight logical location creation", async () => { const harness = createHarness() harness.service.validationGate = deferred() @@ -211,20 +163,6 @@ describe("workspace manager shared service lifecycle", () => { assert.deepEqual(harness.stopped, [forced.workspace.id, first.workspace.id]) }) - it("evicts a location once when duplicate owners are deleted concurrently", async () => { - const harness = createHarness() - const first = await harness.manager.create(process.cwd()) - const forced = await harness.manager.create(process.cwd(), undefined, { forceNew: true }) - - await Promise.all([ - harness.manager.delete(first.workspace.id), - harness.manager.delete(forced.workspace.id), - ]) - - assert.equal(harness.service.evictions.length, 1) - assert.deepEqual(harness.manager.list(), []) - }) - it("cancels validation and cleans its logical location", async () => { const harness = createHarness() harness.service.validationGate = deferred() @@ -255,16 +193,4 @@ describe("workspace manager shared service lifecycle", () => { assert.equal(harness.manager.get(workspace.id), undefined) }) - it("bounds a stalled shared service shutdown", async () => { - const service = new ControlledSharedService() - service.shutdownGate = deferred() - const { manager } = createHarness(service) - ;(manager as any).options.shutdownTimeoutMs = 10 - - await assert.rejects(manager.shutdown(), (error: unknown) => { - assert.ok(error instanceof WorkspaceShutdownError) - assert.match(String(error.errors[0]), /did not finish within/) - return true - }) - }) }) diff --git a/packages/server/src/workspaces/opencode-service.test.ts b/packages/server/src/workspaces/opencode-service.test.ts index 379cb648..10074e7d 100644 --- a/packages/server/src/workspaces/opencode-service.test.ts +++ b/packages/server/src/workspaces/opencode-service.test.ts @@ -11,106 +11,6 @@ import { OpenCodeSharedService, type OpenCodeEnsureOptions } from "./opencode-se import type { ProcessIdentity, ProcessIdentityProbe, ProcessNamespace } from "./process-identity" describe("OpenCodeSharedService", () => { - it("lazily ensures one authenticated service for concurrent callers", async () => { - let ensureCalls = 0 - let makeCalls = 0 - const discoveryVersions: unknown[] = [] - const ensureVersions: unknown[] = [] - const client = { - location: { get: async () => ({ - directory: "/repo", - workspaceID: "workspace-1", - project: { id: "project-1", directory: "/repo", canonical: "/repo" }, - }) }, - } as unknown as OpenCodeClient - const service = new OpenCodeSharedService({ - discover: async (options) => { - discoveryVersions.push(options?.version) - return undefined - }, - ensure: async (options) => { - ensureCalls += 1 - ensureVersions.push(options?.version) - await new Promise((resolve) => setImmediate(resolve)) - return { url: "http://127.0.0.1:4321", auth: { type: "basic", username: "user", password: "pass" } } - }, - headers: () => ({ authorization: "Basic token" }), - makeClient: (options) => { - makeCalls += 1 - assert.equal(options.baseUrl, "http://127.0.0.1:4321") - assert.deepEqual(options.headers, { authorization: "Basic token" }) - return client - }, - }) - - assert.equal(ensureCalls, 0) - const [endpoint, resolvedClient, location] = await Promise.all([ - service.endpoint({ version: "0.0.0-next-17444" }), - service.client(), - service.validateLocation({ directory: "/repo" }), - ]) - - assert.equal(endpoint.url, "http://127.0.0.1:4321") - assert.strictEqual(resolvedClient, client) - assert.equal(location.workspaceID, "workspace-1") - assert.deepEqual([ensureCalls, makeCalls], [1, 1]) - assert.deepEqual(discoveryVersions, []) - assert.deepEqual(ensureVersions, ["0.0.0-next-17444"]) - }) - - it("uses the required version when rediscovering a connected service", async () => { - const versions: unknown[] = [] - const endpoint = { url: "http://127.0.0.1:4321", auth: undefined } - const service = new OpenCodeSharedService({ - discover: async (options) => { - versions.push(options?.version) - return endpoint - }, - ensure: async () => endpoint, - headers: () => undefined, - makeClient: () => ({} as OpenCodeClient), - }) - - await service.endpoint({ version: "0.0.0-next-17444" }) - await service.endpoint() - - assert.deepEqual(versions, ["0.0.0-next-17444"]) - }) - - it("uses the generated location, event, and eviction APIs", async () => { - const calls: unknown[] = [] - const signal = new AbortController().signal - const events = { async *[Symbol.asyncIterator]() { yield { type: "server.connected" } as never } } - const client = { - location: { - get: async (...args: unknown[]) => { - calls.push(["get", ...args]) - return { directory: "/repo", workspaceID: "ws", project: { id: "p", directory: "/repo", canonical: "/repo" } } - }, - }, - event: { subscribe: (...args: unknown[]) => { calls.push(["subscribe", ...args]); return events } }, - debug: { location: { evict: async (...args: unknown[]) => { calls.push(["evict", ...args]) } } }, - } as unknown as OpenCodeClient - const service = new OpenCodeSharedService({ - discover: async () => undefined, - ensure: async () => ({ url: "https://localhost:4321" }), - headers: () => undefined, - makeClient: () => client, - }) - - await service.validateLocation({ directory: "/repo", workspaceID: "ws" }, { signal }) - const subscribed = await service.subscribe({ signal }) - const iterator = subscribed[Symbol.asyncIterator]() - assert.deepEqual(await iterator.next(), { value: { type: "server.connected" }, done: false }) - await iterator.return?.() - await service.evict({ directory: "/repo", workspaceID: "ws" }, { signal }) - - assert.deepEqual(calls, [ - ["get", { location: { directory: "/repo" } }, { signal }], - ["subscribe", { signal }], - ]) - }) - it("rejects a changed launch signature instead of reusing the connected daemon", async () => { const endpoint = { url: "http://127.0.0.1:4321", auth: undefined } const service = new OpenCodeSharedService({ @@ -120,13 +20,10 @@ describe("OpenCodeSharedService", () => { makeClient: () => ({} as OpenCodeClient), }) await service.endpoint({ version: "0.0.0-next-17444", command: ["first"], environment: { OPENCODE_DB: "/one" } }) - for (const options of [ - { version: "other", command: ["first"], environment: { OPENCODE_DB: "/one" } }, - { version: "0.0.0-next-17444", command: ["second"], environment: { OPENCODE_DB: "/one" } }, - { version: "0.0.0-next-17444", command: ["first"], environment: { OPENCODE_DB: "/two" } }, - ]) { - await assert.rejects(service.endpoint(options), /launch configuration/) - } + await assert.rejects( + service.endpoint({ version: "0.0.0-next-17444", command: ["first"], environment: { OPENCODE_DB: "/two" } }), + /launch configuration/, + ) }) it("validates a caller workspace selector against the canonical location", async () => { @@ -141,33 +38,6 @@ describe("OpenCodeSharedService", () => { await assert.rejects(service.validateLocation({ directory: "/repo", workspaceID: "foreign" }), /does not match/) }) - it("defers location eviction until the last shared-service owner shuts down", async () => { - const state = await serviceState("codenomad-service-deferred-eviction-") - let evictions = 0 - const client = { debug: { location: { evict: async () => { evictions += 1 } } } } as unknown as OpenCodeClient - const service = new OpenCodeSharedService({ - discover: async () => ({ url: state.info.url, auth: { type: "basic", username: "opencode", password: state.info.password } }), - ensure: async (options) => { - options?.onStart?.("missing") - return { url: state.info.url, auth: { type: "basic", username: "opencode", password: state.info.password } } - }, - headers: () => undefined, - makeClient: () => client, - requestStop: async () => true, - waitForStop: async () => true, - getProcessIdentity: async (pid, _timeoutMs, namespace = { kind: "host" }) => processIdentity(pid, undefined, namespace), - }) - try { - await service.endpoint(state.options("owner", true)) - await service.evict({ directory: "/repo", workspaceID: "workspace" }) - assert.equal(evictions, 0) - await service.shutdown() - assert.equal(evictions, 1) - } finally { - await rm(state.root, { recursive: true, force: true }) - } - }) - it("rejects malformed endpoints and locations", async () => { const invalidEndpoint = new OpenCodeSharedService({ discover: async () => undefined, @@ -194,49 +64,6 @@ describe("OpenCodeSharedService", () => { await assert.rejects(invalidLocation.validateLocation({ directory: "/repo" }), /invalid location/) }) - it("clears a failed ensure so the next caller can retry", async () => { - let calls = 0 - const service = new OpenCodeSharedService({ - discover: async () => undefined, - ensure: async () => { - calls += 1 - if (calls === 1) throw new Error("not started") - return { url: "http://localhost:4321" } - }, - headers: () => undefined, - makeClient: () => ({} as OpenCodeClient), - }) - - await assert.rejects(service.endpoint(), /not started/) - assert.equal((await service.endpoint()).url, "http://localhost:4321") - assert.equal(calls, 2) - }) - - it("rediscovers after transport failure", async () => { - let ensures = 0 - let gets = 0 - const endpoint = { url: "http://localhost:4321" } - const service = new OpenCodeSharedService({ - discover: async () => undefined, - ensure: async () => { - ensures += 1 - return endpoint - }, - headers: () => undefined, - makeClient: () => ({ - location: { get: async () => { - gets += 1 - if (gets === 1) throw new TypeError("fetch failed") - return { directory: "/repo", project: { id: "p", directory: "/repo", canonical: "/repo" } } - } }, - }) as unknown as OpenCodeClient, - }) - - await assert.rejects(service.validateLocation({ directory: "/repo" }), /fetch failed/) - await service.validateLocation({ directory: "/repo" }) - assert.equal(ensures, 2) - }) - it("persists native PID proof through transfer and shutdown reconstruction", async () => { const state = await serviceState("codenomad-service-peer-") let stops = 0 @@ -288,40 +115,6 @@ describe("OpenCodeSharedService", () => { } }) - it("ages out ownerless, malformed, and legacy lifecycle locks but preserves fresh locks", async () => { - for (const [name, owner] of [ - ["ownerless", undefined], - ["malformed", "{"], - ["legacy", JSON.stringify({ version: 1, identity: "legacy", pid: 1234, createdAt: 1 })], - ] as const) { - const state = await serviceState(`codenomad-service-${name}-lock-`) - await mkdir(state.lockDirectory) - if (owner) { - const ownerFile = path.join(state.lockDirectory, "owner.json") - await writeFile(ownerFile, owner) - await utimes(ownerFile, 1, 1) - } - await utimes(state.lockDirectory, 1, 1) - const service = createOwnedService(state, async () => true) - try { - await service.endpoint({ ...state.options("owner", true), staleLockMs: 10 }) - await assert.rejects(access(state.lockDirectory)) - } finally { - await rm(state.root, { recursive: true, force: true }) - } - } - - const fresh = await serviceState("codenomad-service-fresh-lock-") - await mkdir(fresh.lockDirectory) - const service = createOwnedService(fresh, async () => true) - try { - await assert.rejects(service.endpoint({ ...fresh.options("owner", true), timeoutMs: 10, staleLockMs: 60_000 }), /lifecycle lock/) - await access(fresh.lockDirectory) - } finally { - await rm(fresh.root, { recursive: true, force: true }) - } - }) - it("prunes an identity-checked lease after PID reuse", async () => { const state = await serviceState("codenomad-service-stale-lease-") const stalePid = 7654321 @@ -393,45 +186,6 @@ describe("OpenCodeSharedService", () => { } }) - it("recovers a registration written after a predecessor launch intent", async () => { - const state = await serviceState("codenomad-service-crash-window-") - const deadPid = 7654321 - const launchCreatedAt = Date.now() - 1_000 - const options = state.options("successor", false) - const launchSignature = signature(options) - await writeFile(state.lease("dead-owner"), JSON.stringify({ - version: 1, - identity: "dead-owner", - pid: deadPid, - processIdentity: processIdentity(deadPid, "dead-codenomad"), - createdAt: launchCreatedAt, - updatedAt: launchCreatedAt, - state: "active", - launchSignature, - launch: { - identity: "launch-before-crash", - createdAt: launchCreatedAt, - nativePid: true, - contenderFile: state.contenders, - }, - })) - const service = new OpenCodeSharedService({ - discover: async () => ({ url: state.info.url, auth: { type: "basic", username: "opencode", password: state.info.password } }), - headers: () => undefined, - isProcessAlive: (pid) => pid !== deadPid, - getProcessIdentity: async (pid, _timeoutMs, namespace = { kind: "host" }) => processIdentity(pid, undefined, namespace), - makeClient: () => ({} as OpenCodeClient), - }) - try { - await service.endpoint(options) - const lease = JSON.parse(await readFile(state.lease("successor"), "utf8")) - assert.deepEqual(lease.service.processIdentity, processIdentity(state.info.pid)) - assert.equal(lease.service.info.pid, state.info.pid) - } finally { - await rm(state.root, { recursive: true, force: true }) - } - }) - it("prunes old invalid lease artifacts while preserving fresh writes", async () => { const state = await serviceState("codenomad-service-invalid-leases-") const oldLease = state.lease("invalid") @@ -521,27 +275,6 @@ describe("OpenCodeSharedService", () => { } }) - it("replaces stale in-memory ownership with a newer transferred service proof", async () => { - const state = await serviceState("codenomad-service-replacement-owner-") - const requested: string[] = [] - const staleOwner = createOwnedService(state, async (info) => { requested.push(info.id!); return true }) - const replacementOwner = createOwnedService(state, async () => { throw new Error("peer must not stop while an owner remains") }) - try { - await staleOwner.endpoint(state.options("stale-owner", true)) - Object.assign(state.info, { id: "instance-2", pid: 5678, url: "http://127.0.0.1:5678", password: "replacement-secret" }) - await writeFile(state.file, JSON.stringify(state.info)) - await writeFile(state.contenders, `${state.info.pid}\n`) - await replacementOwner.endpoint(state.options("replacement-owner", true)) - - await replacementOwner.shutdown() - assert.deepEqual(requested, []) - await staleOwner.shutdown() - assert.deepEqual(requested, ["instance-2"]) - } finally { - await rm(state.root, { recursive: true, force: true }) - } - }) - it("stops the proven endpoint without following a registration swap", async () => { const state = await serviceState("codenomad-service-swap-") const replacement = { ...state.info, id: "replacement", pid: 9999, url: "http://127.0.0.1:9999" } @@ -606,49 +339,6 @@ describe("OpenCodeSharedService", () => { } }) - it("bounds a stalled service stop", async () => { - const state = await serviceState("codenomad-service-stop-timeout-") - let stops = 0 - const service = createOwnedService( - state, - async () => { stops += 1; return new Promise(() => undefined) }, - true, - undefined, - async () => false, - ) - try { - await service.endpoint(state.options("owner", true)) - await assert.rejects(service.shutdown({ timeoutMs: 10 }), /stop timed out/) - const lease = JSON.parse(await readFile(state.lease("owner"), "utf8")) - assert.equal(lease.state, "stopping") - await assert.rejects(service.shutdown({ timeoutMs: 10 }), /uncertain outcome/) - assert.equal(stops, 1) - } finally { - await rm(state.root, { recursive: true, force: true }) - } - }) - - it("retains ownership until an accepted stop actually completes", async () => { - const state = await serviceState("codenomad-service-stop-completion-") - let finishStop!: () => void - let markAccepted!: () => void - const accepted = new Promise((resolve) => { markAccepted = resolve }) - const completion = new Promise((resolve) => { finishStop = () => resolve(true) }) - const service = createOwnedService(state, async () => { markAccepted(); return true }, true, undefined, async () => completion) - try { - await service.endpoint(state.options("owner", true)) - const shutdown = service.shutdown({ timeoutMs: 100 }) - await accepted - await access(state.lease("owner")) - assert.equal(JSON.parse(await readFile(state.lease("owner"), "utf8")).state, "stopping") - finishStop() - await shutdown - await assert.rejects(access(state.lease("owner"))) - } finally { - await rm(state.root, { recursive: true, force: true }) - } - }) - it("checks WSL stop completion in the distro despite a coincidental live Windows PID", async () => { const state = await serviceState("codenomad-service-wsl-stop-") let healthChecks = 0 @@ -713,54 +403,35 @@ describe("OpenCodeSharedService", () => { } }) - it("bounds a stalled ensure", async () => { - const service = new OpenCodeSharedService({ + it("bounds stalled ensure and stop operations", async () => { + const stalledEnsure = new OpenCodeSharedService({ discover: async () => undefined, ensure: async () => new Promise(() => undefined), headers: () => undefined, makeClient: () => ({} as OpenCodeClient), }) - await assert.rejects(service.endpoint({ timeoutMs: 10 }), /timed out after 10ms/) - }) + await assert.rejects(stalledEnsure.endpoint({ timeoutMs: 10 }), /timed out after 10ms/) - it("reconciles a late uncancellable ensure during shutdown", async () => { - const state = await serviceState("codenomad-service-late-ensure-") - let finishEnsure!: () => void - let finishStop!: () => void - let markStopStarted!: () => void - const stopStarted = new Promise((resolve) => { markStopStarted = resolve }) - const stopCompletion = new Promise((resolve) => { finishStop = () => resolve(true) }) + const state = await serviceState("codenomad-service-stop-timeout-") let stops = 0 - const service = new OpenCodeSharedService({ - discover: async () => undefined, - ensure: (options) => new Promise((resolve) => { - options?.onStart?.("missing") - finishEnsure = () => resolve({ - url: state.info.url, - auth: { type: "basic", username: "opencode", password: state.info.password }, - }) - }), - headers: () => undefined, - requestStop: async () => { stops += 1; markStopStarted(); return true }, - waitForStop: async () => stopCompletion, - getProcessIdentity: async (pid, _timeoutMs, namespace = { kind: "host" }) => processIdentity(pid, undefined, namespace), - makeClient: () => ({} as OpenCodeClient), - }) + const stalledStop = createOwnedService( + state, + async () => { stops += 1; return new Promise(() => undefined) }, + true, + undefined, + async () => false, + ) try { - await assert.rejects(service.endpoint({ ...state.options("owner", true), timeoutMs: 10 }), /ensure timed out/) - await access(state.lease("owner")) - await assert.rejects(service.shutdown({ timeoutMs: 10 }), /launch reconciliation timed out/) - finishEnsure() - await stopStarted - const reconciliation = service.shutdown({ timeoutMs: 100 }) - finishStop() - await reconciliation + await stalledStop.endpoint(state.options("owner", true)) + await assert.rejects(stalledStop.shutdown({ timeoutMs: 10 }), /stop timed out/) + assert.equal(JSON.parse(await readFile(state.lease("owner"), "utf8")).state, "stopping") + await assert.rejects(stalledStop.shutdown({ timeoutMs: 10 }), /uncertain outcome/) assert.equal(stops, 1) - await assert.rejects(access(state.lease("owner"))) } finally { await rm(state.root, { recursive: true, force: true }) } }) + }) async function serviceState(prefix: string) { diff --git a/packages/ui/src/components/form-request-auto-open.test.ts b/packages/ui/src/components/form-request-auto-open.test.ts deleted file mode 100644 index 1984e16d..00000000 --- a/packages/ui/src/components/form-request-auto-open.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import assert from "node:assert/strict" -import { describe, it } from "node:test" -import { getFormRequestAutoOpenId } from "./form-request-auto-open.ts" - -describe("form request auto-open", () => { - it("opens each new form once without changing permission or question behavior", () => { - assert.equal(getFormRequestAutoOpenId({ kind: "form", id: "form-1" }, null), "form-1") - assert.equal(getFormRequestAutoOpenId({ kind: "form", id: "form-1" }, "form-1"), null) - assert.equal(getFormRequestAutoOpenId({ kind: "form", id: "form-2" }, "form-1"), "form-2") - assert.equal(getFormRequestAutoOpenId({ kind: "permission", id: "permission-1" }, null), null) - assert.equal(getFormRequestAutoOpenId({ kind: "question", id: "question-1" }, null), null) - }) -}) diff --git a/packages/ui/src/components/form-request-tool-target.test.ts b/packages/ui/src/components/form-request-tool-target.test.ts index ddb21f01..b62d8a52 100644 --- a/packages/ui/src/components/form-request-tool-target.test.ts +++ b/packages/ui/src/components/form-request-tool-target.test.ts @@ -1,48 +1,8 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" -import { - resolveFormToolTarget, - shouldRenderFormInFallback, -} from "./form-request-tool-target.ts" - -function store(messages: Record, ids = Object.keys(messages)) { - return { - getSessionMessageIds: () => ids, - getMessage: (messageId: string) => messages[messageId], - } -} +import { shouldRenderFormInFallback } from "./form-request-tool-target.ts" describe("form request tool target", () => { - it("resolves the explicit question tool reference", () => { - const form = { - id: "form-question", sessionID: "session", title: "Questions", fields: [], - metadata: { kind: "question", tool: { messageID: "message-1", id: "call-1" } }, - } as any - const target = resolveFormToolTarget(form, store({ - "message-1": { partIds: ["part-1"], parts: { "part-1": { data: { id: "part-1", type: "tool", tool: "question", callID: "call-1" } } } }, - })) - - assert.deepEqual(target, { messageId: "message-1", partId: "part-1" }) - }) - - it("attaches web search provider selection to the latest websearch call", () => { - const form = { - id: "form-websearch", sessionID: "session", title: "Web Search", fields: [], - metadata: { kind: "websearch.provider" }, - } as any - const target = resolveFormToolTarget(form, store({ - old: { partIds: ["old-part"], parts: { "old-part": { data: { type: "tool", tool: "websearch" } } } }, - current: { partIds: ["current-part"], parts: { "current-part": { data: { type: "tool", tool: "websearch" } } } }, - }, ["old", "current"])) - - assert.deepEqual(target, { messageId: "current", partId: "current-part" }) - }) - - it("leaves global forms unscoped", () => { - const form = { id: "global", sessionID: "session", title: "Global", fields: [] } as any - assert.equal(resolveFormToolTarget(form, store({})), null) - }) - it("keeps an inline form out of the fallback while its tool call is still arriving", () => { const form = { id: "form-question", sessionID: "other", title: "Questions", fields: [], diff --git a/packages/ui/src/components/form-request.test.ts b/packages/ui/src/components/form-request.test.ts index da983610..b1a401ca 100644 --- a/packages/ui/src/components/form-request.test.ts +++ b/packages/ui/src/components/form-request.test.ts @@ -3,22 +3,15 @@ import { describe, it } from "node:test" import { formatFormStringInputValue, getFormFieldDefaultValue, - getFormStringInputType, normalizeFormStringValue, - shouldRenderFormOptionsAsSelect, - shouldRenderFormOptionsInline, } from "./form-request.tsx" -import { isFormFieldVisible, isHttpFormUrl } from "../lib/form-schema.ts" +import { isHttpFormUrl } from "../lib/form-schema.ts" describe("form request protocol mapping", () => { it("treats a required boolean as present even when false", () => { assert.equal(getFormFieldDefaultValue({ key: "enabled", type: "boolean", required: true }), false) }) - it("maps protocol URI fields to the HTML URL input type", () => { - assert.equal(getFormStringInputType("uri"), "url") - }) - it("round-trips datetime-local values through RFC3339", () => { const local = "2026-08-14T12:34" const protocol = normalizeFormStringValue("date-time", local) @@ -27,28 +20,9 @@ describe("form request protocol mapping", () => { assert.equal(normalizeFormStringValue("date-time", "invalid"), undefined) }) - it("applies protocol visibility semantics to unanswered and multiselect values", () => { - const equalField = { type: "string", key: "detail", when: [{ key: "choices", op: "eq", value: "one" }] } as any - const notEqualField = { type: "string", key: "detail", when: [{ key: "choices", op: "neq", value: "one" }] } as any - - assert.equal(isFormFieldVisible(equalField, {}), false) - assert.equal(isFormFieldVisible(notEqualField, {}), false) - assert.equal(isFormFieldVisible(equalField, { choices: ["one", "two"] }), true) - assert.equal(isFormFieldVisible(notEqualField, { choices: ["one", "two"] }), false) - assert.equal(isFormFieldVisible(notEqualField, { choices: ["two"] }), true) - }) - it("allows only explicit HTTP external links", () => { assert.equal(isHttpFormUrl("https://example.com/form"), true) assert.equal(isHttpFormUrl("javascript:alert(1)"), false) assert.equal(isHttpFormUrl("file:///tmp/form"), false) }) - - it("uses visible choices for short option lists and menus for long lists", () => { - assert.equal(shouldRenderFormOptionsInline([{ value: "one" }, { value: "two" }]), true) - assert.equal(shouldRenderFormOptionsInline(Array.from({ length: 5 })), false) - assert.equal(shouldRenderFormOptionsInline([]), false) - assert.equal(shouldRenderFormOptionsAsSelect(Array.from({ length: 5 })), true) - assert.equal(shouldRenderFormOptionsAsSelect(Array.from({ length: 4 })), false) - }) }) diff --git a/packages/ui/src/components/message-timeline-v2.test.ts b/packages/ui/src/components/message-timeline-v2.test.ts index 50700b26..045c1691 100644 --- a/packages/ui/src/components/message-timeline-v2.test.ts +++ b/packages/ui/src/components/message-timeline-v2.test.ts @@ -30,25 +30,7 @@ describe("V2 timeline projection", () => { assert.notEqual(getTimelineRecordSignature(provisional), getTimelineRecordSignature(authoritative)) }) - it("changes its structural signature when a same-id part changes type or renderability", () => { - const text = record([{ id: "part", type: "text", text: "hello" }]) - const empty = record([{ id: "part", type: "text", text: "" }]) - const tool = record([{ id: "part", type: "tool", revision: 1 }]) - - assert.notEqual(getTimelineRecordSignature(text), getTimelineRecordSignature(empty)) - assert.notEqual(getTimelineRecordSignature(text), getTimelineRecordSignature(tool)) - }) - - it("accepts provisional text and reasoning parts without text", () => { - assert.doesNotThrow(() => getTimelineRecordSignature(record([{ id: "text", type: "text" }]))) - assert.doesNotThrow(() => getTimelineRecordSignature(record([{ id: "reasoning", type: "reasoning" }]))) - assert.equal( - getTimelineRecordSignature(record([{ id: "text", type: "text" }])), - getTimelineRecordSignature(record([{ id: "text", type: "text", text: "" }])), - ) - }) - - it("throttles streamed text projection updates and tracks tool revisions", () => { + it("throttles text updates but invalidates tool and terminal state changes", () => { const shortText = record([{ id: "text", type: "text", text: "a" }]) const sameBucketText = record([{ id: "text", type: "text", text: "a longer streamed value", revision: 20 }]) const nextBucketText = record([{ id: "text", type: "text", text: "a".repeat(129), revision: 40 }]) @@ -58,12 +40,8 @@ describe("V2 timeline projection", () => { assert.equal(getTimelineRecordSignature(shortText), getTimelineRecordSignature(sameBucketText)) assert.notEqual(getTimelineRecordSignature(sameBucketText), getTimelineRecordSignature(nextBucketText)) assert.notEqual(getTimelineRecordSignature(firstTool), getTimelineRecordSignature(updatedTool)) - }) - - it("invalidates the projection once when streaming reaches a terminal status", () => { const streaming = record([{ id: "text", type: "text", text: "partial" }]) const complete = { ...record([{ id: "text", type: "text", text: "final response" }]), status: "complete" as const } - assert.notEqual(getTimelineRecordSignature(streaming), getTimelineRecordSignature(complete)) }) }) diff --git a/packages/ui/src/lib/hooks/use-app-session-capture.test.ts b/packages/ui/src/lib/hooks/use-app-session-capture.test.ts index f19c0729..14c26d45 100644 --- a/packages/ui/src/lib/hooks/use-app-session-capture.test.ts +++ b/packages/ui/src/lib/hooks/use-app-session-capture.test.ts @@ -1,78 +1,15 @@ import assert from "node:assert/strict" import { readFileSync } from "node:fs" -import { describe, it } from "node:test" +import { it } from "node:test" -const source = (file: string) => readFileSync(new URL(file, import.meta.url), "utf8") +const capture = readFileSync(new URL("./use-app-session-capture.ts", import.meta.url), "utf8") -describe("app session capture listener readiness", () => { - it("waits for both Tauri flush listeners before restore starts capture", () => { - const capture = source("./use-app-session-capture.ts") - const restore = source("./use-app-session-restore.ts") - const ready = capture.slice(capture.indexOf("const ready ="), capture.indexOf("const markScrollAuthority")) - assert.match(ready, /Promise\.all/) - assert.match(ready, /client-state:flush-requested/) - assert.match(ready, /client-state:navigation-flush-requested/) - assert.ok(restore.indexOf("await capture.ready") < restore.indexOf("capture.start(")) - }) - - it("uses the serialized commit queue without serializing create requests", () => { - const restore = source("./use-app-session-restore.ts") - assert.match(restore, /runWithSerializedCommits/) - assert.match(restore, /waitForCreateCommit/) - assert.doesNotMatch(restore, /for \(const match of missing\) await restoreWorkspace/) - }) - - it("lets the exact restored active tab replace non-user startup selection", () => { - const restore = source("./use-app-session-restore.ts") - assert.match(restore, /if \(!requested && \(current \|\| ownedActiveTabId\)\) return/) - assert.ok(restore.indexOf("appTabSelectionRevision() !== selectionRevision") < restore.indexOf("selectAppTab(tabId")) - }) - - it("does not track prompt hydration writes in the capture effect", () => { - const capture = source("./use-app-session-capture.ts") - assert.match(capture, /untrack\(\(\) => hydratePreservedPrompts/) - }) - - it("reapplies full preserved state after transient reopen hydration", () => { - const capture = source("./use-app-session-capture.ts") - assert.match(capture, /waitForInstanceInitialSessionHydration/) - assert.match(capture, /hydrateRestoredWorkspaceState/) - assert.match(capture, /settlePreservedTab/) - }) - - it("does not overwrite the native shutdown flush during teardown", () => { - const capture = source("./use-app-session-capture.ts") - const start = capture.lastIndexOf("onCleanup(() => {") - const cleanup = capture.slice(start, capture.indexOf("return {", start)) - assert.match(cleanup, /disposed = true/) - assert.match(cleanup, /if \(timer\) clearTimeout\(timer\)/) - assert.doesNotMatch(cleanup, /flush\(\)/) - }) - - it("uses browser lifecycle flushes only when no local native host owns shutdown", () => { - const capture = source("./use-app-session-capture.ts") - assert.match(capture, /const useBrowserLifecycleFlush = !isLocalWindow\(\)/) - assert.match(capture, /if \(useBrowserLifecycleFlush\) \{\s*window\.addEventListener\("pagehide"/) - }) - - it("does not replace settled tabs with transient teardown state during a native flush", () => { - const capture = source("./use-app-session-capture.ts") - assert.match(capture, /nativeShutdown\s*&& current\.tabs\.length === 0/) - assert.match(capture, /\(nativeFallbackState\?\.tabs\.length \?\? 0\) > 0/) - assert.match(capture, /void flush\(true\)\.then/) - }) - - it("makes native shutdown terminal for later reactive captures", () => { - const capture = source("./use-app-session-capture.ts") - assert.match(capture, /if \(nativeShutdown\) nativeShutdownStarted = true/) - assert.match(capture, /if \(!enabled\(\) \|\| disposed \|\| nativeShutdownStarted\) return/) - assert.match(capture, /enabled\(\) && !disposed && !nativeShutdownStarted/) - }) - - it("clears the native fallback only after an authoritative workspace removal", () => { - const capture = source("./use-app-session-capture.ts") - const removed = capture.slice(capture.indexOf('if (event.type === "removed")'), capture.indexOf("} else {", capture.indexOf('if (event.type === "removed")'))) - assert.match(removed, /markPreservedWorkspaceRemoved/) - assert.match(removed, /nativeFallbackState = authoritativeState\.tabs\.length > 0 \? authoritativeState : null/) - }) +it("preserves settled tabs during native shutdown", () => { + assert.match(capture, /nativeShutdown\s*&& current\.tabs\.length === 0/) + assert.match(capture, /\(nativeFallbackState\?\.tabs\.length \?\? 0\) > 0/) +}) + +it("makes native shutdown terminal for reactive captures", () => { + assert.match(capture, /if \(nativeShutdown\) nativeShutdownStarted = true/) + assert.match(capture, /if \(!enabled\(\) \|\| disposed \|\| nativeShutdownStarted\) return/) }) diff --git a/packages/ui/src/stores/abort-created-workspace-cleanup.test.ts b/packages/ui/src/stores/abort-created-workspace-cleanup.test.ts index b6f7a4d9..a494001d 100644 --- a/packages/ui/src/stores/abort-created-workspace-cleanup.test.ts +++ b/packages/ui/src/stores/abort-created-workspace-cleanup.test.ts @@ -6,6 +6,7 @@ import { AbortCreatedWorkspaceCleanup } from "./abort-created-workspace-cleanup. interface TestWorkspace { id: string; status: "starting" | "ready"; requestId?: string; reused?: boolean } const workspace = (id: string, requestId?: string): TestWorkspace => ({ id, status: "ready", requestId }) async function flushPromises() { await Promise.resolve(); await Promise.resolve() } + function createHarness(options: { failures?: number; pending?: boolean; retryDelay?: number } = {}) { let discardCalls = 0 let finishDiscard: (() => void) | undefined @@ -38,98 +39,24 @@ describe("abort-created workspace cleanup", () => { assert.equal(harness.cleanup.trackPendingRequest(item), true) await harness.cleanup.discardTracked(item.id, { retainTombstone: true }) harness.cleanup.finishRequest(item.requestId!) + assert.equal(harness.discardCalls, 1) assert.equal(harness.discarded[0]?.requestId, "restore-request") - assert.deepEqual(harness.restored, []) assert.equal(harness.cleanup.shouldIgnoreEvent(item.id), true) }) it("does not tombstone a reused workspace after request cancellation", async () => { - const item = { ...workspace("shared workspace", "restore-request"), reused: true }, harness = createHarness() + const item = { ...workspace("shared workspace", "restore-request"), reused: true } + const harness = createHarness() harness.cleanup.beginRequest(item.requestId!) harness.cleanup.quarantineRequest(item.requestId!) assert.equal(harness.cleanup.trackPendingRequest(item), true) await flushPromises() + assert.equal(harness.discardCalls, 1) assert.equal(harness.cleanup.shouldIgnoreEvent(item.id), false) }) - it("forgets only the matching request for a shared workspace", () => { - const leader = workspace("shared", "leader"), harness = createHarness() - harness.cleanup.track(leader) - harness.cleanup.forgetRequest(leader.id, "follower") - assert.equal(harness.cleanup.get(leader.id)?.requestId, "leader") - harness.cleanup.forgetRequest(leader.id, "leader") - assert.equal(harness.cleanup.get(leader.id), undefined) - }) - - it("keeps a workspace user-owned when release succeeds during cancellation", async () => { - const item = workspace("released-during-cancel", "restore-request"), harness = createHarness() - harness.cleanup.track(item) - let finishRelease!: () => void - const release = harness.cleanup.releaseAfter(item.id, () => new Promise((resolve) => { finishRelease = resolve })) - await harness.cleanup.discardTracked(item.id, { retainTombstone: true }) - finishRelease() - const released = await release - harness.cleanup.track(item) - await harness.cleanup.discardTracked(item.id, { retainTombstone: true }) - assert.equal(released?.id, item.id) - assert.equal(harness.cleanup.owns(item.id), false) - assert.equal(harness.discardCalls, 0) - }) - - it("restores cleanup ownership when server release fails", async () => { - const item = workspace("failed-release", "restore-request"), harness = createHarness() - harness.cleanup.track(item) - await assert.rejects(harness.cleanup.releaseAfter(item.id, () => Promise.reject(new Error("release failed")))) - assert.equal(harness.cleanup.owns(item.id), true) - await harness.cleanup.discardTracked(item.id) - assert.equal(harness.discardCalls, 1) - }) - - it("retries rejected cancellation and releases quarantine after success", async () => { - const item = workspace("created"), harness = createHarness({ failures: 1, retryDelay: 25 }) - harness.cleanup.track(item) - const completion = harness.cleanup.discardTracked(item.id) - await flushPromises() - assert.equal(harness.discardCalls, 1) - assert.equal(harness.cleanup.shouldIgnoreEvent(item.id), true) - assert.deepEqual(harness.waits.map(({ delayMs }) => delayMs), [25]) - harness.waits[0]?.resolve(); await completion - assert.equal(harness.discardCalls, 2) - assert.equal(harness.cleanup.shouldIgnoreEvent(item.id), false) - assert.equal(harness.cleanup.owns(item.id), false) - assert.deepEqual(harness.restored, []) - }) - - it("ignores matching events only while cancellation is pending", async () => { - const item = workspace("created"), harness = createHarness({ pending: true }) - const completion = harness.cleanup.discardCreated(item) - assert.equal(harness.cleanup.shouldIgnoreEvent(item.id), true) - assert.equal(harness.cleanup.shouldIgnoreEvent("pre-existing"), false) - harness.finishDiscard(); await completion - assert.equal(harness.cleanup.shouldIgnoreEvent(item.id), false) - }) - - it("retains cancellation quarantine across delayed events and create resolution", async () => { - const item = workspace("cancelled-restore"), harness = createHarness() - harness.cleanup.track(item) - await harness.cleanup.discardTracked(item.id, { retainTombstone: true }) - assert.equal(harness.cleanup.shouldIgnoreEvent(item.id), true, "delayed created/started events stay quarantined") - harness.cleanup.track(item) - assert.equal(harness.cleanup.shouldIgnoreEvent(item.id), true, "late create resolution stays quarantined") - assert.equal(harness.cleanup.owns(item.id), true) - }) - - it("keeps failed cancellation correlation quarantined until late creation is reconciled", async () => { - const item = workspace("late-after-failed-cancel", "failed-request"), harness = createHarness({ pending: true }) - harness.cleanup.beginRequest("failed-request"); harness.cleanup.quarantineRequest("failed-request") - assert.equal(harness.cleanup.trackPendingRequest(item), true) - assert.equal(harness.cleanup.shouldIgnoreEvent(item.id), true, "late workspace.created is never admitted") - harness.finishDiscard(); await flushPromises() - assert.equal(harness.cleanup.shouldIgnoreEvent(item.id), true, "reconciled creation retains its tombstone") - }) - it("adopts an event-before-abort workspace into one bounded cleanup", async () => { const item = workspace("event-before-abort", "restore-request") const harness = createHarness({ failures: 1, retryDelay: 25 }) @@ -139,41 +66,14 @@ describe("abort-created workspace cleanup", () => { const cleanup = harness.cleanup.quarantineRequest(item.requestId!) const duplicate = harness.cleanup.quarantineRequest(item.requestId!) await flushPromises() - assert.equal(harness.discardCalls, 1, "quarantine does not start a parallel cancellation") - assert.equal(harness.cleanup.shouldIgnoreEvent(item.id), true) - assert.deepEqual(harness.waits.map(({ delayMs }) => delayMs), [25]) - + assert.equal(harness.discardCalls, 1) harness.waits[0]?.resolve() await Promise.all([cleanup, duplicate]) - assert.equal(harness.discardCalls, 2, "the failed delete is retried once") - assert.equal(harness.cleanup.shouldIgnoreEvent(item.id), true) - }) - - it("clears a durable tombstone only for explicit user-owned create correlation", async () => { - const item = workspace("reused-id"), harness = createHarness() - await harness.cleanup.discardCreated(item, { retainTombstone: true }) - harness.cleanup.track(item); harness.cleanup.release(item.id) - assert.equal(harness.cleanup.shouldIgnoreEvent(item.id), true, "ordinary track/release cannot clear tombstone") - harness.cleanup.releaseTombstoneForUserCreate(item.id) - assert.equal(harness.cleanup.shouldIgnoreEvent(item.id), false) - assert.equal(harness.cleanup.owns(item.id), false) - harness.cleanup.releaseTombstoneForUserCreate("ordinary-user-workspace") - assert.equal(harness.cleanup.owns("ordinary-user-workspace"), false) - }) - - it("restores a running workspace after bounded cancellation failures", async () => { - const item = workspace("still-running"), harness = createHarness({ failures: 2, retryDelay: 50 }) - const completion = harness.cleanup.discardCreated(item) - await flushPromises() - assert.equal(harness.cleanup.shouldIgnoreEvent(item.id), true) - harness.waits[0]?.resolve(); await completion assert.equal(harness.discardCalls, 2) - assert.deepEqual(harness.restored, [item]) - assert.equal(harness.cleanup.shouldIgnoreEvent(item.id), false) - assert.equal(harness.cleanup.owns(item.id), false) + assert.equal(harness.cleanup.shouldIgnoreEvent(item.id), true) }) - it("restores the newest correlated descriptor after cancellation failures", async () => { + it("restores the newest descriptor after bounded cancellation failures", async () => { const starting = { ...workspace("progressed", "request"), status: "starting" as const } const ready = { ...starting, status: "ready" as const } const harness = createHarness({ failures: 2, retryDelay: 50 }) @@ -181,70 +81,44 @@ describe("abort-created workspace cleanup", () => { const completion = harness.cleanup.discardTracked(starting.id) await flushPromises() harness.cleanup.track(ready) - harness.waits[0]?.resolve(); await completion + harness.waits[0]?.resolve() + await completion + assert.equal(harness.discarded[1]?.status, "ready") assert.deepEqual(harness.restored, [ready]) }) - it("never discards untracked or released workspaces", async () => { - const harness = createHarness() - await harness.cleanup.discardTracked("pre-existing") - harness.cleanup.track(workspace("completed-restore")); harness.cleanup.release("completed-restore") - harness.cleanup.track(workspace("completed-restore")) - await harness.cleanup.discardTracked("completed-restore") - assert.equal(harness.discardCalls, 0) - }) - - it("transfers a tracked workspace to user ownership before cleanup", async () => { - const item = workspace("selected-during-restore", "restore-request"), harness = createHarness() - harness.cleanup.track(item) - assert.equal(harness.cleanup.release(item.id), item) - await harness.cleanup.discardTracked(item.id, { retainTombstone: true }) - assert.equal(harness.cleanup.owns(item.id), false) - assert.equal(harness.discardCalls, 0) - }) - - it("does not start a lazy release after explicit close owns cancellation", async () => { - const item = workspace("closed-before-release", "restore-request") - const harness = createHarness({ pending: true }) - harness.cleanup.track(item) - const deletion = harness.cleanup.discardTracked(item.id, { retainTombstone: true }) - let releaseCalls = 0 - - const released = await harness.cleanup.releaseAfter(item.id, async () => { releaseCalls += 1 }) - - assert.equal(released, undefined) - assert.equal(releaseCalls, 0) - assert.equal(harness.cleanup.shouldIgnoreEvent(item.id), true) - harness.finishDiscard() - await deletion - }) - - it("correlates created before resolution and quarantines explicit-close races", async () => { + it("quarantines created and started events after explicit close", async () => { const item = { ...workspace("slow-restore", "restore-request"), status: "starting" as const } const harness = createHarness({ pending: true }) harness.cleanup.beginRequest("restore-request") assert.equal(harness.cleanup.trackPendingRequest(item), true) - assert.equal(harness.cleanup.shouldIgnoreEvent(item.id), false, "initial correlated created event is accepted") + assert.equal(harness.cleanup.shouldIgnoreEvent(item.id), false) + const deletion = harness.cleanup.discardTracked(item.id, { retainTombstone: true }) - assert.equal(harness.cleanup.shouldIgnoreEvent(item.id), true, "started event and create resolution are quarantined") - assert.equal(harness.discardCalls, 1) - harness.finishDiscard(); await deletion; harness.cleanup.finishRequest("restore-request") - assert.equal(harness.cleanup.shouldIgnoreEvent(item.id), true, "late created/started events stay quarantined") + assert.equal(harness.cleanup.shouldIgnoreEvent(item.id), true) + harness.finishDiscard() + await deletion + harness.cleanup.finishRequest("restore-request") harness.cleanup.track(item) - assert.equal(harness.cleanup.shouldIgnoreEvent(item.id), true, "late create resolution stays quarantined") - assert.equal(harness.cleanup.owns(item.id), true) + assert.equal(harness.cleanup.shouldIgnoreEvent(item.id), true) }) - it("releases explicit-close quarantine after failed deletion reconciliation", async () => { - const item = workspace("reconcile", "request"), harness = createHarness({ failures: 2, retryDelay: 10 }) - harness.cleanup.beginRequest("request"); assert.equal(harness.cleanup.trackPendingRequest(item), true) - const completion = harness.cleanup.discardTracked(item.id, { retainTombstone: true }) - await flushPromises() - assert.equal(harness.cleanup.shouldIgnoreEvent(item.id), true) - harness.waits[0]?.resolve(); await completion - assert.deepEqual(harness.restored, [item]) - assert.equal(harness.cleanup.shouldIgnoreEvent(item.id), false) + it("transfers cleanup ownership only after release succeeds", async () => { + const item = workspace("released-during-cancel", "restore-request") + const harness = createHarness() + harness.cleanup.track(item) + let finishRelease!: () => void + const release = harness.cleanup.releaseAfter(item.id, () => new Promise((resolve) => { finishRelease = resolve })) + await harness.cleanup.discardTracked(item.id, { retainTombstone: true }) + finishRelease() + assert.equal((await release)?.id, item.id) assert.equal(harness.cleanup.owns(item.id), false) + assert.equal(harness.discardCalls, 0) + + const failed = workspace("failed-release", "restore-request") + harness.cleanup.track(failed) + await assert.rejects(harness.cleanup.releaseAfter(failed.id, () => Promise.reject(new Error("release failed")))) + assert.equal(harness.cleanup.owns(failed.id), true) }) }) diff --git a/packages/ui/src/stores/app-session-restore-gate.test.ts b/packages/ui/src/stores/app-session-restore-gate.test.ts deleted file mode 100644 index b4989693..00000000 --- a/packages/ui/src/stores/app-session-restore-gate.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import assert from "node:assert/strict" -import { it } from "node:test" - -import { shouldShowAppHomeOverlay, shouldShowAppRestoreLoading } from "./app-session-restore-gate.ts" - -const tab = { kind: "sidecar" as const, sidecarId: "preview" } - -it("shows loading while saved tabs are restoring", () => { - assert.equal(shouldShowAppRestoreLoading({ tabs: [tab], activeTabIndex: 0 }, true), true) - assert.equal(shouldShowAppRestoreLoading({ tabs: [tab], activeTabIndex: 0, homeActive: true }, true), false) - assert.equal(shouldShowAppRestoreLoading({ tabs: [], activeTabIndex: -1 }, true), false) - assert.equal(shouldShowAppRestoreLoading({ tabs: [tab], activeTabIndex: 0 }, false), false) -}) - -it("mounts the requested home overlay only when tabs exist", () => { - assert.equal(shouldShowAppHomeOverlay(true, 0), false) - assert.equal(shouldShowAppHomeOverlay(true, 1), true) - assert.equal(shouldShowAppHomeOverlay(false, 1), false) -}) diff --git a/packages/ui/src/stores/app-session-restore-readiness.test.ts b/packages/ui/src/stores/app-session-restore-readiness.test.ts deleted file mode 100644 index 5832d642..00000000 --- a/packages/ui/src/stores/app-session-restore-readiness.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import assert from "node:assert/strict" -import { describe, it } from "node:test" -import { shouldWaitForSavedSessionList } from "./app-session-restore-readiness.ts" - -describe("app session restore readiness", () => { - it("does not wait for a full workspace scan after direct saved-session hydration succeeds", () => { - assert.equal(shouldWaitForSavedSessionList("parent", "child", new Set()), false) - }) - - it("waits for the authoritative list only when a saved selection is still unavailable", () => { - assert.equal(shouldWaitForSavedSessionList("parent", "child", new Set(["child"])), true) - assert.equal(shouldWaitForSavedSessionList(null, "info", new Set(["info"])), false) - }) -}) diff --git a/packages/ui/src/stores/app-session-restore-timeout.test.ts b/packages/ui/src/stores/app-session-restore-timeout.test.ts index fc97aa36..7c5adb42 100644 --- a/packages/ui/src/stores/app-session-restore-timeout.test.ts +++ b/packages/ui/src/stores/app-session-restore-timeout.test.ts @@ -6,15 +6,7 @@ const deferred = () => { const promise = new Promise((done) => { resolve = done }) return { promise, resolve } } -const tick = () => new Promise((resolve) => setTimeout(resolve, 0)) describe("app session restore timeouts", () => { - it("rejects an operation that does not settle within its bound", async () => { - let operationSignal: AbortSignal | undefined - await assert.rejects(runAbortable((signal) => { - operationSignal = signal; return new Promise(() => {}) - }, { timeoutMs: 5, message: "restore stalled" }), (error) => error instanceof RestoreTimeoutError && error.message === "restore stalled") - assert.equal(operationSignal?.aborted, true) - }) it("deactivates a timed-out restore before its late completion", async () => { const pending = deferred() let lateWrite = false @@ -23,31 +15,7 @@ describe("app session restore timeouts", () => { if (!signal.aborted) lateWrite = true }, { timeoutMs: 5, message: "startup restore stalled" }), RestoreTimeoutError) pending.resolve() - await tick() + await new Promise((resolve) => setTimeout(resolve, 0)) assert.equal(lateWrite, false) }) - it("propagates a deadline abort signal into a nested operation", async () => { - let nestedSignal: AbortSignal | undefined - await assert.rejects(runAbortable((deadlineSignal) => runAbortable((signal) => { - nestedSignal = signal; return new Promise(() => {}) - }, { timeoutMs: 1000, message: "nested stalled", signal: deadlineSignal }), { timeoutMs: 5, message: "deadline stalled" }), RestoreTimeoutError) - assert.equal(nestedSignal?.aborted, true) - }) - it("cancels the restore deadline when its owner is disposed", async () => { - const controller = new AbortController() - let restoreSignal: AbortSignal | undefined - const completion = runAbortable(async (signal) => { - restoreSignal = signal; await new Promise(() => undefined) - }, { timeoutMs: 1_000, message: "deadline stalled", signal: controller.signal }) - controller.abort(new Error("restore disposed")) - await assert.rejects(completion, /restore disposed/) - assert.equal(restoreSignal?.aborted, true) - }) - it("rejects a SideCar load that completes after its restore signal aborts", async () => { - const load = deferred() - const controller = new AbortController() - const completion = runAbortable(() => load.promise, { signal: controller.signal }) - controller.abort(new Error("SideCar restore timed out")); load.resolve() - await assert.rejects(completion, /SideCar restore timed out/) - }) }) diff --git a/packages/ui/src/stores/app-session-restored-session-ids.test.ts b/packages/ui/src/stores/app-session-restored-session-ids.test.ts deleted file mode 100644 index 4fd24693..00000000 --- a/packages/ui/src/stores/app-session-restored-session-ids.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import assert from "node:assert/strict" -import { describe, it } from "node:test" -import { getRestoredSessionIds } from "./app-session-restored-session-ids.ts" - -describe("restored session ids", () => { - it("deduplicates real session ids and excludes synthetic views from direct hydration", () => { - assert.deepEqual(getRestoredSessionIds([ - ["session-1", "__no_session_draft__"], - ["session-1", "session-2", "info"], - ["session-2"], - ]), ["session-1", "session-2"]) - }) -}) diff --git a/packages/ui/src/stores/app-session-snapshot-merge.test.ts b/packages/ui/src/stores/app-session-snapshot-merge.test.ts index d2f24c8b..ceed916c 100644 --- a/packages/ui/src/stores/app-session-snapshot-merge.test.ts +++ b/packages/ui/src/stores/app-session-snapshot-merge.test.ts @@ -6,25 +6,15 @@ import type { RestorableSessionState, RestorableWorkspaceTabState } from "./clie import { createRestorableSessionPreservation, createRestoredTabCommitGuard, - getPreservedWorkspaceState, - getPreservedWorkspaceReopenTarget, - hasRestoredTabBinding, markPreservedWorkspaceRemoved, markPreservedWorkspaceReopened, markPreservedWorkspaceUnavailable, mergeRestorableSessionState, recordRestoredTab, - settleRestoredTab, - type RestorableSessionPreservation, - type RestorableWorkspaceRuntimeAuthority, } from "./app-session-snapshot-merge.ts" -import { reconcileWorkspaceTabs } from "./app-session-reconciliation.ts" const empty = (): RestorableSessionState => ({ tabs: [], activeTabIndex: -1 }) -const session = ( - tabs: RestorableSessionState["tabs"], - activeTabIndex = tabs.length ? 0 : -1, -): RestorableSessionState => ({ tabs, activeTabIndex }) +const session = (tabs: RestorableSessionState["tabs"]): RestorableSessionState => ({ tabs, activeTabIndex: 0 }) const workspace = ( folder: string, occurrence = 0, @@ -33,303 +23,71 @@ const workspace = ( kind: "workspace", folder, occurrence, drafts: {}, attachments: {}, scrollSnapshots: {}, unseenIdleSince: {}, generationRecovery: {}, ...state, }) -const sidecar = (sidecarId: string) => ({ kind: "sidecar" as const, sidecarId }) -const attachment = (id = "paste", path?: string): RestorableAttachment => ({ - id, type: path ? "file" : "text", display: id, url: "", filename: `${id}.txt`, mediaType: "text/plain", - source: path ? { type: "file", path, mime: "text/plain" } : { type: "text", value: `${id} content` }, +const attachment = (id: string): RestorableAttachment => ({ + id, type: "text", display: id, url: "", filename: `${id}.txt`, mediaType: "text/plain", + source: { type: "text", value: `${id} content` }, }) -const scroll = (scrollTop: number, updatedAt = 1) => ({ scrollTop, atBottom: false, updatedAt }) - -function restored( - saved: RestorableSessionState, - mappings: readonly { source: number; runtime?: string | null; unavailable?: readonly string[]; pending?: boolean }[] = [], -): RestorableSessionPreservation { - const preservation = createRestorableSessionPreservation(saved) - for (const mapping of mappings) { - recordRestoredTab( - preservation, - mapping.source, - mapping.runtime ?? null, - mapping.pending ? undefined : new Set(mapping.unavailable), - ) - } - return preservation -} +const scroll = (scrollTop: number) => ({ scrollTop, atBottom: false, updatedAt: 1 }) function workspaceAt(state: RestorableSessionState, index = 0): RestorableWorkspaceTabState { const tab = state.tabs[index] - assert.equal(tab?.kind, "workspace", `tab ${index} should be a workspace`) - if (tab?.kind !== "workspace") throw new Error(`tab ${index} is not a workspace`) + assert.equal(tab?.kind, "workspace") + if (tab?.kind !== "workspace") throw new Error("expected workspace tab") return tab } -const labels = (state: RestorableSessionState) => state.tabs.map((tab) => - tab.kind === "workspace" ? `${tab.folder}:${tab.occurrence}` : tab.sidecarId) -const mergeOne = ( - savedTab: RestorableWorkspaceTabState, - currentTab = workspace(savedTab.folder, savedTab.occurrence), - authority?: RestorableWorkspaceRuntimeAuthority, -) => workspaceAt(mergeRestorableSessionState( - session([currentTab]), - createRestorableSessionPreservation(session([savedTab])), - { currentTabIds: ["instance:work"], currentTabAuthorities: [authority] }, -)) - describe("app session snapshot merge", () => { - it("retains the latest restored tab state after a non-authoritative stop", () => { + it("retains unsent state after a non-authoritative workspace stop", () => { const saved = session([workspace("/work", 0, { drafts: { missing: "saved", current: "old" } })]) - const preservation = restored(saved, [{ source: 0, runtime: "instance:work" }]) + const preservation = createRestorableSessionPreservation(saved) + recordRestoredTab(preservation, 0, "instance:work", new Set()) markPreservedWorkspaceUnavailable( preservation, { runtimeTabId: "instance:work", folder: "/work", occurrence: 0 }, workspace("/work", 0, { drafts: { current: "latest unsent draft" } }), ) - const merged = mergeRestorableSessionState(empty(), preservation, { currentTabIds: [] }) - assert.deepEqual(workspaceAt(merged).drafts, { missing: "saved", current: "latest unsent draft" }) - assert.equal(getPreservedWorkspaceState( - preservation, - { runtimeTabId: "instance:other", folder: "/work", occurrence: 0 }, - ), null, "a different runtime cannot claim prompts by folder occurrence alone") - }) - it("retains missing sessions, transient tabs, and new runtime tabs", () => { - const savedFile = attachment("path-file", "/work/a/notes.txt") - const saved = session([ - workspace("/work/a", 0, { - activeParentSessionId: "missing", activeSessionId: "missing", - drafts: { missing: "saved draft" }, attachments: { missing: [savedFile] }, - scrollSnapshots: { missing: scroll(42) }, - }), - sidecar("transient"), sidecar("deleted"), workspace("/work/b"), - ], 1) - const preservation = restored(saved, [ - { source: 0, unavailable: ["missing"] }, { source: 2 }, { source: 3 }, - ]) - const merged = mergeRestorableSessionState(session([ - workspace("/work/a", 0, { drafts: { visible: "current draft" } }), - workspace("/work/b"), sidecar("new-runtime-tab"), - ], 2), preservation) - - assert.deepEqual(labels(merged), ["/work/a:0", "transient", "/work/b:0", "new-runtime-tab"]) - assert.equal(merged.activeTabIndex, 3) - const tab = workspaceAt(merged) - assert.deepEqual(tab.drafts, { missing: "saved draft", visible: "current draft" }) - assert.deepEqual(tab.attachments.missing, [savedFile], "path-backed payload survives") - assert.deepEqual(tab.scrollSnapshots.missing, scroll(42), "missing-session scroll is seeded") - assert.deepEqual([tab.activeParentSessionId, tab.activeSessionId], ["missing", "missing"]) - }) - - it("backfills failed hydration without replacing current metadata or user mutations", () => { - const savedFile = attachment("paste") - const saved = session([ - workspace("/failed", 0, { - projectName: "saved metadata", activeParentSessionId: "unsaved", activeSessionId: "unsaved", - drafts: { unsaved: "[paste]", live: "stale" }, attachments: { unsaved: [savedFile] }, - scrollSnapshots: { unsaved: scroll(37) }, - }), - workspace("/restored"), - ]) - const merged = mergeRestorableSessionState( - session([workspace("/restored"), workspace("/failed", 0, { - projectName: "current metadata", drafts: { live: "current draft" }, - })]), - restored(saved, [{ source: 1, runtime: "instance:restored" }]), - { currentTabIds: ["instance:restored", "instance:failed"] }, - ) - - assert.deepEqual(labels(merged), ["/restored:0", "/failed:0"]) - const tab = workspaceAt(merged, 1) - assert.equal(tab.projectName, "current metadata") - assert.deepEqual(tab.drafts, { unsaved: "[paste]", live: "current draft" }) - assert.deepEqual(tab.attachments.unsaved, [savedFile]) - assert.equal(tab.scrollSnapshots.unsaved?.scrollTop, 37) - assert.deepEqual([tab.activeParentSessionId, tab.activeSessionId], ["unsaved", "unsaved"]) - }) - - it("retains payload records at persistence budget boundaries", () => { - const drafts = Object.fromEntries(Array.from({ length: 24 }, (_, index) => [`draft-${index}`, `value-${index}`])) - const scrollSnapshots = Object.fromEntries(Array.from({ length: 96 }, (_, index) => [`scroll-${index}`, scroll(index)])) - const tab = mergeOne(workspace("/budget", 0, { - drafts, scrollSnapshots, attachments: { path: [attachment("large-path", "/budget/large.bin")] }, - })) - assert.equal(Object.keys(tab.drafts).length, 24, "draft limit remains intact") - assert.equal(Object.keys(tab.scrollSnapshots).length, 96, "per-tab scroll limit remains intact") - assert.equal(tab.attachments.path?.[0]?.source.type, "file", "path payload remains intact") - }) - - for (const testCase of [ - { label: "draft cleared by user", field: "drafts" as const, saved: { missing: "saved" }, owned: "missing" }, - { label: "last attachment removed", field: "attachments" as const, saved: { missing: [attachment()] }, owned: "missing" }, - { label: "idle marker seen", field: "unseenIdleSince" as const, saved: { seen: 1_000 }, authority: "idleMarkers" as const, owned: "seen" }, - { label: "generation recovery cleared", field: "generationRecovery" as const, saved: { resumed: "working" as const }, owned: "resumed" }, - ]) { - it(`does not resurrect preserved state after authoritative ${testCase.label}`, () => { - const authorityField = testCase.authority ?? testCase.field - const tab = mergeOne( - workspace("/work", 0, { [testCase.field]: testCase.saved }), - workspace("/work"), - { [authorityField]: new Set([testCase.owned]) }, - ) - assert.deepEqual(tab[testCase.field], {}, `${testCase.field} should remain cleared`) + assert.deepEqual(workspaceAt(mergeRestorableSessionState(empty(), preservation)).drafts, { + missing: "saved", + current: "latest unsent draft", }) - } + }) - it("removes every record and selection for a remotely deleted session", () => { - const tab = mergeOne(workspace("/work", 0, { - activeParentSessionId: "deleted", activeSessionId: "deleted", drafts: { deleted: "draft" }, - attachments: { deleted: [attachment()] }, scrollSnapshots: { deleted: scroll(42) }, + it("keeps current edits and authoritative deletion over preserved state", () => { + const saved = session([workspace("/work", 0, { + activeParentSessionId: "deleted", activeSessionId: "deleted", + drafts: { edited: "saved", deleted: "draft" }, + attachments: { edited: [attachment("saved")], deleted: [attachment("deleted")] }, + scrollSnapshots: { edited: scroll(10), deleted: scroll(42) }, unseenIdleSince: { deleted: 1_000 }, generationRecovery: { deleted: "working" }, - }), workspace("/work"), { deletedSessions: new Set(["deleted"]) }) - assert.deepEqual({ - drafts: tab.drafts, attachments: tab.attachments, scrolls: tab.scrollSnapshots, - idle: tab.unseenIdleSince, recovery: tab.generationRecovery, - }, { drafts: {}, attachments: {}, scrolls: {}, idle: {}, recovery: {} }) + })]) + const current = session([workspace("/work", 0, { + drafts: { edited: "current" }, attachments: { edited: [attachment("current")] }, + scrollSnapshots: { edited: scroll(90) }, + })]) + const merged = mergeRestorableSessionState(current, createRestorableSessionPreservation(saved), { + currentTabIds: ["instance:work"], + currentTabAuthorities: [{ + drafts: new Set(["edited"]), attachments: new Set(["edited"]), + scrollSnapshots: new Set(["edited"]), deletedSessions: new Set(["deleted"]), + }], + }) + const tab = workspaceAt(merged) + + assert.equal(tab.drafts.edited, "current") + assert.equal(tab.attachments.edited?.[0]?.id, "current") + assert.equal(tab.scrollSnapshots.edited?.scrollTop, 90) + assert.equal("deleted" in tab.drafts || "deleted" in tab.attachments || "deleted" in tab.scrollSnapshots, false) assert.deepEqual([tab.activeParentSessionId, tab.activeSessionId], [undefined, undefined]) }) - it("preserves only unavailable idle and recovery records after partial restore", () => { - const saved = session([workspace("/work", 0, { - unseenIdleSince: { missing: 1_000, loaded: 2_000 }, - generationRecovery: { missing: "working", loaded: "interrupted" }, - })]) - const merged = mergeRestorableSessionState( - session([workspace("/work")]), - restored(saved, [{ source: 0, runtime: "instance:work", unavailable: ["missing"] }]), - { currentTabIds: ["instance:work"], currentTabAuthorities: [{ - idleMarkers: new Set(["loaded"]), generationRecovery: new Set(["loaded"]), - }] }, - ) - const tab = workspaceAt(merged) - assert.deepEqual(tab.unseenIdleSince, { missing: 1_000 }) - assert.deepEqual(tab.generationRecovery, { missing: "working" }) - }) - - it("keeps all authoritative current nested values over preserved values", () => { - const tab = mergeOne( - workspace("/work", 0, { - drafts: { id: "saved" }, attachments: { id: [attachment("saved")] }, - scrollSnapshots: { id: scroll(10) }, generationRecovery: { id: "working" }, - }), - workspace("/work", 0, { - drafts: { id: "current" }, attachments: { id: [attachment("current")] }, - scrollSnapshots: { id: scroll(90, 2) }, generationRecovery: { id: "interrupted" }, - }), - { drafts: new Set(["id"]), attachments: new Set(["id"]), scrollSnapshots: new Set(["id"]), - generationRecovery: new Set(["id"]) }, - ) - assert.equal(tab.drafts.id, "current") - assert.equal(tab.attachments.id?.[0]?.id, "current") - assert.equal(tab.scrollSnapshots.id?.scrollTop, 90) - assert.equal(tab.generationRecovery.id, "interrupted") - }) - - it("keeps later expansion while loaded collapse and deletion remain authoritative", () => { - const saved = session([workspace("/work", 0, { expandedSessionIds: ["loaded", "later", "deleted"] })]) - const merged = mergeRestorableSessionState( - session([workspace("/work", 0, { expandedSessionIds: ["current", "deleted"] })]), - restored(saved, [{ source: 0, runtime: "instance:work", unavailable: ["later", "deleted"] }]), - { - currentTabIds: ["instance:work"], - currentTabAuthorities: [{ sessionExpansion: new Set(["loaded"]), deletedSessions: new Set(["deleted"]) }], - }, - ) - assert.deepEqual(workspaceAt(merged).expandedSessionIds, ["current", "later"]) - }) - - for (const testCase of [ - { label: "later parent/child selection", current: { activeParentSessionId: "current-parent", activeSessionId: "current-child" }, - expected: ["current-parent", "current-child"] }, - { label: "current info selection", current: { activeSessionId: "info" }, expected: [undefined, "info"] }, - { label: "untouched runtime selection", current: {}, expected: ["missing-parent", "missing-child"] }, - ]) { - it(`handles user selection during restore: ${testCase.label}`, () => { - const saved = session([workspace("/work", 0, { - activeParentSessionId: "missing-parent", activeSessionId: "missing-child", - })]) - const merged = mergeRestorableSessionState( - session([workspace("/work", 0, testCase.current)]), - restored(saved, [{ source: 0, runtime: "instance:work", unavailable: ["missing-parent", "missing-child"] }]), - { currentTabIds: ["instance:work"] }, - ) - const tab = workspaceAt(merged) - assert.deepEqual([tab.activeParentSessionId, tab.activeSessionId], testCase.expected) - }) - } - - it("keeps unresolved and partial payloads until a reopened workspace is actually restored", () => { - const saved = session([workspace("/failed", 0, { drafts: { missing: "retry", loaded: "discard" } })]) - let preservation = createRestorableSessionPreservation(saved) - assert.equal(mergeRestorableSessionState(empty(), preservation).tabs.length, 1, "transient absence retained") - preservation = markPreservedWorkspaceRemoved(preservation, { - runtimeTabId: "instance:failed", folder: "/failed", occurrence: 0, - }) - assert.deepEqual(mergeRestorableSessionState(empty(), preservation), empty(), "explicit close tombstones") - preservation = markPreservedWorkspaceReopened(preservation, { - runtimeTabId: "instance:reopened", folder: "/failed", occurrence: 0, - }) - assert.equal(mergeRestorableSessionState(empty(), preservation).tabs.length, 1, "reopen clears tombstone") - assert.equal(preservation.results[0]?.runtimeTabId, "instance:reopened", "reopen binds the new runtime") - recordRestoredTab(preservation, 0, "instance:partial", new Set(["missing"])) - markPreservedWorkspaceReopened(preservation, { - runtimeTabId: "instance:partial", folder: "/failed", occurrence: 0, - }) - const partial = workspaceAt(mergeRestorableSessionState(session([workspace("/failed")]), preservation, { - currentTabIds: ["instance:reopened"], - })) - assert.deepEqual(partial.drafts, { missing: "retry" }, "reopen retains unavailable payload") - }) - - it("rebinds preserved state when a workspace runtime is reopened", () => { - const preservation = restored(session([workspace("/work")]), [{ source: 0, runtime: "instance:reused" }]) - assert.equal(preservation.results[0]?.runtimeTabId, "instance:reused") - markPreservedWorkspaceReopened(preservation, { - runtimeTabId: "instance:reused", folder: "/work", occurrence: 0, - }) - assert.equal(preservation.results[0]?.runtimeTabId, "instance:reused") - }) - - it("hydrates only a genuine unavailable or removed workspace reopen", () => { - const preservation = createRestorableSessionPreservation(session([workspace("/work")])) - const opened = { runtimeTabId: "instance:new", folder: "/work", occurrence: 0 } - assert.equal(getPreservedWorkspaceReopenTarget(preservation, opened), null, "initial restore create owns hydration") - recordRestoredTab(preservation, 0, "instance:old") - assert.equal(getPreservedWorkspaceReopenTarget(preservation, opened)?.sourceIndex, 0) - }) - - it("does not seed or settle after an explicit close during hydration", async () => { - const preservation = createRestorableSessionPreservation(session([workspace("/work")])) - recordRestoredTab(preservation, 0, "instance:hydrating") - let resume!: () => void - const hydration = new Promise((resolve) => { resume = resolve }) - const effects: string[] = [] - const completion = (async () => { - await hydration - if (!hasRestoredTabBinding(preservation, 0, "instance:hydrating")) return - effects.push("seed", "release", "select") - settleRestoredTab(preservation, 0, "instance:hydrating", "instance:hydrating", new Set()) - })() - - markPreservedWorkspaceRemoved(preservation, { - runtimeTabId: "instance:hydrating", folder: "/work", occurrence: 0, - }) - markPreservedWorkspaceReopened(preservation, { - runtimeTabId: "instance:reopened", folder: "/work", occurrence: 0, - }) - resume() - await completion - - assert.deepEqual(effects, []) - assert.deepEqual(preservation.results[0], { status: "pending", runtimeTabId: "instance:reopened" }) - }) - - it("invalidates a pending create commit after close even when the workspace reopens", () => { + it("rejects a late restore commit after close and reopen", () => { const preservation = createRestorableSessionPreservation(session([workspace("/work")])) const canCommit = createRestoredTabCommitGuard(preservation, 0) - markPreservedWorkspaceReopened(preservation, { runtimeTabId: "instance:restore-event", folder: "/work", occurrence: 0, }) - assert.equal(canCommit(), true, "restore creation events do not invalidate their own response") + assert.equal(canCommit(), true) markPreservedWorkspaceRemoved(preservation, { runtimeTabId: "instance:restore-event", folder: "/work", occurrence: 0, @@ -337,214 +95,32 @@ describe("app session snapshot merge", () => { markPreservedWorkspaceReopened(preservation, { runtimeTabId: "instance:user-reopened", folder: "/work", occurrence: 0, }) - assert.equal(canCommit(), false, "a late response cannot overwrite the close authority") + assert.equal(canCommit(), false) }) - it("compare-and-set settlement cannot overwrite removed or rebound authority", () => { - const preservation = createRestorableSessionPreservation(session([workspace("/work")])) - recordRestoredTab(preservation, 0, "instance:old") - markPreservedWorkspaceRemoved(preservation, { - runtimeTabId: "instance:old", folder: "/work", occurrence: 0, - }) - assert.equal(settleRestoredTab(preservation, 0, "instance:old", "instance:old", new Set()), false) - assert.equal(settleRestoredTab(preservation, 0, "instance:old", null), false) - assert.deepEqual(preservation.results[0], { status: "removed" }) - - markPreservedWorkspaceReopened(preservation, { - runtimeTabId: "instance:new", folder: "/work", occurrence: 0, - }) - recordRestoredTab(preservation, 0, "instance:new") - assert.equal(settleRestoredTab(preservation, 0, "instance:old", null), false) - assert.deepEqual(preservation.results[0], { status: "pending", runtimeTabId: "instance:new" }) - assert.equal(settleRestoredTab(preservation, 0, "instance:new", "instance:new", new Set()), true) - assert.equal(preservation.results[0]?.status, "restored") - }) - - it("does not duplicate recovered or authoritatively deleted sidecars", () => { - const recovered = session([sidecar("preview"), sidecar("new")]) - assert.deepEqual( - mergeRestorableSessionState(recovered, createRestorableSessionPreservation(session([sidecar("preview")]))), - recovered, - ) - const deleted = restored(session([sidecar("deleted")]), [{ source: 0 }]) - assert.deepEqual(mergeRestorableSessionState(empty(), deleted), empty()) - }) - - for (const testCase of [ - { label: "restored tabs reordered", current: [workspace("/b"), workspace("/a")], ids: ["instance:b", "instance:a"], active: 0 }, - { label: "new tab interleaved", current: [workspace("/a"), sidecar("new"), workspace("/b")], - ids: ["instance:a", "sidecar:new", "instance:b"], active: 1 }, - ]) { - it(`keeps current layout: ${testCase.label}`, () => { - const saved = session([workspace("/a"), workspace("/b")]) - const preservation = restored(saved, [ - { source: 0, runtime: "instance:a" }, { source: 1, runtime: "instance:b" }, - ]) - const current = session(testCase.current, testCase.active) - assert.deepEqual(mergeRestorableSessionState(current, preservation, { currentTabIds: testCase.ids }), current) - }) - } - - it("keeps current active tabs while shifting around unresolved source tabs", () => { - const currentTabs = [workspace("/a"), workspace("/b")] - const mapped = restored(session(currentTabs), [ - { source: 0, runtime: "instance:a", unavailable: ["missing"] }, - { source: 1, runtime: "instance:b" }, - ]) - const options = { currentTabIds: ["instance:a", "instance:b"] } - assert.equal(mergeRestorableSessionState(session(currentTabs, 0), mapped, options).activeTabIndex, 0) - assert.equal(mergeRestorableSessionState(session(currentTabs, 1), mapped, options).activeTabIndex, 1) - - const unresolved = restored(session([sidecar("unresolved"), ...currentTabs]), [ - { source: 1, runtime: "instance:a" }, { source: 2, runtime: "instance:b" }, - ]) - assert.equal(mergeRestorableSessionState(session(currentTabs, 0), unresolved, options).activeTabIndex, 1) - assert.equal(mergeRestorableSessionState(session(currentTabs, 1), unresolved, options).activeTabIndex, 2) - }) - - it("maps the saved active source tab while startup capture has no active tab", () => { - const saved = session([workspace("/a"), workspace("/b"), workspace("/c")], 2) - const preservation = restored(saved, [ - { source: 0, runtime: "instance:a", pending: true }, - { source: 1, runtime: "instance:b", pending: true }, - { source: 2, runtime: "instance:c", pending: true }, - ]) - const merged = mergeRestorableSessionState(session(saved.tabs, -1), preservation, { - currentTabIds: ["instance:a", "instance:b", "instance:c"], - }) - assert.equal(merged.activeTabIndex, 2) - }) - - it("retains saved session IDs during a sub-debounce startup flush unless selection is authoritative", () => { - const saved = session([workspace("/work", 0, { - activeParentSessionId: "saved-parent", activeSessionId: "saved-child", - })]) - const preservation = restored(saved, [{ source: 0, runtime: "instance:work", pending: true }]) - const startup = session([workspace("/work")]) - const beforeDebounce = mergeRestorableSessionState(startup, preservation, { - currentTabIds: ["instance:work"], currentTabAuthorities: [{ sessionSelection: false }], - }) - assert.deepEqual( - [workspaceAt(beforeDebounce).activeParentSessionId, workspaceAt(beforeDebounce).activeSessionId], - ["saved-parent", "saved-child"], - ) - const cleared = mergeRestorableSessionState(startup, preservation, { - currentTabIds: ["instance:work"], currentTabAuthorities: [{ sessionSelection: true }], - }) - assert.deepEqual([workspaceAt(cleared).activeParentSessionId, workspaceAt(cleared).activeSessionId], [undefined, undefined]) - }) - - it("inserts an unresolved tab beside its nearest restored source neighbor", () => { - const saved = session([workspace("/a"), sidecar("unresolved"), workspace("/b")], 1) - const preservation = restored(saved, [ - { source: 0, runtime: "instance:a" }, { source: 2, runtime: "instance:b" }, - ]) - const merged = mergeRestorableSessionState(session([workspace("/a"), workspace("/b")], 1), preservation, { - currentTabIds: ["instance:a", "instance:b"], - }) - assert.deepEqual(labels(merged), ["/a:0", "unresolved", "/b:0"]) - assert.equal(merged.activeTabIndex, 2) - }) - - it("maps duplicate folders atomically and follows runtime reorder", () => { + it("keeps duplicate-folder bindings independent through reorder and close", () => { const saved = session([ - workspace("/same", 0, { drafts: { first: "source first" } }), - workspace("/same", 1, { drafts: { second: "source second" } }), - workspace("/same", 2), + workspace("/same", 0, { drafts: { first: "first" } }), + workspace("/same", 1, { drafts: { second: "second" } }), ]) - const preservation = restored(saved, [ - { source: 0, runtime: "instance:first", pending: true }, - { source: 1, runtime: "instance:second", pending: true }, - { source: 2, runtime: "instance:last", pending: true }, - ]) - assert.deepEqual(preservation.results.map((result) => result.runtimeTabId), [ - "instance:first", "instance:second", "instance:last", - ], "all duplicate bindings are recorded before close events") + const preservation = createRestorableSessionPreservation(saved) + recordRestoredTab(preservation, 0, "instance:first") + recordRestoredTab(preservation, 1, "instance:second") const reordered = mergeRestorableSessionState( - session([workspace("/same", 1), workspace("/same", 0), workspace("/same", 2)]), + session([workspace("/same", 1), workspace("/same", 0)]), preservation, - { currentTabIds: ["instance:second", "instance:first", "instance:last"] }, + { currentTabIds: ["instance:second", "instance:first"] }, ) - assert.equal(workspaceAt(reordered, 0).drafts.second, "source second") - assert.equal(workspaceAt(reordered, 1).drafts.first, "source first") - }) + assert.equal(workspaceAt(reordered, 0).drafts.second, "second") + assert.equal(workspaceAt(reordered, 1).drafts.first, "first") - it("tombstones duplicate folders independently in either close order", () => { - for (const order of [[0, 1], [1, 0]]) { - const saved = session([ - workspace("/same", 0, { drafts: { first: "first" } }), - workspace("/same", 1, { drafts: { second: "second" } }), - ]) - const preservation = restored(saved, [ - { source: 0, runtime: "instance:first", pending: true }, - { source: 1, runtime: "instance:second", pending: true }, - ]) - for (const source of order) markPreservedWorkspaceRemoved(preservation, { - runtimeTabId: `instance:${source ? "second" : "first"}`, folder: "/same", occurrence: source, - }) - assert.equal(mergeRestorableSessionState(empty(), preservation).tabs.length, 0, `close order ${order.join(",")}`) - } - }) - - it("retains mapped duplicates during transient absence and only the uncertain remaining source", () => { - const saved = session([ - workspace("/same", 0, { drafts: { first: "first source" } }), - workspace("/same", 1, { drafts: { second: "uncertain source" } }), - ]) - const absent = restored(saved, [ - { source: 0, runtime: "instance:first", pending: true }, - { source: 1, runtime: "instance:second", pending: true }, - ]) - assert.equal(mergeRestorableSessionState(empty(), absent).tabs.length, 2) - - const uncertain = createRestorableSessionPreservation(saved) - markPreservedWorkspaceRemoved(uncertain, { runtimeTabId: "unknown:first", folder: "/same", occurrence: 0 }) - markPreservedWorkspaceRemoved(uncertain, { runtimeTabId: "unknown:second", folder: "/same", occurrence: 0 }) - const merged = mergeRestorableSessionState(empty(), uncertain) - assert.equal(merged.tabs.length, 1) - assert.equal(workspaceAt(merged).drafts.second, "uncertain source") - }) - - it("does not relaunch a closed duplicate after occurrences renumber", () => { - const saved = session([ - workspace("/same", 0), workspace("/same", 1, { drafts: { missing: "preserve" } }), - ], 1) - const preservation = restored(saved, [ - { source: 0, runtime: "instance:first" }, - { source: 1, runtime: "instance:second", unavailable: ["missing"] }, - ]) - const merged = mergeRestorableSessionState( - session([workspace("/same", 0, { drafts: { current: "captured" } })]), - preservation, - { currentTabIds: ["instance:second"] }, - ) - assert.equal(merged.tabs.length, 1) - assert.deepEqual(workspaceAt(merged).drafts, { missing: "preserve", current: "captured" }) - assert.deepEqual(reconcileWorkspaceTabs( - [{ kind: "workspace", folderPath: "/same", occurrence: 0 }], - [{ id: "second", folderPath: "/same" }], - ).map((match) => match.existingWorkspaceId), ["second"]) - }) - - it("assigns unresolved duplicate a distinct occurrence and reconciliation slot", () => { - const saved = session([ - workspace("/same", 0, { drafts: { failed: "retry" } }), - workspace("/same", 1, { drafts: { restored: "saved" } }), - ], 1) - const merged = mergeRestorableSessionState( - session([workspace("/same", 0, { drafts: { current: "captured" } })]), - restored(saved, [{ source: 1, runtime: "instance:second" }]), - { currentTabIds: ["instance:second"] }, - ) - assert.deepEqual(labels(merged), ["/same:1", "/same:0"]) - assert.equal(workspaceAt(merged, 0).drafts.failed, "retry") - const matches = reconcileWorkspaceTabs( - merged.tabs.map((tab) => tab.kind === "workspace" - ? { kind: "workspace", folderPath: tab.folder, occurrence: tab.occurrence } - : { kind: "sidecar" }), - [{ id: "second", folderPath: "/same" }], - ) - assert.deepEqual(matches.map((match) => match.existingWorkspaceId), [null, "second"]) - assert.equal(matches.filter((match) => !match.existingWorkspaceId).length, 1) + markPreservedWorkspaceRemoved(preservation, { + runtimeTabId: "instance:first", folder: "/same", occurrence: 0, + }) + assert.equal(mergeRestorableSessionState(empty(), preservation).tabs.length, 1) + markPreservedWorkspaceRemoved(preservation, { + runtimeTabId: "instance:second", folder: "/same", occurrence: 1, + }) + assert.equal(mergeRestorableSessionState(empty(), preservation).tabs.length, 0) }) }) diff --git a/packages/ui/src/stores/app-session-workspace-hydration.test.ts b/packages/ui/src/stores/app-session-workspace-hydration.test.ts deleted file mode 100644 index bbf59508..00000000 --- a/packages/ui/src/stores/app-session-workspace-hydration.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import assert from "node:assert/strict" -import { readFileSync } from "node:fs" -import { describe, it } from "node:test" - -describe("restored workspace hydration", () => { - it("seeds scroll snapshots before publishing restored session selection", () => { - const source = readFileSync(new URL("./app-session-workspace-hydration.ts", import.meta.url), "utf8") - const hydrate = source.slice(source.indexOf("export async function hydrateRestoredWorkspaceState")) - const abortIndex = hydrate.indexOf("if (signal.aborted) throw getAbortReason(signal)") - const bindingIndex = hydrate.indexOf("if (!isCurrentBinding()) return null") - const seedIndex = hydrate.indexOf("seedRestoredWorkspaceScrollSnapshots(instanceId, snapshot)") - const sessionHydrationIndex = hydrate.indexOf("await hydrateRestoredSessionChain") - const selectionIndex = hydrate.indexOf("hydrateActiveSessionSelection(instanceId") - - assert.notEqual(abortIndex, -1) - assert.notEqual(bindingIndex, -1) - assert.notEqual(seedIndex, -1) - assert.notEqual(sessionHydrationIndex, -1) - assert.notEqual(selectionIndex, -1) - assert.ok(abortIndex < seedIndex, "cancelled restoration must not seed scroll state") - assert.ok(bindingIndex < seedIndex, "stale workspace bindings must not seed scroll state") - assert.ok(seedIndex < sessionHydrationIndex, "scroll authority must not wait for session network hydration") - assert.ok(seedIndex < selectionIndex, "scroll authority must exist before MessageSection can mount") - }) - - it("seeds existing workspaces before restoring the active app tab", () => { - const source = readFileSync(new URL("../lib/hooks/use-app-session-restore.ts", import.meta.url), "utf8") - const existingSeedIndex = source.indexOf("seedRestoredWorkspaceScrollSnapshots(existingWorkspaceId!, tab)") - const applyOrderIndex = source.indexOf("context.applyOrder()") - const selectActiveIndex = source.indexOf("context.selectActive(provisionalId") - - assert.notEqual(existingSeedIndex, -1) - assert.ok(existingSeedIndex < applyOrderIndex, "existing workspace scroll state must precede restored tab ordering") - assert.ok(existingSeedIndex < selectActiveIndex, "existing workspace scroll state must exist before its tab mounts") - }) - - it("seeds created workspaces before their runtime commit mounts the tab", () => { - const restore = readFileSync(new URL("../lib/hooks/use-app-session-restore.ts", import.meta.url), "utf8") - const instances = readFileSync(new URL("./instances.ts", import.meta.url), "utf8") - - assert.match(restore, /onBeforeCreateCommit: \(id\) => seedRestoredWorkspaceScrollSnapshots\(id, tab\)/) - assert.ok( - instances.indexOf("options?.onBeforeCreateCommit?.(workspace.id)") < instances.indexOf("upsertWorkspace(committedWorkspace"), - "created workspace scroll state must exist before upsert mounts InstanceShell", - ) - }) - - it("retries an initial no-snapshot render when the native scroll seed arrives", () => { - const section = readFileSync(new URL("../components/message-section.tsx", import.meta.url), "utf8") - const snapshotIndex = section.indexOf("const snapshot = initialScrollSnapshot()") - const settledGuardIndex = section.indexOf("if (didRestoreScroll() && (!restoredWithoutSnapshot || !snapshot)) return") - - assert.notEqual(snapshotIndex, -1) - assert.ok(snapshotIndex < settledGuardIndex, "the restore effect must observe a late snapshot before its settled guard") - assert.match(section, /restoredWithoutSnapshot = true\s+setDidRestoreScroll\(true\)/) - assert.match(section, /restoredWithoutSnapshot = false\s+const restoreSessionId/) - }) -}) diff --git a/packages/ui/src/stores/forms.test.ts b/packages/ui/src/stores/forms.test.ts index aac945a2..db0c29c6 100644 --- a/packages/ui/src/stores/forms.test.ts +++ b/packages/ui/src/stores/forms.test.ts @@ -1,6 +1,5 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" -import { addFormToQueue, clearFormQueue, getFormQueue, removeFormFromQueue, replaceFormQueue } from "./forms.ts" import { activeInterruption, addPendingForm, removePendingForm } from "./instances.ts" import { sessions, setSessions } from "./session-state.ts" @@ -11,27 +10,6 @@ const form = { fields: [{ key: "channel", type: "string", required: true }], } as any -describe("V2 form queue", () => { - it("restores pending requests and reconciles created, replied, and cancelled forms", () => { - const instanceId = "forms" - try { - replaceFormQueue(instanceId, [form]) - assert.deepEqual(getFormQueue(instanceId), [form]) - - const second = { ...form, id: "form-2" } - addFormToQueue(instanceId, second) - assert.deepEqual(getFormQueue(instanceId).map((item) => item.id), ["form-1", "form-2"]) - - removeFormFromQueue(instanceId, form.id) - assert.deepEqual(getFormQueue(instanceId).map((item) => item.id), ["form-2"]) - removeFormFromQueue(instanceId, second.id) - assert.equal(getFormQueue(instanceId).length, 0) - } finally { - clearFormQueue(instanceId) - } - }) -}) - describe("form interruption lifecycle", () => { it("marks created or restored forms as pending until reply or cancellation", () => { const instanceId = "form-lifecycle" diff --git a/packages/ui/src/stores/instances-restore-cancellation.test.ts b/packages/ui/src/stores/instances-restore-cancellation.test.ts deleted file mode 100644 index 149c8503..00000000 --- a/packages/ui/src/stores/instances-restore-cancellation.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import assert from "node:assert/strict" -import { it } from "node:test" - -import { serverApi } from "../lib/api-client.ts" -import { cancelRestoreCreation } from "./restore-creation-cancellation.ts" - -it("retries pre-response restore cancellation without SSE correlation", async () => { - const originalCancel = serverApi.cancelWorkspaceCreation - let calls = 0 - serverApi.cancelWorkspaceCreation = async () => { - if (++calls === 1) throw new Error("temporary cancellation failure") - } - - try { - await cancelRestoreCreation("pre-response-request") - assert.equal(calls, 2) - } finally { - serverApi.cancelWorkspaceCreation = originalCancel - } -}) diff --git a/packages/ui/src/stores/native-session-streaming.test.ts b/packages/ui/src/stores/native-session-streaming.test.ts index d06e2ac5..c69e3955 100644 --- a/packages/ui/src/stores/native-session-streaming.test.ts +++ b/packages/ui/src/stores/native-session-streaming.test.ts @@ -10,66 +10,34 @@ import { } from "./native-session-streaming.ts" const delay = (duration: number) => new Promise((resolve) => setTimeout(resolve, duration)) +const data = { sessionID: "session", assistantMessageID: "assistant" } +const text = (id: string, ordinal: number, delta: string, created = ordinal) => ({ + id, created, type: "session.text.delta" as const, data: { ...data, ordinal, delta }, +}) + +function cleanup(instanceId: string) { + clearNativeContentDeltaState(instanceId) + if (messageStoreBus.getInstance(instanceId)) messageStoreBus.unregisterInstance(instanceId) +} describe("native session streaming", () => { - it("creates assistant content and applies text and reasoning deltas immediately", () => { - const instanceId = "native-streaming" - const base = { id: "event", created: 10, data: { sessionID: "session", assistantMessageID: "assistant" } } - - try { - assert.equal(applyNativeContentDelta(instanceId, { - ...base, - type: "session.text.delta", - data: { ...base.data, ordinal: 0, delta: "hello" }, - }), true) - applyNativeContentDelta(instanceId, { - ...base, - id: "event-2", - type: "session.text.delta", - data: { ...base.data, ordinal: 0, delta: " world" }, - }) - applyNativeContentDelta(instanceId, { - ...base, - id: "event-3", - type: "session.reasoning.delta", - data: { ...base.data, ordinal: 0, delta: "thinking" }, - }) - reapplyNativeContentDeltas(instanceId, "session") - - const message = messageStoreBus.getOrCreate(instanceId).getMessage("assistant") - assert.equal(message?.status, "streaming") - assert.equal((message?.parts["assistant-text-native-0"]?.data as any)?.text, "hello world") - assert.equal((message?.parts["assistant-reasoning-native-0"]?.data as any)?.text, "thinking") - assert.equal(message?.partIds.length, 2) - assert.equal(messageStoreBus.getOrCreate(instanceId).getMessageInfo("assistant")?.sessionID, "session") - } finally { - clearNativeContentDeltaState(instanceId) - messageStoreBus.unregisterInstance(instanceId) - } - }) - - it("rejects malformed deltas without creating state", () => { + it("rejects malformed native deltas at the event boundary", () => { const instanceId = "invalid-native-streaming" try { assert.equal(applyNativeContentDelta(instanceId, { type: "session.text.delta", data: { sessionID: "session", assistantMessageID: "", ordinal: 0, delta: "ignored" }, } as any), false) + assert.equal(applyNativeContentDelta(instanceId, text("negative", -1, "ignored")), false) assert.equal(messageStoreBus.getOrCreate(instanceId).getSessionMessageIds("session").length, 0) } finally { - clearNativeContentDeltaState(instanceId) - messageStoreBus.unregisterInstance(instanceId) + cleanup(instanceId) } }) - it("deduplicates replayed events and restores direct content after a stale snapshot", () => { + it("deduplicates replayed events and restores deltas after a stale snapshot", () => { const instanceId = "replayed-native-streaming" - const event = { - id: "event-1", - created: 10, - type: "session.text.delta" as const, - data: { sessionID: "session", assistantMessageID: "assistant", ordinal: 0, delta: "hello" }, - } + const event = text("event-1", 0, "hello", 10) try { applyNativeContentDelta(instanceId, event) applyNativeContentDelta(instanceId, event) @@ -83,154 +51,31 @@ describe("native session streaming", () => { reapplyNativeContentDeltas(instanceId, "session") assert.equal((store.getMessage("assistant")?.parts["assistant-text-0"]?.data as any)?.text, "hello") assert.deepEqual(store.getMessage("assistant")?.partIds, ["assistant-text-0"]) - - settleNativeContentDeltas(instanceId, "session") - assert.equal(applyNativeContentDelta(instanceId, { - ...event, - id: "late-event", - data: { ...event.data, ordinal: 1, delta: " late" }, - }), false) - assert.equal((store.getMessage("assistant")?.parts["assistant-text-0"]?.data as any)?.text, "hello") - reconcileNativeContentAfterSnapshot(instanceId, "session") - assert.equal((store.getMessage("assistant")?.parts["assistant-text-0"]?.data as any)?.text, "hello") - assert.equal(store.getMessage("assistant")?.status, "complete") - store.upsertMessage({ - id: "assistant", sessionId: "session", role: "assistant", status: "complete", - parts: [{ id: "assistant-text-0", type: "text", text: "hello", sessionID: "session", messageID: "assistant" }], - }) - reconcileNativeContentAfterSnapshot(instanceId, "session") - assert.deepEqual(store.getMessage("assistant")?.partIds, ["assistant-text-0"]) - store.upsertMessage({ id: "assistant", sessionId: "session", role: "assistant", status: "streaming" }) - reconcileNativeContentAfterSnapshot(instanceId, "session") - assert.equal(store.getMessage("assistant")?.status, "complete") } finally { - clearNativeContentDeltaState(instanceId) - messageStoreBus.unregisterInstance(instanceId) + cleanup(instanceId) } }) - it("orders out-of-order content parts by their source timestamp", () => { + it("orders content parts by source time when native events arrive out of order", () => { const instanceId = "ordered-native-streaming" - const data = { sessionID: "session", assistantMessageID: "assistant" } try { - applyNativeContentDelta(instanceId, { - id: "second", created: 2, type: "session.text.delta", - data: { ...data, ordinal: 1, delta: "world" }, - }) - applyNativeContentDelta(instanceId, { - id: "first", created: 1, type: "session.text.delta", - data: { ...data, ordinal: 0, delta: "hello " }, - }) + applyNativeContentDelta(instanceId, text("second", 1, "world", 2)) + applyNativeContentDelta(instanceId, text("first", 0, "hello ", 1)) reapplyNativeContentDeltas(instanceId, "session") const message = messageStoreBus.getOrCreate(instanceId).getMessage("assistant") assert.deepEqual(message?.partIds, ["assistant-text-native-0", "assistant-text-native-1"]) assert.equal((message?.parts["assistant-text-native-0"]?.data as any)?.text, "hello ") assert.equal((message?.parts["assistant-text-native-1"]?.data as any)?.text, "world") } finally { - clearNativeContentDeltaState(instanceId) - messageStoreBus.unregisterInstance(instanceId) + cleanup(instanceId) } }) - it("preserves content type transitions as separate ordered parts", () => { - const instanceId = "transition-native-streaming" - const data = { sessionID: "session", assistantMessageID: "assistant" } - try { - applyNativeContentDelta(instanceId, { - id: "text-1", created: 1, type: "session.text.delta", - data: { ...data, ordinal: 0, delta: "before" }, - }) - applyNativeContentDelta(instanceId, { - id: "reasoning", created: 2, type: "session.reasoning.delta", - data: { ...data, ordinal: 0, delta: "thinking" }, - }) - applyNativeContentDelta(instanceId, { - id: "text-2", created: 3, type: "session.text.delta", - data: { ...data, ordinal: 1, delta: "after" }, - }) - reapplyNativeContentDeltas(instanceId, "session") - - const message = messageStoreBus.getOrCreate(instanceId).getMessage("assistant") - assert.deepEqual(message?.partIds, [ - "assistant-text-native-0", - "assistant-reasoning-native-0", - "assistant-text-native-1", - ]) - } finally { - clearNativeContentDeltaState(instanceId) - messageStoreBus.unregisterInstance(instanceId) - } - }) - - it("does not duplicate a fragment already present in an ahead snapshot", () => { - const instanceId = "ahead-snapshot-streaming" - const data = { sessionID: "session", assistantMessageID: "assistant" } - try { - applyNativeContentDelta(instanceId, { - id: "first", created: 1, type: "session.text.delta", - data: { ...data, ordinal: 0, delta: "hello" }, - }) - const store = messageStoreBus.getOrCreate(instanceId) - store.upsertMessage({ - id: "assistant", sessionId: "session", role: "assistant", status: "streaming", - parts: [{ id: "assistant-text-0", type: "text", text: "hello world", sessionID: "session", messageID: "assistant" }], - }) - reconcileNativeContentAfterSnapshot(instanceId, "session") - applyNativeContentDelta(instanceId, { - id: "second", created: 2, type: "session.text.delta", - data: { ...data, ordinal: 0, delta: " world" }, - }) - reapplyNativeContentDeltas(instanceId, "session") - - const message = store.getMessage("assistant") - assert.deepEqual(message?.partIds, ["assistant-text-0"]) - assert.equal((message?.parts["assistant-text-0"]?.data as any)?.text, "hello world") - } finally { - clearNativeContentDeltaState(instanceId) - messageStoreBus.unregisterInstance(instanceId) - } - }) - - it("keeps authoritative terminal content boundaries", () => { - const instanceId = "terminal-boundaries-streaming" - const data = { sessionID: "session", assistantMessageID: "assistant" } - try { - applyNativeContentDelta(instanceId, { - id: "first", created: 1, type: "session.text.delta", - data: { ...data, ordinal: 0, delta: "beforeafter" }, - }) - const store = messageStoreBus.getOrCreate(instanceId) - store.upsertMessage({ - id: "assistant", sessionId: "session", role: "assistant", status: "complete", - parts: [ - { id: "assistant-text-0", type: "text", text: "before", sessionID: "session", messageID: "assistant" }, - { id: "tool", type: "tool", tool: "test", sessionID: "session", messageID: "assistant" } as any, - { id: "assistant-text-2", type: "text", text: "after", sessionID: "session", messageID: "assistant" }, - ], - }) - settleNativeContentDeltas(instanceId, "session") - reconcileNativeContentAfterSnapshot(instanceId, "session") - - assert.deepEqual(store.getMessage("assistant")?.partIds, ["assistant-text-0", "tool", "assistant-text-2"]) - assert.equal(store.getMessage("assistant")?.status, "complete") - } finally { - clearNativeContentDeltaState(instanceId) - messageStoreBus.unregisterInstance(instanceId) - } - }) - - it("does not overlay stale content across an authoritative tool boundary", () => { + it("does not overlay stale deltas across an authoritative tool boundary", () => { const instanceId = "tool-boundary-streaming" - const data = { sessionID: "session", assistantMessageID: "assistant" } try { - applyNativeContentDelta(instanceId, { - id: "first", created: 1, type: "session.text.delta", - data: { ...data, ordinal: 0, delta: "before" }, - }) - applyNativeContentDelta(instanceId, { - id: "second", created: 2, type: "session.text.delta", - data: { ...data, ordinal: 1, delta: "aftermore" }, - }) + applyNativeContentDelta(instanceId, text("first", 0, "before", 1)) + applyNativeContentDelta(instanceId, text("second", 1, "aftermore", 2)) const store = messageStoreBus.getOrCreate(instanceId) store.upsertMessage({ id: "assistant", sessionId: "session", role: "assistant", status: "streaming", @@ -243,69 +88,17 @@ describe("native session streaming", () => { reconcileNativeContentAfterSnapshot(instanceId, "session") assert.deepEqual(store.getMessage("assistant")?.partIds, ["assistant-text-0", "tool", "assistant-text-2"]) - assert.equal((store.getMessage("assistant")?.parts["assistant-text-0"]?.data as any)?.text, "before") assert.equal((store.getMessage("assistant")?.parts["assistant-text-2"]?.data as any)?.text, "aftermore") } finally { - clearNativeContentDeltaState(instanceId) - messageStoreBus.unregisterInstance(instanceId) - } - }) - - it("coalesces long repeated-ordinal streams into one rendered part", () => { - const instanceId = "long-native-streaming" - const data = { sessionID: "session", assistantMessageID: "assistant" } - try { - for (let ordinal = 0; ordinal < 500; ordinal += 1) { - applyNativeContentDelta(instanceId, { - id: `event-${ordinal}`, created: ordinal, type: "session.text.delta", - data: { ...data, ordinal: 0, delta: `${ordinal},` }, - }) - } - applyNativeContentDelta(instanceId, { - id: "replayed-ordinal", created: 501, type: "session.text.delta", - data: { ...data, ordinal: 0, delta: "duplicate" }, - }) - reapplyNativeContentDeltas(instanceId, "session") - - const message = messageStoreBus.getOrCreate(instanceId).getMessage("assistant") - assert.deepEqual(message?.partIds, ["assistant-text-native-0"]) - assert.equal((message?.parts["assistant-text-native-0"]?.data as any)?.text, `${Array.from({ length: 500 }, (_, index) => `${index},`).join("")}duplicate`) - } finally { - clearNativeContentDeltaState(instanceId) - messageStoreBus.unregisterInstance(instanceId) - } - }) - - it("coalesces rapid deltas on the render timer", async () => { - const instanceId = "timed-native-streaming" - const data = { sessionID: "session", assistantMessageID: "assistant", ordinal: 0 } - try { - applyNativeContentDelta(instanceId, { - id: "first", created: 1, type: "session.text.delta", data: { ...data, delta: "a" }, - }) - applyNativeContentDelta(instanceId, { - id: "second", created: 2, type: "session.text.delta", data: { ...data, delta: "b" }, - }) - const store = messageStoreBus.getOrCreate(instanceId) - assert.equal((store.getMessage("assistant")?.parts["assistant-text-native-0"]?.data as any)?.text, "a") - await delay(25) - assert.equal((store.getMessage("assistant")?.parts["assistant-text-native-0"]?.data as any)?.text, "ab") - } finally { - clearNativeContentDeltaState(instanceId) - messageStoreBus.unregisterInstance(instanceId) + cleanup(instanceId) } }) it("flushes pending deltas before settling and cancels cleared timers", async () => { const instanceId = "terminal-timer-streaming" - const data = { sessionID: "session", assistantMessageID: "assistant", ordinal: 0 } try { - applyNativeContentDelta(instanceId, { - id: "first", created: 1, type: "session.text.delta", data: { ...data, delta: "a" }, - }) - applyNativeContentDelta(instanceId, { - id: "second", created: 2, type: "session.text.delta", data: { ...data, delta: "b" }, - }) + applyNativeContentDelta(instanceId, text("first", 0, "a", 1)) + applyNativeContentDelta(instanceId, text("second", 0, "b", 2)) settleNativeContentDeltas(instanceId, "session") const store = messageStoreBus.getOrCreate(instanceId) assert.equal((store.getMessage("assistant")?.parts["assistant-text-native-0"]?.data as any)?.text, "ab") @@ -315,56 +108,20 @@ describe("native session streaming", () => { await delay(25) assert.equal((store.getMessage("assistant")?.parts["assistant-text-native-0"]?.data as any)?.text, "ab") } finally { - clearNativeContentDeltaState(instanceId) - messageStoreBus.unregisterInstance(instanceId) - } - }) - - it("preserves an authoritative error status during reconciliation", () => { - const instanceId = "error-native-streaming" - const data = { sessionID: "session", assistantMessageID: "assistant", ordinal: 0 } - try { - applyNativeContentDelta(instanceId, { - id: "first", created: 1, type: "session.text.delta", data: { ...data, delta: "partial" }, - }) - const store = messageStoreBus.getOrCreate(instanceId) - store.upsertMessage({ id: "assistant", sessionId: "session", role: "assistant", status: "error" }) - reconcileNativeContentAfterSnapshot(instanceId, "session") - settleNativeContentDeltas(instanceId, "session") - assert.equal(store.getMessage("assistant")?.status, "error") - } finally { - clearNativeContentDeltaState(instanceId) - messageStoreBus.unregisterInstance(instanceId) - } - }) - - it("rejects negative ordinals", () => { - const instanceId = "negative-native-streaming" - try { - assert.equal(applyNativeContentDelta(instanceId, { - id: "event", created: 1, type: "session.text.delta", - data: { sessionID: "session", assistantMessageID: "assistant", ordinal: -1, delta: "ignored" }, - }), false) - } finally { - clearNativeContentDeltaState(instanceId) - messageStoreBus.unregisterInstance(instanceId) + cleanup(instanceId) } }) it("does not recreate cleared session state during late reconciliation", () => { const instanceId = "cleared-native-streaming" try { - applyNativeContentDelta(instanceId, { - id: "event", created: 1, type: "session.text.delta", - data: { sessionID: "session", assistantMessageID: "assistant", ordinal: 0, delta: "text" }, - }) + applyNativeContentDelta(instanceId, text("event", 0, "text", 1)) messageStoreBus.unregisterInstance(instanceId) clearNativeContentDeltaState(instanceId, "session") reapplyNativeContentDeltas(instanceId, "session") assert.equal(messageStoreBus.getInstance(instanceId), undefined) } finally { - clearNativeContentDeltaState(instanceId) - if (messageStoreBus.getInstance(instanceId)) messageStoreBus.unregisterInstance(instanceId) + cleanup(instanceId) } }) }) diff --git a/packages/ui/src/stores/pty-store.test.ts b/packages/ui/src/stores/pty-store.test.ts index 1e0a75f8..32b210d7 100644 --- a/packages/ui/src/stores/pty-store.test.ts +++ b/packages/ui/src/stores/pty-store.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" -import type { OpenCodeClient, Pty } from "@opencode-ai/client" -import { createPtyApi, createPtyStore, type PtyApi } from "./pty-store.ts" +import type { Pty } from "@opencode-ai/client" +import { createPtyStore, type PtyApi } from "./pty-store.ts" const pty = (id: string, cwd = "/repo"): Pty => ({ id, @@ -13,30 +13,6 @@ const pty = (id: string, cwd = "/repo"): Pty => ({ pid: 42, }) -describe("native PTY adapter", () => { - it("passes the exact location and native PTY inputs", async () => { - const calls: unknown[] = [] - const client = { - pty: { - list: async (input: unknown) => { calls.push(["list", input]); return { data: [pty("one")] } }, - update: async (input: unknown) => { calls.push(["update", input]); return { data: { ...pty("one"), title: "renamed" } } }, - remove: async (input: unknown) => { calls.push(["remove", input]) }, - }, - } as unknown as OpenCodeClient - const api = createPtyApi(client) - - assert.deepEqual(await api.list("/repo/worktree"), [pty("one")]) - assert.equal((await api.updateTitle("/repo/worktree", "one", "renamed")).title, "renamed") - await api.remove("/repo/worktree", "one") - - assert.deepEqual(calls, [ - ["list", { location: { directory: "/repo/worktree" } }], - ["update", { ptyID: "one", location: { directory: "/repo/worktree" }, title: "renamed" }], - ["remove", { ptyID: "one", location: { directory: "/repo/worktree" } }], - ]) - }) -}) - describe("PTY store", () => { it("keeps state location-scoped and refreshes only on exact PTY events and reconnect", async () => { const lists: string[] = [] @@ -61,28 +37,4 @@ describe("PTY store", () => { await store.refreshForEvent("instance", { type: "server.connected" }) assert.deepEqual(lists.sort(), ["/repo", "/repo/worktree"]) }) - - it("refreshes authoritative state after rename and remove controls", async () => { - const controls: unknown[] = [] - let items = [pty("one")] - const api: PtyApi = { - list: async () => items, - updateTitle: async (_directory, id, title) => { - controls.push(["update", id, title]) - items = [{ ...items[0]!, title }] - return items[0]! - }, - remove: async (_directory, id) => { - controls.push(["remove", id]) - items = [] - }, - } - const store = createPtyStore(() => api) - await store.load("instance", "/repo") - assert.equal(await store.updateTitle("instance", "/repo", "one", "renamed"), true) - assert.equal(store.getState("instance", "/repo").items[0]?.title, "renamed") - assert.equal(await store.remove("instance", "/repo", "one"), true) - assert.deepEqual(store.getState("instance", "/repo").items, []) - assert.deepEqual(controls, [["update", "one", "renamed"], ["remove", "one"]]) - }) }) diff --git a/packages/ui/src/stores/restore-workspace-commit-gates.test.ts b/packages/ui/src/stores/restore-workspace-commit-gates.test.ts index 0ee5b872..1a4acac8 100644 --- a/packages/ui/src/stores/restore-workspace-commit-gates.test.ts +++ b/packages/ui/src/stores/restore-workspace-commit-gates.test.ts @@ -1,5 +1,4 @@ import assert from "node:assert/strict" -import { readFileSync } from "node:fs" import { describe, it } from "node:test" import type { WorkspaceDescriptor } from "../../../server/src/api-types.ts" import { RestoreWorkspaceCommitGates } from "./restore-workspace-commit-gates.ts" @@ -15,52 +14,17 @@ const workspace = ( }) describe("restore workspace commit gates", () => { - it("integrates refresh and terminal events without bypassing the gate", () => { - const source = readFileSync(new URL("./instances.ts", import.meta.url), "utf8") - const refresh = source.slice(source.indexOf("async function refreshWorkspaceList"), source.indexOf("const initialWorkspaceLoad")) - assert.ok(refresh.indexOf("restoreCreationCommitGates.deferRefreshWorkspace(workspace)") < refresh.indexOf("upsertWorkspace(workspace)")) - assert.match(source, /restoreCreationCommitGates\.deferStopped\(event\.workspaceId, event\.reason\)/) - assert.match(source, /settleRestoreWorkspaceTerminal\(committedWorkspace, terminal\)/) - }) - - it("defers refresh/SSE descriptors and prefers a ready HTTP response over stale created state", () => { + it("keeps the newest descriptor while a restore response races native events", () => { const gates = new RestoreWorkspaceCommitGates() gates.begin("request-1", Promise.resolve()) assert.equal(gates.deferWorkspace(workspace("starting", "2026-01-01T00:00:01Z")), true) const response = workspace("ready", "2026-01-01T00:00:02Z", { port: 3000 }) assert.equal(gates.resolve("request-1", response).workspace, response) - }) - - it("defers a refresh descriptor without request correlation when its path is gated", () => { - const gates = new RestoreWorkspaceCommitGates() - gates.begin("request-1", Promise.resolve(), String.raw`C:\Work`) - const refresh = workspace("starting", "2026-01-01T00:00:01Z", { - requestId: undefined, path: "c:/work/", - }) - assert.equal(gates.deferRefreshWorkspace(refresh), true) - assert.equal(gates.resolve("request-1", workspace("ready", "2026-01-01T00:00:02Z")).workspace.status, "ready") - }) - - it("uses a newer equally-advanced SSE descriptor", () => { - const gates = new RestoreWorkspaceCommitGates() - gates.begin("request-1", Promise.resolve()) - const response = workspace("ready", "2026-01-01T00:00:02Z", { port: 3000 }) const event = workspace("ready", "2026-01-01T00:00:03Z", { port: 4000 }) gates.deferWorkspace(event) assert.equal(gates.resolve("request-1", response).workspace, event) }) - it("retains error and stopped terminals until create handling resolves them", () => { - const gates = new RestoreWorkspaceCommitGates() - gates.begin("request-1", Promise.resolve()) - gates.deferWorkspace(workspace("starting", "2026-01-01T00:00:01Z")) - assert.equal(gates.deferStopped("workspace-1", "server stopped"), true) - assert.deepEqual(gates.resolve("request-1", workspace("ready", "2026-01-01T00:00:02Z")).terminal, - { status: "stopped", message: "server stopped" }) - gates.end("request-1") - assert.equal(gates.deferStopped("workspace-1"), false, "terminal events are handled normally after commit") - }) - it("correlates a stopped event that arrives before the HTTP response binds its workspace ID", () => { const gates = new RestoreWorkspaceCommitGates() gates.begin("request-1", Promise.resolve()) @@ -69,13 +33,4 @@ describe("restore workspace commit gates", () => { assert.deepEqual(gates.resolve("request-1", workspace("ready", "2026-01-01T00:00:02Z")).terminal, { status: "stopped", message: "stopped before response" }) }) - - it("retains a correlated workspace error over a ready response", () => { - const gates = new RestoreWorkspaceCommitGates() - gates.begin("request-1", Promise.resolve()) - gates.deferWorkspace(workspace("error", "2026-01-01T00:00:03Z", { error: "launch failed" })) - const resolved = gates.resolve("request-1", workspace("ready", "2026-01-01T00:00:02Z")) - assert.equal(resolved.workspace.status, "error") - assert.deepEqual(resolved.terminal, { status: "error", message: "launch failed" }) - }) }) diff --git a/packages/ui/src/stores/session-native-events.test.ts b/packages/ui/src/stores/session-native-events.test.ts index aeca5b8e..fbaececd 100644 --- a/packages/ui/src/stores/session-native-events.test.ts +++ b/packages/ui/src/stores/session-native-events.test.ts @@ -21,48 +21,6 @@ function session(instanceId: string, id: string): Session { } describe("native session event reducer", () => { - it("applies native metadata events and exits compacting on failure", async () => { - const instanceId = "native-metadata" - const sessionId = "session" - const client = { message: { list: async () => ({ data: [] }) } } as any - ;(sdkManager as any).clients.set(`${instanceId}:/workspaces/${instanceId}/instance`, client) - addInstance({ id: instanceId, folder: "/work", port: 0, pid: 0, proxyPath: "", status: "ready", client }) - const compacting = { ...session(instanceId, sessionId), status: "compacting" as const } - setSessions((prev) => new Map(prev).set(instanceId, new Map([[sessionId, compacting]]))) - - try { - handleNativeSessionEvent(instanceId, { - id: "rename", created: 2, type: "session.renamed", durable: { aggregateID: sessionId, seq: 1, version: 1 }, - data: { sessionID: sessionId, title: "Renamed" }, - }) - handleNativeSessionEvent(instanceId, { - id: "agent", created: 3, type: "session.agent.selected", durable: { aggregateID: sessionId, seq: 2, version: 1 }, - data: { sessionID: sessionId, agent: "plan" }, - }) - handleNativeSessionEvent(instanceId, { - id: "model", created: 4, type: "session.model.selected", durable: { aggregateID: sessionId, seq: 3, version: 1 }, - data: { sessionID: sessionId, model: { providerID: "next", id: "model-2" } }, - }) - handleNativeSessionEvent(instanceId, { - id: "failed", created: 5, type: "session.compaction.failed", durable: { aggregateID: sessionId, seq: 4, version: 1 }, - data: { sessionID: sessionId, reason: "manual", error: { name: "UnknownError", data: { message: "failed" } } }, - } as any) - await delay(10) - - const updated = sessions().get(instanceId)?.get(sessionId) - assert.equal(updated?.title, "Renamed") - assert.equal(updated?.agent, "plan") - assert.deepEqual(updated?.model, { providerId: "next", modelId: "model-2" }) - assert.equal(updated?.status, "idle") - } finally { - messageStoreBus.unregisterInstance(instanceId) - setSessions((prev) => { const next = new Map(prev); next.delete(instanceId); return next }) - clearInstanceDeletedSessionAuthority(instanceId) - removeInstance(instanceId, { authoritative: false }) - sdkManager.destroyClientsForInstance(instanceId) - } - }) - it("coalesces text and tool events, then refreshes authoritatively on idle", async () => { const instanceId = "native-events" const sessionId = "session" @@ -130,40 +88,6 @@ describe("native session event reducer", () => { } }) - it("does not download message history for deltas already applied locally", async () => { - const instanceId = "native-delta-only" - const sessionId = "session" - let calls = 0 - const client = { message: { list: async () => { calls += 1; return { data: [] } } } } as any - ;(sdkManager as any).clients.set(`${instanceId}:/workspaces/${instanceId}/instance`, client) - addInstance({ id: instanceId, folder: "/work", port: 0, pid: 0, proxyPath: "", status: "ready", client }) - setSessions((prev) => new Map(prev).set(instanceId, new Map([[sessionId, session(instanceId, sessionId)]]))) - - try { - handleNativeSessionEvent(instanceId, { - id: "text", created: 1, type: "session.text.delta", - data: { sessionID: sessionId, assistantMessageID: "assistant", ordinal: 0, delta: "streamed" }, - }) - handleNativeSessionEvent(instanceId, { - id: "reasoning", created: 2, type: "session.reasoning.delta", - data: { sessionID: sessionId, assistantMessageID: "assistant", ordinal: 0, delta: "thought" }, - }) - await delay(120) - - assert.equal(calls, 0) - const streamed = messageStoreBus.getOrCreate(instanceId).getMessage("assistant") - assert.equal((streamed?.parts["assistant-text-native-0"]?.data as any)?.text, "streamed") - assert.equal((streamed?.parts["assistant-reasoning-native-0"]?.data as any)?.text, "thought") - } finally { - clearNativeContentDeltaState(instanceId) - messageStoreBus.unregisterInstance(instanceId) - setSessions((prev) => { const next = new Map(prev); next.delete(instanceId); return next }) - clearInstanceDeletedSessionAuthority(instanceId) - removeInstance(instanceId, { authoritative: false }) - sdkManager.destroyClientsForInstance(instanceId) - } - }) - it("refreshes periodically during a continuous fast native stream", async () => { const instanceId = "native-fast-stream" const sessionId = "session" diff --git a/packages/ui/src/stores/session-request-authority.test.ts b/packages/ui/src/stores/session-request-authority.test.ts index 463ffc55..1e183964 100644 --- a/packages/ui/src/stores/session-request-authority.test.ts +++ b/packages/ui/src/stores/session-request-authority.test.ts @@ -2,29 +2,18 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" import { sdkManager } from "../lib/sdk-manager.ts" -import { serverApi } from "../lib/api-client.ts" import type { Session } from "../types/session.ts" import { addInstance, removeInstance } from "./instances.ts" import { messageStoreBus } from "./message-v2/bus.ts" -import { createSession, fetchAgents, fetchProviders, fetchSessions, loadMessages, refreshSessionCatalog, removeSessionRuntimeState, searchSessions } from "./session-api.ts" -import { getCommands } from "./commands.ts" +import { fetchSessions, loadMessages, removeSessionRuntimeState, searchSessions } from "./session-api.ts" import { clearInstanceDeletedSessionAuthority, getSessionSearchResultIds, - getSessionListIds, - invalidateSessionMessageLoad, loading, messagesLoaded, - agents, - providers, sessions, - setAgents, - setProviders, - setActiveSession, - setSessionPage, setSessions, } from "./session-state.ts" -import { reloadWorktrees } from "./worktrees.ts" function deferred() { let resolve!: (value: T) => void @@ -32,9 +21,9 @@ function deferred() { return { promise, resolve } } -function session(instanceId: string, id: string, parentId: string | null = null): Session { +function session(instanceId: string, id: string): Session { return { - id, instanceId, parentId, title: id, agent: "build", model: { providerId: "provider", modelId: "model" }, + id, instanceId, parentId: null, title: id, agent: "build", model: { providerId: "provider", modelId: "model" }, status: "idle", retry: null, idleSince: null, generationRecovery: null, runtimeStatusKnown: true, version: "1", projectID: "project", location: { directory: "/work" }, cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: 1, updated: 1 }, @@ -42,27 +31,16 @@ function session(instanceId: string, id: string, parentId: string | null = null) } function apiSession(id: string, parentID?: string) { - return { id, parentID, title: id, projectID: "project", location: { directory: "/work" }, cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: 1, updated: 1 } } -} - -function apiMessage(id: string, _sessionId: string) { return { - id, type: "assistant", agent: "build", model: { providerID: "provider", id: "model" }, - time: { created: 1 }, content: [], + id, parentID, title: id, projectID: "project", location: { directory: "/work" }, cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: 1, updated: 1 }, } } -async function loadTestWorktree(instanceId: string): Promise { - const original = serverApi.fetchWorktrees - serverApi.fetchWorktrees = async () => ({ - worktrees: [{ slug: "branch", directory: "/worktree" }], - isGitRepo: true, - } as any) - try { - await reloadWorktrees(instanceId) - } finally { - serverApi.fetchWorktrees = original +function apiMessage(id: string) { + return { + id, type: "assistant", agent: "build", model: { providerID: "provider", id: "model" }, + time: { created: 1 }, content: [], } } @@ -74,9 +52,7 @@ function setup(instanceId: string) { client, cleanup() { messageStoreBus.unregisterInstance(instanceId) - setSessions((prev) => { const next = new Map(prev); next.delete(instanceId); return next }) - setAgents((prev) => { const next = new Map(prev); next.delete(instanceId); return next }) - setProviders((prev) => { const next = new Map(prev); next.delete(instanceId); return next }) + setSessions((previous) => { const next = new Map(previous); next.delete(instanceId); return next }) clearInstanceDeletedSessionAuthority(instanceId) removeInstance(instanceId, { authoritative: false }) sdkManager.destroyClientsForInstance(instanceId) @@ -115,14 +91,13 @@ describe("session request authority", () => { const { client, cleanup } = setup(instanceId) const response = deferred() ;(client as any).message = { list: () => response.promise } - setSessions((prev) => new Map(prev).set(instanceId, new Map([[sessionId, session(instanceId, sessionId)]]))) + setSessions((previous) => new Map(previous).set(instanceId, new Map([[sessionId, session(instanceId, sessionId)]]))) try { const request = loadMessages(instanceId, sessionId) removeSessionRuntimeState(instanceId, sessionId) - response.resolve({ data: [apiMessage("deleted-message", sessionId)] }) + response.resolve({ data: [apiMessage("deleted-message")] }) await request - assert.deepEqual(messageStoreBus.getOrCreate(instanceId).getSessionMessageIds(sessionId), []) assert.equal(messagesLoaded().get(instanceId)?.has(sessionId) ?? false, false) } finally { @@ -130,61 +105,14 @@ describe("session request authority", () => { } }) - it("does not hydrate messages after cache eviction", async () => { - const instanceId = "late-message-eviction", sessionId = "session" - const { client, cleanup } = setup(instanceId) - const response = deferred() - ;(client as any).message = { list: () => response.promise } - setSessions((prev) => new Map(prev).set(instanceId, new Map([[sessionId, session(instanceId, sessionId)]]))) - - try { - const request = loadMessages(instanceId, sessionId) - invalidateSessionMessageLoad(instanceId, sessionId) - response.resolve({ data: [apiMessage("evicted-message", sessionId)] }) - await request - - assert.deepEqual(messageStoreBus.getOrCreate(instanceId).getSessionMessageIds(sessionId), []) - assert.equal(messagesLoaded().get(instanceId)?.has(sessionId) ?? false, false) - } finally { - cleanup() - } - }) - - it("does not reuse message load authority after an instance reopens", async () => { - const instanceId = "reopened-message-load", sessionId = "session" - const { client, cleanup } = setup(instanceId) - const oldResponse = deferred() - const newResponse = deferred() - let calls = 0 - ;(client as any).message = { list: () => (++calls === 1 ? oldResponse.promise : newResponse.promise) } - setSessions((prev) => new Map(prev).set(instanceId, new Map([[sessionId, session(instanceId, sessionId)]]))) - - try { - const oldRequest = loadMessages(instanceId, sessionId) - removeInstance(instanceId, { authoritative: false }) - addInstance({ id: instanceId, folder: "/work", port: 0, pid: 0, proxyPath: "", status: "ready", client }) - setSessions((prev) => new Map(prev).set(instanceId, new Map([[sessionId, session(instanceId, sessionId)]]))) - const newRequest = loadMessages(instanceId, sessionId, { force: true }) - - oldResponse.resolve({ data: [apiMessage("old-message", sessionId)] }) - await oldRequest - newResponse.resolve({ data: [apiMessage("new-message", sessionId)] }) - await newRequest - - assert.deepEqual(messageStoreBus.getOrCreate(instanceId).getSessionMessageIds(sessionId), ["new-message"]) - } finally { - cleanup() - } - }) - - it("keeps a newer load authoritative when an older request finishes last", async () => { + it("keeps a newer message load when an older request finishes last", async () => { const instanceId = "newer-message-load", sessionId = "session" const { client, cleanup } = setup(instanceId) const oldResponse = deferred() const newResponse = deferred() let calls = 0 ;(client as any).message = { list: () => (++calls === 1 ? oldResponse.promise : newResponse.promise) } - setSessions((prev) => new Map(prev).set(instanceId, new Map([[sessionId, session(instanceId, sessionId)]]))) + setSessions((previous) => new Map(previous).set(instanceId, new Map([[sessionId, session(instanceId, sessionId)]]))) try { let invalidateOld = () => {} @@ -193,9 +121,9 @@ describe("session request authority", () => { }) const newRequest = loadMessages(instanceId, sessionId, { force: true }) invalidateOld() - newResponse.resolve({ data: [apiMessage("new-message", sessionId)] }) + newResponse.resolve({ data: [apiMessage("new-message")] }) await newRequest - oldResponse.resolve({ data: [apiMessage("old-message", sessionId)] }) + oldResponse.resolve({ data: [apiMessage("old-message")] }) await oldRequest assert.deepEqual(messageStoreBus.getOrCreate(instanceId).getSessionMessageIds(sessionId), ["new-message"]) @@ -234,135 +162,6 @@ describe("session request authority", () => { } }) - it("reconciles stale runtime status from native active sessions", async () => { - const instanceId = "authoritative-idle" - const { client, cleanup } = setup(instanceId) - const working = { ...session(instanceId, "working"), status: "working" as const } - const compacting = { ...session(instanceId, "compacting"), status: "compacting" as const } - const staleWorking = { ...session(instanceId, "stale-working"), status: "working" as const } - await loadTestWorktree(instanceId) - setSessions((prev) => new Map(prev).set(instanceId, new Map([ - [working.id, working], - [compacting.id, compacting], - [staleWorking.id, staleWorking], - ]))) - const statusOptions: unknown[] = [] - let messageOptions: unknown - ;(client.session as any).list = async () => ({ data: [ - apiSession("working"), - { ...apiSession("compacting"), directory: "/worktree", workspaceID: "workspace-1" }, - apiSession("stale-working"), - ] }) - ;(client.session as any).active = async () => ({ working: { type: "running" } }) - ;(client.session as any).status = async (options: unknown) => { - statusOptions.push(options) - return { data: {} } - } - ;(client as any).message = { list: async (options: unknown) => { - messageOptions = options - return { data: [] } - } } - ;(client.session as any).get = async ({ sessionID }: { sessionID: string }) => ({ data: apiSession(sessionID) }) - - try { - await fetchSessions(instanceId) - - assert.equal(sessions().get(instanceId)?.get("working")?.status, "working") - assert.equal(sessions().get(instanceId)?.get("compacting")?.status, "idle") - assert.equal(sessions().get(instanceId)?.get("stale-working")?.status, "idle") - assert.equal(sessions().get(instanceId)?.get("compacting")?.runtimeStatusKnown, true) - assert.deepEqual(statusOptions, []) - await loadMessages(instanceId, "compacting", { force: true }) - assert.deepEqual(messageOptions, { sessionID: "compacting", limit: 200, order: "asc" }) - } finally { - cleanup() - } - }) - - it("lists each logical root and worktree once and reconciles a complete union", async () => { - const instanceId = "multi-directory-session-list" - const { client, cleanup } = setup(instanceId) - await loadTestWorktree(instanceId) - const root = session(instanceId, "root") - const worktree = { ...session(instanceId, "worktree"), location: { directory: "/worktree" } } - const worktreePageTwo = { ...session(instanceId, "worktree-2"), location: { directory: "/worktree" } } - const deleted = session(instanceId, "deleted") - setSessions((prev) => new Map(prev).set(instanceId, new Map([ - [root.id, root], - [worktree.id, worktree], - [deleted.id, deleted], - ]))) - const listOptions: any[] = [] - ;(client.session as any).list = async (options: any) => { - listOptions.push(options) - if (options.directory !== "/worktree") return { data: [apiSession(root.id)], cursor: {} } - return options.cursor - ? { data: [{ ...apiSession(worktreePageTwo.id), location: { directory: "/worktree" } }], cursor: {} } - : { data: [{ ...apiSession(worktree.id), location: { directory: "/worktree" } }], cursor: { next: "worktree-2" } } - } - ;(client.session as any).active = async () => ({}) - - try { - await fetchSessions(instanceId) - - assert.deepEqual(listOptions, [ - { directory: "/work", limit: 10000 }, - { directory: "/worktree", limit: 10000 }, - { directory: "/worktree", cursor: "worktree-2", limit: 10000 }, - ]) - assert.deepEqual(Array.from(sessions().get(instanceId)?.keys() ?? []), [root.id, worktree.id, worktreePageTwo.id]) - } finally { - cleanup() - } - }) - - it("keeps missing local sessions when any directory cursor walk is partial", async () => { - const instanceId = "partial-multi-directory-session-list" - const { client, cleanup } = setup(instanceId) - await loadTestWorktree(instanceId) - const existing = session(instanceId, "existing") - setSessions((prev) => new Map(prev).set(instanceId, new Map([[existing.id, existing]]))) - setSessionPage(instanceId, [existing.id], false, true) - ;(client.session as any).list = async (options: any) => { - if (options.directory === "/work") return { data: [apiSession("root")], cursor: {} } - if (!options.cursor) return { data: [apiSession("worktree")], cursor: { next: "page-2" } } - throw new Error("cursor failed") - } - ;(client.session as any).active = async () => ({}) - - try { - await fetchSessions(instanceId) - assert.deepEqual(Array.from(sessions().get(instanceId)?.keys() ?? []), [existing.id, "root", "worktree"]) - assert.deepEqual(getSessionListIds(instanceId), [existing.id]) - } finally { - cleanup() - } - }) - - it("loads every ascending message page and hydrates once in page order", async () => { - const instanceId = "multi-page-messages", sessionId = "session" - const { client, cleanup } = setup(instanceId) - const options: any[] = [] - ;(client as any).message = { list: async (input: any) => { - options.push(input) - return input.cursor - ? { data: [apiMessage("message-2", sessionId)], cursor: {} } - : { data: [apiMessage("message-1", sessionId)], cursor: { next: "page-2" } } - } } - setSessions((prev) => new Map(prev).set(instanceId, new Map([[sessionId, session(instanceId, sessionId)]]))) - - try { - await loadMessages(instanceId, sessionId) - assert.deepEqual(options, [ - { sessionID: sessionId, limit: 200, order: "asc" }, - { sessionID: sessionId, limit: 200, cursor: "page-2" }, - ]) - assert.deepEqual(messageStoreBus.getOrCreate(instanceId).getSessionMessageIds(sessionId), ["message-1", "message-2"]) - } finally { - cleanup() - } - }) - it("does not replace messages when a later page fails", async () => { const instanceId = "partial-message-pages", sessionId = "session" const { client, cleanup } = setup(instanceId) @@ -370,10 +169,10 @@ describe("session request authority", () => { ;(client as any).message = { list: async (input: any) => { if (input.cursor && failSecondPage) throw new Error("cursor failed") return input.cursor - ? { data: [apiMessage("old-2", sessionId)], cursor: {} } - : { data: [apiMessage("old-1", sessionId)], cursor: { next: "page-2" } } + ? { data: [apiMessage("old-2")], cursor: {} } + : { data: [apiMessage("old-1")], cursor: { next: "page-2" } } } } - setSessions((prev) => new Map(prev).set(instanceId, new Map([[sessionId, session(instanceId, sessionId)]]))) + setSessions((previous) => new Map(previous).set(instanceId, new Map([[sessionId, session(instanceId, sessionId)]]))) try { await loadMessages(instanceId, sessionId) @@ -384,182 +183,4 @@ describe("session request authority", () => { cleanup() } }) - - it("keeps session agent ids independent from catalog labels and defaults new sessions", async () => { - const instanceId = "cold-agent-list" - const { client, cleanup } = setup(instanceId) - const created: any[] = [] - const persisted = { ...session(instanceId, "persisted"), agent: "Build" } - setSessions((prev) => new Map(prev).set(instanceId, new Map([[persisted.id, persisted]]))) - ;(client as any).agent = { - list: async () => ({ data: [] }), - get: async ({ agentID }: any) => ({ data: { - id: agentID, - name: agentID === "build" ? "Build" : "Plan", - description: "", - mode: "primary", - hidden: false, - } }), - } - ;(client as any).model = { default: async () => ({ data: { - id: "model", providerID: "opencode", - } }) } - ;(client.session as any).create = async (input: any) => { - created.push(input) - return apiSession("created") - } - - try { - await fetchAgents(instanceId) - assert.deepEqual(agents().get(instanceId)?.map(({ id, name }) => ({ id, name })), [ - { id: "build", name: "Build" }, - { id: "plan", name: "Plan" }, - ]) - assert.equal(sessions().get(instanceId)?.get("persisted")?.agent, "Build") - setSessions((prev) => new Map(prev).set(instanceId, new Map())) - await createSession(instanceId) - assert.equal(created[0].agent, "build") - assert.deepEqual(created[0].model, { providerID: "opencode", id: "model" }) - assert.equal(sessions().get(instanceId)?.get("created")?.agent, "build") - } finally { - cleanup() - } - }) - - it("builds a deterministic provider catalog from cold-start model results", async () => { - const instanceId = "cold-provider-list" - const { client, cleanup } = setup(instanceId) - const model = { - id: "model", modelID: "model", providerID: "opencode", name: "Model", - variants: [], cost: [{ input: 0, output: 0 }], limit: { context: 100, output: 10 }, - } - ;(client as any).provider = { list: async () => ({ data: [] }) } - ;(client as any).model = { - list: async () => ({ data: [model] }), - default: async () => ({ data: model }), - } - - try { - await fetchProviders(instanceId) - assert.deepEqual(providers().get(instanceId)?.map((provider) => ({ - id: provider.id, - name: provider.name, - defaultModelId: provider.defaultModelId, - models: provider.models.map((item) => item.id), - })), [{ id: "opencode", name: "opencode", defaultModelId: "model", models: ["model"] }]) - } finally { - cleanup() - } - }) - - it("refreshes agents, providers, models, and commands for the active session location", async () => { - const instanceId = "active-catalog-location" - const { client, cleanup } = setup(instanceId) - const locations: Array<[string, unknown]> = [] - const record = (kind: string, input: any) => locations.push([kind, input?.location]) - ;(client as any).agent = { - list: async (input: any) => { - record("agent", input) - return { data: ["build", "plan"].map((id) => ({ id, name: id, description: "", mode: "primary" })) } - }, - } - ;(client as any).provider = { list: async (input: any) => { record("provider", input); return { data: [] } } } - ;(client as any).model = { - list: async (input: any) => { record("model", input); return { data: [] } }, - default: async (input: any) => { record("default", input); return { data: null } }, - } - ;(client as any).command = { list: async (input: any) => { record("command", input); return { data: [] } } } - setSessions((prev) => new Map(prev).set(instanceId, new Map([ - ["root", session(instanceId, "root")], - ["worktree", { ...session(instanceId, "worktree"), location: { directory: "/worktree", workspaceID: "workspace-1" } }], - ]))) - - try { - setActiveSession(instanceId, "root") - await refreshSessionCatalog(instanceId) - setActiveSession(instanceId, "worktree") - await refreshSessionCatalog(instanceId) - - assert.deepEqual(locations, [ - ["agent", { directory: "/work" }], ["provider", { directory: "/work" }], ["model", { directory: "/work" }], ["default", { directory: "/work" }], ["command", { directory: "/work" }], - ["agent", { directory: "/worktree", workspace: "workspace-1" }], ["provider", { directory: "/worktree", workspace: "workspace-1" }], ["model", { directory: "/worktree", workspace: "workspace-1" }], ["default", { directory: "/worktree", workspace: "workspace-1" }], ["command", { directory: "/worktree", workspace: "workspace-1" }], - ]) - assert.deepEqual(getCommands(instanceId), []) - } finally { - cleanup() - } - }) - - it("retries a catalog location after a transient request failure", async () => { - const instanceId = "catalog-refresh-retry" - const { client, cleanup } = setup(instanceId) - let agentCalls = 0 - let providerCalls = 0 - let commandCalls = 0 - ;(client as any).agent = { - list: async () => { - agentCalls++ - return { data: ["build", "plan"].map((id) => ({ id, name: id, description: "", mode: "primary" })) } - }, - } - ;(client as any).provider = { list: async () => { providerCalls++; return { data: [] } } } - ;(client as any).model = { - list: async () => ({ data: [] }), - default: async () => ({ data: null }), - } - ;(client as any).command = { list: async () => { - commandCalls++ - if (commandCalls === 1) throw new Error("temporary") - return { data: [] } - } } - - try { - await refreshSessionCatalog(instanceId) - await refreshSessionCatalog(instanceId) - await refreshSessionCatalog(instanceId) - - assert.deepEqual({ agentCalls, providerCalls, commandCalls }, { agentCalls: 2, providerCalls: 2, commandCalls: 2 }) - } finally { - cleanup() - } - }) - - it("refreshes sessions when active status is unavailable", async () => { - const instanceId = "active-status-unavailable" - const { client, cleanup } = setup(instanceId) - const existing = { ...session(instanceId, "existing"), status: "working" as const } - setSessions((prev) => new Map(prev).set(instanceId, new Map([[existing.id, existing]]))) - ;(client.session as any).list = async () => ({ data: [apiSession(existing.id), apiSession("new")] }) - ;(client.session as any).active = async () => { throw new Error("forbidden") } - - try { - await fetchSessions(instanceId) - assert.equal(sessions().get(instanceId)?.get(existing.id)?.status, "working") - assert.equal(sessions().get(instanceId)?.get(existing.id)?.runtimeStatusKnown, true) - assert.equal(sessions().get(instanceId)?.get("new")?.status, "idle") - assert.equal(sessions().get(instanceId)?.get("new")?.runtimeStatusKnown, false) - } finally { - cleanup() - } - }) - - it("accepts native absolute session locations without workspace probing", async () => { - const instanceId = "unresolved-worktree-status" - const { client, cleanup } = setup(instanceId) - const existing = { ...session(instanceId, "worktree-session"), status: "working" as const } - await loadTestWorktree(instanceId) - setSessions((prev) => new Map(prev).set(instanceId, new Map([[existing.id, existing]]))) - ;(client.session as any).list = async () => ({ data: [ - { ...apiSession(existing.id), location: { directory: "/worktree" } }, - ] }) - ;(client.session as any).active = async () => ({ [existing.id]: { type: "running" } }) - - try { - await fetchSessions(instanceId, { strictStatus: true }) - assert.equal(sessions().get(instanceId)?.get(existing.id)?.status, "working") - assert.equal(sessions().get(instanceId)?.get(existing.id)?.location.directory, "/worktree") - } finally { - cleanup() - } - }) })