diff --git a/apps/shared/ClawdisKit/Package.swift b/apps/shared/ClawdisKit/Package.swift index 89e3431e6..7181fdf21 100644 --- a/apps/shared/ClawdisKit/Package.swift +++ b/apps/shared/ClawdisKit/Package.swift @@ -27,7 +27,7 @@ let package = Package( ]), .testTarget( name: "ClawdisKitTests", - dependencies: ["ClawdisKit"], + dependencies: ["ClawdisKit", "ClawdisChatUI"], swiftSettings: [ .enableUpcomingFeature("StrictConcurrency"), .enableExperimentalFeature("SwiftTesting"), diff --git a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatView.swift b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatView.swift index 4e93763d8..fa7da2be6 100644 --- a/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatView.swift +++ b/apps/shared/ClawdisKit/Sources/ClawdisChatUI/ChatView.swift @@ -5,9 +5,11 @@ public struct ClawdisChatView: View { @State private var viewModel: ClawdisChatViewModel @State private var scrollerBottomID = UUID() @State private var showSessions = false + private let showsSessionSwitcher: Bool - public init(viewModel: ClawdisChatViewModel) { + public init(viewModel: ClawdisChatViewModel, showsSessionSwitcher: Bool = false) { self._viewModel = State(initialValue: viewModel) + self.showsSessionSwitcher = showsSessionSwitcher } public var body: some View { @@ -26,7 +28,11 @@ public struct ClawdisChatView: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .onAppear { self.viewModel.load() } .sheet(isPresented: self.$showSessions) { - ChatSessionsSheet(viewModel: self.viewModel) + if self.showsSessionSwitcher { + ChatSessionsSheet(viewModel: self.viewModel) + } else { + EmptyView() + } } } @@ -80,13 +86,15 @@ public struct ClawdisChatView: View { .foregroundStyle(.secondary) Spacer(minLength: 0) - Button { - self.showSessions = true - } label: { - Image(systemName: "tray.full") + if self.showsSessionSwitcher { + Button { + self.showSessions = true + } label: { + Image(systemName: "tray.full") + } + .buttonStyle(.borderless) + .help("Sessions") } - .buttonStyle(.borderless) - .help("Sessions") Button { self.viewModel.refresh() diff --git a/apps/shared/ClawdisKit/Tests/ClawdisKitTests/ChatViewModelTests.swift b/apps/shared/ClawdisKit/Tests/ClawdisKitTests/ChatViewModelTests.swift new file mode 100644 index 000000000..c1522a7fc --- /dev/null +++ b/apps/shared/ClawdisKit/Tests/ClawdisKitTests/ChatViewModelTests.swift @@ -0,0 +1,235 @@ +@testable import ClawdisChatUI +import ClawdisKit +import Foundation +import Testing + +private struct TimeoutError: Error, CustomStringConvertible { + let label: String + var description: String { "Timeout waiting for: \(self.label)" } +} + +private func waitUntil( + _ label: String, + timeoutSeconds: Double = 2.0, + pollMs: UInt64 = 10, + _ condition: @escaping @Sendable () async -> Bool) async throws +{ + let deadline = Date().addingTimeInterval(timeoutSeconds) + while Date() < deadline { + if await condition() { + return + } + try await Task.sleep(nanoseconds: pollMs * 1_000_000) + } + throw TimeoutError(label: label) +} + +private actor TestChatTransportState { + var historyCallCount: Int = 0 + var sentRunIds: [String] = [] + var abortedRunIds: [String] = [] +} + +private final class TestChatTransport: @unchecked Sendable, ClawdisChatTransport { + private let state = TestChatTransportState() + private let historyResponses: [ClawdisChatHistoryPayload] + + private let stream: AsyncStream + private let continuation: AsyncStream.Continuation + + init(historyResponses: [ClawdisChatHistoryPayload]) { + self.historyResponses = historyResponses + var cont: AsyncStream.Continuation! + self.stream = AsyncStream { c in + cont = c + } + self.continuation = cont + } + + func events() -> AsyncStream { + self.stream + } + + func setActiveSessionKey(_: String) async throws {} + + func requestHistory(sessionKey: String) async throws -> ClawdisChatHistoryPayload { + let idx = await self.state.historyCallCount + await self.state.setHistoryCallCount(idx + 1) + if idx < self.historyResponses.count { + return self.historyResponses[idx] + } + return self.historyResponses.last ?? ClawdisChatHistoryPayload( + sessionKey: sessionKey, + sessionId: nil, + messages: [], + thinkingLevel: "off") + } + + func sendMessage( + sessionKey _: String, + message _: String, + thinking _: String, + idempotencyKey: String, + attachments _: [ClawdisChatAttachmentPayload]) async throws -> ClawdisChatSendResponse + { + await self.state.sentRunIdsAppend(idempotencyKey) + return ClawdisChatSendResponse(runId: idempotencyKey, status: "ok") + } + + func abortRun(sessionKey _: String, runId: String) async throws { + await self.state.abortedRunIdsAppend(runId) + } + + func listSessions(limit _: Int?) async throws -> ClawdisChatSessionsListResponse { + ClawdisChatSessionsListResponse(ts: nil, path: nil, count: 0, defaults: nil, sessions: []) + } + + func requestHealth(timeoutMs _: Int) async throws -> Bool { + true + } + + func emit(_ evt: ClawdisChatTransportEvent) { + self.continuation.yield(evt) + } + + func lastSentRunId() async -> String? { + let ids = await self.state.sentRunIds + return ids.last + } + + func abortedRunIds() async -> [String] { + await self.state.abortedRunIds + } +} + +private extension TestChatTransportState { + func setHistoryCallCount(_ v: Int) { + self.historyCallCount = v + } + + func sentRunIdsAppend(_ v: String) { + self.sentRunIds.append(v) + } + + func abortedRunIdsAppend(_ v: String) { + self.abortedRunIds.append(v) + } +} + +@Suite struct ChatViewModelTests { + @Test func streamsAssistantAndClearsOnFinal() async throws { + let sessionId = "sess-main" + let history1 = ClawdisChatHistoryPayload( + sessionKey: "main", + sessionId: sessionId, + messages: [], + thinkingLevel: "off") + let history2 = ClawdisChatHistoryPayload( + sessionKey: "main", + sessionId: sessionId, + messages: [ + AnyCodable([ + "role": "assistant", + "content": [["type": "text", "text": "final answer"]], + "timestamp": Date().timeIntervalSince1970 * 1000, + ]), + ], + thinkingLevel: "off") + + let transport = TestChatTransport(historyResponses: [history1, history2]) + let vm = await MainActor.run { ClawdisChatViewModel(sessionKey: "main", transport: transport) } + + await MainActor.run { vm.load() } + try await waitUntil("bootstrap") { await MainActor.run { vm.healthOK && vm.sessionId == sessionId } } + + await MainActor.run { + vm.input = "hi" + vm.send() + } + try await waitUntil("pending run starts") { await MainActor.run { vm.pendingRunCount == 1 } } + + transport.emit( + .agent( + ClawdisAgentEventPayload( + runId: sessionId, + seq: 1, + stream: "assistant", + ts: Int(Date().timeIntervalSince1970 * 1000), + data: ["text": AnyCodable("streaming…")]))) + + try await waitUntil("assistant stream visible") { await MainActor.run { vm.streamingAssistantText == "streaming…" } } + + transport.emit( + .agent( + ClawdisAgentEventPayload( + runId: sessionId, + seq: 2, + stream: "tool", + ts: Int(Date().timeIntervalSince1970 * 1000), + data: [ + "phase": AnyCodable("start"), + "name": AnyCodable("demo"), + "toolCallId": AnyCodable("t1"), + "args": AnyCodable(["x": 1]), + ]))) + + try await waitUntil("tool call pending") { await MainActor.run { vm.pendingToolCalls.count == 1 } } + + let runId = try #require(await transport.lastSentRunId()) + transport.emit( + .chat( + ClawdisChatEventPayload( + runId: runId, + sessionKey: "main", + state: "final", + message: nil, + errorMessage: nil))) + + try await waitUntil("pending run clears") { await MainActor.run { vm.pendingRunCount == 0 } } + try await waitUntil("history refresh") { await MainActor.run { vm.messages.contains(where: { $0.role == "assistant" }) } } + #expect(await MainActor.run { vm.streamingAssistantText } == nil) + #expect(await MainActor.run { vm.pendingToolCalls.isEmpty }) + } + + @Test func abortRequestsDoNotClearPendingUntilAbortedEvent() async throws { + let sessionId = "sess-main" + let history = ClawdisChatHistoryPayload( + sessionKey: "main", + sessionId: sessionId, + messages: [], + thinkingLevel: "off") + let transport = TestChatTransport(historyResponses: [history, history]) + let vm = await MainActor.run { ClawdisChatViewModel(sessionKey: "main", transport: transport) } + + await MainActor.run { vm.load() } + try await waitUntil("bootstrap") { await MainActor.run { vm.healthOK && vm.sessionId == sessionId } } + + await MainActor.run { + vm.input = "hi" + vm.send() + } + try await waitUntil("pending run starts") { await MainActor.run { vm.pendingRunCount == 1 } } + + let runId = try #require(await transport.lastSentRunId()) + await MainActor.run { vm.abort() } + + try await waitUntil("abortRun called") { + let ids = await transport.abortedRunIds() + return ids == [runId] + } + + // Pending remains until the gateway broadcasts an aborted/final chat event. + #expect(await MainActor.run { vm.pendingRunCount } == 1) + + transport.emit( + .chat( + ClawdisChatEventPayload( + runId: runId, + sessionKey: "main", + state: "aborted", + message: nil, + errorMessage: nil))) + + try await waitUntil("pending run clears") { await MainActor.run { vm.pendingRunCount == 0 } } + } +} diff --git a/src/gateway/server.test.ts b/src/gateway/server.test.ts index 37324c43c..9b25ffe9e 100644 --- a/src/gateway/server.test.ts +++ b/src/gateway/server.test.ts @@ -2078,6 +2078,194 @@ describe("gateway server", () => { }, ); + test("chat.abort returns aborted=false for unknown runId", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-")); + testSessionStorePath = path.join(dir, "sessions.json"); + await fs.writeFile(testSessionStorePath, JSON.stringify({}, null, 2), "utf-8"); + + const { server, ws } = await startServerWithClient(); + await connectOk(ws); + + ws.send( + JSON.stringify({ + type: "req", + id: "abort-unknown-1", + method: "chat.abort", + params: { sessionKey: "main", runId: "missing-run" }, + }), + ); + + const abortRes = await onceMessage<{ + type: "res"; + id: string; + ok: boolean; + payload?: { ok?: boolean; aborted?: boolean }; + }>(ws, (o) => o.type === "res" && o.id === "abort-unknown-1"); + + expect(abortRes.ok).toBe(true); + expect(abortRes.payload?.aborted).toBe(false); + + ws.close(); + await server.close(); + }); + + test("chat.abort rejects mismatched sessionKey", 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); + let agentStartedResolve: (() => void) | undefined; + const agentStartedP = new Promise((resolve) => { + agentStartedResolve = resolve; + }); + spy.mockImplementationOnce(async (opts) => { + agentStartedResolve?.(); + const signal = (opts as { abortSignal?: AbortSignal }).abortSignal; + await new Promise((resolve) => { + if (!signal) return resolve(); + if (signal.aborted) return resolve(); + signal.addEventListener("abort", () => resolve(), { once: true }); + }); + }); + + ws.send( + JSON.stringify({ + type: "req", + id: "send-mismatch-1", + method: "chat.send", + params: { + sessionKey: "main", + message: "hello", + idempotencyKey: "idem-mismatch-1", + timeoutMs: 30_000, + }, + }), + ); + + await agentStartedP; + + ws.send( + JSON.stringify({ + type: "req", + id: "abort-mismatch-1", + method: "chat.abort", + params: { sessionKey: "other", runId: "idem-mismatch-1" }, + }), + ); + + const abortRes = await onceMessage( + ws, + (o) => o.type === "res" && o.id === "abort-mismatch-1", + ); + expect(abortRes.ok).toBe(false); + expect(abortRes.error?.code).toBe("INVALID_REQUEST"); + + ws.send( + JSON.stringify({ + type: "req", + id: "abort-mismatch-2", + method: "chat.abort", + params: { sessionKey: "main", runId: "idem-mismatch-1" }, + }), + ); + + const abortRes2 = await onceMessage( + ws, + (o) => o.type === "res" && o.id === "abort-mismatch-2", + ); + expect(abortRes2.ok).toBe(true); + + const sendRes = await onceMessage( + ws, + (o) => o.type === "res" && o.id === "send-mismatch-1", + ); + expect(sendRes.ok).toBe(true); + + ws.close(); + await server.close(); + }); + + test("chat.abort is a no-op after chat.send completes", 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.mockResolvedValueOnce(undefined); + + ws.send( + JSON.stringify({ + type: "req", + id: "send-complete-1", + method: "chat.send", + params: { + sessionKey: "main", + message: "hello", + idempotencyKey: "idem-complete-1", + timeoutMs: 30_000, + }, + }), + ); + + const sendRes = await onceMessage( + ws, + (o) => o.type === "res" && o.id === "send-complete-1", + ); + expect(sendRes.ok).toBe(true); + + ws.send( + JSON.stringify({ + type: "req", + id: "abort-complete-1", + method: "chat.abort", + params: { sessionKey: "main", runId: "idem-complete-1" }, + }), + ); + + const abortRes = await onceMessage( + ws, + (o) => o.type === "res" && o.id === "abort-complete-1", + ); + expect(abortRes.ok).toBe(true); + expect(abortRes.payload?.aborted).toBe(false); + + 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");