diff --git a/packages/diagram-client/src/chat-panel-integrated.ts b/packages/diagram-client/src/chat-panel-integrated.ts index efab43d..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) : 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 f30078c..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,19 +1113,54 @@ 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': - 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)); + 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; + } case 'agent.message.end': state.status = 'done'; 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-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']); + }); +}); 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); + }); +}); 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(); + }); +});