Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
18 changes: 17 additions & 1 deletion cli/src/agent/acpSessionTitle.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
import { registerAcpSessionTitleSync } from './acpSessionTitle';
import { createAcpSessionTitleSync, registerAcpSessionTitleSync } from './acpSessionTitle';
import type { AcpSessionInfoUpdate } from './backends/acp/AcpSdkBackend';

describe('registerAcpSessionTitleSync', () => {
Expand Down Expand Up @@ -29,4 +29,20 @@ describe('registerAcpSessionTitleSync', () => {
leafUuid: expect.any(String)
});
});

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

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)
});
});
});
43 changes: 29 additions & 14 deletions cli/src/agent/acpSessionTitle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,32 +6,47 @@ import { normalizeNativeSessionTitle } from '@/agent/nativeSessionTitle';
type AcpSessionTitleBackend = Pick<AcpSdkBackend, 'setSessionInfoUpdateListener'>;
type AcpSessionTitleClient = Pick<ApiSessionClient, 'sendClaudeSessionMessage'>;

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;
let manual = false;

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: () => {
manual = 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);
});
}
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
37 changes: 36 additions & 1 deletion cli/src/cursor/cursorAcpRemoteLauncher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,31 @@ const harness = vi.hoisted(() => ({
stderrErrorHandler: null as ((error: { type: string; message: string; raw?: string }) => void) | null,
disconnectError: null as Error | null,
overlayCleanup: null as ReturnType<typeof vi.fn> | null,
agentActivityListener: null as ((thinking: boolean) => void) | null
agentActivityListener: null as ((thinking: boolean) => void) | null,
titleSyncControllers: [] as unknown[],
titleSyncRegistered: [] as unknown[]
}));

vi.mock('@/agent/acpSessionTitle', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/agent/acpSessionTitle')>();
return {
...actual,
createAcpSessionTitleSync: (...args: Parameters<typeof actual.createAcpSessionTitleSync>) => {
const controller = actual.createAcpSessionTitleSync(...args);
harness.titleSyncControllers.push(controller);
return controller;
},
registerAcpSessionTitleSync: (
backend: Parameters<typeof actual.registerAcpSessionTitleSync>[0],
client: Parameters<typeof actual.registerAcpSessionTitleSync>[1],
controller?: Parameters<typeof actual.registerAcpSessionTitleSync>[2]
) => {
harness.titleSyncRegistered.push(controller ?? null);
return actual.registerAcpSessionTitleSync(backend, client, controller);
}
};
});

const legacyLauncher = vi.hoisted(() => vi.fn());

vi.mock('./cursorLegacyRemoteLauncher', () => ({
Expand Down Expand Up @@ -309,6 +331,8 @@ describe('cursorAcpRemoteLauncher', () => {
harness.disconnectError = null;
harness.overlayCleanup = null;
harness.agentActivityListener = null;
harness.titleSyncControllers = [];
harness.titleSyncRegistered = [];
legacyLauncher.mockClear();
process.stdin.isTTY = false;
process.stdout.isTTY = false;
Expand Down Expand Up @@ -596,6 +620,17 @@ describe('cursorAcpRemoteLauncher', () => {
expect(legacyLauncher).not.toHaveBeenCalled();
});

it('shares one title-sync controller across backend (re)creation paths', async () => {
const session = makeSession(null);
await cursorAcpRemoteLauncher(session);

expect(harness.titleSyncControllers).toHaveLength(1);
expect(harness.titleSyncRegistered.length).toBeGreaterThan(0);
for (const controller of harness.titleSyncRegistered) {
expect(controller).toBe(harness.titleSyncControllers[0]);
}
});

it('applies harness thinking transitions once per edge (#1470)', async () => {
const keepAlive = vi.fn();
const queue = new MessageQueue2<EnhancedMode>(() => 'mode');
Expand Down
11 changes: 6 additions & 5 deletions cli/src/cursor/cursorAcpRemoteLauncher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import { readSharedCursorModelsCache } from '@/modules/common/cursorModelsShared
import type { AcpSdkBackend } from '@/agent/backends/acp';
import type { AcpStderrError } from '@/agent/backends/acp/AcpStdioTransport';
import { isAcpIndeterminateError } from '@/agent/backends/acp/AcpStdioTransport';
import { registerAcpSessionTitleSync } from '@/agent/acpSessionTitle';
import { createAcpSessionTitleSync, registerAcpSessionTitleSync } from '@/agent/acpSessionTitle';
import { RPC_METHODS } from '@hapi/protocol/rpcMethods';
import {
cursorHapiMcpServerId,
Expand Down Expand Up @@ -104,8 +104,9 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase {
const messageBuffer = this.messageBuffer;
session.client.updateAgentState?.((state) => ({ ...state, steeringActive: false }));

const titleSync = createAcpSessionTitleSync(session.client);
const { server: happyServer, mcpServers } = await buildHapiMcpBridge(session.client, {
enableChangeTitle: false,
onChangeTitle: () => titleSync.markManualTitle(),
skillLookup: { workingDirectory: session.path, flavor: 'cursor' }
});
this.happyServer = happyServer;
Expand Down Expand Up @@ -155,7 +156,7 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase {
addDirs: session.cursorAddDirs
});
this.backend = backend;
registerAcpSessionTitleSync(backend, session.client);
registerAcpSessionTitleSync(backend, session.client, titleSync);
this.recordCursorNativeWorktreeMetadata();

backend.setUsageUpdateListener((message) => this.handleAgentMessage(message));
Expand Down Expand Up @@ -271,7 +272,7 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase {
addDirs: session.cursorAddDirs
});
this.backend = backend;
registerAcpSessionTitleSync(backend, session.client);
registerAcpSessionTitleSync(backend, session.client, titleSync);
backend.setUsageUpdateListener((message) => this.handleAgentMessage(message));
this.wireAgentActivityThinking(backend, session);
recentStderrHint = null;
Expand Down Expand Up @@ -332,7 +333,7 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase {
addDirs: session.cursorAddDirs
});
this.backend = backend;
registerAcpSessionTitleSync(backend, session.client);
registerAcpSessionTitleSync(backend, session.client, titleSync);
backend.setUsageUpdateListener((message) => this.handleAgentMessage(message));
this.wireAgentActivityThinking(backend, session);
recentStderrHint = null;
Expand Down
7 changes: 4 additions & 3 deletions cli/src/kimi/kimiRemoteLauncher.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 @@ -44,8 +44,9 @@ class KimiRemoteLauncher 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: 'kimi' }
});
this.happyServer = happyServer;
Expand All @@ -54,7 +55,7 @@ class KimiRemoteLauncher extends RemoteLauncherBase {

const backend = createKimiBackend();
this.backend = backend;
registerAcpSessionTitleSync(backend, session.client);
registerAcpSessionTitleSync(backend, session.client, titleSync);

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