a789495a98
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
548 lines
19 KiB
Swift
548 lines
19 KiB
Swift
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)
|
|
}
|