From 40c178935715dd6bbc60905af6275c4389b4fd67 Mon Sep 17 00:00:00 2001 From: SSU-WEI HUANG Date: Fri, 24 Jul 2026 10:59:47 +0800 Subject: [PATCH] fix(acp): sync native agent session titles (#1028) * fix(acp): sync native session titles * fix(acp): keep title refresh off turn path * fix: reconcile native ACP titles with skill lookup * fix: preserve OpenCode image tool instruction --- cli/src/agent/acpSessionTitle.test.ts | 31 +++++++ cli/src/agent/acpSessionTitle.ts | 35 +++++++ .../agent/backends/acp/AcpSdkBackend.test.ts | 92 ++++++++++++++++++- cli/src/agent/backends/acp/AcpSdkBackend.ts | 86 ++++++++++++++--- cli/src/claude/utils/startHappyServer.test.ts | 19 ++++ cli/src/claude/utils/startHappyServer.ts | 53 ++++++----- cli/src/codex/utils/buildHapiMcpBridge.ts | 11 ++- .../cursor/cursorAcpRemoteLauncher.test.ts | 2 + cli/src/cursor/cursorAcpRemoteLauncher.ts | 4 + cli/src/kimi/kimiRemoteLauncher.test.ts | 2 + cli/src/kimi/kimiRemoteLauncher.ts | 4 + .../opencode/opencodeRemoteLauncher.test.ts | 34 ++++++- cli/src/opencode/opencodeRemoteLauncher.ts | 8 +- cli/src/opencode/utils/systemPrompt.ts | 9 ++ 14 files changed, 339 insertions(+), 51 deletions(-) create mode 100644 cli/src/agent/acpSessionTitle.test.ts create mode 100644 cli/src/agent/acpSessionTitle.ts diff --git a/cli/src/agent/acpSessionTitle.test.ts b/cli/src/agent/acpSessionTitle.test.ts new file mode 100644 index 00000000..c846d3f6 --- /dev/null +++ b/cli/src/agent/acpSessionTitle.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it, vi } from 'vitest'; +import { registerAcpSessionTitleSync } from './acpSessionTitle'; +import type { AcpSessionInfoUpdate } from './backends/acp/AcpSdkBackend'; + +describe('registerAcpSessionTitleSync', () => { + it('forwards normalized unique ACP titles as HAPI summaries', () => { + let listener: ((update: AcpSessionInfoUpdate) => void) | null = null; + const backend = { + setSessionInfoUpdateListener(next: ((update: AcpSessionInfoUpdate) => void) | null) { + listener = next; + } + }; + const sendClaudeSessionMessage = vi.fn(); + + registerAcpSessionTitleSync(backend, { sendClaudeSessionMessage }); + + listener!({ sessionId: 'session-1', title: ' Native Cursor Title ' }); + listener!({ sessionId: 'session-1', title: 'Native Cursor Title' }); + listener!({ sessionId: 'session-1', title: '' }); + listener!({ sessionId: 'session-1', title: null }); + listener!({ sessionId: 'session-1', title: 'Untitled' }); + listener!({ sessionId: 'session-1', title: 'New session - 2026-07-12T15:30:03.251Z' }); + + expect(sendClaudeSessionMessage).toHaveBeenCalledTimes(1); + expect(sendClaudeSessionMessage).toHaveBeenCalledWith({ + type: 'summary', + summary: 'Native Cursor Title', + leafUuid: expect.any(String) + }); + }); +}); diff --git a/cli/src/agent/acpSessionTitle.ts b/cli/src/agent/acpSessionTitle.ts new file mode 100644 index 00000000..04b6bd9a --- /dev/null +++ b/cli/src/agent/acpSessionTitle.ts @@ -0,0 +1,35 @@ +import { randomUUID } from 'node:crypto'; +import type { ApiSessionClient } from '@/api/apiSession'; +import type { AcpSdkBackend } from '@/agent/backends/acp'; + +type AcpSessionTitleBackend = Pick; +type AcpSessionTitleClient = Pick; + +function isPlaceholderTitle(title: string): boolean { + return title === 'Untitled' + || /^(?:New|Child) session - \d{4}-\d{2}-\d{2}T/.test(title); +} + +/** Syncs agent-generated ACP session titles into HAPI session metadata. */ +export function registerAcpSessionTitleSync( + backend: AcpSessionTitleBackend, + client: AcpSessionTitleClient +): void { + let lastTitle: string | null = null; + + backend.setSessionInfoUpdateListener(({ title }) => { + if (typeof title !== 'string') { + return; + } + const normalizedTitle = title.trim(); + if (!normalizedTitle || isPlaceholderTitle(normalizedTitle) || normalizedTitle === lastTitle) { + return; + } + lastTitle = normalizedTitle; + client.sendClaudeSessionMessage({ + type: 'summary', + summary: normalizedTitle, + leafUuid: randomUUID() + }); + }); +} diff --git a/cli/src/agent/backends/acp/AcpSdkBackend.test.ts b/cli/src/agent/backends/acp/AcpSdkBackend.test.ts index cccbeb70..8b3e8fb5 100644 --- a/cli/src/agent/backends/acp/AcpSdkBackend.test.ts +++ b/cli/src/agent/backends/acp/AcpSdkBackend.test.ts @@ -51,6 +51,92 @@ afterEach(() => { }); describe('AcpSdkBackend', () => { + it('forwards ACP session_info_update titles without requiring an active prompt', () => { + const backend = new AcpSdkBackend({ command: 'agent' }); + const updates: Array<{ sessionId: string | null; title: string | null }> = []; + backend.setSessionInfoUpdateListener((update) => updates.push(update)); + + const backendInternal = backend as unknown as { + handleSessionUpdate: (params: unknown) => void; + }; + backendInternal.handleSessionUpdate({ + sessionId: 'session-1', + update: { + sessionUpdate: ACP_SESSION_UPDATE_TYPES.sessionInfoUpdate, + title: 'Native session title' + } + }); + backendInternal.handleSessionUpdate({ + sessionId: 'session-1', + update: { + sessionUpdate: ACP_SESSION_UPDATE_TYPES.sessionInfoUpdate, + updatedAt: '2026-07-12T00:00:00Z' + } + }); + + expect(updates).toEqual([{ sessionId: 'session-1', title: 'Native session title' }]); + }); + + it('refreshes native titles through ACP session/list', async () => { + const backend = new AcpSdkBackend({ command: 'opencode' }); + const calls: Array<{ method: string; params: unknown; options: unknown }> = []; + const backendInternal = backend as unknown as { + transport: { + sendRequest: (method: string, params: unknown, options?: unknown) => Promise; + } | null; + }; + backendInternal.transport = { + sendRequest: async (method, params, options) => { + calls.push({ method, params, options }); + return { + sessions: [ + { sessionId: 'other', title: 'Other title' }, + { sessionId: 'session-1', title: 'Native OpenCode title' } + ] + }; + } + }; + const updates: Array<{ sessionId: string | null; title: string | null }> = []; + backend.setSessionInfoUpdateListener((update) => updates.push(update)); + + await backend.refreshSessionInfo('session-1', '/workspace'); + + expect(calls).toEqual([{ + method: 'session/list', + params: { cwd: '/workspace' }, + options: { timeoutMs: 5000 } + }]); + expect(updates).toEqual([{ sessionId: 'session-1', title: 'Native OpenCode title' }]); + }); + + it('retries session/list while an asynchronously generated title is still a placeholder', async () => { + vi.useFakeTimers(); + try { + const backend = new AcpSdkBackend({ command: 'opencode' }); + const titles = ['New session - 2026-07-12T00:00:00.000Z', 'Native OpenCode title']; + const backendInternal = backend as unknown as { + transport: { sendRequest: () => Promise } | null; + }; + backendInternal.transport = { + sendRequest: async () => ({ + sessions: [{ sessionId: 'session-1', title: titles.shift() }] + }) + }; + const updates: Array<{ sessionId: string | null; title: string | null }> = []; + backend.setSessionInfoUpdateListener((update) => updates.push(update)); + + await backend.refreshSessionInfo('session-1', '/workspace'); + await vi.runAllTimersAsync(); + + expect(updates).toEqual([ + { sessionId: 'session-1', title: 'New session - 2026-07-12T00:00:00.000Z' }, + { sessionId: 'session-1', title: 'Native OpenCode title' } + ]); + } finally { + vi.useRealTimers(); + } + }); + it('hides the ACP stdio shell on Windows', () => { setPlatform('win32'); @@ -830,7 +916,7 @@ describe('AcpSdkBackend', () => { it('forwards title changes from session_info_update', () => { const backend = new AcpSdkBackend({ command: 'agent' }); - const updates: Array<{ title?: string | null }> = []; + const updates: Array<{ sessionId: string | null; title: string | null }> = []; backend.setSessionInfoUpdateListener((update) => updates.push(update)); const backendInternal = backend as unknown as { @@ -869,8 +955,8 @@ describe('AcpSdkBackend', () => { }); expect(updates).toEqual([ - { title: 'Native ACP title' }, - { title: null } + { sessionId: 'session-1', title: 'Native ACP title' }, + { sessionId: 'session-1', title: null } ]); }); diff --git a/cli/src/agent/backends/acp/AcpSdkBackend.ts b/cli/src/agent/backends/acp/AcpSdkBackend.ts index 7e22bdcd..6c5dd384 100644 --- a/cli/src/agent/backends/acp/AcpSdkBackend.ts +++ b/cli/src/agent/backends/acp/AcpSdkBackend.ts @@ -25,10 +25,6 @@ type AcpUsageUpdate = { contextWindow: number | undefined; }; -export type AcpSessionInfoUpdate = { - title?: string | null; -}; - export type AcpModelDescriptor = { modelId: string; name?: string; @@ -40,6 +36,11 @@ export type AcpSessionModelsMetadata = { currentModelId: string | null; }; +export type AcpSessionInfoUpdate = { + sessionId: string | null; + title: string | null; +}; + export type AcpConfigOptionDescriptor = { id: string; category?: string; @@ -64,6 +65,7 @@ export class AcpSdkBackend implements AgentBackend { private readonly pendingPermissions = new Map(); private readonly sessionModelsMetadata = new Map(); private readonly sessionConfigOptions = new Map(); + private readonly sessionInfoRefreshTimers = new Map>(); private readonly initialAvailableCommands = new Set(); private readonly sessionAvailableCommands = new Map>(); private autoPermissionModeEnabled: boolean | null = null; @@ -90,6 +92,7 @@ export class AcpSdkBackend implements AgentBackend { private static readonly UPDATE_DRAIN_TIMEOUT_MS = 2000; private static readonly PRE_PROMPT_UPDATE_QUIET_PERIOD_MS = 200; private static readonly PRE_PROMPT_UPDATE_DRAIN_TIMEOUT_MS = 1200; + private static readonly SESSION_TITLE_REFRESH_DELAYS_MS = [1000, 3000]; // After the initial post-prompt drain, slow-tailing models (DeepSeek, // GPT-5.5, etc.) can keep sending agentMessageChunk notifications. We poll // drainBuffers() on a short interval so the UI keeps streaming smoothly, @@ -411,11 +414,62 @@ export class AcpSdkBackend implements AgentBackend { this.usageUpdateListener = listener; } - /** Forwards ACP `session_info_update` metadata independently of prompt turns. */ + /** Forwards stable ACP session metadata updates independently of prompt streaming. */ setSessionInfoUpdateListener(listener: ((update: AcpSessionInfoUpdate) => void) | null): void { this.sessionInfoUpdateListener = listener; } + /** Reads the agent's persisted native title through stable ACP session/list. */ + async refreshSessionInfo(sessionId: string, cwd: string): Promise { + const existingTimer = this.sessionInfoRefreshTimers.get(sessionId); + if (existingTimer) { + clearTimeout(existingTimer); + this.sessionInfoRefreshTimers.delete(sessionId); + } + await this.refreshSessionInfoAttempt(sessionId, cwd, 0); + } + + private async refreshSessionInfoAttempt(sessionId: string, cwd: string, retryIndex: number): Promise { + if (!this.transport) { + return; + } + try { + const response = await this.transport.sendRequest('session/list', { cwd }, { timeoutMs: 5000 }); + if (!isObject(response) || !Array.isArray(response.sessions)) { + return; + } + const match = response.sessions.find((entry) => + isObject(entry) && asString(entry.sessionId) === sessionId + ); + if (!isObject(match) || (typeof match.title !== 'string' && match.title !== null)) { + return; + } + this.sessionInfoUpdateListener?.({ sessionId, title: match.title }); + if (match.title === null || !this.isPlaceholderSessionTitle(match.title)) { + return; + } + const delayMs = AcpSdkBackend.SESSION_TITLE_REFRESH_DELAYS_MS[retryIndex]; + if (delayMs === undefined) { + return; + } + const timer = setTimeout(() => { + this.sessionInfoRefreshTimers.delete(sessionId); + void this.refreshSessionInfoAttempt(sessionId, cwd, retryIndex + 1); + }, delayMs); + timer.unref(); + this.sessionInfoRefreshTimers.set(sessionId, timer); + } catch (error) { + logger.debug('[ACP] session/list title refresh unavailable', error); + } + } + + private isPlaceholderSessionTitle(title: string): boolean { + const normalizedTitle = title.trim(); + return normalizedTitle.length === 0 + || normalizedTitle === 'Untitled' + || /^(?:New|Child) session - \d{4}-\d{2}-\d{2}T/.test(normalizedTitle); + } + async prompt( sessionId: string, content: PromptContent[], @@ -579,6 +633,10 @@ export class AcpSdkBackend implements AgentBackend { async disconnect(): Promise { if (!this.transport) return; + for (const timer of this.sessionInfoRefreshTimers.values()) { + clearTimeout(timer); + } + this.sessionInfoRefreshTimers.clear(); this.messageHandler?.drainBuffers(); this.messageHandler = null; this.activeSessionId = null; @@ -603,19 +661,19 @@ export class AcpSdkBackend implements AgentBackend { if (sessionId) { this.captureAvailableCommands(sessionId, update); } - this.captureSessionInfoUpdate(update); + this.forwardSessionInfoUpdate(sessionId, update); this.captureUsageUpdate(update); this.messageHandler?.handleUpdate(update); } - private captureSessionInfoUpdate(update: unknown): void { - if (!isObject(update)) return; - if (asString(update.sessionUpdate) !== ACP_SESSION_UPDATE_TYPES.sessionInfoUpdate) return; - if (!Object.prototype.hasOwnProperty.call(update, 'title')) return; - - const title = update.title; - if (typeof title !== 'string' && title !== null) return; - this.sessionInfoUpdateListener?.({ title }); + private forwardSessionInfoUpdate(sessionId: string | null, update: unknown): void { + if (!isObject(update) || update.sessionUpdate !== ACP_SESSION_UPDATE_TYPES.sessionInfoUpdate) { + return; + } + if (typeof update.title !== 'string' && update.title !== null) { + return; + } + this.sessionInfoUpdateListener?.({ sessionId, title: update.title }); } private captureUsageUpdate(update: unknown): void { diff --git a/cli/src/claude/utils/startHappyServer.test.ts b/cli/src/claude/utils/startHappyServer.test.ts index 55df63b0..3814f4d1 100644 --- a/cli/src/claude/utils/startHappyServer.test.ts +++ b/cli/src/claude/utils/startHappyServer.test.ts @@ -110,4 +110,23 @@ describe('startHappyServer skill_lookup', () => { 'display_image' ]) }) + + it('does not expose change_title when native ACP titles are enabled', async () => { + const sessionClient = { + updateMetadata: vi.fn(), + sendAgentMessage: vi.fn(), + sendClaudeSessionMessage: vi.fn() + } as unknown as ApiSessionClient + const server = await startHappyServer(sessionClient, { enableChangeTitle: false }) + 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 tools = await mcp.listTools() + + expect(server.toolNames).toEqual(['display_image']) + expect(tools.tools.map((tool) => tool.name)).toEqual(['display_image']) + }) + }) diff --git a/cli/src/claude/utils/startHappyServer.ts b/cli/src/claude/utils/startHappyServer.ts index 28bde1c4..f8d95653 100644 --- a/cli/src/claude/utils/startHappyServer.ts +++ b/cli/src/claude/utils/startHappyServer.ts @@ -17,6 +17,7 @@ import { resolveSkill } from "@/modules/common/skills"; type StartHappyServerOptions = { emitTitleSummary?: boolean; + enableChangeTitle?: boolean; skillLookup?: { workingDirectory: string; flavor: string; @@ -26,6 +27,7 @@ type StartHappyServerOptions = { function createHapiMcpServer( client: ApiSessionClient, emitTitleSummary: boolean, + enableChangeTitle: boolean, skillLookup: StartHappyServerOptions['skillLookup'] ): McpServer { const handler = async (title: string) => { @@ -63,36 +65,38 @@ function createHapiMcpServer( name: z.string().trim().min(1).max(128).describe('Exact skill name shown by HAPI skill autocomplete'), }); - mcp.registerTool('change_title', { - description: 'Change the title of the current chat session', - title: 'Change Chat Title', - inputSchema: changeTitleInputSchema, - }, async (args: { title: string }) => { - const response = await handler(args.title); - logger.debug('[hapiMCP] Response:', response); + if (enableChangeTitle) { + mcp.registerTool('change_title', { + description: 'Change the title of the current chat session', + title: 'Change Chat Title', + inputSchema: changeTitleInputSchema, + }, async (args: { title: string }) => { + const response = await handler(args.title); + logger.debug('[hapiMCP] Response:', response); + + if (response.success) { + return { + content: [ + { + type: 'text' as const, + text: `Successfully changed chat title to: "${args.title}"`, + }, + ], + isError: false, + }; + } - if (response.success) { return { content: [ { type: 'text' as const, - text: `Successfully changed chat title to: "${args.title}"`, + text: `Failed to change chat title: ${response.error || 'Unknown error'}`, }, ], - isError: false, + isError: true, }; - } - - return { - content: [ - { - type: 'text' as const, - text: `Failed to change chat title: ${response.error || 'Unknown error'}`, - }, - ], - isError: true, - }; - }); + }); + } mcp.registerTool('display_image', { description: 'Display a local image file inline in the current HAPI chat session', @@ -218,11 +222,12 @@ function readMcpSessionId(req: IncomingMessage): string | undefined { export async function startHappyServer(client: ApiSessionClient, options: StartHappyServerOptions = {}) { const emitTitleSummary = options.emitTitleSummary ?? true; + const enableChangeTitle = options.enableChangeTitle ?? true; const transports = new Map(); const mcps = new Map(); const createMcpTransport = () => { - const mcp = createHapiMcpServer(client, emitTitleSummary, options.skillLookup); + const mcp = createHapiMcpServer(client, emitTitleSummary, enableChangeTitle, options.skillLookup); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (sessionId) => { @@ -276,7 +281,7 @@ export async function startHappyServer(client: ApiSessionClient, options: StartH hapiMcpUrl: mcpUrl, })); - const toolNames = ['change_title', 'display_image']; + const toolNames = enableChangeTitle ? ['change_title', 'display_image'] : ['display_image']; if (options.skillLookup) { toolNames.push('skill_lookup'); } diff --git a/cli/src/codex/utils/buildHapiMcpBridge.ts b/cli/src/codex/utils/buildHapiMcpBridge.ts index 5a84e28f..1c7ec2ba 100644 --- a/cli/src/codex/utils/buildHapiMcpBridge.ts +++ b/cli/src/codex/utils/buildHapiMcpBridge.ts @@ -45,6 +45,7 @@ export interface HapiMcpBridge { export interface HapiMcpBridgeOptions { emitTitleSummary?: boolean; + enableChangeTitle?: boolean; skillLookup?: { workingDirectory: string; flavor: string; @@ -78,6 +79,7 @@ export async function buildHapiMcpBridge( const happyServer = await startHappyServer(client, { emitTitleSummary: options.emitTitleSummary, + enableChangeTitle: options.enableChangeTitle, skillLookup: options.skillLookup }); const bridgeCommand = getHappyCliCommand([ @@ -87,11 +89,12 @@ export async function buildHapiMcpBridge( '--tools', happyServer.toolNames.join(',') ]); - const tools: Record = { - change_title: { + const tools: Record = {}; + if (options.enableChangeTitle !== false) { + tools.change_title = { approval_mode: 'approve' - } - }; + }; + } if (options.skillLookup) { tools.skill_lookup = { approval_mode: 'approve' diff --git a/cli/src/cursor/cursorAcpRemoteLauncher.test.ts b/cli/src/cursor/cursorAcpRemoteLauncher.test.ts index e75c4ceb..55a3d3d7 100644 --- a/cli/src/cursor/cursorAcpRemoteLauncher.test.ts +++ b/cli/src/cursor/cursorAcpRemoteLauncher.test.ts @@ -98,6 +98,8 @@ vi.mock('./utils/cursorAcpBackend', () => ({ respondToPermission: vi.fn(async () => {}), onStderrError: vi.fn(), setUsageUpdateListener: vi.fn(), + setSessionInfoUpdateListener: vi.fn(), + refreshSessionInfo: vi.fn(async () => {}), onPermissionRequest: vi.fn(), registerExtensionRequestHandler: vi.fn(), disconnect: vi.fn(async () => {}) diff --git a/cli/src/cursor/cursorAcpRemoteLauncher.ts b/cli/src/cursor/cursorAcpRemoteLauncher.ts index fd362bb5..ef2d8cfa 100644 --- a/cli/src/cursor/cursorAcpRemoteLauncher.ts +++ b/cli/src/cursor/cursorAcpRemoteLauncher.ts @@ -30,6 +30,7 @@ import { cursorPassThroughStatusMessage, parseCursorSpecialCommand } from './cur import { buildCursorModelsSeedPayload, seedCursorModelsCache } from '@/modules/common/cursorModels'; import { readSharedCursorModelsCache } from '@/modules/common/cursorModelsSharedCache'; import type { AcpSdkBackend } from '@/agent/backends/acp'; +import { registerAcpSessionTitleSync } from '@/agent/acpSessionTitle'; class CursorAcpRemoteLauncher extends RemoteLauncherBase { private readonly session: CursorSession; private backend: ReturnType | null = null; @@ -67,6 +68,7 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { const messageBuffer = this.messageBuffer; const { server: happyServer, mcpServers } = await buildHapiMcpBridge(session.client, { + enableChangeTitle: false, skillLookup: { workingDirectory: session.path, flavor: 'cursor' } }); this.happyServer = happyServer; @@ -81,6 +83,7 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { addDirs: session.cursorAddDirs }); this.backend = backend; + registerAcpSessionTitleSync(backend, session.client); this.recordCursorNativeWorktreeMetadata(); backend.setUsageUpdateListener((message) => this.handleAgentMessage(message)); @@ -252,6 +255,7 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase { await backend.prompt(acpSessionId, promptContent, (message) => { this.handleAgentMessage(message); }); + void backend.refreshSessionInfo(acpSessionId, session.path); } catch (error) { logger.warn('[cursor-acp] prompt failed', error); const errMsg = error instanceof Error ? error.message : String(error); diff --git a/cli/src/kimi/kimiRemoteLauncher.test.ts b/cli/src/kimi/kimiRemoteLauncher.test.ts index 0e0c75cf..b0debeba 100644 --- a/cli/src/kimi/kimiRemoteLauncher.test.ts +++ b/cli/src/kimi/kimiRemoteLauncher.test.ts @@ -18,6 +18,8 @@ vi.mock('./utils/kimiBackend', () => ({ cancelPrompt: vi.fn(async () => {}), respondToPermission: vi.fn(async () => {}), onStderrError: vi.fn(), + setSessionInfoUpdateListener: vi.fn(), + refreshSessionInfo: vi.fn(async () => {}), onPermissionRequest: vi.fn(), disconnect: vi.fn(async () => {}) })) diff --git a/cli/src/kimi/kimiRemoteLauncher.ts b/cli/src/kimi/kimiRemoteLauncher.ts index 33e9ae4b..1bcd8738 100644 --- a/cli/src/kimi/kimiRemoteLauncher.ts +++ b/cli/src/kimi/kimiRemoteLauncher.ts @@ -1,4 +1,5 @@ import React from 'react'; +import { registerAcpSessionTitleSync } from '@/agent/acpSessionTitle'; import { logger } from '@/ui/logger'; import { buildHapiMcpBridge } from '@/codex/utils/buildHapiMcpBridge'; import { convertAgentMessage } from '@/agent/messageConverter'; @@ -44,6 +45,7 @@ class KimiRemoteLauncher extends RemoteLauncherBase { const messageBuffer = this.messageBuffer; const { server: happyServer, mcpServers } = await buildHapiMcpBridge(session.client, { + enableChangeTitle: false, skillLookup: { workingDirectory: session.path, flavor: 'kimi' } }); this.happyServer = happyServer; @@ -52,6 +54,7 @@ class KimiRemoteLauncher extends RemoteLauncherBase { const backend = createKimiBackend(); this.backend = backend; + registerAcpSessionTitleSync(backend, session.client); backend.onStderrError((error) => { logger.debug('[kimi-remote] stderr error', error); @@ -180,6 +183,7 @@ class KimiRemoteLauncher extends RemoteLauncherBase { await backend.prompt(acpSessionId, promptContent, (message: AgentMessage) => { this.handleAgentMessage(message); }); + void backend.refreshSessionInfo(acpSessionId, session.path); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger.warn('[kimi-remote] prompt failed', { message: errorMessage }); diff --git a/cli/src/opencode/opencodeRemoteLauncher.test.ts b/cli/src/opencode/opencodeRemoteLauncher.test.ts index 4a369c4e..81f16ee7 100644 --- a/cli/src/opencode/opencodeRemoteLauncher.test.ts +++ b/cli/src/opencode/opencodeRemoteLauncher.test.ts @@ -7,6 +7,8 @@ const harness = vi.hoisted(() => ({ setConfigOptionArgs: [] as Array<{ sessionId: string; configId: string; value: string }>, promptCount: 0, promptContents: [] as unknown[], + refreshSessionInfoCalls: [] as Array<{ sessionId: string; cwd: string }>, + bridgeOptions: null as { enableChangeTitle?: boolean; skillLookup?: { workingDirectory: string; flavor: string } } | null, events: [] as string[], setModelImpl: null as null | ((sessionId: string, modelId: string) => Promise), setConfigOptionImpl: null as null | ((sessionId: string, configId: string, value: string) => Promise), @@ -45,6 +47,10 @@ vi.mock('./utils/opencodeBackend', () => ({ cancelPrompt: vi.fn(async () => {}), respondToPermission: vi.fn(async () => {}), onStderrError: vi.fn(), + setSessionInfoUpdateListener: vi.fn(), + refreshSessionInfo: vi.fn(async (sessionId: string, cwd: string) => { + harness.refreshSessionInfoCalls.push({ sessionId, cwd }); + }), onPermissionRequest: vi.fn(), disconnect: vi.fn(async () => {}), getSessionModelsMetadata: vi.fn(() => undefined), @@ -53,10 +59,13 @@ vi.mock('./utils/opencodeBackend', () => ({ })); vi.mock('@/codex/utils/buildHapiMcpBridge', () => ({ - buildHapiMcpBridge: async () => ({ - server: { stop: () => {} }, - mcpServers: {} - }) + buildHapiMcpBridge: async (_client: unknown, options?: { enableChangeTitle?: boolean; skillLookup?: { workingDirectory: string; flavor: string } }) => { + harness.bridgeOptions = options ?? null; + return { + server: { stop: () => {} }, + mcpServers: {} + }; + } })); vi.mock('./utils/permissionHandler', () => ({ @@ -131,6 +140,7 @@ function createSessionStub(items: Array<{ message: string; mode: OpencodeMode }> } }, sendAgentMessage(_message: unknown) {}, + sendClaudeSessionMessage(_message: unknown) {}, sendUserMessage(_text: string) {}, sendSessionEvent(event: { type: string; [key: string]: unknown }) { sessionEvents.push(event); @@ -172,6 +182,8 @@ describe('opencodeRemoteLauncher inline model switch', () => { harness.setConfigOptionArgs = []; harness.promptCount = 0; harness.promptContents = []; + harness.refreshSessionInfoCalls = []; + harness.bridgeOptions = null; harness.events = []; harness.setModelImpl = null; harness.setConfigOptionImpl = null; @@ -188,6 +200,8 @@ describe('opencodeRemoteLauncher inline model switch', () => { expect(JSON.stringify(harness.promptContents[0])).toContain('$name'); expect(JSON.stringify(harness.promptContents[0])).toContain('skill_lookup'); + expect(JSON.stringify(harness.promptContents[0])).toContain('hapi_display_image'); + expect(JSON.stringify(harness.promptContents[0])).not.toContain('hapi_change_title'); expect(JSON.stringify(harness.promptContents[1])).not.toContain('skill_lookup'); }); @@ -199,6 +213,15 @@ describe('opencodeRemoteLauncher inline model switch', () => { await opencodeRemoteLauncher(session as never); + expect(harness.bridgeOptions).toEqual({ + enableChangeTitle: false, + skillLookup: { workingDirectory: '/tmp/hapi-opencode-test', flavor: 'opencode' } + }); + expect(harness.refreshSessionInfoCalls).toEqual([ + { sessionId: 'acp-session-1', cwd: '/tmp/hapi-opencode-test' }, + { sessionId: 'acp-session-1', cwd: '/tmp/hapi-opencode-test' } + ]); + expect(harness.setModelArgs).toEqual([ { sessionId: 'acp-session-1', modelId: 'mlx/qwen3:0.6b', flavor: 'opencode' } ]); @@ -409,6 +432,7 @@ describe('opencodeRemoteLauncher inline model switch', () => { expect(content[0]?.text).toContain('You are in plan mode'); expect(content[0]?.text).toContain('Do not execute tools'); expect(content[0]?.text).toContain('design the fix'); + expect(content[0]?.text).not.toContain('hapi_change_title'); }); it('registers a listOpencodeModels RPC handler that returns the backend cache', async () => { @@ -428,6 +452,8 @@ describe('opencodeRemoteLauncher inline model switch', () => { cancelPrompt: vi.fn(async () => {}), respondToPermission: vi.fn(async () => {}), onStderrError: vi.fn(), + setSessionInfoUpdateListener: vi.fn(), + refreshSessionInfo: vi.fn(async () => {}), onPermissionRequest: vi.fn(), disconnect: vi.fn(async () => {}), getSessionModelsMetadata: vi.fn((sessionId: string) => { diff --git a/cli/src/opencode/opencodeRemoteLauncher.ts b/cli/src/opencode/opencodeRemoteLauncher.ts index 9ccf6a41..4ec0c360 100644 --- a/cli/src/opencode/opencodeRemoteLauncher.ts +++ b/cli/src/opencode/opencodeRemoteLauncher.ts @@ -1,4 +1,5 @@ import React from 'react'; +import { registerAcpSessionTitleSync } from '@/agent/acpSessionTitle'; import { logger } from '@/ui/logger'; import { buildHapiMcpBridge } from '@/codex/utils/buildHapiMcpBridge'; import { convertAgentMessage } from '@/agent/messageConverter'; @@ -10,7 +11,7 @@ import type { OpencodeMode, PermissionMode } from './types'; import { RPC_METHODS } from '@hapi/protocol/rpcMethods'; import { createOpencodeBackend } from './utils/opencodeBackend'; import { OpencodePermissionHandler } from './utils/permissionHandler'; -import { PLAN_MODE_INSTRUCTION, TITLE_INSTRUCTION } from './utils/systemPrompt'; +import { OPENCODE_NATIVE_TOOL_INSTRUCTION, PLAN_MODE_INSTRUCTION } from './utils/systemPrompt'; import { resolveThoughtLevelEffort } from './thoughtLevelEffort'; type OpencodeRemoteLauncherOptions = { @@ -56,6 +57,7 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { const messageBuffer = this.messageBuffer; const { server: happyServer, mcpServers } = await buildHapiMcpBridge(session.client, { + enableChangeTitle: false, skillLookup: { workingDirectory: session.path, flavor: 'opencode' } }); this.happyServer = happyServer; @@ -64,6 +66,7 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { cwd: session.path }); this.backend = backend; + registerAcpSessionTitleSync(backend, session.client); backend.onStderrError((error) => { logger.debug('[opencode-remote] stderr error', error); @@ -274,7 +277,7 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { messageText = `${PLAN_MODE_INSTRUCTION}\n\n${messageText}`; } if (!this.instructionsSent) { - messageText = `${TITLE_INSTRUCTION}\n\n${messageText}`; + messageText = `${OPENCODE_NATIVE_TOOL_INSTRUCTION}\n\n${messageText}`; this.instructionsSent = true; } @@ -289,6 +292,7 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { await backend.prompt(acpSessionId, promptContent, (message: AgentMessage) => { this.handleAgentMessage(message); }); + void backend.refreshSessionInfo(acpSessionId, session.path); } catch (error) { logger.warn('[opencode-remote] prompt failed', error); session.sendSessionEvent({ diff --git a/cli/src/opencode/utils/systemPrompt.ts b/cli/src/opencode/utils/systemPrompt.ts index 1061279b..1c22fad1 100644 --- a/cli/src/opencode/utils/systemPrompt.ts +++ b/cli/src/opencode/utils/systemPrompt.ts @@ -17,6 +17,15 @@ export const TITLE_INSTRUCTION = trimIdent(` ${SKILL_LOOKUP_INSTRUCTION} `); +/** + * Tool instructions for native ACP sessions. Title updates come from ACP, so + * advertise only the MCP tools that remain available to the model. + */ +export const OPENCODE_NATIVE_TOOL_INSTRUCTION = trimIdent(` + When you create or find a local image file that the user should see, call the tool "hapi_display_image" with the image path so HAPI can show it inline. + ${SKILL_LOOKUP_INSTRUCTION} +`); + /** * The system prompt to inject for OpenCode sessions. */