chore: import upstream snapshot with attribution
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import JCodeKit
|
||||
|
||||
/// Scriptable in-memory transport for Connection tests.
|
||||
actor FakeTransport: WebSocketTransport {
|
||||
enum Behavior {
|
||||
case succeed
|
||||
case failConnect
|
||||
case unauthorized
|
||||
}
|
||||
|
||||
let behavior: Behavior
|
||||
private(set) var sentLines: [String] = []
|
||||
private var incoming: [String] = []
|
||||
private var waiters: [CheckedContinuation<String?, Never>] = []
|
||||
private var closed = false
|
||||
|
||||
init(behavior: Behavior = .succeed) {
|
||||
self.behavior = behavior
|
||||
}
|
||||
|
||||
func connect(url: URL, authToken: String) async throws {
|
||||
if behavior == .failConnect {
|
||||
throw TransportError.notConnected
|
||||
}
|
||||
if behavior == .unauthorized {
|
||||
throw TransportError.unauthorized
|
||||
}
|
||||
}
|
||||
|
||||
func send(text: String) async throws {
|
||||
if closed { throw TransportError.notConnected }
|
||||
sentLines.append(text)
|
||||
}
|
||||
|
||||
func receiveText() async throws -> String? {
|
||||
if closed { return nil }
|
||||
if !incoming.isEmpty {
|
||||
return incoming.removeFirst()
|
||||
}
|
||||
return await withCheckedContinuation { continuation in
|
||||
waiters.append(continuation)
|
||||
}
|
||||
}
|
||||
|
||||
func close() async {
|
||||
closed = true
|
||||
for waiter in waiters {
|
||||
waiter.resume(returning: nil)
|
||||
}
|
||||
waiters.removeAll()
|
||||
}
|
||||
|
||||
/// Test helper: push a server frame to the client.
|
||||
func push(_ line: String) {
|
||||
if let waiter = waiters.first {
|
||||
waiters.removeFirst()
|
||||
waiter.resume(returning: line)
|
||||
} else {
|
||||
incoming.append(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func makeConnection(transport: FakeTransport) -> Connection {
|
||||
Connection(
|
||||
configuration: .init(
|
||||
gateway: Gateway(host: "test.local"),
|
||||
authToken: "tok",
|
||||
maxReconnectAttempts: 1,
|
||||
baseBackoffSeconds: 0.01
|
||||
),
|
||||
makeTransport: { transport }
|
||||
)
|
||||
}
|
||||
|
||||
@Test func connectSubscribesAndSyncsHistory() async throws {
|
||||
let transport = FakeTransport()
|
||||
let connection = makeConnection(transport: transport)
|
||||
let stream = await connection.start()
|
||||
|
||||
var iterator = stream.makeAsyncIterator()
|
||||
// connecting -> connected
|
||||
#expect(await iterator.next() == .phase(.connecting))
|
||||
#expect(await iterator.next() == .phase(.connected))
|
||||
|
||||
// Wait for the subscribe + get_history requests to land.
|
||||
var sent: [String] = []
|
||||
for _ in 0..<50 {
|
||||
sent = await transport.sentLines
|
||||
if sent.count >= 2 { break }
|
||||
try await Task.sleep(nanoseconds: 10_000_000)
|
||||
}
|
||||
#expect(sent.count == 2)
|
||||
#expect(sent[0].contains("\"type\":\"subscribe\""))
|
||||
#expect(sent[1].contains("\"type\":\"get_history\""))
|
||||
|
||||
await connection.stop()
|
||||
}
|
||||
|
||||
@Test func decodesPushedEvents() async throws {
|
||||
let transport = FakeTransport()
|
||||
let connection = makeConnection(transport: transport)
|
||||
let stream = await connection.start()
|
||||
|
||||
var iterator = stream.makeAsyncIterator()
|
||||
#expect(await iterator.next() == .phase(.connecting))
|
||||
#expect(await iterator.next() == .phase(.connected))
|
||||
|
||||
await transport.push(#"{"type":"text_delta","text":"hi"}"#)
|
||||
#expect(await iterator.next() == .event(.textDelta(text: "hi")))
|
||||
|
||||
// Multiple newline-delimited events in one frame.
|
||||
await transport.push("{\"type\":\"message_end\"}\n{\"type\":\"done\",\"id\":1}")
|
||||
#expect(await iterator.next() == .event(.messageEnd))
|
||||
#expect(await iterator.next() == .event(.done(id: 1)))
|
||||
|
||||
await connection.stop()
|
||||
}
|
||||
|
||||
@Test func sendAssignsMonotonicRequestIDs() async throws {
|
||||
let transport = FakeTransport()
|
||||
let connection = makeConnection(transport: transport)
|
||||
let stream = await connection.start()
|
||||
|
||||
var iterator = stream.makeAsyncIterator()
|
||||
_ = await iterator.next()
|
||||
_ = await iterator.next()
|
||||
|
||||
// Wait until the automatic subscribe/get_history sends (IDs 1-2) finish
|
||||
// so they cannot interleave with our test sends.
|
||||
for _ in 0..<100 {
|
||||
if await transport.sentLines.count >= 2 { break }
|
||||
try await Task.sleep(nanoseconds: 5_000_000)
|
||||
}
|
||||
|
||||
let first = try await connection.send { .message(id: $0, content: "a") }
|
||||
let second = try await connection.send { .ping(id: $0) }
|
||||
#expect(second == first + 1)
|
||||
|
||||
await connection.stop()
|
||||
}
|
||||
|
||||
@Test func failedConnectReportsFailureAfterRetries() async throws {
|
||||
let transport = FakeTransport(behavior: .failConnect)
|
||||
let connection = makeConnection(transport: transport)
|
||||
let stream = await connection.start()
|
||||
|
||||
var phases: [ConnectionPhase] = []
|
||||
for await output in stream {
|
||||
if case let .phase(phase) = output {
|
||||
phases.append(phase)
|
||||
if case .failed = phase { break }
|
||||
}
|
||||
}
|
||||
#expect(phases.first == .connecting)
|
||||
#expect(phases.contains(.reconnecting(attempt: 1)))
|
||||
if case .failed = phases.last {
|
||||
} else {
|
||||
Issue.record("expected failed phase, got \(phases)")
|
||||
}
|
||||
await connection.stop()
|
||||
}
|
||||
|
||||
@Test func trackSessionIDForResubscribe() async throws {
|
||||
let transport = FakeTransport()
|
||||
let connection = makeConnection(transport: transport)
|
||||
let stream = await connection.start()
|
||||
|
||||
var iterator = stream.makeAsyncIterator()
|
||||
_ = await iterator.next() // connecting
|
||||
_ = await iterator.next() // connected
|
||||
|
||||
await transport.push(#"{"type":"session","session_id":"sess_42"}"#)
|
||||
#expect(await iterator.next() == .event(.sessionID(sessionID: "sess_42")))
|
||||
|
||||
await connection.stop()
|
||||
}
|
||||
|
||||
@Test func sessionCloseRequestStopsReconnecting() async throws {
|
||||
let transport = FakeTransport()
|
||||
let connection = makeConnection(transport: transport)
|
||||
let stream = await connection.start()
|
||||
|
||||
var iterator = stream.makeAsyncIterator()
|
||||
#expect(await iterator.next() == .phase(.connecting))
|
||||
#expect(await iterator.next() == .phase(.connected))
|
||||
|
||||
await transport.push(#"{"type":"session_close_requested","reason":"taken over"}"#)
|
||||
#expect(
|
||||
await iterator.next()
|
||||
== .event(.sessionCloseRequested(reason: "taken over")))
|
||||
|
||||
// The loop must terminate with a failed phase instead of reconnecting.
|
||||
var sawFailed = false
|
||||
while let output = await iterator.next() {
|
||||
if case .phase(.reconnecting) = output {
|
||||
Issue.record("must not reconnect after session_close_requested")
|
||||
break
|
||||
}
|
||||
if case .phase(.failed(let reason)) = output {
|
||||
#expect(reason == "taken over")
|
||||
sawFailed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
#expect(sawFailed)
|
||||
await connection.stop()
|
||||
}
|
||||
|
||||
@Test func reloadingTriggersFastReconnect() async throws {
|
||||
let transport = FakeTransport()
|
||||
let connection = makeConnection(transport: transport)
|
||||
let stream = await connection.start()
|
||||
|
||||
var iterator = stream.makeAsyncIterator()
|
||||
#expect(await iterator.next() == .phase(.connecting))
|
||||
#expect(await iterator.next() == .phase(.connected))
|
||||
|
||||
await transport.push(#"{"type":"reloading"}"#)
|
||||
#expect(await iterator.next() == .event(.reloading(newSocket: nil)))
|
||||
|
||||
// Simulate the server dropping the socket for the restart.
|
||||
await transport.close()
|
||||
|
||||
// The connection reconnects (the shared FakeTransport accepts again).
|
||||
var phases: [ConnectionPhase] = []
|
||||
while let output = await iterator.next() {
|
||||
if case let .phase(phase) = output {
|
||||
phases.append(phase)
|
||||
if phase == .connected { break }
|
||||
}
|
||||
}
|
||||
#expect(phases.contains(.reconnecting(attempt: 1)))
|
||||
#expect(phases.last == .connected)
|
||||
|
||||
await connection.stop()
|
||||
}
|
||||
|
||||
@Test func unauthorizedStopsReconnectingAndAsksForRePair() async throws {
|
||||
let transport = FakeTransport(behavior: .unauthorized)
|
||||
let connection = Connection(
|
||||
configuration: .init(
|
||||
gateway: Gateway(host: "test.local"),
|
||||
authToken: "tok",
|
||||
maxReconnectAttempts: nil, // would retry forever without the 401 short-circuit
|
||||
baseBackoffSeconds: 0.01
|
||||
),
|
||||
makeTransport: { transport }
|
||||
)
|
||||
let stream = await connection.start()
|
||||
|
||||
var iterator = stream.makeAsyncIterator()
|
||||
#expect(await iterator.next() == .phase(.connecting))
|
||||
let final = await iterator.next()
|
||||
guard case .phase(.failed(let reason))? = final else {
|
||||
Issue.record("expected .failed phase, got \(String(describing: final))")
|
||||
return
|
||||
}
|
||||
#expect(reason.contains("Re-pair"))
|
||||
|
||||
await connection.stop()
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import JCodeKit
|
||||
|
||||
private func run(_ events: [ConnectionOutput], from state: SessionState = SessionState())
|
||||
-> SessionState
|
||||
{
|
||||
events.reduce(state) { SessionReducer.reduce($0, $1) }
|
||||
}
|
||||
|
||||
private func event(_ line: String) -> ConnectionOutput {
|
||||
.event(try! ServerEvent.decode(line: line))
|
||||
}
|
||||
|
||||
// MARK: - Streaming text
|
||||
|
||||
@Test func ackDoesNotMarkProcessing() {
|
||||
let state = run([event(#"{"type":"ack","id":1}"#)])
|
||||
#expect(state.isProcessing == false)
|
||||
}
|
||||
|
||||
@Test func streamsAssistantText() {
|
||||
let state = run([
|
||||
.phase(.connected),
|
||||
event(#"{"type":"ack","id":1}"#),
|
||||
event(#"{"type":"text_delta","text":"Hel"}"#),
|
||||
event(#"{"type":"text_delta","text":"lo"}"#),
|
||||
event(#"{"type":"done","id":1}"#),
|
||||
])
|
||||
#expect(state.transcript.count == 1)
|
||||
#expect(state.transcript[0].role == .assistant)
|
||||
#expect(state.transcript[0].text == "Hello")
|
||||
#expect(state.transcript[0].isStreaming == false)
|
||||
#expect(state.isProcessing == false)
|
||||
}
|
||||
|
||||
@Test func userMessageThenResponse() {
|
||||
var state = SessionState()
|
||||
state = SessionReducer.reduce(state, intent: .userSentMessage("hi"))
|
||||
#expect(state.isProcessing)
|
||||
state = run(
|
||||
[
|
||||
event(#"{"type":"text_delta","text":"hey"}"#),
|
||||
event(#"{"type":"done","id":1}"#),
|
||||
], from: state)
|
||||
#expect(state.transcript.map(\.role) == [.user, .assistant])
|
||||
#expect(state.transcript[1].text == "hey")
|
||||
}
|
||||
|
||||
@Test func textReplaceOverwritesStreamedText() {
|
||||
let state = run([
|
||||
event(#"{"type":"text_delta","text":"garbled{{tool"}"#),
|
||||
event(#"{"type":"text_replace","text":"clean prefix"}"#),
|
||||
])
|
||||
#expect(state.transcript[0].text == "clean prefix")
|
||||
}
|
||||
|
||||
@Test func messageEndSplitsTurns() {
|
||||
let state = run([
|
||||
event(#"{"type":"text_delta","text":"first"}"#),
|
||||
event(#"{"type":"message_end"}"#),
|
||||
event(#"{"type":"text_delta","text":"second"}"#),
|
||||
event(#"{"type":"done","id":1}"#),
|
||||
])
|
||||
#expect(state.transcript.map(\.text) == ["first", "second"])
|
||||
}
|
||||
|
||||
// MARK: - Reasoning
|
||||
|
||||
@Test func reasoningStreamsSeparately() {
|
||||
var state = run([
|
||||
event(#"{"type":"reasoning_delta","text":"thinking..."}"#)
|
||||
])
|
||||
#expect(state.isReasoning)
|
||||
#expect(state.transcript[0].reasoning == "thinking...")
|
||||
#expect(state.transcript[0].text == "")
|
||||
|
||||
state = run(
|
||||
[
|
||||
event(#"{"type":"reasoning_done","duration_secs":2.0}"#),
|
||||
event(#"{"type":"text_delta","text":"answer"}"#),
|
||||
], from: state)
|
||||
#expect(state.isReasoning == false)
|
||||
#expect(state.transcript[0].text == "answer")
|
||||
}
|
||||
|
||||
// MARK: - Tool lifecycle
|
||||
|
||||
@Test func toolLifecycleTransitions() {
|
||||
var state = run([
|
||||
event(#"{"type":"tool_start","id":"t1","name":"bash"}"#),
|
||||
event(#"{"type":"tool_input","delta":"{\"command\":"}"#),
|
||||
event(#"{"type":"tool_input","delta":"\"ls\"}"}"#),
|
||||
])
|
||||
#expect(state.transcript[0].toolCalls.count == 1)
|
||||
#expect(state.transcript[0].toolCalls[0].status == .streamingInput)
|
||||
#expect(state.transcript[0].toolCalls[0].input == #"{"command":"ls"}"#)
|
||||
|
||||
state = run([event(#"{"type":"tool_exec","id":"t1","name":"bash"}"#)], from: state)
|
||||
#expect(state.transcript[0].toolCalls[0].status == .running)
|
||||
|
||||
state = run(
|
||||
[event(#"{"type":"tool_done","id":"t1","name":"bash","output":"file.txt"}"#)],
|
||||
from: state)
|
||||
#expect(state.transcript[0].toolCalls[0].status == .succeeded)
|
||||
#expect(state.transcript[0].toolCalls[0].output == "file.txt")
|
||||
}
|
||||
|
||||
@Test func toolFailureRecorded() {
|
||||
let state = run([
|
||||
event(#"{"type":"tool_start","id":"t1","name":"bash"}"#),
|
||||
event(#"{"type":"tool_exec","id":"t1","name":"bash"}"#),
|
||||
event(#"{"type":"tool_done","id":"t1","name":"bash","output":"","error":"exit 1"}"#),
|
||||
])
|
||||
#expect(state.transcript[0].toolCalls[0].status == .failed("exit 1"))
|
||||
}
|
||||
|
||||
@Test func multipleToolsInOneTurn() {
|
||||
let state = run([
|
||||
event(#"{"type":"tool_start","id":"t1","name":"read"}"#),
|
||||
event(#"{"type":"tool_done","id":"t1","name":"read","output":"a"}"#),
|
||||
event(#"{"type":"tool_start","id":"t2","name":"bash"}"#),
|
||||
event(#"{"type":"tool_done","id":"t2","name":"bash","output":"b"}"#),
|
||||
event(#"{"type":"text_delta","text":"done"}"#),
|
||||
event(#"{"type":"done","id":1}"#),
|
||||
])
|
||||
#expect(state.transcript.count == 1)
|
||||
#expect(state.transcript[0].toolCalls.map(\.name) == ["read", "bash"])
|
||||
#expect(state.transcript[0].text == "done")
|
||||
}
|
||||
|
||||
// MARK: - Errors and interrupts
|
||||
|
||||
@Test func errorSetsBannerAndStopsProcessing() {
|
||||
var state = SessionReducer.reduce(SessionState(), intent: .userSentMessage("go"))
|
||||
state = run(
|
||||
[
|
||||
event(
|
||||
#"{"type":"error","id":1,"message":"overloaded","retry_after_secs":15}"#)
|
||||
], from: state)
|
||||
#expect(state.errorBanner == "overloaded (retry in 15s)")
|
||||
#expect(state.isProcessing == false)
|
||||
|
||||
state = SessionReducer.reduce(state, intent: .dismissError)
|
||||
#expect(state.errorBanner == nil)
|
||||
}
|
||||
|
||||
@Test func interruptStopsProcessingAndNotes() {
|
||||
var state = SessionReducer.reduce(SessionState(), intent: .userSentMessage("go"))
|
||||
state = run(
|
||||
[
|
||||
event(#"{"type":"text_delta","text":"partial"}"#),
|
||||
event(#"{"type":"interrupted"}"#),
|
||||
], from: state)
|
||||
#expect(state.isProcessing == false)
|
||||
#expect(state.transcript.last?.text == "partial")
|
||||
#expect(state.transcript.last?.isStreaming == false)
|
||||
#expect(state.notices.contains { $0.message == "Interrupted" })
|
||||
}
|
||||
|
||||
// MARK: - Notices (notifications / compaction / dismissal)
|
||||
|
||||
@Test func notificationBecomesDismissibleNotice() {
|
||||
let state = run([
|
||||
event(#"{"type":"notification","from_name":"swarm","message":"build done"}"#)
|
||||
])
|
||||
#expect(state.notices.count == 1)
|
||||
#expect(state.notices[0].kind == .notification)
|
||||
#expect(state.notices[0].message == "swarm: build done")
|
||||
}
|
||||
|
||||
@Test func notificationWithoutSenderHasNoPrefix() {
|
||||
let state = run([
|
||||
event(#"{"type":"notification","message":"heads up"}"#)
|
||||
])
|
||||
#expect(state.notices[0].message == "heads up")
|
||||
}
|
||||
|
||||
@Test func foregroundCompactionSurfacesNotice() {
|
||||
let state = run([
|
||||
event(#"{"type":"compaction","trigger":"manual","tokens_saved":1234}"#)
|
||||
])
|
||||
#expect(state.notices.count == 1)
|
||||
#expect(state.notices[0].kind == .compaction)
|
||||
#expect(state.notices[0].message.contains("1234"))
|
||||
}
|
||||
|
||||
@Test func backgroundCompactionIsSilent() {
|
||||
let state = run([
|
||||
event(#"{"type":"compaction","trigger":"background","tokens_saved":50}"#)
|
||||
])
|
||||
#expect(state.notices.isEmpty)
|
||||
}
|
||||
|
||||
@Test func dismissNoticeRemovesOnlyThatNotice() {
|
||||
var state = run([
|
||||
event(#"{"type":"notification","message":"first"}"#),
|
||||
event(#"{"type":"notification","message":"second"}"#),
|
||||
])
|
||||
#expect(state.notices.count == 2)
|
||||
let firstID = state.notices[0].id
|
||||
state = SessionReducer.reduce(state, intent: .dismissNotice(firstID))
|
||||
#expect(state.notices.count == 1)
|
||||
#expect(state.notices[0].message == "second")
|
||||
}
|
||||
|
||||
@Test func clearedConversationWipesTranscriptButKeepsSession() {
|
||||
var state = run([
|
||||
event(#"{"type":"session","session_id":"sess_keep"}"#),
|
||||
event(#"{"type":"text_delta","text":"stale"}"#),
|
||||
event(#"{"type":"done","id":1}"#),
|
||||
])
|
||||
#expect(!state.transcript.isEmpty)
|
||||
state = SessionReducer.reduce(state, intent: .clearedConversation)
|
||||
#expect(state.transcript.isEmpty)
|
||||
#expect(state.isProcessing == false)
|
||||
#expect(state.sessionID == "sess_keep")
|
||||
}
|
||||
|
||||
@Test func emptyStreamingStubIsDropped() {
|
||||
let state = run([
|
||||
event(#"{"type":"ack","id":1}"#),
|
||||
event(#"{"type":"interrupted"}"#),
|
||||
])
|
||||
#expect(state.transcript.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - History sync
|
||||
|
||||
@Test func historyReplacesTranscript() {
|
||||
var state = run([
|
||||
event(#"{"type":"text_delta","text":"stale"}"#)
|
||||
])
|
||||
let history = """
|
||||
{"type":"history","id":2,"session_id":"sess_1","messages":[\
|
||||
{"role":"user","content":"question"},\
|
||||
{"role":"assistant","content":"answer"}\
|
||||
],"provider_model":"claude-sonnet-4","available_models":["a","b"],\
|
||||
"all_sessions":["sess_1","sess_2"],"total_tokens":[100,50],\
|
||||
"server_version":"v1","display_title":"T"}
|
||||
"""
|
||||
state = run([event(history)], from: state)
|
||||
#expect(state.transcript.map(\.text) == ["question", "answer"])
|
||||
#expect(state.sessionID == "sess_1")
|
||||
#expect(state.modelName == "claude-sonnet-4")
|
||||
#expect(state.availableModels == ["a", "b"])
|
||||
#expect(state.allSessions == ["sess_1", "sess_2"])
|
||||
#expect(state.tokenInput == 100)
|
||||
#expect(state.tokenOutput == 50)
|
||||
#expect(state.sessionTitle == "T")
|
||||
}
|
||||
|
||||
@Test func historySkipsEmptyAssistantStubs() {
|
||||
let history = """
|
||||
{"type":"history","id":1,"session_id":"s","messages":[\
|
||||
{"role":"user","content":"q"},\
|
||||
{"role":"assistant","content":""},\
|
||||
{"role":"tool","content":"raw tool output"}\
|
||||
]}
|
||||
"""
|
||||
let state = run([event(history)])
|
||||
#expect(state.transcript.count == 1)
|
||||
#expect(state.transcript[0].role == .user)
|
||||
}
|
||||
|
||||
@Test func historyMapsToolData() {
|
||||
let history = """
|
||||
{"type":"history","id":1,"session_id":"s","messages":[\
|
||||
{"role":"assistant","content":"used a tool","tool_data":\
|
||||
{"id":"t1","name":"bash","input":"{}","output":"ok"}}\
|
||||
]}
|
||||
"""
|
||||
let state = run([event(history)])
|
||||
#expect(state.transcript[0].toolCalls.count == 1)
|
||||
#expect(state.transcript[0].toolCalls[0].status == .succeeded)
|
||||
}
|
||||
|
||||
// MARK: - Connection phases
|
||||
|
||||
@Test func disconnectFinishesStreamingEntries() {
|
||||
let state = run([
|
||||
.phase(.connected),
|
||||
event(#"{"type":"text_delta","text":"part"}"#),
|
||||
.phase(.reconnecting(attempt: 1)),
|
||||
])
|
||||
#expect(state.phase == .reconnecting(attempt: 1))
|
||||
#expect(state.transcript[0].isStreaming == false)
|
||||
#expect(state.isProcessing == false)
|
||||
}
|
||||
|
||||
@Test func failureSetsBanner() {
|
||||
let state = run([.phase(.failed(reason: "Could not reach server"))])
|
||||
#expect(state.errorBanner == "Could not reach server")
|
||||
}
|
||||
|
||||
@Test func reconnectClearsBannerOnConnected() {
|
||||
var state = run([.phase(.failed(reason: "down"))])
|
||||
state = run([.phase(.connected)], from: state)
|
||||
#expect(state.errorBanner == nil)
|
||||
}
|
||||
|
||||
// MARK: - Session metadata
|
||||
|
||||
@Test func sessionRenameAppliesToCurrentSession() {
|
||||
var state = run([event(#"{"type":"session","session_id":"sess_1"}"#)])
|
||||
state = run(
|
||||
[
|
||||
event(
|
||||
#"{"type":"session_renamed","session_id":"sess_1","display_title":"Renamed"}"#)
|
||||
], from: state)
|
||||
#expect(state.sessionTitle == "Renamed")
|
||||
|
||||
state = run(
|
||||
[
|
||||
event(
|
||||
#"{"type":"session_renamed","session_id":"other","display_title":"Nope"}"#)
|
||||
], from: state)
|
||||
#expect(state.sessionTitle == "Renamed")
|
||||
}
|
||||
|
||||
@Test func modelChangeUpdatesOrErrors() {
|
||||
var state = run([event(#"{"type":"model_changed","id":1,"model":"gpt-5"}"#)])
|
||||
#expect(state.modelName == "gpt-5")
|
||||
|
||||
state = run(
|
||||
[
|
||||
event(
|
||||
#"{"type":"model_changed","id":2,"model":"","error":"unknown model"}"#)
|
||||
], from: state)
|
||||
#expect(state.errorBanner == "unknown model")
|
||||
#expect(state.modelName == "gpt-5")
|
||||
}
|
||||
|
||||
@Test func resetClearsEverything() {
|
||||
var state = run([
|
||||
event(#"{"type":"text_delta","text":"data"}"#),
|
||||
event(#"{"type":"tokens","input":5,"output":6}"#),
|
||||
])
|
||||
state = SessionReducer.reduce(state, intent: .reset)
|
||||
#expect(state == SessionState())
|
||||
}
|
||||
|
||||
// MARK: - Queued soft-interrupts
|
||||
|
||||
@Test func queuedInterruptTracksPendingState() {
|
||||
var state = SessionReducer.reduce(SessionState(), intent: .userSentMessage("go"))
|
||||
state = SessionReducer.reduce(state, intent: .userQueuedInterrupt("also do y"))
|
||||
#expect(state.hasPendingInterrupts)
|
||||
#expect(state.pendingInterrupts == ["also do y"])
|
||||
#expect(state.transcript.last?.isQueued == true)
|
||||
#expect(state.transcript.last?.text == "also do y")
|
||||
}
|
||||
|
||||
@Test func injectionClearsMatchingQueuedInterrupt() {
|
||||
var state = SessionReducer.reduce(SessionState(), intent: .userSentMessage("go"))
|
||||
state = SessionReducer.reduce(state, intent: .userQueuedInterrupt("also do y"))
|
||||
state = run(
|
||||
[
|
||||
event(
|
||||
#"{"type":"soft_interrupt_injected","content":"also do y","point":"C"}"#)
|
||||
], from: state)
|
||||
#expect(state.pendingInterrupts.isEmpty)
|
||||
#expect(state.transcript.last?.isQueued == false)
|
||||
#expect(state.transcript.last?.text == "also do y")
|
||||
}
|
||||
|
||||
@Test func injectionFromAnotherClientAppendsEntry() {
|
||||
let state = run([
|
||||
event(
|
||||
#"{"type":"soft_interrupt_injected","content":"external note","display_role":"system","point":"A"}"#
|
||||
)
|
||||
])
|
||||
#expect(state.transcript.count == 1)
|
||||
#expect(state.transcript[0].role == .system)
|
||||
#expect(state.transcript[0].text == "external note")
|
||||
#expect(state.transcript[0].isQueued == false)
|
||||
}
|
||||
|
||||
@Test func cancelledQueuedInterruptsRemovesOptimisticBubbles() {
|
||||
var state = SessionReducer.reduce(SessionState(), intent: .userSentMessage("go"))
|
||||
state = SessionReducer.reduce(state, intent: .userQueuedInterrupt("nevermind"))
|
||||
state = SessionReducer.reduce(state, intent: .cancelledQueuedInterrupts)
|
||||
#expect(state.pendingInterrupts.isEmpty)
|
||||
#expect(!state.transcript.contains { $0.isQueued })
|
||||
#expect(state.transcript.map(\.text) == ["go"])
|
||||
}
|
||||
|
||||
@Test func turnCompletionDrainsPendingInterrupts() {
|
||||
var state = SessionReducer.reduce(SessionState(), intent: .userSentMessage("go"))
|
||||
state = SessionReducer.reduce(state, intent: .userQueuedInterrupt("late note"))
|
||||
state = run(
|
||||
[
|
||||
event(#"{"type":"text_delta","text":"answer"}"#),
|
||||
event(#"{"type":"done","id":1}"#),
|
||||
], from: state)
|
||||
#expect(state.pendingInterrupts.isEmpty)
|
||||
#expect(!state.transcript.contains { $0.isQueued })
|
||||
}
|
||||
|
||||
@Test func historyResyncClearsPendingInterrupts() {
|
||||
var state = SessionReducer.reduce(SessionState(), intent: .userQueuedInterrupt("queued"))
|
||||
#expect(state.hasPendingInterrupts)
|
||||
state = run(
|
||||
[
|
||||
event(
|
||||
#"{"type":"history","id":1,"session_id":"s","messages":[{"role":"user","content":"q"}]}"#
|
||||
)
|
||||
], from: state)
|
||||
#expect(state.pendingInterrupts.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Turn-level connection phase
|
||||
|
||||
@Test func connectionPhaseTracksAndClears() {
|
||||
var state = run([event(#"{"type":"connection_phase","phase":"authenticating"}"#)])
|
||||
#expect(state.serverPhase == "authenticating")
|
||||
|
||||
state = run([event(#"{"type":"done","id":1}"#)], from: state)
|
||||
#expect(state.serverPhase == nil)
|
||||
|
||||
state = run([event(#"{"type":"connection_phase","phase":"waiting"}"#)], from: state)
|
||||
state = run([.phase(.reconnecting(attempt: 1))], from: state)
|
||||
#expect(state.serverPhase == nil)
|
||||
}
|
||||
|
||||
// MARK: - Retry rollback
|
||||
|
||||
@Test func retryRollbackDiscardsPartialOutput() {
|
||||
var state = SessionReducer.reduce(SessionState(), intent: .userSentMessage("go"))
|
||||
state = run(
|
||||
[
|
||||
event(#"{"type":"reasoning_delta","text":"thinking"}"#),
|
||||
event(#"{"type":"text_delta","text":"partial answer"}"#),
|
||||
event(#"{"type":"retry_rollback","attempt":1,"max":5}"#),
|
||||
], from: state)
|
||||
// Only the user message survives; the partial assistant entry is discarded.
|
||||
#expect(state.transcript.map(\.role) == [.user])
|
||||
#expect(state.isReasoning == false)
|
||||
#expect(state.statusDetail == "Retrying (1/5)")
|
||||
|
||||
// The replayed response streams into a fresh entry without duplication.
|
||||
state = run(
|
||||
[
|
||||
event(#"{"type":"text_delta","text":"clean answer"}"#),
|
||||
event(#"{"type":"done","id":1}"#),
|
||||
], from: state)
|
||||
#expect(state.transcript.map(\.text) == ["go", "clean answer"])
|
||||
}
|
||||
|
||||
@Test func retryRollbackKeepsCommittedTurns() {
|
||||
let state = run([
|
||||
event(#"{"type":"text_delta","text":"first"}"#),
|
||||
event(#"{"type":"message_end"}"#),
|
||||
event(#"{"type":"text_delta","text":"second partial"}"#),
|
||||
event(#"{"type":"retry_rollback","attempt":1,"max":3}"#),
|
||||
])
|
||||
#expect(state.transcript.map(\.text) == ["first"])
|
||||
}
|
||||
|
||||
// MARK: - Session titles
|
||||
|
||||
@Test func renameOfOtherSessionStillRecordsTitle() {
|
||||
var state = run([event(#"{"type":"session","session_id":"sess_1"}"#)])
|
||||
state = run(
|
||||
[
|
||||
event(
|
||||
#"{"type":"session_renamed","session_id":"other","display_title":"Other work"}"#)
|
||||
], from: state)
|
||||
#expect(state.sessionTitle == nil)
|
||||
#expect(state.title(forSession: "other") == "Other work")
|
||||
}
|
||||
|
||||
@Test func historyRecordsTitleForSessionList() {
|
||||
let state = run([
|
||||
event(
|
||||
#"{"type":"history","id":1,"session_id":"sess_1","messages":[],"all_sessions":["sess_1","sess_2"],"display_title":"Main"}"#
|
||||
)
|
||||
])
|
||||
#expect(state.title(forSession: "sess_1") == "Main")
|
||||
#expect(state.title(forSession: "sess_2") == nil)
|
||||
#expect(state.sessionTitle == "Main")
|
||||
}
|
||||
|
||||
// MARK: - Reasoning effort
|
||||
|
||||
@Test func reasoningEffortChangeUpdatesOrErrors() {
|
||||
var state = run([
|
||||
event(#"{"type":"reasoning_effort_changed","id":1,"effort":"high"}"#)
|
||||
])
|
||||
#expect(state.reasoningEffort == "high")
|
||||
|
||||
state = run(
|
||||
[
|
||||
event(#"{"type":"reasoning_effort_changed","id":2,"error":"unsupported"}"#)
|
||||
], from: state)
|
||||
#expect(state.errorBanner == "unsupported")
|
||||
#expect(state.reasoningEffort == "high")
|
||||
}
|
||||
|
||||
@Test func historyCarriesReasoningEffortIntoState() {
|
||||
let state = run([
|
||||
event(
|
||||
#"{"type":"history","id":1,"session_id":"s","messages":[],"reasoning_effort":"medium"}"#
|
||||
)
|
||||
])
|
||||
#expect(state.reasoningEffort == "medium")
|
||||
}
|
||||
|
||||
// MARK: - Compaction result
|
||||
|
||||
@Test func compactResultSuccessBecomesNotice() {
|
||||
let state = run([
|
||||
event(#"{"type":"compact_result","id":1,"message":"Compaction started","success":true}"#)
|
||||
])
|
||||
#expect(state.notices.count == 1)
|
||||
#expect(state.notices[0].kind == .compaction)
|
||||
#expect(state.errorBanner == nil)
|
||||
}
|
||||
|
||||
@Test func compactResultFailureBecomesError() {
|
||||
let state = run([
|
||||
event(#"{"type":"compact_result","id":1,"message":"Nothing to compact","success":false}"#)
|
||||
])
|
||||
#expect(state.notices.isEmpty)
|
||||
#expect(state.errorBanner == "Nothing to compact")
|
||||
}
|
||||
|
||||
// MARK: - Server lifecycle events
|
||||
|
||||
@Test func reloadingSurfacesNotice() {
|
||||
let state = run([event(#"{"type":"reloading"}"#)])
|
||||
#expect(state.notices.count == 1)
|
||||
#expect(state.notices[0].message.contains("updating"))
|
||||
}
|
||||
|
||||
@Test func sessionCloseRequestedStopsProcessingWithReason() {
|
||||
var state = SessionReducer.reduce(SessionState(), intent: .userSentMessage("go"))
|
||||
state = run(
|
||||
[
|
||||
event(#"{"type":"text_delta","text":"partial"}"#),
|
||||
event(#"{"type":"session_close_requested","reason":"taken over by TUI"}"#),
|
||||
], from: state)
|
||||
#expect(state.isProcessing == false)
|
||||
#expect(state.errorBanner == "taken over by TUI")
|
||||
#expect(state.transcript.last?.isStreaming == false)
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import JCodeKit
|
||||
|
||||
// MARK: - Gateway / PairURI
|
||||
|
||||
@Test func gatewayBuildsEndpoints() {
|
||||
let gateway = Gateway(host: "devbox.tailnet.ts.net")
|
||||
#expect(gateway.healthURL.absoluteString == "http://devbox.tailnet.ts.net:7643/health")
|
||||
#expect(gateway.pairURL.absoluteString == "http://devbox.tailnet.ts.net:7643/pair")
|
||||
#expect(gateway.webSocketURL.absoluteString == "ws://devbox.tailnet.ts.net:7643/ws")
|
||||
}
|
||||
|
||||
@Test func pairURIParsesQRPayload() {
|
||||
let payload = PairURI.parse("jcode://pair?host=mybox.ts.net&port=7643&code=123456")
|
||||
#expect(payload?.gateway.host == "mybox.ts.net")
|
||||
#expect(payload?.gateway.port == 7643)
|
||||
#expect(payload?.code == "123456")
|
||||
}
|
||||
|
||||
@Test func pairURIDefaultsPort() {
|
||||
let payload = PairURI.parse("jcode://pair?host=mybox&code=987654")
|
||||
#expect(payload?.gateway.port == Gateway.defaultPort)
|
||||
}
|
||||
|
||||
@Test func pairURIRejectsGarbage() {
|
||||
#expect(PairURI.parse("https://example.com/pair?host=x&code=1") == nil)
|
||||
#expect(PairURI.parse("jcode://pair?host=&code=1") == nil)
|
||||
#expect(PairURI.parse("jcode://pair?host=x") == nil)
|
||||
#expect(PairURI.parse("not a uri") == nil)
|
||||
}
|
||||
|
||||
// MARK: - Request encoding (must match crates/jcode-protocol/src/wire.rs)
|
||||
|
||||
private func encodedObject(_ request: Request) throws -> [String: Any] {
|
||||
let line = try request.encodedLine()
|
||||
let data = line.data(using: .utf8)!
|
||||
return try JSONSerialization.jsonObject(with: data) as! [String: Any]
|
||||
}
|
||||
|
||||
@Test func encodesMessageRequest() throws {
|
||||
let object = try encodedObject(.message(id: 7, content: "hello"))
|
||||
#expect(object["type"] as? String == "message")
|
||||
#expect(object["id"] as? UInt64 == 7)
|
||||
#expect(object["content"] as? String == "hello")
|
||||
}
|
||||
|
||||
@Test func encodesSubscribeWithTargetSession() throws {
|
||||
let object = try encodedObject(.subscribe(id: 1, targetSessionID: "sess_abc"))
|
||||
#expect(object["type"] as? String == "subscribe")
|
||||
#expect(object["target_session_id"] as? String == "sess_abc")
|
||||
|
||||
let bare = try encodedObject(.subscribe(id: 2, targetSessionID: nil))
|
||||
#expect(bare["target_session_id"] == nil)
|
||||
}
|
||||
|
||||
@Test func encodesControlRequests() throws {
|
||||
#expect(try encodedObject(.cancel(id: 3))["type"] as? String == "cancel")
|
||||
#expect(try encodedObject(.ping(id: 4))["type"] as? String == "ping")
|
||||
#expect(try encodedObject(.getHistory(id: 5))["type"] as? String == "get_history")
|
||||
#expect(try encodedObject(.clear(id: 6))["type"] as? String == "clear")
|
||||
#expect(
|
||||
try encodedObject(.cancelSoftInterrupts(id: 8))["type"] as? String
|
||||
== "cancel_soft_interrupts")
|
||||
|
||||
let soft = try encodedObject(.softInterrupt(id: 9, content: "also do x", urgent: true))
|
||||
#expect(soft["type"] as? String == "soft_interrupt")
|
||||
#expect(soft["content"] as? String == "also do x")
|
||||
#expect(soft["urgent"] as? Bool == true)
|
||||
|
||||
let resume = try encodedObject(.resumeSession(id: 10, sessionID: "sess_x"))
|
||||
#expect(resume["type"] as? String == "resume_session")
|
||||
#expect(resume["session_id"] as? String == "sess_x")
|
||||
|
||||
let model = try encodedObject(.setModel(id: 11, model: "claude-sonnet-4"))
|
||||
#expect(model["type"] as? String == "set_model")
|
||||
#expect(model["model"] as? String == "claude-sonnet-4")
|
||||
|
||||
let rename = try encodedObject(.renameSession(id: 12, title: "My session"))
|
||||
#expect(rename["type"] as? String == "rename_session")
|
||||
#expect(rename["title"] as? String == "My session")
|
||||
}
|
||||
|
||||
@Test func encodesReasoningEffortAndCompact() throws {
|
||||
let effort = try encodedObject(.setReasoningEffort(id: 13, effort: "high"))
|
||||
#expect(effort["type"] as? String == "set_reasoning_effort")
|
||||
#expect(effort["id"] as? UInt64 == 13)
|
||||
#expect(effort["effort"] as? String == "high")
|
||||
|
||||
let compact = try encodedObject(.compact(id: 14))
|
||||
#expect(compact["type"] as? String == "compact")
|
||||
#expect(compact["id"] as? UInt64 == 14)
|
||||
}
|
||||
|
||||
// MARK: - ServerEvent decoding (fixtures mirror real server output)
|
||||
|
||||
@Test func decodesStreamingEvents() throws {
|
||||
#expect(
|
||||
try ServerEvent.decode(line: #"{"type":"text_delta","text":"Hel"}"#)
|
||||
== .textDelta(text: "Hel"))
|
||||
#expect(
|
||||
try ServerEvent.decode(line: #"{"type":"reasoning_delta","text":"hmm"}"#)
|
||||
== .reasoningDelta(text: "hmm"))
|
||||
#expect(
|
||||
try ServerEvent.decode(line: #"{"type":"reasoning_done","duration_secs":1.5}"#)
|
||||
== .reasoningDone(durationSecs: 1.5))
|
||||
#expect(
|
||||
try ServerEvent.decode(line: #"{"type":"text_replace","text":"clean"}"#)
|
||||
== .textReplace(text: "clean"))
|
||||
#expect(try ServerEvent.decode(line: #"{"type":"message_end"}"#) == .messageEnd)
|
||||
#expect(try ServerEvent.decode(line: #"{"type":"done","id":3}"#) == .done(id: 3))
|
||||
#expect(try ServerEvent.decode(line: #"{"type":"interrupted"}"#) == .interrupted)
|
||||
}
|
||||
|
||||
@Test func decodesToolLifecycle() throws {
|
||||
#expect(
|
||||
try ServerEvent.decode(line: #"{"type":"tool_start","id":"t1","name":"bash"}"#)
|
||||
== .toolStart(id: "t1", name: "bash"))
|
||||
#expect(
|
||||
try ServerEvent.decode(line: #"{"type":"tool_input","delta":"{\"cmd\""}"#)
|
||||
== .toolInput(delta: "{\"cmd\""))
|
||||
#expect(
|
||||
try ServerEvent.decode(line: #"{"type":"tool_exec","id":"t1","name":"bash"}"#)
|
||||
== .toolExec(id: "t1", name: "bash"))
|
||||
#expect(
|
||||
try ServerEvent.decode(
|
||||
line: #"{"type":"tool_done","id":"t1","name":"bash","output":"ok"}"#)
|
||||
== .toolDone(id: "t1", name: "bash", output: "ok", error: nil))
|
||||
#expect(
|
||||
try ServerEvent.decode(
|
||||
line: #"{"type":"tool_done","id":"t2","name":"bash","output":"","error":"boom"}"#)
|
||||
== .toolDone(id: "t2", name: "bash", output: "", error: "boom"))
|
||||
}
|
||||
|
||||
@Test func decodesErrorAndStatus() throws {
|
||||
#expect(
|
||||
try ServerEvent.decode(
|
||||
line: #"{"type":"error","id":1,"message":"rate limited","retry_after_secs":30}"#)
|
||||
== .error(id: 1, message: "rate limited", retryAfterSecs: 30))
|
||||
#expect(
|
||||
try ServerEvent.decode(line: #"{"type":"tokens","input":1200,"output":340}"#)
|
||||
== .tokenUsage(input: 1200, output: 340))
|
||||
#expect(
|
||||
try ServerEvent.decode(line: #"{"type":"status_detail","detail":"thinking"}"#)
|
||||
== .statusDetail(detail: "thinking"))
|
||||
#expect(
|
||||
try ServerEvent.decode(
|
||||
line:
|
||||
#"{"type":"state","id":2,"session_id":"s1","message_count":4,"is_processing":true}"#
|
||||
) == .state(id: 2, sessionID: "s1", messageCount: 4, isProcessing: true))
|
||||
}
|
||||
|
||||
@Test func decodesSessionEvents() throws {
|
||||
#expect(
|
||||
try ServerEvent.decode(line: #"{"type":"session","session_id":"sess_1"}"#)
|
||||
== .sessionID(sessionID: "sess_1"))
|
||||
#expect(
|
||||
try ServerEvent.decode(
|
||||
line:
|
||||
#"{"type":"session_renamed","session_id":"sess_1","display_title":"Fix bug"}"#)
|
||||
== .sessionRenamed(sessionID: "sess_1", displayTitle: "Fix bug"))
|
||||
#expect(
|
||||
try ServerEvent.decode(
|
||||
line: #"{"type":"model_changed","id":5,"model":"gpt-5","provider_name":"openai"}"#)
|
||||
== .modelChanged(id: 5, model: "gpt-5", error: nil))
|
||||
}
|
||||
|
||||
@Test func decodesHistoryPayload() throws {
|
||||
let line = """
|
||||
{"type":"history","id":2,"session_id":"sess_9","messages":[\
|
||||
{"role":"user","content":"hi"},\
|
||||
{"role":"assistant","content":"hello!","tool_calls":["bash"]},\
|
||||
{"role":"assistant","content":"","tool_data":{"id":"t1","name":"read","input":"{}","output":"data"}}\
|
||||
],"provider_name":"anthropic","provider_model":"claude-sonnet-4",\
|
||||
"available_models":["claude-sonnet-4","claude-opus-4"],\
|
||||
"total_tokens":[1500,800],"all_sessions":["sess_9","sess_8"],\
|
||||
"server_version":"v0.26.11","display_title":"My chat"}
|
||||
"""
|
||||
guard case let .history(payload) = try ServerEvent.decode(line: line) else {
|
||||
Issue.record("expected history event")
|
||||
return
|
||||
}
|
||||
#expect(payload.sessionID == "sess_9")
|
||||
#expect(payload.messages.count == 3)
|
||||
#expect(payload.messages[0].role == "user")
|
||||
#expect(payload.messages[1].toolCalls == ["bash"])
|
||||
#expect(payload.messages[2].toolData?.name == "read")
|
||||
#expect(payload.providerModel == "claude-sonnet-4")
|
||||
#expect(payload.availableModels.count == 2)
|
||||
#expect(payload.totalTokens == .init(input: 1500, output: 800))
|
||||
#expect(payload.allSessions == ["sess_9", "sess_8"])
|
||||
#expect(payload.serverVersion == "v0.26.11")
|
||||
#expect(payload.displayTitle == "My chat")
|
||||
}
|
||||
|
||||
@Test func unknownEventTypesAreTolerated() throws {
|
||||
let event = try ServerEvent.decode(
|
||||
line: #"{"type":"some_future_event","payload":{"x":1}}"#)
|
||||
#expect(event == .unknown(type: "some_future_event"))
|
||||
}
|
||||
|
||||
@Test func decodesTurnLifecycleSignals() throws {
|
||||
#expect(
|
||||
try ServerEvent.decode(line: #"{"type":"connection_phase","phase":"authenticating"}"#)
|
||||
== .connectionPhase(phase: "authenticating"))
|
||||
#expect(
|
||||
try ServerEvent.decode(line: #"{"type":"retry_rollback","attempt":2,"max":5}"#)
|
||||
== .retryRollback(attempt: 2, max: 5))
|
||||
#expect(
|
||||
try ServerEvent.decode(
|
||||
line:
|
||||
#"{"type":"soft_interrupt_injected","content":"also fix y","point":"C","tools_skipped":1}"#
|
||||
)
|
||||
== .softInterruptInjected(
|
||||
content: "also fix y", displayRole: nil, point: "C", toolsSkipped: 1))
|
||||
#expect(
|
||||
try ServerEvent.decode(
|
||||
line:
|
||||
#"{"type":"soft_interrupt_injected","content":"note","display_role":"system","point":"A"}"#
|
||||
)
|
||||
== .softInterruptInjected(
|
||||
content: "note", displayRole: "system", point: "A", toolsSkipped: nil))
|
||||
}
|
||||
|
||||
@Test func decodesEffortAndCompactResponses() throws {
|
||||
#expect(
|
||||
try ServerEvent.decode(
|
||||
line: #"{"type":"reasoning_effort_changed","id":3,"effort":"high"}"#)
|
||||
== .reasoningEffortChanged(id: 3, effort: "high", error: nil))
|
||||
#expect(
|
||||
try ServerEvent.decode(
|
||||
line: #"{"type":"reasoning_effort_changed","id":4,"error":"unsupported"}"#)
|
||||
== .reasoningEffortChanged(id: 4, effort: nil, error: "unsupported"))
|
||||
#expect(
|
||||
try ServerEvent.decode(
|
||||
line: #"{"type":"compact_result","id":5,"message":"Compaction started","success":true}"#
|
||||
)
|
||||
== .compactResult(id: 5, message: "Compaction started", success: true))
|
||||
}
|
||||
|
||||
@Test func decodesServerLifecycleEvents() throws {
|
||||
#expect(
|
||||
try ServerEvent.decode(line: #"{"type":"reloading"}"#)
|
||||
== .reloading(newSocket: nil))
|
||||
#expect(
|
||||
try ServerEvent.decode(line: #"{"type":"reloading","new_socket":"/tmp/jcode.sock"}"#)
|
||||
== .reloading(newSocket: "/tmp/jcode.sock"))
|
||||
#expect(
|
||||
try ServerEvent.decode(
|
||||
line: #"{"type":"session_close_requested","reason":"taken over"}"#)
|
||||
== .sessionCloseRequested(reason: "taken over"))
|
||||
}
|
||||
|
||||
@Test func historyCarriesReasoningEffort() throws {
|
||||
let line =
|
||||
#"{"type":"history","id":1,"session_id":"s","messages":[],"reasoning_effort":"medium"}"#
|
||||
guard case let .history(payload) = try ServerEvent.decode(line: line) else {
|
||||
Issue.record("expected history event")
|
||||
return
|
||||
}
|
||||
#expect(payload.reasoningEffort == "medium")
|
||||
}
|
||||
|
||||
@Test func malformedLinesThrow() {
|
||||
#expect(throws: WireError.self) {
|
||||
try ServerEvent.decode(line: "not json")
|
||||
}
|
||||
#expect(throws: WireError.self) {
|
||||
try ServerEvent.decode(line: #"{"no_type":true}"#)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user