feat(chat): Swift chat parity (abort/sessions/stream)

This commit is contained in:
Peter Steinberger
2025-12-17 15:51:31 +01:00
parent cc235fc312
commit 428a82e734
16 changed files with 1131 additions and 54 deletions

View File

@@ -9,6 +9,28 @@ struct IOSBridgeChatTransport: ClawdisChatTransport, Sendable {
self.bridge = bridge
}
func abortRun(sessionKey: String, runId: String) async throws {
struct Params: Codable {
var sessionKey: String
var runId: String
}
let data = try JSONEncoder().encode(Params(sessionKey: sessionKey, runId: runId))
let json = String(data: data, encoding: .utf8)
_ = try await self.bridge.request(method: "chat.abort", paramsJSON: json, timeoutSeconds: 10)
}
func listSessions(limit: Int?) async throws -> ClawdisChatSessionsListResponse {
struct Params: Codable {
var includeGlobal: Bool
var includeUnknown: Bool
var limit: Int?
}
let data = try JSONEncoder().encode(Params(includeGlobal: true, includeUnknown: false, limit: limit))
let json = String(data: data, encoding: .utf8)
let res = try await self.bridge.request(method: "sessions.list", paramsJSON: json, timeoutSeconds: 15)
return try JSONDecoder().decode(ClawdisChatSessionsListResponse.self, from: res)
}
func setActiveSessionKey(_ sessionKey: String) async throws {
struct Subscribe: Codable { var sessionKey: String }
let data = try JSONEncoder().encode(Subscribe(sessionKey: sessionKey))
@@ -79,6 +101,11 @@ struct IOSBridgeChatTransport: ClawdisChatTransport, Sendable {
if let payload = try? JSONDecoder().decode(ClawdisChatEventPayload.self, from: data) {
continuation.yield(.chat(payload))
}
case "agent":
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { break }
if let payload = try? JSONDecoder().decode(ClawdisAgentEventPayload.self, from: data) {
continuation.yield(.agent(payload))
}
default:
break
}

View File

@@ -18,6 +18,31 @@ struct MacGatewayChatTransport: ClawdisChatTransport, Sendable {
try await GatewayConnection.shared.chatHistory(sessionKey: sessionKey)
}
func abortRun(sessionKey: String, runId: String) async throws {
_ = try await GatewayConnection.shared.request(
method: "chat.abort",
params: [
"sessionKey": AnyCodable(sessionKey),
"runId": AnyCodable(runId),
],
timeoutMs: 10_000)
}
func listSessions(limit: Int?) async throws -> ClawdisChatSessionsListResponse {
var params: [String: AnyCodable] = [
"includeGlobal": AnyCodable(true),
"includeUnknown": AnyCodable(false),
]
if let limit {
params["limit"] = AnyCodable(limit)
}
let data = try await GatewayConnection.shared.request(
method: "sessions.list",
params: params,
timeoutMs: 15_000)
return try JSONDecoder().decode(ClawdisChatSessionsListResponse.self, from: data)
}
func sendMessage(
sessionKey: String,
message: String,
@@ -88,6 +113,15 @@ struct MacGatewayChatTransport: ClawdisChatTransport, Sendable {
return nil
}
return .chat(chat)
case "agent":
guard let payload = evt.payload else { return nil }
guard let agent = try? JSONDecoder().decode(
ClawdisAgentEventPayload.self,
from: JSONEncoder().encode(payload))
else {
return nil
}
return .agent(agent)
default:
return nil
}

View File

@@ -169,19 +169,38 @@ struct ClawdisChatComposer: View {
}
private var sendButton: some View {
Button {
self.viewModel.send()
} label: {
if self.viewModel.isSending {
ProgressView().controlSize(.small)
Group {
if self.viewModel.pendingRunCount > 0 {
Button {
self.viewModel.abort()
} label: {
if self.viewModel.isAborting {
ProgressView().controlSize(.small)
} else {
Image(systemName: "stop.fill")
.font(.system(size: 13, weight: .semibold))
}
}
.buttonStyle(.bordered)
.tint(.red)
.controlSize(.small)
.disabled(self.viewModel.isAborting)
} else {
Image(systemName: "arrow.up")
.font(.system(size: 13, weight: .semibold))
Button {
self.viewModel.send()
} label: {
if self.viewModel.isSending {
ProgressView().controlSize(.small)
} else {
Image(systemName: "arrow.up")
.font(.system(size: 13, weight: .semibold))
}
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
.disabled(!self.viewModel.canSend)
}
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
.disabled(!self.viewModel.canSend)
}
#if os(macOS)

View File

@@ -158,6 +158,63 @@ struct ChatTypingIndicatorBubble: View {
}
}
@MainActor
struct ChatStreamingAssistantBubble: View {
let text: String
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Label("Assistant (streaming)", systemImage: "sparkles")
.font(.caption)
.foregroundStyle(.secondary)
ChatMarkdownBody(text: self.text)
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(ClawdisChatTheme.subtleCard))
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.strokeBorder(Color.white.opacity(0.10), lineWidth: 1))
.frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: .leading)
}
}
@MainActor
struct ChatPendingToolsBubble: View {
let toolCalls: [ClawdisChatPendingToolCall]
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Label("Running tools…", systemImage: "hammer")
.font(.caption)
.foregroundStyle(.secondary)
ForEach(self.toolCalls) { call in
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(call.name)
.font(.footnote.monospaced())
.lineLimit(1)
Spacer(minLength: 0)
ProgressView().controlSize(.mini)
}
.padding(10)
.background(Color.white.opacity(0.06))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(ClawdisChatTheme.subtleCard))
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.strokeBorder(Color.white.opacity(0.10), lineWidth: 1))
.frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: .leading)
}
}
@MainActor
private struct TypingDots: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@@ -202,6 +259,48 @@ private struct MarkdownTextView: View {
}
}
@MainActor
private struct ChatMarkdownBody: View {
let text: String
var body: some View {
let split = ChatMarkdownSplitter.split(markdown: self.text)
VStack(alignment: .leading, spacing: 10) {
ForEach(split.blocks) { block in
switch block.kind {
case .text:
MarkdownTextView(text: block.text)
case let .code(language):
CodeBlockView(code: block.text, language: language)
}
}
if !split.images.isEmpty {
ForEach(
split.images,
id: \ChatMarkdownSplitter.InlineImage.id)
{ (item: ChatMarkdownSplitter.InlineImage) in
if let img = item.image {
ClawdisPlatformImageFactory.image(img)
.resizable()
.scaledToFit()
.frame(maxHeight: 260)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1))
} else {
Text(item.label.isEmpty ? "Image" : item.label)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
}
}
.textSelection(.enabled)
}
}
@MainActor
private struct CodeBlockView: View {
let code: String

View File

@@ -1,6 +1,8 @@
import ClawdisKit
import Foundation
// NOTE: keep this file lightweight; decode must be resilient to varying transcript formats.
#if canImport(AppKit)
import AppKit
@@ -11,25 +13,125 @@ import UIKit
public typealias ClawdisPlatformImage = UIImage
#endif
public struct ClawdisChatUsageCost: Codable, Hashable, Sendable {
public let input: Double?
public let output: Double?
public let cacheRead: Double?
public let cacheWrite: Double?
public let total: Double?
}
public struct ClawdisChatUsage: Codable, Hashable, Sendable {
public let input: Int?
public let output: Int?
public let cacheRead: Int?
public let cacheWrite: Int?
public let cost: ClawdisChatUsageCost?
public let total: Int?
enum CodingKeys: String, CodingKey {
case input
case output
case cacheRead
case cacheWrite
case cost
case total
case totalTokens
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.input = try container.decodeIfPresent(Int.self, forKey: .input)
self.output = try container.decodeIfPresent(Int.self, forKey: .output)
self.cacheRead = try container.decodeIfPresent(Int.self, forKey: .cacheRead)
self.cacheWrite = try container.decodeIfPresent(Int.self, forKey: .cacheWrite)
self.cost = try container.decodeIfPresent(ClawdisChatUsageCost.self, forKey: .cost)
self.total =
try container.decodeIfPresent(Int.self, forKey: .total) ??
container.decodeIfPresent(Int.self, forKey: .totalTokens)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(self.input, forKey: .input)
try container.encodeIfPresent(self.output, forKey: .output)
try container.encodeIfPresent(self.cacheRead, forKey: .cacheRead)
try container.encodeIfPresent(self.cacheWrite, forKey: .cacheWrite)
try container.encodeIfPresent(self.cost, forKey: .cost)
try container.encodeIfPresent(self.total, forKey: .total)
}
}
public struct ClawdisChatMessageContent: Codable, Hashable, Sendable {
public let type: String?
public let text: String?
public let thinking: String?
public let thinkingSignature: String?
public let mimeType: String?
public let fileName: String?
public let content: String?
public let content: AnyCodable?
// Tool-call fields (when `type == "toolCall"` or similar)
public let id: String?
public let name: String?
public let arguments: AnyCodable?
public init(
type: String?,
text: String?,
thinking: String? = nil,
thinkingSignature: String? = nil,
mimeType: String?,
fileName: String?,
content: String?)
content: AnyCodable?,
id: String? = nil,
name: String? = nil,
arguments: AnyCodable? = nil)
{
self.type = type
self.text = text
self.thinking = thinking
self.thinkingSignature = thinkingSignature
self.mimeType = mimeType
self.fileName = fileName
self.content = content
self.id = id
self.name = name
self.arguments = arguments
}
enum CodingKeys: String, CodingKey {
case type
case text
case thinking
case thinkingSignature
case mimeType
case fileName
case content
case id
case name
case arguments
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.type = try container.decodeIfPresent(String.self, forKey: .type)
self.text = try container.decodeIfPresent(String.self, forKey: .text)
self.thinking = try container.decodeIfPresent(String.self, forKey: .thinking)
self.thinkingSignature = try container.decodeIfPresent(String.self, forKey: .thinkingSignature)
self.mimeType = try container.decodeIfPresent(String.self, forKey: .mimeType)
self.fileName = try container.decodeIfPresent(String.self, forKey: .fileName)
self.id = try container.decodeIfPresent(String.self, forKey: .id)
self.name = try container.decodeIfPresent(String.self, forKey: .name)
self.arguments = try container.decodeIfPresent(AnyCodable.self, forKey: .arguments)
if let any = try container.decodeIfPresent(AnyCodable.self, forKey: .content) {
self.content = any
} else if let str = try container.decodeIfPresent(String.self, forKey: .content) {
self.content = AnyCodable(str)
} else {
self.content = nil
}
}
}
@@ -38,27 +140,47 @@ public struct ClawdisChatMessage: Codable, Identifiable, Sendable {
public let role: String
public let content: [ClawdisChatMessageContent]
public let timestamp: Double?
public let toolCallId: String?
public let usage: ClawdisChatUsage?
public let stopReason: String?
enum CodingKeys: String, CodingKey {
case role, content, timestamp
case role
case content
case timestamp
case toolCallId
case tool_call_id
case usage
case stopReason
}
public init(
id: UUID = .init(),
role: String,
content: [ClawdisChatMessageContent],
timestamp: Double?)
timestamp: Double?,
toolCallId: String? = nil,
usage: ClawdisChatUsage? = nil,
stopReason: String? = nil)
{
self.id = id
self.role = role
self.content = content
self.timestamp = timestamp
self.toolCallId = toolCallId
self.usage = usage
self.stopReason = stopReason
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.role = try container.decode(String.self, forKey: .role)
self.timestamp = try container.decodeIfPresent(Double.self, forKey: .timestamp)
self.toolCallId =
try container.decodeIfPresent(String.self, forKey: .toolCallId) ??
container.decodeIfPresent(String.self, forKey: .tool_call_id)
self.usage = try container.decodeIfPresent(ClawdisChatUsage.self, forKey: .usage)
self.stopReason = try container.decodeIfPresent(String.self, forKey: .stopReason)
if let decoded = try? container.decode([ClawdisChatMessageContent].self, forKey: .content) {
self.content = decoded
@@ -71,15 +193,30 @@ public struct ClawdisChatMessage: Codable, Identifiable, Sendable {
ClawdisChatMessageContent(
type: "text",
text: text,
thinking: nil,
thinkingSignature: nil,
mimeType: nil,
fileName: nil,
content: nil),
content: nil,
id: nil,
name: nil,
arguments: nil),
]
return
}
self.content = []
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.role, forKey: .role)
try container.encodeIfPresent(self.timestamp, forKey: .timestamp)
try container.encodeIfPresent(self.toolCallId, forKey: .toolCallId)
try container.encodeIfPresent(self.usage, forKey: .usage)
try container.encodeIfPresent(self.stopReason, forKey: .stopReason)
try container.encode(self.content, forKey: .content)
}
}
public struct ClawdisChatHistoryPayload: Codable, Sendable {
@@ -102,6 +239,24 @@ public struct ClawdisChatEventPayload: Codable, Sendable {
public let errorMessage: String?
}
public struct ClawdisAgentEventPayload: Codable, Sendable, Identifiable {
public var id: String { "\(self.runId)-\(self.seq ?? -1)" }
public let runId: String
public let seq: Int?
public let stream: String
public let ts: Int?
public let data: [String: AnyCodable]
}
public struct ClawdisChatPendingToolCall: Identifiable, Hashable, Sendable {
public var id: String { self.toolCallId }
public let toolCallId: String
public let name: String
public let args: AnyCodable?
public let startedAt: Double?
public let isError: Bool?
}
public struct ClawdisGatewayHealthOK: Codable, Sendable {
public let ok: Bool?
}

View File

@@ -0,0 +1,69 @@
import Foundation
public struct ClawdisChatSessionsDefaults: Codable, Sendable {
public let model: String?
public let contextTokens: Int?
}
public enum ClawdisChatSessionSyncing: Codable, Hashable, Sendable {
case bool(Bool)
case string(String)
public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let b = try? container.decode(Bool.self) {
self = .bool(b)
return
}
if let s = try? container.decode(String.self) {
self = .string(s)
return
}
throw DecodingError.typeMismatch(
ClawdisChatSessionSyncing.self,
DecodingError.Context(
codingPath: decoder.codingPath,
debugDescription: "Expected Bool or String"))
}
public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case let .bool(b):
try container.encode(b)
case let .string(s):
try container.encode(s)
}
}
}
public struct ClawdisChatSessionEntry: Codable, Identifiable, Sendable, Hashable {
public var id: String { self.key }
public let key: String
public let kind: String?
public let updatedAt: Double?
public let sessionId: String?
public let systemSent: Bool?
public let abortedLastRun: Bool?
public let thinkingLevel: String?
public let verboseLevel: String?
public let inputTokens: Int?
public let outputTokens: Int?
public let totalTokens: Int?
public let model: String?
public let contextTokens: Int?
public let syncing: ClawdisChatSessionSyncing?
}
public struct ClawdisChatSessionsListResponse: Codable, Sendable {
public let ts: Double?
public let path: String?
public let count: Int?
public let defaults: ClawdisChatSessionsDefaults?
public let sessions: [ClawdisChatSessionEntry]
}

View File

@@ -0,0 +1,67 @@
import Observation
import SwiftUI
@MainActor
struct ChatSessionsSheet: View {
@Bindable var viewModel: ClawdisChatViewModel
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
List(self.viewModel.sessions) { session in
Button {
self.viewModel.switchSession(to: session.key)
self.dismiss()
} label: {
VStack(alignment: .leading, spacing: 4) {
Text(session.key)
.font(.system(.body, design: .monospaced))
.lineLimit(1)
if let updatedAt = session.updatedAt, updatedAt > 0 {
Text(Date(timeIntervalSince1970: updatedAt / 1000).formatted(date: .abbreviated, time: .shortened))
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
.navigationTitle("Sessions")
.toolbar {
#if os(macOS)
ToolbarItem(placement: .automatic) {
Button {
self.viewModel.refreshSessions(limit: 200)
} label: {
Image(systemName: "arrow.clockwise")
}
}
ToolbarItem(placement: .primaryAction) {
Button {
self.dismiss()
} label: {
Image(systemName: "xmark")
}
}
#else
ToolbarItem(placement: .topBarLeading) {
Button {
self.viewModel.refreshSessions(limit: 200)
} label: {
Image(systemName: "arrow.clockwise")
}
}
ToolbarItem(placement: .topBarTrailing) {
Button {
self.dismiss()
} label: {
Image(systemName: "xmark")
}
}
#endif
}
.onAppear {
self.viewModel.refreshSessions(limit: 200)
}
}
}
}

View File

@@ -4,6 +4,7 @@ public enum ClawdisChatTransportEvent: Sendable {
case health(ok: Bool)
case tick
case chat(ClawdisChatEventPayload)
case agent(ClawdisAgentEventPayload)
case seqGap
}
@@ -16,6 +17,9 @@ public protocol ClawdisChatTransport: Sendable {
idempotencyKey: String,
attachments: [ClawdisChatAttachmentPayload]) async throws -> ClawdisChatSendResponse
func abortRun(sessionKey: String, runId: String) async throws
func listSessions(limit: Int?) async throws -> ClawdisChatSessionsListResponse
func requestHealth(timeoutMs: Int) async throws -> Bool
func events() -> AsyncStream<ClawdisChatTransportEvent>
@@ -24,4 +28,18 @@ public protocol ClawdisChatTransport: Sendable {
extension ClawdisChatTransport {
public func setActiveSessionKey(_: String) async throws {}
public func abortRun(sessionKey _: String, runId _: String) async throws {
throw NSError(
domain: "ClawdisChatTransport",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "chat.abort not supported by this transport"])
}
public func listSessions(limit _: Int?) async throws -> ClawdisChatSessionsListResponse {
throw NSError(
domain: "ClawdisChatTransport",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "sessions.list not supported by this transport"])
}
}

View File

@@ -4,6 +4,7 @@ import SwiftUI
public struct ClawdisChatView: View {
@State private var viewModel: ClawdisChatViewModel
@State private var scrollerBottomID = UUID()
@State private var showSessions = false
public init(viewModel: ClawdisChatViewModel) {
self._viewModel = State(initialValue: viewModel)
@@ -24,6 +25,9 @@ public struct ClawdisChatView: View {
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.onAppear { self.viewModel.load() }
.sheet(isPresented: self.$showSessions) {
ChatSessionsSheet(viewModel: self.viewModel)
}
}
private var messageList: some View {
@@ -42,6 +46,16 @@ public struct ClawdisChatView: View {
.frame(maxWidth: .infinity, alignment: .leading)
}
if !self.viewModel.pendingToolCalls.isEmpty {
ChatPendingToolsBubble(toolCalls: self.viewModel.pendingToolCalls)
.frame(maxWidth: .infinity, alignment: .leading)
}
if let text = self.viewModel.streamingAssistantText, !text.isEmpty {
ChatStreamingAssistantBubble(text: text)
.frame(maxWidth: .infinity, alignment: .leading)
}
Color.clear
.frame(height: 1)
.id(self.scrollerBottomID)
@@ -64,6 +78,23 @@ public struct ClawdisChatView: View {
Text(self.viewModel.healthOK ? "Connected" : "Connecting…")
.font(.caption)
.foregroundStyle(.secondary)
Spacer(minLength: 0)
Button {
self.showSessions = true
} label: {
Image(systemName: "tray.full")
}
.buttonStyle(.borderless)
.help("Sessions")
Button {
self.viewModel.refresh()
} label: {
Image(systemName: "arrow.clockwise")
}
.buttonStyle(.borderless)
.help("Refresh")
}
.padding(.horizontal, 10)
.padding(.vertical, 6)

View File

@@ -20,12 +20,17 @@ public final class ClawdisChatViewModel {
public var thinkingLevel: String = "off"
public private(set) var isLoading = false
public private(set) var isSending = false
public private(set) var isAborting = false
public var errorText: String?
public var attachments: [ClawdisPendingAttachment] = []
public private(set) var healthOK: Bool = false
public private(set) var pendingRunCount: Int = 0
public let sessionKey: String
public private(set) var sessionKey: String
public private(set) var sessionId: String?
public private(set) var streamingAssistantText: String?
public private(set) var pendingToolCalls: [ClawdisChatPendingToolCall] = []
public private(set) var sessions: [ClawdisChatSessionEntry] = []
private let transport: any ClawdisChatTransport
@ObservationIgnored
@@ -38,6 +43,12 @@ public final class ClawdisChatViewModel {
private nonisolated(unsafe) var pendingRunTimeoutTasks: [String: Task<Void, Never>] = [:]
private let pendingRunTimeoutMs: UInt64 = 120_000
private var pendingToolCallsById: [String: ClawdisChatPendingToolCall] = [:] {
didSet {
self.pendingToolCalls = self.pendingToolCallsById.values.sorted { ($0.startedAt ?? 0) < ($1.startedAt ?? 0) }
}
}
private var lastHealthPollAt: Date?
public init(sessionKey: String, transport: any ClawdisChatTransport) {
@@ -75,6 +86,18 @@ public final class ClawdisChatViewModel {
Task { await self.performSend() }
}
public func abort() {
Task { await self.performAbort() }
}
public func refreshSessions(limit: Int? = nil) {
Task { await self.fetchSessions(limit: limit) }
}
public func switchSession(to sessionKey: String) {
Task { await self.performSwitchSession(to: sessionKey) }
}
public func addAttachments(urls: [URL]) {
Task { await self.loadAttachments(urls: urls) }
}
@@ -89,7 +112,7 @@ public final class ClawdisChatViewModel {
public var canSend: Bool {
let trimmed = self.input.trimmingCharacters(in: .whitespacesAndNewlines)
return !self.isSending && (!trimmed.isEmpty || !self.attachments.isEmpty)
return !self.isSending && self.pendingRunCount == 0 && (!trimmed.isEmpty || !self.attachments.isEmpty)
}
// MARK: - Internals
@@ -99,6 +122,9 @@ public final class ClawdisChatViewModel {
self.errorText = nil
self.healthOK = false
self.clearPendingRuns(reason: nil)
self.pendingToolCallsById = [:]
self.streamingAssistantText = nil
self.sessionId = nil
defer { self.isLoading = false }
do {
do {
@@ -109,10 +135,12 @@ public final class ClawdisChatViewModel {
let payload = try await self.transport.requestHistory(sessionKey: self.sessionKey)
self.messages = Self.decodeMessages(payload.messages ?? [])
self.sessionId = payload.sessionId
if let level = payload.thinkingLevel, !level.isEmpty {
self.thinkingLevel = level
}
await self.pollHealthIfNeeded(force: true)
await self.fetchSessions(limit: 50)
self.errorText = nil
} catch {
self.errorText = error.localizedDescription
@@ -140,15 +168,24 @@ public final class ClawdisChatViewModel {
self.errorText = nil
let runId = UUID().uuidString
let messageText = trimmed.isEmpty && !self.attachments.isEmpty ? "See attached." : trimmed
self.pendingRuns.insert(runId)
self.armPendingRunTimeout(runId: runId)
self.pendingToolCallsById = [:]
self.streamingAssistantText = nil
// Optimistically append user message to UI.
var userContent: [ClawdisChatMessageContent] = [
ClawdisChatMessageContent(
type: "text",
text: messageText,
thinking: nil,
thinkingSignature: nil,
mimeType: nil,
fileName: nil,
content: nil),
content: nil,
id: nil,
name: nil,
arguments: nil),
]
let encodedAttachments = self.attachments.map { att -> ClawdisChatAttachmentPayload in
ClawdisChatAttachmentPayload(
@@ -162,9 +199,14 @@ public final class ClawdisChatViewModel {
ClawdisChatMessageContent(
type: att.type,
text: nil,
thinking: nil,
thinkingSignature: nil,
mimeType: att.mimeType,
fileName: att.fileName,
content: att.content))
content: AnyCodable(att.content),
id: nil,
name: nil,
arguments: nil))
}
self.messages.append(
ClawdisChatMessage(
@@ -180,9 +222,13 @@ public final class ClawdisChatViewModel {
thinking: self.thinkingLevel,
idempotencyKey: runId,
attachments: encodedAttachments)
self.pendingRuns.insert(response.runId)
self.armPendingRunTimeout(runId: response.runId)
if response.runId != runId {
self.clearPendingRun(runId)
self.pendingRuns.insert(response.runId)
self.armPendingRunTimeout(runId: response.runId)
}
} catch {
self.clearPendingRun(runId)
self.errorText = error.localizedDescription
chatUILogger.error("chat.send failed \(error.localizedDescription, privacy: .public)")
}
@@ -192,6 +238,39 @@ public final class ClawdisChatViewModel {
self.isSending = false
}
private func performAbort() async {
guard !self.pendingRuns.isEmpty else { return }
guard !self.isAborting else { return }
self.isAborting = true
defer { self.isAborting = false }
let runIds = Array(self.pendingRuns)
for runId in runIds {
do {
try await self.transport.abortRun(sessionKey: self.sessionKey, runId: runId)
} catch {
// Best-effort.
}
}
}
private func fetchSessions(limit: Int?) async {
do {
let res = try await self.transport.listSessions(limit: limit)
self.sessions = res.sessions
} catch {
// Best-effort.
}
}
private func performSwitchSession(to sessionKey: String) async {
let next = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !next.isEmpty else { return }
guard next != self.sessionKey else { return }
self.sessionKey = next
await self.bootstrap()
}
private func handleTransportEvent(_ evt: ClawdisChatTransportEvent) {
switch evt {
case let .health(ok):
@@ -200,6 +279,8 @@ public final class ClawdisChatViewModel {
Task { await self.pollHealthIfNeeded(force: false) }
case let .chat(chat):
self.handleChatEvent(chat)
case let .agent(agent):
self.handleAgentEvent(agent)
case .seqGap:
self.errorText = "Event stream interrupted; try refreshing."
self.clearPendingRuns(reason: nil)
@@ -217,29 +298,66 @@ public final class ClawdisChatViewModel {
}
switch chat.state {
case "final":
if let raw = chat.message,
let msg = try? ChatPayloadDecoding.decode(raw, as: ClawdisChatMessage.self)
{
self.messages.append(msg)
case "final", "aborted", "error":
if chat.state == "error" {
self.errorText = chat.errorMessage ?? "Chat failed"
}
if let runId = chat.runId {
self.clearPendingRun(runId)
} else if self.pendingRuns.count <= 1 {
self.clearPendingRuns(reason: nil)
}
case "error":
self.errorText = chat.errorMessage ?? "Chat failed"
if let runId = chat.runId {
self.clearPendingRun(runId)
} else if self.pendingRuns.count <= 1 {
self.clearPendingRuns(reason: nil)
self.pendingToolCallsById = [:]
self.streamingAssistantText = nil
Task { await self.refreshHistoryAfterRun() }
default:
break
}
}
private func handleAgentEvent(_ evt: ClawdisAgentEventPayload) {
if let sessionId, evt.runId != sessionId {
return
}
switch evt.stream {
case "assistant":
if let text = evt.data["text"]?.value as? String {
self.streamingAssistantText = text
}
case "tool":
guard let phase = evt.data["phase"]?.value as? String else { return }
guard let name = evt.data["name"]?.value as? String else { return }
guard let toolCallId = evt.data["toolCallId"]?.value as? String else { return }
if phase == "start" {
let args = evt.data["args"]
self.pendingToolCallsById[toolCallId] = ClawdisChatPendingToolCall(
toolCallId: toolCallId,
name: name,
args: args,
startedAt: evt.ts.map(Double.init) ?? Date().timeIntervalSince1970 * 1000,
isError: nil)
} else if phase == "result" {
self.pendingToolCallsById[toolCallId] = nil
}
default:
break
}
}
private func refreshHistoryAfterRun() async {
do {
let payload = try await self.transport.requestHistory(sessionKey: self.sessionKey)
self.messages = Self.decodeMessages(payload.messages ?? [])
self.sessionId = payload.sessionId
if let level = payload.thinkingLevel, !level.isEmpty {
self.thinkingLevel = level
}
} catch {
chatUILogger.error("refresh history failed \(error.localizedDescription, privacy: .public)")
}
}
private func armPendingRunTimeout(runId: String) {
self.pendingRunTimeoutTasks[runId]?.cancel()
self.pendingRunTimeoutTasks[runId] = Task { [weak self] in

View File

@@ -141,6 +141,7 @@ export async function runEmbeddedPiAgent(params: {
verboseLevel?: VerboseLevel;
timeoutMs: number;
runId: string;
abortSignal?: AbortSignal;
onPartialReply?: (payload: {
text?: string;
mediaUrls?: string[];
@@ -246,7 +247,7 @@ export async function runEmbeddedPiAgent(params: {
const toolMetaById = new Map<string, string | undefined>();
let deltaBuffer = "";
let lastStreamedAssistant: string | undefined;
let aborted = false;
let aborted = Boolean(params.abortSignal?.aborted);
const unsubscribe = session.subscribe(
(evt: AgentEvent | { type: string; [k: string]: unknown }) => {
@@ -342,18 +343,31 @@ export async function runEmbeddedPiAgent(params: {
if (chunk) {
deltaBuffer += chunk;
const next = deltaBuffer.trim();
if (
next &&
next !== lastStreamedAssistant &&
params.onPartialReply
) {
if (next && next !== lastStreamedAssistant) {
lastStreamedAssistant = next;
const { text: cleanedText, mediaUrls } =
splitMediaFromOutput(next);
void params.onPartialReply({
text: cleanedText,
mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
emitAgentEvent({
runId: params.runId,
stream: "assistant",
data: {
text: cleanedText,
mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
},
});
params.onAgentEvent?.({
stream: "assistant",
data: {
text: cleanedText,
mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
},
});
if (params.onPartialReply) {
void params.onPartialReply({
text: cleanedText,
mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
});
}
}
}
}
@@ -385,15 +399,36 @@ export async function runEmbeddedPiAgent(params: {
let messagesSnapshot: AppMessage[] = [];
let sessionIdUsed = session.sessionId;
const onAbort = () => {
aborted = true;
void session.abort();
};
if (params.abortSignal) {
if (params.abortSignal.aborted) {
onAbort();
} else {
params.abortSignal.addEventListener("abort", onAbort, { once: true });
}
}
let promptError: unknown | null = null;
try {
await session.prompt(params.prompt);
messagesSnapshot = session.messages.slice();
sessionIdUsed = session.sessionId;
try {
await session.prompt(params.prompt);
} catch (err) {
promptError = err;
} finally {
messagesSnapshot = session.messages.slice();
sessionIdUsed = session.sessionId;
}
} finally {
clearTimeout(abortTimer);
unsubscribe();
toolDebouncer.flush();
session.dispose();
params.abortSignal?.removeEventListener?.("abort", onAbort);
}
if (promptError && !aborted) {
throw promptError;
}
const lastAssistant = messagesSnapshot

View File

@@ -46,6 +46,7 @@ type AgentCommandOpts = {
surface?: string;
provider?: string; // delivery provider (whatsapp|telegram|...)
bestEffortDeliver?: boolean;
abortSignal?: AbortSignal;
};
type SessionResolution = {
@@ -251,6 +252,7 @@ export async function agentCommand(
verboseLevel: resolvedVerboseLevel,
timeoutMs,
runId: sessionId,
abortSignal: opts.abortSignal,
onAgentEvent: (evt) => {
emitAgentEvent({
runId: sessionId,
@@ -269,6 +271,7 @@ export async function agentCommand(
to: opts.to ?? null,
sessionId,
durationMs: Date.now() - startedAt,
aborted: result.meta.aborted ?? false,
},
});
} catch (err) {
@@ -308,6 +311,7 @@ export async function agentCommand(
model: modelUsed,
contextTokens,
};
next.abortedLastRun = result.meta.aborted ?? false;
if (usage) {
const input = usage.input ?? 0;
const output = usage.output ?? 0;

View File

@@ -3,6 +3,8 @@ import {
type AgentEvent,
AgentEventSchema,
AgentParamsSchema,
type ChatAbortParams,
ChatAbortParamsSchema,
type ChatEvent,
ChatEventSchema,
ChatHistoryParamsSchema,
@@ -137,6 +139,9 @@ export const validateCronRunsParams =
ajv.compile<CronRunsParams>(CronRunsParamsSchema);
export const validateChatHistoryParams = ajv.compile(ChatHistoryParamsSchema);
export const validateChatSendParams = ajv.compile(ChatSendParamsSchema);
export const validateChatAbortParams = ajv.compile<ChatAbortParams>(
ChatAbortParamsSchema,
);
export const validateChatEvent = ajv.compile(ChatEventSchema);
export function formatValidationErrors(

View File

@@ -480,6 +480,14 @@ export const ChatSendParamsSchema = Type.Object(
{ additionalProperties: false },
);
export const ChatAbortParamsSchema = Type.Object(
{
sessionKey: NonEmptyString,
runId: NonEmptyString,
},
{ additionalProperties: false },
);
export const ChatEventSchema = Type.Object(
{
runId: NonEmptyString,
@@ -488,6 +496,7 @@ export const ChatEventSchema = Type.Object(
state: Type.Union([
Type.Literal("delta"),
Type.Literal("final"),
Type.Literal("aborted"),
Type.Literal("error"),
]),
message: Type.Optional(Type.Unknown()),
@@ -533,6 +542,7 @@ export const ProtocolSchemas: Record<string, TSchema> = {
CronRunLogEntry: CronRunLogEntrySchema,
ChatHistoryParams: ChatHistoryParamsSchema,
ChatSendParams: ChatSendParamsSchema,
ChatAbortParams: ChatAbortParamsSchema,
ChatEvent: ChatEventSchema,
TickEvent: TickEventSchema,
ShutdownEvent: ShutdownEventSchema,
@@ -570,6 +580,7 @@ export type CronRemoveParams = Static<typeof CronRemoveParamsSchema>;
export type CronRunParams = Static<typeof CronRunParamsSchema>;
export type CronRunsParams = Static<typeof CronRunsParamsSchema>;
export type CronRunLogEntry = Static<typeof CronRunLogEntrySchema>;
export type ChatAbortParams = Static<typeof ChatAbortParamsSchema>;
export type ChatEvent = Static<typeof ChatEventSchema>;
export type TickEvent = Static<typeof TickEventSchema>;
export type ShutdownEvent = Static<typeof ShutdownEventSchema>;

View File

@@ -1970,6 +1970,87 @@ describe("gateway server", () => {
await server.close();
});
test("chat.abort cancels an in-flight chat.send", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-"));
testSessionStorePath = path.join(dir, "sessions.json");
await fs.writeFile(
testSessionStorePath,
JSON.stringify(
{
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const spy = vi.mocked(agentCommand);
spy.mockImplementationOnce(async (opts) => {
const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
await new Promise<void>((resolve) => {
if (!signal) return resolve();
if (signal.aborted) return resolve();
signal.addEventListener("abort", () => resolve(), { once: true });
});
});
const abortedEventP = onceMessage(
ws,
(o) => o.type === "event" && o.event === "chat" && o.payload?.state === "aborted",
);
ws.send(
JSON.stringify({
type: "req",
id: "send-abort-1",
method: "chat.send",
params: {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-abort-1",
timeoutMs: 30_000,
},
}),
);
await new Promise((r) => setTimeout(r, 10));
ws.send(
JSON.stringify({
type: "req",
id: "abort-1",
method: "chat.abort",
params: { sessionKey: "main", runId: "idem-abort-1" },
}),
);
const abortRes = await onceMessage(
ws,
(o) => o.type === "res" && o.id === "abort-1",
);
expect(abortRes.ok).toBe(true);
const sendRes = await onceMessage(
ws,
(o) => o.type === "res" && o.id === "send-abort-1",
);
expect(sendRes.ok).toBe(true);
const evt = await abortedEventP;
expect(evt.payload?.runId).toBe("idem-abort-1");
expect(evt.payload?.sessionKey).toBe("main");
ws.close();
await server.close();
});
test("bridge RPC chat.history returns session messages", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-"));
testSessionStorePath = path.join(dir, "sessions.json");
@@ -2029,6 +2110,54 @@ describe("gateway server", () => {
await server.close();
});
test("bridge RPC sessions.list returns session rows", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-"));
testSessionStorePath = path.join(dir, "sessions.json");
await fs.writeFile(
testSessionStorePath,
JSON.stringify(
{
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
null,
2,
),
"utf-8",
);
const port = await getFreePort();
const server = await startGatewayServer(port);
const bridgeCall = bridgeStartCalls.at(-1);
expect(bridgeCall?.onRequest).toBeDefined();
const res = await bridgeCall?.onRequest?.("ios-node", {
id: "r1",
method: "sessions.list",
paramsJSON: JSON.stringify({
includeGlobal: true,
includeUnknown: false,
limit: 50,
}),
});
expect(res?.ok).toBe(true);
const payload = JSON.parse(
String((res as { payloadJSON?: string }).payloadJSON ?? "{}"),
) as {
sessions?: unknown[];
count?: number;
path?: string;
};
expect(Array.isArray(payload.sessions)).toBe(true);
expect(typeof payload.count).toBe("number");
expect(typeof payload.path).toBe("string");
await server.close();
});
test("bridge chat events are pushed to subscribed nodes", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-"));
testSessionStorePath = path.join(dir, "sessions.json");
@@ -2092,6 +2221,13 @@ describe("gateway server", () => {
// Wait a tick for the bridge send to happen.
await new Promise((r) => setTimeout(r, 25));
expect(bridgeSendEvent).toHaveBeenCalledWith(
expect.objectContaining({
nodeId: "ios-node",
event: "agent",
}),
);
expect(bridgeSendEvent).toHaveBeenCalledWith(
expect.objectContaining({
nodeId: "ios-node",

View File

@@ -103,6 +103,7 @@ import {
type SessionsPatchParams,
type Snapshot,
validateAgentParams,
validateChatAbortParams,
validateChatHistoryParams,
validateChatSendParams,
validateConnectParams,
@@ -208,6 +209,7 @@ const METHODS = [
"agent",
// WebChat WebSocket-native chat methods
"chat.history",
"chat.abort",
"chat.send",
];
@@ -282,7 +284,11 @@ const chatRunSessions = new Map<
string,
{ sessionKey: string; clientRunId: string }
>();
const chatRunBuffers = new Map<string, string[]>();
const chatRunBuffers = new Map<string, string>();
const chatAbortControllers = new Map<
string,
{ controller: AbortController; sessionId: string; sessionKey: string }
>();
const getGatewayToken = () => process.env.CLAWDIS_GATEWAY_TOKEN;
@@ -903,6 +909,120 @@ export async function startGatewayServer(
const snap = await refreshHealthSnapshot({ probe: false });
return { ok: true, payloadJSON: JSON.stringify(snap) };
}
case "sessions.list": {
const params = parseParams();
if (!validateSessionsListParams(params)) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `invalid sessions.list params: ${formatValidationErrors(validateSessionsListParams.errors)}`,
},
};
}
const p = params as SessionsListParams;
const cfg = loadConfig();
const storePath = resolveStorePath(cfg.inbound?.session?.store);
const store = loadSessionStore(storePath);
const result = listSessionsFromStore({
cfg,
storePath,
store,
opts: p,
});
return { ok: true, payloadJSON: JSON.stringify(result) };
}
case "sessions.patch": {
const params = parseParams();
if (!validateSessionsPatchParams(params)) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `invalid sessions.patch params: ${formatValidationErrors(validateSessionsPatchParams.errors)}`,
},
};
}
const p = params as SessionsPatchParams;
const key = String(p.key ?? "").trim();
if (!key) {
return {
ok: false,
error: { code: ErrorCodes.INVALID_REQUEST, message: "key required" },
};
}
const cfg = loadConfig();
const storePath = resolveStorePath(cfg.inbound?.session?.store);
const store = loadSessionStore(storePath);
const now = Date.now();
const existing = store[key];
const next: SessionEntry = existing
? {
...existing,
updatedAt: Math.max(existing.updatedAt ?? 0, now),
}
: { sessionId: randomUUID(), updatedAt: now };
if ("thinkingLevel" in p) {
const raw = p.thinkingLevel;
if (raw === null) {
delete next.thinkingLevel;
} else if (raw !== undefined) {
const normalized = normalizeThinkLevel(String(raw));
if (!normalized) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `invalid thinkingLevel: ${String(raw)}`,
},
};
}
next.thinkingLevel = normalized;
}
}
if ("verboseLevel" in p) {
const raw = p.verboseLevel;
if (raw === null) {
delete next.verboseLevel;
} else if (raw !== undefined) {
const normalized = normalizeVerboseLevel(String(raw));
if (!normalized) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `invalid verboseLevel: ${String(raw)}`,
},
};
}
next.verboseLevel = normalized;
}
}
if ("syncing" in p) {
const raw = p.syncing;
if (raw === null) {
delete next.syncing;
} else if (raw !== undefined) {
next.syncing = raw as boolean | string;
}
}
store[key] = next;
await saveSessionStore(storePath, store);
const payload: SessionsPatchResult = {
ok: true,
path: storePath,
key,
entry: next,
};
return { ok: true, payloadJSON: JSON.stringify(payload) };
}
case "chat.history": {
const params = parseParams();
if (!validateChatHistoryParams(params)) {
@@ -945,6 +1065,60 @@ export async function startGatewayServer(
}),
};
}
case "chat.abort": {
const params = parseParams();
if (!validateChatAbortParams(params)) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `invalid chat.abort params: ${formatValidationErrors(validateChatAbortParams.errors)}`,
},
};
}
const { sessionKey, runId } = params as {
sessionKey: string;
runId: string;
};
const active = chatAbortControllers.get(runId);
if (!active) {
return {
ok: true,
payloadJSON: JSON.stringify({ ok: true, aborted: false }),
};
}
if (active.sessionKey !== sessionKey) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: "runId does not match sessionKey",
},
};
}
active.controller.abort();
chatAbortControllers.delete(runId);
chatRunBuffers.delete(runId);
const current = chatRunSessions.get(active.sessionId);
if (current?.clientRunId === runId && current.sessionKey === sessionKey) {
chatRunSessions.delete(active.sessionId);
}
const payload = {
runId,
sessionKey,
seq: (agentRunSeq.get(active.sessionId) ?? 0) + 1,
state: "aborted" as const,
};
broadcast("chat", payload);
bridgeSendToSession(sessionKey, "chat", payload);
return {
ok: true,
payloadJSON: JSON.stringify({ ok: true, aborted: true }),
};
}
case "chat.send": {
const params = parseParams();
if (!validateChatSendParams(params)) {
@@ -1052,6 +1226,13 @@ export async function startGatewayServer(
};
}
const abortController = new AbortController();
chatAbortControllers.set(clientRunId, {
controller: abortController,
sessionId,
sessionKey: p.sessionKey,
});
try {
await agentCommand(
{
@@ -1061,6 +1242,7 @@ export async function startGatewayServer(
deliver: p.deliver,
timeout: Math.ceil(timeoutMs / 1000).toString(),
surface: `Iris(${nodeId})`,
abortSignal: abortController.signal,
},
defaultRuntime,
deps,
@@ -1095,6 +1277,8 @@ export async function startGatewayServer(
message: String(err),
},
};
} finally {
chatAbortControllers.delete(clientRunId);
}
}
default:
@@ -1504,26 +1688,25 @@ export async function startGatewayServer(
agentRunSeq.set(evt.runId, evt.seq);
broadcast("agent", evt);
const chatLink = chatRunSessions.get(evt.runId);
if (chatLink) {
// Map agent bus events to chat events for WS WebChat clients.
// Use clientRunId so the webchat can correlate with its pending promise.
const { sessionKey, clientRunId } = chatLink;
const chatLink = chatRunSessions.get(evt.runId);
if (chatLink) {
// Map agent bus events to chat events for WS WebChat clients.
// Use clientRunId so the webchat can correlate with its pending promise.
const { sessionKey, clientRunId } = chatLink;
bridgeSendToSession(sessionKey, "agent", evt);
const base = {
runId: clientRunId,
sessionKey,
seq: evt.seq,
};
if (evt.stream === "assistant" && typeof evt.data?.text === "string") {
const buf = chatRunBuffers.get(clientRunId) ?? [];
buf.push(evt.data.text);
chatRunBuffers.set(clientRunId, buf);
chatRunBuffers.set(clientRunId, evt.data.text);
} else if (
evt.stream === "job" &&
typeof evt.data?.state === "string" &&
(evt.data.state === "done" || evt.data.state === "error")
) {
const text = chatRunBuffers.get(clientRunId)?.join("\n").trim() ?? "";
const text = chatRunBuffers.get(clientRunId)?.trim() ?? "";
chatRunBuffers.delete(clientRunId);
if (evt.data.state === "done") {
const payload = {
@@ -1962,6 +2145,62 @@ export async function startGatewayServer(
});
break;
}
case "chat.abort": {
const params = (req.params ?? {}) as Record<string, unknown>;
if (!validateChatAbortParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid chat.abort params: ${formatValidationErrors(validateChatAbortParams.errors)}`,
),
);
break;
}
const { sessionKey, runId } = params as {
sessionKey: string;
runId: string;
};
const active = chatAbortControllers.get(runId);
if (!active) {
respond(true, { ok: true, aborted: false });
break;
}
if (active.sessionKey !== sessionKey) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"runId does not match sessionKey",
),
);
break;
}
active.controller.abort();
chatAbortControllers.delete(runId);
chatRunBuffers.delete(runId);
const current = chatRunSessions.get(active.sessionId);
if (
current?.clientRunId === runId &&
current.sessionKey === sessionKey
) {
chatRunSessions.delete(active.sessionId);
}
const payload = {
runId,
sessionKey,
seq: (agentRunSeq.get(active.sessionId) ?? 0) + 1,
state: "aborted" as const,
};
broadcast("chat", payload);
bridgeSendToSession(sessionKey, "chat", payload);
respond(true, { ok: true, aborted: true });
break;
}
case "chat.send": {
const params = (req.params ?? {}) as Record<string, unknown>;
if (!validateChatSendParams(params)) {
@@ -2061,6 +2300,13 @@ export async function startGatewayServer(
}
try {
const abortController = new AbortController();
chatAbortControllers.set(clientRunId, {
controller: abortController,
sessionId,
sessionKey: p.sessionKey,
});
await agentCommand(
{
message: messageWithAttachments,
@@ -2069,6 +2315,7 @@ export async function startGatewayServer(
deliver: p.deliver,
timeout: Math.ceil(timeoutMs / 1000).toString(),
surface: "WebChat",
abortSignal: abortController.signal,
},
defaultRuntime,
deps,
@@ -2100,6 +2347,8 @@ export async function startGatewayServer(
runId: clientRunId,
error: formatForLog(err),
});
} finally {
chatAbortControllers.delete(clientRunId);
}
break;
}