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 <codex@openai.com>
This commit is contained in:
ekko
2026-05-25 15:48:17 +08:00
committed by GitHub
parent 6e2e502a75
commit 0eab6a1125
21 changed files with 622 additions and 49 deletions
@@ -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)
+128
View File
@@ -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)
})
})
+108
View File
@@ -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<string, any>, 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())
})
})