diff --git a/packages/diagram-client/test/chat-panel-confirm.test.ts b/packages/diagram-client/test/chat-panel-confirm.test.ts
new file mode 100644
index 0000000..0973640
--- /dev/null
+++ b/packages/diagram-client/test/chat-panel-confirm.test.ts
@@ -0,0 +1,53 @@
+/**
+ * A confirmation the host puts in the chat (resuming a run the session's agent
+ * fixed): a card in the session's timeline, its choices as buttons; pressing
+ * one answers the host once.
+ */
+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<{ type: string; data: any }> = [];
+ (panel as any).channel = { sendToHost: (_method: string, env: any) => void sent.push(env) };
+ const showSpy = vi.fn(() => { (panel as any).isVisible = true; });
+ (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;
+});
+
+const confirm = { id: '1', title: 'Resume the failed run', text: 'Resume the run of top from where it failed?', choices: ['Resume', 'Not now'] };
+
+describe('a confirmation in the chat', () => {
+ it('is a card in the session’s timeline, and opens the panel on the session', () => {
+ const { panel, showSpy } = makePanel();
+ (panel as any).view = { kind: 'agent', instance: 'planner' };
+
+ (panel as any).handleIncomingMessage('chat.confirm', confirm);
+
+ expect((panel as any).timeline.at(-1)).toMatchObject({ kind: 'confirm', id: '1', choices: ['Resume', 'Not now'] });
+ expect((panel as any).view).toEqual({ kind: 'session' });
+ expect(showSpy).toHaveBeenCalled();
+ });
+
+ it('answers the host with the button pressed, once', () => {
+ const { panel, sent } = makePanel();
+ (panel as any).handleIncomingMessage('chat.confirm', confirm);
+ const item = (panel as any).timeline.at(-1);
+
+ (panel as any).answerConfirm(item, 'Resume');
+ (panel as any).answerConfirm(item, 'Not now');
+
+ expect(sent.filter(m => m.type === 'chat.confirmAnswer').map(m => m.data)).toEqual([{ id: '1', choice: 'Resume' }]);
+ expect(item.resolved).toBe('Resume');
+ });
+});
diff --git a/packages/extension-core/src/api.ts b/packages/extension-core/src/api.ts
index 24cc865..0b3ce9f 100644
--- a/packages/extension-core/src/api.ts
+++ b/packages/extension-core/src/api.ts
@@ -231,6 +231,23 @@ export interface DiagramRunHost {
* `task.mode`, named `task.name`, whose first message is `task.prompt`.
* `false` when no chat can take it there. */
startChatTask?(task: DiagramChatTask, sourceUri: string): Promise;
+ /** Asks the person to confirm, in the chat session shown on the diagram at
+ * `sourceUri`: a card with `confirm.choices` as buttons. The answer's
+ * `choice` is the button pressed, absent when declined; `undefined` when no
+ * chat can ask there. */
+ confirmInChat?(confirm: DiagramChatConfirm, sourceUri: string): Promise;
+}
+
+/** A confirmation the platform puts to the person in a diagram's chat (see `DiagramRunHost.confirmInChat`). */
+export interface DiagramChatConfirm {
+ title: string;
+ text: string;
+ choices: string[];
+}
+
+/** The person's answer to a {@link DiagramChatConfirm}: the button pressed, none when declined. */
+export interface DiagramChatConfirmAnswer {
+ choice?: string;
}
/** A task the platform asks a diagram's chat to start (see `DiagramRunHost.startChatTask`). */
diff --git a/packages/extension-core/src/extension/chat/chat-runtime.ts b/packages/extension-core/src/extension/chat/chat-runtime.ts
index 76e8c10..8b90df7 100644
--- a/packages/extension-core/src/extension/chat/chat-runtime.ts
+++ b/packages/extension-core/src/extension/chat/chat-runtime.ts
@@ -15,7 +15,7 @@
import * as path from "node:path";
import * as vscode from "vscode";
import { ACPClientService, type AcpAgentSpec, type TurnPart } from "../acp-client.js";
-import type { DiagramRunAnswer, DiagramRunQuestion } from "../../api";
+import type { DiagramChatConfirm, DiagramChatConfirmAnswer, DiagramRunAnswer, DiagramRunQuestion } from "../../api";
import { SessionManager } from "../session-manager.js";
import type {
ChatMessageSink,
@@ -161,6 +161,10 @@ export class ChatRuntime {
{ resolve: (answer: DiagramRunAnswer) => void; timer?: ReturnType }
>();
+ /** A confirmation put to the person in a session, waiting for the panel's `chat.confirmAnswer`. */
+ private readonly pendingConfirms = new Map void>();
+ private nextConfirmId = 1;
+
constructor(
context: vscode.ExtensionContext,
private readonly config: ChatRuntimeConfig,
@@ -838,6 +842,13 @@ export class ChatRuntime {
case "chat.permissionResponse":
this.acp.respondToPermission(data.requestId, data.optionId ?? null);
return;
+ case "chat.confirmAnswer": {
+ const resolve = this.pendingConfirms.get(String(data?.id));
+ if (!resolve) return;
+ this.pendingConfirms.delete(String(data.id));
+ resolve(typeof data?.choice === "string" ? { choice: data.choice } : {});
+ return;
+ }
case "chat.runAnswer": {
const pending = this.pendingRunQuestions.get(String(data?.id));
if (!pending) return;
@@ -881,6 +892,24 @@ export class ChatRuntime {
});
}
+ /**
+ * Ask the person to confirm, in the session shown by the chat panel open on
+ * `uri`: a card in its timeline with `confirm.choices` as buttons. Resolves
+ * with the button pressed (none when declined); `undefined` when no panel can
+ * be reached, so the caller asks another way.
+ */
+ confirmInChat(uri: string, confirm: DiagramChatConfirm): Promise {
+ if (this.canReach && !this.canReach(uri)) {
+ this.logLine(`confirm "${confirm.title}": no chat panel on ${uri}`);
+ return Promise.resolve(undefined);
+ }
+ const id = String(this.nextConfirmId++);
+ return new Promise((resolve) => {
+ this.pendingConfirms.set(id, resolve);
+ this.postToWebview(uri, { type: "chat.confirm", data: { id, ...confirm } });
+ });
+ }
+
/**
* Start a task in the chat panel open on `uri`: a new session named
* `task.name`, in `task.mode`, whose first message is `task.prompt` -- sent by
@@ -1163,6 +1192,9 @@ export class ChatRuntime {
dispose(): void {
this.clearConnectWatchdog();
+ // Nobody is left to answer: a pending confirmation is declined.
+ for (const resolve of this.pendingConfirms.values()) resolve({});
+ this.pendingConfirms.clear();
this.acpForwardingDisposer();
this.acp.stop();
this.mcpHttp?.stop();
diff --git a/packages/extension-core/src/extension/diagram/glsp-activation.ts b/packages/extension-core/src/extension/diagram/glsp-activation.ts
index 67ee7b1..594d8d5 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 DiagramChatTask, type DiagramProfile, type DiagramRunAnswer, type DiagramRunHost, type DiagramRunQuestion } from '../../api';
+import { type DiagramChatConfirm, type DiagramChatConfirmAnswer, type DiagramChatTask, 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';
@@ -304,6 +304,8 @@ interface GlspActivationState {
runQuestionHandler?: RunQuestionHandler;
/** See {@link GlspIntegrationHandle.setChatTaskHandler}. */
chatTaskHandler?: ChatTaskHandler;
+ /** See {@link GlspIntegrationHandle.setChatConfirmHandler}. */
+ chatConfirmHandler?: ChatConfirmHandler;
context: vscode.ExtensionContext;
profile: DiagramProfile;
// Transient cross-file drill-down handoff, scoped to this activation (per profile instance).
@@ -345,11 +347,15 @@ export interface GlspIntegrationHandle extends vscode.Disposable {
setRunQuestionHandler(handler: RunQuestionHandler | undefined): void;
/** Where the run driver's chat tasks go ("Fix with AI" on a failed run). */
setChatTaskHandler(handler: ChatTaskHandler | undefined): void;
+ /** Where the run driver's confirmations go (resuming a run the chat fixed). */
+ setChatConfirmHandler(handler: ChatConfirmHandler | undefined): void;
}
export type RunQuestionHandler = (question: DiagramRunQuestion, sourceUri: string) => Promise;
export type ChatTaskHandler = (task: DiagramChatTask, sourceUri: string) => Promise;
+export type ChatConfirmHandler = (confirm: DiagramChatConfirm, sourceUri: string) => Promise;
+
/**
* Activate the GLSP integration for Workflow diagrams.
*
@@ -989,6 +995,9 @@ export async function activateGlspIntegration(
setChatTaskHandler: (handler) => {
state.chatTaskHandler = handler;
},
+ setChatConfirmHandler: (handler) => {
+ state.chatConfirmHandler = handler;
+ },
dispose: () => disposable.dispose()
};
}
@@ -1468,7 +1477,10 @@ function registerCalDiagramCommands(
// Only a profile with a chat can take a task; without one a failed
// run is reported and nothing is offered.
...(profile.chat
- ? { startChatTask: async (task: DiagramChatTask, sourceUri: string) => (await state.chatTaskHandler?.(task, sourceUri)) ?? false }
+ ? {
+ startChatTask: async (task: DiagramChatTask, sourceUri: string) => (await state.chatTaskHandler?.(task, sourceUri)) ?? false,
+ confirmInChat: async (confirm: DiagramChatConfirm, sourceUri: string) => state.chatConfirmHandler?.(confirm, sourceUri)
+ }
: {}),
requestRefresh: requestRunRefresh,
output: runOutput,
diff --git a/packages/extension-core/src/extension/profile-runtime.ts b/packages/extension-core/src/extension/profile-runtime.ts
index 6048dc8..c4ad59d 100644
--- a/packages/extension-core/src/extension/profile-runtime.ts
+++ b/packages/extension-core/src/extension/profile-runtime.ts
@@ -73,6 +73,7 @@ export async function activateProfileRuntime(
const runtime = chatRuntime;
glsp.setRunQuestionHandler((question, uri) => runtime.askRunQuestion(uri, question));
glsp.setChatTaskHandler(async (task, uri) => runtime.startTask(uri, task));
+ glsp.setChatConfirmHandler((confirm, uri) => runtime.confirmInChat(uri, confirm));
context.subscriptions.push(chatRuntime, {
dispose: () => {
transport?.dispose();
diff --git a/packages/extension-core/test/chat-runtime-confirm.test.ts b/packages/extension-core/test/chat-runtime-confirm.test.ts
new file mode 100644
index 0000000..4d740dc
--- /dev/null
+++ b/packages/extension-core/test/chat-runtime-confirm.test.ts
@@ -0,0 +1,57 @@
+/**
+ * A confirmation put to the person in the chat (resuming a run the chat
+ * fixed): `confirmInChat` posts a card to the panel on the diagram's URI and
+ * resolves with the button pressed; no panel there means `undefined`, so the
+ * caller asks another way.
+ */
+import { describe, it, expect } from 'vitest';
+import { ChatRuntime } from '../src/extension/chat/chat-runtime';
+import type { ChatPayload } from '../src/api';
+
+const URI = 'file:///w/top.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 runtime = new ChatRuntime(
+ { workspaceState: memento } as any,
+ { key: 'test', displayName: 'Test', settingsSection: 'test.chat' } as any,
+ (uri, payload) => posts.push({ uri, payload }),
+ canReach
+ );
+ return { runtime, posts };
+}
+
+const confirm = { title: 'Resume the failed run', text: 'Resume?', choices: ['Resume', 'Not now'] };
+
+describe('ChatRuntime.confirmInChat', () => {
+ it('posts the card and resolves with the button pressed', async () => {
+ const { runtime, posts } = makeRuntime(() => true);
+ const pending = runtime.confirmInChat(URI, confirm);
+ expect(posts).toHaveLength(1);
+ expect(posts[0].uri).toBe(URI);
+ expect(posts[0].payload).toMatchObject({ type: 'chat.confirm', data: confirm });
+ const id = (posts[0].payload as any).data.id;
+
+ await runtime.handleMessage(URI, { type: 'chat.confirmAnswer', data: { id: 'other', choice: 'Not now' } });
+ await runtime.handleMessage(URI, { type: 'chat.confirmAnswer', data: { id, choice: 'Resume' } });
+ await expect(pending).resolves.toEqual({ choice: 'Resume' });
+ });
+
+ it('resolves with no choice when declined', async () => {
+ const { runtime, posts } = makeRuntime();
+ const pending = runtime.confirmInChat(URI, confirm);
+ await runtime.handleMessage(URI, { type: 'chat.confirmAnswer', data: { id: (posts[0].payload as any).data.id } });
+ await expect(pending).resolves.toEqual({});
+ });
+
+ it('is undefined when no panel can be reached on that diagram', async () => {
+ const { runtime, posts } = makeRuntime(() => false);
+ await expect(runtime.confirmInChat(URI, confirm)).resolves.toBeUndefined();
+ expect(posts).toEqual([]);
+ });
+});
diff --git a/packages/sidecar-toolkit/src/cli-run-driver.ts b/packages/sidecar-toolkit/src/cli-run-driver.ts
index 752a8c4..01f904b 100644
--- a/packages/sidecar-toolkit/src/cli-run-driver.ts
+++ b/packages/sidecar-toolkit/src/cli-run-driver.ts
@@ -175,6 +175,10 @@ export interface CliRunDriverHost {
* `task.mode`, its first message `task.prompt`); `false` when no chat can.
* Without it a failed run is reported, and nothing is offered. */
startChatTask?(task: FixTask, sourceUri: string): Promise;
+ /** Asks the person to confirm in the chat session on the diagram at
+ * `sourceUri` (`choices` as buttons): the button pressed, none when
+ * declined, `undefined` when no chat can ask -- then a VS Code prompt does. */
+ confirmInChat?(confirm: { title: string; text: string; choices: string[] }, sourceUri: string): Promise<{ choice?: string } | undefined>;
}
/** Subscription handle returned by the driver's live-overlay APIs. */
@@ -769,12 +773,18 @@ export class CliRunDriver {
return 'A run is already in progress; the failed run can be resumed once it ends.';
}
const RESUME = 'Resume';
+ const NOT_NOW = 'Not now';
const where = failureWhere(failed.failure);
- const choice = await vscode.window.showInformationMessage(
- `The chat's fix is in. Resume the run${failed.workflowName ? ` of ${failed.workflowName}` : ''} from where it failed${where ? ` (${where})` : ''}? What ran before is replayed, not run again.`,
- RESUME,
- 'Not now'
- );
+ const text = `Resume the run${failed.workflowName ? ` of ${failed.workflowName}` : ''} from where it failed${where ? ` (${where})` : ''}? `
+ + 'What ran before is replayed, not run again.';
+ // Asked in the chat that made the fix, where the person is; a VS Code
+ // prompt only when no chat can ask.
+ const answer = this.host.confirmInChat
+ ? await this.host.confirmInChat({ title: 'Resume the failed run', text, choices: [RESUME, NOT_NOW] }, failed.sourceUri.toString())
+ : undefined;
+ const choice = answer
+ ? answer.choice
+ : await vscode.window.showInformationMessage(`The chat's fix is in. ${text}`, RESUME, NOT_NOW);
if (choice !== RESUME) {
return 'The user chose not to resume the run now.';
}
diff --git a/packages/sidecar-toolkit/src/run-failure.ts b/packages/sidecar-toolkit/src/run-failure.ts
index 5d0e5c9..956cead 100644
--- a/packages/sidecar-toolkit/src/run-failure.ts
+++ b/packages/sidecar-toolkit/src/run-failure.ts
@@ -88,8 +88,8 @@ export function fixTask(opts: {
if (opts.canResume) {
lines.push(
'',
- `Once the fix is applied, call the \`${RESUME_TOOL}\` tool: it offers the user to resume the run from where it failed, `
- + 'with what ran before replayed rather than run again.'
+ `Once the fix is applied, call the \`${RESUME_TOOL}\` tool: it asks the user, here in the chat, whether to resume the run `
+ + 'from where it failed, with what ran before replayed rather than run again. Do not ask them yourself first.'
);
}
const where = failureWhere(opts.failure);
diff --git a/packages/sidecar-toolkit/src/sidecar-diagram-profile.ts b/packages/sidecar-toolkit/src/sidecar-diagram-profile.ts
index 6e885d8..fc7fa74 100644
--- a/packages/sidecar-toolkit/src/sidecar-diagram-profile.ts
+++ b/packages/sidecar-toolkit/src/sidecar-diagram-profile.ts
@@ -373,7 +373,9 @@ export function createSidecarDiagramProfile(input: SidecarProfileInput) {
// (the chat panel on the diagram); without it the driver prompts.
askUser: host.askUser ? (question, sourceUri) => host.askUser!(question, sourceUri) : undefined,
// "Fix with AI" on a failed run: a task for the diagram's chat.
- startChatTask: host.startChatTask ? (task, sourceUri) => host.startChatTask!(task, sourceUri) : undefined
+ startChatTask: host.startChatTask ? (task, sourceUri) => host.startChatTask!(task, sourceUri) : undefined,
+ // Resuming the run the chat fixed: confirmed in that chat.
+ confirmInChat: host.confirmInChat ? (confirm, sourceUri) => host.confirmInChat!(confirm, sourceUri) : undefined
});
driver.registerCommands(context);
host.useLiveOverlaySignatureSource({
@@ -474,7 +476,7 @@ export function createSidecarDiagramProfile(input: SidecarProfileInput) {
/**
* The chat tool that resumes a failed run once the agent's fix is in. It asks
- * the person first (the run driver's `resumeFailedRun`); the agent learns the
+ * the person first, in the chat (the run driver's `resumeFailedRun`); the agent learns the
* answer, and the run's outcome shows on the diagram.
*/
export function resumeFailedRunTool(runWorkflowCommandId: string): RegistryChatTool {
@@ -482,7 +484,7 @@ export function resumeFailedRunTool(runWorkflowCommandId: string): RegistryChatT
name: RESUME_TOOL,
description:
'After fixing the cause of a failed workflow run, offer the user to resume that run from where it failed. '
- + 'The user is asked to confirm; what ran before the failure is replayed, not run again. '
+ + 'The user is asked to confirm in this chat (do not ask them yourself first); what ran before the failure is replayed, not run again. '
+ 'Call it once the fix is applied to the files -- not before, and not to start a new run.',
inputSchema: { type: 'object', properties: {} },
handler: async (file) => {
diff --git a/packages/sidecar-toolkit/test/resume-failed-run.test.ts b/packages/sidecar-toolkit/test/resume-failed-run.test.ts
index 5d31ac1..b2e2028 100644
--- a/packages/sidecar-toolkit/test/resume-failed-run.test.ts
+++ b/packages/sidecar-toolkit/test/resume-failed-run.test.ts
@@ -51,7 +51,7 @@ import { resumeFailedRunTool } from '../src/sidecar-diagram-profile';
const RUN_CMD = 'test.resume.runWorkflow';
-function makeDriver(canResume = true) {
+function makeDriver(canResume = true, confirmInChat?: (confirm: any, sourceUri: string) => Promise<{ choice?: string } | undefined>) {
const config: CliRunDriverConfig = {
settingsNamespace: 'wfLang', customEditorViewType: 'workflow.networkDiagram',
cliCommandSettingKey: 'wfpyCommand', cliCommandDefault: 'fake-wfpy', cliPythonModule: undefined,
@@ -68,7 +68,8 @@ function makeDriver(canResume = true) {
const driver = new CliRunDriver(config, {
overlay: { emitEvents: () => {} },
requestRefresh: () => {},
- output: { show: () => {}, append: () => {}, appendLine: () => {} }
+ output: { show: () => {}, append: () => {}, appendLine: () => {} },
+ confirmInChat
} as any);
driver.registerCommands({ subscriptions: [] } as any);
return driver;
@@ -164,6 +165,46 @@ describe('resuming a failed run when the agent proposes it', () => {
});
});
+describe('asked in the chat', () => {
+ it('asks in the chat on the run\u2019s diagram, not with a notification', async () => {
+ const confirms: Array<{ confirm: any; sourceUri: string }> = [];
+ makeDriver(true, async (confirm, sourceUri) => {
+ confirms.push({ confirm, sourceUri });
+ return { choice: 'Resume' };
+ });
+ const file = await failRun();
+
+ const result = await vscode.commands.executeCommand(resumeFailedRunCommandId(RUN_CMD), { file });
+ await settle();
+
+ expect(confirms).toHaveLength(1);
+ expect(confirms[0].sourceUri).toContain('top.py');
+ expect(confirms[0].confirm.choices).toEqual(['Resume', 'Not now']);
+ expect(confirms[0].confirm.text).toContain('from where it failed (x (in m))');
+ expect(asked.some(q => q.startsWith("The chat's fix"))).toBe(false);
+ expect(result).toContain('resuming');
+ expect(spawns).toHaveLength(2);
+ });
+
+ it('takes a decline in the chat as not now', async () => {
+ makeDriver(true, async () => ({}));
+ const file = await failRun();
+ const result = await vscode.commands.executeCommand(resumeFailedRunCommandId(RUN_CMD), { file });
+ expect(result).toContain('chose not to resume');
+ expect(spawns).toHaveLength(1);
+ });
+
+ it('falls back to a notification when no chat can ask', async () => {
+ makeDriver(true, async () => undefined);
+ const file = await failRun();
+ answer = 'Resume';
+ await vscode.commands.executeCommand(resumeFailedRunCommandId(RUN_CMD), { file });
+ await settle();
+ expect(asked.some(q => q.startsWith("The chat's fix"))).toBe(true);
+ expect(spawns).toHaveLength(2);
+ });
+});
+
describe('the chat tool', () => {
it('runs the driver’s command with the session’s file and returns its answer', async () => {
const tool = resumeFailedRunTool(RUN_CMD);