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:
@@ -28,6 +28,7 @@ const bridgeCommands = computed(() => [
|
|||||||
{ name: 'status', args: '', description: t('chat.slashCommands.status') },
|
{ name: 'status', args: '', description: t('chat.slashCommands.status') },
|
||||||
{ name: 'abort', args: '', description: t('chat.slashCommands.abort') },
|
{ name: 'abort', args: '', description: t('chat.slashCommands.abort') },
|
||||||
{ name: 'queue', args: t('chat.slashCommandArgs.message'), description: t('chat.slashCommands.queue') },
|
{ 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: '', description: t('chat.slashCommands.clear') },
|
||||||
{ name: 'clear', args: '--history', insertText: 'clear --history', description: t('chat.slashCommands.clearHistory') },
|
{ name: 'clear', args: '--history', insertText: 'clear --history', description: t('chat.slashCommands.clearHistory') },
|
||||||
{ name: 'title', args: t('chat.slashCommandArgs.title'), description: t('chat.slashCommands.title') },
|
{ name: 'title', args: t('chat.slashCommandArgs.title'), description: t('chat.slashCommands.title') },
|
||||||
|
|||||||
@@ -1307,7 +1307,7 @@ async function handleSessionModelCustomSubmit() {
|
|||||||
{{ t('chat.clarifyDismiss') }}
|
{{ t('chat.clarifyDismiss') }}
|
||||||
</NButton>
|
</NButton>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="clarify-actions">
|
<div v-else class="clarify-actions clarify-actions-open">
|
||||||
<div class="clarify-input-row">
|
<div class="clarify-input-row">
|
||||||
<NInput
|
<NInput
|
||||||
v-model:value="clarifyResponse"
|
v-model:value="clarifyResponse"
|
||||||
@@ -2168,12 +2168,24 @@ async function handleSessionModelCustomSubmit() {
|
|||||||
.clarify-input-row {
|
.clarify-input-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
.n-input {
|
:deep(.n-input) {
|
||||||
flex: 1;
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:deep(.n-button) {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.clarify-actions-open {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.approval-bar {
|
.approval-bar {
|
||||||
@@ -2212,9 +2224,18 @@ async function handleSessionModelCustomSubmit() {
|
|||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.clarify-actions-open {
|
||||||
|
display: flex;
|
||||||
|
grid-template-columns: none;
|
||||||
|
}
|
||||||
|
|
||||||
.clarify-actions :deep(.n-button) {
|
.clarify-actions :deep(.n-button) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.clarify-actions-open :deep(.n-button) {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 420px) {
|
@media (max-width: 420px) {
|
||||||
@@ -2233,6 +2254,15 @@ async function handleSessionModelCustomSubmit() {
|
|||||||
.clarify-actions {
|
.clarify-actions {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.clarify-input-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clarify-actions-open :deep(.n-button) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes rainbow-glow {
|
@keyframes rainbow-glow {
|
||||||
|
|||||||
@@ -218,6 +218,7 @@ export default {
|
|||||||
status: 'Sitzungsstatus und Warteschlange anzeigen',
|
status: 'Sitzungsstatus und Warteschlange anzeigen',
|
||||||
abort: 'Aktiven Bridge-Lauf stoppen',
|
abort: 'Aktiven Bridge-Lauf stoppen',
|
||||||
queue: 'Nachricht hinter dem aktiven Lauf einreihen',
|
queue: 'Nachricht hinter dem aktiven Lauf einreihen',
|
||||||
|
plan: 'Markdown-Implementierungsplan schreiben',
|
||||||
clear: 'Aktuelle Anzeige leeren',
|
clear: 'Aktuelle Anzeige leeren',
|
||||||
clearHistory: 'Gespeicherten Nachrichtenverlauf dieser Sitzung löschen',
|
clearHistory: 'Gespeicherten Nachrichtenverlauf dieser Sitzung löschen',
|
||||||
title: 'Diese Sitzung umbenennen',
|
title: 'Diese Sitzung umbenennen',
|
||||||
|
|||||||
@@ -219,6 +219,7 @@ export default {
|
|||||||
status: 'Show session status and queue',
|
status: 'Show session status and queue',
|
||||||
abort: 'Stop the active bridge run',
|
abort: 'Stop the active bridge run',
|
||||||
queue: 'Queue a message behind the active run',
|
queue: 'Queue a message behind the active run',
|
||||||
|
plan: 'Write a markdown implementation plan',
|
||||||
clear: 'Clear the current display',
|
clear: 'Clear the current display',
|
||||||
clearHistory: 'Delete this session’s stored message history',
|
clearHistory: 'Delete this session’s stored message history',
|
||||||
title: 'Rename this session',
|
title: 'Rename this session',
|
||||||
|
|||||||
@@ -218,6 +218,7 @@ export default {
|
|||||||
status: 'Mostrar estado de sesión y cola',
|
status: 'Mostrar estado de sesión y cola',
|
||||||
abort: 'Detener la ejecución activa de Bridge',
|
abort: 'Detener la ejecución activa de Bridge',
|
||||||
queue: 'Poner un mensaje en cola tras la ejecución activa',
|
queue: 'Poner un mensaje en cola tras la ejecución activa',
|
||||||
|
plan: 'Escribir un plan de implementación en Markdown',
|
||||||
clear: 'Limpiar la vista actual',
|
clear: 'Limpiar la vista actual',
|
||||||
clearHistory: 'Eliminar el historial de mensajes guardado de esta sesión',
|
clearHistory: 'Eliminar el historial de mensajes guardado de esta sesión',
|
||||||
title: 'Renombrar esta sesión',
|
title: 'Renombrar esta sesión',
|
||||||
|
|||||||
@@ -218,6 +218,7 @@ export default {
|
|||||||
status: 'Afficher l’état de la session et la file',
|
status: 'Afficher l’état de la session et la file',
|
||||||
abort: 'Arrêter l’exécution Bridge active',
|
abort: 'Arrêter l’exécution Bridge active',
|
||||||
queue: 'Mettre un message en file après l’exécution active',
|
queue: 'Mettre un message en file après l’exécution active',
|
||||||
|
plan: 'Rédiger un plan d’implémentation Markdown',
|
||||||
clear: 'Effacer l’affichage actuel',
|
clear: 'Effacer l’affichage actuel',
|
||||||
clearHistory: 'Supprimer l’historique des messages enregistrés de cette session',
|
clearHistory: 'Supprimer l’historique des messages enregistrés de cette session',
|
||||||
title: 'Renommer cette session',
|
title: 'Renommer cette session',
|
||||||
|
|||||||
@@ -218,6 +218,7 @@ export default {
|
|||||||
status: 'セッション状態とキューを表示',
|
status: 'セッション状態とキューを表示',
|
||||||
abort: '実行中の Bridge を停止',
|
abort: '実行中の Bridge を停止',
|
||||||
queue: '実行中の処理の後ろにメッセージをキュー追加',
|
queue: '実行中の処理の後ろにメッセージをキュー追加',
|
||||||
|
plan: 'Markdown の実装計画を作成',
|
||||||
clear: '現在の表示をクリア',
|
clear: '現在の表示をクリア',
|
||||||
clearHistory: 'このセッションの保存済みメッセージ履歴を削除',
|
clearHistory: 'このセッションの保存済みメッセージ履歴を削除',
|
||||||
title: 'このセッション名を変更',
|
title: 'このセッション名を変更',
|
||||||
|
|||||||
@@ -218,6 +218,7 @@ export default {
|
|||||||
status: '세션 상태와 대기열 표시',
|
status: '세션 상태와 대기열 표시',
|
||||||
abort: '활성 Bridge 실행 중지',
|
abort: '활성 Bridge 실행 중지',
|
||||||
queue: '활성 실행 뒤에 메시지 대기열 추가',
|
queue: '활성 실행 뒤에 메시지 대기열 추가',
|
||||||
|
plan: 'Markdown 구현 계획 작성',
|
||||||
clear: '현재 표시 내용 지우기',
|
clear: '현재 표시 내용 지우기',
|
||||||
clearHistory: '이 세션의 저장된 메시지 기록 삭제',
|
clearHistory: '이 세션의 저장된 메시지 기록 삭제',
|
||||||
title: '이 세션 이름 변경',
|
title: '이 세션 이름 변경',
|
||||||
|
|||||||
@@ -218,6 +218,7 @@ export default {
|
|||||||
status: 'Mostrar status da sessão e fila',
|
status: 'Mostrar status da sessão e fila',
|
||||||
abort: 'Parar a execução ativa do Bridge',
|
abort: 'Parar a execução ativa do Bridge',
|
||||||
queue: 'Enfileirar uma mensagem após a execução ativa',
|
queue: 'Enfileirar uma mensagem após a execução ativa',
|
||||||
|
plan: 'Escrever um plano de implementação em Markdown',
|
||||||
clear: 'Limpar a visualização atual',
|
clear: 'Limpar a visualização atual',
|
||||||
clearHistory: 'Excluir o histórico de mensagens salvo desta sessão',
|
clearHistory: 'Excluir o histórico de mensagens salvo desta sessão',
|
||||||
title: 'Renomear esta sessão',
|
title: 'Renomear esta sessão',
|
||||||
|
|||||||
@@ -218,6 +218,7 @@ export default {
|
|||||||
status: '查看會話狀態和佇列',
|
status: '查看會話狀態和佇列',
|
||||||
abort: '停止目前 Bridge 執行',
|
abort: '停止目前 Bridge 執行',
|
||||||
queue: '將訊息加入目前執行後的佇列',
|
queue: '將訊息加入目前執行後的佇列',
|
||||||
|
plan: '產生一份 Markdown 實作計畫',
|
||||||
clear: '清空目前顯示內容',
|
clear: '清空目前顯示內容',
|
||||||
clearHistory: '刪除目前會話已儲存的訊息歷史',
|
clearHistory: '刪除目前會話已儲存的訊息歷史',
|
||||||
title: '重新命名目前會話',
|
title: '重新命名目前會話',
|
||||||
|
|||||||
@@ -219,6 +219,7 @@ export default {
|
|||||||
status: '查看会话状态和队列',
|
status: '查看会话状态和队列',
|
||||||
abort: '停止当前 Bridge 运行',
|
abort: '停止当前 Bridge 运行',
|
||||||
queue: '把消息加入当前运行后的队列',
|
queue: '把消息加入当前运行后的队列',
|
||||||
|
plan: '生成一份 Markdown 实施计划',
|
||||||
clear: '清空当前显示内容',
|
clear: '清空当前显示内容',
|
||||||
clearHistory: '删除当前会话已入库的消息历史',
|
clearHistory: '删除当前会话已入库的消息历史',
|
||||||
title: '重命名当前会话',
|
title: '重命名当前会话',
|
||||||
|
|||||||
@@ -967,12 +967,14 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
const timestamp = typeof peer?.timestamp === 'number' && Number.isFinite(peer.timestamp)
|
const timestamp = typeof peer?.timestamp === 'number' && Number.isFinite(peer.timestamp)
|
||||||
? Math.round(peer.timestamp * 1000)
|
? Math.round(peer.timestamp * 1000)
|
||||||
: Date.now()
|
: Date.now()
|
||||||
|
const role = peer?.role === 'command' ? 'command' : 'user'
|
||||||
return [{
|
return [{
|
||||||
id: messageId,
|
id: messageId,
|
||||||
role: 'user' as const,
|
role,
|
||||||
content,
|
content,
|
||||||
timestamp,
|
timestamp,
|
||||||
queued: true,
|
queued: true,
|
||||||
|
systemType: role === 'command' ? 'command' as const : undefined,
|
||||||
}]
|
}]
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1028,11 +1030,12 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
enqueueUserMessage(sessionId, {
|
enqueueUserMessage(sessionId, {
|
||||||
...(existing || {}),
|
...(existing || {}),
|
||||||
id: messageId,
|
id: messageId,
|
||||||
role: 'user',
|
role: peer?.role === 'command' ? 'command' : 'user',
|
||||||
content,
|
content,
|
||||||
timestamp: existing?.timestamp || timestamp,
|
timestamp: existing?.timestamp || timestamp,
|
||||||
attachments: existing?.attachments,
|
attachments: existing?.attachments,
|
||||||
queued: true,
|
queued: true,
|
||||||
|
systemType: peer?.role === 'command' ? 'command' : existing?.systemType,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1093,6 +1096,22 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
pendingClarifies.value = new Map(pendingClarifies.value)
|
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) {
|
function respondToClarify(response: string) {
|
||||||
const pending = activePendingClarify.value
|
const pending = activePendingClarify.value
|
||||||
if (!pending) return
|
if (!pending) return
|
||||||
@@ -1169,8 +1188,9 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
: false
|
: false
|
||||||
const isBridgeSlashCommand = content.trim().startsWith('/')
|
const isBridgeSlashCommand = content.trim().startsWith('/')
|
||||||
const isBridgeCompressCommand = isBridgeSlashCommand && /^\/compress(?:\s|$)/i.test(content.trim())
|
const isBridgeCompressCommand = isBridgeSlashCommand && /^\/compress(?:\s|$)/i.test(content.trim())
|
||||||
|
const isBridgePlanCommand = isBridgeSlashCommand && /^\/plan(?:\s|$)/i.test(content.trim())
|
||||||
const wasLiveBeforeSend = isSessionLive(sid)
|
const wasLiveBeforeSend = isSessionLive(sid)
|
||||||
const shouldQueue = wasLiveBeforeSend && !isBridgeSlashCommand
|
const shouldQueue = wasLiveBeforeSend && (!isBridgeSlashCommand || isBridgePlanCommand)
|
||||||
|
|
||||||
const userMsg: Message = {
|
const userMsg: Message = {
|
||||||
id: uid(),
|
id: uid(),
|
||||||
@@ -1449,6 +1469,7 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
|
|
||||||
case 'abort.completed': {
|
case 'abort.completed': {
|
||||||
setAbortState({ aborting: false, synced: (evt as any).synced ?? false })
|
setAbortState({ aborting: false, synced: (evt as any).synced ?? false })
|
||||||
|
clearPendingInteractions(sid)
|
||||||
if ((evt as any).queue_length > 0) {
|
if ((evt as any).queue_length > 0) {
|
||||||
queueLengths.value.set(sid, (evt as any).queue_length)
|
queueLengths.value.set(sid, (evt as any).queue_length)
|
||||||
setAbortState(null)
|
setAbortState(null)
|
||||||
@@ -1798,7 +1819,7 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
{ onReconnectResume: applyReconnectResume },
|
{ onReconnectResume: applyReconnectResume },
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!isBridgeSlashCommand || isBridgeCompressCommand) {
|
if (!isBridgeSlashCommand || isBridgeCompressCommand || isBridgePlanCommand) {
|
||||||
streamStates.value.set(sid, ctrl)
|
streamStates.value.set(sid, ctrl)
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -1920,6 +1941,7 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
|
|
||||||
case 'abort.completed': {
|
case 'abort.completed': {
|
||||||
setAbortState({ aborting: false, synced: (evt as any).synced ?? false })
|
setAbortState({ aborting: false, synced: (evt as any).synced ?? false })
|
||||||
|
clearPendingInteractions(sid)
|
||||||
if ((evt as any).queue_length > 0) {
|
if ((evt as any).queue_length > 0) {
|
||||||
queueLengths.value.set(sid, (evt as any).queue_length)
|
queueLengths.value.set(sid, (evt as any).queue_length)
|
||||||
setAbortState(null)
|
setAbortState(null)
|
||||||
@@ -2286,10 +2308,11 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
|
|
||||||
const message: Message = {
|
const message: Message = {
|
||||||
id: messageId || uid(),
|
id: messageId || uid(),
|
||||||
role: 'user',
|
role: peer?.role === 'command' ? 'command' : 'user',
|
||||||
content,
|
content,
|
||||||
timestamp,
|
timestamp,
|
||||||
queued: !!peer?.queued,
|
queued: !!peer?.queued,
|
||||||
|
systemType: peer?.role === 'command' ? 'command' : undefined,
|
||||||
}
|
}
|
||||||
if (peer?.queued) {
|
if (peer?.queued) {
|
||||||
enqueueUserMessage(sid, message)
|
enqueueUserMessage(sid, message)
|
||||||
@@ -2307,6 +2330,7 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
const sid = activeSessionId.value
|
const sid = activeSessionId.value
|
||||||
if (!sid) return
|
if (!sid) return
|
||||||
if (isAborting.value) return
|
if (isAborting.value) return
|
||||||
|
clearPendingInteractions(sid)
|
||||||
const ctrl = streamStates.value.get(sid)
|
const ctrl = streamStates.value.get(sid)
|
||||||
if (ctrl) {
|
if (ctrl) {
|
||||||
setAbortState({ aborting: true, synced: null })
|
setAbortState({ aborting: true, synced: null })
|
||||||
|
|||||||
@@ -109,7 +109,12 @@ export interface AgentBridgeCommandResult extends AgentBridgeResponse {
|
|||||||
session_id: string
|
session_id: string
|
||||||
command: string
|
command: string
|
||||||
handled: boolean
|
handled: boolean
|
||||||
|
type?: string
|
||||||
message?: string
|
message?: string
|
||||||
|
output?: string
|
||||||
|
notice?: string
|
||||||
|
loaded?: string[]
|
||||||
|
missing?: string[]
|
||||||
new_session_id?: string
|
new_session_id?: string
|
||||||
history?: unknown[]
|
history?: unknown[]
|
||||||
retry?: boolean
|
retry?: boolean
|
||||||
@@ -405,11 +410,12 @@ export class AgentBridgeClient {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
command(sessionId: string, command: string): Promise<AgentBridgeCommandResult> {
|
command(sessionId: string, command: string, profile?: string): Promise<AgentBridgeCommandResult> {
|
||||||
return this.request<AgentBridgeCommandResult>({
|
return this.request<AgentBridgeCommandResult>({
|
||||||
action: 'command',
|
action: 'command',
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
command,
|
command,
|
||||||
|
...(profile ? { profile } : {}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1470,6 +1470,80 @@ class AgentPool:
|
|||||||
with session.lock:
|
with session.lock:
|
||||||
return {"session_id": session_id, "history": copy.deepcopy(session.history)}
|
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]:
|
def get_result(self, run_id: str) -> dict[str, Any]:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
record = self._runs.get(run_id)
|
record = self._runs.get(run_id)
|
||||||
@@ -1701,6 +1775,16 @@ class BridgeServer:
|
|||||||
if action == "get_history":
|
if action == "get_history":
|
||||||
return self.pool.get_history(str(req.get("session_id") or ""))
|
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":
|
if action == "destroy":
|
||||||
return self.pool.destroy(str(req.get("session_id") or ""))
|
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 ""))
|
profile = self._profile_for_run(str(req.get("run_id") or ""))
|
||||||
return self._forward(profile, req)
|
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 "")
|
session_id = str(req.get("session_id") or "")
|
||||||
profile = self._profile_for_session(session_id, req.get("profile"))
|
profile = self._profile_for_session(session_id, req.get("profile"))
|
||||||
resp = self._forward(profile, req)
|
resp = self._forward(profile, req)
|
||||||
|
|||||||
@@ -126,11 +126,11 @@ function cacheBridgeContext(state: SessionState, data: Record<string, unknown> |
|
|||||||
export async function handleBridgeRun(
|
export async function handleBridgeRun(
|
||||||
nsp: ReturnType<Server['of']>,
|
nsp: ReturnType<Server['of']>,
|
||||||
socket: Socket,
|
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,
|
profile: string,
|
||||||
sessionMap: Map<string, SessionState>,
|
sessionMap: Map<string, SessionState>,
|
||||||
bridge: AgentBridgeClient,
|
bridge: AgentBridgeClient,
|
||||||
_skipUserMessage = false,
|
skipUserMessage = false,
|
||||||
loadSessionStateFromDbFn: (sid: string, sessionMap: Map<string, SessionState>) => Promise<SessionState>,
|
loadSessionStateFromDbFn: (sid: string, sessionMap: Map<string, SessionState>) => Promise<SessionState>,
|
||||||
dequeueNextQueuedRun: (socket: Socket, sessionId: string, fallbackProfile?: string) => void,
|
dequeueNextQueuedRun: (socket: Socket, sessionId: string, fallbackProfile?: string) => void,
|
||||||
) {
|
) {
|
||||||
@@ -193,29 +193,41 @@ export async function handleBridgeRun(
|
|||||||
state.bridgePendingTools = []
|
state.bridgePendingTools = []
|
||||||
state.responseRun = undefined
|
state.responseRun = undefined
|
||||||
|
|
||||||
const inputStr = contentBlocksToString(input)
|
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 (shouldPersistUserMessage) {
|
||||||
state.messages.push({
|
state.messages.push({
|
||||||
id: state.messages.length + 1,
|
id: state.messages.length + 1,
|
||||||
session_id,
|
session_id,
|
||||||
runMarker,
|
runMarker,
|
||||||
role: 'user',
|
role: displayRole,
|
||||||
content: inputStr,
|
content: inputStr,
|
||||||
timestamp: now,
|
timestamp: now,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!getSession(session_id)) {
|
if (!getSession(session_id)) {
|
||||||
const previewText = extractTextForPreview(input)
|
const previewText = extractTextForPreview(displayInput || input)
|
||||||
const preview = previewText.replace(/[\r\n]/g, ' ').substring(0, 100)
|
const preview = previewText.replace(/[\r\n]/g, ' ').substring(0, 100)
|
||||||
createSession({ id: session_id, profile, source: 'cli', model: resolvedModel, provider: resolvedProvider, title: preview })
|
createSession({ id: session_id, profile, source: 'cli', model: resolvedModel, provider: resolvedProvider, title: preview })
|
||||||
}
|
}
|
||||||
const messageId = addMessage({
|
messageId = addMessage({
|
||||||
session_id,
|
session_id,
|
||||||
role: 'user',
|
role: displayRole,
|
||||||
content: inputStr,
|
content: inputStr,
|
||||||
timestamp: now,
|
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 })
|
||||||
|
}
|
||||||
|
|
||||||
socket.join(`session:${session_id}`)
|
socket.join(`session:${session_id}`)
|
||||||
|
if (shouldPersistUserMessage) {
|
||||||
const peerTarget = data.peerExcludeSocketId
|
const peerTarget = data.peerExcludeSocketId
|
||||||
? nsp.to(`session:${session_id}`).except(data.peerExcludeSocketId)
|
? nsp.to(`session:${session_id}`).except(data.peerExcludeSocketId)
|
||||||
: socket.to(`session:${session_id}`)
|
: socket.to(`session:${session_id}`)
|
||||||
@@ -224,11 +236,12 @@ export async function handleBridgeRun(
|
|||||||
session_id,
|
session_id,
|
||||||
message: {
|
message: {
|
||||||
id: data.queue_id || messageId,
|
id: data.queue_id || messageId,
|
||||||
role: 'user',
|
role: displayRole,
|
||||||
content: inputStr,
|
content: inputStr,
|
||||||
timestamp: now,
|
timestamp: now,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
}
|
||||||
const emit = (event: string, payload: any) => {
|
const emit = (event: string, payload: any) => {
|
||||||
const tagged = { ...payload, session_id }
|
const tagged = { ...payload, session_id }
|
||||||
nsp.to(`session:${session_id}`).emit(event, tagged)
|
nsp.to(`session:${session_id}`).emit(event, tagged)
|
||||||
@@ -278,7 +291,9 @@ export async function handleBridgeRun(
|
|||||||
const bridgeInput = isContentBlockArray(input)
|
const bridgeInput = isContentBlockArray(input)
|
||||||
? await convertContentBlocksForAgent(input)
|
? await convertContentBlocksForAgent(input)
|
||||||
: input
|
: input
|
||||||
const bridgeStorageInput = isContentBlockArray(input)
|
const bridgeStorageInput = data.storage_message !== undefined
|
||||||
|
? data.storage_message
|
||||||
|
: isContentBlockArray(input)
|
||||||
? inputStr
|
? inputStr
|
||||||
: undefined
|
: undefined
|
||||||
logger.info('[chat-run-socket] starting CLI bridge run for session %s', session_id)
|
logger.info('[chat-run-socket] starting CLI bridge run for session %s', session_id)
|
||||||
@@ -606,6 +621,25 @@ async function applyBridgeChunkAsync(
|
|||||||
}
|
}
|
||||||
replaceState(sessionMap, sessionId, 'approval.resolved', payload)
|
replaceState(sessionMap, sessionId, 'approval.resolved', payload)
|
||||||
emit('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') {
|
} else if (evType === 'bridge.compression.requested') {
|
||||||
const bridgeHistory = await buildDbHistory(sessionId, { excludeLastUser: true })
|
const bridgeHistory = await buildDbHistory(sessionId, { excludeLastUser: true })
|
||||||
const bridgeUsage = estimateUsageTokensFromMessages(bridgeHistory)
|
const bridgeUsage = estimateUsageTokensFromMessages(bridgeHistory)
|
||||||
|
|||||||
@@ -99,6 +99,9 @@ export class ChatRunSocket {
|
|||||||
|
|
||||||
socket.on('run', async (data: {
|
socket.on('run', async (data: {
|
||||||
input: string | ContentBlock[]
|
input: string | ContentBlock[]
|
||||||
|
display_input?: string | ContentBlock[] | null
|
||||||
|
display_role?: 'user' | 'command'
|
||||||
|
storage_message?: string
|
||||||
session_id?: string
|
session_id?: string
|
||||||
model?: string
|
model?: string
|
||||||
instructions?: string
|
instructions?: string
|
||||||
@@ -133,6 +136,7 @@ export class ChatRunSocket {
|
|||||||
profile: runProfile,
|
profile: runProfile,
|
||||||
model: data.model,
|
model: data.model,
|
||||||
instructions: data.instructions,
|
instructions: data.instructions,
|
||||||
|
queueId: data.queue_id,
|
||||||
runQueuedItem: this.runQueuedItem.bind(this),
|
runQueuedItem: this.runQueuedItem.bind(this),
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -268,6 +272,9 @@ export class ChatRunSocket {
|
|||||||
socket: Socket,
|
socket: Socket,
|
||||||
data: {
|
data: {
|
||||||
input: string | ContentBlock[]
|
input: string | ContentBlock[]
|
||||||
|
display_input?: string | ContentBlock[] | null
|
||||||
|
display_role?: 'user' | 'command'
|
||||||
|
storage_message?: string
|
||||||
session_id?: string
|
session_id?: string
|
||||||
model?: string
|
model?: string
|
||||||
provider?: string
|
provider?: string
|
||||||
@@ -358,8 +365,12 @@ export class ChatRunSocket {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private runQueuedItem(socket: Socket, sessionId: string, next: QueuedRun, fallbackProfile = 'default') {
|
private runQueuedItem(socket: Socket, sessionId: string, next: QueuedRun, fallbackProfile = 'default') {
|
||||||
|
const skipUserMessage = next.displayInput === null
|
||||||
void this.handleRun(socket, {
|
void this.handleRun(socket, {
|
||||||
input: next.input,
|
input: next.input,
|
||||||
|
display_input: next.displayInput,
|
||||||
|
display_role: next.displayRole,
|
||||||
|
storage_message: next.storageMessage,
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
model: next.model,
|
model: next.model,
|
||||||
provider: next.provider,
|
provider: next.provider,
|
||||||
@@ -368,7 +379,7 @@ export class ChatRunSocket {
|
|||||||
source: next.source,
|
source: next.source,
|
||||||
queue_id: next.queue_id,
|
queue_id: next.queue_id,
|
||||||
peerExcludeSocketId: next.originSocketId,
|
peerExcludeSocketId: next.originSocketId,
|
||||||
}, next.profile || fallbackProfile, true)
|
}, next.profile || fallbackProfile, skipUserMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Helpers ---
|
// --- Helpers ---
|
||||||
@@ -384,8 +395,10 @@ export class ChatRunSocket {
|
|||||||
private serializeQueuedMessages(queue: QueuedRun[]) {
|
private serializeQueuedMessages(queue: QueuedRun[]) {
|
||||||
return queue.map(item => ({
|
return queue.map(item => ({
|
||||||
id: item.queue_id,
|
id: item.queue_id,
|
||||||
role: 'user',
|
role: item.displayRole || (typeof item.displayInput === 'string' && item.displayInput.trim().startsWith('/') ? 'command' : 'user'),
|
||||||
content: contentBlocksToString(item.input),
|
content: item.displayInput === null
|
||||||
|
? (item.storageMessage || '')
|
||||||
|
: contentBlocksToString(item.displayInput ?? item.input),
|
||||||
timestamp: Math.floor(Date.now() / 1000),
|
timestamp: Math.floor(Date.now() / 1000),
|
||||||
queued: true,
|
queued: true,
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ type CommandName =
|
|||||||
| 'status'
|
| 'status'
|
||||||
| 'abort'
|
| 'abort'
|
||||||
| 'queue'
|
| 'queue'
|
||||||
|
| 'plan'
|
||||||
| 'clear'
|
| 'clear'
|
||||||
| 'title'
|
| 'title'
|
||||||
| 'compress'
|
| 'compress'
|
||||||
@@ -34,6 +35,7 @@ interface SessionCommandContext {
|
|||||||
profile: string
|
profile: string
|
||||||
model?: string
|
model?: string
|
||||||
instructions?: string
|
instructions?: string
|
||||||
|
queueId?: string
|
||||||
runQueuedItem: (socket: Socket, sessionId: string, next: QueuedRun, fallbackProfile?: string) => void
|
runQueuedItem: (socket: Socket, sessionId: string, next: QueuedRun, fallbackProfile?: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,6 +44,7 @@ const COMMAND_ALIASES: Record<string, CommandName> = {
|
|||||||
status: 'status',
|
status: 'status',
|
||||||
abort: 'abort',
|
abort: 'abort',
|
||||||
queue: 'queue',
|
queue: 'queue',
|
||||||
|
plan: 'plan',
|
||||||
clear: 'clear',
|
clear: 'clear',
|
||||||
title: 'title',
|
title: 'title',
|
||||||
compress: 'compress',
|
compress: 'compress',
|
||||||
@@ -74,7 +77,9 @@ export async function handleSessionCommand(
|
|||||||
const state = getOrCreateSession(ctx.sessionMap, sessionId)
|
const state = getOrCreateSession(ctx.sessionMap, sessionId)
|
||||||
ctx.socket.join(`session:${sessionId}`)
|
ctx.socket.join(`session:${sessionId}`)
|
||||||
ensureCommandSession(sessionId, ctx)
|
ensureCommandSession(sessionId, ctx)
|
||||||
|
if (command.name !== 'plan') {
|
||||||
persistCommandMessage(sessionId, state, `/${command.rawName}${command.args ? ` ${command.args}` : ''}`)
|
persistCommandMessage(sessionId, state, `/${command.rawName}${command.args ? ` ${command.args}` : ''}`)
|
||||||
|
}
|
||||||
|
|
||||||
const emitCommand = (payload: Record<string, unknown>) => {
|
const emitCommand = (payload: Record<string, unknown>) => {
|
||||||
const message = typeof payload.message === 'string' ? payload.message : ''
|
const message = typeof payload.message === 'string' ? payload.message : ''
|
||||||
@@ -182,6 +187,74 @@ export async function handleSessionCommand(
|
|||||||
return
|
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': {
|
case 'clear': {
|
||||||
if (command.args === '--history') {
|
if (command.args === '--history') {
|
||||||
if (state.isWorking) {
|
if (state.isWorking) {
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ export interface SessionMessage {
|
|||||||
export interface QueuedRun {
|
export interface QueuedRun {
|
||||||
queue_id: string
|
queue_id: string
|
||||||
input: string | ContentBlock[]
|
input: string | ContentBlock[]
|
||||||
|
displayInput?: string | ContentBlock[] | null
|
||||||
|
displayRole?: 'user' | 'command'
|
||||||
|
storageMessage?: string
|
||||||
model?: string
|
model?: string
|
||||||
provider?: string
|
provider?: string
|
||||||
model_groups?: Array<{ provider: string; models: string[] }>
|
model_groups?: Array<{ provider: string; models: string[] }>
|
||||||
|
|||||||
@@ -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 () => {
|
it('refreshes full context tokens when a bridge run fails', async () => {
|
||||||
const emit = vi.fn()
|
const emit = vi.fn()
|
||||||
const nsp = makeNamespace(emit)
|
const nsp = makeNamespace(emit)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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())
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user