feat(task-execution): support human decision contracts
This commit is contained in:
parent
a6fef8c207
commit
4886bf828a
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 ?? [])
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
});
|
||||
});
|
||||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue