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
31 changes: 31 additions & 0 deletions packages/diagram-client/src/chat-panel-integrated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,8 @@ export class ChatPanel implements IDiagramStartup, ISelectionListener {
private isVisible = false;
private isCompact = false;
private currentMode: 'plan' | 'build' = 'build';
/** A task the host asked for (`chat.startTask`), sent once its session exists. */
private pendingTask: { name: string; mode: 'plan' | 'build'; prompt: string } | undefined;
private currentSessionId: string | null = null;
private timeline: TimelineItem[] = [];
private sessions: SessionEntry[] = [];
Expand Down Expand Up @@ -494,11 +496,40 @@ export class ChatPanel implements IDiagramStartup, ISelectionListener {
this.pushMessage('system', `Created session: ${data.session.name ?? data.session.id}`);
}
this.update();
// A task's session: send its message, as if typed, so it is in the
// transcript like any other.
if (data?.session?.id && this.pendingTask) {
const task = this.pendingTask;
this.pendingTask = undefined;
this.inputValue = task.prompt;
this.sendMessage();
}
break;

case 'chat.startTask':
// The host asks for a task in a new session: "Fix with AI" on a run
// that failed. Open the panel, start the session in the task's mode,
// and send its message once the session exists.
if (data && typeof data.prompt === 'string' && data.prompt.trim() !== '') {
const mode: 'plan' | 'build' = data.mode === 'build' ? 'build' : 'plan';
this.pendingTask = {
name: typeof data.name === 'string' && data.name.trim() !== '' ? data.name : 'Task',
mode,
prompt: data.prompt
};
this.currentMode = mode;
this.isLoadingSession = true;
this.loadingLabel = 'Creating session…';
this.autoShow('task');
this.update();
this.sendToHost('chat.createSession', { mode, name: this.pendingTask.name });
}
break;

case 'chat.sessionCreateAborted':
// Name prompt cancelled — drop the "Creating session…" spinner.
this.isLoadingSession = false;
this.pendingTask = undefined;
this.update();
break;

Expand Down
61 changes: 61 additions & 0 deletions packages/diagram-client/test/chat-panel-start-task.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* A task the host asks the chat to start ("Fix with AI" on a failed run): the
* panel opens, creates a session in the task's mode under the task's name, and
* once it exists sends the task's message as if typed.
*/
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 task = { name: 'Fix: x (in m › i)', mode: 'plan', prompt: 'The last run of workflow `top` failed. …' };

describe('a task the host starts in the chat', () => {
it('opens the panel and creates a session in the task’s mode and name', () => {
const { panel, sent, showSpy } = makePanel();

(panel as any).handleIncomingMessage('chat.startTask', task);

expect(showSpy).toHaveBeenCalled();
expect(sent.find(m => m.type === 'chat.createSession')?.data).toMatchObject({ mode: 'plan', name: task.name });
expect(sent.some(m => m.type === 'chat.sendMessage')).toBe(false);
});

it('sends the task’s message once the session exists, in plan mode, once', () => {
const { panel, sent } = makePanel();
(panel as any).handleIncomingMessage('chat.startTask', task);

(panel as any).handleIncomingMessage('chat.sessionCreated', { session: { id: 's1', name: task.name } });
(panel as any).handleIncomingMessage('chat.sessionCreated', { session: { id: 's2', name: 'later' } });

const messages = sent.filter(m => m.type === 'chat.sendMessage').map(m => m.data);
expect(messages).toHaveLength(1);
expect(messages[0]).toMatchObject({ text: task.prompt, sessionId: 's1', mode: 'plan' });
});

it('drops the task when the session is not created', () => {
const { panel, sent } = makePanel();
(panel as any).handleIncomingMessage('chat.startTask', task);
(panel as any).handleIncomingMessage('chat.sessionCreateAborted', {});
(panel as any).handleIncomingMessage('chat.sessionCreated', { session: { id: 's1' } });

expect(sent.some(m => m.type === 'chat.sendMessage')).toBe(false);
});
});
11 changes: 11 additions & 0 deletions packages/extension-core/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,17 @@ export interface DiagramRunHost {
): void;
output: vscode.OutputChannel;
useLiveOverlaySignatureSource(source: DiagramLiveOverlaySource): void;
/** Starts a task in the chat on the diagram at `sourceUri`: a new session in
* `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>;
}

/** A task the platform asks a diagram's chat to start (see `DiagramRunHost.startChatTask`). */
export interface DiagramChatTask {
name: string;
mode: "plan" | "build";
prompt: string;
}

/**
Expand Down
15 changes: 15 additions & 0 deletions packages/extension-core/src/extension/chat/chat-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -881,6 +881,21 @@ export class ChatRuntime {
});
}

/**
* 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
* the panel as if typed, so the person sees what was asked. `false` when no
* panel can be reached there.
*/
startTask(uri: string, task: { name: string; mode: "plan" | "build"; prompt: string }): boolean {
if (this.canReach && !this.canReach(uri)) {
this.logLine(`chat task "${task.name}": no chat panel on ${uri}`);
return false;
}
this.postToWebview(uri, { type: "chat.startTask", data: { ...task } });
return true;
}

/** The last model the user explicitly chose (migrated from the legacy key). */
private getPreferredModel(): string | undefined {
return readStateWithFallback<string | undefined>(
Expand Down
15 changes: 14 additions & 1 deletion 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 DiagramProfile, type DiagramRunAnswer, type DiagramRunHost, type DiagramRunQuestion } from '../../api';
import { 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 @@ -302,6 +302,8 @@ function serializedRangeToVscodeRange(range: SerializedRange): vscode.Range {
interface GlspActivationState {
/** See {@link GlspIntegrationHandle.setRunQuestionHandler}. */
runQuestionHandler?: RunQuestionHandler;
/** See {@link GlspIntegrationHandle.setChatTaskHandler}. */
chatTaskHandler?: ChatTaskHandler;
context: vscode.ExtensionContext;
profile: DiagramProfile;
// Transient cross-file drill-down handoff, scoped to this activation (per profile instance).
Expand Down Expand Up @@ -341,9 +343,12 @@ export interface GlspIntegrationHandle extends vscode.Disposable {
* the driver falls back to a VS Code prompt.
*/
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;
}

export type RunQuestionHandler = (question: DiagramRunQuestion, sourceUri: string) => Promise<DiagramRunAnswer | undefined>;
export type ChatTaskHandler = (task: DiagramChatTask, sourceUri: string) => Promise<boolean>;

/**
* Activate the GLSP integration for Workflow diagrams.
Expand Down Expand Up @@ -981,6 +986,9 @@ export async function activateGlspIntegration(
setRunQuestionHandler: (handler) => {
state.runQuestionHandler = handler;
},
setChatTaskHandler: (handler) => {
state.chatTaskHandler = handler;
},
dispose: () => disposable.dispose()
};
}
Expand Down Expand Up @@ -1457,6 +1465,11 @@ function registerCalDiagramCommands(
const host: DiagramRunHost = {
overlay: executionOverlay,
askUser: async (question, sourceUri) => state.runQuestionHandler?.(question, sourceUri),
// 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 }
: {}),
requestRefresh: requestRunRefresh,
output: runOutput,
useLiveOverlaySignatureSource: (source) => {
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 @@ -72,6 +72,7 @@ export async function activateProfileRuntime(
// 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));
glsp.setChatTaskHandler(async (task, uri) => runtime.startTask(uri, task));
context.subscriptions.push(chatRuntime, {
dispose: () => {
transport?.dispose();
Expand Down
114 changes: 105 additions & 9 deletions packages/sidecar-toolkit/src/cli-run-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import type { ExecutionOverlaySink } from '@dialogram/shared';
import { resumeStepFor } from './resume-at-step.js';
import { appendTail, failureHeadline, fixTask, type FixTask, type RunFailure } from './run-failure.js';
import { requestWorkflowStop, spawnWorkflowProcess } from './process-control.js';
import { RunEventStreamClient, type RunStreamEvent } from './run-event-stream-client.js';

Expand Down Expand Up @@ -163,6 +164,10 @@ export interface CliRunDriverHost {
requestRefresh(sourceUri: string, kind: 'full' | 'agentContextOnly', networkName?: string): void;
/** Run output channel, owned by core. */
output: vscode.OutputChannel;
/** Starts a task in the chat on the diagram at `sourceUri` (a new session in
* `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<boolean>;
}

/** Subscription handle returned by the driver's live-overlay APIs. */
Expand Down Expand Up @@ -611,7 +616,8 @@ export class CliRunDriver {
return { cmd: cliCommand, argsPrefix: [], cwd: startDir };
}

private async findLatestQueueTracePath(opts: {
/** The directory of the latest run of this workflow that started after `startedAfterMs`. */
private async findLatestRunDir(opts: {
baseOutDir: string;
sourcePath: string;
startedAfterMs: number;
Expand Down Expand Up @@ -660,11 +666,21 @@ export class CliRunDriver {
}
}

if (!latest?.outDir) {
return latest?.outDir;
}

private async findLatestQueueTracePath(opts: {
baseOutDir: string;
sourcePath: string;
startedAfterMs: number;
workflowName?: string;
}): Promise<string | undefined> {
const runDir = await this.findLatestRunDir(opts);
if (!runDir) {
return undefined;
}

const queueTracePath = path.join(latest.outDir, 'run.wf-queues.json');
const queueTracePath = path.join(runDir, 'run.wf-queues.json');
if (!(await this.fileExists(queueTracePath))) {
return undefined;
}
Expand All @@ -675,7 +691,8 @@ export class CliRunDriver {
inv: { cmd: string; args: string[]; cwd: string },
output: vscode.OutputChannel,
onSpawn?: (child: cp.ChildProcessWithoutNullStreams) => void,
wasStopRequested?: () => boolean
wasStopRequested?: () => boolean,
onStderr?: (text: string) => void
): Promise<number> {
return await new Promise<number>((resolve) => {
const child = spawnWorkflowProcess(inv);
Expand All @@ -685,7 +702,11 @@ export class CliRunDriver {
}

child.stdout.on('data', (d) => output.append(d.toString()));
child.stderr.on('data', (d) => output.append(d.toString()));
child.stderr.on('data', (d) => {
const text = d.toString();
output.append(text);
onStderr?.(text);
});
child.on('close', (code, signal) => {
const exitSummary = `\n[wf-lang] Process exited (code=${code ?? 'null'}${signal ? `, signal=${signal}` : ''})\n`;
output.append(exitSummary);
Expand All @@ -707,6 +728,67 @@ export class CliRunDriver {
});
}

/** The error a run recorded, if its record has one. */
private async readRunFailure(runDir: string): Promise<RunFailure | undefined> {
try {
const record = JSON.parse(await fs.readFile(path.join(runDir, 'run.wf-run.json'), 'utf-8'));
const error = record?.error;
if (!error || typeof error !== 'object') {
return undefined;
}
return {
...(typeof error.message === 'string' ? { message: error.message } : {}),
...(typeof error.entityInstanceName === 'string' ? { entityInstanceName: error.entityInstanceName } : {}),
...(Array.isArray(error.entityInstancePath)
? { entityInstancePath: error.entityInstancePath.filter((p: unknown): p is string => typeof p === 'string') }
: {})
};
} catch {
return undefined;
}
}

/**
* Tell the person the run failed, and where. With a chat behind the
* diagram, offer to have the chat find the cause and propose a fix: a new
* session in plan mode, its first message the failure.
*/
private async reportFailure(opts: {
sourceUri: vscode.Uri;
workflowName?: string;
failure: RunFailure | undefined;
exitCode: number;
stderrTail: string;
runDir?: string;
output: vscode.OutputChannel;
}): Promise<void> {
const headline = failureHeadline(opts.failure, opts.exitCode);
const FIX = 'Fix with AI';
const OUTPUT = 'Show Output';
const actions = this.host.startChatTask ? [FIX, OUTPUT] : [OUTPUT];
const choice = await vscode.window.showErrorMessage(headline, ...actions);
if (choice === OUTPUT) {
opts.output.show(true);
return;
}
if (choice !== FIX || !this.host.startChatTask) {
return;
}
const task: FixTask = fixTask({
workflowName: opts.workflowName,
sourceFile: opts.sourceUri.fsPath,
failure: opts.failure,
exitCode: opts.exitCode,
stderrTail: opts.stderrTail,
runDir: opts.runDir,
canResume: !!this.config.cliResumeArgs && !!opts.runDir
});
const started = await this.host.startChatTask(task, opts.sourceUri.toString());
if (!started) {
void vscode.window.showWarningMessage('Open the chat on this diagram to have the failure looked at.');
}
}

private async stopWorkflow(): Promise<{ ok: boolean; message: string }> {
const active = this.activeRun;
if (!active) {
Expand Down Expand Up @@ -980,6 +1062,8 @@ export class CliRunDriver {
this.stopElicitSocket();
};

// The end of what the run wrote to stderr -- its traceback, when it fails.
let stderrTail = '';
const exitCode = await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
Expand Down Expand Up @@ -1007,7 +1091,10 @@ export class CliRunDriver {
stopRequested: false
};
},
() => this.activeRun?.stopRequested === true
() => this.activeRun?.stopRequested === true,
(text) => {
stderrTail = appendTail(stderrTail, text);
}
);
}
);
Expand All @@ -1026,9 +1113,18 @@ export class CliRunDriver {
}

if (exitCode !== 0) {
const msg = `Workflow run failed (exit code ${exitCode}). See 'wf-lang Run' output.`;
vscode.window.showErrorMessage(msg);
return { error: msg };
// Show where it failed on the diagram, then say so -- and, with a
// chat behind the diagram, offer to have it looked at.
await refreshDiagram(true);
const runDir = await this.findLatestRunDir({
baseOutDir: runOutDir,
sourcePath: sourceUri.fsPath,
startedAfterMs: runStartedAtMs,
workflowName
});
const failure = runDir ? await this.readRunFailure(runDir) : undefined;
void this.reportFailure({ sourceUri, workflowName, failure, exitCode, stderrTail, runDir, output });
return { error: failureHeadline(failure, exitCode) };
}

// Refresh diagram so overlay decorations can pick up the new run artifacts.
Expand Down
Loading
Loading