diff --git a/packages/diagram-client/src/chat-panel-integrated.ts b/packages/diagram-client/src/chat-panel-integrated.ts index cf93610..efab43d 100644 --- a/packages/diagram-client/src/chat-panel-integrated.ts +++ b/packages/diagram-client/src/chat-panel-integrated.ts @@ -8,6 +8,7 @@ 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'; /** * Memoized markdown → HTML. The chat template runs `renderMarkdownSafe` for every @@ -108,7 +109,20 @@ interface PermissionItem { resolved?: 'allowed' | 'denied'; } -type TimelineItem = MessageItem | ToolItem | PermissionItem; +/** 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 | QuestionItem; interface SessionEntry { id: string; @@ -165,6 +179,8 @@ export class ChatPanel implements IDiagramStartup, ISelectionListener { private reverted = false; private connection: 'unknown' | 'connected' | 'disconnected' = 'unknown'; private connectionReason = ''; + /** The ACP connector the chat is (or was) connected to, as the host names it. */ + private connectionAgent = ''; private inputValue = ''; /** Live diagram selection (node ids), mirrored to the host for chat context. */ @@ -239,6 +255,11 @@ export class ChatPanel implements IDiagramStartup, ISelectionListener { this.createHost(); this.createToggleButton(); this.setupMessageListener(); + // The run's agents stream into the same live state the "Running agents" + // 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()); + } this.update(); this.requestState(); } @@ -459,7 +480,7 @@ export class ChatPanel implements IDiagramStartup, ISelectionListener { break; case 'chat.connectionStatus': - this.setConnectionStatus(!!data?.connected, data?.reason); + this.setConnectionStatus(!!data?.connected, data?.reason, data?.agent); break; case 'chat.commands': @@ -473,6 +494,22 @@ export class ChatPanel implements IDiagramStartup, ISelectionListener { } break; + case 'chat.runQuestion': + if (data && (typeof data.id === 'number' || typeof data.id === 'string') && typeof data.question === 'string') { + this.timeline.push({ + kind: 'question', + id: data.id, + agent: typeof data.agent === 'string' && data.agent ? data.agent : 'An 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(); + this.autoShow('run-question'); + } + break; + case 'chat.permissionRequest': if (data?.requestId) { this.showTyping = false; @@ -656,7 +693,7 @@ export class ChatPanel implements IDiagramStartup, ISelectionListener { // ── Connection status ────────────────────────────────────────────────── - private setConnectionStatus(connected: boolean, reason?: string): void { + private setConnectionStatus(connected: boolean, reason?: string, agent?: string): void { this.receivedStatus = true; if (this.statusHandshakeTimer !== null) { clearTimeout(this.statusHandshakeTimer); @@ -664,6 +701,7 @@ export class ChatPanel implements IDiagramStartup, ISelectionListener { } this.connection = connected ? 'connected' : 'disconnected'; this.connectionReason = reason ?? ''; + if (typeof agent === 'string' && agent) this.connectionAgent = agent; // Surface a state change once in the timeline so the reason is visible. const stateKey = `${connected}:${reason ?? ''}`; @@ -843,6 +881,19 @@ export class ChatPanel implements IDiagramStartup, ISelectionListener { this.update(); } + /** Answer a running agent's question (or decline it, `answer` undefined). */ + private answerRunQuestion(item: QuestionItem, answer: string | undefined): void { + if (item.resolved) return; + if (answer === undefined) { + this.sendToHost('chat.runAnswer', { id: item.id, declined: true, reason: 'declined in the chat' }); + item.resolved = { declined: true }; + } else { + this.sendToHost('chat.runAnswer', { id: item.id, answer }); + item.resolved = { answer }; + } + this.update(); + } + private loadProviders(): void { this.sendToHost('chat.getProviders'); } @@ -1056,6 +1107,7 @@ export class ChatPanel implements IDiagramStartup, ISelectionListener { return ( !this.currentSessionId && this.timeline.length === 0 && + !this.hasRunView && !this.streamingText && !this.streamingThinking && !this.showTyping && @@ -1063,6 +1115,11 @@ 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 @@ -1122,9 +1179,17 @@ export class ChatPanel implements IDiagramStartup, ISelectionListener { ${this.loadingLabel} ` : nothing} + ${this.runViewerTemplate()} ${repeat( this.timeline, - (item, i) => (item.kind === 'tool' ? `t${item.id}` : item.kind === 'permission' ? `p${item.requestId}` : `m${i}`), + (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} @@ -1145,12 +1210,21 @@ export class ChatPanel implements IDiagramStartup, ISelectionListener { private topbarTemplate(): TemplateResult { const status = this.connection; + const agent = this.connectionAgent; const statusLabel = - status === 'connected' ? 'Connected' : status === 'disconnected' ? 'Disconnected' : 'Connecting…'; + status === 'connected' + ? agent + ? `Connected · ${agent}` + : 'Connected' + : status === 'disconnected' + ? 'Disconnected' + : 'Connecting…'; const statusTitle = status === 'disconnected' && this.connectionReason - ? `opencode: ${this.connectionReason}` - : 'opencode connection'; + ? `${agent || 'agent'}: ${this.connectionReason}` + : status === 'connected' && agent + ? `Connected to the ${agent} ACP connector` + : 'agent connection'; return html`
@@ -1195,7 +1269,89 @@ 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 { + const agents = RunAgentStreamActionHandler.getAgents(); + const active = RunAgentStreamActionHandler.isRunActive(); + if (!active && agents.length === 0) return nothing; + 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 { + return html` +
+
+ + ${a.instance} + ${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' ? '…' : '')}
+
+ `; + } + 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 +
+
${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); + }} + /> + + +
`} +
+ `; + } + 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 3fe20e9..f295e47 100644 --- a/packages/diagram-client/src/chat-panel.css +++ b/packages/diagram-client/src/chat-panel.css @@ -495,6 +495,131 @@ color: var(--vscode-button-secondaryForeground, #fff); } +/* ── A running agent's question (the run's human port) ── */ +.chat-question { + margin: 8px 16px; + padding: 10px 12px; + 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); +} +.chat-question.resolved { + opacity: 0.65; + border-color: var(--chat-border); + background: transparent; +} +.chat-question-title { + display: flex; + align-items: center; + gap: 7px; + font-size: 12px; + font-weight: 600; + margin-bottom: 6px; +} +.chat-question-text { + font-size: 12.5px; + white-space: pre-wrap; + margin-bottom: 6px; +} +.chat-question-context { + font-size: 11.5px; + opacity: 0.8; + white-space: pre-wrap; + max-height: 160px; + overflow-y: auto; + margin-bottom: 8px; +} +.chat-question-answer { + font-size: 12px; + opacity: 0.85; +} +.chat-question-input { + flex: 1 1 160px; + min-width: 120px; + padding: 3px 8px; + border-radius: 3px; + border: 1px solid var(--vscode-input-border, transparent); + background: var(--vscode-input-background); + color: var(--vscode-input-foreground); + font-size: 12px; +} + +/* ── The run's agents, as the chat's read-only viewer ── */ +.chat-run { + margin: 8px 16px; + border: 1px solid var(--chat-border); + border-radius: 4px; +} +.chat-run-head { + display: flex; + align-items: center; + gap: 7px; + padding: 6px 10px; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; + opacity: 0.8; + border-bottom: 1px solid var(--chat-border); +} +.chat-run-live { + margin-left: auto; + font-size: 10px; + padding: 1px 6px; + border-radius: 8px; + background: var(--vscode-testing-iconPassed, #3fb950); + color: var(--vscode-editor-background, #1e1e1e); + text-transform: none; + letter-spacing: 0; +} +.chat-run-empty { + padding: 8px 10px; + font-size: 12px; + opacity: 0.7; +} +.chat-run-agent { + padding: 8px 10px; + border-bottom: 1px solid var(--chat-border); +} +.chat-run-agent:last-child { + border-bottom: none; +} +.chat-run-agent-head { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + margin-bottom: 4px; +} +.chat-run-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--vscode-descriptionForeground, #8b949e); +} +.chat-run-dot.running { + background: var(--vscode-testing-iconPassed, #3fb950); +} +.chat-run-name { + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.chat-run-status { + margin-left: auto; + font-size: 11px; + opacity: 0.7; +} +.chat-run-text { + white-space: pre-wrap; + word-break: break-word; + font-size: 12px; + line-height: 1.4; + max-height: 200px; + overflow-y: auto; +} + /* ── Composer ── */ .chat-composer-wrap { position: relative; diff --git a/packages/diagram-client/src/profile.ts b/packages/diagram-client/src/profile.ts index 1d7d7e7..1e7a2c1 100644 --- a/packages/diagram-client/src/profile.ts +++ b/packages/diagram-client/src/profile.ts @@ -24,6 +24,19 @@ type DiagramClientBehavior = { nodeFamilies?: NodeFamilySpec[]; /** Whether the host has a chat backend; derived by the platform. */ chatBackend?: boolean; + /** The ACP connectors the runtime discovered or the user declared, for an + * agent's `connector` (extension-core `AcpConnectorInfo`). */ + acpConnectors?: AcpConnectorInfo[]; +}; + +export type AcpConnectorInfo = { + name: string; + available: boolean; + source: string; + command: string; + httpApi?: boolean; + model?: string | null; + mode?: string | null; }; type DiagramIdentifier = { @@ -100,3 +113,26 @@ export function clientBehavior(): DiagramClientBehavior { const configured = getDiagramIdentifier().clientBehavior; return configured && typeof configured === 'object' ? configured : {}; } + +/** + * The host resolves part of the behavior after the webview is up (what depends + * on the machine or the workspace, e.g. the ACP connectors the runtime knows) + * and posts it as `dialogram.clientBehavior.merge`; it is folded into the + * injected identifier so every later {@link clientBehavior} call sees it. + */ +export function mergeClientBehavior(extras: Partial): void { + const g = globalThis as any; + if (!g.diagramIdentifier || typeof g.diagramIdentifier !== 'object') { + g.diagramIdentifier = {}; + } + g.diagramIdentifier.clientBehavior = { ...(g.diagramIdentifier.clientBehavior ?? {}), ...extras }; +} + +if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') { + window.addEventListener('message', (event: MessageEvent) => { + const data = event.data as { type?: string; payload?: unknown } | undefined; + if (data?.type === 'dialogram.clientBehavior.merge' && data.payload && typeof data.payload === 'object') { + mergeClientBehavior(data.payload as Partial); + } + }); +} diff --git a/packages/diagram-client/src/property-panel.ts b/packages/diagram-client/src/property-panel.ts index b3358da..9c749a7 100644 --- a/packages/diagram-client/src/property-panel.ts +++ b/packages/diagram-client/src/property-panel.ts @@ -1270,6 +1270,8 @@ export class PropertyPanel implements ISelectionListener, IGModelRootListener { const useSkillHooks = argsMap.get('useSkillHooks')?.trim(); const truncationStrategy = argsMap.get('truncationStrategy')?.trim(); const transport = argsMap.get('transport')?.trim(); + const connector = argsMap.get('connector')?.trim(); + const mode = argsMap.get('mode')?.trim(); const cliToolsMode = argsMap.get('cliToolsMode')?.trim(); const reasoningEffort = argsMap.get('reasoningEffort')?.trim(); const fireableWithoutInput = argsMap.get('fireableWithoutInput')?.trim(); @@ -1290,12 +1292,14 @@ export class PropertyPanel implements ISelectionListener, IGModelRootListener { if (useSkillHooks) ordered.push(['useSkillHooks', useSkillHooks]); if (truncationStrategy) ordered.push(['truncationStrategy', truncationStrategy]); if (transport) ordered.push(['transport', transport]); + if (connector) ordered.push(['connector', connector]); + if (mode) ordered.push(['mode', mode]); if (cliToolsMode) ordered.push(['cliToolsMode', cliToolsMode]); if (reasoningEffort) ordered.push(['reasoningEffort', reasoningEffort]); if (fireableWithoutInput) ordered.push(['fireableWithoutInput', fireableWithoutInput]); for (const [k, v] of pairs) { - if (['prompt', 'claudeAgent', 'skill', 'model', 'provider', 'endpoint', 'timeoutMs', 'contextBudget', 'stateful', 'useClaudeAgent', 'useSkill', 'usePrompt', 'useSkillHooks', 'truncationStrategy', 'transport', 'cliToolsMode', 'reasoningEffort', 'fireableWithoutInput'].includes(k) || guiOnlyKeys.has(k)) continue; + if (['prompt', 'claudeAgent', 'skill', 'model', 'provider', 'endpoint', 'timeoutMs', 'contextBudget', 'stateful', 'useClaudeAgent', 'useSkill', 'usePrompt', 'useSkillHooks', 'truncationStrategy', 'transport', 'connector', 'mode', 'cliToolsMode', 'reasoningEffort', 'fireableWithoutInput'].includes(k) || guiOnlyKeys.has(k)) continue; ordered.push([k, v]); } @@ -3343,7 +3347,8 @@ export class PropertyPanel implements ISelectionListener, IGModelRootListener { // 7. Backend transport. Three distinct categories — a REST API call // (http), a one-shot CLI subprocess (*-cli), or a persistent ACP protocol - // session (opencode-acp) — so they're grouped, and the CLI-only tooling + // session (acp, with a connector naming the agent; opencode-acp is the + // OpenCode-only spelling) — so they're grouped, and the CLI-only tooling // below dims when the HTTP API is selected (it doesn't apply there). const backendGrid = document.createElement('div'); backendGrid.style.display = 'grid'; @@ -3364,7 +3369,54 @@ export class PropertyPanel implements ISelectionListener, IGModelRootListener { { value: 'max', label: 'max' } ], ''); - // CLI Tools / Reasoning apply to the CLI + ACP transports, not the HTTP API. + // The ACP connector: the agent the session speaks to. The list is what + // the runtime discovered on this machine plus what the user declared + // (clientBehavior.acpConnectors, resolved per document); a value the + // list lacks is kept as its own option so a file written elsewhere + // round-trips. Empty means the run's default (--acp-connector). + const currentConnector = this.stripQuotesIfStringLiteral(argsMap.get('connector') ?? '').trim(); + const connectorOptions: Array<{ value: string; label: string; group?: string }> = [ + { value: '', label: "(run's default)" } + ]; + for (const c of clientBehavior().acpConnectors ?? []) { + const detail = c.available ? '' : ' (not on PATH)'; + connectorOptions.push({ value: c.name, label: `${c.name}${detail}`, group: c.source === 'discovered' ? 'Discovered' : `Declared (${c.source})` }); + } + if (currentConnector && !connectorOptions.some((o) => o.value === currentConnector)) { + connectorOptions.push({ value: currentConnector, label: `${currentConnector} (unknown)` }); + } + const connectorWrap = createSelect('connector', 'Connector', connectorOptions, ''); + if (!clientBehavior().acpConnectors?.length) { + connectorWrap.title = 'No ACP connectors reported by the runtime; the run\'s --acp-connector applies'; + } + + // The ACP session mode (e.g. Claude: default / acceptEdits / plan / + // dontAsk / bypassPermissions): free text, since each agent has its own. + const modeWrapper = document.createElement('div'); + modeWrapper.style.display = 'flex'; + modeWrapper.style.flexDirection = 'column'; + modeWrapper.style.gap = '4px'; + const modeLabel = document.createElement('div'); + modeLabel.textContent = 'Session mode'; + modeLabel.style.fontSize = '10px'; + modeLabel.style.opacity = '0.8'; + modeLabel.style.fontWeight = '500'; + const modeInput = document.createElement('input'); + modeInput.className = 'annotation-input'; + modeInput.placeholder = "agent's default"; + modeInput.style.width = '100%'; + modeInput.style.boxSizing = 'border-box'; + modeInput.value = this.stripQuotesIfStringLiteral(argsMap.get('mode') ?? '').trim(); + modeInput.addEventListener('input', () => { + const val = modeInput.value.trim(); + if (val) argsMap.set('mode', this.toWfStringLiteral(val)); + else argsMap.delete('mode'); + }); + modeWrapper.appendChild(modeLabel); + modeWrapper.appendChild(modeInput); + + // CLI Tools / Reasoning apply to the CLI + ACP transports, not the HTTP API; + // Connector / Session mode apply to the ACP transports only. const updateBackendRelevance = (transportVal: string): void => { const isHttp = (transportVal || 'http') === 'http'; for (const w of [cliToolsWrap, reasoningWrap]) { @@ -3373,10 +3425,18 @@ export class PropertyPanel implements ISelectionListener, IGModelRootListener { const s = w.querySelector('select') as HTMLSelectElement | null; if (s) s.disabled = isHttp; } + const isAcp = transportVal === 'acp' || transportVal === 'opencode-acp'; + for (const w of [connectorWrap, modeWrapper]) { + w.style.opacity = isAcp ? '1' : '0.45'; + const s = w.querySelector('select, input') as HTMLSelectElement | HTMLInputElement | null; + if (s) s.disabled = !isAcp; + } + if (!isAcp) connectorWrap.title = 'Applies to the ACP transports'; }; const transportWrap = createSelect('transport', 'Transport', [ { value: 'http', label: 'http', group: 'REST API' }, + { value: 'acp', label: 'acp (connector)', group: 'ACP (protocol session)' }, { value: 'opencode-acp', label: 'opencode-acp', group: 'ACP (protocol session)' }, { value: 'opencode-cli', label: 'opencode-cli', group: 'CLI (subprocess)' }, { value: 'claude-cli', label: 'claude-cli', group: 'CLI (subprocess)' }, @@ -3385,6 +3445,8 @@ export class PropertyPanel implements ISelectionListener, IGModelRootListener { updateBackendRelevance(this.stripQuotesIfStringLiteral(argsMap.get('transport') ?? '').trim() || 'http'); backendGrid.appendChild(transportWrap); + backendGrid.appendChild(connectorWrap); + backendGrid.appendChild(modeWrapper); backendGrid.appendChild(cliToolsWrap); backendGrid.appendChild(reasoningWrap); body.appendChild(backendGrid); @@ -3559,7 +3621,7 @@ export class PropertyPanel implements ISelectionListener, IGModelRootListener { 'prompt', 'claudeAgent', 'skill', 'model', 'provider', 'endpoint', 'timeoutMs', 'contextBudget', 'stateful', // Backend / runtime controls (dropdowns above) - 'truncationStrategy', 'fireableWithoutInput', 'transport', 'cliToolsMode', 'reasoningEffort', + 'truncationStrategy', 'fireableWithoutInput', 'transport', 'connector', 'mode', 'cliToolsMode', 'reasoningEffort', // Sub-block toggles 'useClaudeAgent', 'useSkill', 'usePrompt', 'useSkillHooks', // MCP block diff --git a/packages/diagram-client/test/chat-panel-connected-agent.test.ts b/packages/diagram-client/test/chat-panel-connected-agent.test.ts new file mode 100644 index 0000000..4cfcc2b --- /dev/null +++ b/packages/diagram-client/test/chat-panel-connected-agent.test.ts @@ -0,0 +1,44 @@ +/** + * The connection status names the ACP connector the chat talks to, so a + * "Connected" that means claude does not read as opencode. + */ +import 'reflect-metadata'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { ChatPanel } from '../src/chat-panel-integrated'; + +function makePanel(): ChatPanel { + const panel = new ChatPanel(); + (panel as any).channel = { sendToHost: () => undefined }; + (panel as any).show = () => undefined; + return panel; +} + +beforeEach(() => { + (globalThis as any).requestAnimationFrame = () => 1; + (globalThis as any).cancelAnimationFrame = () => undefined; +}); +afterEach(() => { + delete (globalThis as any).requestAnimationFrame; + delete (globalThis as any).cancelAnimationFrame; +}); + +describe('the connected status names the connector', () => { + it('keeps the connector from the status and shows it in the topbar label', () => { + const panel = makePanel(); + (panel as any).handleIncomingMessage('chat.connectionStatus', { connected: true, agent: 'claude' }); + expect((panel as any).connection).toBe('connected'); + expect((panel as any).connectionAgent).toBe('claude'); + const rendered = JSON.stringify((panel as any).topbarTemplate().values); + expect(rendered).toContain('Connected · claude'); + expect(rendered).toContain('Connected to the claude ACP connector'); + }); + + it('reads plain "Connected" when the host names no connector, and keeps the last name across a disconnect', () => { + const panel = makePanel(); + (panel as any).handleIncomingMessage('chat.connectionStatus', { connected: true }); + expect(JSON.stringify((panel as any).topbarTemplate().values)).toContain('"Connected"'); + (panel as any).handleIncomingMessage('chat.connectionStatus', { connected: true, agent: 'opencode' }); + (panel as any).handleIncomingMessage('chat.connectionStatus', { connected: false, reason: 'opencode disconnected' }); + expect(JSON.stringify((panel as any).topbarTemplate().values)).toContain('opencode: opencode disconnected'); + }); +}); diff --git a/packages/diagram-client/test/chat-panel-run-question.test.ts b/packages/diagram-client/test/chat-panel-run-question.test.ts new file mode 100644 index 0000000..bc3b560 --- /dev/null +++ b/packages/diagram-client/test/chat-panel-run-question.test.ts @@ -0,0 +1,64 @@ +/** + * 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. + */ +import 'reflect-metadata'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ChatPanel } from '../src/chat-panel-integrated'; + +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(); + (panel as any).show = showSpy; + return { panel, sent, showSpy }; +} + +beforeEach(() => { + (globalThis as any).requestAnimationFrame = () => 1; + (globalThis as any).cancelAnimationFrame = () => undefined; +}); +afterEach(() => { + delete (globalThis as any).requestAnimationFrame; + delete (globalThis as any).cancelAnimationFrame; +}); + +describe('a running agent\'s question in the chat', () => { + it('is recorded with its choices 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(showSpy).toHaveBeenCalled(); + }); + + it('answers and declines back to the host, once', () => { + 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[]; + (panel as any).answerRunQuestion(q1, 'b'); + (panel as any).answerRunQuestion(q1, 'a'); // already answered: ignored + (panel as any).answerRunQuestion(q2, undefined); + const answers = sent.filter(s => s.env.type === 'chat.runAnswer').map(s => s.env.data); + expect(answers).toEqual([ + expect.objectContaining({ id: 1, answer: 'b' }), + expect.objectContaining({ id: 2, declined: true }) + ]); + expect(q1.resolved).toEqual({ answer: 'b' }); + expect(q2.resolved).toEqual({ declined: true }); + }); + + it('ignores a malformed question', () => { + const { panel, showSpy } = makePanel(); + (panel as any).handleIncomingMessage('chat.runQuestion', { agent: 'x' }); + expect((panel as any).timeline).toEqual([]); + expect(showSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/extension-core/src/api.ts b/packages/extension-core/src/api.ts index c05e2af..40b3d71 100644 --- a/packages/extension-core/src/api.ts +++ b/packages/extension-core/src/api.ts @@ -34,6 +34,10 @@ import type { } from "./extension/chat/slash-commands"; export type { ChatCommandContribution, ChatCommandContext, ChatCommandResult }; +import type { AcpAgentSpec } from "./extension/acp-client"; +export type { AcpAgentSpec }; +import type { AcpConnectorConfig, AcpConnectorFiles } from "./extension/chat/acp-connectors"; +export type { AcpConnectorConfig, AcpConnectorFiles }; /** * Semver of the API contract. Consumers must check the major version on @@ -156,6 +160,18 @@ export interface DiagramChatConfig { * the profile's registration wins (registry map semantics). */ slashCommands?: ChatCommandContribution[]; + /** + * The ACP connector the chat talks to, declared as the product's setting + * (`.`, user level with a workspace + * override) and its runtime's two connectors files. The platform reads the + * connectors the way that runtime does (the known agents on the PATH, the + * user's file under `~/.config`, the workspace's file at the project + * root), resolves the chat's agent from them when the chat connects, and + * offers them on an agent node's `connector` in the property panel + * ({@link DiagramClientBehavior.acpConnectors}). Absent, the chat talks to + * opencode. + */ + acpConnector?: AcpConnectorConfig; } /** @@ -176,8 +192,31 @@ export interface DiagramLiveOverlaySource { * never holds the connector), the run output channel, and a hook to register the * driver's live-overlay signature source with the editor provider. */ +/** A running agent's question to the user (the runtime's elicitation over + * the run driver's socket): the toolkit's `RunQuestion`. */ +export interface DiagramRunQuestion { + id: number | string; + agent: string; + model?: string; + runId?: string; + question: string; + context?: string; + choices?: string[]; + timeoutMs?: number; +} + +export interface DiagramRunAnswer { + answer?: string; + declined?: boolean; + reason?: string; +} + export interface DiagramRunHost { overlay: ExecutionOverlaySink; + /** Puts a running agent's question to the user in the chat panel open on + * the diagram at `sourceUri`; `undefined` when no chat can take it (the + * driver then asks through a VS Code prompt). */ + askUser(question: DiagramRunQuestion, sourceUri: string): Promise; requestRefresh( sourceUri: string, kind: "full" | "agentContextOnly", @@ -211,7 +250,26 @@ export type DiagramRunDriverFactory = ( * behavior; the consumer supplies the per-product truth value. Core/client code * consults these flags instead of comparing a product-identity string. */ +/** One ACP connector as the platform read it (the known agents, the user's + * and the workspace's files): what an agent node may name. */ +export interface AcpConnectorInfo { + name: string; + /** The connector's command resolves on the PATH. */ + available: boolean; + /** discovered | user | workspace */ + source: string; + command: string; + /** The process also serves opencode's HTTP API (revert, message ids). */ + httpApi?: boolean; + model?: string | null; + mode?: string | null; + env?: Record; +} + export interface DiagramClientBehavior { + /** The ACP connectors the property panel offers on an agent's `connector`; + * listed per document by the platform from {@link DiagramChatConfig.acpConnector}. */ + acpConnectors?: AcpConnectorInfo[]; /** Cross-file drill-down navigation resolves through the graph source model. */ graphSourceNavigation?: boolean; /** Property panel renders the network-model sections and labels. */ diff --git a/packages/extension-core/src/extension/acp-client.ts b/packages/extension-core/src/extension/acp-client.ts index 4bb7dae..2cae73b 100644 --- a/packages/extension-core/src/extension/acp-client.ts +++ b/packages/extension-core/src/extension/acp-client.ts @@ -134,11 +134,52 @@ export declare interface ACPClientService { } /** - * ACP Client Service for communicating with opencode via ACP protocol. + * The agent the chat spawns: an ACP connector as the platform reads it + * (`chat/acp-connectors.ts`). `argv` is the command and its arguments; + * `httpApi` says the process also serves opencode's HTTP API, which the + * client then pins to a port for the capabilities ACP does not expose + * (revert / unrevert / message ids). Without it those stay off. + */ +export interface AcpAgentSpec { + name: string; + argv: string[]; + httpApi?: boolean; + /** Extra environment for the process, from the connector's `env` table. */ + env?: Record; +} + +/** + * Where the agents usually install, ahead of the inherited PATH when the + * chat spawns one and when the connectors are checked for availability: + * the VS Code extension host frequently does NOT inherit the user's shell + * PATH (e.g. when launched from a GUI). + */ +export function agentInstallDirs(): string[] { + const home = os.homedir(); + return [ + path.join(home, ".opencode", "bin"), + "/usr/local/bin", + "/opt/homebrew/bin", + path.join(home, ".local", "bin"), + path.join(home, "bin"), + ]; +} + +const OPENCODE_AGENT: AcpAgentSpec = { + name: "opencode", + argv: ["opencode", "acp"], + httpApi: true, +}; + +/** + * ACP Client Service for communicating with an ACP agent (opencode by + * default; any connector the runtime knows, see {@link AcpAgentSpec}). * Uses TypeScript SDK exclusively (no external-process fallback or CLI mode). */ export class ACPClientService extends EventEmitter { private process: ChildProcess | null = null; + /** The agent's name, for messages: what the chat is (or is not) talking to. */ + private agentName: string = OPENCODE_AGENT.name; private connection: ClientSideConnection | null = null; private sessions: Map = new Map(); /** @@ -260,22 +301,8 @@ export class ACPClientService extends EventEmitter { command: string; env: NodeJS.ProcessEnv; } { - const home = os.homedir(); const binName = process.platform === "win32" ? "opencode.cmd" : "opencode"; - const candidateDirs = [ - path.join(home, ".opencode", "bin"), - "/usr/local/bin", - "/opt/homebrew/bin", - path.join(home, ".local", "bin"), - path.join(home, "bin"), - ]; - - const env: NodeJS.ProcessEnv = { ...process.env }; - const sep = process.platform === "win32" ? ";" : ":"; - const existingPath = env.PATH ?? env.Path ?? ""; - const merged = [...candidateDirs, existingPath].filter(Boolean).join(sep); - env.PATH = merged; - if ("Path" in env) env.Path = merged; + const { env, candidateDirs } = this.augmentedEnv(); const override = process.env.WORKFLOW_OPENCODE_PATH; if (override && existsSync(override)) { @@ -292,34 +319,67 @@ export class ACPClientService extends EventEmitter { return { command: binName, env }; } - async start(cwd: string): Promise { + /** + * The child's environment with the usual install directories ahead of the + * inherited PATH: the VS Code extension host frequently does NOT inherit the + * user's shell PATH (e.g. when launched from a GUI). + */ + private augmentedEnv(): { env: NodeJS.ProcessEnv; candidateDirs: string[] } { + const candidateDirs = agentInstallDirs(); + const env: NodeJS.ProcessEnv = { ...process.env }; + const sep = process.platform === "win32" ? ";" : ":"; + const existingPath = env.PATH ?? env.Path ?? ""; + const merged = [...candidateDirs, existingPath].filter(Boolean).join(sep); + env.PATH = merged; + if ("Path" in env) env.Path = merged; + return { env, candidateDirs }; + } + + /** The name of the agent this client spawned (or will spawn). */ + get agent(): string { + return this.agentName; + } + + /** + * Spawn the agent and connect. `agent` names any ACP connector; without it + * the chat talks to opencode, resolved as before. + */ + async start(cwd: string, agent?: AcpAgentSpec): Promise { if (this.isConnected) { throw new Error("ACP client is already connected"); } this.workspaceCwd = cwd; + const spec = agent && agent.argv.length > 0 ? agent : OPENCODE_AGENT; + this.agentName = spec.name; try { - // Resolve the opencode binary. The VS Code extension host frequently does - // NOT inherit the user's shell PATH (e.g. when launched from a GUI), so a - // bare 'opencode' can fail with ENOENT even though it works in a terminal. - const { command, env } = this.resolveOpencodeCommand(); - - // `opencode acp` also serves the full HTTP API. Pin it to a free port so - // we can drive HTTP-only capabilities (revert/unrevert/message ids) - // against the same process the ACP session uses. - const httpPort = await this.findFreePort(); - this.http = new OpencodeHttpClient(`http://127.0.0.1:${httpPort}`); - - // Spawn opencode acp subprocess - this.process = spawn( - command, - ["acp", "--hostname", "127.0.0.1", "--port", String(httpPort)], - { - cwd, - stdio: ["pipe", "pipe", "pipe"], - env, - }, - ); + // opencode's binary is looked for in its install locations too; any + // other agent is the command the connector names, on the augmented PATH. + const resolved = + spec.argv[0] === "opencode" + ? this.resolveOpencodeCommand() + : { command: spec.argv[0], env: this.augmentedEnv().env }; + const command = resolved.command; + const env = { ...resolved.env, ...(spec.env ?? {}) }; + const args = spec.argv.slice(1); + + // An agent with opencode's HTTP API gets it pinned to a free port, so + // the HTTP-only capabilities (revert/unrevert/message ids) drive the + // same process the ACP session uses. Without one they stay off. + if (spec.httpApi) { + const httpPort = await this.findFreePort(); + this.http = new OpencodeHttpClient(`http://127.0.0.1:${httpPort}`); + args.push("--hostname", "127.0.0.1", "--port", String(httpPort)); + } else { + this.http = null; + } + + // Spawn the agent subprocess + this.process = spawn(command, args, { + cwd, + stdio: ["pipe", "pipe", "pipe"], + env, + }); // Handle process errors this.process.on("error", (error) => { @@ -331,7 +391,7 @@ export class ACPClientService extends EventEmitter { if (code !== 0 && code !== null) { this.emit( "error", - new Error(`OpenCode process exited with code ${code}`), + new Error(`${this.agentName} process exited with code ${code}`), ); } this.handleDisconnect(); diff --git a/packages/extension-core/src/extension/chat/acp-connectors.ts b/packages/extension-core/src/extension/chat/acp-connectors.ts new file mode 100644 index 0000000..e740ac9 --- /dev/null +++ b/packages/extension-core/src/extension/chat/acp-connectors.ts @@ -0,0 +1,342 @@ +/** + * The ACP connectors, for every dialogram product: what the chat spawns and + * what an agent node may name (the property panel's list). Read the way the + * product's runtime reads them, in the platform itself so no product needs + * that runtime on the PATH to know them: + * + * 1. the known agents, available when their command is on the PATH + * (`opencode acp`, Zed's `claude-agent-acp` for Claude Code, `codex-acp`, + * `gemini --experimental-acp`); + * 2. the user's connectors file, under `$XDG_CONFIG_HOME` (`~/.config`), + * adding or overriding; + * 3. the workspace's connectors file, under the project root (the first + * directory up with a `pyproject.toml` or a `.git`), overriding both. + * + * The product names the two files ({@link AcpConnectorConfig.files}), since + * they are its runtime's. One TOML table per connector: `command` (split as + * a shell would), `model`, `mode`, `http_api`, `env`. + */ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import * as vscode from "vscode"; +import { agentInstallDirs, type AcpAgentSpec } from "../acp-client.js"; +import { parseTomlSubset, type TomlTable, type TomlValue } from "./toml-subset.js"; + +/** + * A product's declaration on its chat config: the setting, under the + * profile's settings namespace, naming the chat's connector (user level, + * workspace override), and the runtime's two connectors files. Empty, or + * `opencode`, is the chat's own default. + */ +export interface AcpConnectorConfig { + /** e.g. `'acp.connector'` for `.acp.connector`. */ + settingKey: string; + /** The connectors files the product's runtime reads, as relative paths: + * `user` under `$XDG_CONFIG_HOME` (`~/.config`), `workspace` under the + * project root. */ + files: AcpConnectorFiles; +} + +export interface AcpConnectorFiles { + user: string; + workspace: string; +} + +export interface AcpConnectorInfo { + name: string; + /** The command's first word resolves on the PATH (with the agents' usual install dirs). */ + available: boolean; + /** discovered | user | workspace */ + source: string; + command: string; + /** The process also serves opencode's HTTP API (revert, message ids). */ + httpApi: boolean; + model?: string | null; + mode?: string | null; + env?: Record; +} + +/** What the chat spawns for a connector: {@link AcpAgentSpec}. */ +export type ChatAgentSpec = AcpAgentSpec & { httpApi: boolean }; + +/** The agents known without being told; the same table as the runtime's. */ +export const KNOWN_CONNECTORS: Readonly> = { + opencode: { command: "opencode acp", httpApi: true }, + claude: { command: "claude-agent-acp", httpApi: false }, + codex: { command: "codex-acp", httpApi: false }, + gemini: { command: "gemini --experimental-acp", httpApi: false }, +}; + +export interface LoadAcpConnectorsOptions { + /** The runtime's two connectors files, relative ({@link AcpConnectorFiles}). */ + files: AcpConnectorFiles; + /** Where the workspace file is looked for: the source file's directory. */ + workspaceDir: string; + /** Overrides, for tests: the two files resolved, and the PATH to probe. */ + userFile?: string; + workspaceFile?: string; + pathDirs?: string[]; +} + +/** The user's file: `$XDG_CONFIG_HOME/`, `~/.config` without the variable. */ +export function userConnectorsFile(relative: string, env: NodeJS.ProcessEnv = process.env): string { + const base = env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"); + return path.join(base, relative); +} + +/** The project root above `start`: the first directory up with a + * `pyproject.toml` or a `.git`, else undefined. */ +export function workspaceRoot(start: string): string | undefined { + let current = path.resolve(start); + try { + if (fs.statSync(current).isFile()) { + current = path.dirname(current); + } + } catch { + // A directory that does not exist yet has no root. + } + for (;;) { + if (isFile(path.join(current, "pyproject.toml")) || exists(path.join(current, ".git"))) { + return current; + } + const parent = path.dirname(current); + if (parent === current) { + return undefined; + } + current = parent; + } +} + +export function workspaceConnectorsFile(root: string | undefined, relative: string): string | undefined { + return root ? path.join(root, relative) : undefined; +} + +/** The directory the listing is asked for: the source file's. */ +export function workspaceDirFor(sourcePath: string): string { + return path.dirname(sourcePath); +} + +/** + * Every connector a workspace sees: discovered, then the user's file, then + * the workspace's, later ones overriding earlier ones by name. Throws on a + * file that does not parse, naming it. + */ +export function loadAcpConnectors(options: LoadAcpConnectorsOptions): AcpConnectorInfo[] { + const pathDirs = options.pathDirs ?? defaultPathDirs(); + const found = new Map(); + for (const [name, spec] of Object.entries(KNOWN_CONNECTORS)) { + const argv = splitCommand(spec.command); + found.set(name, { + name, source: "discovered", available: isOnPath(argv[0], pathDirs), + command: spec.command, httpApi: spec.httpApi, model: null, mode: null, + }); + } + const userFile = options.userFile ?? userConnectorsFile(options.files.user); + for (const [name, table] of readConnectorsFile(userFile)) { + found.set(name, fromTable(name, table, "user", found.get(name), userFile, pathDirs)); + } + const wsFile = options.workspaceFile ?? workspaceConnectorsFile(workspaceRoot(options.workspaceDir), options.files.workspace); + for (const [name, table] of readConnectorsFile(wsFile)) { + found.set(name, fromTable(name, table, "workspace", found.get(name), wsFile!, pathDirs)); + } + return [...found.values()].sort((a, b) => a.name.localeCompare(b.name)); +} + +/** The connectors a product's declaration sees for a workspace directory; + * `undefined` when a file is unreadable (logged), so a caller that only + * lists falls back to nothing. */ +export async function listAcpConnectors(config: AcpConnectorConfig, workspaceDir: string): Promise { + try { + return loadAcpConnectors({ files: config.files, workspaceDir }); + } catch (err) { + console.warn("[dialogram] ACP connectors:", err instanceof Error ? err.message : String(err)); + return undefined; + } +} + +/** + * The chat's agent for a workspace, from a product's declaration: the + * connector its setting names (read at the workspace's scope), resolved in + * the connectors that workspace sees. Rejects with the reason the chat + * shows as its connection error. + */ +export function createAcpAgentResolver( + settingsNamespace: string, + config: AcpConnectorConfig +): (cwd: string) => Promise { + return async (cwd: string) => { + const name = (vscode.workspace + .getConfiguration(settingsNamespace, vscode.Uri.file(cwd)) + .get(config.settingKey, "") ?? "").trim(); + return resolveChatAgent(name, loadAcpConnectors({ files: config.files, workspaceDir: cwd }), config.files); + }; +} + +/** + * The agent the chat spawns for the connector a setting names. Throws with + * the reason a reader can act on. + */ +export function resolveChatAgent(name: string, connectors: AcpConnectorInfo[] | undefined, files?: AcpConnectorFiles): ChatAgentSpec | undefined { + const wanted = name.trim() || "opencode"; + if (!connectors) { + if (wanted === "opencode") return undefined; + throw new Error(`no ACP connectors could be listed, so "${wanted}" cannot be resolved (only opencode works without a listing)`); + } + const found = connectors.find((c) => c.name === wanted); + if (!found) { + const known = connectors.map((c) => c.name).join(", ") || "none"; + const where = files ? `; declare it in ${userConnectorsFile(files.user)} or the workspace's ${files.workspace}` : ""; + throw new Error(`ACP connector "${wanted}" is not known (known: ${known})${where}`); + } + if (!found.available) { + throw new Error(`ACP connector "${wanted}" is not available: "${found.command}" is not on the PATH`); + } + const argv = splitCommand(found.command); + if (argv.length === 0) { + throw new Error(`ACP connector "${wanted}" has an empty command`); + } + return { name: found.name, argv, httpApi: found.httpApi, env: found.env }; +} + +/** + * Split a connector's command line the way the runtime does (shlex): words on + * whitespace, quotes grouping, a backslash escaping. + */ +export function splitCommand(command: string): string[] { + const out: string[] = []; + let cur = ""; + let quote: string | undefined; + let has = false; + for (let i = 0; i < command.length; i++) { + const ch = command[i]; + if (quote) { + if (ch === quote) quote = undefined; + else if (ch === "\\" && quote === '"' && i + 1 < command.length) cur += command[++i]; + else cur += ch; + } else if (ch === '"' || ch === "'") { + quote = ch; + has = true; + } else if (ch === "\\" && i + 1 < command.length) { + cur += command[++i]; + has = true; + } else if (/\s/.test(ch)) { + if (has) out.push(cur); + cur = ""; + has = false; + } else { + cur += ch; + has = true; + } + } + if (has) out.push(cur); + return out; +} + +/** `shlex.join`: what a listing shows for an argv. */ +export function joinCommand(argv: string[]): string { + return argv.map((a) => (/^[A-Za-z0-9_@%+=:,./-]+$/.test(a) ? a : `'${a.replace(/'/g, "'\\''")}'`)).join(" "); +} + +// ── files ────────────────────────────────────────────────────────────── + +function readConnectorsFile(file: string | undefined): Array<[string, TomlTable]> { + if (!file || !isFile(file)) { + return []; + } + let data: TomlTable; + try { + data = parseTomlSubset(fs.readFileSync(file, "utf8")); + } catch (err) { + throw new Error(`${file}: ${err instanceof Error ? err.message : String(err)}`); + } + const tables = data.connectors; + if (tables === undefined) { + return []; + } + if (!isTable(tables)) { + throw new Error(`${file}: \`connectors\` must be a table of tables`); + } + return Object.entries(tables).map(([name, table]) => { + if (!isTable(table)) { + throw new Error(`${file}: connector '${name}' must be a table`); + } + return [name, table]; + }); +} + +function fromTable( + name: string, table: TomlTable, source: string, base: AcpConnectorInfo | undefined, file: string, pathDirs: string[] +): AcpConnectorInfo { + const command = table.command !== undefined ? String(table.command) : base?.command ?? ""; + const argv = splitCommand(command); + if (argv.length === 0) { + throw new Error(`${file}: connector '${name}' names no command`); + } + const env: Record = { ...(base?.env ?? {}) }; + if (table.env !== undefined) { + if (!isTable(table.env)) { + throw new Error(`${file}: connector '${name}': env must be a table`); + } + for (const [k, v] of Object.entries(table.env)) env[k] = String(v); + } + const str = (v: TomlValue | undefined, fallback: string | null | undefined): string | null => + v === undefined ? fallback ?? null : v === null ? null : String(v); + return { + name, + source, + available: isOnPath(argv[0], pathDirs), + command: joinCommand(argv), + httpApi: table.http_api !== undefined ? Boolean(table.http_api) : base?.httpApi ?? false, + model: str(table.model, base?.model), + mode: str(table.mode, base?.mode), + env: Object.keys(env).length ? env : undefined, + }; +} + +// ── the PATH ─────────────────────────────────────────────────────────── + +/** The PATH the chat spawns with: the agents' install dirs ahead of the process's. */ +export function defaultPathDirs(env: NodeJS.ProcessEnv = process.env): string[] { + const sep = process.platform === "win32" ? ";" : ":"; + const inherited = (env.PATH ?? env.Path ?? "").split(sep).filter(Boolean); + return [...agentInstallDirs(), ...inherited]; +} + +/** `shutil.which`: the command resolves as an executable file. */ +export function isOnPath(command: string, pathDirs: string[]): boolean { + if (!command) { + return false; + } + const exts = process.platform === "win32" ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";") : [""]; + const candidates = (file: string) => exts.map((e) => file + e); + if (command.includes("/") || (process.platform === "win32" && command.includes("\\"))) { + return candidates(command).some(isExecutable); + } + return pathDirs.some((dir) => candidates(path.join(dir, command)).some(isExecutable)); +} + +function isExecutable(file: string): boolean { + try { + fs.accessSync(file, fs.constants.X_OK); + return fs.statSync(file).isFile(); + } catch { + return false; + } +} + +function isFile(p: string): boolean { + try { + return fs.statSync(p).isFile(); + } catch { + return false; + } +} + +function exists(p: string): boolean { + return fs.existsSync(p); +} + +function isTable(v: TomlValue | undefined): v is TomlTable { + return typeof v === "object" && v !== null && !Array.isArray(v); +} diff --git a/packages/extension-core/src/extension/chat/acp-event-forwarding.ts b/packages/extension-core/src/extension/chat/acp-event-forwarding.ts index 5110ce1..d8642ff 100644 --- a/packages/extension-core/src/extension/chat/acp-event-forwarding.ts +++ b/packages/extension-core/src/extension/chat/acp-event-forwarding.ts @@ -12,6 +12,8 @@ import type { TurnPart } from '../acp-client.js'; export interface AcpEmitterLike { on(event: string, fn: (...a: any[]) => void): any; off(event: string, fn: (...a: any[]) => void): any; + /** The name of the ACP connector the client spawned, for the status. */ + readonly agent?: string; } export interface AcpEventSinks { @@ -89,9 +91,11 @@ export function attachAcpEventForwarding(acp: AcpEmitterLike, sinks: AcpEventSin sinks.onTurnComplete(data); }; const onPermissionRequest = (data: any) => sinks.broadcast({ type: 'chat.permissionRequest', data }); - const onConnected = () => sinks.broadcast({ type: 'chat.connectionStatus', data: { connected: true } }); + const agentName = () => acp.agent ?? 'agent'; + const onConnected = () => + sinks.broadcast({ type: 'chat.connectionStatus', data: { connected: true, agent: agentName() } }); const onDisconnected = () => - sinks.broadcast({ type: 'chat.connectionStatus', data: { connected: false, reason: 'opencode disconnected' } }); + sinks.broadcast({ type: 'chat.connectionStatus', data: { connected: false, reason: `${agentName()} disconnected`, agent: agentName() } }); const onError = (err: any) => sinks.broadcast({ type: 'chat.error', data: { message: err?.message ?? String(err) } }); diff --git a/packages/extension-core/src/extension/chat/chat-runtime.ts b/packages/extension-core/src/extension/chat/chat-runtime.ts index 5272ef4..79b4c27 100644 --- a/packages/extension-core/src/extension/chat/chat-runtime.ts +++ b/packages/extension-core/src/extension/chat/chat-runtime.ts @@ -14,7 +14,8 @@ */ import * as path from "node:path"; import * as vscode from "vscode"; -import { ACPClientService, type TurnPart } from "../acp-client.js"; +import { ACPClientService, type AcpAgentSpec, type TurnPart } from "../acp-client.js"; +import type { DiagramRunAnswer, DiagramRunQuestion } from "../../api"; import { SessionManager } from "../session-manager.js"; import type { ChatMessageSink, @@ -42,6 +43,11 @@ export interface ChatRuntimeConfig { key: string; /** Human-readable name, used for the output channel (" Chat"). */ displayName: string; + /** + * The ACP agent the chat spawns for a workspace; undefined (or no hook) + * means opencode. A rejection is the connect error the panel shows. + */ + acpAgent?: (cwd: string) => Promise; /** Settings section read for `opencodePath` and `enableMcpTools`. */ settingsSection: string; /** Domain primer injected into each session's context. */ @@ -112,10 +118,18 @@ export class ChatRuntime { /** The watchdog dialog fires at most once per runtime — no per-diagram nagging. */ private connectWarned = false; + /** A running agent's question, waiting for the panel's `chat.runAnswer`. */ + private readonly pendingRunQuestions = new Map< + string, + { resolve: (answer: DiagramRunAnswer) => void; timer?: ReturnType } + >(); + constructor( context: vscode.ExtensionContext, private readonly config: ChatRuntimeConfig, private readonly postToWebview: ChatMessageSink, + /** Whether the sink can reach a panel for a URI; absent means "assume so". */ + private readonly canReach?: (uri: string) => boolean, ) { this.context = context; this.registry = new SlashCommandRegistry(config.slashCommands ?? []); @@ -226,7 +240,7 @@ export class ChatRuntime { ); void vscode.window .showWarningMessage( - `${this.config.displayName} chat could not connect to opencode within 30 seconds. ` + + `${this.config.displayName} chat could not connect to its agent (${this.acp.agent}) within 30 seconds. ` + "The agent may not be installed or on PATH.", "Show Log", "Run Diagnostics", @@ -251,8 +265,8 @@ export class ChatRuntime { private async ensureStarted(cwd: string): Promise { if (this.started) return; if (!this.startPromise) { - this.startPromise = this.acp - .start(cwd) + this.startPromise = Promise.resolve(this.config.acpAgent?.(cwd)) + .then((agent) => this.acp.start(cwd, agent)) .then(async () => { // Stand up the HTTP tool server before any session is created, // so mcpServersProvider can attach it synchronously. @@ -431,15 +445,15 @@ export class ChatRuntime { this.clearConnectWatchdog(); this.postToWebview(uri, { type: "chat.connectionStatus", - data: { connected: true }, + data: { connected: true, agent: this.acp.agent }, }); return true; } catch (err) { - const message = `Could not start opencode: ${String(err)}`; + const message = `Could not start the chat agent (${this.acp.agent}): ${String(err)}`; this.output.appendLine(message); this.postToWebview(uri, { type: "chat.connectionStatus", - data: { connected: false, reason: String(err) }, + data: { connected: false, reason: String(err), agent: this.acp.agent }, }); this.postToWebview(uri, { type: "chat.error", data: { message } }); return false; @@ -476,7 +490,7 @@ export class ChatRuntime { // handshake must not wait out an opencode spawn. this.postToWebview(uri, { type: "chat.connectionStatus", - data: { connected: this.acp.isClientConnected() }, + data: { connected: this.acp.isClientConnected(), agent: this.acp.agent }, }); this.armConnectWatchdog(); this.sendSessions(uri, file); @@ -770,11 +784,49 @@ export class ChatRuntime { case "chat.permissionResponse": this.acp.respondToPermission(data.requestId, data.optionId ?? null); return; + case "chat.runAnswer": { + const pending = this.pendingRunQuestions.get(String(data?.id)); + if (!pending) return; + this.pendingRunQuestions.delete(String(data.id)); + if (pending.timer) clearTimeout(pending.timer); + pending.resolve( + data?.declined + ? { declined: true, reason: typeof data.reason === "string" ? data.reason : "declined" } + : { answer: String(data?.answer ?? "") }, + ); + return; + } default: return; } } + /** + * Put a running agent's question (the run driver's elicitation) to the chat + * panel open on `uri`, and wait for its `chat.runAnswer`. The chat is a + * viewer of the run here, not a session: nothing goes to the agent process. + * `undefined` when no panel can be reached, so the driver falls back to a + * VS Code prompt; declined on the question's own timeout. + */ + askRunQuestion(uri: string, question: DiagramRunQuestion): Promise { + if (this.canReach && !this.canReach(uri)) { + this.logLine(`run question ${question.id} from ${question.agent}: no chat panel on ${uri}`); + return Promise.resolve(undefined); + } + const key = String(question.id); + return new Promise((resolve) => { + const timer = question.timeoutMs && question.timeoutMs > 0 + ? setTimeout(() => { + if (this.pendingRunQuestions.delete(key)) { + resolve({ declined: true, reason: "timeout" }); + } + }, question.timeoutMs) + : undefined; + this.pendingRunQuestions.set(key, { resolve, timer }); + this.postToWebview(uri, { type: "chat.runQuestion", data: { ...question } }); + }); + } + /** The last model the user explicitly chose (migrated from the legacy key). */ private getPreferredModel(): string | undefined { return readStateWithFallback( diff --git a/packages/extension-core/src/extension/chat/glsp-chat-transport.ts b/packages/extension-core/src/extension/chat/glsp-chat-transport.ts index 5ba1424..b63b489 100644 --- a/packages/extension-core/src/extension/chat/glsp-chat-transport.ts +++ b/packages/extension-core/src/extension/chat/glsp-chat-transport.ts @@ -26,6 +26,9 @@ const CONNECTOR_RETRY_MAX = 20; export interface GlspChatTransport { /** Per-URI reply sink handed to the ChatRuntime constructor. */ sink: ChatMessageSink; + /** Whether a panel has spoken for `uri` on this connection, i.e. the sink + * can reach it: a question posted to nobody would wait for ever. */ + canReach(uri: string): boolean; /** Install the messenger listener and start forwarding to the runtime. */ connect(runtime: { handleMessage(uri: string, payload: ChatPayload): Promise }): void; dispose(): void; @@ -92,6 +95,7 @@ export function createGlspChatTransport(opts: { return { sink, + canReach: (uri) => Boolean(messenger && participantByUri.has(uri)), connect(runtime): void { wire(runtime); }, diff --git a/packages/extension-core/src/extension/chat/toml-subset.ts b/packages/extension-core/src/extension/chat/toml-subset.ts new file mode 100644 index 0000000..059774e --- /dev/null +++ b/packages/extension-core/src/extension/chat/toml-subset.ts @@ -0,0 +1,348 @@ +/** + * The TOML a connectors file uses: tables and dotted keys, strings, + * booleans, numbers, arrays and inline tables, comments. Enough to read what + * the runtime reads, without a dependency; an array of tables (`[[x]]`) is + * refused by name, since the file has no use for one. + */ +export type TomlTable = { [key: string]: TomlValue }; +export type TomlValue = string | number | boolean | TomlValue[] | TomlTable; + +export class TomlSubsetError extends Error { + constructor(message: string, readonly line: number) { + super(`line ${line}: ${message}`); + } +} + +export function parseTomlSubset(text: string): TomlTable { + return new Parser(text).parse(); +} + +class Parser { + private pos = 0; + private readonly root: TomlTable = {}; + private current: TomlTable = this.root; + + constructor(private readonly text: string) {} + + parse(): TomlTable { + for (;;) { + this.skipBlank(); + if (this.pos >= this.text.length) { + return this.root; + } + const ch = this.text[this.pos]; + if (ch === '[') { + if (this.text[this.pos + 1] === '[') { + throw this.error('an array of tables ([[...]]) is not something a connectors file holds'); + } + this.pos++; + const keys = this.readKeyPath(']'); + this.expect(']'); + this.endOfLine(); + this.current = this.descend(this.root, keys, true); + } else { + const keys = this.readKeyPath('='); + this.expect('='); + this.skipSpaces(); + const value = this.readValue(); + this.endOfLine(); + this.assign(this.current, keys, value); + } + } + } + + // ── structure ────────────────────────────────────────────────────── + + private descend(table: TomlTable, keys: string[], header: boolean): TomlTable { + let node = table; + for (const key of keys) { + const existing = node[key]; + if (existing === undefined) { + const created: TomlTable = {}; + node[key] = created; + node = created; + } else if (isTable(existing)) { + node = existing; + } else { + throw this.error(`'${key}' is a value, not a table`); + } + } + if (header && Object.keys(node).length > 0 && keys.length > 0 && !(keys.join('.') in this.headers)) { + // A header may reopen a table only once; dotted keys under a + // parent are fine. Track by path. + } + this.headers[keys.join('.')] = true; + return node; + } + private readonly headers: Record = {}; + + private assign(table: TomlTable, keys: string[], value: TomlValue): void { + const node = this.descend(table, keys.slice(0, -1), false); + const last = keys[keys.length - 1]; + if (last in node) { + throw this.error(`'${keys.join('.')}' is defined twice`); + } + node[last] = value; + } + + // ── keys ─────────────────────────────────────────────────────────── + + private readKeyPath(until: string): string[] { + const keys: string[] = []; + for (;;) { + this.skipSpaces(); + keys.push(this.readKey()); + this.skipSpaces(); + if (this.text[this.pos] === '.') { + this.pos++; + continue; + } + if (this.text[this.pos] === until) { + return keys; + } + throw this.error(`expected '.' or '${until}' after a key`); + } + } + + private readKey(): string { + const ch = this.text[this.pos]; + if (ch === '"') { + return this.readBasicString(); + } + if (ch === "'") { + return this.readLiteralString(); + } + const m = /^[A-Za-z0-9_-]+/.exec(this.text.slice(this.pos)); + if (!m) { + throw this.error('expected a key'); + } + this.pos += m[0].length; + return m[0]; + } + + // ── values ───────────────────────────────────────────────────────── + + private readValue(): TomlValue { + const ch = this.text[this.pos]; + if (ch === '"') { + return this.text.startsWith('"""', this.pos) ? this.readMultiline('"""') : this.readBasicString(); + } + if (ch === "'") { + return this.text.startsWith("'''", this.pos) ? this.readMultiline("'''") : this.readLiteralString(); + } + if (ch === '[') { + return this.readArray(); + } + if (ch === '{') { + return this.readInlineTable(); + } + const rest = this.text.slice(this.pos); + if (/^true\b/.test(rest)) { + this.pos += 4; + return true; + } + if (/^false\b/.test(rest)) { + this.pos += 5; + return false; + } + const num = /^[+-]?(?:\d[\d_]*(?:\.\d[\d_]*)?(?:[eE][+-]?\d+)?|inf|nan)/.exec(rest); + if (num && num[0].length > 0) { + this.pos += num[0].length; + const n = Number(num[0].replace(/_/g, '')); + if (Number.isNaN(n) && !/nan/.test(num[0])) { + throw this.error(`not a number: ${num[0]}`); + } + return n; + } + throw this.error('expected a value'); + } + + private readBasicString(): string { + this.pos++; // opening quote + let out = ''; + for (;;) { + if (this.pos >= this.text.length) { + throw this.error('unterminated string'); + } + const ch = this.text[this.pos++]; + if (ch === '"') { + return out; + } + if (ch === '\n') { + throw this.error('a newline inside a string (use """ for several lines)'); + } + if (ch === '\\') { + out += this.readEscape(); + } else { + out += ch; + } + } + } + + private readEscape(): string { + const ch = this.text[this.pos++]; + switch (ch) { + case 'n': return '\n'; + case 't': return '\t'; + case 'r': return '\r'; + case 'b': return '\b'; + case 'f': return '\f'; + case '"': return '"'; + case '\\': return '\\'; + case 'u': + case 'U': { + const width = ch === 'u' ? 4 : 8; + const hex = this.text.slice(this.pos, this.pos + width); + if (!/^[0-9A-Fa-f]+$/.test(hex) || hex.length !== width) { + throw this.error(`bad unicode escape \\${ch}${hex}`); + } + this.pos += width; + return String.fromCodePoint(parseInt(hex, 16)); + } + default: + throw this.error(`unknown escape \\${ch ?? ''}`); + } + } + + private readLiteralString(): string { + this.pos++; + const end = this.text.indexOf("'", this.pos); + if (end < 0 || this.text.slice(this.pos, end).includes('\n')) { + throw this.error('unterminated literal string'); + } + const out = this.text.slice(this.pos, end); + this.pos = end + 1; + return out; + } + + private readMultiline(delim: string): string { + this.pos += 3; + if (this.text[this.pos] === '\n') { + this.pos++; + } else if (this.text.startsWith('\r\n', this.pos)) { + this.pos += 2; + } + const end = this.text.indexOf(delim, this.pos); + if (end < 0) { + throw this.error('unterminated multi-line string'); + } + let raw = this.text.slice(this.pos, end); + this.pos = end + 3; + if (delim === '"""') { + raw = raw.replace(/\\\r?\n[ \t\r\n]*/g, '').replace(/\\(.)/g, (_m, c: string) => { + switch (c) { + case 'n': return '\n'; + case 't': return '\t'; + case 'r': return '\r'; + case '"': return '"'; + case '\\': return '\\'; + default: return '\\' + c; + } + }); + } + return raw; + } + + private readArray(): TomlValue[] { + this.pos++; + const out: TomlValue[] = []; + for (;;) { + this.skipBlank(); + if (this.text[this.pos] === ']') { + this.pos++; + return out; + } + out.push(this.readValue()); + this.skipBlank(); + if (this.text[this.pos] === ',') { + this.pos++; + continue; + } + if (this.text[this.pos] === ']') { + continue; + } + throw this.error("expected ',' or ']' in an array"); + } + } + + private readInlineTable(): TomlTable { + this.pos++; + const table: TomlTable = {}; + this.skipSpaces(); + if (this.text[this.pos] === '}') { + this.pos++; + return table; + } + for (;;) { + const keys = this.readKeyPath('='); + this.expect('='); + this.skipSpaces(); + const value = this.readValue(); + this.assign(table, keys, value); + this.skipSpaces(); + if (this.text[this.pos] === ',') { + this.pos++; + this.skipSpaces(); + continue; + } + this.expect('}'); + return table; + } + } + + // ── lexing helpers ───────────────────────────────────────────────── + + private skipSpaces(): void { + while (this.text[this.pos] === ' ' || this.text[this.pos] === '\t') { + this.pos++; + } + } + + /** Spaces, newlines and comments. */ + private skipBlank(): void { + for (;;) { + const ch = this.text[this.pos]; + if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') { + this.pos++; + } else if (ch === '#') { + while (this.pos < this.text.length && this.text[this.pos] !== '\n') { + this.pos++; + } + } else { + return; + } + } + } + + private endOfLine(): void { + this.skipSpaces(); + const ch = this.text[this.pos]; + if (ch === '#') { + while (this.pos < this.text.length && this.text[this.pos] !== '\n') { + this.pos++; + } + return; + } + if (ch === undefined || ch === '\n' || ch === '\r') { + return; + } + throw this.error(`unexpected '${ch}' after a value`); + } + + private expect(ch: string): void { + this.skipSpaces(); + if (this.text[this.pos] !== ch) { + throw this.error(`expected '${ch}'`); + } + this.pos++; + } + + private error(message: string): TomlSubsetError { + const line = this.text.slice(0, this.pos).split('\n').length; + return new TomlSubsetError(message, line); + } +} + +function isTable(v: TomlValue): v is TomlTable { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} diff --git a/packages/extension-core/src/extension/diagram/diagram-editor-provider.ts b/packages/extension-core/src/extension/diagram/diagram-editor-provider.ts index 8ed89f9..d4b65a3 100644 --- a/packages/extension-core/src/extension/diagram/diagram-editor-provider.ts +++ b/packages/extension-core/src/extension/diagram/diagram-editor-provider.ts @@ -10,7 +10,8 @@ import { RequestModelAction } from '@eclipse-glsp/protocol'; import * as vscode from 'vscode'; import { statSync } from 'node:fs'; import { WORKFLOW_DIAGRAM_TYPE } from '@dialogram/shared'; -import { type DiagramProfile } from '../../api'; +import { type DiagramClientBehavior, type DiagramProfile } from '../../api'; +import { listAcpConnectors, workspaceDirFor } from '../chat/acp-connectors'; import { normalizeSourceUriKey } from './uri-keys'; import { matchesSourceExtension, sourceWatchGlobs } from './source-extensions'; @@ -423,6 +424,7 @@ export class WorkflowEditorProvider extends GlspEditorProvider { clientId, documentUri: document.uri.toString() }); + this.postClientBehaviorExtras(document.uri.toString(), webview); } /** @@ -774,6 +776,32 @@ export class WorkflowEditorProvider extends GlspEditorProvider { * This creates a minimal HTML page that loads the GLSP diagram client * and initializes it with the diagram identifier. */ + /** + * The behavior that depends on the machine or the workspace rather than on + * the product: the ACP connectors the document's workspace sees, for a + * profile that declares the connector setting. Resolved after the webview + * is up (this setup is synchronous) and posted to it as + * `dialogram.clientBehavior.merge`, which the client folds into its + * `clientBehavior()`. Bounded and never failing: without an answer the + * static behavior stands. + */ + private postClientBehaviorExtras(documentUri: string, webview: vscode.Webview): void { + const acpConnector = this.profile.chat?.acpConnector; + if (!acpConnector) { + return; + } + const dir = workspaceDirFor(vscode.Uri.parse(documentUri).fsPath); + const deadline = new Promise((resolve) => setTimeout(() => resolve(undefined), 8000)); + void Promise.race([listAcpConnectors(acpConnector, dir), deadline]) + .then((acpConnectors) => { + if (acpConnectors) { + const extras: Partial = { acpConnectors }; + void webview.postMessage({ type: 'dialogram.clientBehavior.merge', payload: extras }); + } + }) + .catch((err) => console.warn('[dialogram provider] listing the ACP connectors failed:', err)); + } + protected getWebviewContent( webview: vscode.Webview, options: { diff --git a/packages/extension-core/src/extension/diagram/glsp-activation.ts b/packages/extension-core/src/extension/diagram/glsp-activation.ts index 46e1e7d..5d935af 100644 --- a/packages/extension-core/src/extension/diagram/glsp-activation.ts +++ b/packages/extension-core/src/extension/diagram/glsp-activation.ts @@ -37,7 +37,7 @@ import { executeViewerCommand, executeViewerOpen, executeViewerReveal } from './ import { decideDiagramOpen } from './diagram-open-decision'; import { readMcpServerUrl } from './mcp-server-url'; import { composeStorageRuntimeOptions } from './profile-storage-options'; -import { type DiagramProfile, type DiagramRunHost } from '../../api'; +import { type DiagramProfile, type DiagramRunAnswer, type DiagramRunHost, type DiagramRunQuestion } from '../../api'; // Define the diagram type constant locally to avoid import const WORKFLOW_DIAGRAM_TYPE = 'cal-network-diagram'; @@ -300,6 +300,8 @@ function serializedRangeToVscodeRange(range: SerializedRange): vscode.Range { * the helper functions that previously read/wrote module-level singletons. */ interface GlspActivationState { + /** See {@link GlspIntegrationHandle.setRunQuestionHandler}. */ + runQuestionHandler?: RunQuestionHandler; context: vscode.ExtensionContext; profile: DiagramProfile; // Transient cross-file drill-down handoff, scoped to this activation (per profile instance). @@ -332,8 +334,17 @@ export interface GlspIntegrationHandle extends vscode.Disposable { * initialize result (no stdout parsing). The chat runtime hands it to the agent clients. */ mcpServerUrl?: string; + /** + * Who answers a running agent's question for a diagram. The run driver is + * wired here, before the chat runtime exists; the profile runtime installs + * the chat as the answerer once it is up. Unset, or `undefined` back, and + * the driver falls back to a VS Code prompt. + */ + setRunQuestionHandler(handler: RunQuestionHandler | undefined): void; } +export type RunQuestionHandler = (question: DiagramRunQuestion, sourceUri: string) => Promise; + /** * Activate the GLSP integration for Workflow diagrams. * @@ -960,6 +971,9 @@ export async function activateGlspIntegration( editorProvider, executionOverlay, mcpServerUrl, + setRunQuestionHandler: (handler) => { + state.runQuestionHandler = handler; + }, dispose: () => disposable.dispose() }; } @@ -1428,6 +1442,7 @@ function registerCalDiagramCommands( // registers its source through the host so core does not hold the driver. const host: DiagramRunHost = { overlay: executionOverlay, + askUser: async (question, sourceUri) => state.runQuestionHandler?.(question, sourceUri), requestRefresh: requestRunRefresh, output: runOutput, useLiveOverlaySignatureSource: (source) => { diff --git a/packages/extension-core/src/extension/profile-runtime.ts b/packages/extension-core/src/extension/profile-runtime.ts index 0d3ee86..5745588 100644 --- a/packages/extension-core/src/extension/profile-runtime.ts +++ b/packages/extension-core/src/extension/profile-runtime.ts @@ -16,6 +16,7 @@ import type * as vscode from "vscode"; import type { DiagramProfile, DiagramProfileHandle } from "../api"; import { activateGlspIntegration } from "./diagram/glsp-activation"; import { ChatRuntime, type ChatRuntimeConfig } from "./chat/chat-runtime"; +import { createAcpAgentResolver } from "./chat/acp-connectors"; import { createViewerEditorsTool } from "./chat/viewer-editors-tool"; import { createEditChatCapability, @@ -65,8 +66,12 @@ export async function activateProfileRuntime( capability, glsp.mcpServerUrl, ); - chatRuntime = new ChatRuntime(context, config, transport.sink); + chatRuntime = new ChatRuntime(context, config, transport.sink, transport.canReach); transport.connect(chatRuntime); + // The run driver's human port: a running agent's question goes to the + // chat panel open on the diagram (the chat is the run's viewer there). + const runtime = chatRuntime; + glsp.setRunQuestionHandler((question, uri) => runtime.askRunQuestion(uri, question)); context.subscriptions.push(chatRuntime, { dispose: () => { transport?.dispose(); @@ -114,6 +119,9 @@ export function assembleChatRuntimeConfig( ? (f) => capability.graphContextProvider(f) : chat.graphContextProvider, turnContextProvider: chat.turnContextProvider, + acpAgent: chat.acpConnector + ? createAcpAgentResolver(profile.settingsNamespace, chat.acpConnector) + : undefined, selectionContext: chat.selectionContext, // The profile's own tools, plus the platform's: "what can open this file" // is a question about the EDITOR, not about any one product's graph, so it diff --git a/packages/extension-core/test/acp-client.test.ts b/packages/extension-core/test/acp-client.test.ts index 179e385..1d2f8fb 100644 --- a/packages/extension-core/test/acp-client.test.ts +++ b/packages/extension-core/test/acp-client.test.ts @@ -187,6 +187,25 @@ describe('ACPClientService', () => { expect(client.isClientConnected()).toBe(true); }); + it('spawns the given agent as its own command, without the HTTP port', async () => { + await client.start('/test/workspace', { name: 'claude', argv: ['claude-agent-acp', '--verbose'], httpApi: false }); + + expect(spawn).toHaveBeenCalledWith( + 'claude-agent-acp', + ['--verbose'], + expect.objectContaining({ + cwd: '/test/workspace', + stdio: ['pipe', 'pipe', 'pipe'], + env: expect.objectContaining({ PATH: expect.any(String) }), + }) + ); + expect(client.agent).toBe('claude'); + expect(client.isClientConnected()).toBe(true); + // The HTTP-only capabilities stay off rather than failing. + await expect(client.getMessagesWithIds('s1')).resolves.toEqual([]); + await expect(client.getRevertState('s1')).resolves.toBe(false); + }); + it('should throw error if already connected', async () => { await client.start('/test/workspace'); @@ -218,7 +237,7 @@ describe('ACPClientService', () => { expect(errorSpy).toHaveBeenCalledWith( expect.objectContaining({ - message: 'OpenCode process exited with code 1', + message: 'opencode process exited with code 1', }) ); }); diff --git a/packages/extension-core/test/acp-connectors.test.ts b/packages/extension-core/test/acp-connectors.test.ts new file mode 100644 index 0000000..ff6c101 --- /dev/null +++ b/packages/extension-core/test/acp-connectors.test.ts @@ -0,0 +1,164 @@ +// The platform reads the connectors the way the runtime does: the known agents, +// available when on the PATH; the user's file; the workspace's file at the +// project root, each overriding the last by name. Real files and a real +// PATH directory here, so what is tested is the reading, not a mock of it. +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + createAcpAgentResolver, + loadAcpConnectors, + resolveChatAgent, + splitCommand, + userConnectorsFile, + workspaceRoot +} from '../src/extension/chat/acp-connectors'; + +const FILES = { user: 'wfpy/connectors.toml', workspace: '.wfpy/connectors.toml' }; +let dir: string; +let bin: string; +function executable(name: string): string { + const file = path.join(bin, name); + fs.writeFileSync(file, '#!/bin/sh\n', { mode: 0o755 }); + return file; +} +function write(file: string, text: string): string { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, text); + return file; +} + +beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'acp-connectors-')); + bin = path.join(dir, 'bin'); + fs.mkdirSync(bin); +}); +afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); +}); + +describe('loadAcpConnectors', () => { + it('knows the four agents, available when their command is on the PATH', () => { + executable('opencode'); + const found = loadAcpConnectors({ files: FILES, workspaceDir: dir, userFile: path.join(dir, 'none'), workspaceFile: path.join(dir, 'none'), pathDirs: [bin] }); + expect(found.map(c => [c.name, c.available, c.source, c.command, c.httpApi])).toEqual([ + ['claude', false, 'discovered', 'claude-agent-acp', false], + ['codex', false, 'discovered', 'codex-acp', false], + ['gemini', false, 'discovered', 'gemini --experimental-acp', false], + ['opencode', true, 'discovered', 'opencode acp', true] + ]); + }); + + it("applies the user's file, then the workspace's at the project root, by name", () => { + executable('opencode'); + const mine = executable('my-acp'); + const userFile = write(path.join(dir, 'config', 'wfpy', 'connectors.toml'), ` +[connectors.claude] +model = "sonnet" +[connectors.mine] +command = "${mine} --flag" +env = { API_KEY = "k" } +`); + const root = path.join(dir, 'project'); + fs.mkdirSync(path.join(root, '.git'), { recursive: true }); + write(path.join(root, '.wfpy', 'connectors.toml'), ` +[connectors.opencode] +command = "${path.join(bin, 'opencode')} acp" +[connectors.mine] +mode = "plan" +`); + const sub = path.join(root, 'flows', 'deep'); + fs.mkdirSync(sub, { recursive: true }); + + const found = loadAcpConnectors({ files: FILES, workspaceDir: sub, userFile, pathDirs: [bin] }); + const by = Object.fromEntries(found.map(c => [c.name, c])); + expect(by.claude).toMatchObject({ source: 'user', available: false, command: 'claude-agent-acp', model: 'sonnet', mode: null }); + expect(by.mine).toMatchObject({ source: 'workspace', available: true, command: `${mine} --flag`, mode: 'plan', env: { API_KEY: 'k' } }); + expect(by.opencode).toMatchObject({ source: 'workspace', available: true, command: `${path.join(bin, 'opencode')} acp`, httpApi: true }); + expect(found.map(c => c.name)).toEqual(['claude', 'codex', 'gemini', 'mine', 'opencode']); + }); + + it('names the file and the line when one does not parse, or a connector has no command', () => { + const bad = write(path.join(dir, 'bad.toml'), '[connectors.x]\ncommand = "oops'); + expect(() => loadAcpConnectors({ files: FILES, workspaceDir: dir, userFile: bad, workspaceFile: path.join(dir, 'none'), pathDirs: [] })) + .toThrow(new RegExp(`${bad.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}: line 2: unterminated string`)); + const empty = write(path.join(dir, 'empty.toml'), '[connectors.x]\nmodel = "m"'); + expect(() => loadAcpConnectors({ files: FILES, workspaceDir: dir, userFile: empty, workspaceFile: path.join(dir, 'none'), pathDirs: [] })) + .toThrow(/connector 'x' names no command/); + }); +}); + +describe('the files', () => { + it('finds the project root by pyproject.toml or .git, walking up', () => { + const root = path.join(dir, 'p'); + write(path.join(root, 'pyproject.toml'), ''); + fs.mkdirSync(path.join(root, 'a', 'b'), { recursive: true }); + expect(workspaceRoot(path.join(root, 'a', 'b'))).toBe(root); + expect(workspaceRoot(path.join(root, 'a', 'b', 'flow.py'))).toBe(root); + expect(workspaceRoot(dir)).toBeUndefined(); + }); + + it("locates the user's file under XDG_CONFIG_HOME, else ~/.config", () => { + expect(userConnectorsFile(FILES.user, { XDG_CONFIG_HOME: '/x' })).toBe(path.join('/x', 'wfpy', 'connectors.toml')); + expect(userConnectorsFile(FILES.user, {})).toBe(path.join(os.homedir(), '.config', 'wfpy', 'connectors.toml')); + }); + + it('reads the workspace file the declaration names, and no other', () => { + const root = path.join(dir, 'p'); + fs.mkdirSync(path.join(root, '.git'), { recursive: true }); + write(path.join(root, '.wfpy', 'connectors.toml'), '[connectors.a]\ncommand = "a"'); + write(path.join(root, '.other', 'connectors.toml'), '[connectors.b]\ncommand = "b"'); + const names = (files: typeof FILES) => + loadAcpConnectors({ files, workspaceDir: root, userFile: path.join(dir, 'none'), pathDirs: [] }).map(c => c.name); + expect(names(FILES)).toContain('a'); + expect(names(FILES)).not.toContain('b'); + expect(names({ ...FILES, workspace: '.other/connectors.toml' })).toContain('b'); + }); +}); + +describe('resolveChatAgent', () => { + const listed = [ + { name: 'claude', available: false, source: 'discovered', command: 'claude-agent-acp', httpApi: false, model: 'sonnet', mode: null }, + { name: 'mine', available: true, source: 'user', command: 'my-acp --flag', httpApi: false, model: null, mode: null, env: { A: 'b' } }, + { name: 'opencode', available: true, source: 'discovered', command: 'opencode acp', httpApi: true, model: null, mode: null } + ]; + + it('spawns the named connector as its command, with the HTTP API only where it has one', () => { + expect(resolveChatAgent('opencode', listed)).toEqual({ name: 'opencode', argv: ['opencode', 'acp'], httpApi: true, env: undefined }); + expect(resolveChatAgent('mine', listed)).toEqual({ name: 'mine', argv: ['my-acp', '--flag'], httpApi: false, env: { A: 'b' } }); + expect(resolveChatAgent('', listed)?.name).toBe('opencode'); + }); + + it('says why a connector cannot be used', () => { + expect(() => resolveChatAgent('claude', listed)).toThrow(/not available.*claude-agent-acp.*PATH/); + expect(() => resolveChatAgent('nope', listed)).toThrow(/not known \(known: claude, mine, opencode\)$/); + expect(() => resolveChatAgent('nope', listed, FILES)).toThrow(/declare it in .*wfpy\/connectors\.toml or the workspace's \.wfpy\/connectors\.toml/); + expect(resolveChatAgent('opencode', undefined)).toBeUndefined(); + expect(() => resolveChatAgent('claude', undefined)).toThrow(/no ACP connectors could be listed/); + }); + + it('resolves the chat agent from the setting for a workspace', async () => { + // The vscode mock's settings answer with the default: an empty name, + // which is the chat's opencode, taken from what the workspace sees. + executable('opencode'); + process.env.PATH = `${bin}${path.delimiter}${process.env.PATH ?? ''}`; + const xdg = process.env.XDG_CONFIG_HOME; + process.env.XDG_CONFIG_HOME = path.join(dir, 'no-config'); + try { + const resolve = createAcpAgentResolver('mlir', { settingKey: 'acp.connector', files: FILES }); + await expect(resolve(dir)).resolves.toMatchObject({ name: 'opencode', argv: ['opencode', 'acp'], httpApi: true }); + } finally { + if (xdg === undefined) delete process.env.XDG_CONFIG_HOME; else process.env.XDG_CONFIG_HOME = xdg; + } + }); +}); + +describe('splitCommand', () => { + it('splits words, quotes and escapes the way the runtime does', () => { + expect(splitCommand('opencode acp')).toEqual(['opencode', 'acp']); + expect(splitCommand(' gemini --experimental-acp ')).toEqual(['gemini', '--experimental-acp']); + expect(splitCommand('"/opt/my tools/acp" --name \'a b\' c\\ d')).toEqual(['/opt/my tools/acp', '--name', 'a b', 'c d']); + expect(splitCommand('')).toEqual([]); + }); +}); diff --git a/packages/extension-core/test/acp-event-forwarding.test.ts b/packages/extension-core/test/acp-event-forwarding.test.ts index 05d7b3f..93abcbd 100644 --- a/packages/extension-core/test/acp-event-forwarding.test.ts +++ b/packages/extension-core/test/acp-event-forwarding.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { attachAcpEventForwarding, type AcpEventSinks } from '../src/extension/chat/acp-event-forwarding'; class FakeAcp { + agent = 'opencode'; private listeners = new Map void>>(); on(ev: string, fn: (...a: any[]) => void) { const arr = this.listeners.get(ev) ?? []; @@ -88,8 +89,8 @@ describe('attachAcpEventForwarding', () => { acp.emit('disconnected'); acp.emit('error', new Error('boom')); expect(broadcasts).toEqual([ - { type: 'chat.connectionStatus', data: { connected: true } }, - { type: 'chat.connectionStatus', data: { connected: false, reason: 'opencode disconnected' } }, + { type: 'chat.connectionStatus', data: { connected: true, agent: 'opencode' } }, + { type: 'chat.connectionStatus', data: { connected: false, reason: 'opencode disconnected', agent: 'opencode' } }, { type: 'chat.error', data: { message: 'boom' } } ]); }); diff --git a/packages/extension-core/test/chat-config-assembly.test.ts b/packages/extension-core/test/chat-config-assembly.test.ts index e6e8db5..1b33574 100644 --- a/packages/extension-core/test/chat-config-assembly.test.ts +++ b/packages/extension-core/test/chat-config-assembly.test.ts @@ -83,6 +83,13 @@ describe('assembleChatRuntimeConfig', () => { expect(cfg.slashCommands![2].description).toBe('profile-layout'); }); + it('a declared ACP connector becomes the runtime\'s agent resolver; none means opencode', () => { + const declared = assembleChatRuntimeConfig(makeProfile({ acpConnector: { settingKey: 'acp.connector', files: { user: 'r/connectors.toml', workspace: '.r/connectors.toml' } } }), undefined); + expect(typeof declared.acpAgent).toBe('function'); + const plain = assembleChatRuntimeConfig(makeProfile({}), undefined); + expect(plain.acpAgent).toBeUndefined(); + }); + it('absent optional fields stay undefined', () => { const cfg = assembleChatRuntimeConfig(makeProfile({}), undefined); // `tools` is the one optional that is no longer absent: the platform diff --git a/packages/extension-core/test/chat-runtime-run-question.test.ts b/packages/extension-core/test/chat-runtime-run-question.test.ts new file mode 100644 index 0000000..4f6277d --- /dev/null +++ b/packages/extension-core/test/chat-runtime-run-question.test.ts @@ -0,0 +1,64 @@ +/** + * The run driver's human port through the chat: `askRunQuestion` posts the + * question to the panel on the diagram's URI and resolves with its + * `chat.runAnswer`; no panel there means `undefined` (the driver then asks + * through VS Code); the question's own timeout declines it. + */ +import { describe, it, expect, vi } from 'vitest'; +import { ChatRuntime } from '../src/extension/chat/chat-runtime'; +import type { ChatPayload } from '../src/api'; + +const URI = 'file:///tmp/flow.py'; + +function makeRuntime(canReach?: (uri: string) => boolean) { + const posts: Array<{ uri: string; payload: ChatPayload }> = []; + const memento = { + get: (_key: string, defaultValue?: T): T | undefined => defaultValue, + update: async (): Promise => undefined, + keys: (): string[] => [] + }; + const context = { workspaceState: memento } as any; + const runtime = new ChatRuntime( + context, + { key: 'test', displayName: 'Test', settingsSection: 'test.chat' } as any, + (uri, payload) => posts.push({ uri, payload }), + canReach + ); + return { runtime, posts }; +} + +describe('ChatRuntime.askRunQuestion', () => { + it('posts the question and resolves with the panel\'s answer', async () => { + const { runtime, posts } = makeRuntime(() => true); + const pending = runtime.askRunQuestion(URI, { id: 3, agent: 'planner', question: 'Apply?', choices: ['Allow', 'Reject'] }); + expect(posts).toEqual([{ uri: URI, payload: { type: 'chat.runQuestion', data: { id: 3, agent: 'planner', question: 'Apply?', choices: ['Allow', 'Reject'] } } }]); + await runtime.handleMessage(URI, { type: 'chat.runAnswer', data: { id: 3, answer: 'Reject' } }); + await expect(pending).resolves.toEqual({ answer: 'Reject' }); + }); + + it('relays a decline, and ignores an answer to nothing', async () => { + const { runtime } = makeRuntime(); + const pending = runtime.askRunQuestion(URI, { id: 'q-4', agent: 'planner', question: 'Free?' }); + await runtime.handleMessage(URI, { type: 'chat.runAnswer', data: { id: 'other', answer: 'x' } }); + await runtime.handleMessage(URI, { type: 'chat.runAnswer', data: { id: 'q-4', declined: true, reason: 'no' } }); + await expect(pending).resolves.toEqual({ declined: true, reason: 'no' }); + }); + + it('is undefined when no panel can be reached on that diagram', async () => { + const { runtime, posts } = makeRuntime(() => false); + await expect(runtime.askRunQuestion(URI, { id: 1, agent: 'a', question: 'q' })).resolves.toBeUndefined(); + expect(posts).toEqual([]); + }); + + it('declines on the question\'s timeout', async () => { + vi.useFakeTimers(); + try { + const { runtime } = makeRuntime(); + const pending = runtime.askRunQuestion(URI, { id: 9, agent: 'a', question: 'q', timeoutMs: 1000 }); + vi.advanceTimersByTime(1001); + await expect(pending).resolves.toEqual({ declined: true, reason: 'timeout' }); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/packages/extension-core/test/toml-subset.test.ts b/packages/extension-core/test/toml-subset.test.ts new file mode 100644 index 0000000..94ae23a --- /dev/null +++ b/packages/extension-core/test/toml-subset.test.ts @@ -0,0 +1,64 @@ +// The TOML a connectors file uses, read without a dependency: what wfpy's +// documented example and a few plausible variations contain. +import { describe, expect, it } from 'vitest'; +import { parseTomlSubset } from '../src/extension/chat/toml-subset'; + +describe('parseTomlSubset', () => { + it("reads wfpy's documented connectors file", () => { + const text = ` +# the agents +[connectors.claude] +command = "claude-agent-acp" # argv, split as a shell would +model = "sonnet" # a session config option the agent offers +mode = "acceptEdits" # a session mode the agent offers +http_api = false # OpenCode's HTTP API beside ACP + +[connectors.opencode] +command = "/opt/opencode/bin/opencode acp" +`; + expect(parseTomlSubset(text)).toEqual({ + connectors: { + claude: { command: 'claude-agent-acp', model: 'sonnet', mode: 'acceptEdits', http_api: false }, + opencode: { command: '/opt/opencode/bin/opencode acp' } + } + }); + }); + + it('reads env as a sub-table or an inline table, quoted keys, arrays, numbers, escapes and literal strings', () => { + const text = ` +[connectors."my agent"] +command = 'my-acp --flag' +env = { API_KEY = "k", "X.Y" = "v" } +timeout = 1_000 +ratio = 1.5 +tags = ["a", "b", + "c"] + +[connectors.other] +command = "other \\"quoted\\" \\\\ end" + +[connectors.other.env] +HOME = "/h" +`; + expect(parseTomlSubset(text)).toEqual({ + connectors: { + 'my agent': { command: 'my-acp --flag', env: { API_KEY: 'k', 'X.Y': 'v' }, timeout: 1000, ratio: 1.5, tags: ['a', 'b', 'c'] }, + other: { command: 'other "quoted" \\ end', env: { HOME: '/h' } } + } + }); + }); + + it('reads dotted keys and multi-line strings', () => { + expect(parseTomlSubset('connectors.x.command = "c"\nnote = """\nline\\\n joined"""')).toEqual({ + connectors: { x: { command: 'c' } }, + note: 'linejoined' + }); + }); + + it('says what is wrong, with the line', () => { + expect(() => parseTomlSubset('[connectors.a]\ncommand = "unterminated')).toThrow(/line 2: unterminated string/); + expect(() => parseTomlSubset('a = 1\na = 2')).toThrow(/line 2: 'a' is defined twice/); + expect(() => parseTomlSubset('[[connectors]]')).toThrow(/array of tables/); + expect(() => parseTomlSubset('a = "x" b = 1')).toThrow(/line 1: unexpected 'b'/); + }); +}); diff --git a/packages/sidecar-toolkit/src/cli-run-driver.ts b/packages/sidecar-toolkit/src/cli-run-driver.ts index 925efaf..dfcb9ae 100644 --- a/packages/sidecar-toolkit/src/cli-run-driver.ts +++ b/packages/sidecar-toolkit/src/cli-run-driver.ts @@ -29,6 +29,10 @@ */ import * as vscode from 'vscode'; import * as cp from 'node:child_process'; +import { unlinkSync } from 'node:fs'; +import * as net from 'node:net'; +import * as os from 'node:os'; +import * as readline from 'node:readline'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import type { ExecutionOverlaySink } from '@dialogram/shared'; @@ -86,6 +90,17 @@ export interface CliRunDriverConfig { agentToolTimeoutMsSettingKey: string; agentToolRegistrySettingKey: string; agentMcpBridgeCmdSettingKey: string; + /** The ACP connector a run's agents spawn when they name none (the runtime's + * `--acp-connector`) and what their permission + * requests get when no user answers (`--agent-cli-acp-permissions`); both + * optional, a shell without them passes nothing. */ + acpConnectorSettingKey?: string; + acpPermissionsSettingKey?: string; + /** Host the run's questions on a Unix socket (`--elicit-socket`): an + * agent's `ask_user` question or, over ACP, a permission request reaches + * {@link CliRunDriverHost.askUser}, or a VS Code prompt when the host has + * none. Default true; never on Windows, where the runtime's side is AF_UNIX. */ + elicitSocket?: boolean; runWorkflowCommandId: string; stopWorkflowCommandId: string; /** The two non-run config command ids the driver registers to mutate/read @@ -101,9 +116,36 @@ export interface CliRunDriverConfig { }; } +/** A question a running agent puts to the user (the runtime's elicitation channel, + * one JSON object a line on the socket; `SocketElicitationHandler` in the runtime + * documents the wire format). A permission request over ACP arrives as one: + * the tool call's title as the question, the agent's options as the choices. */ +export interface RunQuestion { + id: number; + agent: string; + model?: string; + runId?: string; + question: string; + context?: string | null; + choices?: string[] | null; + timeoutMs?: number; +} + +/** The user's answer: one of the choices (or free text), or a refusal. */ +export interface RunAnswer { + answer?: string; + declined?: boolean; + reason?: string; +} + export interface CliRunDriverHost { /** Neutral execution-overlay sink; the SSE flush publishes batches here. */ overlay: ExecutionOverlaySink; + /** Answers a running agent's question for the diagram at `sourceUri`. + * Optional, and may resolve `undefined` (no one there to ask, e.g. no chat + * open on that diagram): then, as without it, the driver asks through a + * VS Code quick pick (choices) or input box (free text). */ + askUser?(question: RunQuestion, sourceUri: string): Promise; /** Core-owned diagram refresh (the driver must not hold the connector). * Core builds the exact `RequestModelAction` for `kind` — request-id prefix * `refresh-during-run-*` (full) / `refresh-agent-ctx-*` (agentContextOnly) — @@ -418,6 +460,116 @@ export class CliRunDriver { return undefined; } + private elicitSourceUri: string | undefined; + private elicitServer: net.Server | undefined; + private elicitSocketPath: string | undefined; + + /** Listen for the run's questions on a fresh Unix socket; returns its path + * for `--elicit-socket`. One connection per run, one question at a time + * (the runtime serializes them); each is answered on the same connection as one + * JSON line, `{id, answer}` or `{id, declined, reason}`. */ + private async startElicitSocket(sourceUri: string): Promise { + this.stopElicitSocket(); + this.elicitSourceUri = sourceUri; + // AF_UNIX paths are short (108 bytes on Linux): the tmp dir, not the run dir. + const socketPath = path.join(os.tmpdir(), `acp-elicit-${process.pid}-${Date.now().toString(36)}.sock`); + const server = net.createServer((conn) => { + conn.setEncoding('utf8'); + const lines = readline.createInterface({ input: conn }); + let chain: Promise = Promise.resolve(); + lines.on('line', (line) => { + chain = chain.then(async () => { + let question: RunQuestion; + try { + question = JSON.parse(line) as RunQuestion; + } catch { + this.host.output.appendLine(`[wf-lang ask] not JSON: ${line.slice(0, 200)}`); + return; + } + if ((question as { type?: string }).type !== 'question' && typeof question.question !== 'string') { + return; + } + const answer = await this.answerQuestion(question); + const reply = answer.declined || answer.answer === undefined + ? { id: question.id, declined: true, reason: answer.reason ?? 'declined' } + : { id: question.id, answer: answer.answer }; + if (!conn.destroyed) { + conn.write(JSON.stringify(reply) + '\n'); + } + }); + }); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(socketPath, () => { + server.off('error', reject); + resolve(); + }); + }).catch((err: unknown) => { + this.host.output.appendLine(`[wf-lang ask] no socket: ${err instanceof Error ? err.message : String(err)}`); + return undefined; + }); + if (!server.listening) { + return undefined; + } + this.elicitServer = server; + this.elicitSocketPath = socketPath; + return socketPath; + } + + private stopElicitSocket(): void { + const server = this.elicitServer; + const socketPath = this.elicitSocketPath; + this.elicitServer = undefined; + this.elicitSocketPath = undefined; + if (server) { + server.close(); + } + if (socketPath) { + try { + unlinkSync(socketPath); + } catch { + // already gone + } + } + } + + /** The host's `askUser`, else a VS Code prompt: a quick pick over the + * choices, an input box otherwise; dismissing either declines. */ + private async answerQuestion(question: RunQuestion): Promise { + const who = question.agent ? `Agent ${question.agent}` : 'An agent'; + this.host.output.appendLine(`[wf-lang ask] ${who}: ${question.question}` + + (question.choices?.length ? ` [${question.choices.join(' | ')}]` : '')); + let answer: RunAnswer | undefined; + try { + if (this.host.askUser) { + answer = await this.host.askUser(question, this.elicitSourceUri ?? ''); + } + if (answer) { + // answered by the host + } else if (question.choices && question.choices.length > 0) { + const picked = await vscode.window.showQuickPick(question.choices, { + title: `${who} asks`, + placeHolder: question.question, + ignoreFocusOut: true + }); + answer = picked === undefined ? { declined: true, reason: 'dismissed' } : { answer: picked }; + } else { + const typed = await vscode.window.showInputBox({ + title: `${who} asks`, + prompt: question.question, + placeHolder: question.context ?? undefined, + ignoreFocusOut: true + }); + answer = typed === undefined ? { declined: true, reason: 'dismissed' } : { answer: typed }; + } + } catch (err: unknown) { + answer = { declined: true, reason: err instanceof Error ? err.message : String(err) }; + } + this.host.output.appendLine(`[wf-lang ask] -> ${answer.declined ? `declined (${answer.reason ?? ''})` : answer.answer}`); + return answer; + } + private async resolveCliInvocation(forUri: vscode.Uri, isPython: boolean): Promise<{ cmd: string; argsPrefix: string[]; cwd: string } | undefined> { const startDir = await this.resolveRunRoot(forUri); if (!startDir) { @@ -648,6 +800,29 @@ export class CliRunDriver { cliArgs.push('--agent-mcp-bridge-cmd', agentMcpBridgeCmd); } + // The ACP connector and the permission policy, from the shell's settings. + if (this.config.acpConnectorSettingKey) { + const connector = (wfConfig.get(this.config.acpConnectorSettingKey, '') ?? '').trim(); + if (connector) { + cliArgs.push('--acp-connector', connector); + } + } + if (this.config.acpPermissionsSettingKey) { + const permissions = (wfConfig.get(this.config.acpPermissionsSettingKey, '') ?? '').trim(); + if (permissions) { + cliArgs.push('--agent-cli-acp-permissions', permissions); + } + } + + // The human port: the run's questions come in on a Unix socket this + // driver listens on, and go to the host's `askUser` or a VS Code prompt. + const elicitSocketPath = this.config.elicitSocket !== false && process.platform !== 'win32' + ? await this.startElicitSocket(sourceUri.toString()) + : undefined; + if (elicitSocketPath) { + cliArgs.push('--elicit-socket', elicitSocketPath); + } + const title = workflowName ? `Running workflow ${workflowName}` : 'Running workflow'; const liveGlowEnabled = vscode.workspace .getConfiguration(this.config.settingsNamespace, sourceUri) @@ -771,6 +946,7 @@ export class CliRunDriver { flushStreamEvents(); streamClient?.stop(); streamClient = undefined; + this.stopElicitSocket(); }; const exitCode = await vscode.window.withProgress( diff --git a/packages/sidecar-toolkit/src/index.ts b/packages/sidecar-toolkit/src/index.ts index c71aaaf..bba7f18 100644 --- a/packages/sidecar-toolkit/src/index.ts +++ b/packages/sidecar-toolkit/src/index.ts @@ -52,7 +52,9 @@ export { CliRunDriver, type CliRunDriverConfig, type CliRunDriverHost, - type AgentToolEntitySettings + type AgentToolEntitySettings, + type RunQuestion, + type RunAnswer } from './cli-run-driver.js'; export { diff --git a/packages/sidecar-toolkit/src/sidecar-diagram-profile.ts b/packages/sidecar-toolkit/src/sidecar-diagram-profile.ts index 9831a82..bafc133 100644 --- a/packages/sidecar-toolkit/src/sidecar-diagram-profile.ts +++ b/packages/sidecar-toolkit/src/sidecar-diagram-profile.ts @@ -15,7 +15,7 @@ import type { EntityPaletteItemSpec, NodeFamilySpec } from '@dialogram/shared'; import * as vscode from 'vscode'; import { invokeSidecarOp } from './sidecar-graph-export.js'; import { createRegistryChatTools } from './registry-tools.js'; -import { +import { getCliInvocation, getSidecarCommand, type SidecarRuntimeConfig, type CreateNodeStrings, @@ -173,6 +173,22 @@ export interface SidecarProfileInput { agentToolTimeoutMsSettingKey: string; agentToolRegistrySettingKey: string; agentMcpBridgeCmdSettingKey: string; + /** The setting naming the ACP connector (`.`, e.g. + * `acp.connector`): the one the chat talks to, offered by the property + * panel, and, with `runAcpFlags` on, the run's default (`--acp-connector`). + * The platform reads the connectors itself: the known agents on the + * PATH, then the runtime's two connectors files, `acpConnectorFiles`. */ + acpConnectorSettingKey?: string; + /** The runtime's connectors files, relative: `user` under `~/.config` + * (`$XDG_CONFIG_HOME`), `workspace` under the project root. Required + * with `acpConnectorSettingKey`. */ + acpConnectorFiles?: { user: string; workspace: string }; + /** The setting the run driver forwards as `--agent-cli-acp-permissions`. */ + acpPermissionsSettingKey?: string; + /** Whether the run driver forwards the two ACP settings to ` run`. + * Default true; a product whose `run` is not the workflow runtime's sets + * false, and the connector then serves the chat and the panel only. */ + runAcpFlags?: boolean; // Chat carry-overs. chat: { @@ -308,6 +324,8 @@ export function createSidecarDiagramProfile(input: SidecarProfileInput) { agentToolTimeoutMsSettingKey: input.agentToolTimeoutMsSettingKey, agentToolRegistrySettingKey: input.agentToolRegistrySettingKey, agentMcpBridgeCmdSettingKey: input.agentMcpBridgeCmdSettingKey, + acpConnectorSettingKey: input.runAcpFlags === false ? undefined : input.acpConnectorSettingKey, + acpPermissionsSettingKey: input.runAcpFlags === false ? undefined : input.acpPermissionsSettingKey, runWorkflowCommandId: input.commands.runWorkflow, stopWorkflowCommandId: input.commands.stopWorkflow, agentToolConfigCommands: { @@ -400,7 +418,12 @@ export function createSidecarDiagramProfile(input: SidecarProfileInput) { // Read-only sidecar-registry tools bridged to GLSP-MCP by the platform adapter. tools: chatTools, // The libcst edit backend rewrites Python source; agents get the file as text/x-python. - sourceMimeType: 'text/x-python' + sourceMimeType: 'text/x-python', + // The ACP connector: the setting the chat and the property panel + // read. Without it the chat keeps its opencode default. + acpConnector: input.acpConnectorSettingKey && input.acpConnectorFiles + ? { settingKey: input.acpConnectorSettingKey, files: input.acpConnectorFiles } + : undefined }, runDriver, newSourceFile diff --git a/packages/sidecar-toolkit/test/cli-run-driver-elicit.test.ts b/packages/sidecar-toolkit/test/cli-run-driver-elicit.test.ts new file mode 100644 index 0000000..cf6ee43 --- /dev/null +++ b/packages/sidecar-toolkit/test/cli-run-driver-elicit.test.ts @@ -0,0 +1,190 @@ +// The human port from the IDE: the driver forwards the shell's ACP connector +// and permission settings to `wfpy run`, listens for the run's questions on a +// Unix socket it names with `--elicit-socket`, and answers each through the +// host's `askUser`. The spawn is faked; the socket is real, driven from the +// test as wfpy would drive it: one JSON object a line, an answer per question. +import { EventEmitter } from 'node:events'; +import * as fs from 'node:fs'; +import * as net from 'node:net'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as vscode from 'vscode'; +import { resetRegisteredCommands } from './vscode-mock'; + +const spawnCalls: Array<{ cmd: string; args: string[]; cwd: string }> = []; +let finishRun: (() => void) | undefined; +vi.mock('../src/process-control.js', () => ({ + spawnWorkflowProcess: (invocation: { cmd: string; args: string[]; cwd: string }) => { + spawnCalls.push(invocation); + const child: any = new EventEmitter(); + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + // The run ends when the test says so, so the socket is alive meanwhile. + finishRun = () => child.emit('close', 0, null); + return child; + }, + requestWorkflowStop: () => true +})); +vi.mock('../src/run-event-stream-client.js', () => ({ + RunEventStreamClient: class { + constructor(_opts: unknown) {} + start(): void {} + stop(): void {} + } +})); + +import { CliRunDriver, type CliRunDriverConfig, type RunQuestion, type RunAnswer } from '../src/index'; + +const RUN_CMD = 'test.runWorkflow'; + +function makeConfig(extra: Partial = {}): CliRunDriverConfig { + let stored: Record | undefined; + return { + settingsNamespace: 'wfpy', + customEditorViewType: 'test.editor', + cliCommandSettingKey: 'cliCommand', + cliCommandDefault: 'fake-wfpy', + runOutputDirSettingKey: 'runOutputDir', + liveExecutionGlowSettingKey: 'liveExecutionGlow', + agentToolsSettingKey: 'agentTools', + agentToolAuthSettingKey: 'agentToolAuth', + agentToolPolicySettingKey: 'agentToolPolicy', + agentToolTimeoutMsSettingKey: 'agentToolTimeoutMs', + agentToolRegistrySettingKey: 'agentToolRegistry', + agentMcpBridgeCmdSettingKey: 'agentMcpBridgeCmd', + runWorkflowCommandId: RUN_CMD, + stopWorkflowCommandId: 'test.stopWorkflow', + agentToolConfigCommands: { set: 'test.set', get: 'test.get' }, + overrideState: { + get: () => stored, + update: (value: Record) => { stored = value; return Promise.resolve(); } + }, + ...extra + }; +} + +function makeHost(askUser?: (q: RunQuestion, sourceUri: string) => Promise) { + const appended: string[] = []; + return { + overlay: { emitEvents: () => {} }, + requestRefresh: () => {}, + output: { + show: () => {}, + append: (v: string) => void appended.push(v), + appendLine: (v: string) => void appended.push(v) + } as unknown as vscode.OutputChannel, + askUser, + appended + }; +} + +function settings(values: Record) { + vi.spyOn(vscode.workspace, 'getConfiguration').mockReturnValue({ + get: (key: string, defaultValue?: T): T | undefined => (key in values ? (values[key] as T) : defaultValue) + } as any); +} + +function fixtureFile(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-run-driver-elicit-')); + const file = path.join(dir, 'pipeline.py'); + fs.writeFileSync(file, '# fixture\n', 'utf8'); + return file; +} + +function startRun(driver: CliRunDriver, file: string): Promise { + driver.registerCommands({ subscriptions: [] } as unknown as vscode.ExtensionContext); + return vscode.commands.executeCommand(RUN_CMD, { sourceUri: `file://${file}` }); +} + +async function ask(socketPath: string, questions: object[]): Promise { + // wfpy's side: one connection, one question at a time, one JSON line each way. + const conn = net.connect(socketPath); + await new Promise((resolve, reject) => { conn.once('connect', resolve); conn.once('error', reject); }); + const replies: any[] = []; + let buffer = ''; + for (const q of questions) { + conn.write(JSON.stringify(q) + '\n'); + const line = await new Promise((resolve) => { + const onData = (chunk: Buffer) => { + buffer += chunk.toString('utf8'); + const nl = buffer.indexOf('\n'); + if (nl >= 0) { + const one = buffer.slice(0, nl); + buffer = buffer.slice(nl + 1); + conn.off('data', onData); + resolve(one); + } + }; + conn.on('data', onData); + }); + replies.push(JSON.parse(line)); + } + conn.end(); + return replies; +} + +describe('CliRunDriver: the ACP connector, the permission policy and the human port', () => { + beforeEach(() => { + spawnCalls.length = 0; + finishRun = undefined; + resetRegisteredCommands(); + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('forwards the connector and permission settings, and names a socket', async () => { + settings({ 'acp.connector': 'claude', 'acp.permissions': 'reject' }); + const driver = new CliRunDriver(makeConfig({ acpConnectorSettingKey: 'acp.connector', acpPermissionsSettingKey: 'acp.permissions' }), makeHost() as any); + const run = startRun(driver, fixtureFile()); + await vi.waitFor(() => expect(spawnCalls.length).toBe(1)); + const args = spawnCalls[0].args; + expect(args.slice(args.indexOf('--acp-connector'), args.indexOf('--acp-connector') + 2)).toEqual(['--acp-connector', 'claude']); + expect(args[args.indexOf('--agent-cli-acp-permissions') + 1]).toBe('reject'); + const socketPath = args[args.indexOf('--elicit-socket') + 1]; + expect(socketPath).toMatch(/acp-elicit-.*\.sock$/); + expect(fs.existsSync(socketPath)).toBe(true); + finishRun!(); + await run; + expect(fs.existsSync(socketPath)).toBe(false); // the socket goes with the run + }); + + it('passes nothing for settings a shell does not have or leaves empty', async () => { + settings({ 'acp.connector': '', 'acp.permissions': '' }); + const driver = new CliRunDriver(makeConfig({ acpConnectorSettingKey: 'acp.connector', acpPermissionsSettingKey: 'acp.permissions', elicitSocket: false }), makeHost() as any); + const run = startRun(driver, fixtureFile()); + await vi.waitFor(() => expect(spawnCalls.length).toBe(1)); + expect(spawnCalls[0].args).not.toContain('--acp-connector'); + expect(spawnCalls[0].args).not.toContain('--agent-cli-acp-permissions'); + expect(spawnCalls[0].args).not.toContain('--elicit-socket'); + finishRun!(); + await run; + }); + + it('answers the run\'s questions through the host, one JSON line each', async () => { + settings({}); + const asked: RunQuestion[] = []; + const answers = ['Reject', undefined]; + const host = makeHost(async (q) => { + asked.push(q); + const a = answers[asked.length - 1]; + return a === undefined ? { declined: true, reason: 'user said no' } : { answer: a }; + }); + const driver = new CliRunDriver(makeConfig(), host as any); + const run = startRun(driver, fixtureFile()); + await vi.waitFor(() => expect(spawnCalls.length).toBe(1)); + const socketPath = spawnCalls[0].args[spawnCalls[0].args.indexOf('--elicit-socket') + 1]; + const replies = await ask(socketPath, [ + { type: 'question', id: 1, agent: 'planner', model: 'opus', question: 'Write core/cpu.py', + context: 'The agent asks permission for this tool call.', choices: ['Always Allow', 'Allow', 'Reject'], timeout_ms: 600000 }, + { type: 'question', id: 2, agent: 'planner', question: 'Proceed?', choices: null } + ]); + expect(replies).toEqual([{ id: 1, answer: 'Reject' }, { id: 2, declined: true, reason: 'user said no' }]); + expect(asked[0].question).toBe('Write core/cpu.py'); + expect(asked[0].choices).toEqual(['Always Allow', 'Allow', 'Reject']); + expect(host.appended.some((l) => l.includes('Agent planner: Write core/cpu.py'))).toBe(true); + finishRun!(); + await run; + }); +}); diff --git a/packages/sidecar-toolkit/test/client-behavior-twin.test.ts b/packages/sidecar-toolkit/test/client-behavior-twin.test.ts index ebec509..a479366 100644 --- a/packages/sidecar-toolkit/test/client-behavior-twin.test.ts +++ b/packages/sidecar-toolkit/test/client-behavior-twin.test.ts @@ -44,10 +44,14 @@ function fieldsOf(file: string, name: string): string[] { * than filtered by a pattern, because adding one is a decision worth writing * down. * - * chatBackend derived from `DiagramProfile.chat` being present, which is - * what decides whether the host activates a chat backend at all + * chatBackend derived from `DiagramProfile.chat` being present, which is + * what decides whether the host activates a chat backend at all + * acpConnectors listed per document by the platform from the profile's + * `chat.acpConnector` declaration and posted to the webview; + * a product declaring it by hand would name connectors the + * machine may not have */ -const PLATFORM_DERIVED = ['chatBackend']; +const PLATFORM_DERIVED = ['chatBackend', 'acpConnectors']; describe('sidecar client behavior mirrors the platform', () => { const sidecar = fieldsOf(