Skip to content
This repository was archived by the owner on Nov 8, 2026. It is now read-only.

Commit a9d9290

Browse files
justinhelmerJustin Helmer
andauthored
fix(core): show when routing first produces content (#3441)
Co-authored-by: Justin Helmer <justin@coreplane.ai>
1 parent 6c40c1b commit a9d9290

9 files changed

Lines changed: 62 additions & 5 deletions

File tree

‎docs/reference/specs/routing-and-config.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,7 @@ An accepted non-review operator bind carries its repository separately from the
375375
| Required saved-context preparation and catalogue reads consume the same operator deadline; attachment preparation cannot renew it before the first model turn. | `[unit]` `src/core/dispatch/operatorReReview.test.ts::required context shares the operator deadline::*` |
376376

377377
| Routing receipts retain bounded phase timing, remaining deadline, provider/model, measured usage and request IDs when reported, redacted rejected questions/proposals and specific proposal validation codes. The phase active at cancellation survives; character-based output counts are labeled estimates. | `[unit]` `src/core/dispatch/operator.test.ts::runOperator — the loop over a scripted model::retains timed rejected proposals and the model call active at the deadline` |
378+
| A streamed operator completion records its first content time after HTTP headers. A later HTTP attempt clears the prior attempt's marker; observer failure cannot change the answer. The receipt retains no stream content. | `[unit]` `src/core/harness/piAi.test.ts::PiAiProviders — the provider table config.yaml names, built on pi's model library::observes request send and response headers even without a provider request ID`, `src/core/dispatch/route.test.ts::the readers' router is retired::forwards HTTP send and headers observations to the routing recorder`, `src/core/dispatch/operatorTelemetry.test.ts::routing telemetry retention::records the first streamed content after headers and clears it on a later HTTP attempt` |
378379
| A stalled required-context read retains the phase active at cancellation; a model answer returned after the shared deadline admits no action. Named credentials stay out of telemetry, and late callbacks cannot change sealed evidence. Unicode and step-count bounds preserve the first and terminal phases with explicit loss. | `[unit]` `src/core/dispatch/operator.test.ts::runOperator — the loop over a scripted model::records a stalled context read at cancellation and redacts registry credentials`, `::refuses a valid action returned after the shared deadline`; `src/core/dispatch/operatorTelemetry.test.ts::routing telemetry retention::*` |
379380
| Each routing tool proposal retains its redacted JSON input and outcome, including all calls in a rejected multi-call answer and every repair. Selection does not claim execution. Grounding helpers retain arguments and returned text. Capped content and omitted evidence are explicit; raw provider bodies and reasoning are excluded. | `[unit]` `src/core/dispatch/operator.test.ts::runOperator — the loop over a scripted model::retains every rejected routing call with its input and validation result`, `::retains helper arguments and results beside the selected action`; `src/core/dispatch/operatorTelemetry.test.ts::routing telemetry retention::*` |
380381
| Exhausted multi-call recovery uses the ordinary deadline-checked admission path, including recorded context, catalogue and command-fulfillment phases. Late actions and unavailable verdicts admit no execution. | `[unit]` `src/core/dispatch/operator.test.ts::long asks and multi-call answers (issue 2099)::recovered sole actions keep deadline and phase evidence: …`; `src/core/dispatch/operatorFulfillment.test.ts::last-read command correction::recovered commands retain fulfillment evidence and refuse unavailable verdicts: …` |

‎src/core/dispatch/operatorTelemetry.test.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,35 @@ describe("routing telemetry retention", () => {
2626
expect(step.responseStatus).toBeUndefined();
2727
});
2828

29+
it("records the first streamed content after headers and clears it on a later HTTP attempt", async () => {
30+
let clock = 1_000;
31+
const recorder = new RoutingRecorder(() => clock, clock, 61_000, new AbortController().signal);
32+
await recorder.during("model", async (observe) => {
33+
clock = 1_100;
34+
observe({ transport: "request_send", transportId: "first" });
35+
clock = 1_200;
36+
observe({ transport: "response_headers", status: 503 });
37+
clock = 1_300;
38+
observe({ stream: "first_content" });
39+
clock = 1_400;
40+
observe({ transport: "request_send", transportId: "second" });
41+
clock = 1_500;
42+
observe({ transport: "response_headers", status: 200 });
43+
clock = 1_700;
44+
observe({ stream: "first_content" });
45+
clock = 1_800;
46+
observe({ stream: "first_content" });
47+
return "done";
48+
});
49+
expect(recorder.snapshot().steps[0]).toMatchObject({
50+
transportAttempts: 2,
51+
requestSentAt: 1_400,
52+
responseHeadersAt: 1_500,
53+
firstContentAt: 1_700,
54+
finishedAt: 1_800,
55+
});
56+
});
57+
2958
it.each(["proposed", "selected", "rejected"] as const)(
3059
"redacts original strings on a %s scripted proposal",
3160
async (outcome) => {

‎src/core/dispatch/operatorTelemetry.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ export class RoutingRecorder {
107107
if (value.transportId) step.transportId = safe(value.transportId, 128);
108108
delete step.requestId;
109109
delete step.responseHeadersAt;
110+
delete step.firstContentAt;
110111
delete step.responseStatus;
111112
} else if (
112113
value.transport === "response_headers" &&
@@ -117,6 +118,8 @@ export class RoutingRecorder {
117118
) {
118119
step.responseHeadersAt = this.now();
119120
step.responseStatus = value.status;
121+
} else if (value.stream === "first_content" && step.responseHeadersAt !== undefined) {
122+
step.firstContentAt ??= this.now();
120123
}
121124
for (const key of ["provider", "model", "effort", "effortWord", "requestId", "stopReason"] as const) {
122125
if (value[key] !== undefined) step[key] = safe(value[key], 128);

‎src/core/dispatch/route.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ describe("the readers' router is retired", () => {
7474
complete: async (request) => {
7575
request.observer?.onRequestStart?.("transport-fixture");
7676
request.observer?.onResponseHeaders?.(200);
77+
request.observer?.onFirstContent?.();
7778
return { content: [], stopReason: "end_turn" };
7879
},
7980
};
@@ -83,6 +84,7 @@ describe("the readers' router is retired", () => {
8384
);
8485
expect(observations).toContainEqual({ transport: "request_send", transportId: "transport-fixture" });
8586
expect(observations).toContainEqual({ transport: "response_headers", status: 200 });
87+
expect(observations).toContainEqual({ stream: "first_content" });
8688
});
8789
});
8890

‎src/core/dispatch/route.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -533,6 +533,7 @@ export function providerStructuredModel(
533533
observer: {
534534
onRequestStart: (transportId: string) => observe({ transport: "request_send", transportId }),
535535
onResponseHeaders: (status: number) => observe({ transport: "response_headers", status }),
536+
onFirstContent: () => observe({ stream: "first_content" }),
536537
onResponse: (requestId: string) => observe({ requestId }),
537538
},
538539
}

‎src/core/harness/piAi.test.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,14 +137,18 @@ describe("PiAiProviders — the provider table config.yaml names, built on pi's
137137
observer: {
138138
onRequestStart: () => events.push("send"),
139139
onResponseHeaders: (status) => events.push(`headers:${status}`),
140+
onFirstContent: () => {
141+
events.push("first-content");
142+
throw new Error("diagnostic sink failed");
143+
},
140144
},
141145
});
142146
await vi.waitFor(() => expect(events).toEqual(["send", "fetch"]));
143147
returnHeaders(
144148
new Response(anthropicToolCallStream(), { status: 200, headers: { "content-type": "text/event-stream" } }),
145149
);
146150
await expect(completion).resolves.toMatchObject({ stopReason: "tool_use" });
147-
expect(events).toEqual(["send", "fetch", "headers:200"]);
151+
expect(events).toEqual(["send", "fetch", "headers:200", "first-content"]);
148152
});
149153

150154
it("observes the provider request ID at response headers without changing the completion", async () => {

‎src/core/harness/piAi.ts‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,22 @@ export class PiAiProvider implements Provider {
260260
// Direct API modules take pi's normalized transcript, not the public
261261
// Context shorthand. Shape tools before moving them into its system message.
262262
const transcript = normalizeContext(shaped.body as unknown as Context);
263-
const message = await this.api$().stream(model, transcript, options).result();
263+
const stream = this.api$().stream(model, transcript, options);
264+
let firstContent = false;
265+
for await (const event of stream) {
266+
if (
267+
!firstContent &&
268+
(event.type === "text_start" || event.type === "thinking_start" || event.type === "toolcall_start")
269+
) {
270+
firstContent = true;
271+
try {
272+
req.observer?.onFirstContent?.();
273+
} catch {
274+
/* Diagnostics cannot fail a completion. */
275+
}
276+
}
277+
}
278+
const message = await stream.result();
264279
return fromPiMessage(message, this.name, req.tools, failureContext);
265280
} catch (err) {
266281
// Pi's `aborted` stop reason is local cancellation, not provider health.

‎src/core/provider.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ export interface CompletionRequest {
6666
* it to every breakpoint. Absent → the provider default (`5m`). */
6767
cacheTtl?: CacheTtl;
6868
/** Timing hooks for the call's span (docs/reference/specs/tracing.md): a streaming
69-
* provider reports the first token; the span layer stamps the time. A
69+
* provider reports the first content block; the span layer stamps the time. A
7070
* provider that cannot observe its stream simply never calls them. */
7171
observer?: CompletionObserver;
7272
}
@@ -80,7 +80,7 @@ export interface CompletionObserver {
8080
onResponse?(requestId: string): void;
8181
/** One HTTP response, including failures and responses without a request ID. */
8282
onHttpResponse?(status: number, requestId?: string): void;
83-
onFirstToken?(): void;
83+
onFirstContent?(): void;
8484
/** A content block began / ended, by kind (`text`, `thinking`, `redacted_thinking`,
8585
* `tool_use`, …) and stream index: the span layer sums a turn's thinking and
8686
* writing time from these (docs/reference/specs/tracing.md; live-view item 15). */

‎src/core/routingTelemetry.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { SpanEndEvent } from "./runEvents.js";
44
export interface RoutingModelObservation {
55
/** Transient provider transport events; the recorder stamps them with its clock. */
66
transport?: "request_send" | "response_headers";
7+
stream?: "first_content";
78
status?: number;
89
transportId?: string;
910
provider?: string;
@@ -30,7 +31,7 @@ export interface RoutingToolCall {
3031
result?: string;
3132
}
3233

33-
export interface RoutingStep extends Omit<RoutingModelObservation, "toolCalls" | "transport" | "status"> {
34+
export interface RoutingStep extends Omit<RoutingModelObservation, "toolCalls" | "transport" | "stream" | "status"> {
3435
index: number;
3536
phase: RoutingPhase;
3637
startedAt: number;
@@ -42,6 +43,7 @@ export interface RoutingStep extends Omit<RoutingModelObservation, "toolCalls" |
4243
transportAttempts?: number;
4344
requestSentAt?: number;
4445
responseHeadersAt?: number;
46+
firstContentAt?: number;
4547
responseStatus?: number;
4648
tool?: string;
4749
question?: string;

0 commit comments

Comments
 (0)