diff --git a/packages/diagram-client/src/chat-panel-integrated.ts b/packages/diagram-client/src/chat-panel-integrated.ts index dbf8e16..512d262 100644 --- a/packages/diagram-client/src/chat-panel-integrated.ts +++ b/packages/diagram-client/src/chat-panel-integrated.ts @@ -125,7 +125,18 @@ interface PermissionItem { resolved?: 'allowed' | 'denied'; } -type TimelineItem = MessageItem | ToolItem | PermissionItem; +/** The platform asking the person to confirm something the session's agent proposed. */ +interface ConfirmItem { + kind: 'confirm'; + id: string; + title: string; + text: string; + choices: string[]; + /** The button pressed, or `null` once declined. */ + resolved?: string | null; +} + +type TimelineItem = MessageItem | ToolItem | PermissionItem | ConfirmItem; /** * What the panel shows: the diagram's own session with its agent, or one of @@ -617,6 +628,22 @@ export class ChatPanel implements IDiagramStartup, ISelectionListener { } break; + case 'chat.confirm': + if (data && typeof data.id === 'string' && typeof data.text === 'string' && Array.isArray(data.choices)) { + this.timeline.push({ + kind: 'confirm', + id: data.id, + title: typeof data.title === 'string' ? data.title : 'Confirm', + text: data.text, + choices: data.choices.map(String), + }); + // It is the session's agent that proposed it: show the session. + if (this.view.kind !== 'session') this.showSession(); + else this.update(); + this.autoShow('confirm'); + } + break; + case 'chat.permissionRequest': if (data?.requestId) { this.showTyping = false; @@ -989,6 +1016,14 @@ export class ChatPanel implements IDiagramStartup, ISelectionListener { this.update(); } + /** Answer a confirmation with the button pressed (`null`: declined). */ + private answerConfirm(item: ConfirmItem, choice: string | null): void { + if (item.resolved !== undefined) return; + this.sendToHost('chat.confirmAnswer', choice === null ? { id: item.id } : { id: item.id, choice }); + item.resolved = choice; + this.update(); + } + /** Answer a running agent's question (or decline it, `answer` undefined). */ private answerRunQuestion(q: LiveAgentQuestion, answer: string | undefined): void { if (q.resolved) return; @@ -1311,7 +1346,7 @@ export class ChatPanel implements IDiagramStartup, ISelectionListener { : nothing} ${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 === 'confirm' ? `c${item.id}` : `m${i}`), (item) => this.itemTemplate(item) )} ${this.streamingText || this.streamingThinking ? this.streamingTemplate() : nothing} @@ -1570,6 +1605,30 @@ export class ChatPanel implements IDiagramStartup, ISelectionListener { `; } + if (item.kind === 'confirm') { + return html` +
+
+ + ${item.title} +
+
${item.text}
+ ${item.resolved === undefined + ? html`
+ ${item.choices.map( + (c, i) => html`` + )} +
` + : html`
${item.resolved ?? 'Declined'}
`} +
+ `; + } + if (item.kind === 'permission') { return html`
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);