From 55a0d996c187ae1e4b774c508e1ab90a335eedec Mon Sep 17 00:00:00 2001 From: Endri Bezati Date: Tue, 15 Sep 2026 14:37:10 +0200 Subject: [PATCH 1/3] The sidecar profile hands the platform's askUser to the run driver The profile rebuilt the driver's host from three fields and dropped the human port, so a running agent's question never reached the chat and the driver prompted through VS Code's quick pick. Forwarded, with a test that pins it. --- .../src/sidecar-diagram-profile.ts | 5 +- .../test/sidecar-profile-run-host.test.ts | 82 +++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 packages/sidecar-toolkit/test/sidecar-profile-run-host.test.ts diff --git a/packages/sidecar-toolkit/src/sidecar-diagram-profile.ts b/packages/sidecar-toolkit/src/sidecar-diagram-profile.ts index bafc133..5ef9325 100644 --- a/packages/sidecar-toolkit/src/sidecar-diagram-profile.ts +++ b/packages/sidecar-toolkit/src/sidecar-diagram-profile.ts @@ -341,7 +341,10 @@ export function createSidecarDiagramProfile(input: SidecarProfileInput) { const driver = new CliRunDriver(config, { overlay: host.overlay, requestRefresh: host.requestRefresh, - output: host.output + output: host.output, + // The human port: a running agent's question goes to the platform + // (the chat panel on the diagram); without it the driver prompts. + askUser: host.askUser ? (question, sourceUri) => host.askUser!(question, sourceUri) : undefined }); driver.registerCommands(context); host.useLiveOverlaySignatureSource({ diff --git a/packages/sidecar-toolkit/test/sidecar-profile-run-host.test.ts b/packages/sidecar-toolkit/test/sidecar-profile-run-host.test.ts new file mode 100644 index 0000000..592bb2c --- /dev/null +++ b/packages/sidecar-toolkit/test/sidecar-profile-run-host.test.ts @@ -0,0 +1,82 @@ +// The run driver's host is the platform's, field by field; the human port +// (`askUser`) must be among them, or a running agent's question never reaches +// the chat and the driver prompts through VS Code instead. +import { describe, expect, it, vi } from 'vitest'; +import * as vscode from 'vscode'; + +const constructed: Array<{ host: any }> = []; +vi.mock('../src/cli-run-driver.js', () => ({ + CliRunDriver: class { + constructor(_config: unknown, host: any) { + constructed.push({ host }); + } + registerCommands(): void {} + watchLiveOverlay(): { dispose(): void } { return { dispose: () => {} }; } + onLiveOverlaySignature(): { dispose(): void } { return { dispose: () => {} }; } + dispose(): void {} + } +})); + +import { createSidecarDiagramProfile, type SidecarProfileInput } from '../src/sidecar-diagram-profile'; + +function input(): SidecarProfileInput { + const commands = Object.fromEntries([ + 'openDiagram', 'openDiagramSplit', 'layoutDiagram', 'refreshDiagramModel', 'renameEntityByName', 'undo', 'redo', + 'fitToScreen', 'center', 'exportSvg', 'toggleGrid', 'setQueueTraceVisible', 'stopWorkflow', 'runWorkflow', + 'layoutDiagramIfNeeded', 'setAgentToolConfig', 'getAgentToolConfig', 'createAgentToolPolicyFile', + 'chatAddViewerEditor', 'chatAddViewerTask', 'sidecarEdit', 'sidecarSend', 'createNewContainer' + ].map((k) => [k, `pfx.${k}`])) as SidecarProfileInput['commands']; + return { + key: 'p', displayName: 'P', settingsNamespace: 'p', customEditorViewType: 'p.diagram', + glspClientId: 'p.client', glspClientName: 'p', commands, + operationKinds: { createEntityPort: 'op.c', deleteEntityPort: 'op.d' }, + sidecarOperationPrefix: 'p', sidecarCommandSettingKey: 'sidecarCommand', sidecarCommandDefault: 'p-sidecar', + cliCommandSettingKey: 'cliCommand', cliCommandDefault: 'p', acceptedOperationPrefixes: ['p'], + graphAcquisition: 'cli-plan', cliGraphArgs: (file) => ['plan', file], + undoLabelSuffix: ' (p)', + createNodeStrings: { + newTypeNamePrompt: () => 'n', typeLabel: () => 't', classNamePlaceholder: () => 'c', sidecarDisplayName: 's', + invalidCapabilitiesResponse: 'i', missingCapabilities: () => 'm', invalidListResponse: () => 'l' + }, + createNodeBehavior: { capabilityProbeBeforeCreate: false, mergeProjectDiscoveredTypes: true, surfaceSidecarListErrors: false }, + sourceExtension: '.py', exportOp: 'export', mcpEnabledSetting: { section: 'p.chat', key: 'enableMcpTools', default: true }, + scopeArgKey: 'workflow', newContainer: { label: 'W', decorator: 'workflow', importLine: 'from p import workflow' }, + identifierNoun: 'Python', + runOutputDirSettingKey: 'runOutputDir', liveExecutionGlowSettingKey: 'glow', agentToolsSettingKey: 'agentTools', + agentToolAuthSettingKey: 'agentToolAuth', agentToolPolicySettingKey: 'agentToolPolicy', + agentToolTimeoutMsSettingKey: 'agentToolTimeoutMs', agentToolRegistrySettingKey: 'agentToolRegistry', + agentMcpBridgeCmdSettingKey: 'agentMcpBridgeCmd', + chat: { name: 'p', fullName: 'P chat' } + } as SidecarProfileInput; +} + +function platformHost(askUser?: (q: unknown, uri: string) => Promise) { + return { + overlay: { emitEvents: () => {} }, + requestRefresh: () => {}, + output: { appendLine: () => {}, append: () => {}, show: () => {} } as unknown as vscode.OutputChannel, + useLiveOverlaySignatureSource: () => {}, + askUser + }; +} + +describe("the run driver's host", () => { + const context = { workspaceState: { get: () => undefined, update: async () => undefined }, subscriptions: [] } as any; + + it("forwards the platform's askUser, question and diagram alike", async () => { + constructed.length = 0; + const asked: Array<[unknown, string]> = []; + const profile = createSidecarDiagramProfile(input()); + profile.runDriver!(context, platformHost(async (q, uri) => { asked.push([q, uri]); return { answer: 'Allow' }; }) as any); + const host = constructed[0].host; + const question = { id: 1, agent: 'ask', question: 'Write /tmp/ask-flow/1-x.txt', choices: ['Allow', 'Reject'] }; + await expect(host.askUser(question, 'file:///w/flow.py')).resolves.toEqual({ answer: 'Allow' }); + expect(asked).toEqual([[question, 'file:///w/flow.py']]); + }); + + it('leaves askUser out when the platform has none, so the driver prompts itself', () => { + constructed.length = 0; + createSidecarDiagramProfile(input()).runDriver!(context, platformHost() as any); + expect(constructed[0].host.askUser).toBeUndefined(); + }); +}); From 5b9a9ccdddbfe15735247ffec528a4652ac495c0 Mon Sep 17 00:00:00 2001 From: Endri Bezati Date: Tue, 15 Sep 2026 14:54:09 +0200 Subject: [PATCH 2/3] The chat's run viewer opens the reasoning while an agent streams; a tool call is labelled by its title The reasoning was a collapsed block, so the chat showed "Thinking" and nothing else while the bar showed the text; it is open while the agent runs and collapses when it is done. An ACP tool call has a title and a kind rather than a name, and read as "unknown"; the title, or the kind, labels it now (wfpy carries them since its fix). --- .../src/chat-panel-integrated.ts | 2 +- .../src/editing-action-handlers.ts | 10 +++++-- .../test/run-agents-tool-call-title.test.ts | 28 +++++++++++++++++++ 3 files changed, 36 insertions(+), 4 deletions(-) create mode 100644 packages/diagram-client/test/run-agents-tool-call-title.test.ts diff --git a/packages/diagram-client/src/chat-panel-integrated.ts b/packages/diagram-client/src/chat-panel-integrated.ts index efab43d..d3f38a7 100644 --- a/packages/diagram-client/src/chat-panel-integrated.ts +++ b/packages/diagram-client/src/chat-panel-integrated.ts @@ -1300,7 +1300,7 @@ export class ChatPanel implements IDiagramStartup, ISelectionListener { ${a.instance} ${a.status === 'running' ? 'streaming…' : 'done'} - ${a.reasoning ? this.thinkingTemplate(a.reasoning) : nothing} + ${a.reasoning ? this.thinkingTemplate(a.reasoning, { open: a.status === 'running' }) : nothing} ${a.toolCalls.length ? html`
diff --git a/packages/diagram-client/src/editing-action-handlers.ts b/packages/diagram-client/src/editing-action-handlers.ts index f30078c..2915b15 100644 --- a/packages/diagram-client/src/editing-action-handlers.ts +++ b/packages/diagram-client/src/editing-action-handlers.ts @@ -1051,11 +1051,15 @@ export class RunAgentStreamActionHandler implements IActionHandler { state.text += String(ev.delta ?? ''); } break; - case 'agent.tool_call': - if (ev.name) { - state.toolCalls.push(String(ev.name)); + case 'agent.tool_call': { + // The runtime names the call (`name`); an ACP agent's call has + // a title ("Write choice-a.txt") where the name may be missing. + const label = ev.name ?? ev.title ?? ev.kind; + if (label) { + state.toolCalls.push(String(label)); } break; + } case 'agent.message.end': state.status = 'done'; break; diff --git a/packages/diagram-client/test/run-agents-tool-call-title.test.ts b/packages/diagram-client/test/run-agents-tool-call-title.test.ts new file mode 100644 index 0000000..1004c4b --- /dev/null +++ b/packages/diagram-client/test/run-agents-tool-call-title.test.ts @@ -0,0 +1,28 @@ +// An ACP agent's tool call arrives with a title and a kind rather than a +// name; the live state labels it by the title, so the chat and the bar say +// "Write choice-a.txt" and not "unknown". +import 'reflect-metadata'; +import { afterEach, describe, expect, it } from 'vitest'; +import { EXECUTION_OVERLAY_ACTION_KIND } from '@dialogram/shared'; +import { RunAgentStreamActionHandler } from '../src/editing-action-handlers'; + +afterEach(() => RunAgentStreamActionHandler.reset()); + +describe('a tool call in the live agent state', () => { + it('is labelled by its name, else its title, else its kind', () => { + const handler = new RunAgentStreamActionHandler(); + handler.handle({ + kind: EXECUTION_OVERLAY_ACTION_KIND, + events: [ + { seq: 1, type: 'run.started' }, + { seq: 2, type: 'agent.message.start', instance: 'ask' }, + { seq: 3, type: 'agent.tool_call', instance: 'ask', name: 'read_file', title: 'Read x' }, + { seq: 4, type: 'agent.tool_call', instance: 'ask', title: 'Write choice-a.txt', kind: 'edit' }, + { seq: 5, type: 'agent.tool_call', instance: 'ask', kind: 'execute' }, + { seq: 6, type: 'agent.tool_call', instance: 'ask' } + ] + } as any); + const [agent] = RunAgentStreamActionHandler.getAgents(); + expect(agent.toolCalls).toEqual(['read_file', 'Write choice-a.txt', 'execute']); + }); +}); From 0b6e7a36e0d000d88297bb4db93891ff43eca989 Mon Sep 17 00:00:00 2001 From: Endri Bezati Date: Tue, 15 Sep 2026 16:00:02 +0200 Subject: [PATCH 3/3] The chat shows either the session or one of the run's agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two kinds of conversation, switched from the topbar: Session, the diagram's own chat with its agent (selector, new/rename/delete, the composer), and Run · n, the run's agents, each read only. A running agent's view is its transcript in arrival order (turns, reasoning open while it streams, text, tool calls by title with their status) with its questions inline where they were asked, answered from there; the footer says it takes no prompt. A question switches the panel to its agent, unless the user is typing to the session, then a banner with Go and a badge on the Run segment. The bar on the canvas opens an agent in the chat on click and hides while the chat shows one. The live state keeps an ordered transcript per agent beside the bar's per-turn summary. --- .../src/chat-panel-integrated.ts | 445 ++++++++++++------ packages/diagram-client/src/chat-panel.css | 141 ++++-- .../diagram-client/src/diagram-client.css | 1 + .../src/editing-action-handlers.ts | 117 ++++- .../diagram-client/src/running-agents-bar.ts | 14 +- .../test/chat-panel-run-question.test.ts | 55 ++- .../test/run-agents-transcript.test.ts | 65 +++ 7 files changed, 631 insertions(+), 207 deletions(-) create mode 100644 packages/diagram-client/test/run-agents-transcript.test.ts diff --git a/packages/diagram-client/src/chat-panel-integrated.ts b/packages/diagram-client/src/chat-panel-integrated.ts index d3f38a7..1a827af 100644 --- a/packages/diagram-client/src/chat-panel-integrated.ts +++ b/packages/diagram-client/src/chat-panel-integrated.ts @@ -8,7 +8,12 @@ import { repeat } from 'lit/directives/repeat.js'; import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import { renderMarkdownSafe } from './markdown'; import { shouldStick } from './chat-scroll'; -import { RunAgentStreamActionHandler, type LiveAgentState } from './editing-action-handlers'; +import { + RunAgentStreamActionHandler, + type LiveAgentPart, + type LiveAgentQuestion, + type LiveAgentState +} from './editing-action-handlers'; /** * Memoized markdown → HTML. The chat template runs `renderMarkdownSafe` for every @@ -109,20 +114,13 @@ interface PermissionItem { resolved?: 'allowed' | 'denied'; } -/** A running agent's question to the user (the run driver's human port), - * answered here: the chat is the run's viewer, not its session. */ -interface QuestionItem { - kind: 'question'; - id: number | string; - agent: string; - model?: string; - question: string; - context?: string; - choices: string[]; - resolved?: { answer?: string; declined?: boolean }; -} +type TimelineItem = MessageItem | ToolItem | PermissionItem; -type TimelineItem = MessageItem | ToolItem | PermissionItem | QuestionItem; +/** + * What the panel shows: the diagram's own session with its agent, or one of + * the run's agents, read only (its transcript and its questions). + */ +type ChatView = { kind: 'session' } | { kind: 'agent'; instance: string }; interface SessionEntry { id: string; @@ -182,6 +180,10 @@ export class ChatPanel implements IDiagramStartup, ISelectionListener { /** The ACP connector the chat is (or was) connected to, as the host names it. */ private connectionAgent = ''; private inputValue = ''; + private view: ChatView = { kind: 'session' }; + /** A running agent's question that arrived while the user was typing in + * the session: shown as a banner instead of switching the view. */ + private runBanner: LiveAgentQuestion | null = null; /** Live diagram selection (node ids), mirrored to the host for chat context. */ private selectedNodeIds: string[] = []; @@ -259,11 +261,52 @@ export class ChatPanel implements IDiagramStartup, ISelectionListener { // bar renders; the panel shows them too, as a read-only viewer. if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') { window.addEventListener('dialogram.runAgents.updated', () => this.update()); + window.addEventListener('dialogram.chat.showAgent', (e) => { + const instance = (e as CustomEvent<{ instance?: string }>).detail?.instance; + if (instance) this.showAgent(instance); + }); } this.update(); this.requestState(); } + // ── The view: the session, or one of the run's agents ───────────────── + + /** Show a running agent's transcript (and open the panel). */ + showAgent(instance: string): void { + this.view = { kind: 'agent', instance }; + if (this.runBanner?.agent === instance) this.runBanner = null; + this.show(); + this.update(); + this.announceRunView(); + } + + /** Back to the diagram's own session. */ + showSession(): void { + this.view = { kind: 'session' }; + this.update(); + this.announceRunView(); + } + + /** The Run segment: the agent with a question waiting, else the latest. */ + private openRun(): void { + const agents = RunAgentStreamActionHandler.getAgents(); + const target = agents.find((a) => a.pendingQuestions > 0) ?? agents[0]; + if (target) this.showAgent(target.instance); + } + + /** The bar on the canvas hides while the panel shows a running agent. */ + private announceRunView(): void { + if (typeof window === 'undefined' || typeof window.dispatchEvent !== 'function') return; + const visible = this.isVisible && this.view.kind === 'agent'; + window.dispatchEvent(new CustomEvent('dialogram.chat.runView', { detail: { visible } })); + } + + /** The user is in the middle of a message to the session. */ + private isComposing(): boolean { + return this.view.kind === 'session' && this.inputValue.trim().length > 0; + } + // ── Transport ─────────────────────────────────────────────────────────── /** @@ -496,17 +539,25 @@ export class ChatPanel implements IDiagramStartup, ISelectionListener { case 'chat.runQuestion': if (data && (typeof data.id === 'number' || typeof data.id === 'string') && typeof data.question === 'string') { - this.timeline.push({ - kind: 'question', + const question: LiveAgentQuestion = { id: data.id, - agent: typeof data.agent === 'string' && data.agent ? data.agent : 'An agent', + agent: typeof data.agent === 'string' && data.agent ? data.agent : 'agent', model: typeof data.model === 'string' ? data.model : undefined, question: data.question, context: typeof data.context === 'string' && data.context ? data.context : undefined, choices: Array.isArray(data.choices) ? data.choices.map(String) : [], - }); - this.update(); + }; + RunAgentStreamActionHandler.addQuestion(question.agent, question); + if (this.isComposing()) { + // Do not pull the user out of a message: a banner, and the Run segment's badge. + this.runBanner = question; + this.update(); + } else { + this.view = { kind: 'agent', instance: question.agent }; + this.update(); + } this.autoShow('run-question'); + this.announceRunView(); } break; @@ -882,15 +933,18 @@ export class ChatPanel implements IDiagramStartup, ISelectionListener { } /** Answer a running agent's question (or decline it, `answer` undefined). */ - private answerRunQuestion(item: QuestionItem, answer: string | undefined): void { - if (item.resolved) return; + private answerRunQuestion(q: LiveAgentQuestion, answer: string | undefined): void { + if (q.resolved) return; + let resolved: { answer?: string; declined?: boolean }; if (answer === undefined) { - this.sendToHost('chat.runAnswer', { id: item.id, declined: true, reason: 'declined in the chat' }); - item.resolved = { declined: true }; + this.sendToHost('chat.runAnswer', { id: q.id, declined: true, reason: 'declined in the chat' }); + resolved = { declined: true }; } else { - this.sendToHost('chat.runAnswer', { id: item.id, answer }); - item.resolved = { answer }; + this.sendToHost('chat.runAnswer', { id: q.id, answer }); + resolved = { answer }; } + RunAgentStreamActionHandler.resolveQuestion(q.agent, q.id, resolved); + if (this.runBanner?.id === q.id) this.runBanner = null; this.update(); } @@ -980,6 +1034,7 @@ export class ChatPanel implements IDiagramStartup, ISelectionListener { document.body.classList.add(ChatPanel.VISIBLE_BODY_CLASS); this.isVisible = true; this.focusInput(); + this.announceRunView(); } hide(): void { @@ -987,6 +1042,7 @@ export class ChatPanel implements IDiagramStartup, ISelectionListener { this.panel.classList.remove('visible'); document.body.classList.remove(ChatPanel.VISIBLE_BODY_CLASS); this.isVisible = false; + this.announceRunView(); } toggle(): void { @@ -1107,7 +1163,6 @@ export class ChatPanel implements IDiagramStartup, ISelectionListener { return ( !this.currentSessionId && this.timeline.length === 0 && - !this.hasRunView && !this.streamingText && !this.streamingThinking && !this.showTyping && @@ -1115,11 +1170,6 @@ export class ChatPanel implements IDiagramStartup, ISelectionListener { ); } - /** Whether a run's agents are (or were, this run) streaming: the viewer shows. */ - private get hasRunView(): boolean { - return RunAgentStreamActionHandler.isRunActive() || RunAgentStreamActionHandler.getAgents().length > 0; - } - /** Placeholder copy for the empty state — varies on whether sessions exist. */ private get emptyStateText(): string { return this.sessions.length === 0 @@ -1167,44 +1217,48 @@ export class ChatPanel implements IDiagramStartup, ISelectionListener {
this.startResize(e)}>
${this.topbarTemplate()}
- ${this.isEmptyState - ? html`
- - ${this.emptyStateText} -
` - : nothing} - ${this.isLoadingSession - ? html`
- - ${this.loadingLabel} -
` - : nothing} - ${this.runViewerTemplate()} - ${repeat( - this.timeline, - (item, i) => - item.kind === 'tool' - ? `t${item.id}` - : item.kind === 'permission' - ? `p${item.requestId}` - : item.kind === 'question' - ? `q${item.id}` - : `m${i}`, - (item) => this.itemTemplate(item) - )} - ${this.streamingText || this.streamingThinking ? this.streamingTemplate() : nothing} - ${this.showTyping ? html`
` : nothing} + ${this.view.kind === 'agent' ? this.agentTranscriptTemplate(this.view.instance) : this.sessionBodyTemplate()}
- ${this.reverted - ? html`
- - Session reverted — messages and file changes were undone. - + ${this.view.kind === 'agent' + ? this.viewerBarTemplate() + : html` + ${this.reverted + ? html`
+ + Session reverted — messages and file changes were undone. + +
` + : nothing} + ${this.runBannerTemplate()} + ${this.composerTemplate()} + `} + `; + } + + /** The session's timeline: messages, tool calls, its own permission requests. */ + private sessionBodyTemplate(): TemplateResult { + return html` + ${this.isEmptyState + ? html`
+ + ${this.emptyStateText}
` : nothing} - ${this.composerTemplate()} + ${this.isLoadingSession + ? html`
+ + ${this.loadingLabel} +
` + : nothing} + ${repeat( + this.timeline, + (item, i) => (item.kind === 'tool' ? `t${item.id}` : item.kind === 'permission' ? `p${item.requestId}` : `m${i}`), + (item) => this.itemTemplate(item) + )} + ${this.streamingText || this.streamingThinking ? this.streamingTemplate() : nothing} + ${this.showTyping ? html`
` : nothing} `; } @@ -1231,30 +1285,35 @@ export class ChatPanel implements IDiagramStartup, ISelectionListener { Chat - { - const value = (e.target as any).value as string; - if (value && value !== this.currentSessionId) this.loadSession(value); - }} - > - ${!this.currentSessionId - ? html`${this.sessions.length === 0 ? 'No session' : 'Select a session'}` - : nothing} - ${this.sessions.map( - (s) => html`${s.name}` - )} - - - - + ${this.segmentsTemplate()} + ${this.view.kind === 'session' + ? html` + { + const value = (e.target as any).value as string; + if (value && value !== this.currentSessionId) this.loadSession(value); + }} + > + ${!this.currentSessionId + ? html`${this.sessions.length === 0 ? 'No session' : 'Select a session'}` + : nothing} + ${this.sessions.map( + (s) => html`${s.name}` + )} + + + + + ` + : this.agentSelectTemplate(this.view.instance)}
${statusLabel} @@ -1269,89 +1328,167 @@ export class ChatPanel implements IDiagramStartup, ISelectionListener { `; } - /** - * The run's agents, read-only: the same live state (text, reasoning, tool - * calls) the "Running agents" bar folds from the run's event stream. Their - * questions arrive as `question` items below, answered from here. - */ - private runViewerTemplate(): TemplateResult | typeof nothing { + // ── The run's agents ──────────────────────────────────────────────── + + /** Session | Run · n, with a badge for the questions waiting. */ + private segmentsTemplate(): TemplateResult { const agents = RunAgentStreamActionHandler.getAgents(); const active = RunAgentStreamActionHandler.isRunActive(); - if (!active && agents.length === 0) return nothing; + const pending = RunAgentStreamActionHandler.pendingQuestionCount(); + const isAgent = this.view.kind === 'agent'; return html` -
-
- - Running agents - ${active ? html`live` : nothing} -
- ${agents.length === 0 - ? html`
Waiting for agents…
` - : agents.map((a) => this.runAgentTemplate(a))} +
+ +
`; } - private runAgentTemplate(a: LiveAgentState): TemplateResult { + /** The run's agents, one selected: what the topbar shows in the agent view. */ + private agentSelectTemplate(instance: string): TemplateResult { + const agents = RunAgentStreamActionHandler.getAgents(); + const label = (a: LiveAgentState): string => + `${a.instance}${a.pendingQuestions > 0 ? ' (asking…)' : a.status === 'running' ? ' (streaming…)' : ' (done)'}`; return html` -
+ { + const value = (e.target as any).value as string; + if (value && value !== instance) this.showAgent(value); + }} + > + ${agents.map((a) => html`${label(a)}`)} + + `; + } + + /** A running agent's transcript: its turns, reasoning, text, tool calls and questions, in order. */ + private agentTranscriptTemplate(instance: string): TemplateResult { + const a = RunAgentStreamActionHandler.getAgent(instance); + if (!a) { + return html`
+ + No agent “${instance}” in this run. +
`; + } + const lastReasoning = [...a.parts].reverse().find((p) => p.kind === 'reasoning'); + return html` +
${a.instance} - ${a.status === 'running' ? 'streaming…' : 'done'} + ${a.pendingQuestions > 0 ? 'asking…' : a.status === 'running' ? 'streaming…' : 'done'}
- ${a.reasoning ? this.thinkingTemplate(a.reasoning, { open: a.status === 'running' }) : nothing} - ${a.toolCalls.length - ? html`
- - ${a.toolCalls.join(', ')} -
` - : nothing} -
${a.text || (a.status === 'running' ? '…' : '')}
+ ${a.parts.length === 0 ? html`
Waiting for the agent…
` : nothing} + ${a.parts.map((p) => this.partTemplate(p, p === lastReasoning && a.status === 'running'))}
`; } - private itemTemplate(item: TimelineItem): TemplateResult { - if (item.kind === 'question') { - const who = item.model ? `${item.agent} (${item.model})` : item.agent; - const readInput = (e: Event): string => - ((e.currentTarget as HTMLElement | null)?.closest('.chat-question')?.querySelector('input') as HTMLInputElement | null) - ?.value ?? ''; - return html` -
-
- - ${who} asks + private partTemplate(p: LiveAgentPart, open: boolean): TemplateResult { + switch (p.kind) { + case 'turn': + return html`
turn ${p.index}
`; + case 'reasoning': + return this.thinkingTemplate(p.text, { open }); + case 'text': + return html`
${p.text}
`; + case 'tool': { + const icon = p.status === 'completed' ? 'codicon-check' : p.status === 'failed' ? 'codicon-warning' : 'codicon-tools'; + const statusText = p.status && p.status !== 'completed' ? ` — ${p.status.replace('_', ' ')}` : ''; + return html` +
+ + ${p.name} + ${statusText}
-
${item.question}
- ${item.context ? html`
${item.context}
` : nothing} - ${item.resolved - ? html`
- ${item.resolved.declined ? 'Declined' : `Answered: ${item.resolved.answer}`} -
` - : item.choices.length > 0 - ? html`
- ${item.choices.map( - (c) => html`` - )} - -
` - : html`
- { - if (e.key === 'Enter') this.answerRunQuestion(item, (e.currentTarget as HTMLInputElement).value); - }} - /> - - -
`} -
- `; + `; + } + case 'question': + return this.questionTemplate(p.question); } + } + + /** The footer under a running agent: no prompt, answers only. */ + private viewerBarTemplate(): TemplateResult { + return html` +
+ + Viewer of a running agent: its questions are answered above; it takes no prompt. + +
+ `; + } + /** A question that arrived while the user was typing: a way to it. */ + private runBannerTemplate(): TemplateResult | typeof nothing { + const q = this.runBanner; + if (!q || q.resolved) return nothing; + return html` +
+ + ${q.agent} asks: ${q.question} + +
+ `; + } + + private questionTemplate(q: LiveAgentQuestion): TemplateResult { + const who = q.model ? `${q.agent} (${q.model})` : q.agent; + const readInput = (e: Event): string => + ((e.currentTarget as HTMLElement | null)?.closest('.chat-question')?.querySelector('input') as HTMLInputElement | null) + ?.value ?? ''; + return html` +
+
+ + ${who} asks +
+
${q.question}
+ ${q.context ? html`
${q.context}
` : nothing} + ${q.resolved + ? html`
+ ${q.resolved.declined ? 'Declined' : `Answered: ${q.resolved.answer}`} +
` + : q.choices.length > 0 + ? html`
+ ${q.choices.map( + (c) => html`` + )} + +
` + : html`
+ { + if (e.key === 'Enter') this.answerRunQuestion(q, (e.currentTarget as HTMLInputElement).value); + }} + /> + + +
`} +
+ `; + } + + private itemTemplate(item: TimelineItem): TemplateResult { if (item.kind === 'tool') { const icon = item.status === 'completed' diff --git a/packages/diagram-client/src/chat-panel.css b/packages/diagram-client/src/chat-panel.css index f295e47..e653095 100644 --- a/packages/diagram-client/src/chat-panel.css +++ b/packages/diagram-client/src/chat-panel.css @@ -545,50 +545,78 @@ font-size: 12px; } -/* ── The run's agents, as the chat's read-only viewer ── */ -.chat-run { - margin: 8px 16px; +/* ── Session | Run: which conversation the panel shows ── */ +.chat-segments { + display: inline-flex; border: 1px solid var(--chat-border); border-radius: 4px; + overflow: hidden; + flex: none; } -.chat-run-head { - display: flex; +.chat-segment { + display: inline-flex; align-items: center; - gap: 7px; - padding: 6px 10px; - font-size: 11px; - text-transform: uppercase; - letter-spacing: 0.04em; + gap: 6px; + padding: 2px 10px; + border: none; + background: transparent; + color: var(--vscode-foreground); + font-size: 11.5px; + cursor: pointer; opacity: 0.8; - border-bottom: 1px solid var(--chat-border); } -.chat-run-live { - margin-left: auto; - font-size: 10px; - padding: 1px 6px; +.chat-segment + .chat-segment { + border-left: 1px solid var(--chat-border); +} +.chat-segment:hover:not(:disabled) { + opacity: 1; + background: var(--vscode-toolbar-hoverBackground, rgba(128, 128, 128, 0.15)); +} +.chat-segment.active { + opacity: 1; + background: var(--vscode-button-background); + color: var(--vscode-button-foreground); +} +.chat-segment:disabled { + opacity: 0.4; + cursor: default; +} +.chat-segment-badge { + min-width: 16px; + padding: 0 5px; border-radius: 8px; - background: var(--vscode-testing-iconPassed, #3fb950); - color: var(--vscode-editor-background, #1e1e1e); - text-transform: none; - letter-spacing: 0; + background: var(--vscode-inputValidation-infoBorder, #007acc); + color: #fff; + font-size: 10px; + line-height: 16px; + text-align: center; } -.chat-run-empty { - padding: 8px 10px; - font-size: 12px; - opacity: 0.7; +.chat-segment.active .chat-segment-badge { + background: var(--vscode-button-foreground); + color: var(--vscode-button-background); } -.chat-run-agent { - padding: 8px 10px; - border-bottom: 1px solid var(--chat-border); +.chat-segment-live { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--vscode-testing-iconPassed, #3fb950); +} +.chat-agent-select { + min-width: 160px; + max-width: 260px; } -.chat-run-agent:last-child { - border-bottom: none; + +/* ── A running agent's transcript, read only ── */ +.chat-run-transcript { + padding: 4px 16px 8px; } .chat-run-agent-head { display: flex; align-items: center; gap: 6px; font-size: 12px; + padding: 6px 0; + border-bottom: 1px solid var(--chat-border); margin-bottom: 4px; } .chat-run-dot { @@ -611,13 +639,66 @@ font-size: 11px; opacity: 0.7; } +.chat-run-empty { + padding: 8px 0; + font-size: 12px; + opacity: 0.7; +} +.chat-run-turn { + display: flex; + align-items: center; + gap: 8px; + margin: 10px 0 4px; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.06em; + opacity: 0.6; +} +.chat-run-turn::before, +.chat-run-turn::after { + content: ''; + flex: 1; + border-top: 1px solid var(--chat-border); +} +.chat-run-transcript .chat-question { + margin: 8px 0; +} .chat-run-text { white-space: pre-wrap; word-break: break-word; font-size: 12px; line-height: 1.4; - max-height: 200px; - overflow-y: auto; + margin: 4px 0; +} +.chat-viewer-bar { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + border-top: 1px solid var(--chat-border); + font-size: 12px; + opacity: 0.9; +} +.chat-viewer-bar span:nth-child(2) { + flex: 1; +} +.chat-run-banner { + display: flex; + align-items: center; + gap: 8px; + margin: 0 16px 6px; + padding: 6px 10px; + border: 1px solid var(--vscode-inputValidation-infoBorder, #007acc); + border-left-width: 3px; + border-radius: 4px; + background: color-mix(in srgb, var(--vscode-inputValidation-infoBackground, #063b49) 60%, transparent); + font-size: 12px; +} +.chat-run-banner-text { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } /* ── Composer ── */ diff --git a/packages/diagram-client/src/diagram-client.css b/packages/diagram-client/src/diagram-client.css index 578e2c5..09ed365 100644 --- a/packages/diagram-client/src/diagram-client.css +++ b/packages/diagram-client/src/diagram-client.css @@ -3186,6 +3186,7 @@ details[open] > summary .agent-chat-expand-arrow { gap: 6px; } .wf-rab-empty { opacity: 0.6; padding: 8px; } +.wf-rab-card { cursor: pointer; } .wf-rab-card { border: 1px solid var(--vscode-panel-border); border-radius: 6px; diff --git a/packages/diagram-client/src/editing-action-handlers.ts b/packages/diagram-client/src/editing-action-handlers.ts index 2915b15..f1d1661 100644 --- a/packages/diagram-client/src/editing-action-handlers.ts +++ b/packages/diagram-client/src/editing-action-handlers.ts @@ -939,14 +939,40 @@ export interface RunStreamEvent { [key: string]: unknown; } +/** A running agent's question to the user (the run driver's human port). */ +export interface LiveAgentQuestion { + id: number | string; + agent: string; + model?: string; + question: string; + context?: string; + choices: string[]; + resolved?: { answer?: string; declined?: boolean }; +} + +/** One entry of an agent's transcript, in arrival order. */ +export type LiveAgentPart = + | { kind: 'turn'; index: number } + | { kind: 'reasoning'; text: string } + | { kind: 'text'; text: string } + | { kind: 'tool'; name: string; status?: string; id?: string } + | { kind: 'question'; question: LiveAgentQuestion }; + export interface LiveAgentState { instance: string; + /** The current turn's text, reasoning and tool calls (the bar's summary). */ text: string; reasoning: string; status: 'running' | 'done'; toolCalls: string[]; lastSeq: number; updatedAt: number; + /** Turns (firings) seen so far. */ + turns: number; + /** The whole run's transcript of this agent: every turn, in order. */ + parts: LiveAgentPart[]; + /** Questions of this agent still waiting for an answer. */ + pendingQuestions: number; } @@ -964,6 +990,53 @@ export class RunAgentStreamActionHandler implements IActionHandler { return RunAgentStreamActionHandler.runActive; } + static getAgent(instance: string): LiveAgentState | undefined { + return RunAgentStreamActionHandler.agents.get(instance); + } + + /** Questions of every agent still waiting for an answer. */ + static pendingQuestionCount(): number { + let n = 0; + for (const a of RunAgentStreamActionHandler.agents.values()) n += a.pendingQuestions; + return n; + } + + /** A running agent's question, into its transcript (the agent is created + * when its stream has not reached the client yet). */ + static addQuestion(instance: string, question: LiveAgentQuestion): void { + const state = RunAgentStreamActionHandler.ensure(instance); + state.parts.push({ kind: 'question', question }); + state.pendingQuestions += 1; + state.updatedAt = Date.now(); + RunAgentStreamActionHandler.notify(); + } + + /** The user's answer, onto the question in the transcript. */ + static resolveQuestion(instance: string, id: number | string, resolved: { answer?: string; declined?: boolean }): void { + const state = RunAgentStreamActionHandler.agents.get(instance); + if (!state) return; + for (const part of state.parts) { + if (part.kind === 'question' && part.question.id === id && !part.question.resolved) { + part.question.resolved = resolved; + state.pendingQuestions = Math.max(0, state.pendingQuestions - 1); + break; + } + } + RunAgentStreamActionHandler.notify(); + } + + private static ensure(instance: string): LiveAgentState { + let state = RunAgentStreamActionHandler.agents.get(instance); + if (!state) { + state = { + instance, text: '', reasoning: '', status: 'running', toolCalls: [], lastSeq: 0, updatedAt: 0, + turns: 0, parts: [], pendingQuestions: 0 + }; + RunAgentStreamActionHandler.agents.set(instance, state); + } + return state; + } + /** Clear all live state (e.g. when a new run starts or the panel is dismissed). */ static reset(): void { RunAgentStreamActionHandler.agents.clear(); @@ -1029,13 +1102,10 @@ export class RunAgentStreamActionHandler implements IActionHandler { if (!instance) { return; } - let state = store.get(instance); - if (!state) { - state = { instance, text: '', reasoning: '', status: 'running', toolCalls: [], lastSeq: 0, updatedAt: 0 }; - store.set(instance, state); - } + const state = RunAgentStreamActionHandler.ensure(instance); state.lastSeq = typeof ev.seq === 'number' ? ev.seq : state.lastSeq; state.updatedAt = Date.now(); + const last = state.parts[state.parts.length - 1]; switch (type) { case 'agent.message.start': // New firing: reset the in-progress message so the view shows the current turn. @@ -1043,20 +1113,51 @@ export class RunAgentStreamActionHandler implements IActionHandler { state.text = ''; state.reasoning = ''; state.toolCalls = []; + state.turns += 1; + state.parts.push({ kind: 'turn', index: state.turns }); break; - case 'agent.message.delta': + case 'agent.message.delta': { + const delta = String(ev.delta ?? ''); if (ev.field === 'reasoning') { - state.reasoning += String(ev.delta ?? ''); + state.reasoning += delta; + if (last && last.kind === 'reasoning') last.text += delta; + else state.parts.push({ kind: 'reasoning', text: delta }); } else { - state.text += String(ev.delta ?? ''); + state.text += delta; + if (last && last.kind === 'text') last.text += delta; + else state.parts.push({ kind: 'text', text: delta }); } break; + } case 'agent.tool_call': { // The runtime names the call (`name`); an ACP agent's call has // a title ("Write choice-a.txt") where the name may be missing. const label = ev.name ?? ev.title ?? ev.kind; if (label) { state.toolCalls.push(String(label)); + state.parts.push({ + kind: 'tool', name: String(label), + status: typeof ev.status === 'string' ? ev.status : undefined, + id: ev.id !== undefined && ev.id !== null ? String(ev.id) : undefined + }); + } + break; + } + case 'agent.tool_call_update': { + // The call it updates: by id, else the latest call. + const id = ev.id !== undefined && ev.id !== null ? String(ev.id) : undefined; + let target: Extract | undefined; + for (let i = state.parts.length - 1; i >= 0; i--) { + const p = state.parts[i]; + if (p.kind === 'tool' && (id === undefined || p.id === id)) { + target = p; + break; + } + } + if (target) { + if (typeof ev.status === 'string') target.status = ev.status; + const label = ev.name ?? ev.title; + if (label && (target.name === 'tool' || target.name === target.status)) target.name = String(label); } break; } diff --git a/packages/diagram-client/src/running-agents-bar.ts b/packages/diagram-client/src/running-agents-bar.ts index 35fbc60..8e0732a 100644 --- a/packages/diagram-client/src/running-agents-bar.ts +++ b/packages/diagram-client/src/running-agents-bar.ts @@ -18,10 +18,16 @@ import { RunAgentStreamActionHandler, type LiveAgentState } from './editing-acti export class RunningAgentsBar implements IDiagramStartup { private host?: HTMLElement; private scheduled = false; + /** The chat panel shows a running agent: one place for the run, not two. */ + private behindChat = false; postModelInitialization(): void { this.ensureHost(); window.addEventListener('dialogram.runAgents.updated', () => this.scheduleRender()); + window.addEventListener('dialogram.chat.runView', (e) => { + this.behindChat = Boolean((e as CustomEvent<{ visible?: boolean }>).detail?.visible); + this.scheduleRender(); + }); this.scheduleRender(); // eslint-disable-next-line no-console console.log('[wf-lang overlay] RunningAgentsBar mounted; listening for dialogram.runAgents.updated'); @@ -55,7 +61,7 @@ export class RunningAgentsBar implements IDiagramStartup { } const agents = RunAgentStreamActionHandler.getAgents(); const active = RunAgentStreamActionHandler.isRunActive(); - const visible = active || agents.length > 0; + const visible = !this.behindChat && (active || agents.length > 0); // eslint-disable-next-line no-console console.log(`[wf-lang overlay] RunningAgentsBar render: agents=${agents.length} active=${active} visible=${visible}`); this.host.classList.toggle('hidden', !visible); @@ -96,7 +102,11 @@ export class RunningAgentsBar implements IDiagramStartup { private card(a: LiveAgentState): TemplateResult { return html` -
+
window.dispatchEvent(new CustomEvent('dialogram.chat.showAgent', { detail: { instance: a.instance } }))} + >
${a.instance} diff --git a/packages/diagram-client/test/chat-panel-run-question.test.ts b/packages/diagram-client/test/chat-panel-run-question.test.ts index bc3b560..f3c99a7 100644 --- a/packages/diagram-client/test/chat-panel-run-question.test.ts +++ b/packages/diagram-client/test/chat-panel-run-question.test.ts @@ -1,17 +1,19 @@ /** - * A running agent's question (the run driver's human port) lands in the chat - * timeline, opens the panel, and is answered back to the host as - * `chat.runAnswer`: the chat is the run's viewer here, not its session. + * A running agent's question (the run driver's human port) goes into that + * agent's transcript, switches the panel to the agent (unless the user is + * typing to the session, then a banner), and is answered back to the host + * as `chat.runAnswer`. */ import 'reflect-metadata'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ChatPanel } from '../src/chat-panel-integrated'; +import { RunAgentStreamActionHandler } from '../src/editing-action-handlers'; function makePanel() { const panel = new ChatPanel(); const sent: Array<{ method: string; env: any }> = []; (panel as any).channel = { sendToHost: (method: string, env: any) => void sent.push({ method, env }) }; - const showSpy = vi.fn(); + const showSpy = vi.fn(() => { (panel as any).isVisible = true; }); (panel as any).show = showSpy; return { panel, sent, showSpy }; } @@ -19,30 +21,36 @@ function makePanel() { beforeEach(() => { (globalThis as any).requestAnimationFrame = () => 1; (globalThis as any).cancelAnimationFrame = () => undefined; + RunAgentStreamActionHandler.reset(); }); afterEach(() => { delete (globalThis as any).requestAnimationFrame; delete (globalThis as any).cancelAnimationFrame; + RunAgentStreamActionHandler.reset(); }); -describe('a running agent\'s question in the chat', () => { - it('is recorded with its choices and opens the panel', () => { +describe("a running agent's question in the chat", () => { + it("lands in the agent's transcript, switches the view to it and opens the panel", () => { const { panel, showSpy } = makePanel(); (panel as any).handleIncomingMessage('chat.runQuestion', { id: 7, agent: 'planner', model: 'opus', question: 'Apply the patch?', context: 'diff…', choices: ['Allow', 'Reject'], timeoutMs: 60000 }); - const timeline = (panel as any).timeline as any[]; - expect(timeline).toEqual([ - { kind: 'question', id: 7, agent: 'planner', model: 'opus', question: 'Apply the patch?', context: 'diff…', choices: ['Allow', 'Reject'] } - ]); + expect((panel as any).timeline).toEqual([]); + expect((panel as any).view).toEqual({ kind: 'agent', instance: 'planner' }); + const agent = RunAgentStreamActionHandler.getAgent('planner')!; + expect(agent.pendingQuestions).toBe(1); + expect(agent.parts).toEqual([{ kind: 'question', question: { + id: 7, agent: 'planner', model: 'opus', question: 'Apply the patch?', context: 'diff…', choices: ['Allow', 'Reject'] + } }]); + expect(RunAgentStreamActionHandler.pendingQuestionCount()).toBe(1); expect(showSpy).toHaveBeenCalled(); }); - it('answers and declines back to the host, once', () => { + it('answers and declines back to the host, once, and resolves the transcript', () => { const { panel, sent } = makePanel(); (panel as any).handleIncomingMessage('chat.runQuestion', { id: 1, agent: 'planner', question: 'Which?', choices: ['a', 'b'] }); (panel as any).handleIncomingMessage('chat.runQuestion', { id: 2, agent: 'planner', question: 'Free text?' }); - const [q1, q2] = (panel as any).timeline as any[]; + const [q1, q2] = RunAgentStreamActionHandler.getAgent('planner')!.parts.map((p: any) => p.question); (panel as any).answerRunQuestion(q1, 'b'); (panel as any).answerRunQuestion(q1, 'a'); // already answered: ignored (panel as any).answerRunQuestion(q2, undefined); @@ -53,12 +61,33 @@ describe('a running agent\'s question in the chat', () => { ]); expect(q1.resolved).toEqual({ answer: 'b' }); expect(q2.resolved).toEqual({ declined: true }); + expect(RunAgentStreamActionHandler.pendingQuestionCount()).toBe(0); + }); + + it('does not pull the user out of a message they are typing: a banner instead, and Go switches', () => { + const { panel } = makePanel(); + (panel as any).inputValue = 'please rename the'; + (panel as any).handleIncomingMessage('chat.runQuestion', { id: 3, agent: 'ask', question: 'Write /tmp/x', choices: ['Allow', 'Reject'] }); + expect((panel as any).view).toEqual({ kind: 'session' }); + expect((panel as any).runBanner).toMatchObject({ id: 3, agent: 'ask' }); + (panel as any).showAgent('ask'); + expect((panel as any).view).toEqual({ kind: 'agent', instance: 'ask' }); + expect((panel as any).runBanner).toBeNull(); + }); + + it('the Run segment opens the agent with a question waiting, and Session comes back', () => { + const { panel } = makePanel(); + (panel as any).handleIncomingMessage('chat.runQuestion', { id: 4, agent: 'analyst', question: 'q', choices: ['ok'] }); + (panel as any).showSession(); + expect((panel as any).view).toEqual({ kind: 'session' }); + (panel as any).openRun(); + expect((panel as any).view).toEqual({ kind: 'agent', instance: 'analyst' }); }); it('ignores a malformed question', () => { const { panel, showSpy } = makePanel(); (panel as any).handleIncomingMessage('chat.runQuestion', { agent: 'x' }); - expect((panel as any).timeline).toEqual([]); + expect(RunAgentStreamActionHandler.getAgents()).toEqual([]); expect(showSpy).not.toHaveBeenCalled(); }); }); diff --git a/packages/diagram-client/test/run-agents-transcript.test.ts b/packages/diagram-client/test/run-agents-transcript.test.ts new file mode 100644 index 0000000..fd1bf4c --- /dev/null +++ b/packages/diagram-client/test/run-agents-transcript.test.ts @@ -0,0 +1,65 @@ +// A running agent's transcript: every turn's reasoning, text and tool calls +// in arrival order, tool statuses updated in place, the questions among them; +// the bar's per-turn summary stays what it was. +import 'reflect-metadata'; +import { afterEach, describe, expect, it } from 'vitest'; +import { EXECUTION_OVERLAY_ACTION_KIND } from '@dialogram/shared'; +import { RunAgentStreamActionHandler } from '../src/editing-action-handlers'; + +afterEach(() => RunAgentStreamActionHandler.reset()); + +function feed(events: Array>): void { + new RunAgentStreamActionHandler().handle({ kind: EXECUTION_OVERLAY_ACTION_KIND, events } as any); +} + +describe("an agent's transcript", () => { + it('keeps every turn in order, with tool statuses updated in place', () => { + feed([ + { seq: 1, type: 'run.started' }, + { seq: 2, type: 'agent.message.start', instance: 'ask' }, + { seq: 3, type: 'agent.message.delta', instance: 'ask', field: 'reasoning', delta: 'I should ' }, + { seq: 4, type: 'agent.message.delta', instance: 'ask', field: 'reasoning', delta: 'write.' }, + { seq: 5, type: 'agent.tool_call', instance: 'ask', name: 'Write /tmp/1.txt', status: 'pending', id: 'c1' }, + { seq: 6, type: 'agent.tool_call_update', instance: 'ask', id: 'c1', status: 'completed' }, + { seq: 7, type: 'agent.message.delta', instance: 'ask', delta: '{"answer": 1}' }, + { seq: 8, type: 'agent.message.end', instance: 'ask' }, + { seq: 9, type: 'agent.message.start', instance: 'ask' }, + { seq: 10, type: 'agent.message.delta', instance: 'ask', delta: 'second turn' } + ]); + const a = RunAgentStreamActionHandler.getAgent('ask')!; + expect(a.parts).toEqual([ + { kind: 'turn', index: 1 }, + { kind: 'reasoning', text: 'I should write.' }, + { kind: 'tool', name: 'Write /tmp/1.txt', status: 'completed', id: 'c1' }, + { kind: 'text', text: '{"answer": 1}' }, + { kind: 'turn', index: 2 }, + { kind: 'text', text: 'second turn' } + ]); + expect(a.turns).toBe(2); + // The bar's summary is the current turn only. + expect(a.text).toBe('second turn'); + expect(a.reasoning).toBe(''); + expect(a.toolCalls).toEqual([]); + expect(a.status).toBe('running'); + }); + + it('places a question where it was asked and counts it until answered', () => { + feed([ + { seq: 1, type: 'run.started' }, + { seq: 2, type: 'agent.message.start', instance: 'ask' }, + { seq: 3, type: 'agent.message.delta', instance: 'ask', delta: 'hello' } + ]); + RunAgentStreamActionHandler.addQuestion('ask', { id: 9, agent: 'ask', question: 'Write?', choices: ['Allow'] }); + feed([{ seq: 4, type: 'agent.message.delta', instance: 'ask', delta: ' world' }]); + const a = RunAgentStreamActionHandler.getAgent('ask')!; + expect(a.parts.map(p => p.kind)).toEqual(['turn', 'text', 'question', 'text']); + expect(a.pendingQuestions).toBe(1); + expect(RunAgentStreamActionHandler.pendingQuestionCount()).toBe(1); + RunAgentStreamActionHandler.resolveQuestion('ask', 9, { answer: 'Allow' }); + expect(a.pendingQuestions).toBe(0); + expect((a.parts[2] as any).question.resolved).toEqual({ answer: 'Allow' }); + // An agent the stream has not reached yet still takes a question. + RunAgentStreamActionHandler.addQuestion('late', { id: 10, agent: 'late', question: 'q', choices: [] }); + expect(RunAgentStreamActionHandler.getAgent('late')!.parts).toHaveLength(1); + }); +});