mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-08-21 14:23:26 +00:00
test(v2): remove redundant migration coverage
Reduce the V2 test matrix to CodeNomad-owned behavior instead of repeating generated OpenCode request shapes, mock choreography, equivalent endpoint permutations, and source-level helper wiring. Retain representative happy paths and the boundaries that protect workspace ownership, traversal and credentials, PID and WSL lifecycle proof, shutdown timeouts, permission isolation, restore handoff, deletion authority, streaming reconciliation, and persisted client state. This removes 2,818 net test and CI lines. The full server suite passes with 249 tests and 3 platform skips; the PR UI matrices pass with 204 standard and 53 browser-conditioned tests, both server and UI typechecks pass, and git diff checks are clean.
This commit is contained in:
parent
775c6c6ca1
commit
4c65e60fa9
27 changed files with 185 additions and 3003 deletions
8
.github/workflows/pr-build.yml
vendored
8
.github/workflows/pr-build.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>[] = []
|
||||
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<string, unknown>[] = []
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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<Record<string, unknown>> = []
|
||||
const client = {
|
||||
session: {
|
||||
get: async () => ({ location: { directory: "/repo" } }),
|
||||
},
|
||||
permission: { reply: async (input: Record<string, unknown>) => { 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<Record<string, unknown>> = []
|
||||
const client = {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -31,11 +31,8 @@ class ControlledSharedService {
|
|||
validationCalls: Array<{ location: LocationRef; options?: OpenCodeEnsureOptions }> = []
|
||||
evictions: LocationRef[] = []
|
||||
failEvictions = 0
|
||||
shutdownGate?: ReturnType<typeof deferred<void>>
|
||||
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<string, unknown> = {}) {
|
||||
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<void>()
|
||||
|
|
@ -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<void>()
|
||||
|
|
@ -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<void>()
|
||||
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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<void>((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<boolean>(() => 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<void>((resolve) => { markAccepted = resolve })
|
||||
const completion = new Promise<boolean>((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<never>(() => 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<void>((resolve) => { markStopStarted = resolve })
|
||||
const stopCompletion = new Promise<boolean>((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<Endpoint>((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<boolean>(() => 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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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<string, any>, 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: [],
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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/)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<void>((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<void>((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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -6,15 +6,7 @@ const deferred = () => {
|
|||
const promise = new Promise<void>((done) => { resolve = done })
|
||||
return { promise, resolve }
|
||||
}
|
||||
const tick = () => new Promise<void>((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<never>(() => {})
|
||||
}, { 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<void>((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<never>(() => {})
|
||||
}, { 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<never>(() => 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/)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
})
|
||||
})
|
||||
|
|
@ -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<void>((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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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/)
|
||||
})
|
||||
})
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
|
|
@ -10,66 +10,34 @@ import {
|
|||
} from "./native-session-streaming.ts"
|
||||
|
||||
const delay = (duration: number) => new Promise<void>((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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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"]])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<WorkspaceDescriptor>()
|
||||
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<WorkspaceDescriptor>()
|
||||
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<WorkspaceDescriptor>()
|
||||
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<WorkspaceDescriptor>()
|
||||
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<WorkspaceDescriptor>()
|
||||
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<WorkspaceDescriptor>()
|
||||
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" })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
|
|
@ -32,9 +21,9 @@ function deferred<T>() {
|
|||
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<void> {
|
||||
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<any>()
|
||||
;(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<any>()
|
||||
;(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<any>()
|
||||
const newResponse = deferred<any>()
|
||||
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<any>()
|
||||
const newResponse = deferred<any>()
|
||||
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<string, Session>([
|
||||
[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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue