From 04613a5314cc629193cb0b5846d3cca85ed74bbd Mon Sep 17 00:00:00 2001 From: Endri Bezati Date: Wed, 14 Oct 2026 10:48:29 +0200 Subject: [PATCH] run agents: open a turn's session in the agent's own CLI, or copy its id A runtime may report on a firing's `agent.message.end` the session it talked to: `session: {id, connector, cwd}`. The run-agent transcript keeps it on that firing's turn, and the turn's header offers the same two buttons as the chat's session: open it in the agent's CLI (a terminal in the session's directory) and copy its id. The host resolves the named connector's `resume` template as the workspace's connectors files declare it (`connectorResume`, from the profile's `acpConnector`), falling back to the known connectors' without them. No connector named -- an explicit command line -- and the id is copied. Claude-Session: https://claude.ai/code/session_01N33eMqpCzhDs8HDNez3Yja --- .../src/chat-panel-integrated.ts | 24 +++++++++- packages/diagram-client/src/chat-panel.css | 10 +++++ .../src/editing-action-handlers.ts | 39 +++++++++++++++- .../test/run-agents-session.test.ts | 45 +++++++++++++++++++ .../src/extension/chat/acp-connectors.ts | 15 +++++++ .../src/extension/chat/chat-runtime.ts | 29 ++++++++++++ .../src/extension/profile-runtime.ts | 5 ++- .../test/chat-runtime-native-session.test.ts | 41 ++++++++++++++++- 8 files changed, 201 insertions(+), 7 deletions(-) create mode 100644 packages/diagram-client/test/run-agents-session.test.ts 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']); + }); +});