window.dispatchEvent(new CustomEvent('dialogram.chat.showAgent', { detail: { instance: a.instance } }))}
+ >
${a.instance}
diff --git a/packages/diagram-client/test/chat-panel-run-question.test.ts b/packages/diagram-client/test/chat-panel-run-question.test.ts
index bc3b560..f3c99a7 100644
--- a/packages/diagram-client/test/chat-panel-run-question.test.ts
+++ b/packages/diagram-client/test/chat-panel-run-question.test.ts
@@ -1,17 +1,19 @@
/**
- * A running agent's question (the run driver's human port) lands in the chat
- * timeline, opens the panel, and is answered back to the host as
- * `chat.runAnswer`: the chat is the run's viewer here, not its session.
+ * A running agent's question (the run driver's human port) goes into that
+ * agent's transcript, switches the panel to the agent (unless the user is
+ * typing to the session, then a banner), and is answered back to the host
+ * as `chat.runAnswer`.
*/
import 'reflect-metadata';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ChatPanel } from '../src/chat-panel-integrated';
+import { RunAgentStreamActionHandler } from '../src/editing-action-handlers';
function makePanel() {
const panel = new ChatPanel();
const sent: Array<{ method: string; env: any }> = [];
(panel as any).channel = { sendToHost: (method: string, env: any) => void sent.push({ method, env }) };
- const showSpy = vi.fn();
+ const showSpy = vi.fn(() => { (panel as any).isVisible = true; });
(panel as any).show = showSpy;
return { panel, sent, showSpy };
}
@@ -19,30 +21,36 @@ function makePanel() {
beforeEach(() => {
(globalThis as any).requestAnimationFrame = () => 1;
(globalThis as any).cancelAnimationFrame = () => undefined;
+ RunAgentStreamActionHandler.reset();
});
afterEach(() => {
delete (globalThis as any).requestAnimationFrame;
delete (globalThis as any).cancelAnimationFrame;
+ RunAgentStreamActionHandler.reset();
});
-describe('a running agent\'s question in the chat', () => {
- it('is recorded with its choices and opens the panel', () => {
+describe("a running agent's question in the chat", () => {
+ it("lands in the agent's transcript, switches the view to it and opens the panel", () => {
const { panel, showSpy } = makePanel();
(panel as any).handleIncomingMessage('chat.runQuestion', {
id: 7, agent: 'planner', model: 'opus', question: 'Apply the patch?', context: 'diff…', choices: ['Allow', 'Reject'], timeoutMs: 60000
});
- const timeline = (panel as any).timeline as any[];
- expect(timeline).toEqual([
- { kind: 'question', id: 7, agent: 'planner', model: 'opus', question: 'Apply the patch?', context: 'diff…', choices: ['Allow', 'Reject'] }
- ]);
+ expect((panel as any).timeline).toEqual([]);
+ expect((panel as any).view).toEqual({ kind: 'agent', instance: 'planner' });
+ const agent = RunAgentStreamActionHandler.getAgent('planner')!;
+ expect(agent.pendingQuestions).toBe(1);
+ expect(agent.parts).toEqual([{ kind: 'question', question: {
+ id: 7, agent: 'planner', model: 'opus', question: 'Apply the patch?', context: 'diff…', choices: ['Allow', 'Reject']
+ } }]);
+ expect(RunAgentStreamActionHandler.pendingQuestionCount()).toBe(1);
expect(showSpy).toHaveBeenCalled();
});
- it('answers and declines back to the host, once', () => {
+ it('answers and declines back to the host, once, and resolves the transcript', () => {
const { panel, sent } = makePanel();
(panel as any).handleIncomingMessage('chat.runQuestion', { id: 1, agent: 'planner', question: 'Which?', choices: ['a', 'b'] });
(panel as any).handleIncomingMessage('chat.runQuestion', { id: 2, agent: 'planner', question: 'Free text?' });
- const [q1, q2] = (panel as any).timeline as any[];
+ const [q1, q2] = RunAgentStreamActionHandler.getAgent('planner')!.parts.map((p: any) => p.question);
(panel as any).answerRunQuestion(q1, 'b');
(panel as any).answerRunQuestion(q1, 'a'); // already answered: ignored
(panel as any).answerRunQuestion(q2, undefined);
@@ -53,12 +61,33 @@ describe('a running agent\'s question in the chat', () => {
]);
expect(q1.resolved).toEqual({ answer: 'b' });
expect(q2.resolved).toEqual({ declined: true });
+ expect(RunAgentStreamActionHandler.pendingQuestionCount()).toBe(0);
+ });
+
+ it('does not pull the user out of a message they are typing: a banner instead, and Go switches', () => {
+ const { panel } = makePanel();
+ (panel as any).inputValue = 'please rename the';
+ (panel as any).handleIncomingMessage('chat.runQuestion', { id: 3, agent: 'ask', question: 'Write /tmp/x', choices: ['Allow', 'Reject'] });
+ expect((panel as any).view).toEqual({ kind: 'session' });
+ expect((panel as any).runBanner).toMatchObject({ id: 3, agent: 'ask' });
+ (panel as any).showAgent('ask');
+ expect((panel as any).view).toEqual({ kind: 'agent', instance: 'ask' });
+ expect((panel as any).runBanner).toBeNull();
+ });
+
+ it('the Run segment opens the agent with a question waiting, and Session comes back', () => {
+ const { panel } = makePanel();
+ (panel as any).handleIncomingMessage('chat.runQuestion', { id: 4, agent: 'analyst', question: 'q', choices: ['ok'] });
+ (panel as any).showSession();
+ expect((panel as any).view).toEqual({ kind: 'session' });
+ (panel as any).openRun();
+ expect((panel as any).view).toEqual({ kind: 'agent', instance: 'analyst' });
});
it('ignores a malformed question', () => {
const { panel, showSpy } = makePanel();
(panel as any).handleIncomingMessage('chat.runQuestion', { agent: 'x' });
- expect((panel as any).timeline).toEqual([]);
+ expect(RunAgentStreamActionHandler.getAgents()).toEqual([]);
expect(showSpy).not.toHaveBeenCalled();
});
});
diff --git a/packages/diagram-client/test/run-agents-tool-call-title.test.ts b/packages/diagram-client/test/run-agents-tool-call-title.test.ts
new file mode 100644
index 0000000..1004c4b
--- /dev/null
+++ b/packages/diagram-client/test/run-agents-tool-call-title.test.ts
@@ -0,0 +1,28 @@
+// An ACP agent's tool call arrives with a title and a kind rather than a
+// name; the live state labels it by the title, so the chat and the bar say
+// "Write choice-a.txt" and not "unknown".
+import 'reflect-metadata';
+import { afterEach, describe, expect, it } from 'vitest';
+import { EXECUTION_OVERLAY_ACTION_KIND } from '@dialogram/shared';
+import { RunAgentStreamActionHandler } from '../src/editing-action-handlers';
+
+afterEach(() => RunAgentStreamActionHandler.reset());
+
+describe('a tool call in the live agent state', () => {
+ it('is labelled by its name, else its title, else its kind', () => {
+ const handler = new RunAgentStreamActionHandler();
+ handler.handle({
+ kind: EXECUTION_OVERLAY_ACTION_KIND,
+ events: [
+ { seq: 1, type: 'run.started' },
+ { seq: 2, type: 'agent.message.start', instance: 'ask' },
+ { seq: 3, type: 'agent.tool_call', instance: 'ask', name: 'read_file', title: 'Read x' },
+ { seq: 4, type: 'agent.tool_call', instance: 'ask', title: 'Write choice-a.txt', kind: 'edit' },
+ { seq: 5, type: 'agent.tool_call', instance: 'ask', kind: 'execute' },
+ { seq: 6, type: 'agent.tool_call', instance: 'ask' }
+ ]
+ } as any);
+ const [agent] = RunAgentStreamActionHandler.getAgents();
+ expect(agent.toolCalls).toEqual(['read_file', 'Write choice-a.txt', 'execute']);
+ });
+});
diff --git a/packages/diagram-client/test/run-agents-transcript.test.ts b/packages/diagram-client/test/run-agents-transcript.test.ts
new file mode 100644
index 0000000..fd1bf4c
--- /dev/null
+++ b/packages/diagram-client/test/run-agents-transcript.test.ts
@@ -0,0 +1,65 @@
+// A running agent's transcript: every turn's reasoning, text and tool calls
+// in arrival order, tool statuses updated in place, the questions among them;
+// the bar's per-turn summary stays what it was.
+import 'reflect-metadata';
+import { afterEach, describe, expect, it } from 'vitest';
+import { EXECUTION_OVERLAY_ACTION_KIND } from '@dialogram/shared';
+import { RunAgentStreamActionHandler } from '../src/editing-action-handlers';
+
+afterEach(() => RunAgentStreamActionHandler.reset());
+
+function feed(events: Array>): void {
+ new RunAgentStreamActionHandler().handle({ kind: EXECUTION_OVERLAY_ACTION_KIND, events } as any);
+}
+
+describe("an agent's transcript", () => {
+ it('keeps every turn in order, with tool statuses updated in place', () => {
+ feed([
+ { seq: 1, type: 'run.started' },
+ { seq: 2, type: 'agent.message.start', instance: 'ask' },
+ { seq: 3, type: 'agent.message.delta', instance: 'ask', field: 'reasoning', delta: 'I should ' },
+ { seq: 4, type: 'agent.message.delta', instance: 'ask', field: 'reasoning', delta: 'write.' },
+ { seq: 5, type: 'agent.tool_call', instance: 'ask', name: 'Write /tmp/1.txt', status: 'pending', id: 'c1' },
+ { seq: 6, type: 'agent.tool_call_update', instance: 'ask', id: 'c1', status: 'completed' },
+ { seq: 7, type: 'agent.message.delta', instance: 'ask', delta: '{"answer": 1}' },
+ { seq: 8, type: 'agent.message.end', instance: 'ask' },
+ { seq: 9, type: 'agent.message.start', instance: 'ask' },
+ { seq: 10, type: 'agent.message.delta', instance: 'ask', delta: 'second turn' }
+ ]);
+ const a = RunAgentStreamActionHandler.getAgent('ask')!;
+ expect(a.parts).toEqual([
+ { kind: 'turn', index: 1 },
+ { kind: 'reasoning', text: 'I should write.' },
+ { kind: 'tool', name: 'Write /tmp/1.txt', status: 'completed', id: 'c1' },
+ { kind: 'text', text: '{"answer": 1}' },
+ { kind: 'turn', index: 2 },
+ { kind: 'text', text: 'second turn' }
+ ]);
+ expect(a.turns).toBe(2);
+ // The bar's summary is the current turn only.
+ expect(a.text).toBe('second turn');
+ expect(a.reasoning).toBe('');
+ expect(a.toolCalls).toEqual([]);
+ expect(a.status).toBe('running');
+ });
+
+ it('places a question where it was asked and counts it until answered', () => {
+ feed([
+ { seq: 1, type: 'run.started' },
+ { seq: 2, type: 'agent.message.start', instance: 'ask' },
+ { seq: 3, type: 'agent.message.delta', instance: 'ask', delta: 'hello' }
+ ]);
+ RunAgentStreamActionHandler.addQuestion('ask', { id: 9, agent: 'ask', question: 'Write?', choices: ['Allow'] });
+ feed([{ seq: 4, type: 'agent.message.delta', instance: 'ask', delta: ' world' }]);
+ const a = RunAgentStreamActionHandler.getAgent('ask')!;
+ expect(a.parts.map(p => p.kind)).toEqual(['turn', 'text', 'question', 'text']);
+ expect(a.pendingQuestions).toBe(1);
+ expect(RunAgentStreamActionHandler.pendingQuestionCount()).toBe(1);
+ RunAgentStreamActionHandler.resolveQuestion('ask', 9, { answer: 'Allow' });
+ expect(a.pendingQuestions).toBe(0);
+ expect((a.parts[2] as any).question.resolved).toEqual({ answer: 'Allow' });
+ // An agent the stream has not reached yet still takes a question.
+ RunAgentStreamActionHandler.addQuestion('late', { id: 10, agent: 'late', question: 'q', choices: [] });
+ expect(RunAgentStreamActionHandler.getAgent('late')!.parts).toHaveLength(1);
+ });
+});
diff --git a/packages/sidecar-toolkit/src/sidecar-diagram-profile.ts b/packages/sidecar-toolkit/src/sidecar-diagram-profile.ts
index bafc133..5ef9325 100644
--- a/packages/sidecar-toolkit/src/sidecar-diagram-profile.ts
+++ b/packages/sidecar-toolkit/src/sidecar-diagram-profile.ts
@@ -341,7 +341,10 @@ export function createSidecarDiagramProfile(input: SidecarProfileInput) {
const driver = new CliRunDriver(config, {
overlay: host.overlay,
requestRefresh: host.requestRefresh,
- output: host.output
+ output: host.output,
+ // The human port: a running agent's question goes to the platform
+ // (the chat panel on the diagram); without it the driver prompts.
+ askUser: host.askUser ? (question, sourceUri) => host.askUser!(question, sourceUri) : undefined
});
driver.registerCommands(context);
host.useLiveOverlaySignatureSource({
diff --git a/packages/sidecar-toolkit/test/sidecar-profile-run-host.test.ts b/packages/sidecar-toolkit/test/sidecar-profile-run-host.test.ts
new file mode 100644
index 0000000..592bb2c
--- /dev/null
+++ b/packages/sidecar-toolkit/test/sidecar-profile-run-host.test.ts
@@ -0,0 +1,82 @@
+// The run driver's host is the platform's, field by field; the human port
+// (`askUser`) must be among them, or a running agent's question never reaches
+// the chat and the driver prompts through VS Code instead.
+import { describe, expect, it, vi } from 'vitest';
+import * as vscode from 'vscode';
+
+const constructed: Array<{ host: any }> = [];
+vi.mock('../src/cli-run-driver.js', () => ({
+ CliRunDriver: class {
+ constructor(_config: unknown, host: any) {
+ constructed.push({ host });
+ }
+ registerCommands(): void {}
+ watchLiveOverlay(): { dispose(): void } { return { dispose: () => {} }; }
+ onLiveOverlaySignature(): { dispose(): void } { return { dispose: () => {} }; }
+ dispose(): void {}
+ }
+}));
+
+import { createSidecarDiagramProfile, type SidecarProfileInput } from '../src/sidecar-diagram-profile';
+
+function input(): SidecarProfileInput {
+ const commands = Object.fromEntries([
+ 'openDiagram', 'openDiagramSplit', 'layoutDiagram', 'refreshDiagramModel', 'renameEntityByName', 'undo', 'redo',
+ 'fitToScreen', 'center', 'exportSvg', 'toggleGrid', 'setQueueTraceVisible', 'stopWorkflow', 'runWorkflow',
+ 'layoutDiagramIfNeeded', 'setAgentToolConfig', 'getAgentToolConfig', 'createAgentToolPolicyFile',
+ 'chatAddViewerEditor', 'chatAddViewerTask', 'sidecarEdit', 'sidecarSend', 'createNewContainer'
+ ].map((k) => [k, `pfx.${k}`])) as SidecarProfileInput['commands'];
+ return {
+ key: 'p', displayName: 'P', settingsNamespace: 'p', customEditorViewType: 'p.diagram',
+ glspClientId: 'p.client', glspClientName: 'p', commands,
+ operationKinds: { createEntityPort: 'op.c', deleteEntityPort: 'op.d' },
+ sidecarOperationPrefix: 'p', sidecarCommandSettingKey: 'sidecarCommand', sidecarCommandDefault: 'p-sidecar',
+ cliCommandSettingKey: 'cliCommand', cliCommandDefault: 'p', acceptedOperationPrefixes: ['p'],
+ graphAcquisition: 'cli-plan', cliGraphArgs: (file) => ['plan', file],
+ undoLabelSuffix: ' (p)',
+ createNodeStrings: {
+ newTypeNamePrompt: () => 'n', typeLabel: () => 't', classNamePlaceholder: () => 'c', sidecarDisplayName: 's',
+ invalidCapabilitiesResponse: 'i', missingCapabilities: () => 'm', invalidListResponse: () => 'l'
+ },
+ createNodeBehavior: { capabilityProbeBeforeCreate: false, mergeProjectDiscoveredTypes: true, surfaceSidecarListErrors: false },
+ sourceExtension: '.py', exportOp: 'export', mcpEnabledSetting: { section: 'p.chat', key: 'enableMcpTools', default: true },
+ scopeArgKey: 'workflow', newContainer: { label: 'W', decorator: 'workflow', importLine: 'from p import workflow' },
+ identifierNoun: 'Python',
+ runOutputDirSettingKey: 'runOutputDir', liveExecutionGlowSettingKey: 'glow', agentToolsSettingKey: 'agentTools',
+ agentToolAuthSettingKey: 'agentToolAuth', agentToolPolicySettingKey: 'agentToolPolicy',
+ agentToolTimeoutMsSettingKey: 'agentToolTimeoutMs', agentToolRegistrySettingKey: 'agentToolRegistry',
+ agentMcpBridgeCmdSettingKey: 'agentMcpBridgeCmd',
+ chat: { name: 'p', fullName: 'P chat' }
+ } as SidecarProfileInput;
+}
+
+function platformHost(askUser?: (q: unknown, uri: string) => Promise) {
+ return {
+ overlay: { emitEvents: () => {} },
+ requestRefresh: () => {},
+ output: { appendLine: () => {}, append: () => {}, show: () => {} } as unknown as vscode.OutputChannel,
+ useLiveOverlaySignatureSource: () => {},
+ askUser
+ };
+}
+
+describe("the run driver's host", () => {
+ const context = { workspaceState: { get: () => undefined, update: async () => undefined }, subscriptions: [] } as any;
+
+ it("forwards the platform's askUser, question and diagram alike", async () => {
+ constructed.length = 0;
+ const asked: Array<[unknown, string]> = [];
+ const profile = createSidecarDiagramProfile(input());
+ profile.runDriver!(context, platformHost(async (q, uri) => { asked.push([q, uri]); return { answer: 'Allow' }; }) as any);
+ const host = constructed[0].host;
+ const question = { id: 1, agent: 'ask', question: 'Write /tmp/ask-flow/1-x.txt', choices: ['Allow', 'Reject'] };
+ await expect(host.askUser(question, 'file:///w/flow.py')).resolves.toEqual({ answer: 'Allow' });
+ expect(asked).toEqual([[question, 'file:///w/flow.py']]);
+ });
+
+ it('leaves askUser out when the platform has none, so the driver prompts itself', () => {
+ constructed.length = 0;
+ createSidecarDiagramProfile(input()).runDriver!(context, platformHost() as any);
+ expect(constructed[0].host.askUser).toBeUndefined();
+ });
+});