mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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)
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 }
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -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<any, any>('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<any, any>('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<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');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 () => {})
|
||||
}))
|
||||
|
||||
@@ -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 () => ({
|
||||
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) => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user