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
Expand Up @@ -133,6 +133,75 @@ describe('runGuardrailCheck', () => {
);
expect(recordedCheckRequests).toHaveLength(0);
});

it('snapshots the saved config coverage onto the run', async () => {
const { run } = await runGuardrailCheck(WORKSPACE, snapshot('benign-greeting'));

// cfg-1 declares two input and two output flows, none of which the registry
// recognizes — so each falls back to its raw name.
expect(run.activated_guardrails?.map((g) => g.label)).toEqual([
'check pii',
'check toxicity',
'mask pii output',
'check output facts',
]);
expect(run.is_draft).toBeUndefined();
});
});

describe('runGuardrailCheck against a draft', () => {
/** A draft that differs from cfg-1 in both its model and its rails. */
const DRAFT: RailsConfig = {
models: [{ type: 'main', engine: 'openai', model: 'gpt-4o-draft' }],
instructions: [{ type: 'general', content: 'Be extremely cautious.' }],
rails: { input: { flows: ['jailbreak detection'] } },
};

it('sends the draft inline instead of referencing the saved config by id', async () => {
await runGuardrailCheck(WORKSPACE, snapshot('benign-greeting'), DRAFT);

expect(recordedCheckRequests).toEqual([
{
model: 'gpt-4o-draft',
messages: [{ role: 'user', content: 'Hello there' }],
guardrails: { config: DRAFT },
},
]);
// Never both: the service's validator would silently discard config_ids.
expect(recordedCheckRequests[0]?.guardrails).not.toHaveProperty('config_ids');
});

it('records the run as a draft with no config version', async () => {
const { run } = await runGuardrailCheck(WORKSPACE, snapshot('benign-greeting'), DRAFT);

expect(run.is_draft).toBe(true);
expect(run.config_version).toBeUndefined();
expect(getMockGuardrailCheck('benign-greeting')?.data.runs).toEqual([run]);
});

it("snapshots the draft's coverage, not the saved config's", async () => {
const { run } = await runGuardrailCheck(WORKSPACE, snapshot('benign-greeting'), DRAFT);

const labels = run.activated_guardrails?.map((g) => g.label);
expect(labels).toContain('Jailbreak Detection');
expect(labels).not.toContain('check pii');
});

it("overrides a check's stored guardrails rather than silently ignoring the draft", async () => {
const check = snapshot('benign-greeting');
check.data.guardrails = { config_ids: ['some-other-config'] };

await runGuardrailCheck(WORKSPACE, check, DRAFT);

expect(recordedCheckRequests[0]?.guardrails).toEqual({ config: DRAFT });
});

it('rejects a draft with no usable model before calling /checks', async () => {
await expect(
runGuardrailCheck(WORKSPACE, snapshot('benign-greeting'), { models: [] })
).rejects.toThrow("Guardrail config 'pii-filter' has no usable model to run checks against.");
expect(recordedCheckRequests).toHaveLength(0);
});
});

describe('runGuardrailChecks', () => {
Expand Down
58 changes: 47 additions & 11 deletions web/packages/studio/src/api/guardrail-checks/guardrailChecks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ import {
type GuardrailChecksPage,
type RunRecord,
} from '@studio/api/guardrail-checks/types';
// Layering wrinkle: this reaches into the components layer. Deliberate — it keeps one
// definition of guardrail identity shared with the config editor's detector catalog.
import { getActivatedGuardrails } from '@studio/components/sidePanels/GuardrailCheckDetailSidePanel/railLabels';

// ---------------------------------------------------------------------------
// Query keys
Expand Down Expand Up @@ -183,18 +186,32 @@ export function resolveConfigModel(config: RailsConfig | undefined, configLabel:
return chosen.model;
}

/** What the run targeted, for stamping onto the record. */
export interface RunRecordContext {
/** Parent config db_version. Omitted for a draft run — a draft has no version. */
configVersion?: number;
/** True when the run targeted an unsaved draft. */
isDraft?: boolean;
/** The config that actually ran, for the activated-guardrail snapshot. */
config?: RailsConfig;
}

/** Map a /checks response to a persisted run record. */
export function responseToRunRecord(
response: GuardrailCheckResponse,
runAt: string,
configVersion?: number
context: RunRecordContext = {}
): RunRecord {
return {
run_at: runAt,
status: response.status,
rails_status: response.rails_status,
config_ids: response.guardrails_data?.config_ids,
config_version: configVersion,
config_version: context.configVersion,
...(context.isDraft ? { is_draft: true } : {}),
// Resolved now, not at render time: the config that produced this run may be edited
// (or, for a draft, cease to exist) before anyone opens the result panel.
activated_guardrails: getActivatedGuardrails(context.config, response.rails_status),
};
}

Expand All @@ -213,10 +230,15 @@ export function executeGuardrailCheck(
*
* Takes the check entity directly (rather than re-fetching by name) because checks are
* child entities — name-based lookups must be scoped by `parent`, which the entity carries.
*
* `draftConfig` runs the check against an unsaved edit instead of the saved config: it is
* sent inline (the /checks endpoint accepts a whole RailsConfig, not just an id) and the
* resulting record is marked as a draft run with no config version.
*/
export async function runGuardrailCheck(
workspace: string,
check: GuardrailCheckEntity
check: GuardrailCheckEntity,
draftConfig?: RailsConfig
): Promise<{ entity: GuardrailCheckEntity; run: RunRecord }> {
if (!check.parent) {
throw new Error(
Expand All @@ -227,19 +249,32 @@ export async function runGuardrailCheck(
const configEntity = await entitiesGetEntityById(check.parent);
// A guardrail_config entity nests the rails config under `data.data`
// (`data` also carries the config's description).
const configData = (configEntity.data as { data?: RailsConfig }).data;
const model = resolveConfigModel(configData, configEntity.name);
const savedData = (configEntity.data as { data?: RailsConfig }).data;
// Still fetched on the draft path: `persistRun` needs the entity, and its name is the
// label in error messages.
const effectiveConfig = draftConfig ?? savedData;
const model = resolveConfigModel(effectiveConfig, configEntity.name);

const request: GuardrailCheckRequest = {
model,
messages: check.data.messages,
// The check references its config by name (the /checks endpoint resolves config_ids to
// `workspace/name`), unless the check carries explicit guardrails options.
guardrails: check.data.guardrails ?? { config_ids: [configEntity.name] },
// A draft has no id to reference, so it travels whole. Never both: the service's
// validator nulls out `config_ids` when `config` is an object, which would make the
// request's meaning non-obvious from the wire.
//
// Draft wins over a check's stored `guardrails`: the user explicitly chose the target,
// and silently running something else would be worse than ignoring the override.
guardrails: draftConfig
? { config: draftConfig }
: (check.data.guardrails ?? { config_ids: [configEntity.name] }),
};

const response = await executeGuardrailCheck(workspace, request);
const run = responseToRunRecord(response, new Date().toISOString(), configEntity.db_version);
const run = responseToRunRecord(response, new Date().toISOString(), {
configVersion: draftConfig ? undefined : configEntity.db_version,
isDraft: Boolean(draftConfig),
config: effectiveConfig,
});

const entity = await persistRun(workspace, check, run);

Expand Down Expand Up @@ -272,12 +307,13 @@ async function persistRun(
/** Batch execution — backs the "Re-run N Tests" action. Failures are captured per check. */
export function runGuardrailChecks(
workspace: string,
checks: GuardrailCheckEntity[]
checks: GuardrailCheckEntity[],
draftConfig?: RailsConfig
): Promise<Array<{ name: string; run: RunRecord } | { name: string; error: Error }>> {
return Promise.all(
checks.map(async (check) => {
try {
const { run } = await runGuardrailCheck(workspace, check);
const { run } = await runGuardrailCheck(workspace, check, draftConfig);
return { name: check.name, run };
} catch (error) {
return {
Expand Down
12 changes: 7 additions & 5 deletions web/packages/studio/src/api/guardrail-checks/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import type { EntitiesListEntitiesParams } from '@nemo/sdk/generated/platform/schema';
import type { EntitiesListEntitiesParams, RailsConfig } from '@nemo/sdk/generated/platform/schema';
import {
createGuardrailCheck,
type CreateGuardrailCheckInput,
Expand Down Expand Up @@ -158,7 +158,7 @@ export type UseRunGuardrailCheckOptions = Omit<
UseMutationOptions<
Awaited<ReturnType<typeof runGuardrailCheck>>,
Error,
{ workspace: string; check: GuardrailCheckEntity }
{ workspace: string; check: GuardrailCheckEntity; draftConfig?: RailsConfig }
>,
'mutationFn'
>;
Expand All @@ -167,7 +167,8 @@ export type UseRunGuardrailCheckOptions = Omit<
export const useRunGuardrailCheck = (options?: UseRunGuardrailCheckOptions) =>
useMutation({
...options,
mutationFn: ({ workspace, check }) => runGuardrailCheck(workspace, check),
mutationFn: ({ workspace, check, draftConfig }) =>
runGuardrailCheck(workspace, check, draftConfig),
onSuccess: (...args) => {
const [, variables] = args;
invalidateGuardrailChecksCaches(variables.workspace, variables.check.name);
Expand All @@ -179,7 +180,7 @@ export type UseRunGuardrailChecksOptions = Omit<
UseMutationOptions<
Awaited<ReturnType<typeof runGuardrailChecks>>,
Error,
{ workspace: string; checks: GuardrailCheckEntity[] }
{ workspace: string; checks: GuardrailCheckEntity[]; draftConfig?: RailsConfig }
>,
'mutationFn'
>;
Expand All @@ -188,7 +189,8 @@ export type UseRunGuardrailChecksOptions = Omit<
export const useRunGuardrailChecks = (options?: UseRunGuardrailChecksOptions) =>
useMutation({
...options,
mutationFn: ({ workspace, checks }) => runGuardrailChecks(workspace, checks),
mutationFn: ({ workspace, checks, draftConfig }) =>
runGuardrailChecks(workspace, checks, draftConfig),
onSuccess: (...args) => {
const [, variables] = args;
invalidateGuardrailChecksCaches(variables.workspace);
Expand Down
20 changes: 20 additions & 0 deletions web/packages/studio/src/api/guardrail-checks/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,19 @@ export type GuardrailCheckMessage = GuardrailCheckRequest['messages'][number];
/** Per-rail verdict map returned by the /checks endpoint. */
export type RailsStatus = GuardrailCheckResponse['rails_status'];

/**
* One guardrail a config declared at run time, and whether it actually reported a
* verdict. Snapshotted onto each run: the config that produced a run is gone the
* moment the user edits again, so deriving this at render time would describe
* coverage that never ran.
*/
export interface ActivatedGuardrail {
/** Dedupe identity (detector key, else the friendly label) and the only safe React key. */
id: string;
label: string;
active: boolean;
}

/** One execution of a check against /checks, recorded on the check's history. */
export type RunRecord = {
/** ISO 8601 timestamp of when the run completed. */
Expand All @@ -33,6 +46,13 @@ export type RunRecord = {
config_ids?: string[];
/** The parent config's db_version at run time, for honest history across config edits. */
config_version?: number;
/**
* Set when the run targeted an unsaved draft rather than the saved config. Mutually
* exclusive with `config_version` — a draft has no version to stamp.
*/
is_draft?: boolean;
/** Guardrail coverage of the config that ran. Absent on records written before this existed. */
activated_guardrails?: ActivatedGuardrail[];
};

/** Studio-owned payload stored in a guardrail_checks entity's `data`. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,34 @@ describe('RailStatusTab', () => {
expect(screen.getByText('No runs yet.')).toBeInTheDocument();
expect(screen.getByText('Activated Guardrails')).toBeInTheDocument();
});

// The config that produced a run may have been edited since — or, for a draft, never saved.
it("prefers the run's own coverage snapshot over the current config", () => {
render(
<RailStatusTab
latestRun={{
run_at: '2026-04-12T11:05:00.000Z',
status: 'success',
rails_status: {},
is_draft: true,
activated_guardrails: [{ id: 'jailbreak', label: 'Jailbreak Detection', active: true }],
}}
configData={COLLIDING_LABELS}
/>
);

expect(screen.getByText('Jailbreak Detection')).toBeInTheDocument();
expect(screen.queryByText('Acme Guard')).not.toBeInTheDocument();
});

it('falls back to the current config for a run recorded before snapshots existed', () => {
render(
<RailStatusTab
latestRun={{ run_at: '2026-04-12T11:05:00.000Z', status: 'success', rails_status: {} }}
configData={COLLIDING_LABELS}
/>
);

expect(screen.getAllByText('Acme Guard')).toHaveLength(2);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,12 @@ export const RailStatusTab: FC<RailStatusTabProps> = ({ latestRun, configData })
// Not gated on a run: the guardrails below come from the config, so a check
// that has never run still shows its declared coverage, all of it inactive.
const railEntries = Object.entries(latestRun?.rails_status ?? {});
const guardrails = getActivatedGuardrails(configData, latestRun?.rails_status);
// The snapshot describes the config that actually ran — which for a draft no longer
// exists, and for a saved run may since have been edited. Deriving from `configData`
// is the fallback for runs recorded before snapshots existed, and for checks with no
// runs at all (where declared coverage is still worth showing).
const guardrails =
latestRun?.activated_guardrails ?? getActivatedGuardrails(configData, latestRun?.rails_status);

return (
<Stack gap="density-xl">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import type { RunRecord } from '@studio/api/guardrail-checks/types';
import { RunHistoryTab } from '@studio/components/sidePanels/GuardrailCheckDetailSidePanel/RunHistoryTab';
import { render, screen } from '@testing-library/react';

const run = (overrides: Partial<RunRecord>): RunRecord => ({
run_at: '2026-04-12T11:05:00.000Z',
status: 'success',
rails_status: {},
...overrides,
});

describe('RunHistoryTab', () => {
it('labels a saved run with the config version it ran against', () => {
render(<RunHistoryTab runs={[run({ config_version: 3 })]} />);

expect(screen.getByText('v3')).toBeInTheDocument();
expect(screen.queryByText('Draft')).not.toBeInTheDocument();
});

it('marks a draft run instead of showing a version', () => {
render(<RunHistoryTab runs={[run({ is_draft: true })]} />);

expect(screen.getByText('Draft')).toBeInTheDocument();
expect(screen.queryByText(/^v\d+$/)).not.toBeInTheDocument();
});

// Records written before either field existed must still render.
it('shows no origin badge for a record carrying neither field', () => {
render(<RunHistoryTab runs={[run({ run_at: '2026-04-12T11:06:00.000Z' })]} />);

expect(screen.queryByText('Draft')).not.toBeInTheDocument();
expect(screen.queryByText(/^v\d+$/)).not.toBeInTheDocument();
});
});
Loading