Skip to content

Commit fc23ca4

Browse files
committed
fix(tui): correct Dynamic Workflow row rendering and progress
The mission-control card rendered raw model output without validating it. When a workflow was called with object items, rows showed [object Object], the streaming argument scanner counted object keys and nested values as extra items, and streamed text was concatenated onto the tool-activity label with no separator. Member progress also crept toward 99% on every streamed token, so a long-running subagent pinned at 99% within seconds and stayed there. Progress now reflects the observed stage only; elapsed time and the latest line carry liveness.
1 parent 602546e commit fc23ca4

5 files changed

Lines changed: 116 additions & 53 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 the Dynamic Workflow card showing `[object Object]`, phantom extra agent rows, and tool labels fused into streamed text when a workflow is called with object items.
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+
Show Dynamic Workflow member progress from the observed stage only, so a running subagent no longer sits at 99% for the rest of its run.

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

Lines changed: 53 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ export interface DynamicWorkflowMember {
4141
item: string;
4242
phase: DynamicWorkflowPhase;
4343
latest: string;
44+
/** `latest` holds a tool-activity label, not streamed model text. */
45+
latestFromTool?: boolean;
4446
statusDetail?: string;
4547
startedAtMs?: number;
4648
endedAtMs?: number;
@@ -201,6 +203,13 @@ export class DynamicWorkflowMissionControlComponent implements Component {
201203
this.model.knownTotal = this.completeItems.length;
202204
this.ensureMemberCount(this.completeItems.length);
203205
this.updateItemTexts(this.completeItems);
206+
// Streaming may have over-counted items; drop the unclaimed surplus rows.
207+
if (this.completeItems.length > 0) {
208+
this.model.members = this.model.members.filter(
209+
(member) => member.index <= this.completeItems.length || member.agentId !== undefined,
210+
);
211+
this.model.itemsStarted = this.model.members.length;
212+
}
204213
for (const member of this.model.members) {
205214
if (member.phase === 'pending') member.phase = 'queued';
206215
}
@@ -249,36 +258,22 @@ export class DynamicWorkflowMissionControlComponent implements Component {
249258
this.advanceMemberProgress(member, DYNAMIC_WORKFLOW_RENDERING.toolActivityProgress);
250259
const latest = input.name === undefined ? 'Using a tool' : `Using ${input.name}`;
251260
this.setLatest(member, latest, true);
261+
// Streamed text that follows starts a new line, never continues this label.
262+
member.latestFromTool = true;
252263
}
253264

254265
appendModelDelta(input: { readonly agentId: string; readonly delta: string }): void {
255266
const member = this.findMemberByAgentId(input.agentId);
256267
if (member === undefined || isTerminalPhase(member.phase) || input.delta.length === 0) return;
257268
this.markStarted(input.agentId);
258269
const recordActivity = input.delta.includes('\n') || member.latest.length === 0;
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-
);
275-
this.advanceMemberProgress(
276-
member,
277-
percent >= toolActivityProgress
278-
? creepToward(finalizingCreepCeiling)
279-
: Math.max(modelActivityProgress, creepToward(midworkCreepCeiling)),
280-
);
281-
const latest = latestNonEmptyLine(`${member.latest}${input.delta}`);
270+
// Progress reflects the observed stage only. The protocol emits no per-task
271+
// completion signal, so streamed text never advances past its stage floor —
272+
// elapsed time and the latest line carry liveness instead.
273+
this.advanceMemberProgress(member, DYNAMIC_WORKFLOW_RENDERING.modelActivityProgress);
274+
const carried = member.latestFromTool === true ? '' : member.latest;
275+
const latest = latestNonEmptyLine(`${carried}${input.delta}`);
276+
member.latestFromTool = false;
282277
this.setLatest(member, latest, recordActivity);
283278
}
284279

@@ -711,20 +706,51 @@ export class DynamicWorkflowMissionControlComponent implements Component {
711706
/** Item list from the completed tool-call `items` argument. */
712707
export function dynamicWorkflowItemsFromArgs(args: Record<string, unknown>): string[] {
713708
const items = args['items'];
714-
return Array.isArray(items) ? items.map(String) : [];
709+
return Array.isArray(items) ? items.map(itemLabel) : [];
710+
}
711+
712+
/**
713+
* The schema requires plain strings, but a model may still emit objects. Render
714+
* a readable field instead of `[object Object]`; the tool call fails validation
715+
* either way.
716+
*/
717+
function itemLabel(item: unknown): string {
718+
if (typeof item === 'string') return item;
719+
if (typeof item !== 'object' || item === null) return String(item);
720+
const record = item as Record<string, unknown>;
721+
for (const key of ['prompt', 'description', 'title', 'task']) {
722+
const value = record[key];
723+
if (typeof value === 'string' && value.length > 0) return value;
724+
}
725+
return '';
715726
}
716727

717-
/** Best-effort `items` read from a partially streamed JSON arguments string. */
728+
/**
729+
* Best-effort `items` read from a partially streamed JSON arguments string.
730+
* Only top-level array members count: strings nested inside an object or array
731+
* member (and object keys) are skipped, not counted as items.
732+
*/
718733
export function dynamicWorkflowPartialItemsFromArguments(argumentsText: string): string[] {
719734
const match = /"items"\s*:\s*\[/.exec(argumentsText);
720735
if (match === null) return [];
721736
const items: string[] = [];
737+
let depth = 0;
722738
for (let index = match.index + match[0].length; index < argumentsText.length; index += 1) {
723739
const character = argumentsText[index];
724-
if (character === ']') return items;
740+
if (character === '{' || character === '[') {
741+
// A nested member still occupies one item slot.
742+
if (depth === 0) items.push('');
743+
depth += 1;
744+
continue;
745+
}
746+
if (character === '}' || character === ']') {
747+
if (depth === 0) return items;
748+
depth -= 1;
749+
continue;
750+
}
725751
if (character !== '"') continue;
726752
const parsed = parsePartialJsonString(argumentsText, index + 1);
727-
items.push(parsed.value);
753+
if (depth === 0) items.push(parsed.value);
728754
if (!parsed.closed) return items;
729755
index = parsed.nextIndex;
730756
}

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

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,6 @@ export const DYNAMIC_WORKFLOW_RENDERING = {
3131
startedProgress: 20,
3232
modelActivityProgress: 50,
3333
toolActivityProgress: 75,
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,
4234
// Two 2×4 Braille cells form a compact 4×4 dotted cube that fills bottom-up.
4335
cubeFillLevels: [' ', '⡀', '⣀', '⣄', '⣤', '⣦', '⣶', '⣷', '⣿'],
4436
} as const;

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

Lines changed: 53 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@ function memberLine(output: string, index: number): string {
2929
return line;
3030
}
3131

32+
function memberRowCount(output: string): number {
33+
return output.split('\n').filter(
34+
(candidate) => /^\d{3}\s/u.test(candidate.replace(/^\s*/u, '')),
35+
).length;
36+
}
37+
3238
function displayedPercent(output: string, index: number): number {
3339
const match = /(\d+)%/u.exec(memberLine(output, index));
3440
if (match === null) throw new Error(`Missing percent for member ${String(index)}`);
@@ -598,34 +604,26 @@ describe('DynamicWorkflowMissionControlComponent', () => {
598604
},
599605
);
600606

601-
it('creeps past 90 across streamed deltas and completes only on the terminal event', () => {
607+
it('holds the observed stage across streamed deltas and completes only on the terminal event', () => {
602608
const component = createComponent();
603609
component.updateArgs({ items: ['Long streaming work'] });
604610
component.markInputComplete();
605611
register(component, 'agent-1');
606612
component.markStarted('agent-1');
607613
component.recordToolCall({ agentId: 'agent-1', name: 'Read' });
608614

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-
617615
for (let index = 0; index < 200; index += 1) {
618-
component.appendModelDelta({ agentId: 'agent-1', delta: 'more ' });
616+
component.appendModelDelta({ agentId: 'agent-1', delta: `chunk ${String(index)} ` });
619617
}
620-
const late = displayedPercent(renderText(component, 100), 1);
621-
expect(late).toBeGreaterThan(90);
622-
expect(late).toBeLessThan(100);
618+
// No invented progress: text after a tool call never climbs toward 100.
619+
expect(displayedPercent(renderText(component, 100), 1))
620+
.toBe(DYNAMIC_WORKFLOW_RENDERING.toolActivityProgress);
623621

624622
component.markCompleted('agent-1', 'Done');
625623
expect(displayedPercent(renderText(component, 100), 1)).toBe(100);
626624
});
627625

628-
it('keeps mid-work delta creep under the tool-activity stage until a tool call lifts it', () => {
626+
it('keeps streamed text at the model stage until a tool call lifts it', () => {
629627
const component = createComponent();
630628
component.updateArgs({ items: ['Chatty work'] });
631629
component.markInputComplete();
@@ -635,13 +633,50 @@ describe('DynamicWorkflowMissionControlComponent', () => {
635633
for (let index = 0; index < 300; index += 1) {
636634
component.appendModelDelta({ agentId: 'agent-1', delta: 'more ' });
637635
}
638-
const midwork = displayedPercent(renderText(component, 100), 1);
639-
expect(midwork).toBeGreaterThan(50);
640-
expect(midwork).toBeLessThan(DYNAMIC_WORKFLOW_RENDERING.toolActivityProgress);
636+
expect(displayedPercent(renderText(component, 100), 1))
637+
.toBe(DYNAMIC_WORKFLOW_RENDERING.modelActivityProgress);
641638

642639
component.recordToolCall({ agentId: 'agent-1', name: 'Read' });
643640
expect(displayedPercent(renderText(component, 100), 1))
644-
.toBeGreaterThanOrEqual(DYNAMIC_WORKFLOW_RENDERING.toolActivityProgress);
641+
.toBe(DYNAMIC_WORKFLOW_RENDERING.toolActivityProgress);
642+
});
643+
644+
it('starts a new line for model text after a tool label instead of fusing them', () => {
645+
const component = createComponent();
646+
component.updateArgs({ items: ['Work'] });
647+
component.markInputComplete();
648+
register(component, 'agent-1');
649+
component.markStarted('agent-1');
650+
component.recordToolCall({ agentId: 'agent-1', name: 'Read' });
651+
component.appendModelDelta({ agentId: 'agent-1', delta: "I've read the files" });
652+
653+
const line = memberLine(renderText(component, 200), 1);
654+
expect(line).not.toContain("Using ReadI've");
655+
expect(line).toContain("I've read the files");
656+
});
657+
658+
it('renders object items by their prompt field and drops streamed phantom rows', () => {
659+
const component = createComponent();
660+
const streamingArguments =
661+
'{"items": [{"prompt": "Explore records", "description": "Records"},'
662+
+ ' {"prompt": "Explore events", "description": "Events"}';
663+
component.updateArgs({}, { streamingArguments });
664+
// Object keys and nested values are not items: two members, not eight.
665+
expect(memberRowCount(renderText(component, 200))).toBe(2);
666+
667+
component.updateArgs({
668+
items: [
669+
{ prompt: 'Explore records', description: 'Records' },
670+
{ prompt: 'Explore events', description: 'Events' },
671+
],
672+
});
673+
component.markInputComplete();
674+
675+
const output = renderText(component, 200);
676+
expect(memberRowCount(output)).toBe(2);
677+
expect(memberLine(output, 1)).toContain('Explore records');
678+
expect(memberLine(output, 1)).not.toContain('[object Object]');
679+
expect(memberLine(output, 2)).toContain('Explore events');
645680
});
646681

647682
it('shimmers Finalizing once every member is terminal but the result has not arrived', () => {

0 commit comments

Comments
 (0)