diff --git a/packages/diagram-client/src/chat-panel-integrated.ts b/packages/diagram-client/src/chat-panel-integrated.ts
index 962827d..7a1fe46 100644
--- a/packages/diagram-client/src/chat-panel-integrated.ts
+++ b/packages/diagram-client/src/chat-panel-integrated.ts
@@ -1517,8 +1517,28 @@ export class ChatPanel implements IDiagramStartup, ISelectionListener {
private partTemplate(p: LiveAgentPart, open: boolean): TemplateResult {
switch (p.kind) {
- case 'turn':
- return html`
turn ${p.index}
`;
+ case 'turn': {
+ const session = p.session;
+ return html`
+ turn ${p.index}
+ ${session
+ ? html`
+ `
+ : nothing}
+
`;
+ }
case 'reasoning':
return this.thinkingTemplate(p.text, { open });
case 'text': {
diff --git a/packages/diagram-client/src/chat-panel.css b/packages/diagram-client/src/chat-panel.css
index e653095..320a081 100644
--- a/packages/diagram-client/src/chat-panel.css
+++ b/packages/diagram-client/src/chat-panel.css
@@ -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;
}
diff --git a/packages/diagram-client/src/editing-action-handlers.ts b/packages/diagram-client/src/editing-action-handlers.ts
index fc5dd89..de16ebb 100644
--- a/packages/diagram-client/src/editing-action-handlers.ts
+++ b/packages/diagram-client/src/editing-action-handlers.ts
@@ -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 }
@@ -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;
+ 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();
@@ -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;
}
diff --git a/packages/diagram-client/test/run-agents-session.test.ts b/packages/diagram-client/test/run-agents-session.test.ts
new file mode 100644
index 0000000..80ceae5
--- /dev/null
+++ b/packages/diagram-client/test/run-agents-session.test.ts
@@ -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>): 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();
+ });
+});
diff --git a/packages/extension-core/src/extension/chat/acp-connectors.ts b/packages/extension-core/src/extension/chat/acp-connectors.ts
index 5fa817c..6297d2d 100644
--- a/packages/extension-core/src/extension/chat/acp-connectors.ts
+++ b/packages/extension-core/src/extension/chat/acp-connectors.ts
@@ -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.
diff --git a/packages/extension-core/src/extension/chat/chat-runtime.ts b/packages/extension-core/src/extension/chat/chat-runtime.ts
index 2f69dc7..600dcfe 100644
--- a/packages/extension-core/src/extension/chat/chat-runtime.ts
+++ b/packages/extension-core/src/extension/chat/chat-runtime.ts
@@ -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,
@@ -49,6 +50,12 @@ export interface ChatRuntimeConfig {
* means opencode. A rejection is the connect error the panel shows.
*/
acpAgent?: (cwd: string) => Promise;
+ /**
+ * 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. */
@@ -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;
diff --git a/packages/extension-core/src/extension/profile-runtime.ts b/packages/extension-core/src/extension/profile-runtime.ts
index c4ad59d..c571080 100644
--- a/packages/extension-core/src/extension/profile-runtime.ts
+++ b/packages/extension-core/src/extension/profile-runtime.ts
@@ -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,
@@ -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
diff --git a/packages/extension-core/test/chat-runtime-native-session.test.ts b/packages/extension-core/test/chat-runtime-native-session.test.ts
index 680d153..e6d580f 100644
--- a/packages/extension-core/test/chat-runtime-native-session.test.ts
+++ b/packages/extension-core/test/chat-runtime-native-session.test.ts
@@ -9,7 +9,7 @@ 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: (_key: string, defaultValue?: T): T | undefined => defaultValue,
update: async (): Promise => undefined,
@@ -17,7 +17,7 @@ function makeRuntime(cwd: 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
);
@@ -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']);
+ });
+});