feat(task-execution): support human decision contracts

This commit is contained in:
Lucio Lelii 2026-07-23 11:30:32 +02:00
parent a6fef8c207
commit 4886bf828a
20 changed files with 896 additions and 112 deletions

View File

@ -61,7 +61,11 @@ export type NodeFamily = 'block' | 'container';
export type BlockTypeSchema = Record<string, unknown> | null;
export type BlockInteractionContractKind = 'chat-session' | 'single-response' | string;
export type BlockInteractionContractKind =
| 'chat-session'
| 'single-response'
| 'human-decision'
| string;
export type BlockInteractionContract = {
kind: BlockInteractionContractKind;

View File

@ -7,6 +7,16 @@ export type HumanInteractionChatMessage = {
content: string;
};
export type HumanDecisionOption = {
name: string;
label: string;
};
export type HumanInteractionRuntimeInput = {
name: string;
value: string;
};
export type HumanInteractionDialogInput = {
executionId?: string | null;
nodeId?: string | null;
@ -26,38 +36,49 @@ export type HumanInteractionDialogInput = {
isRunning?: boolean;
isSubmitting?: boolean;
submitError?: string | null;
runtimeInputs?: HumanInteractionRuntimeInput[];
question?: string;
decisionOptions?: HumanDecisionOption[];
rationaleRequired?: boolean;
rationaleLabel?: string;
onSubmit?: (value: HumanInteractionDialogResult) => void;
};
export type HumanInteractionDialogResult = {
mode: 'message' | 'complete';
value: string;
export type HumanInteractionDialogResult =
| { mode: 'message' | 'complete'; value: string }
| { mode: 'decision'; choice: string; rationale: string };
export type HumanInteractionDialogState = {
executionId: string | null;
nodeId: string | null;
title: string;
kind: BlockInteractionContractKind;
actionDescription: string;
currentInput: string;
runtimeInputs: HumanInteractionRuntimeInput[];
history: HumanInteractionChatMessage[];
latestResponse: string;
historyField: string | null;
responseField: string | null;
messageField: string | null;
completionField: string | null;
pendingUserMessage: string | null;
awaitingAssistantResponse: boolean;
assistantResponseBaseline: string;
isRunning: boolean;
isSubmitting: boolean;
submitError: string | null;
question: string;
decisionOptions: HumanDecisionOption[];
rationaleRequired: boolean;
rationaleLabel: string;
onSubmit: ((value: HumanInteractionDialogResult) => void) | null;
resolve: (value: HumanInteractionDialogResult | null) => void;
};
@Injectable({ providedIn: 'root' })
export class HumanInteractionDialogService {
private _state = signal<{
executionId: string | null;
nodeId: string | null;
title: string;
kind: BlockInteractionContractKind;
actionDescription: string;
currentInput: string;
history: HumanInteractionChatMessage[];
latestResponse: string;
historyField: string | null;
responseField: string | null;
messageField: string | null;
completionField: string | null;
pendingUserMessage: string | null;
awaitingAssistantResponse: boolean;
assistantResponseBaseline: string;
isRunning: boolean;
isSubmitting: boolean;
submitError: string | null;
onSubmit: ((value: HumanInteractionDialogResult) => void) | null;
resolve: (value: HumanInteractionDialogResult | null) => void;
} | null>(null);
private _state = signal<HumanInteractionDialogState | null>(null);
readonly state = this._state.asReadonly();
@ -70,6 +91,7 @@ export class HumanInteractionDialogService {
kind: input.kind,
actionDescription: input.actionDescription ?? '',
currentInput: input.currentInput ?? '',
runtimeInputs: input.runtimeInputs ?? [],
history: input.history ?? [],
latestResponse: input.latestResponse ?? '',
historyField: input.historyField ?? null,
@ -82,6 +104,10 @@ export class HumanInteractionDialogService {
isRunning: input.isRunning === true,
isSubmitting: input.isSubmitting === true,
submitError: input.submitError ?? null,
question: input.question ?? '',
decisionOptions: input.decisionOptions ?? [],
rationaleRequired: input.rationaleRequired === true,
rationaleLabel: input.rationaleLabel ?? 'Rationale',
onSubmit: input.onSubmit ?? null,
resolve
});
@ -102,6 +128,7 @@ export class HumanInteractionDialogService {
kind: input.kind ?? state.kind,
actionDescription: input.actionDescription ?? state.actionDescription,
currentInput: input.currentInput ?? state.currentInput,
runtimeInputs: input.runtimeInputs ?? state.runtimeInputs,
history: input.history ?? state.history,
latestResponse: input.latestResponse ?? state.latestResponse,
historyField: input.historyField !== undefined ? input.historyField : state.historyField,
@ -114,6 +141,10 @@ export class HumanInteractionDialogService {
isRunning: input.isRunning ?? state.isRunning,
isSubmitting: input.isSubmitting ?? state.isSubmitting,
submitError: input.submitError !== undefined ? input.submitError : state.submitError,
question: input.question ?? state.question,
decisionOptions: input.decisionOptions ?? state.decisionOptions,
rationaleRequired: input.rationaleRequired ?? state.rationaleRequired,
rationaleLabel: input.rationaleLabel ?? state.rationaleLabel,
onSubmit: input.onSubmit !== undefined ? input.onSubmit : state.onSubmit
});
}

View File

@ -816,14 +816,21 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase
value: string
): Observable<TaskExecution> {
const execution = this.findExecution(executionId);
execution.context.result[`${nodeId}:${fieldName}`] = value;
execution.context.waitingSteps = execution.context.waitingSteps.filter((stepId) => stepId !== nodeId);
const step = execution.context.steps[nodeId];
if (step) {
step.status = 'COMPLETED';
const isPartialField = fieldName === 'message' || fieldName === 'rationale';
if (isPartialField) {
execution.context.partialResult = {
...(execution.context.partialResult ?? {}),
[`${nodeId}:${fieldName}`]: value
};
if (step) step.status = 'WAITING_FOR_INTERACTION';
execution.context.status = 'WAITING';
return of(execution);
}
execution.context.result[`${nodeId}:${fieldName}`] = value;
execution.context.waitingSteps = execution.context.waitingSteps.filter((stepId) => stepId !== nodeId);
if (step) step.status = 'COMPLETED';
execution.context.status = execution.context.waitingSteps.length ? 'WAITING' : 'RUNNING';
return of(execution);
}

View File

@ -1,5 +1,6 @@
import { TestBed } from '@angular/core/testing';
import { BiasImpactJob } from '@models/bias-impact';
import { TaskExecution } from '@models/task-execution';
import { lastValueFrom, of, throwError } from 'rxjs';
import { vi } from 'vitest';
@ -84,4 +85,43 @@ describe('TaskExecutionsService bias operations', () => {
expect(getBiasImpactJob).toHaveBeenCalledTimes(3);
vi.useRealTimers();
});
it('replaces the local execution immediately with the interaction response', async () => {
const baseline: TaskExecution = {
id: 'execution-1',
name: 'Human flow',
creationTime: 1,
context: {
inputs: {},
result: {},
errors: {},
warnings: [],
steps: {},
status: 'WAITING',
waitingSteps: ['decision-1']
}
};
const updated: TaskExecution = {
...baseline,
context: {
...baseline.context,
status: 'SUCCESS',
waitingSteps: []
}
};
(service as any)._taskExecutions.set([baseline]);
vi.spyOn(service, 'refresh').mockImplementation(() => undefined);
service.taskExecutionsCallService = {
submitInteractionText: vi.fn().mockReturnValue(of(updated))
} as unknown as typeof service.taskExecutionsCallService;
await lastValueFrom(service.submitInteractionText(
'execution-1',
'decision-1',
'choice',
'approve'
));
expect(service.taskExecutions()[0].context.status).toBe('SUCCESS');
});
});

View File

@ -296,7 +296,9 @@ export class TaskExecutionsService {
}
return this.withRefreshAndErrorHandling(
this.taskExecutionsCallService.submitInteractionText(executionId, nodeId, fieldName, value),
this.taskExecutionsCallService.submitInteractionText(executionId, nodeId, fieldName, value).pipe(
tap((updatedExecution) => this.replaceExecution(updatedExecution))
),
'Submit interaction text failed'
);
}
@ -321,6 +323,31 @@ export class TaskExecutionsService {
this.stopPolling();
}
private replaceExecution(updatedExecution: TaskExecution) {
this._taskExecutions.update((executions) => {
const index = executions.findIndex((execution) => execution.id === updatedExecution.id);
if (index < 0) return [updatedExecution, ...executions];
return executions.map((execution) =>
execution.id === updatedExecution.id ? updatedExecution : execution
);
});
this._taskExecutionGroups.update((groups) =>
groups.map((group) => {
if (!(group.executions ?? []).some((execution) => execution.id === updatedExecution.id)) {
return group;
}
return {
...group,
executions: group.executions.map((execution) =>
execution.id === updatedExecution.id ? updatedExecution : execution
),
lastExecutionTime: Math.max(group.lastExecutionTime, updatedExecution.creationTime)
};
})
);
}
private flattenGroups(groups: TaskExecutionGroup[]): TaskExecution[] {
return groups
.flatMap((group) => group.executions ?? [])

View File

@ -0,0 +1,43 @@
.decision-option {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 11px 13px;
border: 1px solid #cbd5e1;
border-radius: 10px;
background: #fff;
color: #1e293b;
text-align: left;
cursor: pointer;
}
.decision-option:hover:not(:disabled) {
border-color: #64748b;
background: #f8fafc;
}
.decision-option-selected {
border-color: #2563eb;
background: #eff6ff;
color: #1d4ed8;
}
.decision-option:disabled {
cursor: not-allowed;
opacity: 0.65;
}
.decision-radio {
width: 16px;
height: 16px;
border: 2px solid #94a3b8;
border-radius: 999px;
background: #fff;
box-shadow: inset 0 0 0 3px #fff;
}
.decision-radio-selected {
border-color: #2563eb;
background: #2563eb;
}

View File

@ -0,0 +1,61 @@
<div class="grid gap-4">
<div class="text-base font-semibold text-slate-900">{{ state.question || 'Select an option' }}</div>
@if (state.runtimeInputs.length) {
<fieldset class="rounded-lg border border-slate-200 bg-slate-50 p-3">
<legend class="px-1 text-xs font-semibold uppercase tracking-wide text-slate-500">Runtime inputs</legend>
<div class="grid gap-3">
@for (input of state.runtimeInputs; track input.name) {
<div>
<div class="text-xs font-semibold text-slate-500">{{ input.name }}</div>
<div class="mt-1 whitespace-pre-wrap break-words font-mono text-sm text-slate-800">{{ input.value || '-' }}</div>
</div>
}
</div>
</fieldset>
}
<fieldset class="grid gap-2">
<legend class="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-500">Decision</legend>
@for (option of state.decisionOptions; track option.name) {
<button
type="button"
class="decision-option"
[class.decision-option-selected]="selectedChoice === option.name"
[disabled]="state.isSubmitting || state.isRunning"
(click)="selectChoice(option.name)">
<span class="decision-radio" [class.decision-radio-selected]="selectedChoice === option.name"></span>
<span>{{ option.label }}</span>
</button>
}
</fieldset>
@if (state.decisionOptions.length < 2 || state.decisionOptions.length > 10) {
<div class="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
This decision must define between 2 and 10 options.
</div>
}
@if (state.submitError) {
<div class="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{{ state.submitError }}
</div>
}
<mat-form-field appearance="outline">
<mat-label>{{ state.rationaleLabel }}{{ state.rationaleRequired ? ' *' : '' }}</mat-label>
<textarea
matInput
class="min-h-32"
rows="5"
[(ngModel)]="rationale"
[disabled]="state.isSubmitting || state.isRunning"
[attr.data-autofocus]="'true'">
</textarea>
</mat-form-field>
<div class="flex justify-end">
<button type="button" mat-flat-button [disabled]="!canSubmit()" (click)="submit($event)">
@if (state.isSubmitting) { Sending... } @else { Confirm decision }
</button>
</div>
</div>

View File

@ -0,0 +1,85 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HumanInteractionDialogState } from '@services/dialogs/human-interaction-dialog';
import { HumanDecisionInteractionComponent } from './human-decision-interaction';
function state(overrides: Partial<HumanInteractionDialogState> = {}): HumanInteractionDialogState {
return {
executionId: 'execution-1',
nodeId: 'decision-1',
title: 'Decision',
kind: 'human-decision',
actionDescription: '',
currentInput: '',
runtimeInputs: [{ name: 'input', value: 'Candidate evidence' }],
history: [],
latestResponse: '',
historyField: null,
responseField: 'choice',
messageField: 'rationale',
completionField: 'choice',
pendingUserMessage: null,
awaitingAssistantResponse: false,
assistantResponseBaseline: '',
isRunning: false,
isSubmitting: false,
submitError: null,
question: 'Should the candidate proceed?',
decisionOptions: [
{ name: 'approve', label: 'Approve' },
{ name: 'reject', label: 'Reject' }
],
rationaleRequired: true,
rationaleLabel: 'Evidence-based rationale',
onSubmit: null,
resolve: () => undefined,
...overrides
};
}
describe('HumanDecisionInteractionComponent', () => {
let fixture: ComponentFixture<HumanDecisionInteractionComponent>;
let component: HumanDecisionInteractionComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [HumanDecisionInteractionComponent]
}).compileComponents();
fixture = TestBed.createComponent(HumanDecisionInteractionComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('state', state());
fixture.detectChanges();
});
it('requires one option and the mandatory rationale', () => {
expect(component.canSubmit()).toBe(false);
component.selectChoice('approve');
expect(component.canSubmit()).toBe(false);
component.rationale = 'Documented evidence satisfies the criteria.';
expect(component.canSubmit()).toBe(true);
});
it('emits the technical option name, never its label', () => {
const emitted: unknown[] = [];
component.submitDecision.subscribe((value) => emitted.push(value));
component.selectChoice('approve');
component.rationale = 'Documented evidence';
component.submit();
expect(emitted).toEqual([{
mode: 'decision',
choice: 'approve',
rationale: 'Documented evidence'
}]);
});
it('keeps local values when an API error updates the dialog state', () => {
component.selectChoice('reject');
component.rationale = 'Mandatory criterion is missing';
fixture.componentRef.setInput('state', state({ submitError: 'Network error' }));
fixture.detectChanges();
expect(component.selectedChoice).toBe('reject');
expect(component.rationale).toBe('Mandatory criterion is missing');
expect(fixture.nativeElement.textContent).toContain('Network error');
});
});

View File

@ -0,0 +1,48 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import {
HumanInteractionDialogResult,
HumanInteractionDialogState
} from '@services/dialogs/human-interaction-dialog';
@Component({
selector: 'app-human-decision-interaction',
standalone: true,
imports: [FormsModule, MatButtonModule, MatFormFieldModule, MatInputModule],
templateUrl: './human-decision-interaction.html',
styleUrl: './human-decision-interaction.css',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class HumanDecisionInteractionComponent {
@Input({ required: true }) state!: HumanInteractionDialogState;
@Output() submitDecision = new EventEmitter<Extract<HumanInteractionDialogResult, { mode: 'decision' }>>();
selectedChoice = '';
rationale = '';
selectChoice(name: string) {
if (this.state.isSubmitting || this.state.isRunning) return;
this.selectedChoice = name;
}
canSubmit(): boolean {
if (this.state.isSubmitting || this.state.isRunning) return false;
if (this.state.decisionOptions.length < 2 || this.state.decisionOptions.length > 10) return false;
if (!this.state.decisionOptions.some((option) => option.name === this.selectedChoice)) return false;
return !this.state.rationaleRequired || this.rationale.trim().length > 0;
}
submit(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (!this.canSubmit()) return;
this.submitDecision.emit({
mode: 'decision',
choice: this.selectedChoice,
rationale: this.rationale.trim()
});
}
}

View File

@ -9,8 +9,12 @@
<p class="mt-1 text-sm text-slate-500">
@if (currentState.kind === 'chat-session') {
Continue the chat session or send the final completion value.
} @else if (currentState.kind === 'human-decision') {
Select one option and document the rationale for the decision.
} @else if (currentState.kind === 'single-response') {
Provide the requested response to complete this activity.
} @else {
Confirm the input as node output or edit it before sending.
This interaction type is not supported.
}
</p>
</div>
@ -91,50 +95,31 @@
}
</div>
</div>
} @else if (currentState.kind === 'human-decision') {
<div class="flex-1 overflow-y-auto px-5 py-4">
<app-human-decision-interaction
[state]="currentState"
(submitDecision)="submitHumanDecision($event)" />
</div>
} @else if (currentState.kind === 'single-response') {
<div class="flex-1 overflow-y-auto px-5 py-4">
<app-human-text-interaction
[state]="currentState"
(submitResponse)="submitTextResponse($event)" />
</div>
} @else {
<div class="grid flex-1 gap-4 overflow-y-auto px-5 py-4">
<fieldset class="rounded-lg border border-slate-200 bg-slate-50 p-3">
<legend class="px-1 text-xs font-semibold uppercase tracking-wide text-slate-500">Action Description</legend>
<div class="max-h-32 overflow-y-auto whitespace-pre-wrap break-words text-sm text-slate-800">{{ currentState.actionDescription || '-' }}</div>
</fieldset>
<fieldset class="rounded-lg border border-slate-200 bg-slate-50 p-3">
<legend class="px-1 text-xs font-semibold uppercase tracking-wide text-slate-500">Current Input</legend>
<div class="max-h-56 overflow-y-auto whitespace-pre-wrap break-words font-mono text-sm text-slate-800">{{ currentState.currentInput || '-' }}</div>
</fieldset>
@if (editing()) {
<mat-form-field appearance="outline">
<mat-label>
Edit Response
</mat-label>
<textarea
matInput
class="min-h-40"
rows="7"
[ngModel]="draftValue"
[disabled]="currentState.isSubmitting || currentState.isRunning"
[attr.data-autofocus]="'true'"
(ngModelChange)="setDraftValue($event)">
</textarea>
</mat-form-field>
}
<div class="flex-1 px-5 py-8 text-center text-sm text-slate-600">
Unsupported interaction contract: {{ currentState.kind }}
</div>
}
@if (currentState.kind === 'chat-session') {
<div class="flex justify-end gap-2 border-t border-slate-200 px-5 py-4">
<button type="button" mat-stroked-button (click)="cancel($event)">Cancel</button>
@if (currentState.kind === 'chat-session') {
<button type="button" mat-stroked-button [disabled]="!canSendEditedOutput()" (click)="sendChatMessage($event)">Send</button>
<button type="button" mat-flat-button [disabled]="!canSendEditedOutput()" (click)="completeChatSession($event)">Send Final Response</button>
} @else if (!editing()) {
<button type="button" mat-stroked-button (click)="startEditing($event)">Edit Response</button>
<button type="button" mat-flat-button (click)="confirmInput($event)">Confirm Input</button>
} @else {
<button type="button" mat-stroked-button (click)="backToConfirm($event)">Back</button>
<button type="button" mat-flat-button [disabled]="!canSendEditedOutput()" (click)="sendEditedOutput($event)">Send Output</button>
}
</div>
}
</div>
</div>
}

View File

@ -0,0 +1,67 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HumanInteractionDialogService } from '@services/dialogs/human-interaction-dialog';
import { HumanInteractionDialogHostComponent } from './human-interaction-dialog';
describe('HumanInteractionDialogHostComponent dispatcher', () => {
let fixture: ComponentFixture<HumanInteractionDialogHostComponent>;
let dialog: HumanInteractionDialogService;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [HumanInteractionDialogHostComponent]
}).compileComponents();
fixture = TestBed.createComponent(HumanInteractionDialogHostComponent);
dialog = TestBed.inject(HumanInteractionDialogService);
});
afterEach(() => dialog.close(null));
it('keeps chat-session on the existing chat UI', () => {
void dialog.open({
executionId: 'execution-1',
nodeId: 'chat-1',
kind: 'chat-session',
history: [{ role: 'assistant', content: 'How can I help?' }],
messageField: 'message',
completionField: 'response'
});
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('How can I help?');
expect(fixture.nativeElement.textContent).toContain('Send Final Response');
expect(fixture.nativeElement.querySelector('app-human-decision-interaction')).toBeNull();
expect(fixture.nativeElement.querySelector('app-human-text-interaction')).toBeNull();
});
it('dispatches human-decision to its dedicated component', () => {
void dialog.open({
executionId: 'execution-1',
nodeId: 'decision-1',
kind: 'human-decision',
question: 'Approve?',
decisionOptions: [
{ name: 'approve', label: 'Approve' },
{ name: 'reject', label: 'Reject' }
],
rationaleRequired: true
});
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('app-human-decision-interaction')).not.toBeNull();
expect(fixture.nativeElement.textContent).not.toContain('Send Final Response');
});
it('dispatches single-response to its dedicated textarea component', () => {
void dialog.open({
executionId: 'execution-1',
nodeId: 'interaction-1',
kind: 'single-response',
actionDescription: 'Provide evidence',
runtimeInputs: [{ name: 'input', value: 'Context' }]
});
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('app-human-text-interaction')).not.toBeNull();
expect(fixture.nativeElement.textContent).toContain('Provide evidence');
});
});

View File

@ -1,4 +1,4 @@
import { ChangeDetectionStrategy, Component, computed, effect, ElementRef, inject, signal, viewChild } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, effect, ElementRef, inject, viewChild } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
@ -7,11 +7,20 @@ import {
HumanInteractionDialogResult,
HumanInteractionDialogService
} from '@services/dialogs/human-interaction-dialog';
import { HumanDecisionInteractionComponent } from '@shared/human-decision-interaction/human-decision-interaction';
import { HumanTextInteractionComponent } from '@shared/human-text-interaction/human-text-interaction';
@Component({
selector: 'app-human-interaction-dialog-host',
standalone: true,
imports: [FormsModule, MatButtonModule, MatFormFieldModule, MatInputModule],
imports: [
FormsModule,
MatButtonModule,
MatFormFieldModule,
MatInputModule,
HumanDecisionInteractionComponent,
HumanTextInteractionComponent
],
templateUrl: './human-interaction-dialog.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
@ -22,7 +31,6 @@ export class HumanInteractionDialogHostComponent {
private readonly messagesContainer = viewChild<ElementRef<HTMLElement>>('messagesContainer');
readonly state = this.dialog.state;
readonly editing = signal(false);
readonly displayMessages = computed(() => {
const state = this.state();
if (!state) return [];
@ -68,7 +76,6 @@ export class HumanInteractionDialogHostComponent {
const dialogKey = `${state.executionId ?? ''}:${state.nodeId ?? ''}:${state.kind}`;
if (dialogKey !== this.lastDialogKey) {
this.lastDialogKey = dialogKey;
this.editing.set(state.kind !== 'chat-session');
this.draftValue = '';
}
queueMicrotask(() => {
@ -95,40 +102,10 @@ export class HumanInteractionDialogHostComponent {
this.dialog.close(null);
}
startEditing(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
this.editing.set(true);
queueMicrotask(() => {
const target = this.host.nativeElement.querySelector('[data-autofocus="true"]') as HTMLElement | null;
target?.focus();
});
}
backToConfirm(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
this.editing.set(false);
}
setDraftValue(value: string) {
this.draftValue = value;
}
confirmInput(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
this.dialog.submit({ mode: 'complete', value: this.state()?.currentInput ?? '' });
}
sendEditedOutput(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
const value = this.draftValue.trim();
if (!value) return;
this.dialog.submit({ mode: 'complete', value: this.draftValue });
}
sendChatMessage(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
@ -147,6 +124,14 @@ export class HumanInteractionDialogHostComponent {
this.draftValue = '';
}
submitTextResponse(value: string) {
this.dialog.submit({ mode: 'complete', value });
}
submitHumanDecision(result: Extract<HumanInteractionDialogResult, { mode: 'decision' }>) {
this.dialog.submit(result);
}
canSendEditedOutput(): boolean {
const state = this.state();
if (state?.isSubmitting || state?.isRunning) return false;

View File

@ -0,0 +1,46 @@
<div class="grid gap-4">
<fieldset class="rounded-lg border border-slate-200 bg-slate-50 p-3">
<legend class="px-1 text-xs font-semibold uppercase tracking-wide text-slate-500">Instructions</legend>
<div class="whitespace-pre-wrap break-words text-sm text-slate-800">
{{ state.actionDescription || '-' }}
</div>
</fieldset>
@if (state.runtimeInputs.length) {
<fieldset class="rounded-lg border border-slate-200 bg-slate-50 p-3">
<legend class="px-1 text-xs font-semibold uppercase tracking-wide text-slate-500">Runtime inputs</legend>
<div class="grid gap-3">
@for (input of state.runtimeInputs; track input.name) {
<div>
<div class="text-xs font-semibold text-slate-500">{{ input.name }}</div>
<div class="mt-1 whitespace-pre-wrap break-words font-mono text-sm text-slate-800">{{ input.value || '-' }}</div>
</div>
}
</div>
</fieldset>
}
@if (state.submitError) {
<div class="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{{ state.submitError }}
</div>
}
<mat-form-field appearance="outline">
<mat-label>Response</mat-label>
<textarea
matInput
class="min-h-40"
rows="7"
[(ngModel)]="draftValue"
[disabled]="state.isSubmitting || state.isRunning"
[attr.data-autofocus]="'true'">
</textarea>
</mat-form-field>
<div class="flex justify-end">
<button type="button" mat-flat-button [disabled]="!canSubmit()" (click)="submit($event)">
@if (state.isSubmitting) { Sending... } @else { Confirm response }
</button>
</div>
</div>

View File

@ -0,0 +1,38 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HumanInteractionDialogState } from '@services/dialogs/human-interaction-dialog';
import { HumanTextInteractionComponent } from './human-text-interaction';
describe('HumanTextInteractionComponent', () => {
let fixture: ComponentFixture<HumanTextInteractionComponent>;
let component: HumanTextInteractionComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [HumanTextInteractionComponent]
}).compileComponents();
fixture = TestBed.createComponent(HumanTextInteractionComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('state', {
kind: 'single-response',
actionDescription: 'Provide the candidate profile',
runtimeInputs: [{ name: 'input', value: 'Runtime context' }],
isSubmitting: false,
isRunning: false,
submitError: null
} as HumanInteractionDialogState);
fixture.detectChanges();
});
it('rejects an empty response and emits a trimmed textual response', () => {
const emitted: string[] = [];
component.submitResponse.subscribe((value) => emitted.push(value));
component.draftValue = ' ';
component.submit();
expect(emitted).toEqual([]);
component.draftValue = ' Candidate profile ';
component.submit();
expect(emitted).toEqual(['Candidate profile']);
});
});

View File

@ -0,0 +1,34 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { HumanInteractionDialogState } from '@services/dialogs/human-interaction-dialog';
@Component({
selector: 'app-human-text-interaction',
standalone: true,
imports: [FormsModule, MatButtonModule, MatFormFieldModule, MatInputModule],
templateUrl: './human-text-interaction.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class HumanTextInteractionComponent {
@Input({ required: true }) state!: HumanInteractionDialogState;
@Output() submitResponse = new EventEmitter<string>();
draftValue = '';
canSubmit(): boolean {
return !this.state.isSubmitting
&& !this.state.isRunning
&& this.draftValue.trim().length > 0;
}
submit(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
const value = this.draftValue.trim();
if (!this.canSubmit()) return;
this.submitResponse.emit(value);
}
}

View File

@ -77,7 +77,7 @@ describe('TaskStepNodeComponent bias canvas highlighting', () => {
{ provide: NodeSettingsDialogService, useValue: { open: vi.fn().mockResolvedValue(null) } },
{ provide: SubflowPreviewDialogService, useValue: { open: vi.fn() } },
{ provide: HumanInteractionDialogService, useValue: { open: vi.fn(), close: vi.fn(), update: vi.fn(), state: vi.fn().mockReturnValue(null) } },
{ provide: TaskExecutionsService, useValue: { submitInteractionText: vi.fn().mockReturnValue(of(null)) } },
{ provide: TaskExecutionsService, useValue: { submitInteractionText: vi.fn().mockReturnValue(of(null)), refresh: vi.fn() } },
{ provide: BiasImpactExperimentDialogService, useValue: { open: vi.fn() } }
]
}).compileComponents();
@ -199,6 +199,99 @@ describe('TaskStepNodeComponent bias canvas highlighting', () => {
expect(fixture.nativeElement.querySelector('.llm-node-bias-summary')).toBeNull();
});
describe('human interaction contracts', () => {
function configureInteraction(kind: 'single-response' | 'human-decision') {
component.data.data.specificConfiguration = {
...component.data.data.specificConfiguration,
__executionId: 'execution-1',
__executionNodeId: 'node-1',
__executionStatus: 'WAITING',
__stepStatus: 'WAITING_FOR_INTERACTION',
question: 'Should the candidate proceed?',
options: [
{ name: 'approve', label: 'Approve' },
{ name: 'reject', label: 'Reject' }
],
rationaleRequired: true,
rationaleLabel: 'Evidence-based rationale'
};
(component as any).blockDescriptor = {
interactionContract: {
kind,
messageField: kind === 'human-decision' ? 'rationale' : null,
completionField: kind === 'human-decision' ? 'choice' : 'output',
historyField: null,
responseField: kind === 'human-decision' ? 'choice' : 'output',
supportsPartialResult: kind === 'human-decision'
}
};
}
it('dispatches a human decision with dynamic options from the node configuration', () => {
configureInteraction('human-decision');
const dialog = TestBed.inject(HumanInteractionDialogService) as any;
component.openInteractionModal();
expect(dialog.open).toHaveBeenCalledWith(expect.objectContaining({
kind: 'human-decision',
question: 'Should the candidate proceed?',
decisionOptions: [
{ name: 'approve', label: 'Approve' },
{ name: 'reject', label: 'Reject' }
],
rationaleRequired: true,
rationaleLabel: 'Evidence-based rationale'
}));
});
it('submits rationale first and the technical choice last', () => {
configureInteraction('human-decision');
const dialog = TestBed.inject(HumanInteractionDialogService) as any;
const executions = TestBed.inject(TaskExecutionsService) as any;
component.openInteractionModal();
const input = dialog.open.mock.calls.at(-1)[0];
input.onSubmit({
mode: 'decision',
choice: 'approve',
rationale: 'Documented evidence'
});
expect(executions.submitInteractionText.mock.calls).toEqual([
['execution-1', 'node-1', 'rationale', 'Documented evidence'],
['execution-1', 'node-1', 'choice', 'approve']
]);
});
it('uses the single-response completion field and never confirms the runtime input', () => {
configureInteraction('single-response');
const dialog = TestBed.inject(HumanInteractionDialogService) as any;
const executions = TestBed.inject(TaskExecutionsService) as any;
component.openInteractionModal();
const input = dialog.open.mock.calls.at(-1)[0];
input.onSubmit({ mode: 'complete', value: 'Human response' });
expect(executions.submitInteractionText).toHaveBeenCalledWith(
'execution-1',
'node-1',
'output',
'Human response'
);
});
it('does not open a form unless the authoritative step status is WAITING_FOR_INTERACTION', () => {
configureInteraction('single-response');
component.data.data.specificConfiguration.__stepStatus = 'WAITING_FOR_INPUT';
const dialog = TestBed.inject(HumanInteractionDialogService) as any;
component.openInteractionModal();
expect(dialog.open).not.toHaveBeenCalled();
});
});
it('does not mark EndBlock as bias capable when the catalog forbids bias annotations', () => {
component.data.data.typeName = 'EndBlock';
component.data.data.capabilities = undefined;

View File

@ -8,7 +8,12 @@ import { BlocksService } from '@services/blocks/blocks';
import { ContainersService } from '@services/containers/containers';
import { NodeSettingsDialogService } from '@services/dialogs/node-settings-dialog';
import { SubflowPreviewDialogService } from '@services/dialogs/subflow-preview-dialog';
import { HumanInteractionDialogService } from '@services/dialogs/human-interaction-dialog';
import {
HumanDecisionOption,
HumanInteractionDialogResult,
HumanInteractionDialogService,
HumanInteractionRuntimeInput
} from '@services/dialogs/human-interaction-dialog';
import { TaskExecutionsService } from '@services/task-executions/task-executions';
import { BiasImpactExperimentDialogService } from '@services/dialogs/bias-impact-experiment-dialog';
import { BiasComparisonViewStateService } from '@services/bias/bias-comparison-view-state';
@ -429,7 +434,7 @@ export class TaskStepNodeComponent {
}
needsAttention(): boolean {
return this.blockConfiguration?.['__isWaitingStep'] === true;
return this.stepStatus() === 'WAITING_FOR_INTERACTION';
}
isCompleted(): boolean {
@ -566,6 +571,7 @@ export class TaskStepNodeComponent {
if (this.interactionSubmitting) return;
if (this.executionStatus() === 'SUSPENDED') return;
if (this.isInteractionSimulationEnabled()) return;
if (this.stepStatus() !== 'WAITING_FOR_INTERACTION') return;
const executionId = this.executionId();
const executionNodeId = this.executionNodeId();
@ -778,6 +784,9 @@ export class TaskStepNodeComponent {
if (contract.kind === 'chat-session') {
return `Chat with ${this.name || 'Interaction Step'}`;
}
if (contract.kind === 'human-decision') {
return `Decision: ${this.name || 'Human Decision'}`;
}
return `Send response for ${this.name || 'Interaction Step'}`;
}
@ -893,6 +902,7 @@ export class TaskStepNodeComponent {
kind: contract.kind,
actionDescription: this.actionDescriptionValue(),
currentInput: this.currentInputValue(),
runtimeInputs: this.interactionRuntimeInputs(),
history: this.chatHistory(contract),
latestResponse: this.latestInteractionResponse(contract),
historyField: contract.historyField,
@ -904,7 +914,11 @@ export class TaskStepNodeComponent {
assistantResponseBaseline: this.latestInteractionResponse(contract),
isRunning: this.isRunning(),
isSubmitting: this.interactionSubmitting,
submitError: null
submitError: null,
question: this.decisionQuestion(),
decisionOptions: this.decisionOptions(),
rationaleRequired: this.blockConfiguration?.['rationaleRequired'] === true,
rationaleLabel: this.decisionRationaleLabel()
};
}
@ -912,9 +926,18 @@ export class TaskStepNodeComponent {
executionId: string,
executionNodeId: string,
contract: BlockInteractionContract,
result: { mode: 'message' | 'complete'; value: string }
result: HumanInteractionDialogResult
) {
if (this.isInteractionSimulationEnabled()) return;
if (this.stepStatus() !== 'WAITING_FOR_INTERACTION') {
this.humanInteractionDialog.close(null);
this.taskExecutionsService.refresh();
return;
}
if (result.mode === 'decision') {
this.submitHumanDecision(executionId, executionNodeId, contract, result);
return;
}
const interactionFieldName = result.mode === 'message'
? contract.messageField
@ -957,6 +980,124 @@ export class TaskStepNodeComponent {
});
}
private submitHumanDecision(
executionId: string,
executionNodeId: string,
contract: BlockInteractionContract,
result: Extract<HumanInteractionDialogResult, { mode: 'decision' }>
) {
const options = this.decisionOptions();
if (!options.some((option) => option.name === result.choice)) {
this.humanInteractionDialog.update({
submitError: 'Select one of the available decision options.'
});
return;
}
const rationale = result.rationale.trim();
if (this.blockConfiguration?.['rationaleRequired'] === true && !rationale) {
this.humanInteractionDialog.update({
submitError: `${this.decisionRationaleLabel()} is required.`
});
return;
}
const rationaleField = contract.messageField;
const choiceField = contract.completionField;
if (rationale && !rationaleField) {
this.humanInteractionDialog.update({
submitError: 'The decision contract has no rationale field.'
});
return;
}
if (!choiceField) {
this.humanInteractionDialog.update({
submitError: 'The decision contract has no completion field.'
});
return;
}
this.interactionSubmitting = true;
this.humanInteractionDialog.update({ isSubmitting: true, submitError: null });
const submitChoice = () => {
this.taskExecutionsService.submitInteractionText(
executionId,
executionNodeId,
choiceField,
result.choice
).subscribe({
next: () => {
this.interactionSubmitting = false;
this.humanInteractionDialog.close(result);
},
error: (error) => {
this.handleDecisionSubmitError(error);
this.taskExecutionsService.refresh();
}
});
};
if (rationale && rationaleField) {
this.taskExecutionsService.submitInteractionText(
executionId,
executionNodeId,
rationaleField,
rationale
).subscribe({
next: submitChoice,
error: (error) => this.handleDecisionSubmitError(error)
});
return;
}
submitChoice();
}
private handleDecisionSubmitError(error: unknown) {
this.interactionSubmitting = false;
const errorBody = (error as { error?: unknown })?.error;
const serialized = typeof errorBody === 'string'
? errorBody
: JSON.stringify(errorBody ?? {});
const invalidChoice = serialized.includes('HUMAN_DECISION_INVALID_CHOICE');
this.humanInteractionDialog.update({
isSubmitting: false,
submitError: invalidChoice
? 'The selected option is no longer valid. Reload the execution and choose again.'
: 'Unable to submit the decision. Your choice and rationale have been preserved.'
});
}
private interactionRuntimeInputs(): HumanInteractionRuntimeInput[] {
return this.resolvePorts('input').map((input) => ({
name: input.name,
value: this.executionInputTooltip(input.name) ?? 'Not available'
}));
}
private decisionQuestion(): string {
const question = this.blockConfiguration?.['question'];
return typeof question === 'string' ? question : '';
}
private decisionRationaleLabel(): string {
const label = this.blockConfiguration?.['rationaleLabel'];
return typeof label === 'string' && label.trim().length > 0 ? label.trim() : 'Rationale';
}
private decisionOptions(): HumanDecisionOption[] {
const options = this.blockConfiguration?.['options'];
if (!Array.isArray(options)) return [];
return options.flatMap((option) => {
if (!option || typeof option !== 'object' || Array.isArray(option)) return [];
const record = option as Record<string, unknown>;
const name = typeof record['name'] === 'string' ? record['name'].trim() : '';
const label = typeof record['label'] === 'string' ? record['label'].trim() : '';
return name ? [{ name, label: label || name }] : [];
}).slice(0, 10);
}
private parseChatHistoryLine(rawLine: string): { role: 'user' | 'assistant' | 'system'; content: string } | null {
const line = rawLine.trim();
if (!line) return null;

View File

@ -0,0 +1,34 @@
import { TaskExecutionStep } from '@models/task-execution';
import {
getExecutionInputValues,
getExecutionOutputValues
} from './execution-viewer.utils';
describe('execution viewer runtime values', () => {
const documentedStep: TaskExecutionStep = {
id: 'decision-1',
status: 'WAITING_FOR_INTERACTION',
simulated: false,
node: {
id: 'decision-1',
name: 'Decision',
typeName: 'HumanDecisionBlock',
inputs: [{ name: 'input', type: 'TEXT', multiple: false }],
outputs: [
{ name: 'approve', type: 'TEXT', multiple: false },
{ name: 'reject', type: 'TEXT', multiple: false }
],
specificConfiguration: {}
}
};
it('uses node port descriptors when the execution step omits runtime wrappers', () => {
expect(getExecutionInputValues(documentedStep, {
'decision-1:input': 'Candidate evidence'
})).toEqual({ input: 'Candidate evidence' });
expect(getExecutionOutputValues(documentedStep, {
'decision-1:approve': 'Candidate evidence'
})).toEqual({ approve: 'Candidate evidence' });
});
});

View File

@ -267,14 +267,20 @@ export function getExecutionInputValues(
contextInputs: Record<string, unknown>
): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const input of step.inputs ?? []) {
const inputName = input.descriptor?.name;
const runtimeInputs = step.inputs ?? [];
const inputDescriptors = runtimeInputs.length
? runtimeInputs.map((input) => input.descriptor)
: getTaskExecutionStepNode(step)?.inputs ?? [];
for (const descriptor of inputDescriptors) {
const inputName = descriptor?.name;
if (!inputName) continue;
const key = `${step.id}:${inputName}`;
if (Object.prototype.hasOwnProperty.call(contextInputs, key)) {
result[inputName] = contextInputs[key];
continue;
}
const input = runtimeInputs.find((candidate) => candidate.descriptor?.name === inputName);
if (!input) continue;
if (input.set || input.registered || input.value != null) {
result[inputName] = input.value;
}
@ -287,8 +293,12 @@ export function getExecutionOutputValues(
contextResults: Record<string, unknown>
): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const output of step.outputs ?? []) {
const outputName = output.descriptor?.name;
const runtimeOutputs = step.outputs ?? [];
const outputDescriptors = runtimeOutputs.length
? runtimeOutputs.map((output) => output.descriptor)
: getTaskExecutionStepNode(step)?.outputs ?? [];
for (const descriptor of outputDescriptors) {
const outputName = descriptor?.name;
if (!outputName) continue;
const key = `${step.id}:${outputName}`;
if (Object.prototype.hasOwnProperty.call(contextResults, key)) {

View File

@ -250,6 +250,11 @@ export class TaskExecutionViewerComponent implements OnDestroy {
}
const step = execution.context.steps?.[dialogState.nodeId];
const stepStatus = String(step?.status ?? '').toUpperCase();
if (dialogState.kind !== 'chat-session' && stepStatus !== 'WAITING_FOR_INTERACTION') {
this.humanInteractionDialog.close(null);
return;
}
const stepResult = step?.result && typeof step.result === 'object' ? step.result as Record<string, unknown> : {};
const finalResult = execution.context.result ?? {};
const partialResult = execution.context.partialResult ?? {};
@ -272,7 +277,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
const nextHistory = this.toDialogHistory(rawHistory);
const nextLatestResponse = typeof rawResponse === 'string' ? rawResponse : '';
const nextIsRunning = String(step?.status ?? '').toUpperCase() === 'RUNNING';
const nextIsRunning = stepStatus === 'RUNNING';
const historyHasPendingUser = !!dialogState.pendingUserMessage
&& nextHistory.some((message) =>
message.role === 'user' && String(message.content ?? '').trim() === String(dialogState.pendingUserMessage ?? '').trim()