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
This commit is contained in:
SSU-WEI HUANG
2026-07-24 10:59:47 +08:00
committed by GitHub
parent 72476b9cea
commit 40c1789357
14 changed files with 339 additions and 51 deletions
+31
View File
@@ -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)
});
});
});
+35
View File
@@ -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<AcpSdkBackend, 'setSessionInfoUpdateListener'>;
type AcpSessionTitleClient = Pick<ApiSessionClient, 'sendClaudeSessionMessage'>;
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()
});
});
}
@@ -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<unknown>;
} | 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<unknown> } | 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 }
]);
});
+72 -14
View File
@@ -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<string, PendingPermission>();
private readonly sessionModelsMetadata = new Map<string, AcpSessionModelsMetadata>();
private readonly sessionConfigOptions = new Map<string, AcpConfigOptionDescriptor[]>();
private readonly sessionInfoRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
private readonly initialAvailableCommands = new Set<string>();
private readonly sessionAvailableCommands = new Map<string, Set<string>>();
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<void> {
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<void> {
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<void> {
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 {
@@ -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'])
})
})
+7 -2
View File
@@ -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,6 +65,7 @@ function createHapiMcpServer(
name: z.string().trim().min(1).max(128).describe('Exact skill name shown by HAPI skill autocomplete'),
});
if (enableChangeTitle) {
mcp.registerTool<any, any>('change_title', {
description: 'Change the title of the current chat session',
title: 'Change Chat Title',
@@ -93,6 +96,7 @@ function createHapiMcpServer(
isError: true,
};
});
}
mcp.registerTool<any, any>('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<string, StreamableHTTPServerTransport>();
const mcps = new Map<string, McpServer>();
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');
}
+6 -3
View File
@@ -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<string, McpServerToolConfig> = {
change_title: {
const tools: Record<string, McpServerToolConfig> = {};
if (options.enableChangeTitle !== false) {
tools.change_title = {
approval_mode: 'approve'
}
};
}
if (options.skillLookup) {
tools.skill_lookup = {
approval_mode: 'approve'
@@ -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 () => {})
@@ -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<typeof createCursorAcpBackend> | 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);
+2
View File
@@ -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 () => {})
}))
+4
View File
@@ -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 });
@@ -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<void>),
setConfigOptionImpl: null as null | ((sessionId: string, configId: string, value: string) => Promise<void>),
@@ -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 () => ({
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) => {
+6 -2
View File
@@ -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({
+9
View File
@@ -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.
*/