diff --git a/docs/INDEX.md b/docs/INDEX.md index 7e5b631a..c08dbd57 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -9,5 +9,6 @@ - `context/INDEX.md`: Index for critical project context needed by new contributors. - `runbooks/INDEX.md`: Index for operational procedures (testing, deployment, issue triage, worktree flow). - `operations/INDEX.md`: Index for recurring operations and nightly agent execution docs. +- `residual-review-findings/INDEX.md`: Index for post-implementation review findings, repairs, and deferred proof obligations. - `assets/INDEX.md`: Index for README, site, and documentation media assets. - `site/INDEX.md`: GitHub Pages landing page source for go-code. diff --git a/docs/logs/engineering-log.md b/docs/logs/engineering-log.md index ef9c2f2d..0cee79ee 100644 --- a/docs/logs/engineering-log.md +++ b/docs/logs/engineering-log.md @@ -2462,6 +2462,117 @@ Skipped creating separate issues for Op/EventMsg protocol (already covered by SS and then the full regression suite, directly in the logged-in context passed. This is a test-launch environment distinction, not an accepted failing baseline. + +## 2026-07-31 (PR #1021 GUI Hardening Production-Review Repairs) + +- Symptom: the exact PR head `1f2444b2480b5832139318e4fa034f4240d92b8d` + passed its original slice tests but still allowed stale async completions, + overlapping transcript-scroll timers, duplicate run-control requests, and + refresh failures that replaced truthful prior rows. Required impact/log/index + artifacts were also absent. +- Integration: merged `origin/main` at + `b3afc7ec487c60762a91a1219ceb92c523ef0e78` into the isolated repair branch. + The merge preserved #1008 conversation replay deduplication and #1028 + failed/cancelled terminal reconciliation. +- Fix: + - `RunSession` now owns generations for run registration, answers, + pending-input fetches, and acknowledged control requests. Approve, deny, + and steer are single-flight; failed steering restores the exact draft only + if the operator has not edited it since. + - `ProjectSession` now applies last-request-wins ownership independently to + catalog, conversation, activity, rewind, open-conversation, and durable + sync results. A busy refusal releases its pending-selection ownership so + later sync is not stranded. + - transcript autoscroll owns one cancellable, generation-checked completion + task, honors Reduce Motion, and exposes an accessible Jump to Latest path. + - lifecycle actions, including rewind, remain guarded in the session and + expose the shared disabled reason in mouse, keyboard, and VoiceOver + surfaces. Conversation-stream activity from an external run participates + in the busy guard without adopting the run-control identity owned by + #1007. + - collection refresh failures preserve stale rows with a compact Retry + notice; duplicate prompt-history traversal no longer leaves recall + bookkeeping armed after an ignored or equal-value recall. +- TDD evidence: focused red runs observed missing collection failure modes, + missing lifecycle reason wiring, stale selection ownership after a busy + refusal, and recall suppression left armed after a declined key. The + transcript/autoscroll and run-control slices were also test-first; the + initial ProjectSession ownership red run was obscured by concurrent + shared-target compilation and is not claimed as a clean behavioral red. +- Verification: integrated repair suites pass 93 tests / 12 suites; full Swift + build, 303-test / 55-suite Swift test run, and strict recursive Swift format + lint pass. Relevant Go packages + (`./internal/server`, `./internal/harness`, `./internal/store`) pass. + `./scripts/test-regression.sh` passes in the logged-in GUI context, including + `go test ./...`, the complete race suite, and + `coveragegate: PASS (total=85.6%, min=80.0%, zero-functions=0)`. A tmux run + timed out only in the two real Keychain tests because its `security` + subprocess lacked the logged-in GUI bootstrap context; the exact direct + rerun passed, so no red baseline is accepted. Installed-app smokes remain a + separate lifecycle gate. +- Remaining proof: live installed-app smokes and the Settings-specific + `setCost` investigation stay open under #1020. External scheduled-run + control identity remains #1007 and is intentionally not implemented here. +- Hosted follow-up: the first repaired head's `live-harnessd` job exposed a + real #1008/#1028 integration race. The per-run and conversation streams can + schedule `run.completed` before a duplicate stream's earlier `usage.delta`; + the immediate durable-message reconciliation then rebuilt rows and erased + the sealed usage/cost totals. The live assertion reproduced locally. A new + reducer regression was observed red, then `Transcript` began reconciling + authoritative terminal `usage_totals` / `cost_totals`, retaining accounting + across durable-row rebuilds, and keeping cumulative values monotonic against + late duplicate events. The exact live RunSession suite passes after the fix; + the full Swift result is 304 tests / 55 suites. +- The same hosted run's unrelated Go race failure is deterministic in 10/10 + targeted `-race` repetitions on current-main + `TestWorktreeContainment_ToolCwdIsWorktree`. Its synchronized-cleanup repair + is already issue #1039 / green PR #1041 at `bd0682c4`; PR #1021 deliberately + does not duplicate that owned Go test change and remains blocked until #1041 + is promoted into `main`. +- Final Codex review found three more cross-request ownership gaps. All were + reproduced red before repair: `AskUserView` retained its answer dictionary + when a new prompt reused the same question shape; a run ending during its + todo fetch returned from the entire activity refresh and stranded tasks/runs + in loading; and a delayed rewind refusal could be presented or force-retried + against a newly selected conversation. The view is now keyed by `callID`; + stale todos are discarded without aborting independent collections; and + rewind refusals carry, validate, and retry only their originating + conversation. Focused review regressions pass 13 tests / 3 suites; full + Swift verification passes 308 tests / 55 suites with strict format lint. +- The remaining terminal-accounting threads were then reproduced red. Usage + was monotonic across the whole conversation instead of one run, so a cheaper + follow-up inherited the prior run's tokens, dollars, and sticky priced + status; and only `run.completed` consumed sealed totals even though failed + and cancelled terminal payloads carry the same authoritative fields. + `Transcript` now owns accounting by `runID`, clears it as soon as a follow-up + is queued, rejects late prior-run accounting/terminal state, preserves that + ownership through durable reconciliation, and consumes sealed totals for all + three terminal outcomes. The two reducer regressions were red before repair; + focused accounting coverage passes 4 tests / 1 suite, the relevant Go + packages pass, and full Swift verification passes 310 tests / 55 suites. +- The next exact-head review surfaced four final ordering gaps, each covered + red before repair: the destructive alert dismissed a rewind refusal before + its scheduled force retry could claim it; a local second-press cancel left + `currentRunID` pointing at a stream it had cancelled; a slow todo request + withheld ready tasks/runs; and an authoritative non-priced terminal + `cost_status` could not replace an earlier `available` delta. Force rewind + now claims synchronously before scheduling I/O, local force cancel releases + the run id synchronously and on stream cancellation cleanup, activity starts + all three requests together but commits global collections before awaiting + todos, and sealed terminal status overrides and locks out late duplicate + status. The combined focused run passes 5 tests / 4 suites; relevant Go + packages pass; full Swift verification passes 313 tests / 55 suites. +- The final exact-head review added three ownership boundaries, again captured + red before repair. While `startRun` was pending, an unrelated + conversation-stream callback could claim the submitted run's accounting and + keep its real lifecycle stuck queued; delayed startup catalog/conversation + refreshes could set newer loaded data back to loading before rejecting their + stale generations; and transcript pin/autoscroll state survived a + conversation switch. `RunSession` now binds accounting to the server-returned + run id before consuming its stream, reserved refreshes validate ownership + before any state mutation, and `TranscriptView` identity follows the selected + conversation. The focused set passes 5 tests / 2 suites; relevant Go packages + pass; full Swift verification passes 316 tests / 55 suites. # 2026-07-28 — macOS inline loading states - Added `CollectionLoadState` and a single Reduce-Motion-aware `LoadingPlaceholder` primitive in GoCodeUI's DesignSystem. diff --git a/docs/logs/long-term-thinking-log.md b/docs/logs/long-term-thinking-log.md index 3f0efddc..ad2f8bd5 100644 --- a/docs/logs/long-term-thinking-log.md +++ b/docs/logs/long-term-thinking-log.md @@ -1563,6 +1563,37 @@ Decision rule: when uncertain, default to `command intent` and `user intent` bel - Whether `reset_context` (still registered by no catalog, pre-existing) should be wired up or removed along with its step-engine handling. - Next verification step: review the diff, then promote through the repo's normal verify-and-merge flow. +## 2026-07-30 (macapp GUI Correctness, Safety, and Accessibility Hardening — Epic #991) + +- Command intent: Implement epic #991's 8 child slices (#992–#999) as a single branch — GUI + correctness, destructive-action safety, and accessibility fixes across the macOS app — test-first + per slice, then run an adversarial review pass and fix what it finds. +- Delivered as one branch, not 8 independent PRs. Every unit shares at least one file with another + (`ChatView.swift` across U1/U3/U7; `ProjectSession.swift` across U2/U4/U5/U6; `SessionsView.swift` + across U2/U5/U6/U8), which forces serial execution rather than the epic's assumed independent + fan-out. Documented in the plan (`docs/plans/2026-07-30-001-feat-macapp-gui-hardening-plan.md`) + as a deliberate deviation, flagged for maintainer judgment rather than silently taken. +- Test baseline was stale before re-verification: the plan carried forward **177 tests passed** from + a prior epic briefing, itself not re-run when the plan was written. The actual pre-branch baseline, + re-measured, was **174**. Final count after all 8 slices plus the review-fix pass: **277** tests, + `swift build` and `swift format lint --strict` green. Key learning: a baseline copied from an + earlier document without re-running it drifts silently — re-measure baselines at the point they're + used, not just at the point they were first recorded. +- An adversarial review pass (4 dimensions: correctness, concurrency/races, safety-guard + completeness, accessibility) ran after the 8 slices landed and found 15 real findings — a mix of + races and guard gaps that test-first slice work did not surface on its own, because each slice's + own tests were scoped to that slice's behavior, not to cross-slice interaction. 14 were fixed + on-branch (commits `dd0ae517`/`4250d9f2`/`c4a0b5ea`/`e8e06514`/`cb8ab7db`/`83d3a29e`/`c540f3e1`/ + `158a26b4`); 1 residual (a `setCost` status-erasure gap not brought in line with its five siblings) + plus deferred manual-only smokes and a documented platform-floor deferral are tracked in + https://github.com/dennisonbertram/go-code/issues/1020 and recorded in + `docs/residual-review-findings/feat-macapp-gui-hardening.md`. +- Key learning: TDD-per-slice and adversarial-review-after-integration are complementary, not + redundant — the review pass caught cross-slice races (e.g. a stale `runControlTask` write) that no + single slice's test suite was positioned to see, because the hazard only exists once multiple + slices' code coexists. +- Next verification step: review the PR, then promote through the repo's normal verify-and-merge flow. + ## 2026-07-30 (Issue #1026 Direct GitHub Feedback Publication) - Command intent: Make `/feedback` consume an image attached to the current TUI @@ -1591,3 +1622,29 @@ Decision rule: when uncertain, default to `command intent` and `user intent` bel - Next verification step: write the attached-image/direct-publication tests, confirm their expected failures, implement the smallest publisher and selective cleanup path, then run live GitHub proof. + +## 2026-07-31 (PR #1021 Production-Review Repair) + +- Command intent: repair the existing GUI-hardening PR on an isolated branch, + integrate current `origin/main`, preserve replay/terminal reconciliation, + close every confirmed async/control/history/process finding test-first, and + update the hosted PR without merging it. +- User intent: promote one rigorously reviewed macOS GUI change set without + losing the epic child-closing references or conflating source tests with + installed-app proof. +- Success definition: + - stale answers, pending questions, collections, conversation selections, + rewinds, and durable syncs cannot overwrite newer state; + - transcript following, prompt history, lifecycle controls, and + acknowledgement-bearing controls behave deterministically and accessibly; + - required impact, plan, log, residual, and index artifacts exist; + - Swift and Go gates pass on the exact pushed PR head; + - live installed-app and Settings-specific results remain visibly pending + until their separate investigation provides evidence. +- Non-goals: implementing #1007 external cron/callback run-control identity, + merging PR #1021, or claiming the deferred native smokes from headless tests. +- Guardrails: keep #992–#999 same-repository closing references, do not touch + the user's root checkout, and preserve current-main #1008/#1028 behavior. +- Next verification step: finish the full regression gate, push the reviewed + exact head, reply to each inline thread with its test evidence, and request a + fresh `@codex review`; then wait for Settings/native-smoke follow-up. diff --git a/docs/plans/2026-07-30-001-feat-macapp-gui-hardening-plan.md b/docs/plans/2026-07-30-001-feat-macapp-gui-hardening-plan.md new file mode 100644 index 00000000..eac53080 --- /dev/null +++ b/docs/plans/2026-07-30-001-feat-macapp-gui-hardening-plan.md @@ -0,0 +1,613 @@ +--- +artifact_contract: ce-unified-plan/v1 +artifact_readiness: implementation-ready +execution: code +product_contract_source: ce-plan-bootstrap +date: 2026-07-30 +epic: 991 +child_issues: [992, 993, 994, 995, 996, 997, 998, 999] +plan_depth: deep +baseline_commit: 8f2e412 +--- + +# Plan: macapp GUI correctness, safety, and accessibility hardening (epic #991) + +## 2026-07-31 Repair Phase Addendum + +PR #1021 is being repaired after an independent production review at exact head +`1f2444b2480b5832139318e4fa034f4240d92b8d`. The repair branch merged current +`origin/main` at `b3afc7ec487c60762a91a1219ceb92c523ef0e78` and must preserve the +#1008/#1028 persisted-conversation replay and terminal-reconciliation behavior. +The complete repair impact map is +`2026-07-31-pr-1021-gui-hardening-repair-impact-map.md`. + +Confirmed repair scope uses strict red-green TDD for: + +- answer-request generation ownership across reset/new conversations; +- generation-safe pending-question fetches; +- single-flight approve/deny/steer and non-destructive steering failure; +- cancel/rearm or generation-safe autoscroll completion; +- latest-request ownership for project collection and conversation loads; +- identical prompt-history recall bookkeeping; +- stale-row retention, lifecycle disabled reasons, and the missing rewind busy guard. + +External cron/callback run-control binding remains owned by #1007 and is not +duplicated here. Settings-specific root-cause work and installed-app smokes +remain pending the separately coordinated investigation and issue #1020. The +repair phase may not merge until the current-scope automated gates, independent +review, and remaining live proof obligations are reconciled. + +Automated repair status: Swift and live-harnessd scope is complete on the +isolated repair branch. The focused integration run passed 93 tests / 12 +suites; the full Swift build, 316-test / 55-suite Swift test run, strict +recursive format lint, and exact live RunSession suite pass. The relevant Go +packages and the exact-head hosted checks pass. A prior hosted attempt exposed +the current-main worktree-cleanup race already owned by #1039 / green PR #1041, +so the safe stacking order remains #1041 first rather than duplicating its Go +test fix here. Installed-app smokes and the separate Settings investigation +also remain required before the overall PR is considered fully proven. + +## Summary + +Eight scoped slices harden the SwiftUI macOS app (`macapp/`) in three areas that are currently wrong rather than merely unpolished: + +1. **Correctness** — the transcript autoscrolls even when the operator has scrolled up to read; failed collection fetches render as permanent loading skeletons; run-control calls (`cancel`/`approve`/`deny`/`answerInput`) throw their acknowledgement away with `try?`. +2. **Safety** — conversation lifecycle actions (new / fork / undo) run during an active run with no guard; delete and undo fire immediately with no confirmation and no statement of what will be lost; the server's `409 rewind_refused` safety refusal is collapsed into a generic status string with no distinct force path. +3. **Accessibility & feedback** — conversation and model rows are `onTapGesture` targets rather than controls, the per-model exposure `Toggle` has no accessibility label, and a successful model fetch's status message is erased by the reload that follows it. + +Each slice is delivered test-first (epic requirement) against the existing Swift Testing suites in `macapp/Tests/`. + +--- + +## Problem Frame + +**Who is hurt.** The operator reading a long streamed transcript, the operator recovering from a flaky daemon, and the operator using the keyboard or VoiceOver. All three currently get either a silent failure or a destructive action with no warning. + +**Why now.** The app has reached the point where its remaining defects are behavioural, not visual. Every finding below was verified against source at `8f2e412`: + +| # | Finding | Evidence (repo-relative) | +|---|---|---| +| F1 | Autoscroll never yields to the user. `pinnedToBottom` is initialised `true` and never mutated. | `macapp/Sources/GoCodeUI/ChatView.swift:90` (declaration), `:128` (only read site) | +| F2 | `.failed` renders identically to `.loading` — endless skeletons, no message, no retry. `CollectionLoadState` carries no failure detail; the reason lands in the single-slot `statusMessage` shared by 8 collections. | `macapp/Sources/GoCodeUI/DesignSystem/CollectionLoadState.swift:9-18`; `macapp/Sources/GoCodeUI/ActivityView.swift:46`; `macapp/Sources/GoCodeUI/ProjectSession.swift:93-116, 205-300`; `macapp/Sources/GoCodeUI/ModelSettingsView.swift:18, 182, 235` | +| F3 | Run-control acknowledgements discarded. `try?` on cancel/approve/deny/answerInput; `pendingQuestions` cleared *before* the server answers; a freeform answer edited back to empty counts as answered. | `macapp/Sources/GoCodeUI/RunSession.swift:139, 144, 149, 171-172`; `macapp/Sources/GoCodeUI/ChatView.swift:855` | +| F4 | Conversation lifecycle unguarded during a run: `newConversation`, `fork`, `undo` never consult `run?.isBusy`. | `macapp/Sources/GoCodeUI/ProjectSession.swift:356-382` | +| F5 | Delete and undo are immediate and unpreviewed; delete is a bare destructive `Button` in a context menu. | `macapp/Sources/GoCodeUI/ProjectSession.swift:345, 374`; `macapp/Sources/GoCodeUI/SessionsView.swift:59`; `macapp/Sources/GoCodeUI/SettingsView.swift:198` | +| F6 | `rewind` collapses every `HarnessError` — including the server's deliberate `rewind_refused` — into `statusMessage` text, so no force path can exist. The dead `forceNext` toggle was removed rather than wired, with a NOTE saying exactly this. | `macapp/Sources/GoCodeUI/ProjectSession.swift:386-399`; `macapp/Sources/GoCodeUI/SessionsView.swift:144-152`; server side: `internal/server/http_conversations.go:370`; client contract note: `macapp/Sources/HarnessKit/ClientConversations.swift:126-130` | +| F7 | Prompt history is append-only with no cursor, no forward navigation, and no production key handler — `recallPreviousPrompt()` has no call site in `Sources/`. | `macapp/Sources/GoCodeUI/RunSession.swift:26, 74, 205-208`; composer has no key handling: `macapp/Sources/GoCodeUI/ChatView.swift:886-894` | +| F8 | Accessibility and settings feedback gaps: rows are tap gestures, the exposure `Toggle` label is hidden with no replacement, `load()` clears the status a successful fetch just set, provider `Remove` is immediate. | `macapp/Sources/GoCodeUI/SessionsView.swift:44`; `macapp/Sources/GoCodeUI/SettingsView.swift:158`; `macapp/Sources/GoCodeUI/ModelSettingsView.swift:356-365` (toggle), `:45-50` + `:58-66` (status erased), `:291-295` (immediate Remove) | + +**Constraint that shapes every slice.** This package has no SwiftUI view-rendering tests. The three established patterns are: pure value-type tests (`CollectionLoadStateTests`, `MarkdownBlockTests`, `DesignTokenTests`), observable-session tests driven through a `URLProtocol` stub (`ProjectSessionActivityTests`, `RunSessionConversationStreamTests`), and source-scan reachability tests (`TranscriptFeatureReachabilityTests`). Every unit below must therefore push its decision logic **out of the view body** into something one of those three patterns can reach. + +--- + +## Requirements + +Traced to the findings above and to the epic's acceptance criteria. + +| ID | Requirement | Source | +|---|---|---| +| R1 | Auto-scroll to the transcript bottom happens only while the view is already at (or within a small threshold of) the bottom. Scrolling up during a stream must not be yanked back. | F1 / #992 | +| R2 | A failed collection load renders an inline error naming the failure reason for *that* collection plus a retry affordance, never a loading skeleton. A `.failed` state must not be indistinguishable from `.loading`. | F2 / #993 | +| R3 | Every run-control call surfaces its result. A rejected or failed `cancel`/`approve`/`deny`/`answerInput` produces visible feedback and does not leave the UI asserting the action succeeded. Pending questions clear only after the server accepts the answers. | F3 / #994 | +| R4 | An answer set is submittable only when every question has a non-blank answer. | F3 / #994 | +| R5 | `newConversation`, `fork`, and `undo` refuse (with an explanation) while a run is active, at the shared `ProjectSession` boundary so every call site is covered. | F4 / #995 | +| R6 | Deleting a conversation and undoing a turn require an explicit confirmation that states what will be lost before it is lost. | F5 / #996 | +| R7 | A `rewind_refused` refusal is represented structurally (not as prose in `statusMessage`) and offers a **second, distinctly worded** confirmation that calls `rewind(force: true)`. A refusal is never auto-retried with force. | F6 / #997 | +| R8 | Up/Down in the composer navigate prompt history with a cursor: repeated Up walks backwards, Down walks forwards and back out to the pre-recall draft, and navigation declines rather than destroys an in-progress draft. | F7 / #998 | +| R9 | Conversation rows and model rows are real controls (focusable, actionable, and named); the per-model exposure toggle has an accessibility label naming the model. | F8 / #999 | +| R10 | Model-settings status feedback survives the reload that follows the action that produced it; destructive provider removal is confirmed. | F8 / #999 | +| R11 | Every slice lands test-first, and `swift build`, `swift test`, and `swift format lint --strict` are green per slice. | Epic #991 | + +--- + +## Key Technical Decisions + +**KTD-1 — Run-control acknowledgement model: await the call, surface the error, no new transport work.** +`HarnessClient.cancel/approve/deny/answerInput` all route through `sendVoid`, which already throws a typed `HarnessError` on any non-2xx (`macapp/Sources/HarnessKit/HarnessClient.swift:327-361`). The defect is purely that `RunSession` wraps them in `try?`. Fix: `await` the call inside the existing `Task`, catch `HarnessError`, and publish the message on a `RunSession`-owned observable. Reuse the existing `connectionError` slot — it is already rendered by `InlineRunStatus` (`ChatView.swift:716`) and by definition means "the last thing this session asked the server for did not work". *Rejected:* a new per-control error enum and a new banner — one more surface for the same information. + +**KTD-2 — `CollectionLoadState.failed` gains an associated message.** +`statusMessage` is a single last-writer-wins slot shared by 8 collections (`ProjectSession.swift:205-300`), so an inline error next to a failed list cannot truthfully name *that* list's failure by reading it. Change `case failed` → `case failed(String)` and add `showsError` / `showsPlaceholder(itemCount:)` helpers alongside the existing `showsEmptyState(itemCount:)` so views stop hand-rolling `state != .loaded && items.isEmpty`. This is the widest diff in the plan (six files) and is why U2 runs early — a fail-fast placement. *Rejected:* a parallel `failureMessage` dictionary keyed by collection — two things to keep in sync, and nothing stops them diverging. + +**KTD-3 — Retry is a closure supplied by the view, not a registry.** +Each load state already pairs one-to-one with a `refresh*` method. `CollectionErrorState(message:retry:)` takes `{ Task { await project.refreshConversations() } }`. No mapping table, no protocol. + +**KTD-4 — One shared destructive-confirmation presentation, built once in U5 and reused by U6 and U8.** +The app already uses `.alert` with a `Binding` derived from an optional item (`SessionsView.swift:184-200`) — that shape is the pattern, extracted into one `DestructiveConfirmation` component so delete, undo, rewind, force-rewind, and provider-remove read identically and cannot drift apart in wording severity. + +**KTD-5 — Delete/undo previews are derived client-side. There is no server dry-run.** +Verified: `POST /v1/conversations/{id}/undo` mutates and *then* reports `removed_from_step` / `remaining_messages` (`internal/server/http_conversations.go:588-616`); `handleDeleteConversation` has no preview mode (`:831`). So a pre-action preview must be composed from data the app already holds: `ConversationInfo.displayTitle` + `messageCount` for delete, and the last `.userPrompt` item in `run.transcript.items` for undo. Server changes stay out of scope. + +**KTD-6 — Match `rewind_refused` on `HarnessError.code`, not the HTTP status.** +The server passes a computed status to `writeError(w, code, "rewind_refused", …)` (`internal/server/http_conversations.go:370`), so the code string is the stable part of the contract. `ProjectSession.rewind` returns a typed outcome and records `rewindRefusal` (point id + server message) instead of stringifying into `statusMessage`. + +**KTD-7 — Prompt-history cursor is a pure value type; "cursor-aware" is approximated by draft state, not caret position.** +`PromptHistory` (entries + cursor index + a stashed pre-recall draft) is a plain struct, unit-testable without a view. The literal reading of #998 — "cursor-aware", i.e. only recall when the text caret is on the first/last line — is **not implementable on this package's platform floor**: `Package.swift` pins `.macOS(.v14)`, and a SwiftUI `TextField`/`TextEditor` selection binding (`TextSelection`) is a macOS 15 API. Approximation: Up recalls only when the draft is empty or unchanged from the current recall; otherwise it declines and lets the field handle the key normally. See Assumptions and the deviation note. + +**KTD-8 — Autoscroll pinning is a pure model fed by geometry, not a view-local `Bool`.** +`TranscriptScrollPin` (threshold + `mutating func update(distanceFromBottom:)` + `isPinned`) is unit-testable; the view supplies the distance via a `GeometryReader` on the existing bottom anchor read in a named coordinate space. `.onScrollGeometryChange` / `.onScrollPhaseChange` are macOS 15 APIs and are unavailable here; `scrollPosition(id:)` reports the leading visible item, which is the wrong end of the scroll view for this decision. + +**KTD-9 — Guards live in `ProjectSession`, not at each call site.** +`newConversation` has four callers (`SessionsView.swift:17`, `ChatView.swift:901`, `ConversationChrome.swift:32`, and `ProjectSession.deleteConversation` itself at `:349`); `fork` and `undo` each have three (`ChatView.swift:343/351`, `ConversationChrome.swift:34/35`, `SettingsView.swift:197/198`). One guard inside each `ProjectSession` method is a smaller diff than a `.disabled(...)` per call site *and* is the only version that cannot be bypassed by a caller added later. Disabled-state polish on the controls is additive, not the fix. + +--- + +## High-Level Technical Design + +### Collection load-state rendering (U2) + +```mermaid +stateDiagram-v2 + [*] --> idle + idle --> loading: refresh*() called + loading --> loaded: request succeeds + loading --> failed: request throws + failed --> loading: Retry pressed + loaded --> loading: refresh*() called again + + note right of loading + renders LoadingPlaceholder rows + (only when the collection is still empty) + end note + note right of failed + renders CollectionErrorState: + this collection's message + Retry. + Never a skeleton. Previously loaded + rows stay on screen. + end note + note right of loaded + itemCount == 0 -> EmptyState + itemCount > 0 -> rows + end note +``` + +### Run-control acknowledgement (U3) and lifecycle guard (U4) + +```mermaid +sequenceDiagram + participant V as View (ApprovalBar / AskUserView) + participant R as RunSession + participant C as HarnessClient + participant D as harnessd + + V->>R: approve() / answer(answers) + R->>R: guard currentRunID, guard answers complete (R4) + R->>C: await approve / answerInput + C->>D: POST /v1/runs/{id}/... + alt 2xx + D-->>C: 200 + C-->>R: returns + R->>R: clear pendingQuestions (only here) + else non-2xx or transport failure + D-->>C: error envelope + C-->>R: throws HarnessError + R->>R: connectionError = error.message + R->>R: pendingQuestions retained + end + R-->>V: observable state change +``` + +```mermaid +flowchart TD + A[newConversation / fork / undo] --> B{run?.isBusy} + B -- yes --> C[statusMessage: name the action and say a run is active] --> D[no mutation, no server call] + B -- no --> E[proceed as today] +``` + +--- + +## Implementation Units + +Execution is **serial** in the order below. The epic's logical dependency chain (U3 → U4 → U5 → U6) is preserved, and U2 — the widest diff — is front-loaded so a bad shape fails fast. Units that are logically independent (U1, U2, U7) are still ordered rather than parallel because they share files: U1, U3, and U7 all edit `ChatView.swift`; U2, U4, U5, U6 all edit `ProjectSession.swift`. + +### U1 — Stop transcript autoscroll when the user has scrolled up + +**Goal.** Streaming output stops yanking the view to the bottom once the operator has scrolled back to read. + +**Requirements.** R1, R11. + +**Dependencies.** None. + +**Files.** +- `macapp/Sources/GoCodeUI/TranscriptScrollPin.swift` (new) +- `macapp/Sources/GoCodeUI/ChatView.swift` (modify `TranscriptView`, lines ~82-132) +- `macapp/Sources/GoCodeUI/DesignSystem/Layout.swift` (add the pin threshold token) +- `macapp/Tests/GoCodeUITests/TranscriptScrollPinTests.swift` (new) + +**Approach.** +1. New value type owning the decision: + ``` + struct TranscriptScrollPin { + var isPinned: Bool // starts true — a fresh transcript is at the bottom + mutating func update(distanceFromBottom: CGFloat) // <= threshold -> pinned + } + ``` + Threshold as a `Layout` token (`Layout.autoscrollPinThreshold`), consistent with every other measurement in this module living in the token layer. +2. In `TranscriptView`, wrap the existing bottom anchor (`ChatView.swift:103`) in a `GeometryReader` and read its frame in a `.coordinateSpace(name:)` attached to the `ScrollView`; feed `scrollViewHeight - anchorMinY` into `pin.update(distanceFromBottom:)`. +3. `scrollIfPinned` (`:127`) now guards on `pin.isPinned`. A programmatic `scrollTo` re-enters the geometry callback with distance ≈ 0, which re-pins — correct and self-consistent. +4. Keep the existing `withAnimation` and both `onChange` triggers unchanged. + +**Patterns to follow.** `LoadingPlaceholder` (a small view whose behaviour is driven by injected values); token-first measurements per `DesignTokenTests`; `MarkdownBlock` as the precedent for "the logic is a value type, the view just renders it". + +**Test scenarios** (`macapp/Tests/GoCodeUITests/TranscriptScrollPinTests.swift`): +- *Happy:* a new pin is pinned; `update(distanceFromBottom: 0)` keeps it pinned; `scrollIfPinned` therefore fires. +- *Core regression:* `update(distanceFromBottom: threshold + 1)` → `isPinned == false`. This is the assertion that fails if `pinnedToBottom` is ever reverted to a never-mutated constant. +- *Edge:* distance exactly `threshold` → still pinned (boundary is inclusive, asserted explicitly). +- *Edge:* returning to the bottom (`update(0)`) after unpinning → re-pinned, so autoscroll resumes without a relaunch. +- *Edge:* negative distance (overscroll / bounce) → pinned, not unpinned. +- *Reachability:* extend `TranscriptFeatureReachabilityTests` to assert `ChatView.swift` contains `pin.update(distanceFromBottom:` and `guard pin.isPinned` — the "wired to production" check that this module's existing tests use, and the one that catches a pure-model-with-no-call-site regression. + +**Execution note.** Strict TDD: write `TranscriptScrollPinTests` red first, implement the value type, then wire the view and add the reachability assertion. + +**Verification.** From `macapp/`: `swift build`, `swift test`, `swift format lint --strict --recursive Sources Tests`. + +--- + +### U2 — Failed collection loads render an inline error with retry + +**Goal.** A failed fetch says what failed, for which collection, and offers Retry. No endless skeletons. + +**Requirements.** R2, R11. + +**Dependencies.** None (but ordered first among the wide-diff units). + +**Files.** +- `macapp/Sources/GoCodeUI/DesignSystem/CollectionLoadState.swift` (`failed(String)`, `showsError`, `showsPlaceholder(itemCount:)`, new `CollectionErrorState` view) +- `macapp/Sources/GoCodeUI/ProjectSession.swift` (all `= .failed` assignments: lines ~217, 224, 231, 243, 259, 272, 284, 293) +- `macapp/Sources/GoCodeUI/ActivityView.swift` (tasks + runs sections, lines ~33-64) +- `macapp/Sources/GoCodeUI/SessionsView.swift` (conversations list ~24-65; checkpoints ~154-182) +- `macapp/Sources/GoCodeUI/SettingsView.swift` (providers ~49; models ~131) +- `macapp/Sources/GoCodeUI/ModelSettingsView.swift` (`loadState` ~18, 47-50, 182, 235) +- `macapp/Tests/GoCodeUITests/CollectionLoadStateTests.swift` (extend) +- `macapp/Tests/GoCodeUITests/ProjectSessionLoadStateTests.swift` (new) + +**Approach.** +1. `CollectionLoadState` becomes `idle | loading | loaded | failed(String)`. Keep `showsEmptyState(itemCount:)` semantics exactly. Add: + - `var showsError: Bool` / `var errorMessage: String?` + - `func showsPlaceholder(itemCount: Int) -> Bool` — `self == .loading || self == .idle`, and `itemCount == 0`. Critically **false** for `.failed`, which is the bug. +2. `CollectionErrorState(message:retry:)` — an icon + the server's own message (verbatim, per the existing convention in `ModelSettingsModel.fetch`) + a `Retry` button. Mirrors `StartupFailureView` (`AppShell.swift:245-268`), which is already the app's "this failed, here is the reason, try again" shape. +3. `ProjectSession` failure branches become `.failed(error.localizedDescription)`. Keep the existing `statusMessage` write — it is the cross-cutting toast and is asserted by `ProjectSessionActivityTests`; U2 must not regress those two tests. +4. Each consuming view: `showsPlaceholder` → skeletons, `showsError` → `CollectionErrorState` with that collection's `refresh*` as retry, `showsEmptyState` → `EmptyState` (unchanged), else rows. +5. `ModelSettingsModel.load()` already sets `.failed`; give it the message too and render `CollectionErrorState` in both `providerList` and `modelList` instead of the current `loadState != .loaded` skeleton branches. + +**Patterns to follow.** `CollectionLoadStateTests` (pure state-table tests); `ProjectSessionActivityTests`' `URLProtocol` stub on a fixed loopback port for session-level tests; verbatim server messages, as `ModelSettingsView.swift:62-66` argues. + +**Test scenarios.** +- `CollectionLoadStateTests` (extend): `.failed("boom").showsEmptyState(itemCount: 0) == false` (existing guarantee, preserved); `.failed("boom").showsPlaceholder(itemCount: 0) == false` — **the core regression**, red before the change; `.loading.showsPlaceholder(itemCount: 0) == true`; `.loading.showsPlaceholder(itemCount: 3) == false` (a refresh over existing rows must not blank them); `.failed("boom").errorMessage == "boom"`; `.loaded.showsError == false`. +- `ProjectSessionLoadStateTests` (new, stub-driven like `ProjectSessionActivityTests`): + - *Error:* stub `/v1/conversations/` → 500 with `{"error":{"code":"boom","message":"conversations exploded"}}`; `await project.refreshConversations()`; expect `project.conversationsLoadState.errorMessage` contains `conversations exploded` **and** `project.conversations.isEmpty`. + - *Integration:* stub 500 then 200; refresh, assert failed-with-message; refresh again (the retry path), assert `.loaded` and the rows present — proves Retry actually recovers. + - *Edge:* a failed refresh after a successful one must keep the previously loaded rows (the `refreshCatalog` guarantee `ProjectSessionActivityTests` already asserts for `models`, extended to the new state shape). + - *Per-collection isolation:* stub `/v1/models` → 500 while `/v1/providers` → 200; assert `modelsLoadState.showsError` and `providersLoadState == .loaded` — the reason `failed` carries its own message rather than reading the shared `statusMessage`. +- *Reachability:* assert `Sources/GoCodeUI` contains `CollectionErrorState(` (the component has production call sites) — the `TranscriptFeatureReachabilityTests` pattern. + +**Execution note.** Strict TDD. Land the `CollectionLoadStateTests` additions red first; the enum change is what turns them green, and it is also what breaks compilation across the six views — expected and intended. + +**Verification.** `swift build`, `swift test`, `swift format lint --strict --recursive Sources Tests`. `ProjectSessionActivityTests` must still pass unmodified in intent. + +--- + +### U3 — Reliable run-control acknowledgements + +**Goal.** No run-control call silently fails, and the UI never claims an unacknowledged action succeeded. + +**Requirements.** R3, R4, R11. + +**Dependencies.** None. **Must precede U4** (U4's guard messaging reuses this unit's feedback slot). + +**Files.** +- `macapp/Sources/GoCodeUI/RunSession.swift` (`cancel` ~128-140, `approve` ~142-145, `deny` ~147-150, `answer` ~169-173) +- `macapp/Sources/GoCodeUI/AskUserAnswers.swift` (new — the completeness predicate) +- `macapp/Sources/GoCodeUI/ChatView.swift` (`AskUserView` Send `disabled` at ~855) +- `macapp/Tests/GoCodeUITests/RunControlAckTests.swift` (new) +- `macapp/Tests/GoCodeUITests/AskUserAnswersTests.swift` (new) + +**Approach.** +1. Replace each `try? await client.…` with an awaited call in a do/catch that sets `connectionError` from `HarnessError.message` (or `localizedDescription` for transport errors) — the same catch shape `steer()` already uses correctly at `RunSession.swift:158-166`. `steer` is the in-repo reference implementation; the other four are the outliers. +2. `answer(_:)`: validate first (step 3), then `await client.answerInput`, and clear `pendingQuestions` **only after** it returns. On failure keep the prompt on screen and set `connectionError`. +3. `AskUserAnswers.isComplete(prompt:answers:)` — every question id present with a non-blank trimmed value. Used by both `RunSession.answer`'s guard (root cause: covers any future caller) and `AskUserView`'s `disabled` (so the button reflects the same rule). Replaces the current `answers.count < prompt.questions.count`, which counts a field edited back to `""` as answered. +4. `cancel()`'s two-stage interrupt semantics stay exactly as they are; only the discarded acknowledgement changes. A failed first cancel must **not** leave `cancelRequested == true`, or the operator's second press force-kills a run whose cancel never actually reached the server — reset it in the catch. + +**Patterns to follow.** `RunSession.steer()` (the correct error handling already in this file); `RunSessionConversationStreamTests`' per-file `URLProtocol` stub with per-path queued responses — it can script a 409/500 on `/v1/runs/{id}/approve` directly. + +**Test scenarios** (`RunControlAckTests.swift`, stub-driven): +- *Error / core regression:* `POST /v1/runs/run_1/approve` → 500; call `approve()`; expect `connectionError != nil`. Fails today because `try?` swallows it. +- *Error:* `POST …/deny` → 500 → `connectionError` set. +- *Happy:* `POST …/input` → 200 → `pendingQuestions == nil`. +- *Error / core regression:* `POST …/input` → 409 → `pendingQuestions` is **still non-nil** and `connectionError` is set. Fails today because line 171 clears it before the call. +- *Edge:* `cancel()` where `POST …/cancel` → 500 → `connectionError` set **and** a second `cancel()` still issues a cooperative cancel rather than force-abandoning the stream. +- *Happy:* `cancel()` with 200 → no `connectionError`, `cancelRequested` set, second press marks cancelled (existing behaviour, pinned). +- `AskUserAnswersTests`: all-answered → complete; one id missing → incomplete; a value of `""` → incomplete; a value of `" "` → incomplete (**the finding**); a single freeform question answered → complete. + +**Execution note.** Strict TDD, one red test per behaviour before the corresponding `try?` is removed. + +**Verification.** `swift build`, `swift test`, `swift format lint --strict --recursive Sources Tests`. + +--- + +### U4 — Guard conversation lifecycle during an active run + +**Goal.** New / fork / undo cannot silently race a running turn. + +**Requirements.** R5, R11. + +**Dependencies.** U3. + +**Files.** +- `macapp/Sources/GoCodeUI/ProjectSession.swift` (`newConversation` ~356, `fork` ~362, `undo` ~374) +- `macapp/Tests/GoCodeUITests/ProjectSessionLifecycleGuardTests.swift` (new) + +**Approach.** +1. Each of the three methods gains an early guard on `run?.isBusy == true`: set `statusMessage` naming the action and the reason ("Stop the running task before starting a new conversation."), then return without mutating state or calling the server. +2. `deleteConversation`'s internal `newConversation()` call (`:349`) inherits the guard automatically — that call happens only after a successful server delete, and if a run is active on the deleted conversation the guard's refusal is the correct outcome to surface rather than a silent reset. Assert this explicitly in a test so the interaction is intentional, not incidental. +3. Do **not** add `.disabled(...)` to the six UI call sites in this unit; the shared guard is the correctness fix. Control-state polish is out of scope (see Scope Boundaries). + +**Patterns to follow.** `ProjectSession`'s existing `guard let client …` early-return style; `ProjectSessionActivityTests`' stub for driving a session with a scripted daemon. + +**Test scenarios** (`ProjectSessionLifecycleGuardTests.swift`, stub-driven): +- *Error / core regression:* with a busy run (drive `run.transcript` into a running state via a scripted `run.started` frame, or submit against a stub that never terminates), call `newConversation()`; expect the conversation id is unchanged and `statusMessage` mentions the active run. Fails today. +- *Error:* `await fork()` while busy → **no** `POST /v1/conversations/{id}/fork` recorded by the stub, and `statusMessage` set. Asserting on recorded requests (the `ConversationStreamStub.requests` pattern) is what proves the server was never called. +- *Error:* `await undo()` while busy → no `POST …/undo` recorded. +- *Happy:* with no active run, all three behave exactly as before (fork rebinds, undo reloads, new resets) — the guard must not break the idle path. +- *Integration:* run completes → the previously refused `fork()` now succeeds, proving the guard is state-based and not sticky. + +**Execution note.** Strict TDD. + +**Verification.** `swift build`, `swift test`, `swift format lint --strict --recursive Sources Tests`. + +--- + +### U5 — Delete and undo require a preview confirmation + +**Goal.** Nothing destructive happens without first stating what will be lost. Builds the shared confirmation presentation that U6 and U8 reuse. + +**Requirements.** R6, R11. + +**Dependencies.** U4. + +**Files.** +- `macapp/Sources/GoCodeUI/DesignSystem/DestructiveConfirmation.swift` (new — shared presentation + the preview-text builders) +- `macapp/Sources/GoCodeUI/SessionsView.swift` (delete path ~59, 108-110) +- `macapp/Sources/GoCodeUI/SettingsView.swift` ("Undo Last Prompt" ~198) +- `macapp/Sources/GoCodeUI/ChatView.swift` (`MessageActions` undo ~350-357) +- `macapp/Sources/GoCodeUI/ConversationChrome.swift` (menu undo ~35) +- `macapp/Tests/GoCodeUITests/DestructiveConfirmationTests.swift` (new) + +**Approach.** +1. `DestructiveConfirmation` — a small `Identifiable` value (`title`, `message`, `confirmLabel`, `action`) plus a `View` extension presenting it via the `.alert` + optional-item `Binding` shape already used at `SessionsView.swift:184-200`. One presentation, five call sites (delete, undo, rewind, force rewind in U6, provider remove in U8). +2. Preview text as **pure functions** so they are testable without a view (KTD-5, client-derived): + - `DeletePreview.message(for: ConversationInfo)` → title + `messageCount` when known, and an explicit "message count unknown" wording when the server omitted it. Never fabricate a count. + - `UndoPreview.message(lastPrompt: String?)` → quotes the truncated last user prompt from `run.transcript.items`, or a neutral "the last turn" when the transcript holds none. +3. Route all four undo entry points and the delete entry point through the shared confirmation. `ProjectSession.deleteConversation` / `undo` themselves stay unchanged in behaviour — confirmation is a presentation concern and the guard from U4 is the model-level protection. + +**Patterns to follow.** `CheckpointsView`'s existing alert (`SessionsView.swift:184-200`) — same `Binding` derivation, same "Cancel is `.cancel`, the destructive verb is `.destructive`" role assignment, same "it cannot be undone" plainness. + +**Test scenarios** (`DestructiveConfirmationTests.swift`): +- *Happy:* `DeletePreview.message` for a conversation with `messageCount == 12` contains the title and `12`. +- *Edge:* `messageCount == nil` → the message states the count is unknown and contains **no** invented number (assert the string has no digits, or matches the explicit unknown wording). +- *Edge:* a very long conversation title is truncated to a bounded length (assert an upper bound), so the alert cannot become unreadable. +- *Happy:* `UndoPreview.message(lastPrompt: "fix the parser")` quotes it; `lastPrompt: nil` → the neutral wording. +- *Edge:* a multi-line prompt is flattened to one line. +- *Reachability:* `Sources/GoCodeUI` contains no bare `Button("Delete", role: .destructive) { delete(` immediate-action shape and does contain `destructiveConfirmation(` at the delete and undo sites — the source-scan pattern, which is the only available way to assert the confirmation is actually in the path on this test stack. + +**Execution note.** Strict TDD for the preview builders; the presentation wiring is covered by the reachability assertions. + +**Verification.** `swift build`, `swift test`, `swift format lint --strict --recursive Sources Tests`. Manual smoke required (see Verification Contract) because alert presentation itself is not headlessly assertable. + +--- + +### U6 — Surface `rewind_refused` structurally and offer a distinct force confirmation + +**Goal.** The server's safety refusal becomes a real, actionable state with its own, more severe second confirmation. + +**Requirements.** R7, R11. + +**Dependencies.** U5 (shared confirmation). + +**Files.** +- `macapp/Sources/GoCodeUI/ProjectSession.swift` (`rewind` ~386-399, plus a new `rewindRefusal` observable) +- `macapp/Sources/GoCodeUI/SessionsView.swift` (`CheckpointsView` ~142-201, including removal of the stale NOTE at ~144-152) +- `macapp/Tests/GoCodeUITests/ProjectSessionRewindTests.swift` (new) + +**Approach.** +1. Add `public private(set) var rewindRefusal: RewindRefusal?` where `RewindRefusal` is `{ pointID: String, message: String }`. In `rewind`'s `catch let error as HarnessError`, branch on `error.code == "rewind_refused"` (KTD-6): set `rewindRefusal` and leave `statusMessage` for the generic case. Clear `rewindRefusal` at the start of every `rewind` call and on success. +2. `CheckpointsView` presents a **second** `DestructiveConfirmation` keyed off `project.rewindRefusal`, worded distinctly from the first: the first says the restore overwrites files and truncates history; the second says a file changed **outside the harness** since the checkpoint, quotes the server's message, and its confirm label is "Restore Anyway" (not "Restore"). Confirming calls `rewind(to:force: true)`. +3. Never auto-retry with force — the honest reading of the `ClientConversations.swift:126-130` contract note. Cancelling the second confirmation clears `rewindRefusal` and performs nothing. +4. Delete the now-false NOTE at `SessionsView.swift:144-152`. + +**Patterns to follow.** The existing checkpoint alert; `importSubscription`'s pattern of catching `HarnessError` specifically to add actionable context (`ProjectSession.swift:418-425`). + +**Test scenarios** (`ProjectSessionRewindTests.swift`, stub-driven): +- *Error / core regression:* stub `POST /v1/conversations/{id}/rewind` → 409 `{"error":{"code":"rewind_refused","message":"README.md changed outside the harness"}}`; `await project.rewind(to: point)`; expect `rewindRefusal?.pointID == point.id` and its message contains the server text. Fails today (collapsed into `statusMessage`). +- *Error isolation:* a 500 `internal_error` → `rewindRefusal == nil` and `statusMessage` set. A generic failure must not offer a force path. +- *Integration:* 409 then, on the forced call, 200 → the second request body carries `"force":true` (assert on the recorded request body), `rewindRefusal` cleared, and `statusMessage` reports the restore counts. +- *Edge:* a second refusal on the forced call → `rewindRefusal` set again rather than looping or clearing silently. +- *Happy:* a 200 first attempt → `rewindRefusal == nil`, counts reported, conversation reloaded (existing behaviour, pinned). +- *Reachability:* `SessionsView.swift` contains `rewind(to:` with `force: true` **only** inside the refusal-confirmation branch, and the stale NOTE text is gone. + +**Execution note.** Strict TDD. + +**Verification.** `swift build`, `swift test`, `swift format lint --strict --recursive Sources Tests`, plus a manual force-path smoke (edit a file outside the app, then attempt a restore). + +--- + +### U7 — Cursor-aware Up/Down prompt-history navigation + +**Goal.** Up/Down in the composer walk prompt history in both directions and stop destroying in-progress drafts. Closes the gap left by contract issue #927. + +**Requirements.** R8, R11. + +**Dependencies.** None (ordered after U6 only because it shares `ChatView.swift` with U1/U3). + +**Files.** +- `macapp/Sources/GoCodeUI/PromptHistory.swift` (new) +- `macapp/Sources/GoCodeUI/RunSession.swift` (replace `promptHistory: [String]` ~26, its append at ~74, and `recallPreviousPrompt()` ~205-208) +- `macapp/Sources/GoCodeUI/ChatView.swift` (`Composer` — add `.onKeyPress` handling to the draft field ~888-894) +- `macapp/Tests/GoCodeUITests/PromptHistoryTests.swift` (new) + +**Approach.** +1. `PromptHistory` value type: + ``` + struct PromptHistory { + private var entries: [String] + private var cursor: Int? // nil == not navigating + private var stashedDraft: String? + mutating func record(_ prompt: String) + mutating func recallPrevious(currentDraft: String) -> String? // nil == decline + mutating func recallNext() -> String? // nil == past the newest; caller restores stash + mutating func reset() + } + ``` +2. Decline rule (KTD-7): `recallPrevious` returns `nil` when not already navigating **and** `currentDraft` is non-blank — an in-progress draft is never overwritten. When navigation starts from an empty draft, the draft is stashed and the cursor walks backwards; `recallNext` walks forward and, past the newest entry, restores the stash. +3. `RunSession` keeps a `PromptHistory`, records on `submit()`, and exposes `recallPreviousPrompt()` / `recallNextPrompt()` that assign into `draft`. Preserve the existing public `promptHistory` read accessor (or a read-only `entries` projection) so nothing outside breaks silently. +4. `Composer`: `.onKeyPress(.upArrow)` / `.onKeyPress(.downArrow)` on the draft `TextField`, returning `.handled` only when the session actually recalled something and `.ignored` otherwise, so the field's own navigation still works inside a multi-line draft. `.onKeyPress` is available on the `.macOS(.v14)` floor; caret position is not (see KTD-7). +5. Any draft edit that is not a recall clears the cursor, so typing after recalling starts a fresh navigation next time. + +**Patterns to follow.** `MarkdownBlock` (pure parser value type, exhaustively unit-tested, thin view on top); `FileCompletion` + `MentionQuery` (composer behaviour already lives in testable helpers rather than in the view body). + +**Test scenarios** (`PromptHistoryTests.swift`): +- *Happy:* record `["a","b","c"]`; `recallPrevious(currentDraft: "")` → `"c"`, again → `"b"`, again → `"a"`. +- *Edge:* a fourth `recallPrevious` at the oldest entry → stays `"a"` (no wraparound, no nil-after-start). +- *Happy:* after walking back to `"a"`, `recallNext()` → `"b"`, → `"c"`, → the stashed draft (empty string), and one more → `nil`. +- *Core regression:* `recallPrevious(currentDraft: "half-typed thought")` with history present → `nil`, and the history cursor is unmoved. This is the "does not destroy a draft" guarantee. +- *Edge:* navigation started from empty, then `recallNext` past the newest → the stash is restored exactly, including a draft that was empty. +- *Edge:* `record` while navigating resets the cursor, so the next Up starts from the newest entry. +- *Edge:* empty history → `recallPrevious` returns `nil` and does not crash. +- *Regression:* duplicate consecutive prompts are both recorded (history is literal, not deduped) — pinned so a later "tidy-up" cannot silently change navigation counts. +- *Reachability:* `ChatView.swift` contains `.onKeyPress(.upArrow` and `.onKeyPress(.downArrow` — #998's specific finding was that `recallPreviousPrompt` had **no** production call site, so a reachability assertion is mandatory here, not optional. + +**Execution note.** Strict TDD. + +**Verification.** `swift build`, `swift test`, `swift format lint --strict --recursive Sources Tests`, plus a manual key smoke in the running app (a key handler cannot be asserted headlessly). + +--- + +### U8 — Accessibility for rows and toggles, and truthful settings feedback + +**Goal.** Rows are controls, toggles are named, and a settings action's result is not erased by its own reload. + +**Requirements.** R9, R10, R11. + +**Dependencies.** U5 (reuses `DestructiveConfirmation` for provider removal). + +**Files.** +- `macapp/Sources/GoCodeUI/SessionsView.swift` (conversation rows ~41-61) +- `macapp/Sources/GoCodeUI/SettingsView.swift` (`ModelsTab` row tap ~157-158) +- `macapp/Sources/GoCodeUI/ModelSettingsView.swift` (`load()` status clearing ~45-50; `fetch` ~53-67; exposure `Toggle` ~356-365; provider `Remove` ~291-295) +- `macapp/Tests/GoCodeUITests/ModelSettingsFeedbackTests.swift` (new) +- `macapp/Tests/GoCodeUITests/AccessibilityReachabilityTests.swift` (new) + +**Approach.** +1. **Rows become controls.** Replace `onTapGesture` with a `Button` wrapping the row content (`.buttonStyle(.plain)` to keep the current look), which restores keyboard focus, Return activation, and a VoiceOver actionable trait. Two sites, same class of defect: `SessionsView.swift:44` and `SettingsView.swift:158`. The context menu on the conversation row is retained. +2. **Named toggle.** `.accessibilityLabel("Show \(entry.modelID) in the picker")` alongside the existing `.labelsHidden()` + `.help(...)`. `labelsHidden` is a layout choice; it must not also remove the accessible name. +3. **Status survives its reload.** `ModelSettingsModel.load()` currently sets `status = nil` on success (`:45`), which erases the message `fetch` just set two lines earlier (`:58-59`) — the operator never sees "Fetched N models". Give `load(clearingStatus: Bool = true)`; the `.task` initial load clears, and `fetch` / `setExposed` / `setAllVisible` / `saveProvider` / `delete` call `load(clearingStatus: false)` so their own message survives. +4. **Confirmed provider removal.** Route `Remove` through `DestructiveConfirmation` from U5, stating that removing a provider drops its exposed models (the consequence already documented at `ModelSettingsView.swift:115-117`). + +**Patterns to follow.** `RailRow` (`AppShell.swift:170-212`) is this app's reference accessible row: a `Button`, decorative icon `.accessibilityHidden(true)`, explicit `.accessibilityLabel`. `CopyMessageButton` / `ChatView`'s toolbar show the paired `.help` + `.accessibilityLabel` convention. `ProjectSessionActivityTests`' stub is the model for driving `ModelSettingsModel` headlessly. + +**Test scenarios.** +- `ModelSettingsFeedbackTests.swift` (stub-driven against `ModelSettingsModel`): + - *Core regression:* stub `POST /v1/model-settings/{provider}/fetch` → success and `GET` model-settings → success; `await model.fetch("openai")`; expect `model.status` still contains "Fetched". Fails today because `load()` nils it. + - *Error:* fetch fails → `status` contains the provider's verbatim reason **and** survives the subsequent `load()`. + - *Happy:* the initial `.task` load with no prior status → `status == nil`, `loadState == .loaded` (the clearing path is still correct where it is wanted). + - *Edge:* `setExposed` failing → its error message survives the reload. + - *Edge:* `load(clearingStatus: true)` after a stale error → status cleared, so an error cannot become permanent. +- `AccessibilityReachabilityTests.swift` (source-scan, the pattern from `TranscriptFeatureReachabilityTests`): + - `SessionsView.swift` no longer contains `.onTapGesture` on a conversation row, and the row carries an `.accessibilityLabel`. + - `SettingsView.swift` no longer contains `.onTapGesture { project.selectedModel = model.id }`. + - `ModelSettingsView.swift` contains `.accessibilityLabel(` within the exposure-toggle region and no bare immediate `Task { await model.delete(` outside a confirmation. + - Guard against regression breadth: assert `Sources/GoCodeUI` contains no `.onTapGesture` on any row that also carries `.contentShape(.rect)` — the exact shape of this defect, so a third instance cannot be introduced. + +**Execution note.** Strict TDD: feedback tests red first; the reachability assertions are written red against current source and go green with the view edits. + +**Verification.** `swift build`, `swift test`, `swift format lint --strict --recursive Sources Tests`, plus a VoiceOver + keyboard-only smoke on the Sessions list and the Models settings pane. + +--- + +## Scope Boundaries + +**In scope.** The eight units above, entirely within `macapp/Sources/GoCodeUI`, `macapp/Sources/GoCodeUI/DesignSystem`, and `macapp/Tests/GoCodeUITests`. + +**Out of scope.** +- **`ProjectSession` re-architecture.** It stays one observable owning phase, collections, and actions. In particular the missing `HarnessClient` injection seam (noted at `ProjectSessionActivityTests.swift:9-13`) is *not* introduced here, even though it would make U2/U4/U6 tests cleaner than a globally registered `URLProtocol`. +- **Visual restyling.** No token values change except the one new autoscroll threshold in U1. No colour, type, or spacing revisions. +- **Server / API redesign.** No Go changes. Two contract gaps were checked and found *not* to require server work: run-control acknowledgements already throw structurally (KTD-1), and `rewind_refused` is already a distinct error code (KTD-6). The one genuine absence — a dry-run preview for undo/delete — is worked around client-side (KTD-5) rather than met with a new endpoint. +- **`HarnessKit` changes.** None required. `HarnessClient.undo` still discards the server's `removed_from_step` / `remaining_messages` response body; surfacing it would be a `HarnessKit` change and is deferred. + +**Deferred to follow-up work.** +- D1: True caret-position-aware history navigation (needs an `NSTextView` bridge or a macOS 15 platform floor). See KTD-7. +- D2: Decoding the undo response so the *post-action* confirmation can report the server's real counts instead of only a pre-action client-derived preview. +- D3: A `HarnessClient` injection seam on `ProjectSession`, replacing the global `URLProtocol` registration in session tests. +- D4: `.disabled(...)` polish on the six lifecycle call sites, so a guarded action looks unavailable before it is pressed (U4 makes it *safe*; this would make it *obvious*). + +--- + +## Risks + +| ID | Risk | Mitigation | +|---|---|---| +| K1 | U2's `failed(String)` change breaks compilation in six view files and both existing load-state tests at once. | It runs early and alone; `ProjectSessionActivityTests` and `CollectionLoadStateTests` are the tripwires. Its diff is mechanical after the enum lands. | +| K2 | U1's geometry feedback loop could oscillate — a programmatic `scrollTo` re-pins, which triggers another scroll. | The pin is a threshold predicate, not an animation trigger; `scrollIfPinned` already only fires on item-id / length change, not on geometry change. Assert boundary and overscroll cases explicitly. | +| K3 | Alert presentation, `.onKeyPress`, and VoiceOver traits cannot be asserted headlessly on this test stack. | Testable logic is extracted into value types; wiring is pinned by source-scan reachability tests; a manual smoke is a required gate (see Verification Contract), not optional. | +| K4 | Source-scan reachability tests are brittle — a refactor that renames a symbol fails a test that is not describing a real regression. | Follow the existing precedent (`TranscriptFeatureReachabilityTests:31-38`): scan the whole module for a behaviour's presence rather than pinning a file/line. | +| K5 | U3 changes interrupt semantics; getting `cancelRequested` wrong turns a failed cooperative cancel into a surprise force-kill. | Explicit test: a failed first cancel leaves the second press cooperative. | +| K6 | Confirmations added to delete/undo/rewind/remove could become fatigue-inducing and get click-through-ed. | Confirmations are added only to the four genuinely destructive actions; the force-rewind wording is deliberately distinct from the ordinary rewind wording so the second one still reads as more severe. | +| K7 | Eight sequential slices on one fast-moving `main` (see `CLAUDE.md` merge discipline) risk a conflict-heavy long-lived branch. | One PR per unit, merged promptly; each unit is independently green. | + +--- + +## Assumptions + +Headless-mode calls made without asking, each recorded so they can be overridden: + +1. **A1 — Serial execution, not parallel.** Every unit shares at least one file with another (`ChatView.swift` across U1/U3/U7; `ProjectSession.swift` across U2/U4/U5/U6; `SessionsView.swift` across U2/U5/U6/U8), so the plan orders them rather than fanning them out. The epic's logical constraint (U3→U4→U5→U6) is preserved inside that order. +2. **A2 — U8 depends on U5.** The epic lists slice 8 as independent, but slice 8's provider-`Remove` item is a destructive confirmation and would otherwise need its own competing presentation. See the deviation note below. +3. **A3 — "Cursor-aware" (#998) is approximated by draft state, not caret position.** Justified by the `.macOS(.v14)` platform floor in `macapp/Package.swift`. See the deviation note. +4. **A4 — Undo/delete previews are client-derived and explicitly say when a count is unknown.** No fabricated numbers; no new server endpoint (KTD-5). +5. **A5 — `connectionError` is the acknowledgement-failure slot** rather than a new observable, because `InlineRunStatus` already renders it (KTD-1). +6. **A6 — `CollectionErrorState` shows the server's message verbatim**, consistent with `ModelSettingsView.swift:62-66`'s stated reasoning, rather than a summarised house style. +7. **A7 — One PR per unit.** Matches this repo's merge discipline; the epic's eight child issues map one-to-one to the eight units. +8. **A8 — The `.failed(String)` message is `error.localizedDescription`** (which for `HarnessError` is its `message`, via the `LocalizedError` conformance at `HarnessClient.swift:17-19`), so no extra unwrapping is needed at the 8 assignment sites. + +--- + +## Verification Contract + +**Per unit, in order, all run from `macapp/`:** + +1. `swift build` — clean. +2. `swift test` — green. Baseline for comparison: **177 tests passed, 0 failures at `8f2e412`** — this figure comes from the epic briefing (prior investigation) and was **not re-run while writing this plan**; re-establish it before U1 so later counts are comparable. +3. `swift format lint --strict --recursive Sources Tests` — clean. This is exactly the `format` job in `.github/workflows/macapp.yml:83`. +4. Test count must strictly increase per unit (every unit adds tests), and no previously passing test may be deleted to make a unit pass. + +**After the automated gates, per unit, against the real app and a real daemon** (`macapp/scripts/live-test.sh` builds `harnessd`; `HARNESS_TEST_BASE_URL` also enables the live suites): + +| Unit | Manual smoke | +|---|---| +| U1 | Start a long run, scroll up mid-stream, confirm the view stays put; scroll back to the bottom, confirm autoscroll resumes. | +| U2 | Kill `harnessd` mid-session, open Sessions / Activity / Models, confirm an inline error with a reason and a working Retry — not skeletons. | +| U3 | Deny an approval with the daemon stopped; confirm the failure is visible and the approval bar does not falsely clear. | +| U4 | Start a run, then attempt New / Fork / Undo; confirm each refuses with an explanation and nothing changes. | +| U5 | Delete a conversation and undo a turn; confirm each names what will be lost, and Cancel really cancels. | +| U6 | Edit a checkpointed file outside the app, attempt a restore; confirm the refusal is explained and the distinct "Restore Anyway" path works. | +| U7 | Press Up/Down in the composer with history present, then with a half-typed draft; confirm the draft is never clobbered. | +| U8 | Navigate the Sessions list and the Models pane by keyboard only, then with VoiceOver; confirm rows are reachable and named and that "Fetched N models" stays on screen. | + +Both CI jobs (`build-test` hermetic and `live-harnessd`) must be green before merge; re-run known-flaky checks rather than merging red. + +--- + +## Definition of Done + +- All eight units merged, each as its own PR closing its child issue (#992–#999), with #991 closed last. +- Every requirement R1–R11 has at least one test that fails if the behaviour regresses, and each unit's "core regression" test was demonstrably red before its implementation commit. +- `swift build`, `swift test`, and `swift format lint --strict --recursive Sources Tests` green on `main` after the final merge; test count strictly above the 177 baseline. +- The manual smoke table above is executed against a real `harnessd`, including the VoiceOver and keyboard-only pass for U8. +- Stale in-code notes retired: the `rewind_refused` NOTE at `SessionsView.swift:144-152` is deleted (U6), and `recallPreviousPrompt` has a production call site (U7). +- Deferred items D1–D4 are filed as follow-up issues rather than left in this document as the only record. +- `docs/logs/long-term-thinking-log.md` updated with the durable intent and success criteria for this epic, per `CLAUDE.md`. + +### Deviations from the epic text (reported, not suppressed) + +1. **#998's "cursor-aware" cannot be implemented literally on this platform floor.** `macapp/Package.swift:6` pins `.macOS(.v14)`. A SwiftUI text-selection/caret binding (`TextSelection`) is a macOS 15 API, so no supported way exists to read the caret from a SwiftUI `TextField` here. *Provenance: the platform floor is verified in the manifest; the API-availability claim is from my own knowledge of the SwiftUI release timeline and was not re-checked against the installed SDK in this session.* The plan therefore approximates the intent (never clobber an in-progress draft) via draft state, and defers true caret-awareness as D1. If literal caret-awareness is required, the cost is an `NSViewRepresentable` `NSTextView` composer — a much larger change than #998 implies. +2. **#999 (slice 8) is not independent of #996 (slice 5).** The epic's own rationale for ordering 5→6 is a shared destructive-confirmation presentation; slice 8's immediate provider `Remove` (`ModelSettingsView.swift:291-295`) is the same kind of action and needs the same component, so U8 is ordered after U5. Keeping it "independent" would mean either a second, competing confirmation implementation or leaving provider removal unconfirmed. +3. **File overlap makes the epic's "slices 1, 2, 7, 8 independent" true logically but not operationally.** U1, U3, and U7 all edit `ChatView.swift`; U2, U4, U5, and U6 all edit `ProjectSession.swift`. They are ordered serially for that reason (A1), not because of a hidden logical dependency. diff --git a/docs/plans/2026-07-31-pr-1021-gui-hardening-repair-impact-map.md b/docs/plans/2026-07-31-pr-1021-gui-hardening-repair-impact-map.md new file mode 100644 index 00000000..f2b2b134 --- /dev/null +++ b/docs/plans/2026-07-31-pr-1021-gui-hardening-repair-impact-map.md @@ -0,0 +1,72 @@ +# PR #1021 GUI Hardening Repair Impact Map + +## Task + +- Task / issue: Repair PR #1021 for epic #991 children #992–#999 after independent production review. +- Plan link: `2026-07-30-001-feat-macapp-gui-hardening-plan.md` +- Owner: PR #1021 repair branch `codex/pr-1021-repair` +- Status: Source repair and Swift/live-harnessd gates complete; hosted Go race remains blocked on existing issue #1039 / PR #1041, while installed-app smokes and the separate Settings investigation remain pending. + +## Current Ownership, Callers, and Data Flow + +- Entry points: transcript streaming and history keys in `ChatView`; run controls and conversation SSE in `RunSession`; project collection/lifecycle actions in `ProjectSession`; Sessions, Activity, Models, and conversation chrome controls. +- Owning packages/types/functions and source of truth: `RunSession` owns active run/control request state; `ProjectSession` owns project collections and conversation selection; `Transcript` owns rendered event reduction; harnessd remains authoritative for runs, pending input, conversations, and rewind results. +- Callers, consumers, events, and downstream data: per-run and conversation-wide SSE both call `RunSession.apply`; SwiftUI tasks and Retry controls call project refresh methods; lifecycle controls call the shared `ProjectSession` boundaries. +- Similar abstractions searched: `cancelState`, `answerInFlight`, `seenEventIDs`, `reconcilePersistedMessages`, `syncCurrentConversation`, collection load states, prompt-history cursor state, and destructive-confirmation presentation. +- Search commands/evidence: `rg -n "answerInFlight|runControlTask|pendingInput|refreshCatalog|refreshConversations|refreshActivity|refreshRewindPoints|openConversation|scrollIfPinned|isRecallingHistory|rewind\\(" macapp`; three-tree merge inspection against `origin/main`. +- Duplication/ownership conclusion: request identity belongs with the owning async model, not individual views. Views consume observable state and supply accessibility presentation only. + +## Config, API, CLI, and Tools + +- User-facing config added or changed: None; repository and macOS settings formats are unchanged. +- Defaults / fallbacks: Existing model, profile, provider, and run fallbacks are unchanged. +- Environment variables, config files, or saved settings touched: None; live-test environment variables are used only by verification. +- Endpoints, request fields, response fields, or server wiring affected: None. Existing runs, pending-input, conversation, catalog, and rewind endpoints are consumed without wire changes. +- CLI commands, tools, wire formats, or integrations affected: None; TUI and web code are unchanged. +- Error states / validation changes: stale async completions are ignored; controls become single-flight; steering restores an unmodified draft after failure; stale collection data stays visible with a refresh failure. + +## Persistence and Compatibility + +- Schemas, migrations, caches, generated data, or ownership changes: None. +- Backward/forward compatibility and versioning: Client-only state ownership; compatible with the current harnessd API. +- Partial rollout and mixed-version behavior: A repaired app remains compatible with current harnessd. An older app retains the reviewed races but does not corrupt persisted data. + +## Lifecycle, Security, and Reliability + +- Concurrency, cancellation, retries, cleanup, and resource ownership: Per-request generations or owned tasks protect answer, pending-input, run-control, autoscroll, and collection loads. Cancellation and generation invalidation occur on reset, selection changes, and newer requests. Pending-answer view identity follows the server call id; transcript pin/autoscroll identity follows the selected conversation; reserved startup refreshes validate generation before setting loading; a slow or stale run-scoped todo result cannot block or abort independent task/run refreshes; rewind refusals retain and synchronously claim their originating conversation before force I/O is scheduled; local force cancel releases the run identity; submitted-run accounting is bound to the server-returned run id and sealed cost status is owned by that run so unrelated or late duplicate-stream events cannot overwrite it. +- Authentication, authorization, permissions, trust, privacy, and secrets: None; no credentials or authorization boundaries change. +- Failure modes, recovery, idempotency, and data repair: Duplicate control POSTs and stale state writes are prevented. Existing Retry controls remain the recovery path. No data repair is required. + +## Product and Integration Surfaces + +- Server/runtime: API behavior unchanged; merged #1008/#1028 replay and terminal reconciliation must remain authoritative. +- TUI/web/macOS/other clients: macOS only. TUI/web are regression-only surfaces. +- Provider/model/tool catalog and routing: Catalog display refresh ownership changes; provider/model routing and saved settings do not. +- External systems and automation: #1007 external cron/callback run-control binding is explicitly not implemented here. Observable external-run busy state remains a #995 guard input when delivered. +- UX states, keyboard/focus/accessibility/motion: Jump to Latest, Reduce Motion, disabled lifecycle reasons, single-flight control feedback, stale-data notices, and duplicate-history key bookkeeping are affected. + +## Deployment and Operations + +- Deployment/migration order and feature flags: Ship with the next macOS app build after current-main integration; no flag or server ordering. +- Logs, metrics, traces, alerts, and support diagnostics: Existing visible status/error surfaces remain; no new telemetry. +- Rollback triggers and recovery steps: Revert the repair commits while retaining the `origin/main` merge if controls deadlock, transcript following regresses, or current conversation reconciliation duplicates/drops rows. +- Runbooks and operator docs: Existing macOS verification contract and issue #1020 track live installed-app proof. + +## Regression Tests + +- Characterization and first expected red test: stale answer A clearing newer B; overlapping autoscroll completion; older collection/open response overwriting newer state; older pending-input result replacing a newer prompt; duplicate control request; identical prompt recall without `onChange`. +- New acceptance tests required: generations and reset invalidation; latest-request wins per collection; conversation target validation; single-flight control state; steering draft restoration; Jump to Latest and Reduce Motion reachability; rewind busy guard. +- Edge, negative, failure, lifecycle, and security tests: stale run, new manual draft after steering, response reordering, reset mid-request, repeated keys with equal strings, cancellation/retry, active external transcript state. +- Integration/e2e/real-path proof: Swift package and live-harnessd automated suites now; installed app/manual interactions remain pending issue #1020 and the separate Settings investigation. +- Cross-surface regressions to guard: #1008 persisted/live replay dedupe, #1028 terminal reconciliation (including sealed usage/cost accounting for completed, failed, and cancelled runs across a durable-row rebuild), #995 lifecycle guards, #994 pending-input retention. +- Exact targeted and full commands: focused repair integration passed 93 tests / 12 suites; per-run terminal accounting, pending-answer identity, partial activity refresh, conversation-bound rewind, force-confirmation ordering, local cancel cleanup, slow-todo independence, sealed cost-status authority, submitted-run reservation, reserved-refresh loading ownership, and conversation-scoped transcript pin regressions were observed red then green; the latest focused run passes 5 tests / 2 suites; the exact `RunSessionLiveTests` failure reproduced locally and passed after the repair; `swift build --package-path macapp` passed; `swift test --package-path macapp` passed 316 tests / 55 suites; strict recursive Swift format lint passed; and `go test ./internal/server ./internal/harness ./internal/store` passed. The prior exact hosted head passed build-test, format, live-harnessd, test-fast, and test-race; the new review-repair head must rerun those gates. A prior hosted attempt and repeated targeted diagnostics exposed the current-main worktree-cleanup race already owned by #1039 / green PR #1041; PR #1021 does not duplicate that Go test fix, and the safe stacking order remains #1041 first. + +## Documentation and Handoff + +- Specs/public docs before code: Child issue repair comments and this impact map; original plan repair addendum. +- Implementation notes/logs/indexes after code: engineering and long-term logs, plan index, residual-findings index, and master docs index. +- Training/onboarding/release notes: None; this repairs already-promised behavior without adding a new public API. + +## Warning Check + +- Every required surface is covered above. `None` entries include a searched rationale. diff --git a/docs/plans/INDEX.md b/docs/plans/INDEX.md index d88d3da9..c1f11b8d 100644 --- a/docs/plans/INDEX.md +++ b/docs/plans/INDEX.md @@ -1,5 +1,7 @@ # Plans Index +- `2026-07-31-pr-1021-gui-hardening-repair-impact-map.md` — Cross-surface repair map for PR #1021 after independent production review. +- `2026-07-30-001-feat-macapp-gui-hardening-plan.md` — Epic #991 macOS GUI correctness, safety, accessibility, and PR #1021 source repair (Swift/live gates complete; #1041 and native proof pending). - `2026-07-30-issue-1049-workflow-failure-timeout-plan.md` — Issue #1049 planned contention-tolerant workflow failure-event regression wait. - `2026-07-30-issue-1049-workflow-failure-timeout-impact-map.md` — Cross-surface impact map for Issue #1049. - `2026-07-30-issue-1044-ask-status-race-plan.md` — Issue #1044 planned synchronization of the AskUserQuestion status regression fixture. diff --git a/docs/residual-review-findings/INDEX.md b/docs/residual-review-findings/INDEX.md new file mode 100644 index 00000000..fe7fd7f9 --- /dev/null +++ b/docs/residual-review-findings/INDEX.md @@ -0,0 +1,3 @@ +# Residual Review Findings Index + +- `feat-macapp-gui-hardening.md`: Independent and adversarial findings for epic #991 / PR #1021, including repaired and intentionally deferred obligations. diff --git a/docs/residual-review-findings/feat-macapp-gui-hardening.md b/docs/residual-review-findings/feat-macapp-gui-hardening.md new file mode 100644 index 00000000..f9251a46 --- /dev/null +++ b/docs/residual-review-findings/feat-macapp-gui-hardening.md @@ -0,0 +1,95 @@ +# Residual review findings — feat/macapp-gui-hardening (epic #991) + +Plan: `docs/plans/2026-07-30-001-feat-macapp-gui-hardening-plan.md` + +## Adversarial review summary + +An adversarial review pass ran across 4 dimensions (correctness, concurrency/races, safety-guard +completeness, accessibility) against the branch's 8 implemented slices. It confirmed 15 findings; +14 were fixed on-branch. Fix commits: + +- `dd0ae517` — test(red)(macapp): failing tests for review fixes F1-F8 +- `4250d9f2` — fix(macapp): implement review fixes F1-F8 +- `c4a0b5ea` — test(regression)(macapp): regression coverage for F1a/F4 fixes in `4250d9f2` +- `e8e06514` — test(red)(macapp): failing test for stale runControlTask write race +- `cb8ab7db` — fix(macapp): guard runControlTask against stale post-reset writes +- `83d3a29e` — test(macapp): steer failure coverage and try?-absence scan for steer +- `c540f3e1` — refactor(macapp): rewire duplicate source-scan helpers to ReachabilitySource +- `158a26b4` — fix(macapp): ReachabilitySource.wholeModule scans Sources/GoCodeUI recursively + +## 2026-07-31 independent production-review repair + +PR #1021 was re-reviewed at exact head +`1f2444b2480b5832139318e4fa034f4240d92b8d` and integrated with +`origin/main` at `b3afc7ec487c60762a91a1219ceb92c523ef0e78`. +The repair branch closes the newly confirmed source/test gaps: + +- generation ownership for run registration, answer acknowledgements, pending + questions, catalog/conversation/activity/rewind loads, conversation opening, + and durable conversation sync; +- one cancellable and generation-checked transcript autoscroll completion, + Reduce Motion behavior, and an accessible Jump to Latest control; +- single-flight approve/deny/steer controls with exact failed-steer draft + restoration that never overwrites a newer manual edit; +- lifecycle and rewind busy guards with shared disabled reasons exposed through + mouse help and VoiceOver hints; +- stale collection rows preserved under a failed refresh; +- duplicate/declined prompt-history recall bookkeeping; +- the missing impact map, repair plan addendum, engineering/intent logs, and + documentation indexes. + +The merge explicitly preserves #1008 conversation replay deduplication and +#1028 failed/cancelled terminal reconciliation. External conversation-stream +activity contributes observable busy state to #995 guards, but actionable +scheduled-run identity remains #1007 and is not implemented by this PR. +Hosted live-harnessd verification additionally exposed and repaired terminal +usage/cost loss when durable replay immediately followed a terminal event. +Hosted Go race promotion remains blocked by the already-owned current-main +cleanup race in #1039 / PR #1041 rather than duplicating that fix here. +A subsequent Codex pass repaired three further ownership edges: pending-answer +view state now follows `callID`, a stale run-todo response no longer aborts +independent activity collections, and rewind refusals cannot cross or retry +against a different conversation. +The remaining accounting review threads are also repaired: cumulative +usage/cost and priced status reset at the new-run boundary, late prior-run +events cannot reclaim the active run's accounting or terminal state, and +sealed totals are applied consistently to completed, failed, and cancelled +runs. +A final ordering pass additionally binds force confirmation before alert +dismissal, releases the active run id on local force cancel, applies ready +tasks/runs without waiting for slow todos, and lets sealed terminal cost status +override earlier deltas while remaining immune to late duplicate status. +The final ownership pass additionally reserves accounting for the exact run id +returned by submission, rejects stale reserved refreshes before they set +loading, and resets transcript pin/autoscroll state when conversation identity +changes. + +## Residuals (not fixed on this branch) + +1. **`setCost` status-erasure gap / Settings investigation.** `ModelSettingsModel.setCost` (`macapp/Sources/GoCodeUI/ModelSettingsView.swift:132-140`) + was not brought in line with the `load(clearingStatus: false)` pattern applied to its five + siblings (`fetch`/`setExposed`/`setAllVisible`/`saveProvider`/`delete`) for the same class of + finding. It remains intentionally pending the separate native Settings root-cause investigation + and is not claimed fixed by the shared stale-row refresh repair. Tracked in follow-up issue: + https://github.com/dennisonbertram/go-code/issues/1020 + +2. **Deferred manual smokes.** The plan's Verification Contract lists six smokes that require a + live app/daemon and were not run in this pass: transcript autoscroll scroll-up, failed-load + retry with the daemon killed, delete/undo confirmation cancel paths, force-rewind "Restore + Anyway", prompt-history Up/Down with a half-typed draft, and VoiceOver + keyboard-only passes + on Sessions/Models. Tracked in the same follow-up issue above. + +3. **Caret-aware prompt history (D1).** Literal caret-position-aware history recall (`#998` + wording) is not implementable on the package's `.macOS(.v14)` floor (`macapp/Package.swift`); + `TextSelection`/caret bindings are a macOS 15 API. The shipped approximation (recall only when + the draft is empty/unchanged) is documented as KTD-7 in the plan. Tracked in the same follow-up + issue above. + +## Process deviation + +The epic's acceptance criteria state each child issue (#992–#999) merges independently. This +branch instead delivers all 8 slices in a single PR. Reason: every unit shares at least one file +with another (`ChatView.swift` across U1/U3/U7; `ProjectSession.swift` across U2/U4/U5/U6; +`SessionsView.swift` across U2/U5/U6/U8), forcing serial execution rather than independent +fan-out — documented in the plan's Assumptions (A1) and Deviations section. Flagged for +maintainer judgment in the PR. diff --git a/macapp/Sources/GoCodeUI/ActivityView.swift b/macapp/Sources/GoCodeUI/ActivityView.swift index 0b01af75..d2f1ceda 100644 --- a/macapp/Sources/GoCodeUI/ActivityView.swift +++ b/macapp/Sources/GoCodeUI/ActivityView.swift @@ -30,12 +30,28 @@ struct ActivityView: View { } SectionBox(title: "Background work") { - if project.tasksLoadState.showsEmptyState(itemCount: project.tasks.count) { + if project.tasksLoadState.showsBlockingError(itemCount: project.tasks.count) { + CollectionErrorState(message: project.tasksLoadState.errorMessage ?? "") { + Task { await project.refreshActivity() } + } + } else if project.tasksLoadState.showsEmptyState(itemCount: project.tasks.count) + { Text("Nothing running.") .font(Typography.body).foregroundStyle(Theme.foregroundTertiary) - } else if project.tasks.isEmpty { + } else if project.tasksLoadState.showsPlaceholder( + itemCount: project.tasks.count) + { LoadingPlaceholder() } else { + if project.tasksLoadState.showsRefreshError( + itemCount: project.tasks.count) + { + CollectionRefreshErrorState( + message: project.tasksLoadState.errorMessage ?? "" + ) { + Task { await project.refreshActivity() } + } + } ForEach(project.tasks) { task in TaskRow(task: task) } @@ -43,9 +59,24 @@ struct ActivityView: View { } SectionBox(title: "Runs") { - if project.runsLoadState != .loaded, project.runs == nil { + if project.runsLoadState.showsBlockingError( + itemCount: project.runs?.count ?? 0) + { + CollectionErrorState(message: project.runsLoadState.errorMessage ?? "") { + Task { await project.refreshActivity() } + } + } else if project.runsLoadState.showsPlaceholder( + itemCount: project.runs?.count ?? 0) + { LoadingPlaceholder() } else if let runs = project.runs { + if project.runsLoadState.showsRefreshError(itemCount: runs.count) { + CollectionRefreshErrorState( + message: project.runsLoadState.errorMessage ?? "" + ) { + Task { await project.refreshActivity() } + } + } if runs.isEmpty { Text("No runs recorded yet.") .font(Typography.body).foregroundStyle(Theme.foregroundTertiary) diff --git a/macapp/Sources/GoCodeUI/AskUserAnswers.swift b/macapp/Sources/GoCodeUI/AskUserAnswers.swift new file mode 100644 index 00000000..ec0cc3d9 --- /dev/null +++ b/macapp/Sources/GoCodeUI/AskUserAnswers.swift @@ -0,0 +1,19 @@ +import Foundation +import HarnessKit + +/// Whether `answers` covers every question in `prompt` with a non-blank +/// value. +/// +/// Replaces `answers.count < prompt.questions.count`, which happily counted +/// a freeform field the operator typed into and then cleared back to `""` +/// (or left as only whitespace) as answered (#994 / F3). Shared by +/// `RunSession.answer`'s guard -- the root-cause fix, covering any future +/// caller -- and `AskUserView`'s `Send` button, so the button's enabled +/// state and the guard that actually submits agree on the same rule. +public enum AskUserAnswers { + public static func isComplete(prompt: AskUserPrompt, answers: [String: String]) -> Bool { + prompt.questions.allSatisfy { question in + !(answers[question.id]?.trimmed.isEmpty ?? true) + } + } +} diff --git a/macapp/Sources/GoCodeUI/ChatView.swift b/macapp/Sources/GoCodeUI/ChatView.swift index 7d1fb204..0e81c89d 100644 --- a/macapp/Sources/GoCodeUI/ChatView.swift +++ b/macapp/Sources/GoCodeUI/ChatView.swift @@ -26,10 +26,14 @@ struct ChatView: View { selected: $selected, project: project ) + .id(run.conversationID) if let plan = run.transcript.pendingPlan { PlanApprovalView(plan: plan, run: run) } else if let prompt = run.pendingQuestions { - AskUserView(prompt: prompt) { run.answer($0) } + AskUserView(prompt: prompt, answerInFlight: run.answerInFlight) { + run.answer($0) + } + .id(prompt.callID) } else if let approval = run.transcript.pendingApproval { ApprovalBar(approval: approval, run: run) } @@ -88,33 +92,100 @@ struct TranscriptView: View { @Bindable var run: RunSession @Binding var selected: ToolActivity? @Bindable var project: ProjectSession + @Environment(\.accessibilityReduceMotion) private var reduceMotion /// Auto-scroll only while the user is already at the bottom, so scrolling /// back to read is not yanked away mid-stream. - @State private var pinnedToBottom = true + @State private var pin = TranscriptScrollPin() + @State private var scrollViewportHeight: CGFloat = 0 + /// The view owns programmatic-scroll timing. Its generation state prevents + /// geometry emitted during an older animation from unpinning a newer one; + /// `TranscriptScrollPin` remains the pure user-intent decision. + @State private var autoscroll = TranscriptAutoscrollState() + @State private var autoscrollCompletionTask: Task? + /// New content that arrived while the operator was reading older rows. + /// This drives the explicit re-follow control without showing it merely + /// because the operator scrolled up through already-seen history. + @State private var hasUnseenContent = false + + private let scrollSpace = "transcript-scroll" var body: some View { ScrollViewReader { proxy in - ScrollView { - ConversationColumn { - LazyVStack(alignment: .leading, spacing: Spacing.large) { - ForEach(TranscriptPresentation.rows(for: items)) { item in - row(for: item).id(item.id) - } - if run.isBusy { - InlineRunStatus(run: run, statusMessage: statusMessage) + ZStack(alignment: .bottomTrailing) { + ScrollView { + ConversationColumn { + LazyVStack(alignment: .leading, spacing: Spacing.large) { + ForEach(TranscriptPresentation.rows(for: items)) { item in + row(for: item).id(item.id) + } + if run.isBusy { + InlineRunStatus(run: run, statusMessage: statusMessage) + } + Color.clear + .frame(height: Spacing.hairline) + .id(bottomAnchor) + .background( + GeometryReader { anchorGeometry in + Color.clear + .preference( + key: TranscriptBottomAnchorKey.self, + value: anchorGeometry.frame(in: .named(scrollSpace)) + .minY + ) + } + ) } - Color.clear.frame(height: Spacing.hairline).id(bottomAnchor) + .padding(.top, Spacing.transcriptTop) + .padding(.bottom, Spacing.large) + // Primary transcript content uses the explicit foreground + // rung so macOS's subdued label default cannot compress the + // measured contrast of the shared body role. + .foregroundStyle(Theme.foreground) + } + } + + if !pin.isPinned && hasUnseenContent { + Button("Jump to Latest") { + jumpToLatest(proxy) } - .padding(.top, Spacing.transcriptTop) - .padding(.bottom, Spacing.large) - // Primary transcript content uses the explicit foreground - // rung so macOS's subdued label default cannot compress the - // measured contrast of the shared body role. - .foregroundStyle(Theme.foreground) + .buttonStyle(.borderedProminent) + .accessibilityHint("Scrolls the transcript to the newest message") + .padding(Spacing.large) } } - .onChange(of: items.last?.id) { _, _ in scrollIfPinned(proxy) } - .onChange(of: lastItemLength) { _, _ in scrollIfPinned(proxy) } + .coordinateSpace(name: scrollSpace) + .background( + GeometryReader { scrollGeometry in + Color.clear + .onAppear { scrollViewportHeight = scrollGeometry.size.height } + .onChange(of: scrollGeometry.size.height) { _, newValue in + scrollViewportHeight = newValue + } + } + ) + .onPreferenceChange(TranscriptBottomAnchorKey.self) { anchorMinY in + // Before the scroll view first reports its own height, + // `anchorMinY - 0` is not a real distance; mid-animation, it + // is real but transient and not the operator's own scroll + // position. Both are skipped for the same reason: this + // update must reflect where the operator actually left the + // scroll, not an artifact of measurement timing or of the + // pin's own programmatic scroll. + guard !autoscroll.suppressesGeometryUpdates, scrollViewportHeight > 0 else { + return + } + pin.update(distanceFromBottom: anchorMinY - scrollViewportHeight) + if pin.isPinned { + hasUnseenContent = false + } + } + .onChange(of: items.last?.id) { _, _ in handleTranscriptChange(proxy) } + .onChange(of: lastItemLength) { _, _ in handleTranscriptChange(proxy) } + .onDisappear { + autoscrollCompletionTask?.cancel() + autoscrollCompletionTask = nil + autoscroll.cancel() + } } } @@ -127,11 +198,46 @@ struct TranscriptView: View { return message.text.count } + private func handleTranscriptChange(_ proxy: ScrollViewProxy) { + guard pin.isPinned else { + hasUnseenContent = true + return + } + scrollIfPinned(proxy) + } + + private func jumpToLatest(_ proxy: ScrollViewProxy) { + hasUnseenContent = false + pin.followLatest() + scrollIfPinned(proxy) + } + private func scrollIfPinned(_ proxy: ScrollViewProxy) { - guard pinnedToBottom else { return } - withAnimation(.easeOut(duration: 0.12)) { + guard pin.isPinned else { return } + autoscrollCompletionTask?.cancel() + let generation = autoscroll.begin(animated: !reduceMotion) + + guard !reduceMotion else { + proxy.scrollTo(bottomAnchor, anchor: .bottom) + return + } + withAnimation(.easeOut(duration: Motion.autoscrollDuration)) { proxy.scrollTo(bottomAnchor, anchor: .bottom) } + // `ScrollViewProxy.scrollTo` has no animation completion callback. + // This owned task is cancelled and generation-checked on every new + // streamed delta, so an older timer cannot clear a newer scroll's + // geometry suppression window. + autoscrollCompletionTask = Task { @MainActor [generation] in + do { + try await Task.sleep(for: .seconds(Motion.autoscrollDuration)) + } catch { + return + } + guard !Task.isCancelled else { return } + autoscroll.finish(generation: generation) + autoscrollCompletionTask = nil + } } @ViewBuilder @@ -167,6 +273,16 @@ struct TranscriptView: View { } } +/// Carries the bottom anchor's position within the transcript scroll view's +/// own coordinate space, so `TranscriptView` can derive its distance from the +/// visible bottom edge without a macOS 15 scroll-geometry API. +private struct TranscriptBottomAnchorKey: PreferenceKey { + static let defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = nextValue() + } +} + /// Collapsed by default, like the TUI's Ctrl+O block: it marks that history was /// folded without burying the conversation in the summary. struct CompactionRow: View { @@ -337,6 +453,7 @@ struct MessageActions: View { let message: String @Bindable var project: ProjectSession @Bindable var run: RunSession + @State private var undoConfirmation: DestructiveConfirmation? var body: some View { HStack(spacing: Spacing.messageActionPitch) { @@ -347,22 +464,41 @@ struct MessageActions: View { } label: { Image(systemName: "arrow.triangle.branch") } - .disabled(run.conversationID == nil) - .help("Fork conversation") + .disabled( + run.conversationID == nil || project.conversationActionDisabledReason != nil + ) + .help(project.conversationActionDisabledReason ?? "Fork conversation") .accessibilityLabel("Fork conversation") + .accessibilityHint(project.conversationActionDisabledReason ?? "") Button { - Task { await project.undo() } + confirmUndo() } label: { Image(systemName: "arrow.uturn.backward") } - .disabled(run.conversationID == nil) - .help("Undo last turn") + .disabled( + run.conversationID == nil || project.conversationActionDisabledReason != nil + ) + .help(project.conversationActionDisabledReason ?? "Undo last turn") .accessibilityLabel("Undo last turn") + .accessibilityHint(project.conversationActionDisabledReason ?? "") } .font(.system(size: IconSize.detail)) .foregroundStyle(Theme.foregroundQuaternary) .buttonStyle(.plain) .frame(maxWidth: .infinity, alignment: .leading) + .destructiveConfirmation($undoConfirmation) + } + + /// States what turn will be lost before it is lost (R6). + private func confirmUndo() { + let lastPrompt = UndoPreview.lastUserPrompt(in: run.transcript.items) + undoConfirmation = DestructiveConfirmation( + title: "Undo last turn?", + message: UndoPreview.message(lastPrompt: lastPrompt), + confirmLabel: "Undo" + ) { + Task { await project.undo() } + } } } @@ -784,7 +920,10 @@ struct ApprovalBar: View { .buttonStyle(.plain).font(Typography.caption).foregroundStyle( Theme.foregroundTertiary) Button("Deny") { run.deny() } - Button("Allow") { run.approve() }.buttonStyle(.borderedProminent) + .disabled(run.runControlInFlight) + Button("Allow") { run.approve() } + .buttonStyle(.borderedProminent) + .disabled(run.runControlInFlight) } if showArguments { ScrollView { @@ -805,6 +944,7 @@ struct ApprovalBar: View { struct AskUserView: View { let prompt: AskUserPrompt + let answerInFlight: Bool let onAnswer: ([String: String]) -> Void @State private var answers: [String: String] = [:] @@ -855,7 +995,9 @@ struct AskUserView: View { Spacer() Button("Send") { onAnswer(answers) } .buttonStyle(.borderedProminent) - .disabled(answers.count < prompt.questions.count) + .disabled( + !AskUserAnswers.isComplete(prompt: prompt, answers: answers) + || answerInFlight) } } // Same 16pt left inset as the transcript column and the status bar. @@ -872,6 +1014,9 @@ struct Composer: View { @FocusState private var focused: Bool @State private var mentions: [FileCompletion.Match] = [] @State private var mentionTask: Task? + /// Set for the one `run.draft` change caused by a history recall, so the + /// `onChange` below skips clearing navigation for its own update (#998). + @State private var isRecallingHistory = false var body: some View { VStack(alignment: .leading, spacing: Spacing.standard) { @@ -893,7 +1038,43 @@ struct Composer: View { .lineLimit(1...10) .focused($focused) .onSubmit(send) - .onChange(of: run.draft) { _, text in updateMentions(for: text) } + .onChange(of: run.draft) { _, text in + updateMentions(for: text) + if isRecallingHistory { + isRecallingHistory = false + } else { + run.noteManualDraftEdit() + } + } + // Up/Down recall prompt history (#998). Returning + // `.ignored` when nothing was recalled lets the field + // handle the key itself -- e.g. moving within a + // multi-line draft -- instead of swallowing it. + .onKeyPress(.upArrow) { + let draftBeforeRecall = run.draft + isRecallingHistory = true + guard run.recallPreviousPrompt() else { + isRecallingHistory = false + return .ignored + } + // Adjacent identical submitted prompts are distinct + // history entries, but assigning the same string + // does not trigger SwiftUI's `onChange`. Do not + // leave the next manual keystroke mislabeled as a + // history recall in that case. + isRecallingHistory = run.draft != draftBeforeRecall + return .handled + } + .onKeyPress(.downArrow) { + let draftBeforeRecall = run.draft + isRecallingHistory = true + guard run.recallNextPrompt() else { + isRecallingHistory = false + return .ignored + } + isRecallingHistory = run.draft != draftBeforeRecall + return .handled + } HStack(spacing: Spacing.comfortable) { ModelChip(project: project) @@ -903,7 +1084,14 @@ struct Composer: View { Spacer() Button("New") { project.newConversation() } .buttonStyle(.plain).font(Typography.caption).foregroundStyle( - Theme.foregroundTertiary) + Theme.foregroundTertiary + ) + .disabled(project.conversationActionDisabledReason != nil) + .help( + project.conversationActionDisabledReason + ?? "Start a new conversation" + ) + .accessibilityHint(project.conversationActionDisabledReason ?? "") Button(action: send) { Image( @@ -916,7 +1104,7 @@ struct Composer: View { .font(.system(size: 34)) } .buttonStyle(.plain) - .disabled(run.draft.trimmed.isEmpty) + .disabled(run.draft.trimmed.isEmpty || run.runControlInFlight) .help(run.canSteer ? "Steer the running task" : "Send") .accessibilityLabel( run.canSteer ? "Steer the running task" : "Send message") diff --git a/macapp/Sources/GoCodeUI/ConversationChrome.swift b/macapp/Sources/GoCodeUI/ConversationChrome.swift index b9172960..12fc4816 100644 --- a/macapp/Sources/GoCodeUI/ConversationChrome.swift +++ b/macapp/Sources/GoCodeUI/ConversationChrome.swift @@ -16,6 +16,7 @@ struct ConversationColumn: View { struct ConversationHeader: View { @Bindable var project: ProjectSession @Bindable var run: RunSession + @State private var undoConfirmation: DestructiveConfirmation? var body: some View { ConversationColumn { @@ -30,9 +31,24 @@ struct ConversationHeader: View { Spacer(minLength: Spacing.none) Menu { Button("New conversation") { project.newConversation() } + .disabled(project.conversationActionDisabledReason != nil) + .help( + project.conversationActionDisabledReason ?? "Start a new conversation" + ) + .accessibilityHint(project.conversationActionDisabledReason ?? "") if run.conversationID != nil { Button("Fork conversation") { Task { await project.fork() } } - Button("Undo last turn") { Task { await project.undo() } } + .disabled(project.conversationActionDisabledReason != nil) + .help( + project.conversationActionDisabledReason ?? "Fork conversation" + ) + .accessibilityHint(project.conversationActionDisabledReason ?? "") + Button("Undo last turn") { confirmUndo() } + .disabled(project.conversationActionDisabledReason != nil) + .help( + project.conversationActionDisabledReason ?? "Undo last turn" + ) + .accessibilityHint(project.conversationActionDisabledReason ?? "") } } label: { Image(systemName: "ellipsis") @@ -44,6 +60,7 @@ struct ConversationHeader: View { } .frame(height: Spacing.conversationHeaderHeight) } + .destructiveConfirmation($undoConfirmation) } private var title: String { @@ -52,4 +69,16 @@ struct ConversationHeader: View { else { return "New conversation" } return conversation.displayTitle } + + /// States what turn will be lost before it is lost (R6). + private func confirmUndo() { + let lastPrompt = UndoPreview.lastUserPrompt(in: run.transcript.items) + undoConfirmation = DestructiveConfirmation( + title: "Undo last turn?", + message: UndoPreview.message(lastPrompt: lastPrompt), + confirmLabel: "Undo" + ) { + Task { await project.undo() } + } + } } diff --git a/macapp/Sources/GoCodeUI/ConversationRail.swift b/macapp/Sources/GoCodeUI/ConversationRail.swift index 2b721c04..53a34aef 100644 --- a/macapp/Sources/GoCodeUI/ConversationRail.swift +++ b/macapp/Sources/GoCodeUI/ConversationRail.swift @@ -124,7 +124,9 @@ private struct ConversationRailRow: View { .contentShape(.rect) } .buttonStyle(.plain) - .help(conversation.displayTitle) + .disabled(project.conversationActionDisabledReason != nil) + .help(project.conversationActionDisabledReason ?? conversation.displayTitle) .accessibilityLabel("Open \(conversation.displayTitle)") + .accessibilityHint(project.conversationActionDisabledReason ?? "") } } diff --git a/macapp/Sources/GoCodeUI/DesignSystem/CollectionLoadState.swift b/macapp/Sources/GoCodeUI/DesignSystem/CollectionLoadState.swift index 80136b94..6aee4cd3 100644 --- a/macapp/Sources/GoCodeUI/DesignSystem/CollectionLoadState.swift +++ b/macapp/Sources/GoCodeUI/DesignSystem/CollectionLoadState.swift @@ -10,11 +10,43 @@ public enum CollectionLoadState: Sendable, Equatable { case idle case loading case loaded - case failed + case failed(String) public func showsEmptyState(itemCount: Int) -> Bool { self == .loaded && itemCount == 0 } + + /// A skeleton is truthful only while a result is still pending and + /// nothing is on screen yet. `.failed` is deliberately excluded: showing + /// a skeleton for a failure looks identical to a slow load that will + /// finish momentarily, when nothing further is coming until the + /// operator retries (#991 finding 2). + public func showsPlaceholder(itemCount: Int) -> Bool { + (self == .loading || self == .idle) && itemCount == 0 + } + + public var showsError: Bool { + if case .failed = self { return true } + return false + } + + /// A first-load failure owns the region only when there is no truthful + /// content to keep showing. + public func showsBlockingError(itemCount: Int) -> Bool { + showsError && itemCount == 0 + } + + /// A refresh failure is nonblocking when the prior successful rows remain + /// available. The operator keeps their context and gets an explicit retry. + public func showsRefreshError(itemCount: Int) -> Bool { + showsError && itemCount > 0 + } + + /// The server's own reason, verbatim — nil for every other state. + public var errorMessage: String? { + if case .failed(let message) = self { return message } + return nil + } } /// A quiet loading shape that keeps a region's eventual geometry in place. @@ -62,3 +94,44 @@ struct LoadingPlaceholder: View { } } } + +/// A failed collection fetch: the server's own reason plus a way to try +/// again. Mirrors `StartupFailureView` (`AppShell.swift`), the app's +/// existing "this failed, here is why, retry" shape at full-window scale — +/// this is the inline, per-collection version, so one bad request never +/// renders as an endless skeleton. +struct CollectionErrorState: View { + let message: String + let retry: () -> Void + + var body: some View { + VStack(spacing: Spacing.standard) { + Label(message, systemImage: "exclamationmark.triangle.fill") + .font(Typography.caption) + .foregroundStyle(.orange) + .multilineTextAlignment(.center) + Button("Retry", action: retry) + } + .frame(maxWidth: .infinity, alignment: .center) + .padding(Spacing.inset) + } +} + +/// A compact refresh failure that sits beside the last successful rows instead +/// of replacing them. +struct CollectionRefreshErrorState: View { + let message: String + let retry: () -> Void + + var body: some View { + HStack(spacing: Spacing.standard) { + Label(message, systemImage: "exclamationmark.triangle.fill") + .font(Typography.caption) + .foregroundStyle(.orange) + .lineLimit(2) + Spacer() + Button("Retry", action: retry) + } + .padding(.vertical, Spacing.compact) + } +} diff --git a/macapp/Sources/GoCodeUI/DesignSystem/DestructiveConfirmation.swift b/macapp/Sources/GoCodeUI/DesignSystem/DestructiveConfirmation.swift new file mode 100644 index 00000000..1cc54352 --- /dev/null +++ b/macapp/Sources/GoCodeUI/DesignSystem/DestructiveConfirmation.swift @@ -0,0 +1,84 @@ +import HarnessKit +import SwiftUI + +/// One shared destructive-confirmation presentation (KTD-4): delete, undo, +/// rewind, force-rewind, and provider-remove read identically instead of +/// each hand-rolling its own alert wording and severity. +struct DestructiveConfirmation: Identifiable { + let id = UUID() + let title: String + let message: String + let confirmLabel: String + let action: () -> Void +} + +extension View { + /// Presents `confirmation` as a destructive alert bound to an optional + /// value. Mirrors `CheckpointsView`'s existing alert shape: `Cancel` is + /// `.cancel`, the destructive verb is `.destructive`. Confirming or + /// cancelling clears the binding. + func destructiveConfirmation(_ confirmation: Binding) -> some View { + alert( + confirmation.wrappedValue?.title ?? "", + isPresented: Binding( + get: { confirmation.wrappedValue != nil }, + set: { if !$0 { confirmation.wrappedValue = nil } } + ) + ) { + if let item = confirmation.wrappedValue { + Button("Cancel", role: .cancel) { confirmation.wrappedValue = nil } + Button(item.confirmLabel, role: .destructive) { + item.action() + confirmation.wrappedValue = nil + } + } + } message: { + Text(confirmation.wrappedValue?.message ?? "") + } + } +} + +/// Client-derived preview text for deleting a conversation (KTD-5: no +/// server dry-run exists for delete, so this is composed only from data the +/// app already holds). Never fabricates a message count. +enum DeletePreview { + static let titleCharacterLimit = 60 + + static func message(for conversation: ConversationInfo) -> String { + let title = truncated(conversation.displayTitle, limit: titleCharacterLimit) + guard let count = conversation.messageCount else { + return "\"\(title)\" will be permanently deleted. Its message count is unknown." + } + let noun = count == 1 ? "message" : "messages" + return "\"\(title)\" and its \(count) \(noun) will be permanently deleted." + } + + static func truncated(_ text: String, limit: Int) -> String { + guard text.count > limit else { return text } + return String(text.prefix(limit)) + "…" + } +} + +/// Client-derived preview text for undoing the last turn. +enum UndoPreview { + static let promptCharacterLimit = 80 + + /// The last `.userPrompt` item in a run's transcript -- the turn an undo + /// would remove. Reused by every undo entry point so each states the + /// same fact instead of hand-rolling its own reading of the transcript. + static func lastUserPrompt(in items: [TranscriptItem]) -> String? { + for item in items.reversed() { + if case .userPrompt(let text) = item.kind, !text.isEmpty { return text } + } + return nil + } + + static func message(lastPrompt: String?) -> String { + guard let lastPrompt else { + return "This removes the last turn. It cannot be undone." + } + let flattened = lastPrompt.split(whereSeparator: \.isNewline).joined(separator: " ") + let truncated = DeletePreview.truncated(flattened, limit: promptCharacterLimit) + return "This removes the last turn — \"\(truncated)\" — and cannot be undone." + } +} diff --git a/macapp/Sources/GoCodeUI/DesignSystem/Layout.swift b/macapp/Sources/GoCodeUI/DesignSystem/Layout.swift index 346ac2ad..5e8fc92d 100644 --- a/macapp/Sources/GoCodeUI/DesignSystem/Layout.swift +++ b/macapp/Sources/GoCodeUI/DesignSystem/Layout.swift @@ -46,4 +46,8 @@ enum Layout { static let modelSettingsRowHeight: CGFloat = 40 static let inlineActivitySlot: CGFloat = IconSize.standard static let loadingPlaceholderRowCount = 4 + /// How close to the transcript's bottom edge counts as "already there" for + /// autoscroll purposes. Small enough that the operator must deliberately + /// scroll up to disengage, large enough to absorb sub-pixel geometry noise. + static let autoscrollPinThreshold: CGFloat = 40 } diff --git a/macapp/Sources/GoCodeUI/DesignSystem/Motion.swift b/macapp/Sources/GoCodeUI/DesignSystem/Motion.swift index a0ead43d..a09b77b5 100644 --- a/macapp/Sources/GoCodeUI/DesignSystem/Motion.swift +++ b/macapp/Sources/GoCodeUI/DesignSystem/Motion.swift @@ -5,4 +5,8 @@ import SwiftUI enum Motion { static let loadingFadeDuration: TimeInterval = 0.16 static let loadingPulseDuration: TimeInterval = 1.2 + /// The transcript's autoscroll-to-bottom animation, named so the view + /// can also size the delay before it clears the flag that suppresses + /// scroll-geometry updates during that same animation. + static let autoscrollDuration: TimeInterval = 0.12 } diff --git a/macapp/Sources/GoCodeUI/ModelSettingsView.swift b/macapp/Sources/GoCodeUI/ModelSettingsView.swift index b14c08d1..1b728157 100644 --- a/macapp/Sources/GoCodeUI/ModelSettingsView.swift +++ b/macapp/Sources/GoCodeUI/ModelSettingsView.swift @@ -37,15 +37,21 @@ final class ModelSettingsModel { return all.filter { $0.modelID.localizedCaseInsensitiveContains(search) } } - func load() async { + /// `clearingStatus` defaults to `true` for the page's own initial load, + /// where there is no prior action's message to protect. Every action + /// below (fetch/setExposed/setAllVisible/saveProvider/delete) reloads to + /// pick up the server's new state but passes `false`, so the message it + /// just set two lines earlier survives the reload it triggers instead of + /// being erased before the operator can read it (#999 finding 8). + func load(clearingStatus: Bool = true) async { loadState = .loading do { providers = try await client.modelSettings() if selectedProvider == nil { selectedProvider = providers.first?.name } - status = nil + if clearingStatus { status = nil } loadState = .loaded } catch { - loadState = .failed + loadState = .failed(error.localizedDescription) status = "Could not load model settings: \(error.localizedDescription)" } } @@ -56,23 +62,24 @@ final class ModelSettingsModel { do { let count = try await client.fetchProviderModels(name: provider) status = "Fetched \(count) models from \(provider)." - await load() + await load(clearingStatus: false) await onSelectionChanged() } catch { // The provider's own reason is the useful part — a bad key, an // unreachable host — so it is shown verbatim rather than summarised. status = "Fetch failed: \(error.localizedDescription)" - await load() + await load(clearingStatus: false) } } func setExposed(_ provider: String, _ model: String, _ exposed: Bool) async { do { try await client.setExposedModels(provider: provider, exposed: [model: exposed]) - await load() + await load(clearingStatus: false) await onSelectionChanged() } catch { status = "Could not save: \(error.localizedDescription)" + await load(clearingStatus: false) } } @@ -83,10 +90,11 @@ final class ModelSettingsModel { guard !wanted.isEmpty else { return } do { try await client.setExposedModels(provider: provider, exposed: wanted) - await load() + await load(clearingStatus: false) await onSelectionChanged() } catch { status = "Could not save: \(error.localizedDescription)" + await load(clearingStatus: false) } } @@ -97,7 +105,7 @@ final class ModelSettingsModel { try await client.saveProvider( name: name, baseURL: baseURL, protocolName: protocolName, authKind: authKind, keyRef: nil, apiKey: apiKey) - await load() + await load(clearingStatus: false) selectedProvider = name status = "Saved \(name)." return true @@ -111,12 +119,13 @@ final class ModelSettingsModel { do { try await client.deleteProvider(name: provider) if selectedProvider == provider { selectedProvider = nil } - await load() + await load(clearingStatus: false) // Removing a provider drops its exposed models, so the picker has to // be rebuilt or it keeps offering models the daemon can no longer run. await onSelectionChanged() } catch { status = "Could not remove: \(error.localizedDescription)" + await load(clearingStatus: false) } } @@ -134,6 +143,7 @@ final class ModelSettingsModel { struct ModelSettingsView: View { @Bindable var model: ModelSettingsModel @State private var addingProvider = false + @State private var removeConfirmation: DestructiveConfirmation? @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { @@ -146,6 +156,7 @@ struct ModelSettingsView: View { .sheet(isPresented: $addingProvider) { AddProviderSheet(model: model, isPresented: $addingProvider) } + .destructiveConfirmation($removeConfirmation) .safeAreaInset(edge: .bottom) { if let status = model.status { HStack(spacing: Spacing.standard) { @@ -179,11 +190,22 @@ struct ModelSettingsView: View { Divider() List(selection: $model.selectedProvider) { - if model.providers.isEmpty && model.loadState != .loaded { + if model.loadState.showsPlaceholder(itemCount: model.providers.count) { ForEach(0.. some View { List(model.visibleModels) { entry in HStack(spacing: Spacing.comfortable) { @@ -363,6 +411,7 @@ struct ModelSettingsView: View { ) .labelsHidden() .help("Show this model in the picker") + .accessibilityLabel("Show \(entry.modelID) in the picker") VStack(alignment: .leading, spacing: Spacing.tight) { Text(entry.displayName ?? entry.modelID) diff --git a/macapp/Sources/GoCodeUI/ProjectSession.swift b/macapp/Sources/GoCodeUI/ProjectSession.swift index c8c59d7c..99d1d6c0 100644 --- a/macapp/Sources/GoCodeUI/ProjectSession.swift +++ b/macapp/Sources/GoCodeUI/ProjectSession.swift @@ -78,6 +78,22 @@ public enum ProjectPhase: Sendable, Equatable { case failed(String) } +/// Structural representation of the server's `409 rewind_refused` safety +/// refusal (KTD-6): a file changed outside the harness since the checkpoint, +/// so a restore was declined. Matched on `HarnessError.code`, not the HTTP +/// status the server happens to send it with, because the code string is the +/// stable part of the contract. Carries the point the refusal was for so the +/// UI can offer a distinct, more severe "restore anyway" confirmation that +/// calls `rewind(to:force:)` on the same point without the caller having to +/// look it back up. +public struct RewindRefusal: Sendable, Equatable { + public let conversationID: String + public let point: RewindPoint + public let message: String + + public var pointID: String { point.id } +} + /// Everything scoped to one open project: its harnessd, its client, and its /// current conversation. /// @@ -105,6 +121,9 @@ public final class ProjectSession { public private(set) var runs: [RunSummaryInfo]? public private(set) var runsLoadState: CollectionLoadState = .idle public private(set) var statusMessage: String? + /// Set only for the server's deliberate `rewind_refused` safety refusal + /// (KTD-6); every other `rewind` failure still lands in `statusMessage`. + public private(set) var rewindRefusal: RewindRefusal? /// Model applied to the next run; nil uses the server's default. public var selectedModel: String? @@ -119,6 +138,26 @@ public final class ProjectSession { private var supervisor: HarnessSupervisor? private var client: HarnessClient? + // Refreshes overlap in normal SwiftUI use: `.task`, Retry, pull-to-refresh, + // navigation, and action completion can all ask for the same collection. + // The daemon has no request ordering guarantee, so this session—not a view— + // owns the last-request-wins boundary for every mutable result. + private var connectionGeneration = 0 + private var modelsRequestGeneration = 0 + private var providersRequestGeneration = 0 + private var profilesRequestGeneration = 0 + private var conversationsRequestGeneration = 0 + private var rewindPointsRequestGeneration = 0 + private var tasksRequestGeneration = 0 + private var runsRequestGeneration = 0 + private var todosRequestGeneration = 0 + private var conversationSyncGeneration = 0 + private var conversationSelectionGeneration = 0 + /// Non-nil between initiating a selection and accepting its response. + /// A durable sync for the old selection must not reconcile during that + /// window, even though `run.conversationID` still names the old row. + private var pendingConversationSelectionID: String? + /// Exposed so the model settings page can talk to this project's daemon. /// Read-only: the session still owns the client's lifetime. public var harnessClient: HarnessClient? { client } @@ -138,6 +177,13 @@ public final class ProjectSession { public var name: String { workspace.lastPathComponent } public var isReady: Bool { phase == .ready } + /// Shared explanation for controls that would otherwise be refused by + /// `refuseIfBusy(_:)`. Views own their disabled presentation, while the + /// session remains the authoritative lifecycle guard. + public var conversationActionDisabledReason: String? { + guard run?.isBusy == true else { return nil } + return "Stop the running task before changing conversations." + } // MARK: - Lifecycle @@ -172,6 +218,8 @@ public final class ProjectSession { /// Terminates this project's server. Called on window close so a day of /// opening projects does not leave a trail of orphaned servers. public func shutdown() async { + connectionGeneration &+= 1 + invalidateConversationSelection() run?.cancel() run?.stopConversationStream() await supervisor?.stop() @@ -181,12 +229,37 @@ public final class ProjectSession { } private func connect(to baseURL: URL) { + connectionGeneration &+= 1 + invalidateConversationSelection() let client = HarnessClient(baseURL: baseURL) self.client = client self.run = RunSession(client: client) phase = .ready - Task { await refreshCatalog() } - Task { await refreshConversations() } + // Reserve the initial request generations before scheduling their + // unstructured work. A caller that refreshes immediately after + // `start()` must own the newer generation; the delayed startup task + // is not allowed to begin later and replace that explicit refresh. + modelsRequestGeneration &+= 1 + providersRequestGeneration &+= 1 + profilesRequestGeneration &+= 1 + let modelsGeneration = modelsRequestGeneration + let providersGeneration = providersRequestGeneration + let profilesGeneration = profilesRequestGeneration + let initialConnection = connectionGeneration + Task { + await refreshCatalog( + modelsGeneration: modelsGeneration, + providersGeneration: providersGeneration, + profilesGeneration: profilesGeneration, + requestedConnection: initialConnection) + } + + conversationsRequestGeneration &+= 1 + let conversationsGeneration = conversationsRequestGeneration + Task { + await refreshConversations( + generation: conversationsGeneration, requestedConnection: initialConnection) + } } private var isFailed: Bool { @@ -203,7 +276,32 @@ public final class ProjectSession { /// rather than blanking a working catalog over one bad request (#951 /// finding 3). public func refreshCatalog() async { + modelsRequestGeneration &+= 1 + providersRequestGeneration &+= 1 + profilesRequestGeneration &+= 1 + let modelsGeneration = modelsRequestGeneration + let providersGeneration = providersRequestGeneration + let profilesGeneration = profilesRequestGeneration + let requestedConnection = connectionGeneration + await refreshCatalog( + modelsGeneration: modelsGeneration, + providersGeneration: providersGeneration, + profilesGeneration: profilesGeneration, + requestedConnection: requestedConnection) + } + + private func refreshCatalog( + modelsGeneration: Int, + providersGeneration: Int, + profilesGeneration: Int, + requestedConnection: Int + ) async { guard let client else { return } + guard connectionGeneration == requestedConnection, + modelsRequestGeneration == modelsGeneration, + providersRequestGeneration == providersGeneration, + profilesRequestGeneration == profilesGeneration + else { return } modelsLoadState = .loading providersLoadState = .loading profilesLoadState = .loading @@ -211,41 +309,82 @@ public final class ProjectSession { async let providers = try await client.providers() async let profiles = try await client.profiles() do { - self.models = try await models + let fetchedModels = try await models + guard connectionGeneration == requestedConnection, + modelsRequestGeneration == modelsGeneration + else { return } + self.models = fetchedModels modelsLoadState = .loaded } catch { - modelsLoadState = .failed + guard connectionGeneration == requestedConnection, + modelsRequestGeneration == modelsGeneration + else { return } + modelsLoadState = .failed(error.localizedDescription) statusMessage = error.localizedDescription } do { - self.providers = try await providers + let fetchedProviders = try await providers + guard connectionGeneration == requestedConnection, + providersRequestGeneration == providersGeneration + else { return } + self.providers = fetchedProviders providersLoadState = .loaded } catch { - providersLoadState = .failed + guard connectionGeneration == requestedConnection, + providersRequestGeneration == providersGeneration + else { return } + providersLoadState = .failed(error.localizedDescription) statusMessage = error.localizedDescription } do { - self.profiles = try await profiles + let fetchedProfiles = try await profiles + guard connectionGeneration == requestedConnection, + profilesRequestGeneration == profilesGeneration + else { return } + self.profiles = fetchedProfiles profilesLoadState = .loaded } catch { - profilesLoadState = .failed + guard connectionGeneration == requestedConnection, + profilesRequestGeneration == profilesGeneration + else { return } + profilesLoadState = .failed(error.localizedDescription) statusMessage = error.localizedDescription } } public func refreshConversations() async { + conversationsRequestGeneration &+= 1 + let generation = conversationsRequestGeneration + let requestedConnection = connectionGeneration + await refreshConversations(generation: generation, requestedConnection: requestedConnection) + } + + private func refreshConversations(generation: Int, requestedConnection: Int) async { guard let client else { return } + guard connectionGeneration == requestedConnection, + conversationsRequestGeneration == generation + else { return } conversationsLoadState = .loading do { - conversations = try await client.conversations(limit: 100) + let fetchedConversations = try await client.conversations(limit: 100) + guard connectionGeneration == requestedConnection, + conversationsRequestGeneration == generation + else { return } + conversations = fetchedConversations conversationsLoadState = .loaded } catch { - conversationsLoadState = .failed + guard connectionGeneration == requestedConnection, + conversationsRequestGeneration == generation + else { return } + conversationsLoadState = .failed(error.localizedDescription) statusMessage = error.localizedDescription } } public func refreshRewindPoints() async { + rewindPointsRequestGeneration &+= 1 + let generation = rewindPointsRequestGeneration + let requestedConnection = connectionGeneration guard let client, let conversationID = run?.conversationID else { rewindPoints = [] rewindPointsLoadState = .loaded @@ -253,67 +392,152 @@ public final class ProjectSession { } rewindPointsLoadState = .loading do { - rewindPoints = try await client.rewindPoints(conversationID: conversationID) + let fetchedPoints = try await client.rewindPoints(conversationID: conversationID) + guard connectionGeneration == requestedConnection, + rewindPointsRequestGeneration == generation, + run?.conversationID == conversationID + else { return } + rewindPoints = fetchedPoints rewindPointsLoadState = .loaded } catch { - rewindPointsLoadState = .failed + guard connectionGeneration == requestedConnection, + rewindPointsRequestGeneration == generation, + run?.conversationID == conversationID + else { return } + rewindPointsLoadState = .failed(error.localizedDescription) statusMessage = error.localizedDescription } } public func refreshActivity() async { + tasksRequestGeneration &+= 1 + runsRequestGeneration &+= 1 + todosRequestGeneration &+= 1 + let tasksGeneration = tasksRequestGeneration + let runsGeneration = runsRequestGeneration + let todosGeneration = todosRequestGeneration + let requestedConnection = connectionGeneration guard let client else { return } + let runID = run?.currentRunID tasksLoadState = .loading runsLoadState = .loading + if runID != nil { + todosLoadState = .loading + } else { + todos = [] + todosLoadState = .loaded + } + async let fetchedTasks = try await client.tasks() + async let fetchedRuns = try await client.runs() + async let fetchedTodos = Self.fetchTodos(client: client, runID: runID) + // Every request begins together, but global collections are committed + // before awaiting the run-scoped result. A slow or hung todo endpoint + // must not keep already-ready Activity lists in `.loading`. do { - tasks = try await client.tasks() + let latestTasks = try await fetchedTasks + guard connectionGeneration == requestedConnection, + tasksRequestGeneration == tasksGeneration + else { return } + tasks = latestTasks tasksLoadState = .loaded } catch { - tasksLoadState = .failed + guard connectionGeneration == requestedConnection, + tasksRequestGeneration == tasksGeneration + else { return } + tasksLoadState = .failed(error.localizedDescription) statusMessage = error.localizedDescription } do { // `client.runs()` already turns the deliberate "no run store // configured" 501 into `nil`; anything thrown here is a genuine // transport/server failure and must not be folded into that same - // nil, or a network blip reads as "no run store configured" — a - // lie about the daemon's configuration (#951 finding 3). - runs = try await client.runs() + // nil, or a network blip reads as "no run store configured". + let latestRuns = try await fetchedRuns + guard connectionGeneration == requestedConnection, + runsRequestGeneration == runsGeneration + else { return } + runs = latestRuns runsLoadState = .loaded } catch { - runsLoadState = .failed + guard connectionGeneration == requestedConnection, + runsRequestGeneration == runsGeneration + else { return } + runsLoadState = .failed(error.localizedDescription) statusMessage = error.localizedDescription } - if let runID = run?.currentRunID { - todosLoadState = .loading - do { - todos = try await client.todos(runID: runID) + guard let runID else { return } + do { + let latestTodos = try await fetchedTodos ?? [] + if connectionGeneration == requestedConnection, + todosRequestGeneration == todosGeneration + { + if run?.currentRunID == runID { + todos = latestTodos + } else { + // This request still belongs to the current refresh, but + // its run ended while todos were loading. Discard only + // that run-scoped result. + todos = [] + } todosLoadState = .loaded - } catch { - todosLoadState = .failed - statusMessage = error.localizedDescription } - } else { - todos = [] - todosLoadState = .loaded + } catch { + if connectionGeneration == requestedConnection, + todosRequestGeneration == todosGeneration + { + if run?.currentRunID == runID { + todosLoadState = .failed(error.localizedDescription) + statusMessage = error.localizedDescription + } else { + todos = [] + todosLoadState = .loaded + } + } } } + private static func fetchTodos(client: HarnessClient, runID: String?) async throws + -> [TodoItem]? + { + guard let runID else { return nil } + return try await client.todos(runID: runID) + } + /// Rehydrates the selected conversation from durable messages when Chat /// becomes visible. Conversation SSE remains the low-latency path; this is /// the durability safety net for a completed scheduled run that happened /// across a dropped/recreated stream or while the view was elsewhere. public func syncCurrentConversation() async { + conversationSyncGeneration &+= 1 + let syncGeneration = conversationSyncGeneration + let selectionGeneration = conversationSelectionGeneration + let requestedConnection = connectionGeneration guard let client, let run, let conversationID = run.conversationID, - !run.isBusy + !run.isBusy, + pendingConversationSelectionID == nil else { return } do { let messages = try await client.messages(conversationID: conversationID) + guard connectionGeneration == requestedConnection, + conversationSyncGeneration == syncGeneration, + conversationSelectionGeneration == selectionGeneration, + pendingConversationSelectionID == nil, + self.run === run, + run.conversationID == conversationID, + !run.isBusy + else { return } run.reconcilePersistedMessages(messages) } catch { + guard connectionGeneration == requestedConnection, + conversationSyncGeneration == syncGeneration, + conversationSelectionGeneration == selectionGeneration, + pendingConversationSelectionID == nil, + self.run === run, + run.conversationID == conversationID + else { return } statusMessage = error.localizedDescription } } @@ -351,17 +575,23 @@ public final class ProjectSession { } public func openConversation(_ conversation: ConversationInfo) async { - guard let client else { return } - do { - let messages = try await client.messages(conversationID: conversation.id) - run?.load(messages: messages, conversationID: conversation.id) - await refreshRewindPoints() - } catch { - statusMessage = error.localizedDescription - } + guard !refuseIfBusy("switching conversations") else { return } + await loadConversation(id: conversation.id, reportFailure: true) } public func deleteConversation(_ conversation: ConversationInfo) async { + // Refused up front, not deleted-then-locally-refused: this used to + // call the server's `DELETE` unconditionally and rely on + // `newConversation()`'s own guard (below) to refuse the *local* + // reset afterwards -- which actually deleted the conversation on + // the server while leaving the app still bound to it, since the + // local refusal only stopped the reset, not the delete that already + // happened. + guard run?.conversationID != conversation.id || run?.isBusy != true else { + statusMessage = + "Stop the running task before deleting the conversation it's running in." + return + } guard let client else { return } do { try await client.deleteConversation(id: conversation.id) @@ -372,16 +602,36 @@ public final class ProjectSession { } } + /// Guards `newConversation`/`fork`/`undo` (KTD-9): one check here covers + /// every call site, including `deleteConversation`'s own internal call, + /// rather than a `.disabled(...)` per call site that a caller added + /// later could bypass. + private func refuseIfBusy(_ action: String) -> Bool { + guard run?.isBusy == true else { return false } + statusMessage = "Stop the running task before \(action)." + return true + } + public func newConversation() { + guard !refuseIfBusy("starting a new conversation") else { return } + invalidateConversationSelection() run?.reset() rewindPoints = [] rewindPointsLoadState = .loaded } public func fork() async { + guard !refuseIfBusy("forking this conversation") else { return } guard let client, let conversationID = run?.conversationID else { return } do { let result = try await client.fork(conversationID: conversationID) + // Re-checked after the server call: a run can start on this same + // conversation while fork's request is in flight, and applying + // the result anyway would retarget the run's tracked + // conversation out from under it mid-turn. The server-side fork + // already happened either way -- only the local rebind is + // skipped. + guard !refuseIfBusy("forking this conversation") else { return } run?.rebind(conversationID: result.conversationID) statusMessage = "Forked into a new conversation" await refreshConversations() @@ -391,9 +641,14 @@ public final class ProjectSession { } public func undo(count: Int = 1) async { + guard !refuseIfBusy("undoing the last turn") else { return } guard let client, let conversationID = run?.conversationID else { return } do { try await client.undo(conversationID: conversationID, count: count) + // Re-checked after the server call, same reasoning as `fork` + // above: a run started mid-flight must not have its + // conversation reloaded out from under it. + guard !refuseIfBusy("undoing the last turn") else { return } await openConversationByID(conversationID) } catch { statusMessage = error.localizedDescription @@ -402,21 +657,66 @@ public final class ProjectSession { /// Restores files and truncates history. Destructive; `force` overrides the /// server's refusal when a file changed outside the harness. + /// + /// Cleared at the start of every call -- including this one's own retry -- + /// so a stale refusal from a previous point can never be mistaken for one + /// on the point this call is now acting on. Never auto-retried with + /// `force`: setting `rewindRefusal` only records the refusal for the UI to + /// present a distinct, explicit second confirmation (R7). public func rewind(to point: RewindPoint, force: Bool = false) async { + guard !refuseIfBusy("rewinding this conversation") else { return } + rewindRefusal = nil guard let client, let conversationID = run?.conversationID else { return } do { let result = try await client.rewind( conversationID: conversationID, pointID: point.id, force: force) + // A run can become active while the destructive request is in + // flight. Its eventual persisted work must never be replaced by + // the historical reload below. + guard run?.conversationID == conversationID else { return } + guard !refuseIfBusy("rewinding this conversation") else { return } statusMessage = "Restored \(result.filesRestored) file(s), removed \(result.messagesTruncated) message(s)" await openConversationByID(conversationID) + } catch let error as HarnessError where error.code == "rewind_refused" { + guard run?.conversationID == conversationID else { return } + rewindRefusal = RewindRefusal( + conversationID: conversationID, point: point, message: error.message) } catch let error as HarnessError { + guard run?.conversationID == conversationID else { return } statusMessage = error.message } catch { + guard run?.conversationID == conversationID else { return } statusMessage = error.localizedDescription } } + /// Retries only the refusal the operator actually confirmed. A + /// conversation switch invalidates the confirmation instead of applying + /// its conversation-scoped checkpoint id to the newly selected chat. + public func forceRewind(_ refusal: RewindRefusal) { + guard rewindRefusal == refusal, run?.conversationID == refusal.conversationID else { + if rewindRefusal == refusal { rewindRefusal = nil } + return + } + // Claim the refusal synchronously. SwiftUI's alert clears its binding + // immediately after invoking the button action; if validation lived + // inside the asynchronous task, that dismissal could clear the + // refusal before the task ever began and silently suppress the retry. + rewindRefusal = nil + Task { [weak self] in + guard let self, self.run?.conversationID == refusal.conversationID else { return } + await self.rewind(to: refusal.point, force: true) + } + } + + /// Dismisses a `rewind_refused` refusal without contacting the server -- + /// the "Cancel" path on the force-rewind confirmation. A refusal is a UI + /// presentation concern once recorded; declining it performs nothing. + public func dismissRewindRefusal() { + rewindRefusal = nil + } + public func setProviderKey(provider: String, key: String) async { guard let client else { return } do { @@ -456,10 +756,54 @@ public final class ProjectSession { } private func openConversationByID(_ id: String) async { + guard !refuseIfBusy("reloading this conversation") else { return } + await loadConversation(id: id, reportFailure: false) + } + + private func loadConversation(id: String, reportFailure: Bool) async { guard let client else { return } - if let messages = try? await client.messages(conversationID: id) { + let generation = beginConversationSelection(id) + let requestedConnection = connectionGeneration + do { + let messages = try await client.messages(conversationID: id) + guard + ownsConversationSelection( + generation, id: id, connectionGeneration: requestedConnection) + else { return } + // This request still owns the pending-selection slot even when a + // run began while it was awaiting the server. Release that slot + // before refusing the switch, or later durable syncs remain + // blocked after the run ends. + pendingConversationSelectionID = nil + guard !refuseIfBusy("switching conversations") else { return } run?.load(messages: messages, conversationID: id) + await refreshRewindPoints() + } catch { + guard + ownsConversationSelection( + generation, id: id, connectionGeneration: requestedConnection) + else { return } + pendingConversationSelectionID = nil + if reportFailure { statusMessage = error.localizedDescription } } - await refreshRewindPoints() + } + + private func beginConversationSelection(_ id: String) -> Int { + conversationSelectionGeneration &+= 1 + pendingConversationSelectionID = id + return conversationSelectionGeneration + } + + private func invalidateConversationSelection() { + conversationSelectionGeneration &+= 1 + pendingConversationSelectionID = nil + } + + private func ownsConversationSelection( + _ generation: Int, id: String, connectionGeneration: Int + ) -> Bool { + self.connectionGeneration == connectionGeneration + && conversationSelectionGeneration == generation + && pendingConversationSelectionID == id } } diff --git a/macapp/Sources/GoCodeUI/PromptHistory.swift b/macapp/Sources/GoCodeUI/PromptHistory.swift new file mode 100644 index 00000000..4ebedb07 --- /dev/null +++ b/macapp/Sources/GoCodeUI/PromptHistory.swift @@ -0,0 +1,90 @@ +import Foundation + +/// Up/Down prompt-history recall for the composer, as a plain value type +/// (pattern: `MarkdownBlock`) so navigation logic is unit-testable without a +/// view. `cursor == nil` means "not currently navigating" -- the normal state +/// while the operator is typing a fresh draft. +/// +/// "Cursor-aware" per #998 would mean only recalling when the text caret sits +/// on the composer's first/last line, which needs a SwiftUI caret binding +/// (`TextSelection`, macOS 15) unavailable on this package's `.macOS(.v14)` +/// floor (see plan KTD-7). This approximates the same intent -- never +/// destroy an in-progress draft -- via draft state instead: `recallPrevious` +/// only starts a new recall from an empty draft, and any non-recall edit +/// resets navigation so the next Up starts fresh. +public struct PromptHistory: Sendable, Equatable { + private var entries: [String] = [] + private var cursor: Int? + private var stashedDraft: String? + + public init() {} + + /// Appends a submitted prompt and ends any in-progress navigation, so the + /// next Up starts from this newest entry. + public mutating func record(_ prompt: String) { + entries.append(prompt) + cursor = nil + stashedDraft = nil + } + + /// Walks one entry further into the past, or declines. Declines (returns + /// `nil`, leaving the cursor unmoved) when navigation has not yet started + /// and `currentDraft` holds an in-progress thought -- the guarantee that + /// prompted #998: an Up press must never overwrite unsaved typing. + /// Already-navigating calls ignore `currentDraft` and never wrap around + /// past the oldest entry. + public mutating func recallPrevious(currentDraft: String) -> String? { + if let currentCursor = cursor { + // Already navigating: KTD-7's approximation of caret-awareness. + // Continue only while the draft is empty or still matches the + // entry last recalled -- if it diverged, an edit is in progress + // and Up must decline rather than silently replace it. + guard currentDraft.isEmpty || currentDraft == entries[currentCursor] else { + return nil + } + guard currentCursor > 0 else { + // Already showing the oldest entry: returning it again here + // would be a same-value no-op once `currentDraft` already + // equals it. SwiftUI's `onChange` never fires for a + // reassignment that does not actually change the value, so + // the composer's own "a recall just happened" flag would + // never get cleared -- stuck, it then misattributes the + // *next* real keystroke to this recall instead of ending + // navigation, and a later Down silently clobbers the edit. + guard currentDraft != entries[currentCursor] else { return nil } + return entries[currentCursor] + } + cursor = currentCursor - 1 + return entries[currentCursor - 1] + } + guard currentDraft.trimmed.isEmpty, !entries.isEmpty else { return nil } + stashedDraft = currentDraft + cursor = entries.count - 1 + return entries[entries.count - 1] + } + + /// Walks one entry back toward the present. Past the newest entry, + /// restores the exact draft that was in progress when navigation + /// started (which may itself have been empty) and ends navigation. A + /// call while not navigating declines. + public mutating func recallNext() -> String? { + guard let currentCursor = cursor else { return nil } + let next = currentCursor + 1 + guard next < entries.count else { + let stash = stashedDraft ?? "" + cursor = nil + stashedDraft = nil + return stash + } + cursor = next + return entries[next] + } + + /// Ends navigation without discarding recorded entries -- called on any + /// draft edit that is not itself a recall, so typing after a recall + /// starts a fresh navigation next time. + public mutating func reset() { + cursor = nil + stashedDraft = nil + } +} diff --git a/macapp/Sources/GoCodeUI/RunSession.swift b/macapp/Sources/GoCodeUI/RunSession.swift index a77cafd7..818d105a 100644 --- a/macapp/Sources/GoCodeUI/RunSession.swift +++ b/macapp/Sources/GoCodeUI/RunSession.swift @@ -16,19 +16,56 @@ public final class RunSession { public private(set) var currentRunID: String? /// Set when the agent asks a structured question mid-run. public private(set) var pendingQuestions: AskUserPrompt? - - public var draft: String = "" + /// True while `answer()`'s request is awaiting the server. The composer's + /// Send button reads this to disable itself -- without it, an impatient + /// second click fired a second `answerInput` request before the first + /// one came back. + public private(set) var answerInFlight = false + /// True while one acknowledgement-bearing run control request (approve, + /// deny, or steer) is awaiting harnessd. The view can use this to disable + /// every conflicting control, while this class enforces the same contract + /// even when an action is invoked programmatically. + public private(set) var runControlInFlight = false + + public var draft: String = "" { + didSet { draftGeneration &+= 1 } + } public var model: String? public var planMode = false public var extraDirs: [String] = [] public var profile: String? - /// Recalled with Up/Down in the composer. - public private(set) var promptHistory: [String] = [] + /// Recalled with Up/Down in the composer via `recallPreviousPrompt()` / + /// `recallNextPrompt()` below -- no external reader of this value exists, + /// so it stays private rather than a public accessor with nothing on the + /// other end of it. + private var promptHistory = PromptHistory() private let client: HarnessClient private var streamTask: Task? - /// Escalates a second interrupt from cooperative cancel to a hard stop. - private var cancelRequested = false + /// The two-stage interrupt's own state, replacing a single + /// `cancelRequested` bool that used to flip to `true` *synchronously* -- + /// before the first cooperative cancel's request had even reached the + /// server -- so a second press arriving during that round trip + /// force-abandoned the stream locally, ahead of any server + /// acknowledgement. `.requesting` is the round trip itself: a press + /// during it is a no-op, not an escalation; only `.requested` (the + /// server has actually acknowledged the first cancel) allows a further + /// press to escalate. + private enum CancelState { + case idle + case requesting + case requested + } + private var cancelState: CancelState = .idle + /// Request generations make a completion belong to the operation that + /// started it, rather than to whichever run happens to be current when it + /// finishes. Resetting or loading another conversation invalidates all + /// three ownership domains synchronously. + private var answerRequestGeneration: UInt = 0 + private var pendingInputRequestGeneration: UInt = 0 + private var runControlRequestGeneration: UInt = 0 + private var runRequestGeneration: UInt = 0 + private var draftGeneration: UInt = 0 /// Keeps the conversation-wide stream (issue #950) open for as long as a /// conversation is selected, independent of whether this app instance @@ -70,9 +107,11 @@ public final class RunSession { guard !prompt.isEmpty, !isBusy else { return } draft = "" connectionError = nil - cancelRequested = false - promptHistory.append(prompt) + cancelState = .idle + promptHistory.record(prompt) transcript.appendUserPrompt(prompt) + runRequestGeneration &+= 1 + let requestGeneration = runRequestGeneration // `startingConversationID` is deliberately renamed away from the // property it's captured from: a capture named `conversationID` @@ -100,6 +139,8 @@ public final class RunSession { request.allowFallback = model == nil let started = try await client.startRun(request) + guard runRequestGeneration == requestGeneration else { return } + transcript.bindAccountingRun(started.runID) currentRunID = started.runID if self.conversationID == nil { self.conversationID = started.runID } // Keyed by conversation, not by this run: on a conversation's @@ -110,15 +151,23 @@ public final class RunSession { } for try await event in client.events(runID: started.runID) { + guard runRequestGeneration == requestGeneration else { return } await apply(event, runID: started.runID) } } catch let error as HarnessError { + guard runRequestGeneration == requestGeneration else { return } connectionError = error.message transcript.markFailed() + } catch is CancellationError { + // Local force-cancel intentionally ends this stream. Fall + // through to the generation-guarded cleanup below so the + // session no longer advertises a run that has no stream. } catch { + guard runRequestGeneration == requestGeneration else { return } connectionError = error.localizedDescription transcript.markFailed() } + guard runRequestGeneration == requestGeneration else { return } currentRunID = nil } } @@ -130,56 +179,182 @@ public final class RunSession { streamTask?.cancel() return } - if cancelRequested { + switch cancelState { + case .requested: + // The server has already acknowledged the first cooperative + // cancel -- a further press escalates to a local force-stop. + currentRunID = nil streamTask?.cancel() transcript.markCancelled() + cancelState = .idle + case .requesting: + // The first cancel has not come back yet. Escalating here would + // force-stop before the server ever acknowledged it -- exactly + // the mid-round-trip race a single `cancelRequested` bool (set + // `true` before the request resolved) used to allow. return + case .idle: + cancelState = .requesting + Task { [client] in + do { + try await client.cancel(runID: runID) + // Only this run's own outcome may advance the state + // machine -- a `reset()`/new run in between already + // reset it, and a stale completion must not overwrite + // that. + guard currentRunID == runID else { return } + cancelState = .requested + } catch let error as HarnessError { + guard currentRunID == runID else { return } + connectionError = error.message + // A cancel that never reached the server must not leave + // the operator's next press escalating to a local + // force-kill -- it has to retry the same cooperative + // request. + cancelState = .idle + } catch { + guard currentRunID == runID else { return } + connectionError = error.localizedDescription + cancelState = .idle + } + } } - cancelRequested = true - Task { [client] in try? await client.cancel(runID: runID) } } public func approve(option: String? = nil) { - guard let runID = currentRunID else { return } - Task { [client] in try? await client.approve(runID: runID, option: option) } + guard let runID = currentRunID, !runControlInFlight else { return } + let client = self.client + runControlTask(runID: runID) { try await client.approve(runID: runID, option: option) } } public func deny() { - guard let runID = currentRunID else { return } - Task { [client] in try? await client.deny(runID: runID) } + guard let runID = currentRunID, !runControlInFlight else { return } + let client = self.client + runControlTask(runID: runID) { try await client.deny(runID: runID) } } /// Redirects an in-flight run without cancelling it. Applied at the run's /// next step boundary. public func steer() { - let prompt = draft.trimmed - guard !prompt.isEmpty, let runID = currentRunID else { return } + let originalDraft = draft + let prompt = originalDraft.trimmed + guard !prompt.isEmpty, let runID = currentRunID, !runControlInFlight else { return } draft = "" - Task { [client] in + let clearedDraftGeneration = draftGeneration + let client = self.client + runControlTask( + runID: runID, restoreDraft: originalDraft, + clearedDraftGeneration: clearedDraftGeneration + ) { try await client.steer(runID: runID, prompt: prompt) } + } + + /// Shared error-surfacing shape for the three run-control calls + /// (`approve`/`deny`/`steer`) whose only observable effect on failure is + /// `connectionError` -- `cancel` and `answer` also touch other state in + /// their catch blocks, so they keep their own `do`/`catch` rather than + /// forcing this helper to take an `onFailure` hook for two call sites. + /// + /// `runID` is the run this call was issued for, captured at the call + /// site the same way `cancel`/`answer` guard their own completions: a + /// `reset()`/new run arriving before this Task's completion must not + /// write `connectionError` into whatever context this `RunSession` has + /// since moved on to. + private func runControlTask( + runID: String, + restoreDraft: String? = nil, + clearedDraftGeneration: UInt? = nil, + _ operation: @escaping () async throws -> Void + ) { + // This second guard makes the single-flight invariant hold even if a + // future call site forgets to perform the UI-facing guard above. + guard !runControlInFlight else { return } + runControlInFlight = true + runControlRequestGeneration &+= 1 + let requestGeneration = runControlRequestGeneration + Task { + defer { + if runControlRequestGeneration == requestGeneration { + runControlInFlight = false + } + } do { - try await client.steer(runID: runID, prompt: prompt) + try await operation() } catch let error as HarnessError { + guard currentRunID == runID, runControlRequestGeneration == requestGeneration else { + return + } connectionError = error.message + restoreSteeringDraftIfUnedited( + restoreDraft, clearedDraftGeneration: clearedDraftGeneration) } catch { + guard currentRunID == runID, runControlRequestGeneration == requestGeneration else { + return + } connectionError = error.localizedDescription + restoreSteeringDraftIfUnedited( + restoreDraft, clearedDraftGeneration: clearedDraftGeneration) } } } + private func restoreSteeringDraftIfUnedited( + _ originalDraft: String?, clearedDraftGeneration: UInt? + ) { + guard let originalDraft, let clearedDraftGeneration, + draftGeneration == clearedDraftGeneration + else { return } + draft = originalDraft + } + public func answer(_ answers: [String: String]) { - guard let runID = currentRunID else { return } - pendingQuestions = nil - Task { [client] in try? await client.answerInput(runID: runID, answers: answers) } + guard let runID = currentRunID, let prompt = pendingQuestions, + AskUserAnswers.isComplete(prompt: prompt, answers: answers), !answerInFlight + else { return } + answerInFlight = true + answerRequestGeneration &+= 1 + let requestGeneration = answerRequestGeneration + Task { [client] in + // A reset may release this guard for a later request while this + // older request is still in flight. Only the request that set the + // current generation may clear it on completion. + defer { + if answerRequestGeneration == requestGeneration { + answerInFlight = false + } + } + do { + try await client.answerInput(runID: runID, answers: answers) + // A `reset()`/new run in between must not have this stale + // completion write into the new context. + guard currentRunID == runID else { return } + // Cleared only when the prompt still pending is the one this + // very call answered, identified by its call id -- a newer + // question (a fresh `run.waiting_for_user`) can be assigned + // while this request was in flight and must survive an + // older call's success, not be silently dropped by it. + if pendingQuestions?.callID == prompt.callID { + pendingQuestions = nil + } + } catch let error as HarnessError { + guard currentRunID == runID else { return } + connectionError = error.message + } catch { + guard currentRunID == runID else { return } + connectionError = error.localizedDescription + } + } } // MARK: - Conversation switching public func load(messages: [StoredMessage], conversationID: String) { streamTask?.cancel() + invalidateAsyncRequestOwnership() transcript.load(messages: messages) self.conversationID = conversationID currentRunID = nil connectionError = nil + pendingQuestions = nil trackConversationStream(conversationID) } @@ -202,11 +377,26 @@ public final class RunSession { public func reset() { streamTask?.cancel() stopConversationStream() + invalidateAsyncRequestOwnership() transcript.reset() conversationID = nil currentRunID = nil connectionError = nil pendingQuestions = nil + // `currentRunID = nil` above already makes stale completion writes a + // no-op for this run. The ownership invalidation above also releases + // guards synchronously, so a request that never returns cannot leave + // the next conversation disabled. + cancelState = .idle + } + + private func invalidateAsyncRequestOwnership() { + answerRequestGeneration &+= 1 + pendingInputRequestGeneration &+= 1 + runControlRequestGeneration &+= 1 + runRequestGeneration &+= 1 + answerInFlight = false + runControlInFlight = false } public func rebind(conversationID: String) { @@ -214,9 +404,36 @@ public final class RunSession { trackConversationStream(conversationID) } - public func recallPreviousPrompt() { - guard let last = promptHistory.last else { return } - draft = last + /// Recalls one entry further into the past. Returns `false` (and leaves + /// `draft` untouched) when history is empty, the oldest entry is already + /// showing, or an in-progress draft would be clobbered -- the caller + /// (the composer's key handler) uses this to decide whether it handled + /// the key press or should let the field's own navigation run instead. + @discardableResult + public func recallPreviousPrompt() -> Bool { + guard let recalled = promptHistory.recallPrevious(currentDraft: draft) else { + return false + } + draft = recalled + return true + } + + /// Recalls one entry back toward the present, restoring the pre-recall + /// draft once navigation runs past the newest entry. Returns `false` + /// while not currently navigating history. + @discardableResult + public func recallNextPrompt() -> Bool { + guard let recalled = promptHistory.recallNext() else { return false } + draft = recalled + return true + } + + /// Ends any in-progress history navigation. The composer calls this for + /// every draft edit that did not come from a recall, so typing after + /// recalling an entry starts a fresh navigation next time Up is pressed + /// instead of the next arrow press silently overwriting the edit. + public func noteManualDraftEdit() { + promptHistory.reset() } // MARK: - Conversation-wide event stream (issue #950) @@ -294,7 +511,21 @@ public final class RunSession { // The question text lives behind a separate endpoint, not in the event. guard event.type == .runWaitingForUser || event.type == .other("run.waiting_for_user") else { return } - pendingQuestions = try? await client.pendingInput(runID: runID) + pendingInputRequestGeneration &+= 1 + let requestGeneration = pendingInputRequestGeneration + do { + let prompt = try await client.pendingInput(runID: runID) + // A newer waiting event, reset, conversation load, or run switch + // must own the visible question. An older HTTP response is not + // allowed to resurrect or replace that newer prompt. + guard pendingInputRequestGeneration == requestGeneration, currentRunID == runID else { + return + } + pendingQuestions = prompt + } catch { + // Keep the existing prompt on a transient fetch failure; a later + // waiting event can retry without an older failure clearing UI. + } } } diff --git a/macapp/Sources/GoCodeUI/SessionsView.swift b/macapp/Sources/GoCodeUI/SessionsView.swift index aa99f56a..03c20712 100644 --- a/macapp/Sources/GoCodeUI/SessionsView.swift +++ b/macapp/Sources/GoCodeUI/SessionsView.swift @@ -7,6 +7,7 @@ struct SessionsView: View { @Binding var section: Section @State private var search = "" @State private var exportError: String? + @State private var deleteConfirmation: DestructiveConfirmation? var body: some View { VStack(spacing: Spacing.none) { @@ -17,11 +18,22 @@ struct SessionsView: View { project.newConversation() section = .chat } + .disabled(project.conversationActionDisabledReason != nil) + .help(project.conversationActionDisabledReason ?? "Start a new conversation") + .accessibilityHint(project.conversationActionDisabledReason ?? "") } .padding(Spacing.inset) Divider() - if project.conversationsLoadState.showsEmptyState( + if project.conversationsLoadState.showsBlockingError( + itemCount: project.conversations.count) + { + CollectionErrorState( + message: project.conversationsLoadState.errorMessage ?? "" + ) { + Task { await project.refreshConversations() } + } + } else if project.conversationsLoadState.showsEmptyState( itemCount: project.conversations.count ) { EmptyState( @@ -33,31 +45,62 @@ struct SessionsView: View { ) } else { List { - if project.conversations.isEmpty { + if project.conversationsLoadState.showsRefreshError( + itemCount: project.conversations.count) + { + CollectionRefreshErrorState( + message: project.conversationsLoadState.errorMessage ?? "" + ) { + Task { await project.refreshConversations() } + } + } + if project.conversationsLoadState.showsPlaceholder( + itemCount: project.conversations.count) + { ForEach(0.. String { + var label = conversation.displayTitle + if let count = conversation.messageCount { + label += ", \(count) \(count == 1 ? "message" : "messages")" + } + if conversation.pinned == true { label += ", pinned" } + return label + } + + /// States what will be lost before it is lost (R6) rather than deleting + /// the moment the menu item is tapped. + private func confirmDelete(_ conversation: ConversationInfo) { + deleteConfirmation = DestructiveConfirmation( + title: "Delete conversation?", + message: DeletePreview.message(for: conversation), + confirmLabel: "Delete" + ) { + Task { await project.deleteConversation(conversation) } + } } } @@ -143,17 +207,18 @@ struct CheckpointsView: View { @Bindable var project: ProjectSession @State private var confirming: RewindPoint? - // NOTE (issue #951 finding 9): a "Restore anyway" path for the server's - // `409 rewind_refused` (a file changed outside the harness) still has no - // UI. Wiring it properly needs `ProjectSession.rewind` to surface that - // refusal distinctly instead of collapsing every failure into - // `statusMessage` text — `ProjectSession.swift` is out of scope for this - // task, so the previously dead `forceNext` toggle (set to `false` on tap, - // never `true`) is removed rather than left half-wired. - var body: some View { Group { - if project.rewindPointsLoadState.showsEmptyState(itemCount: project.rewindPoints.count) + if project.rewindPointsLoadState.showsBlockingError( + itemCount: project.rewindPoints.count) + { + CollectionErrorState( + message: project.rewindPointsLoadState.errorMessage ?? "" + ) { + Task { await project.refreshRewindPoints() } + } + } else if project.rewindPointsLoadState.showsEmptyState( + itemCount: project.rewindPoints.count) { EmptyState( icon: "arrow.uturn.backward.circle", @@ -164,13 +229,27 @@ struct CheckpointsView: View { } else { ScrollView { VStack(alignment: .leading, spacing: Spacing.comfortable) { - if project.rewindPoints.isEmpty { + if project.rewindPointsLoadState.showsRefreshError( + itemCount: project.rewindPoints.count) + { + CollectionRefreshErrorState( + message: project.rewindPointsLoadState.errorMessage ?? "" + ) { + Task { await project.refreshRewindPoints() } + } + } + if project.rewindPointsLoadState.showsPlaceholder( + itemCount: project.rewindPoints.count) + { ForEach(0.. { + Binding( + get: { + guard let refusal = project.rewindRefusal else { return nil } + return DestructiveConfirmation( + title: "This checkpoint changed outside the harness", + message: + "\(refusal.message) Restoring anyway overwrites it with the checkpoint's version. It cannot be undone.", + confirmLabel: "Restore Anyway" + ) { + project.forceRewind(refusal) + } + }, + set: { newValue in + if newValue == nil { project.dismissRewindRefusal() } + } + ) } } private struct CheckpointCard: View { let point: RewindPoint + let disabledReason: String? let onRestore: () -> Void var body: some View { @@ -218,6 +327,9 @@ private struct CheckpointCard: View { } Spacer() Button("Restore", action: onRestore) + .disabled(disabledReason != nil) + .help(disabledReason ?? "Restore this checkpoint") + .accessibilityHint(disabledReason ?? "") } if let files = point.files, !files.isEmpty { diff --git a/macapp/Sources/GoCodeUI/SettingsView.swift b/macapp/Sources/GoCodeUI/SettingsView.swift index 6b8c6147..72b05d3f 100644 --- a/macapp/Sources/GoCodeUI/SettingsView.swift +++ b/macapp/Sources/GoCodeUI/SettingsView.swift @@ -46,11 +46,26 @@ private struct ProvidersTab: View { var body: some View { List { - if project.providers.isEmpty && project.providersLoadState != .loaded { + if project.providersLoadState.showsPlaceholder(itemCount: project.providers.count) { ForEach(0.. String { + var label = "\(model.id), \(model.provider)" + if project.selectedModel == model.id { label += ", selected" } + return label + } } private struct ProjectTab: View { @Bindable var project: ProjectSession + @State private var undoConfirmation: DestructiveConfirmation? var body: some View { Form { @@ -195,11 +237,30 @@ private struct ProjectTab: View { LabeledContent("Conversation actions") { HStack { Button("Fork") { Task { await project.fork() } } - Button("Undo Last Prompt") { Task { await project.undo() } } + .disabled(project.conversationActionDisabledReason != nil) + .help(project.conversationActionDisabledReason ?? "Fork conversation") + .accessibilityHint(project.conversationActionDisabledReason ?? "") + Button("Undo Last Prompt") { confirmUndo() } + .disabled(project.conversationActionDisabledReason != nil) + .help(project.conversationActionDisabledReason ?? "Undo last prompt") + .accessibilityHint(project.conversationActionDisabledReason ?? "") } } } .formStyle(.grouped) + .destructiveConfirmation($undoConfirmation) + } + + /// States what turn will be lost before it is lost (R6). + private func confirmUndo() { + let lastPrompt = UndoPreview.lastUserPrompt(in: project.run?.transcript.items ?? []) + undoConfirmation = DestructiveConfirmation( + title: "Undo last turn?", + message: UndoPreview.message(lastPrompt: lastPrompt), + confirmLabel: "Undo" + ) { + Task { await project.undo() } + } } } diff --git a/macapp/Sources/GoCodeUI/TranscriptScrollPin.swift b/macapp/Sources/GoCodeUI/TranscriptScrollPin.swift new file mode 100644 index 00000000..faf7cedb --- /dev/null +++ b/macapp/Sources/GoCodeUI/TranscriptScrollPin.swift @@ -0,0 +1,56 @@ +import SwiftUI + +/// Decides whether the transcript should auto-scroll to the bottom as new +/// content streams in. +/// +/// A fresh transcript starts pinned. Once the operator scrolls away from the +/// bottom by more than `Layout.autoscrollPinThreshold`, autoscroll stops so a +/// stream does not yank the view away mid-read; returning to the bottom +/// re-pins it. This is a pure decision — the view supplies the measured +/// distance and only reads `isPinned`. +struct TranscriptScrollPin { + private(set) var isPinned = true + + mutating func update(distanceFromBottom: CGFloat) { + isPinned = distanceFromBottom <= Layout.autoscrollPinThreshold + } + + /// Restores the operator's explicit intent to follow the live end of the + /// transcript. The view calls this before its programmatic scroll, so new + /// stream deltas continue to follow after the operator chooses Jump to + /// Latest. + mutating func followLatest() { + isPinned = true + } +} + +/// Owns the generation of the view's programmatic transcript scroll. Geometry +/// is ignored only for the matching animated scroll, so a completion from an +/// earlier streamed delta cannot expose mid-animation geometry from a later +/// one and accidentally unpin the transcript. +struct TranscriptAutoscrollState { + private(set) var generation = 0 + private(set) var suppressesGeometryUpdates = false + + /// Starts a new programmatic scroll and invalidates any earlier completion. + /// Reduce Motion scrolls directly, so it has no animation interval to + /// suppress. + mutating func begin(animated: Bool) -> Int { + generation &+= 1 + suppressesGeometryUpdates = animated + return generation + } + + /// Ends suppression only when this is still the latest scroll generation. + mutating func finish(generation: Int) { + guard generation == self.generation else { return } + suppressesGeometryUpdates = false + } + + /// Invalidates a pending completion when the transcript view leaves the + /// hierarchy. + mutating func cancel() { + generation &+= 1 + suppressesGeometryUpdates = false + } +} diff --git a/macapp/Sources/HarnessKit/Transcript.swift b/macapp/Sources/HarnessKit/Transcript.swift index db33f35e..71978c08 100644 --- a/macapp/Sources/HarnessKit/Transcript.swift +++ b/macapp/Sources/HarnessKit/Transcript.swift @@ -109,6 +109,21 @@ public struct Transcript: Sendable { public private(set) var pendingPlan: PendingPlan? public private(set) var lastEventID: String? + /// The run whose cumulative usage/cost currently owns `usage`. Harnessd + /// restarts cumulative accounting at zero for every run, while a + /// conversation transcript survives across runs, so monotonic merging is + /// valid only inside this identity. + private var accountingRunID: String? + /// Set after a local prompt starts a new run but before harnessd reveals + /// its id. Events from the prior run can still arrive through the + /// conversation stream during this gap and must not reclaim accounting. + private var awaitingAccountingRun = false + private var previousAccountingRunID: String? + /// A terminal event's cost status is the server's sealed conclusion for + /// this run. Duplicate conversation streams may deliver older deltas + /// afterward, but they cannot reclassify that conclusion. + private var sealedTerminalCostStatus: String? + /// Index into `items` of the assistant message currently accumulating /// deltas, so a new tool row between turns starts a fresh message. private var streamingMessageIndex: Int? @@ -121,33 +136,64 @@ public struct Transcript: Sendable { items.append(.init(id: UUID(), kind: .userPrompt(text))) // A new prompt ends any previous streaming message. streamingMessageIndex = nil + // Usage is per run, not per conversation. Clear it immediately so the + // queued follow-up never presents the previous run's tokens/cost while + // waiting for the new run id. + previousAccountingRunID = accountingRunID + accountingRunID = nil + awaitingAccountingRun = true + resetUsage() // Go busy immediately rather than waiting for the server's first event: // otherwise the composer stays enabled during the round trip and a // second submit can start a duplicate run. runState = .queued } + /// Reserves cumulative accounting for the run id returned by + /// `startRun`. Until this is called, conversation-stream events may still + /// render, but none can claim the submitted run's usage or lifecycle. + public mutating func bindAccountingRun(_ runID: String) { + guard awaitingAccountingRun, !runID.isEmpty else { return } + accountingRunID = runID + awaitingAccountingRun = false + resetUsage() + } + public mutating func apply(_ event: HarnessEvent) { lastEventID = event.id let payload = event.payload + let ownsAccounting = prepareAccounting(for: event) + // Local synthetic terminal events represent transport failure or an + // operator force-stop for the active run. They carry no accounting, + // but must still settle the UI state. + let ownsRunState = ownsAccounting || event.runID == "local" switch event.type { case .runQueued: - runState = .queued + if ownsRunState { runState = .queued } case .runStarted, .runResumed: - runState = .running + if ownsRunState { runState = .running } case .runCompleted: - finishStreaming() - runState = .completed + if ownsRunState { + if ownsAccounting { applyTerminalUsage(payload) } + finishStreaming() + runState = .completed + } case .runFailed: - finishStreaming() - runState = .failed - if let message = payload["error"]?.stringValue, !message.isEmpty { - items.append(.init(id: UUID(), kind: .error(message))) + if ownsRunState { + if ownsAccounting { applyTerminalUsage(payload) } + finishStreaming() + runState = .failed + if let message = payload["error"]?.stringValue, !message.isEmpty { + items.append(.init(id: UUID(), kind: .error(message))) + } } case .runCancelled: - finishStreaming() - runState = .cancelled + if ownsRunState { + if ownsAccounting { applyTerminalUsage(payload) } + finishStreaming() + runState = .cancelled + } case .assistantMessageDelta: guard let chunk = payload["content"]?.stringValue, !chunk.isEmpty else { break } @@ -256,7 +302,7 @@ public struct Transcript: Sendable { ?? payload["removed"]?.intValue ?? 0))) case .usageDelta: - applyUsage(payload) + if ownsAccounting { applyUsage(payload) } default: break @@ -265,6 +311,44 @@ public struct Transcript: Sendable { // MARK: - Helpers + /// Chooses the single run allowed to mutate cumulative accounting. + /// Duplicate events for that run remain monotonic; late events from an + /// older run are ignored instead of resetting or inflating the new run. + private mutating func prepareAccounting(for event: HarnessEvent) -> Bool { + guard !event.runID.isEmpty else { return accountingRunID == nil } + if accountingRunID == event.runID { return true } + + let startsRun: Bool + switch event.type { + case .runQueued, .runStarted, .runResumed: + startsRun = true + default: + startsRun = false + } + + if awaitingAccountingRun { + // The conversation stream can deliver background/callback events + // from other runs while `startRun` is in flight. Only the id + // returned by that request may end this reservation. + return false + } + + if accountingRunID == nil { + accountingRunID = event.runID + return true + } + + // A server-started follow-up has no local `appendUserPrompt` boundary. + // Its queued/started event may claim accounting only after the prior + // run is terminal; while a run is active, a different id is a late + // event from another stream and cannot replace the owner. + guard startsRun, !runState.isActive else { return false } + previousAccountingRunID = accountingRunID + accountingRunID = event.runID + resetUsage() + return true + } + private mutating func appendDelta(_ chunk: String) { if let index = streamingMessageIndex, case .assistantMessage(var message) = items[index].kind @@ -306,15 +390,66 @@ public struct Transcript: Sendable { /// with cost as a sibling flat field. private mutating func applyUsage(_ payload: [String: JSONValue]) { if let totals = payload["cumulative_usage"]?.objectValue { - usage.promptTokens = totals["prompt_tokens"]?.intValue ?? usage.promptTokens - usage.completionTokens = - totals["completion_tokens"]?.intValue ?? usage.completionTokens - usage.totalTokens = totals["total_tokens"]?.intValue ?? usage.totalTokens + mergeUsageTotals( + promptTokens: totals["prompt_tokens"]?.intValue, + completionTokens: totals["completion_tokens"]?.intValue, + totalTokens: totals["total_tokens"]?.intValue) } if let cost = payload["cumulative_cost_usd"]?.doubleValue { - usage.costUSD = cost + usage.costUSD = max(usage.costUSD, cost) } if let status = payload["cost_status"]?.stringValue { + if sealedTerminalCostStatus == nil { + mergeCostStatus(status) + } + } + } + + /// `run.completed` is the server's sealed, authoritative accounting + /// snapshot. The app consumes the same run through both per-run and + /// conversation streams, so the terminal event can win the scheduling + /// race before a duplicate stream's earlier `usage.delta`. Reconcile from + /// the terminal payload before publishing `.completed`, then keep all + /// cumulative values monotonic when older duplicate events arrive. + private mutating func applyTerminalUsage(_ payload: [String: JSONValue]) { + if let totals = payload["usage_totals"]?.objectValue { + mergeUsageTotals( + promptTokens: totals["prompt_tokens_total"]?.intValue, + completionTokens: totals["completion_tokens_total"]?.intValue, + totalTokens: totals["total_tokens"]?.intValue) + } + if let costs = payload["cost_totals"]?.objectValue { + if let cost = costs["cost_usd_total"]?.doubleValue { + usage.costUSD = max(usage.costUSD, cost) + } + if let status = costs["cost_status"]?.stringValue { + usage.costStatus = status + sealedTerminalCostStatus = status + } + } + } + + private mutating func resetUsage() { + usage = UsageTotals() + sealedTerminalCostStatus = nil + } + + private mutating func mergeUsageTotals( + promptTokens: Int?, completionTokens: Int?, totalTokens: Int? + ) { + if let promptTokens { + usage.promptTokens = max(usage.promptTokens, promptTokens) + } + if let completionTokens { + usage.completionTokens = max(usage.completionTokens, completionTokens) + } + if let totalTokens { + usage.totalTokens = max(usage.totalTokens, totalTokens) + } + } + + private mutating func mergeCostStatus(_ status: String) { + if status == "available" || usage.costStatus != "available" { usage.costStatus = status } } @@ -371,12 +506,22 @@ extension Transcript { /// message rebuild as well. public mutating func reconcile(messages: [StoredMessage]) { let terminalState = runState + let terminalUsage = usage + let terminalCostStatus = sealedTerminalCostStatus + let terminalAccountingRunID = accountingRunID + let wasAwaitingAccountingRun = awaitingAccountingRun + let priorAccountingRunID = previousAccountingRunID let terminalErrors = items.compactMap { item -> String? in if case .error(let message) = item.kind { return message } return nil } load(messages: messages) + usage = terminalUsage + sealedTerminalCostStatus = terminalCostStatus + accountingRunID = terminalAccountingRunID + awaitingAccountingRun = wasAwaitingAccountingRun + previousAccountingRunID = priorAccountingRunID switch terminalState { case .failed: diff --git a/macapp/Tests/GoCodeUITests/AccessibilityReachabilityTests.swift b/macapp/Tests/GoCodeUITests/AccessibilityReachabilityTests.swift new file mode 100644 index 00000000..8a378888 --- /dev/null +++ b/macapp/Tests/GoCodeUITests/AccessibilityReachabilityTests.swift @@ -0,0 +1,92 @@ +import Foundation +import Testing + +@testable import GoCodeUI + +/// Exercises the fix for #999 (F8, R9): conversation rows and model rows were +/// `onTapGesture` targets rather than controls, the per-model exposure toggle +/// had no accessibility name, and provider removal fired immediately. Follows +/// `TranscriptFeatureReachabilityTests`' pattern: scan production source for +/// the shape of the defect and the shape of the fix, since a `Button` versus +/// an `onTapGesture`, an `.accessibilityLabel`, and an alert presentation are +/// none of them assertable through a headless view-rendering test on this +/// stack. +@Suite("Accessibility and settings-feedback reachability") +struct AccessibilityReachabilityTests { + + @Test("the conversation row is a real control, not an onTapGesture target") + func conversationRowIsARealControl() throws { + let contents = try ReachabilitySource.file("SessionsView.swift") + #expect(!contents.contains(".onTapGesture")) + #expect(contents.contains(".accessibilityLabel(")) + } + + @Test("the model row is a real control, not an onTapGesture target") + func modelRowIsARealControl() throws { + let contents = try ReachabilitySource.file("SettingsView.swift") + #expect(!contents.contains(".onTapGesture { project.selectedModel = model.id }")) + #expect(contents.contains(".accessibilityLabel(")) + } + + /// Regression angle distinct from the "no .onTapGesture" check above: + /// that check alone would still pass if the row were left an inert, + /// unfocusable view with only a bystander `.accessibilityLabel` + /// somewhere else in the file. This pins that the label is attached to + /// an actual `Button` wrapping `ConversationRow`, which is what restores + /// keyboard focus and Return activation -- the actual defect (#999 + /// finding 1), not merely the absence of the old gesture. + @Test( + "the conversation row's accessibility label is attached to a real Button, not a bystander" + ) + func conversationRowAccessibilityLabelIsOnARealButton() throws { + let contents = try ReachabilitySource.file("SessionsView.swift") + let pattern = + #"Button \{[\s\S]*?\}\s*label:\s*\{\s*ConversationRow\(conversation: conversation\)\s*\}\s*\n\s*\.buttonStyle\(\.plain\)\s*\n\s*\.accessibilityLabel\("# + #expect(contents.range(of: pattern, options: .regularExpression) != nil) + } + + /// Same reasoning as above, for the model row. + @Test("the model row's accessibility label is attached to a real Button, not a bystander") + func modelRowAccessibilityLabelIsOnARealButton() throws { + let contents = try ReachabilitySource.file("SettingsView.swift") + let pattern = + #"Button \{\s*project\.selectedModel = model\.id\s*\}\s*label:\s*\{[\s\S]*?\}\s*\n\s*\.buttonStyle\(\.plain\)\s*\n\s*\.accessibilityLabel\("# + #expect(contents.range(of: pattern, options: .regularExpression) != nil) + } + + @Test("the exposure toggle carries an accessibility label naming the model") + func exposureToggleIsNamed() throws { + let contents = try ReachabilitySource.file("ModelSettingsView.swift") + #expect(contents.contains(".accessibilityLabel(\"Show \\(entry.modelID) in the picker\")")) + // `.labelsHidden()` is a layout choice; it must not also drop the name. + #expect(contents.contains(".labelsHidden()")) + } + + /// #999's finding was that `Remove` fired `model.delete` the instant it + /// was tapped. Mirrors `DestructiveConfirmationTests`' per-callsite check: + /// counting occurrences of the helper catches a reverted call site that + /// left a now-dangling helper declaration behind, which a bare + /// `contains("confirmRemove")` check would miss. + @Test("provider Remove routes through confirmRemove, not an immediate delete") + func providerRemoveRoutesThroughConfirmRemove() throws { + let contents = try ReachabilitySource.file("ModelSettingsView.swift") + #expect(occurrences(of: "confirmRemove(", in: contents) >= 2) + #expect(contents.contains(".destructiveConfirmation(")) + } + + /// Distinct from the two file-specific tests above: this is the exact + /// shape of the defect (#991 finding 8) rather than a named file, so a + /// third instance introduced anywhere in the module is still caught. + @Test("no row in the module pairs .contentShape(.rect) with .onTapGesture") + func noRowPairsContentShapeWithTapGesture() throws { + let source = try ReachabilitySource.wholeModule() + let pattern = #"\.contentShape\(\.rect\)\s*\n\s*\.onTapGesture"# + #expect(source.range(of: pattern, options: .regularExpression) == nil) + } + + // MARK: - Helpers + + private func occurrences(of needle: String, in haystack: String) -> Int { + haystack.components(separatedBy: needle).count - 1 + } +} diff --git a/macapp/Tests/GoCodeUITests/AskUserAnswersTests.swift b/macapp/Tests/GoCodeUITests/AskUserAnswersTests.swift new file mode 100644 index 00000000..0fa88b2a --- /dev/null +++ b/macapp/Tests/GoCodeUITests/AskUserAnswersTests.swift @@ -0,0 +1,57 @@ +import Foundation +import HarnessKit +import Testing + +@testable import GoCodeUI + +/// Exercises the fix for #994 (F3): the `Send` button and `RunSession.answer`'s +/// guard used to accept `answers.count < prompt.questions.count`, which counts +/// a question whose field was typed into and then cleared back to `""` as +/// answered. `AskUserAnswers.isComplete` is the shared, testable replacement. +@Suite("AskUserAnswers completeness") +struct AskUserAnswersTests { + + /// `AskUserPrompt` only decodes from the wire shape (no memberwise init), + /// so tests build one the same way production code does. + private func prompt(questionTexts: [String]) throws -> AskUserPrompt { + let questions = questionTexts.map { #"{"question":"\#($0)"}"# }.joined(separator: ",") + let json = #"{"run_id":"run_1","call_id":"call_1","questions":[\#(questions)]}"# + return try JSONDecoder().decode(AskUserPrompt.self, from: Data(json.utf8)) + } + + @Test("every question answered with non-blank text is complete") + func allAnswered() throws { + let prompt = try prompt(questionTexts: ["a?", "b?"]) + let answers = [prompt.questions[0].id: "yes", prompt.questions[1].id: "no"] + #expect(AskUserAnswers.isComplete(prompt: prompt, answers: answers)) + } + + @Test("a missing answer id is incomplete") + func missingID() throws { + let prompt = try prompt(questionTexts: ["a?", "b?"]) + let answers = [prompt.questions[0].id: "yes"] + #expect(!AskUserAnswers.isComplete(prompt: prompt, answers: answers)) + } + + @Test("an empty-string answer is incomplete") + func emptyAnswer() throws { + let prompt = try prompt(questionTexts: ["a?"]) + let answers = [prompt.questions[0].id: ""] + #expect(!AskUserAnswers.isComplete(prompt: prompt, answers: answers)) + } + + @Test("a whitespace-only answer is incomplete -- the finding this predicate fixes") + func whitespaceOnlyAnswer() throws { + let prompt = try prompt(questionTexts: ["a?"]) + let answers = [prompt.questions[0].id: " "] + #expect(!AskUserAnswers.isComplete(prompt: prompt, answers: answers)) + } + + @Test("a single freeform question answered is complete") + func singleFreeformAnswered() throws { + let prompt = try prompt(questionTexts: ["What is the file path?"]) + #expect(prompt.questions[0].isFreeform) + let answers = [prompt.questions[0].id: "/tmp/x.txt"] + #expect(AskUserAnswers.isComplete(prompt: prompt, answers: answers)) + } +} diff --git a/macapp/Tests/GoCodeUITests/CollectionLoadStateTests.swift b/macapp/Tests/GoCodeUITests/CollectionLoadStateTests.swift index f297a29a..77b3cb6a 100644 --- a/macapp/Tests/GoCodeUITests/CollectionLoadStateTests.swift +++ b/macapp/Tests/GoCodeUITests/CollectionLoadStateTests.swift @@ -9,8 +9,57 @@ struct CollectionLoadStateTests { func emptyStateRequiresLoadedCollection() { #expect(!CollectionLoadState.idle.showsEmptyState(itemCount: 0)) #expect(!CollectionLoadState.loading.showsEmptyState(itemCount: 0)) - #expect(!CollectionLoadState.failed.showsEmptyState(itemCount: 0)) + #expect(!CollectionLoadState.failed("boom").showsEmptyState(itemCount: 0)) #expect(CollectionLoadState.loaded.showsEmptyState(itemCount: 0)) #expect(!CollectionLoadState.loaded.showsEmptyState(itemCount: 1)) } + + /// The core regression: `.failed` used to render identically to + /// `.loading` (an endless skeleton, since neither was `.loaded`). A + /// failure with nothing on screen yet must not claim a result is still + /// pending. + @Test("a failed load never shows a loading placeholder") + func failedNeverShowsPlaceholder() { + #expect(!CollectionLoadState.failed("boom").showsPlaceholder(itemCount: 0)) + } + + @Test("loading shows a placeholder only while nothing is on screen yet") + func loadingShowsPlaceholderOnlyWhenEmpty() { + #expect(CollectionLoadState.loading.showsPlaceholder(itemCount: 0)) + #expect(!CollectionLoadState.loading.showsPlaceholder(itemCount: 3)) + } + + @Test("idle shows a placeholder before the first load starts") + func idleShowsPlaceholderBeforeFirstLoad() { + #expect(CollectionLoadState.idle.showsPlaceholder(itemCount: 0)) + #expect(!CollectionLoadState.idle.showsPlaceholder(itemCount: 1)) + } + + @Test("a failed state carries its own message, verbatim") + func failedCarriesItsMessage() { + #expect(CollectionLoadState.failed("boom").errorMessage == "boom") + #expect(CollectionLoadState.loading.errorMessage == nil) + } + + @Test("showsError is true only for a failed state") + func showsErrorOnlyForFailed() { + #expect(CollectionLoadState.failed("boom").showsError) + #expect(!CollectionLoadState.loaded.showsError) + #expect(!CollectionLoadState.loading.showsError) + #expect(!CollectionLoadState.idle.showsError) + } + + @Test("a failed first load is a blocking error") + func failedFirstLoadShowsBlockingError() { + #expect(CollectionLoadState.failed("boom").showsBlockingError(itemCount: 0)) + #expect(!CollectionLoadState.failed("boom").showsBlockingError(itemCount: 2)) + #expect(!CollectionLoadState.loading.showsBlockingError(itemCount: 0)) + } + + @Test("a failed refresh preserves stale rows with a nonblocking error") + func failedRefreshShowsNonblockingError() { + #expect(CollectionLoadState.failed("boom").showsRefreshError(itemCount: 2)) + #expect(!CollectionLoadState.failed("boom").showsRefreshError(itemCount: 0)) + #expect(!CollectionLoadState.loaded.showsRefreshError(itemCount: 2)) + } } diff --git a/macapp/Tests/GoCodeUITests/DestructiveConfirmationTests.swift b/macapp/Tests/GoCodeUITests/DestructiveConfirmationTests.swift new file mode 100644 index 00000000..39113d78 --- /dev/null +++ b/macapp/Tests/GoCodeUITests/DestructiveConfirmationTests.swift @@ -0,0 +1,144 @@ +import Foundation +import HarnessKit +import Testing + +@testable import GoCodeUI + +/// Exercises the fix for #996 (F5, R6): delete and undo fired immediately +/// with no statement of what would be lost. `DeletePreview` / `UndoPreview` +/// are pure, client-derived preview builders (KTD-5 -- there is no server +/// dry-run) reused by every destructive entry point through the shared +/// `DestructiveConfirmation` presentation (KTD-4). +@Suite("Destructive confirmation previews") +struct DestructiveConfirmationTests { + + // MARK: - DeletePreview + + @Test("a delete preview names the conversation title and a known message count") + func deletePreviewNamesKnownCount() throws { + let conversation = try makeConversation(title: "Fix the parser", messageCount: 12) + let message = DeletePreview.message(for: conversation) + #expect(message.contains("Fix the parser")) + #expect(message.contains("12")) + } + + /// Core regression: no fabricated count. `ConversationInfo.messageCount` + /// is `nil` whenever the server omitted it, and the preview must say so + /// plainly rather than inventing a number. + @Test("a delete preview with an unknown message count never invents a number") + func deletePreviewNeverInventsCount() throws { + let conversation = try makeConversation(title: "Untitled conversation", messageCount: nil) + let message = DeletePreview.message(for: conversation) + #expect(message.contains("unknown")) + #expect(!message.contains(where: { $0.isNumber })) + } + + @Test("a very long conversation title is truncated to a bounded length") + func deletePreviewTruncatesLongTitles() throws { + let conversation = try makeConversation( + title: String(repeating: "a", count: 500), messageCount: 3) + let message = DeletePreview.message(for: conversation) + #expect(message.count < 200) + } + + // MARK: - UndoPreview + + @Test("an undo preview quotes the last prompt") + func undoPreviewQuotesLastPrompt() { + let message = UndoPreview.message(lastPrompt: "fix the parser") + #expect(message.contains("fix the parser")) + } + + /// When the transcript holds no prior user prompt, the wording must stay + /// neutral rather than quoting an empty string. + @Test("an undo preview with no prior prompt reads neutrally") + func undoPreviewWithNoPromptIsNeutral() { + let message = UndoPreview.message(lastPrompt: nil) + #expect(message.contains("the last turn")) + #expect(!message.contains("\"\"")) + } + + @Test("an undo preview flattens a multi-line prompt to one line") + func undoPreviewFlattensMultilinePrompt() { + let message = UndoPreview.message(lastPrompt: "line one\nline two") + #expect(!message.contains("\n")) + #expect(message.contains("line one line two")) + } + + @Test("lastUserPrompt finds the most recent user prompt in a transcript") + func lastUserPromptFindsMostRecent() { + var transcript = Transcript() + transcript.appendUserPrompt("first") + transcript.appendUserPrompt("second") + #expect(UndoPreview.lastUserPrompt(in: transcript.items) == "second") + } + + @Test("lastUserPrompt returns nil for a transcript with no user prompts") + func lastUserPromptReturnsNilWithoutOne() { + let transcript = Transcript() + #expect(UndoPreview.lastUserPrompt(in: transcript.items) == nil) + } + + // MARK: - Reachability (KTD-4 wiring) + + /// #996's finding was that delete and undo fired the moment the button + /// was tapped. This pins that the immediate-action shape is gone from + /// the whole module and that the shared confirmation is actually used, + /// not merely that `DeletePreview`/`UndoPreview` exist unreferenced. + @Test( + "delete and undo route through the shared destructive confirmation, not an immediate action" + ) + func destructiveActionsRouteThroughSharedConfirmation() throws { + let source = try ReachabilitySource.wholeModule() + #expect(!source.contains("Button(\"Delete\", role: .destructive) { delete(")) + #expect(source.contains("destructiveConfirmation(")) + } + + // MARK: - Regression: per-callsite wiring + + /// Distinct from the module-wide reachability check above, which would + /// still pass even if three of the four undo entry points reverted to + /// firing `project.undo()` immediately -- `destructiveConfirmation(` + /// would still be found via whichever single site kept it, and a bare + /// `contains("confirmUndo()")` check would too, since that string also + /// appears in the (still-present, now merely unused) function + /// declaration. Counting occurrences catches that: a genuine call site + /// plus its declaration is two occurrences; a reverted call site that + /// left the declaration dangling is one. + @Test("every undo entry point calls its own confirmUndo helper, not project.undo() directly") + func everyUndoEntryPointRoutesThroughItsOwnConfirmation() throws { + for file in ["ChatView.swift", "ConversationChrome.swift", "SettingsView.swift"] { + let contents = try ReachabilitySource.file(file) + #expect( + occurrences(of: "confirmUndo()", in: contents) >= 2, + "\(file) should both declare and call confirmUndo(), not call project.undo() directly" + ) + #expect( + contents.contains("UndoPreview.message(lastPrompt:"), + "\(file)'s confirmUndo() should build its preview text from UndoPreview" + ) + } + } + + /// Same reasoning for delete: pins `SessionsView`'s specific call site + /// rather than the module-wide presence of the shared presentation. + @Test("the conversation delete menu item calls confirmDelete, not deleteConversation directly") + func deleteMenuItemRoutesThroughConfirmDelete() throws { + let contents = try ReachabilitySource.file("SessionsView.swift") + #expect(occurrences(of: "confirmDelete(", in: contents) >= 2) + #expect(contents.contains("DeletePreview.message(for:")) + } + + // MARK: - Helpers + + private func occurrences(of needle: String, in haystack: String) -> Int { + haystack.components(separatedBy: needle).count - 1 + } + + private func makeConversation(title: String, messageCount: Int?) throws -> ConversationInfo { + let json = """ + {"id": "c1", "title": "\(title)", "message_count": \(messageCount.map(String.init) ?? "null")} + """ + return try JSONDecoder().decode(ConversationInfo.self, from: Data(json.utf8)) + } +} diff --git a/macapp/Tests/GoCodeUITests/ModelSettingsFeedbackTests.swift b/macapp/Tests/GoCodeUITests/ModelSettingsFeedbackTests.swift new file mode 100644 index 00000000..da4b9aca --- /dev/null +++ b/macapp/Tests/GoCodeUITests/ModelSettingsFeedbackTests.swift @@ -0,0 +1,188 @@ +import Foundation +import HarnessKit +import Testing + +@testable import GoCodeUI + +/// Minimal HTTP stub scoped to this file's tests, keyed on HTTP method and +/// path. `ModelSettingsModel` takes a `HarnessClient` directly (no +/// `ProjectSession` in between), so the stub is wired through the client's +/// injectable `URLSessionConfiguration` rather than a global `URLProtocol` +/// registration -- the same shape `RunControlAckTests` uses. +private final class ModelSettingsStub: URLProtocol, @unchecked Sendable { + struct Response: Sendable { + var status: Int = 200 + var body: Data = Data() + } + + nonisolated(unsafe) private static var handler: (@Sendable (URLRequest) -> Response)? + private static let lock = NSLock() + + static func set(_ handler: @escaping @Sendable (URLRequest) -> Response) { + lock.withLock { self.handler = handler } + } + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + let response = Self.lock.withLock { Self.handler }?(request) ?? Response() + let http = HTTPURLResponse( + url: request.url!, statusCode: response.status, + httpVersion: "HTTP/1.1", headerFields: ["Content-Type": "application/json"])! + client?.urlProtocol(self, didReceive: http, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: response.body) + client?.urlProtocolDidFinishLoading(self) + } + override func stopLoading() {} +} + +/// Exercises the fix for #999 (F8, R10): `ModelSettingsModel.load()` +/// unconditionally set `status = nil` on success, which erased the message +/// `fetch`/`setExposed`/etc. had just set two lines earlier -- the reload +/// every action already triggers threw its own result away before the +/// operator could read it. +@Suite("ModelSettingsModel status feedback", .serialized) +@MainActor +struct ModelSettingsFeedbackTests { + + private let providerJSON = """ + {"providers":[{"name":"openai","base_url":"https://api.openai.com","protocol":"openai_compat","auth_kind":"api_key","builtin":true,"has_credential":true,"key_ref":null,"model_count":1,"exposed_count":1,"fetched_at":null,"fetch_error":null,"models":null}]} + """ + + private func makeModel() -> ModelSettingsModel { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [ModelSettingsStub.self] + let client = HarnessClient( + baseURL: URL(string: "http://127.0.0.1:18899")!, + session: URLSession(configuration: config)) + return ModelSettingsModel(client: client) + } + + @Test("a successful fetch's status survives the reload that follows it -- core regression") + func fetchStatusSurvivesReload() async throws { + let providerJSON = providerJSON + ModelSettingsStub.set { request in + switch (request.httpMethod, request.url?.path) { + case ("GET", "/v1/model-settings"): + return .init(status: 200, body: Data(providerJSON.utf8)) + case ("POST", "/v1/model-settings/providers/openai/fetch"): + return .init(status: 200, body: Data(#"{"model_count":3}"#.utf8)) + default: + return .init(status: 200, body: Data("{}".utf8)) + } + } + let model = makeModel() + + await model.fetch("openai") + + #expect(model.status?.contains("Fetched") == true) + #expect(model.loadState == .loaded) + } + + @Test("a failed fetch's reason survives the reload that follows it") + func fetchFailureStatusSurvivesReload() async throws { + let providerJSON = providerJSON + ModelSettingsStub.set { request in + switch (request.httpMethod, request.url?.path) { + case ("GET", "/v1/model-settings"): + return .init(status: 200, body: Data(providerJSON.utf8)) + case ("POST", "/v1/model-settings/providers/openai/fetch"): + return .init( + status: 500, + body: Data(#"{"error":{"code":"boom","message":"bad key"}}"#.utf8)) + default: + return .init(status: 200, body: Data("{}".utf8)) + } + } + let model = makeModel() + + await model.fetch("openai") + + #expect(model.status?.contains("bad key") == true) + } + + @Test("the initial load has no prior status to clear, and lands loaded") + func initialLoadStartsClean() async throws { + let providerJSON = providerJSON + ModelSettingsStub.set { request in + .init(status: 200, body: Data(providerJSON.utf8)) + } + let model = makeModel() + + await model.load() + + #expect(model.status == nil) + #expect(model.loadState == .loaded) + } + + @Test("a failed setExposed's reason survives the reload that follows it") + func setExposedFailureStatusSurvivesReload() async throws { + let providerJSON = providerJSON + ModelSettingsStub.set { request in + switch (request.httpMethod, request.url?.path) { + case ("GET", "/v1/model-settings"): + return .init(status: 200, body: Data(providerJSON.utf8)) + case ("POST", "/v1/model-settings/providers/openai/expose"): + return .init( + status: 500, + body: Data(#"{"error":{"code":"boom","message":"could not save"}}"#.utf8)) + default: + return .init(status: 200, body: Data("{}".utf8)) + } + } + let model = makeModel() + + await model.setExposed("openai", "gpt-5", true) + + #expect(model.status?.contains("could not save") == true) + } + + /// `clearingStatus` is the escape hatch for the initial `.task` load, + /// which must still clear a stale status rather than leave a past + /// failure looking permanent. + @Test("load(clearingStatus: true) clears a stale status on success") + func clearingStatusTrueClearsStaleStatus() async throws { + let providerJSON = providerJSON + ModelSettingsStub.set { request in + .init(status: 200, body: Data(providerJSON.utf8)) + } + let model = makeModel() + model.status = "some stale message" + + await model.load(clearingStatus: true) + + #expect(model.status == nil) + } + + /// Regression angle distinct from the fetch/setExposed tests above: the + /// fix is a shared change to `load(clearingStatus:)` itself plus every + /// mutating call site, not a special case hard-coded for one method. + /// This proves a *different* mutation -- removing a provider -- is + /// covered too, so a change that special-cased `fetch`/`setExposed` + /// alone and left `delete`'s catch branch calling the old `load()` + /// (which would nil the "Could not remove" message it had just set) + /// would still be caught. + @Test("a failed delete's reason survives the reload that follows it") + func deleteFailureStatusSurvivesReload() async throws { + let providerJSON = providerJSON + ModelSettingsStub.set { request in + switch (request.httpMethod, request.url?.path) { + case ("GET", "/v1/model-settings"): + return .init(status: 200, body: Data(providerJSON.utf8)) + case ("DELETE", "/v1/model-settings/providers/openai"): + return .init( + status: 500, + body: Data(#"{"error":{"code":"boom","message":"provider is builtin"}}"#.utf8) + ) + default: + return .init(status: 200, body: Data("{}".utf8)) + } + } + let model = makeModel() + + await model.delete("openai") + + #expect(model.status?.contains("provider is builtin") == true) + } +} diff --git a/macapp/Tests/GoCodeUITests/ProjectSessionLifecycleGuardTests.swift b/macapp/Tests/GoCodeUITests/ProjectSessionLifecycleGuardTests.swift new file mode 100644 index 00000000..82428ff8 --- /dev/null +++ b/macapp/Tests/GoCodeUITests/ProjectSessionLifecycleGuardTests.swift @@ -0,0 +1,581 @@ +import Foundation +import HarnessKit +import Testing + +@testable import GoCodeUI + +/// Minimal HTTP stub scoped to this file's tests. Mirrors `RunControlStub` +/// (`RunControlAckTests.swift`) rather than the path-only +/// `ActivityStubProtocol`/`LoadStateStubProtocol` stubs, because these tests +/// must assert *no* fork/undo request ever reached the server -- which +/// requires recording every request, not just answering the +/// last-registered handler. Scoped to its own fixed loopback port so it +/// never intercepts another suite's traffic. +private final class LifecycleGuardStub: URLProtocol, @unchecked Sendable { + struct Response: Sendable { + var status: Int = 200 + var body: Data = Data() + } + + static let port = 18915 + nonisolated(unsafe) private static var handler: (@Sendable (URLRequest) -> Response)? + nonisolated(unsafe) private static var recorded: [URLRequest] = [] + private static let lock = NSLock() + + static func set(_ handler: @escaping @Sendable (URLRequest) -> Response) { + lock.withLock { self.handler = handler } + } + + static func reset() { + lock.withLock { + handler = nil + recorded = [] + } + } + + static func requests(matching path: String) -> [URLRequest] { + lock.withLock { recorded.filter { $0.url?.path == path } } + } + + override class func canInit(with request: URLRequest) -> Bool { + request.url?.port == port + } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + let request = self.request + let response = Self.lock.withLock { + Self.recorded.append(request) + return Self.handler?(request) ?? Response() + } + let http = HTTPURLResponse( + url: request.url!, statusCode: response.status, + httpVersion: "HTTP/1.1", headerFields: ["Content-Type": "application/json"])! + client?.urlProtocol(self, didReceive: http, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: response.body) + client?.urlProtocolDidFinishLoading(self) + } + override func stopLoading() {} +} + +/// Exercises the fix for #995 (F4): `newConversation`/`fork`/`undo` never +/// consulted `run?.isBusy`, so any of the six call sites listed in KTD-9 +/// could race a running turn and silently reset or replace the conversation +/// underneath it. The guard lives once inside `ProjectSession` rather than +/// at each call site, so these tests drive the session methods directly -- +/// the same shape `ProjectSessionActivityTests` uses. +@Suite("ProjectSession lifecycle guard", .serialized) +@MainActor +struct ProjectSessionLifecycleGuardTests { + + private static let baseURL = URL(string: "http://127.0.0.1:\(LifecycleGuardStub.port)")! + + private func makeProject() -> ProjectSession { + URLProtocol.registerClass(LifecycleGuardStub.self) + return ProjectSession( + workspace: URL(fileURLWithPath: NSTemporaryDirectory()), + externalBaseURL: Self.baseURL) + } + + private func wait( + timeout: Duration = .seconds(5), for condition: () -> Bool + ) async throws { + let deadline = ContinuousClock.now.advanced(by: timeout) + while ContinuousClock.now < deadline { + if condition() { return } + try await Task.sleep(for: .milliseconds(20)) + } + Issue.record("timed out waiting for condition") + } + + /// Puts `project.run` into a busy state on conversation `conv_1`. + /// `RunSession.submit()` sets `transcript.runState = .queued` + /// synchronously -- before its background task even reaches the server + /// -- so `isBusy` is already true the moment this returns. The run's + /// events stream answers with an empty body (no terminal event), so the + /// run stays busy for the rest of a test unless it explicitly waits for + /// completion. + private func makeBusyProject() async -> ProjectSession { + let project = makeProject() + LifecycleGuardStub.set { request in + switch (request.httpMethod, request.url?.path) { + case ("POST", "/v1/runs"): + return .init( + status: 202, body: Data(#"{"run_id":"run_1","status":"queued"}"#.utf8)) + case ("GET", "/v1/runs/run_1/events"): + return .init(status: 200, body: Data()) + default: + return .init(status: 200, body: Data("{}".utf8)) + } + } + await project.start() + project.run?.rebind(conversationID: "conv_1") + project.run?.draft = "keep going" + project.run?.submit() + return project + } + + @Test("lifecycle controls expose one human-readable disabled reason") + func lifecycleDisabledReasonTracksBusyState() async { + LifecycleGuardStub.reset() + let project = await makeBusyProject() + + #expect( + project.conversationActionDisabledReason? + .localizedCaseInsensitiveContains("running") == true) + + project.run?.reset() + #expect(project.conversationActionDisabledReason == nil) + } + + @Test("newConversation refuses while a run is active -- core regression") + func newConversationRefusesWhileBusy() async throws { + LifecycleGuardStub.reset() + let project = await makeBusyProject() + + project.newConversation() + + #expect(project.run?.conversationID == "conv_1") + #expect(project.run?.isBusy == true) + #expect(project.statusMessage?.localizedCaseInsensitiveContains("running") == true) + + project.run?.reset() + } + + @Test("fork refuses while a run is active and never reaches the server") + func forkRefusesWhileBusy() async throws { + LifecycleGuardStub.reset() + let project = await makeBusyProject() + + await project.fork() + + #expect(LifecycleGuardStub.requests(matching: "/v1/conversations/conv_1/fork").isEmpty) + #expect(project.run?.conversationID == "conv_1") + #expect(project.statusMessage?.localizedCaseInsensitiveContains("running") == true) + + project.run?.reset() + } + + @Test("undo refuses while a run is active and never reaches the server") + func undoRefusesWhileBusy() async throws { + LifecycleGuardStub.reset() + let project = await makeBusyProject() + + await project.undo() + + #expect(LifecycleGuardStub.requests(matching: "/v1/conversations/conv_1/undo").isEmpty) + #expect(project.run?.conversationID == "conv_1") + #expect(project.statusMessage?.localizedCaseInsensitiveContains("running") == true) + + project.run?.reset() + } + + @Test("with no active run, fork, undo, and newConversation behave exactly as before") + func idlePathUnaffected() async throws { + LifecycleGuardStub.reset() + let project = makeProject() + LifecycleGuardStub.set { request in + switch (request.httpMethod, request.url?.path) { + case ("POST", "/v1/conversations/conv_1/fork"): + return .init(status: 200, body: Data(#"{"conversation_id":"conv_2"}"#.utf8)) + case ("POST", "/v1/conversations/conv_2/undo"): + return .init(status: 200) + default: + return .init(status: 200, body: Data("{}".utf8)) + } + } + await project.start() + project.run?.rebind(conversationID: "conv_1") + #expect(project.run?.isBusy == false) + + await project.fork() + #expect(project.run?.conversationID == "conv_2", "fork rebinds to the server's new id") + #expect(project.statusMessage == "Forked into a new conversation") + + await project.undo() + #expect( + !LifecycleGuardStub.requests(matching: "/v1/conversations/conv_2/undo").isEmpty, + "undo reloads the conversation it was called on") + + project.newConversation() + #expect(project.run?.conversationID == nil, "new resets the conversation") + } + + @Test( + "a previously refused fork succeeds once the run completes -- guard is state-based, not sticky" + ) + func guardClearsWhenRunCompletes() async throws { + LifecycleGuardStub.reset() + let project = makeProject() + LifecycleGuardStub.set { request in + switch (request.httpMethod, request.url?.path) { + case ("POST", "/v1/runs"): + return .init( + status: 202, body: Data(#"{"run_id":"run_1","status":"queued"}"#.utf8)) + case ("GET", "/v1/runs/run_1/events"): + let frame = """ + id: run_1:0 + event: run.completed + data: {"id":"run_1:0","run_id":"run_1","type":"run.completed","payload":{}} + + + """ + return .init(status: 200, body: Data(frame.utf8)) + case ("POST", "/v1/conversations/conv_1/fork"): + return .init(status: 200, body: Data(#"{"conversation_id":"conv_2"}"#.utf8)) + default: + return .init(status: 200, body: Data("{}".utf8)) + } + } + await project.start() + project.run?.rebind(conversationID: "conv_1") + project.run?.draft = "hi" + project.run?.submit() + + await project.fork() + #expect( + LifecycleGuardStub.requests(matching: "/v1/conversations/conv_1/fork").isEmpty, + "the run has not completed yet -- fork must still be refused") + + try await wait { project.run?.transcript.runState == .completed } + + await project.fork() + #expect( + !LifecycleGuardStub.requests(matching: "/v1/conversations/conv_1/fork").isEmpty, + "the run is no longer busy -- fork must now reach the server") + #expect(project.run?.conversationID == "conv_2") + + project.run?.reset() + } + + /// Exercises the fix for #995 (F4): `deleteConversation` used to call the + /// server's `DELETE` *before* consulting busyness at all, relying on its + /// own internal `newConversation()` call (`:349`) to refuse the local + /// reset afterwards -- which left the conversation actually deleted on + /// the server while the app stayed bound to it. The guard must now refuse + /// up front, before the server is ever contacted. + @Test( + "deleteConversation refuses up front for its own busy conversation -- the DELETE must never reach the server -- core regression" + ) + func deleteConversationInheritsGuardForOwnBusyRun() async throws { + LifecycleGuardStub.reset() + let project = await makeBusyProject() + LifecycleGuardStub.set { request in + switch (request.httpMethod, request.url?.path) { + case ("DELETE", "/v1/conversations/conv_1"): + return .init(status: 200) + case ("POST", "/v1/runs"): + return .init( + status: 202, body: Data(#"{"run_id":"run_1","status":"queued"}"#.utf8)) + case ("GET", "/v1/runs/run_1/events"): + return .init(status: 200, body: Data()) + default: + return .init(status: 200, body: Data("{}".utf8)) + } + } + let conversation = try JSONDecoder().decode( + ConversationInfo.self, from: Data(#"{"id":"conv_1"}"#.utf8)) + + await project.deleteConversation(conversation) + + #expect( + LifecycleGuardStub.requests(matching: "/v1/conversations/conv_1").filter { + $0.httpMethod == "DELETE" + }.isEmpty, + "a busy conversation's delete must be refused before the server is ever contacted, not deleted and then locally refused" + ) + #expect( + project.run?.conversationID == "conv_1", + "the guard refuses the reset; the deleted conversation's still-busy run must not be silently dropped" + ) + #expect(project.statusMessage?.localizedCaseInsensitiveContains("running") == true) + + project.run?.reset() + } + + /// Regression angle distinct from the test above: that one proves the + /// guard fires for the busy conversation itself. This proves it is + /// conjunctive (`conversation.id == run?.conversationID` *and* busy), + /// not merely `run?.isBusy == true` -- a simplification that would + /// still pass the test above while wrongly blocking every other + /// conversation's delete for as long as anything at all is running. + @Test( + "deleteConversation still reaches the server for a different, non-busy conversation while another is busy" + ) + func deleteConversationSucceedsForUnrelatedConversationWhileAnotherIsBusy() async throws { + LifecycleGuardStub.reset() + let project = await makeBusyProject() + LifecycleGuardStub.set { request in + switch (request.httpMethod, request.url?.path) { + case ("DELETE", "/v1/conversations/conv_3"): + return .init(status: 200) + case ("POST", "/v1/runs"): + return .init( + status: 202, body: Data(#"{"run_id":"run_1","status":"queued"}"#.utf8)) + case ("GET", "/v1/runs/run_1/events"): + return .init(status: 200, body: Data()) + default: + return .init(status: 200, body: Data("{}".utf8)) + } + } + let unrelatedConversation = try JSONDecoder().decode( + ConversationInfo.self, from: Data(#"{"id":"conv_3"}"#.utf8)) + + await project.deleteConversation(unrelatedConversation) + + #expect( + !LifecycleGuardStub.requests(matching: "/v1/conversations/conv_3").filter { + $0.httpMethod == "DELETE" + }.isEmpty, + "a different conversation's delete must still reach the server -- busyness elsewhere does not block it" + ) + #expect( + project.run?.conversationID == "conv_1", + "the busy conversation itself is untouched by deleting an unrelated one") + + project.run?.reset() + } + + /// Exercises the fix for #995 (F6): `openConversation` had no busy guard + /// at all, so switching conversations mid-run would load a different + /// conversation's messages into the transcript out from under an active + /// run's stream tracking. + @Test("openConversation refuses while a run is active and never reaches the server") + func openConversationRefusesWhileBusy() async throws { + LifecycleGuardStub.reset() + let project = await makeBusyProject() + let other = try JSONDecoder().decode( + ConversationInfo.self, from: Data(#"{"id":"conv_2"}"#.utf8)) + + await project.openConversation(other) + + #expect(LifecycleGuardStub.requests(matching: "/v1/conversations/conv_2/messages").isEmpty) + #expect(project.run?.conversationID == "conv_1") + #expect(project.statusMessage?.localizedCaseInsensitiveContains("running") == true) + + project.run?.reset() + } + + @Test("openConversation succeeds when idle") + func openConversationSucceedsWhenIdle() async throws { + LifecycleGuardStub.reset() + let project = makeProject() + LifecycleGuardStub.set { request in + switch (request.httpMethod, request.url?.path) { + case ("GET", "/v1/conversations/conv_2/messages"): + return .init(status: 200, body: Data(#"{"messages":[]}"#.utf8)) + default: + return .init(status: 200, body: Data("{}".utf8)) + } + } + await project.start() + let other = try JSONDecoder().decode( + ConversationInfo.self, from: Data(#"{"id":"conv_2"}"#.utf8)) + + await project.openConversation(other) + + #expect(!LifecycleGuardStub.requests(matching: "/v1/conversations/conv_2/messages").isEmpty) + #expect(project.run?.conversationID == "conv_2") + } + + /// Exercises the fix for #995 (F5): `fork`/`undo` only checked busyness + /// *before* their server call, not after -- if a run started on this + /// same conversation while the request was in flight, the result was + /// applied anyway, retargeting (`fork`) or reloading (`undo`) the + /// conversation out from under the run that just started. + @Test( + "fork re-checks busy after the server call: a run started mid-flight is not clobbered -- core regression" + ) + func forkReCheckusBusyAfterAwait() async throws { + LifecycleGuardStub.reset() + let project = makeProject() + let forkArrived = Flag() + let releaseFork = DispatchSemaphore(value: 0) + LifecycleGuardStub.set { request in + switch (request.httpMethod, request.url?.path) { + case ("POST", "/v1/conversations/conv_1/fork"): + forkArrived.set() + releaseFork.wait() + return .init(status: 200, body: Data(#"{"conversation_id":"conv_2"}"#.utf8)) + case ("POST", "/v1/runs"): + return .init( + status: 202, body: Data(#"{"run_id":"run_1","status":"queued"}"#.utf8)) + case ("GET", "/v1/runs/run_1/events"): + return .init(status: 200, body: Data()) + default: + return .init(status: 200, body: Data("{}".utf8)) + } + } + await project.start() + project.run?.rebind(conversationID: "conv_1") + + let forkTask = Task { await project.fork() } + // `LifecycleGuardStub` serializes every request through one global + // lock held for the handler's duration -- polling + // `.requests(matching:)` here would contend for that same lock + // while fork's handler is still blocked holding it. `forkArrived` + // observes "the request reached the stub" without touching it. A + // blocking wait would be just as wrong here: it would stall the + // MainActor executor that `forkTask` itself needs in order to run + // far enough to send the request in the first place. + try await wait { forkArrived.isSet } + + // A run starts on the original conversation while fork's request to + // the server is still in flight. `RunSession.submit()` marks + // `isBusy` synchronously (before its own `POST /v1/runs` even + // reaches this same stub, where it would itself queue behind + // fork's still-held lock) -- so this does not depend on that + // request completing. + project.run?.draft = "keep going" + project.run?.submit() + try await wait { project.run?.isBusy == true } + + releaseFork.signal() + await forkTask.value + + #expect( + project.run?.conversationID == "conv_1", + "fork must not rebind onto the forked conversation while a run started mid-flight is still active" + ) + + project.run?.reset() + } + + @Test( + "undo re-checks busy after the server call: a run started mid-flight is not clobbered -- core regression" + ) + func undoReCheckusBusyAfterAwait() async throws { + LifecycleGuardStub.reset() + let project = makeProject() + let undoArrived = Flag() + let releaseUndo = DispatchSemaphore(value: 0) + LifecycleGuardStub.set { request in + switch (request.httpMethod, request.url?.path) { + case ("POST", "/v1/conversations/conv_1/undo"): + undoArrived.set() + releaseUndo.wait() + return .init(status: 200) + case ("GET", "/v1/conversations/conv_1/messages"): + return .init(status: 200, body: Data(#"{"messages":[{"role":"user"}]}"#.utf8)) + case ("POST", "/v1/runs"): + return .init( + status: 202, body: Data(#"{"run_id":"run_1","status":"queued"}"#.utf8)) + case ("GET", "/v1/runs/run_1/events"): + return .init(status: 200, body: Data()) + default: + return .init(status: 200, body: Data("{}".utf8)) + } + } + await project.start() + project.run?.rebind(conversationID: "conv_1") + + let undoTask = Task { await project.undo() } + try await wait { undoArrived.isSet } + + project.run?.draft = "keep going" + project.run?.submit() + try await wait { project.run?.isBusy == true } + + releaseUndo.signal() + await undoTask.value + + #expect( + LifecycleGuardStub.requests(matching: "/v1/conversations/conv_1/messages").isEmpty, + "undo must not reload the conversation's messages while a run started mid-flight is still active" + ) + + project.run?.reset() + } + + /// Regression angle distinct from the busy-state tests above: those all + /// exercise `run?.isBusy == true`. This proves the guard's nil-coalescing + /// reads the *other* direction correctly too -- before a project ever + /// connects, `run` itself is nil, and nil must never be mistaken for + /// busy. A guard written as `run?.isBusy != false` (instead of + /// `run?.isBusy == true`) would pass every test above and still wrongly + /// refuse every lifecycle action on a session that has no run at all. + @Test("the guard does not fire when there is no run at all -- nil is not busy") + func guardDoesNotFireWithNoRun() async throws { + let project = ProjectSession(workspace: URL(fileURLWithPath: NSTemporaryDirectory())) + #expect(project.run == nil) + + project.newConversation() + #expect(project.statusMessage == nil) + + await project.fork() + #expect(project.statusMessage == nil) + + await project.undo() + #expect(project.statusMessage == nil) + } + + /// Regression angle distinct from the busy-state tests above: those only + /// assert each message contains "running". A revert that collapses all + /// three refusals to one shared generic string (e.g. "Action refused -- + /// a run is active") would still satisfy that assertion while losing + /// R5's "refuse (with an explanation)" naming the specific action -- + /// this pins that each of the three messages is distinct and names its + /// own action. + @Test("each guarded action's refusal message names that specific action") + func refusalMessagesNameTheirOwnAction() async throws { + LifecycleGuardStub.reset() + let newConversationProject = await makeBusyProject() + newConversationProject.newConversation() + let newConversationMessage = try #require(newConversationProject.statusMessage) + newConversationProject.run?.reset() + + LifecycleGuardStub.reset() + let forkProject = await makeBusyProject() + await forkProject.fork() + let forkMessage = try #require(forkProject.statusMessage) + forkProject.run?.reset() + + LifecycleGuardStub.reset() + let undoProject = await makeBusyProject() + await undoProject.undo() + let undoMessage = try #require(undoProject.statusMessage) + undoProject.run?.reset() + + #expect(newConversationMessage != forkMessage) + #expect(forkMessage != undoMessage) + #expect(newConversationMessage != undoMessage) + #expect(newConversationMessage.localizedCaseInsensitiveContains("new conversation")) + #expect(forkMessage.localizedCaseInsensitiveContains("fork")) + #expect(undoMessage.localizedCaseInsensitiveContains("undo")) + } +} + +@Suite("Conversation lifecycle control reachability") +struct ConversationLifecycleControlReachabilityTests { + @Test("every lifecycle surface disables controls and exposes the reason accessibly") + func everyLifecycleSurfaceUsesTheSharedReason() throws { + for file in [ + "ChatView.swift", "ConversationChrome.swift", "ConversationRail.swift", + "SessionsView.swift", "SettingsView.swift", + ] { + let source = try ReachabilitySource.file(file) + #expect( + source.contains("conversationActionDisabledReason"), + "\(file) does not use the centralized lifecycle reason") + #expect(source.contains(".disabled("), "\(file) does not disable lifecycle controls") + #expect( + source.contains(".accessibilityHint("), + "\(file) does not expose the disabled reason to VoiceOver") + } + } +} + +/// A plain thread-safe boolean, set by a stub handler running on a +/// background (non-Swift-concurrency) thread and observed by `wait { }`'s +/// cooperative polling loop. A blocking `DispatchSemaphore.wait()` on the +/// test side would stall the MainActor executor that the `Task` under test +/// needs in order to run far enough to send the very request this flag +/// waits for -- polling instead (via `Task.sleep` between checks) leaves +/// that executor free to make progress. +private final class Flag: @unchecked Sendable { + private let lock = NSLock() + private var value = false + + func set() { lock.withLock { value = true } } + var isSet: Bool { lock.withLock { value } } +} diff --git a/macapp/Tests/GoCodeUITests/ProjectSessionLoadStateTests.swift b/macapp/Tests/GoCodeUITests/ProjectSessionLoadStateTests.swift new file mode 100644 index 00000000..c5dd8463 --- /dev/null +++ b/macapp/Tests/GoCodeUITests/ProjectSessionLoadStateTests.swift @@ -0,0 +1,263 @@ +import Foundation +import HarnessKit +import Testing + +@testable import GoCodeUI + +/// Minimal HTTP stub for exercising `ProjectSession`'s load-state surfaces +/// without a live harnessd. Mirrors `ActivityStubProtocol` in +/// `ProjectSessionActivityTests.swift` (that stub is `private` to its own +/// file, so it cannot be reused directly) — scoped to its own fixed loopback +/// port so it never intercepts another suite's traffic. +private final class LoadStateStubProtocol: URLProtocol, @unchecked Sendable { + struct Response: Sendable { + var status: Int = 200 + var body: Data = Data() + } + + static let port = 18913 + nonisolated(unsafe) private static var handler: (@Sendable (URLRequest) -> Response)? + private static let lock = NSLock() + + static func set(_ handler: @escaping @Sendable (URLRequest) -> Response) { + lock.withLock { self.handler = handler } + } + + override class func canInit(with request: URLRequest) -> Bool { + request.url?.port == port + } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + let response = Self.lock.withLock { Self.handler }?(request) ?? Response() + let http = HTTPURLResponse( + url: request.url!, statusCode: response.status, + httpVersion: "HTTP/1.1", headerFields: ["Content-Type": "application/json"])! + client?.urlProtocol(self, didReceive: http, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: response.body) + client?.urlProtocolDidFinishLoading(self) + } + override func stopLoading() {} +} + +/// `.failed` used to carry no message, so an inline error next to a failed +/// list could not truthfully name *that* list's failure — it could only fall +/// back to the single-slot `statusMessage` shared by every collection (#991 +/// finding 2). These prove `CollectionLoadState.failed` on `ProjectSession` +/// carries its own per-collection reason, that a retry actually recovers, +/// and that one collection's failure never blanks another's data. +@Suite("ProjectSession load-state messages", .serialized) +@MainActor +struct ProjectSessionLoadStateTests { + + private static let baseURL = URL(string: "http://127.0.0.1:\(LoadStateStubProtocol.port)")! + + private func makeProject() -> ProjectSession { + URLProtocol.registerClass(LoadStateStubProtocol.self) + return ProjectSession( + workspace: URL(fileURLWithPath: NSTemporaryDirectory()), + externalBaseURL: Self.baseURL) + } + + private final class Box: @unchecked Sendable { + private let lock = NSLock() + private var value: Int + init(_ value: Int) { self.value = value } + var current: Int { + get { lock.withLock { value } } + set { lock.withLock { value = newValue } } + } + } + + @Test("a failed conversations refresh carries the server's own message") + func failedRefreshCarriesServerMessage() async throws { + let project = makeProject() + LoadStateStubProtocol.set { request in + switch request.url?.path { + case "/v1/conversations/": + return .init( + status: 500, + body: Data( + #"{"error":{"code":"boom","message":"conversations exploded"}}"#.utf8)) + default: + return .init(status: 200, body: Data("{}".utf8)) + } + } + + await project.start() + await project.refreshConversations() + + #expect( + project.conversationsLoadState.errorMessage?.contains("conversations exploded") == true) + #expect(project.conversations.isEmpty) + } + + @Test("retrying a failed conversations refresh recovers to loaded with rows") + func retryAfterFailureRecovers() async throws { + let project = makeProject() + let conversationsStatus = Box(500) + LoadStateStubProtocol.set { request in + switch request.url?.path { + case "/v1/conversations/": + switch conversationsStatus.current { + case 200: + return .init( + status: 200, + body: Data(#"{"conversations":[{"id":"c1"}]}"#.utf8)) + default: + return .init( + status: 500, + body: Data( + #"{"error":{"code":"boom","message":"conversations exploded"}}"#.utf8 + )) + } + default: + return .init(status: 200, body: Data("{}".utf8)) + } + } + + await project.start() + await project.refreshConversations() + #expect(project.conversationsLoadState.showsError) + #expect(project.conversations.isEmpty) + + // Retry pressed: same call, now the daemon is healthy. + conversationsStatus.current = 200 + await project.refreshConversations() + #expect(project.conversationsLoadState == .loaded) + #expect(project.conversations.count == 1) + } + + @Test("a failed refresh after a successful one keeps the previously loaded rows") + func failedRefreshAfterSuccessKeepsRows() async throws { + let project = makeProject() + let conversationsStatus = Box(200) + LoadStateStubProtocol.set { request in + switch request.url?.path { + case "/v1/conversations/": + switch conversationsStatus.current { + case 200: + return .init( + status: 200, + body: Data(#"{"conversations":[{"id":"c1"}]}"#.utf8)) + default: + return .init( + status: 500, + body: Data( + #"{"error":{"code":"boom","message":"conversations exploded"}}"#.utf8 + )) + } + default: + return .init(status: 200, body: Data("{}".utf8)) + } + } + + await project.start() + await project.refreshConversations() + #expect(project.conversations.count == 1) + + conversationsStatus.current = 500 + await project.refreshConversations() + #expect(project.conversationsLoadState.showsError) + #expect( + project.conversations.count == 1, + "a failed refresh must not blank rows the prior successful refresh loaded") + } + + @Test("one collection's failure carries its own message without affecting a healthy sibling") + func perCollectionIsolation() async throws { + let project = makeProject() + LoadStateStubProtocol.set { request in + switch request.url?.path { + case "/v1/models": + return .init( + status: 500, + body: Data(#"{"error":{"code":"boom","message":"models exploded"}}"#.utf8)) + case "/v1/providers": + return .init( + status: 200, + body: Data(#"{"providers":[{"name":"openai","configured":true}]}"#.utf8)) + default: + return .init(status: 200, body: Data("{}".utf8)) + } + } + + await project.start() + await project.refreshCatalog() + + #expect(project.modelsLoadState.errorMessage?.contains("models exploded") == true) + #expect(project.providersLoadState == .loaded) + } + + /// `ModelSettingsModel` holds its own `CollectionLoadState`, entirely + /// separate from `ProjectSession`'s catalogue refreshes above — a + /// different integration point (`client.modelSettings()`, not + /// `client.models()` / `client.providers()`) that the tests above cannot + /// exercise. Kept in this `.serialized` suite (rather than a suite of its + /// own) because it shares `LoadStateStubProtocol`'s single global + /// handler; a separate suite could run concurrently with this one and + /// race it. This regresses if `ModelSettingsModel.load()`'s catch branch + /// is ever reverted from `.failed(error.localizedDescription)` back to a + /// bare `.failed` with no message. + @Test("a failed model-settings load carries the server's own message") + func modelSettingsLoadCarriesServerMessage() async throws { + URLProtocol.registerClass(LoadStateStubProtocol.self) + LoadStateStubProtocol.set { request in + switch request.url?.path { + case "/v1/model-settings": + return .init( + status: 500, + body: Data( + #"{"error":{"code":"boom","message":"model settings exploded"}}"#.utf8)) + default: + return .init(status: 200, body: Data("{}".utf8)) + } + } + + let client = HarnessClient(baseURL: Self.baseURL) + let model = ModelSettingsModel(client: client) + + await model.load() + + #expect(model.loadState.errorMessage?.contains("model settings exploded") == true) + #expect(model.providers.isEmpty) + } +} + +/// Distinct from the state-table tests in `CollectionLoadStateTests`: those +/// prove the type's own logic; this proves the failure UI it enables has a +/// real production call site rather than being a component nobody uses. +@Suite("CollectionErrorState reachability") +struct CollectionErrorStateReachabilityTests { + + @Test("CollectionErrorState has production call sites") + func hasProductionCallSites() throws { + #expect(try ReachabilitySource.wholeModule().contains("CollectionErrorState(")) + } + + /// Regression angle distinct from the module-wide check above: that one + /// is satisfied the moment *any* view wires the component in, so a + /// revert that drops the wiring from five of the six U2 consumers while + /// leaving it in the sixth would still pass it silently. This pins every + /// listed consumer individually, so a partial revert is caught. + @Test("every U2 consumer distinguishes blocking failures from stale-row refresh failures") + func everyConsumerPreservesStaleRowsOnRefreshFailure() throws { + for file in [ + "ActivityView.swift", "SessionsView.swift", "SettingsView.swift", + "ModelSettingsView.swift", + ] { + let source = try ReachabilitySource.file(file) + #expect( + source.contains("CollectionErrorState("), "\(file) never renders the error state") + #expect( + source.contains(".showsBlockingError("), + "\(file) never distinguishes an empty blocking failure") + #expect( + source.contains(".showsRefreshError("), + "\(file) never preserves stale rows after a refresh failure") + #expect( + source.contains("CollectionRefreshErrorState("), + "\(file) never renders a nonblocking refresh failure") + } + } +} diff --git a/macapp/Tests/GoCodeUITests/ProjectSessionRequestOwnershipTests.swift b/macapp/Tests/GoCodeUITests/ProjectSessionRequestOwnershipTests.swift new file mode 100644 index 00000000..ed0b520b --- /dev/null +++ b/macapp/Tests/GoCodeUITests/ProjectSessionRequestOwnershipTests.swift @@ -0,0 +1,564 @@ +import Foundation +import HarnessKit +import Testing + +@testable import GoCodeUI + +/// A deliberately blocking URL protocol for request-ownership regressions. +/// Each test arms selected paths after `ProjectSession.start()` has finished +/// its connection-time refreshes. The first armed request waits until the +/// test releases it; a later request receives a newer payload immediately. +/// This makes response ordering, rather than request ordering, observable. +private final class RequestOwnershipStub: URLProtocol, @unchecked Sendable { + struct Response: Sendable { + var status: Int = 200 + var body = Data() + /// The response is delivered asynchronously after this gate opens. + /// `URLProtocol.startLoading()` itself must return immediately so a + /// newer URLSession request can race the older response. + var completionGate: DispatchSemaphore? + } + + static let port = 18921 + nonisolated(unsafe) private static var handler: (@Sendable (URLRequest) -> Response)? + private static let lock = NSLock() + + static func set(_ handler: @escaping @Sendable (URLRequest) -> Response) { + lock.withLock { self.handler = handler } + } + + override class func canInit(with request: URLRequest) -> Bool { + request.url?.port == port + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + let response = Self.lock.withLock { Self.handler }?(request) ?? Response() + if let gate = response.completionGate { + DispatchQueue.global().async { [self] in + gate.wait() + finishLoading(response) + } + return + } + finishLoading(response) + } + + private func finishLoading(_ response: Response) { + let http = HTTPURLResponse( + url: request.url!, statusCode: response.status, + httpVersion: "HTTP/1.1", headerFields: ["Content-Type": "application/json"])! + client?.urlProtocol(self, didReceive: http, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: response.body) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} +} + +/// Thread-safe response sequencer. Never holds its lock while waiting: the +/// test needs a newer URLSession request to make progress while the older one +/// is deliberately paused. +private final class RequestOwnershipResponses: @unchecked Sendable { + private let lock = NSLock() + private var armedPaths: Set = [] + private var requestsByPath: [String: Int] = [:] + private var reachedPaths: Set = [] + private var releases: [String: DispatchSemaphore] = [:] + private let oldBodies: [String: Data] + private let newBodies: [String: Data] + + init(oldBodies: [String: Data], newBodies: [String: Data]) { + self.oldBodies = oldBodies + self.newBodies = newBodies + } + + func arm(_ paths: Set) { + lock.withLock { + armedPaths = paths + requestsByPath = [:] + reachedPaths = [] + releases = Dictionary( + uniqueKeysWithValues: paths.map { ($0, DispatchSemaphore(value: 0)) }) + } + } + + func response(for request: URLRequest) -> RequestOwnershipStub.Response { + guard let path = request.url?.path else { return .init() } + guard armedPathsSnapshotContains(path) else { + return .init(body: newBodies[path] ?? baseline(path: path).body) + } + let firstRequest: Bool = lock.withLock { + let count = requestsByPath[path, default: 0] + 1 + requestsByPath[path] = count + if count == 1 { + reachedPaths.insert(path) + return true + } + return false + } + + if firstRequest { + return .init(body: oldBodies[path] ?? Data(), completionGate: releasesForPath(path)) + } + return .init(body: newBodies[path] ?? Data()) + } + + func reached(_ paths: Set) -> Bool { + lock.withLock { paths.isSubset(of: reachedPaths) } + } + + func release(_ paths: Set) { + let semaphores = lock.withLock { paths.compactMap { releases[$0] } } + for semaphore in semaphores { semaphore.signal() } + } + + private func armedPathsSnapshotContains(_ path: String) -> Bool { + lock.withLock { armedPaths.contains(path) } + } + + private func releasesForPath(_ path: String) -> DispatchSemaphore { + lock.withLock { releases[path]! } + } + + private func baseline(path: String) -> RequestOwnershipStub.Response { + switch path { + case "/v1/models": + return .init( + body: Data(#"{"models":[{"id":"baseline-model","provider":"openai"}]}"#.utf8)) + case "/v1/providers": + return .init( + body: Data(#"{"providers":[{"name":"baseline-provider","configured":true}]}"#.utf8)) + case "/v1/profiles": + return .init(body: Data(#"{"profiles":[{"name":"baseline-profile"}]}"#.utf8)) + case "/v1/conversations/": + return .init(body: Data(#"{"conversations":[]}"#.utf8)) + case "/v1/tasks": + return .init(body: Data(#"{"tasks":[]}"#.utf8)) + case "/v1/runs": + return .init(body: Data(#"{"runs":[]}"#.utf8)) + default: + return .init(body: Data(#"{}"#.utf8)) + } + } +} + +@Suite("ProjectSession request ownership", .serialized) +@MainActor +struct ProjectSessionRequestOwnershipTests { + private static let baseURL = URL(string: "http://127.0.0.1:\(RequestOwnershipStub.port)")! + + private func makeProject(_ responses: RequestOwnershipResponses) -> ProjectSession { + URLProtocol.registerClass(RequestOwnershipStub.self) + RequestOwnershipStub.set { responses.response(for: $0) } + return ProjectSession( + workspace: URL(fileURLWithPath: NSTemporaryDirectory()), externalBaseURL: Self.baseURL) + } + + private func wait( + timeout: Duration = .seconds(3), for condition: () -> Bool + ) async throws { + let deadline = ContinuousClock.now.advanced(by: timeout) + while ContinuousClock.now < deadline { + if condition() { return } + try await Task.sleep(for: .milliseconds(10)) + } + Issue.record("timed out waiting for a request") + } + + private func start(_ project: ProjectSession) async throws { + await project.start() + // `connect(to:)` starts two unstructured baseline refreshes. Let their + // non-blocking stub responses finish before arming this test's race. + try await Task.sleep(for: .milliseconds(50)) + } + + @Test("a late conversations refresh cannot replace a newer list") + func conversationsKeepLatestResponse() async throws { + let path = "/v1/conversations/" + let responses = RequestOwnershipResponses( + oldBodies: [path: Data(#"{"conversations":[{"id":"old-conversation"}]}"#.utf8)], + newBodies: [path: Data(#"{"conversations":[{"id":"new-conversation"}]}"#.utf8)]) + let project = makeProject(responses) + try await start(project) + responses.arm([path]) + + let older = Task { await project.refreshConversations() } + try await wait { responses.reached([path]) } + await project.refreshConversations() + responses.release([path]) + await older.value + + #expect(project.conversations.map(\.id) == ["new-conversation"]) + } + + @Test("each catalog collection keeps the newest independently refreshed value") + func catalogCollectionsKeepLatestResponsesIndependently() async throws { + let modelPath = "/v1/models" + let providerPath = "/v1/providers" + let profilePath = "/v1/profiles" + let paths: Set = [modelPath, providerPath, profilePath] + let responses = RequestOwnershipResponses( + oldBodies: [ + modelPath: Data(#"{"models":[{"id":"old-model","provider":"openai"}]}"#.utf8), + providerPath: Data( + #"{"providers":[{"name":"old-provider","configured":true}]}"#.utf8), + profilePath: Data(#"{"profiles":[{"name":"old-profile"}]}"#.utf8), + ], + newBodies: [ + modelPath: Data(#"{"models":[{"id":"new-model","provider":"openai"}]}"#.utf8), + providerPath: Data( + #"{"providers":[{"name":"new-provider","configured":true}]}"#.utf8), + profilePath: Data(#"{"profiles":[{"name":"new-profile"}]}"#.utf8), + ]) + let project = makeProject(responses) + try await start(project) + responses.arm(paths) + + let older = Task { await project.refreshCatalog() } + try await wait { responses.reached(paths) } + await project.refreshCatalog() + responses.release(paths) + await older.value + + #expect(project.models.map(\.id) == ["new-model"]) + #expect(project.providers.map(\.name) == ["new-provider"]) + #expect(project.profiles.map(\.name) == ["new-profile"]) + } + + @Test("a late activity refresh cannot replace newer tasks, runs, or current-run todos") + func activityCollectionsKeepLatestResponsesIndependently() async throws { + let taskPath = "/v1/tasks" + let runPath = "/v1/runs" + let todoPath = "/v1/runs/run-current/todos" + let paths: Set = [taskPath, runPath, todoPath] + let responses = RequestOwnershipResponses( + oldBodies: [ + taskPath: Data( + #"{"tasks":[{"id":"old-task","type":"cron","status":"running","label":"old"}]}"# + .utf8), + runPath: Data(#"{"runs":[{"id":"old-run"}]}"#.utf8), + todoPath: Data( + #"{"todos":[{"id":"old-todo","text":"old","status":"pending"}]}"#.utf8), + ], + newBodies: [ + taskPath: Data( + #"{"tasks":[{"id":"new-task","type":"cron","status":"running","label":"new"}]}"# + .utf8), + runPath: Data(#"{"runs":[{"id":"new-run"}]}"#.utf8), + todoPath: Data( + #"{"todos":[{"id":"new-todo","text":"new","status":"pending"}]}"#.utf8), + ]) + let project = makeProject(responses) + try await start(project) + let eventRelease = DispatchSemaphore(value: 0) + RequestOwnershipStub.set { request in + if request.httpMethod == "POST", request.url?.path == "/v1/runs" { + return .init( + status: 202, body: Data(#"{"run_id":"run-current","status":"queued"}"#.utf8)) + } + if request.url?.path == "/v1/runs/run-current/events" { + return .init(completionGate: eventRelease) + } + return responses.response(for: request) + } + project.run?.draft = "make activity current" + project.run?.submit() + try await wait { project.run?.currentRunID == "run-current" } + responses.arm(paths) + + let older = Task { await project.refreshActivity() } + try await wait { responses.reached(paths) } + await project.refreshActivity() + responses.release(paths) + await older.value + + #expect(project.tasks.map(\.id) == ["new-task"]) + #expect(project.runs?.map(\.id) == ["new-run"]) + #expect(project.todos.map(\.text) == ["new"]) + eventRelease.signal() + project.run?.reset() + } + + @Test("a run ending during todo fetch still applies independently valid tasks and runs") + func terminalRunOnlyDiscardsItsTodos() async throws { + let taskPath = "/v1/tasks" + let runPath = "/v1/runs" + let todoPath = "/v1/runs/run-current/todos" + let responses = RequestOwnershipResponses( + oldBodies: [ + todoPath: Data( + #"{"todos":[{"id":"stale-todo","text":"stale","status":"pending"}]}"#.utf8) + ], + newBodies: [ + taskPath: Data( + #"{"tasks":[{"id":"current-task","type":"cron","status":"running","label":"current"}]}"# + .utf8), + runPath: Data(#"{"runs":[{"id":"current-summary"}]}"#.utf8), + ]) + let project = makeProject(responses) + try await start(project) + let eventRelease = DispatchSemaphore(value: 0) + let terminalEvent = Data( + """ + id: run-current:1 + event: run.completed + data: {"id":"run-current:1","run_id":"run-current","type":"run.completed","payload":{}} + + + """.utf8) + RequestOwnershipStub.set { request in + if request.httpMethod == "POST", request.url?.path == "/v1/runs" { + return .init( + status: 202, body: Data(#"{"run_id":"run-current","status":"queued"}"#.utf8)) + } + if request.url?.path == "/v1/runs/run-current/events" { + return .init(body: terminalEvent, completionGate: eventRelease) + } + return responses.response(for: request) + } + project.run?.draft = "make activity current" + project.run?.submit() + try await wait { project.run?.currentRunID == "run-current" } + responses.arm([todoPath]) + + let refresh = Task { await project.refreshActivity() } + try await wait { responses.reached([todoPath]) } + eventRelease.signal() + try await wait { project.run?.currentRunID == nil } + responses.release([todoPath]) + await refresh.value + + #expect(project.tasks.map(\.id) == ["current-task"]) + #expect(project.runs?.map(\.id) == ["current-summary"]) + #expect(project.tasksLoadState == .loaded) + #expect(project.runsLoadState == .loaded) + #expect(project.todos.isEmpty) + #expect(project.todosLoadState == .loaded) + project.run?.reset() + } + + @Test("ready tasks and runs apply while current-run todos are still loading") + func activityDoesNotBlockIndependentCollectionsOnTodos() async throws { + let taskPath = "/v1/tasks" + let runPath = "/v1/runs" + let todoPath = "/v1/runs/run-current/todos" + let responses = RequestOwnershipResponses( + oldBodies: [ + todoPath: Data( + #"{"todos":[{"id":"old-todo","text":"old","status":"pending"}]}"#.utf8) + ], + newBodies: [ + taskPath: Data( + #"{"tasks":[{"id":"ready-task","type":"cron","status":"running","label":"ready"}]}"# + .utf8), + runPath: Data(#"{"runs":[{"id":"ready-run"}]}"#.utf8), + todoPath: Data( + #"{"todos":[{"id":"ready-todo","text":"ready","status":"pending"}]}"#.utf8), + ]) + let project = makeProject(responses) + try await start(project) + let eventRelease = DispatchSemaphore(value: 0) + RequestOwnershipStub.set { request in + if request.httpMethod == "POST", request.url?.path == "/v1/runs" { + return .init( + status: 202, + body: Data(#"{"run_id":"run-current","status":"queued"}"#.utf8)) + } + if request.url?.path == "/v1/runs/run-current/events" { + return .init(completionGate: eventRelease) + } + return responses.response(for: request) + } + project.run?.draft = "keep todos loading" + project.run?.submit() + try await wait { project.run?.currentRunID == "run-current" } + responses.arm([todoPath]) + + let refresh = Task { await project.refreshActivity() } + try await wait { responses.reached([todoPath]) } + try await wait { + project.tasks.map(\.id) == ["ready-task"] + && project.runs?.map(\.id) == ["ready-run"] + } + #expect(project.todosLoadState == .loading) + + responses.release([todoPath]) + await refresh.value + eventRelease.signal() + project.run?.reset() + } + + @Test("a late rewind-point response for an old conversation is discarded") + func rewindPointsValidateTheirConversationTarget() async throws { + let oldPath = "/v1/conversations/old-conversation/rewind-points" + let newPath = "/v1/conversations/new-conversation/rewind-points" + let responses = RequestOwnershipResponses( + oldBodies: [oldPath: Data(#"{"points":[{"id":"old-point"}]}"#.utf8)], + newBodies: [ + oldPath: Data(#"{"points":[{"id":"ignored-old-point"}]}"#.utf8), + newPath: Data(#"{"points":[{"id":"new-point"}]}"#.utf8), + ]) + let project = makeProject(responses) + try await start(project) + project.run?.rebind(conversationID: "old-conversation") + responses.arm([oldPath]) + + let older = Task { await project.refreshRewindPoints() } + try await wait { responses.reached([oldPath]) } + project.run?.rebind(conversationID: "new-conversation") + await project.refreshRewindPoints() + responses.release([oldPath]) + await older.value + + #expect(project.rewindPoints.map(\.id) == ["new-point"]) + } + + @Test("a pending open of an older conversation cannot win after a newer selection") + func openConversationKeepsLatestSelection() async throws { + let oldPath = "/v1/conversations/old-conversation/messages" + let newPath = "/v1/conversations/new-conversation/messages" + let responses = RequestOwnershipResponses( + oldBodies: [ + oldPath: Data( + #"{"messages":[{"role":"assistant","content":"old reply","step":0}]}"#.utf8) + ], + newBodies: [ + oldPath: Data(#"{"messages":[]}"#.utf8), + newPath: Data( + #"{"messages":[{"role":"assistant","content":"new reply","step":0}]}"#.utf8), + ]) + let project = makeProject(responses) + try await start(project) + let old = try JSONDecoder().decode( + ConversationInfo.self, from: Data(#"{"id":"old-conversation"}"#.utf8)) + let new = try JSONDecoder().decode( + ConversationInfo.self, from: Data(#"{"id":"new-conversation"}"#.utf8)) + responses.arm([oldPath]) + + let older = Task { await project.openConversation(old) } + try await wait { responses.reached([oldPath]) } + await project.openConversation(new) + responses.release([oldPath]) + await older.value + + #expect(project.run?.conversationID == "new-conversation") + #expect( + project.run?.transcript.items.contains { + if case .assistantMessage(let message) = $0.kind { + return message.text == "new reply" + } + return false + } == true) + } + + @Test("an open refused by a run starting in flight releases selection ownership") + func busyRefusalDoesNotLeaveConversationSyncBlocked() async throws { + let blockedPath = "/v1/conversations/blocked/messages" + let currentPath = "/v1/conversations/current/messages" + let responses = RequestOwnershipResponses( + oldBodies: [blockedPath: Data(#"{"messages":[]}"#.utf8)], + newBodies: [ + blockedPath: Data(#"{"messages":[]}"#.utf8), + currentPath: Data( + #"{"messages":[{"role":"assistant","content":"sync recovered","step":0}]}"# + .utf8), + ]) + let project = makeProject(responses) + try await start(project) + project.run?.rebind(conversationID: "current") + let blocked = try JSONDecoder().decode( + ConversationInfo.self, from: Data(#"{"id":"blocked"}"#.utf8)) + responses.arm([blockedPath]) + + let opening = Task { await project.openConversation(blocked) } + try await wait { responses.reached([blockedPath]) } + project.run?.draft = "start while open is pending" + project.run?.submit() + #expect(project.run?.isBusy == true) + responses.release([blockedPath]) + await opening.value + + project.run?.reset() + project.run?.rebind(conversationID: "current") + await project.syncCurrentConversation() + + #expect( + project.run?.transcript.items.contains { + if case .assistantMessage(let message) = $0.kind { + return message.text == "sync recovered" + } + return false + } == true, + "a busy refusal must not leave pending selection ownership blocking later syncs") + } + + @Test("a late durable sync cannot reconcile messages after a newer selection starts") + func syncValidatesSelectionBeforeReconciling() async throws { + let currentPath = "/v1/conversations/current/messages" + let nextPath = "/v1/conversations/next/messages" + let responses = RequestOwnershipResponses( + oldBodies: [ + currentPath: Data( + #"{"messages":[{"role":"assistant","content":"stale durable reply","step":0}]}"# + .utf8) + ], + newBodies: [ + currentPath: Data(#"{"messages":[]}"#.utf8), + nextPath: Data( + #"{"messages":[{"role":"assistant","content":"new durable reply","step":0}]}"# + .utf8), + ]) + let project = makeProject(responses) + try await start(project) + project.run?.rebind(conversationID: "current") + let next = try JSONDecoder().decode( + ConversationInfo.self, from: Data(#"{"id":"next"}"#.utf8)) + responses.arm([currentPath]) + + let sync = Task { await project.syncCurrentConversation() } + try await wait { responses.reached([currentPath]) } + await project.openConversation(next) + responses.release([currentPath]) + await sync.value + + #expect(project.run?.conversationID == "next") + #expect( + project.run?.transcript.items.contains { + if case .assistantMessage(let message) = $0.kind { + return message.text == "stale durable reply" + } + return false + } == false) + } + + @Test("rewind refuses at the session boundary while a run is active") + func rewindRefusesWhileBusy() async throws { + let rewindPath = "/v1/conversations/busy-conversation/rewind" + let responses = RequestOwnershipResponses(oldBodies: [:], newBodies: [:]) + let project = makeProject(responses) + try await start(project) + RequestOwnershipStub.set { request in + if request.httpMethod == "POST", request.url?.path == "/v1/runs" { + return .init( + status: 202, body: Data(#"{"run_id":"busy-run","status":"queued"}"#.utf8)) + } + if request.url?.path == "/v1/runs/busy-run/events" { return .init() } + if request.url?.path == rewindPath { + Issue.record("rewind reached the server while a run was active") + } + return responses.response(for: request) + } + project.run?.rebind(conversationID: "busy-conversation") + project.run?.draft = "stay busy" + project.run?.submit() + try await wait { project.run?.isBusy == true } + let point = try JSONDecoder().decode(RewindPoint.self, from: Data(#"{"id":"point"}"#.utf8)) + + await project.rewind(to: point) + + #expect(project.statusMessage?.localizedCaseInsensitiveContains("running") == true) + project.run?.reset() + } +} diff --git a/macapp/Tests/GoCodeUITests/ProjectSessionRewindTests.swift b/macapp/Tests/GoCodeUITests/ProjectSessionRewindTests.swift new file mode 100644 index 00000000..0d04784e --- /dev/null +++ b/macapp/Tests/GoCodeUITests/ProjectSessionRewindTests.swift @@ -0,0 +1,501 @@ +import Foundation +import HarnessKit +import Testing + +@testable import GoCodeUI + +/// Minimal HTTP stub scoped to this file's tests, mirroring +/// `LifecycleGuardStub` (`ProjectSessionLifecycleGuardTests.swift`): it +/// records every request (and, unlike that stub, the request body) on a +/// fixed loopback port so it never intercepts another suite's traffic and so +/// these tests can assert the second, forced request actually carries +/// `"force":true` rather than merely trusting the client's own unit tests. +private final class RewindStub: URLProtocol, @unchecked Sendable { + struct Response: Sendable { + var status: Int = 200 + var body: Data = Data() + var completionGate: DispatchSemaphore? + } + + static let port = 18917 + nonisolated(unsafe) private static var handler: (@Sendable (URLRequest) -> Response)? + nonisolated(unsafe) private static var recordedBodies: [String: [Data]] = [:] + private static let lock = NSLock() + + static func set(_ handler: @escaping @Sendable (URLRequest) -> Response) { + lock.withLock { self.handler = handler } + } + + static func reset() { + lock.withLock { + handler = nil + recordedBodies = [:] + } + } + + static func bodies(matching path: String) -> [Data] { + lock.withLock { recordedBodies[path] ?? [] } + } + + override class func canInit(with request: URLRequest) -> Bool { + request.url?.port == port + } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + let request = self.request + // The handler itself may call `bodies(matching:)` (to count prior + // attempts) -- invoke it after releasing the lock, or a handler doing + // so would deadlock re-entering this file's non-reentrant `NSLock`. + let handler = Self.lock.withLock { () -> (@Sendable (URLRequest) -> Response)? in + if let path = request.url?.path { + Self.recordedBodies[path, default: []].append(request.httpBodyData ?? Data()) + } + return Self.handler + } + let response = handler?(request) ?? Response() + if let gate = response.completionGate { + DispatchQueue.global().async { [self] in + gate.wait() + finishLoading(response) + } + } else { + finishLoading(response) + } + } + override func stopLoading() {} + + private func finishLoading(_ response: Response) { + let http = HTTPURLResponse( + url: request.url!, statusCode: response.status, + httpVersion: "HTTP/1.1", headerFields: ["Content-Type": "application/json"])! + client?.urlProtocol(self, didReceive: http, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: response.body) + client?.urlProtocolDidFinishLoading(self) + } +} + +/// A `409 rewind_refused` envelope, matching the server's actual wire shape +/// (`internal/server/http_conversations.go:370`). A free function, not a +/// method on the `@MainActor` test suite, so it can be called from the +/// stub's non-isolated `@Sendable` handler closures without hopping actors. +private let rewindPath = "/v1/conversations/conv_1/rewind" + +private func refused( + message: String, completionGate: DispatchSemaphore? = nil +) -> RewindStub.Response { + .init( + status: 409, + body: Data(#"{"error":{"code":"rewind_refused","message":"\#(message)"}}"#.utf8), + completionGate: completionGate) +} + +extension URLRequest { + /// `URLProtocol` may strip `httpBody` onto a stream; read either. + fileprivate var httpBodyData: Data? { + if let body = httpBody { return body } + guard let stream = httpBodyStream else { return nil } + stream.open() + defer { stream.close() } + var data = Data() + let size = 4096 + let buffer = UnsafeMutablePointer.allocate(capacity: size) + defer { buffer.deallocate() } + while stream.hasBytesAvailable { + let read = stream.read(buffer, maxLength: size) + if read <= 0 { break } + data.append(buffer, count: read) + } + return data + } +} + +/// Exercises the fix for #997 (F6, R7, KTD-6): `rewind` used to collapse +/// every `HarnessError` -- including the server's deliberate +/// `409 rewind_refused` safety refusal -- into `statusMessage` prose, so no +/// distinct "restore anyway" path could exist. `rewind` now branches on +/// `HarnessError.code == "rewind_refused"` (not the HTTP status, which is +/// merely the transport for it) and records a structural `RewindRefusal` +/// instead. +@Suite("ProjectSession rewind refusal", .serialized) +@MainActor +struct ProjectSessionRewindTests { + + private static let baseURL = URL(string: "http://127.0.0.1:\(RewindStub.port)")! + + private func makeProject() -> ProjectSession { + URLProtocol.registerClass(RewindStub.self) + return ProjectSession( + workspace: URL(fileURLWithPath: NSTemporaryDirectory()), + externalBaseURL: Self.baseURL) + } + + private func makeReadyProject() async -> ProjectSession { + let project = makeProject() + await project.start() + project.run?.rebind(conversationID: "conv_1") + return project + } + + private func makePoint(id: String = "point_1") throws -> RewindPoint { + try JSONDecoder().decode(RewindPoint.self, from: Data(#"{"id":"\#(id)"}"#.utf8)) + } + + private func wait( + timeout: Duration = .seconds(3), for condition: () -> Bool + ) async throws { + let deadline = ContinuousClock.now.advanced(by: timeout) + while ContinuousClock.now < deadline { + if condition() { return } + try await Task.sleep(for: .milliseconds(10)) + } + Issue.record("timed out waiting for rewind request") + } + + // MARK: - Behavioral + + @Test( + "a rewind_refused refusal is captured structurally, matched on HarnessError.code -- core regression" + ) + func refusalCapturedStructurally() async throws { + RewindStub.reset() + let project = await makeReadyProject() + let point = try makePoint() + RewindStub.set { request in + switch (request.httpMethod, request.url?.path) { + case ("POST", rewindPath): + return refused(message: "README.md changed outside the harness") + default: + return .init(status: 200, body: Data("{}".utf8)) + } + } + + await project.rewind(to: point) + + #expect(project.rewindRefusal?.pointID == point.id) + #expect( + project.rewindRefusal?.message.contains("README.md changed outside the harness") + == true) + #expect(project.rewindRefusal?.conversationID == "conv_1") + } + + @Test("a rewind refusal that completes after conversation switch is discarded") + func refusalValidatesConversationBeforePresentation() async throws { + RewindStub.reset() + let project = await makeReadyProject() + let point = try makePoint() + let responseGate = DispatchSemaphore(value: 0) + RewindStub.set { request in + switch (request.httpMethod, request.url?.path) { + case ("POST", rewindPath): + return refused( + message: "README.md changed outside the harness", + completionGate: responseGate) + default: + return .init(status: 200, body: Data("{}".utf8)) + } + } + + let pending = Task { await project.rewind(to: point) } + try await wait { RewindStub.bodies(matching: rewindPath).count == 1 } + project.run?.rebind(conversationID: "conv_2") + responseGate.signal() + await pending.value + + #expect(project.rewindRefusal == nil) + } + + @Test("a captured refusal cannot force-rewind a newly selected conversation") + func forceRetryValidatesRefusalConversation() async throws { + RewindStub.reset() + let project = await makeReadyProject() + let point = try makePoint() + RewindStub.set { request in + switch (request.httpMethod, request.url?.path) { + case ("POST", rewindPath): + return refused(message: "README.md changed outside the harness") + default: + return .init(status: 200, body: Data("{}".utf8)) + } + } + + await project.rewind(to: point) + let refusal = try #require(project.rewindRefusal) + let requestsBeforeSwitch = RewindStub.bodies(matching: rewindPath).count + project.run?.rebind(conversationID: "conv_2") + + project.forceRewind(refusal) + + #expect(RewindStub.bodies(matching: rewindPath).count == requestsBeforeSwitch) + #expect(project.rewindRefusal == nil) + } + + @Test("force retry claims the refusal before the alert binding dismisses") + func forceRetrySurvivesImmediateBindingDismissal() async throws { + RewindStub.reset() + let project = await makeReadyProject() + let point = try makePoint() + RewindStub.set { request in + switch (request.httpMethod, request.url?.path) { + case ("POST", rewindPath): + let attempt = RewindStub.bodies(matching: rewindPath).count + if attempt <= 1 { + return refused(message: "README.md changed outside the harness") + } + return .init( + status: 200, + body: Data(#"{"files_restored":1,"messages_truncated":1}"#.utf8)) + default: + return .init(status: 200, body: Data("{}".utf8)) + } + } + + await project.rewind(to: point) + let refusal = try #require(project.rewindRefusal) + + // `DestructiveConfirmation` invokes the action, then synchronously + // clears its binding. The action must claim the refusal before its + // asynchronous HTTP work begins. + project.forceRewind(refusal) + project.dismissRewindRefusal() + + try await wait { RewindStub.bodies(matching: rewindPath).count == 2 } + let body = try #require(RewindStub.bodies(matching: rewindPath).last) + let decoded = try JSONSerialization.jsonObject(with: body) as? [String: Any] + #expect(decoded?["force"] as? Bool == true) + #expect(project.statusMessage == "Restored 1 file(s), removed 1 message(s)") + } + + @Test("a generic failure sets statusMessage and offers no force path") + func genericFailureDoesNotOfferForce() async throws { + RewindStub.reset() + let project = await makeReadyProject() + let point = try makePoint() + RewindStub.set { request in + switch (request.httpMethod, request.url?.path) { + case ("POST", rewindPath): + return .init( + status: 500, + body: Data(#"{"error":{"code":"internal_error","message":"boom"}}"#.utf8)) + default: + return .init(status: 200, body: Data("{}".utf8)) + } + } + + await project.rewind(to: point) + + #expect(project.rewindRefusal == nil) + #expect(project.statusMessage == "boom") + } + + @Test( + "confirming the refusal sends force:true, and the refusal clears once the forced call succeeds" + ) + func forceRewindSendsForceTrueAndClears() async throws { + RewindStub.reset() + let project = await makeReadyProject() + let point = try makePoint() + RewindStub.set { request in + switch (request.httpMethod, request.url?.path) { + case ("POST", rewindPath): + let attempt = RewindStub.bodies(matching: rewindPath).count + if attempt <= 1 { + return refused(message: "README.md changed outside the harness") + } + return .init( + status: 200, + body: Data(#"{"files_restored":2,"messages_truncated":3}"#.utf8)) + default: + return .init(status: 200, body: Data("{}".utf8)) + } + } + + await project.rewind(to: point) + #expect(project.rewindRefusal != nil) + + await project.rewind(to: point, force: true) + + let bodies = RewindStub.bodies(matching: rewindPath) + #expect(bodies.count == 2) + let secondBody = try #require(bodies.last) + let decoded = try JSONSerialization.jsonObject(with: secondBody) as? [String: Any] + #expect(decoded?["force"] as? Bool == true) + #expect(project.rewindRefusal == nil) + #expect(project.statusMessage == "Restored 2 file(s), removed 3 message(s)") + } + + @Test( + "a second refusal on the forced call sets rewindRefusal again rather than looping or clearing silently" + ) + func secondRefusalOnForcedCallSetsAgain() async throws { + RewindStub.reset() + let project = await makeReadyProject() + let point = try makePoint() + RewindStub.set { request in + switch (request.httpMethod, request.url?.path) { + case ("POST", rewindPath): + return refused(message: "still changed outside the harness") + default: + return .init(status: 200, body: Data("{}".utf8)) + } + } + + await project.rewind(to: point) + #expect(project.rewindRefusal != nil) + + await project.rewind(to: point, force: true) + + #expect(project.rewindRefusal?.pointID == point.id) + #expect( + project.rewindRefusal?.message.contains("still changed outside the harness") == true) + } + + @Test( + "a successful rewind reports the restore counts with no refusal -- existing behaviour, pinned" + ) + func successfulRewindReportsCounts() async throws { + RewindStub.reset() + let project = await makeReadyProject() + let point = try makePoint() + RewindStub.set { request in + switch (request.httpMethod, request.url?.path) { + case ("POST", rewindPath): + return .init( + status: 200, + body: Data(#"{"files_restored":4,"messages_truncated":1}"#.utf8)) + default: + return .init(status: 200, body: Data("{}".utf8)) + } + } + + await project.rewind(to: point) + + #expect(project.rewindRefusal == nil) + #expect(project.statusMessage == "Restored 4 file(s), removed 1 message(s)") + } + + // MARK: - Regression + + /// Distinct from the behavioral tests above, which only exercise the + /// clear-on-a-fresh-call and clear-on-success paths: this pins that + /// declining the confirmation (`dismissRewindRefusal()`, the binding's + /// Cancel path in `SessionsView`) clears the refusal on its own, with no + /// server call at all. "Never auto-retried with force" would still read + /// true if Cancel silently issued `rewind(force: false)` instead of doing + /// nothing, and only asserting on the request count -- not merely that + /// `rewindRefusal` became nil -- catches that. + @Test("dismissing a refusal clears it without contacting the server") + func dismissingRefusalContactsNoServer() async throws { + RewindStub.reset() + let project = await makeReadyProject() + let point = try makePoint() + RewindStub.set { request in + switch (request.httpMethod, request.url?.path) { + case ("POST", rewindPath): + return refused(message: "README.md changed outside the harness") + default: + return .init(status: 200, body: Data("{}".utf8)) + } + } + + await project.rewind(to: point) + #expect(project.rewindRefusal != nil) + let requestsBeforeDismiss = RewindStub.bodies(matching: rewindPath).count + + project.dismissRewindRefusal() + + #expect(project.rewindRefusal == nil) + #expect(RewindStub.bodies(matching: rewindPath).count == requestsBeforeDismiss) + } + + /// Distinct regression angle from `refusalCapturedStructurally` and + /// `successfulRewindReportsCounts` above, which both only ever act on a + /// single point: this proves the guarantee is "cleared at the start of + /// every call", not merely "cleared on success" -- a stale refusal for a + /// prior checkpoint must not still read as current once the operator + /// moves on to a different one, even when the new attempt itself fails + /// for an unrelated reason. + @Test( + "attempting a rewind on a different checkpoint clears a stale refusal from a prior one" + ) + func newRewindAttemptClearsStaleRefusalFromPriorPoint() async throws { + RewindStub.reset() + let project = await makeReadyProject() + let pointA = try makePoint(id: "point_a") + let pointB = try makePoint(id: "point_b") + RewindStub.set { request in + switch (request.httpMethod, request.url?.path) { + case ("POST", rewindPath): + return refused(message: "point A changed outside the harness") + default: + return .init(status: 200, body: Data("{}".utf8)) + } + } + await project.rewind(to: pointA) + #expect(project.rewindRefusal?.pointID == pointA.id) + + // A fresh attempt on a *different* point fails for an unrelated + // reason. The stale point-A refusal must not survive into this call. + RewindStub.set { request in + switch (request.httpMethod, request.url?.path) { + case ("POST", rewindPath): + return .init( + status: 500, + body: Data(#"{"error":{"code":"internal_error","message":"boom"}}"#.utf8)) + default: + return .init(status: 200, body: Data("{}".utf8)) + } + } + await project.rewind(to: pointB) + + #expect(project.rewindRefusal == nil) + #expect(project.statusMessage == "boom") + } + + /// Distinct from `sessionsViewWiresForceOnlyInRefusalBranch` below, which + /// only pins that the force call and the stale NOTE are handled -- this + /// pins the specific decline-path symbol by name, so a revert that leaves + /// the second confirmation presented but wires its Cancel button to + /// nothing (a refusal that can never be dismissed) is caught, not just a + /// revert of the force call itself. + @Test("the force-rewind confirmation's decline path calls dismissRewindRefusal") + func declinePathCallsDismissRewindRefusal() throws { + let contents = try fileContents("SessionsView.swift") + #expect(contents.contains("project.dismissRewindRefusal()")) + } + + // MARK: - Reachability (KTD-6 wiring) + + /// #951 finding 9's NOTE said a force path could not exist without this + /// unit's model-level change; that NOTE must be retired along with the + /// dead-toggle prose it explained, and the production force call must + /// exist only inside the refusal-confirmation branch -- never as a + /// second, independent call site that could auto-retry with force. + @Test( + "SessionsView retries only through the conversation-bound refusal, and the stale NOTE is gone" + ) + func sessionsViewWiresForceOnlyInRefusalBranch() throws { + let contents = try fileContents("SessionsView.swift") + #expect(!contents.contains("finding 9")) + #expect(!contents.contains("forceNext")) + #expect(!contents.contains("Task { await project.forceRewind(refusal) }")) + #expect(occurrences(of: "forceRewind(", in: contents) == 1) + #expect(occurrences(of: "rewind(to:", in: contents) == 1) + } + + // MARK: - Helpers + + private func occurrences(of needle: String, in haystack: String) -> Int { + haystack.components(separatedBy: needle).count - 1 + } + + private func fileContents(_ name: String) throws -> String { + let url = URL(filePath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .appending(path: "Sources/GoCodeUI") + .appending(path: name) + return try String(contentsOf: url, encoding: .utf8) + } +} diff --git a/macapp/Tests/GoCodeUITests/PromptHistoryTests.swift b/macapp/Tests/GoCodeUITests/PromptHistoryTests.swift new file mode 100644 index 00000000..b0d3e781 --- /dev/null +++ b/macapp/Tests/GoCodeUITests/PromptHistoryTests.swift @@ -0,0 +1,248 @@ +import Foundation +import HarnessKit +import Testing + +@testable import GoCodeUI + +@Suite("PromptHistory cursor navigation") +struct PromptHistoryTests { + + @Test("Up walks backwards through recorded prompts, newest first") + func recallsBackwardsFromNewest() { + var history = PromptHistory() + history.record("a") + history.record("b") + history.record("c") + + #expect(history.recallPrevious(currentDraft: "") == "c") + #expect(history.recallPrevious(currentDraft: "") == "b") + #expect(history.recallPrevious(currentDraft: "") == "a") + } + + @Test("Up at the oldest entry stays there -- no wraparound, no nil-after-start") + func staysAtOldestEntry() { + var history = PromptHistory() + history.record("a") + history.record("b") + history.record("c") + + _ = history.recallPrevious(currentDraft: "") + _ = history.recallPrevious(currentDraft: "") + _ = history.recallPrevious(currentDraft: "") + #expect(history.recallPrevious(currentDraft: "") == "a") + } + + @Test("Down walks forward and back out to the stashed draft") + func recallsForwardToStashedDraft() { + var history = PromptHistory() + history.record("a") + history.record("b") + history.record("c") + + _ = history.recallPrevious(currentDraft: "") + _ = history.recallPrevious(currentDraft: "") + _ = history.recallPrevious(currentDraft: "") + // now at "a" + + #expect(history.recallNext() == "b") + #expect(history.recallNext() == "c") + #expect(history.recallNext() == "") + #expect(history.recallNext() == nil) + } + + @Test( + "core regression: Up on a half-typed draft declines and leaves the cursor unmoved" + ) + func declinesToClobberAnInProgressDraft() { + var history = PromptHistory() + history.record("a") + history.record("b") + + #expect(history.recallPrevious(currentDraft: "half-typed thought") == nil) + // The cursor never moved, so a subsequent Up from an empty draft still + // starts from the newest entry -- proof the decline did not silently + // advance navigation state. + #expect(history.recallPrevious(currentDraft: "") == "b") + } + + @Test("Down past the newest restores the exact stashed draft, including an empty one") + func restoresStashedDraftExactly() { + var history = PromptHistory() + history.record("a") + + _ = history.recallPrevious(currentDraft: "") + #expect(history.recallNext() == "") + } + + @Test("recording while navigating resets the cursor so the next Up starts from the newest") + func recordingResetsNavigation() { + var history = PromptHistory() + history.record("a") + history.record("b") + + _ = history.recallPrevious(currentDraft: "") + _ = history.recallPrevious(currentDraft: "") + // now at "a"; recording a new prompt mid-navigation must reset + history.record("c") + + #expect(history.recallPrevious(currentDraft: "") == "c") + } + + @Test("empty history declines without crashing") + func emptyHistoryDeclines() { + var history = PromptHistory() + #expect(history.recallPrevious(currentDraft: "") == nil) + } + + /// KTD-7's approximation of caret-awareness: since this package cannot + /// read the text caret (macOS 14 floor, `TextSelection` needs 15), Up + /// must still never silently replace an edit made to an already-recalled + /// entry -- it declines and leaves the cursor exactly where it was, so a + /// later Up (once the draft matches again) resumes normally. + @Test("regression: Up declines without clobbering once the recalled entry has been edited") + func declinesWhenRecalledEntryHasBeenEdited() { + var history = PromptHistory() + history.record("a") + history.record("b") + + #expect(history.recallPrevious(currentDraft: "") == "b") + #expect(history.recallPrevious(currentDraft: "b, but edited") == nil) + #expect(history.recallPrevious(currentDraft: "b") == "a") + } + + /// Exercises the fix for #995 (F7): repeating Up once already at the + /// oldest entry used to keep returning that same string. Reassigning + /// `draft` to a value it already holds never fires SwiftUI's `onChange`, + /// which left the composer's `isRecallingHistory` flag stuck `true` -- + /// the next real keystroke was then misattributed to the recall instead + /// of calling `noteManualDraftEdit()`, so a later Down silently clobbered + /// the user's edit. Declining (nil) instead of repeating the same value + /// is what lets the composer tell "nothing happened" apart from "the + /// same entry was recalled again". + @Test( + "regression: Up again at the oldest entry, once the draft already shows it, declines instead of repeating a same-value no-op" + ) + func staysAtOldestEntryDeclinesNoOpRepeat() { + var history = PromptHistory() + history.record("a") + history.record("b") + + #expect(history.recallPrevious(currentDraft: "") == "b") + #expect(history.recallPrevious(currentDraft: "b") == "a") + // Now at the oldest entry ("a") and the composer's draft already + // shows it -- Up again must decline, not return "a" again. + #expect(history.recallPrevious(currentDraft: "a") == nil) + } + + @Test("regression: duplicate consecutive prompts are both recorded, not deduped") + func duplicatePromptsAreNotDeduped() { + var history = PromptHistory() + history.record("same") + history.record("same") + + #expect(history.recallPrevious(currentDraft: "") == "same") + // The second entry is still recalled even though it has the same + // string as the first and therefore will not trigger SwiftUI's + // `onChange` when assigned to the text field. + #expect(history.recallPrevious(currentDraft: "same") == "same") + // The cursor advanced through both identical entries, rather than + // deduping them or remaining on the newest one. + #expect(history.recallNext() == "same") + #expect(history.recallNext() == "") + } + + @Test("reset clears navigation state without touching recorded entries") + func resetClearsNavigation() { + var history = PromptHistory() + history.record("a") + history.record("b") + + _ = history.recallPrevious(currentDraft: "") + history.reset() + + #expect(history.recallPrevious(currentDraft: "") == "b") + } + + @Test("reachability: ChatView wires Up/Down to prompt-history recall in production") + func chatViewWiresArrowKeysToRecall() throws { + let chatViewURL = URL(filePath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .appending(path: "Sources/GoCodeUI/ChatView.swift") + let source = try String(contentsOf: chatViewURL, encoding: .utf8) + + #expect(source.contains(".onKeyPress(.upArrow")) + #expect(source.contains(".onKeyPress(.downArrow")) + #expect(source.contains("isRecallingHistory = run.draft != draftBeforeRecall")) + #expect( + source.components( + separatedBy: + "isRecallingHistory = false\n return .ignored" + ).count - 1 == 2, + "both declined Up and declined Down must clear recall suppression before returning") + } +} + +/// Exercises the RunSession/composer wiring that sits outside PromptHistory +/// itself: `submit()` recording, and `noteManualDraftEdit()` -- which the +/// composer's key handlers and `onChange` cooperate to call -- ending +/// navigation so the *next* arrow press cannot silently replace an edit. +/// None of these calls touch the network, so a plain client pointed at an +/// address nothing is listening on is enough (mirrors `RunControlAckTests`' +/// construction, minus its stub since these paths never dial out). +@Suite("RunSession prompt-history wiring") +@MainActor +struct RunSessionPromptHistoryWiringTests { + + private func makeSession() -> RunSession { + RunSession(baseURL: URL(string: "http://127.0.0.1:0")!) + } + + @Test("submit() records the prompt so a later Up recalls it") + func submitRecordsPrompt() { + let session = makeSession() + session.draft = "first prompt" + session.submit() + + #expect(session.recallPreviousPrompt()) + #expect(session.draft == "first prompt") + } + + @Test("recallPreviousPrompt declines and returns false when history is empty") + func recallPreviousDeclinesOnEmptyHistory() { + let session = makeSession() + #expect(session.recallPreviousPrompt() == false) + #expect(session.draft.isEmpty) + } + + @Test( + "regression: noteManualDraftEdit resets navigation so Down cannot silently overwrite an edit made after a recall" + ) + func manualEditAfterRecallResetsNavigation() { + // A single entry is deliberate: `submit()` optimistically marks the + // run `.queued` (`Transcript.appendUserPrompt`), so a second + // synchronous `submit()` in the same test would be silently blocked + // by `canSubmit`'s `isBusy` guard before ever reaching this method. + let session = makeSession() + session.draft = "first prompt" + session.submit() + + #expect(session.recallPreviousPrompt()) + #expect(session.draft == "first prompt") + + // Simulates the composer's onChange firing for a manual keystroke, + // not the recall that just happened. + session.draft = "first prompt, but edited" + session.noteManualDraftEdit() + + // `recallNext` (unlike `recallPrevious`) takes no draft parameter + // to compare against -- per the plan's `PromptHistory` signature -- + // so without the reset it would still see itself "at" the one + // recalled entry and restore the pre-recall stash (empty string), + // silently erasing the edit. The reset ends navigation first, so + // Down correctly declines instead. + #expect(session.recallNextPrompt() == false) + #expect(session.draft == "first prompt, but edited") + } +} diff --git a/macapp/Tests/GoCodeUITests/ReachabilitySource.swift b/macapp/Tests/GoCodeUITests/ReachabilitySource.swift new file mode 100644 index 00000000..fe5994bf --- /dev/null +++ b/macapp/Tests/GoCodeUITests/ReachabilitySource.swift @@ -0,0 +1,47 @@ +import Foundation + +/// Shared source-scan helpers for reachability tests (`TranscriptFeatureReachabilityTests`, +/// `AccessibilityReachabilityTests`): read one named file from `Sources/GoCodeUI`, or every +/// `.swift` file there joined together. These tests assert production source contains (or +/// no longer contains) a literal shape, since alert presentation, key handling, and +/// accessibility traits are not otherwise assertable through a headless test on this stack. +/// +/// `testFilePath` defaults to `#filePath` evaluated at the *call site* (the standard +/// `#filePath`-as-default-parameter idiom), so each caller resolves paths relative to its +/// own file without repeating the `deletingLastPathComponent()` walk up to +/// `Tests/GoCodeUITests` itself. +enum ReachabilitySource { + static func file(_ name: String, testFilePath: String = #filePath) throws -> String { + try String( + contentsOf: sourcesDirectory(from: testFilePath).appending(path: name), + encoding: .utf8) + } + + /// Walks `Sources/GoCodeUI` recursively so subdirectories (e.g. + /// `DesignSystem/`) are scanned too -- a flat `contentsOfDirectory` here + /// used to see only top-level files, silently exempting anything moved + /// into a subdirectory from every module-wide reachability assertion. + static func wholeModule(testFilePath: String = #filePath) throws -> String { + let directory = sourcesDirectory(from: testFilePath) + guard + let enumerator = FileManager.default.enumerator( + at: directory, includingPropertiesForKeys: nil) + else { + throw CocoaError(.fileReadNoSuchFile) + } + return + try enumerator + .compactMap { $0 as? URL } + .filter { $0.pathExtension == "swift" } + .map { try String(contentsOf: $0, encoding: .utf8) } + .joined(separator: "\n") + } + + private static func sourcesDirectory(from testFilePath: String) -> URL { + URL(filePath: testFilePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .appending(path: "Sources/GoCodeUI") + } +} diff --git a/macapp/Tests/GoCodeUITests/RunControlAckTests.swift b/macapp/Tests/GoCodeUITests/RunControlAckTests.swift new file mode 100644 index 00000000..8070476a --- /dev/null +++ b/macapp/Tests/GoCodeUITests/RunControlAckTests.swift @@ -0,0 +1,1127 @@ +import Foundation +import HarnessKit +import Testing + +@testable import GoCodeUI + +/// Defers an HTTP response without blocking URLSession's delegate queue, so +/// another request can model the newer completion that races it. +private final class ResponseGate: @unchecked Sendable { + private let semaphore = DispatchSemaphore(value: 0) + + func open() { semaphore.signal() } + func wait() { semaphore.wait() } +} + +/// Minimal HTTP+SSE stub scoped to this file's tests, keyed on HTTP method +/// *and* path (not path alone), because `GET /v1/runs/{id}/input` +/// (`pendingInput`) and `POST /v1/runs/{id}/input` (`answerInput`) share a +/// path and must be scripted independently. +private final class RunControlStub: URLProtocol, @unchecked Sendable { + struct Response: Sendable { + var status: Int = 200 + var headers: [String: String] = ["Content-Type": "application/json"] + var body: Data = Data() + /// Simulates a connection that never terminates. Used for the run's + /// (and conversation's) `/events` stream so `RunSession.currentRunID` + /// stays set for the duration of a test instead of clearing the + /// moment an empty stream finishes normally. + var neverFinishes = false + /// Makes `stopLoading()` report cancellation rather than ending the + /// stream normally, exercising RunSession's CancellationError path. + var failsWithCancellationWhenStopped = false + var gate: ResponseGate? + } + + nonisolated(unsafe) private static var handler: (@Sendable (URLRequest) -> Response)? + nonisolated(unsafe) private static var recorded: [URLRequest] = [] + private static let lock = NSLock() + private var failsWithCancellationWhenStopped = false + + static func set(_ handler: @escaping @Sendable (URLRequest) -> Response) { + lock.withLock { self.handler = handler } + } + + static func reset() { + lock.withLock { + handler = nil + recorded = [] + } + } + + static func requests(matching path: String) -> [URLRequest] { + lock.withLock { recorded.filter { $0.url?.path == path } } + } + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + let request = self.request + // Do not hold the bookkeeping lock while the programmable handler + // waits. Real URLSession requests may overlap, and the regression + // tests below need to model an older request completing after a newer + // one rather than serializing every response through this test double. + let handler = Self.lock.withLock { + Self.recorded.append(request) + return Self.handler + } + let response = handler?(request) ?? Response() + failsWithCancellationWhenStopped = response.failsWithCancellationWhenStopped + if let gate = response.gate { + DispatchQueue.global().async { [self] in + gate.wait() + deliver(response) + } + } else { + deliver(response) + } + } + + private func deliver(_ response: Response) { + let http = HTTPURLResponse( + url: request.url!, statusCode: response.status, + httpVersion: "HTTP/1.1", headerFields: response.headers)! + client?.urlProtocol(self, didReceive: http, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: response.body) + if !response.neverFinishes { + client?.urlProtocolDidFinishLoading(self) + } + } + + override func stopLoading() { + guard failsWithCancellationWhenStopped else { return } + client?.urlProtocol(self, didFailWithError: CancellationError()) + } +} + +/// Exercises the fix for #994 (F3): `RunSession.cancel/approve/deny/answer` +/// discarded their server acknowledgement with `try?`, so a rejected or +/// failed call left the UI asserting an action had succeeded when it had +/// not. These tests drive `RunSession` directly against the stub above, the +/// same shape `RunSessionConversationStreamTests` uses. +@Suite("RunSession control acknowledgements", .serialized) +@MainActor +struct RunControlAckTests { + + private func makeSession() -> RunSession { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [RunControlStub.self] + // Race regressions intentionally keep multiple SSE streams and HTTP + // acknowledgements open at once; do not let URLSession's per-host + // connection cap serialize the test fixture into a different shape. + config.httpMaximumConnectionsPerHost = 20 + let client = HarnessClient( + baseURL: URL(string: "http://127.0.0.1:8897")!, + session: URLSession(configuration: config)) + return RunSession(client: client) + } + + private func wait( + timeout: Duration = .seconds(5), for condition: () -> Bool + ) async throws { + let deadline = ContinuousClock.now.advanced(by: timeout) + while ContinuousClock.now < deadline { + if condition() { return } + try await Task.sleep(for: .milliseconds(20)) + } + Issue.record("timed out waiting for condition") + } + + /// Starts a run and waits for `currentRunID` to be set, with its events + /// streams left open (never a terminal event) so the run stays "current" + /// for the rest of the test -- cancel/approve/deny all guard on + /// `currentRunID`. `extra` answers every other path the test needs + /// (cancel/approve/deny). + private func startBusyRun( + _ session: RunSession, + runEventsFailWithCancellationWhenStopped: Bool = false, + extra: @escaping @Sendable (URLRequest) -> RunControlStub.Response + ) async throws { + RunControlStub.set { request in + switch (request.httpMethod, request.url?.path) { + case ("POST", "/v1/runs"): + return .init(status: 202, body: Data(#"{"run_id":"run_1","status":"queued"}"#.utf8)) + case ("GET", "/v1/runs/run_1/events"), ("GET", "/v1/conversations/run_1/events"): + return .init( + status: 200, headers: ["Content-Type": "text/event-stream"], + neverFinishes: true, + failsWithCancellationWhenStopped: request.url?.path == "/v1/runs/run_1/events" + && runEventsFailWithCancellationWhenStopped + ) + default: + return extra(request) + } + } + session.draft = "hi" + session.submit() + try await wait { session.currentRunID == "run_1" } + } + + @Test("a failed approve surfaces via connectionError -- core regression") + func approveFailureSurfaces() async throws { + RunControlStub.reset() + let session = makeSession() + try await startBusyRun(session) { request in + guard request.httpMethod == "POST", request.url?.path == "/v1/runs/run_1/approve" else { + return .init() + } + return .init( + status: 500, + body: Data( + #"{"error":{"code":"internal_error","message":"approve rejected"}}"#.utf8)) + } + + session.approve() + try await wait { session.connectionError != nil } + #expect(session.connectionError == "approve rejected") + + session.reset() + } + + @Test("a failed deny surfaces via connectionError") + func denyFailureSurfaces() async throws { + RunControlStub.reset() + let session = makeSession() + try await startBusyRun(session) { request in + guard request.httpMethod == "POST", request.url?.path == "/v1/runs/run_1/deny" else { + return .init() + } + return .init( + status: 500, + body: Data(#"{"error":{"code":"internal_error","message":"deny rejected"}}"#.utf8)) + } + + session.deny() + try await wait { session.connectionError != nil } + #expect(session.connectionError == "deny rejected") + + session.reset() + } + + /// `steer` had no failure-path coverage: `approve`/`deny` above prove the + /// shared `runControlTask` helper surfaces a rejection, but steer is the + /// only one of the three that also mutates `draft`, so it gets its own + /// test rather than relying on the shared helper's coverage by proxy. + @Test("a failed steer surfaces via connectionError") + func steerFailureSurfaces() async throws { + RunControlStub.reset() + let session = makeSession() + try await startBusyRun(session) { request in + guard request.httpMethod == "POST", request.url?.path == "/v1/runs/run_1/steer" else { + return .init() + } + return .init( + status: 500, + body: Data(#"{"error":{"code":"internal_error","message":"steer rejected"}}"#.utf8) + ) + } + + session.draft = " go the other way " + session.steer() + try await wait { session.connectionError != nil } + #expect(session.connectionError == "steer rejected") + #expect(session.draft == " go the other way ") + + session.reset() + } + + @Test("answerInput succeeding clears pendingQuestions") + func answerSuccessClearsPendingQuestions() async throws { + RunControlStub.reset() + let session = makeSession() + let promptJSON = + #"{"run_id":"run_1","call_id":"call_1","questions":[{"question":"Continue?"}]}"# + RunControlStub.set { request in + switch (request.httpMethod, request.url?.path) { + case ("POST", "/v1/runs"): + return .init(status: 202, body: Data(#"{"run_id":"run_1","status":"queued"}"#.utf8)) + case ("GET", "/v1/conversations/run_1/events"): + return .init( + status: 200, headers: ["Content-Type": "text/event-stream"], neverFinishes: true + ) + case ("GET", "/v1/runs/run_1/events"): + let frame = """ + id: run_1:0 + event: run.waiting_for_user + data: {"id":"run_1:0","run_id":"run_1","type":"run.waiting_for_user","payload":{}} + + + """ + return .init( + status: 200, headers: ["Content-Type": "text/event-stream"], + body: Data(frame.utf8), neverFinishes: true) + case ("GET", "/v1/runs/run_1/input"): + return .init(status: 200, body: Data(promptJSON.utf8)) + case ("POST", "/v1/runs/run_1/input"): + return .init(status: 200) + default: + return .init() + } + } + + session.draft = "hi" + session.submit() + try await wait { session.pendingQuestions != nil } + let questionID = try #require(session.pendingQuestions?.questions.first?.id) + + session.answer([questionID: "yes"]) + try await wait { session.pendingQuestions == nil } + #expect(session.connectionError == nil) + + session.reset() + } + + @Test("a rejected answerInput keeps pendingQuestions and surfaces the error -- core regression") + func answerFailureKeepsPendingQuestions() async throws { + RunControlStub.reset() + let session = makeSession() + let promptJSON = + #"{"run_id":"run_1","call_id":"call_1","questions":[{"question":"Continue?"}]}"# + RunControlStub.set { request in + switch (request.httpMethod, request.url?.path) { + case ("POST", "/v1/runs"): + return .init(status: 202, body: Data(#"{"run_id":"run_1","status":"queued"}"#.utf8)) + case ("GET", "/v1/conversations/run_1/events"): + return .init( + status: 200, headers: ["Content-Type": "text/event-stream"], neverFinishes: true + ) + case ("GET", "/v1/runs/run_1/events"): + let frame = """ + id: run_1:0 + event: run.waiting_for_user + data: {"id":"run_1:0","run_id":"run_1","type":"run.waiting_for_user","payload":{}} + + + """ + return .init( + status: 200, headers: ["Content-Type": "text/event-stream"], + body: Data(frame.utf8), neverFinishes: true) + case ("GET", "/v1/runs/run_1/input"): + return .init(status: 200, body: Data(promptJSON.utf8)) + case ("POST", "/v1/runs/run_1/input"): + return .init( + status: 409, + body: Data( + #"{"error":{"code":"no_pending_input","message":"already answered"}}"#.utf8) + ) + default: + return .init() + } + } + + session.draft = "hi" + session.submit() + try await wait { session.pendingQuestions != nil } + let questionID = try #require(session.pendingQuestions?.questions.first?.id) + + session.answer([questionID: "yes"]) + try await wait { session.connectionError != nil } + #expect( + session.pendingQuestions != nil, "a rejected answer must not clear the pending question" + ) + + session.reset() + } + + @Test( + "a failed cancel surfaces via connectionError and a second press stays cooperative -- core regression" + ) + func cancelFailureStaysCooperative() async throws { + RunControlStub.reset() + let session = makeSession() + try await startBusyRun(session) { request in + guard request.httpMethod == "POST", request.url?.path == "/v1/runs/run_1/cancel" else { + return .init() + } + return .init( + status: 500, + body: Data(#"{"error":{"code":"internal_error","message":"cancel rejected"}}"#.utf8) + ) + } + + session.cancel() + try await wait { session.connectionError != nil } + #expect(session.transcript.runState != .cancelled) + + session.cancel() + try await wait { RunControlStub.requests(matching: "/v1/runs/run_1/cancel").count == 2 } + #expect( + session.transcript.runState != .cancelled, + "a failed first cancel must not let the second press force-abandon the stream locally" + ) + + session.reset() + } + + @Test("cancel succeeding leaves no connectionError, and a second press marks cancelled") + func cancelSuccessThenSecondPressCancels() async throws { + RunControlStub.reset() + let session = makeSession() + try await startBusyRun(session, runEventsFailWithCancellationWhenStopped: true) { request in + guard request.httpMethod == "POST", request.url?.path == "/v1/runs/run_1/cancel" else { + return .init() + } + return .init(status: 200) + } + + session.cancel() + try await wait { RunControlStub.requests(matching: "/v1/runs/run_1/cancel").count == 1 } + try await Task.sleep(for: .milliseconds(50)) + #expect(session.connectionError == nil) + + session.cancel() + #expect( + session.currentRunID == nil, + "a forced local cancel must synchronously release the active run identity" + ) + try await wait { session.transcript.runState == .cancelled } + try await wait { session.currentRunID == nil } + #expect( + RunControlStub.requests(matching: "/v1/runs/run_1/cancel").count == 1, + "the second press must abandon locally, not call cancel again") + #expect( + session.currentRunID == nil, + "a forced local cancel must release the active run identity after cancelling its stream" + ) + + session.reset() + } + + /// Exercises the fix for #995 (F2): `cancelRequested` used to flip to + /// `true` *synchronously*, before the first cooperative cancel's request + /// had even reached the server -- so a second press arriving during that + /// round trip force-abandoned the stream locally, ahead of any server + /// acknowledgement. The replacement state machine must not escalate + /// until the first cancel actually comes back. + @Test( + "a second press while the first cooperative cancel is still in flight does not escalate -- core regression" + ) + func secondPressDuringInFlightCancelDoesNotEscalate() async throws { + RunControlStub.reset() + let session = makeSession() + let cancelArrived = Flag() + let releaseCancel = DispatchSemaphore(value: 0) + try await startBusyRun(session) { request in + guard request.httpMethod == "POST", request.url?.path == "/v1/runs/run_1/cancel" else { + return .init() + } + cancelArrived.set() + releaseCancel.wait() + return .init(status: 200) + } + + session.cancel() + // `RunControlStub` serializes every request through one global lock + // held for the duration of the handler call -- polling + // `.requests(matching:)` here would try to take that same lock while + // the handler above is still blocked holding it, deadlocking the + // test. `cancelArrived` observes "the request reached the stub" + // without touching the lock. A *blocking* wait here (rather than + // this cooperative poll) would be just as wrong: it would stall the + // MainActor executor that `session.cancel()`'s own `Task` needs in + // order to run far enough to send the request in the first place. + try await wait { cancelArrived.isSet } + + // The first cancel is now blocked in flight. A second press here + // must not force-abandon the stream. + session.cancel() + try await Task.sleep(for: .milliseconds(80)) + #expect( + session.transcript.runState != .cancelled, + "a press during the in-flight cooperative cancel must not force-stop before the server acknowledges it" + ) + + // Signalled generously: harmless if only one request is blocked + // (the fixed/expected shape), but insurance against a leaked, + // permanently blocked background thread if a revert lets a second + // press fire its own request here too. + for _ in 0..<5 { releaseCancel.signal() } + try await Task.sleep(for: .milliseconds(150)) + #expect( + RunControlStub.requests(matching: "/v1/runs/run_1/cancel").count == 1, + "a second press while the first cancel is in flight must not send a duplicate request") + + // Now that the first cancel has been acknowledged, a further press + // is free to escalate. + session.cancel() + try await wait { session.transcript.runState == .cancelled } + + session.reset() + } + + /// Exercises the fix for #995 (F3): a fire-and-forget `cancel()` Task + /// that completes *after* `reset()` has already moved this session onto + /// a different (or no) run must not write `connectionError` for that + /// stale context -- reset() already cleared it, and a late failure + /// response arriving afterwards must not resurrect it. + @Test( + "a cancel Task that resolves after reset() must not surface a stale connectionError -- core regression" + ) + func cancelTaskAfterResetDoesNotWriteStaleError() async throws { + RunControlStub.reset() + let session = makeSession() + let cancelArrived = Flag() + let releaseCancel = DispatchSemaphore(value: 0) + try await startBusyRun(session) { request in + guard request.httpMethod == "POST", request.url?.path == "/v1/runs/run_1/cancel" else { + return .init() + } + cancelArrived.set() + releaseCancel.wait() + return .init( + status: 500, + body: Data(#"{"error":{"code":"internal_error","message":"cancel rejected"}}"#.utf8) + ) + } + + session.cancel() + try await wait { cancelArrived.isSet } + + session.reset() + #expect(session.connectionError == nil) + + for _ in 0..<5 { releaseCancel.signal() } + try await Task.sleep(for: .milliseconds(80)) + #expect( + session.connectionError == nil, + "a cancel response arriving after reset() must not resurrect state for an abandoned run" + ) + } + + /// Exercises the same stale-Task write race just fixed for `cancel`/ + /// `answer` above (#995 F3), but for `runControlTask` -- the helper + /// shared by `approve`/`deny`/`steer`. A fire-and-forget approve Task + /// that completes *after* `reset()` has already moved this session onto + /// a different (or no) run must not write `connectionError` for that + /// stale context. + @Test( + "an approve Task that resolves after reset() must not surface a stale connectionError -- core regression" + ) + func approveTaskAfterResetDoesNotWriteStaleError() async throws { + RunControlStub.reset() + let session = makeSession() + let approveArrived = Flag() + let releaseApprove = DispatchSemaphore(value: 0) + try await startBusyRun(session) { request in + guard request.httpMethod == "POST", request.url?.path == "/v1/runs/run_1/approve" else { + return .init() + } + approveArrived.set() + releaseApprove.wait() + return .init( + status: 500, + body: Data( + #"{"error":{"code":"internal_error","message":"approve rejected"}}"#.utf8)) + } + + session.approve() + try await wait { approveArrived.isSet } + + session.reset() + #expect(session.connectionError == nil) + + for _ in 0..<5 { releaseApprove.signal() } + try await Task.sleep(for: .milliseconds(80)) + #expect( + session.connectionError == nil, + "an approve response arriving after reset() must not resurrect state for an abandoned run" + ) + } + + @Test( + "approve, deny, and steer are one acknowledged control action at a time -- core regression") + func runControlsAreSingleFlight() async throws { + RunControlStub.reset() + let session = makeSession() + let approveArrived = Flag() + let releaseApprove = DispatchSemaphore(value: 0) + try await startBusyRun(session) { request in + switch (request.httpMethod, request.url?.path) { + case ("POST", "/v1/runs/run_1/approve"): + approveArrived.set() + releaseApprove.wait() + return .init(status: 200) + case ("POST", "/v1/runs/run_1/deny"), ("POST", "/v1/runs/run_1/steer"): + return .init(status: 500) + default: + return .init() + } + } + + session.draft = "a conflicting steer" + session.approve() + try await wait { approveArrived.isSet } + #expect(session.runControlInFlight) + + session.deny() + session.steer() + try await Task.sleep(for: .milliseconds(80)) + #expect(RunControlStub.requests(matching: "/v1/runs/run_1/approve").count == 1) + #expect(RunControlStub.requests(matching: "/v1/runs/run_1/deny").isEmpty) + #expect(RunControlStub.requests(matching: "/v1/runs/run_1/steer").isEmpty) + + for _ in 0..<5 { releaseApprove.signal() } + try await wait { !session.runControlInFlight } + #expect(session.connectionError == nil) + + session.reset() + } + + @Test("a failed steer preserves a newer manual draft edit -- core regression") + func steerFailureDoesNotOverwriteNewerManualDraft() async throws { + RunControlStub.reset() + let session = makeSession() + let steerArrived = Flag() + let releaseSteer = DispatchSemaphore(value: 0) + try await startBusyRun(session) { request in + guard request.httpMethod == "POST", request.url?.path == "/v1/runs/run_1/steer" else { + return .init() + } + steerArrived.set() + releaseSteer.wait() + return .init( + status: 500, + body: Data(#"{"error":{"code":"internal_error","message":"steer rejected"}}"#.utf8)) + } + + session.draft = "original steering instruction" + session.steer() + try await wait { steerArrived.isSet } + #expect(session.draft.isEmpty) + session.draft = "new manual edit" + + for _ in 0..<5 { releaseSteer.signal() } + try await wait { session.connectionError == "steer rejected" } + #expect(session.draft == "new manual edit") + + session.reset() + } + + /// Exercises the fix for #995 (F1a): a second `answer()` call while the + /// first is still awaiting the server must not fire a second request -- + /// this is the model-level guard behind the composer's disabled Send + /// button. + @Test( + "a second answer() call while the first is still in flight sends only one request -- core regression" + ) + func answerGuardsAgainstDoubleSubmission() async throws { + RunControlStub.reset() + let session = makeSession() + let promptJSON = + #"{"run_id":"run_1","call_id":"call_1","questions":[{"question":"Continue?"}]}"# + let answerArrived = Flag() + let releaseAnswer = DispatchSemaphore(value: 0) + RunControlStub.set { request in + switch (request.httpMethod, request.url?.path) { + case ("POST", "/v1/runs"): + return .init(status: 202, body: Data(#"{"run_id":"run_1","status":"queued"}"#.utf8)) + case ("GET", "/v1/conversations/run_1/events"): + return .init( + status: 200, headers: ["Content-Type": "text/event-stream"], neverFinishes: true + ) + case ("GET", "/v1/runs/run_1/events"): + let frame = """ + id: run_1:0 + event: run.waiting_for_user + data: {"id":"run_1:0","run_id":"run_1","type":"run.waiting_for_user","payload":{}} + + + """ + return .init( + status: 200, headers: ["Content-Type": "text/event-stream"], + body: Data(frame.utf8), neverFinishes: true) + case ("GET", "/v1/runs/run_1/input"): + return .init(status: 200, body: Data(promptJSON.utf8)) + case ("POST", "/v1/runs/run_1/input"): + answerArrived.set() + releaseAnswer.wait() + return .init(status: 200) + default: + return .init() + } + } + + session.draft = "hi" + session.submit() + try await wait { session.pendingQuestions != nil } + let questionID = try #require(session.pendingQuestions?.questions.first?.id) + + session.answer([questionID: "yes"]) + // `RunControlStub` serializes every request through one global lock + // held for the handler's duration -- `answerArrived` observes "the + // POST reached the stub" without contending for that lock, which the + // blocked handler is still holding. Polling cooperatively (not a + // blocking wait) matters here too: a blocking wait would stall the + // MainActor executor that `answer()`'s own `Task` needs in order to + // run far enough to send this very request. + try await wait { answerArrived.isSet } + + // A second press before the first answer's request resolves must not + // fire a second POST. + session.answer([questionID: "yes"]) + try await Task.sleep(for: .milliseconds(80)) + + // Signalled generously rather than once: pre-fix, a second press + // fires a genuine second POST that queues (and then blocks) on this + // same stub -- a single `.signal()` would release only one waiter + // and leak the other blocked forever, stalling URLSession's shared + // thread pool for every test that runs after this one. Extra + // signals with no waiter left are a harmless no-op. + for _ in 0..<5 { releaseAnswer.signal() } + try await wait { session.pendingQuestions == nil } + // Let the stub's lock fully drain before counting requests -- a + // stray second POST (pre-fix) may still be queued on it at the + // moment `pendingQuestions` clears. + try await Task.sleep(for: .milliseconds(150)) + + #expect( + RunControlStub.requests(matching: "/v1/runs/run_1/input").filter { + $0.httpMethod == "POST" + }.count == 1, + "a second answer() call while the first is in flight must not send a duplicate request" + ) + + session.reset() + } + + /// Exercises the fix for #995 (F1b): a success response for an answer + /// must only clear `pendingQuestions` when it is still the very prompt + /// that was answered -- a newer question assigned while the request was + /// in flight (a second `run.waiting_for_user`) must survive. + @Test( + "a success answer only clears the prompt it actually answered, not a newer one assigned mid-flight -- core regression" + ) + func answerSuccessOnlyClearsItsOwnPrompt() async throws { + RunControlStub.reset() + let promptJSON1 = + #"{"run_id":"run_1","call_id":"call_1","questions":[{"question":"First?"}]}"# + let promptJSON2 = + #"{"run_id":"run_1","call_id":"call_2","questions":[{"question":"Second?"}]}"# + let session = makeSession() + let releaseSecondFetch = DispatchSemaphore(value: 0) + let releaseAnswer = DispatchSemaphore(value: 0) + let inputGetCount = Locked(0) + RunControlStub.set { request in + switch (request.httpMethod, request.url?.path) { + case ("POST", "/v1/runs"): + return .init(status: 202, body: Data(#"{"run_id":"run_1","status":"queued"}"#.utf8)) + case ("GET", "/v1/conversations/run_1/events"): + return .init( + status: 200, headers: ["Content-Type": "text/event-stream"], neverFinishes: true + ) + case ("GET", "/v1/runs/run_1/events"): + let frame = """ + id: run_1:0 + event: run.waiting_for_user + data: {"id":"run_1:0","run_id":"run_1","type":"run.waiting_for_user","payload":{}} + + id: run_1:1 + event: run.waiting_for_user + data: {"id":"run_1:1","run_id":"run_1","type":"run.waiting_for_user","payload":{}} + + + """ + return .init( + status: 200, headers: ["Content-Type": "text/event-stream"], + body: Data(frame.utf8), neverFinishes: true) + case ("GET", "/v1/runs/run_1/input"): + let callNumber = inputGetCount.increment() + if callNumber == 1 { + return .init(status: 200, body: Data(promptJSON1.utf8)) + } + releaseSecondFetch.wait() + return .init(status: 200, body: Data(promptJSON2.utf8)) + case ("POST", "/v1/runs/run_1/input"): + releaseAnswer.wait() + return .init(status: 200) + default: + return .init() + } + } + + session.draft = "hi" + session.submit() + try await wait { session.pendingQuestions?.callID == "call_1" } + let firstQuestionID = try #require(session.pendingQuestions?.questions.first?.id) + + // Answer the first prompt. `RunControlStub` serializes every request + // through one global lock held for the handler's duration, so this + // POST cannot even reach its own gate yet -- it queues behind the + // still-blocked second `pendingInput` fetch above. + session.answer([firstQuestionID: "yes"]) + try await Task.sleep(for: .milliseconds(80)) + + // Release the second fetch: it completes, assigning a newer prompt + // (call_2) while the first answer's request is still queued. + for _ in 0..<5 { releaseSecondFetch.signal() } + try await wait { session.pendingQuestions?.callID == "call_2" } + + // The queued POST can now reach its own gate; release it too, then + // let its success response finish processing. + for _ in 0..<5 { releaseAnswer.signal() } + try await Task.sleep(for: .milliseconds(150)) + + #expect( + session.pendingQuestions?.callID == "call_2", + "an older answer's success must not clear a newer prompt assigned while it was in flight" + ) + #expect(session.connectionError == nil) + + session.reset() + } + + @Test( + "an old answer completion cannot release a newer answer request after reset -- core regression" + ) + func staleAnswerCompletionDoesNotClearNewerAnswerGuard() async throws { + RunControlStub.reset() + let session = makeSession() + let firstAnswerArrived = Flag() + let secondAnswerArrived = Flag() + let firstAnswerGate = ResponseGate() + let secondAnswerGate = ResponseGate() + let startedRuns = Locked(0) + RunControlStub.set { request in + switch (request.httpMethod, request.url?.path) { + case ("POST", "/v1/runs"): + let run = startedRuns.increment() + return .init( + status: 202, + body: Data(#"{"run_id":"run_\#(run)","status":"queued"}"#.utf8)) + case ("GET", "/v1/conversations/run_1/events"), + ("GET", "/v1/conversations/run_2/events"): + return .init( + status: 200, headers: ["Content-Type": "text/event-stream"], neverFinishes: true + ) + case ("GET", "/v1/runs/run_1/events"), ("GET", "/v1/runs/run_2/events"): + let runID = request.url!.path.contains("run_1") ? "run_1" : "run_2" + let frame = """ + id: \(runID):0 + event: run.waiting_for_user + data: {"id":"\(runID):0","run_id":"\(runID)","type":"run.waiting_for_user","payload":{}} + + + """ + return .init( + status: 200, headers: ["Content-Type": "text/event-stream"], + body: Data(frame.utf8), neverFinishes: true) + case ("GET", "/v1/runs/run_1/input"): + return .init( + status: 200, + body: Data( + #"{"run_id":"run_1","call_id":"call_1","questions":[{"question":"First?"}]}"# + .utf8)) + case ("GET", "/v1/runs/run_2/input"): + return .init( + status: 200, + body: Data( + #"{"run_id":"run_2","call_id":"call_2","questions":[{"question":"Second?"}]}"# + .utf8)) + case ("POST", "/v1/runs/run_1/input"): + firstAnswerArrived.set() + return .init(status: 200, gate: firstAnswerGate) + case ("POST", "/v1/runs/run_2/input"): + secondAnswerArrived.set() + return .init(status: 200, gate: secondAnswerGate) + default: + return .init() + } + } + + session.draft = "first run" + session.submit() + try await wait { session.pendingQuestions?.callID == "call_1" } + let firstQuestionID = try #require(session.pendingQuestions?.questions.first?.id) + session.answer([firstQuestionID: "yes"]) + try await wait { firstAnswerArrived.isSet } + + session.reset() + #expect(!session.isBusy) + session.draft = "second run" + session.submit() + try await Task.sleep(for: .milliseconds(80)) + #expect(RunControlStub.requests(matching: "/v1/runs").count == 2) + try await wait { session.currentRunID == "run_2" } + #expect(session.currentRunID == "run_2") + try await wait { session.pendingQuestions?.callID == "call_2" } + let secondQuestionID = try #require(session.pendingQuestions?.questions.first?.id) + session.answer([secondQuestionID: "yes"]) + try await wait { secondAnswerArrived.isSet } + #expect(session.answerInFlight) + + firstAnswerGate.open() + try await Task.sleep(for: .milliseconds(80)) + #expect( + session.answerInFlight, + "the first answer completion must not release the second request's in-flight guard" + ) + + secondAnswerGate.open() + try await wait { !session.answerInFlight } + session.reset() + } + + @Test("an older pending-input fetch cannot replace a newer run prompt -- core regression") + func stalePendingInputFetchDoesNotReplaceNewerRunPrompt() async throws { + RunControlStub.reset() + let session = makeSession() + let firstFetchArrived = Flag() + let firstFetchGate = ResponseGate() + let startedRuns = Locked(0) + RunControlStub.set { request in + switch (request.httpMethod, request.url?.path) { + case ("POST", "/v1/runs"): + let run = startedRuns.increment() + return .init( + status: 202, + body: Data(#"{"run_id":"run_\#(run)","status":"queued"}"#.utf8)) + case ("GET", "/v1/conversations/run_1/events"), + ("GET", "/v1/conversations/run_2/events"): + return .init( + status: 200, headers: ["Content-Type": "text/event-stream"], neverFinishes: true + ) + case ("GET", "/v1/runs/run_1/events"), ("GET", "/v1/runs/run_2/events"): + let runID = request.url!.path.contains("run_1") ? "run_1" : "run_2" + let frame = """ + id: \(runID):0 + event: run.waiting_for_user + data: {"id":"\(runID):0","run_id":"\(runID)","type":"run.waiting_for_user","payload":{}} + + + """ + return .init( + status: 200, headers: ["Content-Type": "text/event-stream"], + body: Data(frame.utf8), neverFinishes: true) + case ("GET", "/v1/runs/run_1/input"): + firstFetchArrived.set() + return .init( + status: 200, + body: Data( + #"{"run_id":"run_1","call_id":"call_1","questions":[{"question":"First?"}]}"# + .utf8), + gate: firstFetchGate) + case ("GET", "/v1/runs/run_2/input"): + return .init( + status: 200, + body: Data( + #"{"run_id":"run_2","call_id":"call_2","questions":[{"question":"Second?"}]}"# + .utf8)) + default: + return .init() + } + } + + session.draft = "first run" + session.submit() + try await wait { firstFetchArrived.isSet } + + session.reset() + session.draft = "second run" + session.submit() + try await wait { session.pendingQuestions?.callID == "call_2" } + + firstFetchGate.open() + try await Task.sleep(for: .milliseconds(100)) + #expect(session.pendingQuestions?.callID == "call_2") + session.reset() + } + + @Test( + "an older pending-input fetch cannot replace a newer prompt for the same run -- core regression" + ) + func stalePendingInputFetchDoesNotReplaceNewerPromptInSameRun() async throws { + RunControlStub.reset() + let session = makeSession() + let firstFetchArrived = Flag() + let firstFetchGate = ResponseGate() + let conversationEventGate = ResponseGate() + let inputFetches = Locked(0) + RunControlStub.set { request in + switch (request.httpMethod, request.url?.path) { + case ("POST", "/v1/runs"): + return .init(status: 202, body: Data(#"{"run_id":"run_1","status":"queued"}"#.utf8)) + case ("GET", "/v1/runs/run_1/events"): + let frame = """ + id: run_1:0 + event: run.waiting_for_user + data: {"id":"run_1:0","run_id":"run_1","type":"run.waiting_for_user","payload":{}} + + + """ + return .init( + status: 200, headers: ["Content-Type": "text/event-stream"], + body: Data(frame.utf8), + neverFinishes: true) + case ("GET", "/v1/conversations/run_1/events"): + // Let the per-run stream start its older input fetch first; + // the real app can receive both streams concurrently. + let frame = """ + id: run_1:1 + event: run.waiting_for_user + data: {"id":"run_1:1","run_id":"run_1","type":"run.waiting_for_user","payload":{}} + + + """ + return .init( + status: 200, headers: ["Content-Type": "text/event-stream"], + body: Data(frame.utf8), + neverFinishes: true, + gate: conversationEventGate) + case ("GET", "/v1/runs/run_1/input"): + if inputFetches.increment() == 1 { + firstFetchArrived.set() + return .init( + status: 200, + body: Data( + #"{"run_id":"run_1","call_id":"call_1","questions":[{"question":"First?"}]}"# + .utf8), + gate: firstFetchGate) + } + return .init( + status: 200, + body: Data( + #"{"run_id":"run_1","call_id":"call_2","questions":[{"question":"Second?"}]}"# + .utf8)) + default: + return .init() + } + } + + session.draft = "run" + session.submit() + try await wait { firstFetchArrived.isSet } + + conversationEventGate.open() + try await wait { session.pendingQuestions?.callID == "call_2" } + + firstFetchGate.open() + try await Task.sleep(for: .milliseconds(100)) + #expect(session.pendingQuestions?.callID == "call_2") + session.reset() + } + + /// Regression angle distinct from F1a/F1b above: those prove + /// `answerInFlight` gates *this call's own outcome* while in flight. + /// This proves `reset()` actually clears the flag for whatever + /// conversation this same `RunSession` goes on to serve next -- a + /// revert that keeps the guard in `answer()` but drops + /// `answerInFlight = false` from `reset()` would still pass every test + /// above (none of them ever call `answer()` a second time after a + /// `reset()`) while leaving Send permanently disabled for every + /// conversation opened after one whose answer never came back. + @Test( + "reset() clears answerInFlight, so an answer stuck in flight when the conversation changes does not permanently disable Send -- regression" + ) + func resetClearsAnswerInFlightForLaterConversations() async throws { + RunControlStub.reset() + let session = makeSession() + let promptJSON = + #"{"run_id":"run_1","call_id":"call_1","questions":[{"question":"Continue?"}]}"# + let answerArrived = Flag() + let releaseAnswer = DispatchSemaphore(value: 0) + RunControlStub.set { request in + switch (request.httpMethod, request.url?.path) { + case ("POST", "/v1/runs"): + return .init(status: 202, body: Data(#"{"run_id":"run_1","status":"queued"}"#.utf8)) + case ("GET", "/v1/conversations/run_1/events"): + return .init( + status: 200, headers: ["Content-Type": "text/event-stream"], neverFinishes: true + ) + case ("GET", "/v1/runs/run_1/events"): + let frame = """ + id: run_1:0 + event: run.waiting_for_user + data: {"id":"run_1:0","run_id":"run_1","type":"run.waiting_for_user","payload":{}} + + + """ + return .init( + status: 200, headers: ["Content-Type": "text/event-stream"], + body: Data(frame.utf8), neverFinishes: true) + case ("GET", "/v1/runs/run_1/input"): + return .init(status: 200, body: Data(promptJSON.utf8)) + case ("POST", "/v1/runs/run_1/input"): + answerArrived.set() + releaseAnswer.wait() + return .init(status: 200) + default: + return .init() + } + } + + session.draft = "hi" + session.submit() + try await wait { session.pendingQuestions != nil } + let questionID = try #require(session.pendingQuestions?.questions.first?.id) + + session.answer([questionID: "yes"]) + try await wait { answerArrived.isSet } + #expect( + session.answerInFlight, "sanity check: the flag is set while the request is in flight") + + // The operator abandons this conversation (e.g. "New") before the + // server ever responds to the answer. + session.reset() + + #expect( + session.answerInFlight == false, + "reset() must clear answerInFlight -- otherwise Send stays disabled for every later conversation this RunSession goes on to serve" + ) + + // Drain the still-blocked handler thread so it does not linger past + // this test. + for _ in 0..<5 { releaseAnswer.signal() } + } +} + +@Suite("Run control UI reachability") +struct RunControlUIReachabilityTests { + @Test("approval and steering controls disable while an acknowledgement is pending") + func controlSurfacesReadRunControlInFlight() throws { + let source = try ReachabilitySource.file("ChatView.swift") + let disabledUses = + source.components( + separatedBy: ".disabled(run.runControlInFlight)" + ).count - 1 + + #expect( + disabledUses >= 2, + "Allow and Deny must both disable while one acknowledgement is pending") + #expect( + source.contains("run.draft.trimmed.isEmpty || run.runControlInFlight"), + "the shared Send/Steer control must disable while a run-control request is pending") + } +} + +/// A plain thread-safe boolean, set by a stub handler running on a +/// background (non-Swift-concurrency) thread and observed by `wait { }`'s +/// cooperative polling loop. A blocking `DispatchSemaphore.wait()` on the +/// test side would stall the MainActor executor that the `Task` under test +/// needs in order to run far enough to send the very request this flag +/// waits for -- polling instead (via `Task.sleep` between checks) leaves +/// that executor free to make progress. +private final class Flag: @unchecked Sendable { + private let lock = NSLock() + private var value = false + + func set() { lock.withLock { value = true } } + var isSet: Bool { lock.withLock { value } } +} + +/// A tiny lock-guarded counter -- `RunControlStub`'s handler runs on a +/// background queue owned by the URL loading system, so a plain `var` +/// captured by its `@Sendable` closure is not safe to mutate directly. +private final class Locked: @unchecked Sendable { + private let lock = NSLock() + private var value: Int + + init(_ value: Int) { self.value = value } + + func increment() -> Int { + lock.withLock { + value += 1 + return value + } + } +} diff --git a/macapp/Tests/GoCodeUITests/RunSessionConversationStreamTests.swift b/macapp/Tests/GoCodeUITests/RunSessionConversationStreamTests.swift index f7f1fbd5..e3fc616e 100644 --- a/macapp/Tests/GoCodeUITests/RunSessionConversationStreamTests.swift +++ b/macapp/Tests/GoCodeUITests/RunSessionConversationStreamTests.swift @@ -131,6 +131,35 @@ struct RunSessionConversationStreamTests { session.reset() } + @Test("an external active event makes lifecycle guards busy without adopting run controls") + func externalRunActivityGuardsLifecycleWithoutCrossingIssue1007Boundary() async throws { + ConversationStreamStub.reset() + let frames = """ + id: run_external:0 + event: run.started + data: {"id":"run_external:0","run_id":"run_external","type":"run.started","payload":{}} + + + """ + ConversationStreamStub.queue( + "/v1/conversations/conv_external/events", + [ + .init( + status: 200, headers: ["Content-Type": "text/event-stream"], + chunks: [Data(frames.utf8)]) + ]) + + let session = makeSession() + session.load(messages: [], conversationID: "conv_external") + + try await wait { session.isBusy } + #expect( + session.currentRunID == nil, + "external run-control identity remains owned by #1007, outside PR #1021") + + session.reset() + } + /// Regression for requirement 4: without the dedup in `apply(_:runID:)`, /// a run this app *did* start renders twice, because submit()'s per-run /// stream and the conversation-wide stream both observe the same events diff --git a/macapp/Tests/GoCodeUITests/TranscriptFeatureReachabilityTests.swift b/macapp/Tests/GoCodeUITests/TranscriptFeatureReachabilityTests.swift index d5e0c0e7..8bee6be1 100644 --- a/macapp/Tests/GoCodeUITests/TranscriptFeatureReachabilityTests.swift +++ b/macapp/Tests/GoCodeUITests/TranscriptFeatureReachabilityTests.swift @@ -8,16 +8,7 @@ struct TranscriptFeatureReachabilityTests { @Test("usage and whole-conversation copy retain production call sites") func featuresHaveProductionCallSites() throws { - let sourceDirectory = URL(filePath: #filePath) - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - .appending(path: "Sources/GoCodeUI") - let source = try FileManager.default - .contentsOfDirectory(at: sourceDirectory, includingPropertiesForKeys: nil) - .filter { $0.pathExtension == "swift" } - .map { try String(contentsOf: $0, encoding: .utf8) } - .joined(separator: "\n") + let source = try ReachabilitySource.wholeModule() #expect(source.contains("UsageLabel(usage: usage)")) #expect(source.contains("TranscriptText.plain(items)")) @@ -30,16 +21,7 @@ struct TranscriptFeatureReachabilityTests { /// the thing worth catching — a treatment being dropped entirely. @Test("rail selection and user prompts retain their semantic layout tokens") func transcriptAndRailUseSemanticTokens() throws { - let sourceDirectory = URL(filePath: #filePath) - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - .appending(path: "Sources/GoCodeUI") - let source = try FileManager.default - .contentsOfDirectory(at: sourceDirectory, includingPropertiesForKeys: nil) - .filter { $0.pathExtension == "swift" } - .map { try String(contentsOf: $0, encoding: .utf8) } - .joined(separator: "\n") + let source = try ReachabilitySource.wholeModule() #expect(source.contains("Theme.selectedRowSurface")) #expect(source.contains("Theme.selectedRowForeground")) @@ -55,13 +37,7 @@ struct TranscriptFeatureReachabilityTests { /// wired to the scale, which is the part that silently broke. @Test("transcript prose is bound to the shared type scale") func transcriptConsumesTypeScale() throws { - let chatView = try String( - contentsOf: URL(filePath: #filePath) - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - .appending(path: "Sources/GoCodeUI/ChatView.swift"), - encoding: .utf8) + let chatView = try ReachabilitySource.file("ChatView.swift") #expect(chatView.contains(".font(Typography.body)")) #expect(chatView.contains(".lineSpacing(Typography.bodyLineSpacing)")) @@ -72,4 +48,180 @@ struct TranscriptFeatureReachabilityTests { #expect(Typography.bodyLineSpacing == Typography.bodyLinePitch - Typography.bodyLineHeight) #expect(Typography.bodyLinePitch == 26.5) } + + /// #992's finding was a pure model with no call site: `pinnedToBottom` was + /// declared and read but never mutated by scroll geometry. This pins the + /// wiring, not just the value type's own tests. + @Test("transcript autoscroll is actually wired to a live scroll pin") + func autoscrollPinIsWiredToScrollGeometry() throws { + let chatView = try ReachabilitySource.file("ChatView.swift") + + #expect(chatView.contains("pin.update(distanceFromBottom:")) + #expect(chatView.contains("guard pin.isPinned")) + } + + @Test("conversation identity resets transcript pin and pending autoscroll state") + func transcriptPinIdentityFollowsConversation() throws { + let chatView = try ReachabilitySource.file("ChatView.swift") + + #expect(chatView.contains(".id(run.conversationID)")) + } + + /// Distinct from the wiring test above: that one only proves the *consumer* + /// side (`pin.update`/`guard pin.isPinned`) is present, which a stray + /// hardcoded distance would still satisfy textually. This proves the + /// *feed* is real scroll geometry — a named coordinate space plus a + /// preference key carrying the anchor's frame — so a change that keeps + /// the call site but rips out the geometry plumbing underneath it (e.g. + /// replacing the fed value with a constant) is still caught. + @Test("the scroll pin is fed by a real geometry preference, not a placeholder value") + func autoscrollPinIsFedByLiveGeometry() throws { + let chatView = try ReachabilitySource.file("ChatView.swift") + + #expect(chatView.contains(".coordinateSpace(name: scrollSpace)")) + #expect(chatView.contains("TranscriptBottomAnchorKey")) + #expect(chatView.contains(".onPreferenceChange(TranscriptBottomAnchorKey.self)")) + } + + /// #994's finding (R4) was that `AskUserView`'s `Send` button used + /// `answers.count < prompt.questions.count`, which counts a field typed + /// into and then cleared back to `""` as answered. `AskUserAnswersTests` + /// pins the predicate's own logic but does not prove the view still + /// calls it -- a revert that reintroduces the count comparison while + /// leaving `AskUserAnswers.swift` itself untouched (and passing) would + /// slip through that suite alone. This pins the call site. + @Test("AskUserView's Send button is gated by the shared completeness predicate") + func askUserViewUsesSharedCompletenessPredicate() throws { + let chatView = try ReachabilitySource.file("ChatView.swift") + + #expect(chatView.contains("AskUserAnswers.isComplete(prompt: prompt, answers: answers)")) + #expect(!chatView.contains("answers.count < prompt.questions.count")) + } + + /// Exercises the fix for #995 (F1a): `RunSession.answer()` gained an + /// `answerInFlight` guard so a second call while the first is still + /// awaiting the server is a no-op (`RunControlAckTests` proves that at + /// the model level). The composer's own Send button must reflect the + /// same in-flight state, or an impatient double-click still reads as + /// "nothing happened" instead of "still sending". + @Test("AskUserView's Send button is disabled while an answer is in flight") + func askUserViewSendDisabledWhileAnswerInFlight() throws { + let chatView = try ReachabilitySource.file("ChatView.swift") + + // Two substrings rather than one exact multi-line `.disabled(...)` + // string: swift-format may re-wrap the expression across lines, and + // this must keep matching either shape. + #expect(chatView.contains("let answerInFlight: Bool")) + #expect( + chatView.contains( + "!AskUserAnswers.isComplete(prompt: prompt, answers: answers)\n")) + #expect(chatView.contains("|| answerInFlight)")) + } + + @Test("AskUserView identity follows the pending call so prior answers cannot carry over") + func askUserViewIdentityFollowsCallID() throws { + let chatView = try ReachabilitySource.file("ChatView.swift") + + #expect(chatView.contains(".id(prompt.callID)")) + } + + @Test("reserved startup refreshes validate ownership before setting loading state") + func reservedRefreshesGuardBeforeLoading() throws { + let source = try ReachabilitySource.file("ProjectSession.swift") + let catalogStart = try #require(source.range(of: "private func refreshCatalog(")) + let conversationsPublic = try #require( + source.range( + of: "public func refreshConversations()", + range: catalogStart.upperBound.. 0 else {"), + "pin.update must be skipped both mid-animation and before the viewport reports a real height" + ) + #expect( + chatView.contains("autoscrollCompletionTask?.cancel()"), + "a newer scroll must cancel the prior completion task") + #expect( + chatView.contains("autoscroll.finish(generation: generation)"), + "only the matching generation may end geometry suppression") + + #expect( + chatView.contains("accessibilityReduceMotion"), + "programmatic transcript scrolling must honor Reduce Motion") + #expect( + chatView.contains("Button(\"Jump to Latest\")"), + "an unpinned transcript with unseen content needs a keyboard and VoiceOver-operable way to re-follow" + ) + #expect( + chatView.contains("pin.followLatest()"), + "Jump to Latest must restore pin semantics before scrolling") + #expect( + chatView.contains(".onDisappear"), + "the owned autoscroll completion task must be cancelled with its view") + + let pin = try ReachabilitySource.file("TranscriptScrollPin.swift") + #expect( + !pin.contains("isAutoScrolling"), + "TranscriptScrollPin must stay a pure decision with no view-layer animation state") + } } diff --git a/macapp/Tests/GoCodeUITests/TranscriptScrollPinTests.swift b/macapp/Tests/GoCodeUITests/TranscriptScrollPinTests.swift new file mode 100644 index 00000000..b66b8ab6 --- /dev/null +++ b/macapp/Tests/GoCodeUITests/TranscriptScrollPinTests.swift @@ -0,0 +1,82 @@ +import Foundation +import Testing + +@testable import GoCodeUI + +@Suite("Transcript scroll pin") +struct TranscriptScrollPinTests { + + @Test("a stale autoscroll completion cannot expose geometry during a newer scroll") + func staleAutoscrollCompletionDoesNotEndNewerSuppression() { + var state = TranscriptAutoscrollState() + let firstGeneration = state.begin(animated: true) + let secondGeneration = state.begin(animated: true) + + state.finish(generation: firstGeneration) + #expect(state.suppressesGeometryUpdates) + + state.finish(generation: secondGeneration) + #expect(!state.suppressesGeometryUpdates) + } + + @Test("a Reduce Motion scroll does not suppress geometry updates") + func reduceMotionAutoscrollDoesNotSuppressGeometry() { + var state = TranscriptAutoscrollState() + _ = state.begin(animated: false) + + #expect(!state.suppressesGeometryUpdates) + } + + @Test("a fresh pin starts pinned to the bottom") + func startsPinned() { + let pin = TranscriptScrollPin() + #expect(pin.isPinned) + } + + @Test("staying at distance 0 keeps the pin pinned") + func staysPinnedAtBottom() { + var pin = TranscriptScrollPin() + pin.update(distanceFromBottom: 0) + #expect(pin.isPinned) + } + + @Test("scrolling past the threshold unpins autoscroll") + func unpinsPastThreshold() { + var pin = TranscriptScrollPin() + pin.update(distanceFromBottom: Layout.autoscrollPinThreshold + 1) + #expect(!pin.isPinned) + } + + @Test("distance exactly at the threshold is still pinned") + func boundaryIsInclusive() { + var pin = TranscriptScrollPin() + pin.update(distanceFromBottom: Layout.autoscrollPinThreshold) + #expect(pin.isPinned) + } + + @Test("scrolling back to the bottom re-pins autoscroll") + func rePinsOnReturnToBottom() { + var pin = TranscriptScrollPin() + pin.update(distanceFromBottom: Layout.autoscrollPinThreshold + 1) + #expect(!pin.isPinned) + pin.update(distanceFromBottom: 0) + #expect(pin.isPinned) + } + + @Test("overscroll bounce reports a negative distance and stays pinned") + func negativeDistanceStaysPinned() { + var pin = TranscriptScrollPin() + pin.update(distanceFromBottom: -12) + #expect(pin.isPinned) + } + + @Test("following latest re-pins after the operator has scrolled away") + func followingLatestRePins() { + var pin = TranscriptScrollPin() + pin.update(distanceFromBottom: Layout.autoscrollPinThreshold + 1) + #expect(!pin.isPinned) + + pin.followLatest() + #expect(pin.isPinned) + } +} diff --git a/macapp/Tests/HarnessKitTests/TranscriptTests.swift b/macapp/Tests/HarnessKitTests/TranscriptTests.swift index 6058dcfa..8c18c3b4 100644 --- a/macapp/Tests/HarnessKitTests/TranscriptTests.swift +++ b/macapp/Tests/HarnessKitTests/TranscriptTests.swift @@ -23,6 +23,8 @@ struct TranscriptTests { func buildsFromGoldenStream() throws { var transcript = Transcript() transcript.appendUserPrompt("list the workspace") + transcript.bindAccountingRun( + "run_8cacf024-8246-4c1c-8d53-7764b878d664") try replayGolden(into: &transcript) #expect(transcript.runState == .completed) @@ -247,6 +249,253 @@ struct TranscriptTests { #expect(transcript.usage.completionTokens == 30) #expect(transcript.usage.costUSD == 0.0025) #expect(transcript.usage.costIsKnown) + + } + + @Test("run.completed reconciles authoritative usage when duplicate streams arrive out of order") + func terminalEventReconcilesUsage() { + var transcript = Transcript() + transcript.apply( + event( + .runCompleted, + [ + "usage_totals": [ + "prompt_tokens_total": 260, + "completion_tokens_total": 22, + "total_tokens": 282, + ], + "cost_totals": [ + "cost_usd_total": 0.0025, + "cost_status": "available", + ], + ])) + + #expect(transcript.runState == .completed) + #expect(transcript.usage.promptTokens == 260) + #expect(transcript.usage.completionTokens == 22) + #expect(transcript.usage.totalTokens == 282) + #expect(transcript.usage.costUSD == 0.0025) + #expect(transcript.usage.costIsKnown) + + // Conversation replay rebuilds durable rows immediately after the + // terminal event. That reconciliation must retain sealed accounting. + transcript.reconcile(messages: []) + #expect(transcript.usage.promptTokens == 260) + #expect(transcript.usage.completionTokens == 22) + #expect(transcript.usage.totalTokens == 282) + #expect(transcript.usage.costUSD == 0.0025) + #expect(transcript.usage.costIsKnown) + + // A slower duplicate stream can still deliver an earlier cumulative + // usage event after the terminal event. Totals must not move backward. + transcript.apply( + event( + .usageDelta, + [ + "cumulative_usage": [ + "prompt_tokens": 120, + "completion_tokens": 10, + "total_tokens": 130, + ], + "cumulative_cost_usd": 0, + "cost_status": "unpriced_model", + ])) + #expect(transcript.usage.promptTokens == 260) + #expect(transcript.usage.completionTokens == 22) + #expect(transcript.usage.totalTokens == 282) + #expect(transcript.usage.costUSD == 0.0025) + #expect(transcript.usage.costIsKnown) + } + + @Test("sealed terminal cost status overrides an earlier available stream status") + func terminalCostStatusIsAuthoritative() { + var transcript = Transcript() + transcript.apply( + event( + .usageDelta, + [ + "cumulative_usage": ["total_tokens": 120], + "cumulative_cost_usd": 0.02, + "cost_status": "available", + ])) + #expect(transcript.usage.costIsKnown) + + // The terminal payload is sealed accounting. A provider can emit an + // optimistic available delta before its final accounting establishes + // that the model was not actually priced. + transcript.apply( + event( + .runCompleted, + [ + "usage_totals": ["total_tokens": 140], + "cost_totals": [ + "cost_usd_total": 0, + "cost_status": "provider_unreported", + ], + ])) + #expect(transcript.usage.totalTokens == 140) + #expect(transcript.usage.costUSD == 0.02) + #expect(transcript.usage.costStatus == "provider_unreported") + #expect(!transcript.usage.costIsKnown) + + // A duplicate conversation stream can still arrive after the sealed + // terminal event. Its cumulative numbers remain monotonic, but its + // older available status cannot re-open the terminal decision. + transcript.apply( + event( + .usageDelta, + [ + "cumulative_usage": ["total_tokens": 130], + "cumulative_cost_usd": 0.03, + "cost_status": "available", + ])) + #expect(transcript.usage.totalTokens == 140) + #expect(transcript.usage.costUSD == 0.03) + #expect(transcript.usage.costStatus == "provider_unreported") + #expect(!transcript.usage.costIsKnown) + } + + @Test("a new run resets accounting and ignores late totals from the prior run") + func accountingIsScopedToRunIdentity() { + var transcript = Transcript() + transcript.apply(event(.runStarted, [:], runID: "run_old")) + transcript.apply( + event( + .usageDelta, + [ + "cumulative_usage": [ + "prompt_tokens": 400, + "completion_tokens": 100, + "total_tokens": 500, + ], + "cumulative_cost_usd": 0.5, + "cost_status": "available", + ], + runID: "run_old")) + + transcript.appendUserPrompt("start a cheaper follow-up") + #expect(transcript.usage == UsageTotals()) + + transcript.bindAccountingRun("run_new") + transcript.apply(event(.runStarted, [:], runID: "run_new")) + transcript.apply( + event( + .usageDelta, + [ + "cumulative_usage": [ + "prompt_tokens": 40, + "completion_tokens": 10, + "total_tokens": 50, + ], + "cumulative_cost_usd": 0, + "cost_status": "unpriced_model", + ], + runID: "run_new")) + + // A slower duplicate stream can still finish delivering the previous + // run after the new run has become authoritative. + transcript.apply( + event( + .usageDelta, + [ + "cumulative_usage": ["total_tokens": 700], + "cumulative_cost_usd": 0.7, + "cost_status": "available", + ], + runID: "run_old")) + transcript.apply( + event( + .runCompleted, + [ + "usage_totals": ["total_tokens": 900], + "cost_totals": [ + "cost_usd_total": 0.9, + "cost_status": "available", + ], + ], + runID: "run_old")) + + #expect(transcript.usage.promptTokens == 40) + #expect(transcript.usage.completionTokens == 10) + #expect(transcript.usage.totalTokens == 50) + #expect(transcript.usage.costUSD == 0) + #expect(!transcript.usage.costIsKnown) + #expect(transcript.runState == .running) + } + + @Test("an unrelated conversation event cannot claim a submitted run's accounting") + func submittedRunReservesAccountingOwnership() { + var transcript = Transcript() + transcript.apply(event(.runStarted, [:], runID: "run_old")) + transcript.apply( + event( + .usageDelta, + [ + "cumulative_usage": ["total_tokens": 200], + "cumulative_cost_usd": 0.02, + "cost_status": "available", + ], + runID: "run_old")) + + transcript.appendUserPrompt("new submitted run") + transcript.apply( + event( + .backgroundJobCompleted, + ["command": "old callback", "output": "done"], + runID: "run_unrelated")) + transcript.bindAccountingRun("run_submitted") + transcript.apply(event(.runStarted, [:], runID: "run_submitted")) + transcript.apply( + event( + .usageDelta, + [ + "cumulative_usage": ["total_tokens": 40], + "cumulative_cost_usd": 0, + "cost_status": "unpriced_model", + ], + runID: "run_submitted")) + + #expect(transcript.runState == .running) + #expect(transcript.usage.totalTokens == 40) + #expect(transcript.usage.costUSD == 0) + #expect(!transcript.usage.costIsKnown) + } + + @Test("failed and cancelled runs consume their sealed terminal accounting") + func everyTerminalEventReconcilesUsage() { + for (type, expectedState) in [ + (HarnessEventType.runFailed, RunState.failed), + (.runCancelled, .cancelled), + ] { + var transcript = Transcript() + transcript.apply( + event( + type, + [ + "usage_totals": [ + "prompt_tokens_total": 80, + "completion_tokens_total": 20, + "total_tokens": 100, + ], + "cost_totals": [ + "cost_usd_total": 0.01, + "cost_status": "available", + ], + ])) + + #expect(transcript.runState == expectedState) + #expect(transcript.usage.promptTokens == 80) + #expect(transcript.usage.completionTokens == 20) + #expect(transcript.usage.totalTokens == 100) + #expect(transcript.usage.costUSD == 0.01) + #expect(transcript.usage.costIsKnown) + + transcript.reconcile(messages: []) + #expect(transcript.runState == expectedState) + #expect(transcript.usage.totalTokens == 100) + #expect(transcript.usage.costUSD == 0.01) + #expect(transcript.usage.costIsKnown) + } } /// The golden run's first turn is unpriced and its second is priced, so @@ -283,14 +532,16 @@ struct TranscriptTests { } /// Builds a synthetic event for reducer tests from a plain JSON payload. -private func event(_ type: HarnessEventType, _ payload: [String: Any]) -> HarnessEvent { +private func event( + _ type: HarnessEventType, _ payload: [String: Any], runID: String = "run_t" +) -> HarnessEvent { let envelope: [String: Any] = [ - "id": "run_t:0", "run_id": "run_t", "type": type.rawValue, "payload": payload, + "id": "\(runID):0", "run_id": runID, "type": type.rawValue, "payload": payload, ] let data = try! JSONSerialization.data(withJSONObject: envelope) return try! HarnessEvent( frame: SSEFrame( - id: "run_t:0", event: type.rawValue, data: String(decoding: data, as: UTF8.self))) + id: "\(runID):0", event: type.rawValue, data: String(decoding: data, as: UTF8.self))) } extension TranscriptTests {