mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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
This commit is contained in:
parent
376fc6de62
commit
87caeb8cb0
@ -1,5 +1,9 @@
|
|||||||
import { describe, expect, it } from 'bun:test'
|
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', () => {
|
describe('WebSocket memory events', () => {
|
||||||
it('forwards CLI memory_saved system messages to the desktop client', () => {
|
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' },
|
{ 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: '<command-name>/cost</command-name>\n<command-args></command-args>',
|
||||||
|
})).toBe(false)
|
||||||
|
expect(shouldForward({
|
||||||
|
type: 'system',
|
||||||
|
subtype: 'local_command',
|
||||||
|
content: '<command-name>/goal</command-name>\n<command-args>ship the smoke test</command-args>',
|
||||||
|
})).toBe(true)
|
||||||
|
expect(shouldForward({
|
||||||
|
type: 'system',
|
||||||
|
subtype: 'local_command',
|
||||||
|
content: '<local-command-stdout>Goal created.\nGoal: active\nObjective: ship the smoke test</local-command-stdout>',
|
||||||
|
})).toBe(true)
|
||||||
|
expect(shouldForward({
|
||||||
|
type: 'system',
|
||||||
|
subtype: 'local_command_output',
|
||||||
|
content: 'late unrelated output',
|
||||||
|
})).toBe(false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('WebSocket stream event translation', () => {
|
describe('WebSocket stream event translation', () => {
|
||||||
|
|||||||
@ -333,9 +333,16 @@ async function handleUserMessage(
|
|||||||
// Keep output muted until the current user turn is enqueued to avoid forwarding
|
// Keep output muted until the current user turn is enqueued to avoid forwarding
|
||||||
// any pre-turn SDK chatter as fresh chat history.
|
// any pre-turn SDK chatter as fresh chat history.
|
||||||
let userMessageSent = false
|
let userMessageSent = false
|
||||||
|
const shouldForwardCurrentTurnLocalCommand =
|
||||||
|
createCurrentTurnLocalCommandForwarder(desktopSlashCommand)
|
||||||
|
|
||||||
rebindSessionOutput(sessionId, ws, {
|
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(
|
const sent = conversationService.sendMessage(
|
||||||
@ -1324,6 +1331,56 @@ function getDesktopSlashCommand(content: string): ReturnType<typeof parseSlashCo
|
|||||||
return parsed
|
return parsed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createCurrentTurnLocalCommandForwarder(
|
||||||
|
command: ReturnType<typeof parseSlashCommand>,
|
||||||
|
): (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<ReturnType<typeof parseSlashCommand>>,
|
||||||
|
): 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(
|
function extractLocalCommandOutput(
|
||||||
content: unknown,
|
content: unknown,
|
||||||
options: { allowUntagged?: boolean } = {},
|
options: { allowUntagged?: boolean } = {},
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user