This document describes what “good tests” mean for OpenCAN, what the current suite already protects, and which gaps are worth filling next.
Use this document together with CLAUDE.md and docs/daemon-architecture.md.
OpenCAN is not a CRUD app with a thin API boundary. The hard parts are:
- unstable mobile SSH transport
- daemon-owned runtime lifecycle that survives SSH disconnects
- durable
conversationIdversus ephemeralruntimeId - single-owner attach/reclaim semantics via
ownerId - buffered event replay and prompt terminal-state settlement
- a UI that must stay responsive while the transport is unreliable
Because of that, the highest-value tests are the ones that protect contracts and state transitions, not implementation branches.
- Lifecycle contract tests: prompt start -> updates -> terminal signal -> settled state
- Identity tests:
conversationId,runtimeId, andownerIdeach keep their role - Recovery tests: disconnect, reconnect, reclaim, replay, restore, missing-session handling
- Ordering/replay tests:
__seq, overflow, cursor reuse, runtime replacement - Focused smoke tests: one short end-to-end path that proves the whole stack still works
- Observability tests: trace/log surfaces that make production debugging fast
- tests that only lock in temporary migration branches
- tests that assert on incidental internal implementation details
- broad UI click-through tests that duplicate lower-layer coverage
- mocks that emulate removed APIs “just in case”
Tests/AppStateTests.swift is the main product-contract suite. It currently protects:
- mock connection and workspace directory behavior
- agent probing and fallback selection
- conversation creation and prompt sending
- image mention prompt block generation
- prompt termination when
prompt_completeis missing - timeout/watchdog behavior and retry safety
- busy-session reopen and queued follow-up sends
- cross-session notification filtering
- conversation open/reopen for ready, running, draining, completed, missing, and restorable conversations
- detached-session recovery after unexpected transport interruption
- interrupted prompt recovery with buffered tail replay
- reconnect restore onto a replacement runtime and post-restore prompt continuity
- daemon session snapshot refresh and picker merge behavior
Tests/ACPClientTests.swift protects the JSON-RPC client boundary:
result: nullhandling- cancellation cleanup and request pipeline health
- notification stream termination when the transport ends
_meta.traceIdinjection rules- ACP error classification helpers
Tests/SessionPickerPathMatchingTests.swift protects workspace matching and conversation/runtime merging:
- trailing slash normalization
- tilde/home normalization
- collapse of recovered runtime rows onto a single stable conversation row
- preserving known local conversations even when daemon cwd metadata drifts
Additional smaller suites protect parsing and local persistence helpers:
Tests/SessionUpdateParserTests.swiftTests/JSONRPCMessageTests.swiftTests/TimeoutHelperTests.swiftTests/SSHKeyPairSecurityTests.swift
The Go daemon suite protects the runtime-side contract:
- JSON-RPC parsing and param rewriting
- attach/reclaim state machine semantics
- prompt terminal-state cleanup
- event buffer ordering, overflow, and snapshot safety
- log ring buffer and trace filtering
conversation.create,conversation.open,conversation.list- external conversation discovery and restore
- same-owner reclaim versus different-owner rejection
- stale owner pruning and idle-timeout behavior
- tail-only replay after disconnect using
lastEventSeq - buffered tool-call replay after disconnect
- same-owner reclaim while a prompt is still streaming
- lost
conversation.createresponse still leaves a recoverable managed conversation - lost
conversation.openrestore response can be retried onto the same restored runtime
The most important daemon tests are the ones that assert:
- prompting never stays stuck after a terminal signal
- reattach/reclaim preserves the single-owner rule
- restored conversations continue to use the stable conversation identity upstream
- buffered replay stays ordered and monotonic under overflow
UITests/OpenCANUITests.swift covers the minimum mock-backed product flows:
- node/workspace navigation
- creating a conversation
- sending a message
- reopening a conversation
- long streaming scroll behavior
UIIntegrationTests/OpenCANUIIntegrationTests.swift covers the SSH-backed smoke path:
- connect to a real target
- reach session picker
- create a conversation
- send a message
- reopen from the session picker
These tests are intentionally few. Their job is to prove the stack still hangs together, not to exhaustively cover every lifecycle branch.
The following behaviors should stay protected whenever the architecture changes:
- Prompt lifecycle terminates
- every prompt ends via
prompt_complete, prompt success response, or prompt error response
- every prompt ends via
- No stale running state
- daemon/UI do not remain stuck in
promptingordraining
- daemon/UI do not remain stuck in
- Scoped delivery
- updates from other runtimes do not mutate the active chat
- Replay continuity
- replay uses monotonic sequence ordering and reopens from the correct cursor when the runtime is unchanged
- Identity discipline
conversationIdis durable,runtimeIdis operational,ownerIdis reclaim identity
- Recoverability
- transport interruption preserves enough context to reconnect and reopen the intended conversation
- Renderable transcript guarantee
- live streaming and replay both produce durable
AppState.messages
- live streaming and replay both produce durable
These are the next tests worth adding before broadening the suite further.
- reconnect after daemon must settle duplicate or overlapping reconnect attempts
- phone attempts to open a conversation already attached by another owner
- same
ownerIdreclaim after stale transport eviction - desktop-started conversation later opened on phone, with follow-up prompt preserving context
- old transport can no longer mutate the conversation after reclaim
- app relaunch after OS kill with recoverable conversation in SwiftData
- migration fixtures for old
Sessionrows to current conversation-first model - large transcript replay performance and correctness
- image resource mention behavior across reconnects and expired uploads
- key open/recover/prompt boundaries emit enough structured context for log correlation
traceIdis easy to follow across app and daemon logs for a single lifecycle- app/daemon log rotation policies stay bounded and diagnostics export enough storage metadata to explain retention
This round of comparison against upstream codex-acp and claude-agent-acp found one confirmed protocol bug and a few high-value follow-ups. Keep these tracked as TODO items until the relevant coverage exists.
- TODO: add an end-to-end permission-interrupt flow where
session/request_permissionarrives mid-prompt, the client replies, and streaming continues with subsequenttool_call/tool_call_update/ text updates. - TODO: add a daemon or
AppStateregression that proves newer ACP tool statuses (pending,in_progress,completed,failed) are tolerated everywhere we consume tool events, not just in the parser and mocks. - TODO: decide whether product surfaces should consume
current_mode_update,config_options_update,usage_update, andavailable_commands_update; today we safely ignore them, but the UI has no visibility into these upstream state changes. - TODO: keep local mocks aligned with upstream structured tool outputs (
rawOutputas content blocks/JSON), because this mismatch previously hid a real bug where tool output text could be dropped from the visible transcript.
Note: the confirmed bug from this review was the structured rawOutput parsing gap. That parser issue is now fixed; the TODOs above are the remaining realism and coverage follow-ups.
When adding a new regression test, prefer the cheapest layer that can prove the contract.
- daemon unit test: daemon state machine, replay, rewrite, owner semantics
- iOS unit test:
AppStateorchestration, transcript mutation, reconnect policy, UI-facing behavior - UI test: view wiring, navigation, scroll, and visible user affordances
- integration test: one true-path proof that SSH + daemon + ACP still work together
A good rule: if a bug can be reproduced without rendering SwiftUI, it probably belongs in daemon or AppState tests, not UI tests.
Before adding a test, ask:
- Is this protecting a user-visible contract?
- Is this one of the identities or lifecycle boundaries that routinely breaks?
- Will this still be valuable after the next refactor?
- Could a smaller lower-layer test prove the same thing faster and more deterministically?
If the answer is “no”, do not add the test.
For day-to-day development:
- add or update the smallest lifecycle/contract test that proves the bug
- run the focused suite for the touched layer
- keep one SSH-backed smoke run green before merging large runtime changes
- update
CLAUDE.md/ docs when the contract changes
Useful commands:
SKIP_DAEMON_BUNDLE_BUILD=1 xcodebuild test -scheme OpenCAN \
-destination "platform=iOS Simulator,name=iPhone 17 Pro" \
-only-testing:OpenCANTests/AppStateTests \
-only-testing:OpenCANTests/ACPClientTests \
-only-testing:OpenCANTests/SessionPickerPathMatchingTests
cd opencan-daemon && go test ./...
OPENCAN_INTEGRATION_TEST_MODE=smoke ./Scripts/run-local-integration.sh