From eee380077aa6d78815a35d20f259b183c5541b3d Mon Sep 17 00:00:00 2001 From: elkaix Date: Fri, 7 Aug 2026 19:13:45 -0400 Subject: [PATCH 01/10] fix(tui): keep the Dynamic Workflow task and activity lines readable The member row measured the streamed detail at full length before the task, so a finished agent's summary left the task one character wide and every row read as ".". Budget the row instead: the task keeps a floor and a share, the detail takes what is left. Streamed deltas carried a closed line into the next delta, so one message fused into a single string that grew for as long as the agent talked and filled all three activity slots with the same prefix. Track the pending line apart from the displayed one and cap it. --- .changeset/dynamic-workflow-activity-lines.md | 5 ++ .changeset/dynamic-workflow-task-column.md | 5 ++ .../dynamic-workflow-mission-control.ts | 81 +++++++++++++++---- .../src/tui/constant/rendering.ts | 12 +++ .../dynamic-workflow-mission-control.test.ts | 43 ++++++++++ 5 files changed, 129 insertions(+), 17 deletions(-) create mode 100644 .changeset/dynamic-workflow-activity-lines.md create mode 100644 .changeset/dynamic-workflow-task-column.md diff --git a/.changeset/dynamic-workflow-activity-lines.md b/.changeset/dynamic-workflow-activity-lines.md new file mode 100644 index 00000000..a82a8240 --- /dev/null +++ b/.changeset/dynamic-workflow-activity-lines.md @@ -0,0 +1,5 @@ +--- +"@pythoughts/pythinker-code": patch +--- + +Fix Dynamic Workflow recent activity showing one growing line three times instead of the last three lines an agent wrote. diff --git a/.changeset/dynamic-workflow-task-column.md b/.changeset/dynamic-workflow-task-column.md new file mode 100644 index 00000000..b769267f --- /dev/null +++ b/.changeset/dynamic-workflow-task-column.md @@ -0,0 +1,5 @@ +--- +"@pythoughts/pythinker-code": patch +--- + +Fix the Dynamic Workflow card clipping an agent's task down to one character once that agent returned a long summary. 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 e64e0015..5a837fed 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 @@ -9,6 +9,8 @@ import { currentTheme } from '#/tui/theme'; import { shimmerText } from '#/tui/utils/shimmer'; const RESUMED_ITEM_LABEL = '(resumed)'; +/** Divider between the cells that share a member row's free space. */ +const MEMBER_SEPARATOR = ' · '; const ORCHESTRATING_LABEL = 'Orchestrating'; const FINALIZING_LABEL = 'Finalizing'; // Pad to the wider live label so the suffix column never shifts between them. @@ -42,8 +44,12 @@ export interface DynamicWorkflowMember { item: string; phase: DynamicWorkflowPhase; latest: string; - /** `latest` holds a tool-activity label, not streamed model text. */ - latestFromTool?: boolean; + /** + * The part of the streamed line that has not been closed by a newline yet. + * Held apart from `latest` because `latest` may be a finished line or a tool + * label, and neither may be prepended to the next delta. + */ + carry: string; statusDetail?: string; startedAtMs?: number; endedAtMs?: number; @@ -266,7 +272,7 @@ export class DynamicWorkflowMissionControlComponent implements Component { 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; + member.carry = ''; } appendModelDelta(input: { readonly agentId: string; readonly delta: string }): void { @@ -275,10 +281,15 @@ export class DynamicWorkflowMissionControlComponent implements Component { this.markStarted(input.agentId); const recordActivity = input.delta.includes('\n') || member.latest.length === 0; member.lastEventAtMs = Date.now(); - const carried = member.latestFromTool === true ? '' : member.latest; - const latest = latestNonEmptyLine(`${carried}${input.delta}`); - member.latestFromTool = false; - this.setLatest(member, latest, recordActivity); + const combined = `${member.carry}${input.delta}`; + // Only the text after the last newline is still being written. A delta that + // ends exactly at a newline leaves nothing pending, so carrying the closed + // line into the next delta fused a whole streamed message into one string + // that grew for as long as the agent talked. + const newlineIndex = combined.lastIndexOf('\n'); + const pending = newlineIndex < 0 ? combined : combined.slice(newlineIndex + 1); + member.carry = clampLine(pending); + this.setLatest(member, clampLine(latestNonEmptyLine(combined)), recordActivity); } markSuspended(input: { @@ -581,16 +592,42 @@ export class DynamicWorkflowMissionControlComponent implements Component { const elapsed = member.startedAtMs === undefined ? undefined : `${String(elapsedSeconds(member.startedAtMs, member.endedAtMs ?? nowMs))}s`; - const showDetail = showWork && detail !== undefined && detail.length > 0; - const showElapsed = showWork && elapsed !== undefined; - const tail = [ - showDetail ? currentTheme.fg('textDim', detail) : '', - showElapsed ? currentTheme.fg('textMuted', elapsed) : '', - ].filter((part) => part.length > 0).join(' · '); - const separator = tail.length > 0 ? ' · ' : ''; - const taskWidth = Math.max(1, width - visibleWidth(prefix) - visibleWidth(separator) - visibleWidth(tail)); - const taskText = truncateToWidth(currentTheme.fg('text', task), taskWidth); - return truncateToWidth(`${prefix}${taskText}${separator}${tail}`, width); + const free = Math.max(1, width - visibleWidth(prefix)); + + // The elapsed cell is short and fixed, so it is reserved first — but only + // while the task still keeps its floor. + const elapsedPart = showWork && elapsed !== undefined + ? `${MEMBER_SEPARATOR}${currentTheme.fg('textMuted', elapsed)}` + : ''; + const elapsedWidth = visibleWidth(elapsedPart); + const keepsElapsed = elapsedPart.length > 0 && + free - elapsedWidth >= DYNAMIC_WORKFLOW_RENDERING.memberTaskMinWidth; + const rest = free - (keepsElapsed ? elapsedWidth : 0); + + // The task names the row, so it is measured before the detail rather than + // with whatever the detail leaves over: a finished agent returns its whole + // summary as the detail, which used to collapse the task to one character. + // The share keeps a short task from starving the detail in turn. + const taskCap = Math.max( + DYNAMIC_WORKFLOW_RENDERING.memberTaskMinWidth, + Math.floor(rest * DYNAMIC_WORKFLOW_RENDERING.memberTaskShare), + ); + const detailBudget = showWork && detail !== undefined && detail.length > 0 + ? rest - Math.min(visibleWidth(task), taskCap) - MEMBER_SEPARATOR.length + : 0; + const detailPart = detailBudget >= DYNAMIC_WORKFLOW_RENDERING.memberDetailMinWidth + ? `${MEMBER_SEPARATOR}${truncateToWidth(currentTheme.fg('textDim', detail ?? ''), detailBudget)}` + : ''; + + // Whatever the detail did not take goes back to the task. + const taskText = truncateToWidth( + currentTheme.fg('text', task), + Math.max(1, rest - visibleWidth(detailPart)), + ); + return truncateToWidth( + `${prefix}${taskText}${detailPart}${keepsElapsed ? elapsedPart : ''}`, + width, + ); } private renderActivity(entry: DynamicWorkflowActivity, width: number): string { @@ -655,6 +692,7 @@ export class DynamicWorkflowMissionControlComponent implements Component { item: '', phase: this.model.inputComplete ? 'queued' : 'pending', latest: '', + carry: '', toolCalls: 0, lastEventAtMs: Date.now(), }); @@ -1022,6 +1060,15 @@ function latestNonEmptyLine(text: string): string { return ''; } +/** + * Keeps the head of one streamed line. The row shows the head and clips the + * rest, so dropping the tail is invisible — and it is the only bound on a line + * the model never closes with a newline. + */ +function clampLine(text: string): string { + return text.slice(0, DYNAMIC_WORKFLOW_RENDERING.memberLatestMaxChars); +} + function normalizeText(text: string | undefined): string { return text?.replaceAll(/\s+/g, ' ').trim() ?? ''; } diff --git a/apps/pythinker-code/src/tui/constant/rendering.ts b/apps/pythinker-code/src/tui/constant/rendering.ts index e42fda80..add689da 100644 --- a/apps/pythinker-code/src/tui/constant/rendering.ts +++ b/apps/pythinker-code/src/tui/constant/rendering.ts @@ -28,6 +28,18 @@ export const DYNAMIC_WORKFLOW_RENDERING = { frameHorizontalInset: 4, memberProgressMinWidth: 60, memberProgressWidth: 9, + /** Least room the task keeps before the detail may claim any of the row. */ + memberTaskMinWidth: 12, + /** Share of the free row the task may take before the detail gets the rest. */ + memberTaskShare: 0.6, + /** Below this the detail is dropped: a few clipped characters say nothing. */ + memberDetailMinWidth: 8, + /** + * Upper bound on one buffered output line. A model may stream a single line + * with no newline in it at all, so this is the only thing that stops the + * buffered text from growing for as long as the agent runs. + */ + memberLatestMaxChars: 512, /** Idle age at which a row's silence is worth noticing. */ quietIdleMs: 60_000, /** Idle age at which a row has almost certainly stalled. */ 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 cb88c423..86d393d7 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 @@ -856,6 +856,49 @@ describe('DynamicWorkflowMissionControlComponent', () => { expect(line).toContain("I've read the files"); }); + it.each([64, 70, 80, 100, 200])( + 'keeps the task readable beside a long agent summary at width %i', + (width) => { + const component = createComponent(); + component.updateArgs({ items: ['Cluster B: verify the plan appendix'] }); + component.markInputComplete(); + register(component, 'agent-1'); + component.markStarted('agent-1'); + component.markCompleted( + 'agent-1', + 'Verification complete. All six Phase-1 items checked against the current tree. '.repeat(4), + ); + + // The task names the row. A long summary may be clipped; the identity may + // not — it used to collapse to a single character once a detail arrived. + const rendered = component.render(width); + expect(rendered.every((line) => visibleWidth(line) <= width)).toBe(true); + const line = memberLine(strip(rendered.join('\n')), 1); + expect(line).toContain('Cluster B: v'); + expect(line).toContain('Verific'); + expect(line).toMatch(/\b0s\s*│?\s*$/u); + }, + ); + + it('closes a streamed line at its newline instead of fusing the whole message', () => { + const component = createComponent(); + component.updateArgs({ items: ['Stream a report'] }); + component.markInputComplete(); + register(component, 'agent-1'); + component.markStarted('agent-1'); + for (const delta of ['First line\n', 'Second line\n', 'Third line\n']) { + component.appendModelDelta({ agentId: 'agent-1', delta }); + } + + const output = renderText(component, 200); + expect(output).not.toContain('First lineSecond line'); + expect(memberLine(output, 1)).toContain('Third line'); + expect(memberLine(output, 1)).not.toContain('First line'); + // Each closed line is its own activity entry, not three copies of one prefix. + expect(output).toContain('First line'); + expect(output).toContain('Second line'); + }); + it('renders object items by their prompt field and drops streamed phantom rows', () => { const component = createComponent(); const streamingArguments = From 535966541c7f0d0bd49e98e35b02febe079647f4 Mon Sep 17 00:00:00 2001 From: elkaix Date: Fri, 7 Aug 2026 20:14:55 -0400 Subject: [PATCH 02/10] fix(workflow): close the plan-preview, model-rule, and saved-workflow gaps Saving a workflow wrote a SKILL.md into a root the open session had already scanned. The registry is built once at construction, so the file was invisible and / stayed a plain message until the session reloaded. Session.reloadSkills re-discovers it, and the two half-refresh methods in the TUI collapse into one that reloads before it rebuilds. A permission rule naming a model parsed and then never fired: the Agent matcher globbed it against the profile name. Rule subjects now carry the model a call explicitly asks for, namespaced so an existing profile rule cannot start matching a same-named model. Auto mode approved the DynamicWorkflow call itself, so the plan preview never rendered for the mode the start prompt offers by default. Auto now asks once per distinct plan; a session grant or an allow rule falls through, and yolo is unchanged. Corrects the tool and config reference, which still described a progress cube the TUI no longer draws, claimed argument patterns were unsupported, and omitted disable_workflows and workflow_size_guideline. --- .changeset/model-permission-rules.md | 5 ++ .changeset/plan-preview-in-auto-mode.md | 5 ++ .changeset/saved-workflow-invocable.md | 5 ++ .../pythinker-code/src/tui/commands/config.ts | 2 +- .../src/tui/commands/dispatch.ts | 2 +- .../src/tui/commands/dynamic-workflow.ts | 7 +- .../pythinker-code/src/tui/commands/reload.ts | 2 +- .../dynamic-workflow-mission-control.ts | 2 - apps/pythinker-code/src/tui/pythinker-tui.ts | 27 ++++--- .../tui/commands/dynamic-workflow.test.ts | 18 +++-- .../test/tui/commands/experiments.test.ts | 6 +- .../test/tui/commands/reload.test.ts | 6 +- docs/configuration/config-files.md | 4 +- docs/reference/tools.md | 6 +- .../policies/dynamic-workflow-plan-ask.ts | 44 ++++++++++ .../src/agent/permission/policies/index.ts | 4 + packages/agent-core/src/rpc/core-api.ts | 2 + packages/agent-core/src/rpc/core-impl.ts | 7 ++ packages/agent-core/src/session/index.ts | 15 ++++ packages/agent-core/src/session/rpc.ts | 4 + .../src/tools/builtin/collaboration/agent.ts | 9 ++- .../builtin/collaboration/dynamic-workflow.ts | 12 ++- .../src/tools/support/rule-match.ts | 24 +++++- .../agent-core/test/agent/permission.test.ts | 80 ++++++++++++++++--- packages/agent-core/test/session/init.test.ts | 34 ++++++++ packages/agent-core/test/tools/agent.test.ts | 23 ++++++ .../test/tools/builtin-current.test.ts | 12 +++ packages/node-sdk/src/rpc.ts | 5 ++ packages/node-sdk/src/session.ts | 6 ++ 29 files changed, 327 insertions(+), 51 deletions(-) create mode 100644 .changeset/model-permission-rules.md create mode 100644 .changeset/plan-preview-in-auto-mode.md create mode 100644 .changeset/saved-workflow-invocable.md create mode 100644 packages/agent-core/src/agent/permission/policies/dynamic-workflow-plan-ask.ts diff --git a/.changeset/model-permission-rules.md b/.changeset/model-permission-rules.md new file mode 100644 index 00000000..ecb160da --- /dev/null +++ b/.changeset/model-permission-rules.md @@ -0,0 +1,5 @@ +--- +"@pythoughts/pythinker-code": minor +--- + +Let permission rules gate the model a subagent runs on, so `Agent(model:some-model)` and `DynamicWorkflow(model:some-model)` now match instead of being silently ignored. diff --git a/.changeset/plan-preview-in-auto-mode.md b/.changeset/plan-preview-in-auto-mode.md new file mode 100644 index 00000000..b78dd7eb --- /dev/null +++ b/.changeset/plan-preview-in-auto-mode.md @@ -0,0 +1,5 @@ +--- +"@pythoughts/pythinker-code": minor +--- + +Show the Dynamic Workflow plan before the run in `auto` permission mode, which previously approved the call without displaying it. The approval is asked once per distinct plan; `yolo` still approves without asking. diff --git a/.changeset/saved-workflow-invocable.md b/.changeset/saved-workflow-invocable.md new file mode 100644 index 00000000..0f801550 --- /dev/null +++ b/.changeset/saved-workflow-invocable.md @@ -0,0 +1,5 @@ +--- +"@pythoughts/pythinker-code": patch +--- + +Fix `/workflow save` leaving the saved workflow uncallable until the session was reloaded. diff --git a/apps/pythinker-code/src/tui/commands/config.ts b/apps/pythinker-code/src/tui/commands/config.ts index bff3709d..07c925bd 100644 --- a/apps/pythinker-code/src/tui/commands/config.ts +++ b/apps/pythinker-code/src/tui/commands/config.ts @@ -1198,7 +1198,7 @@ export async function applyExperimentalFeatureChanges( await host.harness.setConfig({ experimental }); const features = await host.harness.getExperimentalFeatures(); setExperimentalFeatures(features); - host.refreshSlashCommandAutocomplete(); + await host.refreshSkillCommands(host.session); host.restoreEditor(); if (host.session !== undefined) { await host.session.reloadSession(); diff --git a/apps/pythinker-code/src/tui/commands/dispatch.ts b/apps/pythinker-code/src/tui/commands/dispatch.ts index fc2d93e1..aacef9ef 100644 --- a/apps/pythinker-code/src/tui/commands/dispatch.ts +++ b/apps/pythinker-code/src/tui/commands/dispatch.ts @@ -161,7 +161,7 @@ export interface SlashCommandHost { mountEditorReplacement(panel: Component & Focusable): void; restoreEditor(): void; restoreInputText(text: string): void; - refreshSlashCommandAutocomplete(): void; + refreshSkillCommands(session?: Session): Promise; reloadKeybindings?(): readonly string[]; setExternalEditorRunning?(running: boolean): void; diff --git a/apps/pythinker-code/src/tui/commands/dynamic-workflow.ts b/apps/pythinker-code/src/tui/commands/dynamic-workflow.ts index e2bb86f2..cf4b0d3f 100644 --- a/apps/pythinker-code/src/tui/commands/dynamic-workflow.ts +++ b/apps/pythinker-code/src/tui/commands/dynamic-workflow.ts @@ -163,7 +163,12 @@ async function handleSaveSubcommand(host: SlashCommandHost, input: string): Prom outputSchema: recordArg(args, 'output_schema'), }, }); - host.refreshSlashCommandAutocomplete(); + // The skill registry is built once when the session opens, so the file just + // written is invisible to it. Re-discover before rebuilding the command set, + // or `/` stays a plain message until the session is reloaded. + const session = host.session; + if (session !== undefined) await session.reloadSkills(); + await host.refreshSkillCommands(session); host.showStatus(`Saved /${savedWorkflowSkillName(name)} to ${dir}.`); } catch (error) { host.showError(`Failed to save workflow: ${formatErrorMessage(error)}`); diff --git a/apps/pythinker-code/src/tui/commands/reload.ts b/apps/pythinker-code/src/tui/commands/reload.ts index 44d6f8aa..9cad0a16 100644 --- a/apps/pythinker-code/src/tui/commands/reload.ts +++ b/apps/pythinker-code/src/tui/commands/reload.ts @@ -26,7 +26,7 @@ export async function handleReloadCommand(host: SlashCommandHost): Promise const config = await host.harness.getConfig({ reload: true }); setExperimentalFeatures(await host.harness.getExperimentalFeatures()); - host.refreshSlashCommandAutocomplete(); + await host.refreshSkillCommands(session); applyRuntimeConfig(host, config); await applyReloadedTuiConfig(host, tuiConfig); host.reloadKeybindings?.(); 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 5a837fed..c8a209ec 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 @@ -1005,8 +1005,6 @@ function decodeXmlEntities(value: string): string { ); } -/** Maps a percent to one of the dotted cube levels; the cube fills bottom-up. */ - function requestPhaseLabel(phase: DynamicWorkflowRequestPhase): string { const labels: Record = { collecting: ORCHESTRATING_LABEL, diff --git a/apps/pythinker-code/src/tui/pythinker-tui.ts b/apps/pythinker-code/src/tui/pythinker-tui.ts index 00e4dadc..cd968b87 100644 --- a/apps/pythinker-code/src/tui/pythinker-tui.ts +++ b/apps/pythinker-code/src/tui/pythinker-tui.ts @@ -431,10 +431,12 @@ export class PythinkerTUI { this.state.editor.setAutocompleteProvider(provider); } - refreshSlashCommandAutocomplete(): void { - this.setupAutocomplete(); - } - + /** + * The one way to refresh the slash-command set. Every caller that can change + * it — session switch, login/logout, experimental flags, `/reload`, saving a + * workflow — goes through here, so autocomplete and `skillCommandMap` are + * never rebuilt from a skill list that has moved on. + */ async refreshSkillCommands(session?: SkillListSession): Promise { if (session === undefined) { this.skillCommands = []; @@ -443,17 +445,16 @@ export class PythinkerTUI { return; } - let skills; try { - skills = await session.listSkills(); + const skillCommands = buildSkillSlashCommands(await session.listSkills()); + this.skillCommands = skillCommands.commands; + this.skillCommandMap.clear(); + for (const [commandName, skillName] of skillCommands.commandMap) { + this.skillCommandMap.set(commandName, skillName); + } } catch { - return; - } - const skillCommands = buildSkillSlashCommands(skills); - this.skillCommands = skillCommands.commands; - this.skillCommandMap.clear(); - for (const [commandName, skillName] of skillCommands.commandMap) { - this.skillCommandMap.set(commandName, skillName); + // Keep the skills already known. The builtin command set may still have + // changed, so the autocomplete provider is rebuilt either way. } this.setupAutocomplete(); } diff --git a/apps/pythinker-code/test/tui/commands/dynamic-workflow.test.ts b/apps/pythinker-code/test/tui/commands/dynamic-workflow.test.ts index 61e779c9..515cab50 100644 --- a/apps/pythinker-code/test/tui/commands/dynamic-workflow.test.ts +++ b/apps/pythinker-code/test/tui/commands/dynamic-workflow.test.ts @@ -35,6 +35,7 @@ function makeHost( const session = { setPermission: vi.fn(async () => {}), setDynamicWorkflowMode: vi.fn(async () => {}), + reloadSkills: vi.fn(async () => {}), }; const hasSession = overrides.hasSession ?? true; const host = { @@ -62,7 +63,7 @@ function makeHost( restoreEditor: vi.fn(), restoreInputText: vi.fn(), sendNormalUserInput: vi.fn(), - refreshSlashCommandAutocomplete: vi.fn(), + refreshSkillCommands: vi.fn(async () => {}), } as unknown as SlashCommandHost; return { host, session }; } @@ -424,7 +425,7 @@ describe('/workflow save', () => { it('writes the last run as a skill and refreshes the command list', async () => { const workDir = await fs.mkdtemp(join(tmpdir(), 'workflow-save-')); try { - const { host } = makeHost({ + const { host, session } = makeHost({ permissionMode: 'auto', workDir, lastDynamicWorkflowArgs: { @@ -446,7 +447,13 @@ describe('/workflow save', () => { expect(saved).toContain('description: "Audit routes for missing auth"'); expect(saved).toContain('subagent-type: "reviewer"'); expect(saved).toContain('Audit {{item}}'); - expect(host.refreshSlashCommandAutocomplete).toHaveBeenCalled(); + // Re-discovery must happen before the command set is rebuilt, or the + // freshly written skill is rebuilt from a registry that never saw it. + expect(session.reloadSkills).toHaveBeenCalledOnce(); + expect(host.refreshSkillCommands).toHaveBeenCalledWith(session); + expect(session.reloadSkills.mock.invocationCallOrder[0]).toBeLessThan( + (host.refreshSkillCommands as ReturnType).mock.invocationCallOrder[0] ?? 0, + ); expect(host.showError).not.toHaveBeenCalled(); } finally { await fs.rm(workDir, { recursive: true, force: true }); @@ -456,7 +463,7 @@ describe('/workflow save', () => { it('refuses a name that would escape the project skills directory', async () => { const workDir = await fs.mkdtemp(join(tmpdir(), 'workflow-save-')); try { - const { host } = makeHost({ + const { host, session } = makeHost({ permissionMode: 'auto', workDir, lastDynamicWorkflowArgs: { description: 'Audit routes' }, @@ -467,7 +474,8 @@ describe('/workflow save', () => { expect(host.showError).toHaveBeenCalledWith( expect.stringContaining('not a valid skill name'), ); - expect(host.refreshSlashCommandAutocomplete).not.toHaveBeenCalled(); + expect(host.refreshSkillCommands).not.toHaveBeenCalled(); + expect(session.reloadSkills).not.toHaveBeenCalled(); await expect(fs.stat(join(workDir, '.pythinker-code'))).rejects.toThrow(/ENOENT/u); } finally { await fs.rm(workDir, { recursive: true, force: true }); diff --git a/apps/pythinker-code/test/tui/commands/experiments.test.ts b/apps/pythinker-code/test/tui/commands/experiments.test.ts index 8e526448..32b44ee3 100644 --- a/apps/pythinker-code/test/tui/commands/experiments.test.ts +++ b/apps/pythinker-code/test/tui/commands/experiments.test.ts @@ -44,7 +44,7 @@ function makeHost() { ]), }, session, - refreshSlashCommandAutocomplete: vi.fn(), + refreshSkillCommands: vi.fn(async () => {}), reloadCurrentSessionView: vi.fn(async () => {}), mountEditorReplacement: vi.fn(), restoreEditor: vi.fn(), @@ -56,7 +56,7 @@ function makeHost() { setConfig: ReturnType; getExperimentalFeatures: ReturnType; }; - refreshSlashCommandAutocomplete: ReturnType; + refreshSkillCommands: ReturnType; reloadCurrentSessionView: ReturnType; mountEditorReplacement: ReturnType; restoreEditor: ReturnType; @@ -85,7 +85,7 @@ describe('experimental feature command handlers', () => { }); expect(host.harness.getExperimentalFeatures).toHaveBeenCalledOnce(); expect(isExperimentalFlagEnabled('micro_compaction')).toBe(false); - expect(host.refreshSlashCommandAutocomplete).toHaveBeenCalled(); + expect(host.refreshSkillCommands).toHaveBeenCalled(); expect(host.restoreEditor).toHaveBeenCalled(); expect(host.session.reloadSession).toHaveBeenCalledOnce(); expect(host.reloadCurrentSessionView).toHaveBeenCalledWith( diff --git a/apps/pythinker-code/test/tui/commands/reload.test.ts b/apps/pythinker-code/test/tui/commands/reload.test.ts index 411eca81..1fa853b9 100644 --- a/apps/pythinker-code/test/tui/commands/reload.test.ts +++ b/apps/pythinker-code/test/tui/commands/reload.test.ts @@ -99,7 +99,7 @@ show_elapsed = false ); expect(host.harness.getConfig).toHaveBeenCalledWith({ reload: true }); expect(host.harness.getExperimentalFeatures).toHaveBeenCalledOnce(); - expect(host.refreshSlashCommandAutocomplete).toHaveBeenCalledOnce(); + expect(host.refreshSkillCommands).toHaveBeenCalledOnce(); expect(host.reloadKeybindings).toHaveBeenCalledOnce(); expect(isExperimentalFlagEnabled('micro_compaction')).toBe(true); expect(host.state.appState.theme).toBe('light'); @@ -188,7 +188,7 @@ function makeHost({ state.appState.theme = theme; }), refreshTerminalThemeTracking: vi.fn(), - refreshSlashCommandAutocomplete: vi.fn(), + refreshSkillCommands: vi.fn(async () => {}), reloadKeybindings: vi.fn(() => []), reloadCurrentSessionView: vi.fn(async () => {}), showStatus: vi.fn(), @@ -197,7 +197,7 @@ function makeHost({ readonly getConfig: ReturnType; readonly getExperimentalFeatures: ReturnType; }; - readonly refreshSlashCommandAutocomplete: ReturnType; + readonly refreshSkillCommands: ReturnType; readonly reloadKeybindings: ReturnType; readonly reloadCurrentSessionView: ReturnType; readonly showStatus: ReturnType; diff --git a/docs/configuration/config-files.md b/docs/configuration/config-files.md index b2ebfd91..8df34b84 100644 --- a/docs/configuration/config-files.md +++ b/docs/configuration/config-files.md @@ -82,6 +82,8 @@ Fields in the config file fall into two categories: **top-level scalars** that d | `merge_all_available_skills` | `boolean` | `true` | Whether to merge Agent Skills from all available directories | | `extra_skill_dirs` | `array` | — | Extra skill search directories, layered on top of the default directories | | `telemetry` | `boolean` | `true` | Whether anonymous telemetry is enabled; disabled only when explicitly set to `false` | +| `disable_workflows` | `boolean` | `false` | Whether to remove the `DynamicWorkflow` tool and hide `/workflow`; the `PYTHINKER_CODE_DISABLE_WORKFLOWS` environment variable overrides it | +| `workflow_size_guideline` | `string` | `medium` | Advisory subagent-count target for one Dynamic Workflow; one of `small` (about 5), `medium` (about 15), `large` (about 40), or `unrestricted` (no target). Exceeding it emits a warning rather than blocking the run; the `PYTHINKER_CODE_WORKFLOW_SIZE_GUIDELINE` environment variable overrides it | | `providers` | `table` | `{}` | API provider table → [`providers`](#providers) | | `models` | `table` | — | Model alias table → [`models`](#models) | | `thinking` | `table` | — | Default parameters for Thinking mode → [`thinking`](#thinking) | @@ -222,7 +224,7 @@ api_key = "sk-xxx" | `pattern` | `string` | Yes | Match pattern in the form `ToolName` or `ToolName(arg-pattern)`, e.g. `Read` or `Bash(rm -rf*)` | | `reason` | `string` | No | Rule description for debugging and auditing | -Built-in tool names are listed in [Built-in tools](../reference/tools.md). Most built-in tools that accept rule arguments define their own matching subject, such as `Bash(command-pattern)` or `Read(path-pattern)`. `DynamicWorkflow`, MCP tools, and custom tools can only be matched by tool name — argument patterns are not supported for them. +Built-in tool names are listed in [Built-in tools](../reference/tools.md). Most built-in tools that accept rule arguments define their own matching subject, such as `Bash(command-pattern)` or `Read(path-pattern)`. `DynamicWorkflow` matches on the plan it is about to run, or on `model:` for the model a call asks its subagents to use. `Agent` matches on the subagent type, or on `model:` the same way. MCP tools and custom tools can only be matched by tool name. ```toml [[permission.rules]] diff --git a/docs/reference/tools.md b/docs/reference/tools.md index 96eb61f0..e052302c 100644 --- a/docs/reference/tools.md +++ b/docs/reference/tools.md @@ -85,15 +85,15 @@ Collaboration tools handle inter-Agent coordination, user interaction, and Skill | Tool | Default Approval | Description | | --- | --- | --- | | `Agent` | Auto-allow | Spawn a sub-Agent to execute a subtask | -| `DynamicWorkflow` | Auto-allow in Dynamic Workflow mode; otherwise requires approval | Launch item-based subagents or resume existing subagents | +| `DynamicWorkflow` | Requires approval, which shows the plan; auto-allowed in `yolo` | Launch item-based subagents or resume existing subagents | | `AskUserQuestion` | Auto-allow | Ask the user a question to gather structured input | | `Skill` | Auto-allow | Invoke a registered inline Skill | **`Agent`** delegates a subtask to a sub-Agent. Required parameters: `prompt` (complete task description) and `description` (a 3–5 word short summary). Optional parameters: `subagent_type` (defaults to `coder`), `resume` (ID of an existing Agent to resume; mutually exclusive with `subagent_type`), and `run_in_background` (defaults to false). Agent tasks have a fixed 30-minute timeout. In foreground mode the parent Agent waits for the sub-Agent to complete before continuing; in background mode a task ID is returned immediately and the result is automatically delivered back to the main Agent via a synthetic User message when done. When several foreground `Agent` calls run in the same step, the TUI groups them and shows each subagent's running, waiting, completed, or failed status with elapsed time. See [Agent & Sub-Agents](../customization/agents.md) for details. -**`DynamicWorkflow`** launches several independent subagents in parallel, resumes existing subagents through `resume_agent_ids`, or combines both in one call. It always requires `description`, a short summary of the whole workflow. Each entry in `items` launches one new subagent: without `prompt_template`, every entry is a complete prompt on its own; with `prompt_template`, the template must contain the `{{item}}` placeholder and each entry replaces it. Item prompts must be distinct — duplicates are rejected. Pass `subagent_type` to choose the profile used by every spawned subagent, or omit it to use `coder`. Pass `model` and `effort` to run this workflow's subagents on a different model than the agent orchestrating them — a cheaper or faster model for mechanical work, for example; both apply to every subagent in the call, and omitting them falls back to the subagent profile's own settings and then to the calling agent's. A `model` the provider cannot resolve falls back to the calling agent's model rather than failing the run. Without `resume_agent_ids`, the tool requires at least 2 items; with `resume_agent_ids`, it can resume one or more existing subagents. The tool supports up to 128 total subagents, waits for all of them to finish, and returns an aggregated report. Workflow subagents have no automatic timeout; they run until completion, failure, or user cancellation. If a model response calls `DynamicWorkflow`, that call must be the only tool call in the response; to run several workflows, call one `DynamicWorkflow`, wait for its result, then call the next, or combine the work into a single workflow. In `manual` permission mode, `DynamicWorkflow` calls outside active Dynamic Workflow mode request approval unless a permission rule allows them; while Dynamic Workflow mode is active, `DynamicWorkflow` itself is auto-approved. Permission rules match `DynamicWorkflow` by tool name only — argument patterns such as `DynamicWorkflow(workflow)` are not supported. +**`DynamicWorkflow`** launches several independent subagents in parallel, resumes existing subagents through `resume_agent_ids`, or combines both in one call. It always requires `description`, a short summary of the whole workflow. Each entry in `items` launches one new subagent: without `prompt_template`, every entry is a complete prompt on its own; with `prompt_template`, the template must contain the `{{item}}` placeholder and each entry replaces it. Item prompts must be distinct — duplicates are rejected. Pass `subagent_type` to choose the profile used by every spawned subagent, or omit it to use `coder`. Pass `model` and `effort` to run this workflow's subagents on a different model than the agent orchestrating them — a cheaper or faster model for mechanical work, for example; both apply to every subagent in the call, and omitting them falls back to the subagent profile's own settings and then to the calling agent's. A `model` the provider cannot resolve falls back to the calling agent's model rather than failing the run. Without `resume_agent_ids`, the tool requires at least 2 items; with `resume_agent_ids`, it can resume one or more existing subagents. The tool supports up to 128 total subagents, waits for all of them to finish, and returns an aggregated report. Workflow subagents have no automatic timeout; they run until completion, failure, or user cancellation. If a model response calls `DynamicWorkflow`, that call must be the only tool call in the response; to run several workflows, call one `DynamicWorkflow`, wait for its result, then call the next, or combine the work into a single workflow. In `manual` and `auto` permission modes a `DynamicWorkflow` call requests approval, and that approval shows the plan it is about to run — the description, the subagent type, the prompt template, and every item. Approving for the session is keyed to that exact plan, so a later call that swaps in a different item list asks again; `yolo` approves without asking. Permission rules match `DynamicWorkflow` on the plan, or on `model:` for the model a call asks its subagents to run on, so `DynamicWorkflow(model:some-model)` gates the model a fan-out may use. -In the TUI, a foreground workflow shows a live framed mission-control panel with a coral title. The panel lists one row per subagent with a compact progress cube, state, task, current work, and elapsed time, followed by a recent-activity log. Each cube advances only through observed execution milestones such as startup, model output, tool use, and finalization; it does not predict time remaining. The summary reports only factual completion, failure, and cancellation counts plus elapsed time, without an estimated aggregate percentage or progress bar. In a narrow terminal the per-agent cubes are dropped before subagent identity or state; when vertical space runs out, rows are clipped in workflow-index order and the remainder is summarized as `+ N more agents`. +In the TUI, a foreground workflow shows a live framed mission-control panel with a coral title. The panel lists one row per subagent with its work count, idle age, state, task, current work, and elapsed time, followed by a recent-activity log. The work count is the number of tool calls the subagent has made and the idle age is how long it has been silent, turning amber after 60 seconds and red after 180; neither predicts time remaining, because nothing knows how many steps a subagent will take. The summary reports only factual completion, failure, and cancellation counts plus elapsed time, without an estimated aggregate percentage or progress bar. In a narrow terminal the work and idle columns are dropped before subagent identity or state; when vertical space runs out, rows are clipped in workflow-index order and the remainder is summarized as `+ N more agents`. **`AskUserQuestion`** asks the user a structured multiple-choice question — useful for disambiguation or option selection. The `questions` parameter accepts 1–4 questions; each question requires `question` (ending with `?`), `options` (2–4 choices, each with a `label` and `description`), and optional `header` (max 12 characters) and `multi_select` (defaults to false). An "Other" option is appended automatically. Setting `background` to true starts a background question task and returns a task ID immediately. When the host does not support interactive questioning, a failure message is returned and the Agent should ask the user directly in a text reply instead. diff --git a/packages/agent-core/src/agent/permission/policies/dynamic-workflow-plan-ask.ts b/packages/agent-core/src/agent/permission/policies/dynamic-workflow-plan-ask.ts new file mode 100644 index 00000000..a97216b4 --- /dev/null +++ b/packages/agent-core/src/agent/permission/policies/dynamic-workflow-plan-ask.ts @@ -0,0 +1,44 @@ +import type { Agent } from '../..'; +import type { PermissionPolicy, PermissionPolicyContext, PermissionPolicyResult } from '../types'; +import { SessionApprovalHistoryPermissionPolicy } from './session-approval-history'; +import { UserConfiguredAllowPermissionPolicy } from './user-configured-rules'; + +/** + * Keeps the Dynamic Workflow plan preview reachable in auto mode. + * + * Auto approves every tool call, which included the `DynamicWorkflow` call + * itself — so the plan preview, the one consent gate before a fan-out of up to + * 128 subagents, never rendered. The start prompt makes that the easy path: its + * default option is "Switch to Auto and start", so pressing Enter both enabled + * auto and silently gave up seeing the plan. + * + * Auto still governs everything the subagents then do. It no longer waives + * seeing what is about to be launched. + * + * An explicit grant still wins, so the ask happens once per distinct plan + * rather than on every call: a session approval recorded against this exact + * plan, or a user-configured allow rule, falls through to the auto approval + * below. YOLO is deliberately untouched — it is chosen explicitly and its own + * label promises that everything is approved automatically. + */ +export class DynamicWorkflowPlanAskPermissionPolicy implements PermissionPolicy { + readonly name = 'dynamic-workflow-plan-ask'; + + private readonly sessionApprovals: SessionApprovalHistoryPermissionPolicy; + private readonly userAllows: UserConfiguredAllowPermissionPolicy; + + constructor(private readonly agent: Agent) { + // Composed rather than reimplemented: "approve for this session" and the + // configured allow rules must mean the same thing here as they do below. + this.sessionApprovals = new SessionApprovalHistoryPermissionPolicy(agent); + this.userAllows = new UserConfiguredAllowPermissionPolicy(agent); + } + + evaluate(context: PermissionPolicyContext): PermissionPolicyResult | undefined { + if (this.agent.permission.mode !== 'auto') return; + if (context.toolCall.name !== 'DynamicWorkflow') return; + if (this.sessionApprovals.evaluate(context) !== undefined) return; + if (this.userAllows.evaluate(context) !== undefined) return; + return { kind: 'ask' }; + } +} diff --git a/packages/agent-core/src/agent/permission/policies/index.ts b/packages/agent-core/src/agent/permission/policies/index.ts index f33000ec..4afe1afa 100644 --- a/packages/agent-core/src/agent/permission/policies/index.ts +++ b/packages/agent-core/src/agent/permission/policies/index.ts @@ -1,6 +1,7 @@ import type { Agent } from '../..'; import type { PermissionPolicy } from '../types'; import { DynamicWorkflowExclusiveDenyPermissionPolicy } from './dynamic-workflow-exclusive-deny'; +import { DynamicWorkflowPlanAskPermissionPolicy } from './dynamic-workflow-plan-ask'; import { AutoModeApprovePermissionPolicy } from './auto-mode-approve'; import { AutoModeAskUserQuestionDenyPermissionPolicy } from './auto-mode-ask-user-question-deny'; import { DefaultToolApprovePermissionPolicy } from './default-tool-approve'; @@ -35,6 +36,9 @@ export function createPermissionDecisionPolicies(agent: Agent): PermissionPolicy new PlanModeGuardDenyPermissionPolicy(agent), // User-configured deny rule matches → deny. new UserConfiguredDenyPermissionPolicy(agent), + // auto mode + DynamicWorkflow → ask, so the plan preview still renders. + // Must sit above the auto approval below, which would otherwise swallow it. + new DynamicWorkflowPlanAskPermissionPolicy(agent), // auto mode → approve (any auto-mode block must be a deny rule above this). new AutoModeApprovePermissionPolicy(agent), // Approve-for-session memorized rule matches → approve. Runs before user-configured ask rules so an in-session grant beats a still-matching ask rule on later calls. diff --git a/packages/agent-core/src/rpc/core-api.ts b/packages/agent-core/src/rpc/core-api.ts index 87666649..b8d64d5b 100644 --- a/packages/agent-core/src/rpc/core-api.ts +++ b/packages/agent-core/src/rpc/core-api.ts @@ -477,6 +477,8 @@ export interface SessionAPI extends AgentAPIWithId { addWorkspaceDirectory: (payload: WorkspaceDirectoryPayload) => WorkspaceDirectory; removeWorkspaceDirectory: (payload: WorkspaceDirectoryPayload) => void; listSkills: (payload: EmptyPayload) => readonly SkillSummary[]; + /** Re-discovers skills from disk so one written mid-session becomes usable. */ + reloadSkills: (payload: EmptyPayload) => void; listMcpServers: (payload: EmptyPayload) => readonly McpServerInfo[]; getMcpStartupMetrics: (payload: EmptyPayload) => McpStartupMetrics; reconnectMcpServer: (payload: ReconnectMcpServerPayload) => void; diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index ebbd6037..c76760e3 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -851,6 +851,13 @@ export class PythinkerCore implements PromisableMethods { return this.sessionApi(sessionId).listSkills(payload); } + reloadSkills({ + sessionId, + ...payload + }: SessionScopedPayload): Promise { + return this.sessionApi(sessionId).reloadSkills(payload); + } + listMcpServers({ sessionId, ...payload diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index a5e65634..927e632a 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -1115,6 +1115,21 @@ export class Session { ]; } + /** + * Re-discovers skills from disk and replaces the registry entries. + * + * A skill written while the session is open — `/workflow save`, an edited + * `SKILL.md` — is otherwise invisible until the session is reloaded, because + * the registry is built once at construction. `loadRoots` registers with + * `replace: true`, so re-running is idempotent for skills that already exist + * and additive for new ones. A skill deleted from disk stays until the + * session reloads; nothing needs its removal yet. + */ + async reloadSkills(): Promise { + await this.skillsReady; + await this.loadSkills(); + } + private async loadSkills(): Promise { const roots = await resolveSkillRoots({ paths: { diff --git a/packages/agent-core/src/session/rpc.ts b/packages/agent-core/src/session/rpc.ts index 2f60a318..f56c6f19 100644 --- a/packages/agent-core/src/session/rpc.ts +++ b/packages/agent-core/src/session/rpc.ts @@ -98,6 +98,10 @@ export class SessionAPIImpl implements PromisableMethods { return this.session.listSkills(); } + reloadSkills(_payload: EmptyPayload): Promise { + return this.session.reloadSkills(); + } + listMcpServers(_payload: EmptyPayload): readonly McpServerInfo[] { return this.session.mcp.list(); } diff --git a/packages/agent-core/src/tools/builtin/collaboration/agent.ts b/packages/agent-core/src/tools/builtin/collaboration/agent.ts index 7b34b084..008409b9 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/agent.ts +++ b/packages/agent-core/src/tools/builtin/collaboration/agent.ts @@ -38,7 +38,7 @@ import { } from '../../../utils/abort'; import { AgentBackgroundTask, type BackgroundManager } from '../../../agent/background'; import { toInputJsonSchema } from '../../support/input-schema'; -import { matchesGlobRuleSubject } from '../../support/rule-match'; +import { matchesGlobRuleSubjects, modelRuleSubject } from '../../support/rule-match'; import AGENT_BACKGROUND_DISABLED_DESCRIPTION from './agent-background-disabled.md?raw'; import AGENT_BACKGROUND_DESCRIPTION from './agent-background-enabled.md?raw'; import AGENT_DESCRIPTION_BASE from './agent.md?raw'; @@ -221,7 +221,12 @@ export class AgentTool implements BuiltinTool { cwd: args.cwd, }, approvalRule: this.name, - matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, profileName), + // The model the call asked for is a second subject, so `Agent(model:opus)` + // constrains it. Only an explicit request is gated: inheriting the + // parent's model is not an escalation, and gating that would deny every + // subagent whenever the parent happened to run the named model. + matchesRule: (ruleArgs) => + matchesGlobRuleSubjects(ruleArgs, [profileName, ...modelRuleSubject(args.model)]), execute: (ctx) => this.execution({ ...args, isolation }, ctx), }; } diff --git a/packages/agent-core/src/tools/builtin/collaboration/dynamic-workflow.ts b/packages/agent-core/src/tools/builtin/collaboration/dynamic-workflow.ts index 33fd8245..002cce0f 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/dynamic-workflow.ts +++ b/packages/agent-core/src/tools/builtin/collaboration/dynamic-workflow.ts @@ -21,7 +21,11 @@ import { import { generateWorkflowRunId } from '../../../agent/dynamic-workflow/run-id'; import { estimateTokens } from '../../../utils/tokens'; import { toInputJsonSchema } from '../../support/input-schema'; -import { literalRulePattern, matchesGlobRuleSubject } from '../../support/rule-match'; +import { + literalRulePattern, + matchesGlobRuleSubjects, + modelRuleSubject, +} from '../../support/rule-match'; import DYNAMIC_WORKFLOW_DESCRIPTION from './dynamic-workflow.md?raw'; const DEFAULT_SUBAGENT_TYPE = 'coder'; @@ -184,7 +188,11 @@ export class DynamicWorkflowTool implements BuiltinTool matchesGlobRuleSubject(ruleArgs, approvalSubject), + // The model this workflow asked its subagents to use is a second subject, + // so `DynamicWorkflow(model:opus)` constrains a fan-out that would + // otherwise run 128 children on any model the provider can resolve. + matchesRule: (ruleArgs) => + matchesGlobRuleSubjects(ruleArgs, [approvalSubject, ...modelRuleSubject(args.model)]), execute: (ctx) => this.execution(args, ctx), }; } diff --git a/packages/agent-core/src/tools/support/rule-match.ts b/packages/agent-core/src/tools/support/rule-match.ts index fe206ce5..e359a38a 100644 --- a/packages/agent-core/src/tools/support/rule-match.ts +++ b/packages/agent-core/src/tools/support/rule-match.ts @@ -15,7 +15,29 @@ export function escapeRuleSubjectLiteral(subject: string): string { } export function matchesGlobRuleSubject(ruleArgs: string, subject: string): boolean { - return matchRuleSubjects(ruleArgs, [subject], (pattern, value) => globMatch(value, pattern)); + return matchesGlobRuleSubjects(ruleArgs, [subject]); +} + +/** + * Matches a rule against several subjects for one call, so a tool can be gated + * on more than the one thing it is named after — `Agent(reviewer)` on the + * profile, `Agent(model:opus)` on the model the call asked for. + * + * Namespace every subject past the first (`model:`), or an existing rule + * written for the primary subject silently starts matching the new one too. + */ +export function matchesGlobRuleSubjects( + ruleArgs: string, + subjects: readonly string[], +): boolean { + return matchRuleSubjects(ruleArgs, subjects, (pattern, value) => globMatch(value, pattern)); +} + +/** The rule subject for a model a call explicitly asked its subagents to use. */ +export function modelRuleSubject(modelAlias: string | undefined): readonly string[] { + return modelAlias === undefined || modelAlias.trim().length === 0 + ? [] + : [`model:${modelAlias.trim()}`]; } export function matchesPathRuleSubject( diff --git a/packages/agent-core/test/agent/permission.test.ts b/packages/agent-core/test/agent/permission.test.ts index e3c14cbe..f72c61d4 100644 --- a/packages/agent-core/test/agent/permission.test.ts +++ b/packages/agent-core/test/agent/permission.test.ts @@ -739,6 +739,7 @@ describe('Permission policy chain', () => { 'auto-mode-ask-user-question-deny', 'plan-mode-guard-deny', 'user-configured-deny', + 'dynamic-workflow-plan-ask', 'auto-mode-approve', 'session-approval-history', 'user-configured-ask', @@ -818,20 +819,75 @@ describe('Permission policy chain', () => { ); }); - it('still approves a DynamicWorkflow call without asking in auto and yolo mode', async () => { - for (const mode of ['auto', 'yolo'] as const) { - const { manager, requestApproval } = makePermissionManager( - async () => ({ decision: 'approved' }), - { dynamicWorkflowModeActive: true }, - ); - manager.mode = mode; + // The start prompt's default option is "Switch to Auto and start", so the + // easiest path through it used to hand back the plan preview without saying + // so. Auto still approves everything the subagents do; it no longer waives + // seeing what is about to be launched. + it('asks before a DynamicWorkflow call in auto mode so the plan still renders', async () => { + const { manager, requestApproval, telemetryTrack } = makePermissionManager( + async () => ({ decision: 'approved' }), + { dynamicWorkflowModeActive: true }, + ); + manager.mode = 'auto'; - await manager.beforeToolCall( - hookContext({ id: `call_dynamic_workflow_${mode}`, toolName: 'DynamicWorkflow' }), - ); + await manager.beforeToolCall( + hookContext({ id: 'call_dynamic_workflow_auto', toolName: 'DynamicWorkflow' }), + ); - expect(requestApproval).not.toHaveBeenCalled(); - } + expect(requestApproval).toHaveBeenCalledTimes(1); + expect(telemetryTrack).toHaveBeenCalledWith( + 'permission_policy_decision', + expect.objectContaining({ + policy_name: 'dynamic-workflow-plan-ask', + tool_name: 'DynamicWorkflow', + permission_mode: 'auto', + decision: 'ask', + }), + ); + }); + + // Auto mode is not turned into a nag: the ask is per distinct plan, and an + // explicit grant falls straight through to the auto approval below it. + it('does not re-ask in auto mode once the plan is approved for the session', async () => { + const { manager, requestApproval } = makePermissionManager( + async () => ({ + decision: 'approved', + scope: 'session', + selectedLabel: 'Approve for this session', + }), + { dynamicWorkflowModeActive: true }, + ); + manager.mode = 'auto'; + const args = { + description: 'Review files', + prompt_template: 'Review {{item}}', + items: ['src/a.ts', 'src/b.ts'], + }; + + await manager.beforeToolCall( + hookContext({ id: 'call_dw_1', toolName: 'DynamicWorkflow', args }), + ); + await manager.beforeToolCall( + hookContext({ id: 'call_dw_2', toolName: 'DynamicWorkflow', args }), + ); + + expect(requestApproval).toHaveBeenCalledTimes(1); + }); + + // YOLO is chosen explicitly and its own label promises that everything is + // approved automatically, so it keeps waiving the preview. + it('still approves a DynamicWorkflow call without asking in yolo mode', async () => { + const { manager, requestApproval } = makePermissionManager( + async () => ({ decision: 'approved' }), + { dynamicWorkflowModeActive: true }, + ); + manager.mode = 'yolo'; + + await manager.beforeToolCall( + hookContext({ id: 'call_dynamic_workflow_yolo', toolName: 'DynamicWorkflow' }), + ); + + expect(requestApproval).not.toHaveBeenCalled(); }); }); diff --git a/packages/agent-core/test/session/init.test.ts b/packages/agent-core/test/session/init.test.ts index 6a3a741f..9326f4bc 100644 --- a/packages/agent-core/test/session/init.test.ts +++ b/packages/agent-core/test/session/init.test.ts @@ -1135,6 +1135,40 @@ describe('AgentAPI.startBtw', () => { } }); + it('reloadSkills picks up a skill written after the session opened', async () => { + const workDir = await makeTempDir(); + const sessionDir = await makeTempDir(); + const skillsRoot = join(workDir, 'skills'); + await mkdir(skillsRoot, { recursive: true }); + + const session = new Session({ + id: 'test-reload-skills', + kaos: testKaos.withCwd(workDir), + homedir: sessionDir, + rpc: createSessionRpc([]), + skills: { explicitDirs: [skillsRoot] }, + }); + + try { + expect((await session.listSkills()).map((skill) => skill.name)).not.toContain('audit-routes'); + + // What `/workflow save` does: write a skill into a root the open session + // already scanned. The registry is built once, so it stays invisible + // until something re-discovers it. + await mkdir(join(skillsRoot, 'audit-routes'), { recursive: true }); + await writeFile( + join(skillsRoot, 'audit-routes', 'SKILL.md'), + ['---', 'name: audit-routes', 'description: Audit routes', '---', '', 'Body.'].join('\n'), + ); + expect((await session.listSkills()).map((skill) => skill.name)).not.toContain('audit-routes'); + + await session.reloadSkills(); + expect((await session.listSkills()).map((skill) => skill.name)).toContain('audit-routes'); + } finally { + await session.close(); + } + }); + it('discovers sub-skills and builtins', async () => { const workDir = await makeTempDir(); const sessionDir = await makeTempDir(); diff --git a/packages/agent-core/test/tools/agent.test.ts b/packages/agent-core/test/tools/agent.test.ts index 8db8861d..ed4fbde4 100644 --- a/packages/agent-core/test/tools/agent.test.ts +++ b/packages/agent-core/test/tools/agent.test.ts @@ -65,6 +65,29 @@ describe('AgentTool', () => { }); }); + it('gates a permission rule on the model the call asked for', async () => { + const host = mockSubagentHost({ spawn: vi.fn() }); + const tool = new AgentTool(host); + const base = { prompt: 'Audit auth', description: 'Audit auth', subagent_type: 'reviewer' }; + + const onOpus = await tool.resolveExecution({ ...base, model: 'opus' }); + if (onOpus.isError === true) throw new Error('expected runnable execution'); + // `Agent(model:opus)` parsed before this but was globbed against the profile + // name, so it never fired and any resolvable model got through. + expect(onOpus.matchesRule?.('model:opus')).toBe(true); + expect(onOpus.matchesRule?.('model:sonnet')).toBe(false); + // The profile subject still matches, and the model subject is namespaced so + // an existing profile rule cannot start matching a same-named model. + expect(onOpus.matchesRule?.('reviewer')).toBe(true); + expect(onOpus.matchesRule?.('opus')).toBe(false); + + // Inheriting the parent's model is not an escalation and is not gated. + const inherited = await tool.resolveExecution(base); + if (inherited.isError === true) throw new Error('expected runnable execution'); + expect(inherited.matchesRule?.('model:opus')).toBe(false); + expect(inherited.matchesRule?.('reviewer')).toBe(true); + }); + it('exposes run_in_background and not runInBackground in the JSON schema', () => { const host = mockSubagentHost({ spawn: vi.fn() }); const tool = new AgentTool(host); diff --git a/packages/agent-core/test/tools/builtin-current.test.ts b/packages/agent-core/test/tools/builtin-current.test.ts index d4edb61b..ee57ba48 100644 --- a/packages/agent-core/test/tools/builtin-current.test.ts +++ b/packages/agent-core/test/tools/builtin-current.test.ts @@ -899,6 +899,18 @@ describe('current builtin collaboration tools', () => { ), ).toBe(false); expect(execution.matchesRule?.(subjectOf({ ...base, model: 'other-model' }))).toBe(false); + + // A rule may also name the model the call asked its subagents to run on, so + // `DynamicWorkflow(model:opus)` gates a fan-out that used to be bounded only + // by whether the provider could resolve the alias. + const onOpus = runnableExecution(tool, { ...base, model: 'opus' }); + expect(onOpus.matchesRule?.('model:opus')).toBe(true); + expect(onOpus.matchesRule?.('model:sonnet')).toBe(false); + // No model requested → nothing for a model rule to match. + expect(execution.matchesRule?.('model:opus')).toBe(false); + // The model subject is namespaced, so a rule written for the plan subject + // never starts matching a model that happens to share its name. + expect(onOpus.matchesRule?.('opus')).toBe(false); expect(execution.matchesRule?.(subjectOf({ ...base, subagent_type: 'shell' }))).toBe(false); expect( execution.matchesRule?.(subjectOf({ ...base, prompt_template: 'Rewrite {{item}}' })), diff --git a/packages/node-sdk/src/rpc.ts b/packages/node-sdk/src/rpc.ts index 5f8b9318..31e846e5 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -559,6 +559,11 @@ export abstract class SDKRpcClientBase { return rpc.listSkills({ sessionId: input.sessionId }); } + async reloadSkills(input: SessionIdRpcInput): Promise { + const rpc = await this.getRpc(); + await rpc.reloadSkills({ sessionId: input.sessionId }); + } + async listContextFiles(input: SessionIdRpcInput): Promise { const rpc = await this.getRpc(); return rpc.listContextFiles({ diff --git a/packages/node-sdk/src/session.ts b/packages/node-sdk/src/session.ts index 45dcf54c..ac87eb15 100644 --- a/packages/node-sdk/src/session.ts +++ b/packages/node-sdk/src/session.ts @@ -346,6 +346,12 @@ export class Session { return this.rpc.listSkills({ sessionId: this.id }); } + /** Re-discovers skills from disk; call after writing one into a skill root. */ + async reloadSkills(): Promise { + this.ensureOpen(); + await this.rpc.reloadSkills({ sessionId: this.id }); + } + async listContextFiles(): Promise { this.ensureOpen(); return this.rpc.listContextFiles({ sessionId: this.id }); From 0e9c538c6b8370758921f64d64f33a5945e625e2 Mon Sep 17 00:00:00 2001 From: elkaix Date: Fri, 7 Aug 2026 20:22:57 -0400 Subject: [PATCH 03/10] chore(changelog): fold never-published versions into the release that shipped them 0.9.1, 0.10.0 and 0.11.0 have changelog blocks but appear on neither npm nor any git tag: a Version Packages PR bumped them and the publish step never completed. Their entries describe merged work, which reached users in 0.12.0, so the entries move there rather than being dropped with the headings. The version list now matches npm exactly. Entry count and PR set are unchanged. --- apps/pythinker-code/CHANGELOG.md | 35 +++++++++++--------------------- 1 file changed, 12 insertions(+), 23 deletions(-) diff --git a/apps/pythinker-code/CHANGELOG.md b/apps/pythinker-code/CHANGELOG.md index 2534e01d..970fed60 100644 --- a/apps/pythinker-code/CHANGELOG.md +++ b/apps/pythinker-code/CHANGELOG.md @@ -22,15 +22,6 @@ - [#38](https://github.com/Pythoughts-labs/pythinker-code/pull/38) [`44efbc7`](https://github.com/Pythoughts-labs/pythinker-code/commit/44efbc77360105de0efce185c86740fcf503944e) - Show update availability and live download progress in the status row under the prompt, replacing the startup banner chip that was computed once and never refreshed. -### Patch Changes - -- [#37](https://github.com/Pythoughts-labs/pythinker-code/pull/37) [`12069a8`](https://github.com/Pythoughts-labs/pythinker-code/commit/12069a890144380bff5d648ad51d7411ece94437) - Stop offering updates to versions that were never published: the update channel now advertises only the release that is actually available for download. - -- [#38](https://github.com/Pythoughts-labs/pythinker-code/pull/38) [`44efbc7`](https://github.com/Pythoughts-labs/pythinker-code/commit/44efbc77360105de0efce185c86740fcf503944e) - Stop offering an update with no build for the running platform, give every installer network call a timeout, expire a stale install lease instead of blocking updates forever, and say which version is installing and why a failed one stopped retrying. - -## 0.11.0 - -### Minor Changes - [#32](https://github.com/Pythoughts-labs/pythinker-code/pull/32) [`a504a82`](https://github.com/Pythoughts-labs/pythinker-code/commit/a504a820c4d9db14e213f4a021c86b048c4b916d) - Rename the ACP authentication method to reflect that login is multi-provider: it now reads "Log in with a provider" and explains that the provider is chosen in a terminal. Clients matching the previous wording will need updating. @@ -50,8 +41,16 @@ - [#32](https://github.com/Pythoughts-labs/pythinker-code/pull/32) [`a504a82`](https://github.com/Pythoughts-labs/pythinker-code/commit/a504a820c4d9db14e213f4a021c86b048c4b916d) - Replace the Dynamic Workflow progress bar with the two things it can actually know: how many tool calls each agent has made, and how long it has been silent. The old bar pinned every tool-using agent at 75% until it finished, so an agent working hard and one wedged for ten minutes looked identical. A row that goes quiet now turns amber, then red. + +- [#30](https://github.com/Pythoughts-labs/pythinker-code/pull/30) [`463b176`](https://github.com/Pythoughts-labs/pythinker-code/commit/463b1766a80389fe44cd675bff29b83b3ce6c86b) - Let a Dynamic Workflow run its subagents on a different model than the agent orchestrating them. `DynamicWorkflow` accepts `model` and `effort` for every subagent in the call, and `/workflow model ` sets that model for the session so an expensive orchestrator can hand mechanical work to a cheaper or faster one. + ### Patch Changes +- [#37](https://github.com/Pythoughts-labs/pythinker-code/pull/37) [`12069a8`](https://github.com/Pythoughts-labs/pythinker-code/commit/12069a890144380bff5d648ad51d7411ece94437) - Stop offering updates to versions that were never published: the update channel now advertises only the release that is actually available for download. + +- [#38](https://github.com/Pythoughts-labs/pythinker-code/pull/38) [`44efbc7`](https://github.com/Pythoughts-labs/pythinker-code/commit/44efbc77360105de0efce185c86740fcf503944e) - Stop offering an update with no build for the running platform, give every installer network call a timeout, expire a stale install lease instead of blocking updates forever, and say which version is installing and why a failed one stopped retrying. + + - [#32](https://github.com/Pythoughts-labs/pythinker-code/pull/32) [`a504a82`](https://github.com/Pythoughts-labs/pythinker-code/commit/a504a820c4d9db14e213f4a021c86b048c4b916d) - Survive two malformed inputs that used to end a run. A catalog entry that is not an object is now dropped when the catalog is read, instead of reaching the provider picker and throwing past the bundled-catalog fallback that was meant to save the login. A non-finite subagent concurrency limit now falls back to the default: `NaN` passed every clamp, and each free-slot test against it was false, so the batch launched nothing and never finished. - [#32](https://github.com/Pythoughts-labs/pythinker-code/pull/32) [`a504a82`](https://github.com/Pythoughts-labs/pythinker-code/commit/a504a820c4d9db14e213f4a021c86b048c4b916d) - Offer a model's declared thinking-effort levels when signing in to OpenAI Codex. The picker previously fell back to low / medium / high regardless of what the model supports, disagreeing with the effort list recorded in the config it then wrote. @@ -78,13 +77,6 @@ - [#32](https://github.com/Pythoughts-labs/pythinker-code/pull/32) [`a504a82`](https://github.com/Pythoughts-labs/pythinker-code/commit/a504a820c4d9db14e213f4a021c86b048c4b916d) - Show the whole large-workflow warning in the editor extension. The line was truncated to the panel width, so in a narrow side panel the reader saw the opening words and no reason. -## 0.10.0 - -### Minor Changes - -- [#30](https://github.com/Pythoughts-labs/pythinker-code/pull/30) [`463b176`](https://github.com/Pythoughts-labs/pythinker-code/commit/463b1766a80389fe44cd675bff29b83b3ce6c86b) - Let a Dynamic Workflow run its subagents on a different model than the agent orchestrating them. `DynamicWorkflow` accepts `model` and `effort` for every subagent in the call, and `/workflow model ` sets that model for the session so an expensive orchestrator can hand mechanical work to a cheaper or faster one. - -### Patch Changes - [#28](https://github.com/Pythoughts-labs/pythinker-code/pull/28) [`cf5b6b1`](https://github.com/Pythoughts-labs/pythinker-code/commit/cf5b6b16e999431bd1a8f511c09883c330fc569d) - Keep a subagent on the model and effort its profile assigns when the subagent is resumed or retried, instead of reverting it to the main agent's model. @@ -92,19 +84,16 @@ - [#31](https://github.com/Pythoughts-labs/pythinker-code/pull/31) [`e5e9de4`](https://github.com/Pythoughts-labs/pythinker-code/commit/e5e9de46f0f51be6f3ab3d03d59a5841779c2215) - Let `/yolo` and `/auto` be used in the VS Code extension before the first message is sent — the request now applies to the session that chat opens next instead of failing with "Could not change the permission mode." -## 0.9.2 -### Patch Changes +- [#24](https://github.com/Pythoughts-labs/pythinker-code/pull/24) [`ae01098`](https://github.com/Pythoughts-labs/pythinker-code/commit/ae01098b862a567552c7d49a6d5bd1808077a794) - 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. -- [#25](https://github.com/Pythoughts-labs/pythinker-code/pull/25) [`649ec69`](https://github.com/Pythoughts-labs/pythinker-code/commit/649ec69f8e039c031248ce939faadf253bee7259) - Let `/yolo` and `/auto` take effect in the VS Code extension while the agent is running, and auto-approve the requests already waiting on screen. +- [#24](https://github.com/Pythoughts-labs/pythinker-code/pull/24) [`ae01098`](https://github.com/Pythoughts-labs/pythinker-code/commit/ae01098b862a567552c7d49a6d5bd1808077a794) - 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. -## 0.9.1 +## 0.9.2 ### Patch Changes -- [#24](https://github.com/Pythoughts-labs/pythinker-code/pull/24) [`ae01098`](https://github.com/Pythoughts-labs/pythinker-code/commit/ae01098b862a567552c7d49a6d5bd1808077a794) - 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. - -- [#24](https://github.com/Pythoughts-labs/pythinker-code/pull/24) [`ae01098`](https://github.com/Pythoughts-labs/pythinker-code/commit/ae01098b862a567552c7d49a6d5bd1808077a794) - 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. +- [#25](https://github.com/Pythoughts-labs/pythinker-code/pull/25) [`649ec69`](https://github.com/Pythoughts-labs/pythinker-code/commit/649ec69f8e039c031248ce939faadf253bee7259) - Let `/yolo` and `/auto` take effect in the VS Code extension while the agent is running, and auto-approve the requests already waiting on screen. ## 0.9.0 From dba0dceac4cb926c5eadb8ecaab7ee7646aa3613 Mon Sep 17 00:00:00 2001 From: elkaix Date: Fri, 7 Aug 2026 20:25:04 -0400 Subject: [PATCH 04/10] docs(changelog): sync 0.6.0-0.12.0 from apps/pythinker-code/CHANGELOG.md --- docs/release-notes/changelog.md | 125 ++++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) diff --git a/docs/release-notes/changelog.md b/docs/release-notes/changelog.md index bfc0dd5e..997925cd 100644 --- a/docs/release-notes/changelog.md +++ b/docs/release-notes/changelog.md @@ -6,6 +6,131 @@ outline: 2 This page documents the changes in each Pythinker Code CLI release. +## 0.12.0 (2026-08-07) + +### Features + +- Show the plan before a Dynamic Workflow runs, and let a good one be saved as a command +- Let a Dynamic Workflow require structured output from its subagents. Passing `output_schema` makes each subagent return a validated object instead of free text, and a subagent that cannot satisfy the schema is reported separately from one that failed outright. +- Let a Dynamic Workflow run its subagents on a different model than the agent orchestrating them. `DynamicWorkflow` accepts `model` and `effort` for every subagent in the call, and `/workflow model ` sets that model for the session so an expensive orchestrator can hand mechanical work to a cheaper or faster one. +- `pythinker login` now opens a provider picker instead of going straight to one provider, and accepts `--provider ` to skip it. The VS Code extension's sign-in offers the same providers, and both surfaces present the same thinking-effort levels for a given model. +- Let a release declare a minimum supported version, so a client below it is offered the update without waiting for its staged rollout batch. +- Add two ways to rein in Dynamic Workflow fan-out: `disableWorkflows` turns the tool off entirely, and `workflowSizeGuideline` sets an advisory ceiling that is mentioned to the model and warned about, on every surface, when a run exceeds it. Both are settable in config or by environment variable. +- Give every Dynamic Workflow run an id and stamp it on the subagent events it produces, so a client can tell which run a given subagent belongs to when several are in flight. + +### Bug Fixes + +- Stop offering updates to versions that were never published: the update channel now advertises only the release that is actually available for download. +- Stop offering an update with no build for the running platform, give every installer network call a timeout, expire a stale install lease instead of blocking updates forever, and say which version is installing and why a failed one stopped retrying. +- Survive two malformed inputs that used to end a run. A catalog entry that is not an object is now dropped when the catalog is read, instead of reaching the provider picker and throwing past the bundled-catalog fallback that was meant to save the login. A non-finite subagent concurrency limit now falls back to the default: `NaN` passed every clamp, and each free-slot test against it was false, so the batch launched nothing and never finished. +- Show the whole large-workflow warning in the editor extension. The line was truncated to the panel width, so in a narrow side panel the reader saw the opening words and no reason. +- Keep a subagent on the model and effort its profile assigns when the subagent is resumed or retried, instead of reverting it to the main agent's model. +- 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. +- Ignore empty entries in a Dynamic Workflow's item list instead of rejecting the call. A trailing empty item used to fail argument validation, which discarded the whole workflow before any subagent started and forced the agent to send every prompt again. The dropped count is now reported with the results, and the launch panel counts only the subagents that will actually run. +- Finish handling blank Dynamic Workflow items. A run that dropped one reported its results after a note explaining the drop, which made the whole result parse as unsupported and rendered a successful run as failed; the note now follows the results. A blank entry also no longer leaves a row queued forever with the header stuck below its total, and no longer pushes a full item list over the subagent cap and back into whole-call rejection. +- Let `/yolo` and `/auto` be used in the VS Code extension before the first message is sent — the request now applies to the session that chat opens next instead of failing with "Could not change the permission mode." +- Keep a Dynamic Workflow subagent's output schema when a provider rate limit forces its turn to be retried. The retried turn lost the schema, so the subagent answered in prose and the workflow reported it as completed rather than as a schema failure. +- Stop a Dynamic Workflow row that has not started from reading as stalled. A queued row measured its silence from the launch of the whole run, so a long queue turned every waiting row amber and then red while nothing was wrong. A queued row now shows the same placeholder a finished one does, and a suspended row keeps its count without the alarm colours, because only a running row can stall. +- Show a Dynamic Workflow's running rows with a spinning grey dot, so a working agent reads as motion rather than as a static dot the eye cannot tell from a finished one, and shimmer the Orchestrating label in periwinkle instead of grey. +- Refuse a device authorization whose verification URL is not HTTPS. Every surface hands that URL to the host's "open externally" API, so a provider answering with `file:`, `javascript:`, or an installed application's own scheme had the agent launch it. The check runs where the response is parsed, so the terminal, the TUI, and the editor extension are all covered. +- Keep the configured provider signed in when a login is abandoned. Backing out at the model picker, or a failure while fetching the model list, no longer clears the existing credentials, and dismissing the provider picker returns to the sign-in screen instead of reporting a failed login. +- Write the thinking effort picked at login to disk. The apply step recorded the level, but the patch that saved the result listed everything except it, so an API-key login still reopened at the default effort. Choosing `off` now also clears a level a previous login left behind, which a patch that only merges could not do by omitting the key. +- Save the thinking-effort level picked during login. Only an on/off flag was stored, so choosing low, medium, or xhigh reopened the session at high, and an OpenAI Codex login reopened at the model's maximum effort regardless of the choice. +- Accept a provider's plain id for `--provider` at login, so a catalog provider no longer has to be named by its full display name, and stop a cancelled OpenAI Codex sign-in from holding the process open for the rest of its two-minute callback timeout. In the editor extension, signing in now shows one cancellable progress notification, a repeated sign-in joins the one already running instead of opening a second set of prompts, and a completed sign-in is no longer reported as failed when the status refresh behind it fails. +- Offer a model's declared thinking-effort levels when signing in to OpenAI Codex. The picker previously fell back to low / medium / high regardless of what the model supports, disagreeing with the effort list recorded in the config it then wrote. + +### Polish + +- Bound subagent fan-out with hard caps: 128 subagents per call, 200 per session, and a nesting depth of 3. Nesting was previously unbounded, so a workflow that spawned workflows could grow without limit; past depth 3 the call now fails instead. +- Replace the Dynamic Workflow progress bar with the two things it can actually know: how many tool calls each agent has made, and how long it has been silent. The old bar pinned every tool-using agent at 75% until it finished, so an agent working hard and one wedged for ten minutes looked identical. A row that goes quiet now turns amber, then red. +- Show update availability and live download progress in the status row under the prompt, replacing the startup banner chip that was computed once and never refreshed. +- Rename the ACP authentication method to reflect that login is multi-provider: it now reads "Log in with a provider" and explains that the provider is chosen in a terminal. Clients matching the previous wording will need updating. +- 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. +- Brighten the periwinkle accent in the VS Code extension's dark theme so inline code in chat is easier to read. + +### Refactors + +- Make the login platform layer provider-neutral. Model listing, capability derivation and the on-disk config shape are now one set of types shared by every login path, instead of living in a provider-specific module that other providers imported from; the duplicate copies of the capability derivation and the model-info parser are collapsed into one. + +## 0.9.2 (2026-08-05) + +### Bug Fixes + +- Let `/yolo` and `/auto` take effect in the VS Code extension while the agent is running, and auto-approve the requests already waiting on screen. + +## 0.9.0 (2026-08-05) + +### Features + +- Resolve a workspace's skills without opening a session, so an editor panel can list them before its first message. + +### Bug Fixes + +- Stop the fixed-layout TUI anchoring its first frames to the shell cursor, which pushed the panel border into scrollback. + +### Polish + +- Rename the managed OAuth provider so it is named after the platform that serves it rather than reading as a first-party service: the provider id is now `managed:kimi-code`, its models are aliased `kimi-code/*`, and its credentials are stored under `oauth/kimi-code`. +- Add an SDK routine that imports a catalog provider and its models into the persisted config, and use it for the CLI provider import so both entry points preserve existing defaults the same way. + +## 0.8.1 (2026-08-05) + +### Bug Fixes + +- Fix the native install script exiting immediately without installing anything when run the documented way, `curl -fsSL … | bash`, which also broke automatic background updates for native installs. +- Report why an automatic update failed instead of failing silently: the installer's error output is now recorded and shown on the next update prompt, native installs on macOS and Linux pin the version the rollout picked, and update messages tell you to open a new terminal to apply the update. + +## 0.8.0 (2026-08-04) + +### Features + +- Enable automatic updates for native installs on Windows: /update now installs the new version in the background instead of printing a manual command, and the installer safely replaces the running executable. + +### Bug Fixes + +- Fix Kimi and Moonshot models rejecting every request with an invalid tool schema error when a tool declares `anyOf` alongside its own type or properties. + +### Other + +- Remove the Pythinker Datasource plugin from the marketplace; its data gateway backend is not available, so every datasource query failed. +- Improve performance and fix bugs. + +## 0.7.0 (2026-08-04) + +### Features + +- Prepare verified Homebrew updates in the background and install them automatically on the next interactive launch. + +### Bug Fixes + +- Fix context compaction failing with provider "Invalid max_tokens" errors by capping requested completion tokens to the remaining context window and a safe output ceiling instead of the full context window size. +- 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. + +## 0.6.2 (2026-08-04) + +### Polish + +- Clear the terminal before the install script's animated intro so earlier shell output no longer interleaves with the logo animation. +- Restyle the browser OAuth sign-in confirmation pages for all providers to match the website's light design. + +## 0.6.1 (2026-08-03) + +### Bug Fixes + +- Fix the CLI failing to start on Windows with "process.execve is unavailable" by using the spawn fallback instead of calling execve there. +- Prompt for an API key when connecting a catalog provider whose environment variable is not set, instead of failing with "Environment variable is not set or is empty". Applies to `/login`, `/provider`, and `pythinker provider catalog add`, which now also accepts `--api-key `. +- Point the native install scripts at the published release assets. + +### Polish + +- Show a clear requirement message with the native-installer alternative when the CLI is launched on Node.js older than 26.4, instead of failing with a cryptic flag error. +- Explain in `/update` and the startup update notice that Homebrew installs do not auto-update, and point to the native installer for automatic background updates. + +## 0.6.0 (2026-08-03) + +### Other + +- Maintenance release with internal improvements and dependency updates. ## 0.5.1 (2026-08-03) ### Other From 560c82fedac14361ff4c7a939ca37efe1accbb29 Mon Sep 17 00:00:00 2001 From: elkaix Date: Fri, 7 Aug 2026 20:45:51 -0400 Subject: [PATCH 05/10] fix(tui): drop the preamble every Dynamic Workflow task repeats `prompt_template` is optional, so a caller may pass a whole prompt as each item. Every agent row then opened with the same paragraph and the task column clipped inside it, leaving six rows that named nothing. Measure the shared head across every member, cut it at the last shared word boundary, and mark the elision with a single column. The elision is all-or-nothing and skipped for a short head, so a column never means two different things and a mark never costs more than it frees. --- .../dynamic-workflow-shared-task-preamble.md | 5 ++ .../dynamic-workflow-mission-control.ts | 69 ++++++++++++++- .../src/tui/constant/rendering.ts | 6 ++ .../dynamic-workflow-mission-control.test.ts | 87 +++++++++++++++++++ 4 files changed, 163 insertions(+), 4 deletions(-) create mode 100644 .changeset/dynamic-workflow-shared-task-preamble.md diff --git a/.changeset/dynamic-workflow-shared-task-preamble.md b/.changeset/dynamic-workflow-shared-task-preamble.md new file mode 100644 index 00000000..a07ea197 --- /dev/null +++ b/.changeset/dynamic-workflow-shared-task-preamble.md @@ -0,0 +1,5 @@ +--- +"@pythoughts/pythinker-code": patch +--- + +Drop the preamble every Dynamic Workflow task repeats so each agent row shows the part that names it. 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 c8a209ec..d95401e4 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 @@ -11,6 +11,8 @@ import { shimmerText } from '#/tui/utils/shimmer'; const RESUMED_ITEM_LABEL = '(resumed)'; /** Divider between the cells that share a member row's free space. */ const MEMBER_SEPARATOR = ' · '; +/** Marks a task cell whose shared preamble was dropped. One column wide. */ +const TASK_ELISION_MARK = '…'; const ORCHESTRATING_LABEL = 'Orchestrating'; const FINALIZING_LABEL = 'Finalizing'; // Pad to the wider live label so the suffix column never shifts between them. @@ -440,8 +442,11 @@ export class DynamicWorkflowMissionControlComponent implements Component { const needsMore = members.length > slots; const memberSlots = needsMore && slots >= 2 ? slots - 1 : slots; const visibleMembers = members.slice(0, Math.max(0, memberSlots)); + // Measured across every member, not the visible ones: a prefix that came + // and went as rows scrolled would rewrite the task column under the eye. + const sharedPrefix = sharedTaskPrefix(members); for (const member of visibleMembers) { - lines.push(this.renderMember(member, width, nowMs)); + lines.push(this.renderMember(member, width, nowMs, sharedPrefix)); } const hidden = members.length - visibleMembers.length; if (hidden > 0 && lines.length < rowBudget) { @@ -567,7 +572,12 @@ export class DynamicWorkflowMissionControlComponent implements Component { return truncateToWidth(currentTheme.fg('textDim', header), width); } - private renderMember(member: DynamicWorkflowMember, width: number, nowMs: number): string { + private renderMember( + member: DynamicWorkflowMember, + width: number, + nowMs: number, + sharedPrefix: string, + ): string { const id = currentTheme.fg('primary', String(member.index).padStart(3, '0')); // All running rows share the workflow's clock, so they spin in step instead // of drifting apart by whenever each agent happened to start. @@ -585,6 +595,11 @@ export class DynamicWorkflowMissionControlComponent implements Component { ? `${id} ${workColumn} ${stateColumn} ` : `${id} ${padToWidth(state, 6)} `; const task = member.item || 'Delegated agent'; + // The elision is display-only: the dedup below still compares whole items, + // so a streamed line that merely repeats the task is still suppressed. + const shownTask = sharedPrefix.length > 0 && member.item.startsWith(sharedPrefix) + ? `${TASK_ELISION_MARK}${member.item.slice(sharedPrefix.length)}` + : task; const latest = member.latest.length > 0 && member.latest !== task ? member.latest : undefined; const detail = member.phase === 'suspended' || isTerminalPhase(member.phase) ? member.statusDetail ?? latest @@ -613,7 +628,7 @@ export class DynamicWorkflowMissionControlComponent implements Component { Math.floor(rest * DYNAMIC_WORKFLOW_RENDERING.memberTaskShare), ); const detailBudget = showWork && detail !== undefined && detail.length > 0 - ? rest - Math.min(visibleWidth(task), taskCap) - MEMBER_SEPARATOR.length + ? rest - Math.min(visibleWidth(shownTask), taskCap) - MEMBER_SEPARATOR.length : 0; const detailPart = detailBudget >= DYNAMIC_WORKFLOW_RENDERING.memberDetailMinWidth ? `${MEMBER_SEPARATOR}${truncateToWidth(currentTheme.fg('textDim', detail ?? ''), detailBudget)}` @@ -621,7 +636,7 @@ export class DynamicWorkflowMissionControlComponent implements Component { // Whatever the detail did not take goes back to the task. const taskText = truncateToWidth( - currentTheme.fg('text', task), + currentTheme.fg('text', shownTask), Math.max(1, rest - visibleWidth(detailPart)), ); return truncateToWidth( @@ -1071,6 +1086,52 @@ function normalizeText(text: string | undefined): string { return text?.replaceAll(/\s+/g, ' ').trim() ?? ''; } +/** + * The preamble every task repeats, or `''` when dropping it would not help. + * + * `prompt_template` is optional, so a caller may pass a whole prompt as each + * item. Every row then opens with the same paragraph and the TASK column clips + * inside it — six rows reading `You are auditing the pythinker-code mono...` + * name nothing. Dropping the shared head once puts the tail that identifies the + * row back on screen. + * + * All-or-nothing on purpose: eliding a prefix that only some rows carry would + * make two cells at the same column mean different things. + */ +function sharedTaskPrefix(members: readonly DynamicWorkflowMember[]): string { + const items = members.map((member) => member.item).filter((item) => item.length > 0); + const first = items[0]; + if (first === undefined || items.length < 2) return ''; + + // Skips `first` against itself: that comparison can only return its own + // length, and it walks the whole string to say so on every animation frame. + let length = first.length; + for (const item of items.slice(1)) { + length = commonPrefixLength(first, item, length); + if (length === 0) return ''; + } + + // Cut at the last space inside the shared text. A cut mid-word reads as + // corruption, and a space is always a whole code unit, so ending there is + // also what keeps the slice off the middle of a surrogate pair. + // + // Backing off to before the last shared word is what leaves every row + // something after the mark: items are normalized, so none of them ends in a + // space, and the shortest one therefore still holds the word the cut skipped. + const boundary = first.lastIndexOf(' ', length - 1); + if (boundary < 0) return ''; + const prefix = first.slice(0, boundary + 1); + if (visibleWidth(prefix) < DYNAMIC_WORKFLOW_RENDERING.memberTaskSharedPrefixMinWidth) return ''; + return prefix; +} + +function commonPrefixLength(left: string, right: string, limit: number): number { + const bound = Math.min(limit, left.length, right.length); + let index = 0; + while (index < bound && left[index] === right[index]) index += 1; + return index; +} + /** * The WORK cell: tool calls done, and how long this agent has been silent. * diff --git a/apps/pythinker-code/src/tui/constant/rendering.ts b/apps/pythinker-code/src/tui/constant/rendering.ts index add689da..532ef28f 100644 --- a/apps/pythinker-code/src/tui/constant/rendering.ts +++ b/apps/pythinker-code/src/tui/constant/rendering.ts @@ -34,6 +34,12 @@ export const DYNAMIC_WORKFLOW_RENDERING = { memberTaskShare: 0.6, /** Below this the detail is dropped: a few clipped characters say nothing. */ memberDetailMinWidth: 8, + /** + * Least shared task prefix worth eliding. A short prefix costs about as much + * to mark as it frees, so only a preamble long enough to have been clipping + * the part that names the row is dropped. + */ + memberTaskSharedPrefixMinWidth: 16, /** * Upper bound on one buffered output line. A model may stream a single line * with no newline in it at all, so this is the only thing that stops the 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 86d393d7..7e9d5386 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 @@ -23,6 +23,9 @@ function renderText(component: DynamicWorkflowMissionControlComponent, width = 1 /** The STATE cell of a running row: a grey braille spinner frame, then the label. */ const RUNNING_CELL = /[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] RUN/u; +/** Head of a task cell that lost the preamble every row shared. */ +const TASK_ELISION_MARK = '…'; + function memberLine(output: string, index: number): string { const id = String(index).padStart(3, '0'); const line = output.split('\n').find( @@ -772,6 +775,90 @@ describe('DynamicWorkflowMissionControlComponent', () => { expect(lines.join('\n')).not.toContain('Recent activity'); }); + it('drops the preamble every task repeats so the row keeps what names it', () => { + const preamble = 'You are auditing the pythinker-code monorepo at /Users/panda. Verify '; + const component = createComponent(); + component.updateArgs({ + items: [ + `${preamble}the permission glob`, + `${preamble}the concurrency cap`, + `${preamble}the resume path`, + ], + }); + component.markInputComplete(); + + const output = renderText(component, 100); + expect(output).not.toContain('You are auditing'); + // Greedy on purpose: the shared `the ` goes with the rest of the preamble. + expect(memberLine(output, 1)).toContain('…permission glob'); + expect(memberLine(output, 2)).toContain('…concurrency cap'); + expect(memberLine(output, 3)).toContain('…resume path'); + + // The mark is one column wide, so it never pushes a row past the frame. + for (const width of [20, 40, 63, 64, 79, 80, 100, 150]) { + expect(component.render(width).every((line) => visibleWidth(line) <= width)).toBe(true); + } + }); + + it.each([ + // Nothing shared: every row already names itself. + { name: 'no shared head', items: ['Audit the plan', 'Ship the release'] }, + // Shared but short: the mark would cost about what the elision frees. + { name: 'a short shared head', items: ['Audit the plan', 'Audit the release'] }, + // One row is the whole of what the other shares, and what is left over is + // one short word — below the floor, so the rows stay whole. + { name: 'a row that is the whole shared head', items: ['Audit the plan appendix', 'Audit the plan'] }, + // A prefix with no space in it can only be cut mid-word. + { name: 'an unbroken shared head', items: ['aaaaaaaaaaaaaaaaaaaa-one', 'aaaaaaaaaaaaaaaaaaaa-two'] }, + ])('keeps whole tasks when there is $name', ({ items }) => { + const component = createComponent(); + component.updateArgs({ items }); + component.markInputComplete(); + + const output = renderText(component, 200); + items.forEach((item, index) => { + expect(memberLine(output, index + 1)).toContain(item); + expect(memberLine(output, index + 1)).not.toContain(TASK_ELISION_MARK); + }); + }); + + it('leaves a row whose whole task is the shared head with the word the cut skipped', () => { + const component = createComponent(); + component.updateArgs({ + items: [ + 'Audit the pythinker-code monorepo', + 'Audit the pythinker-code monorepo plan', + ], + }); + component.markInputComplete(); + + // The cut lands before `monorepo`, not after it, so the shorter row keeps a + // word rather than collapsing to the mark on its own. + const output = renderText(component, 100); + expect(memberLine(output, 1)).toContain('…monorepo'); + expect(memberLine(output, 2)).toContain('…monorepo plan'); + expect(output).not.toContain('Audit the pythinker-code'); + }); + + it('holds the elision steady while rows are clipped away', () => { + const preamble = 'Audit the pythinker-code monorepo and report on '; + const items = ['the plan', 'the cap', 'the resume path', 'the glob'].map( + (tail) => `${preamble}${tail}`, + ); + const full = createComponent(); + full.updateArgs({ items }); + full.markInputComplete(); + // Two of the four rows are clipped, but the prefix is measured across every + // member, so the visible rows read exactly as they did before the clip. + const clipped = createComponent({ availableRows: () => 6 }); + clipped.updateArgs({ items }); + clipped.markInputComplete(); + + expect(memberLine(renderText(clipped, 100), 1)) + .toBe(memberLine(renderText(full, 100), 1)); + expect(memberLine(renderText(clipped, 100), 1)).toContain('…plan'); + }); + it('keeps three workflow-relative activity entries with suspension and failure details', () => { vi.useFakeTimers(); vi.setSystemTime(0); From 24f7cc0bf4b2165ad14c62ffe25e9b6dd9f66482 Mon Sep 17 00:00:00 2001 From: elkaix Date: Fri, 7 Aug 2026 21:23:59 -0400 Subject: [PATCH 06/10] fix: address review findings on the workflow and mission-control changes reloadSkills refreshed the registry but not the rendered prompt, so a workflow saved mid-session was invocable as `/` and still absent from the skill listing the model reads. Agent gains a prompt-only re-render that leaves the active tool set and turn limit alone, and remembers the profile its prompt was built from so a main agent on a custom profile is not re-rendered as the default. A member row recorded only the last line of a delta, so a provider that packed several closed lines into one chunk lost the rest. Every closed line is now its own event; the unclosed tail is shown but is not one. refreshSkillCommands applied whichever listing finished last. Several callers start it without awaiting, so a slow list for the session the user just left could land on the one they switched to. A generation guard drops stale results, and a failure now clears commands that belong to another session. Applying experimental flags rebuilt the command set before reloadSession, so it read the registry the reload was about to replace. Also: bump the SDK for the new public reloadSkills, note the auto-mode approval exception in the config reference, split the DynamicWorkflow reference into one idea per paragraph, and format changelog values as code. --- .changeset/saved-workflow-invocable.md | 3 +- apps/pythinker-code/CHANGELOG.md | 4 +- .../pythinker-code/src/tui/commands/config.ts | 5 +- .../dynamic-workflow-mission-control.ts | 25 ++++---- apps/pythinker-code/src/tui/pythinker-tui.ts | 41 ++++++++++--- .../test/tui/commands/experiments.test.ts | 5 ++ .../dynamic-workflow-mission-control.test.ts | 38 ++++++++++++ docs/configuration/config-files.md | 2 +- docs/reference/tools.md | 14 ++++- docs/release-notes/changelog.md | 4 +- packages/agent-core/src/agent/index.ts | 60 +++++++++++++++---- packages/agent-core/src/session/index.ts | 38 ++++++++++-- packages/agent-core/test/session/init.test.ts | 57 ++++++++++++++++++ 13 files changed, 252 insertions(+), 44 deletions(-) diff --git a/.changeset/saved-workflow-invocable.md b/.changeset/saved-workflow-invocable.md index 0f801550..4a91c5e1 100644 --- a/.changeset/saved-workflow-invocable.md +++ b/.changeset/saved-workflow-invocable.md @@ -1,5 +1,6 @@ --- "@pythoughts/pythinker-code": patch +"@pythoughts/pythinker-code-sdk": minor --- -Fix `/workflow save` leaving the saved workflow uncallable until the session was reloaded. +Fix `/workflow save` leaving the saved workflow uncallable until the session was reloaded, and add `Session.reloadSkills()` to re-discover skills written while a session is open. diff --git a/apps/pythinker-code/CHANGELOG.md b/apps/pythinker-code/CHANGELOG.md index 970fed60..a79cfd66 100644 --- a/apps/pythinker-code/CHANGELOG.md +++ b/apps/pythinker-code/CHANGELOG.md @@ -57,7 +57,7 @@ - [#32](https://github.com/Pythoughts-labs/pythinker-code/pull/32) [`a504a82`](https://github.com/Pythoughts-labs/pythinker-code/commit/a504a820c4d9db14e213f4a021c86b048c4b916d) - Accept a provider's plain id for `--provider` at login, so a catalog provider no longer has to be named by its full display name, and stop a cancelled OpenAI Codex sign-in from holding the process open for the rest of its two-minute callback timeout. In the editor extension, signing in now shows one cancellable progress notification, a repeated sign-in joins the one already running instead of opening a second set of prompts, and a completed sign-in is no longer reported as failed when the status refresh behind it fails. -- [#32](https://github.com/Pythoughts-labs/pythinker-code/pull/32) [`a504a82`](https://github.com/Pythoughts-labs/pythinker-code/commit/a504a820c4d9db14e213f4a021c86b048c4b916d) - Save the thinking-effort level picked during login. Only an on/off flag was stored, so choosing low, medium, or xhigh reopened the session at high, and an OpenAI Codex login reopened at the model's maximum effort regardless of the choice. +- [#32](https://github.com/Pythoughts-labs/pythinker-code/pull/32) [`a504a82`](https://github.com/Pythoughts-labs/pythinker-code/commit/a504a820c4d9db14e213f4a021c86b048c4b916d) - Save the thinking-effort level picked during login. Only an on/off flag was stored, so choosing `low`, `medium`, or `xhigh` reopened the session at `high`, and an OpenAI Codex login reopened at the model's maximum effort regardless of the choice. - [#32](https://github.com/Pythoughts-labs/pythinker-code/pull/32) [`a504a82`](https://github.com/Pythoughts-labs/pythinker-code/commit/a504a820c4d9db14e213f4a021c86b048c4b916d) - Write the thinking effort picked at login to disk. The apply step recorded the level, but the patch that saved the result listed everything except it, so an API-key login still reopened at the default effort. Choosing `off` now also clears a level a previous login left behind, which a patch that only merges could not do by omitting the key. @@ -127,7 +127,7 @@ - [`c0f0976`](https://github.com/Pythoughts-labs/pythinker-code/commit/c0f09769e76c92002ca9b9a09d9cb820750f1046) - Remove the Pythinker Datasource plugin from the marketplace; its data gateway backend is not available, so every datasource query failed. -- [`c0f0976`](https://github.com/Pythoughts-labs/pythinker-code/commit/c0f09769e76c92002ca9b9a09d9cb820750f1046) - Enable automatic updates for native installs on Windows: /update now installs the new version in the background instead of printing a manual command, and the installer safely replaces the running executable. +- [`c0f0976`](https://github.com/Pythoughts-labs/pythinker-code/commit/c0f09769e76c92002ca9b9a09d9cb820750f1046) - Enable automatic updates for native installs on Windows: `/update` now installs the new version in the background instead of printing a manual command, and the installer safely replaces the running executable. ### Patch Changes diff --git a/apps/pythinker-code/src/tui/commands/config.ts b/apps/pythinker-code/src/tui/commands/config.ts index 07c925bd..3194a940 100644 --- a/apps/pythinker-code/src/tui/commands/config.ts +++ b/apps/pythinker-code/src/tui/commands/config.ts @@ -1198,15 +1198,18 @@ export async function applyExperimentalFeatureChanges( await host.harness.setConfig({ experimental }); const features = await host.harness.getExperimentalFeatures(); setExperimentalFeatures(features); - await host.refreshSkillCommands(host.session); host.restoreEditor(); if (host.session !== undefined) { await host.session.reloadSession(); + // After the reload, never before: a flag can gate which skills exist, so + // rebuilding first read the registry the reload was about to replace. + await host.refreshSkillCommands(host.session); await host.reloadCurrentSessionView( host.session, 'Experimental features updated. Session reloaded.', ); } else { + await host.refreshSkillCommands(undefined); host.showStatus('Experimental features updated.', 'success'); } host.track('experimental_features_apply', { changed: changes.length }); 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 d95401e4..f6879087 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 @@ -281,7 +281,6 @@ export class DynamicWorkflowMissionControlComponent implements Component { 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; member.lastEventAtMs = Date.now(); const combined = `${member.carry}${input.delta}`; // Only the text after the last newline is still being written. A delta that @@ -291,7 +290,21 @@ export class DynamicWorkflowMissionControlComponent implements Component { const newlineIndex = combined.lastIndexOf('\n'); const pending = newlineIndex < 0 ? combined : combined.slice(newlineIndex + 1); member.carry = clampLine(pending); - this.setLatest(member, clampLine(latestNonEmptyLine(combined)), recordActivity); + + // Every line the delta closed is an event of its own. Recording only the + // last one dropped whole lines whenever a provider sent several in one + // chunk, so the same agent showed less activity on a batching provider than + // on one that streams a token at a time. + if (newlineIndex >= 0) { + for (const line of combined.slice(0, newlineIndex).split('\n')) { + this.setLatest(member, clampLine(line), true); + } + } + // The unclosed tail is shown but is not an event yet — except as the row's + // first text, which would otherwise leave the row blank until a newline. + if (pending.length > 0) { + this.setLatest(member, clampLine(pending), member.latest.length === 0); + } } markSuspended(input: { @@ -1065,14 +1078,6 @@ function elapsedSeconds(startedAtMs: number, endedAtMs: number): number { return Math.floor(Math.max(0, endedAtMs - startedAtMs) / 1_000); } -function latestNonEmptyLine(text: string): string { - for (const line of text.split(/\r?\n/).toReversed()) { - const normalized = normalizeText(line); - if (normalized.length > 0) return normalized; - } - return ''; -} - /** * Keeps the head of one streamed line. The row shows the head and clips the * rest, so dropping the tail is invisible — and it is the only bound on a line diff --git a/apps/pythinker-code/src/tui/pythinker-tui.ts b/apps/pythinker-code/src/tui/pythinker-tui.ts index cd968b87..3ecbd19b 100644 --- a/apps/pythinker-code/src/tui/pythinker-tui.ts +++ b/apps/pythinker-code/src/tui/pythinker-tui.ts @@ -47,6 +47,7 @@ import { sortSlashCommands, type PythinkerSlashCommand, type SkillListSession, + type SkillSlashCommands, } from './commands'; import { isExperimentalFlagEnabled, @@ -259,6 +260,10 @@ export class PythinkerTUI { private readonly reverseRpcDisposers: Array<() => void> = []; private skillCommands: readonly PythinkerSlashCommand[] = []; readonly skillCommandMap = new Map(); + /** Bumped per refresh so a slow one cannot apply over a newer one. */ + private skillCommandRefresh = 0; + /** The session whose skills the commands on screen were built from. */ + private skillCommandSession: SkillListSession | undefined; private readonly imageStore = new ImageAttachmentStore(); private fdPath: string | null = detectFdPath(); private fdDownloadStarted = false; @@ -438,23 +443,43 @@ export class PythinkerTUI { * never rebuilt from a skill list that has moved on. */ async refreshSkillCommands(session?: SkillListSession): Promise { + const refresh = (this.skillCommandRefresh += 1); if (session === undefined) { + this.skillCommandSession = undefined; this.skillCommands = []; this.skillCommandMap.clear(); this.setupAutocomplete(); return; } + let built: SkillSlashCommands | undefined; try { - const skillCommands = buildSkillSlashCommands(await session.listSkills()); - this.skillCommands = skillCommands.commands; - this.skillCommandMap.clear(); - for (const [commandName, skillName] of skillCommands.commandMap) { - this.skillCommandMap.set(commandName, skillName); - } + built = buildSkillSlashCommands(await session.listSkills()); } catch { - // Keep the skills already known. The builtin command set may still have - // changed, so the autocomplete provider is rebuilt either way. + // What to do about a failure depends on which session the commands on + // screen came from, and that is only decided after the await below. + } + + // A refresh that started later has already applied. Several callers start + // this without awaiting it, so a slow list for the session the user just + // left would otherwise land on top of the one they switched to. + if (refresh !== this.skillCommandRefresh) return; + + if (built === undefined && this.skillCommandSession === session) { + // A transient failure for the session already on screen: its commands are + // still the right ones, so keep them. The builtin command set may still + // have changed, so the autocomplete provider is rebuilt either way. + this.setupAutocomplete(); + return; + } + + // Either it listed, or it failed for a session whose skills were never on + // screen — keeping another session's commands would be worse than none. + this.skillCommandSession = built === undefined ? undefined : session; + this.skillCommands = built?.commands ?? []; + this.skillCommandMap.clear(); + for (const [commandName, skillName] of built?.commandMap ?? []) { + this.skillCommandMap.set(commandName, skillName); } this.setupAutocomplete(); } diff --git a/apps/pythinker-code/test/tui/commands/experiments.test.ts b/apps/pythinker-code/test/tui/commands/experiments.test.ts index 32b44ee3..7314d65a 100644 --- a/apps/pythinker-code/test/tui/commands/experiments.test.ts +++ b/apps/pythinker-code/test/tui/commands/experiments.test.ts @@ -88,6 +88,11 @@ describe('experimental feature command handlers', () => { expect(host.refreshSkillCommands).toHaveBeenCalled(); expect(host.restoreEditor).toHaveBeenCalled(); expect(host.session.reloadSession).toHaveBeenCalledOnce(); + // A flag can gate which skills exist, so rebuilding the command set before + // the reload read the registry the reload was about to replace. + expect(host.session.reloadSession.mock.invocationCallOrder[0]).toBeLessThan( + host.refreshSkillCommands.mock.invocationCallOrder[0] ?? 0, + ); expect(host.reloadCurrentSessionView).toHaveBeenCalledWith( host.session, 'Experimental features updated. Session reloaded.', 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 7e9d5386..82c8dc49 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 @@ -943,6 +943,44 @@ describe('DynamicWorkflowMissionControlComponent', () => { expect(line).toContain("I've read the files"); }); + it('records every line a provider packs into one delta', () => { + const component = createComponent(); + component.updateArgs({ items: ['Work'] }); + component.markInputComplete(); + register(component, 'agent-1'); + component.markStarted('agent-1'); + + // A provider that batches sends whole lines at once. Only the last one used + // to reach the activity list, so the same run showed less on that provider. + component.appendModelDelta({ + agentId: 'agent-1', + delta: 'First line\nSecond line\nThird line\n', + }); + + const output = renderText(component, 200); + expect(output).toContain('First line'); + expect(output).toContain('Second line'); + expect(output).toContain('Third line'); + }); + + it('does not report an unfinished line as an event of its own', () => { + const component = createComponent(); + component.updateArgs({ items: ['Work'] }); + component.markInputComplete(); + register(component, 'agent-1'); + component.markStarted('agent-1'); + component.appendModelDelta({ agentId: 'agent-1', delta: 'Closed line\nstill wri' }); + + // The row shows the tail as it arrives, but the activity list only carries + // the line the delta actually closed. + expect(memberLine(renderText(component, 200), 1)).toContain('still wri'); + const activity = renderText(component, 200).split('\n').filter( + (line) => /^│\s*001 \+/u.test(line), + ); + expect(activity.join('\n')).toContain('Closed line'); + expect(activity.join('\n')).not.toContain('still wri'); + }); + it.each([64, 70, 80, 100, 200])( 'keeps the task readable beside a long agent summary at width %i', (width) => { diff --git a/docs/configuration/config-files.md b/docs/configuration/config-files.md index 8df34b84..7cb082d9 100644 --- a/docs/configuration/config-files.md +++ b/docs/configuration/config-files.md @@ -77,7 +77,7 @@ Fields in the config file fall into two categories: **top-level scalars** that d | --- | --- | --- | --- | | `default_model` | `string` | — | Default model alias; must be defined in `models` | | `default_thinking` | `boolean` | `false` | Whether new sessions enable Thinking (deep reasoning) mode by default; can be toggled from the model menu inside a session. Even when set to `true`, `[thinking].mode = "off"` will still force Thinking off | -| `default_permission_mode` | `string` | `manual` | Default permission mode for new sessions; one of `manual` (prompt each time), `yolo` (auto-approve tool actions, but the agent may still ask questions), or `auto` (fully autonomous — the agent decides everything without asking) | +| `default_permission_mode` | `string` | `manual` | Default permission mode for new sessions; one of `manual` (prompt each time), `yolo` (auto-approve tool actions, but the agent may still ask questions), or `auto` (fully autonomous — the agent decides everything without asking, except a `DynamicWorkflow` call, which still shows its plan for approval) | | `default_plan_mode` | `boolean` | `false` | Whether new sessions start in Plan mode (produce a plan before executing) by default | | `merge_all_available_skills` | `boolean` | `true` | Whether to merge Agent Skills from all available directories | | `extra_skill_dirs` | `array` | — | Extra skill search directories, layered on top of the default directories | diff --git a/docs/reference/tools.md b/docs/reference/tools.md index e052302c..78de4798 100644 --- a/docs/reference/tools.md +++ b/docs/reference/tools.md @@ -91,9 +91,19 @@ Collaboration tools handle inter-Agent coordination, user interaction, and Skill **`Agent`** delegates a subtask to a sub-Agent. Required parameters: `prompt` (complete task description) and `description` (a 3–5 word short summary). Optional parameters: `subagent_type` (defaults to `coder`), `resume` (ID of an existing Agent to resume; mutually exclusive with `subagent_type`), and `run_in_background` (defaults to false). Agent tasks have a fixed 30-minute timeout. In foreground mode the parent Agent waits for the sub-Agent to complete before continuing; in background mode a task ID is returned immediately and the result is automatically delivered back to the main Agent via a synthetic User message when done. When several foreground `Agent` calls run in the same step, the TUI groups them and shows each subagent's running, waiting, completed, or failed status with elapsed time. See [Agent & Sub-Agents](../customization/agents.md) for details. -**`DynamicWorkflow`** launches several independent subagents in parallel, resumes existing subagents through `resume_agent_ids`, or combines both in one call. It always requires `description`, a short summary of the whole workflow. Each entry in `items` launches one new subagent: without `prompt_template`, every entry is a complete prompt on its own; with `prompt_template`, the template must contain the `{{item}}` placeholder and each entry replaces it. Item prompts must be distinct — duplicates are rejected. Pass `subagent_type` to choose the profile used by every spawned subagent, or omit it to use `coder`. Pass `model` and `effort` to run this workflow's subagents on a different model than the agent orchestrating them — a cheaper or faster model for mechanical work, for example; both apply to every subagent in the call, and omitting them falls back to the subagent profile's own settings and then to the calling agent's. A `model` the provider cannot resolve falls back to the calling agent's model rather than failing the run. Without `resume_agent_ids`, the tool requires at least 2 items; with `resume_agent_ids`, it can resume one or more existing subagents. The tool supports up to 128 total subagents, waits for all of them to finish, and returns an aggregated report. Workflow subagents have no automatic timeout; they run until completion, failure, or user cancellation. If a model response calls `DynamicWorkflow`, that call must be the only tool call in the response; to run several workflows, call one `DynamicWorkflow`, wait for its result, then call the next, or combine the work into a single workflow. In `manual` and `auto` permission modes a `DynamicWorkflow` call requests approval, and that approval shows the plan it is about to run — the description, the subagent type, the prompt template, and every item. Approving for the session is keyed to that exact plan, so a later call that swaps in a different item list asks again; `yolo` approves without asking. Permission rules match `DynamicWorkflow` on the plan, or on `model:` for the model a call asks its subagents to run on, so `DynamicWorkflow(model:some-model)` gates the model a fan-out may use. +**`DynamicWorkflow`** launches several independent subagents in parallel, resumes existing subagents through `resume_agent_ids`, or combines both in one call. It always requires `description`, a short summary of the whole workflow. -In the TUI, a foreground workflow shows a live framed mission-control panel with a coral title. The panel lists one row per subagent with its work count, idle age, state, task, current work, and elapsed time, followed by a recent-activity log. The work count is the number of tool calls the subagent has made and the idle age is how long it has been silent, turning amber after 60 seconds and red after 180; neither predicts time remaining, because nothing knows how many steps a subagent will take. The summary reports only factual completion, failure, and cancellation counts plus elapsed time, without an estimated aggregate percentage or progress bar. In a narrow terminal the work and idle columns are dropped before subagent identity or state; when vertical space runs out, rows are clipped in workflow-index order and the remainder is summarized as `+ N more agents`. +Each entry in `items` launches one new subagent: without `prompt_template`, every entry is a complete prompt on its own; with `prompt_template`, the template must contain the `{{item}}` placeholder and each entry replaces it. Item prompts must be distinct — duplicates are rejected. Because each item is otherwise a whole prompt, putting the shared preamble in `prompt_template` and only the varying part in `items` also keeps the TUI's task column readable. + +Pass `subagent_type` to choose the profile used by every spawned subagent, or omit it to use `coder`. Pass `model` and `effort` to run this workflow's subagents on a different model than the agent orchestrating them — a cheaper or faster model for mechanical work, for example; both apply to every subagent in the call, and omitting them falls back to the subagent profile's own settings and then to the calling agent's. A `model` the provider cannot resolve falls back to the calling agent's model rather than failing the run. + +Without `resume_agent_ids`, the tool requires at least 2 items; with `resume_agent_ids`, it can resume one or more existing subagents. The tool supports up to 128 total subagents, waits for all of them to finish, and returns an aggregated report. Workflow subagents have no automatic timeout; they run until completion, failure, or user cancellation. + +If a model response calls `DynamicWorkflow`, that call must be the only tool call in the response; to run several workflows, call one `DynamicWorkflow`, wait for its result, then call the next, or combine the work into a single workflow. + +In `manual` and `auto` permission modes a `DynamicWorkflow` call requests approval, and that approval shows the plan it is about to run — the description, the subagent type, the prompt template, and every item. Approving for the session is keyed to that exact plan, so a later call that swaps in a different item list asks again; `yolo` approves without asking. Permission rules match `DynamicWorkflow` on the plan, or on `model:` for the model a call asks its subagents to run on, so `DynamicWorkflow(model:some-model)` gates the model a fan-out may use. + +In the TUI, a foreground workflow shows a live framed mission-control panel with a coral title. The panel lists one row per subagent with its work count, idle age, state, task, current work, and elapsed time, followed by a recent-activity log. The work count is the number of tool calls the subagent has made and the idle age is how long it has been silent, turning amber after 60 seconds and red after 180; neither predicts time remaining, because nothing knows how many steps a subagent will take. The summary reports only factual completion, failure, and cancellation counts plus elapsed time, without an estimated aggregate percentage or progress bar. When every task starts with the same preamble — which happens when `prompt_template` is left empty and each item carries a whole prompt — the shared opening is dropped from every row and replaced by a leading `…`, so the part that names the row is what stays on screen. In a narrow terminal the work and idle columns are dropped before subagent identity or state; when vertical space runs out, rows are clipped in workflow-index order and the remainder is summarized as `+ N more agents`. **`AskUserQuestion`** asks the user a structured multiple-choice question — useful for disambiguation or option selection. The `questions` parameter accepts 1–4 questions; each question requires `question` (ending with `?`), `options` (2–4 choices, each with a `label` and `description`), and optional `header` (max 12 characters) and `multi_select` (defaults to false). An "Other" option is appended automatically. Setting `background` to true starts a background question task and returns a task ID immediately. When the host does not support interactive questioning, a failure message is returned and the Agent should ask the user directly in a text reply instead. diff --git a/docs/release-notes/changelog.md b/docs/release-notes/changelog.md index 997925cd..16e568c1 100644 --- a/docs/release-notes/changelog.md +++ b/docs/release-notes/changelog.md @@ -35,7 +35,7 @@ This page documents the changes in each Pythinker Code CLI release. - Refuse a device authorization whose verification URL is not HTTPS. Every surface hands that URL to the host's "open externally" API, so a provider answering with `file:`, `javascript:`, or an installed application's own scheme had the agent launch it. The check runs where the response is parsed, so the terminal, the TUI, and the editor extension are all covered. - Keep the configured provider signed in when a login is abandoned. Backing out at the model picker, or a failure while fetching the model list, no longer clears the existing credentials, and dismissing the provider picker returns to the sign-in screen instead of reporting a failed login. - Write the thinking effort picked at login to disk. The apply step recorded the level, but the patch that saved the result listed everything except it, so an API-key login still reopened at the default effort. Choosing `off` now also clears a level a previous login left behind, which a patch that only merges could not do by omitting the key. -- Save the thinking-effort level picked during login. Only an on/off flag was stored, so choosing low, medium, or xhigh reopened the session at high, and an OpenAI Codex login reopened at the model's maximum effort regardless of the choice. +- Save the thinking-effort level picked during login. Only an on/off flag was stored, so choosing `low`, `medium`, or `xhigh` reopened the session at `high`, and an OpenAI Codex login reopened at the model's maximum effort regardless of the choice. - Accept a provider's plain id for `--provider` at login, so a catalog provider no longer has to be named by its full display name, and stop a cancelled OpenAI Codex sign-in from holding the process open for the rest of its two-minute callback timeout. In the editor extension, signing in now shows one cancellable progress notification, a repeated sign-in joins the one already running instead of opening a second set of prompts, and a completed sign-in is no longer reported as failed when the status refresh behind it fails. - Offer a model's declared thinking-effort levels when signing in to OpenAI Codex. The picker previously fell back to low / medium / high regardless of what the model supports, disagreeing with the effort list recorded in the config it then wrote. @@ -84,7 +84,7 @@ This page documents the changes in each Pythinker Code CLI release. ### Features -- Enable automatic updates for native installs on Windows: /update now installs the new version in the background instead of printing a manual command, and the installer safely replaces the running executable. +- Enable automatic updates for native installs on Windows: `/update` now installs the new version in the background instead of printing a manual command, and the installer safely replaces the running executable. ### Bug Fixes diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index c90e03d5..6266e90d 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -124,6 +124,7 @@ export class Agent { readonly type: AgentType; private _kaos: Kaos; private additionalDirectories: readonly string[]; + private _activeProfile: ResolvedAgentProfile | undefined; get kaos(): Kaos { return this._kaos; @@ -327,6 +328,53 @@ export class Agent { context?: PreparedSystemPromptContext, outputStyle?: Pick, ): void { + this._activeProfile = profile; + this.config.update({ + profileName: profile.name, + systemPrompt: this.renderSystemPrompt(profile, context, outputStyle), + maxStepsPerTurn: profile.maxTurns, + }); + this.tools.setActiveTools( + context?.agentMemoryPrompt === undefined + ? profile.tools + : [...new Set([...profile.tools, 'Read', 'Write', 'Edit'])], + ); + } + + /** The profile whose render produced the current system prompt, if any. */ + get activeProfile(): ResolvedAgentProfile | undefined { + return this._activeProfile; + } + + /** + * Renders the system prompt again and swaps it in, leaving the active tool + * set and the turn limit as they are. + * + * The skill listing is baked into the prompt when the profile is applied, so + * a skill discovered later — a saved workflow, an edited `SKILL.md` — stays + * invisible to the model until the prompt is rebuilt. Re-applying the whole + * profile would rebuild it, but it would also reset the tools of an agent + * that is already running. + * + * Pass the profile the prompt was built from — `activeProfile` — so a main + * agent running a non-default profile is not re-rendered as the default one. + */ + refreshSystemPrompt( + profile: ResolvedAgentProfile, + context?: PreparedSystemPromptContext, + outputStyle?: Pick, + ): void { + this._activeProfile = profile; + this.config.update({ + systemPrompt: this.renderSystemPrompt(profile, context, outputStyle), + }); + } + + private renderSystemPrompt( + profile: ResolvedAgentProfile, + context?: PreparedSystemPromptContext, + outputStyle?: Pick, + ): string { let profilePrompt = profile.systemPrompt({ osEnv: this.kaos.osEnv, cwd: this.config.cwd, @@ -341,7 +389,7 @@ export class Agent { if (outputStyle !== undefined && outputStyle.keepCodingInstructions !== true) { profilePrompt = withoutBundledCodingInstructions(profilePrompt); } - const systemPrompt = [ + return [ profilePrompt, context?.agentMemoryPrompt, outputStyle === undefined @@ -350,16 +398,6 @@ export class Agent { ] .filter((block): block is string => block !== undefined) .join('\n\n'); - this.config.update({ - profileName: profile.name, - systemPrompt, - maxStepsPerTurn: profile.maxTurns, - }); - this.tools.setActiveTools( - context?.agentMemoryPrompt === undefined - ? profile.tools - : [...new Set([...profile.tools, 'Read', 'Write', 'Edit'])], - ); } async resume(): Promise { diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index 927e632a..06d59c20 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -48,6 +48,7 @@ import { loadAgentsMd, prepareSystemPromptContext, type OutputStyleConfig, + type PreparedSystemPromptContext, type ResolvedAgentProfile, } from '../profile'; import type { ProviderManager } from './provider-manager'; @@ -813,13 +814,31 @@ export class Session { profile: ResolvedAgentProfile, instructionsLoadReason?: 'session_start' | 'compact', ): Promise { + const context = await this.prepareAgentProfileContext(agent, profile, instructionsLoadReason); + agent.useProfile( + profile, + context, + agent.type === 'main' ? (this.options.outputStyle ?? undefined) : undefined, + ); + } + + /** + * Builds the render context for one agent's profile. `InstructionsLoaded` + * fires only when a load reason is given, so a caller that is merely + * re-rendering an existing prompt does not replay a session-start hook. + */ + private async prepareAgentProfileContext( + agent: Agent, + profile: ResolvedAgentProfile, + instructionsLoadReason?: 'session_start' | 'compact', + ): Promise { const memory = agent.experimentalFlags.enabled('agent_memory') && profile.memory !== undefined ? { name: profile.name, scope: profile.memory } : agent.experimentalFlags.enabled('agent_memory') && agent.type === 'main' ? { name: 'agent', scope: 'project' as const } : undefined; - const context = await prepareSystemPromptContext( + return prepareSystemPromptContext( this.systemContextKaos(agent.kaos.getcwd()), this.options.pythinkerHomeDir, memory, @@ -838,11 +857,6 @@ export class Session { } : undefined, ); - agent.useProfile( - profile, - context, - agent.type === 'main' ? (this.options.outputStyle ?? undefined) : undefined, - ); } listWorkspaceDirectories(): readonly WorkspaceDirectory[] { @@ -1128,6 +1142,18 @@ export class Session { async reloadSkills(): Promise { await this.skillsReady; await this.loadSkills(); + // The tool reads the registry as it runs, so it sees the new skill at once. + // The model does not: the skill listing is rendered into the system prompt + // when the profile is applied, so a workflow saved mid-session would stay + // off the list the model reads until the session reloaded. + const main = this.getReadyAgent('main'); + const profile = main?.activeProfile; + if (main === undefined || profile === undefined) return; + main.refreshSystemPrompt( + profile, + await this.prepareAgentProfileContext(main, profile), + this.options.outputStyle ?? undefined, + ); } private async loadSkills(): Promise { diff --git a/packages/agent-core/test/session/init.test.ts b/packages/agent-core/test/session/init.test.ts index 9326f4bc..5ca30310 100644 --- a/packages/agent-core/test/session/init.test.ts +++ b/packages/agent-core/test/session/init.test.ts @@ -1169,6 +1169,49 @@ describe('AgentAPI.startBtw', () => { } }); + it('reloadSkills re-renders the skill listing the model reads', async () => { + const workDir = await makeTempDir(); + const sessionDir = await makeTempDir(); + const skillsRoot = join(workDir, 'skills'); + await mkdir(skillsRoot, { recursive: true }); + + const session = new Session({ + id: 'test-reload-skills-prompt', + kaos: testKaos.withCwd(workDir), + homedir: sessionDir, + rpc: createSessionRpc([]), + skills: { explicitDirs: [skillsRoot] }, + }); + + try { + const { agent: main } = await session.createAgent( + { type: 'main' }, + { profile: skillListingProfile() }, + ); + expect(main.config.systemPrompt).not.toContain('audit-routes'); + + await mkdir(join(skillsRoot, 'audit-routes'), { recursive: true }); + await writeFile( + join(skillsRoot, 'audit-routes', 'SKILL.md'), + ['---', 'name: audit-routes', 'description: Audit routes', '---', '', 'Body.'].join('\n'), + ); + + const setActiveTools = vi.spyOn(main.tools, 'setActiveTools'); + await session.reloadSkills(); + + // Reloading the registry is not enough on its own: the listing is + // rendered into the prompt, so without a re-render the model never + // learns that the skill it was just told about exists. + expect(main.config.systemPrompt).toContain('audit-routes'); + // Only the prompt. Re-applying the whole profile would reset the tools of + // an agent that is already running. + expect(setActiveTools).not.toHaveBeenCalled(); + expect(main.config.profileName).toBe('skill-listing'); + } finally { + await session.close(); + } + }); + it('discovers sub-skills and builtins', async () => { const workDir = await makeTempDir(); const sessionDir = await makeTempDir(); @@ -1261,6 +1304,20 @@ function testProfile(): ResolvedAgentProfile { }; } +/** Renders the skill listing the way the real template's `PYTHINKER_SKILLS` does. */ +function skillListingProfile(): ResolvedAgentProfile { + return { + name: 'skill-listing', + systemPrompt: (context) => + `${ + typeof context.skills === 'string' + ? context.skills + : (context.skills?.getModelSkillListing() ?? '') + }`, + tools: [], + }; +} + function createReadToolKaos(cwd: string, content: string): Kaos { return createFakeKaos({ getcwd: () => cwd, From 87567b7302141ce607b05cb3077b9f27e7de51c4 Mon Sep 17 00:00:00 2001 From: elkaix Date: Fri, 7 Aug 2026 22:11:25 -0400 Subject: [PATCH 07/10] fix(vscode): stop the workflow card reporting work that is not happening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-agent bar was filled by `stepCount / busiest lane`. That compares agents to each other rather than measuring progress through anything, so agents doing similar amounts of work all sat near full and never visibly moved — the card read as stuck while the run was fine, and it needed a caption underneath to explain what the bar even meant. A running lane now shows the silver spinner the webview already ships, and the caption and the bar are gone. The header bar stays: agents finished over agents started is the one ratio on the card that is true and that moves when something happens. A lane also kept spinning after the workflow returned. The tool call had a result and one lane still read as running, so the card showed live work for an agent that was cancelled or cut off with the turn. Once the result is in, those lanes are marked `no result` and counted under the list instead. Rows are a table now — status, label, current activity, counts — so the counts line up in a column, and the activity line folds into the row rather than taking a second line per agent. --- apps/vscode/test/event-handlers.test.ts | 30 ++++++- .../src/components/WorkflowCard.tsx | 83 +++++++++++-------- .../webview-ui/src/lib/workflow-lanes.ts | 19 ++++- 3 files changed, 91 insertions(+), 41 deletions(-) diff --git a/apps/vscode/test/event-handlers.test.ts b/apps/vscode/test/event-handlers.test.ts index cdbffcfa..bb5183b7 100644 --- a/apps/vscode/test/event-handlers.test.ts +++ b/apps/vscode/test/event-handlers.test.ts @@ -8,7 +8,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { useChatStore } from "../webview-ui/src/stores/chat.store"; import { useApprovalStore } from "../webview-ui/src/stores/approval.store"; -import { deriveWorkflowLanes, maxLaneStepCount } from "../webview-ui/src/lib/workflow-lanes"; +import { abandonedLanes, deriveWorkflowLanes, isLaneSettled } from "../webview-ui/src/lib/workflow-lanes"; import type { UIStepItem } from "../webview-ui/src/stores/chat.store"; const boundary = vi.hoisted(() => ({ @@ -299,7 +299,7 @@ describe("Webview DynamicWorkflow per-agent lanes", () => { }); describe("workflow lane derivation", () => { - it("groups steps by agent, orders lanes by agentIndex, and sizes the bar to the busiest lane", () => { + it("groups steps by agent and orders lanes by agentIndex", () => { const steps = [ { n: 1, items: [], agentId: "b", agentLabel: "explore", agentIndex: 2 }, { n: 1, items: [], agentId: "a", agentLabel: "explore", agentIndex: 1 }, @@ -315,7 +315,31 @@ describe("workflow lane derivation", () => { expect(lanes.map((l) => l.agentId)).toEqual(["a", "b", "c"]); expect(lanes.map((l) => l.stepCount)).toEqual([2, 1, 0]); - expect(maxLaneStepCount(lanes)).toBe(2); + expect(lanes.map((l) => l.status)).toEqual(["done", "running", "spawned"]); + }); + + it("calls no lane abandoned while the workflow is still going", () => { + const lanes = [ + { status: "running" as const }, + { status: "spawned" as const }, + { status: "done" as const }, + ]; + + expect(abandonedLanes(lanes as never, false)).toEqual([]); + }); + + it("reports the lanes a finished workflow never got a result from", () => { + const lanes = [ + { agentId: "a", status: "done" as const }, + { agentId: "b", status: "failed" as const }, + // The workflow returned while this one still read as running: it was + // cancelled, or the turn ended under it. + { agentId: "c", status: "running" as const }, + { agentId: "d", status: "spawned" as const }, + ]; + + expect(abandonedLanes(lanes as never, true).map((l) => l.agentId)).toEqual(["c", "d"]); + expect(lanes.filter((l) => isLaneSettled(l)).map((l) => l.agentId)).toEqual(["a", "b"]); }); }); diff --git a/apps/vscode/webview-ui/src/components/WorkflowCard.tsx b/apps/vscode/webview-ui/src/components/WorkflowCard.tsx index 4b1d690a..ace218c1 100644 --- a/apps/vscode/webview-ui/src/components/WorkflowCard.tsx +++ b/apps/vscode/webview-ui/src/components/WorkflowCard.tsx @@ -1,8 +1,9 @@ import { useMemo, useState, type ReactNode } from "react"; import { IconChevronDown, IconChevronRight } from "@tabler/icons-react"; import { cn } from "@/lib/utils"; -import { deriveWorkflowLanes, maxLaneStepCount, type WorkflowLane } from "@/lib/workflow-lanes"; +import { abandonedLanes, deriveWorkflowLanes, isLaneSettled, type WorkflowLane } from "@/lib/workflow-lanes"; import { getToolLabel, parseArgs } from "@/lib/tool-args"; +import { SilverSpinner } from "./SilverSpinner"; import type { UIToolCall, UIStep, UIStepItem, UISubagentStatus, UIWorkflowWarning } from "@/stores/chat.store"; import type { ToolResult } from "shared/legacy-sdk"; @@ -40,47 +41,52 @@ function laneMostRecentToolLabel(lane: WorkflowLane): string | null { return null; } -function StatusDot({ status }: { status: WorkflowLane["status"] }) { - const color = - status === "running" ? "bg-brand" : status === "done" ? "bg-success" : status === "failed" ? "bg-destructive" : "bg-muted-foreground"; - return ; +/** + * A running lane spins; every other state is a dot. + * + * There used to be a per-lane bar here, filled by `stepCount / busiest lane`. + * That is a comparison between agents, not progress through anything: agents + * doing similar amounts of work all sat near full and never visibly moved, so + * the bar read as stuck while the work was fine — and it needed a caption under + * the card to explain what it even meant. A spinner claims only what is true, + * that the lane is still going. + */ +function LaneStatus({ status, spin }: { status: WorkflowLane["status"]; spin: boolean }) { + if (spin) return ; + const color = status === "done" ? "bg-success" : status === "failed" ? "bg-destructive" : "bg-muted-foreground/50"; + return ; } -function LaneBar({ fraction, done }: { fraction: number; done: boolean }) { - return ( -
-
-
- ); -} - -function LaneRow({ lane, maxSteps, renderStepItem }: { lane: WorkflowLane; maxSteps: number; renderStepItem: (item: UIStepItem) => ReactNode }) { +function LaneRow({ lane, workflowEnded, renderStepItem }: { lane: WorkflowLane; workflowEnded: boolean; renderStepItem: (item: UIStepItem) => ReactNode }) { const [expanded, setExpanded] = useState(false); - const done = lane.status === "done"; - // A finished lane always reads full: the bar is relative to the busiest agent, - // so a lane that did fewer steps than the busiest one would otherwise show a - // gap after it completed. - const fraction = done ? 1 : maxSteps > 0 ? lane.stepCount / maxSteps : 0; - const queued = lane.status === "spawned" && lane.stepCount === 0; + // The workflow returned without this lane ever reporting an outcome — it was + // cancelled, or the turn ended under it. It is not running, whatever its last + // status said, and spinning here is the card claiming work that stopped. + const abandoned = workflowEnded && !isLaneSettled(lane); + const queued = !workflowEnded && lane.status === "spawned" && lane.stepCount === 0; const runningToolLabel = lane.status === "running" ? laneMostRecentToolLabel(lane) : null; const duration = lane.startedAt !== undefined && lane.endedAt !== undefined ? formatDuration(lane.endedAt - lane.startedAt) : null; return (
- - {lane.error &&
{lane.error}
} - {runningToolLabel &&
{runningToolLabel}
} + {lane.error &&
{lane.error}
} {expanded && ( -
+
{lane.steps.map((step) => (
Step {step.n}
@@ -103,10 +109,13 @@ export function WorkflowCard({ call, result, subagentSteps, subagentStatus, work () => deriveWorkflowLanes(subagentSteps, subagentStatus), [subagentSteps, subagentStatus], ); - const maxSteps = maxLaneStepCount(lanes); const doneCount = lanes.filter((lane) => lane.status === "done").length; const totalSteps = lanes.reduce((sum, lane) => sum + lane.stepCount, 0); const showBatchBar = lanes.length > 1; + // The tool call returned, so no lane can still be doing work — whatever the + // last status event said about one that never reported an outcome. + const workflowEnded = result !== undefined; + const abandonedCount = abandonedLanes(lanes, workflowEnded).length; return (
@@ -116,10 +125,12 @@ export function WorkflowCard({ call, result, subagentSteps, subagentStatus, work {lanes.length} agent{lanes.length !== 1 ? "s" : ""} · {doneCount} done · {totalSteps} steps + {/* The one honest bar on the card: agents finished over agents started. + It only moves when a lane actually completes. */} {showBatchBar && ( <>
-
0 ? Math.round((doneCount / lanes.length) * 100) : 0}%` }} /> +
{doneCount}/{lanes.length} @@ -133,12 +144,16 @@ export function WorkflowCard({ call, result, subagentSteps, subagentStatus, work {workflowWarning.message}
)} -
+
{lanes.map((lane) => ( - + ))}
- {maxSteps > 0 &&
bar = steps relative to busiest agent ({maxSteps})
} + {abandonedCount > 0 && ( +
+ {abandonedCount} agent{abandonedCount === 1 ? "" : "s"} stopped without reporting a result. +
+ )} {result?.is_error &&
{typeof result.output === "string" ? result.output : "Workflow failed"}
}
); diff --git a/apps/vscode/webview-ui/src/lib/workflow-lanes.ts b/apps/vscode/webview-ui/src/lib/workflow-lanes.ts index 59117a5f..e777fb65 100644 --- a/apps/vscode/webview-ui/src/lib/workflow-lanes.ts +++ b/apps/vscode/webview-ui/src/lib/workflow-lanes.ts @@ -56,8 +56,19 @@ export function deriveWorkflowLanes(steps: UIStep[], statuses: Record Math.max(max, lane.stepCount), 0); +/** A lane that reported an outcome. Anything else is still owed one. */ +export function isLaneSettled(lane: Pick): boolean { + return lane.status === "done" || lane.status === "failed"; +} + +/** + * The lanes a finished workflow never got an outcome from. + * + * Empty while the workflow is still going — a lane that has not reported yet is + * simply still working. Once the tool call has returned nothing is running any + * more, so a lane still marked `running` was cancelled or cut off with the turn, + * and showing it as live is the card reporting work that already stopped. + */ +export function abandonedLanes(lanes: readonly WorkflowLane[], workflowEnded: boolean): WorkflowLane[] { + return workflowEnded ? lanes.filter((lane) => !isLaneSettled(lane)) : []; } From 722c011b57851da99ca92a3b30e3425486c3bbb8 Mon Sep 17 00:00:00 2001 From: elkaix Date: Fri, 7 Aug 2026 22:30:35 -0400 Subject: [PATCH 08/10] feat(vscode): show the permission mode in the chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/yolo` and `/auto` toggle when sent without an argument, and the chat never showed which mode was in effect. Sending `/yolo` to make sure YOLO was on turned it off instead, and nothing on screen said so — the next tool call asked for approval and the command looked broken rather than inverted. The host already fetched the mode for every status announce and dropped it before sending. It now rides along, a mode change announces itself to every attached view, and the composer carries a red badge for YOLO and AUTO in the same danger colour the terminal footer uses. Manual stays unlabelled. --- apps/vscode/shared/legacy-sdk.ts | 4 ++ apps/vscode/src/runtime/session-runtime.ts | 5 +++ apps/vscode/test/pythinker-runtime.test.ts | 35 +++++++++++++++++- .../src/components/PermissionModeBadge.tsx | 37 +++++++++++++++++++ .../vscode/webview-ui/src/components/index.ts | 1 + .../src/components/inputarea/InputArea.tsx | 4 +- .../webview-ui/src/stores/chat.store.ts | 7 +++- .../webview-ui/src/stores/event-handlers.ts | 6 ++- 8 files changed, 95 insertions(+), 4 deletions(-) create mode 100644 apps/vscode/webview-ui/src/components/PermissionModeBadge.tsx diff --git a/apps/vscode/shared/legacy-sdk.ts b/apps/vscode/shared/legacy-sdk.ts index ce07bfdf..63de4074 100644 --- a/apps/vscode/shared/legacy-sdk.ts +++ b/apps/vscode/shared/legacy-sdk.ts @@ -76,11 +76,15 @@ export interface TokenUsage { input_cache_creation: number; } +export type PermissionMode = "manual" | "auto" | "yolo"; + export interface StatusUpdate { context_usage?: number | null; token_usage?: TokenUsage | null; message_id?: string | null; plan_mode?: boolean | null; + /** The live permission mode. Without it the chat cannot show which one is on. */ + permission?: PermissionMode | null; model?: string | null; thinking_effort?: string | null; retrying?: { diff --git a/apps/vscode/src/runtime/session-runtime.ts b/apps/vscode/src/runtime/session-runtime.ts index 410b03aa..f76ad4c4 100644 --- a/apps/vscode/src/runtime/session-runtime.ts +++ b/apps/vscode/src/runtime/session-runtime.ts @@ -144,6 +144,10 @@ export class SessionRuntime { await persistPermissionMode(this.session, mode); this.currentPermissionMode = mode; } + // Tell every attached view at once. `/yolo` is a toggle, so a chat that + // cannot see the mode it landed on is how a user turns YOLO off while + // trying to turn it on. + await Promise.all([...this.webviewIds].map((id) => this.announceStatus(id))); } subscribe(webviewId: string): void { @@ -168,6 +172,7 @@ export class SessionRuntime { model: status.model, thinking_effort: status.thinkingLevel, plan_mode: status.planMode, + permission: status.permission, }, _sessionId: this.id, }, diff --git a/apps/vscode/test/pythinker-runtime.test.ts b/apps/vscode/test/pythinker-runtime.test.ts index bf88a854..468415c3 100644 --- a/apps/vscode/test/pythinker-runtime.test.ts +++ b/apps/vscode/test/pythinker-runtime.test.ts @@ -392,7 +392,40 @@ describe("Pythinker runtime (owns shared SDK sessions for Webviews)", () => { event: Events.StreamEvent, data: { type: "StatusUpdate", - payload: { model: "kimi-test", thinking_effort: "max", plan_mode: true }, + // The permission mode rides along: the chat badge is the only place the + // user can see which mode a toggle command just landed on. + payload: { model: "kimi-test", thinking_effort: "max", plan_mode: true, permission: "manual" }, + _sessionId: "saved-1", + }, + webviewId: "view-1", + }); + }); + + it("announces the new permission mode so the chat badge can show it", async () => { + const sdk = createFakeHarness(); + const broadcasts: { event: string; data: unknown; webviewId?: string }[] = []; + const runtime = new PythinkerRuntime({ + version: "0.6.0", + harness: sdk.harness, + broadcast: (event: string, data: unknown, webviewId?: string) => { + broadcasts.push({ event, data, webviewId }); + }, + captureBaseline: () => undefined, + log: () => undefined, + }); + sdk.addSession("saved-1", "/workspace", { permission: "manual" }); + const opened = await runtime.openSession(openOptions({ sessionId: "saved-1" })); + + broadcasts.length = 0; + await opened.setPermissionMode("yolo"); + + // `/yolo` toggles, so a mode the chat cannot see is a command that silently + // does the opposite of what the user meant. + expect(broadcasts).toContainEqual({ + event: Events.StreamEvent, + data: { + type: "StatusUpdate", + payload: { model: "kimi-test", thinking_effort: "off", plan_mode: false, permission: "yolo" }, _sessionId: "saved-1", }, webviewId: "view-1", diff --git a/apps/vscode/webview-ui/src/components/PermissionModeBadge.tsx b/apps/vscode/webview-ui/src/components/PermissionModeBadge.tsx new file mode 100644 index 00000000..52e6d069 --- /dev/null +++ b/apps/vscode/webview-ui/src/components/PermissionModeBadge.tsx @@ -0,0 +1,37 @@ +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import type { PermissionMode } from "shared/legacy-sdk"; + +const LABELS: Partial> = { + yolo: { + text: "YOLO", + hint: "Tool actions are auto-approved; the agent may still ask questions. Send /yolo off to stop.", + }, + auto: { + text: "AUTO", + hint: "Fully autonomous; the agent will not ask questions. Send /auto off to stop.", + }, +}; + +/** + * Shows the permission mode whenever it is not the default. + * + * `/yolo` and `/auto` toggle when sent without an argument, so a chat that + * never showed the mode let the same command mean "on" or "off" depending on + * state nobody could see — sending `/yolo` to be sure it was on turned it off. + * The terminal has always shown this; the red matches its danger row. + */ +export function PermissionModeBadge({ mode }: { mode: PermissionMode }) { + const label = LABELS[mode]; + if (label === undefined) return null; + + return ( + + + + {label.text} + + + {label.hint} + + ); +} diff --git a/apps/vscode/webview-ui/src/components/index.ts b/apps/vscode/webview-ui/src/components/index.ts index 55601c3f..224f7b5e 100644 --- a/apps/vscode/webview-ui/src/components/index.ts +++ b/apps/vscode/webview-ui/src/components/index.ts @@ -24,5 +24,6 @@ export { InlineError } from "./InlineError"; export { QuestionDialog } from "./QuestionDialog"; export { PlanCard } from "./PlanCard"; export { PlanModeButton } from "./PlanModeButton"; +export { PermissionModeBadge } from "./PermissionModeBadge"; export { BrailleSpinner } from "./BrailleSpinner"; export { SilverSpinner } from "./SilverSpinner"; diff --git a/apps/vscode/webview-ui/src/components/inputarea/InputArea.tsx b/apps/vscode/webview-ui/src/components/inputarea/InputArea.tsx index cf2e2f9a..6e8b34fe 100644 --- a/apps/vscode/webview-ui/src/components/inputarea/InputArea.tsx +++ b/apps/vscode/webview-ui/src/components/inputarea/InputArea.tsx @@ -20,6 +20,7 @@ import { BottomToolbar } from "../BottomToolbar"; import { StreamingConfirmDialog } from "../StreamingConfirmDialog"; import { ThinkingButton } from "../ThinkingButton"; import { PlanModeButton } from "../PlanModeButton"; +import { PermissionModeBadge } from "../PermissionModeBadge"; import { getModelById, getMediaFallbackModel, @@ -53,7 +54,7 @@ export function InputArea({ onAuthAction }: InputAreaProps) { const [cursorPos, setCursorPos] = useState(0); const [previewMedia, setPreviewMedia] = useState(null); - const { isStreaming, sendMessage, abort, draftMedia, removeDraftMedia, hasProcessingMedia, getMediaInConversation, pendingInput, planMode, messages } = useChatStore(); + const { isStreaming, sendMessage, abort, draftMedia, removeDraftMedia, hasProcessingMedia, getMediaInConversation, pendingInput, planMode, permissionMode, messages } = useChatStore(); const { currentModel, thinkingEffort, updateModel, toggleThinking, selectThinkingEffort, models, extensionConfig, getCurrentThinkingMode } = useSettingsStore(); const isProcessing = hasProcessingMedia(); @@ -483,6 +484,7 @@ export function InputArea({ onAuthAction }: InputAreaProps) { onSelectEffort={selectThinkingEffort} /> +
diff --git a/apps/vscode/webview-ui/src/stores/chat.store.ts b/apps/vscode/webview-ui/src/stores/chat.store.ts index bdee7f2f..be3f3f55 100644 --- a/apps/vscode/webview-ui/src/stores/chat.store.ts +++ b/apps/vscode/webview-ui/src/stores/chat.store.ts @@ -7,7 +7,7 @@ import { toast } from "@/components/ui/sonner"; import { useSettingsStore } from "./settings.store"; import { processEvent } from "./event-handlers"; -import type { StatusUpdate, ContentPart, QuestionRequest, ToolResult } from "shared/legacy-sdk"; +import type { StatusUpdate, ContentPart, PermissionMode, QuestionRequest, ToolResult } from "shared/legacy-sdk"; import type { UIStreamEvent } from "shared/types"; const HANDSHAKE_TIMEOUT_MS = 30_000; @@ -122,6 +122,8 @@ export interface ChatState { queue: QueuedItem[]; pendingQuestion: QuestionRequest | null; planMode: boolean; + /** The mode the engine is actually in, as last reported by the host. */ + permissionMode: PermissionMode; sendMessage: (text: string) => void; retryLastMessage: () => void; @@ -249,6 +251,7 @@ export const useChatStore = create((set, get) => ({ queue: [], pendingQuestion: null, planMode: false, + permissionMode: "manual", sendMessage: (text) => { const { draftMedia, isStreaming } = get(); @@ -375,6 +378,7 @@ export const useChatStore = create((set, get) => ({ queue: [], pendingQuestion: null, planMode: false, + permissionMode: "manual", }); useApprovalStore.getState().clearRequests(); @@ -429,6 +433,7 @@ export const useChatStore = create((set, get) => ({ queue: [], pendingQuestion: null, planMode: false, + permissionMode: "manual", }); useApprovalStore.getState().clearRequests(); }, diff --git a/apps/vscode/webview-ui/src/stores/event-handlers.ts b/apps/vscode/webview-ui/src/stores/event-handlers.ts index f6822e74..94651222 100644 --- a/apps/vscode/webview-ui/src/stores/event-handlers.ts +++ b/apps/vscode/webview-ui/src/stores/event-handlers.ts @@ -637,7 +637,7 @@ const eventHandlers: Record = { }, StatusUpdate: (draft, payload) => { - const { context_usage, token_usage, plan_mode, model, thinking_effort, retrying } = payload; + const { context_usage, token_usage, plan_mode, permission, model, thinking_effort, retrying } = payload; if (typeof model === "string" && model.length > 0) { useSettingsStore.getState().setCurrentModel(model); @@ -650,6 +650,10 @@ const eventHandlers: Record = { draft.planMode = plan_mode; } + if (permission !== undefined && permission !== null) { + draft.permissionMode = permission; + } + if (token_usage) { addTokenUsage(draft.activeTokenUsage, { input_other: token_usage.input_other || 0, From 579e093fe458ed36181878a7873ede115ade85f4 Mon Sep 17 00:00:00 2001 From: elkaix Date: Fri, 7 Aug 2026 23:02:24 -0400 Subject: [PATCH 09/10] test(agent-core): pin the tools and turn limit a prompt refresh must keep Cover what refreshSystemPrompt is not allowed to touch: reloadSkills now asserts the active tool names and maxStepsPerTurn are unchanged, and a new case shows a skill saved into an empty root is invocable after the reload. Also replace the ?? 0 index fallback in the experiments call-order assertion with a non-null assertion, so a missing call reports the missing call rather than a comparison against a sentinel, and use a star re-export for PermissionModeBadge. --- .../test/tui/commands/experiments.test.ts | 2 +- .../vscode/webview-ui/src/components/index.ts | 2 +- packages/agent-core/test/session/init.test.ts | 65 ++++++++++++++++++- 3 files changed, 64 insertions(+), 5 deletions(-) diff --git a/apps/pythinker-code/test/tui/commands/experiments.test.ts b/apps/pythinker-code/test/tui/commands/experiments.test.ts index 7314d65a..0a0c192c 100644 --- a/apps/pythinker-code/test/tui/commands/experiments.test.ts +++ b/apps/pythinker-code/test/tui/commands/experiments.test.ts @@ -91,7 +91,7 @@ describe('experimental feature command handlers', () => { // A flag can gate which skills exist, so rebuilding the command set before // the reload read the registry the reload was about to replace. expect(host.session.reloadSession.mock.invocationCallOrder[0]).toBeLessThan( - host.refreshSkillCommands.mock.invocationCallOrder[0] ?? 0, + host.refreshSkillCommands.mock.invocationCallOrder[0]!, ); expect(host.reloadCurrentSessionView).toHaveBeenCalledWith( host.session, diff --git a/apps/vscode/webview-ui/src/components/index.ts b/apps/vscode/webview-ui/src/components/index.ts index 224f7b5e..00e6f29c 100644 --- a/apps/vscode/webview-ui/src/components/index.ts +++ b/apps/vscode/webview-ui/src/components/index.ts @@ -24,6 +24,6 @@ export { InlineError } from "./InlineError"; export { QuestionDialog } from "./QuestionDialog"; export { PlanCard } from "./PlanCard"; export { PlanModeButton } from "./PlanModeButton"; -export { PermissionModeBadge } from "./PermissionModeBadge"; +export * from "./PermissionModeBadge"; export { BrailleSpinner } from "./BrailleSpinner"; export { SilverSpinner } from "./SilverSpinner"; diff --git a/packages/agent-core/test/session/init.test.ts b/packages/agent-core/test/session/init.test.ts index 5ca30310..318a6cfa 100644 --- a/packages/agent-core/test/session/init.test.ts +++ b/packages/agent-core/test/session/init.test.ts @@ -1181,6 +1181,7 @@ describe('AgentAPI.startBtw', () => { homedir: sessionDir, rpc: createSessionRpc([]), skills: { explicitDirs: [skillsRoot] }, + providerManager: testProviderManager(), }); try { @@ -1188,7 +1189,11 @@ describe('AgentAPI.startBtw', () => { { type: 'main' }, { profile: skillListingProfile() }, ); + main.config.update({ modelAlias: 'mock-model', thinkingLevel: 'off' }); expect(main.config.systemPrompt).not.toContain('audit-routes'); + const toolsBefore = main.tools.loopTools.map((tool) => tool.name); + expect(toolsBefore).toEqual(['Read', 'Write']); + expect(main.config.maxStepsPerTurn).toBe(7); await mkdir(join(skillsRoot, 'audit-routes'), { recursive: true }); await writeFile( @@ -1204,14 +1209,66 @@ describe('AgentAPI.startBtw', () => { // learns that the skill it was just told about exists. expect(main.config.systemPrompt).toContain('audit-routes'); // Only the prompt. Re-applying the whole profile would reset the tools of - // an agent that is already running. + // an agent that is already running, and its turn limit with them. expect(setActiveTools).not.toHaveBeenCalled(); + expect(main.tools.loopTools.map((tool) => tool.name)).toEqual(toolsBefore); + expect(main.config.maxStepsPerTurn).toBe(7); expect(main.config.profileName).toBe('skill-listing'); } finally { await session.close(); } }); + it('a skill saved into an empty root is invocable after reloadSkills', async () => { + const workDir = await makeTempDir(); + const sessionDir = await makeTempDir(); + const skillsRoot = join(workDir, 'skills'); + await mkdir(skillsRoot, { recursive: true }); + + const session = new Session({ + id: 'test-reload-skills-tool', + kaos: testKaos.withCwd(workDir), + homedir: sessionDir, + rpc: createSessionRpc([]), + skills: { explicitDirs: [skillsRoot] }, + providerManager: testProviderManager(), + }); + + try { + const { agent: main } = await session.createAgent( + { type: 'main' }, + { profile: skillListingProfile(['Skill', 'Read']) }, + ); + main.config.update({ modelAlias: 'mock-model', thinkingLevel: 'off' }); + + // The builtin set is built once, and it only carries the Skill tool when + // a skill was already invocable. The user root is empty here, so what + // keeps the tool present is `loadSkills` registering the builtin skills + // before any agent is built — `createAgent` awaits that load. Were the + // tool to go missing, a saved workflow would be listed and uncallable. + expect(main.tools.loopTools.map((tool) => tool.name)).toContain('Skill'); + + await mkdir(join(skillsRoot, 'audit-routes'), { recursive: true }); + await writeFile( + join(skillsRoot, 'audit-routes', 'SKILL.md'), + ['---', 'name: audit-routes', 'description: Audit routes', '---', '', 'Body.'].join('\n'), + ); + + const setActiveTools = vi.spyOn(main.tools, 'setActiveTools'); + await session.reloadSkills(); + + // The tool reads the registry as it runs, so the reload is all it needs + // to reach a skill written after the session opened. + expect(main.tools.loopTools.map((tool) => tool.name)).toContain('Skill'); + expect( + (await session.listSkills()).map((skill) => skill.name), + ).toContain('audit-routes'); + expect(setActiveTools).not.toHaveBeenCalled(); + } finally { + await session.close(); + } + }); + it('discovers sub-skills and builtins', async () => { const workDir = await makeTempDir(); const sessionDir = await makeTempDir(); @@ -1305,7 +1362,7 @@ function testProfile(): ResolvedAgentProfile { } /** Renders the skill listing the way the real template's `PYTHINKER_SKILLS` does. */ -function skillListingProfile(): ResolvedAgentProfile { +function skillListingProfile(tools: string[] = ['Read', 'Write']): ResolvedAgentProfile { return { name: 'skill-listing', systemPrompt: (context) => @@ -1314,7 +1371,9 @@ function skillListingProfile(): ResolvedAgentProfile { ? context.skills : (context.skills?.getModelSkillListing() ?? '') }`, - tools: [], + tools, + // Non-default, so a refresh that resets the turn limit is visible. + maxTurns: 7, }; } From ee8ee83f83ff74f037b750c8f3b755935a78508a Mon Sep 17 00:00:00 2001 From: elkaix Date: Fri, 7 Aug 2026 23:11:11 -0400 Subject: [PATCH 10/10] test(agent-core): invoke the saved skill instead of only listing it Asserting the Skill tool is present and the registry holds the skill does not exercise the lookup that activation performs. Call the tool with audit-routes and check it does not error, so a broken lookup fails the test that claims the skill is invocable. --- packages/agent-core/test/session/init.test.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/agent-core/test/session/init.test.ts b/packages/agent-core/test/session/init.test.ts index 318a6cfa..21378256 100644 --- a/packages/agent-core/test/session/init.test.ts +++ b/packages/agent-core/test/session/init.test.ts @@ -1259,10 +1259,16 @@ describe('AgentAPI.startBtw', () => { // The tool reads the registry as it runs, so the reload is all it needs // to reach a skill written after the session opened. - expect(main.tools.loopTools.map((tool) => tool.name)).toContain('Skill'); - expect( - (await session.listSkills()).map((skill) => skill.name), - ).toContain('audit-routes'); + const skill = main.tools.loopTools.find((tool) => tool.name === 'Skill'); + expect(skill).toBeDefined(); + const result = await executeTool(skill!, { + turnId: '0', + toolCallId: 'call_skill', + args: { skill: 'audit-routes' }, + signal: new AbortController().signal, + }); + expect(result.isError).not.toBe(true); + expect(JSON.stringify(result.output)).toContain('audit-routes'); expect(setActiveTools).not.toHaveBeenCalled(); } finally { await session.close();