Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/dynamic-workflow-garbled-rows.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/dynamic-workflow-stage-progress.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@
item: string;
phase: DynamicWorkflowPhase;
latest: string;
/** `latest` holds a tool-activity label, not streamed model text. */
latestFromTool?: boolean;
statusDetail?: string;
startedAtMs?: number;
endedAtMs?: number;
Expand Down Expand Up @@ -201,6 +203,13 @@
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';
}
Expand Down Expand Up @@ -249,36 +258,22 @@
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 {
const member = this.findMemberByAgentId(input.agentId);
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);
}

Expand Down Expand Up @@ -711,20 +706,51 @@
/** Item list from the completed tool-call `items` argument. */
export function dynamicWorkflowItemsFromArgs(args: Record<string, unknown>): 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<string, unknown>;
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);
const match = /"items"\s*:\s*\[/u.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;
}
Expand All @@ -744,7 +770,7 @@

/** Best-effort `description` read from a partially streamed JSON arguments string. */
export function dynamicWorkflowPartialDescriptionFromArguments(argumentsText: string): string {
const match = /"description"\s*:\s*"/.exec(argumentsText);

Check warning on line 773 in apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts

View workflow job for this annotation

GitHub Actions / lint

eslint(require-unicode-regexp)

Use the 'u' flag.
if (match === null) return '';
return parsePartialJsonString(argumentsText, match.index + match[0].length).value;
}
Expand Down Expand Up @@ -793,7 +819,7 @@
}

function dynamicWorkflowPartialResumeItemsFromArguments(argumentsText: string): string[] {
const match = /"resume_agent_ids"\s*:\s*\{/.exec(argumentsText);

Check warning on line 822 in apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts

View workflow job for this annotation

GitHub Actions / lint

eslint(require-unicode-regexp)

Use the 'u' flag.
if (match === null) return [];
return Array.from(
{ length: countPartialJsonObjectEntries(argumentsText, match.index + match[0].length) },
Expand All @@ -807,7 +833,7 @@
}

function dynamicWorkflowPartialPromptTemplateFromArguments(argumentsText: string): string {
const match = /"prompt_template"\s*:\s*"/.exec(argumentsText);

Check warning on line 836 in apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts

View workflow job for this annotation

GitHub Actions / lint

eslint(require-unicode-regexp)

Use the 'u' flag.
if (match === null) return '';
return parsePartialJsonString(argumentsText, match.index + match[0].length).value;
}
Expand All @@ -819,7 +845,7 @@
// Indexes are validated and deduplicated: an explicit index is honored only
// once and within range; a duplicated one is dropped, not remapped.
const usedIndexes = new Set<number>();
const tagPattern = /<subagent\b([^>]*)>/g;

Check warning on line 848 in apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts

View workflow job for this annotation

GitHub Actions / lint

eslint(require-unicode-regexp)

Use the 'u' flag.
let match: RegExpExecArray | null;
while (
statuses.length < MAX_DYNAMIC_WORKFLOW_MEMBERS &&
Expand Down Expand Up @@ -875,9 +901,9 @@

function dynamicWorkflowResultEnvelope(output: string): string | undefined {
let candidate = output.trim();
const prefix = /^dynamic_workflow:\s*(?:(?:completed|failed|cancelled|aborted)\s*)?/i.exec(candidate);

Check warning on line 904 in apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts

View workflow job for this annotation

GitHub Actions / lint

eslint(require-unicode-regexp)

Use the 'u' flag.
if (prefix !== null) candidate = candidate.slice(prefix[0].length).trimStart();
const opening = /^<dynamic_workflow_result\b[^>]*>/.exec(candidate);

Check warning on line 906 in apps/pythinker-code/src/tui/components/messages/dynamic-workflow-mission-control.ts

View workflow job for this annotation

GitHub Actions / lint

eslint(require-unicode-regexp)

Use the 'u' flag.
if (opening === null) return undefined;
const close = candidate.indexOf('</dynamic_workflow_result>', opening[0].length);
if (close < 0) return undefined;
Expand Down
8 changes: 0 additions & 8 deletions apps/pythinker-code/src/tui/constant/rendering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`);
Expand Down Expand Up @@ -598,34 +604,26 @@ 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();
register(component, 'agent-1');
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();
Expand All @@ -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', () => {
Expand Down
Loading