Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 61 additions & 2 deletions packages/diagram-client/src/chat-panel-integrated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -1570,6 +1605,30 @@ export class ChatPanel implements IDiagramStartup, ISelectionListener {
`;
}

if (item.kind === 'confirm') {
return html`
<div class="chat-permission chat-confirm ${item.resolved !== undefined ? 'resolved' : ''}">
<div class="chat-permission-title">
<span class="codicon codicon-debug-continue"></span>
<span>${item.title}</span>
</div>
<div class="chat-question-text">${item.text}</div>
${item.resolved === undefined
? html`<div class="chat-permission-actions">
${item.choices.map(
(c, i) => html`<button
class="chat-permission-btn ${i === item.choices.length - 1 && item.choices.length > 1 ? 'deny' : ''}"
@click=${() => this.answerConfirm(item, c)}
>
${c}
</button>`
)}
</div>`
: html`<div class="chat-question-answer">${item.resolved ?? 'Declined'}</div>`}
</div>
`;
}

if (item.kind === 'permission') {
return html`
<div class="chat-permission ${item.resolved ? 'resolved' : ''}">
Expand Down
53 changes: 53 additions & 0 deletions packages/diagram-client/test/chat-panel-confirm.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
17 changes: 17 additions & 0 deletions packages/extension-core/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>;
/** 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<DiagramChatConfirmAnswer | undefined>;
}

/** 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`). */
Expand Down
34 changes: 33 additions & 1 deletion packages/extension-core/src/extension/chat/chat-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -161,6 +161,10 @@ export class ChatRuntime {
{ resolve: (answer: DiagramRunAnswer) => void; timer?: ReturnType<typeof setTimeout> }
>();

/** A confirmation put to the person in a session, waiting for the panel's `chat.confirmAnswer`. */
private readonly pendingConfirms = new Map<string, (answer: DiagramChatConfirmAnswer) => void>();
private nextConfirmId = 1;

constructor(
context: vscode.ExtensionContext,
private readonly config: ChatRuntimeConfig,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<DiagramChatConfirmAnswer | undefined> {
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
Expand Down Expand Up @@ -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();
Expand Down
16 changes: 14 additions & 2 deletions packages/extension-core/src/extension/diagram/glsp-activation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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<DiagramRunAnswer | undefined>;
export type ChatTaskHandler = (task: DiagramChatTask, sourceUri: string) => Promise<boolean>;

export type ChatConfirmHandler = (confirm: DiagramChatConfirm, sourceUri: string) => Promise<DiagramChatConfirmAnswer | undefined>;

/**
* Activate the GLSP integration for Workflow diagrams.
*
Expand Down Expand Up @@ -989,6 +995,9 @@ export async function activateGlspIntegration(
setChatTaskHandler: (handler) => {
state.chatTaskHandler = handler;
},
setChatConfirmHandler: (handler) => {
state.chatConfirmHandler = handler;
},
dispose: () => disposable.dispose()
};
}
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/extension-core/src/extension/profile-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
57 changes: 57 additions & 0 deletions packages/extension-core/test/chat-runtime-confirm.test.ts
Original file line number Diff line number Diff line change
@@ -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: <T>(_key: string, defaultValue?: T): T | undefined => defaultValue,
update: async (): Promise<void> => 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([]);
});
});
Loading
Loading