test(chat): harden abort/stream + hide session switching

This commit is contained in:
Peter Steinberger
2025-12-17 16:21:08 +01:00
parent 888dbd7d11
commit 44365f2e27
4 changed files with 440 additions and 9 deletions

View File

@@ -27,7 +27,7 @@ let package = Package(
]),
.testTarget(
name: "ClawdisKitTests",
dependencies: ["ClawdisKit"],
dependencies: ["ClawdisKit", "ClawdisChatUI"],
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
.enableExperimentalFeature("SwiftTesting"),

View File

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

View File

@@ -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<ClawdisChatTransportEvent>
private let continuation: AsyncStream<ClawdisChatTransportEvent>.Continuation
init(historyResponses: [ClawdisChatHistoryPayload]) {
self.historyResponses = historyResponses
var cont: AsyncStream<ClawdisChatTransportEvent>.Continuation!
self.stream = AsyncStream { c in
cont = c
}
self.continuation = cont
}
func events() -> AsyncStream<ClawdisChatTransportEvent> {
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 } }
}
}

View File

@@ -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<void>((resolve) => {
agentStartedResolve = resolve;
});
spy.mockImplementationOnce(async (opts) => {
agentStartedResolve?.();
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 });
});
});
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");