test(chat): harden abort/stream + hide session switching
This commit is contained in:
@@ -27,7 +27,7 @@ let package = Package(
|
||||
]),
|
||||
.testTarget(
|
||||
name: "ClawdisKitTests",
|
||||
dependencies: ["ClawdisKit"],
|
||||
dependencies: ["ClawdisKit", "ClawdisChatUI"],
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
.enableExperimentalFeature("SwiftTesting"),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 } }
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user