diff --git a/docs/api/rest.md b/docs/api/rest.md index c384904..00e6d15 100644 --- a/docs/api/rest.md +++ b/docs/api/rest.md @@ -314,7 +314,7 @@ Behavior notes: - `CHAT_CODEX_SSH_PRIVATE_KEY_B64=` (optional fallback when a volume mount is not practical) - `CHAT_CODEX_EXEC_TIMEOUT_MS=600000` (optional) - `CHAT_SHELL_EXEC_TIMEOUT_MS=120000` (optional) -- When a tool call is executed, backend stores a chat `Message` with `role: "tool"` and tool metadata (`metadata.kind = "tool_call"`). Streaming requests persist each completed tool call as its SSE `tool_call` event is emitted, then store the assistant output when the completion finishes. +- When a tool call is executed, backend stores a chat `Message` with `role: "tool"` and tool metadata (`metadata.kind = "tool_call"`). Streaming requests emit an initiated SSE `tool_call` event before execution, then persist each completed or failed tool call as its terminal SSE `tool_call` event is emitted, then store the assistant output when the completion finishes. - `anthropic` currently runs without server-managed tool calls. ## Searches diff --git a/docs/api/streaming-chat.md b/docs/api/streaming-chat.md index 7a21061..1d7883b 100644 --- a/docs/api/streaming-chat.md +++ b/docs/api/streaming-chat.md @@ -91,6 +91,8 @@ Event order: 3. Zero or more `delta` 4. Exactly one terminal event: `done` or `error` +Each tool invocation can emit multiple `tool_call` events with the same `toolCallId`. The backend emits `status: "initiated"` before the tool starts executing, then emits `status: "completed"` or `status: "failed"` when execution finishes. Clients should upsert by `toolCallId` instead of appending each event. + ### `meta` ```json @@ -115,6 +117,19 @@ For `persist: false` streams, `chatId` and `callId` are `null`. ### `tool_call` +```json +{ + "toolCallId": "call_123", + "name": "web_search", + "status": "initiated", + "summary": "Searching web for 'latest CPI release'.", + "args": { "query": "latest CPI release" }, + "startedAt": "2026-03-02T10:00:00.000Z" +} +``` + +Terminal tool-call event: + ```json { "toolCallId": "call_123", @@ -125,11 +140,12 @@ For `persist: false` streams, `chatId` and `callId` are `null`. "startedAt": "2026-03-02T10:00:00.000Z", "completedAt": "2026-03-02T10:00:00.820Z", "durationMs": 820, - "error": null, "resultPreview": "{\"ok\":true,...}" } ``` +`status` is one of `initiated`, `completed`, or `failed`. `completedAt` and `durationMs` are only present on terminal events. `error` is present on failed terminal events; `resultPreview` is present on terminal events when available. + ### `done` ```json @@ -178,7 +194,8 @@ Backend database remains source of truth. For persisted streams: - Client may optimistically render accumulated `delta` text. -- Backend persists each completed tool call as a `tool` message before emitting its `tool_call` SSE event, so chat detail refreshes can show completed tool calls while the assistant response is still running. +- Backend emits initiated tool-call events without persisting them. +- Backend persists each completed or failed tool call as a `tool` message before emitting its terminal `tool_call` SSE event, so chat detail refreshes can show completed tool calls while the assistant response is still running. On successful persisted completion: - Backend persists assistant `Message` and updates `LlmCall` usage/latency in a transaction. diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift index 57ac51e..7b91d5d 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift @@ -138,6 +138,12 @@ private struct MessageBubble: View { } private struct ToolCallActivityChip: View { + enum VisualState { + case initiated + case completed + case failed + } + var metadata: ToolCallMetadata var fallbackContent: String var createdAt: Date @@ -184,11 +190,22 @@ private struct ToolCallActivityChip: View { } private var isFailed: Bool { - (metadata.status ?? "").lowercased() == "failed" + visualState == .failed + } + + private var visualState: VisualState { + switch (metadata.status ?? "").lowercased() { + case "failed": + return .failed + case "initiated": + return .initiated + default: + return .completed + } } private var detailLabel: String { - var pieces: [String] = [isFailed ? "Failed" : "Completed"] + var pieces: [String] = [stateLabel] if let durationMs = metadata.durationMs, durationMs > 0 { pieces.append("\(durationMs) ms") } @@ -200,14 +217,14 @@ private struct ToolCallActivityChip: View { HStack(alignment: .top, spacing: 11) { ZStack { RoundedRectangle(cornerRadius: 9) - .fill((isFailed ? SybilTheme.danger : SybilTheme.accent).opacity(0.13)) + .fill(iconColor.opacity(0.13)) .overlay( RoundedRectangle(cornerRadius: 9) - .stroke((isFailed ? SybilTheme.danger : SybilTheme.accent).opacity(0.34), lineWidth: 1) + .stroke(iconColor.opacity(0.34), lineWidth: 1) ) Image(systemName: iconName) .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(isFailed ? SybilTheme.danger : SybilTheme.accent) + .foregroundStyle(iconColor) } .frame(width: 30, height: 30) @@ -221,7 +238,7 @@ private struct ToolCallActivityChip: View { HStack(spacing: 6) { Text(toolLabel) .font(.sybil(.caption2, weight: .semibold)) - .foregroundStyle(isFailed ? SybilTheme.danger.opacity(0.84) : SybilTheme.accent.opacity(0.90)) + .foregroundStyle(iconColor.opacity(0.90)) .lineLimit(1) Text(detailLabel) @@ -236,12 +253,45 @@ private struct ToolCallActivityChip: View { .padding(.vertical, 10) .background( RoundedRectangle(cornerRadius: 12) - .fill(isFailed ? SybilTheme.failedToolCallGradient : SybilTheme.toolCallGradient) + .fill(backgroundGradient) .overlay( RoundedRectangle(cornerRadius: 12) - .stroke((isFailed ? SybilTheme.danger : SybilTheme.accent).opacity(0.34), lineWidth: 1) + .stroke(iconColor.opacity(0.34), lineWidth: 1) ) ) .frame(maxWidth: 520, alignment: .leading) } + + private var stateLabel: String { + switch visualState { + case .failed: + return "Failed" + case .initiated: + return "Running" + case .completed: + return "Completed" + } + } + + private var iconColor: Color { + switch visualState { + case .failed: + return SybilTheme.danger + case .initiated: + return SybilTheme.warning + case .completed: + return SybilTheme.accent + } + } + + private var backgroundGradient: LinearGradient { + switch visualState { + case .failed: + return SybilTheme.failedToolCallGradient + case .initiated: + return SybilTheme.runningToolCallGradient + case .completed: + return SybilTheme.toolCallGradient + } + } } diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilModels.swift b/ios/Packages/Sybil/Sources/Sybil/SybilModels.swift index c368e06..49f2b91 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilModels.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilModels.swift @@ -514,8 +514,8 @@ public struct CompletionStreamToolCall: Codable, Sendable { public var summary: String public var args: [String: JSONValue] public var startedAt: String - public var completedAt: String - public var durationMs: Int + public var completedAt: String? + public var durationMs: Int? public var error: String? public var resultPreview: String? } diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilTheme.swift b/ios/Packages/Sybil/Sources/Sybil/SybilTheme.swift index 40faed4..85a06d4 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilTheme.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilTheme.swift @@ -78,6 +78,7 @@ enum SybilTheme { static let searchCard = Color(red: 0.07, green: 0.06, blue: 0.14) static let userBubble = Color(red: 0.29, green: 0.13, blue: 0.65) static let danger = Color(red: 0.96, green: 0.32, blue: 0.40) + static let warning = Color(red: 0.95, green: 0.69, blue: 0.25) @MainActor static func applySystemAppearance() { let navAppearance = UINavigationBarAppearance() @@ -186,6 +187,17 @@ enum SybilTheme { ) } + static var runningToolCallGradient: LinearGradient { + LinearGradient( + colors: [ + Color(red: 0.30, green: 0.19, blue: 0.04).opacity(0.72), + Color(red: 0.09, green: 0.05, blue: 0.17).opacity(0.78) + ], + startPoint: .leading, + endPoint: .trailing + ) + } + static var failedToolCallGradient: LinearGradient { LinearGradient( colors: [ diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift b/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift index afd97aa..fba584b 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilViewModel.swift @@ -1186,7 +1186,7 @@ final class SybilViewModel { break case let .toolCall(payload): - insertQuickQuestionToolCallMessage(payload) + upsertQuickQuestionToolCallMessage(payload) case let .delta(payload): guard !payload.text.isEmpty else { return } @@ -2006,7 +2006,7 @@ final class SybilViewModel { } case let .toolCall(payload): - insertPendingToolCallMessage(payload, chatID: chatID) + upsertPendingToolCallMessage(payload, chatID: chatID) case let .delta(payload): guard !payload.text.isEmpty else { return } @@ -2222,12 +2222,14 @@ final class SybilViewModel { quickQuestionMessages[index].content = transform(quickQuestionMessages[index].content) } - private func insertPendingToolCallMessage(_ payload: CompletionStreamToolCall, chatID: String) { + private func upsertPendingToolCallMessage(_ payload: CompletionStreamToolCall, chatID: String) { guard var pending = pendingChatStates[chatID] else { return } - if pending.messages.contains(where: { $0.toolCallMetadata?.toolCallId == payload.toolCallId }) { + if let existingIndex = pending.messages.firstIndex(where: { $0.toolCallMetadata?.toolCallId == payload.toolCallId || $0.id == "temp-tool-\(payload.toolCallId)" }) { + pending.messages[existingIndex] = toolCallMessage(for: payload, id: pending.messages[existingIndex].id) + pendingChatStates[chatID] = pending return } @@ -2242,8 +2244,9 @@ final class SybilViewModel { pendingChatStates[chatID] = pending } - private func insertQuickQuestionToolCallMessage(_ payload: CompletionStreamToolCall) { - if quickQuestionMessages.contains(where: { $0.toolCallMetadata?.toolCallId == payload.toolCallId }) { + private func upsertQuickQuestionToolCallMessage(_ payload: CompletionStreamToolCall) { + if let existingIndex = quickQuestionMessages.firstIndex(where: { $0.toolCallMetadata?.toolCallId == payload.toolCallId || $0.id == "temp-tool-\(payload.toolCallId)" }) { + quickQuestionMessages[existingIndex] = toolCallMessage(for: payload, id: quickQuestionMessages[existingIndex].id) return } @@ -2255,8 +2258,8 @@ final class SybilViewModel { } } - private func toolCallMessage(for payload: CompletionStreamToolCall) -> Message { - let metadata: JSONValue = .object([ + private func toolCallMessage(for payload: CompletionStreamToolCall, id: String? = nil) -> Message { + var metadataObject: [String: JSONValue] = [ "kind": .string("tool_call"), "toolCallId": .string(payload.toolCallId), "toolName": .string(payload.name), @@ -2264,19 +2267,26 @@ final class SybilViewModel { "summary": .string(payload.summary), "args": .object(payload.args), "startedAt": .string(payload.startedAt), - "completedAt": .string(payload.completedAt), - "durationMs": .number(Double(payload.durationMs)), "error": payload.error.map { .string($0) } ?? .null, "resultPreview": payload.resultPreview.map { .string($0) } ?? .null - ]) + ] + + if let completedAt = payload.completedAt { + metadataObject["completedAt"] = .string(completedAt) + } + if let durationMs = payload.durationMs { + metadataObject["durationMs"] = .number(Double(durationMs)) + } + + let metadata: JSONValue = .object(metadataObject) let summary = payload.summary.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "Ran tool '\(payload.name)'." : payload.summary return Message( - id: "temp-tool-\(payload.toolCallId)", - createdAt: Date(), + id: id ?? "temp-tool-\(payload.toolCallId)", + createdAt: toolCallDate(from: payload.completedAt) ?? toolCallDate(from: payload.startedAt) ?? Date(), role: .tool, content: summary, name: payload.name, @@ -2284,6 +2294,19 @@ final class SybilViewModel { ) } + private func toolCallDate(from value: String?) -> Date? { + guard let value else { return nil } + let fractionalFormatter = ISO8601DateFormatter() + fractionalFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = fractionalFormatter.date(from: value) { + return date + } + + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: value) + } + private var currentChatID: String? { if draftKind == .chat { return nil diff --git a/server/src/llm/chat-tools.ts b/server/src/llm/chat-tools.ts index 29ed23a..caa389c 100644 --- a/server/src/llm/chat-tools.ts +++ b/server/src/llm/chat-tools.ts @@ -292,15 +292,17 @@ type ToolAwareCompletionParams = { }; }; +export type ToolExecutionStatus = "initiated" | "completed" | "failed"; + export type ToolExecutionEvent = { toolCallId: string; name: string; - status: "completed" | "failed"; + status: ToolExecutionStatus; summary: string; args: Record; startedAt: string; - completedAt: string; - durationMs: number; + completedAt?: string; + durationMs?: number; error?: string; resultPreview?: string; }; @@ -328,10 +330,13 @@ function toSingleLine(value: string, maxLength = 220) { ); } -function buildToolSummary(name: string, args: Record, status: "completed" | "failed", error?: string) { +function buildToolSummary(name: string, args: Record, status: ToolExecutionStatus, error?: string) { const errSuffix = status === "failed" && error ? ` Error: ${toSingleLine(error, 140)}` : ""; if (name === "web_search") { const query = typeof args.query === "string" ? args.query.trim() : ""; + if (status === "initiated") { + return query ? `Searching web for '${toSingleLine(query, 100)}'.` : "Searching web."; + } if (status === "completed") { return query ? `Performed web search for '${toSingleLine(query, 100)}'.` : "Performed web search."; } @@ -340,6 +345,9 @@ function buildToolSummary(name: string, args: Record, status: " if (name === "fetch_url") { const url = typeof args.url === "string" ? args.url.trim() : ""; + if (status === "initiated") { + return url ? `Fetching URL ${toSingleLine(url, 140)}.` : "Fetching URL."; + } if (status === "completed") { return url ? `Fetched URL ${toSingleLine(url, 140)}.` : "Fetched URL."; } @@ -348,6 +356,9 @@ function buildToolSummary(name: string, args: Record, status: " if (name === "codex_exec") { const prompt = typeof args.prompt === "string" ? args.prompt.trim() : ""; + if (status === "initiated") { + return prompt ? `Running Codex task: '${toSingleLine(prompt, 120)}'.` : "Running Codex task."; + } if (status === "completed") { return prompt ? `Ran Codex task: '${toSingleLine(prompt, 120)}'.` : "Ran Codex task."; } @@ -356,6 +367,9 @@ function buildToolSummary(name: string, args: Record, status: " if (name === "shell_exec") { const command = typeof args.command === "string" ? args.command.trim() : ""; + if (status === "initiated") { + return command ? `Running devbox shell command: '${toSingleLine(command, 120)}'.` : "Running devbox shell command."; + } if (status === "completed") { return command ? `Ran devbox shell command: '${toSingleLine(command, 120)}'.` : "Ran devbox shell command."; } @@ -364,6 +378,9 @@ function buildToolSummary(name: string, args: Record, status: " : `Devbox shell command failed.${errSuffix}`; } + if (status === "initiated") { + return `Running tool '${name}'.`; + } if (status === "completed") { return `Ran tool '${name}'.`; } @@ -969,17 +986,55 @@ function normalizeModelToolCalls(toolCalls: any[], round: number): NormalizedToo })); } -async function executeToolCallAndBuildEvent( - call: NormalizedToolCall, - params: ToolAwareCompletionParams -): Promise<{ event: ToolExecutionEvent; toolResult: ToolRunOutcome }> { +type PreparedToolCallExecution = { + startedAtMs: number; + startedAt: string; + parsedArgs: Record; + eventArgs: Record; + parseError?: unknown; +}; + +function prepareToolCallExecution(call: NormalizedToolCall): { event: ToolExecutionEvent; execution: PreparedToolCallExecution } { const startedAtMs = Date.now(); const startedAt = new Date(startedAtMs).toISOString(); - let toolResult: ToolRunOutcome; let parsedArgs: Record = {}; + + let parseError: unknown; try { parsedArgs = toRecord(parseToolArgs(call.arguments)); - toolResult = await executeTool(call.name, parsedArgs); + } catch (err) { + parseError = err; + } + + const eventArgs = buildEventArgs(call.name, parsedArgs); + return { + event: { + toolCallId: call.id, + name: call.name, + status: "initiated", + summary: buildToolSummary(call.name, eventArgs, "initiated"), + args: eventArgs, + startedAt, + }, + execution: { + startedAtMs, + startedAt, + parsedArgs, + eventArgs, + parseError, + }, + }; +} + +async function executeToolCallAndBuildEvent( + call: NormalizedToolCall, + execution: PreparedToolCallExecution, + params: ToolAwareCompletionParams +): Promise<{ event: ToolExecutionEvent; toolResult: ToolRunOutcome }> { + let toolResult: ToolRunOutcome; + try { + if (execution.parseError) throw execution.parseError; + toolResult = await executeTool(call.name, execution.parsedArgs); } catch (err: any) { toolResult = { ok: false, @@ -996,16 +1051,15 @@ async function executeToolCallAndBuildEvent( : undefined; const completedAtMs = Date.now(); - const eventArgs = buildEventArgs(call.name, parsedArgs); const event: ToolExecutionEvent = { toolCallId: call.id, name: call.name, status, - summary: buildToolSummary(call.name, eventArgs, status, error), - args: eventArgs, - startedAt, + summary: buildToolSummary(call.name, execution.eventArgs, status, error), + args: execution.eventArgs, + startedAt: execution.startedAt, completedAt: new Date(completedAtMs).toISOString(), - durationMs: completedAtMs - startedAtMs, + durationMs: completedAtMs - execution.startedAtMs, error, resultPreview: buildResultPreview(toolResult), }; @@ -1068,7 +1122,8 @@ export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams): input.push(...outputItems); for (const call of normalizedToolCalls) { - const { event, toolResult } = await executeToolCallAndBuildEvent(call, params); + const { execution } = prepareToolCallExecution(call); + const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params); toolEvents.push(event); input.push({ @@ -1155,7 +1210,8 @@ export async function runToolAwareChatCompletions(params: ToolAwareCompletionPar conversation.push(assistantToolCallMessage); for (const call of normalizedToolCalls) { - const { event, toolResult } = await executeToolCallAndBuildEvent(call, params); + const { execution } = prepareToolCallExecution(call); + const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params); toolEvents.push(event); conversation.push({ @@ -1299,7 +1355,9 @@ export async function* runToolAwareOpenAIChatStream( input.push(...responseOutputItems); for (const call of normalizedToolCalls) { - const { event, toolResult } = await executeToolCallAndBuildEvent(call, params); + const { event: initiatedEvent, execution } = prepareToolCallExecution(call); + yield { type: "tool_call", event: initiatedEvent }; + const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params); toolEvents.push(event); yield { type: "tool_call", event }; input.push({ @@ -1436,7 +1494,9 @@ export async function* runToolAwareChatCompletionsStream( conversation.push(assistantToolCallMessage); for (const call of normalizedToolCalls) { - const { event, toolResult } = await executeToolCallAndBuildEvent(call, params); + const { event: initiatedEvent, execution } = prepareToolCallExecution(call); + yield { type: "tool_call", event: initiatedEvent }; + const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params); toolEvents.push(event); yield { type: "tool_call", event }; conversation.push({ diff --git a/server/src/llm/streaming.ts b/server/src/llm/streaming.ts index 4457a63..1017e69 100644 --- a/server/src/llm/streaming.ts +++ b/server/src/llm/streaming.ts @@ -130,7 +130,7 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator } if (ev.type === "tool_call") { - if (shouldPersist && chatId) { + if (ev.event.status !== "initiated" && shouldPersist && chatId) { const toolMessage = buildToolLogMessageData(chatId, ev.event); await prisma.message.create({ data: { diff --git a/server/tests/chat-tools-streaming.test.ts b/server/tests/chat-tools-streaming.test.ts index 4ab1fca..87f6428 100644 --- a/server/tests/chat-tools-streaming.test.ts +++ b/server/tests/chat-tools-streaming.test.ts @@ -140,3 +140,69 @@ test("plain Chat Completions stream does not send Sybil-managed tools", async () ); assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Hi"); }); + +test("OpenAI-compatible Chat Completions stream emits initiated and terminal tool call updates", async () => { + let requestCount = 0; + const client = { + chat: { + completions: { + create: async () => { + requestCount += 1; + if (requestCount === 1) { + return streamFrom([ + { + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: "call_1", + function: { + name: "unknown_tool", + arguments: "{\"query\":\"current weather\"}", + }, + }, + ], + }, + finish_reason: "tool_calls", + }, + ], + }, + ]); + } + + return streamFrom([ + { choices: [{ delta: { content: "Done" } }] }, + { choices: [{ delta: {}, finish_reason: "stop" }] }, + ]); + }, + }, + }, + }; + + const events = await collectEvents( + runToolAwareChatCompletionsStream({ + client: client as any, + model: "grok-test", + messages: [{ role: "user", content: "Use a tool" }], + }) + ); + + assert.deepEqual( + events.map((event) => event.type), + ["tool_call", "tool_call", "delta", "done"] + ); + + const toolEvents = events.flatMap((event) => (event.type === "tool_call" ? [event.event] : [])); + assert.equal(toolEvents[0]?.toolCallId, "call_1"); + assert.equal(toolEvents[0]?.status, "initiated"); + assert.equal(toolEvents[0]?.completedAt, undefined); + assert.equal(toolEvents[0]?.durationMs, undefined); + assert.equal(toolEvents[1]?.toolCallId, "call_1"); + assert.equal(toolEvents[1]?.status, "failed"); + assert.match(toolEvents[1]?.error ?? "", /Unknown tool: unknown_tool/); + assert.equal(typeof toolEvents[1]?.completedAt, "string"); + assert.equal(typeof toolEvents[1]?.durationMs, "number"); + assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Done"); +}); diff --git a/tui/src/index.ts b/tui/src/index.ts index 9e62ff1..7b71f0a 100644 --- a/tui/src/index.ts +++ b/tui/src/index.ts @@ -32,7 +32,7 @@ type ToolLogMetadata = { kind: "tool_call"; toolCallId?: string; toolName?: string; - status?: "completed" | "failed"; + status?: "initiated" | "completed" | "failed"; summary?: string; args?: Record; startedAt?: string; @@ -171,28 +171,47 @@ function isToolCallLogMessage(message: Message) { } function buildOptimisticToolMessage(event: ToolCallEvent): Message { + const metadata: ToolLogMetadata = { + kind: "tool_call", + toolCallId: event.toolCallId, + toolName: event.name, + status: event.status, + summary: event.summary, + args: event.args, + startedAt: event.startedAt, + error: event.error ?? null, + resultPreview: event.resultPreview ?? null, + }; + + if (event.completedAt) metadata.completedAt = event.completedAt; + if (typeof event.durationMs === "number") metadata.durationMs = event.durationMs; + return { id: `temp-tool-${event.toolCallId}`, - createdAt: event.completedAt ?? new Date().toISOString(), + createdAt: event.completedAt ?? event.startedAt ?? new Date().toISOString(), role: "tool", content: event.summary, name: event.name, - metadata: { - kind: "tool_call", - toolCallId: event.toolCallId, - toolName: event.name, - status: event.status, - summary: event.summary, - args: event.args, - startedAt: event.startedAt, - completedAt: event.completedAt, - durationMs: event.durationMs, - error: event.error ?? null, - resultPreview: event.resultPreview ?? null, - } satisfies ToolLogMetadata, + metadata, }; } +function upsertOptimisticToolMessage(messages: Message[], event: ToolCallEvent) { + const toolMessage = buildOptimisticToolMessage(event); + const existingIndex = messages.findIndex( + (message) => asToolLogMetadata(message.metadata)?.toolCallId === event.toolCallId || message.id === `temp-tool-${event.toolCallId}` + ); + if (existingIndex >= 0) { + return messages.map((message, index) => (index === existingIndex ? { ...toolMessage, id: message.id } : message)); + } + + const assistantIndex = messages.findIndex( + (message, index, all) => index === all.length - 1 && message.id.startsWith("temp-assistant-") + ); + if (assistantIndex < 0) return messages.concat(toolMessage); + return [...messages.slice(0, assistantIndex), toolMessage, ...messages.slice(assistantIndex)]; +} + function getModelOptions(catalog: ModelCatalogResponse["providers"], provider: Provider) { const providerModels = catalog[provider]?.models ?? []; if (providerModels.length) return providerModels; @@ -602,7 +621,12 @@ async function main() { for (const message of messages) { const toolMeta = asToolLogMetadata(message.metadata); if (message.role === "tool" && toolMeta) { - const prefix = toolMeta.status === "failed" ? "{red-fg}[tool failed]{/red-fg}" : "{cyan-fg}[tool]{/cyan-fg}"; + const prefix = + toolMeta.status === "failed" + ? "{red-fg}[tool failed]{/red-fg}" + : toolMeta.status === "initiated" + ? "{yellow-fg}[tool running]{/yellow-fg}" + : "{cyan-fg}[tool]{/cyan-fg}"; const summary = toolMeta.summary?.trim() || message.content.trim() || "Tool call executed."; parts.push(`${prefix} ${escapeTags(summary)}`); continue; @@ -1083,29 +1107,7 @@ async function main() { }, onToolCall: (payload) => { if (!pendingChatState) return; - const alreadyPresent = pendingChatState.messages.some( - (message) => - asToolLogMetadata(message.metadata)?.toolCallId === payload.toolCallId || message.id === `temp-tool-${payload.toolCallId}` - ); - if (alreadyPresent) return; - - const toolMessage = buildOptimisticToolMessage(payload); - const assistantIndex = pendingChatState.messages.findIndex( - (message, index, all) => index === all.length - 1 && message.id.startsWith("temp-assistant-") - ); - - if (assistantIndex < 0) { - pendingChatState = { ...pendingChatState, messages: pendingChatState.messages.concat(toolMessage) }; - } else { - pendingChatState = { - ...pendingChatState, - messages: [ - ...pendingChatState.messages.slice(0, assistantIndex), - toolMessage, - ...pendingChatState.messages.slice(assistantIndex), - ], - }; - } + pendingChatState = { ...pendingChatState, messages: upsertOptimisticToolMessage(pendingChatState.messages, payload) }; queueTranscriptScrollToBottomIfFollowing(); updateUI(); diff --git a/tui/src/types.ts b/tui/src/types.ts index 2700efe..852301a 100644 --- a/tui/src/types.ts +++ b/tui/src/types.ts @@ -55,12 +55,12 @@ export type Message = { export type ToolCallEvent = { toolCallId: string; name: string; - status: "completed" | "failed"; + status: "initiated" | "completed" | "failed"; summary: string; args: Record; startedAt: string; - completedAt: string; - durationMs: number; + completedAt?: string; + durationMs?: number; error?: string; resultPreview?: string; }; diff --git a/web/src/App.tsx b/web/src/App.tsx index 2290668..f08d5a5 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -435,7 +435,7 @@ type ToolLogMetadata = { kind: "tool_call"; toolCallId?: string; toolName?: string; - status?: "completed" | "failed"; + status?: "initiated" | "completed" | "failed"; summary?: string; args?: Record; startedAt?: string; @@ -461,28 +461,48 @@ function isDisplayableMessage(message: Message) { } function buildOptimisticToolMessage(event: ToolCallEvent): Message { + const metadata: ToolLogMetadata = { + kind: "tool_call", + toolCallId: event.toolCallId, + toolName: event.name, + status: event.status, + summary: event.summary, + args: event.args, + startedAt: event.startedAt, + error: event.error ?? null, + resultPreview: event.resultPreview ?? null, + }; + + if (event.completedAt) metadata.completedAt = event.completedAt; + if (typeof event.durationMs === "number") metadata.durationMs = event.durationMs; + return { id: `temp-tool-${event.toolCallId}`, - createdAt: event.completedAt ?? new Date().toISOString(), + createdAt: event.completedAt ?? event.startedAt ?? new Date().toISOString(), role: "tool", content: event.summary, name: event.name, - metadata: { - kind: "tool_call", - toolCallId: event.toolCallId, - toolName: event.name, - status: event.status, - summary: event.summary, - args: event.args, - startedAt: event.startedAt, - completedAt: event.completedAt, - durationMs: event.durationMs, - error: event.error ?? null, - resultPreview: event.resultPreview ?? null, - } satisfies ToolLogMetadata, + metadata, }; } +function upsertOptimisticToolMessage(messages: Message[], event: ToolCallEvent, assistantMessagePrefix: string) { + const toolMessage = buildOptimisticToolMessage(event); + const existingIndex = messages.findIndex( + (message) => asToolLogMetadata(message.metadata)?.toolCallId === event.toolCallId || message.id === `temp-tool-${event.toolCallId}` + ); + + if (existingIndex >= 0) { + return messages.map((message, index) => (index === existingIndex ? { ...toolMessage, id: message.id } : message)); + } + + const assistantIndex = messages.findIndex( + (message, index, all) => index === all.length - 1 && message.id.startsWith(assistantMessagePrefix) + ); + if (assistantIndex < 0) return messages.concat(toolMessage); + return [...messages.slice(0, assistantIndex), toolMessage, ...messages.slice(assistantIndex)]; +} + type ModelComboboxProps = { options: string[]; value: string; @@ -2093,33 +2113,10 @@ export default function App() { setPendingChatStates((current) => { const pendingState = current[chatId]; if (!pendingState) return current; - if ( - pendingState.messages.some( - (message) => - asToolLogMetadata(message.metadata)?.toolCallId === payload.toolCallId || message.id === `temp-tool-${payload.toolCallId}` - ) - ) { - return current; - } - - const toolMessage = buildOptimisticToolMessage(payload); - const assistantIndex = pendingState.messages.findIndex( - (message, index, all) => index === all.length - 1 && message.id.startsWith("temp-assistant-") - ); - if (assistantIndex < 0) { - return { - ...current, - [chatId]: { messages: pendingState.messages.concat(toolMessage) }, - }; - } return { ...current, [chatId]: { - messages: [ - ...pendingState.messages.slice(0, assistantIndex), - toolMessage, - ...pendingState.messages.slice(assistantIndex), - ], + messages: upsertOptimisticToolMessage(pendingState.messages, payload, "temp-assistant-"), }, }; }); @@ -2359,30 +2356,10 @@ export default function App() { setPendingChatStates((current) => { const pendingState = current[chatId]; if (!pendingState) return current; - if ( - pendingState.messages.some( - (message) => - asToolLogMetadata(message.metadata)?.toolCallId === payload.toolCallId || message.id === `temp-tool-${payload.toolCallId}` - ) - ) { - return current; - } - - const toolMessage = buildOptimisticToolMessage(payload); - const assistantIndex = pendingState.messages.findIndex( - (message, index, all) => index === all.length - 1 && message.id.startsWith("temp-assistant-") - ); - if (assistantIndex < 0) { - return { ...current, [chatId]: { messages: pendingState.messages.concat(toolMessage) } }; - } return { ...current, [chatId]: { - messages: [ - ...pendingState.messages.slice(0, assistantIndex), - toolMessage, - ...pendingState.messages.slice(assistantIndex), - ], + messages: upsertOptimisticToolMessage(pendingState.messages, payload, "temp-assistant-"), }, }; }); @@ -2649,25 +2626,7 @@ export default function App() { { onToolCall: (payload) => { setQuickQuestionMessages((current) => { - if ( - current.some( - (message) => - asToolLogMetadata(message.metadata)?.toolCallId === payload.toolCallId || message.id === `temp-tool-${payload.toolCallId}` - ) - ) { - return current; - } - - const toolMessage = buildOptimisticToolMessage(payload); - const assistantIndex = current.findIndex( - (message, index, all) => index === all.length - 1 && message.id.startsWith("temp-assistant-quick-") - ); - if (assistantIndex < 0) return current.concat(toolMessage); - return [ - ...current.slice(0, assistantIndex), - toolMessage, - ...current.slice(assistantIndex), - ]; + return upsertOptimisticToolMessage(current, payload, "temp-assistant-quick-"); }); }, onDelta: (payload) => { diff --git a/web/src/components/chat/chat-messages-panel.tsx b/web/src/components/chat/chat-messages-panel.tsx index f029b6b..f79c1eb 100644 --- a/web/src/components/chat/chat-messages-panel.tsx +++ b/web/src/components/chat/chat-messages-panel.tsx @@ -14,7 +14,7 @@ type ToolLogMetadata = { kind: "tool_call"; toolCallId?: string; toolName?: string; - status?: "completed" | "failed"; + status?: "initiated" | "completed" | "failed"; summary?: string; args?: Record; startedAt?: string; @@ -71,9 +71,17 @@ function formatToolTimestamp(...values: Array) { return new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" }).format(new Date(value)); } -function getToolDetailLabel(message: Message, metadata: ToolLogMetadata, isFailed: boolean) { +type ToolCallVisualState = "initiated" | "completed" | "failed"; + +function getToolVisualState(metadata: ToolLogMetadata): ToolCallVisualState { + if (metadata.status === "failed") return "failed"; + if (metadata.status === "initiated") return "initiated"; + return "completed"; +} + +function getToolDetailLabel(message: Message, metadata: ToolLogMetadata, state: ToolCallVisualState) { return [ - isFailed ? "Failed" : "Completed", + state === "failed" ? "Failed" : state === "initiated" ? "Running" : "Completed", formatDuration(metadata.durationMs), formatToolTimestamp(message.createdAt, metadata.completedAt, metadata.startedAt), ] @@ -93,10 +101,12 @@ export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) { if (message.role === "tool" && toolLogMetadata) { const iconKind = getToolIconName(toolLogMetadata.toolName ?? message.name); const Icon = iconKind === "search" ? Globe2 : iconKind === "fetch" ? Link2 : Wrench; - const isFailed = toolLogMetadata.status === "failed"; + const toolState = getToolVisualState(toolLogMetadata); + const isFailed = toolState === "failed"; + const isInitiated = toolState === "initiated"; const toolSummary = getToolSummary(message, toolLogMetadata); const toolLabel = getToolLabel(message, toolLogMetadata); - const toolDetailLabel = getToolDetailLabel(message, toolLogMetadata, isFailed); + const toolDetailLabel = getToolDetailLabel(message, toolLogMetadata, toolState); return (
@@ -121,7 +137,7 @@ export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) { {toolSummary} - + {toolLabel} {toolDetailLabel} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index e7f99db..53a495a 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -45,12 +45,12 @@ export type Message = { export type ToolCallEvent = { toolCallId: string; name: string; - status: "completed" | "failed"; + status: "initiated" | "completed" | "failed"; summary: string; args: Record; startedAt: string; - completedAt: string; - durationMs: number; + completedAt?: string; + durationMs?: number; error?: string; resultPreview?: string; };