[codex] integrate goal command workflow (#1025)

* feat: integrate goal command workflow

* fix: keep goal done visible

* fix: add goal done slash command

* fix: promote queued message on run start
This commit is contained in:
ekko
2026-05-25 19:26:23 +08:00
committed by GitHub
parent 0eab6a1125
commit badb17cf8e
30 changed files with 1535 additions and 85 deletions
+29
View File
@@ -119,6 +119,7 @@ const sessionEventHandlers = new Map<string, {
onAbortStarted: (event: RunEvent) => void
onAbortCompleted: (event: RunEvent) => void
onUsageUpdated: (event: RunEvent) => void
onAgentEvent?: (event: RunEvent) => void
onSessionCommand?: (event: RunEvent) => void
onRunQueued?: (event: RunEvent) => void
onApprovalRequested?: (event: RunEvent) => void
@@ -129,6 +130,7 @@ const sessionEventHandlers = new Map<string, {
}>()
const peerUserMessageHandlers = new Set<(event: RunEvent) => void>()
const sessionCommandHandlers = new Set<(event: RunEvent) => void>()
/**
* Global message.delta event handler
@@ -357,6 +359,20 @@ function globalSessionCommandHandler(event: RunEvent): void {
if (handlers?.onSessionCommand) {
handlers.onSessionCommand(event)
}
for (const handler of sessionCommandHandlers) {
handler(event)
}
}
function globalAgentEventHandler(event: RunEvent): void {
const sid = event.session_id
if (!sid) return
const handlers = sessionEventHandlers.get(sid)
if (handlers?.onAgentEvent) {
handlers.onAgentEvent(event)
}
}
function globalApprovalRequestedHandler(event: RunEvent): void {
@@ -437,6 +453,7 @@ export function registerSessionHandlers(
onAbortStarted: (event: RunEvent) => void
onAbortCompleted: (event: RunEvent) => void
onUsageUpdated: (event: RunEvent) => void
onAgentEvent?: (event: RunEvent) => void
onSessionCommand?: (event: RunEvent) => void
onRunQueued?: (event: RunEvent) => void
onApprovalRequested?: (event: RunEvent) => void
@@ -469,6 +486,13 @@ export function onPeerUserMessage(handler: (event: RunEvent) => void): () => voi
}
}
export function onSessionCommand(handler: (event: RunEvent) => void): () => void {
sessionCommandHandlers.add(handler)
return () => {
sessionCommandHandlers.delete(handler)
}
}
export function respondClarify(
sessionId: string,
clarifyId: string,
@@ -577,6 +601,7 @@ export function connectChatRun(requestedProfile?: string | null): Socket {
// Usage events
chatRunSocket.on('usage.updated', globalUsageUpdatedHandler)
chatRunSocket.on('agent.event', globalAgentEventHandler)
chatRunSocket.on('session.command', globalSessionCommandHandler)
globalListenersRegistered = true
@@ -790,6 +815,10 @@ export function startRunViaSocket(
if (closed) return
onEvent(evt)
},
onAgentEvent: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
},
onSessionCommand: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 MiB

@@ -29,6 +29,13 @@ const bridgeCommands = computed(() => [
{ 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: 'goal', args: t('chat.slashCommandArgs.text'), description: t('chat.slashCommands.goal') },
{ name: 'goal', args: 'status', insertText: 'goal status', description: t('chat.slashCommands.goalStatus') },
{ name: 'goal', args: 'pause', insertText: 'goal pause', description: t('chat.slashCommands.goalPause') },
{ name: 'goal', args: 'resume', insertText: 'goal resume', description: t('chat.slashCommands.goalResume') },
{ name: 'goal', args: 'done', insertText: 'goal done', description: t('chat.slashCommands.goalDone') },
{ name: 'goal', args: 'clear', insertText: 'goal clear', description: t('chat.slashCommands.goalClear') },
{ name: 'subgoal', args: t('chat.slashCommandArgs.text'), description: t('chat.slashCommands.subgoal') },
{ 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') },
@@ -38,7 +38,11 @@ const isAgentError = computed(() => props.message.role === "assistant" && props.
const effectiveHeadingIdPrefix = computed(() => props.headingIdPrefix || `msg-${props.message.id}`);
const isCommandMessage = computed(() => props.message.role === "command" || props.message.systemType === "command");
const isCommandError = computed(() => props.message.role === "command" && props.message.systemType === "error");
const isStatusCommand = computed(() => isCommandMessage.value && props.message.commandAction === "status");
const isStatusCommand = computed(() =>
isCommandMessage.value
&& props.message.commandAction === "status"
&& props.message.commandData?.type !== "goal"
);
const statusItems = computed(() => {
const data = props.message.commandData || {};
return [
@@ -3,8 +3,8 @@ import { ref, computed, watch, nextTick } from "vue";
import { useI18n } from "vue-i18n";
import MessageItem from "./MessageItem.vue";
import { useChatStore } from "@/stores/hermes/chat";
import thinkingVideoLight from "@/assets/thinking-light.mp4";
import thinkingVideoDark from "@/assets/thinking-dark.mp4";
import thinkingImageLight from "@/assets/thinking-light.gif";
import thinkingImageDark from "@/assets/thinking-dark.gif";
import { useTheme } from "@/composables/useTheme";
import { useToolTraceVisibility } from "@/composables/useToolTraceVisibility";
@@ -172,14 +172,12 @@ watch(currentToolCalls, () => {
/>
<Transition name="fade">
<div v-if="chatStore.isRunActive || chatStore.abortState" class="streaming-indicator">
<video
:src="isDark ? thinkingVideoDark : thinkingVideoLight"
autoplay
loop
muted
playsinline
<img
:src="isDark ? thinkingImageDark : thinkingImageLight"
alt=""
aria-hidden="true"
class="thinking-video"
/>
>
<div v-if="visibleToolCalls.length > 0 || chatStore.compressionState || chatStore.abortState" class="tool-calls-panel">
<!-- Abort indicator -->
<div v-if="chatStore.abortState" class="tool-call-item compression-item">
+7
View File
@@ -219,6 +219,13 @@ export default {
abort: 'Aktiven Bridge-Lauf stoppen',
queue: 'Nachricht hinter dem aktiven Lauf einreihen',
plan: 'Markdown-Implementierungsplan schreiben',
goal: 'Set a standing goal that continues across turns',
goalStatus: 'Show the active goal status',
goalPause: 'Pause the active goal loop',
goalResume: 'Resume the paused goal loop',
goalDone: 'Complete and clear the active goal',
goalClear: 'Clear the active goal',
subgoal: 'Add a criterion to the active goal',
clear: 'Aktuelle Anzeige leeren',
clearHistory: 'Gespeicherten Nachrichtenverlauf dieser Sitzung löschen',
title: 'Diese Sitzung umbenennen',
+7
View File
@@ -220,6 +220,13 @@ export default {
abort: 'Stop the active bridge run',
queue: 'Queue a message behind the active run',
plan: 'Write a markdown implementation plan',
goal: 'Set a standing goal that continues across turns',
goalStatus: 'Show the active goal status',
goalPause: 'Pause the active goal loop',
goalResume: 'Resume the paused goal loop',
goalDone: 'Complete and clear the active goal',
goalClear: 'Clear the active goal',
subgoal: 'Add a criterion to the active goal',
clear: 'Clear the current display',
clearHistory: 'Delete this sessions stored message history',
title: 'Rename this session',
+7
View File
@@ -219,6 +219,13 @@ export default {
abort: 'Detener la ejecución activa de Bridge',
queue: 'Poner un mensaje en cola tras la ejecución activa',
plan: 'Escribir un plan de implementación en Markdown',
goal: 'Set a standing goal that continues across turns',
goalStatus: 'Show the active goal status',
goalPause: 'Pause the active goal loop',
goalResume: 'Resume the paused goal loop',
goalDone: 'Complete and clear the active goal',
goalClear: 'Clear the active goal',
subgoal: 'Add a criterion to the active goal',
clear: 'Limpiar la vista actual',
clearHistory: 'Eliminar el historial de mensajes guardado de esta sesión',
title: 'Renombrar esta sesión',
+7
View File
@@ -219,6 +219,13 @@ export default {
abort: 'Arrêter lexécution Bridge active',
queue: 'Mettre un message en file après lexécution active',
plan: 'Rédiger un plan dimplémentation Markdown',
goal: 'Set a standing goal that continues across turns',
goalStatus: 'Show the active goal status',
goalPause: 'Pause the active goal loop',
goalResume: 'Resume the paused goal loop',
goalDone: 'Complete and clear the active goal',
goalClear: 'Clear the active goal',
subgoal: 'Add a criterion to the active goal',
clear: 'Effacer laffichage actuel',
clearHistory: 'Supprimer lhistorique des messages enregistrés de cette session',
title: 'Renommer cette session',
+7
View File
@@ -219,6 +219,13 @@ export default {
abort: '実行中の Bridge を停止',
queue: '実行中の処理の後ろにメッセージをキュー追加',
plan: 'Markdown の実装計画を作成',
goal: 'Set a standing goal that continues across turns',
goalStatus: 'Show the active goal status',
goalPause: 'Pause the active goal loop',
goalResume: 'Resume the paused goal loop',
goalDone: 'Complete and clear the active goal',
goalClear: 'Clear the active goal',
subgoal: 'Add a criterion to the active goal',
clear: '現在の表示をクリア',
clearHistory: 'このセッションの保存済みメッセージ履歴を削除',
title: 'このセッション名を変更',
+7
View File
@@ -219,6 +219,13 @@ export default {
abort: '활성 Bridge 실행 중지',
queue: '활성 실행 뒤에 메시지 대기열 추가',
plan: 'Markdown 구현 계획 작성',
goal: 'Set a standing goal that continues across turns',
goalStatus: 'Show the active goal status',
goalPause: 'Pause the active goal loop',
goalResume: 'Resume the paused goal loop',
goalDone: 'Complete and clear the active goal',
goalClear: 'Clear the active goal',
subgoal: 'Add a criterion to the active goal',
clear: '현재 표시 내용 지우기',
clearHistory: '이 세션의 저장된 메시지 기록 삭제',
title: '이 세션 이름 변경',
+7
View File
@@ -219,6 +219,13 @@ export default {
abort: 'Parar a execução ativa do Bridge',
queue: 'Enfileirar uma mensagem após a execução ativa',
plan: 'Escrever um plano de implementação em Markdown',
goal: 'Set a standing goal that continues across turns',
goalStatus: 'Show the active goal status',
goalPause: 'Pause the active goal loop',
goalResume: 'Resume the paused goal loop',
goalDone: 'Complete and clear the active goal',
goalClear: 'Clear the active goal',
subgoal: 'Add a criterion to the active goal',
clear: 'Limpar a visualização atual',
clearHistory: 'Excluir o histórico de mensagens salvo desta sessão',
title: 'Renomear esta sessão',
@@ -219,6 +219,13 @@ export default {
abort: '停止目前 Bridge 執行',
queue: '將訊息加入目前執行後的佇列',
plan: '產生一份 Markdown 實作計畫',
goal: '設定一個跨輪次持續推進的目標',
goalStatus: '查看目前目標狀態',
goalPause: '暫停目前目標循環',
goalResume: '繼續已暫停的目標循環',
goalDone: '完成並清除目前目標',
goalClear: '清除目前目標',
subgoal: '為目前目標追加驗收條件',
clear: '清空目前顯示內容',
clearHistory: '刪除目前會話已儲存的訊息歷史',
title: '重新命名目前會話',
+7
View File
@@ -220,6 +220,13 @@ export default {
abort: '停止当前 Bridge 运行',
queue: '把消息加入当前运行后的队列',
plan: '生成一份 Markdown 实施计划',
goal: '设置一个跨轮次持续推进的目标',
goalStatus: '查看当前目标状态',
goalPause: '暂停当前目标循环',
goalResume: '继续已暂停的目标循环',
goalDone: '完成并清除当前目标',
goalClear: '清除当前目标',
subgoal: '为当前目标追加验收条件',
clear: '清空当前显示内容',
clearHistory: '删除当前会话已入库的消息历史',
title: '重命名当前会话',
+122 -29
View File
@@ -1,4 +1,4 @@
import { startRunViaSocket, resumeSession, registerSessionHandlers, unregisterSessionHandlers, getChatRunSocket, respondToolApproval, onPeerUserMessage, respondClarify, type RunEvent, type ResumeSessionPayload, type ContentBlock as ContentBlockImport } from '@/api/hermes/chat'
import { startRunViaSocket, resumeSession, registerSessionHandlers, unregisterSessionHandlers, getChatRunSocket, respondToolApproval, onPeerUserMessage, onSessionCommand, respondClarify, type RunEvent, type ResumeSessionPayload, type ContentBlock as ContentBlockImport } from '@/api/hermes/chat'
import { deleteSession as deleteSessionApi, fetchSession, fetchSessions, setSessionModel, type HermesMessage, type SessionSummary } from '@/api/hermes/sessions'
import { getActiveProfileName } from '@/api/client'
import { getDownloadUrl } from '@/api/hermes/download'
@@ -365,6 +365,7 @@ function removeItem(key: string) {
// File objects don't serialize and we only need name/type/size/url for display.
export const useChatStore = defineStore('chat', () => {
const seenSessionCommandEvents = new WeakSet<RunEvent>()
const sessions = ref<Session[]>([])
const activeSessionId = ref<string | null>(null)
const focusMessageId = ref<string | null>(null)
@@ -778,6 +779,12 @@ export const useChatStore = defineStore('chat', () => {
}
}
function clearAgentEventMessages(sessionId: string) {
const s = sessions.value.find(s => s.id === sessionId)
if (!s) return
s.messages = s.messages.filter(m => m.commandAction !== 'agent.event')
}
function handleSubagentEvent(sessionId: string, evt: RunEvent) {
const eventName = String(evt.event || '')
if (!eventName.startsWith('subagent.')) return
@@ -867,12 +874,19 @@ export const useChatStore = defineStore('chat', () => {
}
function handleSessionCommandEvent(evt: RunEvent) {
if (seenSessionCommandEvents.has(evt)) return
seenSessionCommandEvents.add(evt)
const sid = evt.session_id
if (!sid) return
const target = sessions.value.find(s => s.id === sid)
const action = (evt as any).action as string | undefined
const command = String((evt as any).command || '').toLowerCase()
if ((evt as any).started === true && (evt as any).terminal === false) {
serverWorking.value.add(sid)
}
if (action === 'clear') {
if (action === 'clear' && command === 'clear') {
if (target) target.messages = []
queuedUserMessages.value.delete(sid)
queueLengths.value.delete(sid)
@@ -931,6 +945,36 @@ export const useChatStore = defineStore('chat', () => {
}
}
function handleAgentEvent(evt: RunEvent) {
const sid = evt.session_id
if (!sid) return
const text = String((evt as any).text || (evt as any).message || '').trim()
if (!text) return
const msgs = getSessionMsgs(sid)
const last = msgs[msgs.length - 1]
const commandData = { ...(evt as any) }
if (last?.role === 'system' && last.commandAction === 'agent.event') {
if (last.content === text) return
updateMessage(sid, last.id, {
content: text,
timestamp: Date.now(),
commandData,
})
return
}
addMessage(sid, {
id: uid(),
role: 'system',
content: text,
timestamp: Date.now(),
systemType: 'command',
commandAction: 'agent.event',
commandData,
})
}
function enqueueUserMessage(sessionId: string, message: Message) {
const queue = queuedUserMessages.value.get(sessionId) || []
if (queue.some(item => item.id === message.id)) return
@@ -957,6 +1001,23 @@ export const useChatStore = defineStore('chat', () => {
})
}
function promoteNextQueuedUserMessage(sessionId: string) {
const queue = queuedUserMessages.value.get(sessionId)
if (!queue?.length) return
const [next, ...rest] = queue
const nextMap = new Map(queuedUserMessages.value)
if (rest.length > 0) {
nextMap.set(sessionId, rest)
} else {
nextMap.delete(sessionId)
}
queuedUserMessages.value = nextMap
if (!getSessionMsgs(sessionId).some(message => message.id === next.id)) {
addMessage(sessionId, { ...next, queued: false })
updateSessionTitle(sessionId)
}
}
function normalizeQueuedUserMessages(rawMessages: unknown): Message[] {
if (!Array.isArray(rawMessages)) return []
return rawMessages.flatMap((raw) => {
@@ -1004,7 +1065,27 @@ export const useChatStore = defineStore('chat', () => {
queueLengths.value.delete(sessionId)
}
if (Array.isArray((evt as any).queued_messages) && !(evt as any).dequeued_queue_id) {
const dequeuedId = (evt as any).dequeued_queue_id != null
? String((evt as any).dequeued_queue_id)
: ''
if (dequeuedId) {
const existingQueue = queuedUserMessages.value.get(sessionId) || []
const dequeued = existingQueue.find(message => message.id === dequeuedId)
if (Array.isArray((evt as any).queued_messages)) {
const queued = normalizeQueuedUserMessages((evt as any).queued_messages)
replaceQueuedUserMessages(sessionId, queued)
} else {
const nextQueue = existingQueue.filter(message => message.id !== dequeuedId)
replaceQueuedUserMessages(sessionId, nextQueue)
}
if (dequeued && !getSessionMsgs(sessionId).some(message => message.id === dequeued.id)) {
addMessage(sessionId, { ...dequeued, queued: false })
updateSessionTitle(sessionId)
}
return
}
if (Array.isArray((evt as any).queued_messages)) {
const queued = normalizeQueuedUserMessages((evt as any).queued_messages)
replaceQueuedUserMessages(sessionId, queued)
return
@@ -1129,21 +1210,6 @@ export const useChatStore = defineStore('chat', () => {
pendingApprovals.value = new Map(pendingApprovals.value)
}
function showNextQueuedUserMessage(sessionId: string) {
const queue = queuedUserMessages.value.get(sessionId)
if (!queue?.length) return
const [next, ...rest] = queue
const nextMap = new Map(queuedUserMessages.value)
if (rest.length > 0) {
nextMap.set(sessionId, rest)
} else {
nextMap.delete(sessionId)
}
queuedUserMessages.value = nextMap
addMessage(sessionId, { ...next, queued: false })
updateSessionTitle(sessionId)
}
function updateSessionTitle(sessionId: string) {
const target = sessions.value.find(s => s.id === sessionId)
if (!target) return
@@ -1189,6 +1255,7 @@ export const useChatStore = defineStore('chat', () => {
const isBridgeSlashCommand = content.trim().startsWith('/')
const isBridgeCompressCommand = isBridgeSlashCommand && /^\/compress(?:\s|$)/i.test(content.trim())
const isBridgePlanCommand = isBridgeSlashCommand && /^\/plan(?:\s|$)/i.test(content.trim())
const isBridgeGoalCommand = isBridgeSlashCommand && /^\/goal(?:\s|$)/i.test(content.trim())
const wasLiveBeforeSend = isSessionLive(sid)
const shouldQueue = wasLiveBeforeSend && (!isBridgeSlashCommand || isBridgePlanCommand)
@@ -1283,10 +1350,6 @@ export const useChatStore = defineStore('chat', () => {
let runHadToolActivity = false
let activeAssistantMessageId: string | null = null
const startNextQueuedUser = () => {
showNextQueuedUserMessage(sid)
}
const closeStreamingAssistant = () => {
const msgs = getSessionMsgs(sid)
msgs.forEach(m => {
@@ -1386,12 +1449,16 @@ export const useChatStore = defineStore('chat', () => {
case 'run.failed':
addAgentErrorMessage(sid, e.error)
break
case 'agent.event':
handleAgentEvent(e)
break
}
}
}
if (activeSessionId.value === sid) activeSession.value = target
if (!data.isWorking && !(data.queueLength && data.queueLength > 0)) {
clearAgentEventMessages(sid)
cleanup()
activeAssistantMessageId = null
updateSessionTitle(sid)
@@ -1405,12 +1472,13 @@ export const useChatStore = defineStore('chat', () => {
(evt: RunEvent) => {
switch (evt.event) {
case 'run.started':
clearAgentEventMessages(sid)
setAbortState(null)
setCompressionState(null)
runProducedAssistantText = false
runHadToolActivity = false
closeStreamingAssistant()
startNextQueuedUser()
promoteNextQueuedUserMessage(sid)
if ((evt as any).queue_length > 0) {
queueLengths.value.set(sid, (evt as any).queue_length)
} else {
@@ -1428,6 +1496,11 @@ export const useChatStore = defineStore('chat', () => {
break
}
case 'agent.event': {
handleAgentEvent(evt)
break
}
case 'compression.started': {
setCompressionState({
compressing: true,
@@ -1656,6 +1729,7 @@ export const useChatStore = defineStore('chat', () => {
}
case 'run.completed': {
clearAgentEventMessages(sid)
const msgs = getSessionMsgs(sid)
const lastMsg = activeAssistantMessageId
? msgs.find(m => m.id === activeAssistantMessageId)
@@ -1759,6 +1833,7 @@ export const useChatStore = defineStore('chat', () => {
}
case 'run.failed': {
clearAgentEventMessages(sid)
if ((evt as any).inputTokens != null) {
const target = sessions.value.find(s => s.id === sid)
if (target) {
@@ -1819,7 +1894,7 @@ export const useChatStore = defineStore('chat', () => {
{ onReconnectResume: applyReconnectResume },
)
if (!isBridgeSlashCommand || isBridgeCompressCommand || isBridgePlanCommand) {
if (!isBridgeSlashCommand || isBridgeCompressCommand || isBridgePlanCommand || isBridgeGoalCommand) {
streamStates.value.set(sid, ctrl)
}
} catch (err: any) {
@@ -1857,10 +1932,6 @@ export const useChatStore = defineStore('chat', () => {
unregisterSessionHandlers(sid)
}
const startNextQueuedUser = () => {
showNextQueuedUserMessage(sid)
}
const closeStreamingAssistant = () => {
const msgs = getSessionMsgs(sid)
msgs.forEach(m => {
@@ -1887,13 +1958,19 @@ export const useChatStore = defineStore('chat', () => {
break
}
case 'agent.event': {
handleAgentEvent(evt)
break
}
case 'run.started':
clearAgentEventMessages(sid)
setAbortState(null)
setCompressionState(null)
runProducedAssistantText = false
runHadToolActivity = false
closeStreamingAssistant()
startNextQueuedUser()
promoteNextQueuedUserMessage(sid)
if ((evt as any).queue_length > 0) {
queueLengths.value.set(sid, (evt as any).queue_length)
} else {
@@ -2118,6 +2195,7 @@ export const useChatStore = defineStore('chat', () => {
}
case 'run.completed': {
clearAgentEventMessages(sid)
const hasQueue = (evt as any).queue_remaining > 0
if (hasQueue) {
queueLengths.value.set(sid, (evt as any).queue_remaining)
@@ -2207,6 +2285,7 @@ export const useChatStore = defineStore('chat', () => {
}
case 'run.failed': {
clearAgentEventMessages(sid)
if ((evt as any).inputTokens != null) {
const target = sessions.value.find(s => s.id === sid)
if (target) {
@@ -2263,6 +2342,7 @@ export const useChatStore = defineStore('chat', () => {
onAbortStarted: (evt) => handleEvent(evt),
onAbortCompleted: (evt) => handleEvent(evt),
onUsageUpdated: (evt) => handleEvent(evt),
onAgentEvent: (evt) => handleEvent(evt),
onSessionCommand: (evt) => handleEvent(evt),
onRunQueued: (evt) => handleEvent(evt),
onClarifyRequested: (evt) => handleEvent(evt),
@@ -2326,6 +2406,19 @@ export const useChatStore = defineStore('chat', () => {
onPeerUserMessage(handlePeerUserMessage)
function handleGlobalSessionCommand(evt: RunEvent) {
const sid = evt.session_id
if (!sid || activeSessionId.value !== sid || !activeSession.value) return
const shouldAttachToStartedRun = (evt as any).started === true && (evt as any).terminal === false
handleSessionCommandEvent(evt)
if (shouldAttachToStartedRun) {
serverWorking.value.add(sid)
resumeServerWorkingRun(sid, true)
}
}
onSessionCommand(handleGlobalSessionCommand)
function stopStreaming() {
const sid = activeSessionId.value
if (!sid) return
@@ -110,6 +110,7 @@ export interface AgentBridgeCommandResult extends AgentBridgeResponse {
command: string
handled: boolean
type?: string
action?: string
message?: string
output?: string
notice?: string
@@ -120,6 +121,30 @@ export interface AgentBridgeCommandResult extends AgentBridgeResponse {
retry?: boolean
retry_input?: AgentBridgeMessage
title?: string
kickoff_prompt?: string
clear_goal_continuations?: boolean
max_turns?: number
}
export interface AgentBridgeGoalEvaluation extends AgentBridgeResponse {
session_id: string
handled: boolean
active?: boolean
status?: string | null
should_continue?: boolean
continuation_prompt?: string | null
verdict?: string
reason?: string
message?: string
}
export interface AgentBridgeGoalPause extends AgentBridgeResponse {
session_id: string
handled: boolean
active?: boolean
status?: string | null
reason?: string
message?: string
}
export class AgentBridgeError extends Error {
@@ -419,6 +444,15 @@ export class AgentBridgeClient {
})
}
goalEvaluate(sessionId: string, finalResponse: string, profile?: string): Promise<AgentBridgeGoalEvaluation> {
return this.request<AgentBridgeGoalEvaluation>({
action: 'goal_evaluate',
session_id: sessionId,
final_response: finalResponse,
...(profile ? { profile } : {}),
})
}
getOutput(runId: string, cursor = 0, eventCursor = 0, options: AgentBridgeRequestOptions = {}): Promise<AgentBridgeOutput> {
return this.request<AgentBridgeOutput>({
action: 'get_output',
@@ -474,6 +508,15 @@ export class AgentBridgeClient {
})
}
goalPause(sessionId: string, reason: string, profile?: string): Promise<AgentBridgeGoalPause> {
return this.request<AgentBridgeGoalPause>({
action: 'goal_pause',
session_id: sessionId,
reason,
...(profile ? { profile } : {}),
})
}
steer(sessionId: string, text: string, profile?: string): Promise<AgentBridgeResponse> {
return this.request({
action: 'steer',
@@ -518,6 +561,14 @@ export class AgentBridgeClient {
})
}
status(sessionId: string, profile?: string): Promise<AgentBridgeResponse> {
return this.request({
action: 'status',
session_id: sessionId,
...(profile ? { profile } : {}),
})
}
destroy(sessionId: string, profile?: string): Promise<AgentBridgeResponse> {
return this.request({
action: 'destroy',
@@ -1482,6 +1482,11 @@ class AgentPool:
arg = parts[1] if len(parts) > 1 else ""
with _profile_env(profile):
if name == "goal":
return self._dispatch_goal_command(session_id, arg)
if name == "subgoal":
return self._dispatch_subgoal_command(session_id, arg)
try:
try:
from agent.skill_bundles import (
@@ -1544,6 +1549,222 @@ class AgentPool:
"message": f"not a supported bridge command: /{name}",
}
def _goal_max_turns_from_config(self) -> int:
try:
from hermes_cli.config import load_config
goals_cfg = (load_config() or {}).get("goals") or {}
return int(goals_cfg.get("max_turns", 20) or 20)
except Exception:
return 20
def _goal_manager(self, session_id: str):
from hermes_cli.goals import GoalManager
return GoalManager(
session_id=session_id,
default_max_turns=self._goal_max_turns_from_config(),
)
def _dispatch_goal_command(self, session_id: str, arg: str) -> dict[str, Any]:
mgr = self._goal_manager(session_id)
clean_arg = str(arg or "").strip()
lower = clean_arg.lower()
if not clean_arg or lower == "status":
return {
"session_id": session_id,
"command": "goal",
"handled": True,
"type": "goal",
"action": "goal_status",
"message": mgr.status_line(),
}
if lower == "pause":
state = mgr.pause(reason="user-paused")
return {
"session_id": session_id,
"command": "goal",
"handled": True,
"type": "goal",
"action": "pause",
"message": f"⏸ Goal paused: {state.goal}" if state else "No goal set.",
"clear_goal_continuations": True,
}
if lower == "resume":
state = mgr.resume()
prompt = mgr.next_continuation_prompt() if state else None
return {
"session_id": session_id,
"command": "goal",
"handled": True,
"type": "goal",
"action": "resume",
"message": f"▶ Goal resumed: {state.goal}" if state else "No goal to resume.",
"kickoff_prompt": prompt,
"max_turns": state.max_turns if state else None,
}
if lower in {"clear", "stop", "done"}:
had = mgr.has_goal()
mgr.clear()
return {
"session_id": session_id,
"command": "goal",
"handled": True,
"type": "goal",
"action": "clear",
"message": "✓ Goal cleared." if had else "No active goal.",
"clear_goal_continuations": True,
}
try:
state = mgr.set(clean_arg)
except ValueError as exc:
return {
"session_id": session_id,
"command": "goal",
"handled": True,
"type": "goal",
"action": "set",
"message": f"Invalid goal: {exc}",
}
return {
"session_id": session_id,
"command": "goal",
"handled": True,
"type": "goal",
"action": "set",
"message": (
f"⊙ Goal set ({state.max_turns}-turn budget): {state.goal}\n"
"After each turn, a judge model will check if the goal is done. "
"Hermes keeps working until it is, you pause/clear it, or the budget is exhausted."
),
"kickoff_prompt": state.goal,
"max_turns": state.max_turns,
}
def _dispatch_subgoal_command(self, session_id: str, arg: str) -> dict[str, Any]:
mgr = self._goal_manager(session_id)
clean_arg = str(arg or "").strip()
if not mgr.has_goal():
return {
"session_id": session_id,
"command": "subgoal",
"handled": True,
"type": "goal",
"action": "subgoal",
"message": "No active goal. Set one with /goal <text>.",
}
if not clean_arg:
return {
"session_id": session_id,
"command": "subgoal",
"handled": True,
"type": "goal",
"action": "subgoal_status",
"message": f"{mgr.status_line()}\n{mgr.render_subgoals()}",
}
tokens = clean_arg.split(None, 1)
verb = tokens[0].lower()
rest = tokens[1].strip() if len(tokens) > 1 else ""
if verb == "remove":
if not rest:
message = "Usage: /subgoal remove <n>"
else:
try:
idx = int(rest.split()[0])
removed = mgr.remove_subgoal(idx)
message = f"✓ Removed subgoal {idx}: {removed}"
except ValueError:
message = "/subgoal remove: <n> must be an integer (1-based index)."
except (IndexError, RuntimeError) as exc:
message = f"/subgoal remove: {exc}"
return {
"session_id": session_id,
"command": "subgoal",
"handled": True,
"type": "goal",
"action": "subgoal_remove",
"message": message,
}
if verb == "clear":
try:
prev = mgr.clear_subgoals()
message = f"✓ Cleared {prev} subgoal{'s' if prev != 1 else ''}." if prev else "No subgoals to clear."
except RuntimeError as exc:
message = f"/subgoal clear: {exc}"
return {
"session_id": session_id,
"command": "subgoal",
"handled": True,
"type": "goal",
"action": "subgoal_clear",
"message": message,
}
try:
text = mgr.add_subgoal(clean_arg)
idx = len(mgr.state.subgoals) if mgr.state else 0
message = f"✓ Added subgoal {idx}: {text}"
except (ValueError, RuntimeError) as exc:
message = f"/subgoal: {exc}"
return {
"session_id": session_id,
"command": "subgoal",
"handled": True,
"type": "goal",
"action": "subgoal_add",
"message": message,
}
def evaluate_goal(self, session_id: str, final_response: str, profile: str | None = None) -> dict[str, Any]:
with _profile_env(profile):
mgr = self._goal_manager(session_id)
if not mgr.is_active():
return {
"session_id": session_id,
"handled": True,
"active": False,
"should_continue": False,
"continuation_prompt": None,
"message": "",
"verdict": "inactive",
}
decision = mgr.evaluate_after_turn(str(final_response or ""), user_initiated=True)
return {
"session_id": session_id,
"handled": True,
"active": mgr.is_active(),
**decision,
}
def pause_goal(self, session_id: str, reason: str, profile: str | None = None) -> dict[str, Any]:
with _profile_env(profile):
clean_reason = str(reason or "").strip() or "paused"
mgr = self._goal_manager(session_id)
state = mgr.pause(reason=clean_reason)
return {
"session_id": session_id,
"command": "goal",
"handled": True,
"type": "goal",
"action": "pause",
"active": mgr.is_active(),
"status": state.status if state else None,
"reason": clean_reason,
"message": f"⏸ Goal paused: {state.goal}" if state else "No goal set.",
"clear_goal_continuations": True,
}
def get_result(self, run_id: str) -> dict[str, Any]:
with self._lock:
record = self._runs.get(run_id)
@@ -1785,6 +2006,29 @@ class BridgeServer:
req.get("profile"),
)
if action == "goal_evaluate":
session_id = str(req.get("session_id") or "").strip()
if not session_id:
raise ValueError("session_id is required")
return self.pool.evaluate_goal(
session_id,
str(req.get("final_response") or ""),
req.get("profile"),
)
if action == "goal_pause":
session_id = str(req.get("session_id") or "").strip()
if not session_id:
raise ValueError("session_id is required")
return self.pool.pause_goal(
session_id,
str(req.get("reason") or ""),
req.get("profile"),
)
if action == "status":
return self.pool.status(str(req.get("session_id") or ""))
if action == "destroy":
return self.pool.destroy(str(req.get("session_id") or ""))
@@ -2359,7 +2603,7 @@ class BridgeBroker:
profile = self._profile_for_run(str(req.get("run_id") or ""))
return self._forward(profile, req)
if action in {"interrupt", "steer", "command", "get_history", "destroy"}:
if action in {"interrupt", "steer", "command", "goal_evaluate", "goal_pause", "status", "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)
@@ -64,6 +64,12 @@ export async function handleAbort(
} catch (err) {
logger.warn(err, '[chat-run-socket][abort] failed to interrupt CLI bridge for session %s', sessionId)
}
try {
await bridge.goalPause?.(sessionId, 'user-interrupted', state.profile)
state.queue = state.queue.filter(item => !item.goalContinuation)
} catch (err) {
logger.debug(err, '[chat-run-socket][abort] goal pause-on-interrupt skipped for session %s', sessionId)
}
} else if (state.abortController) {
state.abortController.abort()
}
@@ -27,7 +27,7 @@ import {
recordBridgeToolCompleted,
} from './bridge-message'
import { summarizeToolArguments } from './response-utils'
import type { ContentBlock, SessionState } from './types'
import type { ContentBlock, QueuedRun, SessionState } from './types'
import type { ChatMessage } from '../../../lib/context-compressor'
import { resolveBridgeRunModelConfig, type RunModelGroup } from './model-config'
import { filterBridgeToolCallMarkupDelta, flushPendingToolCallMarkup } from './bridge-delta'
@@ -349,6 +349,7 @@ export async function handleBridgeRun(
dequeueNextQueuedRun,
fullInstructions,
{ model: resolvedModel, provider: resolvedProvider },
data.model_groups,
)
if (chunk.done) break
}
@@ -485,6 +486,7 @@ async function applyBridgeChunkAsync(
dequeueNextQueuedRun: (socket: Socket, sessionId: string, fallbackProfile?: string) => void,
instructions: string,
modelContext: { model?: string | null; provider?: string | null },
modelGroups?: RunModelGroup[],
): Promise<void> {
if (state.activeRunMarker !== runMarker) {
bridgeLogger.info({
@@ -737,11 +739,13 @@ async function applyBridgeChunkAsync(
replaceState(sessionMap, sessionId, 'compression.completed', payload)
emit('compression.completed', payload)
} else if (evType === 'status') {
emit('agent.event', {
const payload = {
...ev,
event: 'agent.event',
run_id: chunk.run_id,
...ev,
})
}
replaceState(sessionMap, sessionId, 'agent.event', payload)
emit('agent.event', payload)
}
}
@@ -812,19 +816,15 @@ async function applyBridgeChunkAsync(
outputTokens: usage.outputTokens,
profile: state.profile,
})
const nextQueuedRun = state.queue.length > 0 ? state.queue[0] : undefined
state.isWorking = Boolean(nextQueuedRun)
const terminalError = bridgeTerminalError(chunk)
const hadQueuedRunBeforeGoalEvaluation = state.queue.length > 0
state.isWorking = hadQueuedRunBeforeGoalEvaluation
state.isAborting = false
if (nextQueuedRun) {
state.profile = nextQueuedRun.profile || profile
state.source = nextQueuedRun.source
} else {
state.profile = undefined
}
state.profile = hadQueuedRunBeforeGoalEvaluation ? (state.queue[0]?.profile || profile) : undefined
state.source = hadQueuedRunBeforeGoalEvaluation ? state.queue[0]?.source : state.source
state.runId = undefined
state.activeRunMarker = undefined
state.events = []
const terminalError = bridgeTerminalError(chunk)
const eventName = terminalError ? 'run.failed' : 'run.completed'
const payload = {
event: eventName,
@@ -838,8 +838,157 @@ async function applyBridgeChunkAsync(
queue_remaining: state.queue.length,
}
emit(eventName, payload)
if (state.queue.length > 0) {
if (!terminalError) {
await maybeEnqueueGoalContinuation({
nsp,
socket,
sessionId,
state,
bridge,
profile,
modelContext,
modelGroups,
instructions,
finalResponse: bridgeFinalResponse(chunk, state),
})
}
if (state.queue.length > 0 && !state.activeRunMarker) {
const nextQueuedRun = state.queue[0]
state.isWorking = true
state.profile = nextQueuedRun.profile || profile
state.source = nextQueuedRun.source
dequeueNextQueuedRun(socket, sessionId)
} else if (!state.activeRunMarker) {
state.isWorking = false
state.profile = undefined
}
}
function bridgeFinalResponse(chunk: AgentBridgeOutput, state: SessionState): string {
const result = chunk.result && typeof chunk.result === 'object' && !Array.isArray(chunk.result)
? chunk.result as Record<string, unknown>
: null
const finalResponse = result && typeof result.final_response === 'string'
? result.final_response
: ''
return finalResponse || chunk.output || state.bridgeOutput || ''
}
function hasRealQueuedRun(state: SessionState): boolean {
return state.queue.some(item => !item.goalContinuation)
}
async function maybeEnqueueGoalContinuation(args: {
nsp: ReturnType<Server['of']>
socket: Socket
sessionId: string
state: SessionState
bridge: AgentBridgeClient
profile: string
modelContext: { model?: string | null; provider?: string | null }
modelGroups?: RunModelGroup[]
instructions: string
finalResponse: string
}) {
const finalResponse = args.finalResponse || ''
if (!finalResponse.trim()) return
if (hasRealQueuedRun(args.state)) return
let decision
try {
decision = await args.bridge.goalEvaluate(args.sessionId, finalResponse, args.profile)
} catch (err) {
logger.warn(err, '[chat-run-socket] /goal evaluation failed for session %s', args.sessionId)
return
}
if (isGoalJudgeUnavailable(decision.reason)) {
emitGoalStatus(
args.nsp,
args.socket,
args.sessionId,
args.state,
'judge_unavailable',
'Goal judge is not configured; automatic goal continuation was skipped. The goal remains active, but Hermes cannot mark it done automatically.',
)
return
}
const message = typeof decision.message === 'string' ? decision.message.trim() : ''
if (message) emitGoalStatus(args.nsp, args.socket, args.sessionId, args.state, decision.verdict || 'goal', message)
if (!decision.should_continue) return
if (hasRealQueuedRun(args.state)) return
const prompt = typeof decision.continuation_prompt === 'string'
? decision.continuation_prompt.trim()
: ''
if (!prompt) return
const next: QueuedRun = {
queue_id: `goal_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
input: prompt,
displayInput: null,
storageMessage: prompt,
model: args.modelContext.model || undefined,
provider: args.modelContext.provider || undefined,
model_groups: args.modelGroups,
instructions: undefined,
profile: args.profile,
source: 'cli',
goalContinuation: true,
}
args.state.queue.push(next)
}
function isGoalJudgeUnavailable(reason?: string | null): boolean {
const value = String(reason || '').toLowerCase()
return value.includes('no auxiliary client configured') || value.includes('auxiliary client unavailable')
}
function emitGoalStatus(
nsp: ReturnType<Server['of']>,
socket: Socket,
sessionId: string,
state: SessionState,
action: string,
message: string,
) {
const now = Math.floor(Date.now() / 1000)
const id = addMessage({
session_id: sessionId,
role: 'command',
content: message,
timestamp: now,
})
state.messages.push({
id: id || `goal_${now}_${state.messages.length}`,
session_id: sessionId,
role: 'command',
content: message,
timestamp: now,
})
nsp.to(`session:${sessionId}`).emit('session.command', {
event: 'session.command',
session_id: sessionId,
command: 'goal',
ok: true,
action,
message,
terminal: false,
})
if (!nsp.adapter.rooms.get(`session:${sessionId}`)?.size && socket.connected) {
socket.emit('session.command', {
event: 'session.command',
session_id: sessionId,
command: 'goal',
ok: true,
action,
message,
terminal: false,
})
}
}
@@ -135,6 +135,8 @@ export class ChatRunSocket {
bridge: this.bridge,
profile: runProfile,
model: data.model,
provider: data.provider,
model_groups: data.model_groups,
instructions: data.instructions,
queueId: data.queue_id,
runQueuedItem: this.runQueuedItem.bind(this),
@@ -393,12 +395,10 @@ export class ChatRunSocket {
}
private serializeQueuedMessages(queue: QueuedRun[]) {
return queue.map(item => ({
return queue.filter(item => item.displayInput !== null).map(item => ({
id: item.queue_id,
role: item.displayRole || (typeof item.displayInput === 'string' && item.displayInput.trim().startsWith('/') ? 'command' : 'user'),
content: item.displayInput === null
? (item.storageMessage || '')
: contentBlocksToString(item.displayInput ?? item.input),
content: contentBlocksToString(item.displayInput ?? item.input),
timestamp: Math.floor(Date.now() / 1000),
queued: true,
}))
@@ -40,7 +40,7 @@ export async function resolveBridgeRunModelConfig(options: {
const candidateProvider = sessionProvider || requestedProvider
const hasGroups = Array.isArray(options.modelGroups) && options.modelGroups.length > 0
const candidateAvailable = hasGroups && hasModelInGroups(options.modelGroups, candidateProvider, candidateModel)
const shouldUseDefault = !candidateModel || !candidateProvider || !candidateAvailable
const shouldUseDefault = !candidateModel || !candidateProvider || (hasGroups && !candidateAvailable)
return shouldUseDefault
? resolveDefaultModelConfig(options.profile)
: { model: candidateModel, provider: candidateProvider }
@@ -15,6 +15,8 @@ type CommandName =
| 'abort'
| 'queue'
| 'plan'
| 'goal'
| 'subgoal'
| 'clear'
| 'title'
| 'compress'
@@ -34,6 +36,8 @@ interface SessionCommandContext {
bridge: AgentBridgeClient
profile: string
model?: string
provider?: string
model_groups?: Array<{ provider: string; models: string[] }>
instructions?: string
queueId?: string
runQueuedItem: (socket: Socket, sessionId: string, next: QueuedRun, fallbackProfile?: string) => void
@@ -45,6 +49,8 @@ const COMMAND_ALIASES: Record<string, CommandName> = {
abort: 'abort',
queue: 'queue',
plan: 'plan',
goal: 'goal',
subgoal: 'subgoal',
clear: 'clear',
title: 'title',
compress: 'compress',
@@ -120,24 +126,30 @@ export async function handleSessionCommand(
case 'status': {
const row = getSession(sessionId)
const bridgeStatus = await getBridgeSessionStatus(ctx, sessionId)
const bridgeRunning = bridgeStatus?.running === true
const isWorking = state.isWorking || bridgeRunning
const runId = state.runId || state.activeRunMarker || bridgeStatus?.currentRunId || null
emitCommand({
action: 'status',
terminal: !state.isWorking,
terminal: !isWorking,
message: [
`Status: ${state.isWorking ? 'running' : 'idle'}`,
`Status: ${isWorking ? 'running' : 'idle'}`,
`source: ${state.source || row?.source || 'cli'}`,
`profile: ${state.profile || ctx.profile || row?.profile || 'default'}`,
`model: ${ctx.model || row?.model || '-'}`,
`queue: ${state.queue.length}`,
`run: ${state.runId || state.activeRunMarker || '-'}`,
].join(', '),
isWorking: state.isWorking,
`run: ${runId || '-'}`,
bridgeStatus ? `bridge: ${bridgeRunning ? 'running' : 'idle'}` : null,
].filter(Boolean).join(', '),
isWorking,
isAborting: Boolean(state.isAborting),
queueLength: state.queue.length,
source: state.source || row?.source || 'cli',
profile: state.profile || ctx.profile || row?.profile || 'default',
model: ctx.model || row?.model || null,
runId: state.runId || state.activeRunMarker || null,
runId,
bridgeStatus,
})
return
}
@@ -161,6 +173,8 @@ export async function handleSessionCommand(
queue_id: queueId,
input: command.args,
model: ctx.model,
provider: ctx.provider,
model_groups: ctx.model_groups,
instructions: ctx.instructions,
profile: ctx.profile,
source: 'cli',
@@ -170,13 +184,7 @@ export async function handleSessionCommand(
event: 'run.queued',
session_id: sessionId,
queue_length: state.queue.length,
queued_messages: state.queue.map(item => ({
id: item.queue_id,
role: 'user',
content: contentBlocksToString(item.input),
timestamp: Math.floor(Date.now() / 1000),
queued: true,
})),
queued_messages: serializeVisibleQueuedMessages(state.queue),
})
emitCommand({
action: 'queue',
@@ -221,6 +229,8 @@ export async function handleSessionCommand(
displayRole: 'command',
storageMessage: displayCommand,
model: ctx.model,
provider: ctx.provider,
model_groups: ctx.model_groups,
instructions: ctx.instructions,
profile: ctx.profile,
source: 'cli',
@@ -233,15 +243,7 @@ export async function handleSessionCommand(
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,
})),
queued_messages: serializeVisibleQueuedMessages(state.queue),
})
return
}
@@ -255,6 +257,88 @@ export async function handleSessionCommand(
return
}
case 'goal':
case 'subgoal': {
const isGoalSet = command.name === 'goal'
&& Boolean(command.args)
&& !['status', 'pause', 'resume', 'clear', 'stop', 'done'].includes(command.args.toLowerCase())
if (state.isWorking && isGoalSet) {
emitCommand({
ok: false,
action: 'goal',
terminal: false,
message: 'Agent is running. Use /goal status, /goal pause, or /goal clear mid-run, or /abort before setting a new goal.',
})
return
}
const bridgeCommand = `${command.name}${command.args ? ` ${command.args}` : ''}`
let result
try {
result = await ctx.bridge.command(sessionId, bridgeCommand, ctx.profile)
} catch (err) {
emitCommand({
ok: false,
action: command.name,
terminal: !state.isWorking,
message: `Goal command failed: ${err instanceof Error ? err.message : String(err)}`,
})
return
}
if (result.clear_goal_continuations) {
const removed = removeGoalContinuationRuns(state)
if (removed > 0) emitQueuedState(ctx, sessionId, state)
}
const kickoffPrompt = typeof result.kickoff_prompt === 'string' ? result.kickoff_prompt.trim() : ''
const bridgeStatus = result.action === 'goal_status' || result.action === 'status'
? await getBridgeSessionStatus(ctx, sessionId)
: null
const message = formatGoalStatusMessage(String(result.message || ''), bridgeStatus)
const resultAction = String(result.action || command.name)
const action = (command.name === 'goal' || command.name === 'subgoal') && resultAction === 'clear'
? `${command.name}_clear`
: resultAction
emitCommand({
action,
terminal: !state.isWorking && !kickoffPrompt,
started: Boolean(kickoffPrompt),
message,
type: result.type || 'goal',
maxTurns: result.max_turns,
bridgeStatus,
})
if (!kickoffPrompt) return
const next: QueuedRun = {
queue_id: ctx.queueId || `queue_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
input: kickoffPrompt,
displayInput: null,
storageMessage: kickoffPrompt,
model: ctx.model,
provider: ctx.provider,
model_groups: ctx.model_groups,
instructions: ctx.instructions,
profile: ctx.profile,
source: 'cli',
originSocketId: ctx.socket.id,
}
if (state.isWorking) {
state.queue.push(next)
emitQueuedState(ctx, sessionId, state)
return
}
ctx.runQueuedItem(ctx.socket, sessionId, next, ctx.profile)
return
}
case 'clear': {
if (command.args === '--history') {
if (state.isWorking) {
@@ -462,6 +546,79 @@ function clearTransientRunState(state: SessionState) {
state.isAborting = false
}
function removeGoalContinuationRuns(state: SessionState): number {
const before = state.queue.length
state.queue = state.queue.filter(item => !item.goalContinuation)
return before - state.queue.length
}
function emitQueuedState(ctx: SessionCommandContext, sessionId: string, state: SessionState) {
emitToSession(ctx.nsp, ctx.socket, sessionId, 'run.queued', {
event: 'run.queued',
session_id: sessionId,
queue_length: state.queue.length,
queued_messages: serializeVisibleQueuedMessages(state.queue),
})
}
function serializeVisibleQueuedMessages(queue: QueuedRun[]) {
return queue.filter(item => item.displayInput !== null).map(item => ({
id: item.queue_id,
role: item.displayRole || (typeof item.displayInput === 'string' && item.displayInput.trim().startsWith('/') ? 'command' : 'user'),
content: contentBlocksToString(item.displayInput ?? item.input),
timestamp: Math.floor(Date.now() / 1000),
queued: true,
}))
}
type BridgeSessionStatus = {
exists: boolean
running: boolean
currentRunId: string | null
messageCount: number
}
async function getBridgeSessionStatus(ctx: SessionCommandContext, sessionId: string): Promise<BridgeSessionStatus | null> {
try {
const raw = await ctx.bridge.status(sessionId, ctx.profile) as Record<string, unknown>
return {
exists: raw.exists === true,
running: raw.running === true,
currentRunId: typeof raw.current_run_id === 'string' && raw.current_run_id.trim()
? raw.current_run_id
: null,
messageCount: typeof raw.message_count === 'number' && Number.isFinite(raw.message_count)
? raw.message_count
: 0,
}
} catch (err) {
logger.debug({ err, sessionId }, '[chat-run-socket] bridge status lookup failed')
return null
}
}
function formatGoalStatusMessage(message: string, bridgeStatus: BridgeSessionStatus | null): string {
if (!bridgeStatus) return message
const lines = [message]
if (bridgeStatus.running) {
const progress = parseGoalTurnProgress(message)
lines.push(progress
? `Current turn: ${Math.min(progress.used + 1, progress.max)}/${progress.max} running (completed turns: ${progress.used}/${progress.max}; count updates after the judge).`
: 'Current turn: running (turn count updates after the judge).')
}
lines.push(`Run: ${bridgeStatus.running ? 'running' : 'idle'}${bridgeStatus.currentRunId ? ` (${bridgeStatus.currentRunId})` : ''}`)
return lines.filter(Boolean).join('\n')
}
function parseGoalTurnProgress(message: string): { used: number; max: number } | null {
const match = message.match(/\b(\d+)\s*\/\s*(\d+)\s+turns\b/i)
if (!match) return null
const used = Number(match[1])
const max = Number(match[2])
if (!Number.isFinite(used) || !Number.isFinite(max) || max <= 0) return null
return { used, max }
}
function ensureCommandSession(sessionId: string, ctx: SessionCommandContext) {
if (getSession(sessionId)) return
createSession({
@@ -38,6 +38,7 @@ export interface QueuedRun {
profile: string
source?: ChatRunSource
originSocketId?: string
goalContinuation?: boolean
}
export interface SessionState {
+32
View File
@@ -157,4 +157,36 @@ describe('chat-run socket reconnect handling', () => {
expect(socket.__listenerCount('disconnect')).toBe(1)
expect(socket.emit).toHaveBeenCalledWith('run', body)
})
it('fans session.command events to run-local and global handlers', async () => {
const { onSessionCommand, startRunViaSocket } = await import('../../packages/client/src/api/hermes/chat')
const onEvent = vi.fn()
const onGlobalCommand = vi.fn()
const offGlobalCommand = onSessionCommand(onGlobalCommand)
startRunViaSocket(
{ session_id: 'session-1', input: '/goal status', profile: 'default', source: 'cli' },
onEvent,
vi.fn(),
vi.fn(),
)
const socket = socketState.sockets[0]
const event = {
event: 'session.command',
session_id: 'session-1',
command: 'goal',
action: 'status',
message: 'Goal (active, 0/20 turns): write site',
}
socket.__trigger('session.command', event)
expect(onEvent).toHaveBeenCalledWith(event)
expect(onGlobalCommand).toHaveBeenCalledWith(event)
offGlobalCommand()
socket.__trigger('session.command', { ...event, message: 'next status' })
expect(onGlobalCommand).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,132 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
const chatApi = vi.hoisted(() => ({
registerSessionHandlers: vi.fn(),
unregisterSessionHandlers: vi.fn(),
getChatRunSocket: vi.fn(() => ({ emit: vi.fn() })),
sessionCommandHandlers: [] as Array<(event: any) => void>,
peerUserMessageHandlers: [] as Array<(event: any) => void>,
}))
vi.mock('@/api/hermes/chat', () => ({
startRunViaSocket: vi.fn(),
resumeSession: vi.fn(),
registerSessionHandlers: chatApi.registerSessionHandlers,
unregisterSessionHandlers: chatApi.unregisterSessionHandlers,
getChatRunSocket: chatApi.getChatRunSocket,
respondToolApproval: vi.fn(),
respondClarify: vi.fn(),
onPeerUserMessage: vi.fn((handler: (event: any) => void) => {
chatApi.peerUserMessageHandlers.push(handler)
return vi.fn()
}),
onSessionCommand: vi.fn((handler: (event: any) => void) => {
chatApi.sessionCommandHandlers.push(handler)
return vi.fn()
}),
}))
vi.mock('@/api/client', () => ({
getActiveProfileName: () => 'default',
}))
vi.mock('@/api/hermes/sessions', () => ({
deleteSession: vi.fn(),
fetchSession: vi.fn(),
fetchSessions: vi.fn(),
setSessionModel: vi.fn(),
}))
vi.mock('@/api/hermes/download', () => ({
getDownloadUrl: (_path: string, name: string) => `/download/${name}`,
}))
vi.mock('@/utils/completion-sound', () => ({
primeCompletionSound: vi.fn(),
playCompletionSound: vi.fn(),
}))
import { useChatStore, type Session } from '@/stores/hermes/chat'
function makeSession(): Session {
return {
id: 'session-1',
title: 'session',
messages: [],
createdAt: Date.now(),
updatedAt: Date.now(),
}
}
describe('chat store session.command fanout', () => {
beforeEach(() => {
vi.resetAllMocks()
chatApi.sessionCommandHandlers = []
chatApi.peerUserMessageHandlers = []
setActivePinia(createPinia())
})
it('attaches to a goal resume run started from another window', () => {
const store = useChatStore()
const session = makeSession()
store.sessions = [session]
store.activeSessionId = 'session-1'
store.activeSession = session
expect(chatApi.sessionCommandHandlers).toHaveLength(1)
chatApi.sessionCommandHandlers[0]({
event: 'session.command',
session_id: 'session-1',
command: 'goal',
action: 'resume',
message: 'Goal resumed',
started: true,
terminal: false,
})
expect(store.isStreaming).toBe(true)
expect(chatApi.registerSessionHandlers).toHaveBeenCalledWith('session-1', expect.objectContaining({
onRunStarted: expect.any(Function),
onSessionCommand: expect.any(Function),
}))
expect(store.messages).toEqual([
expect.objectContaining({
role: 'command',
content: 'Goal resumed',
commandAction: 'resume',
}),
])
})
it('does not clear the transcript for goal done commands', () => {
const store = useChatStore()
const session = makeSession()
session.messages = [
{ id: 'user-1', role: 'user', content: 'keep me', timestamp: 1 },
]
store.sessions = [session]
store.activeSessionId = 'session-1'
store.activeSession = session
chatApi.sessionCommandHandlers[0]({
event: 'session.command',
session_id: 'session-1',
command: 'goal',
action: 'clear',
message: 'Goal cleared.',
terminal: true,
})
expect(store.messages).toEqual([
expect.objectContaining({ id: 'user-1', content: 'keep me' }),
expect.objectContaining({
role: 'command',
content: 'Goal cleared.',
commandAction: 'clear',
}),
])
})
})
+88
View File
@@ -0,0 +1,88 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const updateSessionStatsMock = vi.fn()
const flushBridgePendingToDbMock = vi.fn()
const flushResponseRunToDbMock = vi.fn()
const replaceStateMock = vi.fn()
const calcAndUpdateUsageMock = vi.fn()
vi.mock('../../packages/server/src/db/hermes/session-store', () => ({
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/bridge-message', () => ({
flushBridgePendingToDb: flushBridgePendingToDbMock,
}))
vi.mock('../../packages/server/src/services/hermes/run-chat/response-stream', () => ({
flushResponseRunToDb: flushResponseRunToDbMock,
}))
vi.mock('../../packages/server/src/services/hermes/run-chat/compression', () => ({
replaceState: replaceStateMock,
}))
vi.mock('../../packages/server/src/services/hermes/run-chat/usage', () => ({
calcAndUpdateUsage: calcAndUpdateUsageMock,
}))
function makeHarness() {
const emit = vi.fn()
const nsp = {
adapter: { rooms: new Map([['session:session-1', new Set(['socket-1'])]]) },
to: vi.fn(() => ({ emit })),
}
const socket = {
connected: true,
emit: vi.fn(),
}
return { emit, nsp, socket }
}
describe('run chat abort goal handling', () => {
beforeEach(() => {
vi.clearAllMocks()
calcAndUpdateUsageMock.mockResolvedValue({ inputTokens: 0, outputTokens: 0 })
})
it('pauses an active goal and clears hidden goal continuations when aborting a CLI run', async () => {
const { handleAbort } = await import('../../packages/server/src/services/hermes/run-chat/abort')
const { emit, nsp, socket } = makeHarness()
const state = {
messages: [],
isWorking: true,
isAborting: false,
events: [],
queue: [
{ queue_id: 'goal-1', input: 'continue goal', profile: 'default', goalContinuation: true },
{ queue_id: 'user-1', input: 'normal follow-up', profile: 'default', source: 'cli' },
],
runId: 'run-1',
profile: 'default',
source: 'cli',
} as any
const sessionMap = new Map([['session-1', state]])
const bridge = {
interrupt: vi.fn().mockResolvedValue({ ok: true }),
goalPause: vi.fn().mockResolvedValue({ handled: true, status: 'paused', reason: 'user-interrupted' }),
}
const runQueuedItem = vi.fn()
await handleAbort(nsp as any, socket as any, 'session-1', sessionMap, bridge, runQueuedItem)
expect(bridge.interrupt).toHaveBeenCalledWith('session-1', 'Aborted by user', 'default')
expect(bridge.goalPause).toHaveBeenCalledWith('session-1', 'user-interrupted', 'default')
expect(runQueuedItem).toHaveBeenCalledWith(socket, 'session-1', expect.objectContaining({
queue_id: 'user-1',
}), 'default')
expect(state.queue).toEqual([])
expect(emit).toHaveBeenCalledWith('abort.completed', expect.objectContaining({
session_id: 'session-1',
synced: true,
}))
})
})
@@ -188,6 +188,139 @@ describe('bridge run final context usage', () => {
}))
})
it('evaluates active goals after a successful bridge run and queues continuation prompts', async () => {
const emit = vi.fn()
const nsp = makeNamespace(emit)
const socket = makeSocket()
const state = makeState()
const sessionMap = new Map([['session-1', state]])
const dequeueNextQueuedRun = vi.fn()
addMessageMock.mockReturnValue(42)
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,
}),
goalEvaluate: vi.fn().mockResolvedValue({
handled: true,
should_continue: true,
continuation_prompt: '[Continuing toward your standing goal]\nGoal: fix tests',
message: '↻ Continuing toward goal (1/20): tests still fail',
verdict: 'continue',
}),
streamOutput: vi.fn(async function* () {
yield {
run_id: 'run-1',
done: true,
status: 'completed',
output: 'not finished',
result: { final_response: 'not finished' },
}
}),
} as any
const { handleBridgeRun } = await import('../../packages/server/src/services/hermes/run-chat/handle-bridge-run')
await handleBridgeRun(
nsp,
socket,
{
input: 'hello',
session_id: 'session-1',
model_groups: [{ provider: 'openai', models: ['gpt-test'] }],
},
'default',
sessionMap,
bridge,
false,
vi.fn(),
dequeueNextQueuedRun,
)
expect(bridge.goalEvaluate).toHaveBeenCalledWith('session-1', 'not finished', 'default')
expect(addMessageMock).toHaveBeenCalledWith(expect.objectContaining({
session_id: 'session-1',
role: 'command',
content: '↻ Continuing toward goal (1/20): tests still fail',
}))
expect(emit).toHaveBeenCalledWith('session.command', expect.objectContaining({
command: 'goal',
action: 'continue',
message: '↻ Continuing toward goal (1/20): tests still fail',
}))
expect(state.queue).toEqual([expect.objectContaining({
input: '[Continuing toward your standing goal]\nGoal: fix tests',
displayInput: null,
storageMessage: '[Continuing toward your standing goal]\nGoal: fix tests',
model: 'gpt-test',
provider: 'openai',
model_groups: [{ provider: 'openai', models: ['gpt-test'] }],
goalContinuation: true,
})])
expect(dequeueNextQueuedRun).toHaveBeenCalledWith(socket, 'session-1')
})
it('skips hidden goal continuation runs without pausing when the judge is unavailable', async () => {
const emit = vi.fn()
const nsp = makeNamespace(emit)
const socket = makeSocket()
const state = makeState()
const sessionMap = new Map([['session-1', state]])
const dequeueNextQueuedRun = vi.fn()
addMessageMock.mockReturnValue(43)
const bridge = {
chat: vi.fn().mockResolvedValue({ run_id: 'run-1', status: 'started' }),
command: vi.fn(),
contextEstimate: vi.fn().mockResolvedValue({
token_count: 12345,
message_count: 2,
tool_count: 4,
system_prompt_chars: 13,
}),
goalEvaluate: vi.fn().mockResolvedValue({
handled: true,
should_continue: true,
continuation_prompt: '[Continuing toward your standing goal]\nGoal: fix tests',
message: '↻ Continuing toward goal (1/20): no auxiliary client configured',
verdict: 'continue',
reason: 'no auxiliary client configured',
}),
streamOutput: vi.fn(async function* () {
yield {
run_id: 'run-1',
done: true,
status: 'completed',
output: 'done',
result: { final_response: 'done' },
}
}),
} as any
const { handleBridgeRun } = await import('../../packages/server/src/services/hermes/run-chat/handle-bridge-run')
await handleBridgeRun(
nsp,
socket,
{ input: 'hello', session_id: 'session-1' },
'default',
sessionMap,
bridge,
false,
vi.fn(),
dequeueNextQueuedRun,
)
expect(bridge.command).not.toHaveBeenCalled()
expect(state.queue).toEqual([])
expect(dequeueNextQueuedRun).not.toHaveBeenCalled()
expect(emit).toHaveBeenCalledWith('session.command', expect.objectContaining({
command: 'goal',
action: 'judge_unavailable',
message: 'Goal judge is not configured; automatic goal continuation was skipped. The goal remains active, but Hermes cannot mark it done automatically.',
}))
})
it('uses cached fixed context instead of bridge estimate when available', async () => {
const emit = vi.fn()
const nsp = makeNamespace(emit)
@@ -402,4 +535,56 @@ describe('bridge run final context usage', () => {
contextTokens: 54321,
}))
})
it('emits bridge lifecycle status events so retries are visible', 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: false,
status: 'running',
events: [
{ event: 'status', kind: 'lifecycle', text: 'Retrying in 3.0s (attempt 1/3)...' },
],
}
yield { run_id: 'run-1', done: true, status: 'completed', output: 'done' }
}),
} as any
const { handleBridgeRun } = await import('../../packages/server/src/services/hermes/run-chat/handle-bridge-run')
await handleBridgeRun(
nsp,
socket,
{ input: 'hello', session_id: 'session-1' },
'default',
sessionMap,
bridge,
false,
vi.fn(),
vi.fn(),
)
expect(replaceStateMock).toHaveBeenCalledWith(sessionMap, 'session-1', 'agent.event', expect.objectContaining({
event: 'agent.event',
kind: 'lifecycle',
text: 'Retrying in 3.0s (attempt 1/3)...',
}))
expect(emit).toHaveBeenCalledWith('agent.event', expect.objectContaining({
event: 'agent.event',
kind: 'lifecycle',
text: 'Retrying in 3.0s (attempt 1/3)...',
}))
})
})
@@ -47,6 +47,19 @@ describe('run chat model config', () => {
expect(readConfigYamlForProfileMock).not.toHaveBeenCalled()
})
it('keeps an explicit model when no model group list is available', async () => {
const { resolveBridgeRunModelConfig } = await import('../../packages/server/src/services/hermes/run-chat/model-config')
const result = await resolveBridgeRunModelConfig({
profile: 'default',
requestedModel: 'gpt-5.5',
requestedProvider: 'custom',
})
expect(result).toEqual({ model: 'gpt-5.5', provider: 'custom' })
expect(readConfigYamlForProfileMock).not.toHaveBeenCalled()
})
it('falls back to the profile default when the candidate model is unavailable', async () => {
const { resolveBridgeRunModelConfig } = await import('../../packages/server/src/services/hermes/run-chat/model-config')
+202 -4
View File
@@ -40,7 +40,10 @@ vi.mock('../../packages/server/src/services/hermes/run-chat/bridge-message', ()
flushBridgePendingToDb: vi.fn(),
}))
function makeContext(state: any) {
function makeContext(state: any, commandResult: Record<string, unknown> = {
handled: true,
message: '[IMPORTANT: expanded plan skill prompt]',
}) {
const namespaceEmit = vi.fn()
const nsp = {
to: vi.fn(() => ({ emit: namespaceEmit })),
@@ -55,9 +58,12 @@ function makeContext(state: any) {
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]',
command: vi.fn(async () => commandResult),
status: vi.fn(async () => ({
exists: true,
running: false,
current_run_id: null,
message_count: 0,
})),
}
return { bridge, namespaceEmit, nsp, runQueuedItem, sessionMap, socket }
@@ -105,4 +111,196 @@ describe('plan session command', () => {
}))
expect(namespaceEmit).not.toHaveBeenCalledWith('session.command', expect.anything())
})
it('starts an idle goal command as a hidden kickoff run', async () => {
const state = { messages: [], isWorking: false, events: [], queue: [] }
const { bridge, namespaceEmit, runQueuedItem, sessionMap, socket, nsp } = makeContext(state, {
handled: true,
type: 'goal',
action: 'set',
message: 'Goal set.',
kickoff_prompt: 'fix the tests',
max_turns: 20,
})
const { handleSessionCommand, parseSessionCommand } = await import('../../packages/server/src/services/hermes/run-chat/session-command')
const command = parseSessionCommand('/goal fix the tests')!
await handleSessionCommand('session-1', command, {
nsp: nsp as any,
socket: socket as any,
sessionMap,
bridge: bridge as any,
profile: 'default',
queueId: 'goal-queue-id',
runQueuedItem,
})
expect(bridge.command).toHaveBeenCalledWith('session-1', 'goal fix the tests', 'default')
expect(namespaceEmit).toHaveBeenCalledWith('session.command', expect.objectContaining({
action: 'set',
message: 'Goal set.',
terminal: false,
started: true,
}))
expect(runQueuedItem).toHaveBeenCalledWith(socket, 'session-1', expect.objectContaining({
queue_id: 'goal-queue-id',
input: 'fix the tests',
displayInput: null,
storageMessage: 'fix the tests',
source: 'cli',
}), 'default')
})
it('clears queued goal continuations when pausing a goal', async () => {
const state = {
messages: [],
isWorking: true,
events: [],
queue: [
{ queue_id: 'goal-1', input: 'continue', displayInput: null, storageMessage: 'continue', profile: 'default', goalContinuation: true },
{ queue_id: 'user-1', input: 'user message', profile: 'default' },
],
}
const { bridge, namespaceEmit, runQueuedItem, sessionMap, socket, nsp } = makeContext(state, {
handled: true,
type: 'goal',
action: 'pause',
message: 'Goal paused.',
clear_goal_continuations: true,
})
const { handleSessionCommand, parseSessionCommand } = await import('../../packages/server/src/services/hermes/run-chat/session-command')
const command = parseSessionCommand('/goal pause')!
await handleSessionCommand('session-1', command, {
nsp: nsp as any,
socket: socket as any,
sessionMap,
bridge: bridge as any,
profile: 'default',
runQueuedItem,
})
expect(runQueuedItem).not.toHaveBeenCalled()
expect(state.queue).toEqual([expect.objectContaining({ queue_id: 'user-1' })])
expect(namespaceEmit).toHaveBeenCalledWith('run.queued', expect.objectContaining({
queue_length: 1,
queued_messages: [expect.objectContaining({ id: 'user-1', content: 'user message' })],
}))
})
it('emits a goal-specific clear action for goal done', async () => {
const state = {
messages: [],
isWorking: false,
events: [],
queue: [
{ queue_id: 'goal-1', input: 'continue', displayInput: null, storageMessage: 'continue', profile: 'default', goalContinuation: true },
],
}
const { bridge, namespaceEmit, runQueuedItem, sessionMap, socket, nsp } = makeContext(state, {
handled: true,
type: 'goal',
action: 'clear',
message: 'Goal cleared.',
clear_goal_continuations: true,
})
const { handleSessionCommand, parseSessionCommand } = await import('../../packages/server/src/services/hermes/run-chat/session-command')
const command = parseSessionCommand('/goal done')!
await handleSessionCommand('session-1', command, {
nsp: nsp as any,
socket: socket as any,
sessionMap,
bridge: bridge as any,
profile: 'default',
runQueuedItem,
})
expect(bridge.command).toHaveBeenCalledWith('session-1', 'goal done', 'default')
expect(runQueuedItem).not.toHaveBeenCalled()
expect(state.queue).toEqual([])
expect(namespaceEmit).toHaveBeenCalledWith('session.command', expect.objectContaining({
command: 'goal',
action: 'goal_clear',
message: 'Goal cleared.',
terminal: true,
started: false,
}))
})
it('starts a resumed goal as a hidden continuation run', async () => {
const state = { messages: [], isWorking: false, events: [], queue: [] }
const { bridge, namespaceEmit, runQueuedItem, sessionMap, socket, nsp } = makeContext(state, {
handled: true,
type: 'goal',
action: 'resume',
message: 'Goal resumed.',
kickoff_prompt: '[Continuing toward your standing goal]\nGoal: fix the tests',
max_turns: 20,
})
const { handleSessionCommand, parseSessionCommand } = await import('../../packages/server/src/services/hermes/run-chat/session-command')
const command = parseSessionCommand('/goal resume')!
await handleSessionCommand('session-1', command, {
nsp: nsp as any,
socket: socket as any,
sessionMap,
bridge: bridge as any,
profile: 'default',
queueId: 'resume-queue-id',
runQueuedItem,
})
expect(bridge.command).toHaveBeenCalledWith('session-1', 'goal resume', 'default')
expect(namespaceEmit).toHaveBeenCalledWith('session.command', expect.objectContaining({
action: 'resume',
message: 'Goal resumed.',
terminal: false,
started: true,
}))
expect(runQueuedItem).toHaveBeenCalledWith(socket, 'session-1', expect.objectContaining({
queue_id: 'resume-queue-id',
input: '[Continuing toward your standing goal]\nGoal: fix the tests',
displayInput: null,
storageMessage: '[Continuing toward your standing goal]\nGoal: fix the tests',
source: 'cli',
}), 'default')
})
it('includes bridge run state in goal status output', async () => {
const state = { messages: [], isWorking: false, events: [], queue: [] }
const { bridge, namespaceEmit, runQueuedItem, sessionMap, socket, nsp } = makeContext(state, {
handled: true,
type: 'goal',
action: 'goal_status',
message: 'Goal (active, 0/20 turns): build docs',
})
bridge.status.mockResolvedValueOnce({
exists: true,
running: true,
current_run_id: 'run-123',
message_count: 4,
})
const { handleSessionCommand, parseSessionCommand } = await import('../../packages/server/src/services/hermes/run-chat/session-command')
const command = parseSessionCommand('/goal status')!
await handleSessionCommand('session-1', command, {
nsp: nsp as any,
socket: socket as any,
sessionMap,
bridge: bridge as any,
profile: 'default',
runQueuedItem,
})
expect(runQueuedItem).not.toHaveBeenCalled()
expect(namespaceEmit).toHaveBeenCalledWith('session.command', expect.objectContaining({
action: 'goal_status',
message: 'Goal (active, 0/20 turns): build docs\nCurrent turn: 1/20 running (completed turns: 0/20; count updates after the judge).\nRun: running (run-123)',
bridgeStatus: expect.objectContaining({
running: true,
currentRunId: 'run-123',
}),
}))
})
})