@agent-native/core 0.161.2 → 0.161.4

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.
@@ -26,8 +26,19 @@ import { buildCaptionSegmentsFromText } from "../shared/transcript-segments.js";
26
26
  import { booleanParam } from "./lib/cli-params.js";
27
27
  import { isAutoTitleReplaceable } from "./lib/title-source.js";
28
28
 
29
- function nativeSegmentsJson(fullText: string): string {
30
- return JSON.stringify(buildCaptionSegmentsFromText(fullText));
29
+ // web-speech and macos-native are both mic-only engines — see
30
+ // transcription-engine.ts's file header. When a caller sends fullText with
31
+ // no segments (word-level timings were never captured), there's no
32
+ // per-line source to preserve, but for these two engines there's also no
33
+ // ambiguity: every word came from the mic. Leaving source undefined here
34
+ // falls through to resolveSpeaker's default and renders the whole thing as
35
+ // "Them". Whisper mixes mic + system, so it has no safe single-speaker guess.
36
+ function nativeSegmentsJson(
37
+ fullText: string,
38
+ engineSource?: "web-speech" | "macos-native" | "whisper",
39
+ ): string {
40
+ const source = engineSource && engineSource !== "whisper" ? "mic" : undefined;
41
+ return JSON.stringify(buildCaptionSegmentsFromText(fullText, null, source));
31
42
  }
32
43
 
33
44
  // Real transcript segments supplied by a caller that already has accurate
@@ -45,6 +56,11 @@ const segmentSchema = z
45
56
  text: z.string(),
46
57
  // Stream the segment came from; the transcript UI maps mic→"Me", system→"Them".
47
58
  source: z.enum(["mic", "system"]).optional(),
59
+ // Diarized speaker for this segment, when the provider identifies one.
60
+ // Declared so zod keeps it: an undeclared key is stripped before the array
61
+ // is serialized, which would drop a provider's speaker labels on save and
62
+ // leave the transcript unable to tell its speakers apart on reload.
63
+ speaker: z.string().nullable().optional(),
48
64
  })
49
65
  .transform((s) => {
50
66
  if (s.endMs < s.startMs) {
@@ -74,7 +90,7 @@ export default defineAction({
74
90
  .array(segmentSchema)
75
91
  .optional()
76
92
  .describe(
77
- "Real transcript segments with accurate timestamps (ms). When provided, stored verbatim instead of synthesizing timings from fullText.",
93
+ "Transcript segments with per-segment timings (ms) and the `mic`/`system` stream each came from. Stored verbatim when provided, instead of synthesizing timings from fullText. Timings are the engine's own where it reported them; the mic-only engines report none, so callers may send estimates to keep each segment's speaker.",
78
94
  ),
79
95
  overwriteReady: booleanParam
80
96
  .default(false)
@@ -97,7 +113,7 @@ export default defineAction({
97
113
  const segmentsJson =
98
114
  args.segments && args.segments.length > 0
99
115
  ? JSON.stringify(args.segments)
100
- : nativeSegmentsJson(fullText);
116
+ : nativeSegmentsJson(fullText, args.source);
101
117
 
102
118
  const [current] = await db
103
119
  .select({
@@ -61,8 +61,99 @@ export interface SpeakerIdentity {
61
61
  initialsSource: AttendeeStackParticipant | string;
62
62
  isOwner: boolean;
63
63
  accentClass: string;
64
+ /** The capture could not tell speakers apart, so this transcript names
65
+ * nobody — the row renders without an avatar or label rather than claiming
66
+ * a speaker we cannot know. */
67
+ unattributed?: boolean;
64
68
  }
65
69
 
70
+ /**
71
+ * Whether a transcript carries enough signal to name who said what.
72
+ *
73
+ * A capture that only ever produced one speaker signal cannot distinguish two
74
+ * people: the mic-only fallback engines tag every segment `mic` (the remote
75
+ * side reaches the transcript only as bleed into the same microphone), and
76
+ * cloud transcription of a single mixed track tags nothing at all. Attributing
77
+ * those to the recording owner reads as fact and is wrong for every line the
78
+ * other person spoke — including in the AI summary and action items derived
79
+ * from it. A per-segment `speaker` label from a diarizing provider counts as
80
+ * signal even when `source` is absent.
81
+ *
82
+ * Only meaningful when two people could have spoken; a solo recording that is
83
+ * all mic genuinely is all one person.
84
+ */
85
+ export function transcriptDistinguishesSpeakers(
86
+ segments: TranscriptSegment[],
87
+ participants: AttendeeStackParticipant[],
88
+ ownerEmail?: string | null,
89
+ ): boolean {
90
+ if (countPossibleSpeakers(participants, ownerEmail) < 2) return true;
91
+ const signals = new Set<string>();
92
+ for (const segment of segments) {
93
+ const signal = speakerSignal(segment);
94
+ if (signal) signals.add(signal);
95
+ if (signals.size > 1) return true;
96
+ }
97
+ return false;
98
+ }
99
+
100
+ /**
101
+ * What one segment claims about who was speaking, as a comparable key.
102
+ *
103
+ * A generic placeholder is not an identity — it names a side of the
104
+ * conversation, which is what `source` already says. Counting `speaker: "Me"`
105
+ * and a plain `source: "mic"` as two different speakers would mark a mic-only
106
+ * transcript distinguishable and hand the remote side's bleed back to the
107
+ * owner's name, so placeholders resolve to the side they mean instead. A real
108
+ * name wins over `source`, since a diarizing provider knows more than the
109
+ * stream split does; a placeholder yields to it.
110
+ */
111
+ function speakerSignal(segment: TranscriptSegment): string | null {
112
+ const speaker = segment.speaker?.trim();
113
+ const side = placeholderSide(speaker);
114
+ if (speaker && !side) return `speaker:${normalizeSpeaker(speaker)}`;
115
+ if (segment.source) return `source:${segment.source}`;
116
+ // No placeholder and no source is an absence of information, not a side.
117
+ // Defaulting it to "system" here would make a transcript that mixes tagged
118
+ // and untagged segments look like two speakers.
119
+ return side ? `source:${side}` : null;
120
+ }
121
+
122
+ /**
123
+ * How many people could have spoken in this meeting.
124
+ *
125
+ * The participant roster is the calendar attendee list, which routinely omits
126
+ * the recording owner — `create-meeting` deliberately does not synthesize a row
127
+ * for a non-attendee owner, because that table feeds the public share payload.
128
+ * So counting rows alone reads an owner-plus-one-attendee meeting as solo and
129
+ * hands a mic-only transcript back to attribution, which is what labels the
130
+ * remote side's bleed as the owner.
131
+ *
132
+ * A withheld owner (`null`, from the public share page) still counts: it means
133
+ * an owner exists and is not among the participants. An owner we were never
134
+ * told about (`undefined`) also counts, because "we cannot name them" is not
135
+ * the same as "they are not there" — the cost of over-counting is a lost label,
136
+ * and the cost of under-counting is a false one.
137
+ */
138
+ function countPossibleSpeakers(
139
+ participants: AttendeeStackParticipant[],
140
+ ownerEmail?: string | null,
141
+ ): number {
142
+ const ownerInRoster = ownerEmail
143
+ ? Boolean(findParticipant(ownerEmail, participants))
144
+ : false;
145
+ return participants.length + (ownerInRoster ? 0 : 1);
146
+ }
147
+
148
+ const UNATTRIBUTED_SPEAKER: SpeakerIdentity = {
149
+ key: "unattributed",
150
+ label: null,
151
+ initialsSource: "",
152
+ isOwner: false,
153
+ accentClass: "",
154
+ unattributed: true,
155
+ };
156
+
66
157
  const SPEAKER_ACCENTS = [
67
158
  "bg-accent text-accent-foreground",
68
159
  "bg-secondary text-secondary-foreground",
@@ -75,6 +166,37 @@ function normalizeSpeaker(value: string): string {
75
166
  return value.trim().toLowerCase().replace(/\s+/g, " ");
76
167
  }
77
168
 
169
+ // Some providers (and our own seed fixture) tag unresolved segments with a
170
+ // literal placeholder word instead of leaving `speaker` blank. These name a
171
+ // side of the conversation rather than a person, so neither the label nor the
172
+ // attribution check may treat one as an identity.
173
+ const GENERIC_MIC_SPEAKER = /^(me|self|you)$/i;
174
+ const GENERIC_SYSTEM_SPEAKER = /^them$/i;
175
+
176
+ /** The side a generic placeholder names, or null when it names a person. */
177
+ function placeholderSide(
178
+ label: string | null | undefined,
179
+ ): "mic" | "system" | null {
180
+ const speaker = label?.trim();
181
+ if (!speaker) return null;
182
+ if (GENERIC_MIC_SPEAKER.test(speaker)) return "mic";
183
+ if (GENERIC_SYSTEM_SPEAKER.test(speaker)) return "system";
184
+ return null;
185
+ }
186
+
187
+ /**
188
+ * Which side of the conversation a segment belongs to.
189
+ *
190
+ * `source` is the real signal, but a segment can arrive with only a
191
+ * placeholder speaker on it. Falling straight through to "system" then drops
192
+ * every "Me" into the remote group, which is how a transcript labelled purely
193
+ * with placeholders renders entirely as "Them" — the original bug. Read the
194
+ * side the placeholder names before defaulting.
195
+ */
196
+ function segmentSide(segment: TranscriptSegment): "mic" | "system" {
197
+ return segment.source ?? placeholderSide(segment.speaker) ?? "system";
198
+ }
199
+
78
200
  // Exported for regression testing — see transcript-bubbles.test.ts. These
79
201
  // are pure functions with no dependency on the component itself.
80
202
  export function findParticipant(
@@ -139,22 +261,23 @@ export function resolveSpeaker(
139
261
  participants: AttendeeStackParticipant[],
140
262
  ownerEmail?: string | null,
141
263
  ): SpeakerIdentity {
142
- const source = segment.source === "mic" ? "mic" : "system";
264
+ const source = segmentSide(segment);
143
265
  const rawSpeaker = segment.speaker?.trim();
144
266
  const participant =
145
267
  findParticipant(segment.speaker, participants) ||
146
268
  resolveParticipantForSpeaker(source, participants, ownerEmail);
147
269
  const participantName = participant?.name?.trim();
148
270
  const resolvedLabel = participantName || rawSpeaker;
149
- // Some providers (and our own seed fixture) tag unresolved segments with a
150
- // literal placeholder word instead of leaving speaker blank. Treat those the
151
- // same as "no label" on both sides so the UI falls back to the translated
152
- // Me/Them string instead of rendering the raw English placeholder verbatim.
271
+ // Treat a placeholder as "no label" so the UI falls back to the translated
272
+ // Me/Them string instead of rendering the raw English word verbatim. Only
273
+ // the placeholder matching this segment's own side counts: "Them" on a mic
274
+ // segment is a contradiction, not a placeholder, and keeping it visible is
275
+ // better than silently dropping it.
153
276
  const isGenericPlaceholderLabel =
154
277
  !!resolvedLabel &&
155
278
  (source === "mic"
156
- ? /^(me|self|you)$/i.test(resolvedLabel)
157
- : /^them$/i.test(resolvedLabel));
279
+ ? GENERIC_MIC_SPEAKER.test(resolvedLabel)
280
+ : GENERIC_SYSTEM_SPEAKER.test(resolvedLabel));
158
281
  const label = isGenericPlaceholderLabel
159
282
  ? null
160
283
  : participantName || rawSpeaker || null;
@@ -203,9 +326,16 @@ function groupConsecutive(
203
326
  participants: AttendeeStackParticipant[],
204
327
  ownerEmail?: string | null,
205
328
  ): BubbleGroup[] {
329
+ const attributable = transcriptDistinguishesSpeakers(
330
+ segments,
331
+ participants,
332
+ ownerEmail,
333
+ );
206
334
  const groups: BubbleGroup[] = [];
207
335
  segments.forEach((seg, index) => {
208
- const speaker = resolveSpeaker(seg, participants, ownerEmail);
336
+ const speaker = attributable
337
+ ? resolveSpeaker(seg, participants, ownerEmail)
338
+ : UNATTRIBUTED_SPEAKER;
209
339
  const last = groups[groups.length - 1];
210
340
  if (last && last.speaker.key === speaker.key) {
211
341
  last.segments.push({ seg, index });
@@ -509,38 +639,40 @@ export function TranscriptBubbles({
509
639
  key={`${group.speaker.key}:${gi}`}
510
640
  className="space-y-0.5"
511
641
  >
512
- <div className="flex h-6 items-center gap-2">
513
- <Avatar
514
- className={cn(
515
- "size-6 shrink-0",
516
- group.speaker.accentClass,
517
- )}
518
- >
519
- <AvatarFallback
642
+ {!group.speaker.unattributed && (
643
+ <div className="flex h-6 items-center gap-2">
644
+ <Avatar
520
645
  className={cn(
521
- "text-[9px] font-semibold",
646
+ "size-6 shrink-0",
522
647
  group.speaker.accentClass,
523
648
  )}
524
649
  >
525
- {attendeeInitials(group.speaker.initialsSource)}
526
- </AvatarFallback>
527
- </Avatar>
528
- <div className="flex min-h-6 items-center">
529
- <span
530
- className={cn(
531
- "text-xs font-semibold leading-6",
532
- group.speaker.isOwner
533
- ? "text-primary"
534
- : "text-foreground",
535
- )}
536
- >
537
- {group.speaker.label ||
538
- (group.speaker.isOwner
539
- ? t("transcriptBubbles.me")
540
- : t("transcriptBubbles.them"))}
541
- </span>
650
+ <AvatarFallback
651
+ className={cn(
652
+ "text-[9px] font-semibold",
653
+ group.speaker.accentClass,
654
+ )}
655
+ >
656
+ {attendeeInitials(group.speaker.initialsSource)}
657
+ </AvatarFallback>
658
+ </Avatar>
659
+ <div className="flex min-h-6 items-center">
660
+ <span
661
+ className={cn(
662
+ "text-xs font-semibold leading-6",
663
+ group.speaker.isOwner
664
+ ? "text-primary"
665
+ : "text-foreground",
666
+ )}
667
+ >
668
+ {group.speaker.label ||
669
+ (group.speaker.isOwner
670
+ ? t("transcriptBubbles.me")
671
+ : t("transcriptBubbles.them"))}
672
+ </span>
673
+ </div>
542
674
  </div>
543
- </div>
675
+ )}
544
676
  <div className="space-y-1">
545
677
  {group.segments.map(({ seg, index }) => {
546
678
  return (
@@ -36,7 +36,9 @@ function wait(ms: number): Promise<void> {
36
36
  export interface CapturedTranscript {
37
37
  /** Speaker-labelled text, lines joined by blank lines. */
38
38
  text: string;
39
- /** Real whisper segments with verbatim timestamps. */
39
+ /** Whisper's verbatim timestamps where the engine reported them, else one
40
+ * synthesized segment per line — the mic-only engines report no timings,
41
+ * and a dropped line would lose its speaker along with its text. */
40
42
  segments: SourcedTranscriptSegment[];
41
43
  /** Source stored with `save-browser-transcript`. */
42
44
  source?: "web-speech" | "macos-native" | "whisper";
@@ -332,9 +334,14 @@ export async function startTranscriptionCapture(
332
334
  });
333
335
  };
334
336
 
337
+ // `source` reports the engine that actually produced this transcript, not
338
+ // the one we asked for: `startTranscriptionEngine` may have fallen back to
339
+ // mic-only macos-native. Omitting it made the server default to "whisper"
340
+ // and treat a mic-only capture as mixed mic + system audio.
335
341
  const captured = (): CapturedTranscript => ({
336
342
  text: transcriptFullText(lines),
337
343
  segments: transcriptSegments(lines),
344
+ source: engine,
338
345
  });
339
346
 
340
347
  let engine: TranscriptionEngine;
@@ -245,11 +245,48 @@ export function transcriptFullText(lines: TranscriptLine[]): string {
245
245
  .trim();
246
246
  }
247
247
 
248
- /** Flattened verbatim segments, as persisted alongside the text. */
248
+ /** Rough duration estimate for a line with no verbatim timing, matching the
249
+ * pacing `buildCaptionSegmentsFromText` uses server-side. */
250
+ function estimatedLineDurationMs(text: string): number {
251
+ const words = text.split(/\s+/).filter(Boolean).length || 1;
252
+ return Math.max(900, words * 420);
253
+ }
254
+
255
+ /** Flattened verbatim segments, as persisted alongside the text.
256
+ *
257
+ * A line with no verbatim timings still contributes one synthesized segment
258
+ * rather than being dropped: the mic-only fallback engines report no
259
+ * timestamps at all, and dropping the line loses its text and its `source`
260
+ * — and a `source`-less stored segment silently renders as "Them". Those
261
+ * engines report `startMs: null` on *every* line, so a synthesized segment
262
+ * continues from the previous line's end; anchoring each at its own
263
+ * null-coalesced 0 would stack the whole transcript on one instant and break
264
+ * ordering and timestamp seeking. */
249
265
  export function transcriptSegments(
250
266
  lines: TranscriptLine[],
251
267
  ): SourcedTranscriptSegment[] {
252
- return lines.flatMap((line) => line.segments);
268
+ const segments: SourcedTranscriptSegment[] = [];
269
+ let cursorMs = 0;
270
+ for (const line of lines) {
271
+ if (line.segments.length) {
272
+ segments.push(...line.segments);
273
+ cursorMs = line.segments.reduce(
274
+ (latest, segment) => Math.max(latest, segment.endMs),
275
+ cursorMs,
276
+ );
277
+ continue;
278
+ }
279
+ // `appendFinalTranscript` is the only path that yields an untimed line and
280
+ // it always pairs `segments: []` with `startMs: null`, so today this reads
281
+ // as `cursorMs`. Honouring a line's own stamp is for the shape the type
282
+ // still permits (see `historyLine` in overlays/live-transcript.tsx), and
283
+ // clamping to the cursor keeps a stale stamp from reordering the transcript.
284
+ const startMs = Math.max(line.startMs ?? cursorMs, cursorMs);
285
+ const endMs = startMs + estimatedLineDurationMs(line.text);
286
+ segments.push({ startMs, endMs, text: line.text, source: line.source });
287
+ cursorMs = endMs;
288
+ }
289
+ return segments;
253
290
  }
254
291
 
255
292
  /**
@@ -22,7 +22,9 @@ export default defineAction({
22
22
  includeSlides: z
23
23
  .enum(["true", "false"])
24
24
  .optional()
25
- .describe("Set to 'true' for full frontend deck payloads"),
25
+ .describe(
26
+ "Set to 'true' for full frontend deck payloads; omitted returns metadata only",
27
+ ),
26
28
  light: z
27
29
  .enum(["true", "false"])
28
30
  .optional()
@@ -84,6 +86,39 @@ export default defineAction({
84
86
  return { count: rows.length, decks: rows };
85
87
  }
86
88
 
89
+ if (args.includeSlides !== "true") {
90
+ // The deck body is an opaque JSON blob containing every slide's HTML.
91
+ // Metadata callers must opt into it explicitly; the frontend opens one
92
+ // deck at a time through get-deck instead of downloading every body.
93
+ const rows = await db
94
+ .select({
95
+ id: schema.decks.id,
96
+ title: schema.decks.title,
97
+ ownerEmail: schema.decks.ownerEmail,
98
+ designSystemId: schema.decks.designSystemId,
99
+ createdAt: schema.decks.createdAt,
100
+ updatedAt: schema.decks.updatedAt,
101
+ visibility: schema.decks.visibility,
102
+ })
103
+ .from(schema.decks)
104
+ .where(where)
105
+ .orderBy(desc(schema.decks.updatedAt));
106
+
107
+ return {
108
+ count: rows.length,
109
+ decks: rows.map((row) => ({
110
+ id: row.id,
111
+ title: row.title,
112
+ url: getDeckUrl(row.id),
113
+ visibility: row.visibility,
114
+ designSystemId: row.designSystemId ?? null,
115
+ createdByMe: ownerEmail ? row.ownerEmail === ownerEmail : false,
116
+ createdAt: row.createdAt,
117
+ updatedAt: row.updatedAt,
118
+ })),
119
+ };
120
+ }
121
+
87
122
  const rows = await db
88
123
  .select()
89
124
  .from(schema.decks)
@@ -940,7 +940,7 @@ export function deriveInverseOp(
940
940
  }
941
941
 
942
942
  /**
943
- * Fetch the deck list. Returns `null` on any failure (network error, non-2xx
943
+ * Fetch the deck metadata list. Returns `null` on any failure (network error, non-2xx
944
944
  * response) so callers can distinguish "authoritative empty list" from
945
945
  * "couldn't reach the server" — wiping local state on a transient failure
946
946
  * kicks the user out of the editor and shows the "Create your first deck"
@@ -951,7 +951,7 @@ async function fetchDecksFromAPI(): Promise<Deck[] | null> {
951
951
  try {
952
952
  const result = await callAction<DeckListActionResult>(
953
953
  "list-decks",
954
- { includeSlides: "true" },
954
+ { light: "true" },
955
955
  { method: "GET" },
956
956
  );
957
957
  if (!Array.isArray(result?.decks)) {
@@ -1076,12 +1076,23 @@ export async function includeOpenDeckIfMissing(
1076
1076
  async function fetchDecksForCurrentRoute(): Promise<Deck[] | null> {
1077
1077
  const currentOpenDeckId = currentOpenDeckIdFromWindow();
1078
1078
  const loaded = await fetchDecksFromAPI();
1079
- if (loaded !== null) {
1080
- return includeOpenDeckIfMissing(loaded, currentOpenDeckId);
1079
+ if (loaded === null) {
1080
+ if (!currentOpenDeckId) return null;
1081
+ const directDeck = await fetchDeckFromAPI(currentOpenDeckId);
1082
+ return directDeck ? [directDeck] : null;
1081
1083
  }
1082
- if (!currentOpenDeckId) return null;
1084
+ if (!currentOpenDeckId) return loaded;
1085
+
1086
+ // The list is intentionally metadata-only. Hydrate just the deck the user
1087
+ // opened so the editor gets full slide content without making the home page
1088
+ // download every deck body.
1083
1089
  const directDeck = await fetchDeckFromAPI(currentOpenDeckId);
1084
- return directDeck ? [directDeck] : null;
1090
+ if (!directDeck) return loaded;
1091
+ const index = loaded.findIndex((deck) => deck.id === currentOpenDeckId);
1092
+ if (index < 0) return [...loaded, directDeck];
1093
+ const next = [...loaded];
1094
+ next[index] = directDeck;
1095
+ return next;
1085
1096
  }
1086
1097
 
1087
1098
  async function deleteDeckFromAPI(id: string): Promise<void> {
@@ -226,6 +226,17 @@ class BuilderEngine {
226
226
  }
227
227
  : {}),
228
228
  };
229
+ // Measured once, from the exact string that goes on the wire, and carried
230
+ // on every error stop below. A gateway rejection tells us nothing about
231
+ // what we sent, so without this an oversized or malformed request and a
232
+ // gateway outage are the same capture.
233
+ const payload = JSON.stringify(body);
234
+ const requestShape = {
235
+ model: opts.model,
236
+ payloadBytes: new TextEncoder().encode(payload).length,
237
+ toolCount: cachedTools.length,
238
+ messageCount: cachedMessages.length,
239
+ };
229
240
  const gatewayBaseUrl = getBuilderGatewayBaseUrl();
230
241
  const gatewayUrl = new URL("messages", gatewayBaseUrl.endsWith("/") ? gatewayBaseUrl : `${gatewayBaseUrl}/`);
231
242
  gatewayUrl.searchParams.set("apiKey", spaceId);
@@ -246,7 +257,7 @@ class BuilderEngine {
246
257
  ...getBuilderGatewayRequestHeaders(),
247
258
  ...(builderUserId ? { "x-builder-user-id": builderUserId } : {}),
248
259
  },
249
- body: JSON.stringify(body),
260
+ body: payload,
250
261
  signal: gatewayAbort.signal,
251
262
  });
252
263
  }
@@ -265,12 +276,12 @@ class BuilderEngine {
265
276
  elapsedMs: Date.now() - tStart,
266
277
  });
267
278
  }
268
- yield createBuilderGatewayTimeoutStop(err, timedOut, gatewayAbort.effectiveTimeoutMs(), creditsLane);
279
+ yield createBuilderGatewayTimeoutStop(err, timedOut, gatewayAbort.effectiveTimeoutMs(), creditsLane, requestShape);
269
280
  return;
270
281
  }
271
282
  console.log(`[builder-engine] ← ${response.status} ${response.statusText} in ${Date.now() - tStart}ms`);
272
283
  if (!response.ok) {
273
- yield* emitHttpError(response, { creditsLane });
284
+ yield* emitHttpError(response, { creditsLane, requestShape });
274
285
  return;
275
286
  }
276
287
  // A successful gateway call proves the connected credentials are valid
@@ -302,7 +313,7 @@ class BuilderEngine {
302
313
  ...(isTransientGatewayFailure(error, status)
303
314
  ? { providerRetryable: true }
304
315
  : {}),
305
- }, creditsLane);
316
+ }, creditsLane, requestShape);
306
317
  return;
307
318
  }
308
319
  const reader = response.body?.getReader();
@@ -311,7 +322,7 @@ class BuilderEngine {
311
322
  error: "Builder gateway response has no body",
312
323
  errorCode: "builder_gateway_error",
313
324
  statusCode: response.status,
314
- }, creditsLane);
325
+ }, creditsLane, requestShape);
315
326
  return;
316
327
  }
317
328
  yield* parseJsonlStream(reader, opts.model, {
@@ -322,6 +333,7 @@ class BuilderEngine {
322
333
  onFirstEvent: gatewayAbort.markFirstEvent,
323
334
  gatewayUrl,
324
335
  requestStartedAt: tStart,
336
+ requestShape,
325
337
  });
326
338
  }
327
339
  finally {
@@ -358,7 +370,7 @@ function isTransientGatewayFailure(rawMessage, status) {
358
370
  * signals downstream may read: keyword coupling to the message turns a retryable
359
371
  * throttle into a dead turn on credits sites alone.
360
372
  */
361
- function gatewayErrorStop(details, creditsLane) {
373
+ function gatewayErrorStop(details, creditsLane, requestShape) {
362
374
  const { error, errorCode, upgradeUrl, ...retry } = details;
363
375
  return {
364
376
  type: "stop",
@@ -373,6 +385,9 @@ function gatewayErrorStop(details, creditsLane) {
373
385
  ...(isContextOverflowMessage(error) || isContextOverflowCode(errorCode)
374
386
  ? { contextOverflow: true }
375
387
  : {}),
388
+ // Absent before the request is built (missing credentials): a stop with no
389
+ // shape means nothing was sent, not that the payload measured zero.
390
+ ...(requestShape ? { requestShape } : {}),
376
391
  ...retry,
377
392
  };
378
393
  }
@@ -394,7 +409,7 @@ async function* emitHttpError(response, opts) {
394
409
  }
395
410
  const code = errBody.code ?? `http_${status}`;
396
411
  const message = errBody.message ?? `Builder gateway returned ${status}`;
397
- const stop = (details) => gatewayErrorStop(details, opts.creditsLane);
412
+ const stop = (details) => gatewayErrorStop(details, opts.creditsLane, opts.requestShape);
398
413
  // Belt-and-suspenders: 402 without a structured `credits-limit` code
399
414
  // (e.g. bare proxy response) still means quota → show upgrade CTA.
400
415
  if (code.startsWith("credits-limit") || status === 402) {
@@ -543,7 +558,7 @@ async function* parseJsonlStream(reader, model, captureContext = {}) {
543
558
  errorCode: "http_502",
544
559
  statusCode: 502,
545
560
  providerRetryable: true,
546
- }, captureContext.creditsLane);
561
+ }, captureContext.creditsLane, captureContext.requestShape);
547
562
  return;
548
563
  }
549
564
  // Heartbeats are transport-level keepalives, not proof the model is
@@ -624,7 +639,7 @@ async function* parseJsonlStream(reader, model, captureContext = {}) {
624
639
  yield* recoverUndeliveredToolCalls();
625
640
  yield { type: "assistant-content", parts };
626
641
  const reason = event.reason ?? "end_turn";
627
- const stop = (details) => gatewayErrorStop(details, captureContext.creditsLane);
642
+ const stop = (details) => gatewayErrorStop(details, captureContext.creditsLane, captureContext.requestShape);
628
643
  if (reason === "rate_limited") {
629
644
  yield stop({
630
645
  error: `rate_limit exceeded: ${event.error ?? "upstream provider rate limited"}`,
@@ -744,7 +759,7 @@ async function* parseJsonlStream(reader, model, captureContext = {}) {
744
759
  yield gatewayErrorStop({
745
760
  error: "Builder gateway stream ended without a stop event",
746
761
  errorCode: BUILDER_GATEWAY_STREAM_ENDED_ERROR_CODE,
747
- }, captureContext.creditsLane);
762
+ }, captureContext.creditsLane, captureContext.requestShape);
748
763
  }
749
764
  catch (err) {
750
765
  const timedOut = captureContext.didGatewayTimeout?.() ?? false;
@@ -762,7 +777,7 @@ async function* parseJsonlStream(reader, model, captureContext = {}) {
762
777
  : undefined,
763
778
  });
764
779
  }
765
- yield createBuilderGatewayTimeoutStop(err, timedOut, gatewayTimeoutMs, captureContext.creditsLane);
780
+ yield createBuilderGatewayTimeoutStop(err, timedOut, gatewayTimeoutMs, captureContext.creditsLane, captureContext.requestShape);
766
781
  }
767
782
  finally {
768
783
  // Release the reader on every exit path — early returns (invalid JSONL,
@@ -976,27 +991,27 @@ function normalizeBuilderGatewayFetchError(err, timedOut, timeoutMs) {
976
991
  * the credits lane run-manager has no text left to classify at persistence time,
977
992
  * and a run persisted as `unknown` reads as "do not attempt recovery".
978
993
  */
979
- function createBuilderGatewayTimeoutStop(err, timedOut, timeoutMs, creditsLane) {
994
+ function createBuilderGatewayTimeoutStop(err, timedOut, timeoutMs, creditsLane, requestShape) {
980
995
  const error = normalizeBuilderGatewayFetchError(err, timedOut, timeoutMs);
981
996
  if (timedOut) {
982
997
  // Deliberately no `providerRetryable`: the timeout spent the whole request
983
998
  // budget, so the recovery is a fresh invocation (the client's
984
999
  // `builder_gateway_timeout` continuation), never an in-call retry.
985
- return gatewayErrorStop({ error, errorCode: "builder_gateway_timeout" }, creditsLane);
1000
+ return gatewayErrorStop({ error, errorCode: "builder_gateway_timeout" }, creditsLane, requestShape);
986
1001
  }
987
1002
  if (isBuilderGatewayNetworkError(err)) {
988
1003
  return gatewayErrorStop({
989
1004
  error,
990
1005
  errorCode: BUILDER_GATEWAY_NETWORK_ERROR_CODE,
991
1006
  providerRetryable: true,
992
- }, creditsLane);
1007
+ }, creditsLane, requestShape);
993
1008
  }
994
1009
  const errorCode = classifyTerminalErrorCode(error);
995
1010
  return gatewayErrorStop({
996
1011
  error,
997
1012
  ...(errorCode ? { errorCode } : {}),
998
1013
  ...(isTransientGatewayFailure(error) ? { providerRetryable: true } : {}),
999
- }, creditsLane);
1014
+ }, creditsLane, requestShape);
1000
1015
  }
1001
1016
  function formatTimeoutMs(timeoutMs) {
1002
1017
  if (timeoutMs < 1000)