fix(preview/stream): await onSpec/onError handlers
All checks were successful
Deploy to Production / deploy (push) Successful in 1m21s

The llm package called the user-supplied onSpec/onError handlers
without awaiting them. In the /preview/stream route onSpec is async
(it does `await cacheSpec(...)` then writes the SSE `spec` event), so
the api handler's `await streamSpecFromAnthropic(...)` returned BEFORE
the terminal event had been written. The route's finally block then
ran `reply.raw.end()`, the queued `send('spec', ...)` hit a closed
stream and silently no-op'd, and the browser saw zero terminal
events — frontend ran into the "Spec generation failed." fallback
even though Anthropic had delivered a perfectly valid spec.

Verified against prod log: req-8 ran 66s with 200 and produced no
preview_spec_* log line, which is exactly the success-but-event-lost
signature.

Fix:
- StreamHandlers.onSpec / onError typed as Promise<void> | void
- Both call sites in streamSpecFromAnthropic now `await` them
- /preview/stream sets `resolved = true` at the END of each handler
  (after the SSE write completes) so the post-stream "unresolved"
  fallback only fires on a genuine programming bug
- Added preview_spec_ready info log on the happy path so future
  diagnosis doesn't have to infer success from the absence of error
  logs
This commit is contained in:
Marco Sadjadi
2026-05-28 22:00:03 +02:00
parent 29e699dc74
commit 092290bb38
2 changed files with 50 additions and 25 deletions

View File

@@ -300,12 +300,22 @@ async function generateWithAnthropic(
// ──────────────────────────────────────────────────────────────────────────
export interface StreamHandlers {
/** Called for each text delta emitted by the model. */
/** Called for each text delta emitted by the model. Sync — must not throw. */
onText: (text: string) => void;
/** Called once when the stream completes successfully with the final spec. */
onSpec: (result: GenerationResult) => void;
/** Called once on any terminal error (timeout, truncation, validation). */
onError: (err: Error) => void;
/**
* Called once when the stream completes successfully with the final spec.
* MAY return a Promise — the caller awaits it before considering the
* stream finished. This is critical for SSE callers that need to write
* a final event and end the response: returning a void instead of
* Promise<void> would leak the response.end() call before the event
* is actually written, leaving the client with no terminal frame.
*/
onSpec: (result: GenerationResult) => Promise<void> | void;
/**
* Called once on any terminal error (timeout, truncation, validation).
* Same async contract as onSpec.
*/
onError: (err: Error) => Promise<void> | void;
}
/**
@@ -358,13 +368,18 @@ export async function streamSpecFromAnthropic(
throw new SpecValidationError(`${parsed.error.message} :: raw="${preview}"`);
}
scanForInjection(parsed.data);
handlers.onSpec({ spec: parsed.data, source: 'claude' });
// AWAITED on purpose — the SSE caller writes the terminal 'spec' event
// inside this handler and we must not return (and thereby allow the
// caller to .end() the response) until that write has completed.
await handlers.onSpec({ spec: parsed.data, source: 'claude' });
} catch (err) {
if (err instanceof Anthropic.APIConnectionTimeoutError) {
handlers.onError(new SpecTimeoutError('spec generation exceeded the time budget'));
return;
}
handlers.onError(err instanceof Error ? err : new Error(String(err)));
const terminal =
err instanceof Anthropic.APIConnectionTimeoutError
? new SpecTimeoutError('spec generation exceeded the time budget')
: err instanceof Error
? err
: new Error(String(err));
await handlers.onError(terminal);
}
}