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
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Agent context_id — continuity between session messages

**Last validated:** Langflow 1.12.x (nightly `1.12.0.dev44`)
**Last validated:** Langflow 1.12.x (nightly `1.12.0.dev45`)

---

Expand Down Expand Up @@ -72,8 +72,13 @@ surface; `@components` — Message History node drives the retrieval assert.
unflushed batch leaves the model picker on the pre-toggle set — the
`MODEL_PICKER_DEFECT` this spec reported five times on 2026-08-31 (#1649). The
wait and its ~30 s refresh budget live in `tests/helpers/provider-setup/`; the
measurement is in the agent-area `CLAUDE.md` § 5. **This does not change what
this test validates** — it is a precondition of reaching the assertions at all.
measurement is in the agent-area `CLAUDE.md` § 5. When the enable write is
issued and never answers inside that wait's budget — the 2026-09-01 shape, and
what this spec hit again on run 33511210195 — the failure is
`MODEL_TOGGLE_WRITE_STALLED` instead: an INSTANCE stall naming the write, not a
picker defect, because the picker is then correctly showing the five models the
server actually has (agent-area `CLAUDE.md` § 5.1). **Neither changes what this
test validates** — both are preconditions of reaching the assertions at all.

---

Expand Down
101 changes: 101 additions & 0 deletions tests/helpers/provider-setup/model-option.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,3 +366,104 @@ test("with only listedModels observed the message says so instead of claiming EN
assert.match(verdict.message, /listed by the provider panel/);
assert.ok(!/is ENABLED in the provider panel/.test(verdict.message));
});

// --- #1649 (reopened): a stalled write is not a picker defect ---
//
// The loud "is ENABLED in the provider panel" verdict reads `aria-checked`, which is
// `useModelToggleQueue`'s OPTIMISTIC cache: it flips at click time, before any
// request. So when the batched write never answers, the panel claims the model is on
// while the server still has only its `MIN_DEFAULT_MODELS` five — and the picker,
// rendering the server's truth, is the honest source. On the 2026-09-01 daily that
// state produced four `MODEL_PICKER_DEFECT` failures naming two hypotheses nobody
// had measured, while the gate had printed the actual cause 90 s earlier.
//
// The stall verdict is consulted INSIDE the checked-includes-requested branch, after
// `empty`/`match`/`unmatchable`: a picker that DID offer the model is never a stall,
// and a stall must not rewrite a verdict about one.

const STALLED = {
clicked: 30,
verdict: "gave-up" as const,
writesStarted: 1,
writesFinished: 0,
};

test("a picker miss after a STALLED write blames the write, not the picker", () => {
const verdict = resolveModelOption("gemini-3.5-flash", [option("OpenAI", "gpt-4o-mini")], {
listedModels: ["gemini-3.5-flash", "gemini-3.1-flash-lite"],
checkedModels: ["gemini-3.5-flash"],
providerLabel: "Google Generative AI",
toggleWrite: STALLED,
});
assert.equal(verdict.kind, "write-stalled");
// Loud, and never a skip: the whole point of #1461's assertion survives.
assert.ok(!verdict.message.startsWith("MODEL_NOT_AVAILABLE"));
assert.match(verdict.message, /^MODEL_TOGGLE_WRITE_STALLED:/);
// It must NOT keep asserting the panel's claim as a fact about the server.
assert.ok(!/is ENABLED in the provider panel/.test(verdict.message));
assert.match(verdict.message, /OPTIMISTIC/);
assert.match(verdict.message, /1 write\(s\) started, 0 finished/);
// The picker's own counts stay in the message: they are what shows the server had
// the five defaults, which is the reading that makes the picker correct.
assert.match(verdict.message, /1 option\(s\)/);
assert.match(verdict.message, /MIN_DEFAULT_MODELS/);
assert.match(verdict.message, /INSTANCE stall/);
});

test("a picker miss after a SETTLED write is still MODEL_PICKER_DEFECT", () => {
// The load-bearing negative. If the stall branch widened to every give-up-shaped
// context, the genuine picker/panel disagreement #1461 exists to catch would be
// relabelled as an environment problem and stop being investigated.
const verdict = resolveModelOption("gemini-3.5-flash", [option("OpenAI", "gpt-4o-mini")], {
listedModels: ["gemini-3.5-flash"],
checkedModels: ["gemini-3.5-flash"],
providerLabel: "Google Generative AI",
toggleWrite: { clicked: 30, verdict: "settled", writesStarted: 1, writesFinished: 1 },
});
assert.equal(verdict.kind, "unmatchable");
assert.match(verdict.message, /^MODEL_PICKER_DEFECT:/);
assert.match(verdict.message, /is ENABLED in the provider panel/);
});

test("an unobserved batch leaves every existing verdict byte-identical", () => {
// Callers that never ran the gate (and every caller before this change) pass no
// `toggleWrite`. An unobserved source must not be read as a negative one (#1012),
// so the message they get is the one they got before.
const withoutContext = resolveModelOption(
"gemini-3.5-flash",
[option("OpenAI", "gpt-4o-mini")],
{
listedModels: ["gemini-3.5-flash"],
checkedModels: ["gemini-3.5-flash"],
providerLabel: "Google Generative AI",
},
);
assert.equal(withoutContext.kind, "unmatchable");
assert.match(withoutContext.message, /^MODEL_PICKER_DEFECT:/);
});

test("a stall never rewrites a verdict about a picker that DOES offer the model", () => {
// `match` and `unmatchable`-by-identity are decided before the panel sources are
// consulted at all. A stalled write says nothing about a model the picker is
// offering, and reporting an instance stall there would hide the #1459 class of
// suite defect (identity no longer resolving).
const matched = resolveModelOption("gpt-4o-mini", [option("OpenAI", "gpt-4o-mini")], {
checkedModels: ["gpt-4o-mini"],
providerLabel: "OpenAI",
toggleWrite: STALLED,
});
assert.equal(matched.kind, "match");
});

test("a listed model with its toggle OFF stays a setup failure even under a stall", () => {
// The optimistic cache is what makes a stall look enabled; a toggle reading OFF is
// therefore NOT the stall's signature, and `MODEL_NOT_ENABLED` — which already
// names the debounce cause — remains the right verdict.
const verdict = resolveModelOption("gemini-3.5-flash", [option("OpenAI", "gpt-4o-mini")], {
listedModels: ["gemini-3.5-flash", "gemini-3.1-flash-lite"],
checkedModels: ["gemini-3.1-flash-lite"],
providerLabel: "Google Generative AI",
toggleWrite: STALLED,
});
assert.equal(verdict.kind, "not-enabled");
});
92 changes: 92 additions & 0 deletions tests/helpers/provider-setup/model-option.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import type { Locator, Page } from "@playwright/test";
import {
MODEL_TOGGLE_WRITE_STALLED,
modelTriggerStallMessage,
writeStallReason,
type ToggleBatchOutcome,
} from "./model-toggle-batch";

/**
* One entry of the unified ModelInput picker, read straight from the DOM.
Expand Down Expand Up @@ -52,6 +58,7 @@
| { kind: "unmatchable"; message: string; evidence: string[] }
| { kind: "empty"; message: string }
| { kind: "not-enabled"; message: string }
| { kind: "write-stalled"; message: string }
| { kind: "absent"; message: string };

export type ResolveContext = {
Expand Down Expand Up @@ -80,6 +87,19 @@
checkedModels?: string[];
/** Provider the caller is configuring, for the message only. */
providerLabel?: string;
/**
* What the panel's own toggle batch did before the panel was closed —
* `enableAndSettleModelToggles`' return value.
*
* It is the THIRD source, and the only one that can tell a picker/panel
* disagreement with a known cause from one without: `checkedModels` reads
* `aria-checked`, which is `useModelToggleQueue`'s OPTIMISTIC cache and flips at
* click time before any request. So when the batched write never answers, the
* panel claims the model is on while the server still holds its
* `MIN_DEFAULT_MODELS` five, and the picker — rendering the server — is the
* honest one. `undefined` means "not observed" and changes no verdict (#1012).
*/
toggleWrite?: ToggleBatchOutcome;
};

const MODEL_NOT_AVAILABLE = "MODEL_NOT_AVAILABLE";
Expand Down Expand Up @@ -223,6 +243,27 @@
const checked = context.checkedModels;

if (checked?.includes(requested)) {
// The panel says ON — but `aria-checked` is the optimistic cache, so before
// that claim may be turned into a picker defect, ask whether the write behind
// it ever landed. Consulted HERE and not earlier on purpose: `empty`, `match`
// and `unmatchable`-by-identity are already decided above, and a stalled write
// says nothing about a model the picker IS offering — reporting a stall there
// would hide the #1459 class of defect (identity no longer resolving).
const stalled = writeStallReason(context.toggleWrite);
if (stalled !== null) {
return {
kind: "write-stalled",
message:
`${MODEL_TOGGLE_WRITE_STALLED}: "${requested}" reads as enabled in the provider ` +
`panel (llm-toggle-${requested})${provider}, but that is the OPTIMISTIC client ` +
`cache — ${stalled}. The model was therefore never enabled server-side, and the ` +
`picker is CORRECT to offer ${options.length} option(s) ` +
`(${providerCounts(options)}): a freshly configured provider's ` +
`${"`"}MIN_DEFAULT_MODELS${"`"} default. This is an INSTANCE stall — not a picker ` +
`defect and not a missing model. Do not raise the flush budget to make it pass ` +
`(#1649).`,
};
}
return {
kind: "unmatchable",
evidence: [`llm-toggle-${requested} in the provider panel`],
Expand Down Expand Up @@ -448,6 +489,49 @@
await locator.first().click();
}

/**
* Opens the model picker after the provider panel was closed, attributing a
* failure to the toggle batch when the batch is what explains it.
*
* Shared by the three provider setups because the block was copy-pasted three
* times and had already drifted: #1651 landed the same 60 s budgets in each with
* three differently-worded comments, and this is the second change to touch all
* three. Both budgets stay 60 s and are NOT a retry: taking the correct flush path
* means the product genuinely re-fetches, measured at 30 020 ms and 29 640 ms
* against the 4 327 ms the broken path returned in. The click carries its own
* budget because it otherwise falls back to the 20 s `actionTimeout` while the
* trigger re-enters `ModelInputLoadingButton` between "visible" and the click.
*
* What is new is the catch. On a batch that never settled, the post-close refresh
* runs in a write's `onSettled` that never fired, so the trigger can stay
* unusable for the whole budget — measured twice on the 2026-09-01 daily as a bare
* `locator.waitFor: Timeout 60000ms exceeded ... getByTestId('model_model')` with
* nothing naming a cause. The batch's own observation is re-thrown instead, and
* Playwright's original message is kept inside it.
*/
export async function openModelPickerAfterPanelClose(
page: Page,
context: { providerLabel: string; toggleWrite?: ToggleBatchOutcome },
): Promise<void> {
const trigger = page.getByTestId("model_model");
try {
await trigger.waitFor({ state: "visible", timeout: 60000 });

Check failure on line 518 in tests/helpers/provider-setup/model-option.ts

View workflow job for this annotation

GitHub Actions / Run impacted E2E specs

[chromium] › tests/tests-automations/regression/core-functionality/llm-agents/language-model-regression.spec.ts:246:7 › Language Model Component Regression › language model provider switch from OpenAI to Google must persist @stable @release @components @model-provider

2) [chromium] › tests/tests-automations/regression/core-functionality/llm-agents/language-model-regression.spec.ts:246:7 › Language Model Component Regression › language model provider switch from OpenAI to Google must persist @stable @Release @components @model-provider TimeoutError: locator.waitFor: Timeout 60000ms exceeded. Call log: - waiting for getByTestId('model_model') to be visible at helpers/provider-setup/model-option.ts:518 516 | const trigger = page.getByTestId("model_model"); 517 | try { > 518 | await trigger.waitFor({ state: "visible", timeout: 60000 }); | ^ 519 | // The locator is re-resolved on every actionability retry, so this survives the 520 | // element being replaced, and nothing about the assertion that follows is 521 | // weakened. at openModelPickerAfterPanelClose (/home/runner/work/langflow-e2e/langflow-e2e/tests/helpers/provider-setup/model-option.ts:518:19) at setupOpenAI (/home/runner/work/langflow-e2e/langflow-e2e/tests/helpers/provider-setup/setup-openai.ts:103:39) at initialGPTsetup (/home/runner/work/langflow-e2e/langflow-e2e/tests/helpers/other/initialGPTsetup.ts:29:5) at /home/runner/work/langflow-e2e/langflow-e2e/tests/tests-automations/regression/core-functionality/llm-agents/language-model-regression.spec.ts:257:7
// The locator is re-resolved on every actionability retry, so this survives the
// element being replaced, and nothing about the assertion that follows is
// weakened.
await trigger.click({ timeout: 60000 });
} catch (error) {
const attributed = modelTriggerStallMessage(context.toggleWrite, {
providerLabel: context.providerLabel,
original: (error as Error).message,
});
if (attributed !== null) throw new Error(attributed);
// No stall to blame: a trigger that never returns on a healthy flush is a real
// defect and must keep surfacing as Playwright's own error, call log included.
throw error;
}
}

export type PinnedSelection =
| { status: "selected"; model: string }
| { status: "absent"; message: string };
Expand All @@ -468,6 +552,13 @@
listedModels?: string[];
checkedModels?: string[];
providerLabel?: string;
/**
* `enableAndSettleModelToggles`' result. `write-stalled` is deliberately NOT
* returnable through `absentBehavior: "return"`: that hatch exists for a stale
* pin from `models.json` (#606), and degrading on an instance that could not
* accept the write would hide exactly the state #1649 was reopened for.
*/
toggleWrite?: ToggleBatchOutcome;
absentBehavior?: "throw" | "return";
timeout?: number;
},
Expand All @@ -477,6 +568,7 @@
listedModels: opts.listedModels,
checkedModels: opts.checkedModels,
providerLabel: opts.providerLabel,
toggleWrite: opts.toggleWrite,
});

if (verdict.kind === "match") {
Expand Down
101 changes: 100 additions & 1 deletion tests/helpers/provider-setup/model-toggle-batch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,12 @@
// reason `resolveModelOption` and `censusForTarget` are.
import { test } from "node:test";
import assert from "node:assert/strict";
import { flushVerdict, type ToggleBatchObservation } from "./model-toggle-batch";
import {
flushVerdict,
modelTriggerStallMessage,
writeStallReason,
type ToggleBatchObservation,
} from "./model-toggle-batch";

const OPTS = { quietMs: 1500, deadlineAt: 100_000 };

Expand Down Expand Up @@ -106,3 +111,97 @@ test("the deadline never overrides nothing-to-flush", () => {
);
assert.equal(v.kind, "nothing-to-flush");
});

// --- #1649 (reopened): a give-up is an OBSERVED cause, and it must be carried ---
//
// The gate above already prints what it saw. What it did NOT do was hand that
// observation to the picker read that follows, so 90 s later the failure named a
// cause nobody had measured ("the picker did not refresh, or the option list is
// filtered") while the real one — the write never answered — sat in a log line no
// failure message, no `error_signature` and no triage dataset correlates. All eight
// give-ups on the 2026-09-01 daily read `1 write(s) started, 0 finished`.
//
// `writeStallReason` is the carrier, and it is pure for the same reason
// `flushVerdict` is. Three properties ride on it: an UNOBSERVED batch is not a
// negative one (#1012), a SETTLED batch must leave the existing verdict alone, and
// an unchanged panel is never a stall.

test("a gave-up batch yields a reason naming the write that never answered", () => {
const reason = writeStallReason({
clicked: 30,
verdict: "gave-up",
writesStarted: 1,
writesFinished: 0,
});
assert.ok(reason !== null);
assert.match(reason!, /30 toggle\(s\) clicked/);
assert.match(reason!, /1 write\(s\) started/);
assert.match(reason!, /0 finished/);
// The endpoint is named, because "the write" is not actionable on its own.
assert.match(reason!, /enabled_models/);
});

test("an UNOBSERVED batch is not a stalled one", () => {
// The three provider helpers pass what they measured; anything else (a caller
// that never ran the gate) must produce no claim at all rather than a negative.
assert.equal(writeStallReason(undefined), null);
});

test("a settled batch is never a stall, whatever the counts say", () => {
// This is the branch that keeps MODEL_PICKER_DEFECT alive: a picker that
// disagrees AFTER a clean flush is the genuine, unexplained disagreement #1461
// wrote its assertion for, and re-labelling it as an instance stall would blind
// the suite to it.
assert.equal(
writeStallReason({ clicked: 36, verdict: "settled", writesStarted: 1, writesFinished: 1 }),
null,
);
assert.equal(
writeStallReason({ clicked: 0, verdict: "nothing-to-flush", writesStarted: 0, writesFinished: 0 }),
null,
);
});

test("a panel nobody changed is never a stall, even past the deadline", () => {
// `flushVerdict` cannot return gave-up with clicked === 0 today, but the guard is
// cheap and the alternative is a scary instance-stall verdict on a healthy run
// the moment that ordering changes.
assert.equal(
writeStallReason({ clicked: 0, verdict: "gave-up", writesStarted: 0, writesFinished: 0 }),
null,
);
});

test("the model_model message blames the instance, keeps the original error, and cannot skip", () => {
const message = modelTriggerStallMessage(
{ clicked: 30, verdict: "gave-up", writesStarted: 1, writesFinished: 0 },
{
providerLabel: "Google Generative AI",
original: "locator.waitFor: Timeout 60000ms exceeded.",
},
);
assert.ok(message !== null);
// Two of #1649's six occurrences were this timeout, 60 s each, with nothing in
// the message naming a cause. The prefix must NOT be the skip prefix.
assert.ok(!message!.startsWith("MODEL_NOT_AVAILABLE"));
assert.match(message!, /^MODEL_TOGGLE_WRITE_STALLED:/);
assert.match(message!, /Google Generative AI/);
assert.match(message!, /1 write\(s\) started, 0 finished/);
assert.match(message!, /locator\.waitFor: Timeout 60000ms exceeded\./);
// The refresh runs in the batch's own onSettled — saying so is what separates
// this from a trigger/testid defect.
assert.match(message!, /onSettled/);
assert.match(message!, /#1649/);
});

test("with no stall the model_model failure is left exactly as it was", () => {
// A trigger that never appears on a HEALTHY flush is a real defect and must keep
// surfacing as Playwright's own locator error, not be re-labelled.
assert.equal(
modelTriggerStallMessage(
{ clicked: 36, verdict: "settled", writesStarted: 1, writesFinished: 1 },
{ providerLabel: "OpenAI", original: "locator.click: Timeout 60000ms exceeded." },
),
null,
);
});
Loading
Loading