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
56 changes: 53 additions & 3 deletions cli/src/agent/acpSessionTitle.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,26 @@
import { describe, expect, it, vi } from 'vitest';
import { registerAcpSessionTitleSync } from './acpSessionTitle';
import type { Metadata } from '../api/types';
import { createAcpSessionTitleSync, registerAcpSessionTitleSync } from './acpSessionTitle';
import type { AcpSessionInfoUpdate } from './backends/acp/AcpSdkBackend';

type TitleClient = Parameters<typeof createAcpSessionTitleSync>[0];

function makeClient(metadata: Partial<Metadata> = {}) {
const state = { ...metadata } as Metadata;
const sendClaudeSessionMessage = vi.fn();
return {
client: {
getMetadata: () => ({ ...state }),
updateMetadata: (handler: (metadata: Metadata) => Metadata) => {
Object.assign(state, handler(state));
},
sendClaudeSessionMessage
} satisfies TitleClient,
sendClaudeSessionMessage,
state
};
}

describe('registerAcpSessionTitleSync', () => {
it('forwards normalized unique ACP titles as HAPI summaries', () => {
let listener: ((update: AcpSessionInfoUpdate) => void) | null = null;
Expand All @@ -10,9 +29,9 @@ describe('registerAcpSessionTitleSync', () => {
listener = next;
}
};
const sendClaudeSessionMessage = vi.fn();
const { client, sendClaudeSessionMessage } = makeClient();

registerAcpSessionTitleSync(backend, { sendClaudeSessionMessage });
registerAcpSessionTitleSync(backend, client);

listener!({ sessionId: 'session-1', title: ' Native Cursor Title ' });
listener!({ sessionId: 'session-1', title: 'Native Cursor Title' });
Expand All @@ -29,4 +48,35 @@ describe('registerAcpSessionTitleSync', () => {
leafUuid: expect.any(String)
});
});

it('stops syncing native titles after a manual title is set', () => {
const { client, sendClaudeSessionMessage } = makeClient();
const controller = createAcpSessionTitleSync(client);

controller.syncNativeTitle('Native Title');
controller.markManualTitle();
controller.syncNativeTitle('Newer Native Title');

expect(sendClaudeSessionMessage).toHaveBeenCalledTimes(1);
expect(sendClaudeSessionMessage).toHaveBeenCalledWith({
type: 'summary',
summary: 'Native Title',
leafUuid: expect.any(String)
});
});

it('persists manual precedence in metadata and honors it after controller recreation', () => {
const first = makeClient();
const firstController = createAcpSessionTitleSync(first.client);
firstController.syncNativeTitle('Native Title');
firstController.markManualTitle();

expect(first.state.acpManualTitle).toBe(true);

const second = makeClient(first.state);
const secondController = createAcpSessionTitleSync(second.client);
secondController.syncNativeTitle('Newer Native Title');

expect(second.sendClaudeSessionMessage).not.toHaveBeenCalled();
});
});
57 changes: 42 additions & 15 deletions cli/src/agent/acpSessionTitle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,34 +4,61 @@ import type { AcpSdkBackend } from '@/agent/backends/acp';
import { normalizeNativeSessionTitle } from '@/agent/nativeSessionTitle';

type AcpSessionTitleBackend = Pick<AcpSdkBackend, 'setSessionInfoUpdateListener'>;
type AcpSessionTitleClient = Pick<ApiSessionClient, 'sendClaudeSessionMessage'>;
type AcpSessionTitleClient = Pick<
ApiSessionClient,
'sendClaudeSessionMessage' | 'getMetadata' | 'updateMetadata'
>;

export interface AcpSessionTitleController {
syncNativeTitle: (title: unknown) => void;
markManualTitle: () => void;
}

/** Creates a normalized, deduplicated native-title sink for a HAPI session. */
function createSessionTitleSync(client: AcpSessionTitleClient): (title: unknown) => void {
export function createAcpSessionTitleSync(client: AcpSessionTitleClient): AcpSessionTitleController {
let lastTitle: string | null = null;
// Survives launcher recreation / session resume via session metadata, so a
// manual change_title rename is not overwritten by later native titles.
let manual = client.getMetadata()?.acpManualTitle === true;
Comment thread
junmo-kim marked this conversation as resolved.

Comment thread
junmo-kim marked this conversation as resolved.
return (title) => {
const normalizedTitle = normalizeNativeSessionTitle(title);
if (!normalizedTitle || normalizedTitle === lastTitle) {
return;
return {
syncNativeTitle: (title) => {
if (manual) {
return;
}
const normalizedTitle = normalizeNativeSessionTitle(title);
if (!normalizedTitle || normalizedTitle === lastTitle) {
return;
}
lastTitle = normalizedTitle;
client.sendClaudeSessionMessage({
type: 'summary',
summary: normalizedTitle,
leafUuid: randomUUID()
});
},
markManualTitle: () => {
if (manual) {
return;
}
manual = true;
client.updateMetadata((metadata) => ({
...metadata,
acpManualTitle: true
}));
}
lastTitle = normalizedTitle;
client.sendClaudeSessionMessage({
type: 'summary',
summary: normalizedTitle,
leafUuid: randomUUID()
});
};
}

/** Syncs agent-generated ACP session titles into HAPI session metadata. */
export function registerAcpSessionTitleSync(
backend: AcpSessionTitleBackend,
client: AcpSessionTitleClient
client: AcpSessionTitleClient,
controller?: AcpSessionTitleController
): void {
const syncTitle = createSessionTitleSync(client);
const titleSync = controller ?? createAcpSessionTitleSync(client);

backend.setSessionInfoUpdateListener(({ title }) => {
syncTitle(title);
titleSync.syncNativeTitle(title);
});
}
4 changes: 4 additions & 0 deletions cli/src/agent/sessionFactory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ describe('bootstrapExistingSession', () => {
text: 'resume me',
updatedAt: 100
},
acpManualTitle: true,
tools: ['read_file'],
slashCommands: ['/compact'],
conversationHistoryPoints: { 'local-user-1': true },
Expand All @@ -188,6 +189,7 @@ describe('bootstrapExistingSession', () => {
})

expect(result.metadata).toEqual(expect.objectContaining({
acpManualTitle: true,
claudeSessionId: 'claude-thread-1',
codexSessionId: 'codex-thread-1',
geminiSessionId: 'gemini-thread-1',
Expand Down Expand Up @@ -222,13 +224,15 @@ describe('bootstrapExistingSession', () => {
expect(sessionClient.updateMetadata).toHaveBeenCalledOnce()
const updateHandler = sessionClient.updateMetadata.mock.calls[0][0]
expect(updateHandler(session.metadata)).toEqual(expect.objectContaining({
acpManualTitle: true,
codexSessionId: 'codex-thread-1',
grokSessionId: 'grok-thread-1',
conversationHistoryEntryIds: { 'local-user-1': 'pi-entry-1' }
}))
expect(notifyRunnerSessionStartedMock).toHaveBeenCalledWith(
'hapi-session-1',
expect.objectContaining({
acpManualTitle: true,
codexSessionId: 'codex-thread-1',
grokSessionId: 'grok-thread-1',
conversationHistoryEntryIds: { 'local-user-1': 'pi-entry-1' }
Expand Down
1 change: 1 addition & 0 deletions cli/src/agent/sessionFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ function pickExistingSessionMetadata(metadata: Metadata | null | undefined): Par

if (metadata.name !== undefined) preserved.name = metadata.name
if (metadata.summary !== undefined) preserved.summary = metadata.summary
if (metadata.acpManualTitle !== undefined) preserved.acpManualTitle = metadata.acpManualTitle
if (metadata.claudeSessionId !== undefined) preserved.claudeSessionId = metadata.claudeSessionId
if (metadata.codexSessionId !== undefined) preserved.codexSessionId = metadata.codexSessionId
if (metadata.codexSourceSessionId !== undefined) preserved.codexSourceSessionId = metadata.codexSourceSessionId
Expand Down
47 changes: 47 additions & 0 deletions cli/src/claude/utils/startHappyServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,53 @@ describe('startHappyServer skill_lookup', () => {
])
})

it('invokes onChangeTitle when change_title succeeds', async () => {
sendAgentMessage = vi.fn()
const sessionClient = {
updateMetadata: vi.fn(),
sendAgentMessage,
sendClaudeSessionMessage: vi.fn()
} as unknown as ApiSessionClient
const onChangeTitle = vi.fn()
const server = await startHappyServer(sessionClient, { onChangeTitle })
stopServer = server.stop
const mcp = new Client({ name: 'hapi-test', version: '1.0.0' })
client = mcp
await mcp.connect(new StreamableHTTPClientTransport(new URL(server.url)))

await mcp.callTool({
name: 'change_title',
arguments: { title: 'Manual Title' }
})

expect(onChangeTitle).toHaveBeenCalledWith('Manual Title')
})

it('does not invoke onChangeTitle when the title send fails', async () => {
sendAgentMessage = vi.fn()
const sessionClient = {
updateMetadata: vi.fn(),
sendAgentMessage,
sendClaudeSessionMessage: vi.fn(() => {
throw new Error('send failed')
})
} as unknown as ApiSessionClient
const onChangeTitle = vi.fn()
const server = await startHappyServer(sessionClient, { onChangeTitle })
stopServer = server.stop
const mcp = new Client({ name: 'hapi-test', version: '1.0.0' })
client = mcp
await mcp.connect(new StreamableHTTPClientTransport(new URL(server.url)))

const result = await mcp.callTool({
name: 'change_title',
arguments: { title: 'Manual Title' }
}) as ToolResult

expect(result.isError).toBe(true)
expect(onChangeTitle).not.toHaveBeenCalled()
})

it('describes display_image as user output rather than image input', async () => {
const mcp = await connect(false)
const tools = await mcp.listTools()
Expand Down
7 changes: 5 additions & 2 deletions cli/src/claude/utils/startHappyServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { PingPeerError, formatInspectPeerReport, formatPeerSessionsList, inspect
type StartHappyServerOptions = {
emitTitleSummary?: boolean;
enableChangeTitle?: boolean;
onChangeTitle?: (title: string) => void;
skillLookup?: {
workingDirectory: string;
flavor: string;
Expand Down Expand Up @@ -61,7 +62,8 @@ function createHapiMcpServer(
client: ApiSessionClient,
emitTitleSummary: boolean,
enableChangeTitle: boolean,
skillLookup: StartHappyServerOptions['skillLookup']
skillLookup: StartHappyServerOptions['skillLookup'],
onChangeTitle?: StartHappyServerOptions['onChangeTitle']
): McpServer {
const handler = async (title: string) => {
logger.debug('[hapiMCP] Changing title to:', title);
Expand All @@ -73,6 +75,7 @@ function createHapiMcpServer(
leafUuid: randomUUID()
});
}
onChangeTitle?.(title);

return { success: true };
} catch (error) {
Expand Down Expand Up @@ -479,7 +482,7 @@ export async function startHappyServer(client: ApiSessionClient, options: StartH
const mcps = new Map<string, McpServer>();

const createMcpTransport = () => {
const mcp = createHapiMcpServer(client, emitTitleSummary, enableChangeTitle, options.skillLookup);
const mcp = createHapiMcpServer(client, emitTitleSummary, enableChangeTitle, options.skillLookup, options.onChangeTitle);
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (sessionId) => {
Expand Down
2 changes: 2 additions & 0 deletions cli/src/codex/utils/buildHapiMcpBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export interface HapiMcpBridge {
export interface HapiMcpBridgeOptions {
emitTitleSummary?: boolean;
enableChangeTitle?: boolean;
onChangeTitle?: (title: string) => void;
skillLookup?: {
workingDirectory: string;
flavor: string;
Expand Down Expand Up @@ -80,6 +81,7 @@ export async function buildHapiMcpBridge(
const happyServer = await startHappyServer(client, {
emitTitleSummary: options.emitTitleSummary,
enableChangeTitle: options.enableChangeTitle,
onChangeTitle: options.onChangeTitle,
skillLookup: options.skillLookup
});
const bridgeCommand = getHappyCliCommand([
Expand Down
7 changes: 4 additions & 3 deletions cli/src/copilot/copilotRemoteLauncher.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import { registerAcpSessionTitleSync } from '@/agent/acpSessionTitle';
import { createAcpSessionTitleSync, registerAcpSessionTitleSync } from '@/agent/acpSessionTitle';
import { logger } from '@/ui/logger';
import { buildHapiMcpBridge } from '@/codex/utils/buildHapiMcpBridge';
import { convertAgentMessage } from '@/agent/messageConverter';
Expand Down Expand Up @@ -58,8 +58,9 @@ export class CopilotRemoteLauncher extends RemoteLauncherBase {
const session = this.session;
const messageBuffer = this.messageBuffer;

const titleSync = createAcpSessionTitleSync(session.client);
const { server: happyServer, mcpServers } = await buildHapiMcpBridge(session.client, {
enableChangeTitle: false,
onChangeTitle: () => titleSync.markManualTitle(),
skillLookup: { workingDirectory: session.path, flavor: 'copilot' }
});
this.happyServer = happyServer;
Expand All @@ -69,7 +70,7 @@ export class CopilotRemoteLauncher extends RemoteLauncherBase {
this.currentAgentMode = session.getAgentMode();
const backend = createCopilotBackend({ agentMode: this.currentAgentMode });
this.backend = backend;
registerAcpSessionTitleSync(backend, session.client);
registerAcpSessionTitleSync(backend, session.client, titleSync);

backend.onStderrError((error) => {
logger.debug('[copilot-remote] stderr error', error);
Expand Down
Loading
Loading