Skip to content

Commit 5226571

Browse files
Merge pull request #1117 from oriontech-me/fix/issue-1072-agent-credential-settle
fix(agents): make the Agent credential guard non-vacuous and self-describing (#1072)
2 parents f8c6b8e + 4a4c882 commit 5226571

3 files changed

Lines changed: 898 additions & 24 deletions

File tree

Lines changed: 382 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,382 @@
1+
// Unit tests for the Agent credential-settle classifier (issue #1072).
2+
// Run with: npm run test:units
3+
//
4+
// What rides on this module: the guard at the end of every
5+
// `SimpleAgentTemplatePage.load()` fails with whatever this produces, and that
6+
// message is the entire product of the failure in a daily's report. Before #1072
7+
// the guard raised a bare `expect(...).toBe(...)` mismatch — "Expected
8+
// GOOGLE_API_KEY / Received ANTHROPIC_API_KEY" — which was triaged as a
9+
// provider-wiring bug on the 2026-07-22 and 2026-07-27 dailies before anyone
10+
// traced it to this guard.
11+
//
12+
// Two properties these tests exist to protect:
13+
//
14+
// 1. **The payload shape.** `template.model.value` is an ARRAY of model objects,
15+
// not a string. The first cut of this module read it as a string, so every
16+
// probe came back with no model and the verdicts that carry the whole
17+
// distinction became unreachable — while the tests passed, because the fixture
18+
// used a string too. Every fixture below therefore uses the real shape, and
19+
// `MODEL_VALUE_SHAPE` documents where it is verified in the product.
20+
// 2. **The verdict distinctions.** Several states produce the same "wrong
21+
// credential" observation and they send a reader to opposite places: a lagging
22+
// autosave, a model click that never registered, a credential that matches only
23+
// because it is the selector's default, and a read that never succeeded. A
24+
// classifier that collapses any of those silently re-creates the mis-triage,
25+
// and no Playwright spec would fail for it.
26+
import { test } from "node:test";
27+
import assert from "node:assert/strict";
28+
import {
29+
classifyCredentialSettle,
30+
formatCredentialSettleFailure,
31+
readAgentCredentialProbe,
32+
type AgentCredentialProbe,
33+
} from "./agent-credential-settle";
34+
35+
/**
36+
* Where the array shape is established in the product and in this repo:
37+
* `provider-setup/setup-language-model-openai.ts` ("the backend executes
38+
* `model.value[0].name` directly"), `agent-structured-output.spec.ts` (reads
39+
* `value` and maps `m.name`), and `rag-pipeline.spec.ts` (`tmpl.model.value =
40+
* [modelOption]`).
41+
*/
42+
const MODEL_VALUE_SHAPE = (name: string) => [
43+
{ id: `openai/${name}`, name, provider: "OpenAI", icon: "OpenAI" },
44+
];
45+
46+
/**
47+
* A `GET /api/v1/flows/{id}` payload shaped like the real one: the Simple Agent
48+
* template's ChatInput + Agent + ChatOutput, with only the fields the probe reads
49+
* spelled out.
50+
*/
51+
const flowWithAgent = (credential: string, modelValue: unknown) => ({
52+
id: "flow-1",
53+
data: {
54+
nodes: [
55+
{ data: { type: "ChatInput", node: { template: { input_value: { value: "" } } } } },
56+
{
57+
data: {
58+
type: "Agent",
59+
node: {
60+
template: {
61+
api_key: { value: credential },
62+
model: { value: modelValue },
63+
system_prompt: { value: "You are a helpful assistant" },
64+
},
65+
},
66+
},
67+
},
68+
{ data: { type: "ChatOutput", node: { template: {} } } },
69+
],
70+
},
71+
});
72+
73+
// ─── readAgentCredentialProbe — against the real payload shape ───────────────
74+
75+
test("the fixture itself uses the real array-of-objects shape", () => {
76+
// Without this, the pair (fixture, implementation) can be reverted TOGETHER to
77+
// the string shape and every other test still passes — which is exactly the
78+
// inert state described in this file's header. The fixture is the property.
79+
const value = MODEL_VALUE_SHAPE("gpt-4o-mini");
80+
assert.ok(Array.isArray(value), "model.value must be an array");
81+
assert.equal(typeof value[0], "object");
82+
assert.equal(value[0]?.name, "gpt-4o-mini");
83+
});
84+
85+
test("reads the credential and the SELECTED model names off the real shape", () => {
86+
const probe = readAgentCredentialProbe(
87+
flowWithAgent("OPENAI_API_KEY", MODEL_VALUE_SHAPE("gpt-4o-mini")),
88+
);
89+
assert.deepEqual(probe, {
90+
credential: "OPENAI_API_KEY",
91+
selectedModels: ["gpt-4o-mini"],
92+
});
93+
});
94+
95+
test("an unselected model field reads as no models, not as one empty name", () => {
96+
// The template ships `model.value: ""`, and the field stays empty until a
97+
// selection is autosaved. `[""]` would make `includes(expectedModel)` false but
98+
// `length === 0` false too, hiding `nothing-persisted`.
99+
for (const empty of [[], "", null, undefined, [null], [{}], [{ name: 42 }]]) {
100+
const probe = readAgentCredentialProbe(flowWithAgent("ANTHROPIC_API_KEY", empty));
101+
assert.deepEqual(
102+
probe?.selectedModels,
103+
[],
104+
`expected no model names for ${JSON.stringify(empty)}`,
105+
);
106+
}
107+
});
108+
109+
test("a bare-string model value is still read, for a pre-unified-selector payload", () => {
110+
const probe = readAgentCredentialProbe(
111+
flowWithAgent("OPENAI_API_KEY", "gpt-4o-mini"),
112+
);
113+
assert.deepEqual(probe?.selectedModels, ["gpt-4o-mini"]);
114+
});
115+
116+
test("returns null when the flow carries no Agent node", () => {
117+
const noAgent = { data: { nodes: [{ data: { type: "ChatInput" } }] } };
118+
assert.equal(readAgentCredentialProbe(noAgent), null);
119+
});
120+
121+
test("survives every partial payload a poll can observe", () => {
122+
// The probe runs inside a retry loop, so a transient/incomplete body must read
123+
// as "not settled yet" — never as a crash that escapes the loop.
124+
for (const payload of [
125+
undefined,
126+
null,
127+
{},
128+
{ data: {} },
129+
{ data: { nodes: null } },
130+
{ data: { nodes: [{}] } },
131+
{ data: { nodes: [{ data: { type: "Agent" } }] } },
132+
]) {
133+
const probe = readAgentCredentialProbe(payload);
134+
assert.ok(
135+
probe === null ||
136+
(probe.credential === "" && probe.selectedModels.length === 0),
137+
`unexpected probe for ${JSON.stringify(payload)}: ${JSON.stringify(probe)}`,
138+
);
139+
}
140+
});
141+
142+
test("a non-string api_key reads as empty, not as the raw object", () => {
143+
const weird = flowWithAgent(
144+
{ name: "OPENAI_API_KEY" } as unknown as string,
145+
MODEL_VALUE_SHAPE("gpt-4o-mini"),
146+
);
147+
assert.equal(readAgentCredentialProbe(weird)?.credential, "");
148+
});
149+
150+
// ─── classifyCredentialSettle — the distinctions that matter ─────────────────
151+
152+
const probeOf = (credential: string, models: string[]): AgentCredentialProbe => ({
153+
credential,
154+
selectedModels: models,
155+
});
156+
157+
test("credential and model both applied is settled", () => {
158+
assert.equal(
159+
classifyCredentialSettle(
160+
probeOf("GOOGLE_API_KEY", ["gemini-2.5-flash"]),
161+
"GOOGLE_API_KEY",
162+
"gemini-2.5-flash",
163+
),
164+
"settled",
165+
);
166+
});
167+
168+
test("the anthropic default binding alone is model-pending, NOT settled", () => {
169+
// The vacuity #1072 found: for `provider: "anthropic"` the expected credential
170+
// IS the selector's default auto-binding, so a credential-only check declared
171+
// the load settled before the model selection had been applied at all.
172+
assert.equal(
173+
classifyCredentialSettle(
174+
probeOf("ANTHROPIC_API_KEY", []),
175+
"ANTHROPIC_API_KEY",
176+
"claude-sonnet-5",
177+
),
178+
"model-pending",
179+
);
180+
});
181+
182+
test("model applied but credential still on the default is credential-pending", () => {
183+
// The 2026-07-27 daily flake, exactly: a google target observing the default
184+
// ANTHROPIC binding while the requested Gemini model IS already selected.
185+
assert.equal(
186+
classifyCredentialSettle(
187+
probeOf("ANTHROPIC_API_KEY", ["gemini-2.5-flash"]),
188+
"GOOGLE_API_KEY",
189+
"gemini-2.5-flash",
190+
),
191+
"credential-pending",
192+
);
193+
});
194+
195+
test("a different persisted model is model-not-applied", () => {
196+
assert.equal(
197+
classifyCredentialSettle(
198+
probeOf("ANTHROPIC_API_KEY", ["claude-sonnet-5"]),
199+
"GOOGLE_API_KEY",
200+
"gemini-2.5-flash",
201+
),
202+
"model-not-applied",
203+
);
204+
});
205+
206+
test("no persisted model at all is nothing-persisted", () => {
207+
assert.equal(
208+
classifyCredentialSettle(
209+
probeOf("ANTHROPIC_API_KEY", []),
210+
"OPENAI_API_KEY",
211+
"gpt-4o-mini",
212+
),
213+
"nothing-persisted",
214+
);
215+
});
216+
217+
test("without an expected model only the credential axis is available", () => {
218+
// A caller that lets the setup helper pick the first model has no name to
219+
// compare, so the model axis must not manufacture a verdict.
220+
assert.equal(
221+
classifyCredentialSettle(probeOf("OPENAI_API_KEY", []), "OPENAI_API_KEY"),
222+
"settled",
223+
);
224+
assert.equal(
225+
classifyCredentialSettle(
226+
probeOf("ANTHROPIC_API_KEY", ["claude-sonnet-5"]),
227+
"OPENAI_API_KEY",
228+
),
229+
"credential-pending",
230+
);
231+
});
232+
233+
test("a missing Agent node is its own verdict, not a pending credential", () => {
234+
assert.equal(
235+
classifyCredentialSettle(null, "OPENAI_API_KEY", "gpt-4o-mini"),
236+
"no-agent-node",
237+
);
238+
});
239+
240+
// ─── formatCredentialSettleFailure ───────────────────────────────────────────
241+
242+
const failure = (
243+
over: Partial<Parameters<typeof formatCredentialSettleFailure>[0]> = {},
244+
) =>
245+
formatCredentialSettleFailure({
246+
flowId: "abc-123",
247+
provider: "google",
248+
expectedCredential: "GOOGLE_API_KEY",
249+
expectedModel: "gemini-2.5-flash",
250+
probe: probeOf("ANTHROPIC_API_KEY", ["gemini-2.5-flash"]),
251+
verdict: "credential-pending",
252+
elapsedMs: 20_400,
253+
reads: 14,
254+
...over,
255+
});
256+
257+
/** The value on a labelled line, so an assertion cannot pass on a coincidence. */
258+
function field(message: string, label: string): string | undefined {
259+
const line = message
260+
.split("\n")
261+
.find((candidate) => candidate.trim().startsWith(`${label} `));
262+
return line?.trim().slice(label.length).trim();
263+
}
264+
265+
test("the message carries every fact needed to triage without the artifacts", () => {
266+
const message = failure();
267+
// Read per labelled field rather than substring-matching the whole message:
268+
// `gemini-2.5-flash` appears on two lines, so a plain `includes` could not fail
269+
// if the "model wanted" line were dropped.
270+
assert.equal(field(message, "flow"), "abc-123");
271+
assert.equal(field(message, "provider"), 'google (expected credential "GOOGLE_API_KEY")');
272+
assert.equal(field(message, "model wanted"), "gemini-2.5-flash");
273+
assert.equal(
274+
field(message, "observed"),
275+
'api_key="ANTHROPIC_API_KEY" models=[gemini-2.5-flash]',
276+
);
277+
assert.equal(field(message, "waited"), "20.4s over 14 read(s)");
278+
assert.equal(field(message, "verdict"), "credential-pending");
279+
// The two issues a reader needs: why the guard exists, where the load relief is.
280+
assert.ok(message.includes("#751"), message);
281+
assert.ok(message.includes("#1077"), message);
282+
});
283+
284+
test("a caller that chose no model says so on the model line", () => {
285+
assert.equal(
286+
field(failure({ expectedModel: undefined }), "model wanted"),
287+
"(caller let the setup helper choose)",
288+
);
289+
});
290+
291+
test("credential-pending does not wave the failure off as load", () => {
292+
// It prints only AFTER the budget expired, i.e. retrying did not help within it,
293+
// and a credential belonging to a THIRD provider is a real misbinding.
294+
const message = failure();
295+
assert.ok(
296+
message.includes("retrying did NOT help"),
297+
"the guidance must not tell the reader to dismiss a failure that did not settle",
298+
);
299+
assert.ok(message.includes("THIRD provider"), message);
300+
});
301+
302+
test("nothing-persisted points at the request that actually rebinds", () => {
303+
// The flows PATCH only persists the result; the binding is computed by the
304+
// custom_component update. Naming the wrong one sends the reader to a healthy
305+
// request and a wrong conclusion.
306+
const message = failure({
307+
verdict: "nothing-persisted",
308+
probe: probeOf("ANTHROPIC_API_KEY", []),
309+
});
310+
assert.ok(message.includes("custom_component/update"), message);
311+
});
312+
313+
test("model-pending covers BOTH the default-binding and the real-rebind case", () => {
314+
// The verdict is reachable two ways and they mean different things: anthropic,
315+
// where the credential match is free because it is the selector's default; and
316+
// any other provider, where the rebind DID land and only the selection has not.
317+
// Guidance that asserts only the first is false half the time it prints.
318+
const message = failure({
319+
verdict: "model-pending",
320+
provider: "anthropic",
321+
expectedCredential: "ANTHROPIC_API_KEY",
322+
expectedModel: "claude-sonnet-5",
323+
probe: probeOf("ANTHROPIC_API_KEY", []),
324+
});
325+
assert.ok(message.includes("DEFAULT binding"), message);
326+
assert.ok(
327+
message.includes("any other provider"),
328+
"must not assert the default-binding cause for a provider where it is wrong",
329+
);
330+
assert.ok(message.includes("provider setup"), message);
331+
});
332+
333+
test("a guard that never read the flow reports UNKNOWN, not an absent Agent node", () => {
334+
const message = failure({
335+
probe: null,
336+
verdict: "read-failed",
337+
reads: 0,
338+
lastReadError: "apiRequestContext.get: Timeout 20000ms exceeded.",
339+
});
340+
assert.equal(field(message, "observed"), "no successful read of the persisted flow");
341+
assert.equal(field(message, "last read err"), "apiRequestContext.get: Timeout 20000ms exceeded.");
342+
assert.ok(message.includes("UNKNOWN"), message);
343+
assert.ok(
344+
!message.includes("has no Agent node"),
345+
"read-failed must not assert a fact the guard never observed",
346+
);
347+
});
348+
349+
test("no-agent-node is reported only for a flow that WAS read", () => {
350+
const message = failure({ probe: null, verdict: "no-agent-node" });
351+
assert.equal(field(message, "observed"), "the flow was read and carries no Agent node");
352+
assert.ok(
353+
message.includes("did not instantiate as expected"),
354+
"the verdict needs guidance of its own, not an empty line",
355+
);
356+
});
357+
358+
test("model-not-applied covers the wedged save path, not only a wrong click", () => {
359+
// Verified on 1.12.0.dev10: the Agent's mount-time prefill persists
360+
// ANTHROPIC_API_KEY plus the default Claude model, so on a google/openai load a
361+
// save path that never lands leaves EXACTLY this state. Guidance that sends the
362+
// reader to the setup helper only would misdirect every wedged load (#1077).
363+
const message = failure({
364+
verdict: "model-not-applied",
365+
probe: probeOf("ANTHROPIC_API_KEY", ["claude-opus-5"]),
366+
});
367+
assert.ok(message.includes("setup helper"), message);
368+
assert.ok(message.includes("prefill"), message);
369+
assert.ok(message.includes("#1077"), message);
370+
});
371+
372+
test("credential-pending with NO pinned model does not claim the selection registered", () => {
373+
// Reachable from the three specs that call load({ provider }) with no model, and
374+
// from any spec whose models.json came back empty. The usual guidance asserts
375+
// "the requested model IS applied" — nothing checked that here.
376+
const message = failure({ expectedModel: undefined });
377+
assert.ok(
378+
!message.includes("The requested model IS applied"),
379+
"must not assert a check that could not run:\n" + message,
380+
);
381+
assert.ok(message.includes("pinned no model"), message);
382+
});

0 commit comments

Comments
 (0)