[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:
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -247,6 +247,11 @@ export default {
|
||||
approvalAllowSession: 'セッション中は許可',
|
||||
approvalAlways: '常に許可',
|
||||
approvalDeny: '拒否',
|
||||
clarifyKicker: 'エージェントの確認が必要',
|
||||
clarifyTitle: 'エージェントが質問があります',
|
||||
clarifyPlaceholder: '回答を入力...',
|
||||
clarifySubmit: '返信',
|
||||
clarifyDismiss: '閉じる',
|
||||
deleteSession: 'このセッションを削除しますか?',
|
||||
toggleBatchMode: '一括選択',
|
||||
selectAll: 'すべて選択',
|
||||
|
||||
@@ -247,6 +247,11 @@ export default {
|
||||
approvalAllowSession: '이 세션에서 허용',
|
||||
approvalAlways: '항상 허용',
|
||||
approvalDeny: '거부',
|
||||
clarifyKicker: '에이전트 확인 필요',
|
||||
clarifyTitle: '에이전트가 질문이 있습니다',
|
||||
clarifyPlaceholder: '답변 입력...',
|
||||
clarifySubmit: '답장',
|
||||
clarifyDismiss: '무시',
|
||||
deleteSession: '이 세션을 삭제하시겠습니까?',
|
||||
toggleBatchMode: '일괄 선택',
|
||||
selectAll: '모두 선택',
|
||||
|
||||
@@ -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: '批次選取',
|
||||
|
||||
@@ -262,6 +262,11 @@ export default {
|
||||
approvalAllowSession: '本会话允许',
|
||||
approvalAlways: '始终允许',
|
||||
approvalDeny: '拒绝',
|
||||
clarifyKicker: 'Agent 需要确认',
|
||||
clarifyTitle: 'Agent 有一个问题需要您回答',
|
||||
clarifyPlaceholder: '输入你的回答...',
|
||||
clarifySubmit: '回复',
|
||||
clarifyDismiss: '忽略',
|
||||
newCliChat: '新建 CLI',
|
||||
deleteSession: '确定删除此会话?',
|
||||
sessionDeleted: '会话已删除',
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user