Show which round a loop step is on, and answer for the round seen
In an execution, a step on its second round or later carries a Round N badge. Answers to a person's question - decisions, evaluations, evidence, revealing the reference - send the round of its loop the question was opened on, fixed when the dialog opened rather than read at submit time, so an answer to an old round's question is refused by the server instead of being applied to a draft the person never saw. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
fbd27c4344
commit
f2378216ee
|
|
@ -173,6 +173,8 @@ export type TaskExecutionStep = {
|
|||
outputs?: TaskExecutionStepOutput[];
|
||||
result?: Record<string, unknown>;
|
||||
status: StepStatus;
|
||||
/** Which round of its loop the step is on; 1, or absent from older servers, for a step in no loop. */
|
||||
iteration?: number;
|
||||
started?: boolean;
|
||||
skipReason?: string | null;
|
||||
simulated: boolean;
|
||||
|
|
|
|||
|
|
@ -97,11 +97,16 @@ export abstract class TaskExecutionsCallServiceBase {
|
|||
inputName: string,
|
||||
files: File[]
|
||||
): Observable<TaskExecution>;
|
||||
/**
|
||||
* `iteration`, when given, is the round of its loop the answer is for: the server refuses it if
|
||||
* the node has moved on to another, rather than taking it as the answer to a question not seen.
|
||||
*/
|
||||
abstract submitInteractionText(
|
||||
executionId: string,
|
||||
nodeId: string,
|
||||
fieldName: string,
|
||||
value: string
|
||||
value: string,
|
||||
iteration?: number
|
||||
): Observable<TaskExecution>;
|
||||
/**
|
||||
* Submits a human evaluation whole - every criterion and the notes in one call - because the
|
||||
|
|
@ -111,17 +116,20 @@ export abstract class TaskExecutionsCallServiceBase {
|
|||
executionId: string,
|
||||
nodeId: string,
|
||||
verdict: Record<string, string>,
|
||||
notes: string
|
||||
notes: string,
|
||||
iteration?: number
|
||||
): Observable<TaskExecution>;
|
||||
abstract attachEvaluationEvidence(
|
||||
executionId: string,
|
||||
nodeId: string,
|
||||
files: File[]
|
||||
files: File[],
|
||||
iteration?: number
|
||||
): Observable<TaskExecution>;
|
||||
/** Asks to see the reference verdict; the execution records that it was seen before judging. */
|
||||
abstract revealEvaluationReference(
|
||||
executionId: string,
|
||||
nodeId: string
|
||||
nodeId: string,
|
||||
iteration?: number
|
||||
): Observable<TaskExecution>;
|
||||
abstract provideAuthorization(
|
||||
executionId: string,
|
||||
|
|
|
|||
|
|
@ -269,11 +269,13 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase {
|
|||
executionId: string,
|
||||
nodeId: string,
|
||||
fieldName: string,
|
||||
value: string
|
||||
value: string,
|
||||
iteration?: number
|
||||
): Observable<TaskExecution> {
|
||||
const url = `${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/node/${encodeURIComponent(nodeId)}/interaction/${encodeURIComponent(fieldName)}/text`;
|
||||
return this.http.put<unknown>(url, value, {
|
||||
headers: { 'Content-Type': 'text/plain' }
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
params: roundParams(iteration)
|
||||
}).pipe(map((raw) => this.mapExecution(raw)));
|
||||
}
|
||||
|
||||
|
|
@ -281,28 +283,33 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase {
|
|||
executionId: string,
|
||||
nodeId: string,
|
||||
verdict: Record<string, string>,
|
||||
notes: string
|
||||
notes: string,
|
||||
iteration?: number
|
||||
): Observable<TaskExecution> {
|
||||
const url = `${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/node/${encodeURIComponent(nodeId)}/evaluation`;
|
||||
return this.http.put<unknown>(url, { verdict, notes }).pipe(map((raw) => this.mapExecution(raw)));
|
||||
return this.http.put<unknown>(url, { verdict, notes }, { params: roundParams(iteration) })
|
||||
.pipe(map((raw) => this.mapExecution(raw)));
|
||||
}
|
||||
|
||||
override attachEvaluationEvidence(
|
||||
executionId: string,
|
||||
nodeId: string,
|
||||
files: File[]
|
||||
files: File[],
|
||||
iteration?: number
|
||||
): Observable<TaskExecution> {
|
||||
const url = `${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/node/${encodeURIComponent(nodeId)}/evaluation/evidence`;
|
||||
const formData = new FormData();
|
||||
for (const file of files) {
|
||||
formData.append('files', file);
|
||||
}
|
||||
return this.http.put<unknown>(url, formData).pipe(map((raw) => this.mapExecution(raw)));
|
||||
return this.http.put<unknown>(url, formData, { params: roundParams(iteration) })
|
||||
.pipe(map((raw) => this.mapExecution(raw)));
|
||||
}
|
||||
|
||||
override revealEvaluationReference(executionId: string, nodeId: string): Observable<TaskExecution> {
|
||||
override revealEvaluationReference(executionId: string, nodeId: string, iteration?: number): Observable<TaskExecution> {
|
||||
const url = `${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/node/${encodeURIComponent(nodeId)}/evaluation/reveal`;
|
||||
return this.http.put<unknown>(url, {}).pipe(map((raw) => this.mapExecution(raw)));
|
||||
return this.http.put<unknown>(url, {}, { params: roundParams(iteration) })
|
||||
.pipe(map((raw) => this.mapExecution(raw)));
|
||||
}
|
||||
|
||||
override provideAuthorization(
|
||||
|
|
@ -777,3 +784,8 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase {
|
|||
return Number.isFinite(number) ? number : fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/** The round an answer is for, as the endpoint's optional `iteration` parameter. */
|
||||
function roundParams(iteration?: number): Record<string, string> {
|
||||
return typeof iteration === 'number' ? { iteration: String(iteration) } : {};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -347,14 +347,14 @@ export class TaskExecutionsService {
|
|||
);
|
||||
}
|
||||
|
||||
submitInteractionText(executionId: string, nodeId: string, fieldName: string, value: string) {
|
||||
submitInteractionText(executionId: string, nodeId: string, fieldName: string, value: string, iteration?: number) {
|
||||
const execution = this._taskExecutions().find((item) => item.id === executionId);
|
||||
if (execution?.interactionSimulationEnabled === true) {
|
||||
return throwError(() => new Error('Manual interaction is disabled for simulated executions.'));
|
||||
}
|
||||
|
||||
return this.withRefreshAndErrorHandling(
|
||||
this.taskExecutionsCallService.submitInteractionText(executionId, nodeId, fieldName, value).pipe(
|
||||
this.taskExecutionsCallService.submitInteractionText(executionId, nodeId, fieldName, value, iteration).pipe(
|
||||
tap((updatedExecution) => {
|
||||
if (updatedExecution.executionKind === 'SUBFLOW') {
|
||||
this.cacheFollowedExecution(updatedExecution);
|
||||
|
|
@ -367,14 +367,14 @@ export class TaskExecutionsService {
|
|||
);
|
||||
}
|
||||
|
||||
submitEvaluation(executionId: string, nodeId: string, verdict: Record<string, string>, notes: string) {
|
||||
submitEvaluation(executionId: string, nodeId: string, verdict: Record<string, string>, notes: string, iteration?: number) {
|
||||
const execution = this._taskExecutions().find((item) => item.id === executionId);
|
||||
if (execution?.interactionSimulationEnabled === true) {
|
||||
return throwError(() => new Error('Manual interaction is disabled for simulated executions.'));
|
||||
}
|
||||
|
||||
return this.withRefreshAndErrorHandling(
|
||||
this.taskExecutionsCallService.submitEvaluation(executionId, nodeId, verdict, notes).pipe(
|
||||
this.taskExecutionsCallService.submitEvaluation(executionId, nodeId, verdict, notes, iteration).pipe(
|
||||
tap((updatedExecution) => {
|
||||
if (updatedExecution.executionKind === 'SUBFLOW') {
|
||||
this.cacheFollowedExecution(updatedExecution);
|
||||
|
|
@ -387,9 +387,9 @@ export class TaskExecutionsService {
|
|||
);
|
||||
}
|
||||
|
||||
attachEvaluationEvidence(executionId: string, nodeId: string, files: File[]) {
|
||||
attachEvaluationEvidence(executionId: string, nodeId: string, files: File[], iteration?: number) {
|
||||
return this.withRefreshAndErrorHandling(
|
||||
this.taskExecutionsCallService.attachEvaluationEvidence(executionId, nodeId, files).pipe(
|
||||
this.taskExecutionsCallService.attachEvaluationEvidence(executionId, nodeId, files, iteration).pipe(
|
||||
tap((execution) => this.replaceExecution(execution))
|
||||
),
|
||||
'Attach evaluation evidence failed',
|
||||
|
|
@ -397,9 +397,9 @@ export class TaskExecutionsService {
|
|||
);
|
||||
}
|
||||
|
||||
revealEvaluationReference(executionId: string, nodeId: string) {
|
||||
revealEvaluationReference(executionId: string, nodeId: string, iteration?: number) {
|
||||
return this.withRefreshAndErrorHandling(
|
||||
this.taskExecutionsCallService.revealEvaluationReference(executionId, nodeId).pipe(
|
||||
this.taskExecutionsCallService.revealEvaluationReference(executionId, nodeId, iteration).pipe(
|
||||
tap((execution) => this.replaceExecution(execution))
|
||||
),
|
||||
'Reveal reference verdict failed',
|
||||
|
|
|
|||
|
|
@ -393,6 +393,18 @@ button.llm-node-bias-summary:focus-visible {
|
|||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* A loop's round sits at the other corner, so it does not cover a container's subflow badge. */
|
||||
.llm-subflow-badge-wrap:has(.llm-loop-round-badge) {
|
||||
left: auto;
|
||||
right: 12px;
|
||||
}
|
||||
|
||||
.llm-loop-round-badge {
|
||||
border-color: #c4b5fd;
|
||||
background: #f5f3ff;
|
||||
color: #5b21b6;
|
||||
}
|
||||
|
||||
.llm-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -44,6 +44,15 @@
|
|||
</span>
|
||||
</div>
|
||||
}
|
||||
@if (loopRound()) {
|
||||
<div class="llm-subflow-badge-wrap">
|
||||
<span class="llm-subflow-badge llm-loop-round-badge"
|
||||
[title]="'Round ' + loopRound() + ' of its loop - earlier rounds are kept in the execution history'">
|
||||
<i class="bi bi-arrow-repeat"></i>
|
||||
<span>Round {{ loopRound() }}</span>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
@if (needsAttention() && isHumanNode()) {
|
||||
<div class="llm-attention-wrap" (pointerdown)="$event.stopPropagation()" (click)="$event.stopPropagation()">
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -373,6 +373,7 @@ describe('TaskStepNodeComponent bias canvas highlighting', () => {
|
|||
__executionNodeId: 'node-1',
|
||||
__executionStatus: 'WAITING',
|
||||
__stepStatus: 'WAITING_FOR_INTERACTION',
|
||||
__stepIteration: 1,
|
||||
question: 'Should the candidate proceed?',
|
||||
options: [
|
||||
{ name: 'approve', label: 'Approve' },
|
||||
|
|
@ -449,11 +450,28 @@ describe('TaskStepNodeComponent bias canvas highlighting', () => {
|
|||
});
|
||||
|
||||
expect(executions.submitInteractionText.mock.calls).toEqual([
|
||||
['execution-1', 'node-1', 'rationale', 'Documented evidence'],
|
||||
['execution-1', 'node-1', 'choice', 'approve']
|
||||
['execution-1', 'node-1', 'rationale', 'Documented evidence', 1],
|
||||
['execution-1', 'node-1', 'choice', 'approve', 1]
|
||||
]);
|
||||
});
|
||||
|
||||
it('answers for the round of the loop the person saw, even if the page has moved on since', () => {
|
||||
// Polling may refresh the node to the next round while the dialog is still open. The answer
|
||||
// is to the question that was asked, so it carries that question's round - and the server
|
||||
// refuses it rather than applying it to a draft the person never saw.
|
||||
configureInteraction('human-decision');
|
||||
component.data.data.specificConfiguration = { ...component.data.data.specificConfiguration, __stepIteration: 3 };
|
||||
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];
|
||||
component.data.data.specificConfiguration = { ...component.data.data.specificConfiguration, __stepIteration: 4 };
|
||||
|
||||
input.onSubmit({ mode: 'decision', choice: 'approve', rationale: 'Looks right' });
|
||||
|
||||
expect(executions.submitInteractionText.mock.calls.map((call: unknown[]) => call[4])).toEqual([3, 3]);
|
||||
});
|
||||
|
||||
it('uses the single-response completion field and never confirms the runtime input', () => {
|
||||
configureInteraction('single-response');
|
||||
const dialog = TestBed.inject(HumanInteractionDialogService) as any;
|
||||
|
|
@ -467,7 +485,8 @@ describe('TaskStepNodeComponent bias canvas highlighting', () => {
|
|||
'execution-1',
|
||||
'node-1',
|
||||
'output',
|
||||
'Human response'
|
||||
'Human response',
|
||||
1
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -132,6 +132,12 @@ export class TaskStepNodeComponent {
|
|||
name = 'Step';
|
||||
mainContentFields: MainContentView[] = [];
|
||||
interactionSubmitting = false;
|
||||
/**
|
||||
* The round of its loop the open question belongs to, fixed when the person opened it. Read at
|
||||
* submit time instead, a page refreshed by polling would send the new round's number with an
|
||||
* answer to the old round's question - exactly what the round is sent to catch.
|
||||
*/
|
||||
private interactionRound: number | undefined;
|
||||
/** Per dialog session: the step holds the truth, these only drive what the form shows. */
|
||||
private evaluationEvidenceCount = 0;
|
||||
private evaluationReferenceRevealed = false;
|
||||
|
|
@ -610,6 +616,12 @@ export class TaskStepNodeComponent {
|
|||
return this.stepStatus() === 'SKIPPED';
|
||||
}
|
||||
|
||||
/** The round of its loop this step is on, or null for a step on its first - or in no loop. */
|
||||
loopRound(): number | null {
|
||||
const iteration = this.blockConfiguration?.['__stepIteration'];
|
||||
return typeof iteration === 'number' && iteration > 1 ? iteration : null;
|
||||
}
|
||||
|
||||
stepStatus(): string {
|
||||
const status = this.blockConfiguration?.['__stepStatus'];
|
||||
return typeof status === 'string' ? status.toUpperCase() : '';
|
||||
|
|
@ -632,6 +644,8 @@ export class TaskStepNodeComponent {
|
|||
const executionNodeId = this.executionNodeId();
|
||||
const contract = this.interactionContract();
|
||||
if (!executionId || !executionNodeId || !contract) return;
|
||||
const iteration = this.blockConfiguration?.['__stepIteration'];
|
||||
this.interactionRound = typeof iteration === 'number' ? iteration : undefined;
|
||||
|
||||
this.humanInteractionDialog.open({
|
||||
...this.buildInteractionDialogState(executionId, executionNodeId, contract),
|
||||
|
|
@ -1072,7 +1086,7 @@ export class TaskStepNodeComponent {
|
|||
}
|
||||
|
||||
private revealEvaluationReference(executionId: string, executionNodeId: string) {
|
||||
this.taskExecutionsService.revealEvaluationReference(executionId, executionNodeId).subscribe({
|
||||
this.taskExecutionsService.revealEvaluationReference(executionId, executionNodeId, this.interactionRound).subscribe({
|
||||
next: () => {
|
||||
this.evaluationReferenceRevealed = true;
|
||||
this.humanInteractionDialog.update({
|
||||
|
|
@ -1089,7 +1103,7 @@ export class TaskStepNodeComponent {
|
|||
}
|
||||
|
||||
private attachEvaluationEvidence(executionId: string, executionNodeId: string, files: File[]) {
|
||||
this.taskExecutionsService.attachEvaluationEvidence(executionId, executionNodeId, files).subscribe({
|
||||
this.taskExecutionsService.attachEvaluationEvidence(executionId, executionNodeId, files, this.interactionRound).subscribe({
|
||||
next: () => {
|
||||
this.evaluationEvidenceCount += files.length;
|
||||
this.humanInteractionDialog.update({
|
||||
|
|
@ -1142,7 +1156,8 @@ export class TaskStepNodeComponent {
|
|||
executionId,
|
||||
executionNodeId,
|
||||
interactionFieldName,
|
||||
result.value
|
||||
result.value,
|
||||
this.interactionRound
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.interactionSubmitting = false;
|
||||
|
|
@ -1185,7 +1200,8 @@ export class TaskStepNodeComponent {
|
|||
executionId,
|
||||
executionNodeId,
|
||||
result.verdict,
|
||||
result.notes
|
||||
result.notes,
|
||||
this.interactionRound
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.interactionSubmitting = false;
|
||||
|
|
@ -1249,7 +1265,8 @@ export class TaskStepNodeComponent {
|
|||
executionId,
|
||||
executionNodeId,
|
||||
choiceField,
|
||||
result.choice
|
||||
result.choice,
|
||||
this.interactionRound
|
||||
).subscribe({
|
||||
next: () => {
|
||||
this.interactionSubmitting = false;
|
||||
|
|
@ -1267,7 +1284,8 @@ export class TaskStepNodeComponent {
|
|||
executionId,
|
||||
executionNodeId,
|
||||
rationaleField,
|
||||
rationale
|
||||
rationale,
|
||||
this.interactionRound
|
||||
).subscribe({
|
||||
next: submitChoice,
|
||||
error: (error) => this.handleDecisionSubmitError(error)
|
||||
|
|
|
|||
|
|
@ -537,6 +537,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
|
|||
__executionStatus: this.execution()?.context.status ?? null,
|
||||
__interactionSimulationEnabled: this.execution()?.interactionSimulationEnabled === true,
|
||||
__stepStatus: step.status,
|
||||
__stepIteration: typeof step.iteration === 'number' ? step.iteration : 1,
|
||||
// Which iteration a container is on, so a node that is visibly working can say what it
|
||||
// is working on rather than only that it is.
|
||||
__containerIterationIndex: typeof step.containerIterationIndex === 'number'
|
||||
|
|
|
|||
Loading…
Reference in New Issue