diff --git a/cli/src/pi/extensionUiHandler.test.ts b/cli/src/pi/extensionUiHandler.test.ts index e475347ea7..eeba886067 100644 --- a/cli/src/pi/extensionUiHandler.test.ts +++ b/cli/src/pi/extensionUiHandler.test.ts @@ -6,6 +6,7 @@ type PermissionHandler = (response: unknown) => Promise; function createHarness() { let permissionHandler: PermissionHandler | null = null; let state: Record = { requests: {}, completedRequests: {} }; + let capturedUpdater: ((metadata: Record | null) => Record) | null = null; const session = { rpcHandlerManager: { registerHandler: vi.fn((_method: unknown, handler: PermissionHandler) => { permissionHandler = handler; }), @@ -13,8 +14,11 @@ function createHarness() { updateAgentState: vi.fn((updater: (current: never) => unknown) => { state = updater(state as never) as Record; }), sendAgentMessage: vi.fn(), sendSessionEvent: vi.fn(), - getMetadata: vi.fn(() => null), - updateMetadata: vi.fn(), + getMetadata: vi.fn((): Record | null => null), + updateMetadata: vi.fn((updater: (metadata: Record | null) => Record) => { + capturedUpdater = updater; + updater(null); + }), }; const sendResponse = vi.fn(); const handler = new PiExtensionUiHandler({ session: session as never, sendResponse }); @@ -23,6 +27,7 @@ function createHarness() { session, sendResponse, state: () => state, + lastUpdater: () => capturedUpdater, respond: async (response: unknown) => permissionHandler?.(response), }; } @@ -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' }, + }); + }); }); diff --git a/hub/src/web/routes/piSessions.test.ts b/hub/src/web/routes/piSessions.test.ts index 2d3561341a..8abd793e0e 100644 --- a/hub/src/web/routes/piSessions.test.ts +++ b/hub/src/web/routes/piSessions.test.ts @@ -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 + // 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 `; 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 + 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 + 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), summary: { text: 'Agent title', updatedAt: 2_000 } }, + stored.metadataVersion, + 'default' + ) + + const after = store.sessions.getSession(sessionId)!.metadata as Record + // 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)]) diff --git a/hub/src/web/routes/piSessions.ts b/hub/src/web/routes/piSessions.ts index a622f5e611..ff95eb81aa 100644 --- a/hub/src/web/routes/piSessions.ts +++ b/hub/src/web/routes/piSessions.ts @@ -78,7 +78,7 @@ function buildPiMetadata( existing: Record, state: NonNullable ): Metadata { - const summaryText = transcript.lastUserMessage ?? transcript.title + const summaryText = transcript.title ?? transcript.lastUserMessage const entryIds = asRecord(existing.conversationHistoryEntryIds) ?? {} const points = asRecord(existing.conversationHistoryPoints) ?? {} return { @@ -86,7 +86,7 @@ function buildPiMetadata( 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',