From 4886bf828aed40424e20a544529834a2943df4e2 Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Thu, 23 Jul 2026 11:30:32 +0200 Subject: [PATCH] feat(task-execution): support human decision contracts --- src/app/models/flow.ts | 6 +- .../dialogs/human-interaction-dialog.ts | 81 +++++++--- .../task-executions-call.fake.ts | 17 +- .../task-executions/task-executions.spec.ts | 40 +++++ .../task-executions/task-executions.ts | 29 +++- .../human-decision-interaction.css | 43 +++++ .../human-decision-interaction.html | 61 +++++++ .../human-decision-interaction.spec.ts | 85 ++++++++++ .../human-decision-interaction.ts | 48 ++++++ .../human-interaction-dialog.html | 57 +++---- .../human-interaction-dialog.spec.ts | 67 ++++++++ .../human-interaction-dialog.ts | 53 +++---- .../human-text-interaction.html | 46 ++++++ .../human-text-interaction.spec.ts | 38 +++++ .../human-text-interaction.ts | 34 ++++ .../task-step-node/task-step-node.spec.ts | 95 ++++++++++- .../nodes/task-step-node/task-step-node.ts | 149 +++++++++++++++++- .../execution-viewer.utils.spec.ts | 34 ++++ .../execution-viewer.utils.ts | 18 ++- .../task-execution-viewer.ts | 7 +- 20 files changed, 896 insertions(+), 112 deletions(-) create mode 100644 src/app/shared/human-decision-interaction/human-decision-interaction.css create mode 100644 src/app/shared/human-decision-interaction/human-decision-interaction.html create mode 100644 src/app/shared/human-decision-interaction/human-decision-interaction.spec.ts create mode 100644 src/app/shared/human-decision-interaction/human-decision-interaction.ts create mode 100644 src/app/shared/human-interaction-dialog/human-interaction-dialog.spec.ts create mode 100644 src/app/shared/human-text-interaction/human-text-interaction.html create mode 100644 src/app/shared/human-text-interaction/human-text-interaction.spec.ts create mode 100644 src/app/shared/human-text-interaction/human-text-interaction.ts create mode 100644 src/app/shared/task-execution-viewer/execution-viewer.utils.spec.ts diff --git a/src/app/models/flow.ts b/src/app/models/flow.ts index b9d1f7d..2e2e7ab 100644 --- a/src/app/models/flow.ts +++ b/src/app/models/flow.ts @@ -61,7 +61,11 @@ export type NodeFamily = 'block' | 'container'; export type BlockTypeSchema = Record | null; -export type BlockInteractionContractKind = 'chat-session' | 'single-response' | string; +export type BlockInteractionContractKind = + | 'chat-session' + | 'single-response' + | 'human-decision' + | string; export type BlockInteractionContract = { kind: BlockInteractionContractKind; diff --git a/src/app/services/dialogs/human-interaction-dialog.ts b/src/app/services/dialogs/human-interaction-dialog.ts index 89284fd..5a433e1 100644 --- a/src/app/services/dialogs/human-interaction-dialog.ts +++ b/src/app/services/dialogs/human-interaction-dialog.ts @@ -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(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 }); } diff --git a/src/app/services/task-executions/task-executions-call.fake.ts b/src/app/services/task-executions/task-executions-call.fake.ts index 8e90937..acbbf56 100644 --- a/src/app/services/task-executions/task-executions-call.fake.ts +++ b/src/app/services/task-executions/task-executions-call.fake.ts @@ -816,14 +816,21 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase value: string ): Observable { 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); } diff --git a/src/app/services/task-executions/task-executions.spec.ts b/src/app/services/task-executions/task-executions.spec.ts index 504d6b6..30a9ebc 100644 --- a/src/app/services/task-executions/task-executions.spec.ts +++ b/src/app/services/task-executions/task-executions.spec.ts @@ -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'); + }); }); diff --git a/src/app/services/task-executions/task-executions.ts b/src/app/services/task-executions/task-executions.ts index 8852722..6b9d2db 100644 --- a/src/app/services/task-executions/task-executions.ts +++ b/src/app/services/task-executions/task-executions.ts @@ -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 ?? []) diff --git a/src/app/shared/human-decision-interaction/human-decision-interaction.css b/src/app/shared/human-decision-interaction/human-decision-interaction.css new file mode 100644 index 0000000..dc8ad6d --- /dev/null +++ b/src/app/shared/human-decision-interaction/human-decision-interaction.css @@ -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; +} diff --git a/src/app/shared/human-decision-interaction/human-decision-interaction.html b/src/app/shared/human-decision-interaction/human-decision-interaction.html new file mode 100644 index 0000000..3d72f35 --- /dev/null +++ b/src/app/shared/human-decision-interaction/human-decision-interaction.html @@ -0,0 +1,61 @@ +
+
{{ state.question || 'Select an option' }}
+ + @if (state.runtimeInputs.length) { +
+ Runtime inputs +
+ @for (input of state.runtimeInputs; track input.name) { +
+
{{ input.name }}
+
{{ input.value || '-' }}
+
+ } +
+
+ } + +
+ Decision + @for (option of state.decisionOptions; track option.name) { + + } +
+ @if (state.decisionOptions.length < 2 || state.decisionOptions.length > 10) { +
+ This decision must define between 2 and 10 options. +
+ } + + @if (state.submitError) { +
+ {{ state.submitError }} +
+ } + + + {{ state.rationaleLabel }}{{ state.rationaleRequired ? ' *' : '' }} + + + +
+ +
+
diff --git a/src/app/shared/human-decision-interaction/human-decision-interaction.spec.ts b/src/app/shared/human-decision-interaction/human-decision-interaction.spec.ts new file mode 100644 index 0000000..3bc02f9 --- /dev/null +++ b/src/app/shared/human-decision-interaction/human-decision-interaction.spec.ts @@ -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 { + 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; + 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'); + }); +}); diff --git a/src/app/shared/human-decision-interaction/human-decision-interaction.ts b/src/app/shared/human-decision-interaction/human-decision-interaction.ts new file mode 100644 index 0000000..b6a16ed --- /dev/null +++ b/src/app/shared/human-decision-interaction/human-decision-interaction.ts @@ -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>(); + + 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() + }); + } +} diff --git a/src/app/shared/human-interaction-dialog/human-interaction-dialog.html b/src/app/shared/human-interaction-dialog/human-interaction-dialog.html index 268436f..f439658 100644 --- a/src/app/shared/human-interaction-dialog/human-interaction-dialog.html +++ b/src/app/shared/human-interaction-dialog/human-interaction-dialog.html @@ -9,8 +9,12 @@

@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. }

@@ -91,50 +95,31 @@ } + } @else if (currentState.kind === 'human-decision') { +
+ +
+ } @else if (currentState.kind === 'single-response') { +
+ +
} @else { -
-
- Action Description -
{{ currentState.actionDescription || '-' }}
-
- -
- Current Input -
{{ currentState.currentInput || '-' }}
-
- - @if (editing()) { - - - Edit Response - - - - } +
+ Unsupported interaction contract: {{ currentState.kind }}
} + @if (currentState.kind === 'chat-session') {
- @if (currentState.kind === 'chat-session') { - } @else if (!editing()) { - - - } @else { - - - }
+ }
} diff --git a/src/app/shared/human-interaction-dialog/human-interaction-dialog.spec.ts b/src/app/shared/human-interaction-dialog/human-interaction-dialog.spec.ts new file mode 100644 index 0000000..d9e93ab --- /dev/null +++ b/src/app/shared/human-interaction-dialog/human-interaction-dialog.spec.ts @@ -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; + 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'); + }); +}); diff --git a/src/app/shared/human-interaction-dialog/human-interaction-dialog.ts b/src/app/shared/human-interaction-dialog/human-interaction-dialog.ts index a0f7678..0b70a6f 100644 --- a/src/app/shared/human-interaction-dialog/human-interaction-dialog.ts +++ b/src/app/shared/human-interaction-dialog/human-interaction-dialog.ts @@ -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>('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) { + this.dialog.submit(result); + } + canSendEditedOutput(): boolean { const state = this.state(); if (state?.isSubmitting || state?.isRunning) return false; diff --git a/src/app/shared/human-text-interaction/human-text-interaction.html b/src/app/shared/human-text-interaction/human-text-interaction.html new file mode 100644 index 0000000..6118d14 --- /dev/null +++ b/src/app/shared/human-text-interaction/human-text-interaction.html @@ -0,0 +1,46 @@ +
+
+ Instructions +
+ {{ state.actionDescription || '-' }} +
+
+ + @if (state.runtimeInputs.length) { +
+ Runtime inputs +
+ @for (input of state.runtimeInputs; track input.name) { +
+
{{ input.name }}
+
{{ input.value || '-' }}
+
+ } +
+
+ } + + @if (state.submitError) { +
+ {{ state.submitError }} +
+ } + + + Response + + + +
+ +
+
diff --git a/src/app/shared/human-text-interaction/human-text-interaction.spec.ts b/src/app/shared/human-text-interaction/human-text-interaction.spec.ts new file mode 100644 index 0000000..2023a36 --- /dev/null +++ b/src/app/shared/human-text-interaction/human-text-interaction.spec.ts @@ -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; + 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']); + }); +}); diff --git a/src/app/shared/human-text-interaction/human-text-interaction.ts b/src/app/shared/human-text-interaction/human-text-interaction.ts new file mode 100644 index 0000000..bbf697b --- /dev/null +++ b/src/app/shared/human-text-interaction/human-text-interaction.ts @@ -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(); + + 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); + } +} 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 d386a52..825df6b 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 @@ -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; 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 95c401e..9f8af73 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 @@ -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 + ) { + 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; + 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; diff --git a/src/app/shared/task-execution-viewer/execution-viewer.utils.spec.ts b/src/app/shared/task-execution-viewer/execution-viewer.utils.spec.ts new file mode 100644 index 0000000..bc747e3 --- /dev/null +++ b/src/app/shared/task-execution-viewer/execution-viewer.utils.spec.ts @@ -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' }); + }); +}); diff --git a/src/app/shared/task-execution-viewer/execution-viewer.utils.ts b/src/app/shared/task-execution-viewer/execution-viewer.utils.ts index f9853c3..0a6e4c8 100644 --- a/src/app/shared/task-execution-viewer/execution-viewer.utils.ts +++ b/src/app/shared/task-execution-viewer/execution-viewer.utils.ts @@ -267,14 +267,20 @@ export function getExecutionInputValues( contextInputs: Record ): Record { const result: Record = {}; - 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 ): Record { const result: Record = {}; - 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)) { 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 4dc7169..3d872e6 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.ts +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.ts @@ -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 : {}; 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()