Skip to content

Commit e12c562

Browse files
committed
fix(tui): preserve workflow lifecycle progress
1 parent ef9b53d commit e12c562

2 files changed

Lines changed: 56 additions & 4 deletions

File tree

apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -261,13 +261,21 @@ export class DynamicWorkflowMissionControlComponent implements Component {
261261
this.recordActivity(member.index, 'Started');
262262
}
263263

264+
private markStartedFromActivity(member: DynamicWorkflowMember): void {
265+
if (member.phase !== 'pending' && member.phase !== 'queued') return;
266+
member.phase = 'running';
267+
member.startedAtMs ??= Date.now();
268+
delete member.statusDetail;
269+
this.recordActivity(member.index, 'Started');
270+
}
271+
264272
recordToolCall(input: {
265273
readonly agentId: string;
266274
readonly name?: string;
267275
}): void {
268276
const member = this.findMemberByAgentId(input.agentId);
269277
if (member === undefined || isTerminalPhase(member.phase)) return;
270-
this.markStarted(input.agentId);
278+
this.markStartedFromActivity(member);
271279
const latest = input.name === undefined ? 'Using a tool' : `Using ${input.name}`;
272280
this.setLatest(member, latest, true);
273281
// Streamed text that follows starts a new line, never continues this label.
@@ -277,7 +285,7 @@ export class DynamicWorkflowMissionControlComponent implements Component {
277285
appendModelDelta(input: { readonly agentId: string; readonly delta: string }): void {
278286
const member = this.findMemberByAgentId(input.agentId);
279287
if (member === undefined || isTerminalPhase(member.phase) || input.delta.length === 0) return;
280-
this.markStarted(input.agentId);
288+
this.markStartedFromActivity(member);
281289
const combined = `${member.carry}${input.delta}`;
282290
// Only the text after the last newline is still being written. A delta that
283291
// ends exactly at a newline leaves nothing pending, so carrying the closed
@@ -926,7 +934,8 @@ function parseDynamicWorkflowResultStatuses(output: string): DynamicWorkflowResu
926934
outcome === 'completed' ||
927935
outcome === 'failed' ||
928936
outcome === 'aborted' ||
929-
outcome === 'cancelled'
937+
outcome === 'cancelled' ||
938+
outcome === 'schema_error'
930939
) {
931940
// Omitted `index` falls back to the lowest free slot so unordered tags
932941
// still render in ascending row order.
@@ -948,7 +957,11 @@ function parseDynamicWorkflowResultStatuses(output: string): DynamicWorkflowResu
948957
index,
949958
agentId: xmlAttribute(attrs, 'agent_id'),
950959
item: xmlAttribute(attrs, 'item'),
951-
status: outcome === 'aborted' || outcome === 'cancelled' ? 'cancelled' : outcome,
960+
status: outcome === 'aborted' || outcome === 'cancelled'
961+
? 'cancelled'
962+
: outcome === 'schema_error'
963+
? 'failed'
964+
: outcome,
952965
detail: normalizeText(decodeXmlEntities(body)),
953966
});
954967
}

apps/pythinker-code/test/tui/components/messages/dynamic-workflow-mission-control.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,28 @@ describe('DynamicWorkflowMissionControlComponent', () => {
124124
}
125125
});
126126

127+
it('maps schema errors to failed rows without shifting later results', () => {
128+
const result = [
129+
'<dynamic_workflow_result>',
130+
'<subagent outcome="schema_error">Invalid structured output</subagent>',
131+
'<subagent outcome="completed">Valid result</subagent>',
132+
'</dynamic_workflow_result>',
133+
].join('\n');
134+
const component = createComponent();
135+
component.updateArgs({ items: ['Schema work', 'Normal work'] });
136+
component.markInputComplete();
137+
138+
expect(dynamicWorkflowResultSummaryFromOutput(result)).toEqual({
139+
completed: 1,
140+
failed: 1,
141+
aborted: 0,
142+
parsed: true,
143+
});
144+
expect(component.applyResult(result)).toBe(true);
145+
expect(memberLine(renderText(component, 120), 1)).toMatch(/×\s+FAIL\s+Schema work/u);
146+
expect(memberLine(renderText(component, 120), 2)).toMatch(/\s+DONE\s+Normal work/u);
147+
});
148+
127149
it('ignores blank items so no phantom row waits forever', () => {
128150
const component = createComponent();
129151
// The engine drops the blank before launching anything, so counting it here
@@ -479,6 +501,23 @@ describe('DynamicWorkflowMissionControlComponent', () => {
479501
expect(output).not.toMatch(/\d+%/u);
480502
});
481503

504+
it('keeps late activity suspended until a lifecycle start resumes it', () => {
505+
const component = createComponent();
506+
component.updateArgs({ items: ['Rate-limited work'] });
507+
component.markInputComplete();
508+
component.registerSubagent({ agentId: 'agent-1' });
509+
component.markStarted('agent-1');
510+
component.markSuspended({ agentId: 'agent-1', reason: 'Rate limited' });
511+
512+
component.appendModelDelta({ agentId: 'agent-1', delta: 'Late output' });
513+
component.recordToolCall({ agentId: 'agent-1', name: 'Read' });
514+
expect(memberLine(renderText(component, 100), 1)).toMatch(/\s+HOLD/u);
515+
expect(renderText(component, 100)).toContain('Rate limited');
516+
517+
component.markStarted('agent-1');
518+
expect(memberLine(renderText(component, 100), 1)).toMatch(/[]\s+RUN/u);
519+
});
520+
482521
it('prefers a suspension detail over stale model progress in the member row', () => {
483522
const component = createComponent({ availableRows: () => 5 });
484523
component.updateArgs({ items: ['Throttle-sensitive work'] });

0 commit comments

Comments
 (0)