Skip to content

Commit 573f16f

Browse files
Merge pull request #1163 from oriontech-me/fix/issue-1059-max-tokens-error-attribution
test(llm-agents): make an errored agent run name its own cause (#1188)
2 parents edd3075 + 4ef3451 commit 573f16f

2 files changed

Lines changed: 95 additions & 9 deletions

File tree

docs/core-functionality/llm-agents/agent-max-tokens.md

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,10 @@ Shared setup per test:
8888
signal: the turn mounts (`div-chat-message` count rises) and the generating
8989
indicator clears (`button-stop` hidden, `button-send` back). The
9090
`chat-message-token-usage` badge is **not** the completion gate — see the
91-
note on #1059/#569 below.
91+
note on #1059/#569 below. An **error card** at either point ends the run
92+
immediately with the provider's message (#1188): upstream renders
93+
`error-card-stack` *instead of* the bot bubble, so any wait keyed on the
94+
bubble outlives an errored turn.
9295
6. Assert the badge exists (the `max_tokens` observable), then hover it and read
9396
the **Output** token count from its tooltip (`Input: 1.0K / Output: 46`
9497
format; values may be plain integers, `N.NK`, or empty ⇒ 0).
@@ -150,7 +153,9 @@ Shared setup per test:
150153
failures with three distinct messages instead of one blind
151154
`toHaveCount … Received: 0`. A false positive is impossible in the other
152155
direction too — the badge assertion is a hard `toHaveCount(1)`, never skipped
153-
when absent.
156+
when absent. The third of those states only became true in **#1188**: an
157+
errored turn renders no bot bubble at all, so it used to die 20 s later on
158+
`locator.innerText` with the provider's error nowhere in the message.
154159
- **Force-failure check** (CONTRIBUTING §2) run during VERIFY on each hard
155160
assertion before `@stable`.
156161

@@ -240,6 +245,24 @@ Shared setup per test:
240245
prints the reply that did render. Timeouts were **not** loosened (the total
241246
budget went from ~248 s to ~205 s) and no assertion was weakened — only the
242247
attribution changed.
248+
- **An errored run is resolved explicitly, at both points it can appear**
249+
(#1188). Gating on the bot bubble fixed two of the three states but not the
250+
third: upstream `chat-message.tsx` renders `ErrorView` **instead of**
251+
`BotMessage` when `chat.category === "error"`, so an errored turn carries no
252+
`div-chat-message` — only `error-card-stack`. Measured on `main` at
253+
1.12.0.dev10 against a drained Anthropic key (a deterministic reproducer: the
254+
provider answers `400 … credit balance is too low` every time), both tests
255+
failed with `locator.innerText: Timeout 20000ms exceeded — waiting for
256+
getByTestId('div-chat-message').last()`, never reaching the badge assertion
257+
where the informative message lives. The cause was on screen and in the run
258+
stream the whole time — the fixture's flow-error advisory (#1162) printed the
259+
provider's 400 in the same run. `runPrompt()` now checks for the error card
260+
after the turn-start poll (the run can fail before any bubble mounts) **and**
261+
after the completion signal (the measured case: the bubble mounts, then is
262+
replaced), failing with the provider's message expanded out of the error
263+
accordion. The reply read is also count-guarded, so a finished turn that
264+
rendered nothing still reaches the badge assertion instead of a locator
265+
timeout. This adds no pass path — an errored run still fails.
243266
- **Where the #1059 signature came from.** The filed hard failure (daily
244267
2026-07-29, `anthropic / claude-sonnet-5`) was **environmental collateral**, not
245268
a product regression: attempt 0 of that very test died on the day's dominant

tests/tests-automations/regression/core-functionality/llm-agents/agent-max-tokens.spec.ts

Lines changed: 70 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,10 @@ async function getSavedMaxTokens(page: Page): Promise<unknown> {
210210
// same pair that spec uses (the turn mounts, then the generating indicator
211211
// clears), then assert the badge separately so each failure names its own cause.
212212
// No timeout was loosened: the worst case went from ~248 s to ~205 s.
213+
//
214+
// The third state — "finished with an error" — is resolved explicitly at both
215+
// points it can appear, because upstream renders the error card INSTEAD of the
216+
// bot bubble, so every wait keyed on the bubble outlives it (#1188).
213217
async function runPrompt(page: Page): Promise<string> {
214218
const node = page.locator(
215219
'[data-testid^="rf__node-ChatInput"] [data-testid="textarea_str_input_value"]',
@@ -224,35 +228,94 @@ async function runPrompt(page: Page): Promise<string> {
224228
await expect(page.getByTestId("input-chat-playground").last()).toBeVisible({ timeout: 30000 });
225229

226230
const messages = page.getByTestId("div-chat-message");
231+
const errorCard = page.getByTestId("error-card-stack");
227232
const before = await messages.count();
228233
await page.getByTestId("button-send").last().click();
229234

230235
// 1. The turn actually started — guards the "checked completion before
231236
// generation started" race that an indicator-only wait returns early on
232237
// (#354). `toBeGreaterThan` rather than an exact count, so it holds whether
233-
// or not the user bubble carries this testid.
238+
// or not the user bubble carries this testid. An errored turn is accepted
239+
// here as a start too: upstream renders `ErrorView` INSTEAD of the bot
240+
// bubble (`chat-message.tsx`: `chat.category === "error"`), so a run that
241+
// fails before any bubble mounts would otherwise wait the full 60 s for an
242+
// element that is never coming (#1188).
234243
await expect
235-
.poll(() => messages.count(), { timeout: 60000 })
236-
.toBeGreaterThan(before);
244+
.poll(
245+
async () => (await errorCard.count()) > 0 || (await messages.count()) > before,
246+
{
247+
timeout: 60000,
248+
message: "the run neither started a reply nor rendered an error card",
249+
},
250+
)
251+
.toBe(true);
252+
await failIfRunErrored(page);
237253
// 2. Generation finished. This is the completion signal because it is emitted
238254
// for every model and every response (#569).
239255
await expect(page.getByTestId("button-stop")).toBeHidden({ timeout: 120000 });
240256
await expect(page.getByTestId("button-send").last()).toBeVisible({ timeout: 10000 });
241-
242-
const reply = (await messages.last().innerText()).trim();
257+
// The error can also arrive AFTER a bubble mounted, and that is the measured
258+
// case on 1.12.0.dev10: the bubble is replaced by the error card, so
259+
// `messages.last()` resolves to nothing and every later step waits on an
260+
// element the error path does not render (#1188).
261+
await failIfRunErrored(page);
262+
263+
// A finished turn that rendered no bubble at all must still reach the badge
264+
// assertion below — reading `.last()` unguarded is what turned that state into
265+
// a bare `locator.innerText` timeout with no cause in it.
266+
const reply =
267+
(await messages.count()) > 0
268+
? (await messages.last().innerText({ timeout: 5000 }).catch(() => "")).trim()
269+
: "";
243270

244271
// 3. Only now the observable itself. A finished turn that renders no badge is a
245272
// MISSING OBSERVABLE, not a slow model — and the message says so, quoting the
246-
// reply that did render (an error bubble included) instead of timing out blind.
273+
// reply that did render instead of timing out blind.
247274
await expect(
248275
page.getByTestId("chat-message-token-usage"),
249276
`the finished response must expose a token-usage badge — it is the max_tokens ` +
250-
`observable this spec reads. Rendered reply: ${reply.slice(0, 300)}`,
277+
`observable this spec reads. Rendered reply: ${reply.slice(0, 300) || "(none rendered)"}`,
251278
).toHaveCount(1, { timeout: 15000 });
252279

253280
return reply;
254281
}
255282

283+
// An errored run is a real outcome of this spec and must name itself. Without
284+
// this, the run fails several steps later on whatever element the error path
285+
// happens not to render — measured on `main` as `locator.innerText: Timeout
286+
// 20000ms exceeded` with the provider's 400 nowhere in the message (#1188).
287+
async function failIfRunErrored(page: Page): Promise<void> {
288+
if ((await page.getByTestId("error-card-stack").count()) === 0) return;
289+
throw new Error(
290+
`the agent run errored instead of returning a response — ${await readRunError(page)}`,
291+
);
292+
}
293+
294+
// The provider's message sits in a collapsed accordion inside the error card
295+
// (upstream `error-message.tsx`), so expand it before reading — otherwise the
296+
// failure says "An error occurred" and nothing else, which is the same dead end
297+
// the timeout was.
298+
async function readRunError(page: Page): Promise<string> {
299+
// `.last()`: the throw is gated on "any error card", so read the newest one.
300+
const stack = page.getByTestId("error-card-stack").last();
301+
// Best-effort: expanding is how we reach the provider text, never how we
302+
// decide the run failed.
303+
await stack
304+
.getByText("An error occurred")
305+
.last()
306+
.click({ timeout: 5000 })
307+
.catch(() => {});
308+
const text = (await stack.innerText().catch(() => "")).replace(/\s+/g, " ").trim();
309+
// Upstream renders the provider message only when the error carries a
310+
// component (`error-message.tsx`), so a bare label is a real outcome — and it
311+
// must not read as "here is the cause", or this helper reproduces the dead end
312+
// it exists to remove.
313+
return text && text.replace(/an error occurred/i, "").trim().length > 0
314+
? text
315+
: `${text || "(empty error card)"} — the error card carried no provider message; ` +
316+
`check the run's flow-error advisory in the test log or the flow's build log`;
317+
}
318+
256319
// "1.9K" -> 1900, "46" -> 46, missing/empty -> 0 (a tight cap can be fully
257320
// consumed by reasoning before any visible token — observed with max_tokens=1).
258321
function parseTokenCount(raw: string | undefined): number {

0 commit comments

Comments
 (0)