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
24 changes: 22 additions & 2 deletions packages/diagram-client/src/chat-panel-integrated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1517,8 +1517,28 @@ export class ChatPanel implements IDiagramStartup, ISelectionListener {

private partTemplate(p: LiveAgentPart, open: boolean): TemplateResult {
switch (p.kind) {
case 'turn':
return html`<div class="chat-run-turn"><span>turn ${p.index}</span></div>`;
case 'turn': {
const session = p.session;
return html`<div class="chat-run-turn">
<span>turn ${p.index}</span>
${session
? html`<button
class="chat-ibtn chat-run-session-btn"
title=${`Open this turn's session in ${session.connector ?? 'the agent'}'s own CLI, in a terminal`}
@click=${() => this.sendToHost('chat.openAgentSession', { sessionId: session.id, connector: session.connector, cwd: session.cwd })}
>
<span class="codicon codicon-terminal"></span>
</button>
<button
class="chat-ibtn chat-run-session-btn"
title=${`Copy this turn's session id (${session.id})`}
@click=${() => this.sendToHost('chat.copyAgentSession', { sessionId: session.id, connector: session.connector, cwd: session.cwd })}
>
<span class="codicon codicon-copy"></span>
</button>`
: nothing}
</div>`;
}
case 'reasoning':
return this.thinkingTemplate(p.text, { open });
case 'text': {
Expand Down
10 changes: 10 additions & 0 deletions packages/diagram-client/src/chat-panel.css
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,16 @@
flex: 1;
border-top: 1px solid var(--chat-border);
}
/* A finished turn's session: open in the agent's CLI, copy its id. */
.chat-run-turn .chat-run-session-btn {
padding: 0 2px;
margin-left: -4px;
font-size: 11px;
text-transform: none;
}
.chat-run-turn:hover {
opacity: 0.9;
}
.chat-run-transcript .chat-question {
margin: 8px 0;
}
Expand Down
39 changes: 37 additions & 2 deletions packages/diagram-client/src/editing-action-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1001,8 +1001,17 @@ export interface LiveAgentQuestion {
}

/** One entry of an agent's transcript, in arrival order. */
/** The agent session a firing talked to, as the runtime reports it. */
export interface LiveAgentSession {
id: string;
/** The connector that ran it; absent for an explicit command line. */
connector?: string;
/** The directory it ran in. */
cwd?: string;
}

export type LiveAgentPart =
| { kind: 'turn'; index: number }
| { kind: 'turn'; index: number; session?: LiveAgentSession }
| { kind: 'reasoning'; text: string }
| { kind: 'text'; text: string }
| { kind: 'tool'; name: string; status?: string; id?: string }
Expand All @@ -1026,6 +1035,19 @@ export interface LiveAgentState {
}


/** A run event's `session`, if it names one. */
export function liveAgentSession(raw: unknown): LiveAgentSession | undefined {
if (!raw || typeof raw !== 'object') return undefined;
const r = raw as Record<string, unknown>;
const id = typeof r.id === 'string' ? r.id.trim() : '';
if (!id) return undefined;
return {
id,
...(typeof r.connector === 'string' && r.connector ? { connector: r.connector } : {}),
...(typeof r.cwd === 'string' && r.cwd ? { cwd: r.cwd } : {})
};
}

@injectable()
export class RunAgentStreamActionHandler implements IActionHandler {
private static readonly agents = new Map<string, LiveAgentState>();
Expand Down Expand Up @@ -1211,9 +1233,22 @@ export class RunAgentStreamActionHandler implements IActionHandler {
}
break;
}
case 'agent.message.end':
case 'agent.message.end': {
state.status = 'done';
// The firing's session, on its turn: it can be opened in the
// agent's own CLI once the firing is over.
const session = liveAgentSession(ev.session);
if (session) {
for (let i = state.parts.length - 1; i >= 0; i--) {
const p = state.parts[i];
if (p.kind === 'turn') {
p.session = session;
break;
}
}
}
break;
}
default:
break;
}
Expand Down
45 changes: 45 additions & 0 deletions packages/diagram-client/test/run-agents-session.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// A run agent's firing reports the session it talked to on its end event;
// the transcript keeps it on that firing's turn, where the view offers to
// open it in the agent's own CLI.
import 'reflect-metadata';
import { afterEach, describe, expect, it } from 'vitest';
import { EXECUTION_OVERLAY_ACTION_KIND } from '@dialogram/shared';
import { liveAgentSession, RunAgentStreamActionHandler } from '../src/editing-action-handlers';

afterEach(() => RunAgentStreamActionHandler.reset());

function feed(events: Array<Record<string, unknown>>): void {
new RunAgentStreamActionHandler().handle({ kind: EXECUTION_OVERLAY_ACTION_KIND, events } as any);
}

describe("a firing's session", () => {
it('lands on its own turn, each turn keeping its own', () => {
feed([
{ seq: 1, type: 'run.started' },
{ seq: 2, type: 'agent.message.start', instance: 'ask' },
{ seq: 3, type: 'agent.message.end', instance: 'ask', session: { id: 's1', connector: 'claude', cwd: '/w' } },
{ seq: 4, type: 'agent.message.start', instance: 'ask' },
{ seq: 5, type: 'agent.message.delta', instance: 'ask', delta: 'x' },
{ seq: 6, type: 'agent.message.end', instance: 'ask', session: { id: 's2', connector: 'claude', cwd: '/w' } }
]);
const turns = RunAgentStreamActionHandler.getAgent('ask')!.parts.filter(p => p.kind === 'turn');
expect(turns).toEqual([
{ kind: 'turn', index: 1, session: { id: 's1', connector: 'claude', cwd: '/w' } },
{ kind: 'turn', index: 2, session: { id: 's2', connector: 'claude', cwd: '/w' } }
]);
});

it('is absent from a turn whose end names none', () => {
feed([
{ seq: 1, type: 'agent.message.start', instance: 'ask' },
{ seq: 2, type: 'agent.message.end', instance: 'ask' }
]);
expect(RunAgentStreamActionHandler.getAgent('ask')!.parts[0]).toEqual({ kind: 'turn', index: 1 });
});

it('is read only when it names an id', () => {
expect(liveAgentSession({ id: ' s ', connector: '', cwd: '/w' })).toEqual({ id: 's', cwd: '/w' });
expect(liveAgentSession({ connector: 'claude' })).toBeUndefined();
expect(liveAgentSession('s')).toBeUndefined();
});
});
15 changes: 15 additions & 0 deletions packages/extension-core/src/extension/chat/acp-connectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,21 @@ export function createAcpAgentResolver(
};
}

/**
* A named connector's `resume` template, as the connectors files of the
* workspace at `cwd` declare it; undefined when it has none or the files do
* not read.
*/
export function createConnectorResumeResolver(config: AcpConnectorConfig): (connector: string, cwd: string) => string | undefined {
return (connector: string, cwd: string) => {
try {
return loadAcpConnectors({ files: config.files, workspaceDir: cwd }).find((c) => c.name === connector)?.resume;
} catch {
return KNOWN_CONNECTORS[connector]?.resume;
}
};
}

/**
* The agent the chat spawns for the connector a setting names. Throws with
* the reason a reader can act on.
Expand Down
29 changes: 29 additions & 0 deletions packages/extension-core/src/extension/chat/chat-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
} from "./slash-commands";
import { attachAcpEventForwarding } from "./acp-event-forwarding";
import { copySessionId, openSessionNatively, type NativeSession } from "./native-session.js";
import { KNOWN_CONNECTORS } from "./acp-connectors.js";
import {
LEGACY_KEYS,
readStateWithFallback,
Expand All @@ -49,6 +50,12 @@ export interface ChatRuntimeConfig {
* means opencode. A rejection is the connect error the panel shows.
*/
acpAgent?: (cwd: string) => Promise<AcpAgentSpec | undefined>;
/**
* A named connector's `resume` template as a workspace sees it (for a run's
* agents, which name their connector); undefined (or no hook) falls back to
* the known connectors'.
*/
connectorResume?: (connector: string, cwd: string) => string | undefined;
/** Settings section read for `opencodePath` and `enableMcpTools`. */
settingsSection: string;
/** Domain primer injected into each session's context. */
Expand Down Expand Up @@ -856,6 +863,28 @@ export class ChatRuntime {
await (type === "chat.copySessionId" ? copySessionId(session) : openSessionNatively(session));
return;
}
case "chat.openAgentSession":
case "chat.copyAgentSession": {
// A run's agent: its firing named the connector and the directory.
const sessionId = typeof data?.sessionId === "string" ? data.sessionId : "";
if (!sessionId) return;
const connector = typeof data?.connector === "string" && data.connector ? data.connector : undefined;
const cwd = typeof data?.cwd === "string" && data.cwd ? data.cwd : undefined;
const session: NativeSession = {
agent: connector ?? "agent",
sessionId,
cwd,
// The workspace's connectors files decide (they can clear one);
// the known connectors' only without them.
resume: !connector
? undefined
: this.config.connectorResume
? this.config.connectorResume(connector, cwd ?? process.cwd())
: KNOWN_CONNECTORS[connector]?.resume,
};
await (type === "chat.copyAgentSession" ? copySessionId(session) : openSessionNatively(session));
return;
}
case "chat.confirmAnswer": {
const resolve = this.pendingConfirms.get(String(data?.id));
if (!resolve) return;
Expand Down
5 changes: 4 additions & 1 deletion packages/extension-core/src/extension/profile-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import type * as vscode from "vscode";
import type { DiagramProfile, DiagramProfileHandle } from "../api";
import { activateGlspIntegration } from "./diagram/glsp-activation";
import { ChatRuntime, type ChatRuntimeConfig } from "./chat/chat-runtime";
import { createAcpAgentResolver } from "./chat/acp-connectors";
import { createAcpAgentResolver, createConnectorResumeResolver } from "./chat/acp-connectors";
import { createViewerEditorsTool } from "./chat/viewer-editors-tool";
import {
createEditChatCapability,
Expand Down Expand Up @@ -124,6 +124,9 @@ export function assembleChatRuntimeConfig(
acpAgent: chat.acpConnector
? createAcpAgentResolver(profile.settingsNamespace, chat.acpConnector)
: undefined,
connectorResume: chat.acpConnector
? createConnectorResumeResolver(chat.acpConnector)
: undefined,
selectionContext: chat.selectionContext,
// The profile's own tools, plus the platform's: "what can open this file"
// is a question about the EDITOR, not about any one product's graph, so it
Expand Down
41 changes: 39 additions & 2 deletions packages/extension-core/test/chat-runtime-native-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,15 @@ import { beforeEach, describe, expect, it } from 'vitest';
import { ChatRuntime } from '../src/extension/chat/chat-runtime';
import { clipboardWrites, createdTerminals } from './vscode-mock';

function makeRuntime(cwd: string) {
function makeRuntime(cwd: string, connectorResume?: (connector: string, cwd: string) => string | undefined) {
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,
{ key: 'test', displayName: 'Test', settingsSection: 'test.chat', connectorResume } as any,
() => undefined,
() => true
);
Expand Down Expand Up @@ -55,3 +55,40 @@ describe('opening the chat’s session natively', () => {
expect(createdTerminals).toHaveLength(0);
});
});

describe('opening a run agent\u2019s session natively', () => {
it('uses the connector the firing named, as the workspace\u2019s connectors declare it', async () => {
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'run-native-'));
try {
const asked: string[][] = [];
const runtime = makeRuntime('/elsewhere', (connector, dir) => {
asked.push([connector, dir]);
return connector === 'opencode' ? 'opencode -s {sessionId}' : undefined;
});
await runtime.handleMessage('file:///w/top.py', {
type: 'chat.openAgentSession',
data: { sessionId: 'ses_1', connector: 'opencode', cwd }
});
expect(asked).toEqual([['opencode', cwd]]);
expect(createdTerminals[0].options.cwd).toBe(cwd);
expect(createdTerminals[0].sent).toEqual(['opencode -s ses_1']);
} finally {
fs.rmSync(cwd, { recursive: true, force: true });
}
});

it('falls back to the known connectors without a resolver, and copies without a connector', async () => {
const runtime = makeRuntime('/w');
await runtime.handleMessage('file:///w/top.py', { type: 'chat.openAgentSession', data: { sessionId: 'c1', connector: 'claude' } });
expect(createdTerminals[0].sent).toEqual(['claude --resume c1']);

await runtime.handleMessage('file:///w/top.py', { type: 'chat.openAgentSession', data: { sessionId: 'x9' } });
expect(createdTerminals).toHaveLength(1);
expect(clipboardWrites).toEqual(['x9']);
});

it('copies a run agent\u2019s session id', async () => {
await makeRuntime('/w').handleMessage('file:///w/top.py', { type: 'chat.copyAgentSession', data: { sessionId: 'c1', connector: 'claude' } });
expect(clipboardWrites).toEqual(['c1']);
});
});
Loading