From 0eab6a1125ecd817a78e9651ef9ff2125cd49e9b Mon Sep 17 00:00:00 2001 From: ekko <152005280+EKKOLearnAI@users.noreply.github.com> Date: Mon, 25 May 2026 15:48:17 +0800 Subject: [PATCH] Fix plan command support in web bridge (#1018) * fix: support plan command in web bridge * fix: preserve queued bridge messages * fix: avoid duplicate queued plan messages * fix: preserve plan command semantics --------- Co-authored-by: Codex --- .../src/components/hermes/chat/ChatInput.vue | 1 + .../src/components/hermes/chat/ChatPanel.vue | 36 ++++- packages/client/src/i18n/locales/de.ts | 1 + packages/client/src/i18n/locales/en.ts | 1 + packages/client/src/i18n/locales/es.ts | 1 + packages/client/src/i18n/locales/fr.ts | 1 + packages/client/src/i18n/locales/ja.ts | 1 + packages/client/src/i18n/locales/ko.ts | 1 + packages/client/src/i18n/locales/pt.ts | 1 + packages/client/src/i18n/locales/zh-TW.ts | 1 + packages/client/src/i18n/locales/zh.ts | 1 + packages/client/src/stores/hermes/chat.ts | 34 ++++- .../services/hermes/agent-bridge/client.ts | 8 +- .../hermes/agent-bridge/hermes_bridge.py | 86 +++++++++++- .../hermes/run-chat/handle-bridge-run.ts | 104 +++++++++----- .../src/services/hermes/run-chat/index.ts | 19 ++- .../hermes/run-chat/session-command.ts | 75 +++++++++- .../src/services/hermes/run-chat/types.ts | 3 + .../run-chat-bridge-final-context.test.ts | 60 ++++++++ tests/server/run-chat-queued-item.test.ts | 128 ++++++++++++++++++ tests/server/session-command-plan.test.ts | 108 +++++++++++++++ 21 files changed, 622 insertions(+), 49 deletions(-) create mode 100644 tests/server/run-chat-queued-item.test.ts create mode 100644 tests/server/session-command-plan.test.ts diff --git a/packages/client/src/components/hermes/chat/ChatInput.vue b/packages/client/src/components/hermes/chat/ChatInput.vue index 43bebe1..44d96d3 100644 --- a/packages/client/src/components/hermes/chat/ChatInput.vue +++ b/packages/client/src/components/hermes/chat/ChatInput.vue @@ -28,6 +28,7 @@ const bridgeCommands = computed(() => [ { name: 'status', args: '', description: t('chat.slashCommands.status') }, { name: 'abort', args: '', description: t('chat.slashCommands.abort') }, { name: 'queue', args: t('chat.slashCommandArgs.message'), description: t('chat.slashCommands.queue') }, + { name: 'plan', args: t('chat.slashCommandArgs.text'), description: t('chat.slashCommands.plan') }, { name: 'clear', args: '', description: t('chat.slashCommands.clear') }, { name: 'clear', args: '--history', insertText: 'clear --history', description: t('chat.slashCommands.clearHistory') }, { name: 'title', args: t('chat.slashCommandArgs.title'), description: t('chat.slashCommands.title') }, diff --git a/packages/client/src/components/hermes/chat/ChatPanel.vue b/packages/client/src/components/hermes/chat/ChatPanel.vue index 4ce9aae..c85efd3 100644 --- a/packages/client/src/components/hermes/chat/ChatPanel.vue +++ b/packages/client/src/components/hermes/chat/ChatPanel.vue @@ -1307,7 +1307,7 @@ async function handleSessionModelCustomSubmit() { {{ t('chat.clarifyDismiss') }} -
+
{ const timestamp = typeof peer?.timestamp === 'number' && Number.isFinite(peer.timestamp) ? Math.round(peer.timestamp * 1000) : Date.now() + const role = peer?.role === 'command' ? 'command' : 'user' return [{ id: messageId, - role: 'user' as const, + role, content, timestamp, queued: true, + systemType: role === 'command' ? 'command' as const : undefined, }] }) } @@ -1028,11 +1030,12 @@ export const useChatStore = defineStore('chat', () => { enqueueUserMessage(sessionId, { ...(existing || {}), id: messageId, - role: 'user', + role: peer?.role === 'command' ? 'command' : 'user', content, timestamp: existing?.timestamp || timestamp, attachments: existing?.attachments, queued: true, + systemType: peer?.role === 'command' ? 'command' : existing?.systemType, }) } @@ -1093,6 +1096,22 @@ export const useChatStore = defineStore('chat', () => { pendingClarifies.value = new Map(pendingClarifies.value) } + function clearPendingInteractions(sessionId: string) { + let changed = false + if (pendingApprovals.value.has(sessionId)) { + pendingApprovals.value.delete(sessionId) + changed = true + } + if (pendingClarifies.value.has(sessionId)) { + pendingClarifies.value.delete(sessionId) + changed = true + } + if (changed) { + pendingApprovals.value = new Map(pendingApprovals.value) + pendingClarifies.value = new Map(pendingClarifies.value) + } + } + function respondToClarify(response: string) { const pending = activePendingClarify.value if (!pending) return @@ -1169,8 +1188,9 @@ export const useChatStore = defineStore('chat', () => { : false const isBridgeSlashCommand = content.trim().startsWith('/') const isBridgeCompressCommand = isBridgeSlashCommand && /^\/compress(?:\s|$)/i.test(content.trim()) + const isBridgePlanCommand = isBridgeSlashCommand && /^\/plan(?:\s|$)/i.test(content.trim()) const wasLiveBeforeSend = isSessionLive(sid) - const shouldQueue = wasLiveBeforeSend && !isBridgeSlashCommand + const shouldQueue = wasLiveBeforeSend && (!isBridgeSlashCommand || isBridgePlanCommand) const userMsg: Message = { id: uid(), @@ -1449,6 +1469,7 @@ export const useChatStore = defineStore('chat', () => { case 'abort.completed': { setAbortState({ aborting: false, synced: (evt as any).synced ?? false }) + clearPendingInteractions(sid) if ((evt as any).queue_length > 0) { queueLengths.value.set(sid, (evt as any).queue_length) setAbortState(null) @@ -1798,7 +1819,7 @@ export const useChatStore = defineStore('chat', () => { { onReconnectResume: applyReconnectResume }, ) - if (!isBridgeSlashCommand || isBridgeCompressCommand) { + if (!isBridgeSlashCommand || isBridgeCompressCommand || isBridgePlanCommand) { streamStates.value.set(sid, ctrl) } } catch (err: any) { @@ -1920,6 +1941,7 @@ export const useChatStore = defineStore('chat', () => { case 'abort.completed': { setAbortState({ aborting: false, synced: (evt as any).synced ?? false }) + clearPendingInteractions(sid) if ((evt as any).queue_length > 0) { queueLengths.value.set(sid, (evt as any).queue_length) setAbortState(null) @@ -2286,10 +2308,11 @@ export const useChatStore = defineStore('chat', () => { const message: Message = { id: messageId || uid(), - role: 'user', + role: peer?.role === 'command' ? 'command' : 'user', content, timestamp, queued: !!peer?.queued, + systemType: peer?.role === 'command' ? 'command' : undefined, } if (peer?.queued) { enqueueUserMessage(sid, message) @@ -2307,6 +2330,7 @@ export const useChatStore = defineStore('chat', () => { const sid = activeSessionId.value if (!sid) return if (isAborting.value) return + clearPendingInteractions(sid) const ctrl = streamStates.value.get(sid) if (ctrl) { setAbortState({ aborting: true, synced: null }) diff --git a/packages/server/src/services/hermes/agent-bridge/client.ts b/packages/server/src/services/hermes/agent-bridge/client.ts index 2798f86..e727e29 100644 --- a/packages/server/src/services/hermes/agent-bridge/client.ts +++ b/packages/server/src/services/hermes/agent-bridge/client.ts @@ -109,7 +109,12 @@ export interface AgentBridgeCommandResult extends AgentBridgeResponse { session_id: string command: string handled: boolean + type?: string message?: string + output?: string + notice?: string + loaded?: string[] + missing?: string[] new_session_id?: string history?: unknown[] retry?: boolean @@ -405,11 +410,12 @@ export class AgentBridgeClient { }) } - command(sessionId: string, command: string): Promise { + command(sessionId: string, command: string, profile?: string): Promise { return this.request({ action: 'command', session_id: sessionId, command, + ...(profile ? { profile } : {}), }) } diff --git a/packages/server/src/services/hermes/agent-bridge/hermes_bridge.py b/packages/server/src/services/hermes/agent-bridge/hermes_bridge.py index f77dc0d..9216071 100755 --- a/packages/server/src/services/hermes/agent-bridge/hermes_bridge.py +++ b/packages/server/src/services/hermes/agent-bridge/hermes_bridge.py @@ -1470,6 +1470,80 @@ class AgentPool: with session.lock: return {"session_id": session_id, "history": copy.deepcopy(session.history)} + def dispatch_command(self, session_id: str, command: str, profile: str | None = None) -> dict[str, Any]: + raw = str(command or "").strip() + if raw.startswith("/"): + raw = raw[1:].strip() + if not raw: + raise ValueError("command is required") + + parts = raw.split(maxsplit=1) + name = parts[0].lstrip("/").strip().lower() + arg = parts[1] if len(parts) > 1 else "" + + with _profile_env(profile): + try: + try: + from agent.skill_bundles import ( + build_bundle_invocation_message, + resolve_bundle_command_key, + ) + + bundle_key = resolve_bundle_command_key(name) + if bundle_key: + bundle_result = build_bundle_invocation_message( + bundle_key, + arg, + task_id=session_id, + ) + if bundle_result: + message, loaded_names, missing_names = bundle_result + return { + "session_id": session_id, + "command": name, + "handled": True, + "type": "bundle", + "message": message, + "loaded": loaded_names, + "missing": missing_names, + } + except ImportError: + pass + + from agent.skill_commands import ( + build_skill_invocation_message, + resolve_skill_command_key, + ) + + key = resolve_skill_command_key(name) + if key: + message = build_skill_invocation_message( + key, + arg, + task_id=session_id, + runtime_note=( + "If you need user clarification, call the clarify tool. " + "Do not output raw JSON question/choices payloads as the final response." + ), + ) + if message: + return { + "session_id": session_id, + "command": name, + "handled": True, + "type": "skill", + "message": message, + } + except Exception as exc: + raise RuntimeError(f"skill command dispatch failed: {exc}") from exc + + return { + "session_id": session_id, + "command": name, + "handled": False, + "message": f"not a supported bridge command: /{name}", + } + def get_result(self, run_id: str) -> dict[str, Any]: with self._lock: record = self._runs.get(run_id) @@ -1701,6 +1775,16 @@ class BridgeServer: if action == "get_history": return self.pool.get_history(str(req.get("session_id") or "")) + if action == "command": + session_id = str(req.get("session_id") or "").strip() + if not session_id: + raise ValueError("session_id is required") + return self.pool.dispatch_command( + session_id, + str(req.get("command") or ""), + req.get("profile"), + ) + if action == "destroy": return self.pool.destroy(str(req.get("session_id") or "")) @@ -2275,7 +2359,7 @@ class BridgeBroker: profile = self._profile_for_run(str(req.get("run_id") or "")) return self._forward(profile, req) - if action in {"interrupt", "steer", "get_history", "destroy"}: + if action in {"interrupt", "steer", "command", "get_history", "destroy"}: session_id = str(req.get("session_id") or "") profile = self._profile_for_session(session_id, req.get("profile")) resp = self._forward(profile, req) diff --git a/packages/server/src/services/hermes/run-chat/handle-bridge-run.ts b/packages/server/src/services/hermes/run-chat/handle-bridge-run.ts index e52fe79..74c461b 100644 --- a/packages/server/src/services/hermes/run-chat/handle-bridge-run.ts +++ b/packages/server/src/services/hermes/run-chat/handle-bridge-run.ts @@ -126,11 +126,11 @@ function cacheBridgeContext(state: SessionState, data: Record | export async function handleBridgeRun( nsp: ReturnType, socket: Socket, - data: { input: string | ContentBlock[]; session_id?: string; model?: string; provider?: string; model_groups?: RunModelGroup[]; instructions?: string; source?: string; queue_id?: string; peerExcludeSocketId?: string }, + data: { input: string | ContentBlock[]; display_input?: string | ContentBlock[] | null; display_role?: 'user' | 'command'; storage_message?: string; session_id?: string; model?: string; provider?: string; model_groups?: RunModelGroup[]; instructions?: string; source?: string; queue_id?: string; peerExcludeSocketId?: string }, profile: string, sessionMap: Map, bridge: AgentBridgeClient, - _skipUserMessage = false, + skipUserMessage = false, loadSessionStateFromDbFn: (sid: string, sessionMap: Map) => Promise, dequeueNextQueuedRun: (socket: Socket, sessionId: string, fallbackProfile?: string) => void, ) { @@ -193,42 +193,55 @@ export async function handleBridgeRun( state.bridgePendingTools = [] state.responseRun = undefined - const inputStr = contentBlocksToString(input) - state.messages.push({ - id: state.messages.length + 1, - session_id, - runMarker, - role: 'user', - content: inputStr, - timestamp: now, - }) + const displayInput = data.display_input === undefined ? input : data.display_input + const inputStr = displayInput == null ? '' : contentBlocksToString(displayInput) + const shouldPersistUserMessage = !skipUserMessage && displayInput !== null + const displayRole = data.display_role === 'command' ? 'command' : 'user' + let messageId: number | string | undefined - if (!getSession(session_id)) { - const previewText = extractTextForPreview(input) + if (shouldPersistUserMessage) { + state.messages.push({ + id: state.messages.length + 1, + session_id, + runMarker, + role: displayRole, + content: inputStr, + timestamp: now, + }) + + if (!getSession(session_id)) { + const previewText = extractTextForPreview(displayInput || input) + const preview = previewText.replace(/[\r\n]/g, ' ').substring(0, 100) + createSession({ id: session_id, profile, source: 'cli', model: resolvedModel, provider: resolvedProvider, title: preview }) + } + messageId = addMessage({ + session_id, + role: displayRole, + content: inputStr, + timestamp: now, + }) + } else if (!getSession(session_id)) { + const previewText = displayInput === null ? extractTextForPreview(input) : extractTextForPreview(displayInput || input) const preview = previewText.replace(/[\r\n]/g, ' ').substring(0, 100) createSession({ id: session_id, profile, source: 'cli', model: resolvedModel, provider: resolvedProvider, title: preview }) } - const messageId = addMessage({ - session_id, - role: 'user', - content: inputStr, - timestamp: now, - }) socket.join(`session:${session_id}`) - const peerTarget = data.peerExcludeSocketId - ? nsp.to(`session:${session_id}`).except(data.peerExcludeSocketId) - : socket.to(`session:${session_id}`) - peerTarget.emit('run.peer_user_message', { - event: 'run.peer_user_message', - session_id, - message: { - id: data.queue_id || messageId, - role: 'user', - content: inputStr, - timestamp: now, - }, - }) + if (shouldPersistUserMessage) { + const peerTarget = data.peerExcludeSocketId + ? nsp.to(`session:${session_id}`).except(data.peerExcludeSocketId) + : socket.to(`session:${session_id}`) + peerTarget.emit('run.peer_user_message', { + event: 'run.peer_user_message', + session_id, + message: { + id: data.queue_id || messageId, + role: displayRole, + content: inputStr, + timestamp: now, + }, + }) + } const emit = (event: string, payload: any) => { const tagged = { ...payload, session_id } nsp.to(`session:${session_id}`).emit(event, tagged) @@ -278,9 +291,11 @@ export async function handleBridgeRun( const bridgeInput = isContentBlockArray(input) ? await convertContentBlocksForAgent(input) : input - const bridgeStorageInput = isContentBlockArray(input) - ? inputStr - : undefined + const bridgeStorageInput = data.storage_message !== undefined + ? data.storage_message + : isContentBlockArray(input) + ? inputStr + : undefined logger.info('[chat-run-socket] starting CLI bridge run for session %s', session_id) bridgeLogger.info({ sessionId: session_id, @@ -606,6 +621,25 @@ async function applyBridgeChunkAsync( } replaceState(sessionMap, sessionId, 'approval.resolved', payload) emit('approval.resolved', payload) + } else if (evType === 'clarify.requested') { + const payload = { + event: 'clarify.requested', + run_id: chunk.run_id, + clarify_id: ev.clarify_id, + question: ev.question, + choices: Array.isArray(ev.choices) ? ev.choices : null, + timeout_ms: ev.timeout_ms, + } + replaceState(sessionMap, sessionId, 'clarify.requested', payload) + emit('clarify.requested', payload) + } else if (evType === 'clarify.resolved') { + const payload = { + event: 'clarify.resolved', + run_id: chunk.run_id, + clarify_id: ev.clarify_id, + } + replaceState(sessionMap, sessionId, 'clarify.resolved', payload) + emit('clarify.resolved', payload) } else if (evType === 'bridge.compression.requested') { const bridgeHistory = await buildDbHistory(sessionId, { excludeLastUser: true }) const bridgeUsage = estimateUsageTokensFromMessages(bridgeHistory) diff --git a/packages/server/src/services/hermes/run-chat/index.ts b/packages/server/src/services/hermes/run-chat/index.ts index f2a35eb..b0229aa 100644 --- a/packages/server/src/services/hermes/run-chat/index.ts +++ b/packages/server/src/services/hermes/run-chat/index.ts @@ -99,6 +99,9 @@ export class ChatRunSocket { socket.on('run', async (data: { input: string | ContentBlock[] + display_input?: string | ContentBlock[] | null + display_role?: 'user' | 'command' + storage_message?: string session_id?: string model?: string instructions?: string @@ -133,6 +136,7 @@ export class ChatRunSocket { profile: runProfile, model: data.model, instructions: data.instructions, + queueId: data.queue_id, runQueuedItem: this.runQueuedItem.bind(this), }) } catch (err) { @@ -268,6 +272,9 @@ export class ChatRunSocket { socket: Socket, data: { input: string | ContentBlock[] + display_input?: string | ContentBlock[] | null + display_role?: 'user' | 'command' + storage_message?: string session_id?: string model?: string provider?: string @@ -358,8 +365,12 @@ export class ChatRunSocket { } private runQueuedItem(socket: Socket, sessionId: string, next: QueuedRun, fallbackProfile = 'default') { + const skipUserMessage = next.displayInput === null void this.handleRun(socket, { input: next.input, + display_input: next.displayInput, + display_role: next.displayRole, + storage_message: next.storageMessage, session_id: sessionId, model: next.model, provider: next.provider, @@ -368,7 +379,7 @@ export class ChatRunSocket { source: next.source, queue_id: next.queue_id, peerExcludeSocketId: next.originSocketId, - }, next.profile || fallbackProfile, true) + }, next.profile || fallbackProfile, skipUserMessage) } // --- Helpers --- @@ -384,8 +395,10 @@ export class ChatRunSocket { private serializeQueuedMessages(queue: QueuedRun[]) { return queue.map(item => ({ id: item.queue_id, - role: 'user', - content: contentBlocksToString(item.input), + role: item.displayRole || (typeof item.displayInput === 'string' && item.displayInput.trim().startsWith('/') ? 'command' : 'user'), + content: item.displayInput === null + ? (item.storageMessage || '') + : contentBlocksToString(item.displayInput ?? item.input), timestamp: Math.floor(Date.now() / 1000), queued: true, })) diff --git a/packages/server/src/services/hermes/run-chat/session-command.ts b/packages/server/src/services/hermes/run-chat/session-command.ts index db31a85..1b3be79 100644 --- a/packages/server/src/services/hermes/run-chat/session-command.ts +++ b/packages/server/src/services/hermes/run-chat/session-command.ts @@ -14,6 +14,7 @@ type CommandName = | 'status' | 'abort' | 'queue' + | 'plan' | 'clear' | 'title' | 'compress' @@ -34,6 +35,7 @@ interface SessionCommandContext { profile: string model?: string instructions?: string + queueId?: string runQueuedItem: (socket: Socket, sessionId: string, next: QueuedRun, fallbackProfile?: string) => void } @@ -42,6 +44,7 @@ const COMMAND_ALIASES: Record = { status: 'status', abort: 'abort', queue: 'queue', + plan: 'plan', clear: 'clear', title: 'title', compress: 'compress', @@ -74,7 +77,9 @@ export async function handleSessionCommand( const state = getOrCreateSession(ctx.sessionMap, sessionId) ctx.socket.join(`session:${sessionId}`) ensureCommandSession(sessionId, ctx) - persistCommandMessage(sessionId, state, `/${command.rawName}${command.args ? ` ${command.args}` : ''}`) + if (command.name !== 'plan') { + persistCommandMessage(sessionId, state, `/${command.rawName}${command.args ? ` ${command.args}` : ''}`) + } const emitCommand = (payload: Record) => { const message = typeof payload.message === 'string' ? payload.message : '' @@ -182,6 +187,74 @@ export async function handleSessionCommand( return } + case 'plan': { + const bridgeCommand = `plan${command.args ? ` ${command.args}` : ''}` + let result + try { + result = await ctx.bridge.command(sessionId, bridgeCommand, ctx.profile) + } catch (err) { + emitCommand({ + ok: false, + action: 'plan', + terminal: !state.isWorking, + message: `Plan command failed: ${err instanceof Error ? err.message : String(err)}`, + }) + return + } + + if (!result.handled || !result.message) { + emitCommand({ + ok: false, + action: 'plan', + terminal: !state.isWorking, + message: result.message || 'Plan command is not available.', + }) + return + } + + const queueId = ctx.queueId || `queue_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}` + const displayCommand = `/${bridgeCommand}` + const next: QueuedRun = { + queue_id: queueId, + input: result.message, + displayInput: displayCommand, + displayRole: 'command', + storageMessage: displayCommand, + model: ctx.model, + instructions: ctx.instructions, + profile: ctx.profile, + source: 'cli', + originSocketId: ctx.socket.id, + } + + if (state.isWorking) { + state.queue.push(next) + emitToSession(ctx.nsp, ctx.socket, sessionId, 'run.queued', { + event: 'run.queued', + session_id: sessionId, + queue_length: state.queue.length, + queued_messages: state.queue.map(item => ({ + id: item.queue_id, + role: typeof item.displayInput === 'string' && item.displayInput.trim().startsWith('/') ? 'command' : 'user', + content: item.displayInput === null + ? (item.storageMessage || '') + : contentBlocksToString(item.displayInput ?? item.input), + timestamp: Math.floor(Date.now() / 1000), + queued: true, + })), + }) + return + } + + emitCommand({ + action: 'plan', + terminal: false, + started: true, + }) + ctx.runQueuedItem(ctx.socket, sessionId, next, ctx.profile) + return + } + case 'clear': { if (command.args === '--history') { if (state.isWorking) { diff --git a/packages/server/src/services/hermes/run-chat/types.ts b/packages/server/src/services/hermes/run-chat/types.ts index a0974c7..f57897e 100644 --- a/packages/server/src/services/hermes/run-chat/types.ts +++ b/packages/server/src/services/hermes/run-chat/types.ts @@ -28,6 +28,9 @@ export interface SessionMessage { export interface QueuedRun { queue_id: string input: string | ContentBlock[] + displayInput?: string | ContentBlock[] | null + displayRole?: 'user' | 'command' + storageMessage?: string model?: string provider?: string model_groups?: Array<{ provider: string; models: string[] }> diff --git a/tests/server/run-chat-bridge-final-context.test.ts b/tests/server/run-chat-bridge-final-context.test.ts index 047347d..c33c7dc 100644 --- a/tests/server/run-chat-bridge-final-context.test.ts +++ b/tests/server/run-chat-bridge-final-context.test.ts @@ -299,6 +299,66 @@ describe('bridge run final context usage', () => { })) }) + it('persists the visible plan command instead of the expanded skill prompt', async () => { + const emit = vi.fn() + const nsp = makeNamespace(emit) + const socket = makeSocket() + const state = makeState() + const sessionMap = new Map([['session-1', state]]) + const bridge = { + chat: vi.fn().mockResolvedValue({ run_id: 'run-1', status: 'started' }), + contextEstimate: vi.fn().mockResolvedValue({ + token_count: 12345, + message_count: 2, + tool_count: 4, + system_prompt_chars: 13, + }), + streamOutput: vi.fn(async function* () { + yield { run_id: 'run-1', done: true, status: 'completed', output: 'planned' } + }), + } as any + + const { handleBridgeRun } = await import('../../packages/server/src/services/hermes/run-chat/handle-bridge-run') + await handleBridgeRun( + nsp, + socket, + { + input: '[IMPORTANT: expanded plan skill prompt]', + display_input: '/plan build the feature', + display_role: 'command', + storage_message: '/plan build the feature', + session_id: 'session-1', + }, + 'default', + sessionMap, + bridge, + false, + vi.fn(), + vi.fn(), + ) + + expect(state.messages.find((message: any) => message.role === 'command')).toEqual(expect.objectContaining({ + role: 'command', + content: '/plan build the feature', + })) + expect(addMessageMock).toHaveBeenCalledWith(expect.objectContaining({ + role: 'command', + content: '/plan build the feature', + })) + expect(addMessageMock).not.toHaveBeenCalledWith(expect.objectContaining({ + role: 'user', + content: '[IMPORTANT: expanded plan skill prompt]', + })) + expect(bridge.chat).toHaveBeenCalledWith( + 'session-1', + '[IMPORTANT: expanded plan skill prompt]', + expect.any(Array), + expect.any(String), + 'default', + expect.objectContaining({ storage_message: '/plan build the feature' }), + ) + }) + it('refreshes full context tokens when a bridge run fails', async () => { const emit = vi.fn() const nsp = makeNamespace(emit) diff --git a/tests/server/run-chat-queued-item.test.ts b/tests/server/run-chat-queued-item.test.ts new file mode 100644 index 0000000..ef0b5ac --- /dev/null +++ b/tests/server/run-chat-queued-item.test.ts @@ -0,0 +1,128 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const handleBridgeRunMock = vi.hoisted(() => vi.fn(async () => {})) +const handleApiRunMock = vi.hoisted(() => vi.fn(async () => {})) + +vi.mock('../../packages/server/src/services/hermes/run-chat/handle-bridge-run', () => ({ + handleBridgeRun: handleBridgeRunMock, +})) + +vi.mock('../../packages/server/src/services/hermes/run-chat/handle-api-run', () => ({ + handleApiRun: handleApiRunMock, + loadSessionStateFromDb: vi.fn(), + resolveRunSource: vi.fn((source?: string) => source || 'cli'), +})) + +vi.mock('../../packages/server/src/services/hermes/run-chat/session-command', () => ({ + handleSessionCommand: vi.fn(), + isSessionCommand: vi.fn(() => false), + parseSessionCommand: vi.fn(() => null), +})) + +vi.mock('../../packages/server/src/services/hermes/agent-bridge', () => ({ + AgentBridgeClient: vi.fn(() => ({})), +})) + +vi.mock('../../packages/server/src/services/logger', () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})) + +vi.mock('../../packages/server/src/lib/llm-prompt', () => ({ + getSystemPrompt: vi.fn(() => 'system prompt'), +})) + +vi.mock('../../packages/server/src/db/hermes/session-store', () => ({ + getSession: vi.fn(() => ({ id: 'session-1', profile: 'default', source: 'cli' })), +})) + +vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({ + getActiveProfileName: vi.fn(() => 'default'), + getProfileDir: vi.fn(() => '/tmp/hermes-default'), + listProfileNamesFromDisk: vi.fn(() => ['default']), +})) + +vi.mock('../../packages/server/src/middleware/user-auth', () => ({ + authenticateUserToken: vi.fn(), + isAuthEnabled: vi.fn(async () => false), +})) + +vi.mock('../../packages/server/src/db/hermes/users-store', () => ({ + userCanAccessProfile: vi.fn(() => true), +})) + +function makeServerHarness() { + const namespace = { + adapter: { rooms: new Map() }, + to: vi.fn(() => ({ emit: vi.fn() })), + use: vi.fn(), + on: vi.fn(), + } + const io = { of: vi.fn(() => namespace) } + const socket = { + id: 'socket-1', + connected: true, + handshake: { auth: {}, query: { profile: 'default' } }, + data: {}, + emit: vi.fn(), + join: vi.fn(), + to: vi.fn(() => ({ emit: vi.fn() })), + on: vi.fn(), + } + return { io, namespace, socket } +} + +describe('ChatRunSocket queued bridge runs', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('persists normal queued bridge messages when they are dequeued', async () => { + const { ChatRunSocket } = await import('../../packages/server/src/services/hermes/run-chat') + const { io, socket } = makeServerHarness() + const server = new ChatRunSocket(io as any) + + ;(server as any).runQueuedItem(socket, 'session-1', { + queue_id: 'queue-normal', + input: 'queued follow-up', + source: 'cli', + profile: 'default', + }, 'default') + + await vi.waitFor(() => expect(handleBridgeRunMock).toHaveBeenCalled()) + const call = handleBridgeRunMock.mock.calls.at(-1)! + expect(call[2]).toEqual(expect.objectContaining({ + input: 'queued follow-up', + display_input: undefined, + storage_message: undefined, + queue_id: 'queue-normal', + })) + expect(call[6]).toBe(false) + }) + + it('persists the visible plan command when dequeuing expanded plan command runs', async () => { + const { ChatRunSocket } = await import('../../packages/server/src/services/hermes/run-chat') + const { io, socket } = makeServerHarness() + const server = new ChatRunSocket(io as any) + + ;(server as any).runQueuedItem(socket, 'session-1', { + queue_id: 'queue-plan', + input: '[IMPORTANT: expanded plan skill prompt]', + displayInput: '/plan build the feature', + displayRole: 'command', + storageMessage: '/plan build the feature', + source: 'cli', + profile: 'default', + }, 'default') + + await vi.waitFor(() => expect(handleBridgeRunMock).toHaveBeenCalled()) + const call = handleBridgeRunMock.mock.calls.at(-1)! + expect(call[2]).toEqual(expect.objectContaining({ + input: '[IMPORTANT: expanded plan skill prompt]', + display_input: '/plan build the feature', + display_role: 'command', + storage_message: '/plan build the feature', + queue_id: 'queue-plan', + })) + expect(call[6]).toBe(false) + }) +}) diff --git a/tests/server/session-command-plan.test.ts b/tests/server/session-command-plan.test.ts new file mode 100644 index 0000000..f80fc33 --- /dev/null +++ b/tests/server/session-command-plan.test.ts @@ -0,0 +1,108 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const addMessageMock = vi.fn() +const createSessionMock = vi.fn() +const getSessionMock = vi.fn() +const updateSessionStatsMock = vi.fn() + +vi.mock('../../packages/server/src/db/hermes/session-store', () => ({ + addMessage: addMessageMock, + clearSessionMessages: vi.fn(), + createSession: createSessionMock, + getSession: getSessionMock, + renameSession: vi.fn(), + updateSessionStats: updateSessionStatsMock, +})) + +vi.mock('../../packages/server/src/services/logger', () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})) + +vi.mock('../../packages/server/src/services/hermes/run-chat/compression', () => ({ + buildDbHistory: vi.fn(), + estimateSnapshotAwareHistoryUsage: vi.fn(), + forceCompressBridgeHistory: vi.fn(), + getOrCreateSession: vi.fn((_map: Map, sessionId: string) => _map.get(sessionId)), + replaceState: vi.fn(), +})) + +vi.mock('../../packages/server/src/services/hermes/run-chat/usage', () => ({ + calcAndUpdateUsage: vi.fn(), + contextTokensWithCachedOverhead: vi.fn(), + updateMessageContextTokenUsage: vi.fn(), +})) + +vi.mock('../../packages/server/src/services/hermes/run-chat/abort', () => ({ + handleAbort: vi.fn(), +})) + +vi.mock('../../packages/server/src/services/hermes/run-chat/bridge-message', () => ({ + flushBridgePendingToDb: vi.fn(), +})) + +function makeContext(state: any) { + const namespaceEmit = vi.fn() + const nsp = { + to: vi.fn(() => ({ emit: namespaceEmit })), + adapter: { rooms: new Map([['session:session-1', new Set(['socket-1'])]]) }, + } + const socket = { + id: 'socket-1', + connected: true, + join: vi.fn(), + emit: vi.fn(), + } + const sessionMap = new Map([['session-1', state]]) + const runQueuedItem = vi.fn() + const bridge = { + command: vi.fn(async () => ({ + handled: true, + message: '[IMPORTANT: expanded plan skill prompt]', + })), + } + return { bridge, namespaceEmit, nsp, runQueuedItem, sessionMap, socket } +} + +describe('plan session command', () => { + beforeEach(() => { + vi.clearAllMocks() + getSessionMock.mockReturnValue({ id: 'session-1', profile: 'default', source: 'cli' }) + }) + + it('queues running plan commands once without visible command echo', async () => { + const state = { messages: [], isWorking: true, events: [], queue: [] } + const { bridge, namespaceEmit, nsp, runQueuedItem, sessionMap, socket } = makeContext(state) + const { handleSessionCommand, parseSessionCommand } = await import('../../packages/server/src/services/hermes/run-chat/session-command') + const command = parseSessionCommand('/plan build the feature')! + + await handleSessionCommand('session-1', command, { + nsp: nsp as any, + socket: socket as any, + sessionMap, + bridge: bridge as any, + profile: 'default', + queueId: 'client-queue-id', + runQueuedItem, + }) + + expect(addMessageMock).not.toHaveBeenCalled() + expect(runQueuedItem).not.toHaveBeenCalled() + expect(state.queue).toEqual([expect.objectContaining({ + queue_id: 'client-queue-id', + input: '[IMPORTANT: expanded plan skill prompt]', + displayInput: '/plan build the feature', + displayRole: 'command', + storageMessage: '/plan build the feature', + })]) + expect(namespaceEmit).toHaveBeenCalledWith('run.queued', expect.objectContaining({ + queue_length: 1, + queued_messages: [expect.objectContaining({ + id: 'client-queue-id', + role: 'command', + content: '/plan build the feature', + queued: true, + })], + })) + expect(namespaceEmit).not.toHaveBeenCalledWith('session.command', expect.anything()) + }) +})