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..f83b71c0
--- /dev/null
+++ b/web/src/tool-call-debug.js
@@ -0,0 +1,134 @@
+import { GatewayServerEvent } from '../../shared/protocol/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..d8f390a8
--- /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/protocol/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)
+})