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_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

View File

@@ -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.

View File

@@ -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
}
}
}

View File

@@ -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?
}

View File

@@ -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: [

View File

@@ -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

View File

@@ -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<string, unknown>;
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<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)}` : "";
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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>;
eventArgs: Record<string, unknown>;
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<string, unknown> = {};
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({

View File

@@ -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: {

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");
});
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";
toolCallId?: string;
toolName?: string;
status?: "completed" | "failed";
status?: "initiated" | "completed" | "failed";
summary?: string;
args?: Record<string, unknown>;
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();

View File

@@ -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<string, unknown>;
startedAt: string;
completedAt: string;
durationMs: number;
completedAt?: string;
durationMs?: number;
error?: string;
resultPreview?: string;
};

View File

@@ -435,7 +435,7 @@ type ToolLogMetadata = {
kind: "tool_call";
toolCallId?: string;
toolName?: string;
status?: "completed" | "failed";
status?: "initiated" | "completed" | "failed";
summary?: string;
args?: Record<string, unknown>;
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) => {

View File

@@ -14,7 +14,7 @@ type ToolLogMetadata = {
kind: "tool_call";
toolCallId?: string;
toolName?: string;
status?: "completed" | "failed";
status?: "initiated" | "completed" | "failed";
summary?: string;
args?: Record<string, unknown>;
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));
}
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 (
<div key={message.id} className="flex justify-start">
<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)]",
isFailed
? "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))]"
)}
title={`${toolSummary}\n${toolLabel}${toolDetailLabel}`}
@@ -111,7 +123,11 @@ export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
<span
className={cn(
"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" />
@@ -121,7 +137,7 @@ export function ChatMessagesPanel({ messages, isLoading, isSending }: Props) {
{toolSummary}
</span>
<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}
</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 = {
toolCallId: string;
name: string;
status: "completed" | "failed";
status: "initiated" | "completed" | "failed";
summary: string;
args: Record<string, unknown>;
startedAt: string;
completedAt: string;
durationMs: number;
completedAt?: string;
durationMs?: number;
error?: string;
resultPreview?: string;
};