auto-model-router 0.2.9 → 0.2.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,14 +7,14 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "auto-model-router: a local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
10
- "version": "0.2.9",
10
+ "version": "0.2.10",
11
11
  "pluginRoot": "."
12
12
  },
13
13
  "plugins": [
14
14
  {
15
15
  "name": "auto-model-router",
16
16
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter. Runs in-process, routes per turn by price and task complexity, with budget caps, mid-stream escalation, and cache-aware hysteresis.",
17
- "version": "0.2.9",
17
+ "version": "0.2.10",
18
18
  "author": {
19
19
  "name": "drewappling",
20
20
  "email": "drewappling@gmail.com"
package/README.md CHANGED
@@ -620,6 +620,13 @@ shows which model produced which turn. Those messages feed back into the next
620
620
  `context_assemble`, so the model you switch *to* inherits what the model you
621
621
  switched *from* actually did.
622
622
 
623
+ A recorded turn is the whole **user-visible** turn, not one record per upstream
624
+ request. An agentic turn is a loop of dispatches — each tool round-trip finishes
625
+ with `tool_calls` and emits almost no text, and the last user message does not
626
+ move while the loop runs. So the router buffers the assistant's narration across
627
+ the loop and writes it once, together with the closing synthesis, when the
628
+ assistant actually yields back to the user.
629
+
623
630
  Write-backs are queued, bounded, and never awaited: agentdox is an enrichment,
624
631
  not a dependency. If it is unreachable the turn routes and dispatches normally,
625
632
  and a pinned block keeps being served.
@@ -1,7 +1,7 @@
1
1
  # agentdox bridge — handoff
2
2
 
3
- **Status:** implemented, typechecks clean, 409 tests pass, injection verified end-to-end
4
- through omp. **One open bug** in the write-back path (§5). Pick up there.
3
+ **Status:** implemented, typechecks clean, 438 tests pass, injection verified end-to-end
4
+ through omp. The write-back bug in §5 is **fixed**; `context.recordTurns` is safe to enable.
5
5
 
6
6
  Design rationale (why it is built this way):
7
7
  `E:/projects/agentdox/docs/architecture/router-context-bridge.md`.
@@ -86,44 +86,56 @@ Two more traps hit during this work:
86
86
  - Long-running interactive omp sessions hold their own embedded routers from whenever they
87
87
  started. Check `Get-Process omp` before trusting a result.
88
88
 
89
- ## 5. OPEN BUG assistant text is under-captured on the omp path
90
-
91
- **Verified working:** injection reaches the model through omp. The dispatched system message
92
- was confirmed to contain the block (`containsBlock=true`), and on a direct
93
- `/v1/chat/completions` dispatch the model answered *from* the injected memory, verbatim:
94
-
95
- > "The router pins one agentdox context block per conversation, refreshing it only on model
96
- > switches, retries, or TTL."
97
-
98
- **Broken:** through omp, the recorded assistant turn is near-empty `assistantChars=4`
99
- (literally `" high"`) while omp displayed several paragraphs. The session and the model
100
- attribution (`refs: ["model:…", "tier:…"]`) are written correctly; only the assistant
101
- *content* is wrong.
102
-
103
- `assistantText` is accumulated in `src/server/turn.ts` from `ev.type === "text"` deltas
104
- inside the chunk loop. Leads, roughly in order of suspicion:
105
-
106
- 1. **omp issues more than one upstream request per visible turn** (e.g. a title/summary call
107
- on the `smol` role, which also resolves to `auto` → the router). The 4-char record may be
108
- an auxiliary request, with the real answer on a different conversation key. Check by
109
- logging `conversationKey` alongside the record line and counting turns per omp invocation.
110
- 2. **Content arrives as `reasoning` deltas, not `text`**, for reasoning-capable models the
111
- accumulator deliberately ignores `reasoning`. If so, decide whether the transcript should
112
- capture reasoning (probably not) or whether `text` is arriving under a chunk shape the
113
- interpreter is not mapping to a `text` event.
114
- 3. **Escalation resets the buffer.** `assistantText` is declared per attempt; if a turn
115
- commits on a later attempt the earlier text is correctly dropped, but verify the committed
116
- attempt is the one being recorded.
117
-
118
- Start by adding `conversationKey` and `attempt` to the `agentdox record turn` debug line and
119
- running one omp invocation that distinguishes lead 1 from the others immediately.
89
+ ## 5. FIXEDone record per dispatch, not per turn
90
+
91
+ **Symptom:** through omp the recorded assistant turn was near-empty
92
+ `assistantChars=4` (literally `" high"`) while omp displayed several paragraphs. Session and
93
+ model attribution (`refs: ["model:…", "tier:…"]`) were always correct; only the assistant
94
+ *content* was wrong.
95
+
96
+ **Root cause none of the three leads originally listed here.** The text was not
97
+ under-captured; the *wrong requests* were being recorded. A user-visible turn is not one
98
+ upstream request, it is a whole tool loop of them. Live ledger proof, one conversation key,
99
+ `wasted=0` and `attempt=0` on every row:
100
+
101
+ | dispatch | `finish_reason` | `toolLoopDepth` | completion tokens |
102
+ | --- | --- | --- | --- |
103
+ | 1 | `tool_calls` | 0 | 339 |
104
+ | 2–6 | `tool_calls` | 2, 4, 6, 8, 10 | 91, 68, 198, 78, 44 |
105
+ | 7 | **`stop`** | 12 | **596** |
106
+ | 8 | `tool_calls` | 0 *(next turn)* | 167 |
107
+
108
+ Each tool round-trip is its own dispatch, finishing with `tool_calls` and emitting almost no
109
+ `text` the payload is tool calls. `" high"` was a stray word of preamble, a *complete*
110
+ record of a fragment rather than a truncated answer. Only the final `stop` dispatch carries
111
+ the synthesis. `recordTurn` fired on all ~13, and the last writer won.
112
+
113
+ The same root cause explains the §6 pollution: `lastUserText` walks back to the last `user`
114
+ message, which does **not** move while a tool loop runs, so the identical user text was
115
+ appended once per round-trip too.
116
+
117
+ **Fix.** `TurnRecord` gained `turnEnded` (`finishReason !== "tool_calls"`, set in
118
+ `src/server/turn.ts`). The bridge buffers assistant fragments per conversation in a
119
+ process-local map and flushes **once**, when the assistant yields back to the user, writing
120
+ the loop's narration plus the closing synthesis as one message. Bounded by
121
+ `MAX_PENDING_CHARS` / `MAX_PENDING_CONVERSATIONS`, since a turn that dies without a terminal
122
+ dispatch never flushes. The terminal dispatch is appended past the char cap, so the model's
123
+ actual answer is never what gets dropped.
124
+
125
+ Covered by `test/context-bridge.test.ts` (loop records one turn; a running loop writes
126
+ nothing; interleaved conversations buffer independently) and `test/turn.test.ts` (the
127
+ `tool_calls` → `turnEnded=false` wiring). All four were verified to FAIL against the old
128
+ behavior. `tools/agentdox-e2e.ts` step 6 proves it against a live server: four dispatches →
129
+ exactly one user and one assistant message.
120
130
 
121
131
  ## 6. Also worth doing
122
132
 
123
133
  - **Context pollution.** `context_assemble` includes recent session messages, so recorded
124
- test turns feed back into the next block (already observed: the block contained
125
- `assistant:: high` from a prior run). Real usage is fine, but noisy test turns compound.
126
- Consider a `sessionLimit` override for the bridge, or excluding router-authored sessions.
134
+ test turns feed back into the next block (observed: a block containing `assistant:: high`
135
+ from a prior run). The §5 fix removes the ~13×-per-turn duplication that made this acute,
136
+ but noisy *test* turns still compound `tools/agentdox-e2e.ts` writes real sessions into
137
+ the scope every run. Consider a `sessionLimit` override for the bridge, or excluding
138
+ router-authored sessions.
127
139
  - **`context.timeoutMs` is 3000ms** and failures degrade silently at `debug` level by design.
128
140
  If agentdox is cold this can no-op invisibly. Consider logging the first failure at `warn`.
129
141
  - **Four copies of this project exist** on this machine: this repo, the research checkout,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-model-router",
3
- "version": "0.2.9",
3
+ "version": "0.2.10",
4
4
  "private": false,
5
5
  "description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
6
6
  "type": "module",
@@ -49,6 +49,29 @@ function renderBlock(raw: string, maxChars: number): string {
49
49
  ].join("\n");
50
50
  }
51
51
 
52
+ /**
53
+ * Cap on assistant text buffered for one in-flight turn, chars. A memory guard
54
+ * only, not a quality knob: a 200-round-trip loop must not buffer without
55
+ * limit. The dispatch that ENDS the turn is appended past this cap, so the
56
+ * model's actual answer is never the thing that gets dropped.
57
+ */
58
+ const MAX_PENDING_CHARS = 64_000;
59
+
60
+ /**
61
+ * Cap on conversations buffering fragments at once. A turn that dies without a
62
+ * terminal dispatch (client disconnect, upstream error) leaves its buffer
63
+ * behind, so this map is bounded rather than trusted to drain.
64
+ */
65
+ const MAX_PENDING_CONVERSATIONS = 64;
66
+
67
+ /** Appends a mid-loop fragment, bounded. Blank-line joined: separate thoughts. */
68
+ function appendFragment(prior: string, next: string): string {
69
+ if (next === "") return prior;
70
+ if (prior === "") return next.slice(0, MAX_PENDING_CHARS);
71
+ if (prior.length >= MAX_PENDING_CHARS) return prior;
72
+ return `${prior}\n\n${next}`.slice(0, MAX_PENDING_CHARS);
73
+ }
74
+
52
75
  export function createContextBridge(opts: BridgeOptions): ContextBridge {
53
76
  const { client, store, log, maxStalenessMs, maxBlockChars, recordTurns, maxQueue } = opts;
54
77
 
@@ -57,6 +80,10 @@ export function createContextBridge(opts: BridgeOptions): ContextBridge {
57
80
  let queue: Promise<void> = Promise.resolve();
58
81
  let queued = 0;
59
82
  let closed = false;
83
+ // Assistant text buffered across an in-flight tool loop, keyed by
84
+ // conversation. Process-local by design: a turn never spans a restart, and
85
+ // losing a buffer whose turn already died costs nothing.
86
+ const pending = new Map<string, string>();
60
87
 
61
88
  const shouldRefresh = (input: ContextResolveInput, pin: ContextPin | null): boolean => {
62
89
  if (pin === null) return true;
@@ -115,7 +142,34 @@ export function createContextBridge(opts: BridgeOptions): ContextBridge {
115
142
 
116
143
  recordTurn(rec: TurnRecord) {
117
144
  if (!recordTurns || closed || rec.scope === "") return;
118
- if (rec.userText === "" && rec.assistantText === "") return;
145
+
146
+ // Mid-loop dispatch: keep the fragment and wait for the turn to end.
147
+ // Writing here is what produced ~13 near-empty assistant messages per
148
+ // turn plus ~13 copies of an unchanged user message, which both lost
149
+ // the real answer and poisoned later context assembly.
150
+ if (!rec.turnEnded) {
151
+ if (rec.assistantText === "") return;
152
+ const prior = pending.get(rec.conversationKey);
153
+ if (prior === undefined && pending.size >= MAX_PENDING_CONVERSATIONS) {
154
+ log.debug("agentdox pending transcript budget full; dropping fragment", { conversations: pending.size });
155
+ return;
156
+ }
157
+ pending.set(rec.conversationKey, appendFragment(prior ?? "", rec.assistantText));
158
+ return;
159
+ }
160
+
161
+ // Turn over. Flush the whole loop's narration plus this dispatch's
162
+ // synthesis as ONE assistant message, attributed to the served model.
163
+ const buffered = pending.get(rec.conversationKey) ?? "";
164
+ pending.delete(rec.conversationKey);
165
+ const assistantText =
166
+ buffered === ""
167
+ ? rec.assistantText
168
+ : rec.assistantText === ""
169
+ ? buffered
170
+ : `${buffered}\n\n${rec.assistantText}`;
171
+
172
+ if (rec.userText === "" && assistantText === "") return;
119
173
  if (queued >= maxQueue) {
120
174
  log.debug("agentdox write-back queue full; dropping turn record", { queued });
121
175
  return;
@@ -134,7 +188,7 @@ export function createContextBridge(opts: BridgeOptions): ContextBridge {
134
188
  // every turn shows WHICH model produced it.
135
189
  const refs = [`model:${rec.slug}`, `tier:${rec.tier}`];
136
190
  if (rec.userText !== "") await client.append(sessionId, "user", rec.userText, []);
137
- if (rec.assistantText !== "") await client.append(sessionId, "assistant", rec.assistantText, refs);
191
+ if (assistantText !== "") await client.append(sessionId, "assistant", assistantText, refs);
138
192
  })
139
193
  .catch((err: unknown) => {
140
194
  log.debug("agentdox write-back failed", { error: err instanceof Error ? err.message : String(err) });
@@ -150,6 +204,7 @@ export function createContextBridge(opts: BridgeOptions): ContextBridge {
150
204
 
151
205
  close() {
152
206
  closed = true;
207
+ pending.clear();
153
208
  },
154
209
  };
155
210
  }
@@ -49,10 +49,23 @@ export interface TurnRecord {
49
49
  /** Title used if this is the first turn and a session must be created. */
50
50
  title: string;
51
51
  userText: string;
52
+ /** Text THIS dispatch produced. Fragments are joined across a tool loop. */
52
53
  assistantText: string;
53
54
  /** The slug that actually served the turn — the model attribution. */
54
55
  slug: string;
55
56
  tier: string;
57
+ /**
58
+ * Whether the assistant yielded control back to the user — i.e. the upstream
59
+ * finish reason was NOT `tool_calls`.
60
+ *
61
+ * A user-visible turn is many dispatches: every tool round-trip is its own
62
+ * request, and only the last carries the model's synthesis. The intermediate
63
+ * ones are almost pure tool calls with a few stray words of text, and the
64
+ * last *user* message does not move while the loop runs. False therefore
65
+ * means "buffer this fragment, the turn is still running" — recording it as
66
+ * a turn would write a near-empty answer and re-append the same user text.
67
+ */
68
+ turnEnded: boolean;
56
69
  }
57
70
 
58
71
  export interface ContextBridge {
@@ -448,22 +448,28 @@ export async function runTurn(
448
448
 
449
449
  // Record the settled turn into agentdox, attributed to the model that
450
450
  // actually served it. Queued and never awaited: the transcript is an
451
- // artifact of the turn, not a precondition for finishing it.
451
+ // artifact of the turn, not a precondition for finishing it. A
452
+ // `tool_calls` finish means the assistant is still working, so the bridge
453
+ // buffers the fragment rather than writing a near-empty turn.
452
454
  if (doxActive) {
455
+ const userText = lastUserText(req);
456
+ const turnEnded = finishReason !== "tool_calls";
453
457
  log.debug("agentdox record turn", {
454
- userChars: lastUserText(req).length,
458
+ conversationKey: req.conversationKey.slice(0, 8),
459
+ userChars: userText.length,
455
460
  assistantChars: assistantText.length,
456
- messages: req.messages.length,
457
- roles: req.messages.map((m) => m.role).join(","),
461
+ finishReason,
462
+ turnEnded,
458
463
  });
459
464
  bridge.recordTurn({
460
465
  scope: doxScope,
461
466
  conversationKey: req.conversationKey,
462
467
  title: sessionTitle(req),
463
- userText: lastUserText(req),
468
+ userText,
464
469
  assistantText,
465
470
  slug: servedSlug ?? decision.slug,
466
471
  tier: decision.tier,
472
+ turnEnded,
467
473
  });
468
474
  }
469
475
 
@@ -3,7 +3,7 @@ import { describe, expect, test } from "bun:test";
3
3
  import type { AgentDoxClient } from "../src/context/agentdox.ts";
4
4
  import { createContextBridge } from "../src/context/bridge.ts";
5
5
  import { createContextStore } from "../src/context/store.ts";
6
- import type { ContextResolveInput } from "../src/context/types.ts";
6
+ import type { ContextResolveInput, TurnRecord } from "../src/context/types.ts";
7
7
  import { createLogger } from "../src/util/log.ts";
8
8
  import { openDb } from "../src/util/sqlite.ts";
9
9
  import { injectForTest } from "./helpers/inject.ts";
@@ -246,6 +246,7 @@ describe("context bridge write-back", () => {
246
246
  assistantText: "done",
247
247
  slug: "anthropic/claude-haiku-4.5",
248
248
  tier: "simple",
249
+ turnEnded: true,
249
250
  });
250
251
  bridge.recordTurn({
251
252
  scope: "ashlands",
@@ -255,6 +256,7 @@ describe("context bridge write-back", () => {
255
256
  assistantText: "ok",
256
257
  slug: "anthropic/claude-opus-4.5",
257
258
  tier: "hard",
259
+ turnEnded: true,
258
260
  });
259
261
  await bridge.flush();
260
262
 
@@ -280,6 +282,7 @@ describe("context bridge write-back", () => {
280
282
  assistantText: "a",
281
283
  slug: "x",
282
284
  tier: "simple",
285
+ turnEnded: true,
283
286
  });
284
287
  await bridge.flush();
285
288
  expect(client.appended).toHaveLength(0);
@@ -287,6 +290,96 @@ describe("context bridge write-back", () => {
287
290
  db.close();
288
291
  }
289
292
  });
293
+
294
+ /** One dispatch of a turn; `turnEnded` marks the one that yields to the user. */
295
+ function mkRecord(over: Partial<TurnRecord> & { turnEnded: boolean }): TurnRecord {
296
+ return {
297
+ scope: "ashlands",
298
+ conversationKey: "k1",
299
+ title: "movement fix",
300
+ userText: "fix movement",
301
+ assistantText: "",
302
+ slug: "z-ai/glm-5.3-flash",
303
+ tier: "simple",
304
+ ...over,
305
+ };
306
+ }
307
+
308
+ test("a tool loop records one turn, not one record per dispatch", async () => {
309
+ const client = mkClient();
310
+ const { bridge, db } = mkBridge(client);
311
+ try {
312
+ // One user-visible turn: five tool round-trips, then the synthesis.
313
+ // Every dispatch carries the SAME unchanged user text — recording per
314
+ // dispatch appended it once per round-trip and buried the real answer
315
+ // under near-empty assistant messages.
316
+ for (const assistantText of ["let me look", "", "checking the ledger", "", "almost there"]) {
317
+ bridge.recordTurn(mkRecord({ assistantText, turnEnded: false }));
318
+ }
319
+ bridge.recordTurn(mkRecord({ assistantText: "fixed: the damping was inverted.", turnEnded: true }));
320
+ await bridge.flush();
321
+
322
+ expect(client.sessionsCreated).toBe(1);
323
+ const users = client.appended.filter((m) => m.role === "user");
324
+ const assistants = client.appended.filter((m) => m.role === "assistant");
325
+ expect(users).toHaveLength(1);
326
+ expect(assistants).toHaveLength(1);
327
+ // The loop's narration AND the closing synthesis survive, in order.
328
+ expect(assistants[0]?.content).toBe(
329
+ "let me look\n\nchecking the ledger\n\nalmost there\n\nfixed: the damping was inverted.",
330
+ );
331
+ expect(assistants[0]?.refs).toEqual(["model:z-ai/glm-5.3-flash", "tier:simple"]);
332
+ } finally {
333
+ db.close();
334
+ }
335
+ });
336
+
337
+ test("a tool loop still running writes nothing", async () => {
338
+ const client = mkClient();
339
+ const { bridge, db } = mkBridge(client);
340
+ try {
341
+ bridge.recordTurn(mkRecord({ assistantText: "let me look", turnEnded: false }));
342
+ await bridge.flush();
343
+ // The assistant has not answered yet. Writing here is what produced the
344
+ // 4-char transcripts, so mid-loop must stay silent.
345
+ expect(client.appended).toHaveLength(0);
346
+ expect(client.sessionsCreated).toBe(0);
347
+ } finally {
348
+ db.close();
349
+ }
350
+ });
351
+
352
+ test("interleaved conversations buffer independently", async () => {
353
+ const client = mkClient();
354
+ const { bridge, db } = mkBridge(client);
355
+ try {
356
+ bridge.recordTurn(mkRecord({ conversationKey: "k1", assistantText: "k1 narration", turnEnded: false }));
357
+ bridge.recordTurn(mkRecord({ conversationKey: "k2", assistantText: "k2 narration", turnEnded: false }));
358
+ bridge.recordTurn(mkRecord({ conversationKey: "k2", assistantText: "k2 answer", turnEnded: true }));
359
+ bridge.recordTurn(mkRecord({ conversationKey: "k1", assistantText: "k1 answer", turnEnded: true }));
360
+ await bridge.flush();
361
+
362
+ const assistants = client.appended.filter((m) => m.role === "assistant");
363
+ expect(assistants).toHaveLength(2);
364
+ expect(assistants[0]?.content).toBe("k2 narration\n\nk2 answer");
365
+ expect(assistants[1]?.content).toBe("k1 narration\n\nk1 answer");
366
+ } finally {
367
+ db.close();
368
+ }
369
+ });
370
+
371
+ test("a silent turn still records the user message", async () => {
372
+ const client = mkClient();
373
+ const { bridge, db } = mkBridge(client);
374
+ try {
375
+ bridge.recordTurn(mkRecord({ assistantText: "", turnEnded: true }));
376
+ await bridge.flush();
377
+ expect(client.appended.filter((m) => m.role === "user")).toHaveLength(1);
378
+ expect(client.appended.filter((m) => m.role === "assistant")).toHaveLength(0);
379
+ } finally {
380
+ db.close();
381
+ }
382
+ });
290
383
  });
291
384
 
292
385
  describe("context injection into the wire body", () => {
package/test/turn.test.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { createDisabledBridge } from "../src/context/bridge.ts";
3
+ import type { ContextBridge, TurnRecord } from "../src/context/types.ts";
3
4
  import type { CatalogSource } from "../src/catalog/types.ts";
4
5
  import type { EscalationConfig, RouterConfig } from "../src/config/types.ts";
5
6
  import { EMPTY_USAGE, type Ledger, type LedgerEntry, type UsageCounts } from "../src/cost/types.ts";
@@ -553,3 +554,53 @@ describe("exploration reaches the ledger", () => {
553
554
  expect(entries[0]?.exploredFrom).toBeNull();
554
555
  });
555
556
  });
557
+
558
+ describe("agentdox write-back sees the shape of the turn", () => {
559
+ function mkRecordingBridge(): { bridge: ContextBridge; records: TurnRecord[] } {
560
+ const records: TurnRecord[] = [];
561
+ return {
562
+ records,
563
+ bridge: {
564
+ enabled: true,
565
+ resolve: () => Promise.resolve(null),
566
+ recordTurn: (rec) => {
567
+ records.push(rec);
568
+ },
569
+ flush: () => Promise.resolve(),
570
+ close: () => {},
571
+ },
572
+ };
573
+ }
574
+
575
+ test("a tool_calls finish is a fragment; only a yielding finish ends the turn", async () => {
576
+ // A user-visible turn is many dispatches. The orchestrator must tell the
577
+ // bridge which one actually handed control back, or the transcript records
578
+ // a near-empty answer per tool round-trip and re-appends the same user
579
+ // text every time.
580
+ const { router } = mkRouter([
581
+ mkDecision("simple", "cheap/model", { escalateTo: null }),
582
+ mkDecision("simple", "cheap/model", { escalateTo: null }),
583
+ ]);
584
+ const { upstream } = mkUpstream([
585
+ { kind: "chunks", chunks: [startChunk("cheap/model"), textChunk("let me look"), finishChunk("tool_calls"), usageChunk({}, 0.0001)] },
586
+ { kind: "chunks", chunks: [startChunk("cheap/model"), textChunk("all done"), finishChunk("stop"), usageChunk({}, 0.0001)] },
587
+ ]);
588
+ const { ledger } = mkLedger();
589
+ const { store } = mkConversations();
590
+ const { sink, errors } = mkSink();
591
+ const { bridge, records } = mkRecordingBridge();
592
+ // doxActive needs a scope; the request header supplies it.
593
+ const req: NormRequest = { ...mkReq(), agentdoxScope: "proj" };
594
+ const deps = { config: mkConfig({ enabled: false }), router, upstream, ledger, conversations: store, catalog, context: bridge };
595
+
596
+ await runTurn(req, sink, deps, new AbortController().signal);
597
+ await runTurn(req, sink, deps, new AbortController().signal);
598
+
599
+ expect(errors).toHaveLength(0);
600
+ expect(records).toHaveLength(2);
601
+ expect(records[0]?.turnEnded).toBe(false);
602
+ expect(records[0]?.assistantText).toBe("let me look");
603
+ expect(records[1]?.turnEnded).toBe(true);
604
+ expect(records[1]?.assistantText).toBe("all done");
605
+ });
606
+ });
@@ -93,6 +93,7 @@ bridge.recordTurn({
93
93
  assistantText: "yes - refs carry model: and tier:.",
94
94
  slug: "anthropic/claude-haiku-4.5",
95
95
  tier: "simple",
96
+ turnEnded: true,
96
97
  });
97
98
  await bridge.flush();
98
99
 
@@ -118,6 +119,52 @@ if (mine !== undefined) {
118
119
  );
119
120
  }
120
121
 
122
+ // 6. A tool loop must record ONE turn, not one record per dispatch. This is the
123
+ // regression that made transcripts useless: every tool round-trip is its own
124
+ // dispatch, finishing with `tool_calls` and carrying an UNCHANGED last user
125
+ // message, so recording per dispatch wrote a near-empty assistant message
126
+ // and a duplicate user message per round-trip.
127
+ const loopKey = `${conversationKey}-loop`;
128
+ const loopTitle = `bridge e2e loop ${loopKey}`;
129
+ function loopDispatch(assistantText: string, turnEnded: boolean): void {
130
+ bridge.recordTurn({
131
+ scope,
132
+ conversationKey: loopKey,
133
+ title: loopTitle,
134
+ userText: "why did cache read fall?",
135
+ assistantText,
136
+ slug: "z-ai/glm-5.3-flash",
137
+ tier: "simple",
138
+ turnEnded,
139
+ });
140
+ }
141
+ for (const fragment of ["reading the ledger", "", "checking the cache column"]) loopDispatch(fragment, false);
142
+ loopDispatch("the breakpoint index drifted every turn.", true);
143
+ await bridge.flush();
144
+
145
+ const loopRes = await fetch(`${baseUrl}/sessions?scope=${encodeURIComponent(scope)}`, {
146
+ headers: { authorization: `Bearer ${token}` },
147
+ });
148
+ const loopSessions = (await loopRes.json()) as { id: string; title: string }[];
149
+ const loopSession = loopSessions.find((s) => s.title === loopTitle);
150
+ check("tool loop created a session", loopSession !== undefined, loopSession?.id ?? "not found");
151
+
152
+ if (loopSession !== undefined) {
153
+ const full = await fetch(`${baseUrl}/sessions/${loopSession.id}`, {
154
+ headers: { authorization: `Bearer ${token}` },
155
+ });
156
+ const session = (await full.json()) as { messages: { role: string; content: string }[] };
157
+ const users = session.messages.filter((m) => m.role === "user");
158
+ const assistants = session.messages.filter((m) => m.role === "assistant");
159
+ check("four dispatches wrote exactly one user message", users.length === 1, `${users.length} user messages`);
160
+ check("four dispatches wrote exactly one assistant message", assistants.length === 1, `${assistants.length} assistant messages`);
161
+ check(
162
+ "the loop's narration and the closing synthesis both survive",
163
+ assistants[0]?.content === "reading the ledger\n\nchecking the cache column\n\nthe breakpoint index drifted every turn.",
164
+ JSON.stringify(assistants[0]?.content ?? ""),
165
+ );
166
+ }
167
+
121
168
  db.close();
122
169
  console.log(failures === 0 ? "\nAll bridge e2e checks passed." : `\n${failures} check(s) failed.`);
123
170
  process.exit(failures === 0 ? 0 : 1);