Skip to content
Draft
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
22 changes: 20 additions & 2 deletions tests_end_to_end/coverage/taxonomy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -648,18 +648,36 @@ areas:
- online-evaluation/online-evaluation-sampling-rate.spec.ts
- online-evaluation/online-evaluation-python-metric-errors.spec.ts
- online-evaluation/online-evaluation-non-object-sections.spec.ts
- online-evaluation/online-evaluation-json-looking-judge-prompts.spec.ts
- online-evaluation/online-evaluation-judge-prompt-round-trip.spec.ts
capabilities:
create-llm-judge-rule: { covered: true, tier: t1-smoke }
llm-judge-scores: { covered: true, tier: t1-smoke, note: "bimodal safe/unsafe" }
create-python-rule: { covered: true, tier: t1-smoke }
python-rule-scores: { covered: true, tier: t1-smoke, note: "deterministic 3x1.0 / 2x0.0; t2 also covers sub-path mappings over non-object sections and the 400-class classification of a metric that exits 0 without a result line" }
scores-in-trace-panel: { covered: true, tier: t1-smoke }
list-rules: { covered: false }
list-rules: { covered: true, tier: t2-cuj, note: "project-scoped and workspace-wide listing over a project holding six bracket-opening judge prompts, plus the rules page rendering a row for each" }
rule-scope-thread-span: { covered: false, note: "span/thread scope flags" }
rule-filters: { covered: false }
sampling-rate: { covered: true, tier: t2-cuj, note: "50% rule vs 100% control over one 30-trace batch, binomial band 15-85%; plus a 0%-rate rule at trigger_scope=both, which must skip every SDK trace and still score experiment/playground/optimization ones" }
clone-rule: { covered: false }
edit-rule: { covered: false }
# Scoped, and the two halves are not equally scoped:
# - plain-string prompt: the edit dialog IS opened, its hydrated prompt
# asserted byte-exact, and submitted unchanged, so the UI save round
# trip is real.
# - content_array prompt: API-level only. The dialog renders no image
# part, so the url/detail that a regression would drop are not
# observable in the UI; that half is a read-then-write over REST and
# no dialog gesture is made.
# Editing a FIELD through the dialog (renaming, changing the model,
# rewriting the prompt) is still uncovered either way.
#
# OPEN, needs a human call: the content_array test asserts judge-message
# content-SHAPE preservation, which no capability in this area names. It
# rides on edit-rule for want of a better key. Either add a capability for
# it or drop that tag and leave the behaviour honestly uncovered — do not
# let it sit here as a silent widening of edit-rule.
edit-rule: { covered: true, tier: t2-cuj, note: "plain-string prompt: edit-dialog hydration + no-op re-save, byte-for-byte; content_array prompt: API-only read-then-write re-save, no dialog gesture; editing a field through the dialog is not covered" }
Comment on lines +675 to +680

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coverage taxonomy falsely reports edit coverage

edit-rule is marked covered: true even though field editing remains untested, so coverage gates and reviewers may treat rename/model/prompt edits as covered based only on hydration and no-op re-save — should we add a field-edit assertion, or keep it uncovered and add a narrower prompt round-trip capability?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
`tests_end_to_end/coverage/taxonomy.yaml` around lines 675-680, correct the `edit-rule`
coverage declaration so it does not claim field editing is covered when the tests only
perform prompt hydration and no-op re-save round trips. Either add an end-to-end
assertion that renames a rule, changes its model, or rewrites its prompt and then keep
`edit-rule` covered, or mark `edit-rule` uncovered and introduce a narrower capability
(such as prompt round-trip preservation) with an accurate note.

enable-disable-rule: { covered: true, tier: t2-cuj, note: "edit-dialog switch; control rule proves scoring stopped, then resumed" }
delete-rule: { covered: true, tier: t2-cuj, note: "row kebab delete; control rule proves scoring stopped" }
# Deliberately still false. online-evaluation-python-metric-errors.spec.ts
Expand Down
277 changes: 277 additions & 0 deletions tests_end_to_end/e2e/core/backend/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,61 @@ export interface AutomationRuleLogRef {
message: string;
}

/**
* One content part of a structured (multimodal) judge message.
*
* Only the fields the specs assert on are modelled; `video_url` / `audio_url`
* exist on the wire too and are left off deliberately rather than typed and
* ignored.
*/
export interface JudgeMessageContentPartRef {
type: string;
text: string | null;
imageUrl: { url: string; detail: string | null } | null;
Comment on lines +197 to +200

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

API response types hide wire field names

These API-facing interfaces rename the wire fields to imageUrl and contentArray in JudgeMessageRef, so their declared response shapes don't match the image_url and content_array JSON the implementation reads — should we use the exact backend keys or document these as normalized client representations instead?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
tests_end_to_end/e2e/core/backend/client.ts around lines 197-200, update
JudgeMessageContentPartRef and the related JudgeMessageRef definition so their names and
documentation accurately reflect whether they model raw backend JSON or a normalized
client result. Prefer using the wire keys image_url and content_array for API-facing
types, or explicitly rename/document these as normalized representations and keep the
conversion in getLlmJudgeMessages consistent. Update any affected consumers and comments
so the declared types no longer contradict the JSON contract.

}

/**
* One judge message exactly as `GET /automations/evaluators/{id}` returns it.
*
* `content` and `contentArray` are the API's own two mutually-exclusive shapes
* (`LlmAsJudgeMessage`), and telling them apart is the whole point of the specs
* that use this: prose must come back as `content`, a genuine multimodal array
* must come back as `contentArray`. They are therefore kept as the nullable
* union the server sends rather than collapsed into one "text" field, which
* would erase the distinction under test.
*/
export interface JudgeMessageRef {
role: string;
content: string | null;
contentArray: JudgeMessageContentPartRef[] | null;
}

/** One judge message as the create endpoint accepts it — set exactly one of the two. */
export interface JudgeMessageWrite {
role: 'SYSTEM' | 'USER';
content?: string;
contentArray?: Array<{
type: string;
text?: string;
image_url?: { url: string; detail?: string };
}>;
Comment on lines +219 to +227

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Judge helper forwards invalid message shapes

JudgeMessageWrite leaves both content and contentArray optional, and createLlmJudgeRule serializes each independently at tests_end_to_end/e2e/core/backend/client.ts:219-227, so callers can send both wire fields or neither and receive a remote rejection or ambiguous payload — should the helper enforce exactly one shape at its boundary?

Supporting evidence from every grouped finding:

  • tests_end_to_end/e2e/core/backend/client.ts:220-227: JudgeMessageWrite accepts neither content nor contentArray, or both, so createLlmJudgeRule can send evaluator-invalid messages and receive a server rejection — should we model it as a union of { role; content: string; contentArray?: never } and { role; content?: never; contentArray: ... }?

  • tests_end_to_end/e2e/core/backend/client.ts:219-227: Both JudgeMessageWrite content fields are optional and createLlmJudgeRule emits them independently, so requests can contain both content and content_array or neither — should we enforce exactly one at the boundary?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
tests_end_to_end/e2e/core/backend/client.ts around lines 219-227, update
`JudgeMessageWrite` and `createLlmJudgeRule` so each judge message must provide exactly
one of `content` or `contentArray`. Model the type as an exclusive/discriminated union
and add a runtime boundary check before `rawFetch` that rejects messages with both
fields or neither, rather than silently emitting an invalid payload.

}

/**
* One page of `GET /automations/evaluators/`, status included rather than thrown.
*
* The listing answering 200 at all is the assertion in
* `online-evaluation-json-looking-judge-prompts.spec.ts` — the regression it
* guards turned the whole page into a 500 — so the status has to be a value the
* spec can compare, not an exception the client raises on its way out.
*/
export interface AutomationRuleEvaluatorPageRef {
status: number;
/** Server-reported total for the query, not the length of this page. */
total: number;
names: string[];
}

/**
* A trace `input`/`output`/`metadata` payload as the REST API accepts it.
*
Expand Down Expand Up @@ -1546,6 +1601,228 @@ export function makeBackendClient(apiKey: string | null = null, workspaceName: s
return id;
},

/**
* Create an LLM-as-judge rule and return its id.
*
* Separate from `createAutomationRule` (which only builds
* `user_defined_metric_python` rules) because the judge shape is what these
* specs are about: the message `content` / `content_array` split, which the
* python shape has no equivalent of.
*
* `rawFetch` again, for the same two reasons as the python creator: the
* pinned SDK has no `trigger_scope`, and creation answers 201 with an empty
* body so the id only exists in the `Location` header.
*
* No provider key is required — the backend validates neither the model name
* nor its availability at create time, so a rule can be seeded on a
* workspace with no LLM provider configured. It simply never scores, which
* is what the listing / read-back specs want.
*/
async createLlmJudgeRule(args: {
projectId: string;
name: string;
messages: JudgeMessageWrite[];
/** Fraction in [0, 1], the backend's own units — not the dialog's percentage. */
samplingRate?: number;
/** Provider model identifier, e.g. `gpt-4o`. */
model?: string;
/** `score()` variable name -> extraction path (e.g. `output.output`). */
variables?: Record<string, string>;
/**
* Name of the single output-schema entry. Deliberately NOT defaulted to
* the rule name: rule names carry the run namespace and can approach the
* 150-char column bound, while a score name is a short human label, and
* the edit dialog renders it as one.
*/
scoreName?: string;
enabled?: boolean;
}): Promise<string> {
const scoreName = args.scoreName ?? 'Accuracy';
const { status, message, location } = await rawFetch(
'POST',
'/v1/private/automations/evaluators/',
{
Comment on lines +1641 to +1644

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New specs bypass public evaluator client

createLlmJudgeRule seeds through rawFetch('POST', '/v1/private/automations/evaluators/'), so the E2E setup bypasses public Opik/suite-client APIs and violates .agents/skills/writing-e2e-tests/conventions.md — should we use sdk.api.automationRuleEvaluators.createAutomationRuleEvaluator as in workspace-role-resource-actions.ts, or record an approved isolated exception for the unsupported message-shape seam?

Severity

Want Baz to fix this for you? Activate Fixer You can also update your AI coding guidelines based on this comment by apply pr to [branch name]

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
tests_end_to_end/e2e/core/backend/client.ts around lines 1641-1644, refactor
`createLlmJudgeRule` so E2E setup does not call the private
`/v1/private/automations/evaluators/` endpoint through `rawFetch`. Use the existing
public `sdk.api.automationRuleEvaluators.createAutomationRuleEvaluator` client/bridge
path and adapt its response handling as needed; if it cannot support the judge message
shape, document and obtain an explicitly approved, isolated exception instead of
silently using `rawFetch`.

body: {
type: 'llm_as_judge',
action: 'evaluator',
name: args.name,
project_ids: [args.projectId],
sampling_rate: args.samplingRate ?? 1,
enabled: args.enabled ?? true,
code: {
model: { name: args.model ?? 'gpt-4o', temperature: 0 },
messages: args.messages.map((m) => ({
role: m.role,
...(m.content === undefined ? {} : { content: m.content }),
...(m.contentArray === undefined ? {} : { content_array: m.contentArray }),
})),
variables: args.variables ?? { output: 'output.output' },
schema: [
{
name: scoreName,
type: 'INTEGER',
description: 'Score assigned by the judge.',
},
],
},
},
},
);
if (status !== 201) {
throw new Error(
`createLlmJudgeRule: expected 201 for '${args.name}', got ${status}: ${message}`,
);
}
const id = location?.split('/').filter(Boolean).pop();
if (!id) {
throw new Error(
`createLlmJudgeRule: 201 for '${args.name}' carried no usable Location header ` +
`(got '${location}') — cannot address the rule.`,
);
}
return id;
},

/**
* One page of the evaluators listing, reporting the HTTP status rather than
* throwing on it.
*
* `projectId` omitted issues the workspace-wide listing — the same read the
* online-scoring sampler's `findAll()` performs, and the one that a single
* unreadable rule used to take down for every project at once.
*
* `size` defaults to 100 rather than the endpoint's own 10: a spec that
* seeds n rules and then reads a silently-truncated first page would assert
* against a subset without noticing.
*/
async findAutomationRuleEvaluatorsPage(
opts: { projectId?: string; page?: number; size?: number } = {},
): Promise<AutomationRuleEvaluatorPageRef> {
const query = new URLSearchParams();
if (opts.projectId) query.set('project_id', opts.projectId);
query.set('page', String(opts.page ?? 1));
query.set('size', String(opts.size ?? 100));

const { status, json } = await rawFetch(
'GET',
'/v1/private/automations/evaluators/',
{ query },
);
// A non-200 is a legitimate result here, not an error to translate: the
// caller asserts on it. Only the shape of a 200 is trusted.
if (status !== 200) return { status, total: 0, names: [] };
Comment on lines +1711 to +1713

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Evaluator failures lose diagnostics

The non-200 branch drops the message from rawFetch when replacing the response with { status, total: 0, names: [] }, so evaluator failures expose only a generic expectation mismatch — should we retain the message in AutomationRuleEvaluatorPageRef while preserving the non-throwing status-based behavior?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
tests_end_to_end/e2e/core/backend/client.ts around lines 1711-1713, update
`findAutomationRuleEvaluatorsPage` so non-200 evaluator-list responses preserve the
diagnostic `message` from `rawFetch` instead of returning only status and empty data.
Extend `AutomationRuleEvaluatorPageRef` with an appropriate message field and
destructure/return it in the failure result, while keeping the existing status-based,
non-throwing behavior and ensuring successful responses still satisfy the type.


const page = json as { total?: number; content?: Array<{ name?: string }> };
const content = page.content ?? [];
if (typeof page.total !== 'number') {
throw new Error(
`findAutomationRuleEvaluatorsPage: 200 response carried no 'total' — ` +
`cannot assert the listing is complete.`,
);
}
return {
status,
total: page.total,
names: content.map((r) => String(r.name ?? '')),
};
},

/**
* The judge messages of one rule, as the read-back mapper produces them.
*
* This is the surface OPIK-8250 broke: the mapper infers the stored shape
* from the content string, so a prose prompt that happens to open with `[`
* has to come back as `content`, and a genuine multimodal array has to come
* back as `contentArray`. Both fields are surfaced verbatim so a spec can
* assert which one the server chose.
*/
async getLlmJudgeMessages(ruleId: string): Promise<JudgeMessageRef[]> {
const { status, message, json } = await rawFetch(
'GET',
`/v1/private/automations/evaluators/${ruleId}`,
);
if (status !== 200) {
throw new Error(`getLlmJudgeMessages: ${ruleId} answered ${status}: ${message}`);
}
const rule = json as { code?: { messages?: unknown } };
const messages = rule.code?.messages;
if (!Array.isArray(messages)) {
throw new Error(
`getLlmJudgeMessages: ${ruleId} returned no code.messages — not a judge rule?`,
);
}
return messages.map((raw) => {
const m = raw as {
role?: string;
content?: string | null;
content_array?: Array<{
type?: string;
text?: string | null;
image_url?: { url?: string; detail?: string | null } | null;
}> | null;
};
return {
role: String(m.role ?? ''),
content: m.content ?? null,
contentArray:
m.content_array?.map((part) => ({
type: String(part.type ?? ''),
text: part.text ?? null,
imageUrl: part.image_url
? { url: String(part.image_url.url ?? ''), detail: part.image_url.detail ?? null }
: null,
})) ?? null,
};
});
},

/**
* Read a rule and write back exactly what was read — the edit dialog's own
* save shape, with nothing edited.
*
* This is the round trip that turned OPIK-8250's read bug into permanent
* data loss: a truncated read fed straight back into a save persisted the
* truncation, so the prompt the user typed was gone even after the read was
* fixed. Echoing the server's own `code` verbatim is the point — building a
* fresh payload here would test this client's serializer instead.
*/
async resaveAutomationRuleFromReadBack(ruleId: string, projectId: string): Promise<void> {
const read = await rawFetch('GET', `/v1/private/automations/evaluators/${ruleId}`);
if (read.status !== 200) {
throw new Error(
`resaveAutomationRuleFromReadBack: GET ${ruleId} answered ${read.status}: ${read.message}`,
);
}
const rule = read.json as {
type?: string;
name?: string;
sampling_rate?: number;
enabled?: boolean;
trigger_scope?: string;
code?: unknown;
};
const { status, message } = await rawFetch(
'PATCH',
`/v1/private/automations/evaluators/${ruleId}`,
{
body: {
type: rule.type,
name: rule.name,
project_ids: [projectId],
sampling_rate: rule.sampling_rate,
enabled: rule.enabled,
trigger_scope: rule.trigger_scope,
code: rule.code,
Comment on lines +1808 to +1815

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No-op resave destroys evaluator state

resaveAutomationRuleFromReadBack reconstructs an incomplete PATCH at tests_end_to_end/e2e/core/backend/client.ts:1808-1815: it omits persisted filters and sends project_ids: [projectId] instead of the complete association set, so a no-op replay clears filters and detaches a multi-project evaluator from every other project — should the helper derive both values from the complete GET state, or explicitly reject unsupported rule shapes?

Supporting evidence from every grouped finding:

  • tests_end_to_end/e2e/core/backend/client.ts:1804-1815: resaveAutomationRuleFromReadBack omits filters from its PATCH body, so the backend maps it to null and replaying a filtered evaluator clears automation_rules.filters — should we include the read-back filters in the payload?

  • tests_end_to_end/e2e/core/backend/client.ts:1808-1815: The read-back PATCH sends project_ids: [projectId] instead of the rule’s existing project set, so the backend replaces all junction rows with that single association and replaying a multi-project evaluator detaches it from the other projects — should we preserve the full association set?

  • tests_end_to_end/e2e/core/backend/client.ts:1809-1812: resaveAutomationRuleFromReadBack always sends project_ids: [projectId], so a no-op read/save drops the rule's other project associations and narrows its scope — should we preserve project_ids from the read response or reject multi-project rules?

  • tests_end_to_end/e2e/core/backend/client.ts:1808-1815: resaveAutomationRuleFromReadBack rebuilds an exact PATCH without persisted filters and with project_ids: [projectId], so the backend clears saved filters and detaches other projects from multi-project rules. Should we derive both from the complete GET state, including projects, or explicitly limit and test the helper’s supported rule shape?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
`tests_end_to_end/e2e/core/backend/client.ts` around lines 1789-1815, fix
`resaveAutomationRuleFromReadBack` so its PATCH preserves the evaluator’s complete
persisted state instead of reconstructing an incomplete update. Include the `filters`
returned by GET and derive `project_ids` from the evaluator’s full project association
list rather than `[projectId]`; update the response typing and add coverage for filters
and multi-project rules, or explicitly reject unsupported rule shapes.

},
},
);
if (status !== 204) {
throw new Error(
`resaveAutomationRuleFromReadBack: PATCH ${ruleId} expected 204, got ${status}: ${message}`,
);
}
},

/** One rule by id, including the `triggerScope` the pinned SDK cannot see. */
async getAutomationRule(ruleId: string): Promise<AutomationRuleDetail> {
const { status, message, json } = await rawFetch(
Expand Down
4 changes: 4 additions & 0 deletions tests_end_to_end/e2e/core/backend/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ export {
type AutomationRuleRef,
type AutomationRuleDetail,
type AutomationRuleLogRef,
type AutomationRuleEvaluatorPageRef,
type JudgeMessageRef,
type JudgeMessageContentPartRef,
type JudgeMessageWrite,
type TraceJsonSection,
type AnnotationQueueDetail,
type AnnotationQueueReviewerRef,
Expand Down
Loading
Loading