From 87caeb8cb080ad345923fda768ba67e41e45cc91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sat, 16 May 2026 00:09:39 +0800 Subject: [PATCH] Keep typed slash command events visible in desktop Desktop muted CLI output until the user turn was fully enqueued, which dropped early local_command events for typed slash commands such as /goal. The transcript still recorded the goal, but the live desktop client missed the goal event and active-goal indicator. This forwards only the current typed command lifecycle through the pre-turn mute gate while keeping unrelated startup chatter and stale command output suppressed. Constraint: Desktop must show /goal creation immediately while preserving the pre-turn mute gate for SDK startup noise Rejected: Forward all pre-turn local command output | would reintroduce stale or unrelated CLI chatter into fresh desktop turns Confidence: high Scope-risk: narrow Directive: Do not widen this gate without verifying /cost, /context, /goal, and reconnect startup behavior Tested: bun test src/server/__tests__/ws-memory-events.test.ts src/server/__tests__/conversations.test.ts -t 'local command output|/cost|/context|goal|pre-turn mute' Tested: bun run check:server Not-tested: live desktop manual smoke after rebuilding/restarting the app --- src/server/__tests__/ws-memory-events.test.ts | 37 +++++++++++- src/server/ws/handler.ts | 59 ++++++++++++++++++- 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/src/server/__tests__/ws-memory-events.test.ts b/src/server/__tests__/ws-memory-events.test.ts index 86d7e301..bc289ebc 100644 --- a/src/server/__tests__/ws-memory-events.test.ts +++ b/src/server/__tests__/ws-memory-events.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'bun:test' -import { translateCliMessage } from '../ws/handler.js' +import { + createCurrentTurnLocalCommandForwarder, + translateCliMessage, +} from '../ws/handler.js' +import { parseSlashCommand } from '../../utils/slashCommandParsing.js' describe('WebSocket memory events', () => { it('forwards CLI memory_saved system messages to the desktop client', () => { @@ -208,6 +212,37 @@ describe('WebSocket goal command events', () => { { type: 'content_delta', text: 'Goal: active' }, ]) }) + + it('allows the current slash command lifecycle through the pre-turn mute gate', () => { + const shouldForward = createCurrentTurnLocalCommandForwarder( + parseSlashCommand('/goal ship the smoke test'), + ) + + expect(shouldForward({ + type: 'system', + subtype: 'init', + })).toBe(false) + expect(shouldForward({ + type: 'system', + subtype: 'local_command', + content: '/cost\n', + })).toBe(false) + expect(shouldForward({ + type: 'system', + subtype: 'local_command', + content: '/goal\nship the smoke test', + })).toBe(true) + expect(shouldForward({ + type: 'system', + subtype: 'local_command', + content: 'Goal created.\nGoal: active\nObjective: ship the smoke test', + })).toBe(true) + expect(shouldForward({ + type: 'system', + subtype: 'local_command_output', + content: 'late unrelated output', + })).toBe(false) + }) }) describe('WebSocket stream event translation', () => { diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index f6aaa373..33b06dd6 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -333,9 +333,16 @@ async function handleUserMessage( // Keep output muted until the current user turn is enqueued to avoid forwarding // any pre-turn SDK chatter as fresh chat history. let userMessageSent = false + const shouldForwardCurrentTurnLocalCommand = + createCurrentTurnLocalCommandForwarder(desktopSlashCommand) rebindSessionOutput(sessionId, ws, { - shouldForward: (cliMsg) => userMessageSent || (cliMsg.type === 'result' && cliMsg.is_error), + shouldForward: (cliMsg) => { + if (userMessageSent || (cliMsg.type === 'result' && cliMsg.is_error)) { + return true + } + return shouldForwardCurrentTurnLocalCommand(cliMsg) + }, }) const sent = conversationService.sendMessage( @@ -1324,6 +1331,56 @@ function getDesktopSlashCommand(content: string): ReturnType, +): (cliMsg: any) => boolean { + let awaitingCurrentTurnLocalCommandOutput = false + + return (cliMsg: any) => { + if (command && isMatchingCurrentTurnLocalCommand(cliMsg, command)) { + awaitingCurrentTurnLocalCommandOutput = true + return true + } + if ( + awaitingCurrentTurnLocalCommandOutput && + isLocalCommandOutputMessage(cliMsg) + ) { + awaitingCurrentTurnLocalCommandOutput = false + return true + } + return false + } +} + +function isMatchingCurrentTurnLocalCommand( + cliMsg: any, + command: NonNullable>, +): boolean { + if (cliMsg?.type !== 'system' || cliMsg?.subtype !== 'local_command') { + return false + } + const localCommand = extractLocalCommand(cliMsg.content ?? cliMsg.message) + if (!localCommand) return false + return ( + localCommand.name === command.commandName && + localCommand.args.trim() === command.args.trim() + ) +} + +function isLocalCommandOutputMessage(cliMsg: any): boolean { + if ( + cliMsg?.type !== 'system' || + (cliMsg?.subtype !== 'local_command' && + cliMsg?.subtype !== 'local_command_output') + ) { + return false + } + return extractLocalCommandOutput( + cliMsg.content ?? cliMsg.message, + { allowUntagged: cliMsg.subtype === 'local_command_output' }, + ) !== null +} + function extractLocalCommandOutput( content: unknown, options: { allowUntagged?: boolean } = {},