Skip to content

Commit 63ee849

Browse files
committed
fix: keep Dynamic Workflow progress moving instead of pinning at 90%
Per-agent progress previously jumped through fixed stage values and capped at 90 for the entire finalizing stream. Each model delta now creeps toward the stage ceiling with a minimum step, the aggregate line shows Finalizing once every delegated agent is terminal but the result has not arrived, and narrow-width member rows pad the state token so the task column no longer shifts between phases.
1 parent 3c9cffe commit 63ee849

4 files changed

Lines changed: 182 additions & 18 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pythoughts/pythinker-code": patch
3+
---
4+
5+
Fix Dynamic Workflow progress sticking at 90% during long streaming, show a Finalizing state once all delegated agents finish, and fix member row alignment at narrow widths.

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

Lines changed: 48 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,12 @@ import { shimmerText } from '#/tui/utils/shimmer';
99

1010
const RESUMED_ITEM_LABEL = '(resumed)';
1111
const ORCHESTRATING_LABEL = 'Orchestrating';
12-
const ORCHESTRATING_LABEL_WIDTH = visibleWidth(ORCHESTRATING_LABEL);
12+
const FINALIZING_LABEL = 'Finalizing';
13+
// Pad to the wider live label so the suffix column never shifts between them.
14+
const LIVE_LABEL_WIDTH = Math.max(
15+
visibleWidth(ORCHESTRATING_LABEL),
16+
visibleWidth(FINALIZING_LABEL),
17+
);
1318
const MAX_DYNAMIC_WORKFLOW_MEMBERS = 128;
1419

1520
/** Lifecycle state of one delegated agent row, driven only by observed events. */
@@ -41,7 +46,9 @@ export interface DynamicWorkflowMember {
4146
endedAtMs?: number;
4247
/**
4348
* Observed-stage progress heuristic (0-100): the protocol emits no per-task
44-
* percentage, so stages step through fixed values and never predict time.
49+
* percentage, so stage floors map to observed events and streamed deltas
50+
* creep asymptotically toward a ceiling. May hold fractional values
51+
* internally; display floors it. Only a terminal event reaches 100.
4552
*/
4653
progressPercent: number;
4754
}
@@ -249,12 +256,27 @@ export class DynamicWorkflowMissionControlComponent implements Component {
249256
if (member === undefined || isTerminalPhase(member.phase) || input.delta.length === 0) return;
250257
this.markStarted(input.agentId);
251258
const recordActivity = input.delta.includes('\n') || member.latest.length === 0;
252-
// Text after a tool call counts as finalizing; earlier text is mid-work output.
259+
// Text after a tool call counts as finalizing; earlier text is mid-work
260+
// output. Each delta creeps toward the stage ceiling — with a minimum
261+
// step so long streams keep visibly moving — without claiming completion.
262+
const percent = member.progressPercent;
263+
const {
264+
toolActivityProgress,
265+
finalizingCreepCeiling,
266+
modelActivityProgress,
267+
midworkCreepCeiling,
268+
progressCreepRate,
269+
progressCreepMinStep,
270+
} = DYNAMIC_WORKFLOW_RENDERING;
271+
const creepToward = (ceiling: number): number => Math.min(
272+
ceiling,
273+
percent + Math.max(progressCreepMinStep, (ceiling - percent) * progressCreepRate),
274+
);
253275
this.advanceMemberProgress(
254276
member,
255-
member.progressPercent >= DYNAMIC_WORKFLOW_RENDERING.toolActivityProgress
256-
? DYNAMIC_WORKFLOW_RENDERING.finalizingProgress
257-
: DYNAMIC_WORKFLOW_RENDERING.modelActivityProgress,
277+
percent >= toolActivityProgress
278+
? creepToward(finalizingCreepCeiling)
279+
: Math.max(modelActivityProgress, creepToward(midworkCreepCeiling)),
258280
);
259281
const latest = latestNonEmptyLine(`${member.latest}${input.delta}`);
260282
this.setLatest(member, latest, recordActivity);
@@ -465,21 +487,30 @@ export class DynamicWorkflowMissionControlComponent implements Component {
465487
const loader = terminal
466488
? currentTheme.fg(requestPhaseColor(this.model.requestPhase), requestPhaseSymbol(this.model.requestPhase))
467489
: this.activitySpinnerText?.() ?? currentTheme.fg('primary', '●');
490+
const aggregateMembers = this.aggregateMembers();
491+
// All spawned agents are done but the tool result has not arrived yet:
492+
// the label says so instead of pretending orchestration is still active.
493+
// Every member counts — including out-of-band rows beyond knownTotal —
494+
// so the label never claims "done" above a row still marked running.
495+
const finalizing = !terminal &&
496+
this.model.knownTotal !== undefined &&
497+
this.model.knownTotal > 0 &&
498+
aggregateMembers.length === this.model.knownTotal &&
499+
this.model.members.every((member) => isTerminalPhase(member.phase));
468500
// The live label shimmers from elapsed time; no timer is created because
469501
// the host owns animation and only re-renders this block.
470502
const label = terminal
471503
? currentTheme.fg('text', requestPhaseLabel(this.model.requestPhase))
472-
: shimmerText(ORCHESTRATING_LABEL, {
504+
: shimmerText(finalizing ? FINALIZING_LABEL : ORCHESTRATING_LABEL, {
473505
baseToken: 'text',
474506
shimmerToken: 'primaryShimmer',
475507
frame: Math.floor(
476508
Math.max(0, nowMs - this.model.startedAtMs) / BRAILLE_SPINNER_INTERVAL_MS,
477509
),
478510
windowSize: 4,
479511
});
480-
const paddedLabel = padToWidth(label, ORCHESTRATING_LABEL_WIDTH);
512+
const paddedLabel = padToWidth(label, LIVE_LABEL_WIDTH);
481513
const prefix = `${loader} ${paddedLabel}`;
482-
const aggregateMembers = this.aggregateMembers();
483514
const completed = aggregateMembers.filter((member) => member.phase === 'completed').length;
484515
const failed = aggregateMembers.filter((member) => member.phase === 'failed').length;
485516
const cancelled = aggregateMembers.filter((member) => member.phase === 'cancelled').length;
@@ -516,7 +547,7 @@ export class DynamicWorkflowMissionControlComponent implements Component {
516547
padToWidth('STATE', 6),
517548
'TASK',
518549
].join(' ')
519-
: 'ID STATE TASK';
550+
: `${padToWidth('ID', 3)} ${padToWidth('STATE', 6)} TASK`;
520551
return truncateToWidth(currentTheme.fg('textDim', header), width);
521552
}
522553

@@ -527,7 +558,7 @@ export class DynamicWorkflowMissionControlComponent implements Component {
527558
const showProgress = width >= DYNAMIC_WORKFLOW_RENDERING.memberProgressMinWidth;
528559
const progress = `${renderProgressCube(progressPercent)} ${currentTheme.fg(
529560
'textMuted',
530-
`${String(progressPercent).padStart(3, ' ')}%`,
561+
`${String(Math.floor(progressPercent)).padStart(3, ' ')}%`,
531562
)}`;
532563
const progressColumn = padToWidth(
533564
progress,
@@ -536,7 +567,7 @@ export class DynamicWorkflowMissionControlComponent implements Component {
536567
const stateColumn = padToWidth(state, 6);
537568
const prefix = showProgress
538569
? `${id} ${progressColumn} ${stateColumn} `
539-
: `${id} ${state} `;
570+
: `${id} ${padToWidth(state, 6)} `;
540571
const task = member.item || 'Delegated agent';
541572
const latest = member.latest.length > 0 && member.latest !== task ? member.latest : undefined;
542573
const detail = member.phase === 'suspended' || isTerminalPhase(member.phase)
@@ -650,7 +681,10 @@ export class DynamicWorkflowMissionControlComponent implements Component {
650681
this.recordActivity(member.index, normalizedDetail.length > 0 ? `${label}: ${normalizedDetail}` : label);
651682
}
652683

653-
/** Progress only ever advances; stages map to observed events, never to time. */
684+
/**
685+
* Progress only ever advances; stages map to observed events, never to time.
686+
* Creep is per observed event too (each streamed delta), so no timers exist.
687+
*/
654688
private advanceMemberProgress(member: DynamicWorkflowMember, targetPercent: number): void {
655689
member.progressPercent = Math.max(member.progressPercent, targetPercent);
656690
}
@@ -1026,7 +1060,7 @@ function parsePartialJsonString(
10261060
if (escaped === 'u') {
10271061
const hex = text.slice(index + 2, index + 6);
10281062
if (/^[0-9a-fA-F]{4}$/.test(hex)) {
1029-
value += String.fromCharCode(Number.parseInt(hex, 16));
1063+
value += String.fromCodePoint(Number.parseInt(hex, 16));
10301064
index += 5;
10311065
continue;
10321066
}

apps/pythinker-code/src/tui/constant/rendering.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,14 @@ export const DYNAMIC_WORKFLOW_RENDERING = {
3131
startedProgress: 20,
3232
modelActivityProgress: 50,
3333
toolActivityProgress: 75,
34-
finalizingProgress: 90,
34+
// Each streamed model delta creeps progress toward a ceiling instead of
35+
// pinning it: p += max(minStep, (ceiling - p) * rate), clamped to the
36+
// ceiling. The minimum step keeps the tail visibly moving instead of
37+
// asymptoting into a stall. Still event-driven, never a timer.
38+
progressCreepRate: 0.03,
39+
progressCreepMinStep: 0.15,
40+
midworkCreepCeiling: 74,
41+
finalizingCreepCeiling: 99,
3542
// Two 2×4 Braille cells form a compact 4×4 dotted cube that fills bottom-up.
3643
cubeFillLevels: [' ', '⡀', '⣀', '⣄', '⣤', '⣦', '⣶', '⣷', '⣿'],
3744
} as const;

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

Lines changed: 121 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
type DynamicWorkflowMissionControlOptions,
88
dynamicWorkflowResultSummaryFromOutput,
99
} from '#/tui/components/messages/dynamic-workflow-mission-control';
10-
import { BRAILLE_SPINNER_INTERVAL_MS } from '#/tui/constant/rendering';
10+
import { BRAILLE_SPINNER_INTERVAL_MS, DYNAMIC_WORKFLOW_RENDERING } from '#/tui/constant/rendering';
1111
import { currentTheme, darkColors } from '#/tui/theme';
1212

1313
const DESCRIPTION = 'Review the interface';
@@ -29,9 +29,15 @@ function memberLine(output: string, index: number): string {
2929
return line;
3030
}
3131

32+
function displayedPercent(output: string, index: number): number {
33+
const match = /(\d+)%/u.exec(memberLine(output, index));
34+
if (match === null) throw new Error(`Missing percent for member ${String(index)}`);
35+
return Number(match[1]);
36+
}
37+
3238
function aggregateLine(output: string): string {
3339
const line = output.split('\n').find((candidate) =>
34-
/\b(?:Orchestrating|Completed|Failed|Cancelled)\b/u.test(strip(candidate))
40+
/\b(?:Orchestrating|Finalizing|Completed|Failed|Cancelled)\b/u.test(strip(candidate))
3541
);
3642
if (line === undefined) throw new Error('Missing Dynamic Workflow aggregate');
3743
return line;
@@ -508,7 +514,7 @@ describe('DynamicWorkflowMissionControlComponent', () => {
508514
component.recordToolCall({ agentId: 'agent-1', name: 'Read' });
509515
expectProgress(75, '⣶');
510516
component.appendModelDelta({ agentId: 'agent-1', delta: 'Summarizing' });
511-
const activeOutput = expectProgress(90, '');
517+
const activeOutput = expectProgress(75, '');
512518
expect(aggregateLine(activeOutput)).toContain('0/1 complete');
513519
expect(aggregateLine(activeOutput)).not.toMatch(/\b\d+%/u);
514520
expect(aggregateLine(activeOutput)).not.toContain('━');
@@ -591,4 +597,116 @@ describe('DynamicWorkflowMissionControlComponent', () => {
591597
expect(output.includes('PROGRESS')).toBe(showsProgress);
592598
},
593599
);
600+
601+
it('creeps past 90 across streamed deltas and completes only on the terminal event', () => {
602+
const component = createComponent();
603+
component.updateArgs({ items: ['Long streaming work'] });
604+
component.markInputComplete();
605+
register(component, 'agent-1');
606+
component.markStarted('agent-1');
607+
component.recordToolCall({ agentId: 'agent-1', name: 'Read' });
608+
609+
for (let index = 0; index < 10; index += 1) {
610+
component.appendModelDelta({ agentId: 'agent-1', delta: `chunk ${String(index)} ` });
611+
}
612+
const early = displayedPercent(renderText(component, 100), 1);
613+
// No snap to 90: the finalizing phase climbs from 75 instead of jumping.
614+
expect(early).toBeGreaterThan(75);
615+
expect(early).toBeLessThan(90);
616+
617+
for (let index = 0; index < 200; index += 1) {
618+
component.appendModelDelta({ agentId: 'agent-1', delta: 'more ' });
619+
}
620+
const late = displayedPercent(renderText(component, 100), 1);
621+
expect(late).toBeGreaterThan(90);
622+
expect(late).toBeLessThan(100);
623+
624+
component.markCompleted('agent-1', 'Done');
625+
expect(displayedPercent(renderText(component, 100), 1)).toBe(100);
626+
});
627+
628+
it('keeps mid-work delta creep under the tool-activity stage until a tool call lifts it', () => {
629+
const component = createComponent();
630+
component.updateArgs({ items: ['Chatty work'] });
631+
component.markInputComplete();
632+
register(component, 'agent-1');
633+
component.markStarted('agent-1');
634+
635+
for (let index = 0; index < 300; index += 1) {
636+
component.appendModelDelta({ agentId: 'agent-1', delta: 'more ' });
637+
}
638+
const midwork = displayedPercent(renderText(component, 100), 1);
639+
expect(midwork).toBeGreaterThan(50);
640+
expect(midwork).toBeLessThan(DYNAMIC_WORKFLOW_RENDERING.toolActivityProgress);
641+
642+
component.recordToolCall({ agentId: 'agent-1', name: 'Read' });
643+
expect(displayedPercent(renderText(component, 100), 1))
644+
.toBeGreaterThanOrEqual(DYNAMIC_WORKFLOW_RENDERING.toolActivityProgress);
645+
});
646+
647+
it('shimmers Finalizing once every member is terminal but the result has not arrived', () => {
648+
const component = createComponent();
649+
component.updateArgs({ items: ['One', 'Two'] });
650+
component.markInputComplete();
651+
component.registerSubagent({ agentId: 'agent-1', dynamicWorkflowIndex: 1 });
652+
component.registerSubagent({ agentId: 'agent-2', dynamicWorkflowIndex: 2 });
653+
component.markStarted('agent-1');
654+
component.markStarted('agent-2');
655+
component.markCompleted('agent-1', 'Done');
656+
657+
const running = renderText(component, 100);
658+
expect(running).toContain('Orchestrating');
659+
expect(running).not.toContain('Finalizing');
660+
661+
component.markCompleted('agent-2', 'Done');
662+
const finalizing = renderText(component, 100);
663+
expect(finalizing).toContain('Finalizing');
664+
expect(finalizing).not.toContain('Orchestrating');
665+
666+
component.applyResult([
667+
'<dynamic_workflow_result>',
668+
'<subagent index="1" outcome="completed">Done</subagent>',
669+
'<subagent index="2" outcome="completed">Done</subagent>',
670+
'</dynamic_workflow_result>',
671+
].join('\n'));
672+
const done = renderText(component, 100);
673+
expect(done).toContain('✓ Completed');
674+
expect(done).not.toContain('Finalizing');
675+
});
676+
677+
it('keeps Orchestrating while an out-of-band member beyond knownTotal still runs', () => {
678+
const component = createComponent();
679+
component.updateArgs({ items: ['One', 'Two'] });
680+
component.markInputComplete();
681+
component.registerSubagent({ agentId: 'agent-1', dynamicWorkflowIndex: 1 });
682+
component.registerSubagent({ agentId: 'agent-2', dynamicWorkflowIndex: 2 });
683+
component.registerSubagent({ agentId: 'agent-3', dynamicWorkflowIndex: 3 });
684+
component.markStarted('agent-3');
685+
component.markCompleted('agent-1', 'Done');
686+
component.markCompleted('agent-2', 'Done');
687+
688+
const output = renderText(component, 100);
689+
expect(output).toContain('● RUN');
690+
expect(output).toContain('Orchestrating');
691+
expect(output).not.toContain('Finalizing');
692+
693+
component.markCompleted('agent-3', 'Done');
694+
expect(renderText(component, 100)).toContain('Finalizing');
695+
});
696+
697+
it('aligns narrow member rows and the header on the same task column', () => {
698+
const component = prepareObservedWorkflow();
699+
const output = renderText(component, 50);
700+
const unframe = (line: string) => line.replace(/^ /u, '');
701+
const running = unframe(memberLine(output, 1));
702+
const completed = unframe(memberLine(output, 2));
703+
const headerLine = output.split('\n').find((line) => line.includes('STATE'));
704+
if (headerLine === undefined) throw new Error('Missing Dynamic Workflow table header');
705+
const header = unframe(headerLine);
706+
707+
const taskColumn = running.indexOf('Layout hierarchy');
708+
expect(taskColumn).toBeGreaterThan(0);
709+
expect(completed.indexOf('Interaction audit')).toBe(taskColumn);
710+
expect(header.indexOf('TASK')).toBe(taskColumn);
711+
});
594712
});

0 commit comments

Comments
 (0)