From 637f09f47edafdd87f57fdcb47a057f85286482e Mon Sep 17 00:00:00 2001 From: Salman Mohammed Date: Tue, 8 Sep 2026 21:36:51 -0400 Subject: [PATCH] feat: title sessions from client metadata --- README.md | 29 ++++++ src/acp/agent.ts | 43 ++++++++- src/acp/session-store.ts | 11 ++- src/acp/session-title.ts | 6 ++ test/component/session-system-prompt.test.ts | 3 +- test/component/session-title.test.ts | 94 ++++++++++++++++++++ 6 files changed, 181 insertions(+), 5 deletions(-) create mode 100644 src/acp/session-title.ts create mode 100644 test/component/session-title.test.ts diff --git a/README.md b/README.md index b5eaeb25..03c12c04 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,35 @@ extensions may modify the resulting prompt. Replacement overrides Pi's default base or discovered `SYSTEM.md`; explicit append follows Pi's CLI precedence and supersedes automatic `APPEND_SYSTEM.md` discovery. It does not replace the base. +### Client session titles + +Clients can name a new session with `session/new.params._meta.sessionTitle`: + +```json +{ + "cwd": "/absolute/workspace", + "mcpServers": [], + "_meta": { + "sessionTitle": "Fix the login bug", + "systemPrompt": { "append": "Explain your changes concisely." } + } +} +``` + +The adapter collapses whitespace and trims the title. Titles longer than 256 +characters are shortened to 255 characters plus `…`. Missing, non-string, and +blank values are ignored. Support is advertised through +`agentCapabilities._meta.piAcp.sessionTitle: true`. + +The title is applied through Pi's `set_session_name` RPC before `session/new` +returns, then announced through `session_info_update`. If naming fails, session +creation fails and the new session is cleaned up. Pi saves titles in its +transcript. Because Pi defers creating that file until a response is saved, the +adapter also keeps the initial title in its session map and reapplies it when +restoring a session whose transcript does not yet exist. Existing transcripts +retain their current name, including later renames. `session/load` does +not apply `_meta.sessionTitle`; use `/name` to rename an existing session. + ### Slash commands `pi-acp` supports slash commands: diff --git a/src/acp/agent.ts b/src/acp/agent.ts index d04f7025..2e566f21 100644 --- a/src/acp/agent.ts +++ b/src/acp/agent.ts @@ -27,6 +27,7 @@ import { getAuthMethods } from './auth.js' import { SessionManager, type PiAcpSession } from './session.js' import { SessionStore, type StoredSession } from './session-store.js' import { parseSystemPrompt } from './system-prompt.js' +import { sanitizeSessionTitle } from './session-title.js' import { PiRpcProcess } from '../pi-rpc/process.js' import { listPiSessions, findPiSession } from './pi-sessions.js' import { normalizePiAssistantText, normalizePiMessageText } from './translate/pi-messages.js' @@ -158,10 +159,17 @@ export class PiAcpAgent implements ACPAgent { this.store.delete(sessionId) } - private findStoredSession(sessionId: string): Pick | null { + private findStoredSession( + sessionId: string + ): Pick | null { const stored = this.store.get(sessionId) if (stored?.cwd && stored?.sessionFile) { - return { cwd: stored.cwd, sessionFile: stored.sessionFile, systemPrompt: stored.systemPrompt } + return { + cwd: stored.cwd, + sessionFile: stored.sessionFile, + systemPrompt: stored.systemPrompt, + sessionTitle: stored.sessionTitle + } } const piSession = findPiSession(sessionId) @@ -196,6 +204,7 @@ export class PiAcpAgent implements ACPAgent { } const cwd = opts?.cwd ?? stored.cwd + const restoreUnflushedTitle = stored.sessionTitle && !existsSync(stored.sessionFile) let proc: PiRpcProcess try { @@ -212,6 +221,15 @@ export class PiAcpAgent implements ACPAgent { throw e } + if (restoreUnflushedTitle) { + try { + await proc.setSessionName(stored.sessionTitle!) + } catch (error) { + proc.dispose() + throw error + } + } + const fileCommands = loadSlashCommands(cwd) const session = this.sessions.getOrCreate(sessionId, { cwd, @@ -254,7 +272,7 @@ export class PiAcpAgent implements ACPAgent { supportsTerminalAuthMeta: (params as any)?.clientCapabilities?._meta?.['terminal-auth'] === true }), agentCapabilities: { - _meta: { piAcp: { systemPrompt: { replace: true, append: true, persisted: true } } }, + _meta: { piAcp: { systemPrompt: { replace: true, append: true, persisted: true }, sessionTitle: true } }, loadSession: true, mcpCapabilities: { http: false, sse: false }, promptCapabilities: { @@ -274,6 +292,7 @@ export class PiAcpAgent implements ACPAgent { async newSession(params: NewSessionRequest) { const systemPrompt = parseSystemPrompt(params._meta?.systemPrompt) + const sessionTitle = sanitizeSessionTitle(params._meta?.sessionTitle) if (!isAbsolute(params.cwd)) { throw RequestError.invalidParams(`cwd must be an absolute path: ${params.cwd}`) } @@ -351,6 +370,18 @@ export class PiAcpAgent implements ACPAgent { ) } + if (sessionTitle) { + try { + await session.proc.setSessionName(sessionTitle) + const stored = this.store.get(session.sessionId) + if (!stored) throw new Error('Cannot persist session title: session record is missing') + this.store.upsert({ ...stored, sessionTitle }) + } catch (error) { + this.cleanupFailedNewSession(session.sessionId, state) + throw RequestError.internalError({}, `Failed to set session title: ${String(error)}`) + } + } + const { configOptions, models, modes } = await getSessionConfiguration(session.proc, { state, availableModels @@ -403,6 +434,12 @@ export class PiAcpAgent implements ACPAgent { setTimeout(() => { void (async () => { try { + if (sessionTitle) { + await this.conn.sessionUpdate({ + sessionId: session.sessionId, + update: { sessionUpdate: 'session_info_update', title: sessionTitle } + }) + } const pi = (await session.proc.getCommands()) as any const { commands } = toAvailableCommandsFromPiGetCommands(pi, { enableSkillCommands, diff --git a/src/acp/session-store.ts b/src/acp/session-store.ts index cd556083..cac495c2 100644 --- a/src/acp/session-store.ts +++ b/src/acp/session-store.ts @@ -9,6 +9,7 @@ export type StoredSession = { sessionFile: string updatedAt: string systemPrompt?: SystemPrompt + sessionTitle?: string } type SessionMapFile = { @@ -51,14 +52,22 @@ export class SessionStore { return db.sessions[sessionId] ?? null } - upsert(entry: { sessionId: string; cwd: string; sessionFile: string; systemPrompt?: SystemPrompt }): void { + upsert(entry: { + sessionId: string + cwd: string + sessionFile: string + systemPrompt?: SystemPrompt + sessionTitle?: string + }): void { const db = loadFile(this.path) const systemPrompt = entry.systemPrompt ?? db.sessions[entry.sessionId]?.systemPrompt + const sessionTitle = entry.sessionTitle ?? db.sessions[entry.sessionId]?.sessionTitle db.sessions[entry.sessionId] = { sessionId: entry.sessionId, cwd: entry.cwd, sessionFile: entry.sessionFile, ...(systemPrompt ? { systemPrompt } : {}), + ...(sessionTitle ? { sessionTitle } : {}), updatedAt: new Date().toISOString() } saveFile(this.path, db) diff --git a/src/acp/session-title.ts b/src/acp/session-title.ts new file mode 100644 index 00000000..21a7b8c6 --- /dev/null +++ b/src/acp/session-title.ts @@ -0,0 +1,6 @@ +export function sanitizeSessionTitle(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined + const title = value.replace(/\s+/g, ' ').trim() + if (!title) return undefined + return title.length > 256 ? title.slice(0, 255) + '…' : title +} diff --git a/test/component/session-system-prompt.test.ts b/test/component/session-system-prompt.test.ts index 452929e5..f7f4e00e 100644 --- a/test/component/session-system-prompt.test.ts +++ b/test/component/session-system-prompt.test.ts @@ -27,6 +27,7 @@ test('client prompts survive A/B switching, explicit load, and adapter restart', disposed++ } async setThinkingLevel() {} + async setSessionName() {} } return new Process() as unknown as PiRpcProcess }) @@ -41,7 +42,7 @@ test('client prompts survive A/B switching, explicit load, and adapter restart', const agent = createAgent() const initialized = await agent.initialize({ protocolVersion: 1, clientCapabilities: {} }) assert.deepEqual(initialized.agentCapabilities?._meta, { - piAcp: { systemPrompt: { replace: true, append: true, persisted: true } } + piAcp: { systemPrompt: { replace: true, append: true, persisted: true }, sessionTitle: true } }) await assert.rejects(agent.newSession({ cwd: root, mcpServers: [], _meta: { systemPrompt: null } }), { code: -32602 }) assert.equal(calls.length, 0) diff --git a/test/component/session-title.test.ts b/test/component/session-title.test.ts new file mode 100644 index 00000000..ba780367 --- /dev/null +++ b/test/component/session-title.test.ts @@ -0,0 +1,94 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { PiAcpAgent } from '../../src/acp/agent.js' +import { SessionManager } from '../../src/acp/session.js' +import { SessionStore } from '../../src/acp/session-store.js' +import { sanitizeSessionTitle } from '../../src/acp/session-title.js' +import { PiRpcProcess } from '../../src/pi-rpc/process.js' +import { asAgentConn, FakeAgentSideConnection, FakePiRpcProcess } from '../helpers/fakes.js' + +test('session titles follow the whitespace and 256-character contract', () => { + assert.equal(sanitizeSessionTitle(' Fix\nthe login\tbug '), 'Fix the login bug') + assert.equal(sanitizeSessionTitle('x'.repeat(256)), 'x'.repeat(256)) + assert.equal(sanitizeSessionTitle('x'.repeat(300)), 'x'.repeat(255) + '…') + for (const value of [undefined, null, 42, {}, [], true, '', ' \n\t ']) { + assert.equal(sanitizeSessionTitle(value), undefined) + } +}) + +test('new session titles are applied, announced, restored before flush, and do not override persisted names', async t => { + const root = mkdtempSync(join(tmpdir(), 'pi-acp-title-test-')) + const store = new SessionStore(join(root, 'map.json')) + const conn = new FakeAgentSideConnection() + const agent = new PiAcpAgent(asAgentConn(conn)) + const internals = agent as unknown as { store: SessionStore; sessions: SessionManager } + internals.store = store + ;(internals.sessions as unknown as { store: SessionStore }).store = store + t.after(() => { + agent.dispose() + rmSync(root, { recursive: true, force: true }) + }) + let counter = 0 + let failNaming = false + let disposed = 0 + const names: string[] = [] + t.mock.method(PiRpcProcess, 'spawn', async (params: Parameters[0]) => { + const id = params.sessionPath ? '1' : String(++counter) + class Process extends FakePiRpcProcess { + async getState() { + return { sessionId: id, sessionFile: join(root, `${id}.jsonl`), thinkingLevel: 'medium' } + } + async setSessionName(name: string) { + if (failNaming) throw new Error('naming failed') + names.push(name) + } + async setThinkingLevel() {} + dispose() { + disposed++ + } + } + return new Process() as unknown as PiRpcProcess + }) + const a = await agent.newSession({ + cwd: root, + mcpServers: [], + _meta: { + sessionTitle: ' Fix\nthe login bug ', + systemPrompt: { append: 'instructions' } + } + }) + assert.deepEqual(names, ['Fix the login bug']) + assert.equal(store.get(a.sessionId)?.sessionTitle, 'Fix the login bug') + assert.deepEqual(store.get(a.sessionId)?.systemPrompt, { mode: 'append', text: 'instructions' }) + await new Promise(resolve => setTimeout(resolve, 10)) + assert.ok( + conn.updates.some( + ({ update }) => update.sessionUpdate === 'session_info_update' && update.title === 'Fix the login bug' + ) + ) + for (const value of [undefined, null, 42, {}, '', ' ']) { + await agent.newSession({ cwd: root, mcpServers: [], _meta: { sessionTitle: value } }) + } + assert.equal(names.length, 1) + await agent.setSessionMode({ sessionId: a.sessionId, modeId: 'medium' }) + assert.deepEqual(names, ['Fix the login bug', 'Fix the login bug']) + writeFileSync(join(root, '1.jsonl'), JSON.stringify({ type: 'session_info', name: 'Later name' }) + '\n') + await agent.loadSession({ + cwd: root, + mcpServers: [], + sessionId: a.sessionId, + _meta: { sessionTitle: 'Ignored on load' } + }) + assert.equal(names.length, 2) + failNaming = true + const before = disposed + await assert.rejects(agent.newSession({ cwd: root, mcpServers: [], _meta: { sessionTitle: 'Fail' } }), { + code: -32603 + }) + assert.equal(store.get(String(counter)), null) + assert.equal(disposed, before + 1) + await new Promise(resolve => setTimeout(resolve, 10)) +})