diff --git a/src/app/shared/bias-impact-report-list/bias-impact-report-list.css b/src/app/shared/bias-impact-report-list/bias-impact-report-list.css
index e027d15..0d5ecac 100644
--- a/src/app/shared/bias-impact-report-list/bias-impact-report-list.css
+++ b/src/app/shared/bias-impact-report-list/bias-impact-report-list.css
@@ -2,7 +2,32 @@
.bias-report-list__progress { background: #eff6ff; border-left: 3px solid #2563eb; color: #1e3a8a; margin: 0; padding: .6rem .7rem; font-size: .84rem; }
.bias-report-list__error { background: #fff1f2; border-left: 3px solid #e11d48; color: #9f1239; margin: 0; padding: .6rem .7rem; font-size: .84rem; }
.bias-report-list__error-row { align-items: center; display: flex; gap: .6rem; justify-content: space-between; }
-.bias-report-list__empty { color: #64748b; font-size: .84rem; margin: 0; padding: .5rem; }
+/* An empty tab that only said "none yet" left the user with no idea how one is produced. */
+.bias-report-list__empty {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: .5rem;
+ color: #64748b;
+ font-size: .84rem;
+ padding: .5rem;
+}
+
+.bias-report-list__empty p { margin: 0; }
+
+.bias-report-list__empty-lead { color: #334155; font-weight: 600; }
+
+.bias-report-list__empty-body { line-height: 1.45; }
+
+/* The precondition that is in the way, stated instead of left to be guessed from a dead button. */
+.bias-report-list__empty-blocked {
+ border-left: 3px solid #f59e0b;
+ background: #fffbeb;
+ border-radius: 4px;
+ padding: .4rem .55rem;
+ color: #92400e;
+ line-height: 1.45;
+}
.bias-report-list__row {
align-items: center;
diff --git a/src/app/shared/bias-impact-report-list/bias-impact-report-list.html b/src/app/shared/bias-impact-report-list/bias-impact-report-list.html
index 988c330..bb9125b 100644
--- a/src/app/shared/bias-impact-report-list/bias-impact-report-list.html
+++ b/src/app/shared/bias-impact-report-list/bias-impact-report-list.html
@@ -21,7 +21,25 @@
Retry
} @else if (!reports().length) {
-
No bias impact reports for this execution yet.
+
+
No bias impact reports for this run yet.
+
+ A report compares this run against the same run with its bias or mitigation probes turned
+ on, and shows which node outputs it changed. Reports appear here once you produce one.
+
+ @if (blockedReason(); as reason) {
+
{{ reason }}
+ } @else {
+
+ {{ annotatedNodeCount() }}
+ {{ annotatedNodeCount() === 1 ? 'node carries a probe' : 'nodes carry a probe' }}
+ that can be activated on this run.
+
+
+ Run a biased rerun
+
+ }
+
} @else {
@for (report of reports(); track report.id) {
diff --git a/src/app/shared/bias-impact-report-list/bias-impact-report-list.spec.ts b/src/app/shared/bias-impact-report-list/bias-impact-report-list.spec.ts
index 7fe2e22..d6d62d4 100644
--- a/src/app/shared/bias-impact-report-list/bias-impact-report-list.spec.ts
+++ b/src/app/shared/bias-impact-report-list/bias-impact-report-list.spec.ts
@@ -53,6 +53,46 @@ describe('BiasImpactReportListComponent', () => {
fixture = TestBed.createComponent(BiasImpactReportListComponent);
});
+ it('explains how a report is produced, and offers the action, when there are none', () => {
+ // "No bias impact reports yet" alone left no clue that reports come from an experiment you
+ // have to start, nor where to start one.
+ listBiasImpactReports.mockReturnValue(of([]));
+ fixture.componentRef.setInput('executionId', 'execution-1');
+ fixture.componentRef.setInput('annotatedNodeCount', 3);
+ fixture.componentRef.setInput('blockedReason', null);
+ fixture.detectChanges();
+
+ const text = fixture.nativeElement.textContent;
+ expect(text).toContain('No bias impact reports for this run yet');
+ expect(text).toContain('3 nodes carry a probe');
+
+ const started = vi.fn();
+ fixture.componentInstance.startExperimentRequested.subscribe(started);
+ fixture.nativeElement.querySelector('.bias-report-list__empty button').click();
+ expect(started).toHaveBeenCalledTimes(1);
+ });
+
+ it('states the precondition in the way, instead of offering an action that cannot work', () => {
+ listBiasImpactReports.mockReturnValue(of([]));
+ fixture.componentRef.setInput('executionId', 'execution-1');
+ fixture.componentRef.setInput('annotatedNodeCount', 0);
+ fixture.componentRef.setInput('blockedReason', 'This run has not finished yet.');
+ fixture.detectChanges();
+
+ expect(fixture.nativeElement.querySelector('.bias-report-list__empty-blocked').textContent)
+ .toContain('has not finished yet');
+ expect(fixture.nativeElement.querySelector('.bias-report-list__empty button')).toBeNull();
+ });
+
+ it('counts one annotated node in the singular', () => {
+ listBiasImpactReports.mockReturnValue(of([]));
+ fixture.componentRef.setInput('executionId', 'execution-1');
+ fixture.componentRef.setInput('annotatedNodeCount', 1);
+ fixture.detectChanges();
+
+ expect(fixture.nativeElement.textContent).toContain('1 node carries a probe');
+ });
+
it('reloads when a dialog reports that one has just been produced', () => {
// The experiment and compare dialogs render over this still-mounted tab. Without this the tab
// kept saying there were no reports for the execution whose report the user was just reading.
diff --git a/src/app/shared/bias-impact-report-list/bias-impact-report-list.ts b/src/app/shared/bias-impact-report-list/bias-impact-report-list.ts
index a680f04..a2e6f85 100644
--- a/src/app/shared/bias-impact-report-list/bias-impact-report-list.ts
+++ b/src/app/shared/bias-impact-report-list/bias-impact-report-list.ts
@@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common';
-import { ChangeDetectionStrategy, Component, computed, effect, inject, input, signal } from '@angular/core';
+import { ChangeDetectionStrategy, Component, computed, effect, inject, input, output, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { BiasImpactReport } from '@models/bias-impact';
import { TaskExecutionsService } from '@services/task-executions/task-executions';
@@ -23,6 +23,13 @@ export class BiasImpactReportListComponent {
private lastReloadToken = 0;
readonly executionId = input(null);
+ /** How many nodes an experiment could act on, so the empty state can be concrete. */
+ readonly annotatedNodeCount = input(0);
+ /** Why an experiment cannot be started, or null when it can; supplied by the host. */
+ readonly blockedReason = input(null);
+
+ /** The user wants to start one. The host owns the dialog, so it decides what "start" means. */
+ readonly startExperimentRequested = output();
readonly reports = signal([]);
readonly loading = signal(false);
diff --git a/src/app/shared/nodes/task-step-node/task-step-node.spec.ts b/src/app/shared/nodes/task-step-node/task-step-node.spec.ts
index 6f55a7d..06326e6 100644
--- a/src/app/shared/nodes/task-step-node/task-step-node.spec.ts
+++ b/src/app/shared/nodes/task-step-node/task-step-node.spec.ts
@@ -121,6 +121,72 @@ describe('TaskStepNodeComponent bias canvas highlighting', () => {
fixture.detectChanges();
}
+ it('shows the measure control on an interactive node, disabled, saying what is possible instead', () => {
+ // ChatInteraction cannot be replayed in isolation. Hiding the control made an annotated node
+ // look identical to an unannotated one, with nothing to indicate the full-flow route exists.
+ component.data.data.capabilities = {
+ visualRole: 'ACTIVITY',
+ terminal: false,
+ biasAnnotationsAllowed: true,
+ allowsIncomingConnections: true,
+ allowsOutgoingConnections: true,
+ canDependOnOtherNodes: false,
+ canHaveDependentNodes: false
+ };
+ component.data.data.biasAnnotations = [
+ { id: 'a1', biasProbe: { activationMode: 'PROMPT_DIRECTIVE', instruction: 'Nudge it' } }
+ ];
+ (component as any).biasCapabilities = { isolatedExperimentSupported: false };
+ setStepConfig({ __executionStatusGroup: 'FINAL' });
+
+ expect(component.hasMeasurableBiasAnnotations()).toBe(true);
+ expect(component.canMeasureBiasImpact()).toBe(false);
+ const button = fixture.nativeElement.querySelector('.llm-bias-impact-trigger');
+ expect(button).not.toBeNull();
+ expect(button.disabled).toBe(true);
+ expect(button.getAttribute('title')).toContain('Create biased rerun');
+ });
+
+ it('enables the measure control where an isolated experiment is supported and the run is final', () => {
+ component.data.data.capabilities = {
+ visualRole: 'ACTIVITY',
+ terminal: false,
+ biasAnnotationsAllowed: true,
+ allowsIncomingConnections: true,
+ allowsOutgoingConnections: true,
+ canDependOnOtherNodes: false,
+ canHaveDependentNodes: false
+ };
+ component.data.data.biasAnnotations = [
+ { id: 'a1', biasProbe: { activationMode: 'PROMPT_DIRECTIVE', instruction: 'Nudge it' } }
+ ];
+ (component as any).biasCapabilities = { isolatedExperimentSupported: true };
+ setStepConfig({ __executionStatusGroup: 'FINAL' });
+
+ expect(component.canMeasureBiasImpact()).toBe(true);
+ expect(component.measureBiasImpactTooltip()).toBe('Measure bias impact');
+ });
+
+ it('still points at the final-state precondition when the run is not finished', () => {
+ component.data.data.capabilities = {
+ visualRole: 'ACTIVITY',
+ terminal: false,
+ biasAnnotationsAllowed: true,
+ allowsIncomingConnections: true,
+ allowsOutgoingConnections: true,
+ canDependOnOtherNodes: false,
+ canHaveDependentNodes: false
+ };
+ component.data.data.biasAnnotations = [
+ { id: 'a1', biasProbe: { activationMode: 'PROMPT_DIRECTIVE', instruction: 'Nudge it' } }
+ ];
+ (component as any).biasCapabilities = { isolatedExperimentSupported: true };
+ setStepConfig({ __executionStatusGroup: 'RUNNING' });
+
+ expect(component.canMeasureBiasImpact()).toBe(false);
+ expect(component.measureBiasImpactTooltip()).toContain('final state');
+ });
+
it('shows a container working on its subflow, which its own status never said', () => {
// The container's status is WAITING_FOR_SUBFLOW, so it took none of the in-progress styling
// and sat inert for minutes while its child ran - indistinguishable from a stuck run.
diff --git a/src/app/shared/nodes/task-step-node/task-step-node.ts b/src/app/shared/nodes/task-step-node/task-step-node.ts
index 8133ba2..e37fa96 100644
--- a/src/app/shared/nodes/task-step-node/task-step-node.ts
+++ b/src/app/shared/nodes/task-step-node/task-step-node.ts
@@ -501,17 +501,34 @@ export class TaskStepNodeComponent {
].join('\n');
}
+ /**
+ * Whether the node shows the measure control at all: it has probes worth measuring.
+ *
+ * It deliberately does *not* require isolatedExperimentSupported. A user-interactive block -
+ * ChatInteraction, HumanDecision - cannot be replayed in isolation, and hiding the button there
+ * left an annotated node looking identical to an unannotated one. The control is shown and
+ * disabled, and the tooltip says which experiment is available instead.
+ */
hasMeasurableBiasAnnotations(): boolean {
- return this.isBiasCapable()
- && this.executableBiasAnnotations().length > 0
- && this.biasCapabilities?.isolatedExperimentSupported === true;
+ return this.isBiasCapable() && this.executableBiasAnnotations().length > 0;
+ }
+
+ private supportsIsolatedBiasExperiment(): boolean {
+ return this.biasCapabilities?.isolatedExperimentSupported === true;
}
canMeasureBiasImpact(): boolean {
- return this.hasMeasurableBiasAnnotations() && this.blockConfiguration?.['__executionStatusGroup'] === 'FINAL';
+ return this.hasMeasurableBiasAnnotations()
+ && this.supportsIsolatedBiasExperiment()
+ && this.blockConfiguration?.['__executionStatusGroup'] === 'FINAL';
}
measureBiasImpactTooltip(): string {
+ if (!this.supportsIsolatedBiasExperiment()) {
+ // A rule of the domain, not a fault: say what *is* possible rather than only what is not.
+ return 'This node cannot be replayed on its own, so there is no isolated experiment for it. '
+ + 'Use "Create biased rerun" on the toolbar to measure it as part of the whole flow.';
+ }
if (this.blockConfiguration?.['__executionStatusGroup'] !== 'FINAL') {
return 'Available once the execution reaches a final state';
}
diff --git a/src/app/shared/task-execution-viewer/task-execution-viewer.html b/src/app/shared/task-execution-viewer/task-execution-viewer.html
index 1484aa0..5a74f07 100644
--- a/src/app/shared/task-execution-viewer/task-execution-viewer.html
+++ b/src/app/shared/task-execution-viewer/task-execution-viewer.html
@@ -535,7 +535,11 @@
}
} @else {
-
+
}
diff --git a/src/app/shared/task-execution-viewer/task-execution-viewer.ts b/src/app/shared/task-execution-viewer/task-execution-viewer.ts
index 370720d..58f2f6f 100644
--- a/src/app/shared/task-execution-viewer/task-execution-viewer.ts
+++ b/src/app/shared/task-execution-viewer/task-execution-viewer.ts
@@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ChangeDetectionStrategy, Component, computed, effect, ElementRef, HostListener, inject, input, OnDestroy, signal, viewChild } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
+import { NotificationService } from '@services/notifications/notification';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
@@ -120,6 +121,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
private biasRerunDialog = inject(BiasRerunDialogService);
private biasCompareDialog = inject(BiasCompareDialogService);
private biasComparisonViewState = inject(BiasComparisonViewStateService);
+ private notifications = inject(NotificationService);
private router = inject(Router);
private route = inject(ActivatedRoute);
private lastExecutionId: string | null = null;
@@ -682,6 +684,28 @@ export class TaskExecutionViewerComponent implements OnDestroy {
&& getExecutionStatusGroup(this.execution()?.context.status) === 'FINAL'
&& !this.biasRerunOpening()
);
+ /** How many nodes an experiment could act on. Sync, so the empty state can say it for free. */
+ readonly biasAnnotatedNodeCount = computed(() => this.biasAnnotatedNodes().length);
+
+ /**
+ * Why a bias experiment cannot be started here, or null when it can.
+ *
+ * The preconditions were only ever expressed as a disabled control or, worse, a silent return.
+ * Stated as a sentence they are also what the empty Bias impact tab needs to explain itself.
+ */
+ readonly biasExperimentBlockedReason = computed(() => {
+ if (this.isSubflowExecution()) {
+ return 'A subflow run cannot be a baseline. Open its parent run to start an experiment.';
+ }
+ if (getExecutionStatusGroup(this.execution()?.context.status) !== 'FINAL') {
+ return 'This run has not finished yet. An experiment compares it against a rerun, so it needs a completed baseline.';
+ }
+ if (this.biasAnnotatedNodeCount() === 0) {
+ return 'No node in this flow carries a bias probe that can be activated. Add an instruction to a bias or mitigation annotation in the editor first.';
+ }
+ return null;
+ });
+
readonly canCompareBiasExecution = computed(() =>
!this.isSubflowExecution()
&& this.isBiasVariant()
@@ -1081,7 +1105,18 @@ export class TaskExecutionViewerComponent implements OnDestroy {
this.biasRerunOpening.set(true);
try {
const candidates = await this.biasRerunCandidates();
- if (!candidates.length) return;
+ if (!candidates.length) {
+ // Returning silently made the button indistinguishable from a broken one. The reason is
+ // knowable: either nothing carries an activatable probe, or the nodes that do are of a
+ // type the backend will not run a full-flow experiment on.
+ this.notifications.show(
+ this.biasExperimentBlockedReason()
+ ?? 'None of the annotated nodes in this flow support a full-flow bias experiment.',
+ 'info',
+ 6000
+ );
+ return;
+ }
this.biasRerunDialog.open({
executionId: execution.id,
candidates,
@@ -1534,8 +1569,13 @@ export class TaskExecutionViewerComponent implements OnDestroy {
return this.getExecutionDependencies().some((dependency) => String(dependency.targetId) === stepId);
}
- private async biasRerunCandidates(): Promise {
- const candidates = this.stepsArray().flatMap((step): Array<{ nodeId: string; nodeName: string; node: FlowNode }> => {
+ /**
+ * The nodes carrying something an experiment could activate. Split out of biasRerunCandidates
+ * because it needs no network: the capability check does, and the empty state wants a count
+ * without firing a request per node type just to render a sentence.
+ */
+ private biasAnnotatedNodes(): Array<{ nodeId: string; nodeName: string; node: FlowNode }> {
+ return this.stepsArray().flatMap((step): Array<{ nodeId: string; nodeName: string; node: FlowNode }> => {
const node = mergeExecutionStepNode(
step,
this.execution()?.flowSnapshot ?? this.sourceFlowData()
@@ -1552,7 +1592,10 @@ export class TaskExecutionViewerComponent implements OnDestroy {
const annotations = (block.biasAnnotations ?? []).filter((annotation) => isProbeExecutable(annotation.biasProbe) || isProbeExecutable(annotation.mitigationProbe));
return annotations.length ? [{ nodeId: step.id, nodeName: node.name || step.id, node: { ...block, biasAnnotations: annotations } }] : [];
});
+ }
+ private async biasRerunCandidates(): Promise {
+ const candidates = this.biasAnnotatedNodes();
const resolved = await Promise.all(candidates.map(async (candidate) => {
const capabilities = await firstValueFrom(
candidate.node.nodeFamily === 'container'