[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,