From 3eeaa5d96f1bfd15f121a5d8b82650790e188631 Mon Sep 17 00:00:00 2001 From: yimi528 Date: Sat, 5 Sep 2026 04:35:23 +0800 Subject: [PATCH 1/2] feat(web): expose tool call activity --- CHANGELOG.md | 3 + docs/contract.md | 1 + docs/contract.zh.md | 1 + docs/gateway-protocol.md | 2 +- docs/gateway-protocol.zh.md | 2 +- web/src/App.jsx | 71 ++++++++++++- web/src/i18n.js | 8 ++ web/src/message-order.js | 71 +++++++++++-- web/src/styles.css | 68 +++++++++++++ web/src/tool-call-debug.js | 134 ++++++++++++++++++++++++ web/test/message-order.test.js | 40 ++++++++ web/test/tool-call-debug.test.mjs | 162 ++++++++++++++++++++++++++++++ 12 files changed, 550 insertions(+), 13 deletions(-) create mode 100644 web/src/tool-call-debug.js create mode 100644 web/test/tool-call-debug.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index ab67e765..1c9a7eaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- WebUI 展示前台与后台工具调用的有界生命周期,按工具调用 ID 合并状态,并默认不保留 + 原始参数与结果内容。 + - 桌面设置保留通过环境配置选择的通用 ACP 后台,并显示实际可用状态; 应用其他设置不再将 `AGENT_PROTOCOL=acp` 静默覆盖为 `none`。 diff --git a/docs/contract.md b/docs/contract.md index 6b7b48db..14a5d4d9 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -258,6 +258,7 @@ conversation surface. | server → client | `audio.delta`, `audio.done`, `playback.clear` | Audio playback stream and cancellation | | server → client | `response.started`, `response.interrupted` | Response lifecycle keyed by `responseId` | | server → client | `transcript.delta`, `transcript.final`, `transcript.discard` | User and assistant transcript lifecycle | +| server → client | `tool.call` | Bounded foreground/backend Tool Call lifecycle for presentation; it is not an execution request and clients must not infer internal state from it | | server → client | `task.*` | Optional background Task snapshots, progress, authorization, and completion | | server → client | `agent.activity`, `client.state`, `error` | Foreground activity hints, the temporary 5.x client-state migration alias, and errors | diff --git a/docs/contract.zh.md b/docs/contract.zh.md index 2e9908a4..17dbf77b 100644 --- a/docs/contract.zh.md +++ b/docs/contract.zh.md @@ -228,6 +228,7 @@ await orb.load() | 服务端 → 客户端 | `audio.delta`、`audio.done`、`playback.clear` | 播放音频流及清除指令 | | 服务端 → 客户端 | `response.started`、`response.interrupted` | 以 `responseId` 标识的回复生命周期 | | 服务端 → 客户端 | `transcript.delta`、`transcript.final`、`transcript.discard` | 用户与助手转写生命周期 | +| 服务端 → 客户端 | `tool.call` | 有界的前台/后台工具调用生命周期,仅用于展示;它不是执行请求,客户端不得据此推断内部状态 | | 服务端 → 客户端 | `task.*` | 可选的后台 Task 快照、进度、授权与完成事件 | | 服务端 → 客户端 | `agent.activity`、`client.state`、`error` | 前台活动提示、临时保留的 5.x Client State 迁移别名与错误 | diff --git a/docs/gateway-protocol.md b/docs/gateway-protocol.md index 657ff8e4..0b377897 100644 --- a/docs/gateway-protocol.md +++ b/docs/gateway-protocol.md @@ -277,7 +277,7 @@ Use OpenAI Realtime terminology where the semantics match: | `response.output_audio_transcript.delta` / `.done` | G→C | Assistant transcript | | `response.done` | G→C | Final response state; cancellation is `response.status = "cancelled"` | -Gateway extensions include `turn.started`, `transcript.discard`, `playback.clear`, and playback receipts. `input_file` is a Gateway content-part extension, not an OpenAI Realtime standard part. +Gateway extensions include `turn.started`, `transcript.discard`, `tool.call`, `playback.clear`, and playback receipts. `tool.call` is a bounded, presentation-only lifecycle event for a Realtime Tool Call; it is not an execution request, is not replayed, and clients must not infer private Gateway or backend state from it. `input_file` is a Gateway content-part extension, not an OpenAI Realtime standard part. User input is authoritative user intent and opens or supersedes a user turn. Client semantic events never impersonate user input. diff --git a/docs/gateway-protocol.zh.md b/docs/gateway-protocol.zh.md index 9fb40cd5..d8b510f5 100644 --- a/docs/gateway-protocol.zh.md +++ b/docs/gateway-protocol.zh.md @@ -249,7 +249,7 @@ Gateway 采用扁平的 OpenAI Realtime 风格信封: | `response.output_audio_transcript.delta` / `.done` | G→C | 助手转写 | | `response.done` | G→C | 回复最终状态;取消使用 `response.status = "cancelled"` | -Gateway 扩展包括 `turn.started`、`transcript.discard`、`playback.clear` 和播放回执。`input_file` 是 Gateway content part 扩展,不属于 OpenAI Realtime 标准字段。 +Gateway 扩展包括 `turn.started`、`transcript.discard`、`tool.call`、`playback.clear` 和播放回执。`tool.call` 是用于展示 Realtime 工具调用的有界生命周期事件,不是执行请求,不参与回放,客户端不得据此推断 Gateway 或后台的私有状态。`input_file` 是 Gateway content part 扩展,不属于 OpenAI Realtime 标准字段。 用户输入代表明确的用户意图,会开启或替代用户轮次。Client 语义事件不能伪装成用户输入。 diff --git a/web/src/App.jsx b/web/src/App.jsx index d586a45a..0eec043e 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -13,6 +13,12 @@ import { upsertAssistantTranscript, upsertUserTranscript, } from './message-order.js' +import { + cancelToolCallsForResponse, + displayToolName, + mergeToolCallDebug, + toolCallFromGatewayEvent, +} from './tool-call-debug.js' import MessageContent from './MessageContent.jsx' import MultimodalComposer from './composer/MultimodalComposer.jsx' import TaskArtifacts from './TaskArtifacts.jsx' @@ -209,6 +215,7 @@ export default function App() { })) const [waitingForVoice, setWaitingForVoice] = useState(false) const [messages, setMessages] = useState([]) + const [toolCalls, setToolCalls] = useState([]) const [activity, setActivity] = useState(t('正在检查后台 Agent')) const [frontend, setFrontend] = useState({ label: 'Realtime Agent' }) const [modelStatus, setModelStatus] = useState(() => realtimeModelStatus()) @@ -390,7 +397,7 @@ export default function App() { if (container && stickToBottom.current) { container.scrollTop = container.scrollHeight } - }, [messages, agentTasks]) + }, [messages, agentTasks, toolCalls]) useEffect(() => () => { taskDismissTimers.current.forEach(timer => clearTimeout(timer)) @@ -456,6 +463,7 @@ export default function App() { id, content: event.content, turnId: event.turnId, + createdAt: Date.now(), final, })) if (final) noteInteraction() @@ -475,11 +483,25 @@ export default function App() { taskIds: event.taskIds, origin: event.origin, citations: event.citations, + createdAt: Date.now(), final, })) }, []) const onRealtimeEvent = useCallback(event => { + const toolCall = toolCallFromGatewayEvent(event, currentTurnId.current) + if (toolCall) { + setToolCalls(items => mergeToolCallDebug(items, { + ...toolCall, + createdAt: Date.now(), + })) + if ( + ['received', 'running'].includes(toolCall.status) + && (!toolCall.turnId || toolCall.turnId === currentTurnId.current) + ) { + setActivity(t('正在执行工具')) + } + } const animationEvent = spriteAnimationEventForGatewayEvent(event) if (animationEvent) { triggerSpriteAnimation(animationEvent) @@ -492,6 +514,11 @@ export default function App() { } if (event.type === 'gateway.disconnected') { setActivity(t('qwen-audio-agent Gateway 已断开,正在重连')) + setToolCalls(items => items.map(toolCall => ( + ['completed', 'failed', 'cancelled'].includes(toolCall.status) + ? toolCall + : { ...toolCall, status: 'cancelled' } + ))) setAgentTasks(items => items.map(task => ( [ 'queued', @@ -613,6 +640,7 @@ export default function App() { ? { ...message, interrupted: true, live: false } : message ))) + setToolCalls(items => cancelToolCallsForResponse(items, event.responseId)) } if (event.type === 'task.scheduled') { const task = event.task @@ -1061,6 +1089,7 @@ export default function App() { localStorage.setItem('qwen-audio-agent.session', next) setSessionId(next) setMessages([]) + setToolCalls([]) setAgentTasks([]) currentTurnId.current = '' activeVoiceResponse.current = '' @@ -1094,8 +1123,8 @@ export default function App() { } const turns = useMemo( - () => buildConversationTurns(messages, agentTasks), - [messages, agentTasks], + () => buildConversationTurns(messages, agentTasks, toolCalls), + [messages, agentTasks, toolCalls], ) const beginOrbDrag = event => { @@ -1331,6 +1360,36 @@ export default function App() { } + const renderToolCall = toolCall => { + const surface = toolCall.surface === 'backend' ? t('后台工具') : t('前台工具') + const status = { + received: t('工具执行中'), + running: t('工具执行中'), + completed: t('工具已完成'), + failed: t('工具失败'), + cancelled: t('工具已取消'), + }[toolCall.status] || t('工具执行中') + const duration = Number.isFinite(toolCall.durationMs) + ? toolCall.durationMs < 1_000 + ? t('{ms} 毫秒', { ms: Math.max(0, Math.round(toolCall.durationMs)) }) + : t('{seconds} 秒', { + seconds: Number((toolCall.durationMs / 1_000).toFixed(1)), + }) + : '' + return + } + const renderMessage = message =>
- {turn.beforeActivities.map(renderMessage)} + {turn.beforeEvents.map(item => ( + item.type === 'tool-call' + ? renderToolCall(item.value) + : renderMessage(item.value) + ))} {turn.tasks.map(renderTask)} {turn.afterActivities.map(renderMessage)} )} diff --git a/web/src/i18n.js b/web/src/i18n.js index 023b452d..08f8bd91 100644 --- a/web/src/i18n.js +++ b/web/src/i18n.js @@ -40,6 +40,14 @@ const translations = { '正在继续处理': 'Resuming', '正在准备回复': 'Preparing reply', '处理完成': 'Completed', + '前台工具': 'Frontend tool', + '后台工具': 'Backend tool', + '工具执行中': 'Tool running', + '工具已完成': 'Tool completed', + '工具失败': 'Tool failed', + '工具已取消': 'Tool cancelled', + '{ms} 毫秒': '{ms}ms', + '{seconds} 秒': '{seconds}s', '后台失败:{error}': 'Background task failed: {error}', '已取消': 'Cancelled', '已创建新会话': 'New session created', diff --git a/web/src/message-order.js b/web/src/message-order.js index b16d7f62..ac7945cd 100644 --- a/web/src/message-order.js +++ b/web/src/message-order.js @@ -80,6 +80,7 @@ export function upsertUserTranscript(items, { id, content, turnId, + createdAt, final = false, }) { const normalized = normalizeTranscript(content) @@ -89,6 +90,7 @@ export function upsertUserTranscript(items, { role: 'user', content: normalized, turnId, + ...(Number.isFinite(createdAt) && createdAt > 0 ? { createdAt } : {}), voice: true, final, live: !final, @@ -100,7 +102,15 @@ export function upsertUserTranscript(items, { // utterance that may replace it. if (items[index].final) return items const next = [...items] - next[index] = { ...next[index], ...message } + next[index] = { + ...next[index], + ...message, + ...( + Number.isFinite(next[index].createdAt) && next[index].createdAt > 0 + ? { createdAt: next[index].createdAt } + : {} + ), + } return next } @@ -112,6 +122,7 @@ export function upsertAssistantTranscript(items, { taskIds, origin, citations, + createdAt, final = false, }) { const index = items.findIndex(item => item.id === id) @@ -125,6 +136,7 @@ export function upsertAssistantTranscript(items, { taskIds, origin, ...(citations?.length ? { citations } : {}), + ...(Number.isFinite(createdAt) && createdAt > 0 ? { createdAt } : {}), live: !final, }) } @@ -140,6 +152,13 @@ export function upsertAssistantTranscript(items, { taskIds: taskIds || existing.taskIds, origin: origin || existing.origin, ...(citations?.length ? { citations } : {}), + ...( + Number.isFinite(existing.createdAt) && existing.createdAt > 0 + ? { createdAt: existing.createdAt } + : Number.isFinite(createdAt) && createdAt > 0 + ? { createdAt } + : {} + ), live: !final, } return next @@ -151,19 +170,25 @@ export function discardUserTranscript(items, turnId) { return items.filter(item => item.id !== id || item.final) } -export function buildConversationTimeline(messages, tasks) { - return buildConversationTurns(messages, tasks).flatMap(turn => [ - ...turn.beforeActivities.map(value => ({ type: 'message', value })), +export function buildConversationTimeline(messages, tasks, toolCalls = []) { + return buildConversationTurns(messages, tasks, toolCalls).flatMap(turn => [ + ...turn.beforeEvents, ...turn.tasks.map(value => ({ type: 'task', value })), ...turn.afterActivities.map(value => ({ type: 'message', value })), ]) } -export function buildConversationTurns(messages, tasks) { +export function buildConversationTurns(messages, tasks, toolCalls = []) { const turns = [] const byTurnId = new Map() const createTurn = (id, standalone = false) => { - const turn = { id, standalone, messages: [], tasks: [] } + const turn = { + id, + standalone, + messages: [], + tasks: [], + toolCalls: [], + } turns.push(turn) if (!standalone) byTurnId.set(id, turn) return turn @@ -187,6 +212,14 @@ export function buildConversationTurns(messages, tasks) { turn.tasks.push(task) }) + toolCalls.forEach(toolCall => { + const id = toolCall.turnId || `tool:${toolCall.callId}` + const turn = toolCall.turnId + ? byTurnId.get(id) || createTurn(id) + : createTurn(id, true) + turn.toolCalls.push(toolCall) + }) + const orderedTurns = turns.filter(turn => turn.messages.length) const taskOnlyTurns = turns .filter(turn => !turn.messages.length) @@ -213,9 +246,32 @@ export function buildConversationTurns(messages, tasks) { message.taskId, ].some(taskId => taskIds.has(taskId)) ) + const beforeActivities = turn.messages.filter(message => !afterTaskCard(message)) + const beforeEvents = [ + ...beforeActivities.map((value, index) => ({ + type: 'message', + value, + order: index, + })), + ...turn.toolCalls.map((value, index) => ({ + type: 'tool-call', + value, + order: beforeActivities.length + index, + })), + ].sort((left, right) => { + const leftTime = Number(left.value.createdAt) + const rightTime = Number(right.value.createdAt) + const leftHasTime = Number.isFinite(leftTime) && leftTime > 0 + const rightHasTime = Number.isFinite(rightTime) && rightTime > 0 + if (leftHasTime && rightHasTime && leftTime !== rightTime) { + return leftTime - rightTime + } + return left.order - right.order + }).map(({ type, value }) => ({ type, value })) return { ...turn, - beforeActivities: turn.messages.filter(message => !afterTaskCard(message)), + beforeActivities, + beforeEvents, afterActivities: turn.messages.filter(afterTaskCard), } }) @@ -225,6 +281,7 @@ function turnChronologicalTime(turn) { const itemTimes = [ ...turn.messages.map(item => Number(item.createdAt || 0)), ...turn.tasks.map(item => Number(item.createdAt || 0)), + ...turn.toolCalls.map(item => Number(item.createdAt || 0)), ].filter(value => value > 0) if (itemTimes.length) return Math.min(...itemTimes) const timestamp = turnTimestamp(turn.id) diff --git a/web/src/styles.css b/web/src/styles.css index 0f7dfa73..9d3c3140 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -323,6 +323,7 @@ html[data-desktop="orb"] #root { } .desktop-conversation-panel .agent-task, +.desktop-conversation-panel .tool-call-activity, .desktop-conversation-panel article.companion { width: calc(100% - 12px); margin-left: 6px; @@ -1306,6 +1307,73 @@ header .ghost.active { color: white; border-color: #785cf480; background: #785cf animation: none; border-color: #efb569; } +.tool-call-activity { + flex: 0 0 auto; + width: calc(100% - 32px); + min-height: 42px; + margin: 3px 0 5px 18px; + padding: 8px 12px; + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 9px; + border: 1px solid #8069ef3d; + border-radius: 11px; + background: linear-gradient(120deg, #151321, #111219); + animation: task-in .2s ease-out both; +} +.tool-call-activity > div { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} +.tool-call-activity b { + overflow: hidden; + color: #d8d2f4; + font-size: 11px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} +.tool-call-activity small { + overflow: hidden; + color: #858092; + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} +.tool-call-indicator { + width: 9px; + height: 9px; + border: 2px solid #7865d54d; + border-top-color: #9d88ff; + border-radius: 50%; +} +.tool-call-activity.received .tool-call-indicator, +.tool-call-activity.running .tool-call-indicator { + animation: task-spin .8s linear infinite; +} +.tool-call-activity.completed { + border-color: #50dfab33; + background: linear-gradient(120deg, #111d1a, #12161a); +} +.tool-call-activity.completed .tool-call-indicator { + border-color: #50dfab; +} +.tool-call-activity.failed { + border-color: #ef696955; +} +.tool-call-activity.failed .tool-call-indicator { + border-color: #ef6969; +} +.tool-call-activity.cancelled { + border-color: #939aa655; + opacity: .82; +} +.tool-call-activity.cancelled .tool-call-indicator { + border-color: #8b929d; +} article { flex: 0 0 auto; width: fit-content; diff --git a/web/src/tool-call-debug.js b/web/src/tool-call-debug.js new file mode 100644 index 00000000..07e45554 --- /dev/null +++ b/web/src/tool-call-debug.js @@ -0,0 +1,134 @@ +import { GatewayServerEvent } from '../../shared/realtime-events.mjs' + +export const MAX_TOOL_CALLS = 64 + +const MCP_TOOL_NAME = /^mcp__[^_]+__(.+)$/u +const STATUS_ALIASES = Object.freeze({ + received: 'received', + running: 'running', + executing: 'running', + completed: 'completed', + succeeded: 'completed', + failed: 'failed', + error: 'failed', + cancelled: 'cancelled', + canceled: 'cancelled', +}) +const STATUS_RANK = Object.freeze({ + received: 0, + running: 1, + completed: 2, + failed: 2, + cancelled: 2, +}) + +function clean(value) { + return String(value || '').replace(/\s+/g, ' ').trim() +} + +function normalizedStatus(value) { + const status = clean(value).toLowerCase() + return STATUS_ALIASES[status] || 'running' +} + +function isFiniteTimestamp(value) { + return Number.isFinite(value) && value > 0 +} + +function mergeOptionalText(current, incoming) { + return clean(incoming) || clean(current) || '' +} + +export function displayToolName(name) { + const value = clean(name) + const shortName = value.replace(MCP_TOOL_NAME, '$1') + return shortName.replaceAll('_', ' ') +} + +// Tool-call arguments and results can contain user text, URLs, or credentials. +// The presentation model intentionally keeps only bounded, non-content metadata. +export function toolCallFromGatewayEvent(event, fallbackTurnId = '') { + if (event?.type !== GatewayServerEvent.TOOL_CALL) return null + const callId = clean(event.callId) + const name = clean(event.name) + if (!callId || !name) return null + if (!['frontend', 'backend'].includes(event.surface)) return null + + const durationMs = Number(event.durationMs) + const createdAt = Number(event.createdAt) + const responseId = clean(event.responseId) + const turnId = clean(event.turnId) || clean(fallbackTurnId) + const taskId = clean(event.taskId) + return { + callId, + name, + surface: event.surface, + status: normalizedStatus(event.status), + ...(isFiniteTimestamp(createdAt) ? { createdAt } : {}), + ...(Number.isFinite(durationMs) && durationMs >= 0 ? { durationMs } : {}), + ...(responseId ? { responseId } : {}), + ...(turnId ? { turnId } : {}), + ...(taskId ? { taskId } : {}), + } +} + +export function mergeToolCallDebug( + existing = [], + incoming, + { max = MAX_TOOL_CALLS } = {}, +) { + if (!incoming?.callId || !incoming.name) return existing + const limit = Number.isInteger(max) && max > 0 ? max : MAX_TOOL_CALLS + const index = existing.findIndex(call => call.callId === incoming.callId) + if (index < 0) { + const next = [...existing, { + ...incoming, + status: normalizedStatus(incoming.status), + }] + return next.length > limit ? next.slice(-limit) : next + } + + const current = existing[index] + const currentStatus = normalizedStatus(current.status) + const nextStatus = normalizedStatus(incoming.status) + const currentRank = STATUS_RANK[currentStatus] + const nextRank = STATUS_RANK[nextStatus] + const status = nextRank < currentRank ? currentStatus : nextStatus + const next = [...existing] + next[index] = { + ...current, + ...incoming, + status, + // A terminal event can arrive after a duplicate/late received event. Keep + // the first observation time so the item does not move in the timeline. + ...(isFiniteTimestamp(current.createdAt) + ? { createdAt: current.createdAt } + : isFiniteTimestamp(incoming.createdAt) + ? { createdAt: incoming.createdAt } + : {}), + turnId: mergeOptionalText(current.turnId, incoming.turnId), + taskId: mergeOptionalText(current.taskId, incoming.taskId), + responseId: mergeOptionalText(current.responseId, incoming.responseId), + ...(Number.isFinite(incoming.durationMs) && incoming.durationMs >= 0 + ? { durationMs: incoming.durationMs } + : Number.isFinite(current.durationMs) && current.durationMs >= 0 + ? { durationMs: current.durationMs } + : {}), + } + return next.length > limit ? next.slice(-limit) : next +} + +export function cancelToolCallsForResponse(toolCalls = [], responseId) { + const id = clean(responseId) + if (!id) return toolCalls + let changed = false + const next = toolCalls.map(call => { + if ( + call.responseId !== id + || ['completed', 'failed', 'cancelled'].includes(normalizedStatus(call.status)) + ) return call + changed = true + return { ...call, status: 'cancelled' } + }) + return changed ? next : toolCalls +} diff --git a/web/test/message-order.test.js b/web/test/message-order.test.js index 24a0087b..58ca92ff 100644 --- a/web/test/message-order.test.js +++ b/web/test/message-order.test.js @@ -422,3 +422,43 @@ test('does not reorder a later voice turn above earlier text history', () => { assert.deepEqual(turns.map(turn => turn.id), ['text_a', 'voice-200-1']) }) + +test('keeps tool calls with their originating turn and orders them by arrival time', () => { + const messages = [ + { + id: 'user', + role: 'user', + turnId: 'voice-100-1', + content: '查一下天气', + createdAt: 100, + }, + { + id: 'reply', + role: 'assistant', + turnId: 'voice-100-1', + content: '我来查一下。', + createdAt: 300, + }, + ] + const toolCalls = [{ + callId: 'call-weather', + name: 'web_search', + surface: 'frontend', + status: 'completed', + turnId: 'voice-100-1', + createdAt: 200, + }] + + const turns = buildConversationTurns(messages, [], toolCalls) + assert.equal(turns.length, 1) + assert.deepEqual(turns[0].beforeEvents.map(item => item.type), [ + 'message', + 'tool-call', + 'message', + ]) + assert.deepEqual( + buildConversationTimeline(messages, [], toolCalls).map(item => item.type), + ['message', 'tool-call', 'message'], + ) + assert.equal(turns[0].beforeEvents[1].value.callId, 'call-weather') +}) diff --git a/web/test/tool-call-debug.test.mjs b/web/test/tool-call-debug.test.mjs new file mode 100644 index 00000000..6a0f9b1e --- /dev/null +++ b/web/test/tool-call-debug.test.mjs @@ -0,0 +1,162 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { GatewayServerEvent } from '../../shared/realtime-events.mjs' +import { + cancelToolCallsForResponse, + displayToolName, + mergeToolCallDebug, + toolCallFromGatewayEvent, +} from '../src/tool-call-debug.js' + +test('normalizes a tool-call event without retaining content payloads', () => { + const toolCall = toolCallFromGatewayEvent({ + type: GatewayServerEvent.TOOL_CALL, + callId: 'call_1', + name: 'mcp__cockpit__navigation_start', + surface: 'frontend', + status: 'received', + arguments: { destination: '西湖', token: 'should-not-be-retained' }, + result: 'private tool output', + durationMs: -1, + createdAt: 100, + responseId: 'response_1', + turnId: 'turn_1', + }) + + assert.deepEqual(toolCall, { + callId: 'call_1', + name: 'mcp__cockpit__navigation_start', + surface: 'frontend', + status: 'received', + createdAt: 100, + responseId: 'response_1', + turnId: 'turn_1', + }) + assert.equal(displayToolName(toolCall.name), 'navigation start') +}) + +test('uses the current turn when an event omits its turn id', () => { + const toolCall = toolCallFromGatewayEvent({ + type: GatewayServerEvent.TOOL_CALL, + callId: 'call_2', + name: 'get_current_time', + surface: 'frontend', + status: 'running', + }, 'turn_fallback') + + assert.equal(toolCall.turnId, 'turn_fallback') + assert.equal(toolCall.status, 'running') +}) + +test('merges a call lifecycle by call id and preserves first observation order', () => { + let calls = mergeToolCallDebug([], { + callId: 'call_1', + name: 'get_current_time', + surface: 'frontend', + status: 'received', + createdAt: 100, + responseId: 'response_1', + turnId: 'turn_1', + }) + calls = mergeToolCallDebug(calls, { + callId: 'call_1', + name: 'get_current_time', + surface: 'frontend', + status: 'completed', + durationMs: 24, + taskId: 'task_1', + createdAt: 200, + }) + + assert.equal(calls.length, 1) + assert.equal(calls[0].status, 'completed') + assert.equal(calls[0].createdAt, 100) + assert.equal(calls[0].durationMs, 24) + assert.equal(calls[0].taskId, 'task_1') + + const settled = calls + calls = mergeToolCallDebug(calls, { + callId: 'call_1', + name: 'get_current_time', + surface: 'frontend', + status: 'received', + createdAt: 300, + }) + assert.equal(calls[0].status, 'completed') + assert.strictEqual(calls[0].createdAt, settled[0].createdAt) +}) + +test('bounds retained tool calls while allowing terminal updates', () => { + let calls = [ + { callId: 'call_1', name: 'one', surface: 'frontend', status: 'completed' }, + { callId: 'call_2', name: 'two', surface: 'frontend', status: 'completed' }, + ] + calls = mergeToolCallDebug(calls, { + callId: 'call_3', + name: 'three', + surface: 'backend', + status: 'received', + }, { max: 2 }) + assert.deepEqual(calls.map(call => call.callId), ['call_2', 'call_3']) + + calls = mergeToolCallDebug(calls, { + callId: 'call_3', + name: 'three', + surface: 'backend', + status: 'completed', + durationMs: 50, + }, { max: 2 }) + assert.equal(calls.length, 2) + assert.equal(calls[1].status, 'completed') + assert.equal(calls[1].durationMs, 50) +}) + +test('marks only interrupted in-flight calls as cancelled', () => { + const calls = [ + { + callId: 'call_active', + name: 'web_search', + surface: 'frontend', + status: 'running', + responseId: 'response_1', + }, + { + callId: 'call_done', + name: 'get_current_time', + surface: 'frontend', + status: 'completed', + responseId: 'response_1', + }, + { + callId: 'call_other', + name: 'notes', + surface: 'frontend', + status: 'running', + responseId: 'response_2', + }, + ] + + const cancelled = cancelToolCallsForResponse(calls, 'response_1') + assert.equal(cancelled[0].status, 'cancelled') + assert.equal(cancelled[1].status, 'completed') + assert.equal(cancelled[2].status, 'running') + assert.strictEqual(cancelToolCallsForResponse(cancelled, 'response_1'), cancelled) +}) + +test('ignores malformed or unrelated events', () => { + assert.equal(toolCallFromGatewayEvent(null), null) + assert.equal(toolCallFromGatewayEvent({ type: 'transcript.final' }), null) + assert.equal(toolCallFromGatewayEvent({ + type: GatewayServerEvent.TOOL_CALL, + name: 'missing-call-id', + surface: 'frontend', + status: 'received', + }), null) + assert.equal(toolCallFromGatewayEvent({ + type: GatewayServerEvent.TOOL_CALL, + callId: 'call-invalid-surface', + name: 'unknown', + surface: 'private', + status: 'received', + }), null) +}) From 0b93dc2cd4f64e9bfe18605dd6f4e8f48ff70b29 Mon Sep 17 00:00:00 2001 From: yimi528 Date: Fri, 11 Sep 2026 18:45:29 +0800 Subject: [PATCH 2/2] fix(web): render frontend tool call activity --- web/src/tool-call-debug.js | 2 +- web/test/tool-call-debug.test.mjs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/tool-call-debug.js b/web/src/tool-call-debug.js index 07e45554..f83b71c0 100644 --- a/web/src/tool-call-debug.js +++ b/web/src/tool-call-debug.js @@ -1,4 +1,4 @@ -import { GatewayServerEvent } from '../../shared/realtime-events.mjs' +import { GatewayServerEvent } from '../../shared/protocol/realtime-events.mjs' export const MAX_TOOL_CALLS = 64 diff --git a/web/test/tool-call-debug.test.mjs b/web/test/tool-call-debug.test.mjs index 6a0f9b1e..d8f390a8 100644 --- a/web/test/tool-call-debug.test.mjs +++ b/web/test/tool-call-debug.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { GatewayServerEvent } from '../../shared/realtime-events.mjs' +import { GatewayServerEvent } from '../../shared/protocol/realtime-events.mjs' import { cancelToolCallsForResponse, displayToolName,