From fc23ca475c191901af930bdf8e089d319f0f6e39 Mon Sep 17 00:00:00 2001 From: elkaix Date: Wed, 5 Aug 2026 18:08:10 -0400 Subject: [PATCH 1/2] 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. --- .changeset/dynamic-workflow-garbled-rows.md | 5 ++ .changeset/dynamic-workflow-stage-progress.md | 5 ++ .../dynamic-workflow-mission-control.ts | 80 ++++++++++++------- .../src/tui/constant/rendering.ts | 8 -- .../dynamic-workflow-mission-control.test.ts | 71 +++++++++++----- 5 files changed, 116 insertions(+), 53 deletions(-) create mode 100644 .changeset/dynamic-workflow-garbled-rows.md create mode 100644 .changeset/dynamic-workflow-stage-progress.md diff --git a/.changeset/dynamic-workflow-garbled-rows.md b/.changeset/dynamic-workflow-garbled-rows.md new file mode 100644 index 00000000..b5ce37ee --- /dev/null +++ b/.changeset/dynamic-workflow-garbled-rows.md @@ -0,0 +1,5 @@ +--- +"@pythoughts/pythinker-code": patch +--- + +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. diff --git a/.changeset/dynamic-workflow-stage-progress.md b/.changeset/dynamic-workflow-stage-progress.md new file mode 100644 index 00000000..7539ccd8 --- /dev/null +++ b/.changeset/dynamic-workflow-stage-progress.md @@ -0,0 +1,5 @@ +--- +"@pythoughts/pythinker-code": patch +--- + +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. diff --git a/apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts b/apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts index 84982bb7..5ecc1edf 100644 --- a/apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts +++ b/apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts @@ -41,6 +41,8 @@ export interface DynamicWorkflowMember { item: string; phase: DynamicWorkflowPhase; latest: string; + /** `latest` holds a tool-activity label, not streamed model text. */ + latestFromTool?: boolean; statusDetail?: string; startedAtMs?: number; endedAtMs?: number; @@ -201,6 +203,13 @@ export class DynamicWorkflowMissionControlComponent implements Component { this.model.knownTotal = this.completeItems.length; this.ensureMemberCount(this.completeItems.length); this.updateItemTexts(this.completeItems); + // Streaming may have over-counted items; drop the unclaimed surplus rows. + if (this.completeItems.length > 0) { + this.model.members = this.model.members.filter( + (member) => member.index <= this.completeItems.length || member.agentId !== undefined, + ); + this.model.itemsStarted = this.model.members.length; + } for (const member of this.model.members) { if (member.phase === 'pending') member.phase = 'queued'; } @@ -249,6 +258,8 @@ export class DynamicWorkflowMissionControlComponent implements Component { this.advanceMemberProgress(member, DYNAMIC_WORKFLOW_RENDERING.toolActivityProgress); const latest = input.name === undefined ? 'Using a tool' : `Using ${input.name}`; this.setLatest(member, latest, true); + // Streamed text that follows starts a new line, never continues this label. + member.latestFromTool = true; } appendModelDelta(input: { readonly agentId: string; readonly delta: string }): void { @@ -256,29 +267,13 @@ export class DynamicWorkflowMissionControlComponent implements Component { if (member === undefined || isTerminalPhase(member.phase) || input.delta.length === 0) return; this.markStarted(input.agentId); const recordActivity = input.delta.includes('\n') || member.latest.length === 0; - // Text after a tool call counts as finalizing; earlier text is mid-work - // output. Each delta creeps toward the stage ceiling — with a minimum - // step so long streams keep visibly moving — without claiming completion. - const percent = member.progressPercent; - const { - toolActivityProgress, - finalizingCreepCeiling, - modelActivityProgress, - midworkCreepCeiling, - progressCreepRate, - progressCreepMinStep, - } = DYNAMIC_WORKFLOW_RENDERING; - const creepToward = (ceiling: number): number => Math.min( - ceiling, - percent + Math.max(progressCreepMinStep, (ceiling - percent) * progressCreepRate), - ); - this.advanceMemberProgress( - member, - percent >= toolActivityProgress - ? creepToward(finalizingCreepCeiling) - : Math.max(modelActivityProgress, creepToward(midworkCreepCeiling)), - ); - const latest = latestNonEmptyLine(`${member.latest}${input.delta}`); + // Progress reflects the observed stage only. The protocol emits no per-task + // completion signal, so streamed text never advances past its stage floor — + // elapsed time and the latest line carry liveness instead. + this.advanceMemberProgress(member, DYNAMIC_WORKFLOW_RENDERING.modelActivityProgress); + const carried = member.latestFromTool === true ? '' : member.latest; + const latest = latestNonEmptyLine(`${carried}${input.delta}`); + member.latestFromTool = false; this.setLatest(member, latest, recordActivity); } @@ -711,20 +706,51 @@ export class DynamicWorkflowMissionControlComponent implements Component { /** Item list from the completed tool-call `items` argument. */ export function dynamicWorkflowItemsFromArgs(args: Record): string[] { const items = args['items']; - return Array.isArray(items) ? items.map(String) : []; + return Array.isArray(items) ? items.map(itemLabel) : []; +} + +/** + * The schema requires plain strings, but a model may still emit objects. Render + * a readable field instead of `[object Object]`; the tool call fails validation + * either way. + */ +function itemLabel(item: unknown): string { + if (typeof item === 'string') return item; + if (typeof item !== 'object' || item === null) return String(item); + const record = item as Record; + for (const key of ['prompt', 'description', 'title', 'task']) { + const value = record[key]; + if (typeof value === 'string' && value.length > 0) return value; + } + return ''; } -/** Best-effort `items` read from a partially streamed JSON arguments string. */ +/** + * Best-effort `items` read from a partially streamed JSON arguments string. + * Only top-level array members count: strings nested inside an object or array + * member (and object keys) are skipped, not counted as items. + */ export function dynamicWorkflowPartialItemsFromArguments(argumentsText: string): string[] { const match = /"items"\s*:\s*\[/.exec(argumentsText); if (match === null) return []; const items: string[] = []; + let depth = 0; for (let index = match.index + match[0].length; index < argumentsText.length; index += 1) { const character = argumentsText[index]; - if (character === ']') return items; + if (character === '{' || character === '[') { + // A nested member still occupies one item slot. + if (depth === 0) items.push(''); + depth += 1; + continue; + } + if (character === '}' || character === ']') { + if (depth === 0) return items; + depth -= 1; + continue; + } if (character !== '"') continue; const parsed = parsePartialJsonString(argumentsText, index + 1); - items.push(parsed.value); + if (depth === 0) items.push(parsed.value); if (!parsed.closed) return items; index = parsed.nextIndex; } diff --git a/apps/pythinker-code/src/tui/constant/rendering.ts b/apps/pythinker-code/src/tui/constant/rendering.ts index cee41e27..7798a817 100644 --- a/apps/pythinker-code/src/tui/constant/rendering.ts +++ b/apps/pythinker-code/src/tui/constant/rendering.ts @@ -31,14 +31,6 @@ export const DYNAMIC_WORKFLOW_RENDERING = { startedProgress: 20, modelActivityProgress: 50, toolActivityProgress: 75, - // Each streamed model delta creeps progress toward a ceiling instead of - // pinning it: p += max(minStep, (ceiling - p) * rate), clamped to the - // ceiling. The minimum step keeps the tail visibly moving instead of - // asymptoting into a stall. Still event-driven, never a timer. - progressCreepRate: 0.03, - progressCreepMinStep: 0.15, - midworkCreepCeiling: 74, - finalizingCreepCeiling: 99, // Two 2×4 Braille cells form a compact 4×4 dotted cube that fills bottom-up. cubeFillLevels: [' ', '⡀', '⣀', '⣄', '⣤', '⣦', '⣶', '⣷', '⣿'], } as const; diff --git a/apps/pythinker-code/test/tui/components/messages/dynamic-workflow-mission-control.test.ts b/apps/pythinker-code/test/tui/components/messages/dynamic-workflow-mission-control.test.ts index c559597d..b1428cbd 100644 --- a/apps/pythinker-code/test/tui/components/messages/dynamic-workflow-mission-control.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/dynamic-workflow-mission-control.test.ts @@ -29,6 +29,12 @@ function memberLine(output: string, index: number): string { return line; } +function memberRowCount(output: string): number { + return output.split('\n').filter( + (candidate) => /^\d{3}\s/u.test(candidate.replace(/^│\s*/u, '')), + ).length; +} + function displayedPercent(output: string, index: number): number { const match = /(\d+)%/u.exec(memberLine(output, index)); if (match === null) throw new Error(`Missing percent for member ${String(index)}`); @@ -598,7 +604,7 @@ describe('DynamicWorkflowMissionControlComponent', () => { }, ); - it('creeps past 90 across streamed deltas and completes only on the terminal event', () => { + it('holds the observed stage across streamed deltas and completes only on the terminal event', () => { const component = createComponent(); component.updateArgs({ items: ['Long streaming work'] }); component.markInputComplete(); @@ -606,26 +612,18 @@ describe('DynamicWorkflowMissionControlComponent', () => { component.markStarted('agent-1'); component.recordToolCall({ agentId: 'agent-1', name: 'Read' }); - for (let index = 0; index < 10; index += 1) { - component.appendModelDelta({ agentId: 'agent-1', delta: `chunk ${String(index)} ` }); - } - const early = displayedPercent(renderText(component, 100), 1); - // No snap to 90: the finalizing phase climbs from 75 instead of jumping. - expect(early).toBeGreaterThan(75); - expect(early).toBeLessThan(90); - for (let index = 0; index < 200; index += 1) { - component.appendModelDelta({ agentId: 'agent-1', delta: 'more ' }); + component.appendModelDelta({ agentId: 'agent-1', delta: `chunk ${String(index)} ` }); } - const late = displayedPercent(renderText(component, 100), 1); - expect(late).toBeGreaterThan(90); - expect(late).toBeLessThan(100); + // No invented progress: text after a tool call never climbs toward 100. + expect(displayedPercent(renderText(component, 100), 1)) + .toBe(DYNAMIC_WORKFLOW_RENDERING.toolActivityProgress); component.markCompleted('agent-1', 'Done'); expect(displayedPercent(renderText(component, 100), 1)).toBe(100); }); - it('keeps mid-work delta creep under the tool-activity stage until a tool call lifts it', () => { + it('keeps streamed text at the model stage until a tool call lifts it', () => { const component = createComponent(); component.updateArgs({ items: ['Chatty work'] }); component.markInputComplete(); @@ -635,13 +633,50 @@ describe('DynamicWorkflowMissionControlComponent', () => { for (let index = 0; index < 300; index += 1) { component.appendModelDelta({ agentId: 'agent-1', delta: 'more ' }); } - const midwork = displayedPercent(renderText(component, 100), 1); - expect(midwork).toBeGreaterThan(50); - expect(midwork).toBeLessThan(DYNAMIC_WORKFLOW_RENDERING.toolActivityProgress); + expect(displayedPercent(renderText(component, 100), 1)) + .toBe(DYNAMIC_WORKFLOW_RENDERING.modelActivityProgress); component.recordToolCall({ agentId: 'agent-1', name: 'Read' }); expect(displayedPercent(renderText(component, 100), 1)) - .toBeGreaterThanOrEqual(DYNAMIC_WORKFLOW_RENDERING.toolActivityProgress); + .toBe(DYNAMIC_WORKFLOW_RENDERING.toolActivityProgress); + }); + + it('starts a new line for model text after a tool label instead of fusing them', () => { + const component = createComponent(); + component.updateArgs({ items: ['Work'] }); + component.markInputComplete(); + register(component, 'agent-1'); + component.markStarted('agent-1'); + component.recordToolCall({ agentId: 'agent-1', name: 'Read' }); + component.appendModelDelta({ agentId: 'agent-1', delta: "I've read the files" }); + + const line = memberLine(renderText(component, 200), 1); + expect(line).not.toContain("Using ReadI've"); + expect(line).toContain("I've read the files"); + }); + + it('renders object items by their prompt field and drops streamed phantom rows', () => { + const component = createComponent(); + const streamingArguments = + '{"items": [{"prompt": "Explore records", "description": "Records"},' + + ' {"prompt": "Explore events", "description": "Events"}'; + component.updateArgs({}, { streamingArguments }); + // Object keys and nested values are not items: two members, not eight. + expect(memberRowCount(renderText(component, 200))).toBe(2); + + component.updateArgs({ + items: [ + { prompt: 'Explore records', description: 'Records' }, + { prompt: 'Explore events', description: 'Events' }, + ], + }); + component.markInputComplete(); + + const output = renderText(component, 200); + expect(memberRowCount(output)).toBe(2); + expect(memberLine(output, 1)).toContain('Explore records'); + expect(memberLine(output, 1)).not.toContain('[object Object]'); + expect(memberLine(output, 2)).toContain('Explore events'); }); it('shimmers Finalizing once every member is terminal but the result has not arrived', () => { From aced39a270c8c690e3560422ee262a853023ce37 Mon Sep 17 00:00:00 2001 From: elkaix Date: Wed, 5 Aug 2026 18:19:23 -0400 Subject: [PATCH 2/2] fix(tui): add unicode flag to workflow items regex --- .../tui/components/messages/dynamic-workflow-mission-control.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts b/apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts index 5ecc1edf..d7b8f69a 100644 --- a/apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts +++ b/apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts @@ -731,7 +731,7 @@ function itemLabel(item: unknown): string { * member (and object keys) are skipped, not counted as items. */ export function dynamicWorkflowPartialItemsFromArguments(argumentsText: string): string[] { - const match = /"items"\s*:\s*\[/.exec(argumentsText); + const match = /"items"\s*:\s*\[/u.exec(argumentsText); if (match === null) return []; const items: string[] = []; let depth = 0;