Skip to content
Open
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 3 additions & 12 deletions src/acp/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand All @@ -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: {
Expand Down Expand Up @@ -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.
Expand Down
87 changes: 77 additions & 10 deletions src/acp/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,15 @@ type SessionCreateParams = {
export type StopReason = 'end_turn' | 'cancelled' | 'error'

type PendingTurn = {
title?: string
resolve: (reason: StopReason) => void
reject: (err: unknown) => void
}

type QueuedTurn = {
message: string
images: unknown[]
title?: string
resolve: (reason: StopReason) => void
reject: (err: unknown) => void
}
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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<void> = Promise.resolve()

readonly proc: PiRpcProcess
private readonly conn: AgentSideConnection
Expand Down Expand Up @@ -303,13 +310,15 @@ export class PiAcpSession {
proc: PiRpcProcess
conn: AgentSideConnection
fileCommands?: FileSlashCommand[]
autoTitle?: boolean
}) {
this.sessionId = opts.sessionId
this.cwd = opts.cwd
this.mcpServers = opts.mcpServers
this.proc = opts.proc
this.conn = opts.conn
this.fileCommands = opts.fileCommands ?? []
this.initialTitlePending = opts.autoTitle ?? false

this.proc.onEvent(ev => this.handlePiEvent(ev))
}
Expand All @@ -334,12 +343,12 @@ export class PiAcpSession {
})
}

async prompt(message: string, images: unknown[] = []): Promise<StopReason> {
async prompt(message: string, images: unknown[] = [], title?: string): Promise<StopReason> {
// pi RPC mode disables slash command expansion, so we do it here.
const expandedMessage = expandSlashCommand(message, this.fileCommands)

const turnPromise = new Promise<StopReason>((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) {
Expand Down Expand Up @@ -398,6 +407,51 @@ export class PiAcpSession {
return this.cancelRequested
}

async setSessionName(name: string): Promise<void> {
return this.enqueueTitleUpdate(async () => {
await this.proc.setSessionName(name)
this.publishTitle(name)
})
}

private enqueueTitleUpdate(update: () => Promise<void>): Promise<void> {
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<void> {
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
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions src/acp/translate/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@ export type PiImage = {
data: string
}

export function promptToSessionTitle(blocks: ContentBlock[]): string | null {
const normalized = blocks
.filter((block): block is Extract<ContentBlock, { type: 'text' }> => 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[]
Expand Down
117 changes: 117 additions & 0 deletions test/component/session-auto-title.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>): Promise<string> {
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')
})
13 changes: 12 additions & 1 deletion test/unit/builtin-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading