[codex] add clarify support with response path tests (#972)

* feat: 新增 clarify(澄清/确认)交互支持

* test clarify response bridge path

---------

Co-authored-by: GoldenFish123321 <golden_fish@foxmail.com>
This commit is contained in:
ekko
2026-05-24 18:09:39 +08:00
committed by GitHub
parent a7f0a92fe6
commit e743c81ad3
17 changed files with 568 additions and 1 deletions
+47
View File
@@ -105,6 +105,8 @@ const sessionEventHandlers = new Map<string, {
onApprovalRequested?: (event: RunEvent) => void
onApprovalResolved?: (event: RunEvent) => void
onPeerUserMessage?: (event: RunEvent) => void
onClarifyRequested?: (event: RunEvent) => void
onClarifyResolved?: (event: RunEvent) => void
}>()
const peerUserMessageHandlers = new Set<(event: RunEvent) => void>()
@@ -372,6 +374,26 @@ function globalPeerUserMessageHandler(event: RunEvent): void {
}
}
function globalClarifyRequestedHandler(event: RunEvent): void {
const sid = event.session_id
if (!sid) return
const handlers = sessionEventHandlers.get(sid)
if (handlers?.onClarifyRequested) {
handlers.onClarifyRequested(event)
}
}
function globalClarifyResolvedHandler(event: RunEvent): void {
const sid = event.session_id
if (!sid) return
const handlers = sessionEventHandlers.get(sid)
if (handlers?.onClarifyResolved) {
handlers.onClarifyResolved(event)
}
}
/**
* Register event handlers for a session
* @param sessionId - Session ID
@@ -401,6 +423,8 @@ export function registerSessionHandlers(
onApprovalRequested?: (event: RunEvent) => void
onApprovalResolved?: (event: RunEvent) => void
onPeerUserMessage?: (event: RunEvent) => void
onClarifyRequested?: (event: RunEvent) => void
onClarifyResolved?: (event: RunEvent) => void
}
): () => void {
sessionEventHandlers.set(sessionId, handlers)
@@ -426,6 +450,19 @@ export function onPeerUserMessage(handler: (event: RunEvent) => void): () => voi
}
}
export function respondClarify(
sessionId: string,
clarifyId: string,
response: string,
): void {
const socket = connectChatRun()
socket.emit('clarify.respond', {
session_id: sessionId,
clarify_id: clarifyId,
response,
})
}
export function respondToolApproval(
sessionId: string,
approvalId: string,
@@ -510,6 +547,8 @@ export function connectChatRun(requestedProfile?: string | null): Socket {
chatRunSocket.on('approval.requested', globalApprovalRequestedHandler)
chatRunSocket.on('approval.resolved', globalApprovalResolvedHandler)
chatRunSocket.on('run.peer_user_message', globalPeerUserMessageHandler)
chatRunSocket.on('clarify.requested', globalClarifyRequestedHandler)
chatRunSocket.on('clarify.resolved', globalClarifyResolvedHandler)
// Compression events
chatRunSocket.on('compression.started', globalCompressionStartedHandler)
@@ -708,6 +747,14 @@ export function startRunViaSocket(
if (closed) return
onEvent(evt)
},
onClarifyRequested: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
},
onClarifyResolved: (evt: RunEvent) => {
if (closed) return
onEvent(evt)
},
}
// Register handlers in the global session map
@@ -154,6 +154,17 @@ const headerTitle = computed(() =>
const activeApproval = computed(() => chatStore.activePendingApproval);
const visibleApproval = computed(() => activeApproval.value);
const activeClarify = computed(() => chatStore.activePendingClarify);
const visibleClarify = computed(() => activeClarify.value);
const clarifyResponse = ref('');
function handleClarify(response?: string) {
const finalResponse = response !== undefined ? response : clarifyResponse.value.trim();
chatStore.respondToClarify(finalResponse);
clarifyResponse.value = '';
}
const showNewChatModal = ref(false);
const newChatProfile = ref<string>("default");
const newChatProvider = ref<string>("");
@@ -1230,6 +1241,63 @@ async function handleSessionModelCustomSubmit() {
</div>
</div>
</div>
<div v-if="visibleClarify" class="clarify-bar">
<div class="clarify-icon" aria-hidden="true">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
</div>
<div class="clarify-content">
<div class="clarify-main">
<div class="clarify-kicker">{{ t('chat.clarifyKicker') }}</div>
<div class="clarify-title">{{ t('chat.clarifyTitle') }}</div>
<div class="clarify-desc">{{ visibleClarify.question }}</div>
</div>
<div v-if="visibleClarify.choices && visibleClarify.choices.length" class="clarify-actions">
<NButton
v-for="choice in visibleClarify.choices"
:key="choice"
size="small"
type="primary"
@click="handleClarify(choice)"
>
{{ choice }}
</NButton>
<NButton
size="small"
type="error"
secondary
@click="handleClarify('')"
>
{{ t('chat.clarifyDismiss') }}
</NButton>
</div>
<div v-else class="clarify-actions">
<div class="clarify-input-row">
<NInput
v-model:value="clarifyResponse"
size="small"
:placeholder="t('chat.clarifyPlaceholder')"
@keyup.enter="handleClarify()"
/>
<NButton size="small" type="primary" @click="handleClarify()">
{{ t('chat.clarifySubmit') }}
</NButton>
</div>
</div>
</div>
</div>
<ChatInput />
</template>
<ConversationMonitorPane
@@ -2005,6 +2073,83 @@ async function handleSessionModelCustomSubmit() {
border-top: 1px solid $border-color;
}
.clarify-bar {
display: flex;
align-items: flex-start;
gap: 10px;
margin: 0 16px 12px;
padding: 12px;
border: 1px solid $border-color;
border-radius: 8px;
background: $bg-card;
box-shadow: none;
}
.clarify-icon {
display: grid;
place-items: center;
flex: 0 0 32px;
width: 32px;
height: 32px;
color: var(--accent-primary);
background: rgba(var(--accent-primary-rgb), 0.12);
border: 1px solid rgba(var(--accent-primary-rgb), 0.2);
border-radius: 8px;
}
.clarify-content {
flex: 1;
min-width: 0;
}
.clarify-main {
min-width: 0;
}
.clarify-kicker {
margin-bottom: 2px;
font-size: 10px;
font-weight: 700;
line-height: 1.2;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent-primary);
}
.clarify-title {
font-size: 14px;
font-weight: 700;
line-height: 1.3;
color: $text-primary;
}
.clarify-desc {
margin-top: 4px;
font-size: 12px;
line-height: 1.45;
color: $text-secondary;
}
.clarify-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid $border-color;
}
.clarify-input-row {
display: flex;
flex: 1;
gap: 8px;
align-items: center;
.n-input {
flex: 1;
}
}
@media (max-width: 768px) {
.approval-bar {
margin: 0 10px 10px;
@@ -2025,6 +2170,26 @@ async function handleSessionModelCustomSubmit() {
.approval-actions :deep(.n-button) {
width: 100%;
}
.clarify-bar {
margin: 0 10px 10px;
padding: 10px;
}
.clarify-icon {
flex-basis: 28px;
width: 28px;
height: 28px;
}
.clarify-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.clarify-actions :deep(.n-button) {
width: 100%;
}
}
@media (max-width: 420px) {
@@ -2035,6 +2200,14 @@ async function handleSessionModelCustomSubmit() {
.approval-actions {
grid-template-columns: 1fr;
}
.clarify-bar {
gap: 8px;
}
.clarify-actions {
grid-template-columns: 1fr;
}
}
@keyframes rainbow-glow {
+5
View File
@@ -247,6 +247,11 @@ export default {
approvalAllowSession: 'Sitzung erlauben',
approvalAlways: 'Immer',
approvalDeny: 'Ablehnen',
clarifyKicker: 'Agent benötigt Klärung',
clarifyTitle: 'Der Agent hat eine Frage an Sie',
clarifyPlaceholder: 'Geben Sie Ihre Antwort ein...',
clarifySubmit: 'Antworten',
clarifyDismiss: 'Schließen',
deleteSession: 'Diese Sitzung loschen?',
toggleBatchMode: 'Batch-Auswahl',
selectAll: 'Alle auswählen',
+5
View File
@@ -262,6 +262,11 @@ export default {
approvalAllowSession: 'Allow session',
approvalAlways: 'Always',
approvalDeny: 'Deny',
clarifyKicker: 'Agent needs clarification',
clarifyTitle: 'The agent has a question for you',
clarifyPlaceholder: 'Type your answer...',
clarifySubmit: 'Reply',
clarifyDismiss: 'Dismiss',
newCliChat: 'New CLI',
deleteSession: 'Delete this session?',
sessionDeleted: 'Session deleted',
+5
View File
@@ -247,6 +247,11 @@ export default {
approvalAllowSession: 'Permitir sesión',
approvalAlways: 'Siempre',
approvalDeny: 'Denegar',
clarifyKicker: 'El agente necesita aclaración',
clarifyTitle: 'El agente tiene una pregunta para usted',
clarifyPlaceholder: 'Escriba su respuesta...',
clarifySubmit: 'Responder',
clarifyDismiss: 'Descartar',
deleteSession: 'Eliminar esta sesion?',
toggleBatchMode: 'Selección por lotes',
selectAll: 'Seleccionar todo',
+5
View File
@@ -247,6 +247,11 @@ export default {
approvalAllowSession: 'Autoriser la session',
approvalAlways: 'Toujours',
approvalDeny: 'Refuser',
clarifyKicker: 'L\'agent a besoin d\'éclaircissements',
clarifyTitle: 'L\'agent a une question pour vous',
clarifyPlaceholder: 'Tapez votre réponse...',
clarifySubmit: 'Répondre',
clarifyDismiss: 'Ignorer',
deleteSession: 'Supprimer cette session ?',
toggleBatchMode: 'Sélection par lot',
selectAll: 'Tout sélectionner',
+5
View File
@@ -247,6 +247,11 @@ export default {
approvalAllowSession: 'セッション中は許可',
approvalAlways: '常に許可',
approvalDeny: '拒否',
clarifyKicker: 'エージェントの確認が必要',
clarifyTitle: 'エージェントが質問があります',
clarifyPlaceholder: '回答を入力...',
clarifySubmit: '返信',
clarifyDismiss: '閉じる',
deleteSession: 'このセッションを削除しますか?',
toggleBatchMode: '一括選択',
selectAll: 'すべて選択',
+5
View File
@@ -247,6 +247,11 @@ export default {
approvalAllowSession: '이 세션에서 허용',
approvalAlways: '항상 허용',
approvalDeny: '거부',
clarifyKicker: '에이전트 확인 필요',
clarifyTitle: '에이전트가 질문이 있습니다',
clarifyPlaceholder: '답변 입력...',
clarifySubmit: '답장',
clarifyDismiss: '무시',
deleteSession: '이 세션을 삭제하시겠습니까?',
toggleBatchMode: '일괄 선택',
selectAll: '모두 선택',
+5
View File
@@ -247,6 +247,11 @@ export default {
approvalAllowSession: 'Permitir sessão',
approvalAlways: 'Sempre',
approvalDeny: 'Negar',
clarifyKicker: 'Agente precisa de esclarecimento',
clarifyTitle: 'O agente tem uma pergunta para você',
clarifyPlaceholder: 'Digite sua resposta...',
clarifySubmit: 'Responder',
clarifyDismiss: 'Descartar',
deleteSession: 'Excluir esta sessao?',
toggleBatchMode: 'Seleção em lote',
selectAll: 'Selecionar tudo',
@@ -261,6 +261,11 @@ export default {
approvalAllowSession: '本工作階段允許',
approvalAlways: '永遠允許',
approvalDeny: '拒絕',
clarifyKicker: 'Agent 需要確認',
clarifyTitle: 'Agent 有一個問題需要您回答',
clarifyPlaceholder: '輸入你的回答...',
clarifySubmit: '回覆',
clarifyDismiss: '忽略',
deleteSession: '確定刪除此工作階段?',
sessionDeleted: '工作階段已刪除',
toggleBatchMode: '批次選取',
+5
View File
@@ -262,6 +262,11 @@ export default {
approvalAllowSession: '本会话允许',
approvalAlways: '始终允许',
approvalDeny: '拒绝',
clarifyKicker: 'Agent 需要确认',
clarifyTitle: 'Agent 有一个问题需要您回答',
clarifyPlaceholder: '输入你的回答...',
clarifySubmit: '回复',
clarifyDismiss: '忽略',
newCliChat: '新建 CLI',
deleteSession: '确定删除此会话?',
sessionDeleted: '会话已删除',
+79 -1
View File
@@ -1,4 +1,4 @@
import { startRunViaSocket, resumeSession, registerSessionHandlers, unregisterSessionHandlers, getChatRunSocket, respondToolApproval, onPeerUserMessage, type RunEvent, type ContentBlock as ContentBlockImport } from '@/api/hermes/chat'
import { startRunViaSocket, resumeSession, registerSessionHandlers, unregisterSessionHandlers, getChatRunSocket, respondToolApproval, onPeerUserMessage, respondClarify, type RunEvent, 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'
@@ -57,6 +57,15 @@ export interface PendingApproval {
requestedAt: number
}
export interface PendingClarify {
sessionId: string
clarifyId: string
question: string
choices: string[] | null
timeoutMs: number
requestedAt: number
}
export interface Session {
id: string
profile?: string
@@ -373,6 +382,12 @@ export const useChatStore = defineStore('chat', () => {
return sid ? pendingApprovals.value.get(sid) || null : null
})
const pendingClarifies = ref<Map<string, PendingClarify>>(new Map())
const activePendingClarify = computed(() => {
const sid = activeSessionId.value
return sid ? pendingClarifies.value.get(sid) || null : null
})
// 自动播放语音开关
const autoPlaySpeechEnabled = ref(false)
@@ -623,6 +638,10 @@ export const useChatStore = defineStore('chat', () => {
setPendingApproval({ ...e, session_id: sessionId } as RunEvent)
} else if (e.event === 'approval.resolved') {
clearPendingApproval({ ...e, session_id: sessionId } as RunEvent)
} else if (e.event === 'clarify.requested') {
setPendingClarify({ ...e, session_id: sessionId } as RunEvent)
} else if (e.event === 'clarify.resolved') {
clearPendingClarify({ ...e, session_id: sessionId } as RunEvent)
} else if (e.event === 'run.failed') {
addAgentErrorMessage(sessionId, e.error)
serverWorking.value.delete(sessionId)
@@ -1048,6 +1067,41 @@ export const useChatStore = defineStore('chat', () => {
pendingApprovals.value = new Map(pendingApprovals.value)
}
function setPendingClarify(evt: RunEvent) {
const sid = evt.session_id
const clarifyId = (evt as any).clarify_id as string | undefined
if (!sid || !clarifyId) return
pendingClarifies.value.set(sid, {
sessionId: sid,
clarifyId,
question: String((evt as any).question || ''),
choices: Array.isArray((evt as any).choices) ? (evt as any).choices : null,
timeoutMs: Number((evt as any).timeout_ms) || 300000,
requestedAt: Date.now(),
})
pendingClarifies.value = new Map(pendingClarifies.value)
}
function clearPendingClarify(evt: RunEvent) {
const sid = evt.session_id
if (!sid) return
const current = pendingClarifies.value.get(sid)
if (!current) return
const clarifyId = (evt as any).clarify_id
if (clarifyId && current.clarifyId !== clarifyId) return
pendingClarifies.value.delete(sid)
pendingClarifies.value = new Map(pendingClarifies.value)
}
function respondToClarify(response: string) {
const pending = activePendingClarify.value
if (!pending) return
respondClarify(pending.sessionId, pending.clarifyId, response)
pendingClarifies.value.delete(pending.sessionId)
pendingClarifies.value = new Map(pendingClarifies.value)
}
function respondApproval(choice: PendingApproval['choices'][number]) {
const pending = activePendingApproval.value
if (!pending) return
@@ -1469,6 +1523,16 @@ export const useChatStore = defineStore('chat', () => {
break
}
case 'clarify.requested': {
setPendingClarify(evt)
break
}
case 'clarify.resolved': {
clearPendingClarify(evt)
break
}
case 'run.completed': {
const msgs = getSessionMsgs(sid)
const lastMsg = activeAssistantMessageId
@@ -1919,6 +1983,16 @@ export const useChatStore = defineStore('chat', () => {
break
}
case 'clarify.requested': {
setPendingClarify(evt)
break
}
case 'clarify.resolved': {
clearPendingClarify(evt)
break
}
case 'run.completed': {
const hasQueue = (evt as any).queue_remaining > 0
if (hasQueue) {
@@ -2067,6 +2141,8 @@ export const useChatStore = defineStore('chat', () => {
onUsageUpdated: (evt) => handleEvent(evt),
onSessionCommand: (evt) => handleEvent(evt),
onRunQueued: (evt) => handleEvent(evt),
onClarifyRequested: (evt) => handleEvent(evt),
onClarifyResolved: (evt) => handleEvent(evt),
})
// No need to emit resume here — switchSession already did it.
@@ -2259,6 +2335,7 @@ export const useChatStore = defineStore('chat', () => {
queuedUserMessages,
pendingApprovals,
activePendingApproval,
activePendingClarify,
removeQueuedMessage,
isLoadingSessions,
sessionsLoaded,
@@ -2274,6 +2351,7 @@ export const useChatStore = defineStore('chat', () => {
sendMessage,
stopStreaming,
respondApproval,
respondToClarify,
loadSessions,
refreshActiveSession,
getThinkingObservation,
@@ -481,6 +481,10 @@ export class AgentBridgeClient {
return this.request({ action: 'approval_respond', approval_id: approvalId, choice })
}
clarifyRespond(clarifyId: string, response: string): Promise<AgentBridgeResponse> {
return this.request({ action: 'clarify_respond', clarify_id: clarifyId, response })
}
compressionRespond(
requestId: string,
payload: { messages?: unknown[]; system_message?: string; error?: string },
@@ -598,6 +598,7 @@ class AgentPool:
self._approval_requests: dict[str, queue.Queue[str]] = {}
self._gateway_approval_requests: dict[str, str] = {}
self._compression_requests: dict[str, queue.Queue[dict[str, Any]]] = {}
self._clarify_requests: dict[str, queue.Queue[str]] = {}
self._run_context = threading.local()
self._approval_handlers: dict[str, Callable[..., str]] = {}
self._exec_ask_depth = 0
@@ -667,6 +668,7 @@ class AgentPool:
tool_progress_callback=self._tool_progress_callback(session_id),
tool_start_callback=self._tool_start_callback(session_id),
tool_complete_callback=self._tool_complete_callback(session_id),
clarify_callback=self._clarify_callback(session_id),
)
agent.compression_enabled = False
self._install_compression_hook(agent, session_id)
@@ -1053,6 +1055,30 @@ class AgentPool:
return callback
def _clarify_callback(self, session_id: str):
def callback(question: str, choices: list[str] | None = None) -> str:
clarify_id = uuid.uuid4().hex
response_queue: queue.Queue[str] = queue.Queue(maxsize=1)
with self._lock:
self._clarify_requests[clarify_id] = response_queue
self._append_event(session_id, {
"event": "clarify.requested",
"clarify_id": clarify_id,
"question": str(question or ""),
"choices": list(choices) if choices else None,
"timeout_ms": 300_000,
})
try:
user_response = response_queue.get(timeout=300)
except queue.Empty:
user_response = "[user did not respond within 5m]"
finally:
with self._lock:
self._clarify_requests.pop(clarify_id, None)
return user_response
return callback
def _approval_dispatcher(self, command: str, description: str, *, allow_permanent: bool = True) -> str:
session_id = str(getattr(self._run_context, "session_id", "") or "")
if not session_id:
@@ -1425,6 +1451,17 @@ class AgentPool:
pass
return {"approval_id": approval_id, "resolved": True, "choice": cleaned}
def respond_clarify(self, clarify_id: str, response: str) -> dict[str, Any]:
with self._lock:
response_queue = self._clarify_requests.get(clarify_id)
if response_queue is None:
return {"clarify_id": clarify_id, "resolved": False}
try:
response_queue.put_nowait(response)
except queue.Full:
pass
return {"clarify_id": clarify_id, "resolved": True}
def get_history(self, session_id: str) -> dict[str, Any]:
with self._lock:
session = self._sessions.get(session_id)
@@ -1640,6 +1677,13 @@ class BridgeServer:
raise ValueError("approval_id is required")
return self.pool.respond_approval(approval_id, str(req.get("choice") or "deny"))
if action == "clarify_respond":
clarify_id = str(req.get("clarify_id") or "").strip()
if not clarify_id:
raise ValueError("clarify_id is required")
response = str(req.get("response") or "").strip()
return self.pool.respond_clarify(clarify_id, response)
if action == "compression_respond":
request_id = str(req.get("request_id") or "").strip()
if not request_id:
@@ -2087,6 +2131,7 @@ class BridgeBroker:
self._running_run_profile: dict[str, str] = {}
self._session_profile: dict[str, str] = {}
self._approval_profile: dict[str, str] = {}
self._clarify_profile: dict[str, str] = {}
self._compression_profile: dict[str, str] = {}
self._lock = threading.RLock()
self._stop = threading.Event()
@@ -2140,6 +2185,9 @@ class BridgeBroker:
approval_id = str(event.get("approval_id") or "")
if approval_id:
self._approval_profile[approval_id] = profile
clarify_id = str(event.get("clarify_id") or "")
if clarify_id:
self._clarify_profile[clarify_id] = profile
request_id = str(event.get("request_id") or "")
if event.get("event") == "bridge.compression.requested" and request_id:
self._compression_profile[request_id] = profile
@@ -2155,6 +2203,7 @@ class BridgeBroker:
self._running_run_profile.clear()
self._session_profile.clear()
self._approval_profile.clear()
self._clarify_profile.clear()
self._compression_profile.clear()
for worker in workers:
worker.stop()
@@ -2245,6 +2294,16 @@ class BridgeBroker:
raise KeyError(f"unknown approval request: {approval_id}")
return self._forward(profile, req)
if action == "clarify_respond":
clarify_id = str(req.get("clarify_id") or "").strip()
if not clarify_id:
raise ValueError("clarify_id is required")
with self._lock:
profile = self._clarify_profile.get(clarify_id)
if not profile:
raise KeyError(f"unknown clarify request: {clarify_id}")
return self._forward(profile, req)
if action == "compression_respond":
request_id = str(req.get("request_id") or "").strip()
if not request_id:
@@ -2263,6 +2322,7 @@ class BridgeBroker:
self._running_run_profile.clear()
self._session_profile.clear()
self._approval_profile.clear()
self._clarify_profile.clear()
self._compression_profile.clear()
destroyed = 0
for worker in workers:
@@ -2284,6 +2344,7 @@ class BridgeBroker:
self._running_run_profile = {key: value for key, value in self._running_run_profile.items() if value != profile}
self._session_profile = {key: value for key, value in self._session_profile.items() if value != profile}
self._approval_profile = {key: value for key, value in self._approval_profile.items() if value != profile}
self._clarify_profile = {key: value for key, value in self._clarify_profile.items() if value != profile}
self._compression_profile = {key: value for key, value in self._compression_profile.items() if value != profile}
if worker is None or not worker.running:
@@ -241,6 +241,25 @@ export class ChatRunSocket {
})
}
})
socket.on('clarify.respond', async (data: { session_id?: string; clarify_id?: string; response?: string }) => {
if (!data.session_id || !data.clarify_id) return
try {
const result = await this.bridge.clarifyRespond(data.clarify_id, data.response || '')
this.emitToSession(socket, data.session_id, 'clarify.resolved', {
event: 'clarify.resolved',
clarify_id: data.clarify_id,
resolved: Boolean((result as any)?.resolved),
})
} catch (err) {
this.emitToSession(socket, data.session_id, 'clarify.resolved', {
event: 'clarify.resolved',
clarify_id: data.clarify_id,
resolved: false,
error: err instanceof Error ? err.message : String(err),
})
}
})
}
// --- Run dispatcher ---
@@ -0,0 +1,20 @@
import { describe, expect, it, vi } from 'vitest'
describe('AgentBridgeClient clarify responses', () => {
it('sends clarify_respond requests to the bridge', async () => {
const { AgentBridgeClient } = await import('../../packages/server/src/services/hermes/agent-bridge/client')
const client = new AgentBridgeClient({ endpoint: 'tcp://127.0.0.1:1', connectRetryMs: 0, timeoutMs: 1 })
const request = vi.spyOn(client, 'request').mockResolvedValue({ ok: true, resolved: true })
await expect(client.clarifyRespond('clarify-1', 'Use the first option')).resolves.toEqual({
ok: true,
resolved: true,
})
expect(request).toHaveBeenCalledWith({
action: 'clarify_respond',
clarify_id: 'clarify-1',
response: 'Use the first option',
})
})
})
@@ -0,0 +1,120 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const bridgeMock = vi.hoisted(() => ({
clarifyRespond: vi.fn(),
}))
vi.mock('../../packages/server/src/services/hermes/agent-bridge', () => ({
AgentBridgeClient: vi.fn(() => bridgeMock),
}))
vi.mock('../../packages/server/src/services/logger', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}))
vi.mock('../../packages/server/src/db/hermes/session-store', () => ({
getSession: vi.fn(() => null),
}))
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 createSocketHarness() {
const handlers = new Map<string, Function>()
const namespaceEmit = vi.fn()
const namespace = {
adapter: { rooms: new Map([['session:session-1', new Set(['socket-1'])]]) },
to: vi.fn(() => ({ emit: namespaceEmit })),
use: vi.fn(),
on: vi.fn(),
}
const io = {
of: vi.fn(() => namespace),
}
const socket = {
id: 'socket-1',
connected: true,
data: {},
handshake: { auth: {}, query: { profile: 'default' } },
on: vi.fn((event: string, handler: Function) => {
handlers.set(event, handler)
}),
join: vi.fn(),
emit: vi.fn(),
}
return { handlers, io, namespace, namespaceEmit, socket }
}
describe('ChatRunSocket clarify responses', () => {
beforeEach(() => {
vi.resetModules()
bridgeMock.clarifyRespond.mockReset()
})
it('forwards clarify.respond events to the bridge and emits clarify.resolved', async () => {
bridgeMock.clarifyRespond.mockResolvedValue({ ok: true, resolved: true })
const { ChatRunSocket } = await import('../../packages/server/src/services/hermes/run-chat')
const { handlers, io, namespace, namespaceEmit, socket } = createSocketHarness()
const server = new ChatRunSocket(io as any)
;(server as any).onConnection(socket)
await handlers.get('clarify.respond')?.({
session_id: 'session-1',
clarify_id: 'clarify-1',
response: 'Use option A',
})
expect(bridgeMock.clarifyRespond).toHaveBeenCalledWith('clarify-1', 'Use option A')
expect(namespace.to).toHaveBeenCalledWith('session:session-1')
expect(namespaceEmit).toHaveBeenCalledWith('clarify.resolved', {
event: 'clarify.resolved',
session_id: 'session-1',
clarify_id: 'clarify-1',
resolved: true,
})
})
it('emits an unresolved clarify result when the bridge rejects the response', async () => {
bridgeMock.clarifyRespond.mockRejectedValue(new Error('unknown clarify request'))
const { ChatRunSocket } = await import('../../packages/server/src/services/hermes/run-chat')
const { handlers, namespaceEmit, socket } = createSocketHarness()
const namespace = {
adapter: { rooms: new Map([['session:session-1', new Set(['socket-1'])]]) },
to: vi.fn(() => ({ emit: namespaceEmit })),
use: vi.fn(),
on: vi.fn(),
}
const server = new ChatRunSocket({ of: vi.fn(() => namespace) } as any)
;(server as any).onConnection(socket)
await handlers.get('clarify.respond')?.({
session_id: 'session-1',
clarify_id: 'clarify-1',
response: 'Use option B',
})
expect(namespaceEmit).toHaveBeenCalledWith('clarify.resolved', {
event: 'clarify.resolved',
session_id: 'session-1',
clarify_id: 'clarify-1',
resolved: false,
error: 'unknown clarify request',
})
})
})