Show in-progress tool calls
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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?
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,28 +171,47 @@ function isToolCallLogMessage(message: Message) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildOptimisticToolMessage(event: ToolCallEvent): 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 {
|
return {
|
||||||
id: `temp-tool-${event.toolCallId}`,
|
id: `temp-tool-${event.toolCallId}`,
|
||||||
createdAt: event.completedAt ?? new Date().toISOString(),
|
createdAt: event.completedAt ?? event.startedAt ?? new Date().toISOString(),
|
||||||
role: "tool",
|
role: "tool",
|
||||||
content: event.summary,
|
content: event.summary,
|
||||||
name: event.name,
|
name: event.name,
|
||||||
metadata: {
|
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,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
const providerModels = catalog[provider]?.models ?? [];
|
const providerModels = catalog[provider]?.models ?? [];
|
||||||
if (providerModels.length) return providerModels;
|
if (providerModels.length) return providerModels;
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
117
web/src/App.tsx
117
web/src/App.tsx
@@ -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,28 +461,48 @@ function isDisplayableMessage(message: Message) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildOptimisticToolMessage(event: ToolCallEvent): 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 {
|
return {
|
||||||
id: `temp-tool-${event.toolCallId}`,
|
id: `temp-tool-${event.toolCallId}`,
|
||||||
createdAt: event.completedAt ?? new Date().toISOString(),
|
createdAt: event.completedAt ?? event.startedAt ?? new Date().toISOString(),
|
||||||
role: "tool",
|
role: "tool",
|
||||||
content: event.summary,
|
content: event.summary,
|
||||||
name: event.name,
|
name: event.name,
|
||||||
metadata: {
|
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,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 = {
|
||||||
options: string[];
|
options: string[];
|
||||||
value: string;
|
value: string;
|
||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user