mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-07 09:26:26 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0408dc0373 | ||
|
|
64c870e166 |
@@ -7,7 +7,5 @@ export { type FatalRendererErrorLog, type Platform, PlatformProvider } from "./r
|
||||
export { ServerConnection, useServers } from "./runtime/server/registry"
|
||||
export { useTabs } from "./shell/tabs/tabs"
|
||||
export { createDraftStore } from "./runtime/persistence/drafts"
|
||||
export { createNamespaceStorage, type NamespaceStorage } from "./runtime/persistence/namespace"
|
||||
export { flushPersisted } from "./runtime/persistence/persist"
|
||||
export { useWslServers } from "./servers/wsl/context"
|
||||
export { type UpdaterPlatform, type UpdaterState } from "./shell/updates/types"
|
||||
|
||||
@@ -49,36 +49,6 @@ migration rules remain explicit in their schemas.
|
||||
invalid entries individually. Valid entries still pass through their codecs.
|
||||
- Recovery is not a substitute for an explicit historical shape transformation.
|
||||
|
||||
## Writes
|
||||
|
||||
The setter returned by `persisted()` only marks the store dirty (`persist.ts`). The store is
|
||||
serialized once per save window (`persistSaveDelay`), on owner cleanup, and when the page
|
||||
hides, and the write is skipped when the serialized form did not change. Reactive observers
|
||||
therefore see every mutation immediately and a burst of setter calls costs one encode. Call
|
||||
`flushPersisted()` when a test or a shutdown path needs the write to have happened; the
|
||||
desktop platform calls it before flushing its namespaces on shutdown. A real unsaved local
|
||||
change wins over a value arriving from another window, and over a stored value that finishes
|
||||
loading after the user already edited. A remote value that arrives while the store is dirty
|
||||
is held until the save runs; if the local setter calls turned out not to change the
|
||||
serialized form, the remote value is adopted instead of being lost.
|
||||
|
||||
## Namespaces
|
||||
|
||||
On desktop, `platform.storage(name)` returns a `NamespaceStorage` (`namespace.ts`): the
|
||||
in-memory truth for one storage namespace, modelled on VS Code's `Storage` class. The
|
||||
namespace is loaded from the host once, reads are Map lookups from then on, and writes
|
||||
update the cache immediately while being batched into one host round trip per flush
|
||||
window (`namespaceFlushDelay`). `flush()` hands the batch to the driver synchronously, so a
|
||||
flush on page hide is on the wire before the page goes away; the desktop platform flushes
|
||||
every namespace before the IPC runtime is disposed and whenever the window is hidden. Each
|
||||
local write carries a sequence number that is kept until the host accepts that exact write;
|
||||
until then neither the initial load, a change from another window (`accept`), nor the retry
|
||||
of an older failed batch can replace the key. The host also stamps every update with a
|
||||
monotonic revision, returned in the ack and carried by change events and loads, so an event
|
||||
that reaches a window after a newer ack or load for the same key is recognised as stale and
|
||||
dropped; an event held back during an in-flight write is applied after the ack when the host
|
||||
ordered it later.
|
||||
|
||||
## Migrations
|
||||
|
||||
Describe shipped representations with schemas and transform their typed values
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createNamespaceStorage, type NamespaceDriver, type NamespaceStorage } from "./namespace"
|
||||
|
||||
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
type Call = { kind: string; name: string; insert?: Record<string, string>; remove?: string[] }
|
||||
|
||||
// A host with a monotonic revision. Each update is acked to its caller and recorded as an event
|
||||
// that the test delivers to other windows whenever it chooses, like the real event stream.
|
||||
function host(initial: Record<string, Record<string, string>> = {}) {
|
||||
const data = new Map(Object.entries(initial).map(([name, items]) => [name, new Map(Object.entries(items))]))
|
||||
const calls: Call[] = []
|
||||
const events: { name: string; insert: Record<string, string>; remove: string[]; revision: number }[] = []
|
||||
let revision = 0
|
||||
let fail: (insert: Record<string, string>) => boolean = () => false
|
||||
let gate: Promise<void> | undefined
|
||||
const driver: NamespaceDriver = {
|
||||
items: async (name) => {
|
||||
calls.push({ kind: "items", name })
|
||||
return { items: Object.fromEntries(data.get(name) ?? []), revision }
|
||||
},
|
||||
update: async (name, insert, remove) => {
|
||||
calls.push({ kind: "update", name, insert, remove })
|
||||
await gate
|
||||
if (fail(insert)) throw new Error("disk full")
|
||||
const items = data.get(name) ?? new Map()
|
||||
for (const [key, value] of Object.entries(insert)) items.set(key, value)
|
||||
for (const key of remove) items.delete(key)
|
||||
data.set(name, items)
|
||||
events.push({ name, insert, remove, revision: ++revision })
|
||||
return revision
|
||||
},
|
||||
clear: async (name) => {
|
||||
calls.push({ kind: "clear", name })
|
||||
data.delete(name)
|
||||
},
|
||||
}
|
||||
return {
|
||||
driver,
|
||||
data,
|
||||
calls,
|
||||
events,
|
||||
updates: () => calls.filter((call) => call.kind === "update"),
|
||||
setFail: (value: boolean | ((insert: Record<string, string>) => boolean)) =>
|
||||
(fail = typeof value === "boolean" ? () => value : value),
|
||||
setGate: (value: Promise<void> | undefined) => (gate = value),
|
||||
deliver: (target: NamespaceStorage, index: number) => {
|
||||
const event = events[index]!
|
||||
target.accept(event.insert, event.remove, event.revision)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("namespace storage", () => {
|
||||
test("loads the namespace once and serves reads from memory", async () => {
|
||||
const h = host({ w: { tabs: "[]", recent: "{}" } })
|
||||
const storage = createNamespaceStorage(h.driver, "w", { delay: 10 })
|
||||
expect(await storage.getItem("tabs")).toBe("[]")
|
||||
expect(await storage.getItem("recent")).toBe("{}")
|
||||
expect(await storage.getItem("missing")).toBeNull()
|
||||
expect(await storage.getLength()).toBe(2)
|
||||
expect(h.calls.filter((call) => call.kind === "items")).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("reads its own writes immediately and coalesces them into one update", async () => {
|
||||
const h = host()
|
||||
const storage = createNamespaceStorage(h.driver, "w", { delay: 10 })
|
||||
void storage.setItem("tabs", "[1]")
|
||||
void storage.setItem("recent", "{}")
|
||||
void storage.setItem("tabs", "[1,2]")
|
||||
void storage.removeItem("recent")
|
||||
expect(await storage.getItem("tabs")).toBe("[1,2]")
|
||||
expect(await storage.getItem("recent")).toBeNull()
|
||||
expect(h.updates()).toHaveLength(0)
|
||||
await wait(30)
|
||||
expect(h.updates()).toEqual([{ kind: "update", name: "w", insert: { tabs: "[1,2]" }, remove: ["recent"] }])
|
||||
})
|
||||
|
||||
test("writes made while loading win over the loaded snapshot", async () => {
|
||||
const h = host({ w: { tabs: "old" } })
|
||||
const storage = createNamespaceStorage(h.driver, "w", { delay: 10 })
|
||||
const read = storage.getItem("tabs")
|
||||
void storage.setItem("tabs", "new")
|
||||
expect(await read).toBe("new")
|
||||
})
|
||||
|
||||
test("flush writes now and resolves after the driver accepted the batch", async () => {
|
||||
const h = host()
|
||||
const storage = createNamespaceStorage(h.driver, "w", { delay: 10_000 })
|
||||
void storage.setItem("tabs", "[1]")
|
||||
await storage.flush()
|
||||
expect(h.data.get("w")?.get("tabs")).toBe("[1]")
|
||||
await storage.flush()
|
||||
expect(h.updates()).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("a failed update keeps unsuperseded changes queued for the next flush", async () => {
|
||||
const h = host()
|
||||
const storage = createNamespaceStorage(h.driver, "w", { delay: 10_000 })
|
||||
h.setFail(true)
|
||||
void storage.setItem("tabs", "[1]")
|
||||
void storage.setItem("recent", "{}")
|
||||
await storage.flush()
|
||||
expect(h.data.get("w")).toBeUndefined()
|
||||
h.setFail(false)
|
||||
void storage.setItem("tabs", "[2]")
|
||||
await storage.flush()
|
||||
expect(Object.fromEntries(h.data.get("w")!)).toEqual({ tabs: "[2]", recent: "{}" })
|
||||
})
|
||||
|
||||
test("a batch is handed to the driver synchronously, not behind an earlier reply", async () => {
|
||||
const h = host()
|
||||
const first = Promise.withResolvers<void>()
|
||||
h.setGate(first.promise)
|
||||
const storage = createNamespaceStorage(h.driver, "w", { delay: 10_000 })
|
||||
void storage.setItem("tabs", "[1]")
|
||||
void storage.flush()
|
||||
void storage.setItem("tabs", "[2]")
|
||||
void storage.flush()
|
||||
// Both batches reached the driver while the first reply is still outstanding.
|
||||
expect(h.updates().map((call) => call.insert)).toEqual([{ tabs: "[1]" }, { tabs: "[2]" }])
|
||||
first.resolve()
|
||||
await storage.flush()
|
||||
})
|
||||
|
||||
test("a pending load or an external change cannot overwrite a value that is in flight", async () => {
|
||||
const loaded = Promise.withResolvers<{ items: Record<string, string>; revision: number }>()
|
||||
const accepted = Promise.withResolvers<number>()
|
||||
const driver: NamespaceDriver = {
|
||||
items: () => loaded.promise,
|
||||
update: () => accepted.promise,
|
||||
clear: async () => undefined,
|
||||
}
|
||||
const storage = createNamespaceStorage(driver, "g", { delay: 10_000 })
|
||||
const read = storage.getItem("model")
|
||||
void storage.setItem("model", "local")
|
||||
void storage.flush()
|
||||
storage.accept({ model: "other-window-older" }, [], 1)
|
||||
loaded.resolve({ items: { model: "snapshot-older" }, revision: 0 })
|
||||
expect(await read).toBe("local")
|
||||
accepted.resolve(2)
|
||||
await storage.flush()
|
||||
expect(await storage.getItem("model")).toBe("local")
|
||||
storage.accept({ model: "other-window-newer" }, [], 3)
|
||||
expect(await storage.getItem("model")).toBe("other-window-newer")
|
||||
})
|
||||
|
||||
test("a failed batch does not requeue a value a later batch already replaced", async () => {
|
||||
const h = host()
|
||||
const first = Promise.withResolvers<void>()
|
||||
h.setGate(first.promise)
|
||||
// The first batch (old) is held at the host and will be rejected; the second (new) succeeds.
|
||||
h.setFail((insert) => insert.tabs === "old")
|
||||
const storage = createNamespaceStorage(h.driver, "w", { delay: 10_000 })
|
||||
void storage.setItem("tabs", "old")
|
||||
void storage.flush()
|
||||
h.setGate(undefined)
|
||||
void storage.setItem("tabs", "new")
|
||||
void storage.flush()
|
||||
await wait(0)
|
||||
expect(h.data.get("w")?.get("tabs")).toBe("new")
|
||||
first.resolve()
|
||||
await storage.flush()
|
||||
await storage.flush()
|
||||
expect(h.updates().map((call) => call.insert)).toEqual([{ tabs: "old" }, { tabs: "new" }])
|
||||
expect(h.data.get("w")?.get("tabs")).toBe("new")
|
||||
expect(await storage.getItem("tabs")).toBe("new")
|
||||
})
|
||||
|
||||
test("an event that reaches a window after a newer ack for the same key is ignored", async () => {
|
||||
const h = host({ g: { model: "start" } })
|
||||
const one = createNamespaceStorage(h.driver, "g", { delay: 10_000 })
|
||||
const two = createNamespaceStorage(h.driver, "g", { delay: 10_000 })
|
||||
await one.getItem("model")
|
||||
await two.getItem("model")
|
||||
void one.setItem("model", "A")
|
||||
await one.flush()
|
||||
void two.setItem("model", "B")
|
||||
await two.flush()
|
||||
// Both writes are acked. Now the event for A, which the host applied before B, reaches two.
|
||||
h.deliver(two, 0)
|
||||
expect(await two.getItem("model")).toBe("B")
|
||||
expect(h.data.get("g")?.get("model")).toBe("B")
|
||||
// One still receives B, which is newer than its own ack.
|
||||
h.deliver(one, 1)
|
||||
expect(await one.getItem("model")).toBe("B")
|
||||
})
|
||||
|
||||
test("an event held back during an in-flight write wins after the ack if the host applied it later", async () => {
|
||||
const h = host()
|
||||
const gate = Promise.withResolvers<void>()
|
||||
h.setGate(gate.promise)
|
||||
const storage = createNamespaceStorage(h.driver, "g", { delay: 10_000 })
|
||||
void storage.setItem("model", "mine")
|
||||
void storage.flush()
|
||||
// Another window's write for the same key landed at the host after ours will.
|
||||
storage.accept({ model: "theirs" }, [], 2)
|
||||
expect(await storage.getItem("model")).toBe("mine")
|
||||
gate.resolve()
|
||||
await storage.flush()
|
||||
expect(await storage.getItem("model")).toBe("theirs")
|
||||
})
|
||||
|
||||
test("the initial load removes a key an older event inserted while the load was in flight", async () => {
|
||||
const loaded = Promise.withResolvers<{ items: Record<string, string>; revision: number }>()
|
||||
const driver: NamespaceDriver = { items: () => loaded.promise, update: async () => 0, clear: async () => undefined }
|
||||
const storage = createNamespaceStorage(driver, "g", { delay: 10_000 })
|
||||
const read = storage.getItem("model")
|
||||
// Host history: insert at 41, delete at 42; the snapshot was taken at 42.
|
||||
storage.accept({ model: "inserted" }, [], 41)
|
||||
loaded.resolve({ items: {}, revision: 42 })
|
||||
expect(await read).toBeNull()
|
||||
// The delete event is older than the floor and must stay a no-op either way.
|
||||
storage.accept({}, ["model"], 42)
|
||||
expect(await storage.getItem("model")).toBeNull()
|
||||
// A key inserted by an event newer than the snapshot survives the load.
|
||||
const second = Promise.withResolvers<{ items: Record<string, string>; revision: number }>()
|
||||
const other = createNamespaceStorage({ ...driver, items: () => second.promise }, "g", { delay: 10_000 })
|
||||
const pending = other.getItem("model")
|
||||
other.accept({ model: "after-snapshot" }, [], 43)
|
||||
second.resolve({ items: {}, revision: 42 })
|
||||
expect(await pending).toBe("after-snapshot")
|
||||
})
|
||||
|
||||
test("an event older than the initial load is ignored", async () => {
|
||||
const h = host({ g: { model: "loaded" } })
|
||||
void h.driver.update("g", { model: "loaded" }, [])
|
||||
await wait(0)
|
||||
const storage = createNamespaceStorage(h.driver, "g", { delay: 10_000 })
|
||||
await storage.getItem("model")
|
||||
storage.accept({ model: "before-load" }, [], 1)
|
||||
expect(await storage.getItem("model")).toBe("loaded")
|
||||
storage.accept({}, ["model"], 2)
|
||||
expect(await storage.getItem("model")).toBeNull()
|
||||
})
|
||||
|
||||
test("clear drops the cache and queued changes and clears the driver", async () => {
|
||||
const h = host({ w: { tabs: "[]" } })
|
||||
const storage = createNamespaceStorage(h.driver, "w", { delay: 10_000 })
|
||||
await storage.getItem("tabs")
|
||||
void storage.setItem("recent", "{}")
|
||||
await storage.clear()
|
||||
expect(await storage.getItem("tabs")).toBeNull()
|
||||
expect(await storage.getItem("recent")).toBeNull()
|
||||
expect(h.calls.map((call) => call.kind)).toEqual(["items", "clear"])
|
||||
})
|
||||
})
|
||||
@@ -1,159 +0,0 @@
|
||||
import type { AsyncStorage } from "@solid-primitives/storage"
|
||||
|
||||
// The host-side store for one namespace: one bulk read, one bulk write. The host stamps every
|
||||
// update with a monotonic revision and reports it with reads, acks, and change events.
|
||||
export type NamespaceDriver = {
|
||||
items(name: string): Promise<{ items: Record<string, string>; revision: number }>
|
||||
update(name: string, insert: Record<string, string>, remove: string[]): Promise<number>
|
||||
clear(name: string): Promise<void>
|
||||
}
|
||||
|
||||
export type NamespaceStorage = AsyncStorage & {
|
||||
/** Write every queued change now. Resolves when the driver has accepted it. */
|
||||
flush(): Promise<void>
|
||||
/** Apply a change another window made at `revision`, unless this window already holds something newer. */
|
||||
accept(insert: Record<string, string>, remove: string[], revision: number): void
|
||||
}
|
||||
|
||||
export const namespaceFlushDelay = 100
|
||||
|
||||
// In-memory truth for a namespace. Reads load the namespace once and are Map lookups from then
|
||||
// on; writes update the cache immediately and are batched into one driver call per flush window,
|
||||
// so a burst of setter calls costs one round trip. Mirrors VS Code's Storage class.
|
||||
//
|
||||
// Two orderings keep the cache correct. Every local write gets a sequence number that stays
|
||||
// recorded until the host acks that exact write; while recorded, nothing external may replace
|
||||
// the key. Every value the cache holds also carries the host revision it came from, so an event
|
||||
// that reaches this window after a newer ack or load is recognised as stale and dropped. Batches
|
||||
// are posted as soon as they are cut, never behind an earlier reply, so a flush on pagehide is
|
||||
// on the wire before the page goes away.
|
||||
export function createNamespaceStorage(
|
||||
driver: NamespaceDriver,
|
||||
name: string,
|
||||
options: { delay?: number } = {},
|
||||
): NamespaceStorage {
|
||||
const delay = options.delay ?? namespaceFlushDelay
|
||||
const cache = new Map<string, string>()
|
||||
const local = new Map<string, { seq: number; value: string | null }>()
|
||||
const dirty = new Set<string>()
|
||||
const inflight = new Set<Promise<void>>()
|
||||
// Host revision behind each cached key, and the revision the initial load reflected for all keys.
|
||||
const applied = new Map<string, number>()
|
||||
let floor = -1
|
||||
// The newest external change for a key that arrived while a local write was still in flight.
|
||||
const deferred = new Map<string, { revision: number; value: string | null }>()
|
||||
let seq = 0
|
||||
let loading: Promise<void> | undefined
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const place = (key: string, value: string | null, revision: number) => {
|
||||
if (value === null) cache.delete(key)
|
||||
else cache.set(key, value)
|
||||
applied.set(key, revision)
|
||||
}
|
||||
|
||||
// The snapshot is the whole truth at its revision: a key it lacks was deleted by then, even if
|
||||
// an older event inserted it into the cache while the load was in flight.
|
||||
const load = () =>
|
||||
(loading ??= driver.items(name).then((loaded) => {
|
||||
floor = loaded.revision
|
||||
const stale = (key: string) => !local.has(key) && (applied.get(key) ?? -1) <= loaded.revision
|
||||
for (const key of [...cache.keys()]) {
|
||||
if (!(key in loaded.items) && stale(key)) place(key, null, loaded.revision)
|
||||
}
|
||||
for (const [key, value] of Object.entries(loaded.items)) {
|
||||
if (stale(key)) place(key, value, loaded.revision)
|
||||
}
|
||||
}))
|
||||
|
||||
const write = (key: string, value: string | null) => {
|
||||
if (value === null) cache.delete(key)
|
||||
else cache.set(key, value)
|
||||
local.set(key, { seq: ++seq, value })
|
||||
dirty.add(key)
|
||||
timer ??= setTimeout(() => void flush(), delay)
|
||||
}
|
||||
|
||||
// The host accepted this window's value for `key` at `revision`. A change from another window
|
||||
// that was held back meanwhile wins if the host applied it later than ours.
|
||||
const acknowledge = (key: string, revision: number) => {
|
||||
local.delete(key)
|
||||
const later = deferred.get(key)
|
||||
deferred.delete(key)
|
||||
if (later && later.revision > revision) return place(key, later.value, later.revision)
|
||||
applied.set(key, revision)
|
||||
}
|
||||
|
||||
const flush = () => {
|
||||
clearTimeout(timer)
|
||||
timer = undefined
|
||||
if (dirty.size > 0) {
|
||||
const batch = [...dirty].map((key) => ({ key, ...local.get(key)! }))
|
||||
dirty.clear()
|
||||
const insert = Object.fromEntries(batch.filter((entry) => entry.value !== null).map((e) => [e.key, e.value!]))
|
||||
const remove = batch.filter((entry) => entry.value === null).map((entry) => entry.key)
|
||||
const current = (entry: { key: string; seq: number }) => local.get(entry.key)?.seq === entry.seq
|
||||
const request = driver
|
||||
.update(name, insert, remove)
|
||||
.then((revision) => batch.filter(current).forEach((entry) => acknowledge(entry.key, revision)))
|
||||
.catch((error: unknown) => {
|
||||
// Only a value nothing newer has replaced is worth retrying.
|
||||
batch.filter(current).forEach((entry) => dirty.add(entry.key))
|
||||
console.error(`[persistence] flush failed for ${name}`, error)
|
||||
})
|
||||
.finally(() => inflight.delete(request))
|
||||
inflight.add(request)
|
||||
}
|
||||
return Promise.all(inflight).then(() => undefined)
|
||||
}
|
||||
|
||||
const storage: NamespaceStorage = {
|
||||
getItem: async (key) => {
|
||||
await load()
|
||||
return cache.get(key) ?? null
|
||||
},
|
||||
setItem: async (key, value) => write(key, value),
|
||||
removeItem: async (key) => write(key, null),
|
||||
clear: async () => {
|
||||
clearTimeout(timer)
|
||||
timer = undefined
|
||||
cache.clear()
|
||||
local.clear()
|
||||
dirty.clear()
|
||||
applied.clear()
|
||||
deferred.clear()
|
||||
loading = Promise.resolve()
|
||||
await driver.clear(name)
|
||||
},
|
||||
key: async (index: number) => {
|
||||
await load()
|
||||
return [...cache.keys()][index]
|
||||
},
|
||||
getLength: async () => {
|
||||
await load()
|
||||
return cache.size
|
||||
},
|
||||
get length() {
|
||||
return storage.getLength()
|
||||
},
|
||||
flush,
|
||||
accept(insert, remove, revision) {
|
||||
// The initial load already reflects everything up to `floor`.
|
||||
if (revision <= floor) return
|
||||
const changes = [
|
||||
...Object.entries(insert).map(([key, value]) => [key, value] as const),
|
||||
...remove.map((key) => [key, null] as const),
|
||||
]
|
||||
for (const [key, value] of changes) {
|
||||
if (local.has(key)) {
|
||||
const held = deferred.get(key)
|
||||
if (!held || revision > held.revision) deferred.set(key, { revision, value })
|
||||
continue
|
||||
}
|
||||
if (revision <= (applied.get(key) ?? floor)) continue
|
||||
place(key, value, revision)
|
||||
}
|
||||
},
|
||||
}
|
||||
return storage
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { AsyncStorage, PersistenceSyncAPI, PersistenceSyncCallback, SyncStorage } from "@solid-primitives/storage"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { flushPersisted, persistStore } from "./persist"
|
||||
|
||||
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
type State = { count: number; label: string }
|
||||
|
||||
function setup(input: { initial?: string | null | Promise<string | null>; delay?: number; sync?: PersistenceSyncAPI }) {
|
||||
const writes: string[] = []
|
||||
return createRoot((dispose) => {
|
||||
const [store, setStore] = createStore<State>({ count: 0, label: "" })
|
||||
const persist = persistStore({
|
||||
store,
|
||||
setStore,
|
||||
name: "state",
|
||||
// One fixture serves both the sync and the async storage shape.
|
||||
storage: {
|
||||
getItem: () => input.initial ?? null,
|
||||
setItem: (_key: string, value: string) => {
|
||||
writes.push(value)
|
||||
},
|
||||
removeItem: () => {},
|
||||
} as SyncStorage | AsyncStorage,
|
||||
serialize: JSON.stringify,
|
||||
deserialize: JSON.parse,
|
||||
sync: input.sync,
|
||||
delay: input.delay ?? 10,
|
||||
})
|
||||
return { store, set: persist.setStore, persist, writes, dispose }
|
||||
})
|
||||
}
|
||||
|
||||
describe("persistStore", () => {
|
||||
test("marks the store dirty on set and writes once after the delay", async () => {
|
||||
const value = setup({})
|
||||
value.set("count", 1)
|
||||
value.set("count", 2)
|
||||
value.set("label", "a")
|
||||
expect(value.store.count).toBe(2)
|
||||
expect(value.writes).toEqual([])
|
||||
await wait(30)
|
||||
expect(value.writes).toEqual([JSON.stringify({ count: 2, label: "a" })])
|
||||
value.dispose()
|
||||
})
|
||||
|
||||
test("skips the write when the serialized value did not change", () => {
|
||||
const value = setup({ delay: 10_000 })
|
||||
value.set("count", 1)
|
||||
value.persist.flush()
|
||||
value.set("count", 1)
|
||||
value.persist.flush()
|
||||
expect(value.writes).toHaveLength(1)
|
||||
value.dispose()
|
||||
})
|
||||
|
||||
test("hydrates synchronously from sync storage without writing back", () => {
|
||||
const value = setup({ initial: JSON.stringify({ count: 5, label: "saved" }), delay: 10_000 })
|
||||
expect(value.store).toEqual({ count: 5, label: "saved" })
|
||||
value.persist.flush()
|
||||
expect(value.writes).toEqual([])
|
||||
value.dispose()
|
||||
})
|
||||
|
||||
test("a set made while async storage loads wins over the loaded value", async () => {
|
||||
const loading = Promise.withResolvers<string | null>()
|
||||
const value = setup({ initial: loading.promise, delay: 10_000 })
|
||||
value.set("count", 9)
|
||||
loading.resolve(JSON.stringify({ count: 1, label: "old" }))
|
||||
await loading.promise
|
||||
expect(value.store.count).toBe(9)
|
||||
value.dispose()
|
||||
})
|
||||
|
||||
test("applies another window's value when clean and ignores it while dirty", () => {
|
||||
const listeners: PersistenceSyncCallback[] = []
|
||||
const sent: string[] = []
|
||||
const value = setup({
|
||||
delay: 10_000,
|
||||
sync: [(subscriber) => listeners.push(subscriber), (_key, next) => sent.push(String(next))],
|
||||
})
|
||||
listeners[0]!({ key: "state", newValue: JSON.stringify({ count: 3, label: "remote" }), timeStamp: 0 })
|
||||
expect(value.store).toEqual({ count: 3, label: "remote" })
|
||||
value.set("label", "local")
|
||||
listeners[0]!({ key: "state", newValue: JSON.stringify({ count: 4, label: "remote-2" }), timeStamp: 0 })
|
||||
expect(value.store).toEqual({ count: 3, label: "local" })
|
||||
value.persist.flush()
|
||||
expect(sent).toEqual([JSON.stringify({ count: 3, label: "local" })])
|
||||
value.dispose()
|
||||
})
|
||||
|
||||
test("a remote value arriving during a no-op local set is adopted when the save finds no change", () => {
|
||||
const listeners: PersistenceSyncCallback[] = []
|
||||
const sent: string[] = []
|
||||
const value = setup({
|
||||
delay: 10_000,
|
||||
sync: [(subscriber) => listeners.push(subscriber), (_key, next) => sent.push(String(next))],
|
||||
})
|
||||
value.set("count", 1)
|
||||
value.persist.flush()
|
||||
// Setting the same value again marks the store dirty without changing it.
|
||||
value.set("count", 1)
|
||||
listeners[0]!({ key: "state", newValue: JSON.stringify({ count: 1, label: "remote" }), timeStamp: 0 })
|
||||
expect(value.store.label).toBe("")
|
||||
value.persist.flush()
|
||||
expect(value.store).toEqual({ count: 1, label: "remote" })
|
||||
expect(value.writes).toHaveLength(1)
|
||||
expect(sent).toHaveLength(1)
|
||||
// A later save must not consider the adopted value a local change.
|
||||
value.set("count", 1)
|
||||
value.persist.flush()
|
||||
expect(value.writes).toHaveLength(1)
|
||||
value.dispose()
|
||||
})
|
||||
|
||||
test("a remote revert to the saved value during a no-op local set clears an earlier held change", () => {
|
||||
const listeners: PersistenceSyncCallback[] = []
|
||||
const value = setup({ delay: 10_000, sync: [(subscriber) => listeners.push(subscriber), () => {}] })
|
||||
value.set("label", "saved")
|
||||
value.persist.flush()
|
||||
value.set("label", "saved")
|
||||
listeners[0]!({ key: "state", newValue: JSON.stringify({ count: 0, label: "changed" }), timeStamp: 0 })
|
||||
listeners[0]!({ key: "state", newValue: JSON.stringify({ count: 0, label: "saved" }), timeStamp: 0 })
|
||||
value.persist.flush()
|
||||
expect(value.store).toEqual({ count: 0, label: "saved" })
|
||||
expect(value.writes).toHaveLength(1)
|
||||
value.dispose()
|
||||
})
|
||||
|
||||
test("a remote value arriving during a real local change is dropped in favour of the local one", () => {
|
||||
const listeners: PersistenceSyncCallback[] = []
|
||||
const value = setup({ delay: 10_000, sync: [(subscriber) => listeners.push(subscriber), () => {}] })
|
||||
value.set("count", 1)
|
||||
listeners[0]!({ key: "state", newValue: JSON.stringify({ count: 9, label: "remote" }), timeStamp: 0 })
|
||||
value.persist.flush()
|
||||
expect(value.store).toEqual({ count: 1, label: "" })
|
||||
expect(value.writes).toEqual([JSON.stringify({ count: 1, label: "" })])
|
||||
value.dispose()
|
||||
})
|
||||
|
||||
test("disposing the owner and flushPersisted both save pending changes", () => {
|
||||
const first = setup({ delay: 10_000 })
|
||||
first.set("count", 1)
|
||||
first.dispose()
|
||||
expect(first.writes).toHaveLength(1)
|
||||
|
||||
const second = setup({ delay: 10_000 })
|
||||
second.set("count", 2)
|
||||
flushPersisted()
|
||||
expect(second.writes).toEqual([JSON.stringify({ count: 2, label: "" })])
|
||||
second.dispose()
|
||||
expect(second.writes).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -1,100 +0,0 @@
|
||||
import type { AsyncStorage, PersistenceSyncAPI, SyncStorage } from "@solid-primitives/storage"
|
||||
import { getOwner, onCleanup, untrack } from "solid-js"
|
||||
import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
|
||||
export const persistSaveDelay = 100
|
||||
|
||||
const pending = new Set<() => void>()
|
||||
|
||||
/** Serialize and write every store with unsaved changes now. */
|
||||
export function flushPersisted() {
|
||||
for (const save of [...pending]) save()
|
||||
}
|
||||
|
||||
// Covers synchronous web storage. Desktop registers its own pagehide handling earlier than this
|
||||
// module loads, so its shutdown path calls flushPersisted() itself before flushing namespaces.
|
||||
if (typeof document !== "undefined") {
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState === "hidden") flushPersisted()
|
||||
})
|
||||
window.addEventListener("pagehide", flushPersisted)
|
||||
}
|
||||
|
||||
// A store whose serialized form is written to storage on a schedule instead of on every setter
|
||||
// call. The setter only marks the store dirty; serialization happens once per save window, once
|
||||
// per owner cleanup, and when the page hides. Mirrors VS Code's Memento.
|
||||
export function persistStore<T extends object>(input: {
|
||||
store: Store<T>
|
||||
setStore: SetStoreFunction<T>
|
||||
name: string
|
||||
storage: SyncStorage | AsyncStorage
|
||||
serialize: (value: T) => string
|
||||
deserialize: (raw: string) => T
|
||||
sync?: PersistenceSyncAPI
|
||||
delay?: number
|
||||
}) {
|
||||
const delay = input.delay ?? persistSaveDelay
|
||||
let dirty = false
|
||||
let touched = false
|
||||
let last: string | undefined
|
||||
// The newest value another window wrote while this store was dirty; applied at save time if the
|
||||
// local setter calls turned out not to change anything.
|
||||
let remote: string | undefined
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const save = () => {
|
||||
clearTimeout(timer)
|
||||
timer = undefined
|
||||
pending.delete(save)
|
||||
if (!dirty) return
|
||||
dirty = false
|
||||
const held = remote
|
||||
remote = undefined
|
||||
const next = untrack(() => input.serialize(input.store))
|
||||
if (next === last) {
|
||||
if (held !== undefined && held !== last) hydrate(held)
|
||||
return
|
||||
}
|
||||
last = next
|
||||
input.sync?.[1](input.name, next)
|
||||
void input.storage.setItem(input.name, next)
|
||||
}
|
||||
|
||||
// Solid's setter overloads are too deep to spread generically; the wrapper only forwards.
|
||||
const apply = input.setStore as unknown as (...values: unknown[]) => void
|
||||
const setStore = ((...values: unknown[]) => {
|
||||
apply(...values)
|
||||
dirty = true
|
||||
touched = true
|
||||
pending.add(save)
|
||||
timer ??= setTimeout(save, delay)
|
||||
}) as unknown as SetStoreFunction<T>
|
||||
|
||||
const hydrate = (raw: string) => {
|
||||
last = raw
|
||||
input.setStore(reconcile(input.deserialize(raw)))
|
||||
}
|
||||
const init = input.storage.getItem(input.name)
|
||||
// A value the user already changed is newer than whatever storage held.
|
||||
if (init instanceof Promise) void init.then((raw) => raw && !touched && hydrate(raw))
|
||||
else if (init) hydrate(init)
|
||||
|
||||
input.sync?.[0]((data) => {
|
||||
if (data.key !== input.name || (data.url ?? location.href) !== location.href) return
|
||||
if (!data.newValue) return
|
||||
// A real unsaved local change wins over another window's write, as in VS Code's storage
|
||||
// service; whether the change is real is only known when the store is serialized. Every
|
||||
// remote value replaces the held one, including a revert to `last`, so the save sees the
|
||||
// other window's final state rather than an intermediate one.
|
||||
if (dirty) {
|
||||
remote = data.newValue
|
||||
return
|
||||
}
|
||||
if (data.newValue === last) return
|
||||
hydrate(data.newValue)
|
||||
})
|
||||
|
||||
if (getOwner()) onCleanup(save)
|
||||
|
||||
return { setStore, init, flush: save }
|
||||
}
|
||||
@@ -1,15 +1,14 @@
|
||||
import { Platform, usePlatform } from "@/runtime/platform/platform"
|
||||
import { messageSync, type AsyncStorage, type SyncStorage } from "@solid-primitives/storage"
|
||||
import { makePersisted, messageSync, type AsyncStorage, type SyncStorage } from "@solid-primitives/storage"
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
import { createResource, onCleanup, type Accessor } from "solid-js"
|
||||
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import { Option, Schema } from "effect"
|
||||
import { pathKey } from "@/workspaces/path-key"
|
||||
import { ScopedKey, ServerScope } from "@/runtime/server/scope"
|
||||
import { persistStore } from "./persist"
|
||||
import { Persistence } from "./schema"
|
||||
|
||||
type InitType = Promise<string | null> | string | null
|
||||
type InitType = Promise<string> | string | null
|
||||
type PersistedWithReady<T> = [
|
||||
Store<T>,
|
||||
SetStoreFunction<T>,
|
||||
@@ -594,18 +593,13 @@ export function persisted<S extends Schema.ConstraintCodec<object, unknown>>(
|
||||
: undefined
|
||||
if (channel) onCleanup(() => channel.close())
|
||||
|
||||
const persist = persistStore({
|
||||
store: store[0],
|
||||
setStore: store[1],
|
||||
const [state, setState, init] = makePersisted<S["Type"], typeof store>(store, {
|
||||
name: config.key,
|
||||
storage,
|
||||
serialize,
|
||||
deserialize: Schema.decodeUnknownSync(json),
|
||||
sync: channel ? messageSync(channel) : undefined,
|
||||
})
|
||||
const state = store[0]
|
||||
const setState = persist.setStore
|
||||
const init = persist.init
|
||||
|
||||
const isAsync = init instanceof Promise
|
||||
const [ready] = createResource(
|
||||
|
||||
@@ -255,7 +255,7 @@ export function createChildStoreManager(input: {
|
||||
disposers.set(key, dispose)
|
||||
activationToggles.set(key, setInstanceQueriesEnabled)
|
||||
|
||||
const onPersistedInit = (init: Promise<string | null> | string | null, run: () => void) => {
|
||||
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
|
||||
if (!(init instanceof Promise)) return
|
||||
void init.then(() => {
|
||||
if (children[key] !== child) return
|
||||
|
||||
@@ -4,7 +4,6 @@ import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { ServerScope } from "@/runtime/server/scope"
|
||||
import { createComposerState, type ComposerStore } from "@/composer/state"
|
||||
import { createComposerEditorActions } from "@/composer/editor/actions"
|
||||
import { flushPersisted } from "@/runtime/persistence/persist"
|
||||
|
||||
function setup(read: () => string | null | Promise<string | null> = () => null) {
|
||||
return createRoot((dispose) => {
|
||||
@@ -28,72 +27,83 @@ function setup(read: () => string | null | Promise<string | null> = () => null)
|
||||
})
|
||||
}
|
||||
|
||||
test("composer-write-batch: a burst of edits persists once with prompt, cursor and retry together", async () => {
|
||||
test("composer-write-batch: typing persists prompt, cursor and retry together before returning", async () => {
|
||||
const value = setup()
|
||||
try {
|
||||
await value.state.ready.promise
|
||||
value.state.context.add({ type: "file", path: "src/queue.ts", preview: "await queue.flush()" })
|
||||
value.state.retry.set({ id: SessionMessage.ID.create(), agent: "build", providerID: "test", modelID: "test" })
|
||||
flushPersisted()
|
||||
value.writes.length = 0
|
||||
value.editor.setPrompt([{ type: "text", content: "keep ordering", start: 0, end: 13 }], 13)
|
||||
value.editor.setCursor(12)
|
||||
expect(value.writes).toHaveLength(0)
|
||||
flushPersisted()
|
||||
expect(value.writes).toHaveLength(1)
|
||||
expect(value.writes[0]).toMatchObject({
|
||||
prompt: [{ type: "text", content: "keep ordering", start: 0, end: 13 }],
|
||||
cursor: 12,
|
||||
cursor: 13,
|
||||
context: { items: [{ path: "src/queue.ts", preview: "await queue.flush()" }] },
|
||||
})
|
||||
expect(value.writes[0].retry).toBeUndefined()
|
||||
} finally {
|
||||
value.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("composer-write-batch: a save with no serialized change writes nothing", async () => {
|
||||
const value = setup()
|
||||
try {
|
||||
await value.state.ready.promise
|
||||
value.state.set([{ type: "text", content: "previous", start: 0, end: 8 }], 5)
|
||||
value.state.mode.set("shell")
|
||||
flushPersisted()
|
||||
value.writes.length = 0
|
||||
value.editor.setCursor(5)
|
||||
value.state.mode.set("shell")
|
||||
flushPersisted()
|
||||
expect(value.writes).toHaveLength(0)
|
||||
value.state.reset()
|
||||
flushPersisted()
|
||||
value.editor.setCursor(13)
|
||||
expect(value.writes).toHaveLength(1)
|
||||
expect(value.writes[0]).toMatchObject({ prompt: [{ content: "" }], cursor: 0, mode: "shell" })
|
||||
value.editor.setCursor(12)
|
||||
expect(value.writes.map((write) => write.cursor)).toEqual([13, 12])
|
||||
value.editor.setCursor(12)
|
||||
expect(value.writes).toHaveLength(2)
|
||||
} finally {
|
||||
value.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("composer-write-batch: state replacement snapshots the value at save time", async () => {
|
||||
test("composer-write-batch: state replacement preserves an omitted cursor and resets in one write", async () => {
|
||||
const value = setup()
|
||||
try {
|
||||
await value.state.ready.promise
|
||||
value.state.set([{ type: "text", content: "previous", start: 0, end: 8 }], 5)
|
||||
flushPersisted()
|
||||
value.state.mode.set("shell")
|
||||
value.state.retry.set({ id: SessionMessage.ID.create(), agent: "build", providerID: "test", modelID: "test" })
|
||||
value.writes.length = 0
|
||||
const prompt = [{ type: "text" as const, content: "next", start: 0, end: 4 }]
|
||||
value.state.set(prompt)
|
||||
prompt[0].content = "changed outside the store"
|
||||
expect(value.state.current()[0]).toMatchObject({ content: "next" })
|
||||
flushPersisted()
|
||||
expect(value.writes).toHaveLength(1)
|
||||
expect(value.writes[0].prompt).toEqual([{ type: "text", content: "next", start: 0, end: 4 }])
|
||||
expect(value.writes[0].cursor).toBe(5)
|
||||
expect(value.writes[0].retry).toBeUndefined()
|
||||
value.state.reset()
|
||||
expect(value.writes).toHaveLength(2)
|
||||
expect(value.writes[1]).toMatchObject({ prompt: [{ content: "" }], cursor: 0, mode: "shell" })
|
||||
value.state.mode.set("normal")
|
||||
value.state.mode.set("normal")
|
||||
expect(value.writes).toHaveLength(3)
|
||||
expect(value.writes[2].mode).toBe("normal")
|
||||
} finally {
|
||||
value.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("composer-write-batch: the last edit in a window wins and attachments are retained", async () => {
|
||||
test("composer-write-batch: unchanged mode still clears a retry and context writes remain ordered", async () => {
|
||||
const value = setup()
|
||||
try {
|
||||
await value.state.ready.promise
|
||||
value.state.mode.set("normal")
|
||||
value.state.retry.set({ id: SessionMessage.ID.create(), agent: "build", providerID: "test", modelID: "test" })
|
||||
value.writes.length = 0
|
||||
value.editor.setMode("normal")
|
||||
expect(value.writes).toHaveLength(1)
|
||||
expect(value.writes[0].retry).toBeUndefined()
|
||||
value.state.context.add({ type: "file", path: "first.ts" })
|
||||
value.state.context.add({ type: "file", path: "second.ts" })
|
||||
value.state.context.remove(value.state.context.items()[0].key)
|
||||
expect(value.writes.slice(1).map((write) => write.context.items.map((item) => item.path))).toEqual([
|
||||
["first.ts"],
|
||||
["first.ts", "second.ts"],
|
||||
["second.ts"],
|
||||
])
|
||||
} finally {
|
||||
value.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("composer-write-batch: text replacement and insertion each persist once and retain attachments", async () => {
|
||||
const value = setup()
|
||||
try {
|
||||
await value.state.ready.promise
|
||||
@@ -110,14 +120,12 @@ test("composer-write-batch: the last edit in a window wins and attachments are r
|
||||
],
|
||||
3,
|
||||
)
|
||||
flushPersisted()
|
||||
value.writes.length = 0
|
||||
value.editor.setText("new")
|
||||
value.editor.addText(" notes")
|
||||
flushPersisted()
|
||||
expect(value.writes).toHaveLength(1)
|
||||
expect(value.writes[0].cursor).toBe(9)
|
||||
expect(value.writes[0].prompt).toEqual([
|
||||
expect(value.writes).toHaveLength(2)
|
||||
expect(value.writes.map((write) => write.cursor)).toEqual([3, 9])
|
||||
expect(value.writes[1].prompt).toEqual([
|
||||
{ type: "text", content: "new notes", start: 0, end: 9 },
|
||||
{
|
||||
type: "image",
|
||||
@@ -137,6 +145,7 @@ test("composer-write-batch: an edit still wins over a pending persisted read", a
|
||||
const value = setup(() => loading.promise)
|
||||
try {
|
||||
value.editor.setPrompt([{ type: "text", content: "new", start: 0, end: 3 }], 3)
|
||||
expect(value.writes).toHaveLength(1)
|
||||
loading.resolve(
|
||||
JSON.stringify({
|
||||
prompt: [{ type: "text", content: "old", start: 0, end: 3 }],
|
||||
@@ -147,15 +156,13 @@ test("composer-write-batch: an edit still wins over a pending persisted read", a
|
||||
await value.state.ready.promise
|
||||
expect(value.state.current()).toEqual([{ type: "text", content: "new", start: 0, end: 3 }])
|
||||
expect(value.state.cursor()).toBe(3)
|
||||
flushPersisted()
|
||||
expect(value.writes).toHaveLength(1)
|
||||
expect(value.writes[0].prompt).toEqual([{ type: "text", content: "new", start: 0, end: 3 }])
|
||||
} finally {
|
||||
value.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("composer-write-batch: observers see every edit before anything is persisted", async () => {
|
||||
test("composer-write-batch: persistence still precedes reactive observers", async () => {
|
||||
const value = setup()
|
||||
try {
|
||||
await value.state.ready.promise
|
||||
@@ -170,22 +177,10 @@ test("composer-write-batch: observers see every edit before anything is persiste
|
||||
value.editor.addText(" and insert")
|
||||
value.state.set([{ type: "text", content: "restore", start: 0, end: 7 }], 7)
|
||||
value.state.reset()
|
||||
expect(observed).toEqual([0, 0, 0, 0, 0, 0])
|
||||
flushPersisted()
|
||||
expect(value.writes).toHaveLength(1)
|
||||
expect(observed).toEqual([0, 1, 2, 3, 4, 5])
|
||||
dispose()
|
||||
})
|
||||
} finally {
|
||||
value.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("composer-write-batch: disposing the owner saves pending edits", async () => {
|
||||
const value = setup()
|
||||
await value.state.ready.promise
|
||||
value.editor.setPrompt([{ type: "text", content: "unsaved", start: 0, end: 7 }], 7)
|
||||
expect(value.writes).toHaveLength(0)
|
||||
value.dispose()
|
||||
expect(value.writes).toHaveLength(1)
|
||||
expect(value.writes[0].prompt).toEqual([{ type: "text", content: "unsaved", start: 0, end: 7 }])
|
||||
})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import { ModelSelectionSchema } from "@/providers/models/selection"
|
||||
import { flushPersisted } from "@/runtime/persistence/persist"
|
||||
import { persisted } from "@/runtime/persistence/storage"
|
||||
|
||||
test("persisted model selection hydrates, updates and serializes the schema shape", () => {
|
||||
@@ -26,7 +25,6 @@ test("persisted model selection hydrates, updates and serializes the schema shap
|
||||
expect(state.session.session1?.agent).toBe("plan")
|
||||
setState("session", "session1", { agent: "build", variant: null })
|
||||
expect(state.session.session1?.agent).toBe("build")
|
||||
flushPersisted()
|
||||
expect(JSON.parse(localStorage.getItem(key) ?? "null")).toEqual({
|
||||
session: { session1: { agent: "build", variant: null } },
|
||||
})
|
||||
|
||||
@@ -6,7 +6,6 @@ import type { Platform } from "@/runtime/platform/platform"
|
||||
import { createComposerReady, createComposerState } from "@/composer/state"
|
||||
import { ServerScope } from "@/runtime/server/scope"
|
||||
import { createDraftStore } from "@/runtime/persistence/drafts"
|
||||
import { flushPersisted } from "@/runtime/persistence/persist"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
|
||||
let read: ((value: string | null) => void) | undefined
|
||||
@@ -110,7 +109,6 @@ describe("prompt persistence", () => {
|
||||
},
|
||||
])
|
||||
root.session.set([{ type: "text", content: "hello", start: 0, end: 5 }, ...root.session.current()])
|
||||
flushPersisted()
|
||||
await Bun.sleep(0)
|
||||
expect(documents.get(key)).toContain("hello")
|
||||
expect(documents.get(key)).toContain('"blob":{"id":"composer-image"}')
|
||||
|
||||
@@ -2,7 +2,6 @@ import { describe, expect, test } from "bun:test"
|
||||
import { Schema, SchemaGetter } from "effect"
|
||||
import { createComputed, createRoot } from "solid-js"
|
||||
import type { Platform } from "@/runtime/platform/platform"
|
||||
import { flushPersisted } from "@/runtime/persistence/persist"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { TabStorage } from "@/shell/tabs/schema"
|
||||
@@ -58,7 +57,6 @@ describe("schema-backed persistence", () => {
|
||||
expect(state.key).toBe("session-tab")
|
||||
setState("key", undefined)
|
||||
expect(state.key).toBeUndefined()
|
||||
flushPersisted()
|
||||
expect(localStorage.getItem(key)).toBe("{}")
|
||||
} finally {
|
||||
dispose()
|
||||
@@ -76,7 +74,6 @@ describe("schema-backed persistence", () => {
|
||||
expect(state).toEqual({ enabled: true, label: "saved" })
|
||||
expect(JSON.parse(localStorage.getItem(key)!)).toEqual({ enabled: true, label: "saved" })
|
||||
setState("enabled", false)
|
||||
flushPersisted()
|
||||
expect(JSON.parse(localStorage.getItem(key)!)).toEqual({ enabled: false, label: "saved" })
|
||||
dispose()
|
||||
})
|
||||
@@ -128,7 +125,6 @@ describe("schema-backed persistence", () => {
|
||||
label: "desktop",
|
||||
})
|
||||
root.state[1]("label", "changed")
|
||||
flushPersisted()
|
||||
expect(JSON.parse(storage.values.get("opencode.global.dat:schema-desktop")!)).toEqual({
|
||||
enabled: true,
|
||||
label: "changed",
|
||||
|
||||
@@ -92,15 +92,18 @@ export const Plugin = {
|
||||
source,
|
||||
})
|
||||
const result = yield* fileMutation.writeTextPreservingBom({ target, content: input.content })
|
||||
const bom = (yield* FileMutation.readText(environment.files, target.absolute)).bom
|
||||
if (yield* formatter.file(target.absolute)) {
|
||||
yield* FileMutation.syncTextBom(environment.files, target.absolute, bom)
|
||||
const written = yield* FileMutation.readText(environment.files, target.absolute)
|
||||
const formatted = (yield* formatter.file(target.absolute))
|
||||
? yield* FileMutation.syncTextBom(environment.files, target.absolute, written.bom)
|
||||
: written.text
|
||||
return {
|
||||
output: result,
|
||||
content: toModelContent(result),
|
||||
metadata: {
|
||||
files: [fileDiff(result.resource, current?.text ?? "", formatted, current ? "modified" : "added")],
|
||||
},
|
||||
}
|
||||
return result
|
||||
}).pipe(
|
||||
Effect.map((output) => ({ output, content: toModelContent(output) })),
|
||||
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
|
||||
),
|
||||
}).pipe(Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error }))),
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
@@ -119,6 +119,17 @@ describe("WriteTool", () => {
|
||||
existed: false,
|
||||
},
|
||||
content: [{ type: "text", text: "Created file successfully: src/new.txt" }],
|
||||
metadata: {
|
||||
files: [
|
||||
{
|
||||
file: "src/new.txt",
|
||||
status: "added",
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
patch: expect.stringContaining("+created"),
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "src", "new.txt"), "utf8"))).toBe(
|
||||
"created",
|
||||
@@ -158,6 +169,20 @@ describe("WriteTool", () => {
|
||||
Effect.gen(function* () {
|
||||
expect(yield* executeTool(registry, call({ path: "formatted.txt", content: "format me" }))).toMatchObject({
|
||||
status: "completed",
|
||||
metadata: {
|
||||
files: [
|
||||
{
|
||||
file: "formatted.txt",
|
||||
status: "added",
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
patch: expect.stringContaining("+FORMAT ME"),
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(fixture.assertions[0]?.metadata).toMatchObject({
|
||||
files: [{ patch: expect.stringContaining("+format me") }],
|
||||
})
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("FORMAT ME")
|
||||
}),
|
||||
@@ -180,6 +205,7 @@ describe("WriteTool", () => {
|
||||
if (settled.status !== "completed") return
|
||||
expect(settled.content).toEqual([{ type: "text", text: "Wrote file successfully: existing.txt" }])
|
||||
expect(settled.output).toMatchObject({ resource: "existing.txt", existed: true })
|
||||
expect(settled.metadata).toEqual(fixture.assertions[0]?.metadata)
|
||||
expect(fixture.assertions[0]?.metadata).toMatchObject({
|
||||
files: [
|
||||
{
|
||||
@@ -215,7 +241,22 @@ describe("WriteTool", () => {
|
||||
Effect.andThen(
|
||||
withTool(tmp.path, fixture, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
yield* executeTool(registry, call({ path: "preserved.txt", content: "after" }, "call-preserved"))
|
||||
const settled = yield* executeTool(
|
||||
registry,
|
||||
call({ path: "preserved.txt", content: "after" }, "call-preserved"),
|
||||
)
|
||||
expect(settled).toMatchObject({
|
||||
metadata: {
|
||||
files: [
|
||||
{
|
||||
file: "preserved.txt",
|
||||
status: "modified",
|
||||
patch: expect.stringMatching(/-before[\s\S]*\+after/),
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
if (settled.status === "completed") expect(JSON.stringify(settled.metadata)).not.toContain("\uFEFF")
|
||||
yield* executeTool(
|
||||
registry,
|
||||
call({ path: "deduplicated.txt", content: "\uFEFFafter" }, "call-deduplicated"),
|
||||
@@ -230,6 +271,27 @@ describe("WriteTool", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("reports zero-change metadata for empty and unchanged writes", () =>
|
||||
withTempDir((tmp) => {
|
||||
const fixture = makeWriteFixture()
|
||||
return withTool(tmp.path, fixture, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
const created = yield* executeTool(registry, call({ path: "empty.txt", content: "" }, "call-empty"))
|
||||
expect(created).toMatchObject({
|
||||
status: "completed",
|
||||
metadata: { files: [{ file: "empty.txt", status: "added", additions: 0, deletions: 0 }] },
|
||||
})
|
||||
const unchanged = yield* executeTool(registry, call({ path: "empty.txt", content: "" }, "call-unchanged"))
|
||||
expect(unchanged).toMatchObject({
|
||||
status: "completed",
|
||||
metadata: { files: [{ file: "empty.txt", status: "modified", additions: 0, deletions: 0 }] },
|
||||
})
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "empty.txt"), "utf8"))).toBe("")
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("accepts an absolute file path inside the active Location", () =>
|
||||
withTempDir((tmp) => {
|
||||
const fixture = makeWriteFixture()
|
||||
|
||||
@@ -1,30 +1,17 @@
|
||||
import { BrowserWindow } from "electron"
|
||||
import { Effect } from "effect"
|
||||
import { StorageRpcs } from "../../shared/ipc-rpc"
|
||||
import { StorageChanged } from "../../shared/ipc-rpc/events"
|
||||
import { emitIpcEvent } from "../ipc-events"
|
||||
import { IpcPortHandoff } from "../ipc-transport"
|
||||
import { DesktopStorage } from "../storage"
|
||||
import { sender } from "./context"
|
||||
|
||||
export const storageHandlers = StorageRpcs.toLayer(
|
||||
Effect.gen(function* () {
|
||||
const storage = yield* DesktopStorage.Service
|
||||
const handoff = yield* IpcPortHandoff
|
||||
return StorageRpcs.of({
|
||||
StorageItems: ({ name }) => Effect.sync(() => storage.state.items(name)),
|
||||
StorageUpdate: ({ name, insert, remove }, context) =>
|
||||
Effect.sync(() => {
|
||||
const revision = storage.state.update(name, insert, remove)
|
||||
// Other windows hold their own copy of this namespace; tell them what moved.
|
||||
const origin = sender(handoff, context)
|
||||
const event = new StorageChanged({ name, insert, remove, revision })
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (win.webContents !== origin) emitIpcEvent(win.webContents, event)
|
||||
}
|
||||
return revision
|
||||
}),
|
||||
StorageGet: ({ name, key }) => Effect.sync(() => storage.state.get(name, key)),
|
||||
StorageSet: ({ name, key, value }) => Effect.sync(() => storage.state.set(name, key, value)),
|
||||
StorageDelete: ({ name, key }) => Effect.sync(() => storage.state.delete(name, key)),
|
||||
StorageClear: ({ name }) => Effect.sync(() => storage.state.clear(name)),
|
||||
StorageKeys: ({ name }) => Effect.sync(() => storage.state.keys(name)),
|
||||
StorageLength: ({ name }) => Effect.sync(() => storage.state.length(name)),
|
||||
DraftsGet: ({ key }) => Effect.sync(() => storage.drafts.get(key)),
|
||||
DraftsSet: ({ key, value }) => Effect.sync(() => storage.drafts.set(key, value)),
|
||||
DraftsDelete: ({ key }) => Effect.sync(() => storage.drafts.set(key, null)),
|
||||
|
||||
@@ -37,24 +37,21 @@ describe("state store", () => {
|
||||
store.flush()
|
||||
store.delete("global", "model")
|
||||
expect(store.get("global", "model")).toBeNull()
|
||||
expect(store.items("global").items).toEqual({})
|
||||
expect(store.keys("global")).toEqual([])
|
||||
store.flush()
|
||||
expect(rows(db)).toEqual([])
|
||||
})
|
||||
|
||||
test("items merges stored rows with queued changes and update returns a rising revision", () => {
|
||||
const { db, store } = open()
|
||||
expect(store.items("w")).toEqual({ items: {}, revision: 0 })
|
||||
expect(store.update("w", { tabs: "[]", recent: "{}" }, [])).toBe(1)
|
||||
test("keys and length merge stored rows with queued changes", () => {
|
||||
const { store } = open()
|
||||
store.set("w", "tabs", "[]")
|
||||
store.set("w", "recent", "{}")
|
||||
store.flush()
|
||||
expect(store.update("w", { info: "{}" }, ["recent"])).toBe(2)
|
||||
expect(store.items("w")).toEqual({ items: { tabs: "[]", info: "{}" }, revision: 2 })
|
||||
expect(store.items("other")).toEqual({ items: {}, revision: 2 })
|
||||
store.flush()
|
||||
expect(rows(db)).toEqual([
|
||||
{ name: "w", key: "info", value: "{}" },
|
||||
{ name: "w", key: "tabs", value: "[]" },
|
||||
])
|
||||
store.set("w", "info", "{}")
|
||||
store.delete("w", "recent")
|
||||
expect(store.keys("w").sort()).toEqual(["info", "tabs"])
|
||||
expect(store.length("w")).toBe(2)
|
||||
expect(store.keys("other")).toEqual([])
|
||||
})
|
||||
|
||||
test("clear drops a namespace including queued writes and leaves others alone", () => {
|
||||
|
||||
@@ -40,12 +40,22 @@ export function createStateStore(db: Database, input: { delay?: number; onError?
|
||||
}),
|
||||
})
|
||||
const id = (name: string, key: string) => `${name}\0${key}`
|
||||
const set = (name: string, key: string, value: string) => writer.set(id(name, key), { name, key, value })
|
||||
const unset = (name: string, key: string) => writer.set(id(name, key), { name, key, value: null })
|
||||
// Orders updates for renderer caches: acks and change events reach a window on different
|
||||
// paths, so a window compares revisions rather than arrival order. Process-local is enough
|
||||
// because every renderer cache dies with the process too.
|
||||
let revision = 0
|
||||
const keys = (name: string) => {
|
||||
const result = new Set(
|
||||
db
|
||||
.select({ key: state.key })
|
||||
.from(state)
|
||||
.where(eq(state.name, name))
|
||||
.all()
|
||||
.map((row) => row.key),
|
||||
)
|
||||
for (const row of writer.entries()) {
|
||||
if (row.name !== name) continue
|
||||
if (row.value === null) result.delete(row.key)
|
||||
else result.add(row.key)
|
||||
}
|
||||
return [...result]
|
||||
}
|
||||
|
||||
return {
|
||||
get(name: string, key: string) {
|
||||
@@ -53,31 +63,10 @@ export function createStateStore(db: Database, input: { delay?: number; onError?
|
||||
if (queued) return queued.value
|
||||
return read.get({ name, key })?.value ?? null
|
||||
},
|
||||
set,
|
||||
delete: unset,
|
||||
// A renderer loads a namespace once, so queued rows must be folded in for it to see its own
|
||||
// writes from a previous window session that have not flushed yet.
|
||||
items(name: string) {
|
||||
const items = Object.fromEntries(
|
||||
db
|
||||
.select({ key: state.key, value: state.value })
|
||||
.from(state)
|
||||
.where(eq(state.name, name))
|
||||
.all()
|
||||
.map((row) => [row.key, row.value]),
|
||||
)
|
||||
for (const row of writer.entries()) {
|
||||
if (row.name !== name) continue
|
||||
if (row.value === null) delete items[row.key]
|
||||
else items[row.key] = row.value
|
||||
}
|
||||
return { items, revision }
|
||||
},
|
||||
update(name: string, insert: Record<string, string>, removed: readonly string[]) {
|
||||
for (const [key, value] of Object.entries(insert)) set(name, key, value)
|
||||
for (const key of removed) unset(name, key)
|
||||
return ++revision
|
||||
},
|
||||
set: (name: string, key: string, value: string) => writer.set(id(name, key), { name, key, value }),
|
||||
delete: (name: string, key: string) => writer.set(id(name, key), { name, key, value: null }),
|
||||
keys,
|
||||
length: (name: string) => keys(name).length,
|
||||
// Rare (window closed for good, explicit clear) so it goes straight to the database.
|
||||
clear(name: string) {
|
||||
writer.drop((row) => row.name === name)
|
||||
|
||||
@@ -32,12 +32,12 @@ export type ElectronAPI = {
|
||||
finishFirstLaunchOnboarding(createDefaultProject: boolean): Promise<string | null>
|
||||
checkAppExists(appName: string): Promise<boolean>
|
||||
resolveAppPath(appName: string): Promise<string | null>
|
||||
storeItems(name: string): Promise<{ items: Record<string, string>; revision: number }>
|
||||
storeUpdate(name: string, insert: Record<string, string>, remove: string[]): Promise<number>
|
||||
storeGet(name: string, key: string): Promise<string | null>
|
||||
storeSet(name: string, key: string, value: string): Promise<void>
|
||||
storeDelete(name: string, key: string): Promise<void>
|
||||
storeClear(name: string): Promise<void>
|
||||
onStoreChanged(
|
||||
cb: (name: string, insert: Record<string, string>, remove: string[], revision: number) => void,
|
||||
): () => void
|
||||
storeKeys(name: string): Promise<string[]>
|
||||
storeLength(name: string): Promise<number>
|
||||
draftGet(key: string): Promise<string | null>
|
||||
draftSet(key: string, value: string): Promise<void>
|
||||
draftDelete(key: string): Promise<void>
|
||||
|
||||
@@ -75,11 +75,12 @@ export const api: ElectronAPI = {
|
||||
invoke("AppFinishFirstLaunchOnboarding", { createDefaultProject }),
|
||||
checkAppExists: (appName) => invoke("AppCheckAppExists", { appName }),
|
||||
resolveAppPath: (appName) => invoke("AppResolveAppPath", { appName }),
|
||||
storeItems: (name) => invoke("StorageItems", { name }).then(mutable),
|
||||
storeUpdate: (name, insert, remove) => invoke("StorageUpdate", { name, insert, remove }),
|
||||
storeGet: (name, key) => invoke("StorageGet", { name, key }),
|
||||
storeSet: (name, key, value) => invoke("StorageSet", { name, key, value }),
|
||||
storeDelete: (name, key) => invoke("StorageDelete", { name, key }),
|
||||
storeClear: (name) => invoke("StorageClear", { name }),
|
||||
onStoreChanged: (cb) =>
|
||||
listen("StorageChanged", (event) => cb(event.name, mutable(event.insert), mutable(event.remove), event.revision)),
|
||||
storeKeys: (name) => invoke("StorageKeys", { name }).then(mutable),
|
||||
storeLength: (name) => invoke("StorageLength", { name }),
|
||||
draftGet: (key) => invoke("DraftsGet", { key }),
|
||||
draftSet: (key, value) => invoke("DraftsSet", { key, value }),
|
||||
draftDelete: (key) => invoke("DraftsDelete", { key }),
|
||||
|
||||
@@ -28,18 +28,7 @@ const ClientProtocolLive = Layer.unwrap(Effect.promise(() => port).pipe(Effect.m
|
||||
const ClientLive = Layer.effect(DesktopClient, RpcClient.make(DesktopRpcs)).pipe(Layer.provide(ClientProtocolLive))
|
||||
const runtime = ManagedRuntime.make(ClientLive)
|
||||
const listeners = new Map<EventTag, Set<(value: unknown) => void>>()
|
||||
const beforeDispose = new Set<() => Promise<unknown> | void>()
|
||||
// Let queued work (storage flushes) hand its messages to the port before the runtime goes away.
|
||||
window.addEventListener(
|
||||
"pagehide",
|
||||
() => void Promise.allSettled([...beforeDispose].map((callback) => callback())).then(() => runtime.dispose()),
|
||||
{ once: true },
|
||||
)
|
||||
|
||||
export function onBeforeDispose(callback: () => Promise<unknown> | void) {
|
||||
beforeDispose.add(callback)
|
||||
return () => beforeDispose.delete(callback)
|
||||
}
|
||||
window.addEventListener("pagehide", () => void runtime.dispose(), { once: true })
|
||||
|
||||
runtime.runFork(
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -1,36 +1,26 @@
|
||||
import {
|
||||
createDraftStore,
|
||||
createNamespaceStorage,
|
||||
flushPersisted,
|
||||
type NamespaceStorage,
|
||||
type Platform,
|
||||
} from "@opencode-ai/app/desktop"
|
||||
import { createDraftStore, type Platform } from "@opencode-ai/app/desktop"
|
||||
import type { AsyncStorage } from "@solid-primitives/storage"
|
||||
import type { ElectronAPI } from "../api-types"
|
||||
import { onBeforeDispose } from "../ipc-client"
|
||||
|
||||
export function createDesktopStorage(api: ElectronAPI) {
|
||||
const namespaces = new Map<string, NamespaceStorage>()
|
||||
const driver = { items: api.storeItems, update: api.storeUpdate, clear: api.storeClear }
|
||||
const cache = new Map<string, AsyncStorage>()
|
||||
const storage: NonNullable<Platform["storage"]> = (name = "default.dat") => {
|
||||
const cached = namespaces.get(name)
|
||||
const cached = cache.get(name)
|
||||
if (cached) return cached
|
||||
const next = createNamespaceStorage(driver, name)
|
||||
namespaces.set(name, next)
|
||||
const next: AsyncStorage = {
|
||||
getItem: (key) => api.storeGet(name, key),
|
||||
setItem: (key, value) => api.storeSet(name, key, value),
|
||||
removeItem: (key) => api.storeDelete(name, key),
|
||||
clear: () => api.storeClear(name),
|
||||
key: async (index: number) => (await api.storeKeys(name))[index],
|
||||
getLength: () => api.storeLength(name),
|
||||
get length() {
|
||||
return next.getLength()
|
||||
},
|
||||
}
|
||||
cache.set(name, next)
|
||||
return next
|
||||
}
|
||||
// Dirty stores must serialize into their namespaces before the namespaces are sent; the app's
|
||||
// own pagehide listener registers after the IPC client's, so it cannot be relied on here.
|
||||
const flush = () => {
|
||||
flushPersisted()
|
||||
return Promise.all([...namespaces.values()].map((namespace) => namespace.flush()))
|
||||
}
|
||||
|
||||
api.onStoreChanged((name, insert, remove, revision) => namespaces.get(name)?.accept(insert, remove, revision))
|
||||
// Durability boundaries: the window going away, and it leaving the foreground.
|
||||
onBeforeDispose(flush)
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState === "hidden") void flush()
|
||||
})
|
||||
|
||||
return {
|
||||
storage,
|
||||
|
||||
@@ -31,14 +31,6 @@ export class WindowZoomChanged extends Schema.TaggedClass<WindowZoomChanged>()("
|
||||
factor: Schema.Number,
|
||||
}) {}
|
||||
|
||||
// Another window wrote to a storage namespace; recipients refresh their in-memory copy.
|
||||
export class StorageChanged extends Schema.TaggedClass<StorageChanged>()("StorageChanged", {
|
||||
name: Schema.String,
|
||||
insert: Schema.Record(Schema.String, Schema.String),
|
||||
remove: Schema.Array(Schema.String),
|
||||
revision: Schema.Number,
|
||||
}) {}
|
||||
|
||||
export const DesktopEvent = Schema.Union([
|
||||
DeepLinksOpened,
|
||||
MenuCommandTriggered,
|
||||
@@ -47,7 +39,6 @@ export const DesktopEvent = Schema.Union([
|
||||
WindowFullscreenChanged,
|
||||
WindowPinchZoomChanged,
|
||||
WindowZoomChanged,
|
||||
StorageChanged,
|
||||
])
|
||||
export type DesktopEvent = Schema.Schema.Type<typeof DesktopEvent>
|
||||
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
import { Schema } from "effect"
|
||||
import { Rpc, RpcGroup } from "effect/unstable/rpc"
|
||||
|
||||
export const StorageItems = Rpc.make("StorageItems", {
|
||||
payload: { name: Schema.String },
|
||||
success: Schema.Struct({ items: Schema.Record(Schema.String, Schema.String), revision: Schema.Number }),
|
||||
export const StorageGet = Rpc.make("StorageGet", {
|
||||
payload: { name: Schema.String, key: Schema.String },
|
||||
success: Schema.NullOr(Schema.String),
|
||||
})
|
||||
export const StorageUpdate = Rpc.make("StorageUpdate", {
|
||||
payload: {
|
||||
name: Schema.String,
|
||||
insert: Schema.Record(Schema.String, Schema.String),
|
||||
remove: Schema.Array(Schema.String),
|
||||
},
|
||||
success: Schema.Number,
|
||||
export const StorageSet = Rpc.make("StorageSet", {
|
||||
payload: { name: Schema.String, key: Schema.String, value: Schema.String },
|
||||
})
|
||||
export const StorageDelete = Rpc.make("StorageDelete", {
|
||||
payload: { name: Schema.String, key: Schema.String },
|
||||
})
|
||||
export const StorageClear = Rpc.make("StorageClear", { payload: { name: Schema.String } })
|
||||
export const StorageKeys = Rpc.make("StorageKeys", {
|
||||
payload: { name: Schema.String },
|
||||
success: Schema.Array(Schema.String),
|
||||
})
|
||||
export const StorageLength = Rpc.make("StorageLength", {
|
||||
payload: { name: Schema.String },
|
||||
success: Schema.Number,
|
||||
})
|
||||
export const DraftsGet = Rpc.make("DraftsGet", {
|
||||
payload: { key: Schema.String },
|
||||
success: Schema.NullOr(Schema.String),
|
||||
@@ -32,9 +38,12 @@ export const DraftsGetBlob = Rpc.make("DraftsGetBlob", {
|
||||
})
|
||||
|
||||
export const StorageRpcs = RpcGroup.make(
|
||||
StorageItems,
|
||||
StorageUpdate,
|
||||
StorageGet,
|
||||
StorageSet,
|
||||
StorageDelete,
|
||||
StorageClear,
|
||||
StorageKeys,
|
||||
StorageLength,
|
||||
DraftsGet,
|
||||
DraftsSet,
|
||||
DraftsDelete,
|
||||
|
||||
@@ -1,38 +1,49 @@
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
story("merges follow-up patches into one stack with a distinct file count", async ({ mount }, info) => {
|
||||
const root = await mount("current-tool-group--patch-follow-ups")
|
||||
const group = root.locator('[data-component="collapsed-tool-group"]')
|
||||
const patches = group.locator('[data-component="apply-patch-tool"]')
|
||||
await expect(patches).toHaveCount(1)
|
||||
await expect(patches.getByText("2 files", { exact: true })).toBeVisible()
|
||||
const first = patches.locator('[data-scope="apply-patch"] button').filter({ hasText: "a.ts" })
|
||||
await first.click()
|
||||
await expect(first).toHaveAttribute("aria-expanded", "true")
|
||||
await root.getByRole("button", { name: "Start follow-up patch" }).click()
|
||||
await expect(
|
||||
group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'),
|
||||
).toHaveText(/^3 /)
|
||||
await expect(patches).toHaveCount(1)
|
||||
await expect(patches.getByText("2 files", { exact: true })).toBeVisible()
|
||||
await root.getByRole("button", { name: "Finish follow-up patch" }).click()
|
||||
await expect(patches).toHaveCount(1)
|
||||
await expect(patches.getByText("3 files", { exact: true })).toBeVisible()
|
||||
await expect(patches.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "c.ts"])
|
||||
await expect(first).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(patches.locator('[data-component="file"]')).toBeVisible()
|
||||
await group.screenshot({ path: info.outputPath("merged.png") })
|
||||
})
|
||||
|
||||
for (const separator of ["shell", "error", "reasoning"]) {
|
||||
story(`does not merge patches across an intervening ${separator}`, async ({ mount }) => {
|
||||
const root = await mount("current-tool-group--patch-follow-ups", { args: { separator } })
|
||||
await root.getByRole("button", { name: "Finish follow-up patch" }).click()
|
||||
for (const tool of ["patch", "edit", "write", "mixed"]) {
|
||||
story(`merges follow-up ${tool} calls into one stack with a distinct file count`, async ({ mount }, info) => {
|
||||
const root = await mount("current-tool-group--patch-follow-ups", { args: { tool } })
|
||||
const group = root.locator('[data-component="collapsed-tool-group"]')
|
||||
await expect(group.locator('[data-component="apply-patch-tool"]')).toHaveCount(2)
|
||||
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "a.ts", "c.ts"])
|
||||
if (separator === "error") await expect(group.locator('[data-kind="tool-error-card"]')).toBeVisible()
|
||||
const patches = group.locator('[data-component="apply-patch-tool"]')
|
||||
await expect(patches).toHaveCount(1)
|
||||
await expect(patches.getByText("2 files", { exact: true })).toBeVisible()
|
||||
await expect(
|
||||
patches.getByLabel(tool === "mixed" ? "Edit" : `${tool[0].toUpperCase()}${tool.slice(1)}`, { exact: true }),
|
||||
).toBeVisible()
|
||||
const first = patches.locator('[data-scope="apply-patch"] button').filter({ hasText: "a.ts" })
|
||||
await first.click()
|
||||
await expect(first).toHaveAttribute("aria-expanded", "true")
|
||||
await root.getByRole("button", { name: "Start follow-up patch" }).click()
|
||||
await expect(group).toHaveAttribute(
|
||||
"data-timeline-part-ids",
|
||||
tool === "patch" ? "patch_shell,patch_first,patch_next" : "patch_shell,first_0,first_1,next_0,next_1",
|
||||
)
|
||||
await expect(patches).toHaveCount(1)
|
||||
await expect(patches.getByText("2 files", { exact: true })).toBeVisible()
|
||||
await root.getByRole("button", { name: "Finish follow-up patch" }).click()
|
||||
await expect(patches).toHaveCount(1)
|
||||
await expect(patches.getByText("3 files", { exact: true })).toBeVisible()
|
||||
await expect(patches.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "c.ts"])
|
||||
await expect(first).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(patches.locator('[data-component="file"]')).toHaveCount(2)
|
||||
await expect(patches.locator('[data-component="file"]').nth(0)).toBeVisible()
|
||||
await expect(patches.locator('[data-component="file"]').nth(1)).toBeVisible()
|
||||
await expect(patches.locator('[data-component="apply-patch-file-diff"]')).toHaveCount(2)
|
||||
await group.screenshot({ path: info.outputPath("merged.png") })
|
||||
})
|
||||
|
||||
for (const separator of ["shell", "error", "reasoning"]) {
|
||||
story(`does not merge ${tool} calls across an intervening ${separator}`, async ({ mount }) => {
|
||||
const root = await mount("current-tool-group--patch-follow-ups", { args: { separator, tool } })
|
||||
await expect(root.locator("[data-file-tool]")).toHaveAttribute("data-file-tool", tool)
|
||||
await expect(root.locator("[data-file-separator]")).toHaveAttribute("data-file-separator", separator)
|
||||
await root.getByRole("button", { name: "Finish follow-up patch" }).click()
|
||||
const group = root.locator('[data-component="collapsed-tool-group"]')
|
||||
await expect(group.locator('[data-component="apply-patch-tool"]')).toHaveCount(2)
|
||||
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "a.ts", "c.ts"])
|
||||
if (separator === "error") await expect(group.locator('[data-kind="tool-error-card"]')).toBeVisible()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
story("does not retain patch files in the wrong batch when thoughts are shown", async ({ mount }) => {
|
||||
@@ -46,3 +57,33 @@ story("does not retain patch files in the wrong batch when thoughts are shown",
|
||||
await expect(group.locator('[data-component="apply-patch-tool"]')).toHaveCount(2)
|
||||
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "a.ts", "c.ts"])
|
||||
})
|
||||
|
||||
for (const placement of ["separate", "grouped"]) {
|
||||
story(
|
||||
`preserves mixed file disclosures through append and split in ${placement} timeline rows`,
|
||||
async ({ mount, page }) => {
|
||||
await page.setViewportSize({ width: placement === "grouped" ? 390 : 1280, height: 900 })
|
||||
const root = await mount("current-tool-group--patch-follow-ups", {
|
||||
args: { tool: "mixed", placement, separator: "reasoning" },
|
||||
})
|
||||
const timeline = root.locator('[data-component="session-timeline"]')
|
||||
if (placement === "grouped") await timeline.getByRole("button", { name: /^Used / }).click()
|
||||
const stacks = timeline.locator('[data-component="apply-patch-tool"]')
|
||||
const first = stacks.first().locator('[data-scope="apply-patch"] button').filter({ hasText: "a.ts" })
|
||||
await expect(stacks).toHaveCount(1)
|
||||
if (placement === "grouped") await first.click()
|
||||
await expect(first).toHaveAttribute("aria-expanded", "true")
|
||||
await root.getByRole("button", { name: "Hide thoughts", exact: true }).click()
|
||||
await root.getByRole("button", { name: "Finish follow-up patch" }).click()
|
||||
await expect(stacks).toHaveCount(1)
|
||||
await expect(stacks.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "c.ts"])
|
||||
await expect(first).toHaveAttribute("aria-expanded", "true")
|
||||
await root.getByRole("button", { name: "Show thoughts", exact: true }).click()
|
||||
await expect(stacks).toHaveCount(2)
|
||||
await expect(stacks.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "a.ts", "c.ts"])
|
||||
await expect(first).toHaveAttribute("aria-expanded", "true")
|
||||
const second = stacks.nth(1).locator('[data-scope="apply-patch"] button').filter({ hasText: "a.ts" })
|
||||
await expect(second).toHaveAttribute("aria-expanded", "false")
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -92,3 +92,36 @@ story("keeps patch file disclosures independent", async ({ mount }) => {
|
||||
await expect(modified).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(deleted).toHaveAttribute("aria-expanded", "false")
|
||||
})
|
||||
|
||||
for (const placement of ["separate", "grouped"]) {
|
||||
for (const profile of [
|
||||
{ tool: "edit", empty: false, noOp: false },
|
||||
{ tool: "write", empty: false, noOp: false },
|
||||
{ tool: "write", empty: true, noOp: false },
|
||||
{ tool: "edit", empty: false, noOp: true },
|
||||
{ tool: "write", empty: true, noOp: true },
|
||||
]) {
|
||||
story(
|
||||
`keeps ${profile.empty ? "empty " : ""}${profile.tool} input fallback with ${profile.noOp ? "zero-change" : "missing"} metadata in ${placement} rows`,
|
||||
async ({ mount }) => {
|
||||
const root = await mount("current-session-file-changes--file-tool-fallbacks", {
|
||||
args: { ...profile, timeline: true, placement },
|
||||
})
|
||||
const timeline = root.locator('[data-component="session-timeline"]')
|
||||
if (placement === "grouped") await timeline.getByRole("button", { name: /^Used / }).click()
|
||||
const fallback = timeline.locator(`[data-component="${profile.tool}-tool"]`)
|
||||
await expect(fallback).toHaveCount(1)
|
||||
await expect(fallback.getByText("example.ts", { exact: true })).toBeVisible()
|
||||
if (placement === "grouped") await fallback.getByRole("button", { name: /example\.ts/ }).click()
|
||||
await expect(fallback.locator('[data-component="file"]')).toBeAttached()
|
||||
if (!profile.empty) await expect(fallback.locator('[data-component="file"]')).toBeVisible()
|
||||
await root.getByRole("button", { name: "Complete file tool" }).click()
|
||||
await expect(fallback.getByText("example.ts", { exact: true })).toBeVisible()
|
||||
await expect(fallback.getByRole("button", { name: /example\.ts/ })).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(fallback.locator('[data-component="file"]')).toBeAttached()
|
||||
if (!profile.empty) await expect(fallback.locator('[data-component="file"]')).toBeVisible()
|
||||
await expect(timeline.locator('[data-component="apply-patch-tool"]')).toHaveCount(0)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { JsonValue, SessionMessageAssistantTool } from "@opencode-ai/client/promise"
|
||||
import { currentContentDefaultOpen } from "./current-tool-state"
|
||||
import { currentContentDefaultOpen, currentToolCanGroupFiles } from "./current-tool-state"
|
||||
|
||||
function tool(name: string, files: JsonValue[] = []): SessionMessageAssistantTool {
|
||||
return {
|
||||
@@ -17,6 +17,33 @@ function tool(name: string, files: JsonValue[] = []): SessionMessageAssistantToo
|
||||
}
|
||||
}
|
||||
|
||||
describe("current file grouping eligibility", () => {
|
||||
test.each(["edit", "write"])("keeps %s input fallbacks unless changed files are available", (name) => {
|
||||
const unchanged = { file: "src/example.ts", patch: "", status: "modified", additions: 0, deletions: 0 }
|
||||
expect(currentToolCanGroupFiles(tool(name, [unchanged]))).toBe(false)
|
||||
expect(currentToolCanGroupFiles(tool(name))).toBe(false)
|
||||
expect(currentToolCanGroupFiles({ ...tool(name), state: { status: "running", input: {}, metadata: {} } })).toBe(
|
||||
false,
|
||||
)
|
||||
expect(
|
||||
currentToolCanGroupFiles(
|
||||
tool(name, [{ ...unchanged, patch: "@@ -1 +1 @@\n-before\n+after", additions: 1, deletions: 1 }]),
|
||||
),
|
||||
).toBe(true)
|
||||
expect(
|
||||
currentToolCanGroupFiles({
|
||||
...tool(name),
|
||||
state: {
|
||||
status: "error",
|
||||
input: {},
|
||||
error: { type: "ToolError", message: "failed" },
|
||||
metadata: { files: [{ ...unchanged, additions: 1 }] },
|
||||
},
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("current content default open", () => {
|
||||
test("uses the shell disclosure preference", () => {
|
||||
expect(currentContentDefaultOpen(tool("shell"), true, false)).toBe(true)
|
||||
|
||||
@@ -14,6 +14,23 @@ export function currentToolMetadata(tool: SessionMessageAssistantTool): Record<s
|
||||
return tool.state.metadata ?? empty
|
||||
}
|
||||
|
||||
export function currentToolCanGroupFiles(tool: SessionMessageAssistantTool) {
|
||||
if (tool.state.status === "error") return false
|
||||
if (tool.name === "patch") return true
|
||||
// Keep the input-based renderer when older edit/write results have no file diffs.
|
||||
if (tool.name !== "edit" && tool.name !== "write") return false
|
||||
const files = currentToolMetadata(tool).files
|
||||
if (!Array.isArray(files)) return false
|
||||
// Empty and unchanged results still need their filename and content preview.
|
||||
return files.some(
|
||||
(file) =>
|
||||
!!file &&
|
||||
typeof file === "object" &&
|
||||
(("additions" in file && typeof file.additions === "number" && file.additions > 0) ||
|
||||
("deletions" in file && typeof file.deletions === "number" && file.deletions > 0)),
|
||||
)
|
||||
}
|
||||
|
||||
export function currentToolOutput(tool: SessionMessageAssistantTool) {
|
||||
if (tool.state.status === "running") {
|
||||
const output = tool.state.metadata.output
|
||||
|
||||
@@ -264,44 +264,98 @@ export const CreatedANewFile = {
|
||||
}
|
||||
|
||||
export const FileToolFallbacks = {
|
||||
args: { tool: "edit", empty: false, forceOpen: false, controlled: true },
|
||||
args: {
|
||||
tool: "edit",
|
||||
empty: false,
|
||||
forceOpen: false,
|
||||
controlled: true,
|
||||
timeline: false,
|
||||
placement: "separate",
|
||||
noOp: false,
|
||||
},
|
||||
argTypes: {
|
||||
tool: { control: "select", options: ["edit", "write"] },
|
||||
empty: { control: "boolean" },
|
||||
forceOpen: { control: "boolean" },
|
||||
controlled: { control: "boolean" },
|
||||
timeline: { control: "boolean" },
|
||||
noOp: { control: "boolean" },
|
||||
placement: { control: "select", options: ["separate", "grouped"] },
|
||||
},
|
||||
render: (args: { tool: string; empty: boolean; forceOpen: boolean; controlled: boolean }) => {
|
||||
render: (args: {
|
||||
tool: string
|
||||
empty: boolean
|
||||
forceOpen: boolean
|
||||
controlled: boolean
|
||||
timeline: boolean
|
||||
noOp: boolean
|
||||
placement: "separate" | "grouped"
|
||||
}) => {
|
||||
const [state, setState] = createStore({ completed: false, open: false })
|
||||
const document = createMemo(() =>
|
||||
storyDocument([
|
||||
storyTool(
|
||||
"tool_file_fallback",
|
||||
args.tool,
|
||||
state.completed ? "completed" : "running",
|
||||
{
|
||||
path: "src/example.ts",
|
||||
oldString: "export const before = true\n",
|
||||
newString: "export const after = true\n",
|
||||
content: args.empty ? "" : "export const written = true\n",
|
||||
},
|
||||
{
|
||||
metadata:
|
||||
state.completed && args.noOp
|
||||
? {
|
||||
files: [
|
||||
{
|
||||
file: "src/example.ts",
|
||||
patch: createTwoFilesPatch("src/example.ts", "src/example.ts", "", ""),
|
||||
status: "modified",
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
},
|
||||
],
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
),
|
||||
]),
|
||||
)
|
||||
return (
|
||||
<section class="mx-auto flex w-full max-w-[860px] flex-col gap-4 p-6">
|
||||
<button type="button" onClick={() => setState("completed", true)}>
|
||||
Complete file tool
|
||||
</button>
|
||||
<CurrentSessionProviders document={storyDocument([])}>
|
||||
<ToolDisplay
|
||||
id="tool_file_fallback"
|
||||
tool={args.tool}
|
||||
status={state.completed ? "completed" : "running"}
|
||||
input={{
|
||||
path: "src/example.ts",
|
||||
oldString: "export const before = true\n",
|
||||
newString: "export const after = true\n",
|
||||
content: args.empty ? "" : "export const written = true\n",
|
||||
}}
|
||||
metadata={{
|
||||
diagnostics: state.completed
|
||||
? {
|
||||
"src/example.ts": [
|
||||
{ severity: 1, message: "Example diagnostic", range: { start: { line: 0, character: 0 } } },
|
||||
],
|
||||
}
|
||||
: {},
|
||||
}}
|
||||
open={args.controlled ? state.open : undefined}
|
||||
onOpenChange={(open) => setState("open", open)}
|
||||
forceOpen={args.forceOpen}
|
||||
/>
|
||||
<CurrentSessionProviders document={document()}>
|
||||
{args.timeline ? (
|
||||
<SessionTimeline document={document()} editToolDefaultOpen={args.placement === "separate"} />
|
||||
) : (
|
||||
<ToolDisplay
|
||||
id="tool_file_fallback"
|
||||
tool={args.tool}
|
||||
status={state.completed ? "completed" : "running"}
|
||||
input={{
|
||||
path: "src/example.ts",
|
||||
oldString: "export const before = true\n",
|
||||
newString: "export const after = true\n",
|
||||
content: args.empty ? "" : "export const written = true\n",
|
||||
}}
|
||||
metadata={{
|
||||
diagnostics: state.completed
|
||||
? {
|
||||
"src/example.ts": [
|
||||
{ severity: 1, message: "Example diagnostic", range: { start: { line: 0, character: 0 } } },
|
||||
],
|
||||
}
|
||||
: {},
|
||||
}}
|
||||
open={args.controlled ? state.open : undefined}
|
||||
onOpenChange={(open) => setState("open", open)}
|
||||
forceOpen={args.forceOpen}
|
||||
/>
|
||||
)}
|
||||
</CurrentSessionProviders>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -8,7 +8,12 @@ import type {
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { Option, Schema } from "effect"
|
||||
import { createMemo, mapArray, type Accessor } from "solid-js"
|
||||
import { currentContentDefaultOpen, currentToolFailed, currentToolHasLoadedFiles } from "../message/current-tool-state"
|
||||
import {
|
||||
currentContentDefaultOpen,
|
||||
currentToolCanGroupFiles,
|
||||
currentToolFailed,
|
||||
currentToolHasLoadedFiles,
|
||||
} from "../message/current-tool-state"
|
||||
import { TimelineRow, type PartGroup, type PartRef, type TimelineRowMap } from "./timeline-row"
|
||||
import { timelineCategory, timelineNoticeRequired, type TimelineDetail } from "./detail"
|
||||
|
||||
@@ -597,7 +602,7 @@ function groupContent(
|
||||
detail?: TimelineDetail,
|
||||
): PartGroup[] {
|
||||
const groups: PartGroup[] = []
|
||||
let adjacent: { type: "context" | "patch" | "edit"; refs: PartRef[]; tools: boolean } | undefined
|
||||
let adjacent: { type: "context" | "file"; refs: PartRef[]; tools: boolean } | undefined
|
||||
const flush = () => {
|
||||
const current = adjacent
|
||||
const first = current?.refs[0]
|
||||
@@ -665,8 +670,7 @@ function toolGroupType(
|
||||
const category = timelineCategory(content)!
|
||||
if (detail[category].placement === "grouped") return "context"
|
||||
if (currentToolFailed(content)) return undefined
|
||||
if (content.name === "patch") return "patch"
|
||||
if (content.name === "edit") return "edit"
|
||||
if (currentToolCanGroupFiles(content)) return "file"
|
||||
return undefined
|
||||
}
|
||||
if (content.name === "question" || currentToolHasLoadedFiles(content)) return undefined
|
||||
@@ -684,8 +688,7 @@ function toolGroupType(
|
||||
)
|
||||
return undefined
|
||||
if (currentContentDefaultOpen(content, shellExpanded, editExpanded) !== true) return "context"
|
||||
if (content.name === "patch") return "patch"
|
||||
if (content.name === "edit") return "edit"
|
||||
if (currentToolCanGroupFiles(content)) return "file"
|
||||
return undefined
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import type {
|
||||
SessionMessageAssistantTool,
|
||||
SessionMessageInfo,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { storyDocument, storyTool } from "../storybook/current-session-scenarios"
|
||||
import { storyDocument, storyPatchFile, storyTool } from "../storybook/current-session-scenarios"
|
||||
import { createTimelineProjection, Timeline, TimelineRow } from "./projection"
|
||||
|
||||
describe("current session timeline rows", () => {
|
||||
@@ -625,7 +625,7 @@ describe("current session timeline rows", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("groups adjacent successful patches and leaves failed patches separate", () => {
|
||||
test("groups adjacent patch, edit, and write calls and leaves failed calls separate", () => {
|
||||
const source = [
|
||||
{ id: "msg_user", type: "user", text: "edit", time: { created: 1 } },
|
||||
{
|
||||
@@ -681,16 +681,23 @@ describe("current session timeline rows", () => {
|
||||
type: "tool",
|
||||
id: "tool_edit_1",
|
||||
name: "edit",
|
||||
state: { status: "running", input: {}, metadata: { files: [] } },
|
||||
state: { status: "running", input: {}, metadata: { files: [storyPatchFile("src/edited.ts")] } },
|
||||
time: { created: 9 },
|
||||
},
|
||||
{
|
||||
type: "tool",
|
||||
id: "tool_edit_2",
|
||||
name: "edit",
|
||||
state: { status: "running", input: {}, metadata: { files: [] } },
|
||||
state: { status: "running", input: {}, metadata: { files: [storyPatchFile("src/edited.ts")] } },
|
||||
time: { created: 10 },
|
||||
},
|
||||
{
|
||||
type: "tool",
|
||||
id: "tool_write",
|
||||
name: "write",
|
||||
state: { status: "running", input: {}, metadata: { files: [storyPatchFile("src/written.ts")] } },
|
||||
time: { created: 11 },
|
||||
},
|
||||
],
|
||||
time: { created: 2, completed: 8 },
|
||||
},
|
||||
@@ -716,14 +723,11 @@ describe("current session timeline rows", () => {
|
||||
{
|
||||
type: "file",
|
||||
key: "part:msg_assistant:tool_patch_3",
|
||||
refs: [{ messageID: "msg_assistant", partID: "tool_patch_3" }],
|
||||
},
|
||||
{
|
||||
type: "file",
|
||||
key: "part:msg_assistant:tool_edit_1",
|
||||
refs: [
|
||||
{ messageID: "msg_assistant", partID: "tool_patch_3" },
|
||||
{ messageID: "msg_assistant", partID: "tool_edit_1" },
|
||||
{ messageID: "msg_assistant", partID: "tool_edit_2" },
|
||||
{ messageID: "msg_assistant", partID: "tool_write" },
|
||||
],
|
||||
},
|
||||
])
|
||||
@@ -790,8 +794,8 @@ describe("current session timeline rows", () => {
|
||||
test.each([
|
||||
{ shell: false, edit: false, types: ["context"] },
|
||||
{ shell: true, edit: false, types: ["part", "context"] },
|
||||
{ shell: false, edit: true, types: ["context", "file", "part", "file", "context"] },
|
||||
{ shell: true, edit: true, types: ["part", "file", "part", "file", "context"] },
|
||||
{ shell: false, edit: true, types: ["context", "part", "part", "file", "context"] },
|
||||
{ shell: true, edit: true, types: ["part", "part", "part", "file", "context"] },
|
||||
])("keeps tools expanded by settings outside collapsed groups ($shell, $edit)", ({ shell, edit, types }) => {
|
||||
const source = [
|
||||
{ id: "msg_user", type: "user", text: "work", time: { created: 1 } },
|
||||
|
||||
@@ -23,7 +23,7 @@ import type { ContextGroupPart } from "../tools/tool-renderer"
|
||||
import { SessionRetry } from "../components/session-retry"
|
||||
import { SessionError } from "../components/session-error"
|
||||
import { timelineCategory, type TimelineDetail } from "./detail"
|
||||
import { currentToolFailed } from "../message/current-tool-state"
|
||||
import { currentToolCanGroupFiles, currentToolFailed } from "../message/current-tool-state"
|
||||
import {
|
||||
createReactiveTimelineProjection,
|
||||
Timeline,
|
||||
@@ -72,7 +72,7 @@ export function createSessionTimelineRowRenderer(input: {
|
||||
if (row._tag !== "AssistantPart" || row.group.type !== "context") return
|
||||
row.group.refs.forEach((ref) => {
|
||||
const content = Timeline.resolveContent(input.projection.messageByID().get(ref.messageID), ref.partID)
|
||||
if (content?.type !== "tool" || content.name !== "patch" || content.state.status === "error") return
|
||||
if (content?.type !== "tool" || !currentToolCanGroupFiles(content)) return
|
||||
const part = `${ref.messageID}:${ref.partID}`
|
||||
const key = patchGroupKeys.get(part)
|
||||
if (key && !owners.has(key)) owners.set(key, part)
|
||||
@@ -223,7 +223,7 @@ export function createSessionTimelineRowRenderer(input: {
|
||||
const open = input.disclosure.value(`${row().group.key}:file:${path}`)
|
||||
if (open !== undefined) return open
|
||||
if (input.timelineDetail) return input.timelineDetail().edit.details === "expanded"
|
||||
if (tools()[0]?.name !== "edit" || path !== firstPath()) return false
|
||||
if (!["edit", "write"].includes(tools()[0]?.name ?? "") || path !== firstPath()) return false
|
||||
return input.disclosure.value(row().group.key) ?? input.editToolDefaultOpen()
|
||||
}}
|
||||
onFileOpenChange={(path, open) => input.disclosure.set(`${row().group.key}:file:${path}`, open)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createTwoFilesPatch } from "diff"
|
||||
import { CurrentSessionProviders } from "../storybook/current-session-story"
|
||||
import { storyDocument, storyTool } from "../storybook/current-session-scenarios"
|
||||
import { type ContextGroupPart, CurrentContextToolGroup } from "./tool-renderer"
|
||||
import { SessionTimeline } from "../timeline/session-timeline"
|
||||
|
||||
export default {
|
||||
title: "OpenCode/Work/Tool group",
|
||||
@@ -78,36 +79,59 @@ export const MixedReasoning = {
|
||||
}
|
||||
|
||||
export const PatchFollowUps = {
|
||||
args: { separator: "none" },
|
||||
argTypes: { separator: { control: "select", options: ["none", "shell", "error", "reasoning"] } },
|
||||
render: (args: { separator: string }) => {
|
||||
args: { separator: "none", tool: "patch", placement: "used" },
|
||||
argTypes: {
|
||||
separator: { control: "select", options: ["none", "shell", "error", "reasoning"] },
|
||||
tool: { control: "select", options: ["patch", "edit", "write", "mixed"] },
|
||||
placement: { control: "select", options: ["used", "separate", "grouped"] },
|
||||
},
|
||||
render: (args: { separator: string; tool: string; placement: "used" | "separate" | "grouped" }) => {
|
||||
const [state, setState] = createStore({ phase: "initial", open: true, reasoning: true })
|
||||
const source = (value: number) => `export const value = ${value}\n`
|
||||
const file = (path: string, before: number, after: number) => ({
|
||||
file: path,
|
||||
status: "modified",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
patch: createTwoFilesPatch(
|
||||
path,
|
||||
path,
|
||||
`export const value = ${before}\n`,
|
||||
`export const value = ${after}\n`,
|
||||
"",
|
||||
"",
|
||||
{ context: Infinity },
|
||||
),
|
||||
patch: createTwoFilesPatch(path, path, source(before), source(after)),
|
||||
})
|
||||
const changes = (next: boolean) => {
|
||||
const files = next
|
||||
? [file("src/a.ts", 1, 2), file("src/c.ts", 0, 1)]
|
||||
: [file("src/a.ts", 0, 1), file("src/b.ts", 0, 1)]
|
||||
const status = next && state.phase === "running" ? "running" : "completed"
|
||||
if (args.tool === "patch")
|
||||
return [
|
||||
storyTool(
|
||||
next ? "patch_next" : "patch_first",
|
||||
"patch",
|
||||
status,
|
||||
{},
|
||||
{ metadata: status === "running" ? {} : { files } },
|
||||
),
|
||||
]
|
||||
return files.map((file, index) => {
|
||||
const name = args.tool === "mixed" ? (next ? ["patch", "write"] : ["edit", "write"])[index]! : args.tool
|
||||
return storyTool(
|
||||
`${next ? "next" : "first"}_${index}`,
|
||||
name,
|
||||
status,
|
||||
name === "patch"
|
||||
? { patchText: `Update ${file.file}` }
|
||||
: name === "write"
|
||||
? { path: file.file, content: source(next && index === 0 ? 2 : 1) }
|
||||
: {
|
||||
path: file.file,
|
||||
oldString: source(next && index === 0 ? 1 : 0),
|
||||
newString: source(next && index === 0 ? 2 : 1),
|
||||
},
|
||||
{ metadata: status === "running" ? {} : { files: [file] } },
|
||||
)
|
||||
})
|
||||
}
|
||||
const parts = createMemo<ContextGroupPart[]>(() => [
|
||||
storyTool("patch_shell", "shell", "completed", { command: "printf checked" }, { output: "checked" }),
|
||||
storyTool(
|
||||
"patch_first",
|
||||
"patch",
|
||||
"completed",
|
||||
{},
|
||||
{
|
||||
metadata: { files: [file("src/a.ts", 0, 1), file("src/b.ts", 0, 1)] },
|
||||
},
|
||||
),
|
||||
...changes(false),
|
||||
...(state.phase === "initial"
|
||||
? []
|
||||
: [
|
||||
@@ -115,7 +139,15 @@ export const PatchFollowUps = {
|
||||
? [storyTool("patch_separator", "shell", "completed", { command: "printf checked" })]
|
||||
: []),
|
||||
...(args.separator === "error"
|
||||
? [storyTool("patch_error", "patch", "error", {}, { error: "Patch failed" })]
|
||||
? [
|
||||
storyTool(
|
||||
"patch_error",
|
||||
args.tool === "mixed" ? "write" : args.tool,
|
||||
"error",
|
||||
{},
|
||||
{ error: "File change failed" },
|
||||
),
|
||||
]
|
||||
: []),
|
||||
...(args.separator === "reasoning" && state.reasoning
|
||||
? [
|
||||
@@ -126,19 +158,16 @@ export const PatchFollowUps = {
|
||||
},
|
||||
]
|
||||
: []),
|
||||
storyTool(
|
||||
"patch_next",
|
||||
"patch",
|
||||
state.phase === "running" ? "running" : "completed",
|
||||
{},
|
||||
{
|
||||
metadata: state.phase === "running" ? {} : { files: [file("src/a.ts", 1, 2), file("src/c.ts", 0, 1)] },
|
||||
},
|
||||
),
|
||||
...changes(true),
|
||||
]),
|
||||
])
|
||||
const document = createMemo(() => storyDocument(parts()))
|
||||
return (
|
||||
<section class="mx-auto flex w-full max-w-[860px] flex-col gap-4 p-6">
|
||||
<section
|
||||
class="mx-auto flex w-full max-w-[860px] flex-col gap-4 p-6"
|
||||
data-file-tool={args.tool}
|
||||
data-file-separator={args.separator}
|
||||
>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button type="button" onClick={() => setState("phase", "running")}>
|
||||
Start follow-up patch
|
||||
@@ -152,13 +181,20 @@ export const PatchFollowUps = {
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<CurrentSessionProviders document={storyDocument(parts())}>
|
||||
<CurrentContextToolGroup
|
||||
parts={parts()}
|
||||
busy={state.phase === "running"}
|
||||
open={state.open}
|
||||
onOpenChange={(open) => setState("open", open)}
|
||||
/>
|
||||
<CurrentSessionProviders document={document()}>
|
||||
<Show
|
||||
when={args.placement !== "used"}
|
||||
fallback={
|
||||
<CurrentContextToolGroup
|
||||
parts={parts()}
|
||||
busy={state.phase === "running"}
|
||||
open={state.open}
|
||||
onOpenChange={(open) => setState("open", open)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<SessionTimeline document={document()} editToolDefaultOpen={args.placement === "separate"} />
|
||||
</Show>
|
||||
</CurrentSessionProviders>
|
||||
</section>
|
||||
)
|
||||
|
||||
@@ -45,6 +45,7 @@ import type {
|
||||
import {
|
||||
currentToolError,
|
||||
currentToolHasLoadedFiles,
|
||||
currentToolCanGroupFiles,
|
||||
currentToolInput,
|
||||
currentToolMetadata,
|
||||
currentToolOutput,
|
||||
@@ -547,11 +548,10 @@ export function CurrentContextToolGroup(props: {
|
||||
}
|
||||
const previous = groups.at(-1)
|
||||
if (
|
||||
tool.name === "patch" &&
|
||||
tool.state.status !== "error" &&
|
||||
currentToolCanGroupFiles(tool) &&
|
||||
Array.isArray(previous) &&
|
||||
previous?.[0]?.name === "patch" &&
|
||||
previous[0].state.status !== "error"
|
||||
previous[0] &&
|
||||
currentToolCanGroupFiles(previous[0])
|
||||
) {
|
||||
previous.push(tool)
|
||||
return groups
|
||||
@@ -577,7 +577,7 @@ export function CurrentContextToolGroup(props: {
|
||||
const patchKeys = createMemo(() => {
|
||||
const keys = new Map<SessionMessageAssistantTool, string>()
|
||||
items().forEach((item) => {
|
||||
if (!Array.isArray(item) || item[0]?.name !== "patch" || item[0].state.status === "error") return
|
||||
if (!Array.isArray(item) || !item[0] || !currentToolCanGroupFiles(item[0])) return
|
||||
const key = props.patchGroupKey?.(item) ?? item[0].id
|
||||
item.forEach((tool) => keys.set(tool, key))
|
||||
})
|
||||
@@ -682,7 +682,7 @@ export function CurrentContextToolGroup(props: {
|
||||
when={tool().name === "skill" && group().length > 1 && skills().length === group().length}
|
||||
fallback={
|
||||
<Show
|
||||
when={tool().name === "patch" && tool().state.status !== "error"}
|
||||
when={currentToolCanGroupFiles(tool())}
|
||||
fallback={
|
||||
<ToolDisplay
|
||||
id={tool().id}
|
||||
@@ -836,7 +836,7 @@ export function CurrentFileToolGroup(props: {
|
||||
props.tools.some((tool) => tool.state.status === "streaming" || tool.state.status === "running"),
|
||||
)
|
||||
const render = ToolRegistry.render("patch") ?? GenericTool
|
||||
const tool = createMemo(() => (props.tools[0]?.name === "edit" ? "edit" : "patch"))
|
||||
const tool = createMemo(() => props.tools[0]?.name ?? "patch")
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -1879,7 +1879,11 @@ ToolRegistry.register({
|
||||
const files = createMemo(() => patchFileGroups(props.metadata.files))
|
||||
const [expanded, setExpanded] = createSignal<string[]>([])
|
||||
const title = createMemo(() =>
|
||||
props.tool === "edit" ? i18n.t("ui.messagePart.title.edit") : i18n.t("ui.tool.patch"),
|
||||
props.tool === "edit"
|
||||
? i18n.t("ui.messagePart.title.edit")
|
||||
: props.tool === "write"
|
||||
? i18n.t("ui.messagePart.title.write")
|
||||
: i18n.t("ui.tool.patch"),
|
||||
)
|
||||
const open = createMemo(() => {
|
||||
if (!props.fileOpen) return expanded()
|
||||
|
||||
@@ -169,7 +169,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
: members.some((id) => (data.session.form.list(id)?.length ?? 0) > 0)
|
||||
? ("question" as const)
|
||||
: (false as const),
|
||||
busy: members.some((id) => data.session.status(id) === "running"),
|
||||
busy: members.some((id) => data.session.status(id) === "running" || data.session.pending.list(id).length > 0),
|
||||
renaming: data.session.title.pending(session),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { expect, test } from "bun:test"
|
||||
import type { OpenCodeEvent, SessionInboxInfo } from "@opencode-ai/client"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { mkdirSync, watch } from "fs"
|
||||
import path from "path"
|
||||
@@ -38,8 +38,6 @@ async function renderSessionTabs(
|
||||
sessionParents?: Record<string, string>
|
||||
sessionTimes?: Record<string, { idle?: number; viewed?: number }>
|
||||
sessionOutcomes?: Record<string, "succeeded" | "failed" | "interrupted">
|
||||
active?: () => Record<string, { type: "running" }>
|
||||
inbox?: Record<string, SessionInboxInfo[]>
|
||||
newLocation?: "launch" | "inherit"
|
||||
launchDirectory?: string
|
||||
tabsEnabled?: boolean
|
||||
@@ -71,17 +69,10 @@ async function renderSessionTabs(
|
||||
const viewWatermarks: number[] = []
|
||||
const locations: string[] = []
|
||||
const vcsLocations: string[] = []
|
||||
let activeRequests = 0
|
||||
const sessionTimes = Object.fromEntries(
|
||||
Object.entries(options?.sessionTimes ?? {}).map(([sessionID, time]) => [sessionID, { ...time }]),
|
||||
)
|
||||
const calls = createFetch(async (url, request) => {
|
||||
if (url.pathname === "/api/session/active") {
|
||||
activeRequests++
|
||||
return json({ data: options?.active?.() ?? {} })
|
||||
}
|
||||
const inboxID = url.pathname.match(/^\/api\/session\/([^/]+)\/inbox$/)?.[1]
|
||||
if (inboxID && options?.inbox) return json({ data: options.inbox[inboxID] ?? [] })
|
||||
if (url.pathname === "/api/location") {
|
||||
const requested = url.searchParams.get("location[directory]") ?? directory
|
||||
locations.push(requested)
|
||||
@@ -208,8 +199,6 @@ async function renderSessionTabs(
|
||||
sessionTimes[sessionID] = time
|
||||
},
|
||||
emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }),
|
||||
disconnect: () => events.disconnect(),
|
||||
activeRequests: () => activeRequests,
|
||||
focus: () => app.renderer.emit("focus"),
|
||||
blur: () => app.renderer.emit("blur"),
|
||||
flush: () => storage.flush(),
|
||||
@@ -240,28 +229,6 @@ function admitted(sessionID: string, inboxID: string): OpenCodeEvent {
|
||||
}
|
||||
}
|
||||
|
||||
function execution(
|
||||
sessionID: string,
|
||||
state: "started" | "succeeded" | "failed" | "interrupted",
|
||||
seq: number,
|
||||
): OpenCodeEvent {
|
||||
const event = {
|
||||
id: `evt_${sessionID}_${seq}`,
|
||||
created: Date.now(),
|
||||
durable: { aggregateID: sessionID, seq, version: 1 as const },
|
||||
data: { sessionID },
|
||||
}
|
||||
if (state === "failed")
|
||||
return {
|
||||
...event,
|
||||
type: "session.execution.failed",
|
||||
data: { sessionID, error: { type: "provider.transport", message: "Disconnected" } },
|
||||
}
|
||||
if (state === "interrupted")
|
||||
return { ...event, type: "session.execution.interrupted", data: { sessionID, reason: "user" } }
|
||||
return { ...event, type: `session.execution.${state}` }
|
||||
}
|
||||
|
||||
test("loads persisted tab metadata concurrently on connect", async () => {
|
||||
let release!: () => void
|
||||
const sessionGate = new Promise<void>((resolve) => (release = resolve))
|
||||
@@ -834,9 +801,6 @@ test("distinguishes family questions and permissions without clearing them on se
|
||||
await wait(() => setup.data.session.get("child") !== undefined)
|
||||
expect(setup.tabs.status("root").attention).toBe(false)
|
||||
|
||||
setup.emit(execution("child", "started", 1))
|
||||
await wait(() => setup.tabs.status("root").busy)
|
||||
|
||||
setup.emit({
|
||||
id: "evt_question",
|
||||
created: 1,
|
||||
@@ -880,7 +844,6 @@ test("distinguishes family questions and permissions without clearing them on se
|
||||
data: { sessionID: "child", id: "frm_question", answer: {} },
|
||||
})
|
||||
await wait(() => setup.tabs.status("root").attention === false)
|
||||
expect(setup.tabs.status("root").busy).toBe(true)
|
||||
} finally {
|
||||
await setup.destroy()
|
||||
}
|
||||
@@ -970,202 +933,7 @@ test("closing a tab is not undone by another TUI viewing the same session", asyn
|
||||
}
|
||||
})
|
||||
|
||||
test.each(["shell", "child"])("a completed user shell in %s does not leave an idle tab busy", async (sessionID) => {
|
||||
const setup = await renderSessionTabs("shell", { persisted: ["shell"], sessionParents: { child: "shell" } })
|
||||
|
||||
try {
|
||||
await wait(() => setup.data.session.get(sessionID) !== undefined)
|
||||
expect(setup.tabs.status("shell").busy).toBe(false)
|
||||
setup.emit({
|
||||
id: "evt_shell_completion",
|
||||
created: Date.now(),
|
||||
type: "session.inbox.enqueued",
|
||||
durable: { aggregateID: sessionID, seq: 1, version: 1 },
|
||||
data: {
|
||||
sessionID,
|
||||
inboxID: "msg_shell_completion",
|
||||
item: {
|
||||
type: "synthetic",
|
||||
delivery: "steer",
|
||||
payload: {
|
||||
text: "Shell completed with exit code 0: done",
|
||||
metadata: { source: "shell", shellID: "sh_done", state: "completed", exit: 0, truncated: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
await wait(() => setup.data.session.pending.list(sessionID).length === 1)
|
||||
expect(setup.data.session.status(sessionID)).toBe("idle")
|
||||
expect(setup.tabs.status("shell").busy).toBe(false)
|
||||
|
||||
setup.emit({
|
||||
id: "evt_execution_started",
|
||||
created: Date.now(),
|
||||
type: "session.execution.started",
|
||||
durable: { aggregateID: sessionID, seq: 2, version: 1 },
|
||||
data: { sessionID },
|
||||
})
|
||||
await wait(() => setup.data.session.status(sessionID) === "running")
|
||||
expect(setup.tabs.status("shell").busy).toBe(true)
|
||||
|
||||
setup.emit({
|
||||
id: "evt_execution_succeeded",
|
||||
created: Date.now(),
|
||||
type: "session.execution.succeeded",
|
||||
durable: { aggregateID: sessionID, seq: 3, version: 1 },
|
||||
data: { sessionID },
|
||||
})
|
||||
await wait(() => setup.data.session.status(sessionID) === "idle")
|
||||
expect(setup.data.session.pending.list(sessionID)).toHaveLength(1)
|
||||
expect(setup.tabs.status("shell").busy).toBe(false)
|
||||
|
||||
setup.emit(admitted(sessionID, "msg_4"))
|
||||
await wait(() => setup.data.session.pending.list(sessionID).length === 2)
|
||||
expect(setup.tabs.status("shell").busy).toBe(false)
|
||||
} finally {
|
||||
await setup.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
for (const delivery of ["steer", "queue"] as const) {
|
||||
for (const item of [
|
||||
{ type: "user", payload: { text: "Run later" } },
|
||||
{ type: "synthetic", payload: { text: "Saved context" } },
|
||||
{ type: "compaction", payload: {} },
|
||||
{ type: "move", payload: { location: { directory }, projectID: "project" } },
|
||||
] as const) {
|
||||
test(`${delivery} ${item.type} inbox admission and removal do not control the tab spinner`, async () => {
|
||||
const setup = await renderSessionTabs("root", { persisted: ["root"], sessionParents: { child: "root" } })
|
||||
try {
|
||||
await wait(() => setup.data.session.get("child") !== undefined)
|
||||
setup.emit({
|
||||
id: "evt_pending",
|
||||
created: Date.now(),
|
||||
type: "session.inbox.enqueued",
|
||||
durable: { aggregateID: "child", seq: 1, version: 1 },
|
||||
data: { sessionID: "child", inboxID: "msg_pending", item: { ...item, delivery } },
|
||||
})
|
||||
await wait(() => setup.data.session.pending.list("child").length === 1)
|
||||
expect(setup.tabs.status("root").busy).toBe(false)
|
||||
|
||||
setup.emit(execution("child", "started", 2))
|
||||
await wait(() => setup.tabs.status("root").busy)
|
||||
setup.emit({
|
||||
id: "evt_delivered",
|
||||
created: Date.now(),
|
||||
type: delivery === "steer" ? "session.inbox.delivered" : "session.inbox.cancelled",
|
||||
durable: { aggregateID: "child", seq: 3, version: 1 },
|
||||
data: { sessionID: "child", inboxID: "msg_pending" },
|
||||
})
|
||||
await wait(() => setup.data.session.pending.list("child").length === 0)
|
||||
expect(setup.tabs.status("root").busy).toBe(true)
|
||||
|
||||
setup.emit(execution("child", "succeeded", 4))
|
||||
await wait(() => setup.data.session.status("child") === "idle")
|
||||
expect(setup.tabs.status("root").busy).toBe(false)
|
||||
} finally {
|
||||
await setup.destroy()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const state of ["succeeded", "failed", "interrupted"] as const) {
|
||||
test.each(["root", "grandchild"])(
|
||||
`${state} execution in %s clears busy despite retained input`,
|
||||
async (sessionID) => {
|
||||
const setup = await renderSessionTabs("root", {
|
||||
persisted: ["root", "other"],
|
||||
sessionParents: { child: "root", grandchild: "child" },
|
||||
})
|
||||
try {
|
||||
await setup.data.session.sync("child", { children: true })
|
||||
await wait(() => setup.data.session.get("grandchild") !== undefined)
|
||||
setup.emit(execution(sessionID, "started", 1))
|
||||
setup.emit(admitted(sessionID, "msg_2"))
|
||||
await wait(() => setup.data.session.pending.list(sessionID).length === 1)
|
||||
expect(setup.tabs.status("root").busy).toBe(true)
|
||||
expect(setup.tabs.status("other").busy).toBe(false)
|
||||
|
||||
setup.emit(execution(sessionID, state, 3))
|
||||
await wait(() => setup.data.session.status(sessionID) === "idle")
|
||||
expect(setup.data.session.pending.list(sessionID)).toHaveLength(1)
|
||||
expect(setup.tabs.status("root").busy).toBe(false)
|
||||
|
||||
setup.emit(execution(sessionID, "started", 4))
|
||||
await wait(() => setup.data.session.status(sessionID) === "running")
|
||||
expect(setup.tabs.status("root").busy).toBe(true)
|
||||
} finally {
|
||||
await setup.destroy()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
test("tab busy state recovers from active snapshots when lifecycle events were missed", async () => {
|
||||
let active: Record<string, { type: "running" }> = { root: { type: "running" }, child: { type: "running" } }
|
||||
const setup = await renderSessionTabs("root", {
|
||||
persisted: ["root"],
|
||||
sessionParents: { child: "root" },
|
||||
active: () => active,
|
||||
inbox: {
|
||||
root: [
|
||||
{
|
||||
id: "msg_saved",
|
||||
sessionID: "root",
|
||||
timeCreated: 1,
|
||||
type: "user",
|
||||
delivery: "queue",
|
||||
payload: { text: "Later" },
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
try {
|
||||
await wait(() => setup.tabs.status("root").busy)
|
||||
await setup.data.session.pending.sync("root")
|
||||
expect(setup.data.session.pending.list("root")).toHaveLength(1)
|
||||
|
||||
setup.emit(execution("child", "succeeded", 1))
|
||||
await wait(() => setup.data.session.status("child") === "idle")
|
||||
expect(setup.tabs.status("root").busy).toBe(true)
|
||||
|
||||
// Completion happened while disconnected; no terminal event is delivered to this client.
|
||||
active = {}
|
||||
setup.disconnect()
|
||||
await wait(() => setup.activeRequests() >= 2 && setup.data.session.status("root") === "idle", 5_000)
|
||||
expect(setup.data.session.pending.list("root")).toHaveLength(1)
|
||||
expect(setup.tabs.status("root").busy).toBe(false)
|
||||
|
||||
active = { child: { type: "running" } }
|
||||
setup.disconnect()
|
||||
await wait(() => setup.activeRequests() >= 3 && setup.data.session.status("child") === "running", 5_000)
|
||||
expect(setup.tabs.status("root").busy).toBe(true)
|
||||
} finally {
|
||||
await setup.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("optimistic submission and failed setup do not claim the session is executing", async () => {
|
||||
const setup = await renderSessionTabs("root", { persisted: ["root"] })
|
||||
const gate = Promise.withResolvers<void>()
|
||||
try {
|
||||
await wait(() => setup.data.session.get("root") !== undefined)
|
||||
const sending = setup.data.session.prompt({ sessionID: "root", text: "Hello", gate: gate.promise })
|
||||
expect(setup.data.session.pending.list("root")).toHaveLength(1)
|
||||
expect(setup.tabs.status("root").busy).toBe(false)
|
||||
|
||||
gate.reject(new Error("Setup failed"))
|
||||
await expect(sending).rejects.toThrow("Setup failed")
|
||||
expect(setup.data.session.pending.list("root")).toHaveLength(0)
|
||||
expect(setup.tabs.status("root").busy).toBe(false)
|
||||
} finally {
|
||||
gate.resolve()
|
||||
await setup.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("user prompt admissions pulse a background tab independently of execution", async () => {
|
||||
test("user prompt admissions pulse an already-busy background tab", async () => {
|
||||
const setup = await renderSessionTabs("background", { persisted: ["background"] })
|
||||
|
||||
try {
|
||||
@@ -1184,21 +952,16 @@ test("user prompt admissions pulse a background tab independently of execution",
|
||||
item: { type: "synthetic", payload: { text: "editor context" }, delivery: "steer" },
|
||||
},
|
||||
})
|
||||
await wait(() => setup.data.session.pending.list("background").length === 1)
|
||||
await Bun.sleep(20)
|
||||
expect(setup.tabs.status("background").promptPulse).toBe(0)
|
||||
expect(setup.tabs.status("background").busy).toBe(false)
|
||||
|
||||
setup.emit(admitted("background", "msg_1"))
|
||||
await wait(() => setup.tabs.status("background").promptPulse === 1)
|
||||
expect(setup.tabs.status("background").busy).toBe(false)
|
||||
await wait(() => setup.tabs.status("background").promptPulse === 1 && setup.tabs.status("background").busy)
|
||||
|
||||
setup.emit(execution("background", "started", 2))
|
||||
await wait(() => setup.tabs.status("background").busy)
|
||||
|
||||
setup.emit(admitted("background", "msg_3"))
|
||||
setup.emit(admitted("background", "msg_2"))
|
||||
await wait(() => setup.tabs.status("background").promptPulse === 2)
|
||||
|
||||
setup.emit(admitted("active", "msg_4"))
|
||||
setup.emit(admitted("active", "msg_3"))
|
||||
await Bun.sleep(20)
|
||||
expect(setup.tabs.status("active").promptPulse).toBe(0)
|
||||
expect(setup.tabs.status("background")).toMatchObject({ promptPulse: 2, busy: true })
|
||||
|
||||
Reference in New Issue
Block a user