Show in-progress tool calls

This commit is contained in:
2026-06-05 22:20:56 -07:00
parent f71b69ca8b
commit fccc8110f4
14 changed files with 382 additions and 177 deletions

View File

@@ -314,7 +314,7 @@ Behavior notes:
- `CHAT_CODEX_SSH_PRIVATE_KEY_B64=<base64-private-key>` (optional fallback when a volume mount is not practical) - `CHAT_CODEX_SSH_PRIVATE_KEY_B64=<base64-private-key>` (optional fallback when a volume mount is not practical)
- `CHAT_CODEX_EXEC_TIMEOUT_MS=600000` (optional) - `CHAT_CODEX_EXEC_TIMEOUT_MS=600000` (optional)
- `CHAT_SHELL_EXEC_TIMEOUT_MS=120000` (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. - `anthropic` currently runs without server-managed tool calls.
## Searches ## Searches

View File

@@ -91,6 +91,8 @@ Event order:
3. Zero or more `delta` 3. Zero or more `delta`
4. Exactly one terminal event: `done` or `error` 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` ### `meta`
```json ```json
@@ -115,6 +117,19 @@ For `persist: false` streams, `chatId` and `callId` are `null`.
### `tool_call` ### `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 ```json
{ {
"toolCallId": "call_123", "toolCallId": "call_123",
@@ -125,11 +140,12 @@ For `persist: false` streams, `chatId` and `callId` are `null`.
"startedAt": "2026-03-02T10:00:00.000Z", "startedAt": "2026-03-02T10:00:00.000Z",
"completedAt": "2026-03-02T10:00:00.820Z", "completedAt": "2026-03-02T10:00:00.820Z",
"durationMs": 820, "durationMs": 820,
"error": null,
"resultPreview": "{\"ok\":true,...}" "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` ### `done`
```json ```json
@@ -178,7 +194,8 @@ Backend database remains source of truth.
For persisted streams: For persisted streams:
- Client may optimistically render accumulated `delta` text. - 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: On successful persisted completion:
- Backend persists assistant `Message` and updates `LlmCall` usage/latency in a transaction. - Backend persists assistant `Message` and updates `LlmCall` usage/latency in a transaction.

View File

@@ -138,6 +138,12 @@ private struct MessageBubble: View {
} }
private struct ToolCallActivityChip: View { private struct ToolCallActivityChip: View {
enum VisualState {
case initiated
case completed
case failed
}
var metadata: ToolCallMetadata var metadata: ToolCallMetadata
var fallbackContent: String var fallbackContent: String
var createdAt: Date var createdAt: Date
@@ -184,11 +190,22 @@ private struct ToolCallActivityChip: View {
} }
private var isFailed: Bool { 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 { private var detailLabel: String {
var pieces: [String] = [isFailed ? "Failed" : "Completed"] var pieces: [String] = [stateLabel]
if let durationMs = metadata.durationMs, durationMs > 0 { if let durationMs = metadata.durationMs, durationMs > 0 {
pieces.append("\(durationMs) ms") pieces.append("\(durationMs) ms")
} }
@@ -200,14 +217,14 @@ private struct ToolCallActivityChip: View {
HStack(alignment: .top, spacing: 11) { HStack(alignment: .top, spacing: 11) {
ZStack { ZStack {
RoundedRectangle(cornerRadius: 9) RoundedRectangle(cornerRadius: 9)
.fill((isFailed ? SybilTheme.danger : SybilTheme.accent).opacity(0.13)) .fill(iconColor.opacity(0.13))
.overlay( .overlay(
RoundedRectangle(cornerRadius: 9) RoundedRectangle(cornerRadius: 9)
.stroke((isFailed ? SybilTheme.danger : SybilTheme.accent).opacity(0.34), lineWidth: 1) .stroke(iconColor.opacity(0.34), lineWidth: 1)
) )
Image(systemName: iconName) Image(systemName: iconName)
.font(.system(size: 14, weight: .semibold)) .font(.system(size: 14, weight: .semibold))
.foregroundStyle(isFailed ? SybilTheme.danger : SybilTheme.accent) .foregroundStyle(iconColor)
} }
.frame(width: 30, height: 30) .frame(width: 30, height: 30)
@@ -221,7 +238,7 @@ private struct ToolCallActivityChip: View {
HStack(spacing: 6) { HStack(spacing: 6) {
Text(toolLabel) Text(toolLabel)
.font(.sybil(.caption2, weight: .semibold)) .font(.sybil(.caption2, weight: .semibold))
.foregroundStyle(isFailed ? SybilTheme.danger.opacity(0.84) : SybilTheme.accent.opacity(0.90)) .foregroundStyle(iconColor.opacity(0.90))
.lineLimit(1) .lineLimit(1)
Text(detailLabel) Text(detailLabel)
@@ -236,12 +253,45 @@ private struct ToolCallActivityChip: View {
.padding(.vertical, 10) .padding(.vertical, 10)
.background( .background(
RoundedRectangle(cornerRadius: 12) RoundedRectangle(cornerRadius: 12)
.fill(isFailed ? SybilTheme.failedToolCallGradient : SybilTheme.toolCallGradient) .fill(backgroundGradient)
.overlay( .overlay(
RoundedRectangle(cornerRadius: 12) 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) .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
}
}
} }

View File

@@ -514,8 +514,8 @@ public struct CompletionStreamToolCall: Codable, Sendable {
public var summary: String public var summary: String
public var args: [String: JSONValue] public var args: [String: JSONValue]
public var startedAt: String public var startedAt: String
public var completedAt: String public var completedAt: String?
public var durationMs: Int public var durationMs: Int?
public var error: String? public var error: String?
public var resultPreview: String? public var resultPreview: String?
} }

View File

@@ -78,6 +78,7 @@ enum SybilTheme {
static let searchCard = Color(red: 0.07, green: 0.06, blue: 0.14) 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 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 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() { @MainActor static func applySystemAppearance() {
let navAppearance = UINavigationBarAppearance() 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 { static var failedToolCallGradient: LinearGradient {
LinearGradient( LinearGradient(
colors: [ colors: [

View File

@@ -1186,7 +1186,7 @@ final class SybilViewModel {
break break
case let .toolCall(payload): case let .toolCall(payload):
insertQuickQuestionToolCallMessage(payload) upsertQuickQuestionToolCallMessage(payload)
case let .delta(payload): case let .delta(payload):
guard !payload.text.isEmpty else { return } guard !payload.text.isEmpty else { return }
@@ -2006,7 +2006,7 @@ final class SybilViewModel {
} }
case let .toolCall(payload): case let .toolCall(payload):
insertPendingToolCallMessage(payload, chatID: chatID) upsertPendingToolCallMessage(payload, chatID: chatID)
case let .delta(payload): case let .delta(payload):
guard !payload.text.isEmpty else { return } guard !payload.text.isEmpty else { return }
@@ -2222,12 +2222,14 @@ final class SybilViewModel {
quickQuestionMessages[index].content = transform(quickQuestionMessages[index].content) 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 { guard var pending = pendingChatStates[chatID] else {
return 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 return
} }
@@ -2242,8 +2244,9 @@ final class SybilViewModel {
pendingChatStates[chatID] = pending pendingChatStates[chatID] = pending
} }
private func insertQuickQuestionToolCallMessage(_ payload: CompletionStreamToolCall) { private func upsertQuickQuestionToolCallMessage(_ payload: CompletionStreamToolCall) {
if quickQuestionMessages.contains(where: { $0.toolCallMetadata?.toolCallId == payload.toolCallId }) { 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 return
} }
@@ -2255,8 +2258,8 @@ final class SybilViewModel {
} }
} }
private func toolCallMessage(for payload: CompletionStreamToolCall) -> Message { private func toolCallMessage(for payload: CompletionStreamToolCall, id: String? = nil) -> Message {
let metadata: JSONValue = .object([ var metadataObject: [String: JSONValue] = [
"kind": .string("tool_call"), "kind": .string("tool_call"),
"toolCallId": .string(payload.toolCallId), "toolCallId": .string(payload.toolCallId),
"toolName": .string(payload.name), "toolName": .string(payload.name),
@@ -2264,19 +2267,26 @@ final class SybilViewModel {
"summary": .string(payload.summary), "summary": .string(payload.summary),
"args": .object(payload.args), "args": .object(payload.args),
"startedAt": .string(payload.startedAt), "startedAt": .string(payload.startedAt),
"completedAt": .string(payload.completedAt),
"durationMs": .number(Double(payload.durationMs)),
"error": payload.error.map { .string($0) } ?? .null, "error": payload.error.map { .string($0) } ?? .null,
"resultPreview": payload.resultPreview.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 let summary = payload.summary.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? "Ran tool '\(payload.name)'." ? "Ran tool '\(payload.name)'."
: payload.summary : payload.summary
return Message( return Message(
id: "temp-tool-\(payload.toolCallId)", id: id ?? "temp-tool-\(payload.toolCallId)",
createdAt: Date(), createdAt: toolCallDate(from: payload.completedAt) ?? toolCallDate(from: payload.startedAt) ?? Date(),
role: .tool, role: .tool,
content: summary, content: summary,
name: payload.name, 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? { private var currentChatID: String? {
if draftKind == .chat { if draftKind == .chat {
return nil return nil

View File

@@ -292,15 +292,17 @@ type ToolAwareCompletionParams = {
}; };
}; };
export type ToolExecutionStatus = "initiated" | "completed" | "failed";
export type ToolExecutionEvent = { export type ToolExecutionEvent = {
toolCallId: string; toolCallId: string;
name: string; name: string;
status: "completed" | "failed"; status: ToolExecutionStatus;
summary: string; summary: string;
args: Record<string, unknown>; args: Record<string, unknown>;
startedAt: string; startedAt: string;
completedAt: string; completedAt?: string;
durationMs: number; durationMs?: number;
error?: string; error?: string;
resultPreview?: string; resultPreview?: string;
}; };
@@ -328,10 +330,13 @@ function toSingleLine(value: string, maxLength = 220) {
); );
} }
function buildToolSummary(name: string, args: Record<string, unknown>, status: "completed" | "failed", error?: string) { function buildToolSummary(name: string, args: Record<string, unknown>, status: ToolExecutionStatus, error?: string) {
const errSuffix = status === "failed" && error ? ` Error: ${toSingleLine(error, 140)}` : ""; const errSuffix = status === "failed" && error ? ` Error: ${toSingleLine(error, 140)}` : "";
if (name === "web_search") { if (name === "web_search") {
const query = typeof args.query === "string" ? args.query.trim() : ""; 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") { if (status === "completed") {
return query ? `Performed web search for '${toSingleLine(query, 100)}'.` : "Performed web search."; return query ? `Performed web search for '${toSingleLine(query, 100)}'.` : "Performed web search.";
} }
@@ -340,6 +345,9 @@ function buildToolSummary(name: string, args: Record<string, unknown>, status: "
if (name === "fetch_url") { if (name === "fetch_url") {
const url = typeof args.url === "string" ? args.url.trim() : ""; const url = typeof args.url === "string" ? args.url.trim() : "";
if (status === "initiated") {
return url ? `Fetching URL ${toSingleLine(url, 140)}.` : "Fetching URL.";
}
if (status === "completed") { if (status === "completed") {
return url ? `Fetched URL ${toSingleLine(url, 140)}.` : "Fetched URL."; return url ? `Fetched URL ${toSingleLine(url, 140)}.` : "Fetched URL.";
} }
@@ -348,6 +356,9 @@ function buildToolSummary(name: string, args: Record<string, unknown>, status: "
if (name === "codex_exec") { if (name === "codex_exec") {
const prompt = typeof args.prompt === "string" ? args.prompt.trim() : ""; 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") { if (status === "completed") {
return prompt ? `Ran Codex task: '${toSingleLine(prompt, 120)}'.` : "Ran Codex task."; return prompt ? `Ran Codex task: '${toSingleLine(prompt, 120)}'.` : "Ran Codex task.";
} }
@@ -356,6 +367,9 @@ function buildToolSummary(name: string, args: Record<string, unknown>, status: "
if (name === "shell_exec") { if (name === "shell_exec") {
const command = typeof args.command === "string" ? args.command.trim() : ""; 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") { if (status === "completed") {
return command ? `Ran devbox shell command: '${toSingleLine(command, 120)}'.` : "Ran devbox shell command."; return command ? `Ran devbox shell command: '${toSingleLine(command, 120)}'.` : "Ran devbox shell command.";
} }
@@ -364,6 +378,9 @@ function buildToolSummary(name: string, args: Record<string, unknown>, status: "
: `Devbox shell command failed.${errSuffix}`; : `Devbox shell command failed.${errSuffix}`;
} }
if (status === "initiated") {
return `Running tool '${name}'.`;
}
if (status === "completed") { if (status === "completed") {
return `Ran tool '${name}'.`; return `Ran tool '${name}'.`;
} }
@@ -969,17 +986,55 @@ function normalizeModelToolCalls(toolCalls: any[], round: number): NormalizedToo
})); }));
} }
async function executeToolCallAndBuildEvent( type PreparedToolCallExecution = {
call: NormalizedToolCall, startedAtMs: number;
params: ToolAwareCompletionParams startedAt: string;
): Promise<{ event: ToolExecutionEvent; toolResult: ToolRunOutcome }> { parsedArgs: Record<string, unknown>;
eventArgs: Record<string, unknown>;
parseError?: unknown;
};
function prepareToolCallExecution(call: NormalizedToolCall): { event: ToolExecutionEvent; execution: PreparedToolCallExecution } {
const startedAtMs = Date.now(); const startedAtMs = Date.now();
const startedAt = new Date(startedAtMs).toISOString(); const startedAt = new Date(startedAtMs).toISOString();
let toolResult: ToolRunOutcome;
let parsedArgs: Record<string, unknown> = {}; let parsedArgs: Record<string, unknown> = {};
let parseError: unknown;
try { try {
parsedArgs = toRecord(parseToolArgs(call.arguments)); 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) { } catch (err: any) {
toolResult = { toolResult = {
ok: false, ok: false,
@@ -996,16 +1051,15 @@ async function executeToolCallAndBuildEvent(
: undefined; : undefined;
const completedAtMs = Date.now(); const completedAtMs = Date.now();
const eventArgs = buildEventArgs(call.name, parsedArgs);
const event: ToolExecutionEvent = { const event: ToolExecutionEvent = {
toolCallId: call.id, toolCallId: call.id,
name: call.name, name: call.name,
status, status,
summary: buildToolSummary(call.name, eventArgs, status, error), summary: buildToolSummary(call.name, execution.eventArgs, status, error),
args: eventArgs, args: execution.eventArgs,
startedAt, startedAt: execution.startedAt,
completedAt: new Date(completedAtMs).toISOString(), completedAt: new Date(completedAtMs).toISOString(),
durationMs: completedAtMs - startedAtMs, durationMs: completedAtMs - execution.startedAtMs,
error, error,
resultPreview: buildResultPreview(toolResult), resultPreview: buildResultPreview(toolResult),
}; };
@@ -1068,7 +1122,8 @@ export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams):
input.push(...outputItems); input.push(...outputItems);
for (const call of normalizedToolCalls) { 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); toolEvents.push(event);
input.push({ input.push({
@@ -1155,7 +1210,8 @@ export async function runToolAwareChatCompletions(params: ToolAwareCompletionPar
conversation.push(assistantToolCallMessage); conversation.push(assistantToolCallMessage);
for (const call of normalizedToolCalls) { 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); toolEvents.push(event);
conversation.push({ conversation.push({
@@ -1299,7 +1355,9 @@ export async function* runToolAwareOpenAIChatStream(
input.push(...responseOutputItems); input.push(...responseOutputItems);
for (const call of normalizedToolCalls) { 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); toolEvents.push(event);
yield { type: "tool_call", event }; yield { type: "tool_call", event };
input.push({ input.push({
@@ -1436,7 +1494,9 @@ export async function* runToolAwareChatCompletionsStream(
conversation.push(assistantToolCallMessage); conversation.push(assistantToolCallMessage);
for (const call of normalizedToolCalls) { 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); toolEvents.push(event);
yield { type: "tool_call", event }; yield { type: "tool_call", event };
conversation.push({ conversation.push({

View File

@@ -130,7 +130,7 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
} }
if (ev.type === "tool_call") { if (ev.type === "tool_call") {
if (shouldPersist && chatId) { if (ev.event.status !== "initiated" && shouldPersist && chatId) {
const toolMessage = buildToolLogMessageData(chatId, ev.event); const toolMessage = buildToolLogMessageData(chatId, ev.event);
await prisma.message.create({ await prisma.message.create({
data: { data: {

View File

@@ -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"); 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");
});

View File

@@ -32,7 +32,7 @@ type ToolLogMetadata = {
kind: "tool_call"; kind: "tool_call";
toolCallId?: string; toolCallId?: string;
toolName?: string; toolName?: string;
status?: "completed" | "failed"; status?: "initiated" | "completed" | "failed";
summary?: string; summary?: string;
args?: Record<string, unknown>; args?: Record<string, unknown>;
startedAt?: string; startedAt?: string;
@@ -171,13 +171,7 @@ function isToolCallLogMessage(message: Message) {
} }
function buildOptimisticToolMessage(event: ToolCallEvent): Message { function buildOptimisticToolMessage(event: ToolCallEvent): Message {
return { const metadata: ToolLogMetadata = {
id: `temp-tool-${event.toolCallId}`,
createdAt: event.completedAt ?? new Date().toISOString(),
role: "tool",
content: event.summary,
name: event.name,
metadata: {
kind: "tool_call", kind: "tool_call",
toolCallId: event.toolCallId, toolCallId: event.toolCallId,
toolName: event.name, toolName: event.name,
@@ -185,12 +179,37 @@ function buildOptimisticToolMessage(event: ToolCallEvent): Message {
summary: event.summary, summary: event.summary,
args: event.args, args: event.args,
startedAt: event.startedAt, startedAt: event.startedAt,
completedAt: event.completedAt,
durationMs: event.durationMs,
error: event.error ?? null, error: event.error ?? null,
resultPreview: event.resultPreview ?? null, resultPreview: event.resultPreview ?? null,
} satisfies ToolLogMetadata,
}; };
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 ?? event.startedAt ?? new Date().toISOString(),
role: "tool",
content: event.summary,
name: event.name,
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) { function getModelOptions(catalog: ModelCatalogResponse["providers"], provider: Provider) {
@@ -602,7 +621,12 @@ async function main() {
for (const message of messages) { for (const message of messages) {
const toolMeta = asToolLogMetadata(message.metadata); const toolMeta = asToolLogMetadata(message.metadata);
if (message.role === "tool" && toolMeta) { 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."; const summary = toolMeta.summary?.trim() || message.content.trim() || "Tool call executed.";
parts.push(`${prefix} ${escapeTags(summary)}`); parts.push(`${prefix} ${escapeTags(summary)}`);
continue; continue;
@@ -1083,29 +1107,7 @@ async function main() {
}, },
onToolCall: (payload) => { onToolCall: (payload) => {
if (!pendingChatState) return; if (!pendingChatState) return;
const alreadyPresent = pendingChatState.messages.some( pendingChatState = { ...pendingChatState, messages: upsertOptimisticToolMessage(pendingChatState.messages, payload) };
(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),
],
};
}
queueTranscriptScrollToBottomIfFollowing(); queueTranscriptScrollToBottomIfFollowing();
updateUI(); updateUI();

View File

@@ -55,12 +55,12 @@ export type Message = {
export type ToolCallEvent = { export type ToolCallEvent = {
toolCallId: string; toolCallId: string;
name: string; name: string;
status: "completed" | "failed"; status: "initiated" | "completed" | "failed";
summary: string; summary: string;
args: Record<string, unknown>; args: Record<string, unknown>;
startedAt: string; startedAt: string;
completedAt: string; completedAt?: string;
durationMs: number; durationMs?: number;
error?: string; error?: string;
resultPreview?: string; resultPreview?: string;
}; };

View File

@@ -435,7 +435,7 @@ type ToolLogMetadata = {
kind: "tool_call"; kind: "tool_call";
toolCallId?: string; toolCallId?: string;
toolName?: string; toolName?: string;
status?: "completed" | "failed"; status?: "initiated" | "completed" | "failed";
summary?: string; summary?: string;
args?: Record<string, unknown>; args?: Record<string, unknown>;
startedAt?: string; startedAt?: string;
@@ -461,13 +461,7 @@ function isDisplayableMessage(message: Message) {
} }
function buildOptimisticToolMessage(event: ToolCallEvent): Message { function buildOptimisticToolMessage(event: ToolCallEvent): Message {
return { const metadata: ToolLogMetadata = {
id: `temp-tool-${event.toolCallId}`,
createdAt: event.completedAt ?? new Date().toISOString(),
role: "tool",
content: event.summary,
name: event.name,
metadata: {
kind: "tool_call", kind: "tool_call",
toolCallId: event.toolCallId, toolCallId: event.toolCallId,
toolName: event.name, toolName: event.name,
@@ -475,12 +469,38 @@ function buildOptimisticToolMessage(event: ToolCallEvent): Message {
summary: event.summary, summary: event.summary,
args: event.args, args: event.args,
startedAt: event.startedAt, startedAt: event.startedAt,
completedAt: event.completedAt,
durationMs: event.durationMs,
error: event.error ?? null, error: event.error ?? null,
resultPreview: event.resultPreview ?? null, resultPreview: event.resultPreview ?? null,
} satisfies ToolLogMetadata,
}; };
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 ?? event.startedAt ?? new Date().toISOString(),
role: "tool",
content: event.summary,
name: event.name,
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 = { type ModelComboboxProps = {
@@ -2093,33 +2113,10 @@ export default function App() {
setPendingChatStates((current) => { setPendingChatStates((current) => {
const pendingState = current[chatId]; const pendingState = current[chatId];
if (!pendingState) return current; 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 { return {
...current, ...current,
[chatId]: { [chatId]: {
messages: [ messages: upsertOptimisticToolMessage(pendingState.messages, payload, "temp-assistant-"),
...pendingState.messages.slice(0, assistantIndex),
toolMessage,
...pendingState.messages.slice(assistantIndex),
],
}, },
}; };
}); });
@@ -2359,30 +2356,10 @@ export default function App() {
setPendingChatStates((current) => { setPendingChatStates((current) => {
const pendingState = current[chatId]; const pendingState = current[chatId];
if (!pendingState) return current; 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 { return {
...current, ...current,
[chatId]: { [chatId]: {
messages: [ messages: upsertOptimisticToolMessage(pendingState.messages, payload, "temp-assistant-"),
...pendingState.messages.slice(0, assistantIndex),
toolMessage,
...pendingState.messages.slice(assistantIndex),
],
}, },
}; };
}); });
@@ -2649,25 +2626,7 @@ export default function App() {
{ {
onToolCall: (payload) => { onToolCall: (payload) => {
setQuickQuestionMessages((current) => { setQuickQuestionMessages((current) => {
if ( return upsertOptimisticToolMessage(current, payload, "temp-assistant-quick-");
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),
];
}); });
}, },
onDelta: (payload) => { onDelta: (payload) => {

View File

@@ -14,7 +14,7 @@ type ToolLogMetadata = {
kind: "tool_call"; kind: "tool_call";
toolCallId?: string; toolCallId?: string;
toolName?: string; toolName?: string;
status?: "completed" | "failed"; status?: "initiated" | "completed" | "failed";
summary?: string; summary?: string;
args?: Record<string, unknown>; args?: Record<string, unknown>;
startedAt?: string; startedAt?: string;
@@ -71,9 +71,17 @@ function formatToolTimestamp(...values: Array<string | null | undefined>) {
return new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" }).format(new Date(value)); 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 [ return [
isFailed ? "Failed" : "Completed", state === "failed" ? "Failed" : state === "initiated" ? "Running" : "Completed",
formatDuration(metadata.durationMs), formatDuration(metadata.durationMs),
formatToolTimestamp(message.createdAt, metadata.completedAt, metadata.startedAt), formatToolTimestamp(message.createdAt, metadata.completedAt, metadata.startedAt),
] ]
@@ -93,10 +101,12 @@ export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
if (message.role === "tool" && toolLogMetadata) { if (message.role === "tool" && toolLogMetadata) {
const iconKind = getToolIconName(toolLogMetadata.toolName ?? message.name); const iconKind = getToolIconName(toolLogMetadata.toolName ?? message.name);
const Icon = iconKind === "search" ? Globe2 : iconKind === "fetch" ? Link2 : Wrench; 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 toolSummary = getToolSummary(message, toolLogMetadata);
const toolLabel = getToolLabel(message, toolLogMetadata); const toolLabel = getToolLabel(message, toolLogMetadata);
const toolDetailLabel = getToolDetailLabel(message, toolLogMetadata, isFailed); const toolDetailLabel = getToolDetailLabel(message, toolLogMetadata, toolState);
return ( return (
<div key={message.id} className="flex justify-start"> <div key={message.id} className="flex justify-start">
<div <div
@@ -104,6 +114,8 @@ export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
"inline-flex max-w-[85%] min-w-0 items-start gap-3 overflow-hidden rounded-xl border px-3 py-2.5 shadow-[inset_0_1px_0_hsl(180_100%_88%_/_0.06)]", "inline-flex max-w-[85%] min-w-0 items-start gap-3 overflow-hidden rounded-xl border px-3 py-2.5 shadow-[inset_0_1px_0_hsl(180_100%_88%_/_0.06)]",
isFailed isFailed
? "border-rose-400/34 bg-[linear-gradient(90deg,hsl(350_72%_44%_/_0.18),hsl(342_66%_9%_/_0.72))]" ? "border-rose-400/34 bg-[linear-gradient(90deg,hsl(350_72%_44%_/_0.18),hsl(342_66%_9%_/_0.72))]"
: isInitiated
? "border-amber-300/34 bg-[linear-gradient(90deg,hsl(43_74%_30%_/_0.34),hsl(260_48%_13%_/_0.74))]"
: "border-cyan-400/34 bg-[linear-gradient(90deg,hsl(184_89%_21%_/_0.70),hsl(208_66%_12%_/_0.78))]" : "border-cyan-400/34 bg-[linear-gradient(90deg,hsl(184_89%_21%_/_0.70),hsl(208_66%_12%_/_0.78))]"
)} )}
title={`${toolSummary}\n${toolLabel}${toolDetailLabel}`} title={`${toolSummary}\n${toolLabel}${toolDetailLabel}`}
@@ -111,7 +123,11 @@ export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
<span <span
className={cn( className={cn(
"mt-0.5 flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-lg border", "mt-0.5 flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-lg border",
isFailed ? "border-rose-400/34 bg-rose-400/13 text-rose-300" : "border-cyan-300/34 bg-cyan-300/13 text-cyan-300" isFailed
? "border-rose-400/34 bg-rose-400/13 text-rose-300"
: isInitiated
? "border-amber-300/34 bg-amber-300/13 text-amber-200"
: "border-cyan-300/34 bg-cyan-300/13 text-cyan-300"
)} )}
> >
<Icon className="h-4 w-4" /> <Icon className="h-4 w-4" />
@@ -121,7 +137,7 @@ export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
{toolSummary} {toolSummary}
</span> </span>
<span className="flex min-w-0 items-center gap-1.5 text-[11px] leading-4"> <span className="flex min-w-0 items-center gap-1.5 text-[11px] leading-4">
<span className={cn("min-w-0 truncate font-semibold", isFailed ? "text-rose-300/85" : "text-cyan-200/90")}> <span className={cn("min-w-0 truncate font-semibold", isFailed ? "text-rose-300/85" : isInitiated ? "text-amber-200/90" : "text-cyan-200/90")}>
{toolLabel} {toolLabel}
</span> </span>
<span className="min-w-0 truncate text-violet-200/64">{toolDetailLabel}</span> <span className="min-w-0 truncate text-violet-200/64">{toolDetailLabel}</span>

View File

@@ -45,12 +45,12 @@ export type Message = {
export type ToolCallEvent = { export type ToolCallEvent = {
toolCallId: string; toolCallId: string;
name: string; name: string;
status: "completed" | "failed"; status: "initiated" | "completed" | "failed";
summary: string; summary: string;
args: Record<string, unknown>; args: Record<string, unknown>;
startedAt: string; startedAt: string;
completedAt: string; completedAt?: string;
durationMs: number; durationMs?: number;
error?: string; error?: string;
resultPreview?: string; resultPreview?: string;
}; };