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
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
43 changes: 40 additions & 3 deletions src/acp/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -158,10 +159,17 @@ export class PiAcpAgent implements ACPAgent {
this.store.delete(sessionId)
}

private findStoredSession(sessionId: string): Pick<StoredSession, 'cwd' | 'sessionFile' | 'systemPrompt'> | null {
private findStoredSession(
sessionId: string
): Pick<StoredSession, 'cwd' | 'sessionFile' | 'systemPrompt' | 'sessionTitle'> | 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)
Expand Down Expand Up @@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -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: {
Expand All @@ -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}`)
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
11 changes: 10 additions & 1 deletion src/acp/session-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export type StoredSession = {
sessionFile: string
updatedAt: string
systemPrompt?: SystemPrompt
sessionTitle?: string
}

type SessionMapFile = {
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions src/acp/session-title.ts
Original file line number Diff line number Diff line change
@@ -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
}
3 changes: 2 additions & 1 deletion test/component/session-system-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
Expand All @@ -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)
Expand Down
94 changes: 94 additions & 0 deletions test/component/session-title.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof PiRpcProcess.spawn>[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))
})