Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions src/agent/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,24 @@ import { evictStaleVision } from './vision-evict';
const SCREENSHOT_HINT =
'Screenshot of the current result. Inspect it and refine if it does not yet match the intent.';

/**
* Shown when a turn ran to a natural finish having produced NOTHING — no prose, no tool call.
*
* This is the half of "the model returned no output" the AI SDK does not raise
* `AI_NoOutputGeneratedError` for. That error fires only when the stream carried no completed step
* at all; an OpenAI-compatible gateway that answers `200` and then closes still yields a finish
* chunk, so a step IS recorded and `streamText` reports a clean, empty finish. Three real gateway
* replies land here — an empty `text/event-stream` body, a JSON error object served under the
* event-stream content type, and a chunk carrying `choices: []` — and all three are how such a
* gateway says "that model id resolved to no provider" without an HTTP error to trip the SDK's
* retry. Left unnamed, the turn returned `done` with an empty reply and the panel showed the agent
* shrugging: a config bug wearing the costume of a lazy model.
*/
export const EMPTY_TURN_ERROR =
'The model returned nothing — no reply and no tool calls. That usually means the model id is ' +
'not available on this provider, or the provider dropped the request. Check the model in ' +
'Settings, then try again.';

/** How the agent turn ended. `budget` = force-stopped on the step/token ceiling; `aborted` =
* the caller's signal fired (user Stop / superseded turn); `error` = the run threw. */
export type TurnStop = 'done' | 'budget' | 'aborted' | 'error';
Expand Down Expand Up @@ -229,6 +247,9 @@ export async function runTurn(args: RunTurnArgs): Promise<TurnOutcome> {

let text = '';
let stop: TurnStop = 'done';
// Did the model ask for ANY tool this turn? Half of the "the turn produced nothing" test below
// — a turn that only edited the page and never narrated is a real turn, not an empty reply.
let sawToolCall = false;
// Accumulated per COMPLETED step (the SDK builds `step.response.messages` at each
// finish-step), so every exit path below — throw, abort, budget stop, natural finish —
// returns whatever tool activity actually happened. Never read from the result's settled
Expand Down Expand Up @@ -265,6 +286,7 @@ export async function runTurn(args: RunTurnArgs): Promise<TurnOutcome> {
}
break;
case 'tool-call': {
sawToolCall = true;
// `selector`/`kind` (slice 09): feeds the panel tool chip and, when the on-page overlay
// is opted in, background.ts's `forwardOverlayStep` mirror to content.
//
Expand Down Expand Up @@ -351,6 +373,15 @@ export async function runTurn(args: RunTurnArgs): Promise<TurnOutcome> {
}
}

// A natural finish that produced nothing is a FAILURE, not a quiet success — see
// {@link EMPTY_TURN_ERROR}. Gated on `stop === 'done'` so it can only ever describe a turn the
// model ended on its own: an abort (`aborted`), a ceiling (`budget`, relabeled above) and a
// stream error (`error`) each already carry their own reason, and none of them reaches here.
if (stop === 'done' && !sawToolCall && text.trim() === '') {
stop = 'error';
emit({ type: 'error', message: EMPTY_TURN_ERROR });
}

return {
text,
// Deliberately WITHOUT the budget notice appended to `text` above: the notice is panel
Expand Down
91 changes: 85 additions & 6 deletions src/shared/sentry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,10 @@ const REDACTED = '[redacted]';
// The exception's class and stack trace are kept: they are the debugging payload
// and reference the extension's own bundled code, not the page. Only the
// exception's free-form `value` (message) is redacted — a DOM tool could
// interpolate page text into it. The event is never dropped (the return type is
// a non-null event), so a genuine crash still reaches the transport carrying its
// error class and full stack; crash reporting stays useful.
// interpolate page text into it. This scrub never drops an event (the return type
// is a non-null event), so a genuine crash still reaches the transport carrying its
// error class and full stack; crash reporting stays useful. Whether an event is worth
// reporting AT ALL is a separate decision, made by `isUserAbort` in `initSentry`.
export function scrubEvent(event: ErrorEvent): ErrorEvent {
const scrubbed: ErrorEvent = {
type: undefined,
Expand Down Expand Up @@ -56,6 +57,33 @@ function scrubException(exception: Exception): Exception {
};
}

/**
* Is this crash report an `AbortError` — i.e. the browser reporting that WE cancelled our own
* in-flight work?
*
* Nothing in this extension aborts on a deadline: every `AbortController.abort()` lives in
* `background.ts` and every one of the five is a deliberate end-of-turn — supersede (a newer user
* message replaces the running turn), `session-start`, `session-stop` (the Stop button),
* `conversation-new`, and the cross-document nav abort in the `onCommitted` listener. That is the
* whole set: `AbortSignal.timeout` appears nowhere in `src/`, and the only other abortable waits
* (`waitForTabComplete`, `browseDelay`) reject with a plain `Error`, not an `AbortError`. So an
* `AbortError` here is never a hidden timeout, and every one of the five already ends the turn
* with a reason the user can see: `runTurn` returns `stop: 'aborted'` for the first four, and the
* nav abort posts its own "the page navigated away mid-turn" message.
*
* The pending fetch the browser rejects when that abort fires has no listener left by then, so it
* lands in `unhandledrejection` and Sentry reports the user pressing Stop as a crash (GlitchTip
* issue 76: 2 events on 2026-08-06, both stacked on those `turnAbort?.abort()` calls). Reporting a
* cancellation the product performs on purpose is noise, and noise in a crash feed is what makes
* the real reports get skimmed. Dropped here rather than silenced at the throw site: the abort
* itself is still exactly as loud as it was — `background.ts`'s `unhandledrejection` LISTENER
* still records it in the conversation's debug log, and the worker console still prints it.
*/
export function isUserAbort(event: ErrorEvent): boolean {
const values = event.exception?.values;
return !!values?.length && values.every((v) => v.type === 'AbortError');
}

// Defence in depth, not the guarantee. `scrubEvent`'s allowlist omits `breadcrumbs`
// entirely, so NO breadcrumb ever reaches GlitchTip — Sentry merges the scope's
// breadcrumbs onto the event before `beforeSend` runs, and we then rebuild the event
Expand All @@ -71,16 +99,67 @@ export function scrubBreadcrumb(breadcrumb: Breadcrumb): Breadcrumb | null {
return breadcrumb;
}

/**
* The name of the browser SDK's session integration, which {@link initSentry} removes. See
* {@link withoutSessionTracking}.
*/
const BROWSER_SESSION_INTEGRATION = 'BrowserSession';

/** One entry of the browser SDK's default integration set. Derived from the SDK's own factory —
* `@sentry/browser` does not re-export the `Integration` type, and re-declaring the shape here
* would be a copy that can drift from the version we actually install. */
type BrowserIntegration = ReturnType<typeof Sentry.getDefaultIntegrations>[number];

/**
* Crashes only — never release health.
*
* `browserSessionIntegration` ships in the browser SDK's DEFAULT integration set and calls
* `captureSession()` from its `setupOnce`, so a session envelope leaves the browser on every side
* panel mount, before anything has gone wrong. It had been inert here purely by accident:
* `Client.sendSession` discards a session when the client has no `release`, so stamping `release`
* below is precisely what switches it on. Verified as a real request, not a reading of the SDK —
* `test/e2e/smoke.spec.ts` ("zero blocked/remote font or script requests") went red on a
* `glitchtip…/api/2/envelope/` POST from a freshly opened panel that had done nothing.
*
* Two reasons it comes out rather than being tolerated. It is unsolicited network traffic from a
* panel at rest, in an extension whose entire crash-report seam exists to make sure nothing
* leaves the browser unless it has to. And `beforeSend` cannot police it: that hook sees ERROR
* events, not session envelopes, so the scrub and the `isUserAbort` drop both sit downstream of
* a lane they never see.
*/
function withoutSessionTracking(defaults: BrowserIntegration[]): BrowserIntegration[] {
return defaults.filter((integration) => integration.name !== BROWSER_SESSION_INTEGRATION);
}

// Error tracking only. GlitchTip ingests error events but supports neither Sentry
// Session Replay nor performance tracing; and enabling Session Replay in the
// all_urls content script would record every page the user visits — so neither
// integration is enabled here. beforeSend + beforeBreadcrumb strip page content
// (see scrubEvent / scrubBreadcrumb) before anything reaches GlitchTip.
// integration is enabled here. beforeSend drops the reports that are not crashes
// (see isUserAbort) and strips page content off the rest; beforeBreadcrumb stops the
// two page-derived crumb kinds from being recorded (see scrubEvent / scrubBreadcrumb).
export function initSentry(): void {
const release = extensionRelease();
Sentry.init({
dsn: DSN,
environment: import.meta.env.MODE ?? 'development',
beforeSend: scrubEvent,
// The build that crashed. `scrubEvent` has always allowlisted `release`, but nothing ever set
// it, so every report arrived unattributed — and triaging GlitchTip issue 93 stalled on
// exactly that: no way to tell a report from a build that predates a fix from one that
// survives it. The extension version is our own manifest field, not page-derived.
...(release !== undefined ? { release } : {}),
// Subtractive on purpose: keep whatever the SDK version ships by default, minus the one lane
// we refuse. See {@link withoutSessionTracking} — and note it is `release` above that would
// otherwise arm it.
integrations: withoutSessionTracking,
// Two hooks, one per concern: drop the reports that are not crashes (`isUserAbort`), then
// scrub page content off the ones that are.
beforeSend: (event) => (isUserAbort(event) ? null : scrubEvent(event)),
beforeBreadcrumb: scrubBreadcrumb,
});
}

/** `designer@<manifest version>`, or `undefined` outside an extension world (tests). */
function extensionRelease(): string | undefined {
const version = globalThis.chrome?.runtime?.getManifest?.().version;
return version ? `designer@${version}` : undefined;
}
177 changes: 177 additions & 0 deletions test/integration/empty-turn.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
import type { LanguageModelV4StreamPart, LanguageModelV4Usage } from '@ai-sdk/provider';
import { convertArrayToReadableStream, MockLanguageModelV4 } from 'ai/test';
import { describe, expect, it } from 'vitest';
import { EMPTY_TURN_ERROR, runTurn } from '@/agent/loop';
import type { DomDispatch } from '@/agent/tools/dom';
import type { SwToPanel } from '@/shared/messages';

// Integration: "the model returned no output" as the USER meets it (GlitchTip ai-designer,
// `AI_NoOutputGeneratedError` 19 events across issues 67/75/91/93, 2026-07-31 → 2026-08-18).
//
// The SDK raises that error only when the stream carried no completed step at all. An
// OpenAI-compatible gateway that answers 200 and then closes still produces a finish chunk, so the
// step IS recorded and `streamText` reports a clean, empty finish — the turn used to return `done`
// with an empty reply and no error, and the panel showed the agent shrugging. These pin the loud
// verdict, and the second half of each pair pins what must NOT be flagged.

const usage = (input: number, output: number): LanguageModelV4Usage => ({
inputTokens: { total: input, noCache: input, cacheRead: undefined, cacheWrite: undefined },
outputTokens: { total: output, text: output, reasoning: undefined },
});

const finish = (unified: 'stop' | 'tool-calls'): LanguageModelV4StreamPart => ({
type: 'finish',
usage: usage(10, 0),
finishReason: { unified, raw: unified },
});

const dispatch: DomDispatch = async () => ({
type: 'tool-result',
ok: true,
data: { color: 'red' },
});

function collectEmit() {
const events: SwToPanel[] = [];
return { events, emit: (event: SwToPanel) => events.push(event) };
}

const errorsOf = (events: SwToPanel[]) =>
events
.filter((e): e is Extract<SwToPanel, { type: 'error' }> => e.type === 'error')
.map((e) => e.message);

/** A provider wired to a stub `fetch` — the real `@ai-sdk/openai-compatible` parsing path, so the
* gateway replies below are exercised exactly as they arrive over the wire in production. */
function providerAnswering(response: () => Response) {
return createOpenAICompatible({
name: 'test-gateway',
baseURL: 'https://gateway.test/v1',
apiKey: 'k',
includeUsage: true,
fetch: async () => response(),
})('some-model');
}

const eventStream = (body: string) =>
new Response(body, { status: 200, headers: { 'content-type': 'text/event-stream' } });

describe('integration: a turn that produced nothing is reported, not shrugged off', () => {
it('flags a clean finish with no prose and no tool call', async () => {
const { events, emit } = collectEmit();
const model = new MockLanguageModelV4({
doStream: [
{
stream: convertArrayToReadableStream([
{ type: 'stream-start', warnings: [] },
finish('stop'),
]),
},
],
});

const outcome = await runTurn({
tabId: 1,
messages: [{ role: 'user', content: 'make the CTA orange' }],
model,
instructions: 'You are a design agent.',
dispatch,
emit,
});

expect(outcome.stop).toBe('error');
expect(errorsOf(events)).toEqual([EMPTY_TURN_ERROR]);
});

// Anti-vacuity: the guard must key on "produced nothing", not on "said nothing". A turn that
// edits the page and never narrates is the agent doing its job.
it('does NOT flag a turn that only ran tools and never narrated', async () => {
const { events, emit } = collectEmit();
const model = new MockLanguageModelV4({
doStream: [
{
stream: convertArrayToReadableStream([
{ type: 'stream-start', warnings: [] },
{
type: 'tool-call',
toolCallId: 't1',
toolName: 'edit',
input: JSON.stringify({
op: 'setStyle',
intent: 'Test intent',
selector: '#cta',
props: { color: 'red' },
}),
},
finish('tool-calls'),
]),
},
{
stream: convertArrayToReadableStream([
{ type: 'stream-start', warnings: [] },
finish('stop'),
]),
},
],
});

const outcome = await runTurn({
tabId: 1,
messages: [{ role: 'user', content: 'make the CTA orange' }],
model,
instructions: 'You are a design agent.',
dispatch,
emit,
});

expect(outcome.stop).toBe('done');
expect(errorsOf(events)).toEqual([]);
});

// The three gateway replies that produced this in production. All answer 200 — none trips the
// SDK's retry, and none reaches the loop's `error` stream part.
it.each([
['an empty event-stream body', ''],
['a JSON error object served as an event stream', '{"error":{"message":"no endpoints found"}}'],
[
'a chunk carrying no choices',
'data: {"id":"1","object":"chat.completion.chunk","choices":[]}\n\n',
],
])('flags %s', async (_name, body) => {
const { events, emit } = collectEmit();

const outcome = await runTurn({
tabId: 1,
messages: [{ role: 'user', content: 'make the CTA orange' }],
model: providerAnswering(() => eventStream(body)),
instructions: 'You are a design agent.',
dispatch,
emit,
});

expect(outcome.stop).toBe('error');
expect(errorsOf(events)).toEqual([EMPTY_TURN_ERROR]);
});

// Partition: a gateway that DOES stream content is untouched by the guard.
it('leaves a gateway reply that streams real content alone', async () => {
const { events, emit } = collectEmit();
const body =
'data: {"id":"1","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"Done."}}]}\n\n' +
'data: [DONE]\n\n';

const outcome = await runTurn({
tabId: 1,
messages: [{ role: 'user', content: 'make the CTA orange' }],
model: providerAnswering(() => eventStream(body)),
instructions: 'You are a design agent.',
dispatch,
emit,
});

expect(outcome.stop).toBe('done');
expect(outcome.text).toBe('Done.');
expect(errorsOf(events)).toEqual([]);
});
});
Loading
Loading