mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
feat(cli): wire Cursor /summarize and /clear slash builtins (#747)
* feat(cursor): wire /summarize and /clear slash builtins for remote sessions Seed cursor builtins for web autocomplete, parse summarize/clear in cursorRemoteLauncher (pass-through to agent -p; reject /clear with args). Fixes tiann/hapi#738 Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cursor): isolate slash commands before message queue batching Parse summarize/clear at enqueue time (runCursor) with pushIsolateAndClear so waitForMessagesAndGetAsString never merges a slash with the next prompt. Adds queue policy tests for invalid /clear + following message. Addresses PR #747 review. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cursor): preserve pending messages when isolating slash commands pushIsolateAndClear() wipes the entire queue, so a normal prompt queued before /summarize or /clear would be silently dropped. Add pushIsolated() - isolation without clearing - and route Cursor slash commands through it instead. Adds queue tests covering the preserve-then-isolate path. Addresses PR #747 review. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
||||
import type { CursorSession } from './session';
|
||||
import type { CursorStreamEvent } from './utils/cursorEventConverter';
|
||||
import { parseCursorEvent, convertCursorEventToAgentMessage } from './utils/cursorEventConverter';
|
||||
import { parseCursorSpecialCommand } from './cursorSpecialCommands';
|
||||
|
||||
function buildAgentArgs(opts: {
|
||||
message: string;
|
||||
@@ -97,10 +98,29 @@ class CursorRemoteLauncher extends RemoteLauncherBase {
|
||||
}
|
||||
|
||||
const { message, mode } = batch;
|
||||
const specialCommand = parseCursorSpecialCommand(message);
|
||||
|
||||
if (specialCommand.type === 'invalid') {
|
||||
session.sendSessionEvent({ type: 'message', message: specialCommand.message });
|
||||
messageBuffer.addMessage(specialCommand.message, 'status');
|
||||
if (session.queue.size() === 0 && !this.shouldExit) {
|
||||
sendReady();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const { mode: agentMode, yolo } = permissionModeToAgentArgs(mode.permissionMode as string);
|
||||
this.applyDisplayMode(mode.permissionMode as string);
|
||||
messageBuffer.addMessage(message, 'user');
|
||||
|
||||
if (specialCommand.type === 'summarize') {
|
||||
logger.debug('[cursor-remote] /summarize — pass-through to agent -p');
|
||||
messageBuffer.addMessage('Context summarization requested', 'status');
|
||||
} else if (specialCommand.type === 'clear') {
|
||||
logger.debug('[cursor-remote] /clear — pass-through to agent -p');
|
||||
messageBuffer.addMessage('Context clear requested', 'status');
|
||||
}
|
||||
|
||||
const args = buildAgentArgs({
|
||||
message,
|
||||
cwd: session.path,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseCursorSpecialCommand } from './cursorSpecialCommands';
|
||||
|
||||
describe('parseCursorSpecialCommand', () => {
|
||||
it('accepts /summarize with optional instructions', () => {
|
||||
expect(parseCursorSpecialCommand('/summarize')).toEqual({
|
||||
type: 'summarize',
|
||||
message: '/summarize'
|
||||
});
|
||||
expect(parseCursorSpecialCommand(' /summarize keep peer relocate recap ')).toEqual({
|
||||
type: 'summarize',
|
||||
message: '/summarize keep peer relocate recap'
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts exact /clear', () => {
|
||||
expect(parseCursorSpecialCommand(' /clear ')).toEqual({ type: 'clear' });
|
||||
});
|
||||
|
||||
it('rejects /clear with arguments', () => {
|
||||
expect(parseCursorSpecialCommand('/clear now')).toEqual({
|
||||
type: 'invalid',
|
||||
command: 'clear',
|
||||
message: '/clear does not accept arguments'
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores regular slash-like messages', () => {
|
||||
expect(parseCursorSpecialCommand('/summarizer')).toEqual({ type: null });
|
||||
expect(parseCursorSpecialCommand('please /summarize')).toEqual({ type: null });
|
||||
expect(parseCursorSpecialCommand('/clearing')).toEqual({ type: null });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
export type CursorSpecialCommand =
|
||||
| { type: 'summarize'; message: string }
|
||||
| { type: 'clear' }
|
||||
| { type: 'invalid'; command: 'clear'; message: string }
|
||||
| { type: null };
|
||||
|
||||
/**
|
||||
* Parse Cursor-specific slash commands for remote sessions.
|
||||
* Summarize accepts optional trailing instructions after the command.
|
||||
* Messages are still passed verbatim to `agent -p` — this parser is for detection and UI contract only.
|
||||
*/
|
||||
export function parseCursorSpecialCommand(message: string): CursorSpecialCommand {
|
||||
const trimmed = message.trim();
|
||||
|
||||
if (trimmed === '/summarize' || trimmed.startsWith('/summarize ')) {
|
||||
return { type: 'summarize', message: trimmed };
|
||||
}
|
||||
|
||||
if (trimmed === '/clear') {
|
||||
return { type: 'clear' };
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('/clear ')) {
|
||||
return {
|
||||
type: 'invalid',
|
||||
command: 'clear',
|
||||
message: '/clear does not accept arguments'
|
||||
};
|
||||
}
|
||||
|
||||
return { type: null };
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { MessageQueue2 } from '@/utils/MessageQueue2';
|
||||
import { parseCursorSpecialCommand } from './cursorSpecialCommands';
|
||||
import { enqueueCursorUserMessage } from './cursorUserMessageQueue';
|
||||
import type { EnhancedMode } from './loop';
|
||||
|
||||
const mode: EnhancedMode = { permissionMode: 'default' };
|
||||
|
||||
describe('enqueueCursorUserMessage', () => {
|
||||
it('does not batch invalid /clear with a following prompt', async () => {
|
||||
const queue = new MessageQueue2<EnhancedMode>((m) => m.permissionMode);
|
||||
enqueueCursorUserMessage(queue, '/clear now', mode, 'a');
|
||||
enqueueCursorUserMessage(queue, 'continue work', mode, 'b');
|
||||
|
||||
const first = await queue.waitForMessagesAndGetAsString();
|
||||
expect(first?.message).toBe('/clear now');
|
||||
expect(parseCursorSpecialCommand(first!.message).type).toBe('invalid');
|
||||
|
||||
const second = await queue.waitForMessagesAndGetAsString();
|
||||
expect(second?.message).toBe('continue work');
|
||||
});
|
||||
|
||||
it('isolates /summarize from a following same-mode prompt', async () => {
|
||||
const queue = new MessageQueue2<EnhancedMode>((m) => m.permissionMode);
|
||||
enqueueCursorUserMessage(queue, '/summarize keep recap', mode, 'a');
|
||||
enqueueCursorUserMessage(queue, 'next task', mode, 'b');
|
||||
|
||||
const first = await queue.waitForMessagesAndGetAsString();
|
||||
expect(first?.message).toBe('/summarize keep recap');
|
||||
expect(parseCursorSpecialCommand(first!.message).type).toBe('summarize');
|
||||
|
||||
const second = await queue.waitForMessagesAndGetAsString();
|
||||
expect(second?.message).toBe('next task');
|
||||
});
|
||||
|
||||
it('preserves a normal prompt queued before a slash command', async () => {
|
||||
const queue = new MessageQueue2<EnhancedMode>((m) => m.permissionMode);
|
||||
enqueueCursorUserMessage(queue, 'first work', mode, 'a');
|
||||
enqueueCursorUserMessage(queue, '/summarize', mode, 'b');
|
||||
enqueueCursorUserMessage(queue, 'after summarize', mode, 'c');
|
||||
|
||||
const first = await queue.waitForMessagesAndGetAsString();
|
||||
expect(first?.message).toBe('first work');
|
||||
expect(first?.isolate).toBe(false);
|
||||
|
||||
const second = await queue.waitForMessagesAndGetAsString();
|
||||
expect(second?.message).toBe('/summarize');
|
||||
expect(second?.isolate).toBe(true);
|
||||
|
||||
const third = await queue.waitForMessagesAndGetAsString();
|
||||
expect(third?.message).toBe('after summarize');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { EnhancedMode } from './loop';
|
||||
import { parseCursorSpecialCommand } from './cursorSpecialCommands';
|
||||
import type { MessageQueue2 } from '@/utils/MessageQueue2';
|
||||
|
||||
/**
|
||||
* Enqueue a Cursor user message. Special slash commands are isolated so they are
|
||||
* never newline-batched with adjacent same-mode prompts.
|
||||
*/
|
||||
export function enqueueCursorUserMessage(
|
||||
messageQueue: MessageQueue2<EnhancedMode>,
|
||||
formattedText: string,
|
||||
enhancedMode: EnhancedMode,
|
||||
localId?: string
|
||||
): void {
|
||||
const specialCommand = parseCursorSpecialCommand(formattedText);
|
||||
if (specialCommand.type !== null) {
|
||||
messageQueue.pushIsolated(formattedText.trim(), enhancedMode, localId);
|
||||
return;
|
||||
}
|
||||
messageQueue.push(formattedText, enhancedMode, localId);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { createModeChangeHandler, createRunnerLifecycle, setControlledByUser } f
|
||||
import { registerSessionConfigRpc } from '@/agent/sessionConfigRpc';
|
||||
import { formatMessageWithAttachments } from '@/utils/attachmentFormatter';
|
||||
import { getInvokedCwd } from '@/utils/invokedCwd';
|
||||
import { enqueueCursorUserMessage } from './cursorUserMessageQueue';
|
||||
|
||||
const formatFailureReason = (message: string): string => {
|
||||
const maxLength = 200;
|
||||
@@ -96,7 +97,7 @@ export async function runCursor(opts: {
|
||||
model: currentModel
|
||||
};
|
||||
const formattedText = formatMessageWithAttachments(message.content.text, message.content.attachments);
|
||||
messageQueue.push(formattedText, enhancedMode, localId);
|
||||
enqueueCursorUserMessage(messageQueue, formattedText, enhancedMode, localId);
|
||||
});
|
||||
|
||||
session.onCancelQueuedMessage((localId) => {
|
||||
|
||||
@@ -367,6 +367,28 @@ describe('MessageQueue2', () => {
|
||||
expect(batch1?.mode.type).toBe('A');
|
||||
});
|
||||
|
||||
it('should preserve pending messages when pushIsolated is used', async () => {
|
||||
const queue = new MessageQueue2<{ type: string }>((mode) => mode.type);
|
||||
|
||||
queue.push('message1', { type: 'A' });
|
||||
queue.push('message2', { type: 'A' });
|
||||
|
||||
queue.pushIsolated('isolated', { type: 'A' });
|
||||
|
||||
queue.push('message3', { type: 'A' });
|
||||
|
||||
const batch1 = await queue.waitForMessagesAndGetAsString();
|
||||
expect(batch1?.message).toBe('message1\nmessage2');
|
||||
expect(batch1?.isolate).toBe(false);
|
||||
|
||||
const batch2 = await queue.waitForMessagesAndGetAsString();
|
||||
expect(batch2?.message).toBe('isolated');
|
||||
expect(batch2?.isolate).toBe(true);
|
||||
|
||||
const batch3 = await queue.waitForMessagesAndGetAsString();
|
||||
expect(batch3?.message).toBe('message3');
|
||||
});
|
||||
|
||||
it('should isolate messages pushed with pushIsolateAndClear', async () => {
|
||||
const queue = new MessageQueue2<{ type: string }>((mode) => mode.type);
|
||||
|
||||
|
||||
@@ -107,6 +107,43 @@ export class MessageQueue2<T> {
|
||||
logger.debug(`[MessageQueue2] pushImmediate() completed. Queue size: ${this.queue.length}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a message that must be processed in isolation, preserving any
|
||||
* messages already queued ahead of it. The new message is never batched
|
||||
* with siblings (neither the ones before it, nor any that arrive after).
|
||||
* Use this when a slash command must run alone but earlier prompts must
|
||||
* still be delivered in order.
|
||||
*/
|
||||
pushIsolated(message: string, mode: T, localId?: string): void {
|
||||
if (this.closed) {
|
||||
throw new Error('Cannot push to closed queue');
|
||||
}
|
||||
|
||||
const modeHash = this.modeHasher(mode);
|
||||
logger.debug(`[MessageQueue2] pushIsolated() called with mode hash: ${modeHash} - preserving ${this.queue.length} pending messages`);
|
||||
|
||||
this.queue.push({
|
||||
message,
|
||||
mode,
|
||||
modeHash,
|
||||
localId,
|
||||
isolate: true
|
||||
});
|
||||
|
||||
if (this.onMessageHandler) {
|
||||
this.onMessageHandler(message, mode);
|
||||
}
|
||||
|
||||
if (this.waiter) {
|
||||
logger.debug(`[MessageQueue2] Notifying waiter for isolated message`);
|
||||
const waiter = this.waiter;
|
||||
this.waiter = null;
|
||||
waiter(true);
|
||||
}
|
||||
|
||||
logger.debug(`[MessageQueue2] pushIsolated() completed. Queue size: ${this.queue.length}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a message that must be processed in complete isolation.
|
||||
* Clears any pending messages and ensures this message is never batched with others.
|
||||
|
||||
@@ -39,7 +39,10 @@ export const BUILTIN_SLASH_COMMANDS = {
|
||||
{ name: 'default', description: 'Return OpenCode permission mode to default', source: 'builtin' },
|
||||
{ name: 'init', description: 'Generate or refresh AGENTS.md for this project', source: 'builtin' },
|
||||
],
|
||||
cursor: [],
|
||||
cursor: [
|
||||
{ name: 'summarize', description: 'Summarize conversation context to free window space (pass-through to Cursor agent)', source: 'builtin' },
|
||||
{ name: 'clear', description: 'Clear conversation context if supported by Cursor agent', source: 'builtin' },
|
||||
],
|
||||
} as const satisfies Record<string, readonly SlashCommand[]>
|
||||
|
||||
export function getBuiltinSlashCommands(agent: string): SlashCommand[] {
|
||||
|
||||
@@ -19,6 +19,12 @@ describe('getBuiltinSlashCommands', () => {
|
||||
'permission',
|
||||
]))
|
||||
})
|
||||
|
||||
it('exposes Cursor summarize and clear builtins', () => {
|
||||
expect(getBuiltinSlashCommands('cursor').map((command) => command.name)).toEqual(
|
||||
expect.arrayContaining(['summarize', 'clear'])
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeSlashCommands', () => {
|
||||
|
||||
Reference in New Issue
Block a user