feat(tui): add quark timeline experiment
This commit is contained in:
parent
57ff57595a
commit
c1222c1095
22 changed files with 2804 additions and 122 deletions
60
packages/quark/bench/harness.ts
Normal file
60
packages/quark/bench/harness.ts
Normal file
|
|
@ -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)]
|
||||
}
|
||||
217
packages/quark/bench/keyed.ts
Normal file
217
packages/quark/bench/keyed.ts
Normal file
|
|
@ -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<Item, number>({
|
||||
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<Item, number>({
|
||||
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<Item, number>({
|
||||
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<Item, number>({ 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()
|
||||
63
packages/quark/bench/row-key.ts
Normal file
63
packages/quark/bench/row-key.ts
Normal file
|
|
@ -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()
|
||||
20
packages/quark/package.json
Normal file
20
packages/quark/package.json
Normal file
|
|
@ -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:"
|
||||
}
|
||||
}
|
||||
2
packages/quark/src/index.ts
Normal file
2
packages/quark/src/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { Keyed } from "./keyed"
|
||||
export { Computed, State, Transaction, type Readable, type Writable } from "./reactivity"
|
||||
143
packages/quark/src/keyed.ts
Normal file
143
packages/quark/src/keyed.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import { Computed, State, Transaction, type Readable, type Writable } from "./reactivity"
|
||||
|
||||
export namespace Keyed {
|
||||
export type Position<Key> = "end" | { readonly before: Key } | { readonly after: Key }
|
||||
|
||||
export interface Metrics {
|
||||
slotPublications: number
|
||||
structuralPublications: number
|
||||
equivalenceSuppressions: number
|
||||
}
|
||||
|
||||
export interface Keyed<A, Key> {
|
||||
readonly slots: Readable<readonly Readable<A>[]>
|
||||
readonly values: Readable<readonly A[]>
|
||||
has(key: Key): boolean
|
||||
get(key: Key): Readable<A> | undefined
|
||||
set(values: readonly A[]): void
|
||||
update(value: A): boolean
|
||||
insert(value: A, position?: Position<Key>): Readable<A>
|
||||
remove(key: Key): boolean
|
||||
move(key: Key, position?: Position<Key>): boolean
|
||||
}
|
||||
|
||||
export function make<A, Key>(options: {
|
||||
readonly key: (value: A) => Key
|
||||
readonly equivalent?: (left: A, right: A) => boolean
|
||||
readonly metrics?: Metrics
|
||||
}): Keyed<A, Key> {
|
||||
const slots = State.make<readonly Writable<A>[]>([])
|
||||
const byKey = new Map<Key, Writable<A>>()
|
||||
const equivalent = options.equivalent ?? Object.is
|
||||
const values = Computed.make<readonly A[]>((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<A>[], position?: Position<Key>) {
|
||||
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<A>[], 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<A>(left: readonly A[] | undefined, right: readonly A[]) {
|
||||
return left?.length === right.length && left.every((value, index) => Object.is(value, right[index]))
|
||||
}
|
||||
}
|
||||
59
packages/quark/src/reactivity.ts
Normal file
59
packages/quark/src/reactivity.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { computed, effect, endBatch, setActiveSub, signal, startBatch } from "alien-signals"
|
||||
|
||||
export interface Readable<A> {
|
||||
(): A
|
||||
subscribe(listener: (value: A) => void): () => void
|
||||
}
|
||||
|
||||
export interface Writable<A> extends Readable<A> {
|
||||
set(value: A): void
|
||||
update(f: (value: A) => A): void
|
||||
}
|
||||
|
||||
function subscribe<A>(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<A>(initial: A): Writable<A> {
|
||||
const state = signal(initial)
|
||||
const read = (() => state()) as Writable<A>
|
||||
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<A>(evaluate: (previous: A | undefined) => A): Readable<A> {
|
||||
const value = computed(evaluate)
|
||||
const read = (() => value()) as Readable<A>
|
||||
read.subscribe = (listener) => subscribe(read, listener)
|
||||
return read
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Transaction {
|
||||
export function run<A>(f: () => A): A {
|
||||
startBatch()
|
||||
try {
|
||||
return f()
|
||||
} finally {
|
||||
endBatch()
|
||||
}
|
||||
}
|
||||
}
|
||||
22
packages/quark/src/solid.ts
Normal file
22
packages/quark/src/solid.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { For, from, type Accessor, type JSX } from "solid-js"
|
||||
import type { Readable } from "./reactivity"
|
||||
|
||||
export function useValue<A>(readable: Readable<A>): Accessor<A> {
|
||||
return from(readable, readable())
|
||||
}
|
||||
|
||||
export function KeyedFor<A>(props: {
|
||||
readonly each: Accessor<readonly Readable<A>[]>
|
||||
readonly fallback?: JSX.Element
|
||||
readonly children: (value: Accessor<A>, index: Accessor<number>) => JSX.Element
|
||||
}) {
|
||||
return For({
|
||||
get each() {
|
||||
return props.each()
|
||||
},
|
||||
get fallback() {
|
||||
return props.fallback
|
||||
},
|
||||
children: (slot, index) => props.children(useValue(slot), index),
|
||||
})
|
||||
}
|
||||
201
packages/quark/test/keyed.test.ts
Normal file
201
packages/quark/test/keyed.test.ts
Normal file
|
|
@ -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<Item, number>({ 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<Item, number>({
|
||||
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<Item, number>({ 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<Item, number>({ 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<Item, number>({ 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<Item, number>({ 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<Item, number>({ key: (value) => value.id })
|
||||
keyed.set([item(1, "one"), item(2, "two")])
|
||||
const structure = keyed.slots()
|
||||
const two = keyed.slots()[1]
|
||||
const structures: Array<readonly unknown[]> = []
|
||||
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<Item, number>({ 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<Item, number>({ 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<Item, number>({ 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<Item, number>({ 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)
|
||||
})
|
||||
})
|
||||
39
packages/quark/test/reactivity.test.ts
Normal file
39
packages/quark/test/reactivity.test.ts
Normal file
|
|
@ -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])
|
||||
})
|
||||
})
|
||||
|
|
@ -86,6 +86,7 @@
|
|||
"clipboardy": "4.0.0",
|
||||
"diff": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"effect-quark": "workspace:*",
|
||||
"fuzzysort": "catalog:",
|
||||
"get-east-asian-width": "catalog:",
|
||||
"open": "10.1.2",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -205,7 +206,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<string>()
|
||||
const [navigationSlack, setNavigationSlack] = createSignal(0)
|
||||
|
||||
|
|
@ -922,15 +923,15 @@ export function Session() {
|
|||
flexGrow={1}
|
||||
scrollAcceleration={scrollAcceleration()}
|
||||
>
|
||||
<For each={rows}>
|
||||
<KeyedFor each={rows.slots}>
|
||||
{(row, index) => (
|
||||
<SessionRowView
|
||||
row={row}
|
||||
row={row()}
|
||||
message={(messageID) => data.session.message.get(route.sessionID, messageID)}
|
||||
boundaryID={boundaries()[index()]}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</KeyedFor>
|
||||
<BackgroundToolHint messages={messages()} />
|
||||
<Show when={session()?.revert?.messageID}>
|
||||
<RevertMessage
|
||||
|
|
|
|||
|
|
@ -1,39 +1,73 @@
|
|||
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
|
||||
import { createEffect, on, onCleanup, type Accessor } from "solid-js"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { Keyed, Transaction } from "effect-quark"
|
||||
import { useValue } from "effect-quark/solid"
|
||||
import { batch, createEffect, on, onCleanup, type Accessor } from "solid-js"
|
||||
import { useData } from "../../context/data"
|
||||
import { useClient } from "../../context/client"
|
||||
|
||||
export type PartRef = {
|
||||
messageID: string
|
||||
partID: string
|
||||
readonly messageID: string
|
||||
readonly partID: string
|
||||
}
|
||||
|
||||
export type SessionRow =
|
||||
export 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 }
|
||||
)
|
||||
|
||||
export function createSessionRows(sessionID: Accessor<string>) {
|
||||
export function createSessionRows(sessionID: Accessor<string>, options?: { readonly metrics?: Keyed.Metrics }) {
|
||||
const data = useData()
|
||||
const client = useClient()
|
||||
const [rows, setRows] = createStore<SessionRow[]>([])
|
||||
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<string>()
|
||||
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<string>) {
|
|||
...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<string>) {
|
|||
|
||||
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<string>) {
|
|||
// 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<string>) {
|
|||
.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<string>) {
|
|||
]
|
||||
: [],
|
||||
),
|
||||
() => 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<string>) {
|
|||
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<string>) {
|
|||
}),
|
||||
]
|
||||
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<Extract<SessionRow, { type: "message" }>, "type" | "messageID">
|
||||
| Pick<Extract<SessionRow, { type: "compaction-queued" }>, "type">
|
||||
| Pick<Extract<SessionRow, { type: "part" }>, "type" | "ref">
|
||||
| Pick<Extract<SessionRow, { type: "group" }>, "type" | "origin">
|
||||
| Pick<Extract<SessionRow, { type: "assistant-footer" }>, "type" | "messageID">
|
||||
|
||||
export function messageBoundaryIDs(rows: readonly BoundaryRow[], messages: SessionMessageInfo[]) {
|
||||
const byID = new Map(messages.map((message) => [message.id, message]))
|
||||
const seen = new Set<string>()
|
||||
return rows.map((row) => {
|
||||
|
|
@ -296,7 +358,7 @@ export function messageBoundaryIDs(rows: SessionRow[], messages: SessionMessageI
|
|||
})
|
||||
}
|
||||
|
||||
function rowBoundaryMessageID(row: SessionRow, messages: Map<string, SessionMessageInfo>) {
|
||||
function rowBoundaryMessageID(row: BoundaryRow, messages: Map<string, SessionMessageInfo>) {
|
||||
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<string, SessionMess
|
|||
row.type === "part"
|
||||
? row.ref.messageID
|
||||
: row.type === "group"
|
||||
? row.refs[0]?.messageID
|
||||
? row.origin.messageID
|
||||
: row.type === "assistant-footer"
|
||||
? row.messageID
|
||||
: undefined
|
||||
|
|
@ -327,28 +389,25 @@ export function resolvePart(message: SessionMessageAssistant, partID: string) {
|
|||
type AppendPart = { type: "text" } | { type: "reasoning" } | { type: "tool"; name: string }
|
||||
|
||||
function append(rows: SessionRow[], ref: PartRef, part: AppendPart, index = rows.length) {
|
||||
if (part.type === "reasoning") {
|
||||
const previous = rows[index - 1]
|
||||
if (previous?.type === "group" && previous.kind === "reasoning") {
|
||||
previous.refs.push(ref)
|
||||
return
|
||||
}
|
||||
completePrevious(rows, index)
|
||||
rows.splice(index, 0, { type: "group", kind: "reasoning", refs: [ref], completed: false })
|
||||
return
|
||||
}
|
||||
if (part.type === "tool" && exploration(part.name)) {
|
||||
const previous = rows[index - 1]
|
||||
if (previous?.type === "group" && previous.kind === "exploration") {
|
||||
previous.refs.push(ref)
|
||||
return
|
||||
}
|
||||
completePrevious(rows, index)
|
||||
rows.splice(index, 0, { type: "group", kind: "exploration", refs: [ref], pending: [], completed: false })
|
||||
const previous = rows[index - 1]
|
||||
const decision = appendDecision(previous, ref, part)
|
||||
if (decision.type === "join") {
|
||||
decision.group.refs.push(ref)
|
||||
return
|
||||
}
|
||||
completePrevious(rows, index)
|
||||
rows.splice(index, 0, { type: "part", ref })
|
||||
rows.splice(index, 0, decision.row)
|
||||
}
|
||||
|
||||
function appendDecision(previous: SessionRow | undefined, ref: PartRef, part: AppendPart) {
|
||||
const kind = groupKind(part)
|
||||
if (kind && previous?.type === "group" && previous.kind === kind) return { type: "join" as const, group: previous }
|
||||
return { type: "insert" as const, row: kind ? groupRow(kind, ref) : partRow(ref) }
|
||||
}
|
||||
|
||||
function groupKind(part: AppendPart) {
|
||||
if (part.type === "reasoning") return "reasoning" as const
|
||||
if (part.type === "tool" && exploration(part.name)) return "exploration" as const
|
||||
}
|
||||
|
||||
function completePrevious(rows: SessionRow[], index = rows.length) {
|
||||
|
|
@ -369,11 +428,64 @@ function exploration(name: string) {
|
|||
return ["read", "glob", "grep"].includes(name.toLowerCase())
|
||||
}
|
||||
|
||||
function hasPart(rows: SessionRow[], ref: PartRef) {
|
||||
return rows.some((row) => {
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,19 @@ async function wait(fn: () => boolean, timeout = 2000) {
|
|||
}
|
||||
}
|
||||
|
||||
function readRows(rows: ReturnType<typeof createSessionRows>) {
|
||||
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<typeof createEventStream>, event: OpenCodeEvent) {
|
||||
events.emit({ ...event, location: { directory } })
|
||||
}
|
||||
|
|
@ -687,10 +700,16 @@ test("completes exploration when a queued prompt is promoted", async () => {
|
|||
}, events)
|
||||
let rows!: ReturnType<typeof createSessionRows>
|
||||
let client!: ReturnType<typeof useClient>
|
||||
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 <box />
|
||||
}
|
||||
|
||||
|
|
@ -732,31 +751,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()
|
||||
}
|
||||
|
|
@ -804,8 +873,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<typeof useData>
|
||||
let rows!: ReturnType<typeof createSessionRows>
|
||||
let client!: ReturnType<typeof useClient>
|
||||
|
||||
function Probe() {
|
||||
data = useData()
|
||||
client = useClient()
|
||||
rows = createSessionRows(() => sessionID, { metrics })
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts>
|
||||
<ClientProvider api={createApi(calls.fetch)}>
|
||||
<ProjectProvider>
|
||||
<DataProvider>
|
||||
<Probe />
|
||||
</DataProvider>
|
||||
</ProjectProvider>
|
||||
</ClientProvider>
|
||||
</TestTuiContexts>
|
||||
))
|
||||
|
||||
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()
|
||||
}
|
||||
|
|
@ -957,13 +1108,15 @@ test("tracks session status from active sessions and execution events", async ()
|
|||
})
|
||||
}, events)
|
||||
let data!: ReturnType<typeof useData>
|
||||
let rows!: SessionRow[]
|
||||
let manualRows!: SessionRow[]
|
||||
let rows!: ReturnType<typeof createSessionRows>
|
||||
let manualRows!: ReturnType<typeof createSessionRows>
|
||||
let liveRows!: ReturnType<typeof createSessionRows>
|
||||
|
||||
function Probe() {
|
||||
data = useData()
|
||||
rows = createSessionRows(() => "session-retry")
|
||||
manualRows = createSessionRows(() => "session-manual")
|
||||
liveRows = createSessionRows(() => "session-live")
|
||||
return <box />
|
||||
}
|
||||
|
||||
|
|
@ -1159,7 +1312,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,
|
||||
|
|
@ -1176,7 +1329,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",
|
||||
|
|
@ -1231,7 +1386,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,
|
||||
|
|
@ -1243,10 +1400,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,
|
||||
)
|
||||
|
||||
|
|
@ -1273,7 +1432,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",
|
||||
|
|
@ -1291,10 +1453,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()
|
||||
}
|
||||
|
|
@ -1353,8 +1517,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" },
|
||||
])
|
||||
|
|
@ -1371,8 +1539,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",
|
||||
|
|
|
|||
|
|
@ -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<typeof reduceSessionRows>) =>
|
||||
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" },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue