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:
HeavyGee
2026-06-01 12:07:57 +08:00
committed by GitHub
co-authored by Cursor
parent 02d4e93178
commit 449cf6af0a
10 changed files with 230 additions and 2 deletions
+20
View File
@@ -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 });
});
});
+32
View File
@@ -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');
});
});
+21
View File
@@ -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);
}
+2 -1
View File
@@ -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) => {
+22
View File
@@ -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);
+37
View File
@@ -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.
+4 -1
View File
@@ -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[] {
+6
View File
@@ -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', () => {