feat(task-execution): resolve ${{...}} placeholders in human interaction text

question/actionDescription arrive with literal ${{name}} / ${{global.name}}
placeholders since no block type ever persists a "resolved" prompt. Resolves
them client-side in a single pass (not sequential replaces, to avoid
re-resolving a value that itself contains ${{...}}), then renders each
value as its own expandable card or collapsed accordion instead of
concatenating long/array values inline into the surrounding text.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-07-24 21:07:54 +02:00
parent b1e1a08b13
commit 1e5a6e78ca
15 changed files with 613 additions and 9 deletions

View File

@ -41,6 +41,8 @@ export type HumanInteractionDialogInput = {
decisionOptions?: HumanDecisionOption[];
rationaleRequired?: boolean;
rationaleLabel?: string;
/** Flat substitution map for `${{name}}` / `${{global.name}}` / `${{vars.name}}` placeholders in `question`/`actionDescription`. */
templateValues?: Record<string, unknown>;
onSubmit?: (value: HumanInteractionDialogResult) => void;
};
@ -72,6 +74,7 @@ export type HumanInteractionDialogState = {
decisionOptions: HumanDecisionOption[];
rationaleRequired: boolean;
rationaleLabel: string;
templateValues: Record<string, unknown>;
onSubmit: ((value: HumanInteractionDialogResult) => void) | null;
resolve: (value: HumanInteractionDialogResult | null) => void;
};
@ -108,6 +111,7 @@ export class HumanInteractionDialogService {
decisionOptions: input.decisionOptions ?? [],
rationaleRequired: input.rationaleRequired === true,
rationaleLabel: input.rationaleLabel ?? 'Rationale',
templateValues: input.templateValues ?? {},
onSubmit: input.onSubmit ?? null,
resolve
});
@ -145,6 +149,7 @@ export class HumanInteractionDialogService {
decisionOptions: input.decisionOptions ?? state.decisionOptions,
rationaleRequired: input.rationaleRequired ?? state.rationaleRequired,
rationaleLabel: input.rationaleLabel ?? state.rationaleLabel,
templateValues: input.templateValues ?? state.templateValues,
onSubmit: input.onSubmit !== undefined ? input.onSubmit : state.onSubmit
});
}

View File

@ -1,5 +1,11 @@
<div class="grid gap-4">
<div class="text-base font-semibold text-slate-900">{{ state.question || 'Select an option' }}</div>
@if (state.question) {
<div class="text-base font-semibold text-slate-900">
<app-template-placeholder-text [text]="state.question" [values]="state.templateValues"></app-template-placeholder-text>
</div>
} @else {
<div class="text-base font-semibold text-slate-900">Select an option</div>
}
@if (state.runtimeInputs.length) {
<fieldset class="rounded-lg border border-slate-200 bg-slate-50 p-3">

View File

@ -30,6 +30,7 @@ function state(overrides: Partial<HumanInteractionDialogState> = {}): HumanInter
],
rationaleRequired: true,
rationaleLabel: 'Evidence-based rationale',
templateValues: {},
onSubmit: null,
resolve: () => undefined,
...overrides

View File

@ -7,11 +7,12 @@ import {
HumanInteractionDialogResult,
HumanInteractionDialogState
} from '@services/dialogs/human-interaction-dialog';
import { TemplatePlaceholderTextComponent } from '@shared/template-placeholder-text/template-placeholder-text';
@Component({
selector: 'app-human-decision-interaction',
standalone: true,
imports: [FormsModule, MatButtonModule, MatFormFieldModule, MatInputModule],
imports: [FormsModule, MatButtonModule, MatFormFieldModule, MatInputModule, TemplatePlaceholderTextComponent],
templateUrl: './human-decision-interaction.html',
styleUrl: './human-decision-interaction.css',
changeDetection: ChangeDetectionStrategy.OnPush

View File

@ -1,9 +1,11 @@
<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>
@if (state.actionDescription) {
<app-template-placeholder-text [text]="state.actionDescription" [values]="state.templateValues"></app-template-placeholder-text>
} @else {
<div class="text-sm text-slate-800">-</div>
}
</fieldset>
@if (state.runtimeInputs.length) {

View File

@ -4,11 +4,12 @@ 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';
import { TemplatePlaceholderTextComponent } from '@shared/template-placeholder-text/template-placeholder-text';
@Component({
selector: 'app-human-text-interaction',
standalone: true,
imports: [FormsModule, MatButtonModule, MatFormFieldModule, MatInputModule],
imports: [FormsModule, MatButtonModule, MatFormFieldModule, MatInputModule, TemplatePlaceholderTextComponent],
templateUrl: './human-text-interaction.html',
changeDetection: ChangeDetectionStrategy.OnPush
})

View File

@ -245,6 +245,30 @@ describe('TaskStepNodeComponent bias canvas highlighting', () => {
}));
});
it('builds a template substitution map from step inputs, global inputs and execution variables', () => {
configureInteraction('human-decision');
component.data.data.specificConfiguration = {
...component.data.data.specificConfiguration,
__executionInputs: { candidateProfile: 'Jane Doe' },
__globalInputs: { cvs: ['cv-1', 'cv-2'] },
__executionVariables: { retryCount: 2 },
__executionName: 'Ranking run'
};
const dialog = TestBed.inject(HumanInteractionDialogService) as any;
component.openInteractionModal();
expect(dialog.open).toHaveBeenCalledWith(expect.objectContaining({
templateValues: {
candidateProfile: 'Jane Doe',
'global.cvs': ['cv-1', 'cv-2'],
'vars.retryCount': 2,
'context.executionId': 'execution-1',
'context.executionName': 'Ranking run'
}
}));
});
it('submits rationale first and the technical choice last', () => {
configureInteraction('human-decision');
const dialog = TestBed.inject(HumanInteractionDialogService) as any;

View File

@ -15,6 +15,7 @@ import {
HumanInteractionRuntimeInput
} from '@services/dialogs/human-interaction-dialog';
import { TaskExecutionsService } from '@services/task-executions/task-executions';
import { buildTemplateSubstitutions } from '@shared/template-placeholder-text/template-placeholder';
import { BiasImpactExperimentDialogService } from '@services/dialogs/bias-impact-experiment-dialog';
import { BiasComparisonViewStateService } from '@services/bias/bias-comparison-view-state';
import { BiasAnnotationsComponent } from '@shared/bias-annotations/bias-annotations';
@ -659,6 +660,29 @@ export class TaskStepNodeComponent {
return '';
}
private globalInputsValue(): Record<string, unknown> {
const value = this.blockConfiguration?.['__globalInputs'];
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
private executionVariablesValue(): Record<string, unknown> {
const value = this.blockConfiguration?.['__executionVariables'];
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
private executionName(): string | null {
const value = this.blockConfiguration?.['__executionName'];
return typeof value === 'string' && value.trim().length > 0 ? value : null;
}
private templateSubstitutions(): Record<string, unknown> {
const inputs = (this.blockConfiguration?.['__executionInputs'] ?? {}) as Record<string, unknown>;
return buildTemplateSubstitutions(inputs, this.globalInputsValue(), this.executionVariablesValue(), {
executionId: this.executionId(),
executionName: this.executionName()
});
}
private currentInputValue(): string {
const inputKey = this.inputs[0]?.key ?? 'input';
return this.executionInputTooltip(inputKey) ?? 'not ready yet';
@ -918,7 +942,8 @@ export class TaskStepNodeComponent {
question: this.decisionQuestion(),
decisionOptions: this.decisionOptions(),
rationaleRequired: this.blockConfiguration?.['rationaleRequired'] === true,
rationaleLabel: this.decisionRationaleLabel()
rationaleLabel: this.decisionRationaleLabel(),
templateValues: this.templateSubstitutions()
};
}

View File

@ -339,6 +339,9 @@ export class TaskExecutionViewerComponent implements OnDestroy {
const contextWarnings = this.execution()?.context.warnings ?? {};
const waitingSteps = this.execution()?.context.waitingSteps ?? [];
const activeAnnotationIdsByNode = this.execution()?.biasExecutionContext?.activeAnnotationIdsByNode ?? {};
const globalInputsValue = this.execution()?.context.globalInputs ?? {};
const executionVariablesValue = this.execution()?.context.executionVariables ?? {};
const executionName = this.execution()?.name ?? null;
const steps = this.stepsArray();
const execution = this.execution();
const sourceFlow = execution?.flowSnapshot ?? this.sourceFlowData();
@ -394,7 +397,10 @@ export class TaskExecutionViewerComponent implements OnDestroy {
__executionWarnings: getExecutionWarnings(step.id, contextWarnings),
__stepResultData: step.result ?? null,
__executionPartialResult: this.execution()?.context.partialResult ?? null,
__biasActiveAnnotationIds: activeAnnotationIdsByNode[step.id] ?? []
__biasActiveAnnotationIds: activeAnnotationIdsByNode[step.id] ?? [],
__globalInputs: globalInputsValue,
__executionVariables: executionVariablesValue,
__executionName: executionName
},
position: stepNode.position ?? {
x: 120 + (index % 3) * 340,
@ -441,7 +447,10 @@ export class TaskExecutionViewerComponent implements OnDestroy {
__executionWarnings: [],
__stepResultData: null,
__executionPartialResult: this.execution()?.context.partialResult ?? null,
__biasActiveAnnotationIds: activeAnnotationIdsByNode[sourceNode.id] ?? []
__biasActiveAnnotationIds: activeAnnotationIdsByNode[sourceNode.id] ?? [],
__globalInputs: globalInputsValue,
__executionVariables: executionVariablesValue,
__executionName: executionName
}
};

View File

@ -0,0 +1,23 @@
:host {
display: grid;
gap: 10px;
}
.tpl-chevron {
display: inline-block;
font-size: 14px;
line-height: 1;
transition: transform 0.12s ease;
color: #94a3b8;
}
.tpl-chevron-open {
transform: rotate(90deg);
}
.tpl-link {
cursor: pointer;
background: none;
border: none;
padding: 0;
}

View File

@ -0,0 +1,57 @@
@for (segment of segments(); track $index; let segIndex = $index) {
@if (segment.kind === 'text') {
@if (segment.text.trim().length) {
<p class="tpl-text whitespace-pre-wrap break-words text-sm text-slate-800">{{ segment.text }}</p>
}
} @else if (!segment.multiple) {
<div class="tpl-value-card rounded-lg border border-slate-200 bg-white p-3">
<div class="text-xs font-semibold uppercase tracking-wide text-slate-500">{{ segmentLabel(segment.name) }}</div>
@if (hasValue(segment.value)) {
<div class="mt-1 whitespace-pre-wrap break-words font-mono text-sm font-normal text-slate-800">{{ isSingleExpanded(segIndex) ? stringifyValue(segment.value) : truncate(stringifyValue(segment.value)) }}</div>
@if (isLong(stringifyValue(segment.value))) {
<button type="button" class="tpl-link mt-1 text-xs font-semibold text-blue-600 hover:underline" (click)="toggleSingle(segIndex)">{{ isSingleExpanded(segIndex) ? 'Show less' : 'Show all' }}</button>
}
} @else {
<div class="mt-1 text-sm font-normal italic text-slate-400">No data available</div>
}
</div>
} @else {
<div class="tpl-array rounded-lg border border-slate-200 bg-white p-3">
<div class="flex items-center justify-between gap-2">
<span class="text-xs font-semibold uppercase tracking-wide text-slate-500">{{ segmentLabel(segment.name) }} ({{ itemsFor(segment.value).length }})</span>
@if (itemsFor(segment.value).length > 1) {
<button
type="button"
class="tpl-link text-xs font-semibold text-blue-600 hover:underline"
(click)="toggleAllItems(segIndex, itemsFor(segment.value).length, $event)">{{ allItemsExpanded(segIndex, itemsFor(segment.value).length) ? 'Collapse all' : 'Expand all' }}</button>
}
</div>
@if (!itemsFor(segment.value).length) {
<div class="mt-1 text-sm font-normal italic text-slate-400">No data available</div>
} @else {
<div class="mt-2 grid gap-1">
@for (item of itemsFor(segment.value); track $index; let itemIndex = $index) {
<div class="tpl-array-item rounded-md border border-slate-100">
<button
type="button"
class="tpl-array-item-header flex w-full items-center gap-2 px-2 py-1.5 text-left text-sm font-normal text-slate-700 hover:bg-slate-50"
(click)="toggleItem(segIndex, itemIndex)">
<span class="tpl-chevron" [class.tpl-chevron-open]="isItemExpanded(segIndex, itemIndex)" aria-hidden="true">›</span>
<span class="tpl-array-item-label">{{ segmentLabel(segment.name) }} {{ itemIndex + 1 }} of {{ itemsFor(segment.value).length }}</span>
</button>
@if (isItemExpanded(segIndex, itemIndex)) {
<div class="border-t border-slate-100 px-2 py-2">
<div class="whitespace-pre-wrap break-words font-mono text-sm font-normal text-slate-800">{{ isItemFullyExpanded(segIndex, itemIndex) ? stringifyValue(item) : truncate(stringifyValue(item)) }}</div>
@if (isLong(stringifyValue(item))) {
<button type="button" class="tpl-link mt-1 text-xs font-semibold text-blue-600 hover:underline" (click)="toggleItemFull(segIndex, itemIndex, $event)">{{ isItemFullyExpanded(segIndex, itemIndex) ? 'Show less' : 'Show all' }}</button>
}
</div>
}
</div>
}
</div>
}
</div>
}
}

View File

@ -0,0 +1,108 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TemplatePlaceholderTextComponent } from './template-placeholder-text';
function textContent(fixture: ComponentFixture<TemplatePlaceholderTextComponent>, selector: string): string[] {
return Array.from(fixture.nativeElement.querySelectorAll(selector) as NodeListOf<Element>)
.map((el) => el.textContent?.trim() ?? '');
}
describe('TemplatePlaceholderTextComponent', () => {
let fixture: ComponentFixture<TemplatePlaceholderTextComponent>;
let component: TemplatePlaceholderTextComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TemplatePlaceholderTextComponent]
}).compileComponents();
fixture = TestBed.createComponent(TemplatePlaceholderTextComponent);
component = fixture.componentInstance;
});
it('never shows the literal ${{...}} placeholder syntax', () => {
fixture.componentRef.setInput('text', 'Profile:\n${{candidateProfile}}\nReview it.');
fixture.componentRef.setInput('values', { candidateProfile: 'Jane Doe' });
fixture.detectChanges();
expect(fixture.nativeElement.textContent).not.toContain('${{');
expect(textContent(fixture, '.tpl-text').join(' ')).toContain('Profile:');
expect(textContent(fixture, '.tpl-value-card')[0]).toContain('Jane Doe');
});
it('truncates a long single value with an expand toggle, not shown inline in full', () => {
const longValue = 'x'.repeat(250);
fixture.componentRef.setInput('text', '${{notes}}');
fixture.componentRef.setInput('values', { notes: longValue });
fixture.detectChanges();
const body = fixture.nativeElement.querySelector('.tpl-value-card div.mt-1') as HTMLElement;
expect(body.textContent?.length).toBeLessThan(longValue.length);
expect(body.textContent).toContain('…');
const button = fixture.nativeElement.querySelector('.tpl-value-card button') as HTMLButtonElement;
expect(button.textContent?.trim()).toBe('Show all');
button.click();
fixture.detectChanges();
const expandedBody = fixture.nativeElement.querySelector('.tpl-value-card div.mt-1') as HTMLElement;
expect(expandedBody.textContent).toBe(longValue);
});
it('shows an explicit empty-state instead of a blank hole for a missing value', () => {
fixture.componentRef.setInput('text', '${{global.cvs}}');
fixture.componentRef.setInput('values', {});
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('No data available');
});
it('renders a multiple placeholder as a collapsed-by-default accordion, one item per array entry', () => {
fixture.componentRef.setInput('text', 'CVs:\n${{global.cvs[]}}');
fixture.componentRef.setInput('values', { 'global.cvs': ['cv one', 'cv two', 'cv three'] });
fixture.detectChanges();
const headers = textContent(fixture, '.tpl-array-item-label');
expect(headers).toEqual(['Cvs 1 of 3', 'Cvs 2 of 3', 'Cvs 3 of 3']);
expect(fixture.nativeElement.querySelectorAll('.tpl-array-item .border-t').length).toBe(0);
(fixture.nativeElement.querySelectorAll('.tpl-array-item-header')[0] as HTMLButtonElement).click();
fixture.detectChanges();
expect(fixture.nativeElement.querySelectorAll('.tpl-array-item .border-t').length).toBe(1);
});
it('expands and collapses every array item at once', () => {
fixture.componentRef.setInput('text', '${{items[]}}');
fixture.componentRef.setInput('values', { items: ['a', 'b', 'c'] });
fixture.detectChanges();
const expandAllButton = fixture.nativeElement.querySelector('.tpl-array button.tpl-link') as HTMLButtonElement;
expect(expandAllButton.textContent?.trim()).toBe('Expand all');
expandAllButton.click();
fixture.detectChanges();
expect(fixture.nativeElement.querySelectorAll('.tpl-array-item .border-t').length).toBe(3);
expect(expandAllButton.textContent?.trim()).toBe('Collapse all');
expandAllButton.click();
fixture.detectChanges();
expect(fixture.nativeElement.querySelectorAll('.tpl-array-item .border-t').length).toBe(0);
});
it('treats an empty array the same as a missing value', () => {
fixture.componentRef.setInput('text', '${{global.cvs[]}}');
fixture.componentRef.setInput('values', { 'global.cvs': [] });
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('No data available');
expect(fixture.nativeElement.querySelectorAll('.tpl-array-item').length).toBe(0);
});
it('does not render an empty paragraph between two adjacent placeholders', () => {
fixture.componentRef.setInput('text', '${{a}}${{b}}');
fixture.componentRef.setInput('values', { a: '1', b: '2' });
fixture.detectChanges();
expect(fixture.nativeElement.querySelectorAll('.tpl-text').length).toBe(0);
expect(fixture.nativeElement.querySelectorAll('.tpl-value-card').length).toBe(2);
});
});

View File

@ -0,0 +1,149 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, computed, input, signal } from '@angular/core';
import { pathToLabel } from '@shared/nodes/node-utility';
import { resolveTemplateSegments, TemplatePlaceholderSegment } from './template-placeholder';
const TRUNCATE_LENGTH = 200;
/**
* Renders question/actionDescription text with its `${{...}}` placeholders resolved
* to actual values, without concatenating long/array values inline: each resolved
* value becomes its own expandable card (single value) or collapsed-by-default
* accordion (array value), never a giant text blob.
* See docs/human-block-placeholder-resolution-frontend-integration-2026-07-24.md.
*/
@Component({
selector: 'app-template-placeholder-text',
imports: [CommonModule],
templateUrl: './template-placeholder-text.html',
styleUrl: './template-placeholder-text.css',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TemplatePlaceholderTextComponent {
readonly text = input<string | null | undefined>('');
readonly values = input<Record<string, unknown>>({});
private readonly expandedSingleValues = signal<Set<number>>(new Set());
private readonly expandedItems = signal<Set<string>>(new Set());
private readonly fullyExpandedItems = signal<Set<string>>(new Set());
readonly segments = computed<TemplatePlaceholderSegment[]>(() =>
resolveTemplateSegments(this.text(), this.values())
);
segmentLabel(name: string): string {
const lastPart = name.includes('.') ? name.split('.').pop() ?? name : name;
return pathToLabel(lastPart);
}
hasValue(value: unknown): boolean {
if (value == null) return false;
if (typeof value === 'string') return value.trim().length > 0;
return true;
}
stringifyValue(value: unknown): string {
if (value == null) return '';
if (typeof value === 'string') return value;
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
itemsFor(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
isLong(text: string): boolean {
return text.length > TRUNCATE_LENGTH;
}
truncate(text: string): string {
return this.isLong(text) ? `${text.slice(0, TRUNCATE_LENGTH)}…` : text;
}
isSingleExpanded(segIndex: number): boolean {
return this.expandedSingleValues().has(segIndex);
}
toggleSingle(segIndex: number) {
this.expandedSingleValues.update((current) => {
const next = new Set(current);
if (next.has(segIndex)) {
next.delete(segIndex);
} else {
next.add(segIndex);
}
return next;
});
}
isItemExpanded(segIndex: number, itemIndex: number): boolean {
return this.expandedItems().has(this.itemKey(segIndex, itemIndex));
}
toggleItem(segIndex: number, itemIndex: number) {
const key = this.itemKey(segIndex, itemIndex);
this.expandedItems.update((current) => {
const next = new Set(current);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
}
isItemFullyExpanded(segIndex: number, itemIndex: number): boolean {
return this.fullyExpandedItems().has(this.itemKey(segIndex, itemIndex));
}
toggleItemFull(segIndex: number, itemIndex: number, event?: Event) {
event?.preventDefault();
event?.stopPropagation();
const key = this.itemKey(segIndex, itemIndex);
this.fullyExpandedItems.update((current) => {
const next = new Set(current);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
}
allItemsExpanded(segIndex: number, itemCount: number): boolean {
if (!itemCount) return false;
const expanded = this.expandedItems();
for (let i = 0; i < itemCount; i++) {
if (!expanded.has(this.itemKey(segIndex, i))) return false;
}
return true;
}
toggleAllItems(segIndex: number, itemCount: number, event?: Event) {
event?.preventDefault();
event?.stopPropagation();
const shouldExpand = !this.allItemsExpanded(segIndex, itemCount);
this.expandedItems.update((current) => {
const next = new Set(current);
for (let i = 0; i < itemCount; i++) {
const key = this.itemKey(segIndex, i);
if (shouldExpand) {
next.add(key);
} else {
next.delete(key);
}
}
return next;
});
}
private itemKey(segIndex: number, itemIndex: number): string {
return `${segIndex}:${itemIndex}`;
}
}

View File

@ -0,0 +1,99 @@
import { buildTemplateSubstitutions, resolveTemplateSegments } from './template-placeholder';
describe('buildTemplateSubstitutions', () => {
it('prefixes global inputs and execution variables but not plain step inputs', () => {
const values = buildTemplateSubstitutions(
{ candidateProfile: 'Jane Doe' },
{ cvs: ['cv-1', 'cv-2'] },
{ retryCount: 2 },
{ executionId: 'exec-1', executionName: 'Ranking run' }
);
expect(values).toEqual({
candidateProfile: 'Jane Doe',
'global.cvs': ['cv-1', 'cv-2'],
'vars.retryCount': 2,
'context.executionId': 'exec-1',
'context.executionName': 'Ranking run'
});
});
it('omits context keys when execution metadata is missing', () => {
const values = buildTemplateSubstitutions(null, null, null);
expect(values).toEqual({});
});
});
describe('resolveTemplateSegments', () => {
it('returns no segments for empty or missing text', () => {
expect(resolveTemplateSegments('', {})).toEqual([]);
expect(resolveTemplateSegments(null, {})).toEqual([]);
expect(resolveTemplateSegments(undefined, {})).toEqual([]);
});
it('returns a single text segment when there are no placeholders', () => {
expect(resolveTemplateSegments('Plain instructions.', {})).toEqual([
{ kind: 'text', text: 'Plain instructions.', name: '', multiple: false, value: undefined }
]);
});
it('splits surrounding text from a single non-array placeholder', () => {
const segments = resolveTemplateSegments(
'Profile:\n${{candidateProfile}}\nReview it.',
{ candidateProfile: 'Jane Doe, 5 years experience' }
);
expect(segments).toEqual([
{ kind: 'text', text: 'Profile:\n', name: '', multiple: false, value: undefined },
{ kind: 'value', text: '', name: 'candidateProfile', multiple: false, value: 'Jane Doe, 5 years experience' },
{ kind: 'text', text: '\nReview it.', name: '', multiple: false, value: undefined }
]);
});
it('strips the [] marker and marks the segment as multiple', () => {
const segments = resolveTemplateSegments('CVs:\n${{global.cvs[]}}', { 'global.cvs': ['cv-1', 'cv-2', 'cv-3'] });
expect(segments[1]).toEqual({
kind: 'value',
text: '',
name: 'global.cvs',
multiple: true,
value: ['cv-1', 'cv-2', 'cv-3']
});
});
it('resolves a global-prefixed placeholder against the global. key', () => {
const segments = resolveTemplateSegments('${{global.cvs}}', { 'global.cvs': 'resolved' });
expect(segments[0].value).toBe('resolved');
});
it('leaves value undefined when the placeholder has no matching substitution', () => {
const segments = resolveTemplateSegments('${{missing}}', {});
expect(segments[0]).toEqual({ kind: 'value', text: '', name: 'missing', multiple: false, value: undefined });
});
it('does not re-resolve a value that itself contains a placeholder-like string', () => {
const segments = resolveTemplateSegments(
'${{candidateProfile}}',
{ candidateProfile: 'Pasted CV mentioning ${{something}} literally' }
);
expect(segments).toHaveLength(1);
expect(segments[0].value).toBe('Pasted CV mentioning ${{something}} literally');
});
it('resolves multiple placeholders in one pass, preserving order', () => {
const segments = resolveTemplateSegments(
'Hello ${{name}}, your score is ${{score}}.',
{ name: 'Jane', score: 87 }
);
expect(segments.map((segment) => (segment.kind === 'value' ? segment.value : segment.text))).toEqual([
'Hello ',
'Jane',
', your score is ',
87,
'.'
]);
});
});

View File

@ -0,0 +1,94 @@
/**
* Human-facing text on HumanDecisionBlock.question / HumanInteractionBlock.actionDescription
* can contain `${{name}}` / `${{name[]}}` placeholders. The API never resolves them (no
* block type persists or returns a "resolved" prompt - see
* docs/human-block-placeholder-resolution-frontend-integration-2026-07-24.md), so
* resolution happens client-side, right before display.
*/
export type TemplatePlaceholderSegment = {
kind: 'text' | 'value';
text: string;
name: string;
multiple: boolean;
value: unknown;
};
export type TemplateContextMeta = {
executionId?: string | null;
executionName?: string | null;
};
/**
* Builds the flat substitution map from the three possible value sources, keyed exactly
* as placeholders reference them in text (global/vars prefixed, plain step inputs, not).
*/
export function buildTemplateSubstitutions(
inputs: Record<string, unknown> | null | undefined,
globalInputs: Record<string, unknown> | null | undefined,
executionVariables: Record<string, unknown> | null | undefined,
context?: TemplateContextMeta
): Record<string, unknown> {
const values: Record<string, unknown> = {};
for (const [name, value] of Object.entries(inputs ?? {})) {
values[name] = value;
}
for (const [name, value] of Object.entries(globalInputs ?? {})) {
values[`global.${name}`] = value;
}
for (const [name, value] of Object.entries(executionVariables ?? {})) {
values[`vars.${name}`] = value;
}
if (context?.executionId) {
values['context.executionId'] = context.executionId;
}
if (context?.executionName) {
values['context.executionName'] = context.executionName;
}
return values;
}
const PLACEHOLDER_PATTERN = /\$\{\{(.*?)\}\}/g;
/**
* Splits text into alternating text/value segments in a single pass (not sequential
* replaces), so a resolved value that itself contains `${{...}}` (e.g. a pasted CV) is
* never resolved a second time.
*/
export function resolveTemplateSegments(
text: string | null | undefined,
values: Record<string, unknown>
): TemplatePlaceholderSegment[] {
if (!text) return [];
const segments: TemplatePlaceholderSegment[] = [];
const pattern = new RegExp(PLACEHOLDER_PATTERN.source, PLACEHOLDER_PATTERN.flags);
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = pattern.exec(text)) !== null) {
if (match.index > lastIndex) {
segments.push({ kind: 'text', text: text.slice(lastIndex, match.index), name: '', multiple: false, value: undefined });
}
const raw = match[1].trim();
const multiple = raw.endsWith('[]');
const name = multiple ? raw.slice(0, -2).trim() : raw;
segments.push({
kind: 'value',
text: '',
name,
multiple,
value: Object.prototype.hasOwnProperty.call(values, name) ? values[name] : undefined
});
lastIndex = pattern.lastIndex;
}
if (lastIndex < text.length) {
segments.push({ kind: 'text', text: text.slice(lastIndex), name: '', multiple: false, value: undefined });
}
return segments;
}