From daf8e539bf8e3561a5896beab8bf0b17755355b5 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 17 Jul 2026 16:49:14 -0400 Subject: [PATCH] feat(tui): add quark timeline experiment --- bun.lock | 13 + docs/design/quark-architecture-review.md | 303 ++++++++++ docs/design/quark-performance-model.md | 550 ++++++++++++++++++ docs/design/quark-review-recommendations.md | 137 +++++ docs/design/quark-tui-timeline.md | 426 ++++++++++++++ packages/quark/bench/harness.ts | 60 ++ packages/quark/bench/keyed.ts | 217 +++++++ packages/quark/bench/row-key.ts | 63 ++ packages/quark/package.json | 20 + packages/quark/src/index.ts | 2 + packages/quark/src/keyed.ts | 143 +++++ packages/quark/src/reactivity.ts | 59 ++ packages/quark/src/solid.ts | 22 + packages/quark/test/keyed.test.ts | 201 +++++++ packages/quark/test/reactivity.test.ts | 39 ++ packages/tui/package.json | 1 + packages/tui/src/routes/session/index.tsx | 9 +- packages/tui/src/routes/session/rows.ts | 274 ++++++--- packages/tui/test/cli/tui/data.test.tsx | 220 ++++++- .../tui/test/cli/tui/session-rows.test.ts | 45 +- script/quark-group-drive.ts | 76 +++ script/quark-timeline-drive.ts | 46 ++ 22 files changed, 2804 insertions(+), 122 deletions(-) create mode 100644 docs/design/quark-architecture-review.md create mode 100644 docs/design/quark-performance-model.md create mode 100644 docs/design/quark-review-recommendations.md create mode 100644 docs/design/quark-tui-timeline.md create mode 100644 packages/quark/bench/harness.ts create mode 100644 packages/quark/bench/keyed.ts create mode 100644 packages/quark/bench/row-key.ts create mode 100644 packages/quark/package.json create mode 100644 packages/quark/src/index.ts create mode 100644 packages/quark/src/keyed.ts create mode 100644 packages/quark/src/reactivity.ts create mode 100644 packages/quark/src/solid.ts create mode 100644 packages/quark/test/keyed.test.ts create mode 100644 packages/quark/test/reactivity.test.ts create mode 100644 script/quark-group-drive.ts create mode 100644 script/quark-timeline-drive.ts diff --git a/bun.lock b/bun.lock index c52d9e6b17..b4af44142a 100644 --- a/bun.lock +++ b/bun.lock @@ -627,6 +627,14 @@ "typescript": "catalog:", }, }, + "packages/quark": { + "name": "effect-quark", + "version": "0.0.0", + "dependencies": { + "alien-signals": "3.2.1", + "solid-js": "catalog:", + }, + }, "packages/schema": { "name": "@opencode-ai/schema", "version": "1.17.11", @@ -893,6 +901,7 @@ "clipboardy": "4.0.0", "diff": "catalog:", "effect": "catalog:", + "effect-quark": "workspace:*", "fuzzysort": "catalog:", "get-east-asian-width": "catalog:", "open": "10.1.2", @@ -3315,6 +3324,8 @@ "ajv-i18n": ["ajv-i18n@4.2.0", "", { "peerDependencies": { "ajv": "^8.0.0-beta.0" } }, "sha512-v/ei2UkCEeuKNXh8RToiFsUclmU+G57LO1Oo22OagNMENIw+Yb8eMwvHu7Vn9fmkjJyv6XclhJ8TbuigSglPkg=="], + "alien-signals": ["alien-signals@3.2.1", "", {}, "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g=="], + "ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="], "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], @@ -3859,6 +3870,8 @@ "effect": ["effect@4.0.0-beta.98", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.4", "multipasta": "^0.2.8", "toml": "^4.1.2", "uuid": "^14.0.1", "yaml": "^2.9.0" } }, "sha512-oz+bsG5h+6RNrw4t5GMfQrk/xBS8ROoqkYsuvRhBr5O7mCOrpvH/hbw+QrDzvKIpX4HJClwm86F94c87W0sJxg=="], + "effect-quark": ["effect-quark@workspace:packages/quark"], + "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], "electron": ["electron@42.3.3", "", { "dependencies": { "@electron/get": "^5.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-0MwYp9wTb7TrtTalOYqeW+suqd9T/Znstr/nDLKqFGIjHdBZX339guo3mQqTPURRZ/UQmYM4uMpzKpI5wLptfQ=="], diff --git a/docs/design/quark-architecture-review.md b/docs/design/quark-architecture-review.md new file mode 100644 index 0000000000..45da7f1c78 --- /dev/null +++ b/docs/design/quark-architecture-review.md @@ -0,0 +1,303 @@ +# Quark Timeline Architecture — and How It Can Be Faster + +An architecture review of the `opencode-quark-timeline` experiment: what the layers are, why the design is sound, and the *mechanical* reason it can beat the Solid Store timeline — with the actual code. + +## The Stack in One Picture + +Four small layers, each with one job. The reusable core remains about 200 lines. + +```mermaid +flowchart TD + E[Server events] --> D[DataProvider
Solid Store, unchanged] + D --> R[createSessionRows
row reduction policy] + R -->|"set / update / insert / remove"| K[Keyed<SessionRow, string>
packages/quark/src/keyed.ts] + K -->|"slots — structure channel"| FOR["Solid <For>"] + K -->|"slot — per-row value channel"| UV[useValue adapter
one Solid signal per row] + K -->|"slots + immutable identity"| BM[boundaries memo] + UV --> SRV[SessionRowView] + FOR --> SRV +``` + +```tree +packages/quark/src +├── reactivity.ts # 59 lines — Readable/Writable/Computed/Transaction over alien-signals +├── keyed.ts # Keyed collection (the whole idea lives here) +├── solid.ts # 8 lines — useValue: one quark Readable → one Solid signal +└── index.ts # 2 lines — exports +``` + +The experimental boundary is disciplined: `DataProvider`, the event protocol, +rendered row policy, and `SessionRowView` are unchanged. Row ownership and the +incremental mutation mechanics changed; batch and live grouping now share one +policy helper. The boundary remains narrow enough to attribute regressions or +improvements. + +## The Core Idea: Identity and Equivalence Are Inputs, Not Discoveries + +This is the entire architectural bet. Solid Store must *discover* what changed; Quark is *told* what identity and sameness mean, once, at construction: + +```typescript title="packages/tui/src/routes/session/rows.ts" caption="The complete reconciliation policy is two functions" +const state = Keyed.make({ key: rowKey, equivalent: sameRow }) +``` + +`rowKey` answers *"which slot is this?"* — including the subtle case where a group's key is the ref that **created** it, so refs moving to `pending` can never change identity. Keys are precomputed once at row construction with length-prefixed segments, so reconciliation pays zero key-extraction work: + +```typescript title="packages/tui/src/routes/session/rows.ts" caption="Keys are built once, at construction — rowKey is a field read" +export type SessionRow = { readonly id: string } & ( /* ...variants... */ ) + +function partRow(ref: PartRef): SessionRow { + return { id: `p${segment(ref.messageID)}${segment(ref.partID)}`, type: "part", ref } +} + +function segment(value: string) { + return `${value.length}:${value}` // unambiguous without trusting a delimiter +} + +function rowKey(row: SessionRow) { + return row.id +} +``` + +`sameRow` answers a different question: *"can any consumer tell these two values apart?"* It compares only render-relevant fields. If it says yes-they're-the-same, **nothing downstream runs at all**. + +## Two Channels Instead of One + +A Solid Store exposes one reactive graph; every consumer subscribes into the same proxy web. `Keyed` splits change into two independent surfaces: + +```typescript title="packages/quark/src/keyed.ts" start=4 +export interface Keyed { + readonly slots: Readable[]> // fires ONLY on insert/remove/reorder + readonly values: Readable // fires on any current value change + has(key: Key): boolean + get(key: Key): Readable | undefined + set(values: readonly A[]): void + update(value: A): boolean + insert(value: A, position?: "end" | { before: Key } | { after: Key }): Readable + remove(key: Key): boolean + move(key: Key, position?: "end" | { before: Key } | { after: Key }): boolean +} +``` + +```definitions +[ + { "term": "slots", "definition": "The structure channel. An array of stable per-row readables. Its identity changes only when membership or order changes. subscribes here." }, + { "term": "slot", "definition": "The value channel. One writable signal per row identity. A streaming delta touches exactly one slot. The row's component subscribes here via useValue." }, + { "term": "values", "definition": "The aggregate channel. A lazy computed mapping slots to current values. The TUI uses it only for internal mutation snapshots and does not subscribe boundaries to it." } +] +``` + +This separation is what a proxy-based store cannot give you for free: **a value change and a structure change are different events**, published to different audiences. + +## How It CAN Be Faster: One Unique Row Update, Step by Step + +The highest-frequency reducer workload is a repeated streaming ordinal, which +the seen-part set rejects in expected `O(1)` before reading the aggregate. The +next useful comparison is a unique event that extends or completes an existing +group row. Trace that value update through both systems. + +### Before: Solid Store path + +```typescript caption="Old hot path — every access and write crosses a proxy" +setRows(produce((draft) => { + // 1. draft is a proxy — every property read is a trap + // 2. finding the group row walks proxied array elements + // 3. the mutation writes through proxy machinery + // 4. Solid records fine-grained dependencies per touched path + append(draft, ref, part, queuedStart(draft)) +})) +// ...and on reconnect / revert / rebuild: +setRows(reconcile(reduce())) +// reconcile must re-derive identity from item references, +// walk every row's shape, and diff against the proxy graph +``` + +Solid's generality — arbitrary nested access, partial path writes, plain objects — is paid for on **every operation** with proxy traps, shape inspection, and dependency bookkeeping. + +### After: Quark path + +The whole collection is a closure over three pieces of state — the structure signal, the identity map, and the declared equivalence. `update` reads directly against them: + +```typescript title="packages/quark/src/keyed.ts" caption="The closure state update() operates on, plus the whole hot path" +export function make(options: { + readonly key: (value: A) => Key + readonly equivalent?: (left: A, right: A) => boolean +}): Keyed { + const slots = State.make[]>([]) // structure channel: one signal holding the slot array + const byKey = new Map>() // identity → slot address, maintained by set/insert/remove + const equivalent = options.equivalent ?? Object.is // declared sameness (sameRow in the TUI) + const values = Computed.make((previous) => { + const next = slots().map((slot) => slot()) // aggregate channel, lazy until subscribed + return same(previous, next) ? previous! : next + }) + + return { + slots, + values, + // ... + update(value) { + const key = options.key(value) // 1. one key extraction (a field read: row.id) + const slot = byKey.get(key) // 2. one Map lookup — an address, not a search + if (!slot) throw new Error(`Keyed value does not exist: ${String(key)}`) + if (equivalent(slot(), value)) return false // 3. one equivalence check — early cutoff + slot.set(value) // 4. one signal write; slots is untouched + return true + }, + // ... + } +} +``` + +Note what `update` never touches: `slots`. A value change writes one slot signal and the structure channel stays reference-identical — that single fact is the structural cutoff below. + +Four operations. No proxies, no shape discovery, no graph diff. Then the adapter forwards the change to exactly one Solid signal: + +```typescript title="packages/quark/src/solid.ts" caption="The entire Solid boundary — 8 lines" +export function useValue(readable: Readable): Accessor { + const [value, setValue] = createSignal(readable()) + onCleanup(readable.subscribe((next) => setValue(() => next))) + return value +} +``` + +### The structural cutoff — why `` never even wakes up + +Because `slots` only fires on structural change, a value-only update leaves the outer array **reference-identical**. The `` signal never fires, so keyed-list diffing never runs and the component owner survives: + +```typescript caption="Value-only transition: structure untouched, owner preserved" +const structure = rows.slots() +const groupSlot = structure[0] + +rows.update({ ...initial, completed: true }) + +rows.slots() === structure // true — receives no change at all +rows.slots()[0] === groupSlot // true — component owner survives +groupSlot().completed === true // true — the one subscribed row re-renders +``` + +This is also the **beauty** mechanism, not just speed: an expanded reasoning group keeps its local state through permission repartitioning, because refs moving between `refs` and `pending` is a value change on a stable identity — never a remount. + +### The membership cutoff — duplicate deltas avoid the aggregate entirely + +A route-scoped set contains every visible part identity, including refs nested +inside groups. A duplicate streaming delta dies at `seenParts.has(id)` before +reading `state.values()`. Duplicate message and footer events use `Keyed.has` +against the collection's existing key map. For a replacement that reaches +`update`, declared equivalence remains the final publication cutoff. + +## More Before / After, From the Actual Diff + +### Wiring the renderer: one ``, two subscription levels + +Before, every row component read its row through the store proxy, and any reconcile could disturb the list: + +```tsx title="packages/tui/src/routes/session/index.tsx" caption="Before — one reactive graph for everything" + + {(row, index) => ( + + )} + +``` + +After, `KeyedFor` subscribes to the structure channel and each row component subscribes to exactly one slot: + +```tsx title="packages/tui/src/routes/session/index.tsx" caption="After — structure and value subscriptions are separate" + + {(row, index) => ( + + )} + +``` + +### Appending a footer row + +```typescript caption="Before — mutate a proxied draft; Solid infers what changed" +setRows( + produce((draft) => { + if (draft.some((row) => row.type === "assistant-footer" && row.messageID === messageID)) return + const index = queuedStart(draft) + completePrevious(draft, index) + draft.splice(index, 0, { type: "assistant-footer", messageID }) + }), +) +``` + +```typescript caption="After — say exactly what happened: maybe one value update, then one insert" +mutate(() => { + if (state.has(footerRowID(messageID))) return + const current = state.values() + const index = queuedStart(current) + complete(current, index) // one state.update on the previous group, if open + insert(current, index, footerRow(messageID)) // one new slot + one structural publication +}) +``` + +Same policy, but the after version *names* its effects: at most one slot value change plus one structural change, flushed together by `Transaction.run`. Nothing has to diff anything to figure that out afterward. + +### Removing a row + +```typescript caption="Before — linear search, then splice through the proxy" +setRows( + produce((draft) => { + const index = draft.findIndex((row) => row.type === "assistant-footer" && row.messageID === messageID) + if (index !== -1) draft.splice(index, 1) + }), +) +``` + +```typescript caption="After — remove by identity; the ownership law does the rest" +mutate(() => { + state.remove(footerRowID(messageID)) +}) +``` + +### Full rebuild (reconnect, revert, compaction) + +```typescript caption="Before — reconcile re-derives identity from scratch" +setRows(reconcile(reduce())) +``` + +```typescript caption="After — set() reconciles with declared identity and equivalence" +setRows(reduce()) // → state.set(next): Map lookups + sameRow checks; unchanged rows publish nothing +``` + +This is the one place the two systems do comparable O(N) work — and it's exactly the workload the checked benchmark measured. Every path above it is where Quark does structurally *less*. + +## The Cost Ledger + +| Per operation | Solid Store (`produce`/`reconcile`) | Quark `Keyed` | +| --- | --- | --- | +| Find the row | proxied array walk / identity re-derivation | one `Map.get` | +| Detect "no change" | proxy-graph diff per touched path | one `equivalent()` call | +| Value change | proxy writes + per-path invalidation | one signal write → one Solid signal | +| Structure unchanged | reconcile still walks the list | outer array identity preserved; `` silent | +| Structure changed | full reconcile | one new array; retained slots reused by reference | +| Batch of writes | store batching | one `Transaction.run` — settled publication | + +Same asymptotics — `O(N)` for a whole-array `set` — but far fewer instructions, allocations, and invalidations per unit of change. **The speedup is a constant-factor win purchased with a stronger contract** (unique stable keys, declared equivalence), not framework magic. The checked-in claim: ~4.7× on sparse value publication, ~6.4× on reorder, at 1,000 rows. + +## Architecture Verdict + +### What's genuinely good + +- **Deep module, tiny surface.** One small `keyed.ts` module carries the whole idea; the laws (unique key, stable key, equivalence, structural cutoff, settled publication, ownership) are explicit and testable. +- **The adapter is honest.** `useValue` is 8 lines and creates no second reconciliation system — the failure mode the design doc itself warns about. +- **Identity-as-input is the right call** for this domain: OpenCode *has* real identities (messageID, partID) and was previously throwing that information away for Solid to rediscover. +- **Falsifiable framing.** The docs predict where Quark should lose. That's rare and worth preserving. + +### Where the architecture leaked — all fixed during review + +Every leak identified in the first pass has since been closed: + +- ~~**`JSON.stringify` keys.**~~ Keys are precomputed `id` fields built once at row construction with length-prefixed segments (`rows.ts` smart constructors); `rowKey` is a field read. +- ~~**The aggregate channel reintroduces O(N) per delta.**~~ `boundaries` now derives from the structure channel (`rows.slots()`) and reads slot values untracked (quark reads are invisible to Solid's tracker); the raw `values` readable is no longer mirrored into Solid. Per-delta boundary cost is zero; the O(N) recompute fires only on structural change or tracked `messages()` field changes. +- ~~**Boundary staleness as an implicit invariant.**~~ The untracked-read trick is now type-enforced: `messageBoundaryIDs` accepts `readonly BoundaryRow[]`, a projection of `Pick`ed identity-immutable fields, so a future dependence on a mutable row field is a compile error. +- ~~**Two grouping engines.**~~ Join-vs-insert policy is unified in `appendDecision` + `groupKind`; both the batch `reduce()` path and the incremental `appendPart` path consume it, and row construction goes through shared smart constructors. +- ~~**Benchmark not checked in / wrong hot path.**~~ `packages/quark/bench/` now exists and covers incremental `keyed.update` — including against Solid's *direct path write*, the adversarial case — plus dense updates and reorders. +- ~~**No work counters.**~~ `Keyed` accepts an optional `Metrics` sink; `OPENCODE_QUARK_METRICS=1` reports timeline counters on cleanup. +- ~~**Opaque positioning and adapter boilerplate.**~~ `insert` and `move` now share an explicit `Position` vocabulary (`"end"`, `before`, or `after`), `get` exposes stable slot addresses, and `KeyedFor` encapsulates the two-level Solid subscription. + +The remaining asymmetry — `complete` (immutable `state.update`) vs `completePrevious` (in-place mutation on a plain array under construction) — is inherent to the two contexts, three lines each, and shares the same policy. Not worth abstracting. + +None of these are structural flaws — they are integration debts. The layering itself (laws → Keyed → thin adapter → unchanged renderer) is the right shape, and it's the shape worth keeping even if every number in the current doc had to be re-measured. + +See `quark-review-recommendations.md` for the prioritized fix list. diff --git a/docs/design/quark-performance-model.md b/docs/design/quark-performance-model.md new file mode 100644 index 0000000000..84abf5175e --- /dev/null +++ b/docs/design/quark-performance-model.md @@ -0,0 +1,550 @@ +# Why Quark Can Be Faster Than Solid Store + +Status: experimental performance model + +## Conclusion + +Quark can be faster than the current Solid Store timeline because it accepts a +stronger contract and uses that contract to do less runtime work. + +The advantage does **not** come from TypeScript types by themselves. TypeScript +types disappear at runtime. The useful information comes from explicit runtime +inputs and API laws: + +- A row key is unique and cannot change during its ownership lifetime. +- Row equivalence is supplied before updates begin. +- A keyed list has one stable slot per key. +- Value changes and structural changes publish through separate channels. +- A transaction publishes only settled graph state. + +Those guarantees remove choices from the hot path. Quark does not need to +rediscover identity, shape, equivalence, index membership, or ownership on +every update. Less discovery means fewer comparisons, proxy traps, +allocations, writes, and downstream invalidations. + +This is a conditional claim, not a claim that Quark is universally faster than +Solid. Solid can be as fast or faster when an application performs a precise +direct store write, when lists are tiny, or when Quark receives no useful +identity information. The relevant comparison is the current OpenCode path: +whole projected records and ordered rows are reconciled into a Solid Store. + +## The Physical Argument Is “Do Less Work” + +Software cannot escape the physical costs of instructions, memory reads, +allocations, pointer writes, cache misses, and notification fan-out. An +abstraction can only become faster by reducing those costs or arranging them +more favorably. + +Quark's proposed advantage has four concrete sources: + +1. **Declare repeated decisions once.** Identity and equivalence become + preconfigured functions rather than per-event discovery. +2. **Replace searches with addresses.** Stable keys resolve to persistent + slots through a `Map`. +3. **Separate unrelated change dimensions.** An item value can change without + publishing a new list structure. +4. **Stop propagation early.** Schema equivalence and slot equivalence suppress + writes before they reach Solid or the terminal renderer. + +These are ordinary computer costs, not framework mythology. If profiling shows +that Quark executes the same work as Solid plus an adapter, the hypothesis is +false. If counters show fewer structural publications, fewer component-owner +reconciliations, and fewer proxy/store operations, the speedup has a mechanical +explanation. + +## Solid Store Must Preserve Generality + +The current timeline asks Solid Store to accept ordinary JavaScript objects and +reconcile new projections into a proxy-backed graph. + +```mermaid +flowchart LR + A[Projected rows] --> B[Solid reconcile] + B --> C[Inspect keys and shape] + C --> D[Proxy-backed Store writes] + D --> E[Dependency invalidation] + E --> F[For reconciliation] + F --> G[SessionRowView] +``` + +Solid's generality is valuable. It can react to arbitrary nested property +access, partial path writes, and plain objects without requiring a schema-owned +model. That generality also means the runtime must maintain proxy metadata and +interpret each reconciliation against the current store graph. + +Solid is not inherently inefficient. A precise call such as +`setStore(index, "value", next)` can avoid whole-list reconciliation. OpenCode's +timeline frequently receives whole projected messages or rebuilt row arrays, +however, so its current implementation uses `produce` and `reconcile` to +recover identity and preserve nested owners. + +## Quark Makes Identity an Input + +`Keyed` receives the identity and equivalence functions when the collection is +created. In the TUI, every row carries a collision-safe primitive `id`. Groups +also retain their immutable origin for message-boundary projection: + +```typescript +type PartRef = { + messageID: string + partID: string +} + +type SessionRow = { readonly id: string } & ( + | { type: "message"; messageID: string } + | { type: "compaction-queued"; inputID: string } + | { type: "part"; ref: PartRef } + | { + type: "group" + kind: "reasoning" + origin: PartRef + refs: PartRef[] + completed: boolean + } + | { + type: "group" + kind: "exploration" + origin: PartRef + refs: PartRef[] + pending: PartRef[] + completed: boolean + } + | { type: "assistant-footer"; messageID: string } +) +``` + +Factories build the ID once with length-prefixed segments. Length prefixes +avoid delimiter collisions without invoking a serializer: + +```typescript +function segment(value: string) { + return `${value.length}:${value}` +} + +function groupRow(kind: "reasoning" | "exploration", origin: PartRef): SessionRow { + const id = `g${kind === "reasoning" ? "r" : "e"}${segment(origin.messageID)}${segment(origin.partID)}` + if (kind === "reasoning") return { id, type: "group", kind, origin, refs: [origin], completed: false } + return { id, type: "group", kind, origin, refs: [origin], pending: [], completed: false } +} + +function rowKey(row: SessionRow) { + return row.id +} +``` + +`sameRow` answers a different question: can consumers distinguish these two +values for the same identity? It compares only fields that affect row +rendering. + +```typescript +function sameRow(left: SessionRow, right: SessionRow) { + if (left.type !== right.type) return false + + if (left.type === "message" && right.type === "message") return left.messageID === right.messageID + + if (left.type === "compaction-queued" && right.type === "compaction-queued") return left.inputID === right.inputID + + if (left.type === "part" && right.type === "part") return sameRef(left.ref, right.ref) + + if (left.type === "assistant-footer" && right.type === "assistant-footer") return left.messageID === right.messageID + + if (left.type !== "group" || right.type !== "group") return false + if (left.kind !== right.kind) return false + if (left.completed !== right.completed) return false + if (!sameRefs(left.refs, right.refs)) return false + + if (left.kind === "reasoning" || right.kind === "reasoning") return true + return sameRefs(left.pending, right.pending) +} + +function sameRefs(left: PartRef[], right: PartRef[]) { + return left.length === right.length && left.every((ref, index) => sameRef(ref, right[index])) +} + +function sameRef(left: PartRef, right: PartRef) { + return left.messageID === right.messageID && left.partID === right.partID +} +``` + +Those functions form the complete reconciliation policy: + +```typescript +const rows = Keyed.make({ + key: rowKey, + equivalent: sameRow, +}) + +rows.set(nextRows) +``` + +It exposes two reactive surfaces: + +```typescript +rows.slots // changes only when keys are inserted, removed, or reordered +rows.values // changes when any current value changes +``` + +Each slot is itself a readable value. Solid `` receives `slots`, so a +group can grow or complete without replacing its component owner. Generic +aggregate consumers can subscribe to `values` when they need every value +change. + +The TUI's message-boundary projection deliberately does not subscribe to +`values`. Boundary identity uses only immutable row fields, so it tracks the +structural `slots` array and reads each slot value untracked. Value-only deltas +therefore perform no boundary work; inserts, removals, reorders, or message +changes recompute boundaries. + +Here is a complete value-only transition. The group changes, but its key and +position do not: + +```typescript +const origin = { messageID: "assistant-1", partID: "call-read" } +const initial = groupRow("exploration", origin) + +rows.set([initial]) + +const structure = rows.slots() +const groupSlot = structure[0] + +rows.update({ + ...initial, + completed: true, +}) + +rows.slots() === structure // true: receives no structural change +rows.slots()[0] === groupSlot // true: component owner survives +groupSlot().completed === true // true: row value updated +``` + +A structural insertion changes the outer list but preserves every retained +slot: + +```typescript +const footer: SessionRow = { + id: "f11:assistant-1", + type: "assistant-footer", + messageID: "assistant-1", +} + +const footerSlot = rows.insert(footer) + +rows.slots().length === 2 // true +rows.slots()[0] === groupSlot // true +rows.slots()[1] === footerSlot // true +``` + +Removing and later reinserting a key begins a new ownership lifetime: + +```typescript +rows.remove(footer.id) +const nextFooterSlot = rows.insert(footer) + +nextFooterSlot === footerSlot // false +``` + +```mermaid +flowchart LR + A[Next rows] --> B[Keyed.set] + B --> C{Same keys and order?} + C -->|yes| D[Keep slots array] + C -->|no| E[Publish slots array] + B --> F{Equivalent value?} + F -->|yes| G[No slot write] + F -->|no| H[Publish one slot] + D --> I[Solid For unchanged] + E --> J[Solid For reconciles structure] + H --> K[Existing SessionRowView updates] +``` + +The key distinction is that **row identity is not inferred from row value**. +The timeline gives every reasoning or exploration group an immutable creation +key. Permission partitioning may move refs between `refs` and `pending`, but it +cannot change the group slot or reset local expanded state. + +## The `Keyed.set` Algorithm + +The current algorithm is deliberately small: + +```text +set(next): + 1. Compute every next key and reject duplicates. + 2. Build Map. + 3. For each next value: + a. Reuse the previous slot for its key, or create one. + b. Compare previous and next values. + c. Write only a changed slot. + 4. Publish the outer slot array only if membership or order changed. + 5. Flush slot and structural writes in one transaction. +``` + +The aggregate `values` readable maps the current slots to their values. It is a +computed node, so it remains lazy without subscribers and publishes one settled +array when subscribed. + +The algorithm preflights duplicate keys before any slot mutation. A rejected +set therefore cannot partially update the graph. `Map` and `Set` give keys +SameValueZero semantics, including consistent treatment of `0`/`-0` and +`NaN`. + +## The Laws That Unlock the Optimizations + +```definitions +[ + { + "term": "Unique key law", + "definition": "No two current values have the same key. This permits one Map entry and one slot per identity." + }, + { + "term": "Stable key law", + "definition": "An entity or logical row keeps its key for its lifetime. This permits component ownership and subscriptions to survive value changes." + }, + { + "term": "Equivalence law", + "definition": "If equivalent(left, right) is true, publishing right cannot change any consumer-visible meaning. This permits early cutoff." + }, + { + "term": "Structural cutoff law", + "definition": "Value-only updates preserve the exact outer slots-array identity. This prevents keyed-list reconciliation for non-structural changes." + }, + { + "term": "Settled publication law", + "definition": "Subscribers observe the state after every slot and structural write in the operation, never a partial combination." + }, + { + "term": "Ownership law", + "definition": "Removing a key removes its slot from the collection. Reinserting that key creates a fresh slot; old external references cannot attach to a new lifetime." + } +] +``` + +These laws are stronger than a plain `Array` contract. They are also +testable. Quark should reject a violated unique-key law and should have direct +tests for every other law. + +## Fixed Layout Research Is Deferred + +The standalone laboratory applies the same idea to records. The initial prototype +used Effect Schema, but its derived equivalence measured `1.621x` the hand +comparator's direct-update cost. Quark now experiments with a smaller trusted +in-memory `Layout` that compiles: + +- field names into numeric positions; +- field changes into a bit mask; +- field equivalence into precomputed functions; +- entity keys and indexed fields into fixed metadata. + +The minimal API marks the key in the shape itself: + +```typescript +const ItemLayout = Layout.struct({ + id: Layout.key(Layout.number), + value: Layout.number, +}) + +const ItemPlan = Layout.compile(ItemLayout) +const items = ItemPlan.make([ + { id: 1, value: 10 }, + { id: 2, value: 20 }, +]) + +items.update({ id: 2, value: 21 }) +``` + +`ItemPlan` exposes the original field metadata, compiled key field, compiled +equivalence, and collection factory. Simple struct comparison has measured +near handwritten speed, but a representative nested session-row union measured +`1.322x` the handwritten keyed-update cost after closure specialization. +Generated comparison can remove more indirection, but it is optional research, +not a requirement for this timeline experiment. + +A one-field record replacement can first compute the change mask, then write +only changed field slots. An indexed-field write knows exactly which index +buckets to remove from and add to. An entity upsert resolves directly through +its immutable key. + +The static information is therefore not “the compiler knows the type.” It is +“the runtime has already compiled the layout and the application has agreed to +obey its laws.” Effect Schema can still validate or transform data at an +admission boundary without participating in collection updates. None of this +`Layout`, model, mask, index, or columnar-storage machinery is vendored into the +TUI fork. The current landing remains the small `Keyed` abstraction plus the +route reducer; broader promotion requires separate evidence. + +## Asymptotics and Constants + +For a whole next array of `N` keyed values, both Quark `Keyed.set` and a general +keyed reconciliation are `O(N)`. Quark does not break a lower bound: it must +read the next keys to validate and order them. + +The observed win is currently a **constant-factor win at the same asymptotic +complexity**: + +- one key extraction per next item; previous slots are already indexed; +- one `Map` lookup per next item; +- one explicit equivalence check; +- one slot write per changed value; +- one outer write only for changed structure; +- no general nested proxy reconciliation inside Quark. + +For direct collection operations, stronger bounds are possible. An immutable +key lets `Keyed.update` or `Collection.upsert` find an entity in expected `O(1)` +time, after which work is proportional to changed fields and affected declared +indexes rather than total collection size. The TUI now uses event-native +`update`, `insert`, and `remove` operations for live events; full synchronization +and revert rebuilds still use linear `set`. + +## Controlled Benchmark Evidence + +The benchmarks are checked into the fork and run directly: + +```bash +cd packages/quark +bun run bench:keyed +bun run bench:row-key +``` + +Each invocation interleaves and rotates variants over nine measured samples +after a discarded warmup. It reports median nanoseconds per operation, median +absolute deviation, machine-readable metrics, and a checksum. + +The original whole-array benchmark established the initial direction: + +| Workload | Run | Quark | Solid Store | Quark / Solid | +| -------------------------------------------- | --: | -------: | ----------: | ------------: | +| One value changes in a 1,000-item next array | 1 | 177.2 us | 863.7 us | 0.207x | +| One value changes in a 1,000-item next array | 2 | 158.8 us | 755.6 us | 0.213x | +| Reorder a 1,000-item list | 1 | 155.3 us | 1,020.8 us | 0.157x | +| Reorder a 1,000-item list | 2 | 147.1 us | 975.6 us | 0.152x | + +Across these two invocations: + +- keyed value publication was approximately **4.7x-4.8x faster**; +- keyed reorder was approximately **6.4x-6.6x faster**. + +The checksum was stable, and both variants performed the same next-array +construction inside their timed workloads. + +The refreshed integration benchmark measures event-native updates with the +aggregate `values` channel subscribed, plus adverse workloads. The July 17, +2026 evidence run produced: + +| Workload | Quark | Solid | Quark / Solid | +| ----------------------------------------- | ----------: | ----------: | ------------: | +| Direct update, no subscribers, 1,000 | 46.8 ns | 761.6 ns | 0.068x | +| Precise Solid path write, 1,000 | 54.3 ns | 327.5 ns | 0.170x | +| Subscribed aggregate, 10 rows | 608.0 ns | 7.4 us | 0.085x | +| Subscribed aggregate, 100 rows | 4.4 us | 80.0 us | 0.055x | +| Subscribed aggregate, 1,000 rows | 37.4 us | 703.1 us | 0.052x | +| Subscribed aggregate, 10,000 rows | 388.5 us | 8.1 ms | 0.044x | +| Dense 1,000-row update | 270.2 us | 2.3 ms | 0.132x | +| Unstable keys, 100 rows | 68.0 us | 245.2 us | 0.282x | + +A second independent invocation produced paired ratios of `0.084x`, +`0.170x`, `0.077x`, `0.059x`, `0.061x`, `0.042x`, `0.130x`, and `0.266x` +in the same row order. The direct-path result reproduced exactly to three +decimal places; the other workloads retained the same direction and broad +magnitude despite normal timing variance. + +The aggregate path is still `O(N)`, but it remained substantially cheaper than +the equivalent Solid Store projection. The precise direct-path case was added +specifically to favor Solid: it calls `setValues(index, "value", next)` while +Quark still constructs and compares a replacement object. The predicted Solid +win did not occur in this environment; Quark measured `0.170x`. This is a +falsified prediction for this setup, not evidence that Solid can never win a +direct-write comparison. The production TUI has no aggregate subscriber; it +tracks structural slots and immutable boundary metadata instead. + +Key extraction was measured independently over 10,000 representative rows: + +| Key strategy | Median | Paired ratio to JSON | +| --------------------------------------- | ----------: | -------------------: | +| `JSON.stringify` tuple | 109.6 ns/op | 1.000x | +| Collision-safe length-prefixed string | 25.6 ns/op | 0.205x | +| Precomputed primitive row ID field read | 8.7 ns/op | 0.075x | + +The TUI now uses the precomputed primitive ID strategy. + +## What the Benchmark Does Not Prove + +The suite compares whole-array reconciliation, direct point updates, subscribed +aggregate projection, dense changes, unstable keys, and key extraction. It +does not prove that Quark beats: + +- every Solid keyed-list primitive; +- rendering, terminal layout, or paint; +- all list sizes and mutation distributions; +- a complete OpenCode session under real provider and tool traffic. + +The end-to-end `opencode-drive` runs establish behavioral parity, including +visible terminal completion and projected reasoning/text content. Their timing +varied too widely to support a speed claim because it includes provider +simulation, process scheduling, event transport, terminal rendering, and UI +polling. + +## When Quark Should Lose + +The design predicts smaller or negative gains when: + +- lists are so small that setup and adapter costs dominate; +- keys are unstable or expensive to compute; +- equivalence is more expensive than simply publishing; +- almost every item and the structure change on every operation; +- there are no subscribers, so reactive precision has no downstream value; +- Solid receives a cheaper primitive update while Quark must perform a more + expensive projection or whole-array reconciliation; +- copying the next application-level projection dominates both runtimes; +- the Quark-to-Solid adapter duplicates work rather than cutting it off. + +These are falsifiable predictions. The current adverse suite did not find a +Solid win, including the newly added precise path write, but it preserves these +cases so future changes cannot optimize only the favorable sparse path. + +## The Next Tests Must Measure Work, Not Just Time + +The strongest next experiment is a single-binary A/B implementation driven by +one deterministic event trace. It should count: + +| Deterministic transition | Slot delta | Structure delta | Observed ownership behavior | +| ------------------------------ | ---------: | --------------: | ------------------------------------------- | +| First exploration part | +0 | +1 | New group slot | +| Extend exploration group | +1 | +0 | Existing group slot retained | +| Permission repartition | +1 | +0 | Existing group slot retained | +| Insert queued user row | +0 | +1 | Group remains mounted and incomplete | +| Complete promoted-input group | +1 | +0 | Existing group slot retained | +| Duplicate text delta | +0 | +0 | Returns before aggregate materialization | +| Full unchanged two-row rebuild | +0 | +0 | Two equivalence suppressions, no publication | + +`Keyed` exposes optional counters for slot publications, structural +publications, and equivalence suppressions. The focused TUI trace +`completes exploration when a queued prompt is promoted` records the first five +transitions and asserts the same slot identity across extension, repartition, +and completion. `does not publish timeline rows for duplicate streaming +deltas` records the duplicate path. The direct Keyed law test records the full +unchanged rebuild. Boundary tests independently assert that value-only changes +do not invalidate structural boundary projection. + +## Decision Rule + +Continue the Quark timeline experiment if deterministic replay confirms all of +the following: + +1. Behavior and component ownership remain equivalent to the Solid baseline. +2. Value-only events publish no structural changes. +3. CPU or allocation cost improves on the real event distribution. +4. The Solid adapter remains a thin boundary rather than a second reactive + system doing duplicate work. +5. The reusable `Keyed` and layout laws contain the complexity; TUI code + does not grow its own reconciliation engine. + +If those conditions fail, the standalone microbenchmarks are not sufficient +reason to replace Solid Store. If they hold, the speedup is not mysterious: +Quark is faster because OpenCode supplied stronger information and Quark used +that information to execute less work. + +The current evidence satisfies ownership, structural cutoff, adapter +thinness, and containment in `Keyed`. Controlled workloads and the +deterministic TUI trace show lower CPU work for the exercised event shapes. +That is enough to continue and land the timeline experiment, not to claim a +universal framework win. Real-session soak remains the check on whether the +measured event distribution matches production usage. diff --git a/docs/design/quark-review-recommendations.md b/docs/design/quark-review-recommendations.md new file mode 100644 index 0000000000..c38bfb1c72 --- /dev/null +++ b/docs/design/quark-review-recommendations.md @@ -0,0 +1,137 @@ +# Quark Timeline Experiment: Review Recommendations + +Status: point-in-time review feedback; accepted performance and correctness findings implemented +Reviewed: `docs/design/quark-performance-model.md`, `docs/design/quark-tui-timeline.md`, `packages/quark/src/*`, `packages/tui/src/routes/session/rows.ts`, `packages/tui/src/routes/session/index.tsx` + +Goal: really fast and really beautiful. These are ordered recommendations for the next agent. Each item is independently actionable; the order reflects priority. + +This document preserves the audit as written. The current implementation has +checked benchmarks and adverse workloads, structural-only boundaries, +precomputed IDs, shared grouping policy, filtered permission repartition, +optional publication counters, deterministic counter tests, and an `O(1)` +seen-part duplicate path. Current evidence and commands live in +`quark-performance-model.md` and `quark-tui-timeline.md`; claims below describe +the pre-fix state. Optional API polish and broader model/index/layout work are +deferred to avoid expanding the timeline landing. + +## 1. Check in the microbenchmark + +`quark-performance-model.md` cites concrete numbers (177.2µs vs 863.7µs, etc.) but the benchmark source is not in the tree. Unreproducible numbers are the weakest part of an otherwise careful doc. + +- Add the benchmark under `packages/quark/bench/keyed.bench.ts` (or similar). +- Add the exact repro command to the "Controlled Benchmark Evidence" section of `quark-performance-model.md`. +- Add the adverse workloads promised in "When Quark Should Lose" (tiny lists, unstable keys, dense mutation, no subscribers, direct `setStore(index, "field", value)` on the Solid side). That section is currently a promise, not a suite. + +## 2. Benchmark the actual hot path: incremental `update` with subscribed `values` + +The checked-in benchmark story compares `Keyed.set(nextArray)` vs Solid `reconcile(nextArray)`. But after the migration, the streaming hot path is **incremental**: `state.update({...previous, refs: [...refs, ref]})` per delta (`packages/tui/src/routes/session/rows.ts`). Whole-array `set` only runs on reconnect/revert/compaction. + +Add a workload: one `update` on a group row, with `values` subscribed, at 100 / 1,000 / 10,000 rows. This measures what the TUI actually does per streaming delta. + +## 3. Fix the hidden O(N) per value change through `values` + `boundaries` + +The performance-model doc presents `values` as "lazy without subscribers," but in the real integration it is **always subscribed**: + +```ts +// packages/tui/src/routes/session/index.tsx (~line 190) +const boundaries = createMemo(() => messageBoundaryIDs([...rows.values()], messages())) +``` + +So every value-only change that survives `sameRow` pays: + +- O(N) recompute of the `values` computed (N slot reads + dependency tracking, `packages/quark/src/keyed.ts:22-25`) +- O(N) `same` compare +- O(N) array spread in the memo +- a full `messageBoundaryIDs` recompute + +The structural cutoff for `` is real, but this is the remaining per-delta O(N) path. Options, in increasing ambition: + +1. Change `messageBoundaryIDs` to accept `readonly SessionRow[]` so the spread copy dies (trivial). +2. Derive boundaries from `slots` plus per-slot reads so a group value change cannot invalidate them. +3. Maintain boundary info incrementally inside the same mutate operations that change structure. + +At minimum, the doc should state the real cost with a measured number instead of implying laziness the integration doesn't have. + +## 4. Replace `JSON.stringify` row keys with cheap concatenation + +`rowKey` calls `JSON.stringify` per row per `set` (`packages/tui/src/routes/session/rows.ts:439-445`). On a 10,000-row `set` that's 10,000 serializer calls plus allocations, working directly against the "compile repeated decisions once" thesis. + +Use a delimiter that cannot appear in IDs: + +```ts +function rowKey(row: SessionRow) { + if (row.type === "message") return `message\x00${row.messageID}` + if (row.type === "compaction-queued") return `compaction-queued\x00${row.inputID}` + if (row.type === "part") return `part\x00${row.ref.messageID}\x00${row.ref.partID}` + if (row.type === "assistant-footer") return `assistant-footer\x00${row.messageID}` + return `group\x00${row.kind}\x00${row.key.messageID}\x00${row.key.partID}` +} +``` + +Re-measure after; expect a meaningful constant-factor win on key extraction. Update the code sample in `quark-performance-model.md` to match. + +## 5. Unify the two grouping-policy engines + +There are now two implementations of the row grouping rules: + +- The batch rebuild path: `reduce()` builds a plain array via the old `append` / `completePrevious` helpers (`rows.ts:385+`), then calls `state.set`. +- The incremental path: `appendPart` / `appendMessage` / `complete` closures reimplement the same join/complete decisions inline (`rows.ts:147-215`). + +The performance-model doc's own decision rule #5 says "TUI code does not grow its own reconciliation engine" — this duplication is exactly that risk. Extract the shared decisions as pure functions, e.g. `joinsPreviousGroup(previous, part)` and `newRowFor(part, ref)`, and have both paths consume them. Do not lose the incremental path's precision; only deduplicate the policy. + +## 6. Filter before cloning in permission repartition + +The pending-permissions effect clones **every** exploration group on every change before `sameRow` suppresses the no-ops (`rows.ts:83-91`): + +```ts +state.values().forEach((row) => { + if (row.type !== "group" || row.kind !== "exploration") return + const next = { ...row, refs: [...row.refs], pending: [...row.pending] } + partitionPending([next], pending) + state.update(next) +}) +``` + +Filter to groups whose refs actually intersect the pending set before cloning. The clone-then-compare pattern spends the allocations the equivalence law was supposed to save. + +## 7. Add work counters to `Keyed` + +The strongest section of the performance model is "The Next Tests Must Measure Work, Not Just Time" — but `Keyed` has no instrumentation, so none of that table can be produced today. + +Add optional counters behind a debug flag in `packages/quark/src/keyed.ts`: + +- slot publications (value writes) +- structural publications (outer `slots` writes) +- equivalence suppressions (writes avoided by `equivalent`) + +Then run the deterministic trace via `script/quark-timeline-drive.ts` and fill in the expected-behavior table with real counts. This converts the decision rule from vibes to numbers. + +## 8. Demonstrate the "beautiful" claim with a recording + +The user-visible payoff — stable component ownership means no flicker, no reset of expanded reasoning groups, no scroll jumps during permission repartition — is asserted but never demonstrated. + +Capture a terminal-control recording of an expanded group surviving a permission repartition on this branch vs untouched `origin/v2`. That artifact is more persuasive than any benchmark table and belongs alongside it in the doc. + +## 9. Small API and code polish + +- ~~`insert(value, { before? })` where `before: undefined` means append reads poorly at call sites.~~ Fixed with the shared `Position` vocabulary: `"end"`, `{ before: key }`, or `{ after: key }`. +- The duplicate-key error in `Keyed.set` should name the offending key (`packages/quark/src/keyed.ts:32`). +- `update` mixes contracts: throws on missing key, returns `false` on no-op. That's defensible (missing key is a programmer error) but should be documented as a law. +- Doc correction in `quark-performance-model.md`, "Asymptotics and Constants": it claims "one key extraction per previous and next item," but the implementation only extracts **next** keys; previous slots are reached through the `byKey` map (`keyed.ts:30-52`). Fix the claim. + +## What is already good (do not churn) + +- The two-channel `slots` / `values` surface plus verbs is a genuinely clean API. Keep it. +- The laws section and the honest "When Quark Should Lose" / "What the Benchmark Does Not Prove" framing are correct and rare. Preserve that tone. +- The experimental boundary (only the row owner changes; `DataProvider`, reduction rules, and `SessionRowView` untouched) is right. Do not widen it while acting on these items. +- Duplicate-key preflight before any mutation (no partial updates) is correct; keep it. + +## Suggested execution order + +1. Check in benchmark + adverse workloads (items 1, 2) +2. Cheap key extraction, re-measure (item 4) +3. Unify grouping engines (item 5) +4. Counters + deterministic drive trace (item 7) +5. Decide the `values`/boundaries O(N) question with data (item 3) +6. Repartition filtering (item 6) +7. Recording artifact + doc corrections + polish (items 8, 9) diff --git a/docs/design/quark-tui-timeline.md b/docs/design/quark-tui-timeline.md new file mode 100644 index 0000000000..639ab6bb36 --- /dev/null +++ b/docs/design/quark-tui-timeline.md @@ -0,0 +1,426 @@ +# Quark-Owned TUI Timeline Rows + +Status: experiment in progress + +## Summary + +The V2 TUI currently stores its rendered session-row list in a Solid Store. +This experiment changes only that owner: `createSessionRows` stores an ordered +array of stable row slots in Quark state. Each slot owns one `SessionRow`, and +an owner-aware adapter exposes both levels to the existing Solid renderer. + +The event protocol, durable session data, `DataProvider`, row reduction rules, +and `SessionRowView` remain unchanged. This boundary makes the experiment easy +to compare and easy to remove. It does not yet move messages or secondary +indexes into Quark collections. + +The experiment succeeds only if all three checks pass: + +1. Existing TUI timeline tests preserve their behavior. +2. One `opencode-drive` script passes unchanged against untouched `origin/v2` + and this branch. +3. The Quark path improves measured timeline work without increasing visible + latency or invalidation counts. + +## The Row Owner Is the Experimental Boundary + +`DataProvider` receives server events and projects them into its existing Solid +Store. `createSessionRows` reads that projection, subscribes to timeline events, +and maintains the ordered rows consumed by `SessionRowView`. + +Before this branch, `createSessionRows` also used a Solid Store for its row +state: + +```text +server events + | + v +DataProvider Solid Store + | + v +createSessionRows + | + v +SessionRow[] Solid Store + | + v + -> SessionRowView +``` + +This branch replaces only the second store: + +```text +server events + | + v +DataProvider Solid Store + | + v +createSessionRows + | + v +Quark State + | + v +KeyedFor outer + slot adapters + | + v +SessionRowView +``` + +The narrow boundary answers one question: does Quark improve the TUI timeline +when it owns the ordered render rows? Moving message storage at the same time +would make a regression or improvement impossible to attribute. + +## Components and Responsibilities + +| Component | Responsibility | +| ------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| `packages/tui/src/context/data.tsx` | Preserve the existing server-event projection and message lookup API. | +| `packages/tui/src/routes/session/rows.ts` | Reduce loaded messages, apply incremental timeline events, and own ordered rows. | +| `packages/quark/src/reactivity.ts` | Provide synchronous state updates, subscriptions, and transaction batching. | +| `packages/quark/src/keyed.ts` | Reuse per-key slots and separate value changes from structural changes. | +| `packages/quark/src/solid.ts` | Bridge Quark readables into Solid ownership and compose stable slots with `KeyedFor`. | +| `packages/tui/src/routes/session/index.tsx` | Render stable row accessors through `KeyedFor` and the existing `SessionRowView` components. | +| `script/quark-timeline-drive.ts` | Exercise the same streamed timeline scenario against baseline and fork binaries. | + +## Quark State Publishes One Synchronous Value + +`State.make(initial)` wraps an `alien-signals` signal with three operations: + +```ts +read() // track and return the current value +read.set(next) // publish one replacement value +read.update(f) // derive and publish from the current value +``` + +`subscribe` creates an `alien-signals` effect. The effect reads the signal once +to establish its dependency without calling the listener, because `useValue` +already reads the initial value. Later signal writes rerun the effect +synchronously and call the listener once with the published value. + +Before invoking the listener, `subscribe` clears alien-signals' active +subscriber and restores it afterward. Reads performed by the listener or by a +synchronous Solid update therefore cannot become accidental dependencies of +the Quark subscription. + +Transactions call `startBatch()` and `endBatch()`. Row reconciliation can +update several slots plus the outer slot array, so the row owner uses one +transaction for the complete publication. + +## `useValue` Crosses the Reactive Boundary Once + +`useValue(readable)` creates one Solid signal initialized from `readable()`. It +then subscribes to Quark and forwards each published value into that Solid +signal. The timeline uses this algorithm for the structural slot list and each +mounted row slot. Its aggregate `values` readable remains inside Quark and is +not mirrored into Solid: + +```text +outer Quark State.set(nextSlots) + | + v +alien-signals subscription + | + v +Solid setSignal(nextSlots) + | + v +rowSlots() -> + | + v + slot Quark State.set(nextRow) + | + v + row() -> SessionRowView +``` + +The adapter passes `() => nextRows` to Solid's setter. Solid treats a bare +function as an updater, so the wrapper is required when the reactive value +itself could be a function. The same generic adapter therefore works for both +arrays and callable values. + +## Full Rebuilds Use a Deterministic Reducer + +Connection, synchronization, revert, pending-compaction, and message-boundary +changes rebuild rows from the current `DataProvider` projection. The rebuild +algorithm is: + +```text +messages = loaded messages before the revert boundary +inputs = admitted input message IDs +pending = running compaction IDs + input IDs + +ordered messages = + completed messages + + running compactions + + admitted inputs + +for each ordered message: + non-assistant -> append one message row + assistant -> append/group each non-empty content part + finished assistant -> append one footer row + +partition exploration refs by pending permission call IDs +insert queued compactions before the first pending input row +``` + +The first partition puts completed transcript rows before work that has not +crossed its safe execution boundary. A non-pending message completes the group +immediately before it. This prevents reasoning or exploration groups from +visually spanning separate transcript messages. + +Assistant text and reasoning parts use their ordinal among parts of the same +type as their stable part ID, such as `text:0` or `reasoning:1`. Tool parts use +their call ID. Empty text and reasoning parts do not create rows. + +## Incremental Events Avoid Full Reduction + +Most live events update the current row array directly: + +| Event shape | Row operation | +| --------------------------------------------------------------- | --------------------------------------------------------------- | +| New user, synthetic, shell, model, agent, or compaction message | Insert a message row unless its ID already exists. | +| First non-empty text or reasoning delta | Insert or extend the corresponding part/group row. | +| Tool input started | Insert a tool row or extend an exploration group. | +| Step ended, failed, or retry scheduled | Insert an assistant footer. | +| Step started | Remove the previous retry footer. | +| Permission changed | Repartition exploration refs between visible and pending lists. | + +`queuedStart(rows)` finds the first queued compaction or pending message. New +assistant parts and completed messages insert before that boundary. Pending +user inputs append after queued work. A running compaction inserts at the start +of the queued region. + +`append(rows, ref, part, index)` implements grouping: + +```text +reasoning part: + extend the immediately preceding reasoning group + or complete the preceding group and create a reasoning group + +read/glob/grep tool: + extend the immediately preceding exploration group + or complete the preceding group and create an exploration group + +other text or tool part: + complete the preceding group and insert a standalone part row +``` + +The grouping decision depends on the tool name, not the call ID. This matters +because call IDs can look like generated text or reasoning IDs. + +## Incremental Mutations Touch Only Affected Slots + +The old Solid `produce` path created a mutable draft of the row list. The first +Quark implementation also rebuilt a complete next array, then reconciled it. +The event-native path now uses the information already present in each event: + +```text +value change -> Keyed.update(row) +new row -> Keyed.insert(row, { before }) +removed footer -> Keyed.remove(row.id) +full sync / revert -> Keyed.set(nextRows) +``` + +Extending or completing a group creates one new group value with copied refs +and writes its existing slot. It does not copy unrelated groups or publish +structure. Permission repartitioning first checks whether a group's visible or +pending membership would actually change, then allocates arrays only for those +groups. + +Duplicate message, part, or footer events return before a Keyed operation. +Same-ordinal streaming deltas therefore produce no slot or structural +publication after the first part. + +## `Keyed` Preserves Row Identity + +A full rebuild creates fresh row values, but Solid `` uses item identity +to preserve child ownership. A changed group value must not remount its +`SessionRowView`, because that would reset the group's local expanded and hover +state. `` therefore receives stable Quark slots rather than row values. + +`Keyed.set(nextRows)` preserves ownership in four passes: + +```text +1. Index previous slots by the semantic key of their current row. +2. For each next row, find the previous slot with that key. +3. Update that slot only when the rendered row fields changed. +4. Reuse the previous outer slot array when membership and order are unchanged. +``` + +Every row receives one collision-safe length-prefixed primitive ID when it is +created. `Keyed` reads `row.id`; reconciliation performs no serialization: + +| Row | ID shape | +| ----------------- | ----------------------------------------------------- | +| Message | `m{length}:{messageID}` | +| Queued compaction | `c{length}:{inputID}` | +| Part | `p{message segment}{part segment}` | +| Assistant footer | `f{length}:{messageID}` | +| Group | `g{kind}{origin message segment}{origin part segment}` | + +Every group records the ref that created it as immutable `origin` metadata. +Appending refs, completing the group, and moving refs between permission +partitions cannot change its ID or boundary identity. Group equivalence compares +`completed`, every `refs` entry, and every exploration `pending` entry. + +`Keyed` publishes two readables. `slots` changes only when key membership or +order changes, so Solid `` does no structural work for a group-value +update. `values` depends on both the outer list and every slot for consumers +that require aggregate values. The TUI has no reactive `values` subscriber; it +uses the lazy aggregate only as an internal snapshot for unique mutation paths. +Message boundaries track structural slots and messages, then read immutable +row identity fields untracked. A changed group publishes through its existing +slot and preserves the mounted Solid owner without recalculating boundaries. + +Slot updates and the outer-array update run inside both a Quark transaction and +a Solid batch. The Quark transaction settles the alien-signals graph; the +surrounding Solid batch holds adapter writes until every Quark listener has +flushed. Solid therefore observes one settled state rather than a row value +from one ordering and an outer slot array from another. + +## Complexity Is Linear in Rows and Contained Refs + +Let `R` be visible rows, `M` loaded messages, `P` assistant content parts, and +`C` the total refs contained by visible groups. + +| Operation | Time | Allocation | +| -------------------------- | -----------------------: | ----------------------------------------------: | +| Full rebuild | `O(M + P + R)` | New reduction plus reconciled slot array | +| Unique incremental append | `O(R + C)` | One structural array or one changed group array | +| Duplicate part append | `O(1)` | None | +| Duplicate message/footer | `O(1)` | None | +| Footer removal | `O(R)` | One structural slot array | +| Permission repartition | `O(R + C)` | Arrays only for groups whose split moves | +| Quark-to-Solid publication | `O(1)` per changed level | One slot write and, when required, outer write | + +`Keyed.has` handles message and footer membership through the collection's +existing key map. A route-scoped `Set` contains every visible part ID, including +refs nested inside groups. Full rebuilds repopulate it and unique appends add to +it once. Duplicate streaming deltas therefore return before reading the lazy +aggregate or scanning rows. Unique appends still materialize the current row +snapshot and scan for the queued boundary; that lower-frequency path remains +linear deliberately rather than introducing a secondary-index layer. + +## Solid Owns the Adapter Lifecycle + +`createSessionRows` runs inside the Solid owner for the session route. +`useValue` reads the initial Quark value, subscribes once, and registers the +unsubscribe function with Solid's `onCleanup`. + +The existing event-bus subscriptions use the same owner cleanup. Leaving the +route therefore removes both the Quark-to-Solid subscription and all timeline +event subscriptions. Quark state does not outlive the route. + +## Behavior Must Remain Equivalent + +The experiment preserves these existing timeline rules: + +- User, synthetic, compaction, shell, model, and agent messages retain their + current ordering. +- Pending inputs and queued compactions remain after active output. +- Reasoning and exploration parts retain their grouping behavior. +- Exploration tool calls move between pending and completed partitions when + permissions change. +- Retry footers appear and disappear on the same events. +- Duplicate message, part, and footer events remain idempotent. +- Revert boundaries and message synchronization still rebuild the complete row + list from `DataProvider`. +- `SessionRowView` continues to resolve message content from `DataProvider`; + Quark owns row structure, not message content. + +Changing `createSessionRows` from an array-like Solid Store to structural +`slots` plus an internal lazy aggregate is an internal API change. The session +route and its data-test probes are the only consumers on this branch. + +## The Comparison Uses One Drive Script + +The baseline is a detached worktree at +`/Users/kit/code/open-source/opencode-quark-baseline`, pinned to the same +`origin/v2` commit as the fork. The fork is +`/Users/kit/code/open-source/opencode-quark-timeline`. + +`script/quark-timeline-drive.ts` creates one isolated project, submits one +prompt, streams one long reasoning span and one long text span as many delta +events, and waits for the final `QUARK_TIMELINE_COMPLETE` marker. The script +prints elapsed wall time and uses marker visibility as its rendered correctness +assertion. This workload stresses duplicate-part handling after the first +delta; it does not create 120 distinct timeline rows. + +After the marker renders, the script queries the same isolated service and +asserts that the projected assistant message contains the complete reasoning +span, complete text span, and `stop` finish. The API assertion covers content +that has scrolled outside the terminal viewport. + +Run the same checked script against each worktree: + +```sh +cd /Users/kit/code/open-source/opencode-drive + +packages/drive/bin/opencode-drive check \ + /Users/kit/code/open-source/opencode-quark-timeline/script/quark-timeline-drive.ts + +packages/drive/bin/opencode-drive start \ + --name quark-timeline-baseline \ + --script /Users/kit/code/open-source/opencode-quark-timeline/script/quark-timeline-drive.ts \ + --dev /Users/kit/code/open-source/opencode-quark-baseline + +packages/drive/bin/opencode-drive start \ + --name quark-timeline-fork \ + --script /Users/kit/code/open-source/opencode-quark-timeline/script/quark-timeline-drive.ts \ + --dev /Users/kit/code/open-source/opencode-quark-timeline +``` + +Wall time from one end-to-end drive run is supporting evidence, not a stable +microbenchmark. Provider simulation, terminal polling, and process scheduling +also contribute to it. A performance decision requires repeated paired runs +and row-update or invalidation counters in addition to this behavioral check. + +Three alternating smoke pairs completed successfully: + +| Pair | Baseline | Quark fork | Fork / baseline | +| ---------------------: | --------: | ---------: | --------------: | +| 1 | 11,505 ms | 6,455 ms | 0.561x | +| 2 | 4,003 ms | 4,733 ms | 1.182x | +| 3, fork first | 9,296 ms | 7,563 ms | 0.813x | +| 4, API assertions | 4,283 ms | 3,114 ms | 0.727x | +| 5, current `origin/v2` | 3,636 ms | 2,074 ms | 0.570x | +| 6, current Drive API | 2,233 ms | 1,413 ms | 0.633x | + +The range is too wide to support a speed claim. These runs establish that the +same streamed scenario renders and terminates on both implementations. They do +not establish that either implementation is faster. + +## Validation Status + +| Check | Current result | +| ------------------------------------ | -------------------------------------------------------------------------- | +| TUI package typecheck | Pass | +| Full TUI test suite | 267 pass, 1 skip | +| Focused data and row tests | 48 pass | +| Vendored Quark tests | 13 pass | +| Shared drive scripts typecheck | Pass | +| Shared Drive behavior | Six baseline/fork pairs pass; latest pair verifies projected content | +| Publication-counter trace | Pass; exact slot/structure deltas recorded in performance model | +| Paired performance evidence | Pass for controlled workloads; end-to-end wall time remains non-conclusive | + +## Collection Cache Growth Is Outside This Phase + +The standalone `effect-quark` prototype includes reactive missing-key and +secondary-index query caches without eviction. This branch vendors only Quark +state and the Solid adapter; it does not create those caches. + +A later message-collection phase must not delete empty cache entries while a +live readable still references them. Safe eviction therefore needs explicit +collection ownership or subscription reference counting. That design is not a +prerequisite for the route-scoped row-state experiment. + +## Rollback Is One Seam + +Rollback removes the private `effect-quark` workspace package and restores the +Solid Store in `createSessionRows`. No protocol, persisted data, public client +API, or server projection changes need migration. + +The experiment should not expand into `DataProvider` message storage until the +current boundary has behavior parity and repeatable performance evidence. diff --git a/packages/quark/bench/harness.ts b/packages/quark/bench/harness.ts new file mode 100644 index 0000000000..4b3e4fb5e2 --- /dev/null +++ b/packages/quark/bench/harness.ts @@ -0,0 +1,60 @@ +export type Workload = { + run(index: number): void + consume(): number + dispose?(): void +} + +export type Variant = { + readonly name: string + readonly make: () => Workload +} + +export function createHarness(options: { readonly samples?: number; readonly warmup?: number } = {}) { + const samples = options.samples ?? 9 + const warmup = options.warmup ?? 500 + let checksum = 0 + + return { + samples, + compare(iterations: number, variants: readonly Variant[]) { + const timings = variants.map(() => [] as number[]) + for (let sample = -1; sample < samples; sample++) { + const offset = sample < 0 ? 0 : sample % variants.length + variants + .map((_variant, index) => (index + offset) % variants.length) + .forEach((variantIndex) => { + const workload = variants[variantIndex].make() + for (let index = 0; index < Math.min(iterations, warmup); index++) workload.run(index) + const start = Bun.nanoseconds() + for (let index = 0; index < iterations; index++) workload.run(index) + const elapsed = Bun.nanoseconds() - start + checksum += workload.consume() + workload.dispose?.() + if (sample >= 0) timings[variantIndex].push(elapsed / iterations) + }) + } + + const medians = variants.map((variant, index) => { + const median = middle(timings[index]) + const mad = middle(timings[index].map((value) => Math.abs(value - median))) + const metric = variant.name.toLowerCase().replaceAll(/[^a-z0-9]+/g, "_") + console.log(`${variant.name.padEnd(42)} ${median.toFixed(1).padStart(10)} ns/op +/- ${mad.toFixed(1)} MAD`) + console.log(`METRIC ${metric}_ns_per_op=${median.toFixed(3)}`) + return median + }) + return { + medians, + ratio(left: number, right: number) { + return middle(timings[left].map((value, index) => value / timings[right][index])) + }, + } + }, + finish() { + console.log(`CHECKSUM ${checksum}`) + }, + } +} + +function middle(values: number[]) { + return values.toSorted((a, b) => a - b)[Math.floor(values.length / 2)] +} diff --git a/packages/quark/bench/keyed.ts b/packages/quark/bench/keyed.ts new file mode 100644 index 0000000000..3c0628ee73 --- /dev/null +++ b/packages/quark/bench/keyed.ts @@ -0,0 +1,217 @@ +import { createComputed, createRoot } from "solid-js" +import { createStore, reconcile } from "solid-js/store" +import { Keyed } from "../src" +import { createHarness, type Workload } from "./harness" + +type Item = { + readonly id: number + readonly value: number +} + +const bench = createHarness() +const results: Array<{ readonly name: string; readonly ratio: number }> = [] + +function initial(size: number) { + return Array.from({ length: size }, (_, id): Item => ({ id, value: 0 })) +} + +function project(values: readonly Item[]) { + return values.reduce((total, value) => total + value.id + value.value, 0) +} + +function quarkDirect(size: number, aggregate: boolean): Workload { + const values = initial(size) + const keyed = Keyed.make({ + key: (item) => item.id, + equivalent: (left, right) => left.value === right.value, + }) + keyed.set(values) + const target = keyed.slots()[Math.floor(size / 2)] + let sink = aggregate ? project(keyed.values()) : target().value + const dispose = aggregate + ? keyed.values.subscribe((next) => (sink = project(next))) + : target.subscribe((value) => (sink = value.value)) + return { + run: (index) => keyed.update({ id: Math.floor(size / 2), value: index + 1 }), + consume: () => sink, + dispose, + } +} + +function solidDirect(size: number, aggregate: boolean): Workload { + let run = (_index: number) => {} + let consume = () => 0 + let dispose = () => {} + createRoot((rootDispose) => { + dispose = rootDispose + const [values, setValues] = createStore(initial(size)) + const target = Math.floor(size / 2) + let sink = aggregate ? project(values) : values[target].value + if (aggregate) createComputed(() => (sink = project(values))) + else createComputed(() => (sink = values[target].value)) + run = (index) => setValues(target, reconcile({ id: target, value: index + 1 })) + consume = () => sink + }) + return { run, consume, dispose } +} + +function quarkNoSubscriber(size: number): Workload { + const keyed = Keyed.make({ + key: (item) => item.id, + equivalent: (left, right) => left.value === right.value, + }) + keyed.set(initial(size)) + const target = Math.floor(size / 2) + return { + run: (index) => keyed.update({ id: target, value: index + 1 }), + consume: () => keyed.slots()[target]().value, + } +} + +function solidNoSubscriber(size: number): Workload { + const [values, setValues] = createStore(initial(size)) + const target = Math.floor(size / 2) + return { + run: (index) => setValues(target, reconcile({ id: target, value: index + 1 })), + consume: () => values[target].value, + } +} + +function solidPathWriteNoSubscriber(size: number): Workload { + const [values, setValues] = createStore(initial(size)) + const target = Math.floor(size / 2) + return { + run: (index) => setValues(target, "value", index + 1), + consume: () => values[target].value, + } +} + +function quarkDense(size: number): Workload { + const keyed = Keyed.make({ + key: (item) => item.id, + equivalent: (left, right) => left.value === right.value, + }) + keyed.set(initial(size)) + let sink = project(keyed.values()) + const dispose = keyed.values.subscribe((values) => (sink = project(values))) + return { + run: (index) => keyed.set(initial(size).map((item) => ({ ...item, value: index + 1 }))), + consume: () => sink, + dispose, + } +} + +function solidDense(size: number): Workload { + let run = (_index: number) => {} + let consume = () => 0 + let dispose = () => {} + createRoot((rootDispose) => { + dispose = rootDispose + const [values, setValues] = createStore(initial(size)) + let sink = project(values) + createComputed(() => (sink = project(values))) + run = (index) => setValues(reconcile(initial(size).map((item) => ({ ...item, value: index + 1 })))) + consume = () => sink + }) + return { run, consume, dispose } +} + +function quarkUnstable(size: number): Workload { + const keyed = Keyed.make({ key: (item) => item.id }) + keyed.set(initial(size)) + let sink = project(keyed.values()) + const dispose = keyed.values.subscribe((values) => (sink = project(values))) + return { + run(index) { + const offset = (index + 1) * size + keyed.set(initial(size).map((item) => ({ id: item.id + offset, value: index }))) + }, + consume: () => sink, + dispose, + } +} + +function solidUnstable(size: number): Workload { + let run = (_index: number) => {} + let consume = () => 0 + let dispose = () => {} + createRoot((rootDispose) => { + dispose = rootDispose + const [values, setValues] = createStore(initial(size)) + let sink = project(values) + createComputed(() => (sink = project(values))) + run = (index) => { + const offset = (index + 1) * size + setValues(reconcile(initial(size).map((item) => ({ id: item.id + offset, value: index })))) + } + consume = () => sink + }) + return { run, consume, dispose } +} + +function compare(name: string, iterations: number, quark: () => Workload, solid: () => Workload) { + console.log(`\n${name}`) + const result = bench.compare(iterations, [ + { name: `Quark ${name}`, make: quark }, + { name: `Solid ${name}`, make: solid }, + ]) + results.push({ name, ratio: result.ratio(0, 1) }) +} + +console.log(`Keyed integration benchmark (${bench.samples} samples)`) + +compare( + "direct no subscribers 1000", + 200_000, + () => quarkNoSubscriber(1_000), + () => solidNoSubscriber(1_000), +) +compare( + "adversarial direct path write 1000", + 200_000, + () => quarkNoSubscriber(1_000), + () => solidPathWriteNoSubscriber(1_000), +) +compare( + "subscribed values 10", + 100_000, + () => quarkDirect(10, true), + () => solidDirect(10, true), +) +compare( + "subscribed values 100", + 25_000, + () => quarkDirect(100, true), + () => solidDirect(100, true), +) +compare( + "subscribed values 1000", + 2_500, + () => quarkDirect(1_000, true), + () => solidDirect(1_000, true), +) +compare( + "subscribed values 10000", + 250, + () => quarkDirect(10_000, true), + () => solidDirect(10_000, true), +) +compare( + "dense update 1000", + 250, + () => quarkDense(1_000), + () => solidDense(1_000), +) +compare( + "unstable keys 100", + 1_000, + () => quarkUnstable(100), + () => solidUnstable(100), +) + +console.log("\nRatios to Solid (lower is faster)") +results.forEach((result) => { + console.log(`${result.name.padEnd(34)} ${result.ratio.toFixed(3)}x`) + console.log(`METRIC ${result.name.replaceAll(/[^a-z0-9]+/g, "_")}_ratio=${result.ratio.toFixed(6)}`) +}) +bench.finish() diff --git a/packages/quark/bench/row-key.ts b/packages/quark/bench/row-key.ts new file mode 100644 index 0000000000..0e9a76371f --- /dev/null +++ b/packages/quark/bench/row-key.ts @@ -0,0 +1,63 @@ +import { createHarness, type Workload } from "./harness" + +type Row = + | { readonly type: "message"; readonly messageID: string } + | { readonly type: "part"; readonly messageID: string; readonly partID: string } + | { + readonly type: "group" + readonly kind: "reasoning" | "exploration" + readonly messageID: string + readonly partID: string + } + +const size = 10_000 +const iterations = 1_000_000 +const rows = Array.from({ length: size }, (_, index): Row => { + if (index % 3 === 0) return { type: "message", messageID: `message-${index}` } + if (index % 3 === 1) return { type: "part", messageID: `message-${index >> 2}`, partID: `text:${index}` } + return { + type: "group", + kind: index % 2 === 0 ? "reasoning" : "exploration", + messageID: `message-${index >> 2}`, + partID: `call-${index}`, + } +}) +const precomputed = rows.map((row) => ({ row, id: concatenate(row) })) +const bench = createHarness() + +function workload(read: (index: number) => string): Workload { + let sink = 0 + return { + run(index) { + sink += read(index % size).length + }, + consume: () => sink, + } +} + +function json(row: Row) { + if (row.type === "message") return JSON.stringify([row.type, row.messageID]) + if (row.type === "part") return JSON.stringify([row.type, row.messageID, row.partID]) + return JSON.stringify([row.type, row.kind, row.messageID, row.partID]) +} + +function concatenate(row: Row) { + if (row.type === "message") return `m${row.messageID.length}:${row.messageID}` + if (row.type === "part") return `p${row.messageID.length}:${row.messageID}${row.partID.length}:${row.partID}` + return `g${row.kind === "reasoning" ? "r" : "e"}${row.messageID.length}:${row.messageID}${row.partID.length}:${row.partID}` +} + +console.log(`Session row key benchmark (${size.toLocaleString()} rows, ${bench.samples} samples)\n`) + +const result = bench.compare(iterations, [ + { name: "JSON tuple key", make: () => workload((index) => json(rows[index])) }, + { name: "Concatenated key", make: () => workload((index) => concatenate(rows[index])) }, + { name: "Precomputed key", make: () => workload((index) => precomputed[index].id) }, +]) + +console.log("\nRatios to JSON tuple (lower is faster)") +console.log(`Concatenated: ${result.ratio(1, 0).toFixed(3)}x`) +console.log(`Precomputed: ${result.ratio(2, 0).toFixed(3)}x`) +console.log(`METRIC concatenated_key_ratio=${result.ratio(1, 0).toFixed(6)}`) +console.log(`METRIC precomputed_key_ratio=${result.ratio(2, 0).toFixed(6)}`) +bench.finish() diff --git a/packages/quark/package.json b/packages/quark/package.json new file mode 100644 index 0000000000..1548fcbb53 --- /dev/null +++ b/packages/quark/package.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "effect-quark", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./solid": "./src/solid.ts" + }, + "scripts": { + "bench:keyed": "bun --conditions=browser bench/keyed.ts", + "bench:row-key": "bun --conditions=browser bench/row-key.ts", + "test": "bun --conditions=browser test" + }, + "dependencies": { + "alien-signals": "3.2.1", + "solid-js": "catalog:" + } +} diff --git a/packages/quark/src/index.ts b/packages/quark/src/index.ts new file mode 100644 index 0000000000..89b79d8c6e --- /dev/null +++ b/packages/quark/src/index.ts @@ -0,0 +1,2 @@ +export { Keyed } from "./keyed" +export { Computed, State, Transaction, type Readable, type Writable } from "./reactivity" diff --git a/packages/quark/src/keyed.ts b/packages/quark/src/keyed.ts new file mode 100644 index 0000000000..dff27b17d4 --- /dev/null +++ b/packages/quark/src/keyed.ts @@ -0,0 +1,143 @@ +import { Computed, State, Transaction, type Readable, type Writable } from "./reactivity" + +export namespace Keyed { + export type Position = "end" | { readonly before: Key } | { readonly after: Key } + + export interface Metrics { + slotPublications: number + structuralPublications: number + equivalenceSuppressions: number + } + + export interface Keyed { + readonly slots: Readable[]> + readonly values: Readable + has(key: Key): boolean + get(key: Key): Readable | undefined + set(values: readonly A[]): void + update(value: A): boolean + insert(value: A, position?: Position): Readable + remove(key: Key): boolean + move(key: Key, position?: Position): boolean + } + + export function make(options: { + readonly key: (value: A) => Key + readonly equivalent?: (left: A, right: A) => boolean + readonly metrics?: Metrics + }): Keyed { + const slots = State.make[]>([]) + const byKey = new Map>() + const equivalent = options.equivalent ?? Object.is + const values = Computed.make((previous) => { + const next = slots().map((slot) => slot()) + return same(previous, next) ? previous! : next + }) + + return { + slots, + values, + has: (key) => byKey.has(key), + get: (key) => byKey.get(key), + set(next) { + const keys = next.map(options.key) + const retained = new Set(keys) + if (retained.size !== keys.length) throw new Error("Keyed values must have unique keys") + + Transaction.run(() => { + const previous = slots() + const reconciled = next.map((value, index) => { + const key = keys[index] + const slot = byKey.get(key) + if (!slot) { + const created = State.make(value) + byKey.set(key, created) + return created + } + if (!equivalent(slot(), value)) { + slot.set(value) + if (options.metrics) options.metrics.slotPublications++ + } else if (options.metrics) { + options.metrics.equivalenceSuppressions++ + } + return slot + }) + byKey.forEach((_slot, key) => { + if (!retained.has(key)) byKey.delete(key) + }) + if (!same(previous, reconciled)) { + slots.set(reconciled) + if (options.metrics) options.metrics.structuralPublications++ + } + }) + }, + update(value) { + const key = options.key(value) + const slot = byKey.get(key) + if (!slot) throw new Error(`Keyed value does not exist: ${String(key)}`) + if (equivalent(slot(), value)) { + if (options.metrics) options.metrics.equivalenceSuppressions++ + return false + } + slot.set(value) + if (options.metrics) options.metrics.slotPublications++ + return true + }, + insert(value, position) { + const key = options.key(value) + if (byKey.has(key)) throw new Error(`Keyed value already exists: ${String(key)}`) + const current = slots() + const index = positionIndex(current, position) + const slot = State.make(value) + Transaction.run(() => { + byKey.set(key, slot) + slots.set(current.toSpliced(index, 0, slot)) + if (options.metrics) options.metrics.structuralPublications++ + }) + return slot + }, + remove(key) { + const slot = byKey.get(key) + if (!slot) return false + Transaction.run(() => { + byKey.delete(key) + slots.set(slots().filter((candidate) => candidate !== slot)) + if (options.metrics) options.metrics.structuralPublications++ + }) + return true + }, + move(key, position) { + const slot = byKey.get(key) + if (!slot) throw new Error(`Keyed value does not exist: ${String(key)}`) + const current = slots() + const from = current.indexOf(slot) + const target = positionIndex(current, position) + const to = from < target ? target - 1 : target + if (from === to) return false + slots.set(current.toSpliced(from, 1).toSpliced(to, 0, slot)) + if (options.metrics) options.metrics.structuralPublications++ + return true + }, + } + + function positionIndex(current: readonly Writable[], position?: Position) { + if (position === undefined || position === "end") return current.length + if ("before" in position) return indexOf(current, position.before) + return indexOf(current, position.after) + 1 + } + + function indexOf(current: readonly Writable[], key: Key) { + const target = byKey.get(key) + if (!target) throw new Error(`Keyed value does not exist: ${String(key)}`) + return current.indexOf(target) + } + } + + export function metrics(): Metrics { + return { slotPublications: 0, structuralPublications: 0, equivalenceSuppressions: 0 } + } + + function same(left: readonly A[] | undefined, right: readonly A[]) { + return left?.length === right.length && left.every((value, index) => Object.is(value, right[index])) + } +} diff --git a/packages/quark/src/reactivity.ts b/packages/quark/src/reactivity.ts new file mode 100644 index 0000000000..4cb290d2bd --- /dev/null +++ b/packages/quark/src/reactivity.ts @@ -0,0 +1,59 @@ +import { computed, effect, endBatch, setActiveSub, signal, startBatch } from "alien-signals" + +export interface Readable { + (): A + subscribe(listener: (value: A) => void): () => void +} + +export interface Writable extends Readable { + set(value: A): void + update(f: (value: A) => A): void +} + +function subscribe(read: () => A, listener: (value: A) => void) { + let initialized = false + return effect(() => { + const value = read() + if (!initialized) { + initialized = true + return + } + const active = setActiveSub() + try { + listener(value) + } finally { + setActiveSub(active) + } + }) +} + +export namespace State { + export function make(initial: A): Writable { + const state = signal(initial) + const read = (() => state()) as Writable + read.set = (value) => state(value) + read.update = (f) => state(f(state())) + read.subscribe = (listener) => subscribe(read, listener) + return read + } +} + +export namespace Computed { + export function make(evaluate: (previous: A | undefined) => A): Readable { + const value = computed(evaluate) + const read = (() => value()) as Readable + read.subscribe = (listener) => subscribe(read, listener) + return read + } +} + +export namespace Transaction { + export function run(f: () => A): A { + startBatch() + try { + return f() + } finally { + endBatch() + } + } +} diff --git a/packages/quark/src/solid.ts b/packages/quark/src/solid.ts new file mode 100644 index 0000000000..2ca1ecf7bd --- /dev/null +++ b/packages/quark/src/solid.ts @@ -0,0 +1,22 @@ +import { For, from, type Accessor, type JSX } from "solid-js" +import type { Readable } from "./reactivity" + +export function useValue(readable: Readable): Accessor { + return from(readable, readable()) +} + +export function KeyedFor(props: { + readonly each: Accessor[]> + readonly fallback?: JSX.Element + readonly children: (value: Accessor, index: Accessor) => JSX.Element +}) { + return For({ + get each() { + return props.each() + }, + get fallback() { + return props.fallback + }, + children: (slot, index) => props.children(useValue(slot), index), + }) +} diff --git a/packages/quark/test/keyed.test.ts b/packages/quark/test/keyed.test.ts new file mode 100644 index 0000000000..64f9ec23b6 --- /dev/null +++ b/packages/quark/test/keyed.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from "bun:test" +import { Computed, Keyed } from "../src" + +type Item = { + readonly id: number + readonly label: string +} + +const item = (id: number, label: string): Item => ({ id, label }) + +describe("Keyed", () => { + it("keeps slots stable while publishing value and structural changes separately", () => { + const keyed = Keyed.make({ key: (value) => value.id }) + keyed.set([item(1, "one"), item(2, "two")]) + const [one, two] = keyed.slots() + const structure = keyed.slots() + const structures: number[][] = [] + const values: Item[][] = [] + const disposeSlots = keyed.slots.subscribe((slots) => structures.push(slots.map((slot) => slot().id))) + const disposeValues = keyed.values.subscribe((next) => values.push([...next])) + + keyed.set([item(1, "ONE"), item(2, "TWO")]) + + expect(keyed.slots()).toBe(structure) + expect(keyed.slots()).toEqual([one, two]) + expect(values).toEqual([[item(1, "ONE"), item(2, "TWO")]]) + expect(structures).toEqual([]) + + keyed.set([item(2, "TWO"), item(1, "ONE")]) + + expect(keyed.slots()).toEqual([two, one]) + expect(structures).toEqual([[2, 1]]) + disposeSlots() + disposeValues() + }) + + it("uses custom equivalence to cut off slot and aggregate updates", () => { + const keyed = Keyed.make({ + key: (value) => value.id, + equivalent: (left, right) => left.label.toLowerCase() === right.label.toLowerCase(), + }) + const original = item(1, "one") + keyed.set([original]) + const slot = keyed.slots()[0] + const aggregate = keyed.values() + + keyed.set([item(1, "ONE")]) + + expect(slot()).toBe(original) + expect(keyed.values()).toBe(aggregate) + }) + + it("creates a fresh slot after removal and reinsertion", () => { + const keyed = Keyed.make({ key: (value) => value.id }) + keyed.set([item(1, "one"), item(2, "two")]) + const [removed, retained] = keyed.slots() + + keyed.set([item(2, "two")]) + keyed.set([item(1, "new"), item(2, "TWO")]) + + expect(keyed.slots()[0]).not.toBe(removed) + expect(keyed.slots()[1]).toBe(retained) + expect(retained()).toEqual(item(2, "TWO")) + }) + + it("rejects duplicate keys without partially updating", () => { + const keyed = Keyed.make({ key: (value) => value.id }) + keyed.set([item(1, "one"), item(2, "two")]) + const slots = keyed.slots() + const values = keyed.values() + + expect(() => keyed.set([item(1, "changed"), item(1, "duplicate")])).toThrow("Keyed values must have unique keys") + expect(keyed.slots()).toBe(slots) + expect(keyed.values()).toBe(values) + }) + + it("uses SameValueZero key equality", () => { + const keyed = Keyed.make({ key: (value) => value.id }) + keyed.set([item(-0, "zero"), item(Number.NaN, "nan")]) + const [zero, nan] = keyed.slots() + + keyed.set([item(0, "ZERO"), item(Number.NaN, "NAN")]) + + expect(keyed.slots()).toEqual([zero, nan]) + expect(() => keyed.set([item(0, "zero"), item(-0, "duplicate")])).toThrow("Keyed values must have unique keys") + expect(() => keyed.set([item(Number.NaN, "nan"), item(Number.NaN, "duplicate")])).toThrow( + "Keyed values must have unique keys", + ) + }) + + it("publishes one settled aggregate when values and structure change together", () => { + const keyed = Keyed.make({ key: (value) => value.id }) + keyed.set([item(1, "one"), item(2, "two")]) + const observations: string[] = [] + const summary = Computed.make(() => { + const slots = keyed + .slots() + .map((slot) => `${slot().id}:${slot().label}`) + .join(",") + const values = keyed + .values() + .map((value) => `${value.id}:${value.label}`) + .join(",") + return `${slots}|${values}` + }) + const dispose = summary.subscribe((value) => observations.push(value)) + + keyed.set([item(2, "TWO"), item(3, "three")]) + + expect(observations).toEqual(["2:TWO,3:three|2:TWO,3:three"]) + dispose() + }) + + it("updates one existing slot without publishing structure", () => { + const keyed = Keyed.make({ key: (value) => value.id }) + keyed.set([item(1, "one"), item(2, "two")]) + const structure = keyed.slots() + const two = keyed.slots()[1] + const structures: Array = [] + const dispose = keyed.slots.subscribe((slots) => structures.push(slots)) + + const updated = item(2, "TWO") + expect(keyed.update(updated)).toBe(true) + expect(keyed.update(updated)).toBe(false) + + expect(keyed.slots()).toBe(structure) + expect(keyed.slots()[1]).toBe(two) + expect(two()).toEqual(item(2, "TWO")) + expect(structures).toEqual([]) + expect(() => keyed.update(item(3, "three"))).toThrow("Keyed value does not exist: 3") + dispose() + }) + + it("checks key membership without reading the aggregate", () => { + const keyed = Keyed.make({ key: (value) => value.id }) + keyed.set([item(1, "one")]) + + expect(keyed.has(1)).toBe(true) + expect(keyed.has(2)).toBe(false) + expect(keyed.get(1)).toBe(keyed.slots()[0]) + expect(keyed.get(2)).toBeUndefined() + keyed.remove(1) + expect(keyed.has(1)).toBe(false) + expect(keyed.get(1)).toBeUndefined() + }) + + it("inserts, removes, and moves stable slots", () => { + const keyed = Keyed.make({ key: (value) => value.id }) + keyed.set([item(1, "one"), item(3, "three")]) + const one = keyed.slots()[0] + const three = keyed.slots()[1] + + const two = keyed.insert(item(2, "two"), { before: 3 }) + expect(keyed.slots()).toEqual([one, two, three]) + const four = keyed.insert(item(4, "four"), { after: 3 }) + expect(keyed.slots()).toEqual([one, two, three, four]) + expect(keyed.move(3, { before: 1 })).toBe(true) + expect(keyed.slots()).toEqual([three, one, two, four]) + expect(keyed.move(3, { before: 1 })).toBe(false) + expect(keyed.move(3, { after: 2 })).toBe(true) + expect(keyed.slots()).toEqual([one, two, three, four]) + expect(keyed.move(3, "end")).toBe(true) + expect(keyed.slots()).toEqual([one, two, four, three]) + expect(keyed.remove(2)).toBe(true) + expect(keyed.remove(2)).toBe(false) + expect(keyed.slots()).toEqual([one, four, three]) + }) + + it("counts publications and equivalence suppressions when instrumented", () => { + const metrics = Keyed.metrics() + const keyed = Keyed.make({ key: (value) => value.id, metrics }) + + keyed.set([item(1, "one")]) + keyed.update(item(1, "ONE")) + const current = keyed.slots()[0]() + keyed.update(current) + keyed.insert(item(2, "two")) + keyed.move(2, { before: 1 }) + keyed.remove(2) + + expect(metrics).toEqual({ + slotPublications: 1, + structuralPublications: 4, + equivalenceSuppressions: 1, + }) + }) + + it("publishes nothing for a full unchanged rebuild", () => { + const metrics = Keyed.metrics() + const keyed = Keyed.make({ key: (value) => value.id, metrics }) + const values = [item(1, "one"), item(2, "two")] + keyed.set(values) + const before = { ...metrics } + + keyed.set(values) + + expect(metrics.slotPublications).toBe(before.slotPublications) + expect(metrics.structuralPublications).toBe(before.structuralPublications) + expect(metrics.equivalenceSuppressions).toBe(before.equivalenceSuppressions + values.length) + }) +}) diff --git a/packages/quark/test/reactivity.test.ts b/packages/quark/test/reactivity.test.ts new file mode 100644 index 0000000000..66c1f479bc --- /dev/null +++ b/packages/quark/test/reactivity.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "bun:test" +import { createEffect, createRoot } from "solid-js" +import { State } from "../src" +import { useValue } from "../src/solid" + +describe("Quark", () => { + it("does not track state read by subscription listeners", () => { + const source = State.make(1) + const unrelated = State.make(1) + const values: number[] = [] + const dispose = source.subscribe((value) => { + unrelated() + values.push(value) + }) + + source.set(2) + unrelated.set(2) + + expect(values).toEqual([2]) + dispose() + }) + + it("bridges values into a Solid owner and disposes with it", () => { + const source = State.make(1) + const values: number[] = [] + let dispose = () => {} + createRoot((rootDispose) => { + dispose = rootDispose + const value = useValue(source) + createEffect(() => values.push(value())) + }) + + source.set(2) + dispose() + source.set(3) + + expect(values).toEqual([1, 2]) + }) +}) diff --git a/packages/tui/package.json b/packages/tui/package.json index 8ced93ff1f..7ea5fbef68 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -92,6 +92,7 @@ "clipboardy": "4.0.0", "diff": "catalog:", "effect": "catalog:", + "effect-quark": "workspace:*", "fuzzysort": "catalog:", "get-east-asian-width": "catalog:", "open": "10.1.2", diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index d2f3543c0e..f6b1e2ecc6 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -13,6 +13,7 @@ import { Switch, useContext, } from "solid-js" +import { KeyedFor } from "effect-quark/solid" import path from "node:path" import { EOL, tmpdir } from "node:os" import { mkdir, writeFile } from "node:fs/promises" @@ -213,7 +214,7 @@ export function Session() { }) const editor = useEditorContext() const rows = createSessionRows(() => route.sessionID) - const boundaries = createMemo(() => messageBoundaryIDs(rows, messages())) + const boundaries = createMemo(() => messageBoundaryIDs(rows.slots().map((slot) => slot()), messages())) const [navigationMessage, setNavigationMessage] = createSignal() const [navigationSlack, setNavigationSlack] = createSignal(0) @@ -928,15 +929,15 @@ export function Session() { flexGrow={1} scrollAcceleration={scrollAcceleration()} > - + {(row, index) => ( data.session.message.get(route.sessionID, messageID)} boundaryID={boundaries()[index()]} /> )} - + ) { +export function createSessionRows(sessionID: Accessor, options?: { readonly metrics?: Keyed.Metrics }) { const data = useData() const client = useClient() - const [rows, setRows] = createStore([]) + const reportMetrics = process.env.OPENCODE_QUARK_METRICS === "1" + const metrics = options?.metrics ?? (reportMetrics ? Keyed.metrics() : undefined) + const state = Keyed.make({ key: rowKey, equivalent: sameRow, metrics }) + const seenParts = new Set() + const rows = { + slots: useValue(state.slots), + values: state.values, + } const revertBoundary = () => data.session.get(sessionID())?.revert?.messageID + const setRows = (value: SessionRow[]) => { + batch(() => { + state.set(value) + seenParts.clear() + value.forEach((row) => { + if (row.type === "part") { + seenParts.add(row.id) + return + } + if (row.type !== "group") return + row.refs.forEach((ref) => seenParts.add(partRowID(ref))) + if (row.kind === "exploration") row.pending.forEach((ref) => seenParts.add(partRowID(ref))) + }) + }) + } + const mutate = (f: () => void) => batch(() => Transaction.run(f)) + const insert = (current: readonly SessionRow[], index: number, row: SessionRow) => + state.insert(row, index === current.length ? "end" : { before: current[index].id }) + const complete = (current: readonly SessionRow[], index: number) => { + const previous = current[index - 1] + if (previous?.type === "group" && !previous.completed) state.update({ ...previous, completed: true }) + } + function reduce() { const messages = data.session.message.list(sessionID()) const inputs = new Set(data.session.input.list(sessionID())) @@ -47,7 +81,7 @@ export function createSessionRows(sessionID: Accessor) { ...data.session.pending .list(sessionID()) .filter((item) => item.type === "compaction") - .map((item): SessionRow => ({ type: "compaction-queued", inputID: item.id })), + .map((item) => compactionQueuedRow(item.id)), ) return rows } @@ -62,22 +96,31 @@ export function createSessionRows(sessionID: Accessor) { createEffect(() => { const pending = pendingPermissions() - setRows( - produce((draft) => { - partitionPending(draft, pending) - }), - ) + mutate(() => { + state.values().forEach((row) => { + if (row.type !== "group" || row.kind !== "exploration") return + const changed = + row.refs.some((ref) => pending.has(ref.partID)) || row.pending.some((ref) => !pending.has(ref.partID)) + if (!changed) return + const refs = [...row.refs, ...row.pending] + state.update({ + ...row, + refs: refs.filter((ref) => !pending.has(ref.partID)), + pending: refs.filter((ref) => pending.has(ref.partID)), + }) + }) + }) }) createEffect( on([sessionID, () => client.connection.status()], ([id, status]) => { if (status !== "connected") return - setRows(reconcile(reduce())) + setRows(reduce()) void data.session.pending.sync(id).catch(() => undefined) void data.session.message.sync(id).then( () => { if (sessionID() !== id) return - setRows(reconcile(reduce())) + setRows(reduce()) }, () => undefined, ) @@ -87,7 +130,7 @@ export function createSessionRows(sessionID: Accessor) { // Re-reduce when the revert boundary changes (stage/clear/commit). createEffect( on(revertBoundary, () => { - setRows(reconcile(reduce())) + setRows(reduce()) }), ) @@ -98,7 +141,7 @@ export function createSessionRows(sessionID: Accessor) { .list(sessionID()) .filter((item) => item.type === "compaction") .map((item) => item.id), - () => setRows(reconcile(reduce())), + () => setRows(reduce()), ), ) @@ -123,48 +166,57 @@ export function createSessionRows(sessionID: Accessor) { ] : [], ), - () => setRows(reconcile(reduce())), + () => setRows(reduce()), ), ) const appendMessage = (messageID: string) => - setRows( - produce((draft) => { - if (draft.some((row) => row.type === "message" && row.messageID === messageID)) return - const pending = isPending(messageID) - const message = data.session.message.get(sessionID(), messageID) - const index = - message?.type === "compaction" && pending ? queuedStart(draft) : pending ? draft.length : queuedStart(draft) - if (!pending) completePrevious(draft, index) - draft.splice(index, 0, { type: "message", messageID }) - }), - ) + mutate(() => { + if (state.has(messageRowID(messageID))) return + const current = state.values() + const pending = isPending(messageID) + const message = data.session.message.get(sessionID(), messageID) + const index = + message?.type === "compaction" && pending + ? queuedStart(current) + : pending + ? current.length + : queuedStart(current) + if (!pending) complete(current, index) + insert(current, index, messageRow(messageID)) + }) const appendPart = (ref: PartRef, part: AppendPart) => - setRows( - produce((draft) => { - if (hasPart(draft, ref)) return - append(draft, ref, part, queuedStart(draft)) - }), - ) + mutate(() => { + const id = partRowID(ref) + if (seenParts.has(id)) return + const current = state.values() + const index = queuedStart(current) + const previous = current[index - 1] + const decision = appendDecision(previous, ref, part) + if (decision.type === "join") { + state.update({ ...decision.group, refs: [...decision.group.refs, ref] }) + seenParts.add(id) + return + } + complete(current, index) + insert(current, index, decision.row) + seenParts.add(id) + }) const appendFooter = (messageID: string) => - setRows( - produce((draft) => { - if (draft.some((row) => row.type === "assistant-footer" && row.messageID === messageID)) return - const index = queuedStart(draft) - completePrevious(draft, index) - draft.splice(index, 0, { type: "assistant-footer", messageID }) - }), - ) + mutate(() => { + if (state.has(footerRowID(messageID))) return + const current = state.values() + const index = queuedStart(current) + complete(current, index) + insert(current, index, footerRow(messageID)) + }) const removeFooter = (messageID: string) => - setRows( - produce((draft) => { - const index = draft.findIndex((row) => row.type === "assistant-footer" && row.messageID === messageID) - if (index !== -1) draft.splice(index, 1) - }), - ) + mutate(() => { + state.remove(footerRowID(messageID)) + }) const isPending = (messageID: string) => { const message = data.session.message.get(sessionID(), messageID) @@ -172,7 +224,7 @@ export function createSessionRows(sessionID: Accessor) { return message?.type === "compaction" && message.status === "running" } - const queuedStart = (rows: SessionRow[]) => { + const queuedStart = (rows: readonly SessionRow[]) => { const index = rows.findIndex( (row) => row.type === "compaction-queued" || (row.type === "message" && isPending(row.messageID)), ) @@ -252,6 +304,9 @@ export function createSessionRows(sessionID: Accessor) { }), ] onCleanup(() => subscriptions.forEach((unsubscribe) => unsubscribe())) + onCleanup(() => { + if (reportMetrics && metrics) console.error(`QUARK_TIMELINE_METRICS ${sessionID()} ${JSON.stringify(metrics)}`) + }) return rows } @@ -268,7 +323,7 @@ export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new S if (message.type !== "assistant") { if (message.type === "synthetic" && !message.description?.trim()) return rows if (!pending.has(message.id)) completePrevious(rows) - rows.push({ type: "message", messageID: message.id }) + rows.push(messageRow(message.id)) return rows } const ordinals = { text: 0, reasoning: 0 } @@ -279,13 +334,20 @@ export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new S }) if ((message.finish && !["tool-calls", "unknown"].includes(message.finish)) || message.error || message.retry) { completePrevious(rows) - rows.push({ type: "assistant-footer", messageID: message.id }) + rows.push(footerRow(message.id)) } return rows }, []) } -export function messageBoundaryIDs(rows: SessionRow[], messages: SessionMessageInfo[]) { +type BoundaryRow = + | Pick, "type" | "messageID"> + | Pick, "type"> + | Pick, "type" | "ref"> + | Pick, "type" | "origin"> + | Pick, "type" | "messageID"> + +export function messageBoundaryIDs(rows: readonly BoundaryRow[], messages: SessionMessageInfo[]) { const byID = new Map(messages.map((message) => [message.id, message])) const seen = new Set() return rows.map((row) => { @@ -296,7 +358,7 @@ export function messageBoundaryIDs(rows: SessionRow[], messages: SessionMessageI }) } -function rowBoundaryMessageID(row: SessionRow, messages: Map) { +function rowBoundaryMessageID(row: BoundaryRow, messages: Map) { if (row.type === "message") { const message = messages.get(row.messageID) if (message?.type === "user" && message.text.trim()) return message.id @@ -306,7 +368,7 @@ function rowBoundaryMessageID(row: SessionRow, messages: Map { - if (row.type === "part") return row.ref.messageID === ref.messageID && row.ref.partID === ref.partID - if (row.type !== "group") return false - const refs = row.kind === "exploration" ? [...row.refs, ...row.pending] : row.refs - return refs.some((item) => item.messageID === ref.messageID && item.partID === ref.partID) - }) +function messageRow(messageID: string): SessionRow { + return { id: messageRowID(messageID), type: "message", messageID } +} + +function messageRowID(messageID: string) { + return `m${segment(messageID)}` +} + +function compactionQueuedRow(inputID: string): SessionRow { + return { id: `c${segment(inputID)}`, type: "compaction-queued", inputID } +} + +function partRow(ref: PartRef): SessionRow { + return { id: partRowID(ref), type: "part", ref } +} + +function partRowID(ref: PartRef) { + return `p${segment(ref.messageID)}${segment(ref.partID)}` +} + +function groupRow(kind: "reasoning" | "exploration", ref: PartRef): SessionRow { + const id = `g${kind === "reasoning" ? "r" : "e"}${segment(ref.messageID)}${segment(ref.partID)}` + if (kind === "reasoning") return { id, type: "group", kind, origin: ref, refs: [ref], completed: false } + return { id, type: "group", kind, origin: ref, refs: [ref], pending: [], completed: false } +} + +function footerRow(messageID: string): SessionRow { + return { id: footerRowID(messageID), type: "assistant-footer", messageID } +} + +function footerRowID(messageID: string) { + return `f${segment(messageID)}` +} + +function segment(value: string) { + return `${value.length}:${value}` +} + +function rowKey(row: SessionRow) { + return row.id +} + +function sameRow(left: SessionRow, right: SessionRow) { + if (left.type !== right.type) return false + if (left.type === "message" && right.type === "message") return left.messageID === right.messageID + if (left.type === "compaction-queued" && right.type === "compaction-queued") return left.inputID === right.inputID + if (left.type === "part" && right.type === "part") return sameRef(left.ref, right.ref) + if (left.type === "assistant-footer" && right.type === "assistant-footer") return left.messageID === right.messageID + if (left.type !== "group" || right.type !== "group") return false + if (left.kind !== right.kind || left.completed !== right.completed || !sameRefs(left.refs, right.refs)) return false + if (left.kind === "reasoning" || right.kind === "reasoning") return true + return sameRefs(left.pending, right.pending) +} + +function sameRefs(left: PartRef[], right: PartRef[]) { + return left.length === right.length && left.every((ref, index) => sameRef(ref, right[index])) +} + +function sameRef(left: PartRef, right: PartRef) { + return left.messageID === right.messageID && left.partID === right.partID } diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index d392704dc4..d403763841 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -28,6 +28,19 @@ async function wait(fn: () => boolean, timeout = 2000) { } } +function readRows(rows: ReturnType) { + return rows.values() +} + +function withoutRowID(row: SessionRow) { + const { id: _id, ...value } = row + if (value.type === "group") { + const { origin: _origin, ...group } = value + return group + } + return value +} + function emitEvent(events: ReturnType, event: OpenCodeEvent) { events.emit({ ...event, location: { directory } }) } @@ -717,10 +730,16 @@ test("completes exploration when a queued prompt is promoted", async () => { }, events) let rows!: ReturnType let client!: ReturnType + let structuralUpdates = 0 + const metrics = { slotPublications: 0, structuralPublications: 0, equivalenceSuppressions: 0 } function Probe() { client = useClient() - rows = createSessionRows(() => sessionID) + rows = createSessionRows(() => sessionID, { metrics }) + createEffect(() => { + rows.slots() + structuralUpdates++ + }) return } @@ -762,31 +781,81 @@ test("completes exploration when a queued prompt is promoted", async () => { name: "read", }, }) - await wait(() => rows.some((row) => row.type === "group" && !row.completed)) + await wait(() => readRows(rows).some((row) => row.type === "group" && !row.completed)) + expect(metrics.slotPublications).toBe(0) + expect(metrics.structuralPublications).toBe(1) + + emitEvent(events, { + id: "evt_tool_started_2", + created: 2, + type: "session.tool.input.started", + durable: durable(sessionID, 2), + data: { + sessionID, + assistantMessageID: "message-assistant", + callID: "call-read-2", + name: "read", + }, + }) + await wait(() => { + const group = readRows(rows).find((row) => row.type === "group") + return group?.refs.length === 2 + }) + expect(metrics.slotPublications).toBe(1) + expect(metrics.structuralPublications).toBe(1) + const groupSlot = rows.slots().find((slot) => slot().type === "group") + expect(groupSlot).toBeDefined() + const beforePermission = structuralUpdates + + emitEvent(events, { + id: "evt_permission_asked", + created: 2, + type: "permission.v2.asked", + data: { + id: "permission-read", + sessionID, + action: "read", + resources: ["src/example.ts"], + source: { type: "tool", messageID: "message-assistant", callID: "call-read" }, + }, + }) + await wait(() => { + const group = readRows(rows).find((row) => row.type === "group" && row.kind === "exploration") + return group?.pending[0]?.partID === "call-read" + }) + expect(rows.slots().find((slot) => slot().type === "group")).toBe(groupSlot) + expect(structuralUpdates).toBe(beforePermission) + expect(metrics.slotPublications).toBe(2) + expect(metrics.structuralPublications).toBe(1) emitEvent(events, { id: "evt_prompt_admitted", created: 3, type: "session.input.admitted", - durable: durable(sessionID, 2), + durable: durable(sessionID, 3), data: { sessionID, inputID: "message-user", input: { type: "user", data: { text: "Continue" }, delivery: "steer" }, }, }) - await wait(() => rows.at(-1)?.type === "message") - expect(rows.find((row) => row.type === "group")?.completed).toBe(false) + await wait(() => readRows(rows).at(-1)?.type === "message") + expect(readRows(rows).find((row) => row.type === "group")?.completed).toBe(false) + expect(metrics.slotPublications).toBe(2) + expect(metrics.structuralPublications).toBe(2) emitEvent(events, { id: "evt_prompt_promoted", created: 4, type: "session.input.promoted", - durable: durable(sessionID, 3), + durable: durable(sessionID, 4), data: { sessionID, inputID: "message-user" }, }) - await wait(() => rows.find((row) => row.type === "group")?.completed === true) - expect(rows.at(-1)).toEqual({ type: "message", messageID: "message-user" }) + await wait(() => readRows(rows).find((row) => row.type === "group")?.completed === true) + expect(rows.slots().find((slot) => slot().type === "group")).toBe(groupSlot) + expect(withoutRowID(readRows(rows).at(-1)!)).toEqual({ type: "message", messageID: "message-user" }) + expect(metrics.slotPublications).toBe(3) + expect(metrics.structuralPublications).toBe(2) } finally { app.renderer.destroy() } @@ -834,8 +903,90 @@ test("classifies live tool rows independently of their call ID", async () => { }, }) - await wait(() => rows.length > 0) - expect(rows).toEqual([{ type: "part", ref: { messageID: "message-assistant", partID: "reasoning:0" } }]) + await wait(() => readRows(rows).length > 0) + expect(readRows(rows).map(withoutRowID)).toEqual([ + { type: "part", ref: { messageID: "message-assistant", partID: "reasoning:0" } }, + ]) + } finally { + app.renderer.destroy() + } +}) + +test("does not publish timeline rows for duplicate streaming deltas", async () => { + const events = createEventStream() + const sessionID = "session-stream-metrics" + const calls = createFetch((url) => { + if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} }) + }, events) + const metrics = { slotPublications: 0, structuralPublications: 0, equivalenceSuppressions: 0 } + let data!: ReturnType + let rows!: ReturnType + let client!: ReturnType + + function Probe() { + data = useData() + client = useClient() + rows = createSessionRows(() => sessionID, { metrics }) + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await wait(() => client.connection.status() === "connected") + emitEvent(events, { + id: "evt_stream_step", + created: 1, + type: "session.step.started", + durable: durable(sessionID), + data: { + sessionID, + assistantMessageID: "message-assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + }, + }) + emitEvent(events, { + id: "evt_stream_started", + created: 2, + type: "session.text.started", + durable: durable(sessionID, 1), + data: { sessionID, assistantMessageID: "message-assistant", ordinal: 0 }, + }) + emitEvent(events, { + id: "evt_stream_delta_1", + created: 3, + type: "session.text.delta", + data: { sessionID, assistantMessageID: "message-assistant", ordinal: 0, delta: "one" }, + }) + await wait(() => readRows(rows).some((row) => row.type === "part")) + const afterFirst = { ...metrics } + + emitEvent(events, { + id: "evt_stream_delta_2", + created: 4, + type: "session.text.delta", + data: { sessionID, assistantMessageID: "message-assistant", ordinal: 0, delta: "two" }, + }) + await wait(() => { + const message = data.session.message.get(sessionID, "message-assistant") + return ( + message?.type === "assistant" && message.content.some((part) => part.type === "text" && part.text === "onetwo") + ) + }) + + expect(afterFirst).toEqual({ slotPublications: 0, structuralPublications: 1, equivalenceSuppressions: 0 }) + expect(metrics).toEqual(afterFirst) } finally { app.renderer.destroy() } @@ -987,13 +1138,15 @@ test("tracks session status from active sessions and execution events", async () }) }, events) let data!: ReturnType - let rows!: SessionRow[] - let manualRows!: SessionRow[] + let rows!: ReturnType + let manualRows!: ReturnType + let liveRows!: ReturnType function Probe() { data = useData() rows = createSessionRows(() => "session-retry") manualRows = createSessionRows(() => "session-manual") + liveRows = createSessionRows(() => "session-live") return } @@ -1189,7 +1342,7 @@ test("tracks session status from active sessions and execution events", async () const assistant = data.session.message.get("session-retry", "message-retry") return assistant?.type === "assistant" && assistant.retry?.attempt === 2 }) - await wait(() => rows.some((row) => row.type === "assistant-footer" && row.messageID === "message-retry")) + await wait(() => readRows(rows).some((row) => row.type === "assistant-footer" && row.messageID === "message-retry")) emitEvent(events, { id: "evt_retry_next_step", created: 2_000, @@ -1206,7 +1359,9 @@ test("tracks session status from active sessions and execution events", async () const assistant = data.session.message.get("session-retry", "message-retry") return assistant?.type === "assistant" && assistant.retry === undefined }) - await wait(() => !rows.some((row) => row.type === "assistant-footer" && row.messageID === "message-retry")) + await wait( + () => !readRows(rows).some((row) => row.type === "assistant-footer" && row.messageID === "message-retry"), + ) expect(data.session.message.list("session-retry").filter((message) => message.type === "assistant")).toHaveLength(1) emitEvent(events, { id: "evt_retry_scheduled_again", @@ -1261,7 +1416,9 @@ test("tracks session status from active sessions and execution events", async () return message?.type === "compaction" && message.status === "running" && message.summary === "Streamed summary" }) expect(data.session.pending.list("session-manual")).toEqual([]) - const compactionRow = manualRows.find((row) => row.type === "message" && row.messageID === "message-compaction") + const compactionRow = readRows(manualRows).find( + (row) => row.type === "message" && row.messageID === "message-compaction", + ) emitEvent(events, { id: "evt_manual_compaction_ended", created: 3, @@ -1273,10 +1430,12 @@ test("tracks session status from active sessions and execution events", async () const message = data.session.message.get("session-manual", "message-compaction") return message?.type === "compaction" && message.status === "completed" }) - expect(manualRows.filter((row) => row.type === "message")).toEqual([ - { type: "message", messageID: "message-compaction" }, - ]) - expect(manualRows.find((row) => row.type === "message" && row.messageID === "message-compaction")).toBe( + expect( + readRows(manualRows) + .filter((row) => row.type === "message") + .map(withoutRowID), + ).toEqual([{ type: "message", messageID: "message-compaction" }]) + expect(readRows(manualRows).find((row) => row.type === "message" && row.messageID === "message-compaction")).toBe( compactionRow, ) @@ -1303,7 +1462,10 @@ test("tracks session status from active sessions and execution events", async () const message = data.session.message.get("session-live", "msg_compaction_started") return message?.type === "compaction" && message.status === "running" && message.summary === "Live summary" }) - const autoCompactionRow = rows.find((row) => row.type === "message" && row.messageID === "msg_compaction_started") + const autoCompactionRow = readRows(liveRows).find( + (row) => row.type === "message" && row.messageID === "msg_compaction_started", + ) + expect(autoCompactionRow).toBeDefined() emitEvent(events, { id: "evt_compaction_ended", @@ -1321,10 +1483,12 @@ test("tracks session status from active sessions and execution events", async () status: "completed", summary: "Live summary", }) - expect(rows.find((row) => row.type === "message" && row.messageID === "msg_compaction_started")).toBe( + expect(readRows(liveRows).find((row) => row.type === "message" && row.messageID === "msg_compaction_started")).toBe( autoCompactionRow, ) - expect(rows.some((row) => row.type === "message" && row.messageID === "msg_compaction_ended")).toBeFalse() + expect( + readRows(liveRows).some((row) => row.type === "message" && row.messageID === "msg_compaction_ended"), + ).toBeFalse() } finally { app.renderer.destroy() } @@ -1383,8 +1547,12 @@ test("restores queued compaction from durable pending input", async () => { "message-compaction-queued", "message-compaction-later", ]) - await wait(() => rows.filter((row) => row.type === "compaction-queued").length === 2) - expect(rows.filter((row) => row.type === "compaction-queued")).toEqual([ + await wait(() => readRows(rows).filter((row) => row.type === "compaction-queued").length === 2) + expect( + readRows(rows) + .filter((row) => row.type === "compaction-queued") + .map(withoutRowID), + ).toEqual([ { type: "compaction-queued", inputID: "message-compaction-queued" }, { type: "compaction-queued", inputID: "message-compaction-later" }, ]) @@ -1401,8 +1569,8 @@ test("restores queued compaction from durable pending input", async () => { text: "Active output", }, }) - await wait(() => rows.some((row) => row.type === "part")) - expect(rows.map((row) => row.type)).toEqual(["part", "compaction-queued", "compaction-queued"]) + await wait(() => readRows(rows).some((row) => row.type === "part")) + expect(readRows(rows).map((row) => row.type)).toEqual(["part", "compaction-queued", "compaction-queued"]) emitEvent(events, { id: "evt_compaction_started", diff --git a/packages/tui/test/cli/tui/session-rows.test.ts b/packages/tui/test/cli/tui/session-rows.test.ts index c84237a3b7..c69b027a19 100644 --- a/packages/tui/test/cli/tui/session-rows.test.ts +++ b/packages/tui/test/cli/tui/session-rows.test.ts @@ -1,6 +1,13 @@ import { expect, test } from "bun:test" import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client" -import { messageBoundaryIDs, reduceSessionRows } from "../../../src/routes/session/rows" +import { messageBoundaryIDs, reduceSessionRows, type SessionRow } from "../../../src/routes/session/rows" + +const withoutIDs = (rows: ReturnType) => + rows.map(({ id: _id, ...row }) => { + if (row.type !== "group") return row + const { origin: _origin, ...group } = row + return group + }) test("assigns assistant boundaries to the first rendered row instead of the first text row", () => { const messages: SessionMessageInfo[] = [ @@ -16,6 +23,22 @@ test("assigns assistant boundaries to the first rendered row instead of the firs expect(messageBoundaryIDs(rows, messages)).toEqual(["user-1", "assistant-1", undefined, undefined]) }) +test("keeps a group boundary at its immutable origin while visible refs repartition", () => { + const messages = [assistant("assistant-1", []), assistant("assistant-2", [])] + const origin = { messageID: "assistant-1", partID: "read-1" } + const group: SessionRow = { + id: "group", + type: "group", + kind: "exploration", + origin, + refs: [{ messageID: "assistant-2", partID: "grep-1" }], + pending: [origin], + completed: false, + } + + expect(messageBoundaryIDs([group], messages)).toEqual(["assistant-1"]) +}) + test("groups exploration parts across assistant messages until a delimiter", () => { const messages: SessionMessageInfo[] = [ { type: "user", id: "user-1", text: "Explore", time: { created: 0 } }, @@ -30,7 +53,7 @@ test("groups exploration parts across assistant messages until a delimiter", () ]), ] - expect(reduceSessionRows(messages)).toEqual([ + expect(withoutIDs(reduceSessionRows(messages))).toEqual([ { type: "message", messageID: "user-1" }, { type: "part", ref: { messageID: "assistant-1", partID: "text:0" } }, { @@ -57,7 +80,7 @@ test("keeps non-exploration tools as individual part rows", () => { ]), ] - expect(reduceSessionRows(messages)).toEqual([ + expect(withoutIDs(reduceSessionRows(messages))).toEqual([ { type: "group", kind: "exploration", @@ -86,7 +109,7 @@ test("assigns stable kind ordinals within an assistant message", () => { ]), ] - expect(reduceSessionRows(messages)).toEqual([ + expect(withoutIDs(reduceSessionRows(messages))).toEqual([ { type: "part", ref: { messageID: "assistant-1", partID: "text:0" } }, { type: "group", @@ -114,7 +137,7 @@ test("groups adjacent reasoning parts until a visible boundary", () => { ]), ] - expect(reduceSessionRows(messages)).toEqual([ + expect(withoutIDs(reduceSessionRows(messages))).toEqual([ { type: "group", kind: "reasoning", @@ -146,7 +169,7 @@ test("groups across empty assistant reasoning parts", () => { ]), ] - expect(reduceSessionRows(messages)).toEqual([ + expect(withoutIDs(reduceSessionRows(messages))).toEqual([ { type: "group", kind: "reasoning", @@ -177,7 +200,7 @@ test("completes exploration groups when another row follows", () => { finished, ] - expect(reduceSessionRows(messages)).toEqual([ + expect(withoutIDs(reduceSessionRows(messages))).toEqual([ { type: "group", kind: "exploration", @@ -209,7 +232,7 @@ test("hides synthetic messages without descriptions", () => { assistant("assistant-2", [{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } }]), ] - expect(reduceSessionRows(messages)).toEqual([ + expect(withoutIDs(reduceSessionRows(messages))).toEqual([ { type: "group", kind: "exploration", @@ -236,7 +259,7 @@ test("renders synthetic messages with descriptions", () => { assistant("assistant-2", [{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } }]), ] - expect(reduceSessionRows(messages)).toEqual([ + expect(withoutIDs(reduceSessionRows(messages))).toEqual([ { type: "group", kind: "exploration", @@ -263,7 +286,7 @@ test("renders a footer for a pre-output retry assistant after replay", () => { error: { type: "provider.transport", message: "Disconnected" }, } - expect(reduceSessionRows([message])).toEqual([{ type: "assistant-footer", messageID: "assistant-retry" }]) + expect(withoutIDs(reduceSessionRows([message]))).toEqual([{ type: "assistant-footer", messageID: "assistant-retry" }]) }) test("places a running compaction barrier before every queued user message", () => { @@ -287,7 +310,7 @@ test("places a running compaction barrier before every queued user message", () queued("user-after", "After", 3), ] - expect(reduceSessionRows(messages, new Set(["user-before", "user-after"]))).toEqual([ + expect(withoutIDs(reduceSessionRows(messages, new Set(["user-before", "user-after"])))).toEqual([ { type: "message", messageID: "compaction" }, { type: "message", messageID: "user-before" }, { type: "message", messageID: "user-after" }, diff --git a/script/quark-group-drive.ts b/script/quark-group-drive.ts new file mode 100644 index 0000000000..78839beaf6 --- /dev/null +++ b/script/quark-group-drive.ts @@ -0,0 +1,76 @@ +import { Effect, Stream } from "effect" +import { defineScript, Llm } from "opencode-drive" + +const marker = "QUARK_GROUP_COMPLETE" + +export default defineScript({ + project: { + git: true, + files: { + "src/one.ts": "export const one = 1\n", + "src/two.ts": "export const two = 2\n", + }, + }, + config: { + permissions: [ + { action: "*", resource: "*", effect: "ask" }, + { action: "read", resource: "*one.ts", effect: "allow" }, + ], + }, + tui: { viewport: { cols: 120, rows: 36 }, recording: true }, + run: ({ llm, ui }) => + Effect.gen(function* () { + yield* ui.waitFor((state) => state.focused.editor) + let attempt = 0 + yield* llm.serve(() => { + const current = attempt++ + if (current === 0) + return Stream.make( + Llm.toolCall({ + index: 0, + id: "call_read_one", + name: "read", + input: { path: "src/one.ts" }, + }), + Llm.finish("tool-calls"), + ) + if (current === 1) + return Stream.make( + Llm.toolCall({ + index: 0, + id: "call_read_two", + name: "read", + input: { path: "src/two.ts" }, + }), + Llm.finish("tool-calls"), + ) + return Stream.make(Llm.text(marker), Llm.finish("stop")) + }) + yield* ui.submit("Inspect both source modules, then finish.") + yield* ui.waitFor("Permission required", { timeout: 15_000 }) + const frame = yield* ui.capture() + const row = frame.lines.findIndex((line) => line.spans.some((span) => span.text.includes("Exploring"))) + if (row === -1) throw new Error("the exploration summary was not visible") + const column = 8 + const candidates = (yield* ui.state()).elements.filter( + (element) => + element.x <= column && + element.x + element.width > column && + element.y <= row && + element.y + element.height > row, + ) + if (candidates.length === 0) throw new Error("the exploration summary had no renderable target") + const summary = candidates.reduce((smallest, element) => + element.width * element.height < smallest.width * smallest.height ? element : smallest, + ) + yield* ui.click(summary, { x: column - summary.x, y: row - summary.y }) + yield* ui.waitFor("src/one.ts") + const before = yield* ui.screenshot("group-expanded-pending") + yield* ui.enter() + yield* ui.waitFor(marker, { timeout: 15_000 }) + yield* ui.waitFor("src/two.ts") + const after = yield* ui.screenshot("group-expanded-complete") + yield* Effect.sync(() => console.log(`ARTIFACT group_before=${before}`)) + yield* Effect.sync(() => console.log(`ARTIFACT group_after=${after}`)) + }), +}) diff --git a/script/quark-timeline-drive.ts b/script/quark-timeline-drive.ts new file mode 100644 index 0000000000..c9c671a2eb --- /dev/null +++ b/script/quark-timeline-drive.ts @@ -0,0 +1,46 @@ +import { Effect } from "effect" +import { defineScript, Llm } from "opencode-drive" + +const marker = "QUARK_TIMELINE_COMPLETE" +const reasoning = Array.from( + { length: 16 }, + (_, index) => `Reasoning segment ${index + 1} checks streamed timeline updates.`, +).join(" ") +const response = `${Array.from( + { length: 24 }, + (_, index) => `Timeline chunk ${index + 1} remains visible and ordered.`, +).join(" ")} ${marker}` + +export default defineScript({ + project: { + git: true, + files: { + "src/example.ts": "export const timeline = true\n", + }, + }, + tui: { viewport: { cols: 120, rows: 36 } }, + run: ({ opencode, llm, ui }) => + Effect.gen(function* () { + yield* ui.waitFor((state) => state.focused.editor) + yield* llm.title(() => Effect.succeed("Timeline comparison")) + const started = Date.now() + yield* ui.submit("Explain how this project handles its timeline.") + yield* llm.send( + Llm.reasoning(reasoning, { delay: 1, chunkSize: 8 }), + Llm.text(response, { delay: 1, chunkSize: 8 }), + ) + yield* ui.waitFor(marker, { timeout: 30_000 }) + const session = (yield* opencode.session.list({})).data[0] + if (!session) throw new Error("the Drive session was not projected") + const assistant = (yield* opencode.message.list({ sessionID: session.id })).data.findLast( + (message) => message.type === "assistant", + ) + if (assistant?.type !== "assistant") throw new Error("the assistant message was not projected") + if (assistant.content.find((part) => part.type === "reasoning")?.text !== reasoning) + throw new Error("the projected reasoning content did not match") + if (assistant.content.find((part) => part.type === "text")?.text !== response) + throw new Error("the projected text content did not match") + if (assistant.finish !== "stop") throw new Error("the projected assistant message did not finish") + yield* Effect.sync(() => console.log(`METRIC tui_timeline_drive_ms=${Date.now() - started}`)) + }), +})