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
30 changes: 28 additions & 2 deletions cli/src/pi/extensionUiHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,19 @@ type PermissionHandler = (response: unknown) => Promise<void>;
function createHarness() {
let permissionHandler: PermissionHandler | null = null;
let state: Record<string, unknown> = { requests: {}, completedRequests: {} };
let capturedUpdater: ((metadata: Record<string, unknown> | null) => Record<string, unknown>) | null = null;
const session = {
rpcHandlerManager: {
registerHandler: vi.fn((_method: unknown, handler: PermissionHandler) => { permissionHandler = handler; }),
},
updateAgentState: vi.fn((updater: (current: never) => unknown) => { state = updater(state as never) as Record<string, unknown>; }),
sendAgentMessage: vi.fn(),
sendSessionEvent: vi.fn(),
getMetadata: vi.fn(() => null),
updateMetadata: vi.fn(),
getMetadata: vi.fn((): Record<string, unknown> | null => null),
updateMetadata: vi.fn((updater: (metadata: Record<string, unknown> | null) => Record<string, unknown>) => {
capturedUpdater = updater;
updater(null);
}),
};
const sendResponse = vi.fn();
const handler = new PiExtensionUiHandler({ session: session as never, sendResponse });
Expand All @@ -23,6 +27,7 @@ function createHarness() {
session,
sendResponse,
state: () => state,
lastUpdater: () => capturedUpdater,
respond: async (response: unknown) => permissionHandler?.(response),
};
}
Expand Down Expand Up @@ -185,6 +190,27 @@ describe('PiExtensionUiHandler', () => {
harness.handler.handle({ type: 'extension_ui_request', id: 'status', method: 'setStatus', statusKey: 'x', statusText: 'busy' });
expect(harness.session.sendSessionEvent).toHaveBeenCalledWith({ type: 'message', message: '[Pi warning] Heads up' });
});

it('setTitle keeps a manual metadata.name and updates the summary fallback', () => {
const harness = createHarness();
// A session renamed through the web holds a manual metadata.name; the
// extension title must go to the summary slot so the manual name survives
// (hub/src/sync/sessionCache.ts: "A manually chosen name must continue to win").
harness.session.getMetadata.mockReturnValue({ path: '/workspace', host: 'h', name: 'My custom HAPI title' });
harness.handler.handle({
type: 'extension_ui_request', id: 'title-1', method: 'setTitle', title: 'Agent auto title',
});

expect(harness.session.updateMetadata).toHaveBeenCalledTimes(1);
const updater = harness.lastUpdater()!;
const next = updater({ path: '/workspace', host: 'h', name: 'My custom HAPI title' });
expect(next).toMatchObject({
// Manual name survives unchanged.
name: 'My custom HAPI title',
// Agent title lands in the generated/summary fallback slot.
summary: { text: 'Agent auto title' },
});
});
});


Expand Down
58 changes: 58 additions & 0 deletions hub/src/web/routes/piSessions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,64 @@ describe('Pi session import', () => {
expect(store.sessions.getSessionsByNamespace('default')).toHaveLength(2)
})

it('keeps an imported transcript title out of metadata.name so auto titles display', () => {
const { store, engine } = setup()
const source = transcript('imported-title', [userMessage('imported-title', 'entry-1', null, 'First user message', 1_000)])
const result = importPiSession({ store, engine, namespace: 'default', machine: machine('machine-1'), transcript: source })

const metadata = store.sessions.getSession(result.hapiSessionId!)!.metadata as Record<string, unknown>
// The transcript title no longer claims the manual-name slot.
expect(metadata.name).toBeUndefined()
// It lands in the summary fallback, keeping the native title visible.
expect(metadata.summary).toMatchObject({ text: 'Session imported-title' })
})

it('keeps a native session title in summary over the last user message', () => {
const { store, engine } = setup()
// transcript(): title is `Session <id>`; include a distinct last user message.
const source = transcript('login-fix', [userMessage('login-fix', 'entry-1', null, 'thanks', 1_000)])
const result = importPiSession({ store, engine, namespace: 'default', machine: machine('machine-1'), transcript: source })

const metadata = store.sessions.getSession(result.hapiSessionId!)!.metadata as Record<string, unknown>
expect(metadata.name).toBeUndefined()
// The native title wins over the last user message for display.
expect(metadata.summary).toMatchObject({ text: 'Session login-fix' })
})

it('falls back to the transcript title in summary when a session has no user message', () => {
const { store, engine } = setup()
const entry = toolResultMessage('imported-title-only-tools', 'entry-1', null, 'tool ping', 1_000)
const source = transcript('imported-title-only-tools', [entry])
const result = importPiSession({ store, engine, namespace: 'default', machine: machine('machine-1'), transcript: source })

const metadata = store.sessions.getSession(result.hapiSessionId!)!.metadata as Record<string, unknown>
expect(metadata.name).toBeUndefined()
expect(metadata.summary).toMatchObject({ text: 'Session imported-title-only-tools' })
})

it('keeps the manual-name slot free when a later extension-style summary arrives', () => {
const { store, engine } = setup()
const source = transcript('combo', [userMessage('combo', 'entry-1', null, 'thanks', 1_000)])
const first = importPiSession({ store, engine, namespace: 'default', machine: machine('machine-1'), transcript: source })
const sessionId = first.hapiSessionId!

// The Pi extension's setTitle syncs metadata.summary.text only (and does
// not touch name); simulate that write landing through the hub merge.
const stored = store.sessions.getSession(sessionId)!
store.sessions.updateSessionMetadata(
sessionId,
{ ...(stored.metadata as Record<string, unknown>), summary: { text: 'Agent title', updatedAt: 2_000 } },
stored.metadataVersion,
'default'
)

const after = store.sessions.getSession(sessionId)!.metadata as Record<string, unknown>
// The manual-name slot stays empty so the web title helper still prefers
// the agent title from summary over the import fallback.
expect(after.name).toBeUndefined()
expect(after.summary).toMatchObject({ text: 'Agent title' })
})

it('preserves a custom HAPI session name during later Pi reconciliation', () => {
const { store, engine } = setup()
const source = transcript('native-renamed', [userMessage('native-renamed', 'entry-1', null, 'one', 1_000)])
Expand Down
4 changes: 2 additions & 2 deletions hub/src/web/routes/piSessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,15 +78,15 @@ function buildPiMetadata(
existing: Record<string, unknown>,
state: NonNullable<Metadata['piImportState']>
): Metadata {
const summaryText = transcript.lastUserMessage ?? transcript.title
const summaryText = transcript.title ?? transcript.lastUserMessage
const entryIds = asRecord(existing.conversationHistoryEntryIds) ?? {}
const points = asRecord(existing.conversationHistoryPoints) ?? {}
return {
...existing,
path: transcript.cwd ?? (typeof existing.path === 'string' ? existing.path : dirname(transcript.file)),
host: typeof existing.host === 'string' ? existing.host : (machine.metadata?.host ?? machine.id),
os: typeof existing.os === 'string' ? existing.os : (machine.metadata?.platform ?? process.platform),
name: typeof existing.name === 'string' ? existing.name : transcript.title,
name: typeof existing.name === 'string' ? existing.name : undefined,
summary: summaryText ? { text: summaryText, updatedAt: Date.now() } : undefined,
machineId: machine.id,
flavor: 'pi',
Expand Down
Loading