diff --git a/README.md b/README.md index 75925f45..4e74811e 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Expect some minor breaking changes. - Session persistence - pi stores its own sessions in `~/.pi/agent/sessions/...` - `pi-acp` stores a small mapping file at `~/.pi/pi-acp/session-map.json` so `session/load` can reattach to a previous pi session file + - New sessions are named from the first successful text prompt unless pi or the user already assigned a name - Slash commands - Loads file-based slash commands compatible with pi’s conventions - Adds a small set of built-in commands for headless/editor usage diff --git a/src/acp/agent.ts b/src/acp/agent.ts index 381b04d8..b41b0269 100644 --- a/src/acp/agent.ts +++ b/src/acp/agent.ts @@ -40,7 +40,7 @@ import { bashTerminalOutputMeta, isBashTool } from './translate/bash.js' -import { promptToPiMessage } from './translate/prompt.js' +import { promptToPiMessage, promptToSessionTitle } from './translate/prompt.js' import { loadSlashCommands, parseCommandArgs, toAvailableCommands } from './slash-commands.js' import { getAgentDir, getEnableSkillCommands, getQuietStartup } from './pi-settings.js' import { toAvailableCommandsFromPiGetCommands } from './pi-commands.js' @@ -524,7 +524,7 @@ export class PiAcpAgent implements ACPAgent { } try { - await session.proc.setSessionName(name) + await session.setSessionName(name) } catch (e: any) { const msg = String(e?.message ?? e) const hint = /set_session_name/i.test(msg) @@ -541,15 +541,6 @@ export class PiAcpAgent implements ACPAgent { return { stopReason: 'end_turn' } } - await this.conn.sessionUpdate({ - sessionId: session.sessionId, - update: { - sessionUpdate: 'session_info_update', - title: name, - updatedAt: new Date().toISOString() - } - }) - await this.conn.sessionUpdate({ sessionId: session.sessionId, update: { @@ -882,7 +873,7 @@ export class PiAcpAgent implements ACPAgent { } } - const result = await session.prompt(message, images) + const result = await session.prompt(message, images, promptToSessionTitle(params.prompt) ?? undefined) // ACP StopReason does not include "error"; if pi fails we map to end_turn for now, // unless we know this was a cancellation. diff --git a/src/acp/session.ts b/src/acp/session.ts index d2fae685..b1a205d7 100644 --- a/src/acp/session.ts +++ b/src/acp/session.ts @@ -39,6 +39,7 @@ type SessionCreateParams = { export type StopReason = 'end_turn' | 'cancelled' | 'error' type PendingTurn = { + title?: string resolve: (reason: StopReason) => void reject: (err: unknown) => void } @@ -46,6 +47,7 @@ type PendingTurn = { type QueuedTurn = { message: string images: unknown[] + title?: string resolve: (reason: StopReason) => void reject: (err: unknown) => void } @@ -220,7 +222,8 @@ export class SessionManager { mcpServers: params.mcpServers, proc, conn: params.conn, - fileCommands: params.fileCommands ?? [] + fileCommands: params.fileCommands ?? [], + autoTitle: true }) this.sessions.set(sessionId, session) @@ -247,7 +250,8 @@ export class SessionManager { mcpServers: params.mcpServers, proc: params.proc, conn: params.conn, - fileCommands: params.fileCommands ?? [] + fileCommands: params.fileCommands ?? [], + autoTitle: false }) this.sessions.set(sessionId, session) @@ -262,6 +266,9 @@ export class PiAcpSession { private startupInfo: string | null = null private startupInfoSent = false + private initialTitlePending: boolean + private lastPublishedTitle: string | null | undefined + private titleUpdateQueue: Promise = Promise.resolve() readonly proc: PiRpcProcess private readonly conn: AgentSideConnection @@ -303,6 +310,7 @@ export class PiAcpSession { proc: PiRpcProcess conn: AgentSideConnection fileCommands?: FileSlashCommand[] + autoTitle?: boolean }) { this.sessionId = opts.sessionId this.cwd = opts.cwd @@ -310,6 +318,7 @@ export class PiAcpSession { this.proc = opts.proc this.conn = opts.conn this.fileCommands = opts.fileCommands ?? [] + this.initialTitlePending = opts.autoTitle ?? false this.proc.onEvent(ev => this.handlePiEvent(ev)) } @@ -334,12 +343,12 @@ export class PiAcpSession { }) } - async prompt(message: string, images: unknown[] = []): Promise { + async prompt(message: string, images: unknown[] = [], title?: string): Promise { // pi RPC mode disables slash command expansion, so we do it here. const expandedMessage = expandSlashCommand(message, this.fileCommands) const turnPromise = new Promise((resolve, reject) => { - const queued: QueuedTurn = { message: expandedMessage, images, resolve, reject } + const queued: QueuedTurn = { message: expandedMessage, images, title, resolve, reject } // If a turn is already running, enqueue. if (this.pendingTurn) { @@ -398,6 +407,51 @@ export class PiAcpSession { return this.cancelRequested } + async setSessionName(name: string): Promise { + return this.enqueueTitleUpdate(async () => { + await this.proc.setSessionName(name) + this.publishTitle(name) + }) + } + + private enqueueTitleUpdate(update: () => Promise): Promise { + const result = this.titleUpdateQueue.then(update) + this.titleUpdateQueue = result.catch(() => {}) + return result + } + + private publishTitle(title: string | null): void { + if (this.lastPublishedTitle === title) return + this.lastPublishedTitle = title + this.emit({ + sessionUpdate: 'session_info_update', + title, + updatedAt: new Date().toISOString() + }) + } + + private async maybeAutoTitle(title?: string): Promise { + if (!this.initialTitlePending || !title) return + this.initialTitlePending = false + + try { + await this.enqueueTitleUpdate(async () => { + const state = (await this.proc.getState()) as { sessionName?: unknown } | null + const currentTitle = typeof state?.sessionName === 'string' ? state.sessionName.trim() : '' + if (currentTitle) { + this.publishTitle(currentTitle) + return + } + if (this.lastPublishedTitle) return + + await this.proc.setSessionName(title) + this.publishTitle(title) + }) + } catch { + // Auto-titling is best-effort and must not fail the completed prompt. + } + } + private emit(update: SessionUpdate): void { // Serialize update delivery. this.lastEmit = this.lastEmit @@ -475,7 +529,7 @@ export class PiAcpSession { this.cancelRequested = false this.inAgentLoop = false - this.pendingTurn = { resolve: t.resolve, reject: t.reject } + this.pendingTurn = { title: t.title, resolve: t.resolve, reject: t.reject } // Publish queue depth (0 because we're starting the turn now). this.emit({ @@ -517,6 +571,12 @@ export class PiAcpSession { const type = String((ev as any).type ?? '') switch (type) { + case 'session_info_changed': { + const name = typeof (ev as { name?: unknown }).name === 'string' ? String((ev as { name: string }).name) : null + this.publishTitle(name) + break + } + case 'message_update': { const ame = (ev as any).assistantMessageEvent @@ -837,12 +897,19 @@ export class PiAcpSession { } case 'agent_settled': { - // Ensure all updates derived from pi events are delivered before we resolve - // the ACP `session/prompt` request. - void this.flushEmits().finally(() => { + const pendingTurn = this.pendingTurn + + // Ensure all updates derived from pi events and auto-titling are delivered + // before we resolve the ACP `session/prompt` request. + void this.flushEmits().then(async () => { const reason: StopReason = this.cancelRequested ? 'cancelled' : 'end_turn' - this.pendingTurn?.resolve(reason) - this.pendingTurn = null + if (reason === 'end_turn') { + await this.maybeAutoTitle(pendingTurn?.title) + await this.flushEmits() + } + + pendingTurn?.resolve(reason) + if (this.pendingTurn === pendingTurn) this.pendingTurn = null this.inAgentLoop = false // Start next queued prompt, if any. diff --git a/src/acp/translate/prompt.ts b/src/acp/translate/prompt.ts index 609146e7..cd304e73 100644 --- a/src/acp/translate/prompt.ts +++ b/src/acp/translate/prompt.ts @@ -6,6 +6,18 @@ export type PiImage = { data: string } +export function promptToSessionTitle(blocks: ContentBlock[]): string | null { + const normalized = blocks + .filter((block): block is Extract => block.type === 'text') + .map(block => block.text) + .join(' ') + .replace(/\s+/gu, ' ') + .trim() + + if (!normalized) return null + return Array.from(normalized).slice(0, 80).join('') +} + export function promptToPiMessage(blocks: ContentBlock[]): { message: string images: PiImage[] diff --git a/test/component/session-auto-title.test.ts b/test/component/session-auto-title.test.ts new file mode 100644 index 00000000..86176787 --- /dev/null +++ b/test/component/session-auto-title.test.ts @@ -0,0 +1,117 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { PiAcpSession } from '../../src/acp/session.js' +import { FakeAgentSideConnection, FakePiRpcProcess, asAgentConn } from '../helpers/fakes.js' + +function createSession( + conn: FakeAgentSideConnection, + proc: FakePiRpcProcess, + options: { autoTitle?: boolean } = { autoTitle: true } +): PiAcpSession { + return new PiAcpSession({ + sessionId: 's1', + cwd: process.cwd(), + mcpServers: [], + proc: proc as any, + conn: asAgentConn(conn), + fileCommands: [], + autoTitle: options.autoTitle + }) +} + +async function settlePrompt(proc: FakePiRpcProcess, prompt: Promise): Promise { + proc.emit({ type: 'agent_start' }) + proc.emit({ type: 'turn_end' }) + proc.emit({ type: 'agent_end' }) + proc.emit({ type: 'agent_settled' }) + return prompt +} + +test('PiAcpSession: names a new session after its first successful prompt', async () => { + const conn = new FakeAgentSideConnection() + const proc = new FakePiRpcProcess() as any + const assignedNames: string[] = [] + proc.getState = async () => ({}) + proc.setSessionName = async (name: string) => { + assignedNames.push(name) + } + const session = createSession(conn, proc) + + const reason = await settlePrompt(proc, session.prompt('hello', [], 'Fix thread titles')) + + assert.equal(reason, 'end_turn') + assert.deepEqual(assignedNames, ['Fix thread titles']) + const titleUpdates = conn.updates.filter(update => (update.update as any).title !== undefined) + assert.equal(titleUpdates.length, 1) + assert.equal((titleUpdates[0]!.update as any).title, 'Fix thread titles') + assert.equal(typeof (titleUpdates[0]!.update as any).updatedAt, 'string') + + await settlePrompt(proc, session.prompt('second prompt', [], 'Second title')) + assert.deepEqual(assignedNames, ['Fix thread titles']) +}) + +test('PiAcpSession: preserves an existing session name', async () => { + const conn = new FakeAgentSideConnection() + const proc = new FakePiRpcProcess() as any + let setNameCalls = 0 + proc.getState = async () => ({ sessionName: 'Manual title' }) + proc.setSessionName = async () => { + setNameCalls += 1 + } + const session = createSession(conn, proc) + + const reason = await settlePrompt(proc, session.prompt('hello', [], 'Automatic title')) + + assert.equal(reason, 'end_turn') + assert.equal(setNameCalls, 0) + const titleUpdates = conn.updates.filter(update => (update.update as any).title !== undefined) + assert.equal((titleUpdates.at(-1)!.update as any).title, 'Manual title') +}) + +test('PiAcpSession: does not overwrite a manual name assigned during the first prompt', async () => { + const conn = new FakeAgentSideConnection() + const proc = new FakePiRpcProcess() as any + const assignedNames: string[] = [] + proc.getState = async () => ({}) + proc.setSessionName = async (name: string) => { + assignedNames.push(name) + } + const session = createSession(conn, proc) + + const prompt = session.prompt('hello', [], 'Automatic title') + await session.setSessionName('Manual title') + const reason = await settlePrompt(proc, prompt) + + assert.equal(reason, 'end_turn') + assert.deepEqual(assignedNames, ['Manual title']) + const titleUpdates = conn.updates.filter(update => (update.update as any).title !== undefined) + assert.equal((titleUpdates.at(-1)!.update as any).title, 'Manual title') +}) + +test('PiAcpSession: auto-title failures do not fail the prompt', async () => { + const conn = new FakeAgentSideConnection() + const proc = new FakePiRpcProcess() as any + proc.getState = async () => ({}) + proc.setSessionName = async () => { + throw new Error('set_session_name failed') + } + const session = createSession(conn, proc) + + const reason = await settlePrompt(proc, session.prompt('hello', [], 'Automatic title')) + + assert.equal(reason, 'end_turn') + const titleUpdates = conn.updates.filter(update => (update.update as any).title !== undefined) + assert.equal(titleUpdates.length, 0) +}) + +test('PiAcpSession: forwards Pi session name changes to ACP', async () => { + const conn = new FakeAgentSideConnection() + const proc = new FakePiRpcProcess() + createSession(conn, proc, { autoTitle: false }) + + proc.emit({ type: 'session_info_changed', name: 'Extension title' }) + await new Promise(resolve => setTimeout(resolve, 0)) + + const titleUpdate = conn.updates.find(update => (update.update as any).title !== undefined) + assert.equal((titleUpdate!.update as any).title, 'Extension title') +}) diff --git a/test/unit/builtin-commands.test.ts b/test/unit/builtin-commands.test.ts index 819ebc7b..18ee9b56 100644 --- a/test/unit/builtin-commands.test.ts +++ b/test/unit/builtin-commands.test.ts @@ -42,7 +42,18 @@ test('PiAcpAgent: /name sets session display name adapter-side', async () => { } const agent = new PiAcpAgent(asAgentConn(conn)) - ;(agent as any).sessions = new FakeSessions({ sessionId: 's1', proc, fileCommands: [] }) as any + ;(agent as any).sessions = new FakeSessions({ + sessionId: 's1', + proc, + fileCommands: [], + async setSessionName(name: string) { + await proc.setSessionName(name) + await conn.sessionUpdate({ + sessionId: 's1', + update: { sessionUpdate: 'session_info_update', title: name } + } as any) + } + }) as any const res = await agent.prompt({ sessionId: 's1', diff --git a/test/unit/prompt-to-pi-message.test.ts b/test/unit/prompt-to-pi-message.test.ts index ba6ecffe..2113f73d 100644 --- a/test/unit/prompt-to-pi-message.test.ts +++ b/test/unit/prompt-to-pi-message.test.ts @@ -1,6 +1,6 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { promptToPiMessage } from '../../src/acp/translate/prompt.js' +import { promptToPiMessage, promptToSessionTitle } from '../../src/acp/translate/prompt.js' test('promptToPiMessage: concatenates text and resource links', () => { const { message, images } = promptToPiMessage([ @@ -13,6 +13,29 @@ test('promptToPiMessage: concatenates text and resource links', () => { assert.deepEqual(images, []) }) +test('promptToSessionTitle: normalizes text blocks and ignores non-text context', () => { + const title = promptToSessionTitle([ + { type: 'text', text: ' Fix\n\tthe ' }, + { type: 'resource_link', uri: 'file:///tmp/foo.txt', name: 'foo' }, + { type: 'text', text: ' thread title ' } + ]) + + assert.equal(title, 'Fix the thread title') +}) + +test('promptToSessionTitle: limits titles to 80 Unicode characters', () => { + const title = promptToSessionTitle([{ type: 'text', text: ` ${'😀'.repeat(81)} ` }]) + + assert.equal(Array.from(title ?? '').length, 80) + assert.equal(title, '😀'.repeat(80)) +}) + +test('promptToSessionTitle: returns null without text', () => { + const title = promptToSessionTitle([{ type: 'resource_link', uri: 'file:///tmp/foo.txt', name: 'foo' }]) + + assert.equal(title, null) +}) + test('promptToPiMessage: includes embedded resource text as marker', () => { const { message, images } = promptToPiMessage([ {