Skip to content

Commit ee0da19

Browse files
KyleAMathewssamwilliscursoragent
authored
Fix Electric Agents quickstart runner registration (#4610)
## Executive Summary Fixes the Electric Agents quickstart path so the printed CLI commands can spawn, route, send to, and observe Horton entities without extra principal or dispatch-policy setup. The user-visible result is that `electric agents quickstart` creates a usable local runner-backed environment, `electric agents observe` uses the same shared timeline live query as the other UIs, and interactive terminal sends remain visible while they reconcile with the entity stream. ## Root Cause There were several related quickstart gaps: - The built-in runner registered with one principal while the CLI used another, so entity type management, runner ownership checks, and default user permissions did not line up. - Built-in Horton/Worker type registration intentionally omitted a default dispatch policy, so `electric agents spawn /horton/onboarding` could create an entity that no pull-wake runner would claim. - The CLI-launched built-in runtime installed the optional global Undici fetch cache; in local quickstart this could surface as opaque fetch failures such as `invalid onError method` during type registration. - `electric agents observe` and the shared UI timeline query were resolving different `@tanstack/db` package instances. TanStack validates live-query sources with package-local `CollectionImpl` identity, so valid StreamDB collections were rejected as non-collections. - The shared live timeline query concatenated streamed text deltas by row key rather than stream sequence. Once a response reached `msg-0:10`, lexicographic ordering placed that chunk before `msg-0:2`, which only surfaced in consumers reading the live query path. - Live run item ordering mixed text row creation order with tool-call timeline order. A text row can be created before its first delta arrives, so text could render before an earlier tool call. - Interactive `observe` sends posted successfully but did not await the returned stream `txid` or send a stable client key, so the optimistic inbox row could disappear before the real stream event reconciled. - The CLI entity stream helper constructed a bare StreamDB instead of using the agents-runtime `createEntityStreamDB` wrapper used by the web UI. That skipped runtime row-offset materialization (`_timeline_order`), so the shared timeline query fell back to source tie-breakers and grouped inbox rows ahead of run rows. - Published CLI builds floated `electricax/agents-server:latest`, so a released CLI could start a server image with mismatched runtime contracts. ## Approach - Make quickstart resolve one runner owner principal and force that same principal into the built-in runtime's server headers. - Default the CLI principal to `user:<ELECTRIC_AGENTS_IDENTITY>` so the no-env quickstart commands match the built-in `principal_kind=user` grants. - Register built-in Horton/Worker types with the local pull-wake runner as their default dispatch target. - Disable the optional global Durable Streams fetch cache only for the CLI-managed built-in runtime. - Align `electric-ax` with the same TypeScript/TanStack DB peer set as `agents-runtime`, so both packages resolve the same `@tanstack/db` instance. - Restore CLI `observe` to `createEntityTimelineQuery(db)` plus `useLiveQuery`, then adapt the shared query rows into the existing Ink terminal sections. - Order text/reasoning deltas by `_seq` in the shared live query, matching the materialized timeline view. - Order live run child items in one sequence domain: tool calls by `_seq`, text items by first text-delta `_seq` with text-row `_seq` as fallback. - Keep interactive observe sends optimistic until the returned `txid` is observed, using a stable client key and pending timeline order so the optimistic row and synced inbox row reconcile. - Make the CLI entity stream helper delegate to the agents-runtime StreamDB helper, matching the web UI stream setup and preserving `_timeline_order` for shared timeline row ordering. - Add shared timeline indexes for joined collections so the live query path is warning-free in both CLI and UI consumers. - Inject the matching `@electric-ax/agents-server` package version into the CLI Docker image tag at build time, and make `ci:publish` rebuild packages after Changesets updates versions. - Add endpoint/cause context around entity type and runner registration fetch failures. - Clarify browser-only credentials settings copy so it points at the connected runtime, not editable browser-side provider keys. ## Key Invariants - The runner registration principal, runner owner, runner claim headers, and CLI default principal must agree for the local quickstart path. - Built-in agent types must have a runner default dispatch policy when they are registered by a pull-wake built-in runtime. - `ELECTRIC_AGENTS_PRINCIPAL` remains the explicit override for non-default deployments. - `electric-ax`, `agents-runtime`, and UI consumers must resolve a single TanStack DB module instance for shared live-query collection identity checks. - CLI and desktop/web timeline consumers should share `createEntityTimelineQuery(db)`; only the rendering adapter should differ. - Live streamed deltas must be ordered by stream sequence, not by human-readable event keys. - Live run child items must be ordered by model event sequence, not by when a text container row was created. - Terminal optimistic sends must use a stable key and await stream reconciliation before dropping optimistic state. - CLI and web UI StreamDB setup must share the runtime helper so row offsets and `_timeline_order` are materialized consistently. - Released `electric-ax` builds should start an agents-server image matching the release version unless explicitly overridden. ## Non-goals - This does not make provider keys editable from the browser UI. - This does not alter runner ownership authorization rules on the server; quickstart now supplies matching principals instead. - This does not mask model provider failures. - This does not make the terminal observer render every rich desktop-only timeline row. It maps the shared query output into the existing terminal sections. ## Trade-offs The CLI observer now shares the same timeline query as the desktop/web UIs, which keeps query semantics in one place and prevents the terminal observer from drifting. The terminal still has a small adapter because its Ink renderer expects simpler records than the desktop timeline component. The timeline query now creates a few `BasicIndex` indexes before building joins. That is a small setup cost per StreamDB instance, but it avoids repeated TanStack warnings and keeps the shared query suitable for CLI and UI use. The Docker image pin is injected at build time rather than hard-coded because Changesets owns release version updates. Rebuilding during `ci:publish` ensures the generated artifacts see the bumped package versions. ## Verification ```sh pnpm --filter @electric-ax/agents-runtime build pnpm --filter electric-ax build pnpm --filter @electric-ax/agents-runtime exec vitest run test/entity-timeline.test.ts pnpm --filter electric-ax test -- entity-stream-db.test.ts observe-ui.test.tsx pnpm --filter electric-ax test -- observe-ui.test.tsx cli.test.ts start.test.ts pnpm --filter @electric-ax/agents-server-ui typecheck pnpm --filter @electric-ax/agents exec vitest run test/builtin-pull-wake-registration.test.ts ``` Manual isolated quickstart verification: ```sh cd /tmp ELECTRIC_AGENTS_COMPOSE_PROJECT=electric-agents-verify \ ELECTRIC_AGENTS_PORT=4448 \ node /Users/kylemathews/programs/electric/packages/electric-ax/dist/index.js agents quickstart ``` In a second terminal: ```sh ELECTRIC_AGENTS_URL=http://localhost:4448 \ node /Users/kylemathews/programs/electric/packages/electric-ax/dist/index.js agents types ELECTRIC_AGENTS_URL=http://localhost:4448 \ node /Users/kylemathews/programs/electric/packages/electric-ax/dist/index.js agents spawn /horton/onboarding ELECTRIC_AGENTS_URL=http://localhost:4448 \ node /Users/kylemathews/programs/electric/packages/electric-ax/dist/index.js agents inspect /horton/onboarding ELECTRIC_AGENTS_URL=http://localhost:4448 \ node /Users/kylemathews/programs/electric/packages/electric-ax/dist/index.js agents send /horton/onboarding "Onboard me to Electric Agents" ELECTRIC_AGENTS_URL=http://localhost:4448 \ node /Users/kylemathews/programs/electric/packages/electric-ax/dist/index.js agents observe /horton/onboarding ``` Observed results: - Horton and Worker type registration succeeded. - Pull-wake runner `builtin-agents` started. - `agents types` listed built-ins without a manual principal override. - `agents spawn /horton/onboarding` succeeded. - `agents inspect /horton/onboarding` showed `dispatch_policy.targets[0].runnerId = builtin-agents`. - `agents send /horton/onboarding "Onboard me to Electric Agents"` was picked up by the runner. - `agents observe /horton/onboarding` rendered the timeline through the shared live query instead of throwing `Invalid source for live query`, with no TanStack join-index warning. - The terminal observer rendered the full streamed Horton response after `_seq` ordering replaced key ordering for live deltas. - Tool calls now remain before later text when the text row was created before its first delta. ## Files Changed - `.changeset/quickstart-runner-principal.md`: patch release note for affected packages. - `package.json`: rebuild packages before publish/tag so injected version constants are current after Changesets versioning. - `packages/agents-runtime/src/create-handler.ts`: include registration endpoint and fetch cause in entity type registration failures. - `packages/agents-runtime/src/entity-timeline.ts`: create shared timeline join indexes, keep CLI/UI consumers on the same live query, and order live deltas by stream sequence. - `packages/agents-runtime/test/create-handler.test.ts`, `packages/agents-runtime/test/entity-timeline.test.ts`: regression coverage for fetch detail, live delta ordering, top-level live timeline row interleaving, and live run child item ordering. - `packages/agents/src/server.ts`, `packages/agents/test/builtin-pull-wake-registration.test.ts`: default built-in type dispatch to the local runner and cover it. - `packages/electric-ax/package.json`, `pnpm-lock.yaml`: align `electric-ax` with the same TanStack DB/TypeScript peer instance as `agents-runtime`. - `packages/electric-ax/src/start.ts`, `packages/electric-ax/src/index.ts`: align quickstart runner owner/server headers and default CLI principal behavior. - `packages/electric-ax/src/entity-stream-db.ts`, `packages/electric-ax/test/entity-stream-db.test.ts`: delegate CLI stream setup to the agents-runtime helper so timeline ordering matches the web UI. - `packages/electric-ax/src/observe-ui.tsx`, `packages/electric-ax/test/observe-ui.test.tsx`: render observe from the shared live query, fix terminal wrapping, send interactive messages with stable optimistic keys, and await send txids. - `packages/electric-ax/src/version.ts`, `packages/electric-ax/tsdown*.config.ts`: inject the matching agents-server Docker image tag into release builds. - `packages/electric-ax/test/cli.test.ts`, `packages/electric-ax/test/start.test.ts`: coverage for principal/default header behavior. - `packages/agents-server-ui/src/components/settings/pages/CredentialsPage.tsx`: clarify browser-only provider credentials copy. --------- Co-authored-by: Sam Willis <sam.willis@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 37b9922 commit ee0da19

22 files changed

Lines changed: 999 additions & 151 deletions
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"electric-ax": patch
3+
"@electric-ax/agents": patch
4+
"@electric-ax/agents-runtime": patch
5+
"@electric-ax/agents-server-ui": patch
6+
---
7+
8+
Fix Electric Agents quickstart startup by authenticating the built-in pull-wake runner with the same principal it registers as, registering built-in agent types with the local runner as their default dispatch target, and aligning the CLI's default principal with the local quickstart user. Pin the CLI-launched agents-server Docker image to the matching released agents-server version, improve registration fetch errors so startup failures include the endpoint and underlying cause, avoid the CLI observe live-query Collection boundary, and clarify browser-only credentials settings copy.

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
"packageManager": "pnpm@10.12.1",
99
"private": true,
1010
"scripts": {
11-
"ci:publish": "pnpm '/^ci:publish:.+/' && pnpm exec changeset tag",
11+
"ci:publish": "pnpm run -r --filter './packages/**' build && pnpm '/^ci:publish:.+/' && pnpm exec changeset tag",
1212
"ci:publish:hex-electric": "pnpm run --dir packages/sync-service publish:hex",
1313
"ci:publish:hex-electric-client": "pnpm run --dir packages/elixir-client publish:hex",
1414
"ci:publish:npm": "pnpm publish -r --filter './packages/**' --no-git-checks --access public",

‎packages/agents-runtime/src/create-handler.ts‎

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -567,14 +567,23 @@ export function createRuntimeRouter(
567567
}
568568
}
569569

570-
const typeRes = await fetch(
571-
appendPathToUrl(baseUrl, `/_electric/entity-types`),
572-
{
570+
const registrationUrl = appendPathToUrl(
571+
baseUrl,
572+
`/_electric/entity-types`
573+
)
574+
let typeRes: Response
575+
try {
576+
typeRes = await fetch(registrationUrl, {
573577
method: `POST`,
574578
headers: await registrationHeaders(),
575579
body: JSON.stringify(body),
576-
}
577-
)
580+
})
581+
} catch (error) {
582+
const message = `Failed to register type "${name}" at ${registrationUrl}: ${formatFetchError(error)}`
583+
runtimeLog.error(`[agent-runtime]`, message)
584+
failed.push(`${name} (${message})`)
585+
return
586+
}
578587

579588
if (!typeRes.ok) {
580589
const err = await typeRes.text()
@@ -758,6 +767,17 @@ function json(body: Record<string, unknown>, status: number): Response {
758767
})
759768
}
760769

770+
function formatFetchError(error: unknown): string {
771+
if (error instanceof Error) {
772+
const cause =
773+
error.cause instanceof Error
774+
? `: ${error.cause.message || error.cause.name}`
775+
: ``
776+
return `${error.message || error.name || `Unknown error`}${cause}`
777+
}
778+
return String(error) || `Unknown error`
779+
}
780+
761781
async function toFetchRequest(req: IncomingMessage): Promise<Request> {
762782
const body = await readBody(req)
763783
const host =

‎packages/agents-runtime/src/entity-timeline.ts‎

Lines changed: 63 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,13 @@ import {
1010
isNull,
1111
isUndefined,
1212
like,
13+
min,
1314
localOnlyCollectionOptions,
1415
or,
1516
sum,
1617
toArray,
1718
} from '@durable-streams/state/db'
18-
import { caseWhen } from '@tanstack/db'
19+
import { BasicIndex, caseWhen } from '@tanstack/db'
1920
import type {
2021
Collection,
2122
InitialQueryBuilder,
@@ -1316,6 +1317,47 @@ const getEntitySignalsCollection = cachedCollectionFactory(
13161317

13171318
type EntityTimelineQueryBuilder = (q: InitialQueryBuilder) => QueryBuilder<any>
13181319

1320+
const indexedTimelineDbs = new WeakSet<object>()
1321+
1322+
type IndexableCollection = {
1323+
createIndex: (
1324+
index: (row: any) => unknown,
1325+
config: { indexType: typeof BasicIndex }
1326+
) => void
1327+
}
1328+
1329+
function hasCreateIndex(value: unknown): value is IndexableCollection {
1330+
return (
1331+
!!value &&
1332+
typeof (value as { createIndex?: unknown }).createIndex === `function`
1333+
)
1334+
}
1335+
1336+
function createIndexIfAvailable(
1337+
collection: unknown,
1338+
index: (row: any) => unknown
1339+
): void {
1340+
if (hasCreateIndex(collection)) {
1341+
collection.createIndex(index, { indexType: BasicIndex })
1342+
}
1343+
}
1344+
1345+
export function ensureEntityTimelineIndexes(db: EntityStreamDB): void {
1346+
if (indexedTimelineDbs.has(db as object)) return
1347+
indexedTimelineDbs.add(db as object)
1348+
1349+
createIndexIfAvailable(db.collections.texts, (row) => row.run_id)
1350+
createIndexIfAvailable(db.collections.textDeltas, (row) => row.text_id)
1351+
createIndexIfAvailable(db.collections.toolCalls, (row) => row.run_id)
1352+
createIndexIfAvailable(db.collections.reasoning, (row) => row.run_id)
1353+
createIndexIfAvailable(
1354+
db.collections.reasoningDeltas,
1355+
(row) => row.reasoning_id
1356+
)
1357+
createIndexIfAvailable(db.collections.steps, (row) => row.run_id)
1358+
createIndexIfAvailable(db.collections.errors, (row) => row.run_id)
1359+
}
1360+
13191361
/**
13201362
* Builds a live timeline query for an entity stream.
13211363
*
@@ -1339,6 +1381,7 @@ export function createEntityTimelineQuery(
13391381
db: EntityStreamDB,
13401382
opts: EntityTimelineQueryOptions = {}
13411383
): EntityTimelineQueryBuilder {
1384+
ensureEntityTimelineIndexes(db)
13421385
return (q: InitialQueryBuilder) => buildEntityTimelineQuery(q, db, opts)
13431386
}
13441387

@@ -1423,6 +1466,14 @@ function buildEntityTimelineQuery(
14231466
run_id: error.run_id,
14241467
}))
14251468

1469+
const textFirstDeltaSource = q
1470+
.from({ textDelta: db.collections.textDeltas })
1471+
.groupBy(({ textDelta }) => textDelta.text_id)
1472+
.select(({ textDelta }) => ({
1473+
text_id: textDelta.text_id,
1474+
order: min(coalesce(textDelta._seq, -1)),
1475+
}))
1476+
14261477
// Union texts + tool calls into a single ordered stream. The
14271478
// text-delta join lives at this level (vs. inside the consumer's
14281479
// `items.select`) so the correlation key is `text.key` — a field
@@ -1435,31 +1486,31 @@ function buildEntityTimelineQuery(
14351486
text: db.collections.texts,
14361487
toolCall: db.collections.toolCalls,
14371488
})
1438-
.select(({ text, toolCall }) => ({
1439-
order: coalesce(text._timeline_order, toolCall._timeline_order, `~`),
1489+
.leftJoin({ firstDelta: textFirstDeltaSource }, ({ text, firstDelta }) =>
1490+
eq(text.key, firstDelta.text_id)
1491+
)
1492+
.select(({ text, toolCall, firstDelta }) => ({
1493+
order: coalesce(firstDelta.order, text._seq, toolCall._seq, -1),
14401494
run_id: coalesce(text.run_id, toolCall.run_id, ``),
14411495
text: caseWhen(text.key, {
14421496
key: text.key,
14431497
run_id: text.run_id,
1444-
order: coalesce(text._timeline_order, `~`),
1498+
order: coalesce(firstDelta.order, text._seq, -1),
14451499
status: text.status,
14461500
}),
14471501
textContent: concat(
14481502
toArray(
14491503
q
14501504
.from({ textChunk: db.collections.textDeltas })
14511505
.where(({ textChunk }) => eq(textChunk.text_id, text.key))
1452-
.orderBy(({ textChunk }) =>
1453-
coalesce(textChunk._timeline_order, `~`)
1454-
)
1455-
.orderBy(({ textChunk }) => textChunk.key)
1506+
.orderBy(({ textChunk }) => coalesce(textChunk._seq, -1))
14561507
.select(({ textChunk }) => textChunk.delta)
14571508
)
14581509
),
14591510
toolCall: caseWhen(toolCall.key, {
14601511
key: toolCall.key,
14611512
run_id: toolCall.run_id,
1462-
order: coalesce(toolCall._timeline_order, `~`),
1513+
order: coalesce(toolCall._seq, -1),
14631514
tool_call_id: toolCall.tool_call_id,
14641515
tool_name: toolCall.tool_name,
14651516
status: toolCall.status,
@@ -1486,7 +1537,7 @@ function buildEntityTimelineQuery(
14861537
.select(({ reasoning }) => ({
14871538
key: reasoning.key,
14881539
run_id: reasoning.run_id,
1489-
order: coalesce(reasoning._timeline_order, `~`),
1540+
order: coalesce(reasoning._seq, -1),
14901541
status: reasoning.status,
14911542
summary_title: reasoning.summary_title,
14921543
encrypted: reasoning.encrypted,
@@ -1497,10 +1548,7 @@ function buildEntityTimelineQuery(
14971548
.where(({ reasoningChunk }) =>
14981549
eq(reasoningChunk.reasoning_id, reasoning.key)
14991550
)
1500-
.orderBy(({ reasoningChunk }) =>
1501-
coalesce(reasoningChunk._timeline_order, `~`)
1502-
)
1503-
.orderBy(({ reasoningChunk }) => reasoningChunk.key)
1551+
.orderBy(({ reasoningChunk }) => coalesce(reasoningChunk._seq, -1))
15041552
.select(({ reasoningChunk }) => reasoningChunk.delta)
15051553
)
15061554
),
@@ -1667,6 +1715,7 @@ type EntityQueryBuilder = (q: InitialQueryBuilder) => QueryBuilder<any>
16671715
export function createEntityIncludesQuery(
16681716
db: EntityStreamDB
16691717
): EntityQueryBuilder {
1718+
ensureEntityTimelineIndexes(db)
16701719
const seedCollection = getTimelineSeedCollection(db)
16711720
const runsCollection = getEntityRunsCollection(db)
16721721
const inboxCollection = getEntityInboxCollection(db)

‎packages/agents-runtime/test/create-handler.test.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -648,6 +648,23 @@ describe(`createRuntimeHandler`, () => {
648648
)
649649
})
650650

651+
it(`registerTypes includes URL and cause when registration fetch throws`, async () => {
652+
defineEntity(`network-agent`, { handler: async () => {} })
653+
654+
vi.spyOn(globalThis, `fetch`).mockRejectedValue(
655+
new Error(`fetch failed`, { cause: new Error(`ECONNREFUSED 127.0.0.1`) })
656+
)
657+
658+
const handler = createRuntimeHandler({
659+
baseUrl: `http://localhost:3000`,
660+
handlerUrl: `http://localhost:4000/electric-agents`,
661+
})
662+
663+
await expect(handler.registerTypes()).rejects.toThrow(
664+
`Failed to register type "network-agent" at http://localhost:3000/_electric/entity-types: fetch failed: ECONNREFUSED 127.0.0.1`
665+
)
666+
})
667+
651668
it(`registers entity types with a webhook default dispatch policy`, async () => {
652669
defineEntity(`schema-agent`, {
653670
description: `Schema agent`,

‎packages/agents-runtime/test/entity-timeline.test.ts‎

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1835,6 +1835,61 @@ describe(`entity includes query`, () => {
18351835
})
18361836
})
18371837

1838+
it(`interleaves top-level live timeline rows by runtime timeline order`, async () => {
1839+
const { collections, sync } = createEntityCollections()
1840+
const liveQuery = createLiveQueryCollection({
1841+
query: createEntityTimelineQuery({ collections } as any),
1842+
startSync: true,
1843+
})
1844+
await liveQuery.preload()
1845+
1846+
sync.inbox.insert({
1847+
key: `msg-1`,
1848+
_timeline_order: order(1),
1849+
from: `user`,
1850+
payload: `first`,
1851+
timestamp: `2026-04-15T18:00:00.000Z`,
1852+
status: `processed`,
1853+
})
1854+
sync.runs.insert({
1855+
key: `run-1`,
1856+
_timeline_order: order(2),
1857+
status: `started`,
1858+
})
1859+
sync.texts.insert({
1860+
key: `text-1`,
1861+
_timeline_order: order(3),
1862+
run_id: `run-1`,
1863+
status: `streaming`,
1864+
})
1865+
sync.textDeltas.insert({
1866+
key: `td-1`,
1867+
text_id: `text-1`,
1868+
run_id: `run-1`,
1869+
delta: `First response`,
1870+
})
1871+
sync.inbox.insert({
1872+
key: `msg-2`,
1873+
_timeline_order: order(4),
1874+
from: `user`,
1875+
payload: `second`,
1876+
timestamp: `2026-04-15T18:01:00.000Z`,
1877+
status: `processed`,
1878+
})
1879+
await new Promise((r) => setTimeout(r, 50))
1880+
1881+
const rows = getData(liveQuery)
1882+
expect(
1883+
rows.map((row) =>
1884+
row.inbox
1885+
? `inbox:${row.inbox.key}`
1886+
: row.run
1887+
? `run:${row.run.key}`
1888+
: `other:${row.$key}`
1889+
)
1890+
).toEqual([`inbox:msg-1`, `run:run-1`, `inbox:msg-2`])
1891+
})
1892+
18381893
it(`reacts to toolCall updates`, async () => {
18391894
const { collections, sync } = createEntityCollections()
18401895
const queryFn = createEntityIncludesQuery({ collections } as any)
@@ -2497,6 +2552,91 @@ describe(`entity includes query`, () => {
24972552
expect(item.text?.content).toBe(`Hello world`)
24982553
})
24992554

2555+
it(`live items.text.content orders deltas by stream sequence, not key`, async () => {
2556+
// Production regression: delta keys like `msg-0:10` sort before
2557+
// `msg-0:2` lexicographically. The live query must use stream
2558+
// sequence order so streamed text stays in model output order.
2559+
const { collections, sync } = createTimelineCollections()
2560+
const liveQuery = createLiveQueryCollection({
2561+
query: createEntityTimelineQuery({ collections } as any),
2562+
startSync: true,
2563+
})
2564+
await liveQuery.preload()
2565+
2566+
sync.runs.insert({ key: `run-0`, status: `started` })
2567+
sync.texts.insert({
2568+
key: `msg-0`,
2569+
run_id: `run-0`,
2570+
status: `streaming`,
2571+
})
2572+
for (let i = 0; i <= 10; i++) {
2573+
sync.textDeltas.insert({
2574+
key: `msg-0:${i}`,
2575+
text_id: `msg-0`,
2576+
run_id: `run-0`,
2577+
delta: `[${i}]`,
2578+
})
2579+
}
2580+
sync.texts.update({
2581+
key: `msg-0`,
2582+
run_id: `run-0`,
2583+
status: `completed`,
2584+
})
2585+
sync.runs.update({
2586+
key: `run-0`,
2587+
status: `completed`,
2588+
finish_reason: `stop`,
2589+
})
2590+
await new Promise((r) => setTimeout(r, 50))
2591+
2592+
const rows = getRows(liveQuery)
2593+
const runRow = rows.find((r) => r.run?.key === `run-0`)
2594+
expect(runRow).toBeTruthy()
2595+
const items = Array.from(runRow.run.items.toArray) as Array<any>
2596+
expect(items).toHaveLength(1)
2597+
expect(items[0].text?.content).toBe(`[0][1][2][3][4][5][6][7][8][9][10]`)
2598+
})
2599+
2600+
it(`orders live run text items by first delta sequence, not text row creation`, async () => {
2601+
const { collections, sync } = createTimelineCollections()
2602+
const liveQuery = createLiveQueryCollection({
2603+
query: createEntityTimelineQuery({ collections } as any),
2604+
startSync: true,
2605+
})
2606+
await liveQuery.preload()
2607+
2608+
sync.runs.insert({ key: `run-0`, status: `started` })
2609+
sync.texts.insert({
2610+
key: `msg-0`,
2611+
run_id: `run-0`,
2612+
status: `streaming`,
2613+
})
2614+
sync.toolCalls.insert({
2615+
key: `tc-0`,
2616+
run_id: `run-0`,
2617+
tool_call_id: `tc-0`,
2618+
tool_name: `bash`,
2619+
status: `completed`,
2620+
result: { content: [{ type: `text`, text: `/private/tmp\n` }] },
2621+
})
2622+
sync.textDeltas.insert({
2623+
key: `msg-0:0`,
2624+
text_id: `msg-0`,
2625+
run_id: `run-0`,
2626+
delta: `We're in /private/tmp.`,
2627+
})
2628+
await new Promise((r) => setTimeout(r, 50))
2629+
2630+
const rows = getRows(liveQuery)
2631+
const runRow = rows.find((r) => r.run?.key === `run-0`)
2632+
expect(runRow).toBeTruthy()
2633+
const items = Array.from(runRow.run.items.toArray) as Array<any>
2634+
expect(items.map((item) => item.toolCall?.key ?? item.text?.key)).toEqual(
2635+
[`tc-0`, `msg-0`]
2636+
)
2637+
expect(items[1]?.text?.content).toBe(`We're in /private/tmp.`)
2638+
})
2639+
25002640
it(`reasoning content survives multiple run-row updates in sequence`, async () => {
25012641
// Even closer to production: the run row gets updated MULTIPLE
25022642
// times (each delta + status flip), which may invalidate the

0 commit comments

Comments
 (0)