diff --git a/.superpowers/sdd/2026-08-12-adaptive-reasoning-cursor-mode/task-12-report.md b/.superpowers/sdd/2026-08-12-adaptive-reasoning-cursor-mode/task-12-report.md new file mode 100644 index 0000000..7d8b837 --- /dev/null +++ b/.superpowers/sdd/2026-08-12-adaptive-reasoning-cursor-mode/task-12-report.md @@ -0,0 +1,1133 @@ +# Task 12 Report: Direct Cursor SSE Coordinator and Recovery + +Date: 2026-08-13 + +## Status + +Implemented and verified. The direct Cursor path now uses the existing +`cursorrun.Runner`, instance-owned `agent.Agent` approval gate, durable +`CursorSessionState` CAS operations, exact model validation, strict attachment +decoding, and idempotent assistant commit. It does not introduce another Cursor +client, approval gate, or persistence model. + +## Implementation + +### Request preparation and approval + +- Added `POST /api/chat/cursor`. +- Requires dashboard-password authentication before the route-specific + 105 MiB body decoder or image allocation. +- Strictly validates the requested model and exact parameter selection, mode, + images, project directory, normalized GitHub repository, starting ref, and + auto-PR compatibility. +- Preserves the distinction between auto-discovery and explicit no-repository. +- Deep-copies model params and images into a private turn plan. +- Computes agent reuse only when `ReuseValid`, `TargetActive`, `AgentID`, model, + exact params, repository identity, starting ref, and auto-PR all match. Mode + is intentionally excluded from reuse identity. +- Atomically reserves one live run before publishing approval. The durable CAS + remains the arbiter across server revisions/processes. +- Creates or hydrates the Antares session without changing its active Antares + provider, appends the user message, and records `awaiting_approval`. +- Publishes `EventSession` before the approval request. +- Uses a precomputed immutable approval projection containing operation kind, + exact model params, repository/ref/source, mode, auto-PR, a credential-redacted + UTF-8-safe 240-rune prompt preview, and image count. +- Added `Agent.AwaitOperationApproval`, a narrow public adapter over the + existing instance-owned gate. `deny` refuses immediately; `auto`, `prompt`, + and unknown-safe modes require a human decision through the existing + pending/resolve API. + +### Approved execution and streaming + +- CASes to `create_in_flight` or `run_in_flight` before any mutating Cursor POST. +- Calls `CreateAgent` for a changed identity and `CreateRun` for an eligible + follow-up. +- Persists returned agent/run IDs before opening Cursor SSE. +- Treats context/transport uncertainty, HTTP 408, and 5xx create outcomes + without IDs as ambiguous and never auto-retries them. +- Persists Last-Event-ID, remote status, partial reasoning, and partial answer + before publishing each corresponding live event. +- Treats result text as a canonical whole value, not an appended delta. +- Keeps remote tool activity live-only. +- Bounds and redacts upstream status, text, reasoning, progress, errors, and Git + fields before publishing or persistence. Prompt image bytes are never stored + or emitted. +- Handles stream reset by durably clearing Last-Event-ID and both accumulators, + then clearing/replaying the in-memory presentation. + +### Finalization and recovery + +- Reconciles the terminal snapshot's whole result, reasoning, status, and + bounded Git state. +- Uses `CommitCursorAssistant` to atomically append one assistant message and + mark the matching state committed. +- Reuses the winning terminal revision when finalizers race, so the store's + transaction remains the exactly-once arbiter. +- Treats durable `terminal` state as unfinished for new turns/history edits + until the assistant commit is complete. +- `GET /api/chat/attach` first replays the in-memory log at the browser cursor. +- If memory is absent, one watcher is reserved, a reset plus durable partials + are replayed from cursor zero, and the stream resumes with persisted + Last-Event-ID. +- A stale cursor from the lost process is never applied to the fresh recovery + log. +- Recovery fetches/finalizes terminal snapshots without opening SSE, resumes + active runs, marks create-without-IDs ambiguous, and never recreates a lost + approval after restart. +- No recoverable state retains ordinary attach behavior and emits `done`. + +### Interrupt, cancellation, and history mutation + +- HTTP disconnect only detaches that follower. +- `/api/chat/interrupt` invokes the local watcher cancel hook for direct Cursor + runs and never calls `CancelRun`; the durable run remains recoverable. +- Added `POST /api/chat/cursor/cancel`. +- Cancellation has its own immutable approval operation and shared gate. +- A per-run in-memory reservation serializes approval through the durable + pre-POST marker. +- CASes `ANTARES_CANCEL_IN_FLIGHT` before `CancelRun`; success records + `ANTARES_CANCEL_REQUESTED`, and uncertain failure records an ambiguous cancel + outcome. Repeated calls cannot issue a second cancellation POST. +- Single and category/bulk deletion inspect every selected session before any + mutation and return 409 for active remote state. Deletion is allowed after an + approved cancellation request or terminal completion and stops only the + local watcher. +- Message edit/retry history mutation atomically rejects unfinished Cursor + state and invalidates reuse before rollback/deletion. +- Ordinary chat and direct Cursor turns now share the per-session reservation, + preventing a cross-mode race; ordinary chat invalidates the Cursor target + before running. + +## Files + +New: + +- `internal/server/handlers_cursor.go` +- `internal/server/cursor_events.go` +- `internal/server/handlers_cursor_test.go` +- `.superpowers/sdd/2026-08-12-adaptive-reasoning-cursor-mode/task-12-report.md` + +Modified: + +- `internal/agent/approval.go` +- `internal/agent/approval_test.go` +- `internal/server/handlers_chat.go` +- `internal/server/livechat.go` +- `internal/server/routes.go` +- `internal/server/server.go` + +Explicitly excluded: + +- `web/tsconfig.tsbuildinfo` +- pre-existing untracked controller plan/design documents + +## TDD Evidence + +### Baseline + +The affected server/store/runner/approval/agent packages passed before Task 12 +changes. + +### RED + +The initial coordinator matrix was introduced before the production handlers. + +Command: + +```text +go test ./internal/server -run 'TestCursorChat' -count=1 -v +``` + +Observed result: build failed because the new `cursorChatRequest`, +`handleCursorChat`, and `handleCursorCancel` production surface did not exist. + +The shared public approval adapter was also introduced test-first. + +Command: + +```text +go test ./internal/agent -run TestAwaitOperationApprovalSharesExplicitCursorPolicy -count=1 -v +``` + +Observed result: + +```text +a.AwaitOperationApproval undefined +FAIL +``` + +Focused RED iterations then exposed and drove these lifecycle fixes: + +```text +TestCursorChatReservationRejectsConcurrentTurnBeforeApproval: +status=400, want 409 + +TestCursorChatApprovedCancelCallsUpstreamExactlyOnce: +delete after approved cancellation status=409, want 200 + +TestCursorChatApprovalIsBoundedRedactedAndImmutable: +session event leaked prompt credential in title + +TestCursorChatAttachReplaysPersistedPartialsBeforeResuming: +persisted partials were not replayed before resumed events +``` + +The final restart-cursor regression was also demonstrated independently. + +Command: + +```text +go test ./internal/server -run TestCursorChatRecoveryIgnoresCursorFromLostInMemoryRun -count=1 -v +``` + +Output: + +```text +=== RUN TestCursorChatRecoveryIgnoresCursorFromLostInMemoryRun + handlers_cursor_test.go:1211: SSE stream ended before the next event +--- FAIL: TestCursorChatRecoveryIgnoresCursorFromLostInMemoryRun (0.01s) +FAIL +FAIL github.com/enowdev/antares/internal/server 0.041s +``` + +After resetting the cursor only for the absent-memory recovery path: + +```text +=== RUN TestCursorChatRecoveryIgnoresCursorFromLostInMemoryRun +--- PASS: TestCursorChatRecoveryIgnoresCursorFromLostInMemoryRun (0.01s) +PASS +ok github.com/enowdev/antares/internal/server 0.038s +``` + +### GREEN + +Brief-specified focused matrix: + +```text +$ go test ./internal/server -run 'TestCursorChat|TestChatAttach' -count=1 +ok github.com/enowdev/antares/internal/server 0.332s +``` + +Fresh affected-package suite, with all opt-in live credentials removed: + +```text +$ env -u CURSOR_API_KEY -u OPENAI_API_KEY -u AZURE_OPENAI_ENDPOINT \ + -u AZURE_OPENAI_KEY -u AZURE_OPENAI_DEPLOYMENT \ + -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY -u AWS_SESSION_TOKEN \ + -u GOOGLE_APPLICATION_CREDENTIALS -u VERTEX_SA_JSON \ + -u COPILOT_GITHUB_TOKEN \ + go test ./internal/server ./internal/store ./internal/cursorrun \ + ./internal/approval ./internal/agent -count=1 +ok github.com/enowdev/antares/internal/server 2.037s +ok github.com/enowdev/antares/internal/store 0.622s +ok github.com/enowdev/antares/internal/cursorrun 1.112s +ok github.com/enowdev/antares/internal/approval 0.030s +ok github.com/enowdev/antares/internal/agent 0.351s +``` + +Full hermetic repository suite: + +```text +$ env -u CURSOR_API_KEY -u OPENAI_API_KEY -u AZURE_OPENAI_ENDPOINT \ + -u AZURE_OPENAI_KEY -u AZURE_OPENAI_DEPLOYMENT \ + -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY -u AWS_SESSION_TOKEN \ + -u GOOGLE_APPLICATION_CREDENTIALS -u VERTEX_SA_JSON \ + -u COPILOT_GITHUB_TOKEN go test ./... +... +ok github.com/enowdev/antares/internal/server 2.117s +... +``` + +All repository packages passed. Packages without tests reported +`[no test files]`. + +Fresh affected-package race suite: + +```text +$ env -u CURSOR_API_KEY -u OPENAI_API_KEY -u AZURE_OPENAI_ENDPOINT \ + -u AZURE_OPENAI_KEY -u AZURE_OPENAI_DEPLOYMENT \ + -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY -u AWS_SESSION_TOKEN \ + -u GOOGLE_APPLICATION_CREDENTIALS -u VERTEX_SA_JSON \ + -u COPILOT_GITHUB_TOKEN \ + go test -race ./internal/server ./internal/store ./internal/cursorrun \ + ./internal/approval ./internal/agent -count=1 +ok github.com/enowdev/antares/internal/server 14.654s +ok github.com/enowdev/antares/internal/store 6.133s +ok github.com/enowdev/antares/internal/cursorrun 3.427s +ok github.com/enowdev/antares/internal/approval 1.051s +ok github.com/enowdev/antares/internal/agent 2.637s +``` + +One uncached full-suite attempt saw the unrelated +`internal/mcp.TestStdioRoundTrip` exceed its 10-second deadline under parallel +load. It passed immediately in isolation: + +```text +$ go test ./internal/mcp -run TestStdioRoundTrip -count=1 -v +=== RUN TestStdioRoundTrip +--- PASS: TestStdioRoundTrip (0.03s) +PASS +ok github.com/enowdev/antares/internal/mcp 0.055s +``` + +The subsequent complete hermetic suite passed as shown above. + +## Crash-Window and Exactly-Once Review + +1. **Before approval:** only Antares session/message/state writes occur; no + mutating Cursor request is possible. +2. **Approval to POST:** durable state changes to `create_in_flight` or + `run_in_flight` before the POST. +3. **Crash during create without IDs:** restart sees an in-flight state with no + recoverable IDs, marks it ambiguous, and never retries. +4. **Create response before ID persistence:** this remains conservatively + ambiguous after a crash; duplicate paid work is preferred against. +5. **After ID persistence:** recovery uses those exact IDs and Last-Event-ID; + it does not create another agent/run. +6. **Event persistence before publication:** a crash can cause replay, but + cannot expose a live partial that lacks its durable counterpart. +7. **Reset:** durable cursor and accumulators are cleared before in-memory + reset/replay. +8. **Terminal CAS before assistant commit:** `terminal` remains recoverable and + blocks the next turn/history mutation. `CommitCursorAssistant` transactionally + appends and marks committed; competing finalizers reuse the winning revision. +9. **Cancellation:** the durable cancel-in-flight marker precedes `CancelRun`. + A lost response becomes ambiguous/requested and is never submitted twice. +10. **Follower detach/local stop:** neither changes remote state; persisted IDs + retain the recovery path. +11. **Deletion/edit races:** local lifecycle locking and durable CAS ensure a + delete/edit cannot pass its precondition and then race a newly reserved + direct turn. + +## Concerns + +- The first un-sanitized `go test ./...` invocation inherited opt-in live-test + credentials from the environment. It performed read-only Cursor metadata + requests and rejected OpenAI smoke-test requests. It did not invoke Cursor + create, run, or cancel. Every subsequent full/race command explicitly removed + all live-test credential variables. +- Ambiguous create and ambiguous cancellation states intentionally remain + sticky. This prevents duplicate paid/mutating operations but requires future + explicit operator reconciliation rather than automatic retry. +- No remaining Task 12 functional or race-detector failures are known. + +## Fix Round 1 (2026-08-13) + +### Status + +Addressed all nine lifecycle/security review findings with focused +red-green tests. Ambiguous create/cancel outcomes remain non-retryable, but +are now explicitly described and locally deletable without issuing any remote +retry or cancellation. + +### Implementation + +1. Split direct-run local control into explicit approval, create, and watch + phases. Stop cancels approval before the POST boundary, records detachment + without cancelling a non-idempotent create POST, and installs/cancels the + watcher only after returned IDs are durable. +2. Restored ordinary-chat supersede semantics: under the session lifecycle + lock, ordinary chat rejects unfinished Cursor state, invalidates reuse, and + replaces the hub entry with `put`. +3. Classified cancellation outcomes: + - context/transport uncertainty, API status zero, HTTP 408, and 5xx become + `ANTARES_CANCEL_OUTCOME_AMBIGUOUS`; + - definitive 4xx restores the pre-POST status and permits a new approved + attempt; + - 404 records `ANTARES_CANCEL_NO_ACTIVE_RUN`, invalidates reuse, and returns + success; + - a durable cancel-in-flight marker without a matching process-local + reservation is reconciled to cancel-ambiguous and never re-submitted. +4. Marked live logs as ordinary, direct Cursor, or Cursor recovery. Attach now + requires dashboard protection before selecting any credential-using direct + or recovery path and before any runner call; ordinary live/done attach stays + compatible. +5. Replaced the global lifecycle mutex with reference-counted keyed session + locks. Bulk deletion acquires unique IDs in sorted order. Direct/ordinary + reservation, attach recovery reservation, terminal finalization, edit, + single delete, and bulk delete use the same per-session lock. +6. Added immutable approval fields for dirty worktree, local-only commit count, + remote-ref-known, and fixed bounded/redacted warnings. The warnings explain + that local dirty/local-only work is absent from the Cursor cloud VM and that + an unknown remote ref prevents verification. Reuse identity is unchanged. +7. Cursor reset is now derived from both initial absence and the selected live + log kind. A newly selected recovery log, including another recovery winner, + resets to zero; concurrent ordinary/direct logs and existing reconnect logs + preserve the browser cursor. +8. Cancellation's in-memory reservation is session-wide rather than run-ID + specific. +9. Added exported `store.ErrCursorRevisionConflict`; server conflict handling + uses `errors.Is` and preserves the existing safe user-facing behavior. + +Ambiguous create state and cancel-ambiguous state are accepted by both single +and bulk local deletion. New turns/cancel retries explain that deletion is the +operator reconciliation escape. Deletion only removes local data and never +calls `CreateAgent`, `CreateRun`, or `CancelRun`. + +### Files + +New: + +- `internal/server/cursor_lifecycle.go` +- `internal/server/cursor_lifecycle_test.go` +- `internal/server/handlers_cursor_fix_test.go` + +Modified: + +- `internal/server/cursor_events.go` +- `internal/server/handlers_chat.go` +- `internal/server/handlers_cursor.go` +- `internal/server/handlers_cursor_test.go` +- `internal/server/livechat.go` +- `internal/server/server.go` +- `internal/store/cursor_sessions.go` +- `internal/store/cursor_sessions_test.go` +- `internal/store/sql.go` +- `.superpowers/sdd/2026-08-12-adaptive-reasoning-cursor-mode/task-12-report.md` + +Explicitly excluded from staging: + +- `web/tsconfig.tsbuildinfo` +- pre-existing untracked controller plan/design documents + +### Focused TDD evidence + +RED was established before each production change. + +Lifecycle/cancel/projection matrix: + +```text +$ go test ./internal/server -run 'TestCursor(ChatStopDuringCreate|ChatAmbiguousStates|OrdinaryChatSecond|Cancel|ApprovalCarries)' -count=1 -v +TestCursorChatAmbiguousStatesCanBeDeletedLocally: + body={"error":"a turn is already active for this session"}, want local-delete escape +TestCursorCancelDefinitiveFailuresRestoreStatusAndAllowRetry: + HTTP 400 returned 502; other 4xx left ANTARES_CANCEL_OUTCOME_AMBIGUOUS +TestCursorCancelNotFoundReconcilesNoActiveRun: + status=404, want 200 +TestCursorCancelInFlightRecoveryBecomesAmbiguousWithoutResubmission: + durable status remained ANTARES_CANCEL_IN_FLIGHT +TestCursorCancelReservationCoversWholeSession: + different run bypassed the reservation +TestCursorApprovalCarriesRepositoryPreflightWarnings: + dirty=false local-only=0 remote-known=false warnings=[] +TestCursorChatStopDuringCreatePersistsIDsAndDefersWatching: + state became ambiguous with context canceled instead of persisting IDs +FAIL +``` + +Ordinary supersede: + +```text +$ go test ./internal/server -run TestOrdinaryChatSecondTurnSupersedesLiveRun -count=1 -v +second ordinary turn lost supersede compatibility: cursor session is active +FAIL +``` + +Attach, terminal race, and unrelated-session isolation: + +```text +$ go test ./internal/server -run 'TestCursorAttach|TestCursorTerminalRecovery|TestCursorLifecycleSlowSession' -count=1 -v +unprotected Cursor recovery attach status=200, want 428 +unprotected direct live attach status=200, want 428 +delete racing terminal recovery status=200, want 409 +slow session lifecycle blocked an unrelated session +FAIL +``` + +New lock, cursor-reset, and store-sentinel surfaces: + +```text +$ go test ./internal/server -run TestSessionLocker -count=1 +undefined: sessionLocker +FAIL + +$ go test ./internal/server -run TestCursorAttachCursorResetTargetsFreshRecoveryLogOnly -count=1 +undefined: cursorAttachShouldReset +FAIL + +$ go test ./internal/store -run 'TestCursorSessionPutFailuresDoNotMutateCaller/revision_conflict' -count=1 +undefined: ErrCursorRevisionConflict +FAIL +``` + +Additional crash-marker and remote-ref cases: + +```text +TestCursorCancelInFlightMarkerAfterRestartIsLocallyDeletable: + delete status=409, want 200 + +TestCursorApprovalWarnsWhenRemoteRefCannotBeVerified: + remote-ref-known=false warnings=[] + +TestCursorCancelUncertainFailuresRemainAmbiguousAndDeletable/api_transport: + status restored to RUNNING instead of cancel-ambiguous +``` + +Final focused GREEN: + +```text +$ go test ./internal/server -run 'Test(CursorChatStopDuringCreatePersistsIDsAndDefersWatching|CursorChatAmbiguousStatesCanBeDeletedLocally|OrdinaryChatSecondTurnSupersedesLiveRun|CursorCancel|CursorAttach|CursorTerminalRecovery|CursorLifecycleSlowSession|SessionLocker|CursorApproval)' -count=1 && \ + go test ./internal/store -run 'TestCursorSessionPutFailuresDoNotMutateCaller/revision_conflict' -count=1 +ok github.com/enowdev/antares/internal/server 0.550s +ok github.com/enowdev/antares/internal/store 0.018s +``` + +### Full verification + +Fresh hermetic full repository suite: + +```text +$ env -u CURSOR_API_KEY -u OPENAI_API_KEY -u AZURE_OPENAI_ENDPOINT \ + -u AZURE_OPENAI_KEY -u AZURE_OPENAI_DEPLOYMENT \ + -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY -u AWS_SESSION_TOKEN \ + -u GOOGLE_APPLICATION_CREDENTIALS -u VERTEX_SA_JSON \ + -u COPILOT_GITHUB_TOKEN go test ./... -count=1 +... +ok github.com/enowdev/antares/internal/server 3.532s +ok github.com/enowdev/antares/internal/store 1.285s +... +``` + +All repository packages passed. + +Fresh affected-package race suite: + +```text +$ env -u CURSOR_API_KEY -u OPENAI_API_KEY -u AZURE_OPENAI_ENDPOINT \ + -u AZURE_OPENAI_KEY -u AZURE_OPENAI_DEPLOYMENT \ + -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY -u AWS_SESSION_TOKEN \ + -u GOOGLE_APPLICATION_CREDENTIALS -u VERTEX_SA_JSON \ + -u COPILOT_GITHUB_TOKEN \ + go test -race ./internal/server ./internal/store ./internal/cursorrun \ + ./internal/approval ./internal/agent -count=1 +ok github.com/enowdev/antares/internal/server 16.533s +ok github.com/enowdev/antares/internal/store 5.405s +ok github.com/enowdev/antares/internal/cursorrun 3.367s +ok github.com/enowdev/antares/internal/approval 1.049s +ok github.com/enowdev/antares/internal/agent 2.624s +``` + +### Crash-window and exactly-once self-review + +1. Stop before the POST boundary cancels approval or rolls the durable + in-flight marker back to idle without a remote mutation. +2. Once create enters its non-idempotent phase, its context is independent of + local Stop. Returned IDs are CAS-persisted before detachment can skip or + cancel a watcher. +3. A process crash or true transport uncertainty before IDs remains + non-retryable. Local delete is the explicit escape and performs no remote + operation. +4. The cancel marker is durable before `CancelRun`. A locally reserved request + is distinguished from a marker recovered after restart; only the latter is + converted to ambiguous. No ambiguous request is automatically submitted. +5. Definitive cancel rejection restores the exact status captured immediately + before the marker. If that restore itself cannot commit, the marker remains + conservative and becomes ambiguous rather than permitting a duplicate. +6. Per-session locking orders recovery reservation, terminal commit, history + mutation, and deletion. A terminal recovery live log blocks deletion/edit + until finalization; unrelated sessions do not share a lock. +7. Bulk deletion acquires sorted unique session IDs before checking any state, + retaining all-or-nothing precondition checks without deadlock. +8. Approval warnings are generated from booleans/counts, not raw Git output, + then redacted and capped at four entries of 240 runes. Prompt/repository reuse + identity is unchanged. +9. Recovery auth is checked before `GetRun` or `StreamRun`. Ordinary live/done + attach does not enter the protected path. + +### Concerns + +- The intentionally deferred full-accumulator O(n²) persistence concern remains + outside Fix Round 1. +- A true create/cancel transport ambiguity cannot establish remote truth. It is + deliberately never retried; operators may delete only the local session and + reconcile any remote Cursor work separately. +- No live API calls or credentials were used. No remaining functional or + race-detector failures are known. + +## Fix Round 2 (2026-08-13) + +### Status + +Closed the recovery-log cursor reset gap and protected the two remaining +session cleanup escape paths. Ambiguous cancellation failures now return one +bounded non-retryable response, and the deletion predicate no longer performs +durable reconciliation as a side effect. + +### Implementation + +1. Every attach whose selected live log is `liveRunCursorRecovery` starts at + cursor zero. This applies to sequential followers and followers of an + already-reserved concurrent recovery winner. Ordinary and direct live logs + continue from the caller cursor. +2. `handleDeleteEmpty` and `handlePruneSessions` now enumerate all candidate + sessions in 500-entry pages, acquire the sorted keyed session locks, and + precheck every candidate before any cleanup mutation. Existing active remote + state returns HTTP 409. +3. Store cleanup SQL now excludes Cursor states in `awaiting_approval`, + `create_in_flight`, and ordinary `run_in_flight` states at mutation time. + This closes the enumeration-to-delete race with foreign keys both enabled + and disabled. +4. Explicitly deletable states remain deletable: create-ambiguous, + cancel-requested, cancel-ambiguous, and stale cancel-in-flight markers. + Counts remain the number of sessions actually deleted. +5. `cursorSessionHasActiveRemoteState` is now read-only. A process-local cancel + reservation makes a current cancel-in-flight marker active; the same durable + marker without that reservation is a stale local-reconciliation candidate. + Durable crash-marker reconciliation remains in direct-turn preparation, + explicit cancellation, and recovery. +6. Context/transport uncertainty, HTTP 408, and 5xx cancellation failures now + always return HTTP 502 with a fixed bounded message stating that the outcome + is ambiguous and will not be retried. No upstream error body or retry hint is + forwarded. Definitive 4xx and 404 behavior is unchanged. +7. Removed the write-only `cursorLivePhase` type and `phase` field. The live + state machine is represented only by `done`, `detached`, and the currently + installed local stop function. + +CAS backoff and full-accumulator persistence remain deferred. + +### Files + +New: + +- `internal/server/handlers_cursor_cleanup_test.go` + +Modified: + +- `internal/server/cursor_events.go` +- `internal/server/handlers_chat.go` +- `internal/server/handlers_cursor_fix_test.go` +- `internal/server/handlers_cursor_test.go` +- `internal/server/livechat.go` +- `internal/store/cursor_sessions_test.go` +- `internal/store/sessions.go` +- `.superpowers/sdd/2026-08-12-adaptive-reasoning-cursor-mode/task-12-report.md` + +Explicitly excluded: + +- `web/tsconfig.tsbuildinfo` +- pre-existing untracked controller plan/design documents + +### Focused TDD evidence + +Recovery-log reset RED: + +```text +$ go test ./internal/server -run 'TestCursorAttach(EverySequentialRecoveryFollowerStartsAtZero|ConcurrentFollowersResetToRecoveryWinner|CursorResetTargetsFreshRecoveryLogOnly)' -count=1 -v +existing_recovery_reconnect: cursor reset=false, want true +TestCursorAttachEverySequentialRecoveryFollowerStartsAtZero: + SSE stream ended before the next event +TestCursorAttachConcurrentFollowersResetToRecoveryWinner: + SSE stream ended before the next event +FAIL +``` + +Cleanup/predicate RED: + +```text +$ go test ./internal/server -run 'TestCursor(CleanupHandlersPrecheckActiveStateAcrossPagination|ActiveRemotePredicateIsPureForCancelCrashMarker)' -count=1 -v +empty cleanup status=200, want 409 +prune cleanup status=200, want 409 +predicate mutated crash marker to "ANTARES_CANCEL_OUTCOME_AMBIGUOUS" +FAIL + +$ go test ./internal/store -run TestCursorCleanupSkipsActiveStatesWithAndWithoutForeignKeys -count=1 -v +foreign_keys_on/delete_empty: deleted=9, want 5 +foreign_keys_on/prune: deleted=9, want 5 +foreign_keys_off/delete_empty: deleted=9, want 5 +foreign_keys_off/prune: deleted=9, want 5 +FAIL +``` + +Ambiguous cancellation response RED: + +```text +$ go test ./internal/server -run TestCursorCancelAmbiguousResponseIsBoundedAndNonRetryable -count=1 -v +request_timeout: status=408, want 502 +server: status=503, want 502 +context: response did not state ambiguous/non-retryable +transport: response forwarded the bounded upstream error instead of a fixed message +FAIL +``` + +Final focused GREEN: + +```text +$ go test ./internal/server -run 'TestCursor(AttachEverySequentialRecoveryFollowerStartsAtZero|AttachConcurrentFollowersResetToRecoveryWinner|CleanupHandlersPrecheckActiveStateAcrossPagination|ActiveRemotePredicateIsPureForCancelCrashMarker|CancelAmbiguousResponseIsBoundedAndNonRetryable)' -count=1 && \ + go test ./internal/store -run 'TestCursor(CleanupSkipsActiveStatesWithAndWithoutForeignKeys|SessionDeleteEmptySessionsRemovesStateWithoutForeignKeys|SessionBulkDeleteRollsBackOnChildFailure|SessionPruneSessionsRemovesStateWithoutForeignKeys)' -count=1 +ok github.com/enowdev/antares/internal/server 0.162s +ok github.com/enowdev/antares/internal/store 0.097s +``` + +### Affected and race verification + +Fresh affected-package suite: + +```text +$ env -u CURSOR_API_KEY -u OPENAI_API_KEY -u AZURE_OPENAI_ENDPOINT \ + -u AZURE_OPENAI_KEY -u AZURE_OPENAI_DEPLOYMENT \ + -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY -u AWS_SESSION_TOKEN \ + -u GOOGLE_APPLICATION_CREDENTIALS -u VERTEX_SA_JSON \ + -u COPILOT_GITHUB_TOKEN \ + go test ./internal/server ./internal/store ./internal/cursorrun \ + ./internal/approval ./internal/agent -count=1 +ok github.com/enowdev/antares/internal/server 2.951s +ok github.com/enowdev/antares/internal/store 1.069s +ok github.com/enowdev/antares/internal/cursorrun 1.186s +ok github.com/enowdev/antares/internal/approval 0.031s +ok github.com/enowdev/antares/internal/agent 0.332s +``` + +Fresh affected-package race suite: + +```text +$ env -u CURSOR_API_KEY -u OPENAI_API_KEY -u AZURE_OPENAI_ENDPOINT \ + -u AZURE_OPENAI_KEY -u AZURE_OPENAI_DEPLOYMENT \ + -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY -u AWS_SESSION_TOKEN \ + -u GOOGLE_APPLICATION_CREDENTIALS -u VERTEX_SA_JSON \ + -u COPILOT_GITHUB_TOKEN \ + go test -race ./internal/server ./internal/store ./internal/cursorrun \ + ./internal/approval ./internal/agent -count=1 +ok github.com/enowdev/antares/internal/server 25.524s +ok github.com/enowdev/antares/internal/store 9.513s +ok github.com/enowdev/antares/internal/cursorrun 4.227s +ok github.com/enowdev/antares/internal/approval 1.049s +ok github.com/enowdev/antares/internal/agent 3.004s +``` + +### Safety self-review + +1. Recovery-log cursor zero is derived solely from immutable live-log kind. + Replaying its leading reset and durable snapshots is idempotent for every + follower; a stale cursor from the pre-crash process cannot skip them. +2. Cleanup prechecks hold every enumerated session lock through mutation. + Direct/ordinary reservation, recovery, terminal finalization, edit, and + explicit deletion therefore cannot cross the check/mutation boundary for + those sessions. +3. The store-side `NOT EXISTS` predicate independently skips newly visible + active Cursor states at the delete statement, including states inserted + after server enumeration. FK-off child cleanup remains transactional. +4. Cleanup prechecking is all-or-nothing and read-only. Encountering a later + active candidate cannot mutate an earlier stale cancel marker. +5. A current cancel POST is protected by the process-local reservation. After a + restart, the same marker is locally deletable without a write during + precheck; recovery/direct/cancel paths still reconcile it durably before + remote lifecycle work. +6. Ambiguous cancellation never exposes the upstream error, never returns its + 408/5xx status, and retains the durable no-resubmit marker. + +### Concerns + +- The requested affected and race suites pass. +- A parallel full-repository attempt stalled for more than four minutes while + entering the pre-existing MCP test region and was terminated; the known + `internal/mcp.TestStdioRoundTrip` passed immediately in isolation. +- A serial full-repository attempt passed MCP but hit the unrelated flaky + `TestModelSetConcurrentWithConfigReads` async-save assertion; that test passed + immediately in isolation. +- No live API calls or credentials were used. CAS backoff and full-accumulator + persistence remain intentionally deferred. + +## Fix Round 3 (2026-08-13) + +### Status + +Closed the compacted recovery replay gap, preserved terminal-but-uncommitted +work from automatic cleanup, made category deletion enumerate the full stable +session listing, and classified local missing Cursor configuration as a +definitive cancellation failure. + +### Implementation + +1. `liveRun` now folds text and reasoning from evicted original events into a + bounded replay checkpoint. A follower whose absolute cursor is behind the + retained window receives `EventReset`, the complete checkpoint reasoning and + text snapshots, then the retained post-checkpoint events. +2. The checkpoint does not consume original absolute cursor positions. + Followers already at or ahead of `base` continue without a reset. A + checkpoint advances the reconnect cursor to `base` only on its final frame; + a disconnect between reset/reasoning/text therefore repeats the full anchor + instead of skipping a partially delivered snapshot. +3. The retained original-event window remains capped at `maxLiveEvents`. + Reasoning and text checkpoints are independently capped at + `maxCursorPartialRunes`, are UTF-8 safe, and reset with `EventReset`. + Existing short-run event ordering is unchanged. The generic implementation + also makes compacted ordinary and direct logs safer. +4. Automatic empty/prune prechecks now treat + `CursorOperationTerminal` as protected unfinished work even without an + in-memory watcher. The store-side cleanup predicate independently excludes + terminal state at mutation time for both foreign-key modes. +5. Explicit single and category deletion still use the operator policy: + terminal state without a live watcher, create ambiguity, cancel ambiguity, + and stale cancellation markers remain locally deletable. Active state and a + process-local cancellation reservation still return HTTP 409. +6. Category delete-all now enumerates the complete session list in stable + 500-entry pages before applying `chat`/`project`/`all` filtering. It acquires + sorted keyed locks, prechecks every selected session, and submits the full + selected ID set only after all prechecks succeed. +7. `ListSessions` now adds `id ASC` as the final ordering key, making offset + pages deterministic when pinned and timestamp/order values tie. +8. `cursorrun.ErrNotConfigured`, including wrapped instances, is definitive: + cancellation restores the exact prior remote status and returns HTTP 428 + with the actionable configuration error. Context, transport, HTTP 408, 5xx, + definitive API 4xx, and 404 classifications are otherwise unchanged. + +CAS backoff and the full persisted-accumulator rewrite remain deferred. + +### Files + +Modified: + +- `internal/server/cursor_events.go` +- `internal/server/handlers_chat.go` +- `internal/server/handlers_cursor.go` +- `internal/server/handlers_cursor_cleanup_test.go` +- `internal/server/handlers_cursor_fix_test.go` +- `internal/server/livechat.go` +- `internal/server/livechat_test.go` +- `internal/store/cursor_sessions_test.go` +- `internal/store/sessions.go` +- `.superpowers/sdd/2026-08-12-adaptive-reasoning-cursor-mode/task-12-report.md` + +Explicitly excluded: + +- `web/tsconfig.tsbuildinfo` +- pre-existing untracked controller plan/design documents + +### Focused TDD evidence + +Compacted replay RED: + +```text +$ go test ./internal/server -run 'TestLiveRun_(CoalescesBacklogAndReportsAbsoluteCursor|CompactionRetainsCanonicalReplayCheckpoint)$' -count=1 +TestLiveRun_CoalescesBacklogAndReportsAbsoluteCursor: + 4001 backlog events produced 2 frames, want 4 +TestLiveRun_CompactionRetainsCanonicalReplayCheckpoint: + reconnect 1 canonical mismatch: text=11994/12408 reasoning=12000/12414 +FAIL +``` + +Mid-anchor reconnect RED found while verifying absolute cursor behavior: + +```text +$ go test ./internal/server -run TestLiveRun_CompactedCheckpointSurvivesMidAnchorReconnect -count=1 +TestLiveRun_CompactedCheckpointSurvivesMidAnchorReconnect: + disconnect 1 lost checkpoint: text="" reasoning="" cursor=3 +FAIL +``` + +Terminal cleanup and category pagination RED: + +```text +$ go test ./internal/store -run TestCursorCleanupSkipsActiveStatesWithAndWithoutForeignKeys -count=1 +foreign_keys_on/delete_empty: deleted=6, want 5 +foreign_keys_on/prune: deleted=6, want 5 +foreign_keys_off/delete_empty: deleted=6, want 5 +foreign_keys_off/prune: deleted=6, want 5 +FAIL + +$ go test ./internal/server -run 'Test(CursorAutomaticCleanupBlocksTerminalWithoutLiveWatcher|DeleteAllSessionsPaginatesBeforeCategoryFiltering|DeleteAllSessionsPrechecksActiveStateOnLaterPage)$' -count=1 +empty: terminal cleanup status=200, want 409 +prune: terminal cleanup status=200, want 409 +category deletion listed 1 page(s), want at least 3 +late-page active category deletion status=200, want 409 +FAIL +``` + +Missing-configuration cancellation RED: + +```text +$ go test ./internal/server -run TestCursorCancelNotConfiguredRestoresStatusAndReturnsActionableError -count=1 +not-configured cancellation status=502, want 428 +FAIL +``` + +Final focused GREEN: + +```text +$ go test ./internal/server -run 'Test(LiveRun_|CursorAutomaticCleanupBlocksTerminalWithoutLiveWatcher|DeleteAllSessionsPaginatesBeforeCategoryFiltering|DeleteAllSessionsPrechecksActiveStateOnLaterPage|CursorCancelNotConfiguredRestoresStatusAndReturnsActionableError|CursorChatAmbiguousStatesCanBeDeletedLocally|CursorChatDeleteRejectsActiveRemoteState)' -count=1 +ok github.com/enowdev/antares/internal/server 0.403s + +$ go test ./internal/store -run 'Test(CursorCleanupSkipsActiveStatesWithAndWithoutForeignKeys|ListSessions)' -count=1 +ok github.com/enowdev/antares/internal/store 0.184s + +$ go test ./internal/server -run 'TestLiveRun_' -count=1 +ok github.com/enowdev/antares/internal/server 0.185s +``` + +### Affected, race, and full verification + +Fresh affected-package suite: + +```text +$ env -u CURSOR_API_KEY -u OPENAI_API_KEY -u ANTHROPIC_API_KEY \ + -u GEMINI_API_KEY -u AZURE_OPENAI_ENDPOINT -u AZURE_OPENAI_KEY \ + -u AZURE_OPENAI_DEPLOYMENT -u AWS_ACCESS_KEY_ID \ + -u AWS_SECRET_ACCESS_KEY -u AWS_SESSION_TOKEN \ + -u GOOGLE_APPLICATION_CREDENTIALS -u VERTEX_SA_JSON \ + -u COPILOT_GITHUB_TOKEN \ + go test ./internal/server ./internal/store ./internal/cursorrun \ + ./internal/approval ./internal/agent -count=1 +ok github.com/enowdev/antares/internal/server 3.770s +ok github.com/enowdev/antares/internal/store 3.127s +ok github.com/enowdev/antares/internal/cursorrun 2.332s +ok github.com/enowdev/antares/internal/approval 0.029s +ok github.com/enowdev/antares/internal/agent 0.602s +``` + +Fresh affected-package race suite: + +```text +$ env -u CURSOR_API_KEY -u OPENAI_API_KEY -u ANTHROPIC_API_KEY \ + -u GEMINI_API_KEY -u AZURE_OPENAI_ENDPOINT -u AZURE_OPENAI_KEY \ + -u AZURE_OPENAI_DEPLOYMENT -u AWS_ACCESS_KEY_ID \ + -u AWS_SECRET_ACCESS_KEY -u AWS_SESSION_TOKEN \ + -u GOOGLE_APPLICATION_CREDENTIALS -u VERTEX_SA_JSON \ + -u COPILOT_GITHUB_TOKEN \ + go test -race ./internal/server ./internal/store ./internal/cursorrun \ + ./internal/approval ./internal/agent -count=1 +ok github.com/enowdev/antares/internal/server 34.366s +ok github.com/enowdev/antares/internal/store 10.707s +ok github.com/enowdev/antares/internal/cursorrun 5.174s +ok github.com/enowdev/antares/internal/approval 1.062s +ok github.com/enowdev/antares/internal/agent 3.570s +``` + +Fresh full hermetic repository suite: + +```text +$ env -u CURSOR_API_KEY -u OPENAI_API_KEY -u ANTHROPIC_API_KEY \ + -u GEMINI_API_KEY -u AZURE_OPENAI_ENDPOINT -u AZURE_OPENAI_KEY \ + -u AZURE_OPENAI_DEPLOYMENT -u AWS_ACCESS_KEY_ID \ + -u AWS_SECRET_ACCESS_KEY -u AWS_SESSION_TOKEN \ + -u GOOGLE_APPLICATION_CREDENTIALS -u VERTEX_SA_JSON \ + -u COPILOT_GITHUB_TOKEN go test ./... -count=1 +PASS (all packages; internal/server 6.540s, internal/store 5.173s, +internal/mcp 1.353s) +``` + +### Safety self-review + +1. `base` counts only original published events. Folding an evicted event into + the checkpoint and incrementing `base` happen under the same live-run lock, + so retained indexes and checkpoint contents describe one boundary. +2. A follower at `cursor >= base` never receives the synthetic anchor and + continues from its original absolute position. A follower behind `base` + receives reset/full snapshots before any event at `base`. +3. Intermediate anchor frames report `base-1`; only the last snapshot frame + reports `base`. A reconnect after any proper prefix of the anchor is + therefore still behind the boundary and replays the entire idempotent + anchor. Concurrent compaction causes the outer loop to emit the newer + checkpoint instead of indexing a negative retained offset. +4. Checkpoint snapshots and the original-event window are independently + bounded. Folded `EventReset` clears both snapshots before subsequent deltas + are accumulated. +5. Automatic cleanup holds all selected session locks across the precheck and + mutation call. Terminal state is blocked in that server precheck and again + in store SQL, including state that appears after enumeration. +6. Explicit deletion still calls the original active-remote predicate, so the + new automatic terminal rule does not remove the operator reconciliation + escape. Ambiguous and stale cancellation states remain non-mutating during + all-or-nothing precheck. +7. Category filtering occurs while consuming every deterministic 500-entry + page. `LockMany` sorts and deduplicates the complete selected ID set before + any active-state query; `DeleteSessions` receives that same complete set. +8. A local configuration failure cannot represent an accepted remote request. + Its durable in-flight marker is restored to the captured prior status before + the actionable 428 response. Potentially accepted context/transport/API + failures retain their existing conservative classification. + +### Concerns + +- No live API calls or credentials were used. +- CAS backoff and full persisted-accumulator persistence remain intentionally + deferred as requested. +- No functional, full-suite, or race-detector failures remain in this round. + +## Fix Round 4 (2026-08-13) + +### Status + +Closed the remaining local-configuration create ambiguity, made exact-set +category deletion atomic across every requested session, and added a safe +compacted-replay explanation for evicted live-only tool activity. + +### Implementation + +1. `cursorCreateCouldBeAmbiguous` now treats wrapped + `cursorrun.ErrNotConfigured` as definitive because runner option resolution + fails before `CreateAgent` or `CreateRun` submits a POST. The existing + failure transition restores the turn to `idle`, invalidates reuse, and emits + the actionable configuration error instead of the local-delete ambiguity + message. +2. Create classification remains conservative for potentially accepted + requests: context and ordinary transport errors, API transport status zero, + HTTP 408, and 5xx remain ambiguous. Definitive API 4xx remains retryable + without local deletion. +3. `Store.DeleteSessions` now opens one transaction for the complete exact ID + slice. For every ID it explicitly deletes Cursor state, messages, + session-scoped memories, and then the session. Any child/session statement + failure returns zero and rolls the whole set back; commit errors also return + zero. +4. The exact-set transaction continues to run each query through dialect + rebinding, works with FK cascades enabled or disabled, preserves empty input + as zero/no-op, and returns the input ID count after a successful commit. +5. A compacted `liveRun` checkpoint now stores one boolean when evicted history + contains `EventToolCall`, `EventToolProgress`, or `EventToolResult`. It never + copies tool names, arguments, chunks, messages, or results into the anchor. +6. A stale follower receives one fixed bounded `EventNotice` immediately after + the synthetic reset when that boolean is set. The notice precedes canonical + reasoning/text snapshots and participates in the existing safe mid-anchor + cursor protocol. Tool-free checkpoints and short live logs are unchanged. + +CAS backoff, the full persisted-accumulator rewrite, and checkpoint memory +sizing remain deferred. + +### Files + +Modified: + +- `internal/server/handlers_cursor.go` +- `internal/server/handlers_cursor_fix_test.go` +- `internal/server/livechat.go` +- `internal/server/livechat_test.go` +- `internal/store/cursor_sessions_test.go` +- `internal/store/sessions.go` +- `.superpowers/sdd/2026-08-12-adaptive-reasoning-cursor-mode/task-12-report.md` + +Explicitly excluded: + +- `web/tsconfig.tsbuildinfo` +- pre-existing untracked controller plan/design documents + +### Focused TDD evidence + +Create configuration RED: + +```text +$ go test ./internal/server -run 'TestCursor(CreateNotConfiguredRestoresIdleWithoutLocalDeletion|CreateAmbiguityClassification)$' -count=1 +CreateAgent: + configuration failure was not actionable: + "Cursor may have accepted the create request, but no run IDs were returned; it will not be retried automatically" +CreateRun: + configuration failure was not actionable: + "Cursor may have accepted the create request, but no run IDs were returned; it will not be retried automatically" +not_configured: + cursorCreateCouldBeAmbiguous(...)=true, want false +API_transport: + cursorCreateCouldBeAmbiguous(cursor api error: 0)=false, want true +FAIL +``` + +Exact-set transaction RED: + +```text +$ go test ./internal/store -run 'TestCursorDeleteSessions(RollsBackEveryIDOnLaterFailure|DeletesExactSetAndReturnsCount)$' -count=1 +TestCursorDeleteSessionsRollsBackEveryIDOnLaterFailure: + failed atomic deletion count=1, want 0 +FAIL +``` + +Compacted tool-notice RED: + +```text +$ go test ./internal/server -run 'TestLiveRun_(CompactedCheckpointNoticesTrimmedToolActivity|CompactedCheckpointWithoutToolActivityHasNoTrimNotice|ShortToolLogKeepsOriginalOrderingWithoutTrimNotice)$' -count=1 +TestLiveRun_CompactedCheckpointNoticesTrimmedToolActivity: + anchor prefix=[reset reasoning], want reset then notice +FAIL +``` + +Final focused GREEN: + +```text +$ go test ./internal/server -run 'Test(CursorCreateNotConfiguredRestoresIdleWithoutLocalDeletion|CursorCreateAmbiguityClassification|CursorCancelNotConfiguredRestoresStatusAndReturnsActionableError|LiveRun_)' -count=1 +ok github.com/enowdev/antares/internal/server 0.234s + +$ go test ./internal/store -run 'TestCursor(DeleteSessionsRollsBackEveryIDOnLaterFailure|DeleteSessionsDeletesExactSetAndReturnsCount|SessionBulkDeleteRollsBackOnChildFailure|CleanupSkipsActiveStatesWithAndWithoutForeignKeys)' -count=1 +ok github.com/enowdev/antares/internal/store 0.310s +``` + +### Affected, race, and full verification + +Fresh affected-package suite: + +```text +$ env -u CURSOR_API_KEY -u OPENAI_API_KEY -u ANTHROPIC_API_KEY \ + -u GEMINI_API_KEY -u AZURE_OPENAI_ENDPOINT -u AZURE_OPENAI_KEY \ + -u AZURE_OPENAI_DEPLOYMENT -u AWS_ACCESS_KEY_ID \ + -u AWS_SECRET_ACCESS_KEY -u AWS_SESSION_TOKEN \ + -u GOOGLE_APPLICATION_CREDENTIALS -u VERTEX_SA_JSON \ + -u COPILOT_GITHUB_TOKEN \ + go test ./internal/server ./internal/store ./internal/cursorrun \ + ./internal/approval ./internal/agent -count=1 +ok github.com/enowdev/antares/internal/server 2.173s +ok github.com/enowdev/antares/internal/store 1.725s +ok github.com/enowdev/antares/internal/cursorrun 1.085s +ok github.com/enowdev/antares/internal/approval 0.034s +ok github.com/enowdev/antares/internal/agent 0.327s +``` + +Fresh affected-package race suite: + +```text +$ env -u CURSOR_API_KEY -u OPENAI_API_KEY -u ANTHROPIC_API_KEY \ + -u GEMINI_API_KEY -u AZURE_OPENAI_ENDPOINT -u AZURE_OPENAI_KEY \ + -u AZURE_OPENAI_DEPLOYMENT -u AWS_ACCESS_KEY_ID \ + -u AWS_SECRET_ACCESS_KEY -u AWS_SESSION_TOKEN \ + -u GOOGLE_APPLICATION_CREDENTIALS -u VERTEX_SA_JSON \ + -u COPILOT_GITHUB_TOKEN \ + go test -race ./internal/server ./internal/store ./internal/cursorrun \ + ./internal/approval ./internal/agent -count=1 +ok github.com/enowdev/antares/internal/server 20.537s +ok github.com/enowdev/antares/internal/store 6.750s +ok github.com/enowdev/antares/internal/cursorrun 3.205s +ok github.com/enowdev/antares/internal/approval 1.056s +ok github.com/enowdev/antares/internal/agent 2.505s +``` + +Fresh full hermetic repository suite: + +```text +$ env -u CURSOR_API_KEY -u OPENAI_API_KEY -u ANTHROPIC_API_KEY \ + -u GEMINI_API_KEY -u AZURE_OPENAI_ENDPOINT -u AZURE_OPENAI_KEY \ + -u AZURE_OPENAI_DEPLOYMENT -u AWS_ACCESS_KEY_ID \ + -u AWS_SECRET_ACCESS_KEY -u AWS_SESSION_TOKEN \ + -u GOOGLE_APPLICATION_CREDENTIALS -u VERTEX_SA_JSON \ + -u COPILOT_GITHUB_TOKEN go test ./... -count=1 +PASS (all packages; internal/server 3.645s, internal/store 1.628s, +internal/mcp 0.865s) +``` + +### Safety self-review + +1. The durable create marker is still written before entering the runner. + `ErrNotConfigured` can only arise from local option resolution before the + runner's HTTP call, so restoring idle does not risk duplicate remote work. +2. The configuration error is redacted/bounded through the existing Cursor + event and durable-status paths. Both new-agent and reused-agent operations + become non-reusable and do not instruct the operator to delete local data. +3. API status zero is explicitly transport uncertainty. Context, generic + transport, 408, and 5xx still retain the no-resubmit ambiguous state; the + configuration exception does not broaden definitive remote classification. +4. Every exact-set child/session statement uses the same transaction and the + same context. A failure on a later ID rolls back earlier sessions and also + restores child rows already removed for the failing ID. +5. Cursor state and messages are explicitly removed before each session; + session-scoped memories are also explicit and failure-significant. Session + deletion remains last, preserving behavior with and without FK cascades. +6. Successful `DeleteSessions` returns the number of supplied IDs, matching + prior caller-visible behavior. A failed atomic operation returns zero because + no deletion committed. +7. Replay compaction records only `replayToolsTrimmed`; no field from an evicted + tool event is retained or persisted. The fixed notice contains no dynamic + data and is well below the asserted 256-rune bound. +8. The trim notice is synthesized only after a tool event crosses the retention + boundary. Retained short tool events preserve their original order, and + compacted histories without tool activity preserve the Round 3 anchor shape. + +### Concerns + +- No live API calls or credentials were used. +- CAS backoff, full persisted-accumulator persistence, and checkpoint memory + sizing remain intentionally deferred as requested. +- No focused, full-suite, or race-detector failures remain in this round. diff --git a/cmd/antares/main.go b/cmd/antares/main.go index 2a9bc0b..cd4c84b 100644 --- a/cmd/antares/main.go +++ b/cmd/antares/main.go @@ -20,6 +20,8 @@ import ( "github.com/enowdev/antares/internal/commands" "github.com/enowdev/antares/internal/config" "github.com/enowdev/antares/internal/cron" + "github.com/enowdev/antares/internal/cursor" + "github.com/enowdev/antares/internal/cursorrun" "github.com/enowdev/antares/internal/gateway" "github.com/enowdev/antares/internal/httpshim" "github.com/enowdev/antares/internal/hub" @@ -196,16 +198,50 @@ func cmdTUI() error { // runtimeServices bundles everything a running server needs, so a config reload // can rebuild the pieces that depend on configuration. type runtimeServices struct { - mu sync.Mutex - cfg *config.Config - db store.Store - shell *tools.ShellManager - agent *agent.Agent - skills *skills.Manager - cron *cron.Runner - gateway *gateway.Manager - mcp *mcp.Manager - social *socialbrowser.Manager + mu sync.Mutex + cfg *config.Config + db store.Store + shell *tools.ShellManager + agent *agent.Agent + skills *skills.Manager + cron *cron.Runner + gateway *gateway.Manager + mcp *mcp.Manager + social *socialbrowser.Manager + cursorRunner cursorrun.Runner +} + +func newRuntimeCursorRunner(ag *agent.Agent) cursorrun.Runner { + return cursorrun.New(cursorrun.Options{ + ResolveClient: func() (cursor.Options, error) { + if ag == nil { + return cursor.Options{}, errors.New("Cursor is unavailable in this runtime") + } + cfg := ag.Config() + if cfg == nil { + return cursor.Options{}, errors.New("Cursor is unavailable in this runtime") + } + _, provider := cfg.ResolveProvider("cursor") + provider.APIKey = strings.TrimSpace(provider.APIKey) + options := cursor.Options{ + BaseURL: provider.BaseURL, + APIKey: provider.APIKey, + } + if !provider.Enabled || provider.APIKey == "" { + return options, cursorrun.ErrNotConfigured + } + return options, nil + }, + Now: time.Now, + CatalogTTL: 5 * time.Minute, + }) +} + +func (rt *runtimeServices) setCursorRunner(runner cursorrun.Runner) { + rt.cursorRunner = runner + if rt.agent != nil { + rt.agent.SetCursorRunner(runner) + } } func bootstrap(ctx context.Context) (*runtimeServices, error) { @@ -294,6 +330,7 @@ func bootstrap(ctx context.Context) (*runtimeServices, error) { ag.SetRoles(roleReg) rt := &runtimeServices{cfg: cfg, db: db, shell: shell, agent: ag, skills: skillMgr} + rt.setCursorRunner(newRuntimeCursorRunner(ag)) rt.social = socialbrowser.New() ag.SetSocialBrowser(rt.social) @@ -422,6 +459,10 @@ func (rt *runtimeServices) messageIsRelevant(ctx context.Context, b *config.Bind "\n\nMessage:\n" + strings.TrimSpace(text) + "\n\nDoes this message fit the criteria and deserve a reply? Answer with exactly one word: YES or NO." + reasoningEffort := "" + if err := rt.agent.ValidateReasoningEffort(ctx, b.Model, "low"); err == nil { + reasoningEffort = "low" + } var out strings.Builder _, err := rt.agent.Run(ctx, agent.Request{ Message: prompt, @@ -429,7 +470,7 @@ func (rt *runtimeServices) messageIsRelevant(ctx context.Context, b *config.Bind Toolset: "minimal", Quiet: true, MaxTurns: 1, - ReasoningEffort: "low", + ReasoningEffort: reasoningEffort, }, func(e agent.Event) error { if e.Type == agent.EventText { out.WriteString(e.Delta) @@ -606,6 +647,7 @@ func cmdServeForeground() error { Gateway: rt.gateway, MCP: rt.mcp, Social: rt.social, + Cursor: rt.cursorRunner, }) if rt.cfg.Cron.Enabled { diff --git a/cmd/antares/provider_test.go b/cmd/antares/provider_test.go index c731ded..e44b0ed 100644 --- a/cmd/antares/provider_test.go +++ b/cmd/antares/provider_test.go @@ -1,12 +1,20 @@ package main import ( + "context" + "encoding/json" "io" + "net/http" + "net/http/httptest" "os" "strings" + "sync" + "sync/atomic" "testing" + "github.com/enowdev/antares/internal/agent" "github.com/enowdev/antares/internal/config" + "github.com/enowdev/antares/internal/tools" ) func TestProviderAddAndUseCursorPreserveActiveModel(t *testing.T) { @@ -49,6 +57,94 @@ func TestProviderAddAndUseCursorPreserveActiveModel(t *testing.T) { } } +func TestRuntimeCursorRunnerUsesAtomicConfigAndInvalidatesOnReload(t *testing.T) { + var calls atomic.Int32 + var version atomic.Int32 + version.Store(1) + var authMu sync.Mutex + var authorizations []string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/v1/models" { + t.Errorf("request = %s %s, want GET /v1/models", r.Method, r.URL.Path) + http.NotFound(w, r) + return + } + calls.Add(1) + authMu.Lock() + authorizations = append(authorizations, r.Header.Get("Authorization")) + authMu.Unlock() + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []any{map[string]any{ + "id": "model-" + string(rune('0'+version.Load())), + }}, + }) + })) + defer upstream.Close() + + cfg := config.Default() + provider := cfg.Providers["cursor"] + provider.Enabled = true + provider.APIKey = "runtime-key-one" + provider.BaseURL = upstream.URL + cfg.Providers["cursor"] = provider + ag := agent.New(cfg, nil, tools.NewRegistry(), nil, nil) + rt := &runtimeServices{cfg: cfg, agent: ag} + runner := newRuntimeCursorRunner(ag) + rt.setCursorRunner(runner) + if rt.cursorRunner != runner { + t.Fatal("runtimeServices did not retain the installed Cursor runner") + } + + first, err := runner.Catalog(context.Background(), false) + if err != nil || len(first.Items) != 1 || first.Items[0].ID != "model-1" { + t.Fatalf("first catalogue = %+v, %v", first, err) + } + if _, err := runner.Catalog(context.Background(), false); err != nil { + t.Fatal(err) + } + if got := calls.Load(); got != 1 { + t.Fatalf("cached catalogue requests = %d, want 1", got) + } + + version.Store(2) + reloaded := *cfg + reloaded.Providers = make(map[string]config.Provider, len(cfg.Providers)) + for id, configured := range cfg.Providers { + reloaded.Providers[id] = configured + } + ag.SetConfig(&reloaded) + second, err := runner.Catalog(context.Background(), false) + if err != nil || len(second.Items) != 1 || second.Items[0].ID != "model-2" { + t.Fatalf("reloaded catalogue = %+v, %v", second, err) + } + if got := calls.Load(); got != 2 { + t.Fatalf("catalogue requests after same-key reload = %d, want 2", got) + } + + changedKey := reloaded + changedKey.Providers = make(map[string]config.Provider, len(reloaded.Providers)) + for id, configured := range reloaded.Providers { + changedKey.Providers[id] = configured + } + provider = changedKey.Providers["cursor"] + provider.APIKey = "runtime-key-two" + changedKey.Providers["cursor"] = provider + ag.SetConfig(&changedKey) + if _, err := runner.Catalog(context.Background(), false); err != nil { + t.Fatal(err) + } + + authMu.Lock() + gotAuthorizations := append([]string(nil), authorizations...) + authMu.Unlock() + if len(gotAuthorizations) != 3 || + gotAuthorizations[0] != "Bearer runtime-key-one" || + gotAuthorizations[1] != "Bearer runtime-key-one" || + gotAuthorizations[2] != "Bearer runtime-key-two" { + t.Fatalf("resolved authorizations = %v", gotAuthorizations) + } +} + func captureProviderStdout(t *testing.T, f func()) string { t.Helper() old := os.Stdout diff --git a/cmd/antares/reasoning_test.go b/cmd/antares/reasoning_test.go new file mode 100644 index 0000000..3672912 --- /dev/null +++ b/cmd/antares/reasoning_test.go @@ -0,0 +1,67 @@ +package main + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "github.com/enowdev/antares/internal/agent" + "github.com/enowdev/antares/internal/config" + "github.com/enowdev/antares/internal/store" + "github.com/enowdev/antares/internal/tools" +) + +func TestMessageIsRelevantUsesAutoWhenLowUnsupported(t *testing.T) { + var chatCalls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/models"): + _, _ = w.Write([]byte(`{"data":[{"id":"plain-model","name":"Plain"}]}`)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/chat/completions"): + chatCalls.Add(1) + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"NO"}}]}`)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + cfg := config.Default() + cfg.Model.Provider = "router" + cfg.Model.Default = "plain-model" + cfg.Model.MaxRetries = -1 + cfg.Model.ReasoningEffort = "" + cfg.Agent.ReasoningEffort = "" + cfg.Streaming.Enabled = false + cfg.Providers = map[string]config.Provider{ + "router": { + Kind: "openai-compatible", + BaseURL: srv.URL, + Enabled: true, + }, + } + db, err := store.Open(context.Background(), "memory", "", 1, 5000, false) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + rt := &runtimeServices{ + cfg: cfg, + db: db, + agent: agent.New(cfg, db, tools.NewRegistry(), nil, nil), + } + + if got := rt.messageIsRelevant(context.Background(), &config.Binding{ + Model: "plain-model", + RelevanceFilter: "Only answer release announcements.", + }, "How is everyone?"); got { + t.Fatal("messageIsRelevant = true, want classifier's NO response") + } + if got := chatCalls.Load(); got != 1 { + t.Fatalf("classifier chat calls = %d, want one", got) + } +} diff --git a/cmd/antares/setup.go b/cmd/antares/setup.go index f938f0f..7dead6d 100644 --- a/cmd/antares/setup.go +++ b/cmd/antares/setup.go @@ -109,6 +109,7 @@ func runWebSetup(ctx context.Context, rt *runtimeServices) error { Config: rt.cfg, Agent: rt.agent, Store: rt.db, Dist: server.EmbeddedDist(), Reload: rt.reload, Skills: rt.skills, Cron: rt.cron, Gateway: rt.gateway, MCP: rt.mcp, + Cursor: rt.cursorRunner, }) urls := setupURLs(port) diff --git a/docs/configuration.md b/docs/configuration.md index 311a06b..617ba7a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -43,7 +43,7 @@ model: temperature: 0.7 max_tokens: 8192 context_window: 0 # 0 asks the provider - reasoning_effort: medium + reasoning_effort: "" # Auto — see Reasoning below providers: openrouter: @@ -99,17 +99,35 @@ providers: kind: cursor-agent base_url: https://api.cursor.com api_key_env: CURSOR_API_KEY - enabled: true + enabled: false timeout_seconds: 900 ``` Cursor is a built-in cloud-only agent integration. Existing configuration and -providers need no migration and retain their current LLM behavior. The default -Cursor entry is enabled but disconnected; it becomes usable only when Antares -resolves its key. `CURSOR_API_KEY` works with that entry without writing a key -to YAML. One deployment key and its quota are shared by every user who can -invoke the Cursor tools. Repository-backed runs use the repository state -available to Cursor, so unpushed local changes are not included. +providers need no migration and retain their current LLM behavior. It ships +**disabled**, like every other provider you must bring a key for: Cursor runs +cost money, so having `CURSOR_API_KEY` in the environment must not by itself be +enough to spend it. Turn it on from the Providers page, or set `enabled: true`. +`CURSOR_API_KEY` then supplies the key without writing one to YAML. One +deployment key and its quota are shared by every user who can reach it. + +Cursor never becomes an active chat provider. `model.provider`, +`model.default`, `/api/model/set`, the `/model` and `/provider` commands, the +TUI picker, and `llm.New` all refuse it — it is reachable only through the +Cursor tools and the composer's direct Cursor target. + +**Repository state.** A repository-backed run uses what Cursor's cloud VM can +fetch from the remote, so **uncommitted changes and local-only commits are not +part of the run**. Antares reads your project's `origin`, normalises SSH forms +like `git@github.com:owner/repo.git` to `https://github.com/owner/repo`, +proposes the current branch as the starting ref, and warns on the approval card +when the worktree is dirty or ahead of that ref. You can edit the repository and +ref before sending; the server normalises and validates them again. Local +paths, credentials embedded in remote URLs, non-GitHub repositories, and +non-HTTPS destinations are rejected. A chat with no project bound runs with no +repository. + +See [Tools](tools.md) for direct Cursor mode, approval, and attachment limits. `model.auxiliary` is worth setting. Titles, compaction summaries, verification, and goal judging all use it, and a small model does those as well as a large one @@ -126,6 +144,50 @@ GEMINI_API_KEY=… CURSOR_API_KEY=… ``` +## Reasoning + +```yaml +model: + reasoning_effort: "" # Auto +agent: + reasoning_effort: "" # Auto +``` + +Reasoning is **model-aware**. There is no global `none|low|medium|high` ladder +any more: Antares asks the selected provider and model what it actually +supports and offers exactly those values. + +**Auto** is the default and is always available. Auto sends *no* reasoning +field at all, so the model applies its own default — which for the adaptive +families is better than any fixed value Antares could pick. It is also the only +safe default for OpenRouter, where one model name can route to backends with +completely different ladders. + +The values themselves are **opaque provider strings**, passed through +unchanged. `extra-high` is not rewritten to `xhigh`, and two providers that +happen to share a label do not necessarily share a wire format. + +| Provider | Semantics | +|---|---| +| Anthropic | Adaptive-thinking models send `thinking: {type: adaptive}` with `output_config.effort`; legacy models keep fixed token budgets | +| Gemini | Published `thinkingLevel` values. `minimal` means Minimal, not Off — models that cannot disable dynamic thinking show no Off | +| OpenAI / Codex | Documented effort ladder per family; chat requests use the effort field, Responses/Codex the nested reasoning object | +| OpenRouter | `supported_efforts`, `default_effort`, `mandatory` from model metadata are authoritative; a chosen Off sends an explicit disable rather than omitting the field | +| Other OpenAI-compatible | Auto only, unless the provider publishes reasoning metadata | + +**Off** appears only where reasoning can genuinely be disabled. A model with +mandatory reasoning offers no Off, and a provider that merely lowers its budget +does not get to call it Off. + +The dashboard stores your choice per `provider/model`, so switching models +restores that model's own last valid value rather than carrying a stale one +across. A value the newly selected model does not support falls back to Auto. + +`reasoning_effort` stays a string, so existing YAML and automation keep working. +An unsupported value in a **stored config** resolves to Auto with a one-time +notice; an unsupported value in a **new API request** is rejected outright +rather than silently rewritten. + ## Storage ```yaml diff --git a/docs/superpowers/plans/2026-08-12-adaptive-reasoning-cursor-mode.md b/docs/superpowers/plans/2026-08-12-adaptive-reasoning-cursor-mode.md new file mode 100644 index 0000000..dc95981 --- /dev/null +++ b/docs/superpowers/plans/2026-08-12-adaptive-reasoning-cursor-mode.md @@ -0,0 +1,1985 @@ +# Adaptive Reasoning and Direct Cursor Mode Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make reasoning controls match each provider/model and let users select and run exact Cursor Cloud Agent model variants directly from the web composer. + +**Architecture:** Chat models remain behind `llm.Client`, while Cursor remains an agent-capability provider with a dedicated SSE execution path. A shared reasoning-capability contract drives chat UI and adapter validation; a shared Cursor run service drives both tools and direct web runs. The frontend merges separate chat and Cursor catalogues only for search, never for active-provider mutation. + +**Tech Stack:** Go 1.24, `net/http`, SQLite/Postgres store abstraction, React 19, TypeScript, Bun, SSE, Cursor Cloud Agent REST API. + +## Global Constraints + +- Cursor must remain rejected by `/api/model/set`, `llm.New`, CLI model selection, and TUI chat-provider selection. +- Cursor variants are authoritative. Send one exact upstream variant, including hidden params; never synthesize a Cartesian product. +- Cursor start, follow-up, and cancel require explicit human approval even when general tool approval mode is `auto`. +- Never retry Cursor create-agent, create-run, or cancel automatically. +- Never expose API keys in logs, errors, approval data, events, fixtures, tests, or persisted metadata. +- Auto reasoning sends no override. Provider values are opaque and case-sensitive. +- Off is displayed only for a capability value explicitly marked `kind:"disable"`. +- New explicit invalid reasoning values fail before an upstream request. Unsupported legacy stored values fall back to Auto with a one-time notice. +- Existing YAML stays compatible; `reasoning_effort` remains a string. +- Browser disconnect and local Stop do not cancel a remote Cursor run. +- All non-live tests are hermetic. The optional live test may read metadata only and must not create a paid run. +- User-facing copy must be added to all existing locale maps. + +## File Structure + +### New backend files + +- `internal/llm/reasoning.go` — capability types, invariants, and value validation. +- `internal/llm/reasoning_catalog.go` — conservative provider/model capability catalogue. +- `internal/agent/reasoning.go` — effective-model capability resolution and legacy fallback. +- `internal/server/reasoning.go` — request/config/role validation boundary. +- `internal/approval/gate.go` — instance-owned generic operation approval gate. +- `internal/cursorrun/service.go` — shared Cursor lifecycle service and interface. +- `internal/cursorrun/catalog.go` — five-minute catalogue cache and exact variant validation. +- `internal/cursorrun/repository.go` — GitHub remote normalization and project inspection. +- `internal/store/cursor_sessions.go` — durable Cursor session/run state and compare-and-swap. +- `internal/server/handlers_cursor.go` — direct Cursor turn, cancel, and repository handlers. +- `internal/server/cursor_events.go` — Cursor-to-Antares event mapping and recovery. +- `internal/server/cursor_attachments.go` — strict Cursor image decoding and validation. + +### New frontend files + +- `web/src/lib/models.ts` — shared chat model and reasoning capability types. +- `web/src/lib/reasoning.ts` — adaptive options and per-model preference migration. +- `web/src/lib/cursorModels.ts` — Cursor catalogue types and exact-variant filtering. +- `web/src/lib/composerTargets.ts` — grouped chat/Cursor execution-target search. +- `web/src/lib/cursorAttachments.ts` — Cursor attachment preflight. +- `web/src/lib/chatEvents.ts` — approval-event parsing and deduplication. +- `web/src/components/chat/CursorOptions.tsx` — variant, mode, repo/ref, and PR controls. + +### Main modified files + +- Provider adapters under `internal/llm/`. +- `internal/agent/client.go`, `agent.go`, `harness.go`, and `approval.go`. +- Config schema/load and server config/model/role/chat handlers. +- Cursor types/client/stream and existing Cursor tools. +- Store types/migrations/sessions. +- Runtime construction under `cmd/antares/`. +- `ModelPicker`, `ReasoningPicker`, `ChatPage`, `ProvidersPage`, `RolesPage`, `ConfigPage`, and i18n. + +--- + +### Task 1: Define the reasoning capability contract + +**Files:** +- Create: `internal/llm/reasoning.go` +- Create: `internal/llm/reasoning_catalog.go` +- Create: `internal/llm/reasoning_test.go` +- Modify: `internal/llm/types.go` + +**Interfaces:** +- Produces: `ReasoningCapability`, `ReasoningValue`, `ValidateReasoningEffort`, and `StaticReasoningCapability`. +- Consumes: provider kind, provider ID, base URL, and exact model ID. + +- [ ] **Step 1: Write failing invariant and validation tests** + +```go +func TestReasoningCapabilityRejectsInconsistentDisableMetadata(t *testing.T) { + _, err := NewReasoningCapability( + []ReasoningValue{{Value: "none", Label: "Off", Kind: ReasoningValueDisable}}, + "", true, ReasoningCapabilityStatic, + ) + if err == nil || !strings.Contains(err.Error(), "mandatory") { + t.Fatalf("err = %v, want mandatory/disable conflict", err) + } +} + +func TestValidateReasoningEffortPreservesOpaqueValues(t *testing.T) { + cap, err := NewReasoningCapability( + []ReasoningValue{ + {Value: "extra-high", Label: "Extra High"}, + {Value: "xhigh", Label: "Extra High (new)"}, + }, + "extra-high", false, ReasoningCapabilityLive, + ) + if err != nil { + t.Fatal(err) + } + if err := ValidateReasoningEffort("gpt-example", cap, "extra-high"); err != nil { + t.Fatal(err) + } + if err := ValidateReasoningEffort("gpt-example", cap, "EXTRA-HIGH"); err == nil { + t.Fatal("case-normalized value was accepted") + } +} +``` + +Also add: + +- `TestValidateReasoningEffortAcceptsAuto` +- `TestValidateReasoningEffortRejectsUnknownOverride` +- `TestReasoningCapabilityRequiresUniqueValues` +- `TestReasoningCapabilityDefaultMustBeAllowed` + +- [ ] **Step 2: Run the focused test and confirm RED** + +Run: + +```bash +go test ./internal/llm -run 'Test(ReasoningCapability|ValidateReasoningEffort)' -count=1 -v +``` + +Expected: compile failure because the capability contract does not exist. + +- [ ] **Step 3: Implement capability types and invariants** + +```go +type ReasoningValueKind string + +const ReasoningValueDisable ReasoningValueKind = "disable" + +type ReasoningValue struct { + Value string `json:"value"` + Label string `json:"label"` + Kind ReasoningValueKind `json:"kind,omitempty"` +} + +type ReasoningCapabilitySource string + +const ( + ReasoningCapabilityLive ReasoningCapabilitySource = "live" + ReasoningCapabilityStatic ReasoningCapabilitySource = "static" +) + +type ReasoningCapability struct { + Values []ReasoningValue `json:"values"` + Default string `json:"default,omitempty"` + Mandatory bool `json:"mandatory"` + CanDisable bool `json:"can_disable"` + Source ReasoningCapabilitySource `json:"source"` +} + +type UnsupportedReasoningEffortError struct { + Model string + Effort string + Allowed []string +} +``` + +`NewReasoningCapability` must trim neither values nor case, reject empty or +duplicate values, require the default to be present, derive `CanDisable` from +exactly one disable marker, and reject a disable marker on mandatory models. +`ValidateReasoningEffort` must always accept `""` as Auto. + +- [ ] **Step 4: Add capability fields without removing the old boolean** + +```go +type Request struct { + // existing fields remain + ReasoningEffort string + ReasoningCapability *ReasoningCapability +} + +type ModelInfo struct { + // existing fields remain + Reasoning bool `json:"reasoning"` + ReasoningCapability *ReasoningCapability `json:"reasoning_capability,omitempty"` +} +``` + +Set `Reasoning` to true whenever a model has a non-nil capability, while +retaining existing JSON compatibility. + +- [ ] **Step 5: Write failing static-catalogue table tests** + +```go +func TestStaticReasoningCapabilityRepresentativeFamilies(t *testing.T) { + tests := []struct { + kind, provider, baseURL, model string + want []string + disable bool + }{ + {"openai", "openai", "https://api.openai.com/v1", "gpt-5", []string{"minimal", "low", "medium", "high"}, false}, + {"codex", "openai", "https://api.openai.com/v1", "gpt-5.3-codex", []string{"low", "medium", "high", "xhigh"}, false}, + {"anthropic", "anthropic", "https://api.anthropic.com", "claude-sonnet-5", []string{"low", "medium", "high", "xhigh", "max"}, false}, + {"gemini", "google", "https://generativelanguage.googleapis.com/v1beta", "gemini-3.6-flash", []string{"minimal", "low", "medium", "high"}, false}, + } + for _, tt := range tests { + cap := StaticReasoningCapability(tt.kind, tt.provider, tt.baseURL, tt.model) + if got := reasoningValues(cap); !slices.Equal(got, tt.want) { + t.Errorf("%s: got %v, want %v", tt.model, got, tt.want) + } + if cap.CanDisable != tt.disable { + t.Errorf("%s: can_disable=%v", tt.model, cap.CanDisable) + } + } +} + +func TestStaticReasoningCapabilityDoesNotGuessUnknownCompatibleModels(t *testing.T) { + if got := StaticReasoningCapability("openai-compatible", "custom", "https://example.test/v1", "gpt-5"); got != nil { + t.Fatalf("got %#v, want Auto-only", got) + } +} + +func reasoningValues(cap *ReasoningCapability) []string { + if cap == nil { + return nil + } + out := make([]string, 0, len(cap.Values)) + for _, value := range cap.Values { + out = append(out, value.Value) + } + return out +} +``` + +- [ ] **Step 6: Implement a narrow, documented static resolver** + +Use exact families and valid dated-snapshot suffixes. Do not use broad +`strings.Contains`. Keep separate tables for direct OpenAI, Codex, Anthropic, +and Gemini. Unknown models and arbitrary OpenAI-compatible endpoints return +nil. Put the official documentation URL next to each table. + +- [ ] **Step 7: Run the package tests and confirm GREEN** + +```bash +gofmt -w internal/llm/reasoning.go internal/llm/reasoning_catalog.go internal/llm/reasoning_test.go internal/llm/types.go +go test ./internal/llm -run 'Test(ReasoningCapability|ValidateReasoningEffort|StaticReasoning)' -count=1 -v +``` + +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add internal/llm/reasoning.go internal/llm/reasoning_catalog.go internal/llm/reasoning_test.go internal/llm/types.go +git commit -m "$(cat <<'EOF' +Define model-aware reasoning capabilities +EOF +)" +``` + +--- + +### Task 2: Make provider request bodies honor the capability contract + +**Files:** +- Create: `internal/llm/reasoning_request_test.go` +- Modify: `internal/llm/openai.go` +- Modify: `internal/llm/codex.go` +- Modify: `internal/llm/anthropic.go` +- Modify: `internal/llm/gemini.go` +- Modify: `internal/llm/gemini_test.go` + +**Interfaces:** +- Consumes: `Request.ReasoningCapability` and `StaticReasoningCapability`. +- Produces: validated provider-specific request bodies and OpenRouter live capability metadata. + +- [ ] **Step 1: Add exact-body tests for Auto and explicit disable** + +```go +func TestOpenRouterReasoningBodySendsExplicitDisable(t *testing.T) { + cap, _ := NewReasoningCapability( + []ReasoningValue{ + {Value: "none", Label: "Off", Kind: ReasoningValueDisable}, + {Value: "high", Label: "High"}, + }, + "high", false, ReasoningCapabilityLive, + ) + c := &openAIClient{opts: Options{BaseURL: "https://openrouter.ai/api/v1"}} + body := c.buildBody(Request{ + Model: "vendor/model", ReasoningEffort: "none", ReasoningCapability: cap, + }, false) + reasoning, ok := body["reasoning"].(map[string]any) + if !ok || reasoning["effort"] != "none" { + t.Fatalf("reasoning = %#v", body["reasoning"]) + } +} + +func TestReasoningAutoOmitsProviderFields(t *testing.T) { + req := Request{Model: "gpt-5", ReasoningEffort: ""} + if body := (&openAIClient{}).buildBody(req, false); body["reasoning_effort"] != nil { + t.Fatalf("OpenAI body = %#v", body) + } + if body := (&codexClient{}).buildBody(req, false); body["reasoning"] != nil { + t.Fatalf("Codex body = %#v", body) + } +} +``` + +- [ ] **Step 2: Add failing modern Anthropic and Gemini tests** + +```go +func TestAnthropicAdaptiveThinkingBody(t *testing.T) { + cap := StaticReasoningCapability("anthropic", "anthropic", "", "claude-sonnet-5") + body := (&anthropicClient{}).buildBody(Request{ + Model: "claude-sonnet-5", ReasoningEffort: "xhigh", ReasoningCapability: cap, + }, false) + if got, want := body["thinking"], map[string]any{"type": "adaptive"}; !reflect.DeepEqual(got, want) { + t.Fatalf("thinking = %#v, want %#v", got, want) + } + if got, want := body["output_config"], map[string]any{"effort": "xhigh"}; !reflect.DeepEqual(got, want) { + t.Fatalf("output_config = %#v, want %#v", got, want) + } +} + +func TestGemini3MinimalIsNotDisable(t *testing.T) { + got := geminiThinkingConfig("gemini-3.6-flash", "minimal") + want := map[string]any{"thinkingLevel": "MINIMAL", "includeThoughts": true} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %#v, want %#v", got, want) + } +} +``` + +Also add exact tests for Anthropic legacy fixed budget, Gemini 2.5 Flash budget +zero only when Off is supported, OpenAI `reasoning_effort`, and Codex nested +`reasoning.effort`. + +- [ ] **Step 3: Run focused tests and confirm RED** + +```bash +go test ./internal/llm -run 'Test(OpenAI|OpenRouter|Codex|Anthropic|Gemini).*Reason|TestGemini3Minimal' -count=1 -v +``` + +Expected: failures showing omitted disable, fixed Anthropic budgets, and missing +Gemini minimal handling. + +- [ ] **Step 4: Add a shared request-value validator** + +```go +func reasoningValue(req Request, kind, providerID, baseURL string) (string, error) { + value := req.ReasoningEffort + if value == "" { + return "", nil + } + capability := req.ReasoningCapability + if capability == nil { + capability = StaticReasoningCapability(kind, providerID, baseURL, req.Model) + } + if err := ValidateReasoningEffort(req.Model, capability, value); err != nil { + return "", err + } + return value, nil +} +``` + +Call this before every upstream HTTP request. Keep provider values unchanged; +remove adapter-wide lowercasing. + +- [ ] **Step 5: Parse OpenRouter live reasoning metadata** + +Add the wire type: + +```go +type openRouterReasoningMetadata struct { + SupportedEfforts []string `json:"supported_efforts"` + DefaultEffort string `json:"default_effort"` + DefaultEnabled *bool `json:"default_enabled"` + Mandatory bool `json:"mandatory"` + SupportsMaxTokens bool `json:"supports_max_tokens"` +} +``` + +Build a live capability in `openAIClient.Models`. Mark only OpenRouter's +documented `none` value as disable. Contradictory metadata returns no capability +instead of being repaired. Preserve whitelist behavior in the agent layer. + +- [ ] **Step 6: Implement provider-specific body mapping** + +- OpenAI Chat Completions: set `reasoning_effort` for every validated non-empty + value, including disable. +- OpenRouter: set `reasoning: {"effort": value}`. +- Codex Responses: set `reasoning: {"effort": value}`. +- Anthropic modern models: set `thinking: {"type":"adaptive"}` and + `output_config: {"effort": value}`; use `thinking: {"type":"disabled"}` only + for a marked disable value supported by that model. +- Anthropic legacy models: retain fixed budgets only for catalogued legacy + capabilities. +- Gemini 3: use `thinkingLevel`; Minimal has `includeThoughts:true`. +- Gemini legacy: use `thinkingBudget` only for catalogued legacy capabilities. + +- [ ] **Step 7: Prove invalid values fail before network I/O** + +Use an `httptest.Server` request counter. Call `Chat` with a capability that +allows only `low` and an explicit `max`; assert an +`UnsupportedReasoningEffortError` and zero requests. + +- [ ] **Step 8: Run adapter tests and full LLM package** + +```bash +gofmt -w internal/llm/openai.go internal/llm/codex.go internal/llm/anthropic.go internal/llm/gemini.go internal/llm/reasoning_request_test.go internal/llm/gemini_test.go +go test ./internal/llm -run 'Test.*(Reasoning|Thinking|Minimal)' -count=1 -v +go test ./internal/llm -count=1 +``` + +Expected: PASS, excluding credential-gated live tests. + +- [ ] **Step 9: Commit** + +```bash +git add internal/llm +git commit -m "$(cat <<'EOF' +Honor model-specific reasoning controls +EOF +)" +``` + +--- + +### Task 3: Resolve reasoning against the effective model in the agent + +**Files:** +- Create: `internal/agent/reasoning.go` +- Create: `internal/agent/reasoning_test.go` +- Modify: `internal/agent/client.go` +- Modify: `internal/agent/agent.go` +- Modify: `internal/agent/harness.go` +- Modify: `internal/llm/fallback.go` +- Modify: `cmd/antares/main.go` + +**Interfaces:** +- Produces: `Agent.ReasoningCapability` and `Agent.ValidateReasoningEffort`. +- Consumes: live/static model metadata and request/role/config precedence. + +- [ ] **Step 1: Add failing effective-value tests** + +```go +func TestResolveReasoningExplicitUnsupportedReturnsError(t *testing.T) { + a := agentWithConfig(config.Default()) + _, err := a.resolveReasoning(context.Background(), reasoningInput{ + ModelRef: "google/gemini-3.6-flash", + Explicit: "max", + }) + if err == nil || !llm.IsUnsupportedReasoningEffort(err) { + t.Fatalf("err = %v", err) + } +} + +func TestResolveReasoningUnsupportedStoredValueFallsBackToAuto(t *testing.T) { + cfg := config.Default() + cfg.Model.Provider = "google" + cfg.Model.Default = "gemini-3.6-flash" + cfg.Agent.ReasoningEffort = "max" + a := agentWithConfig(cfg) + got, err := a.resolveReasoning(context.Background(), reasoningInput{ModelRef: cfg.Model.Default}) + if err != nil || got.Value != "" || got.DiscardedLegacy != "max" { + t.Fatalf("got=%+v err=%v", got, err) + } +} +``` + +Add tests for role precedence, per-model live metadata, curated-model static +enrichment, and fallback replacement. + +- [ ] **Step 2: Run and confirm RED** + +```bash +go test ./internal/agent -run 'Test.*Reasoning|TestFallbackReplacesPrimaryReasoningCapability' -count=1 -v +``` + +- [ ] **Step 3: Implement agent-level resolution** + +```go +type reasoningInput struct { + ModelRef string + Explicit string + Role string + Agent string + Model string +} + +type reasoningResolution struct { + Value string + Capability *llm.ReasoningCapability + DiscardedLegacy string +} + +func (a *Agent) ReasoningCapability(ctx context.Context, modelRef string) (*llm.ReasoningCapability, error) +func (a *Agent) ValidateReasoningEffort(ctx context.Context, modelRef, effort string) error +func (a *Agent) resolveReasoning(ctx context.Context, in reasoningInput) (reasoningResolution, error) +``` + +Explicit values return errors. Role/agent/model stored values are tried in +precedence order and skipped when unsupported. Return the first discarded +legacy value for a one-time notice. + +- [ ] **Step 4: Enrich both live and curated model lists** + +`Agent.Models` must attach a valid live capability when present. Curated lists +stay whitelists: fetch live metadata only to enrich matching IDs, never append +unlisted models. If live enrichment fails, apply the static resolver. + +- [ ] **Step 5: Carry per-entry capability through fallback** + +```go +type FallbackEntry struct { + Client Client + Model string + ReasoningCapability *ReasoningCapability +} +``` + +Before each fallback call, replace both `Request.Model` and +`Request.ReasoningCapability`. A legacy configured value unsupported by the +fallback entry becomes Auto; an explicit model override has no fallback chain. + +- [ ] **Step 6: Resolve once before the agent turn loop** + +In `Agent.Run`, resolve effective reasoning after the final role/model is known +and before the first model call. Attach both value and capability to every +`llm.Request`. Emit one `EventNotice` if a stored value was discarded. + +Change `applyRole` so it does not erase whether effort came from explicit +request data versus stored role metadata. + +- [ ] **Step 7: Remove the unconditional relevance-classifier `"low"`** + +Resolve the classifier model's capability. Use `low` only when supported; +otherwise use Auto. Add a regression test around `messageIsRelevant`. + +- [ ] **Step 8: Run agent/fallback tests** + +```bash +gofmt -w internal/agent/reasoning.go internal/agent/reasoning_test.go internal/agent/client.go internal/agent/agent.go internal/agent/harness.go internal/llm/fallback.go cmd/antares/main.go +go test ./internal/agent ./internal/llm ./cmd/antares -run 'Reasoning|Fallback|MessageIsRelevant' -count=1 +go test -race ./internal/agent ./internal/llm -count=1 +``` + +- [ ] **Step 9: Commit** + +```bash +git add internal/agent internal/llm/fallback.go cmd/antares/main.go +git commit -m "$(cat <<'EOF' +Resolve reasoning against the effective model +EOF +)" +``` + +--- + +### Task 4: Validate reasoning in server, config, and role boundaries + +**Files:** +- Create: `internal/server/reasoning.go` +- Create: `internal/server/reasoning_test.go` +- Create: `internal/config/schema_reasoning_test.go` +- Modify: `internal/config/defaults.go` +- Modify: `internal/config/schema.go` +- Modify: `internal/config/load.go` +- Modify: `internal/server/handlers_chat.go` +- Modify: `internal/server/handlers_config.go` +- Modify: `internal/server/handlers_providers.go` +- Modify: `internal/server/handlers_roles.go` + +**Interfaces:** +- Consumes: `Agent.ValidateReasoningEffort` and `Agent.Models`. +- Produces: additive `reasoning_capability` API data and mutation-safe validation. + +- [ ] **Step 1: Add config schema and parse-without-write tests** + +```go +func TestSchemaMarksReasoningFieldsModelAware(t *testing.T) { + schema := Schema() + for _, path := range []string{"agent.reasoning_effort", "model.reasoning_effort"} { + field := fieldByPath(t, schema, path) + if len(field.Enum) != 0 || field.OptionsSource != "reasoning_capability" { + t.Fatalf("%s = %+v", path, field) + } + } +} + +func TestParseRawDoesNotWriteConfiguration(t *testing.T) { + before := mustReadConfigFile(t) + if _, err := ParseRaw("model:\n default: gpt-5\n"); err != nil { + t.Fatal(err) + } + if after := mustReadConfigFile(t); after != before { + t.Fatal("ParseRaw changed the config file") + } +} + +func fieldByPath(t *testing.T, fields []Field, path string) Field { + t.Helper() + for _, field := range fields { + if field.Path == path { + return field + } + } + t.Fatalf("field %q not found", path) + return Field{} +} +``` + +- [ ] **Step 2: Implement schema marker and parser** + +Add `OptionsSource string 'json:"options_source,omitempty"'` to `config.Field`. +Remove both static reasoning enums. Make fresh defaults `""` (Auto), but do not +rewrite persisted values. Extract `ParseRaw` and make `SaveRaw` call it. +Define `mustReadConfigFile` in the test with `os.ReadFile(ConfigFile())` and +restore the original bytes through `t.Cleanup`. Set `ANTARES_HOME` to +`t.TempDir()` before calling `ConfigFile()` so the real user config is never +touched. + +- [ ] **Step 3: Add failing server mutation tests** + +Cover: + +- `TestHandleModelListAllIncludesReasoningCapability` +- `TestHandleProviderModelInfoReadsModelQueryAndIncludesCapability` +- `TestHandleChatRejectsUnsupportedReasoningBeforeChatRequest` +- `TestHandleUpdateConfigRejectsChangedUnsupportedReasoningWithoutSaving` +- `TestHandleUpdateConfigAllowsUnrelatedEditWithLegacyUnsupportedReasoning` +- `TestHandleSaveRawConfigRejectsNewUnsupportedReasoningWithoutSaving` +- `TestHandleSaveRoleRejectsExplicitUnsupportedReasoning` + +Each rejected mutation must compare the config/role file before and after. + +- [ ] **Step 4: Run and confirm RED** + +```bash +go test ./internal/config ./internal/server -run 'Reasoning|ParseRaw|ProviderModelInfo' -count=1 -v +``` + +- [ ] **Step 5: Implement the server validation boundary** + +```go +func (s *Server) validateExplicitReasoning( + ctx context.Context, + cfg *config.Config, + modelRef string, + effort string, +) error { + if effort == "" { + return nil + } + return s.agent.ValidateReasoningEffort(ctx, modelRef, effort) +} +``` + +Validate chat request effort before opening SSE/model I/O. For dotted config, +raw config, and role saves, validate only newly introduced or changed values. +Unchanged legacy values remain loadable and resolve to Auto at runtime. + +- [ ] **Step 6: Fix model-info lookup and expose capability** + +Change `r.URL.Query().Get("&model")` to `r.URL.Query().Get("model")`. +Return `reasoning_capability` from model info and list-all while retaining the +legacy `reasoning` boolean. + +- [ ] **Step 7: Run focused and package tests** + +```bash +gofmt -w internal/config/defaults.go internal/config/schema.go internal/config/load.go internal/config/schema_reasoning_test.go internal/server/reasoning.go internal/server/reasoning_test.go internal/server/handlers_chat.go internal/server/handlers_config.go internal/server/handlers_providers.go internal/server/handlers_roles.go +go test ./internal/config ./internal/server -run 'Reasoning|ParseRaw|ProviderModelInfo' -count=1 +go test ./internal/config ./internal/server -count=1 +``` + +- [ ] **Step 8: Commit** + +```bash +git add internal/config internal/server +git commit -m "$(cat <<'EOF' +Validate reasoning at configuration boundaries +EOF +)" +``` + +--- + +### Task 5: Replace static reasoning UI with model-aware controls + +**Files:** +- Create: `web/src/lib/models.ts` +- Create: `web/src/lib/reasoning.ts` +- Create: `web/src/lib/reasoning.test.mjs` +- Modify: `web/src/components/chat/ModelPicker.tsx` +- Modify: `web/src/components/chat/ReasoningPicker.tsx` +- Modify: `web/src/pages/ChatPage.tsx` +- Modify: `web/src/pages/RolesPage.tsx` +- Modify: `web/src/pages/ConfigPage.tsx` +- Modify: `web/src/pages/ModelsPage.tsx` +- Modify: `web/src/pages/ProvidersPage.tsx` +- Modify: `web/src/lib/i18n.tsx` + +**Interfaces:** +- Consumes: API `reasoning_capability`. +- Produces: per-model Auto/effort state and reusable `ChatModelSelection`. + +- [ ] **Step 1: Add failing pure helper tests** + +```javascript +test('options preserve opaque values and mark only explicit disable', () => { + const cap = { + values: [ + { value: 'none', label: 'Off', kind: 'disable' }, + { value: 'extra-high', label: 'Extra High' }, + ], + default: 'extra-high', + mandatory: false, + can_disable: true, + source: 'live', + } + expect(reasoningOptions(cap).map((x) => x.value)).toEqual(['', 'none', 'extra-high']) +}) + +test('legacy preference migrates once only when valid', () => { + const storage = memoryStorage({ 'antares:reasoning': 'high' }) + const cap = capability(['low', 'high']) + expect(loadReasoningPreference(storage, 'openai', 'gpt-5', cap)).toEqual({ + value: 'high', + migrated: true, + }) + expect(storage.getItem('antares:reasoning')).toBeNull() + expect(storage.getItem(reasoningPreferenceKey('openai', 'gpt-5'))).toBe('high') +}) + +function memoryStorage(initial = {}) { + const values = new Map(Object.entries(initial)) + return { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => values.set(key, value), + removeItem: (key) => values.delete(key), + } +} + +function capability(values) { + return { + values: values.map((value) => ({ value, label: value })), + mandatory: false, + can_disable: false, + source: 'static', + } +} +``` + +Add tests for Auto-only models, mandatory models, invalid scoped values, and +provider/model storage-key isolation. + +- [ ] **Step 2: Run and confirm RED** + +```bash +cd web && bun test src/lib/reasoning.test.mjs +``` + +- [ ] **Step 3: Implement shared types and preference helpers** + +```ts +export interface ReasoningValue { + value: string + label: string + kind?: 'disable' +} + +export interface ReasoningCapability { + values: ReasoningValue[] + default?: string + mandatory: boolean + can_disable: boolean + source: 'live' | 'static' +} + +export interface ChatModelSelection { + provider: string + model: string + name: string + providerLabel: string + reasoningCapability?: ReasoningCapability +} +``` + +Use `antares:reasoning:v2::`. Always remove the +old global key after one migration attempt. + +- [ ] **Step 4: Make `ReasoningPicker` presentational** + +```ts +export interface ReasoningPickerProps { + value: string + capability?: ReasoningCapability + onChange(value: string): void + compact?: boolean +} +``` + +Render Auto plus exact capability values. Hide the chip when no capability is +present. Never turn `capability.default` into an explicit override. + +- [ ] **Step 5: Make `ModelPicker` return the full selection** + +Change `onModelChange` to receive `ChatModelSelection`. On initial load, resolve +active metadata through `/providers/{id}/model-info?model=...`; picker rows use +the capability already returned by `/model/list-all`. + +- [ ] **Step 6: Scope composer reasoning by model** + +In `ChatPage`, load/sanitize the preference synchronously whenever provider or +model changes. Store capability and value together in a ref used by `sendText` +so an old model's value cannot leak into the next request. + +- [ ] **Step 7: Replace role/config enums** + +Roles resolve capability from the role's explicit model or inherited active +model. Config fields with `options_source:"reasoning_capability"` render the +same options. Show an unchanged unsupported legacy value as disabled with a +one-time Auto notice; unrelated saves must not rewrite it. + +- [ ] **Step 8: Add copy to all locale maps and verify** + +Add translations for Auto, unsupported legacy value, adaptive/default hint, +mandatory reasoning, and provider-controlled reasoning. + +```bash +cd web +bun test src/lib/reasoning.test.mjs +bun test +bun x tsc -b --noEmit +bun run build +``` + +- [ ] **Step 9: Commit** + +```bash +git add web/src +git commit -m "$(cat <<'EOF' +Adapt reasoning controls to each model +EOF +)" +``` + +--- + +### Task 6: Extend Cursor wire types and resumable stream recovery + +**Files:** +- Modify: `internal/cursor/types.go` +- Modify: `internal/cursor/client_test.go` +- Modify: `internal/cursor/stream.go` +- Modify: `internal/cursor/stream_test.go` + +**Interfaces:** +- Produces: prompt images, complete tool events, and `StreamRunWithOptions`. +- Preserves: existing `StreamRun` API as a compatibility wrapper. + +- [ ] **Step 1: Add failing image and exact-model encoding test** + +```go +func TestCreateAgentEncodesPromptImagesAndExactModelParams(t *testing.T) { + // Capture the request body in an httptest server. + want := CreateAgentRequest{ + Prompt: Prompt{ + Text: "inspect this", + Images: []PromptImage{{Data: "aGVsbG8=", MimeType: "image/png"}}, + }, + Model: &ModelSelection{ + ID: "gpt-5.6-sol", + Params: []ModelParameterSelection{ + {ID: "context", Value: "1m"}, + {ID: "reasoning", Value: "max"}, + {ID: "fast", Value: "true"}, + }, + }, + } + // Assert decoded body equals want exactly. +} +``` + +- [ ] **Step 2: Add failing persisted-event-ID and reset tests** + +Cover: + +- initial request carries the supplied `Last-Event-ID`; +- a 410/invalid ID calls `OnReset` before replay; +- terminal result still wins over a later retryable read error; +- full tool-call ID, args, result, and truncation fields survive decoding. + +- [ ] **Step 3: Run and confirm RED** + +```bash +go test ./internal/cursor -run 'CreateAgentEncodes|StreamRunWithOptions|CompleteToolCall' -count=1 -v +``` + +- [ ] **Step 4: Add prompt image and richer stream types** + +```go +type PromptImage struct { + Data string `json:"data,omitempty"` + URL string `json:"url,omitempty"` + MimeType string `json:"mimeType,omitempty"` +} + +type Prompt struct { + Text string `json:"text"` + Images []PromptImage `json:"images,omitempty"` +} + +type StreamOptions struct { + LastEventID string + OnReset func() error +} +``` + +Extend `StreamEvent` with run/call IDs, raw tool args/result, and truncation. + +- [ ] **Step 5: Implement `StreamRunWithOptions`** + +```go +func (c *Client) StreamRunWithOptions( + ctx context.Context, + agentID, runID string, + options StreamOptions, + emit func(StreamEvent) error, +) (*Run, error) +``` + +`StreamRun` calls this with empty options. When the upstream resume token is +invalid, invoke `OnReset`, clear the token once, then reconnect. + +- [ ] **Step 6: Run Cursor package tests** + +```bash +gofmt -w internal/cursor/types.go internal/cursor/client_test.go internal/cursor/stream.go internal/cursor/stream_test.go +go test ./internal/cursor -count=1 +go test -race ./internal/cursor -count=1 +``` + +- [ ] **Step 7: Commit** + +```bash +git add internal/cursor +git commit -m "$(cat <<'EOF' +Support resumable rich Cursor streams +EOF +)" +``` + +--- + +### Task 7: Build the shared Cursor catalogue and run service + +**Files:** +- Create: `internal/cursorrun/service.go` +- Create: `internal/cursorrun/catalog.go` +- Create: `internal/cursorrun/service_test.go` +- Modify: `internal/server/handlers_providers.go` +- Modify: `internal/server/server.go` +- Modify: `internal/server/cursor_provider_test.go` + +**Interfaces:** +- Produces: `cursorrun.Runner`. +- Consumes: resolved Cursor provider options and `internal/cursor.Client`. + +- [ ] **Step 1: Write failing cache and exact-variant tests** + +```go +func TestValidateModelAcceptsHiddenVariantParams(t *testing.T) { + model := cursor.Model{ + ID: "claude-opus-5", + Parameters: []cursor.ModelParameter{{ID: "effort"}}, + Variants: []cursor.ModelVariant{{ + Params: []cursor.ModelParameterSelection{ + {ID: "cyber", Value: "false"}, + {ID: "effort", Value: "max"}, + }, + IsDefault: true, + }}, + } + runner := newTestRunner(t, cursor.ModelCatalog{Items: []cursor.Model{model}}) + got, err := runner.ValidateModel(context.Background(), &cursor.ModelSelection{ + ID: model.ID, + Params: model.Variants[0].Params, + }, RequireExactVariant) + if err != nil || !reflect.DeepEqual(got.Params, model.Variants[0].Params) { + t.Fatalf("got=%+v err=%v", got, err) + } +} + +func newTestRunner(t *testing.T, catalog cursor.ModelCatalog) Runner { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/models" { + http.NotFound(w, r) + return + } + _ = json.NewEncoder(w).Encode(catalog) + })) + t.Cleanup(srv.Close) + return New(Options{ + ResolveClient: func() (cursor.Options, error) { + return cursor.Options{ + BaseURL: srv.URL, APIKey: "synthetic-key", HTTPClient: srv.Client(), + }, nil + }, + Now: time.Now, CatalogTTL: 5 * time.Minute, + }) +} +``` + +Add tests for five-minute TTL, key-change fingerprint isolation, explicit +invalidation, duplicate param IDs, order-insensitive matching, no synthetic +combination, stale refresh exactly once, and tool omission preserving default. + +- [ ] **Step 2: Run and confirm RED** + +```bash +go test ./internal/cursorrun -count=1 +``` + +- [ ] **Step 3: Define the service interface** + +```go +type SelectionPolicy uint8 + +const ( + PreserveUpstreamDefault SelectionPolicy = iota + RequireExactVariant +) + +type Runner interface { + Catalog(ctx context.Context, force bool) (*cursor.ModelCatalog, error) + InvalidateCatalog() + ValidateModel(ctx context.Context, selection *cursor.ModelSelection, policy SelectionPolicy) (*cursor.ModelSelection, error) + CreateAgent(ctx context.Context, req cursor.CreateAgentRequest) (*cursor.CreateAgentResponse, error) + CreateRun(ctx context.Context, agentID string, req cursor.CreateRunRequest) (*cursor.Run, error) + GetAgent(ctx context.Context, agentID string) (*cursor.Agent, error) + GetRun(ctx context.Context, agentID, runID string) (*cursor.Run, error) + CancelRun(ctx context.Context, agentID, runID string) error + StreamRun(ctx context.Context, agentID, runID, lastEventID string, onReset func() error, emit func(cursor.StreamEvent) error) (*cursor.Run, error) + Progress(cursor.StreamEvent) Progress +} + +type ClientResolver func() (cursor.Options, error) + +type Options struct { + ResolveClient ClientResolver + Now func() time.Time + CatalogTTL time.Duration +} + +type Progress struct { + Message string + Chunk string +} +``` + +The production constructor receives `Options` with a five-minute TTL. + +- [ ] **Step 4: Implement cache and canonical matching** + +Cache per normalized base URL plus SHA-256 credential fingerprint; never expose +the fingerprint. Canonical matching sorts copies by param ID, rejects duplicate +IDs, and returns the original upstream variant order. Empty params are valid +only for a model with no variants or the backward-compatible tool policy. + +- [ ] **Step 5: Implement lifecycle delegation and redaction** + +Delegate to a freshly resolved client. Keep create/run/cancel single-attempt. +Centralize bounded progress and sanitize catalogue, stream, error, and Git text +with the existing Cursor redaction policy. + +- [ ] **Step 6: Return the full Cursor catalogue from the provider endpoint** + +Include: + +```go +type modelOut struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Aliases []string `json:"aliases"` + Parameters []cursor.ModelParameter `json:"parameters"` + Variants []cursor.ModelVariant `json:"variants"` +} +``` + +Normalize nil arrays to empty arrays. Use the shared runner cache. Invalidate it +after key/settings changes through `Server.SetConfig`. + +- [ ] **Step 7: Run service and provider tests** + +```bash +gofmt -w internal/cursorrun internal/server/handlers_providers.go internal/server/server.go internal/server/cursor_provider_test.go +go test ./internal/cursorrun ./internal/server -run 'Catalog|Variant|ProviderModels' -count=1 +go test -race ./internal/cursorrun -count=1 +``` + +- [ ] **Step 8: Commit** + +```bash +git add internal/cursorrun internal/server +git commit -m "$(cat <<'EOF' +Share Cursor catalogue and run lifecycle +EOF +)" +``` + +--- + +### Task 8: Inspect repositories and validate Cursor attachments + +**Files:** +- Create: `internal/cursorrun/repository.go` +- Create: `internal/cursorrun/repository_test.go` +- Create: `internal/server/cursor_attachments.go` +- Create: `internal/server/cursor_attachments_test.go` +- Create: `internal/server/handlers_cursor_repository.go` +- Modify: `internal/server/routes.go` +- Modify: `internal/server/handlers_project_env.go` + +**Interfaces:** +- Produces: `InspectRepository`, `NormalizeGitHubRepository`, and `decodeCursorImages`. + +- [ ] **Step 1: Add repository normalization tests** + +```go +func TestNormalizeGitHubRepository(t *testing.T) { + tests := map[string]string{ + "git@github.com:owner/repo.git": "https://github.com/owner/repo", + "ssh://git@github.com/owner/repo.git": "https://github.com/owner/repo", + "https://github.com/owner/repo.git": "https://github.com/owner/repo", + "https://github.com/owner/repo": "https://github.com/owner/repo", + } + for in, want := range tests { + got, err := NormalizeGitHubRepository(in) + if err != nil || got != want { + t.Errorf("%q => %q, %v; want %q", in, got, err, want) + } + } +} +``` + +Reject credentials, queries/fragments, local paths, non-GitHub hosts, and paths +other than exactly `owner/repo`. + +- [ ] **Step 2: Add linked-worktree and dirty/ahead tests** + +Create temporary Git repositories using `git init`, a bare remote, and +`git worktree add`. Verify origin, branch/detached SHA, dirty state, known +remote-tracking ref, and local-only commit count without fetching the network. + +- [ ] **Step 3: Add strict image validation tests** + +Cover exactly five PNG/JPEG/GIF/WebP images, six-image rejection, unsupported +MIME, decoded payload above 15 MiB, and MIME signature mismatch. Assert failures +occur before an approval/upstream callback. Also test strict JSON decoding and +a 105 MiB route-specific request cap so five legal 15 MiB images are possible +after base64 expansion while larger bodies fail with 413. + +- [ ] **Step 4: Run and confirm RED** + +```bash +go test ./internal/cursorrun ./internal/server -run 'Repository|CursorImages' -count=1 -v +``` + +- [ ] **Step 5: Implement repository inspection using Git commands** + +```go +type RepositoryInfo struct { + Repository bool `json:"repository"` + URL string `json:"url,omitempty"` + StartingRef string `json:"starting_ref,omitempty"` + Dirty bool `json:"dirty"` + LocalOnlyCommits int `json:"local_only_commits"` + RemoteRefKnown bool `json:"remote_ref_known"` + Warning string `json:"warning,omitempty"` +} +``` + +Use `git rev-parse --is-inside-work-tree`, not `.git` directory checks. + +- [ ] **Step 6: Implement strict Cursor image decoding** + +```go +func decodeCursorImages(dataURLs []string) ([]cursor.PromptImage, error) +func decodeCursorChatBody(w http.ResponseWriter, r *http.Request, dst any) error +``` + +Allow maximum five, exact supported MIME types, base64 data URLs only, and +15 MiB decoded per image. Sniff decoded bytes before returning. Do not retain a +second decoded copy after validation. `decodeCursorChatBody` uses +`http.MaxBytesReader` with a 105 MiB cap and +`json.Decoder.DisallowUnknownFields`. + +- [ ] **Step 7: Add repository preflight route** + +Register `GET /api/project/cursor-repository?dir=...`. Resolve the project path +through existing project security checks, require dashboard authentication, +and return `RepositoryInfo`. + +- [ ] **Step 8: Verify and commit** + +```bash +gofmt -w internal/cursorrun/repository.go internal/cursorrun/repository_test.go internal/server/cursor_attachments.go internal/server/cursor_attachments_test.go internal/server/handlers_cursor_repository.go internal/server/routes.go internal/server/handlers_project_env.go +go test ./internal/cursorrun ./internal/server -run 'Repository|CursorImages' -count=1 +git add internal/cursorrun internal/server +git commit -m "$(cat <<'EOF' +Validate Cursor repositories and images +EOF +)" +``` + +--- + +### Task 9: Extract an explicit operation approval gate + +**Files:** +- Create: `internal/approval/gate.go` +- Create: `internal/approval/gate_test.go` +- Modify: `internal/agent/approval.go` +- Modify: `internal/agent/agent.go` +- Modify: `internal/server/handlers_approval.go` +- Modify: `internal/tools/registry.go` +- Modify: `internal/tools/cursor_agent.go` +- Modify: `internal/tools/cursor_agent_test.go` + +**Interfaces:** +- Produces: instance-owned `approval.Gate` and `tools.OperationApproval`. +- Preserves: existing `/api/approvals` list/resolve endpoints. + +- [ ] **Step 1: Add failing generic gate tests** + +```go +func TestGateRetainsImmutableOperation(t *testing.T) { + g := NewGate(time.Minute) + op := Operation{SessionID: "ses-1", Tool: "cursor_agent", Arguments: `{"model":"a"}`, Message: "Start Cursor"} + emitted := make(chan Request, 1) + done := make(chan bool, 1) + go func() { + ok, _ := g.Await(context.Background(), op, func(r Request) error { + emitted <- r + return nil + }) + done <- ok + }() + req := <-emitted + op.Arguments = `{"model":"b"}` + if !g.Resolve(req.ID, true) || !<-done { + t.Fatal("approval did not resolve") + } + if got := req.Arguments; got != `{"model":"a"}` { + t.Fatalf("arguments mutated: %s", got) + } +} +``` + +Add oldest-first, deny, timeout, context cancellation, unknown ID, and +concurrent-resolution tests. + +- [ ] **Step 2: Run and confirm RED** + +```bash +go test ./internal/approval -count=1 +``` + +- [ ] **Step 3: Implement the instance-owned gate** + +```go +type Operation struct { + SessionID string + Tool string + Arguments string + Message string + Reason string +} + +type Gate struct { + mu sync.Mutex + pending map[string]*pendingRequest + timeout time.Duration +} +``` + +Deep-copy strings, sort `Pending()` by `CreatedAt` then ID, and remove requests +exactly once. + +- [ ] **Step 4: Attach one gate to each Agent** + +Construct it in `agent.New`. Keep `PendingApprovals` and `ResolveApproval` as +delegating compatibility methods used by server handlers. + +- [ ] **Step 5: Add explicit Cursor operation classification** + +```go +type OperationApproval interface { + ApprovalOperation(args json.RawMessage, sessionID string) (approval.Operation, error) +} +``` + +`cursorAgentTool` returns a bounded/redacted display projection containing +operation, model/params, repo/ref, mode, auto-PR, and IDs. It must exclude full +prompt text, images, and API key. + +- [ ] **Step 6: Force explicit Cursor approval** + +In `agent.checkApproval`, an `OperationApproval` tool always calls the gate, +regardless of general auto mode. Normal tools retain current auto/prompt/deny +behavior. + +- [ ] **Step 7: Add regressions and run tests** + +```bash +go test ./internal/approval ./internal/agent ./internal/tools -run 'Approval|Cursor.*Approval|Denied|Expired' -count=1 +go test -race ./internal/approval ./internal/agent ./internal/tools -count=1 +``` + +Assert start/follow-up/cancel send zero upstream requests until allowed. + +- [ ] **Step 8: Commit** + +```bash +git add internal/approval internal/agent internal/server/handlers_approval.go internal/tools +git commit -m "$(cat <<'EOF' +Require explicit approval for Cursor operations +EOF +)" +``` + +--- + +### Task 10: Persist recoverable Cursor session state + +**Files:** +- Create: `internal/store/cursor_sessions.go` +- Create: `internal/store/cursor_sessions_test.go` +- Modify: `internal/store/types.go` +- Modify: `internal/store/migrations.go` +- Modify: `internal/store/sessions.go` + +**Interfaces:** +- Produces: durable, compare-and-swap Cursor run state and idempotent finalization. + +- [ ] **Step 1: Add failing migration and round-trip tests** + +Cover: + +- round trip and session cascade delete; +- compare-and-swap rejecting a competing turn; +- recoverable list excluding committed terminal runs; +- reuse invalidation preserving agent/run IDs; +- atomic, idempotent final assistant commit. + +- [ ] **Step 2: Run and confirm RED** + +```bash +go test ./internal/store -run CursorSession -count=1 -v +``` + +- [ ] **Step 3: Define the state model** + +```go +type CursorSessionState struct { + SessionID string `json:"session_id"` + TargetActive bool `json:"target_active"` + ReuseValid bool `json:"reuse_valid"` + ModelID string `json:"model_id"` + ModelParams string `json:"model_params"` + RepositoryURL string `json:"repository_url"` + StartingRef string `json:"starting_ref"` + Mode string `json:"mode"` + AutoCreatePR bool `json:"auto_create_pr"` + AgentID string `json:"agent_id"` + RunID string `json:"run_id"` + RemoteStatus string `json:"remote_status"` + LastEventID string `json:"last_event_id"` + PartialText string `json:"partial_text"` + PartialReasoning string `json:"partial_reasoning"` + GitState string `json:"git_state"` + OperationState string `json:"operation_state"` + UserMessageID string `json:"user_message_id"` + AssistantMessageID string `json:"assistant_message_id"` + Revision int64 `json:"revision"` + UpdatedAt time.Time `json:"updated_at"` +} +``` + +Allowed operation states are `idle`, `awaiting_approval`, `create_in_flight`, +`run_in_flight`, `terminal`, `committed`, and `ambiguous`. + +- [ ] **Step 4: Add SQLite and Postgres migration SQL** + +Use a primary/foreign key on `session_id` with cascade delete, a revision +column, and an index on operation state. Do not store API keys or image data. + +- [ ] **Step 5: Extend the Store interface and implementations** + +```go +PutCursorSessionState(context.Context, *CursorSessionState) error +GetCursorSessionState(context.Context, string) (*CursorSessionState, error) +ListRecoverableCursorSessionStates(context.Context) ([]CursorSessionState, error) +CompareAndSwapCursorSessionState(context.Context, *CursorSessionState, int64) (bool, error) +InvalidateCursorReuse(context.Context, string) error +CommitCursorAssistant(context.Context, *CursorSessionState, *Message) error +``` + +Canonicalize model params before persistence. `CommitCursorAssistant` appends a +deterministic run-associated message and marks committed in one transaction. + +- [ ] **Step 6: Run store tests and race tests** + +```bash +gofmt -w internal/store/cursor_sessions.go internal/store/cursor_sessions_test.go internal/store/types.go internal/store/migrations.go internal/store/sessions.go +go test ./internal/store -run CursorSession -count=1 +go test -race ./internal/store -count=1 +``` + +- [ ] **Step 7: Commit** + +```bash +git add internal/store +git commit -m "$(cat <<'EOF' +Persist recoverable Cursor run state +EOF +)" +``` + +--- + +### Task 11: Refactor existing Cursor tools onto the shared runner + +**Files:** +- Modify: `internal/tools/deps.go` +- Modify: `internal/tools/cursor_agent.go` +- Modify: `internal/tools/cursor_agent_test.go` +- Modify: `internal/agent/agent.go` +- Modify: `cmd/antares/main.go` +- Modify: `cmd/antares/setup.go` + +**Interfaces:** +- Consumes: `cursorrun.Runner`. +- Produces: backward-compatible `cursor_agent` with optional `model_params`. + +- [ ] **Step 1: Add failing backward-compatibility and exact-param tests** + +```go +func TestCursorAgentStartAcceptsExactModelParams(t *testing.T) { + args := `{ + "action":"start", + "prompt":"fix it", + "model":"gpt-5.6-sol", + "model_params":[ + {"id":"context","value":"1m"}, + {"id":"reasoning","value":"max"} + ] + }` + // Execute with a fake Runner and assert CreateAgent receives these params exactly. +} + +func TestCursorAgentModelWithoutParamsPreservesUpstreamDefault(t *testing.T) { + // Existing callers with only "model" must not be forced onto a variant. +} +``` + +- [ ] **Step 2: Run and confirm RED** + +```bash +go test ./internal/tools -run 'CursorAgent.*(Params|Default|Runner)' -count=1 -v +``` + +- [ ] **Step 3: Add the runner dependency** + +```go +type Deps struct { + // existing fields + Cursor cursorrun.Runner +} +``` + +Add `cursorRunner` plus `SetCursorRunner` on `Agent`, and include it in every +tool `Input.Deps`. + +- [ ] **Step 4: Construct the runner at runtime scope** + +Create one runner in `runtimeServices` with a resolver closure that reads the +current atomically published config. Inject the same instance into the Agent and +Server. Config reload invalidates its catalogue. + +- [ ] **Step 5: Refactor tools to call the runner** + +Remove duplicated client construction, streaming, progress bounding, and error +classification from `cursor_agent.go`. Add: + +```go +ModelParams []cursor.ModelParameterSelection `json:"model_params"` +``` + +Omitted params use `PreserveUpstreamDefault`; provided params are exact-variant +validated. Keep all existing result text/meta and timeout semantics. + +- [ ] **Step 6: Run existing and new tool/runtime tests** + +```bash +gofmt -w internal/tools/deps.go internal/tools/cursor_agent.go internal/tools/cursor_agent_test.go internal/agent/agent.go cmd/antares/main.go cmd/antares/setup.go +go test ./internal/tools ./internal/agent ./cmd/antares -run 'Cursor|Runtime' -count=1 +go test -race ./internal/tools ./internal/agent -count=1 +``` + +- [ ] **Step 7: Commit** + +```bash +git add internal/tools internal/agent/agent.go cmd/antares +git commit -m "$(cat <<'EOF' +Run Cursor tools through the shared service +EOF +)" +``` + +--- + +### Task 12: Add the direct Cursor SSE coordinator and recovery + +**Files:** +- Create: `internal/server/handlers_cursor.go` +- Create: `internal/server/cursor_events.go` +- Create: `internal/server/handlers_cursor_test.go` +- Modify: `internal/server/livechat.go` +- Modify: `internal/server/handlers_chat.go` +- Modify: `internal/server/routes.go` +- Modify: `internal/server/server.go` + +**Interfaces:** +- Produces: `/api/chat/cursor`, `/api/chat/cursor/cancel`, and persisted attach recovery. +- Consumes: approval gate, Cursor runner, repository/image validation, store state, and live-run hub. + +- [ ] **Step 1: Add no-request-before-approval SSE test** + +```go +func TestCursorChatSendsNoUpstreamRequestBeforeApproval(t *testing.T) { + s, fake := newCursorDirectTestServer(t) + stream := postCursorChat(t, s, cursorChatRequest{ + Message: "fix it", + Model: cursor.ModelSelection{ + ID: "gpt-5.6-sol", + Params: []cursor.ModelParameterSelection{{ID: "reasoning", Value: "max"}}, + }, + }) + approval := stream.NextType(t, agent.EventApproval) + if got := fake.CreateAgentCalls(); got != 0 { + t.Fatalf("CreateAgent calls before approval = %d", got) + } + resolveApproval(t, s, approval.ID, true) + stream.NextType(t, agent.EventToolProgress) + if got := fake.CreateAgentCalls(); got != 1 { + t.Fatalf("CreateAgent calls after approval = %d", got) + } +} + +type fakeCursorRunner struct { + mu sync.Mutex + createCalls int +} + +func (f *fakeCursorRunner) CreateAgent(context.Context, cursor.CreateAgentRequest) (*cursor.CreateAgentResponse, error) { + f.mu.Lock() + f.createCalls++ + f.mu.Unlock() + return &cursor.CreateAgentResponse{ + Agent: cursor.Agent{ID: "bc-test"}, + Run: cursor.Run{ID: "run-test", Status: "CREATING"}, + }, nil +} + +func (f *fakeCursorRunner) CreateAgentCalls() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.createCalls +} +``` + +The test file's `newCursorDirectTestServer`, `postCursorChat`, +`resolveApproval`, and `sseTestStream.NextType` helpers must build a Server with +this fake runner, consume newline-delimited SSE frames, and use the existing +approval endpoint. Implement every remaining `Runner` method on the fake with +deterministic catalogue/status/stream values or an explicit test failure. + +- [ ] **Step 2: Add lifecycle and identity tests** + +Cover exact immutable selection, consecutive Create Run reuse, and new Create +Agent after model, variant, repository, ref, or auto-PR changes. Mode-only +changes must reuse the same agent. + +- [ ] **Step 3: Add detach/recovery/cancel tests** + +Cover: + +- HTTP follower disconnect leaves remote running; +- `putIfAbsent` rejects a concurrent turn with 409; +- attach replays in-memory events; +- missing in-memory run recovers from persisted IDs and Last-Event-ID; +- reset clears partial accumulators before replay; +- terminal finalization is idempotent; +- local Stop detaches without remote cancel; +- approved Cancel invokes upstream exactly once; +- crash during `create_in_flight` without returned IDs becomes `ambiguous` and is + never auto-retried; +- deleting a session with an active remote run returns 409 until approved + cancellation or terminal completion; +- editing or retrying Cursor history invalidates reuse before the next direct + run. + +- [ ] **Step 4: Run and confirm RED** + +```bash +go test ./internal/server -run 'TestCursorChat' -count=1 -v +``` + +- [ ] **Step 5: Add atomic live-run reservation** + +```go +func (h *liveHub) putIfAbsent(session string, lr *liveRun) bool +``` + +Reserve before approval so two browser requests cannot create competing paid +runs. + +- [ ] **Step 6: Define request and immutable plan** + +```go +type cursorChatRequest struct { + SessionID string `json:"session_id"` + Message string `json:"message"` + Images []string `json:"images"` + Model cursor.ModelSelection `json:"model"` + Mode string `json:"mode"` + ProjectDir string `json:"project_dir,omitempty"` + RepositoryURL *string `json:"repository_url,omitempty"` + StartingRef *string `json:"starting_ref,omitempty"` + AutoCreatePR bool `json:"auto_create_pr"` +} +``` + +Pointer repo fields distinguish auto-discovery from explicit no-repo. Deep-copy +all slices into private `cursorTurnPlan`. The approval projection contains a +240-rune redacted prompt preview and image count, never full prompt/image data. +Decode this route with `decodeCursorChatBody`, not the generic 32 MiB decoder, +and require dashboard password authentication before allocating large image +buffers or preparing a paid operation. + +- [ ] **Step 7: Create or hydrate the Antares session** + +Emit the session event before approval. Persist the user message and +`awaiting_approval` state. Keep `Session.Provider` as the active Antares chat +provider; Cursor target state lives in `CursorSessionState`. + +- [ ] **Step 8: Execute the approved immutable plan** + +Before POST, CAS state to `create_in_flight` or `run_in_flight`. Persist returned +agent/run IDs before opening Cursor SSE. Publish status/reasoning/text/tool +progress through `liveRun`; persist Last-Event-ID and partial accumulators before +publishing each corresponding event. + +- [ ] **Step 9: Finalize idempotently** + +Use Cursor's final whole text as canonical reconciliation, not another delta. +Persist final reasoning and Git state, append one deterministic assistant +message, mark committed, then emit `done`. Remote tool events remain live +progress and are not persisted as ordinary Antares tool history. + +- [ ] **Step 10: Recover from `handleChatAttach`** + +When no in-memory run exists, load Cursor state. Reserve one recovery watcher, +resume from Last-Event-ID, or fetch/finalize a terminal run. Otherwise return +ordinary `done`. + +- [ ] **Step 11: Add explicit cancel route** + +`POST /api/chat/cursor/cancel` prepares an immutable cancel operation and waits +for explicit approval. It never shares semantics with local Stop. + +Update session delete/edit handlers: active remote state returns 409 on delete; +editing or retrying a Cursor-authored turn atomically invalidates reuse. + +- [ ] **Step 12: Run server and race suites** + +```bash +gofmt -w internal/server/handlers_cursor.go internal/server/cursor_events.go internal/server/handlers_cursor_test.go internal/server/livechat.go internal/server/handlers_chat.go internal/server/routes.go internal/server/server.go +go test ./internal/server -run 'TestCursorChat|TestChatAttach' -count=1 +go test -race ./internal/server ./internal/store ./internal/cursorrun ./internal/approval -count=1 +``` + +- [ ] **Step 13: Commit** + +```bash +git add internal/server +git commit -m "$(cat <<'EOF' +Run Cursor agents directly from chat +EOF +)" +``` + +--- + +### Task 13: Add unified model search and Cursor composer controls + +**Files:** +- Create: `web/src/lib/cursorModels.ts` +- Create: `web/src/lib/cursorModels.test.mjs` +- Create: `web/src/lib/composerTargets.ts` +- Create: `web/src/lib/composerTargets.test.mjs` +- Create: `web/src/lib/cursorAttachments.ts` +- Create: `web/src/lib/cursorAttachments.test.mjs` +- Create: `web/src/lib/chatEvents.ts` +- Create: `web/src/lib/chatEvents.test.mjs` +- Create: `web/src/components/chat/CursorOptions.tsx` +- Modify: `web/src/components/chat/ModelPicker.tsx` +- Modify: `web/src/components/chat/ApprovalCard.tsx` +- Modify: `web/src/pages/ChatPage.tsx` +- Modify: `web/src/pages/ProvidersPage.tsx` +- Modify: `web/src/lib/api.ts` +- Modify: `web/src/lib/i18n.tsx` + +**Interfaces:** +- Consumes: separate chat/Cursor catalogues and direct Cursor SSE routes. +- Produces: controlled `ComposerTarget` and exact `CursorOptionsValue`. + +- [ ] **Step 1: Add pure exact-variant tests** + +```javascript +test('default variant keeps hidden params', () => { + const model = { + id: 'claude-opus-5', + name: 'Claude Opus 5', + aliases: [], + parameters: [{ id: 'effort', values: [{ value: 'max' }] }], + variants: [{ + params: [ + { id: 'cyber', value: 'false' }, + { id: 'effort', value: 'max' }, + ], + displayName: 'Claude Opus 5', + isDefault: true, + }], + } + expect(defaultCursorVariant(model).params).toEqual(model.variants[0].params) +}) + +test('filters never synthesize a missing combination', () => { + expect(selectExactVariant(modelFixture, { context: '1m', reasoning: 'max', fast: 'true' })).toBeNull() +}) + +const modelFixture = { + id: 'gpt-test', + name: 'GPT Test', + aliases: [], + parameters: [ + { id: 'context', values: [{ value: '272k' }, { value: '1m' }] }, + { id: 'reasoning', values: [{ value: 'low' }, { value: 'max' }] }, + { id: 'fast', values: [{ value: 'false' }, { value: 'true' }] }, + ], + variants: [{ + params: [ + { id: 'context', value: '272k' }, + { id: 'reasoning', value: 'max' }, + { id: 'fast', value: 'true' }, + ], + displayName: 'GPT Test', + isDefault: true, + }], +} +``` + +- [ ] **Step 2: Add grouped-search, attachment, and approval tests** + +Cover ID/name/alias/provider search, missing-key Connect action, five-image +limit, local-document rejection, approval parsing/deduplication, and +Cursor-local-Stop intentional detach. Include an `auto-smart` fixture and prove +`optimize_for` appears only when the connected catalogue returns it. + +- [ ] **Step 3: Run and confirm RED** + +```bash +cd web +bun test src/lib/cursorModels.test.mjs src/lib/composerTargets.test.mjs src/lib/cursorAttachments.test.mjs src/lib/chatEvents.test.mjs +``` + +- [ ] **Step 4: Implement pure target and variant helpers** + +```ts +export type ChatTarget = { + kind: 'chat' + provider: string + model: string + name: string + providerLabel: string + reasoningCapability?: ReasoningCapability +} + +export type CursorTarget = { + kind: 'cursor' + model: CursorModel + variant: CursorVariant +} + +export type ComposerTarget = ChatTarget | CursorTarget +``` + +Variant filters must return a concrete upstream variant or null. + +- [ ] **Step 5: Make `ModelPicker` controlled and grouped** + +```ts +export function ModelPicker(props: { + value: ComposerTarget | null + onChange(target: ComposerTarget): void +}): JSX.Element +``` + +Fetch both catalogues when opened. Selecting chat calls `/model/set`; selecting +Cursor never calls it. Mark sections and rows clearly. + +- [ ] **Step 6: Implement `CursorOptions`** + +Expose reasoning-like dimension, remaining variant dimensions, Agent/Plan, +repository/ref, warnings, and auto-PR. Controls filter variants and commit only +when one exact variant remains. Changing model/variant/repo/ref/auto-PR shows +“starts a new Cursor agent”; mode-only changes do not. + +- [ ] **Step 7: Branch ChatPage send behavior** + +For chat targets, preserve `/chat` and adaptive reasoning. For Cursor targets: + +- reject local docs before clearing composer state; +- validate image preflight; +- send exact model params to `/chat/cursor`; +- hide RolePicker and generic ReasoningPicker; +- show CursorOptions; +- process approval events; +- load pending approvals for the current session; +- hydrate Cursor state from session detail. + +- [ ] **Step 8: Separate local detach from remote cancel** + +Cursor Stop closes the local stream and sets an intentional-detach flag so the +standing attach loop does not immediately reconnect. A separate Cancel action +posts to `/chat/cursor/cancel` and shows approval. + +- [ ] **Step 9: Handle structured streaming errors** + +Make `streamPost` parse non-2xx JSON like `api`, preserving status/body for 409, +429, auth, and stale-model UI messages. + +- [ ] **Step 10: Improve Providers catalogue** + +Add model search and compact parameter/variant summaries. Keep credential +management there; execution selection remains in composer. + +- [ ] **Step 11: Add all locale strings** + +Translate target groups, Cursor options, new-agent warning, repo/dirty/ahead +warnings, attachment errors, detach/cancel, stale model, and approval details in +all locale maps. + +- [ ] **Step 12: Run frontend verification** + +```bash +cd web +bun test +bun x tsc -b --noEmit +bun run build +``` + +- [ ] **Step 13: Commit** + +```bash +git add web/src +git commit -m "$(cat <<'EOF' +Add direct Cursor controls to the composer +EOF +)" +``` + +--- + +### Task 14: Document, verify, deploy locally, and update the branch + +**Files:** +- Modify: `docs/configuration.md` +- Modify: `docs/tools.md` +- Modify: `docs/verification.md` + +**Interfaces:** +- Consumes: every prior task. +- Produces: verified build, local daemon running the same commit, and updated remote branch when authorized. + +- [ ] **Step 1: Update user documentation** + +Document: + +- Auto and model-aware reasoning; +- provider-specific Off/minimal/effort semantics; +- direct Cursor selection and exact variants; +- repo/ref and local-only change warnings; +- explicit approval, local Stop, remote Cancel, and follow-up reuse identity; +- attachment limits; +- metadata-only live verification. + +- [ ] **Step 2: Run focused suites** + +```bash +go test ./internal/llm ./internal/agent ./internal/config ./internal/cursor ./internal/cursorrun ./internal/approval ./internal/tools ./internal/store ./internal/server ./cmd/antares -count=1 +cd web && bun test && bun x tsc -b --noEmit +``` + +- [ ] **Step 3: Run race and full checks** + +```bash +go test -race ./internal/llm ./internal/agent ./internal/cursor ./internal/cursorrun ./internal/approval ./internal/tools ./internal/store ./internal/server -count=1 +make check +make smoke +``` + +Record any known pre-existing flake separately; do not mask a reproducible +failure with retries. + +- [ ] **Step 4: Run a whole-branch defect review** + +Review `origin/main...HEAD` for: + +- any path that can activate Cursor as a chat provider; +- any paid request before approval; +- variant-param loss or synthesis; +- create/run retry; +- key leakage; +- duplicate stream text after recovery; +- stale reasoning crossing model boundaries; +- local Stop accidentally cancelling remote; +- persistence races and non-idempotent finalization. + +Add a failing regression test before every required fix. + +- [ ] **Step 5: Build and install the exact worktree** + +```bash +make install-cli +"$HOME/.local/bin/antares" version +git rev-parse --short HEAD +``` + +Verify the reported binary commit matches the worktree commit. + +- [ ] **Step 6: Restart and health-check the local daemon** + +```bash +"$HOME/.local/bin/antares" stop +nohup "$HOME/.local/bin/antares" serve >"$HOME/.antares/logs/serve.log" 2>&1 & +curl --fail --silent --show-error "http://127.0.0.1:8787/api/health" +``` + +Do not run a paid Cursor agent automatically. In the dashboard, verify the live +catalogue loads, exact options appear, and the first Send stops at approval. + +- [ ] **Step 7: Commit documentation and final regressions** + +```bash +git add docs/configuration.md docs/tools.md docs/verification.md docs/superpowers/specs/2026-08-12-adaptive-reasoning-cursor-mode-design.md docs/superpowers/plans/2026-08-12-adaptive-reasoning-cursor-mode.md +git commit -m "$(cat <<'EOF' +Document adaptive reasoning and direct Cursor mode +EOF +)" +git status --short --branch +``` + +If prior task commits already include all documentation and no files changed, +skip this commit rather than creating an empty one. + +- [ ] **Step 8: Push/update the existing PR only with explicit authorization** + +```bash +git push -u origin HEAD +gh pr checks --watch +``` + +Return the PR URL, exact deployed commit, verification commands, and any +remaining known limitations. diff --git a/docs/superpowers/specs/2026-08-12-adaptive-reasoning-cursor-mode-design.md b/docs/superpowers/specs/2026-08-12-adaptive-reasoning-cursor-mode-design.md new file mode 100644 index 0000000..0f02a50 --- /dev/null +++ b/docs/superpowers/specs/2026-08-12-adaptive-reasoning-cursor-mode-design.md @@ -0,0 +1,577 @@ +# Adaptive Reasoning and Direct Cursor Mode + +Date: 2026-08-12 +Status: Approved design + +## Summary + +Antares will make reasoning controls model-aware and make Cursor Cloud Agent +models directly usable from the web composer. + +The composer model search will show two clearly separated execution targets: + +- chat models, which continue through Antares' `llm.Client` abstraction; and +- Cursor Cloud Agent models, which execute through a dedicated, approval-gated + Cursor run path. + +Cursor remains an agent integration rather than an active chat provider. +Selecting a Cursor model must not change `model.provider`, `model.default`, or +the model used by ordinary Antares turns. + +Reasoning options will no longer come from the global +`none|low|medium|high` list. Antares will expose the exact values supported by +the selected provider and model. Cursor's live model catalogue is authoritative +for Cursor models, including all preset variant parameters. + +## Evidence and Root Cause + +The connected Cursor account currently returns 34 models from `GET /v1/models`. +Their controls are not uniform: + +- Grok models use `effort`, including `xhigh` on some models. +- GPT models use `reasoning`, with model-dependent values including `none`, + `extra-high`, `xhigh`, and `max`. +- Claude models combine `thinking`, `context`, `effort`, and sometimes `fast`. +- Gemini 3.6 Flash exposes `minimal|low|medium|high`. +- Some models expose no reasoning control. +- Cursor variants may contain required parameters that are not listed as + user-facing parameters, such as `cyber=false`. + +Antares currently loses or bypasses this information in four places: + +1. `/api/providers/cursor/models` drops aliases and variants. +2. Cursor models are deliberately excluded from `/api/model/list-all`, but no + separate usable Cursor picker was added. +3. `cursor_agent` accepts a model ID but not `model.params`. +4. The composer reasoning picker and config schema use a fixed global enum. + +The existing provider adapters also do not implement current model semantics +consistently: + +- Anthropic uses fixed token budgets for every model instead of modern adaptive + thinking where supported. +- OpenRouter omits `none`, which can leave reasoning enabled at the model's + default. +- Gemini maps `none` to minimal thinking but labels it as off and does not + accept the explicit `minimal` value. + +The fix therefore requires an end-to-end capability contract. Adding more +hard-coded picker entries would leave the underlying requests incorrect. + +## Authoritative References + +- Cursor Cloud Agent API: + +- Cursor SDK model selection: + +- Cursor Router: + +- Anthropic adaptive thinking and effort: + + and +- Gemini thinking: + +- OpenRouter reasoning metadata: + + +## Goals + +1. Make every model available to the connected Cursor key searchable and + selectable from the composer. +2. Run a selected Cursor model directly, without asking an Antares LLM to + manufacture a `cursor_agent` tool call. +3. Preserve Cursor's exact model variant, including hidden parameters. +4. Reuse the same Cursor agent for consecutive Cursor-mode follow-ups. +5. Require an explicit approval before every paid or mutating Cursor action. +6. Derive chat reasoning choices from the selected provider and model. +7. Correct provider request bodies so every displayed choice has the advertised + effect. +8. Preserve existing configurations and keep Cursor isolated from active chat + provider selection. + +## Non-Goals + +- Implementing Cursor as an `llm.Client`. +- Sending Antares' local tool registry to Cursor. +- Inventing a task-complexity classifier in Antares. +- Guessing unsupported reasoning values for unknown providers. +- Treating local uncommitted files as if they existed in a Cursor cloud VM. +- Automatically retrying non-idempotent create-agent or create-run requests. +- Replacing the existing `cursor_agent` and `cursor_agent_status` tools. + +## Core Concepts + +### Execution target + +The composer tracks an execution target: + +```text +chat: + provider + model + reasoning override + +cursor: + model + exact variant params + conversation mode + repository and starting ref + auto-create-PR setting +``` + +This is a UI and request-level distinction. It does not add Cursor to +`model.provider`. + +### Reasoning capability + +Chat model metadata receives a structured capability: + +```json +{ + "reasoning_capability": { + "values": [ + { "value": "none", "label": "Off", "kind": "disable" }, + { "value": "low", "label": "Low" }, + { "value": "medium", "label": "Medium" } + ], + "default": "medium", + "mandatory": false, + "can_disable": true, + "source": "live" + } +} +``` + +`values` are opaque provider values. Antares must not normalize +`extra-high` into `xhigh`, or otherwise assume that similar labels share a wire +format. The optional `kind: "disable"` marker, rather than the value's spelling, +determines whether the UI renders an Off choice. `can_disable` must agree with +the presence of exactly one marked disable value; mandatory capabilities have +neither. + +An absent capability means the UI offers only Auto. Auto is represented by no +override and lets the provider apply its model default or native adaptive +reasoning. + +### Cursor model selection + +Cursor model selections use the existing upstream shape: + +```json +{ + "id": "gpt-5.6-sol", + "params": [ + { "id": "context", "value": "1m" }, + { "id": "reasoning", "value": "max" }, + { "id": "fast", "value": "true" } + ] +} +``` + +Antares selects one concrete variant returned by Cursor and copies its complete +`params` array. It never synthesizes a Cartesian product from +`model.parameters`. + +## Architecture + +### 1. Separate catalogues, unified search + +The backend continues to keep chat and agent providers separate: + +- `GET /api/model/list-all` returns chat models and their reasoning capability. +- `GET /api/providers/cursor/models` returns Cursor model IDs, names, + descriptions, aliases, parameters, and variants. + +The frontend merges these responses only for search and presentation. Results +are grouped as `Chat models` and `Cursor Cloud Agents`. + +The existing `/api/model/set` endpoint continues to reject Cursor. A Cursor +result is never marked as the globally active chat model. + +Cursor catalogue responses are cached for five minutes per resolved provider +configuration. Connecting a new key or changing Cursor settings invalidates the +cache. A selection rejected as stale causes one catalogue refresh before +Antares returns an actionable reselect error. + +### 2. Shared Cursor run service + +Remote Cursor lifecycle logic moves behind a service shared by: + +- the existing `cursor_agent` tool; +- the existing `cursor_agent_status` tool where applicable; and +- the direct web Cursor coordinator. + +The service owns: + +- model and variant validation; +- request encoding; +- agent creation and follow-up runs; +- cancellation and status reads; +- resumable SSE handling; +- bounded progress conversion; +- typed error classification; and +- key redaction. + +The tool and web adapters remain responsible for their own input shape, +approval presentation, and result rendering. + +### 3. Direct Cursor chat route + +`POST /api/chat/cursor` accepts a Cursor turn and returns the same Antares SSE +event envelope used by `/api/chat`. + +The request contains: + +- session ID and prompt; +- up to five supported images; +- selected model ID and exact variant params; +- Cursor mode (`agent` or `plan`); +- project directory for repository discovery; +- optional edited repository URL and starting ref; and +- auto-create-PR preference. + +The route starts a background coordinator and publishes through the existing +live-run hub. Browser disconnects therefore detach the viewer without +terminating the Cursor run. `/api/chat/attach` can replay events from the +in-memory run. + +If the daemon restarted and no live run exists, attach checks persisted Cursor +run metadata. A non-terminal remote run starts a recovery watcher using the +stored `agent_id` and `run_id`; a terminal run hydrates its final state. + +The route persists ordinary user and assistant transcript messages. Cursor +status, reasoning summaries, tool progress, final text, branches, and pull +requests use existing event and message segments where possible. + +### 4. Approval gate + +The approval mechanism is extracted from tool-call-specific code into a shared +operation gate. Mutating Cursor operations use it from both direct mode and the +tool adapter. + +Cursor start, follow-up, and cancel always require an explicit human decision, +even when the general tool approval mode is `auto`. + +The approval payload is immutable and includes: + +- operation; +- a bounded prompt preview and attachment count; +- model ID and every parameter; +- repository and starting ref; +- Cursor conversation mode; +- auto-create-PR setting; and +- whether the operation creates a new agent or follows up. + +The server retains the exact pending request behind an opaque approval ID. The +card is a display projection, not a second client-submitted request. Approval +executes the retained request exactly; editing a composer selection after the +card appears cannot change the pending operation. + +No Cursor create or cancel request is sent before approval. + +### 5. Session lifecycle + +A Cursor-mode session stores: + +- current model ID and variant params; +- repository and starting ref; +- Cursor mode; +- `agent_id` and latest `run_id`; +- remote status; and +- whether the last target transition invalidated follow-up reuse. + +Consecutive Cursor turns with the same model, variant, repository, starting +ref, and auto-create-PR setting call Create Run on the existing agent. Cursor +mode may change per follow-up because Create Run supports a mode override. + +Changing model, variant, repository, starting ref, or auto-create-PR starts a +new Cursor agent after approval. Starting a new Antares chat also starts a new +Cursor agent. + +Only one direct turn may run per Antares session. Selection changes can be +prepared while idle but cannot start a competing run in the same session. + +Switching from Cursor mode to a chat model ends automatic reuse for that Cursor +chain. Switching back creates a new agent, because intervening Antares messages +were not part of the remote Cursor conversation. + +Stopping local streaming does not cancel the remote run. Remote cancellation is +an explicit approved action. + +After Create Agent returns, Antares persists the agent and run IDs before it +starts remote streaming. A process failure after Cursor accepts a create +request must not cause Antares to retry that create request. + +## Repository Discovery + +For a project-bound chat, the backend inspects the project repository: + +1. Read the `origin` URL. +2. Normalize GitHub SSH forms such as `git@github.com:owner/repo.git` to + `https://github.com/owner/repo`. +3. Resolve the current branch or detached commit as the proposed starting ref. +4. Report whether the worktree is dirty or has commits not present on the + selected remote ref. + +The composer preflight displays this information in Cursor options. The user +may edit the repository or ref before sending. The server independently +normalizes and validates the submitted values again. + +No-project chats default to a no-repository run. Local file paths, credentials +embedded in remote URLs, non-GitHub repositories, and non-HTTPS normalized +destinations are rejected. + +The approval card warns that dirty files and local-only commits are not present +in the cloud VM. + +## User Experience + +### Model search + +The composer model search: + +- searches ID, display name, aliases, and provider; +- labels every Cursor result as `Cursor Cloud Agent`; +- shows a Connect Cursor action when the provider has no resolved key; +- preserves the active chat model while Cursor is selected; and +- remembers the last target per session. + +The Providers page remains the credential-management surface. Its Cursor model +section gains search and displays parameter/variant summaries, but selection +for execution happens in the composer. + +### Cursor options + +Selecting a Cursor model chooses the `isDefault` variant. If no variant is +marked default, Antares chooses the first returned variant. + +A model the catalogue returned **no** variant for is not runnable from the +composer. It is still listed, so its absence is explained rather than silent, +but the row is disabled and there is no target to select. Antares must not +substitute a synthesized empty parameter list: `{"params": []}` is a selection +Cursor never offered, and the exact-authoritative-variant rule below is +binding. This is the same rule that forbids assembling a Cartesian product — +an upstream variant is the only runnable thing. + +The one selection that legitimately carries no parameters is the +`cursor_agent` tool's preserve-upstream-default path, where a caller names a +model and omits `model_params`. That sends the model id with the `params` field +**absent** so Cursor applies its own default; it is not an empty array, and it +is not available to the composer, which always requires an exact variant. + +The main chip shows `Cursor · `. A Cursor options popover exposes: + +- the reasoning-like axis from `reasoning`, `effort`, or `thinking`; +- other dimensions such as Context and Fast; +- Agent or Plan mode; +- repository and starting ref; and +- auto-create-PR. + +Controls act as filters over concrete variants. A choice is committed only when +one exact variant matches. Values and labels come from Cursor's catalogue. + +`auto-smart` and its `optimize_for` choices appear only when returned for the +connected account. Antares does not hard-code team-entitled models. + +### Chat reasoning picker + +For a chat target: + +- Auto is always available and sends no override. +- Only capability values for the selected provider/model are shown. +- Off appears only when the capability can actually disable reasoning. +- Mandatory reasoning models do not show Off. +- The selection is stored by `provider/model`. +- Changing models restores that model's prior valid value or Auto. + +The same capability source drives the Roles editor and configuration UI. +Role values are validated against the role's explicit model, or against the +inherited active model when no model is specified. + +### Attachments + +Cursor mode supports up to five image inputs accepted by the Cursor API. +Unsupported MIME types and oversized images fail before approval. + +Local non-image attachments are rejected in Cursor mode with a clear +explanation. They are not silently dropped, and local paths are never sent as +if the cloud VM could read them. + +## Provider-Specific Reasoning + +### Cursor + +The live catalogue is the sole source of model params and variants. Antares +does not translate generic chat reasoning into Cursor params. + +### OpenRouter + +When present, model metadata fields `reasoning.supported_efforts`, +`default_effort`, `default_enabled`, `mandatory`, and `supports_max_tokens` are +authoritative. + +The adapter sends an explicit disable value when supported. It does not omit a +user-selected `none` and accidentally fall back to enabled reasoning. + +### Anthropic + +The resolver distinguishes modern adaptive-thinking models from legacy +extended-thinking models. + +Modern supported models use: + +```json +{ + "thinking": { "type": "adaptive" }, + "output_config": { "effort": "" } +} +``` + +Model-specific ladders follow Anthropic's published capability table. Legacy +models retain fixed-budget behavior only where the upstream API supports it. +Unsupported values are never silently mapped to a nearby value. + +### Gemini + +The resolver exposes each model's published thinking levels. Gemini 3 models +use `thinkingLevel`; `minimal` is represented as Minimal rather than Off. +Models that cannot disable dynamic thinking do not offer Off. + +Legacy budget configuration remains only for models whose API contract still +requires it. + +### OpenAI and Codex + +Known model families receive their documented effort ladder. OpenAI chat-style +requests use their supported reasoning-effort field; Responses/Codex requests +use the nested reasoning object. + +When the provider model endpoint supplies richer live metadata, live metadata +wins. Unknown models fall back to Auto rather than inheriting a guessed global +ladder. + +### Other OpenAI-compatible providers + +A provider may expose a reasoning capability through model metadata. Without +that metadata or a tested provider-specific resolver, Antares offers only Auto. + +## Validation and Error Handling + +Both frontend and backend validate selections, but the backend is authoritative. + +The backend returns specific errors for: + +- missing Cursor credentials; +- model or variant no longer available; +- unsupported reasoning value; +- malformed or non-GitHub repository; +- image count, type, or size violations; +- agent busy conflicts; +- authentication and authorization failures; +- rate limits, including retry-after metadata; +- local wait cancellation while the remote run may still be active; and +- terminal Cursor failures. + +Create-agent and create-run requests are never automatically retried. +Idempotent metadata reads and SSE reconnections retain their existing bounded +retry behavior. + +No upstream error, approval payload, log, event, or persisted metadata may +contain an API key. + +## Interrupt and Recovery Semantics + +- Browser navigation or network loss detaches from the local event stream. +- Stop interrupts local waiting and reports that the remote run may continue. +- Cancel requests remote cancellation and requires approval. +- Reattach first uses the in-memory live-run replay buffer. +- After a daemon restart, persisted IDs allow status recovery and a new remote + stream watcher. +- A completed run always wins over a later retryable stream read error. + +## Compatibility and Migration + +Existing YAML remains valid. `reasoning_effort` remains a string so existing +automation does not require a format migration. + +The static config schema enum is replaced by model-aware validation. Existing +values are accepted when supported by the selected model. Unsupported stored +values resolve to Auto and surface a one-time UI notice rather than being sent +upstream. + +This compatibility rule applies while loading old persisted configuration. +An explicit new API request carrying an unsupported value returns a validation +error; it is not silently converted to Auto. + +The old browser key `antares:reasoning` is migrated once: + +- copy it into the active `provider/model` preference if valid; +- otherwise select Auto; and +- remove the old global key. + +Existing Cursor tool callers that send only `model` remain valid. The tool gains +optional model params; omitting them preserves Cursor's model default behavior. + +## Testing Strategy + +### Unit tests + +- Reasoning capability resolution for each supported provider and representative + model family. +- Exact Anthropic, OpenAI, Codex, OpenRouter, and Gemini request bodies. +- Auto omission, explicit disable, mandatory reasoning, and invalid values. +- Cursor model and variant validation, including params not listed in the + user-facing parameter definitions. +- Repository normalization, dirty/ahead detection, and rejection cases. +- Approval immutability and no upstream request before approval. + +### Server and service tests + +- Full Cursor catalogue response, including aliases, params, and variants. +- Five-minute cache and invalidation behavior with an injected clock. +- Direct start, follow-up reuse, and new-agent behavior after target changes. +- Browser detach, in-memory attach, daemon-style recovery from persisted IDs, + local stop, and approved remote cancel. +- Auth, 409, 429, stale model, stream reconnect, terminal error, and key + redaction paths. +- Regression that `/api/model/set`, CLI model selection, TUI provider selection, + and `llm.New` still reject Cursor as a chat provider. + +### Frontend tests + +- Grouped search over chat and Cursor models, including aliases. +- Cursor connection and catalogue errors. +- Per-model reasoning persistence and migration from the global key. +- Hidden/unsupported reasoning controls. +- Variant filtering that always resolves to an upstream variant. +- Approval contents and immutable pending selection. +- Repo/ref warnings, unsupported local files, and model-change new-agent notice. + +### Integration and verification + +- End-to-end direct mode against a fake Cursor server. +- Existing Go package tests and race tests. +- Frontend unit tests, typecheck, and production build. +- Full Antares build. +- Local daemon restart and browser smoke test. +- Optional live Cursor metadata test only; automated verification must not + create a paid remote run. + +## Acceptance Criteria + +1. A connected Cursor account's complete model catalogue appears in composer + search without changing the active Antares chat provider. +2. Choosing a Cursor model and variant results in the exact selected + `model.id` and `model.params` after explicit approval. +3. A consecutive Cursor turn reuses the same agent; changing model, variant, + repo/ref, or auto-create-PR creates a new one. +4. Cursor runs survive browser detachment and can be recovered after daemon + restart from persisted IDs. +5. Chat reasoning choices match the selected model and provider, including + values beyond low/medium/high where supported. +6. Auto sends no override and defers to native provider/model behavior. +7. A displayed Off choice actually disables reasoning; otherwise Off is absent. +8. Stale or unsupported values fail before an upstream model request. +9. Cursor never becomes the active chat provider through web, CLI, TUI, config, + or direct `llm.New` paths. +10. No secret is exposed and no paid Cursor mutation occurs before approval. diff --git a/docs/tools.md b/docs/tools.md index 43dc04c..eeaa7b2 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -142,21 +142,90 @@ tools: Tools declare whether they mutate. `read_file` does not; `write_file`, `terminal`, and `browser` do. +Mutating `cursor_agent` actions are the exception to `auto`: both `auto` and +`prompt` require an explicit approval before start, follow-up, or cancellation. +`deny` remains deny — it refuses immediately without creating a pending +approval. + ## Cursor Cloud Agents `cursor_agent` delegates coding work to a configured Cursor Cloud Agent. It can start a run, follow up on an existing agent, or request cancellation. Starting, -following up, and cancelling are always approval-gated because they create or -change remote work. It defaults to `wait: true`. With `wait: false`, it returns -the agent ID, run ID, and Cursor URL immediately; use `cursor_agent_status` -later instead of busy-polling. +following up, and cancelling require explicit approval in `auto` and `prompt` +because they create or change remote work; `deny` refuses them immediately. It +defaults to `wait: true`. With `wait: false`, it returns the agent ID, run ID, +and Cursor URL immediately; use `cursor_agent_status` later instead of +busy-polling. `cursor_agent_status` is read-only and needs no approval. It defaults to `wait: false` and returns one snapshot; `wait: true` streams until terminal status. Cancelling local waiting does not cancel the remote Cursor run. Use the status tool to inspect an agent/run returned by `cursor_agent`, rather than -repeatedly starting new work. Both tools are available when the Cursor agent -integration has a `CURSOR_API_KEY`; see [Configuration](configuration.md). +repeatedly starting new work. Both tools need the Cursor provider enabled and a +resolved key; it ships disabled, so see [Configuration](configuration.md). + +`cursor_agent` also takes an optional `model_params` — the exact parameter array +of one variant from Cursor's catalogue. Omit it and Cursor's own default for +that model applies; supply it and it must match a real variant exactly. + +### Direct Cursor mode + +The dashboard composer can send a turn straight to a Cursor Cloud Agent instead +of asking an Antares model to manufacture a `cursor_agent` call. Cursor models +appear in the composer's model search under **Cursor Cloud Agents**, alongside +(and clearly separated from) chat models. Picking one changes only where the +next message goes — the active Antares chat model is untouched. + +**Exact variants.** Antares uses Cursor's live catalogue as the sole authority. +Selecting a model picks its `isDefault` variant, and the options popover +(reasoning/effort/thinking, Context, Fast, and whatever else that model +publishes) *filters* the concrete variants Cursor returned. A choice commits +only when exactly one real variant matches. Antares never assembles a +combination Cursor did not list, and it copies the whole parameter array — +including parameters Cursor does not show as user-facing, such as +`cyber=false`. A selection that has gone stale triggers one catalogue refresh +and then an actionable reselect error, never a guess. + +**Approval.** Every paid or state-changing Cursor operation — start, follow-up, +and cancel — needs an explicit human decision, in `auto` as much as in `prompt`. +The guarantee is specifically about mutations: **no create-agent, create-run +(follow-up), or cancel request is sent to Cursor before its own approval.** +Read-only metadata does travel earlier — the composer cannot draw the model +list, the variant options, or the approval card without reading your account +and catalogue (`GET /v1/me`, `GET /v1/models`) — but those requests create +nothing, change nothing, and cost nothing. The card shows the operation, +whether it creates a new agent or follows up, the model and every parameter, +repository and starting ref, mode, auto-create-PR, a bounded prompt preview, +and the attachment count. What executes is the request the server retained, not +the card's contents: editing the composer after the card appears cannot change +what you are approving. + +**Stop and Cancel are different.** Local **Stop** detaches your view — it stops +the local stream and tells you the remote run may still be active. It never +cancels remote work, and once the non-idempotent create request is in flight +Stop can only record the detachment. **Cancel** is a separate, approval-gated +action that asks Cursor to stop the run. Closing the tab or losing the network +likewise only detaches; reattaching replays from the live run, and after a +daemon restart Antares recovers from the persisted agent and run IDs. + +**Follow-up reuse identity.** A consecutive Cursor turn reuses the same Cursor +agent — so the remote conversation keeps its context — as long as the model, +variant parameters, repository, starting ref, and auto-create-PR setting are +unchanged. Change any of them and the next approved turn starts a *new* agent. +Cursor mode (Agent or Plan) may change per follow-up, because Create Run +accepts a mode override. Starting a new Antares chat, or switching to a chat +model and back, also starts a new agent: the intervening messages were never +part of the remote conversation. + +**Attachments.** Cursor mode accepts up to **five images**, each at most +**15 MiB decoded**, as `image/png`, `image/jpeg`, `image/gif`, or `image/webp`. +The declared MIME type must match the file's actual signature. Anything over +the limit or of an unsupported type fails *before* approval, so a rejected +attachment never costs a run. Non-image attachments are refused with an +explanation rather than silently dropped — a local file path means nothing to a +cloud VM that cannot read your disk. + +Only one direct Cursor turn runs per session at a time. ## Limits diff --git a/docs/verification.md b/docs/verification.md index ce69ed9..57ba096 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -39,6 +39,10 @@ OPENAI_API_KEY=sk-… go test ./internal/llm -run TestLiveSpeakRoundTrip -v ## Cursor Cloud Agents +Cursor is the one integration where the last mile **costs money**, so it is +verified by metadata only. Nothing in this repository's automated verification +may create, run, follow up on, or cancel a Cursor agent. + This metadata-only smoke test calls Cursor's `/v1/me` and `/v1/models` endpoints. It does not create an agent or run. Enter the key interactively so it is not stored in shell history: @@ -50,6 +54,46 @@ go test ./internal/cursor -run TestLiveCursorMetadata -count=1 -v unset CURSOR_API_KEY ``` +To check direct Cursor mode in the dashboard without spending anything, stop at +the approval card: + +1. Enable Cursor on the Providers page (it ships disabled) and confirm the page + reports a resolved key. The key itself is never returned by the API — only a + `has_key` flag. +2. Open the composer's model search and confirm the live catalogue loads under + **Cursor Cloud Agents**. +3. Select a model and open its options. The reasoning/effort axis, Context, + Fast, and any other dimensions must come from that model's real variants. +4. Press Send **once**. The turn must stop at the approval card, and the card + must show the exact model, every parameter, repository and starting ref, and + any dirty-worktree or local-only-commit warning. +5. **Deny** it. Denying means no create-agent, create-run, or cancel request is + ever sent, so nothing is created and nothing is billed. Steps 2–4 do reach + Cursor — the catalogue and account reads (`GET /v1/models`, `GET /v1/me`) + are exactly what makes the picker and the card work — but those are + read-only metadata, which is why the pass is still free. Leaving the card + pending is not a substitute for denying it. + +Approving at step 5 starts a billable run. Do that only when you actually want +the work done. + +## Running the suite hermetically + +The credential-gated tests above skip when their variables are absent, so a +machine that happens to export `OPENAI_API_KEY` or `CURSOR_API_KEY` will run +live calls during an ordinary `make check`. Strip them for a hermetic pass: + +```bash +env -u CURSOR_API_KEY -u OPENAI_API_KEY -u ANTHROPIC_API_KEY -u GEMINI_API_KEY \ + -u OPENROUTER_API_KEY -u ANTARES_API_KEY -u ANTARES_LIVE_HTTP \ + -u ANTARES_LIVE_EMAILOSINT -u ANTARES_LIVE_PROXY -u ANTARES_STRESS \ + -u AZURE_OPENAI_ENDPOINT -u AZURE_OPENAI_KEY -u AZURE_OPENAI_DEPLOYMENT \ + -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY \ + -u GOOGLE_APPLICATION_CREDENTIALS -u VERTEX_SA_JSON \ + -u COPILOT_GITHUB_TOKEN \ + make check +``` + ## Chat gateways Gateways need a running bot and, for the webhook ones, a reachable URL. Verify diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 1f43da8..5825c05 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -19,9 +19,11 @@ import ( "sync/atomic" "time" + "github.com/enowdev/antares/internal/approval" "github.com/enowdev/antares/internal/board" "github.com/enowdev/antares/internal/checkpoint" "github.com/enowdev/antares/internal/config" + "github.com/enowdev/antares/internal/cursorrun" "github.com/enowdev/antares/internal/engagement" "github.com/enowdev/antares/internal/findings" "github.com/enowdev/antares/internal/llm" @@ -183,6 +185,8 @@ type Agent struct { roleperf *roleperf.Tracker board *board.Board socialBrowser tools.SocialBrowserManager + cursorMu sync.RWMutex + cursorRunner cursorrun.Runner bg *bgManager // bgAct tracks background-tool usage per session (RAG index/retrieve, etc.). @@ -194,21 +198,30 @@ type Agent struct { mu sync.Mutex active map[string]context.CancelFunc + + approvals *approval.Gate + + catalogMu sync.Mutex + catalogCache map[providerCatalogScope]*providerCatalogEntry + catalogNow func() time.Time } // New builds an agent. func New(cfg *config.Config, db store.Store, reg *tools.Registry, shell *tools.ShellManager, ragProvider tools.RAGProvider) *Agent { a := &Agent{ db: db, reg: reg, shell: shell, rag: ragProvider, - checks: checkpoint.NewStore(config.Path("checkpoints")), - roles: roles.NewRegistry(nil), - findings: findings.NewStore(config.Path("findings")), - intel: engagement.NewStore(config.Path("intel")), - roleperf: roleperf.NewTracker(config.Path("role-performance.json")), - board: board.New(config.Path("boards")), - bg: newBGManager(), - bgAct: newBgActivity(), - active: map[string]context.CancelFunc{}, + checks: checkpoint.NewStore(config.Path("checkpoints")), + roles: roles.NewRegistry(nil), + findings: findings.NewStore(config.Path("findings")), + intel: engagement.NewStore(config.Path("intel")), + roleperf: roleperf.NewTracker(config.Path("role-performance.json")), + board: board.New(config.Path("boards")), + bg: newBGManager(), + bgAct: newBgActivity(), + active: map[string]context.CancelFunc{}, + approvals: approval.NewGate(approvalTimeout), + catalogCache: make(map[providerCatalogScope]*providerCatalogEntry), + catalogNow: time.Now, } a.cfg.Store(cfg) return a @@ -229,11 +242,28 @@ func (a *Agent) SetConfig(cfg *config.Config) { return } a.cfg.Store(cfg) + if runner := a.cursorRunService(); runner != nil { + runner.InvalidateCatalog() + } } // SetRAG swaps the retrieval provider after a config change. func (a *Agent) SetRAG(p tools.RAGProvider) { a.rag = p } +// SetCursorRunner attaches the runtime-scoped Cursor catalogue and lifecycle +// service used by every tool invocation. +func (a *Agent) SetCursorRunner(runner cursorrun.Runner) { + a.cursorMu.Lock() + a.cursorRunner = runner + a.cursorMu.Unlock() +} + +func (a *Agent) cursorRunService() cursorrun.Runner { + a.cursorMu.RLock() + defer a.cursorMu.RUnlock() + return a.cursorRunner +} + // SetSkills attaches the skill library. func (a *Agent) SetSkills(m *skills.Manager) { a.skills = m } @@ -309,6 +339,7 @@ func (a *Agent) Run(ctx context.Context, req Request, emit Emit) (*Result, error req.Role = stored } } + roleReasoningEffort := a.roleReasoningEffort(req.Role) a.applyRole(&req) if !req.Quiet { if err := emit(Event{Type: EventSession, ID: sess.ID, Title: sess.Title}); err != nil { @@ -327,12 +358,34 @@ func (a *Agent) Run(ctx context.Context, req Request, emit Emit) (*Result, error a.mu.Unlock() }() - client, modelName, providerName, err := a.newClient(req.Model, sess.ID) + client, modelName, providerName, err := a.newClientContext(runCtx, req.Model, sess.ID) + if err != nil { + _ = emit(Event{Type: EventError, Err: err.Error()}) + _ = emit(Event{Type: EventDone}) + return nil, err + } + reasoning, err := a.resolveReasoning(runCtx, reasoningInput{ + ModelRef: providerName + "/" + modelName, + Explicit: req.ReasoningEffort, + Role: roleReasoningEffort, + Agent: cfg.Agent.ReasoningEffort, + Model: cfg.Model.ReasoningEffort, + }) if err != nil { _ = emit(Event{Type: EventError, Err: err.Error()}) _ = emit(Event{Type: EventDone}) return nil, err } + if reasoning.DiscardedLegacy != "" { + _ = emit(Event{ + Type: EventNotice, + Message: fmt.Sprintf( + "configured reasoning effort %q is unsupported by %s and was ignored", + reasoning.DiscardedLegacy, + modelName, + ), + }) + } history, err := a.loadHistory(ctx, sess, req) if err != nil { @@ -441,17 +494,18 @@ func (a *Agent) Run(ctx context.Context, req Request, emit Emit) (*Result, error history = a.maybeCompact(runCtx, history, systemPrompt, modelName, toolSpecs, emit, sess) llmReq := llm.Request{ - Model: modelName, - System: systemPrompt, - Messages: ensureToolResults(history), - Tools: toolSpecs, - Temperature: cfg.Model.Temperature, - TopP: cfg.Model.TopP, - MaxTokens: cfg.Model.MaxTokens, - StopSequences: cfg.Agent.StopSequences, - ReasoningEffort: firstNonEmpty(req.ReasoningEffort, cfg.Agent.ReasoningEffort, cfg.Model.ReasoningEffort), - ParallelToolCalls: cfg.Model.ParallelToolCall, - PromptCache: cfg.PromptCaching.Enabled, + Model: modelName, + System: systemPrompt, + Messages: ensureToolResults(history), + Tools: toolSpecs, + Temperature: cfg.Model.Temperature, + TopP: cfg.Model.TopP, + MaxTokens: cfg.Model.MaxTokens, + StopSequences: cfg.Agent.StopSequences, + ReasoningEffort: reasoning.Value, + ReasoningCapability: reasoning.Capability, + ParallelToolCalls: cfg.Model.ParallelToolCall, + PromptCache: cfg.PromptCaching.Enabled, } resp, err := a.callModel(runCtx, client, llmReq, cfg.Streaming.Enabled, emit) @@ -738,6 +792,36 @@ type toolOutcome struct { isError bool } +func (a *Agent) applyPreToolPlugin( + ctx context.Context, + call llm.ToolCall, + sessionID string, + platform string, + emit Emit, +) (llm.ToolCall, *tools.Result) { + if a.plugins == nil { + return call, nil + } + hook := a.plugins.Dispatch(ctx, plugin.Payload{ + Event: plugin.PreToolCall, + SessionID: sessionID, + Platform: platform, + Tool: call.Name, + Arguments: call.Arguments, + }) + if hook.Notice != "" { + _ = emit(Event{Type: EventNotice, Message: hook.Notice}) + } + if hook.Deny { + result := tools.Errorf("refused by policy: %s", hook.Reason) + return call, &result + } + if hook.Arguments != "" { + call.Arguments = hook.Arguments + } + return call, nil +} + // executeTools runs the requested calls, in parallel when the config allows. func (a *Agent) executeTools( ctx context.Context, @@ -749,8 +833,16 @@ func (a *Agent) executeTools( ) []toolOutcome { outcomes := make([]toolOutcome, len(calls)) - // Emit all call announcements up front so the UI can render them in order. + // Emit ordinary call announcements up front so their established ordering + // is unchanged. Explicit operations are announced later from their + // validated, post-plugin approval projection; raw arguments must never be + // exposed before approval. for _, call := range calls { + if tool, ok := byName[call.Name]; ok { + if _, explicit := tool.(tools.OperationApproval); explicit { + continue + } + } _ = emit(Event{Type: EventToolCall, ID: call.ID, Name: call.Name, Arguments: call.Arguments}) } @@ -777,8 +869,23 @@ func (a *Agent) executeTools( return } - // A tool that changes something may need a person to say yes first. - if refusal := a.checkApproval(ctx, call, tool, sess.ID, safeEmit); refusal != nil { + explicitTool, explicitApproval := tool.(tools.OperationApproval) + var refusal *tools.Result + if explicitApproval { + call, refusal = a.checkOperationApproval( + ctx, call, explicitTool, sess.ID, req.Platform, safeEmit, + ) + } else { + // Ordinary tools keep the established approval-before-plugin order. + refusal = a.checkApproval(ctx, call, tool, sess.ID, safeEmit) + if refusal == nil { + call, refusal = a.applyPreToolPlugin( + ctx, call, sess.ID, req.Platform, safeEmit, + ) + } + } + + if refusal != nil { outcomes[i] = toolOutcome{ message: llm.Message{ Role: llm.RoleTool, ToolCallID: call.ID, Name: call.Name, @@ -793,36 +900,6 @@ func (a *Agent) executeTools( return } - // Plugins see the call before it runs, and may refuse it or change - // its arguments. - if a.plugins != nil { - hook := a.plugins.Dispatch(ctx, plugin.Payload{ - Event: plugin.PreToolCall, SessionID: sess.ID, Platform: req.Platform, - Tool: call.Name, Arguments: call.Arguments, - }) - if hook.Notice != "" { - _ = safeEmit(Event{Type: EventNotice, Message: hook.Notice}) - } - if hook.Deny { - content := "refused by policy: " + hook.Reason - outcomes[i] = toolOutcome{ - message: llm.Message{ - Role: llm.RoleTool, ToolCallID: call.ID, Name: call.Name, - Content: content, - }, - isError: true, - } - _ = safeEmit(Event{ - Type: EventToolResult, ID: call.ID, Name: call.Name, - Content: content, IsError: true, - }) - return - } - if hook.Arguments != "" { - call.Arguments = hook.Arguments - } - } - workspace := sess.Workspace if workspace == "" { workspace = a.config().Agent.Workspace @@ -856,6 +933,7 @@ func (a *Agent) executeTools( Config: a.config(), Store: a.db, RAG: a.rag, Shell: a.shell, Sub: a.subAgentFor(req), Tasks: a.backgroundFor(req), Skills: a.skillLibrary(), SocialBrowser: a.socialBrowser, + Cursor: a.cursorRunService(), Checkpoint: func(sessionID, path, tool string) { a.saveCheckpoint(sessionID, path, tool, req.turnMarker) }, diff --git a/internal/agent/approval.go b/internal/agent/approval.go index 92e3663..676fc4f 100644 --- a/internal/agent/approval.go +++ b/internal/agent/approval.go @@ -3,89 +3,151 @@ package agent import ( "context" "encoding/json" + "errors" "fmt" "regexp" "strings" - "sync" "time" + "github.com/enowdev/antares/internal/approval" "github.com/enowdev/antares/internal/llm" "github.com/enowdev/antares/internal/tools" ) -// Approval gates tools that change something. `tools.approval_mode` decides -// what happens: run it, refuse it, or ask a person. +// Approval gates tools that change something. Ordinary tools follow +// `tools.approval_mode`: run, ask, or refuse. Explicit operations such as +// mutating Cursor calls override auto and still ask, while deny remains an +// immediate refusal and never creates a pending approval. // // Asking only works where a person is watching. A cron job or a messaging // thread has nobody to answer, so a request that goes unanswered is refused // when its deadline passes — the failure mode has to be "did not happen", // never "happened without being asked". -// ApprovalRequest is one pending decision. -type ApprovalRequest struct { - ID string `json:"id"` - SessionID string `json:"session_id"` - Tool string `json:"tool"` - Arguments string `json:"arguments"` - // Reason names why this needs asking about, when it is more than the tool - // simply being one that writes. - Reason string `json:"reason,omitempty"` - CreatedAt time.Time `json:"created_at"` - - decided chan bool -} - -type approvalDesk struct { - mu sync.Mutex - pending map[string]*ApprovalRequest -} - -var approvals = approvalDesk{pending: map[string]*ApprovalRequest{}} +// ApprovalRequest preserves the public agent API while the gate itself lives +// in the approval package. +type ApprovalRequest = approval.Request // PendingApprovals lists what is waiting, oldest first. func (a *Agent) PendingApprovals() []ApprovalRequest { - approvals.mu.Lock() - defer approvals.mu.Unlock() - out := make([]ApprovalRequest, 0, len(approvals.pending)) - for _, r := range approvals.pending { - out = append(out, ApprovalRequest{ - ID: r.ID, SessionID: r.SessionID, Tool: r.Tool, - Arguments: r.Arguments, Reason: r.Reason, CreatedAt: r.CreatedAt, - }) - } - return out + return a.approvalGate().Pending() } // ResolveApproval answers a pending request. It reports false when the id is // unknown, which usually means it already timed out. func (a *Agent) ResolveApproval(id string, allow bool) bool { - approvals.mu.Lock() - r, ok := approvals.pending[id] - if ok { - delete(approvals.pending, id) - } - approvals.mu.Unlock() - if !ok { - return false - } - // Buffered, so answering never blocks even if the waiter has given up. - r.decided <- allow - return true + return a.approvalGate().Resolve(id, allow) +} + +// AwaitOperationApproval applies the explicit-operation policy to an immutable +// operation supplied by another adapter (for example direct Cursor chat). +// Deny mode refuses immediately; every other mode waits on this Agent's shared +// gate so PendingApprovals and ResolveApproval remain the single decision +// surface. +func (a *Agent) AwaitOperationApproval( + ctx context.Context, + op approval.Operation, + emit Emit, +) (bool, error) { + if a.approvalMode() == "deny" { + return false, nil + } + if op.Message == "" { + op.Message = approvalMessage(op) + } + allowed, err := a.awaitApprovalDecision(ctx, op, emit) + if err == nil && allowed && emit != nil { + _ = emit(Event{Type: EventNotice, Message: "approved " + op.Tool}) + } + return allowed, err +} + +func (a *Agent) approvalGate() *approval.Gate { + a.mu.Lock() + defer a.mu.Unlock() + if a.approvals == nil { + a.approvals = approval.NewGate(approvalTimeout) + } + return a.approvals } // approvalTimeout is how long a request waits before being refused. const approvalTimeout = 5 * time.Minute -// checkApproval decides whether a call may proceed. It returns an error result -// to hand back to the model when it may not. -func (a *Agent) checkApproval(ctx context.Context, call llm.ToolCall, tool tools.Tool, sessionID string, emit Emit) *tools.Result { +const explicitOperationUnavailableArguments = `{"operation":"unavailable"}` + +func (a *Agent) approvalMode() string { mode := strings.ToLower(strings.TrimSpace(a.config().Tools.ApprovalMode)) if mode == "" { - mode = "auto" + return "auto" + } + return mode +} + +// checkOperationApproval owns the complete explicit-operation policy. It +// dispatches pre-tool plugins unless deny mode forbids the call outright, +// announces only the operation's safe display projection, and is the only +// place that chooses between immediate denial and the explicit approval gate. +func (a *Agent) checkOperationApproval( + ctx context.Context, + call llm.ToolCall, + explicit tools.OperationApproval, + sessionID string, + platform string, + emit Emit, +) (llm.ToolCall, *tools.Result) { + mode := a.approvalMode() + + var pluginRefusal *tools.Result + if mode != "deny" { + call, pluginRefusal = a.applyPreToolPlugin(ctx, call, sessionID, platform, emit) + } + + op, operationErr := explicit.ApprovalOperation(json.RawMessage(call.Arguments), sessionID) + announcementArguments := explicitOperationUnavailableArguments + if operationErr == nil { + if op.Message == "" { + op.Message = approvalMessage(op) + } + if op.Arguments != "" { + announcementArguments = op.Arguments + } + } + _ = emit(Event{ + Type: EventToolCall, + ID: call.ID, + Name: call.Name, + Arguments: announcementArguments, + }) + + if pluginRefusal != nil { + return call, pluginRefusal } + if mode == "deny" { + return call, denyApproval(call.Name) + } + if operationErr != nil { + result := tools.Errorf("%v", operationErr) + return call, &result + } + return call, a.awaitApproval(ctx, op, emit) +} +// checkApproval decides whether a call may proceed. It returns an error result +// to hand back to the model when it may not. Explicit operations use +// checkOperationApproval so plugin rewrites and safe announcements are part of +// the same policy decision. +func (a *Agent) checkApproval(ctx context.Context, call llm.ToolCall, tool tools.Tool, sessionID string, emit Emit) *tools.Result { + mode := a.approvalMode() danger := dangerIn(call.Name, call.Arguments) + if mode == "deny" { + if tools.NeedsApproval(tool) || danger != "" { + return denyApproval(call.Name) + } + return nil + } + switch mode { case "auto": // The mode says run it, so it runs. A destructive command is still @@ -96,14 +158,6 @@ func (a *Agent) checkApproval(ctx context.Context, call llm.ToolCall, tool tools } return nil - case "deny": - if tools.NeedsApproval(tool) || danger != "" { - res := tools.Errorf("refused: %s changes state and tools.approval_mode is \"deny\". "+ - "Report what you would have done instead.", call.Name) - return &res - } - return nil - case "prompt": if !tools.NeedsApproval(tool) && danger == "" { return nil @@ -116,61 +170,74 @@ func (a *Agent) checkApproval(ctx context.Context, call llm.ToolCall, tool tools } } - req := &ApprovalRequest{ - ID: newID("apr"), + op := approval.Operation{ SessionID: sessionID, Tool: call.Name, Arguments: call.Arguments, Reason: danger, - CreatedAt: time.Now(), - decided: make(chan bool, 1), } - approvals.mu.Lock() - approvals.pending[req.ID] = req - approvals.mu.Unlock() - - payload, _ := json.Marshal(req) - _ = emit(Event{ - Type: EventApproval, - ID: req.ID, - Name: call.Name, - Arguments: call.Arguments, - Message: approvalMessage(req), - Content: string(payload), - }) + op.Message = approvalMessage(op) + return a.awaitApproval(ctx, op, emit) +} - defer func() { - approvals.mu.Lock() - delete(approvals.pending, req.ID) - approvals.mu.Unlock() - }() +func denyApproval(toolName string) *tools.Result { + result := tools.Errorf("refused: %s changes state and tools.approval_mode is \"deny\". "+ + "Report what you would have done instead.", toolName) + return &result +} - select { - case allow := <-req.decided: +func (a *Agent) awaitApproval(ctx context.Context, op approval.Operation, emit Emit) *tools.Result { + allow, err := a.awaitApprovalDecision(ctx, op, emit) + if err == nil { if allow { - _ = emit(Event{Type: EventNotice, Message: "approved " + call.Name}) + _ = emit(Event{Type: EventNotice, Message: "approved " + op.Tool}) return nil } res := tools.Errorf("the user refused this %s call. Do not retry it; "+ - "ask what to do instead, or continue without it.", call.Name) + "ask what to do instead, or continue without it.", op.Tool) return &res - - case <-time.After(approvalTimeout): + } + if errors.Is(err, approval.ErrTimeout) { res := tools.Errorf("no one approved this %s call within %s, so it did not run. "+ - "Say what you were about to do and stop.", call.Name, approvalTimeout) + "Say what you were about to do and stop.", op.Tool, approvalTimeout) return &res + } + res := tools.Errorf("interrupted before %s was approved", op.Tool) + return &res +} - case <-ctx.Done(): - res := tools.Errorf("interrupted before %s was approved", call.Name) - return &res +func (a *Agent) awaitApprovalDecision( + ctx context.Context, + op approval.Operation, + emit Emit, +) (bool, error) { + if emit == nil { + emit = func(Event) error { return nil } } + return a.approvalGate().Await(ctx, op, func(req approval.Request) error { + payload, marshalErr := json.Marshal(req) + if marshalErr != nil { + return marshalErr + } + return emit(Event{ + Type: EventApproval, + ID: req.ID, + Name: req.Tool, + Arguments: req.Arguments, + Message: req.Message, + Content: string(payload), + }) + }) } -func approvalMessage(r *ApprovalRequest) string { - if r.Reason != "" { - return fmt.Sprintf("%s wants to run, and %s", r.Tool, r.Reason) +func approvalMessage(op approval.Operation) string { + if op.Message != "" { + return op.Message + } + if op.Reason != "" { + return fmt.Sprintf("%s wants to run, and %s", op.Tool, op.Reason) } - return r.Tool + " wants to change something" + return op.Tool + " wants to change something" } // dangerous names commands that are worth stopping for even when approval is diff --git a/internal/agent/approval_test.go b/internal/agent/approval_test.go index f5a7860..b7165a8 100644 --- a/internal/agent/approval_test.go +++ b/internal/agent/approval_test.go @@ -3,12 +3,22 @@ package agent import ( "context" "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" "strings" + "sync/atomic" "testing" "time" + "github.com/enowdev/antares/internal/approval" "github.com/enowdev/antares/internal/config" + "github.com/enowdev/antares/internal/cursor" + "github.com/enowdev/antares/internal/cursorrun" "github.com/enowdev/antares/internal/llm" + "github.com/enowdev/antares/internal/plugin" + "github.com/enowdev/antares/internal/store" "github.com/enowdev/antares/internal/tools" ) @@ -35,11 +45,51 @@ func (readingTool) Execute(context.Context, tools.Input) tools.Result { func agentWithMode(mode string) *Agent { cfg := config.Default() cfg.Tools.ApprovalMode = mode - a := agentWithConfig(cfg) - a.active = map[string]context.CancelFunc{} + return New(cfg, nil, nil, nil, nil) +} + +func cursorApprovalTestAgent(mode, baseURL string) *Agent { + cfg := config.Default() + cfg.Tools.ApprovalMode = mode + provider := cfg.Providers["cursor"] + provider.Enabled = true + provider.APIKey = "test-only-key" + provider.BaseURL = baseURL + cfg.Providers["cursor"] = provider + a := New(cfg, nil, tools.Default(), nil, nil) + a.SetCursorRunner(cursorrun.New(cursorrun.Options{ + ResolveClient: func() (cursor.Options, error) { + return cursor.Options{ + BaseURL: baseURL, + APIKey: "test-only-key", + }, nil + }, + })) return a } +func cursorApprovalTestPlugin(t *testing.T, reply string) *plugin.Manager { + t.Helper() + root := t.TempDir() + dir := filepath.Join(root, "approval-test") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + manifest := "name: approval-test\ncommand: ./run.sh\nhooks: [pre_tool_call]\n" + if err := os.WriteFile(filepath.Join(dir, "plugin.yaml"), []byte(manifest), 0o644); err != nil { + t.Fatal(err) + } + script := "#!/bin/sh\ncat > /dev/null\ncat <<'EOF'\n" + reply + "\nEOF\n" + if err := os.WriteFile(filepath.Join(dir, "run.sh"), []byte(script), 0o755); err != nil { + t.Fatal(err) + } + manager := plugin.NewManager([]string{root}) + if err := manager.Load(); err != nil { + t.Fatal(err) + } + return manager +} + func TestAutoModeRunsEverything(t *testing.T) { a := agentWithMode("auto") call := llm.ToolCall{ID: "1", Name: "write_file", Arguments: `{"path":"a"}`} @@ -48,6 +98,67 @@ func TestAutoModeRunsEverything(t *testing.T) { } } +func TestAwaitOperationApprovalSharesExplicitCursorPolicy(t *testing.T) { + t.Run("auto still waits for a human", func(t *testing.T) { + a := agentWithMode("auto") + eventCh := make(chan Event, 1) + resultCh := make(chan struct { + allowed bool + err error + }, 1) + go func() { + allowed, err := a.AwaitOperationApproval( + context.Background(), + approval.Operation{ + SessionID: "ses-one", + Tool: "cursor_direct", + Arguments: `{"operation":"start"}`, + Message: "Start Cursor Cloud Agent run", + }, + func(event Event) error { + if event.Type == EventApproval { + eventCh <- event + } + return nil + }, + ) + resultCh <- struct { + allowed bool + err error + }{allowed: allowed, err: err} + }() + + event := <-eventCh + if event.ID == "" || event.Arguments != `{"operation":"start"}` { + t.Fatalf("approval event = %+v", event) + } + if !a.ResolveApproval(event.ID, true) { + t.Fatal("pending operation could not be resolved through Agent") + } + result := <-resultCh + if !result.allowed || result.err != nil { + t.Fatalf("approval result = %+v", result) + } + }) + + t.Run("deny refuses without publishing", func(t *testing.T) { + a := agentWithMode("deny") + emitted := false + allowed, err := a.AwaitOperationApproval( + context.Background(), + approval.Operation{SessionID: "ses-one", Tool: "cursor_direct"}, + func(Event) error { + emitted = true + return nil + }, + ) + if err != nil || allowed || emitted || len(a.PendingApprovals()) != 0 { + t.Fatalf("deny result allowed=%v err=%v emitted=%v pending=%d", + allowed, err, emitted, len(a.PendingApprovals())) + } + }) +} + func TestAutoModeStillNamesDangerousCommands(t *testing.T) { a := agentWithMode("auto") var notices []string @@ -155,6 +266,47 @@ func TestPromptModeRefusalIsToldToTheModel(t *testing.T) { } } +func TestPromptModeCallerDeadlineIsReportedAsInterruption(t *testing.T) { + a := agentWithMode("prompt") + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + emitted := make(chan struct{}, 1) + done := make(chan *tools.Result, 1) + + go func() { + done <- a.checkApproval( + ctx, + llm.ToolCall{ID: "1", Name: "write_file", Arguments: `{"path":"a"}`}, + writingTool{"write_file"}, + "ses-one", + func(e Event) error { + if e.Type == EventApproval { + emitted <- struct{}{} + } + return nil + }, + ) + }() + + select { + case <-emitted: + case <-time.After(time.Second): + t.Fatal("no approval request was emitted") + } + var result *tools.Result + select { + case result = <-done: + case <-time.After(time.Second): + t.Fatal("caller deadline did not stop approval wait") + } + if result == nil || !result.IsError || !strings.Contains(result.Content, "interrupted before") { + t.Fatalf("caller deadline result = %+v, want interruption", result) + } + if strings.Contains(result.Content, approvalTimeout.String()) { + t.Fatalf("caller deadline was reported as gate expiry: %s", result.Content) + } +} + func TestResolvingAnUnknownRequestReportsFalse(t *testing.T) { a := agentWithMode("prompt") if a.ResolveApproval("apr_nope", true) { @@ -162,6 +314,819 @@ func TestResolvingAnUnknownRequestReportsFalse(t *testing.T) { } } +func TestPromptApprovalsBelongToOneAgentInstance(t *testing.T) { + first := agentWithMode("prompt") + second := agentWithMode("prompt") + emitted := make(chan string, 1) + done := make(chan *tools.Result, 1) + + go func() { + done <- first.checkApproval( + context.Background(), + llm.ToolCall{ID: "1", Name: "write_file", Arguments: `{"path":"a"}`}, + writingTool{"write_file"}, + "ses-first", + func(e Event) error { + if e.Type == EventApproval { + emitted <- e.ID + } + return nil + }, + ) + }() + + var requestID string + select { + case requestID = <-emitted: + case <-time.After(3 * time.Second): + t.Fatal("no approval request was emitted") + } + if got := second.PendingApprovals(); len(got) != 0 { + t.Fatalf("second agent saw first agent's approvals: %+v", got) + } + if second.ResolveApproval(requestID, true) { + t.Fatal("second agent resolved first agent's approval") + } + if got := first.PendingApprovals(); len(got) != 1 || got[0].SessionID != "ses-first" { + t.Fatalf("first agent pending approvals = %+v", got) + } + if !first.ResolveApproval(requestID, false) { + t.Fatal("first agent could not resolve its approval") + } + if result := <-done; result == nil || !result.IsError { + t.Fatalf("denied approval result = %+v", result) + } +} + +func TestCursorOperationsWaitForApprovalBeforeUpstreamRequests(t *testing.T) { + tests := []struct { + name string + arguments string + wantCalls int32 + }{ + { + name: "start", + arguments: `{"action":"start","prompt":"private start prompt","wait":false}`, + wantCalls: 1, + }, + { + name: "follow up", + arguments: `{"action":"follow_up","agent_id":"bc-one","prompt":"private follow-up prompt","wait":false}`, + wantCalls: 2, + }, + { + name: "cancel", + arguments: `{"action":"cancel","agent_id":"bc-one","run_id":"run-one"}`, + wantCalls: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + switch { + case r.Method == http.MethodPost && r.URL.Path == "/v1/agents": + _ = json.NewEncoder(w).Encode(map[string]any{ + "agent": map[string]any{ + "id": "bc-one", "status": "ACTIVE", + "url": "https://cursor.com/agents/bc-one", "latestRunId": "run-one", + }, + "run": map[string]any{ + "id": "run-one", "agentId": "bc-one", "status": "CREATING", + }, + }) + case r.Method == http.MethodGet && r.URL.Path == "/v1/agents/bc-one": + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "bc-one", "status": "FINISHED", + "url": "https://cursor.com/agents/bc-one", "latestRunId": "run-one", + }) + case r.Method == http.MethodPost && r.URL.Path == "/v1/agents/bc-one/runs": + _ = json.NewEncoder(w).Encode(map[string]any{ + "run": map[string]any{ + "id": "run-two", "agentId": "bc-one", "status": "CREATING", + }, + }) + case r.Method == http.MethodPost && r.URL.Path == "/v1/agents/bc-one/runs/run-one/cancel": + w.WriteHeader(http.StatusNoContent) + default: + t.Errorf("unexpected upstream request: %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + })) + defer srv.Close() + + a := cursorApprovalTestAgent("auto", srv.URL) + cursorTool, ok := tools.Default().Get("cursor_agent") + if !ok { + t.Fatal("cursor_agent is not registered") + } + + approvalEvent := make(chan Event, 1) + done := make(chan []toolOutcome, 1) + workspace := t.TempDir() + go func() { + done <- a.executeTools( + context.Background(), + []llm.ToolCall{{ID: "call-one", Name: "cursor_agent", Arguments: tt.arguments}}, + map[string]tools.Tool{"cursor_agent": cursorTool}, + Request{}, + &store.Session{ID: "ses-one", Workspace: workspace}, + func(e Event) error { + if e.Type == EventApproval { + approvalEvent <- e + } + return nil + }, + ) + }() + + var event Event + select { + case event = <-approvalEvent: + case outcomes := <-done: + t.Fatalf("Cursor operation finished before approval: %+v", outcomes) + case <-time.After(3 * time.Second): + t.Fatal("Cursor operation did not request approval") + } + if got := calls.Load(); got != 0 { + t.Fatalf("upstream requests before approval = %d, want 0", got) + } + if strings.Contains(event.Arguments, "private") || strings.Contains(event.Arguments, "prompt") { + t.Fatalf("approval event leaked prompt: %s", event.Arguments) + } + if !a.ResolveApproval(event.ID, true) { + t.Fatal("approval could not be resolved") + } + + select { + case outcomes := <-done: + if len(outcomes) != 1 || outcomes[0].isError { + t.Fatalf("approved Cursor outcome = %+v", outcomes) + } + case <-time.After(3 * time.Second): + t.Fatal("approved Cursor operation did not finish") + } + if got := calls.Load(); got != tt.wantCalls { + t.Fatalf("upstream requests after approval = %d, want %d", got, tt.wantCalls) + } + }) + } +} + +func TestCursorOperationApprovalOverridesAutoAndPromptModes(t *testing.T) { + cursorTool, ok := tools.Default().Get("cursor_agent") + if !ok { + t.Fatal("cursor_agent is not registered") + } + + for _, mode := range []string{"auto", "prompt", "unknown"} { + t.Run(mode, func(t *testing.T) { + a := agentWithMode(mode) + emitted := make(chan Event, 1) + done := make(chan []toolOutcome, 1) + workspace := t.TempDir() + go func() { + done <- a.executeTools( + context.Background(), + []llm.ToolCall{{ + ID: "call-one", + Name: "cursor_agent", + Arguments: `{"action":"cancel","agent_id":"bc-one","run_id":"run-one"}`, + }}, + map[string]tools.Tool{"cursor_agent": cursorTool}, + Request{}, + &store.Session{ID: "ses-one", Workspace: workspace}, + func(e Event) error { + if e.Type == EventApproval { + emitted <- e + } + return nil + }, + ) + }() + + var event Event + select { + case event = <-emitted: + case outcomes := <-done: + t.Fatalf("mode %q bypassed explicit approval with outcome %+v", mode, outcomes) + case <-time.After(3 * time.Second): + t.Fatalf("mode %q did not request explicit approval", mode) + } + if !a.ResolveApproval(event.ID, false) { + t.Fatal("explicit approval could not be denied") + } + if outcomes := <-done; len(outcomes) != 1 || !outcomes[0].isError || + !strings.Contains(outcomes[0].message.Content, "refused") { + t.Fatalf("denied explicit approval outcome = %+v", outcomes) + } + }) + } +} + +func TestCursorOperationApprovalDenyModeRefusesImmediately(t *testing.T) { + var upstreamCalls atomic.Int32 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamCalls.Add(1) + http.Error(w, "deny mode must prevent upstream requests", http.StatusInternalServerError) + })) + defer upstream.Close() + + cursorTool, ok := tools.Default().Get("cursor_agent") + if !ok { + t.Fatal("cursor_agent is not registered") + } + a := cursorApprovalTestAgent("deny", upstream.URL) + a.plugins = cursorApprovalTestPlugin(t, `{"deny":true,"reason":"plugin must not run"}`) + events := make(chan Event, 16) + outcomes := a.executeTools( + context.Background(), + []llm.ToolCall{{ + ID: "call-one", + Name: "cursor_agent", + Arguments: `{ + "action":"start", + "prompt":"deny-private-prompt", + "model":"composer-2", + "images":["deny-private-image"], + "api_key":"deny-private-key", + "unknown":"deny-private-unknown" + }`, + }}, + map[string]tools.Tool{"cursor_agent": cursorTool}, + Request{}, + &store.Session{ID: "ses-one", Workspace: t.TempDir()}, + func(e Event) error { + events <- e + return nil + }, + ) + + if len(outcomes) != 1 || !outcomes[0].isError || + !strings.Contains(outcomes[0].message.Content, `approval_mode is "deny"`) || + strings.Contains(outcomes[0].message.Content, "plugin must not run") { + t.Fatalf("deny outcome = %+v", outcomes) + } + var emitted []Event + for { + select { + case event := <-events: + emitted = append(emitted, event) + default: + goto drained + } + } + +drained: + if len(emitted) != 2 || + emitted[0].Type != EventToolCall || + emitted[1].Type != EventToolResult || + emitted[0].ID != "call-one" || + emitted[1].ID != "call-one" { + t.Fatalf("deny events = %+v, want safe tool_call then tool_result", emitted) + } + if !strings.Contains(emitted[0].Arguments, `"action":"start"`) || + !strings.Contains(emitted[0].Arguments, `"model":"composer-2"`) { + t.Errorf("deny tool-call projection = %s", emitted[0].Arguments) + } + for _, forbidden := range []string{"deny-private-prompt", "prompt", "images", "api_key", "unknown", "deny-private-key"} { + if strings.Contains(emitted[0].Arguments, forbidden) { + t.Errorf("deny tool-call projection leaked %q: %s", forbidden, emitted[0].Arguments) + } + } + if pending := a.PendingApprovals(); len(pending) != 0 { + t.Fatalf("deny mode left pending approvals: %+v", pending) + } + if got := upstreamCalls.Load(); got != 0 { + t.Fatalf("deny mode made %d upstream request(s)", got) + } +} + +func TestCursorOperationPluginDenyPrecedesApproval(t *testing.T) { + var upstreamCalls atomic.Int32 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamCalls.Add(1) + http.Error(w, "plugin denial must prevent upstream requests", http.StatusInternalServerError) + })) + defer upstream.Close() + + a := cursorApprovalTestAgent("auto", upstream.URL) + a.plugins = cursorApprovalTestPlugin(t, `{"deny":true,"reason":"blocked before approval"}`) + cursorTool, ok := tools.Default().Get("cursor_agent") + if !ok { + t.Fatal("cursor_agent is not registered") + } + + events := make(chan Event, 16) + done := make(chan []toolOutcome, 1) + workspace := t.TempDir() + go func() { + done <- a.executeTools( + context.Background(), + []llm.ToolCall{{ + ID: "call-one", + Name: "cursor_agent", + Arguments: `{"action":"start","prompt":"must not run","wait":false}`, + }}, + map[string]tools.Tool{"cursor_agent": cursorTool}, + Request{}, + &store.Session{ID: "ses-one", Workspace: workspace}, + func(e Event) error { + events <- e + return nil + }, + ) + }() + + var outcomes []toolOutcome + var emitted []Event + for outcomes == nil { + select { + case event := <-events: + emitted = append(emitted, event) + if event.Type == EventApproval { + a.ResolveApproval(event.ID, false) + <-done + t.Fatalf("plugin denial ran after approval was pending: %+v", event) + } + case outcomes = <-done: + case <-time.After(3 * time.Second): + t.Fatal("plugin denial did not finish") + } + } + if len(outcomes) != 1 || !outcomes[0].isError || + !strings.Contains(outcomes[0].message.Content, "blocked before approval") { + t.Fatalf("plugin denial outcome = %+v", outcomes) + } + if got := upstreamCalls.Load(); got != 0 { + t.Fatalf("plugin denial made %d upstream request(s)", got) + } + if pending := a.PendingApprovals(); len(pending) != 0 { + t.Fatalf("plugin denial left pending approvals: %+v", pending) + } + for { + select { + case event := <-events: + emitted = append(emitted, event) + if event.Type == EventApproval { + t.Fatalf("plugin denial emitted approval: %+v", event) + } + default: + if len(emitted) != 2 || + emitted[0].Type != EventToolCall || + emitted[1].Type != EventToolResult || + emitted[0].ID != "call-one" || + emitted[1].ID != "call-one" { + t.Fatalf("plugin-deny events = %+v, want safe tool_call then tool_result", emitted) + } + if !strings.Contains(emitted[0].Arguments, `"action":"start"`) { + t.Errorf("plugin-deny tool-call projection = %s", emitted[0].Arguments) + } + for _, forbidden := range []string{"must not run", "prompt", "images", "api_key", "unknown"} { + if strings.Contains(emitted[0].Arguments, forbidden) { + t.Errorf("plugin-deny projection leaked %q: %s", forbidden, emitted[0].Arguments) + } + } + return + } + } +} + +func TestCursorOperationPluginRewriteIsApprovedAndExecuted(t *testing.T) { + var upstreamCalls atomic.Int32 + createdBody := make(chan map[string]any, 1) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamCalls.Add(1) + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v1/models": + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []any{map[string]any{ + "id": "gpt-5.6-sol", + "variants": []any{map[string]any{ + "params": []any{ + map[string]any{"id": "reasoning", "value": "max"}, + map[string]any{"id": "context", "value": "1m"}, + }, + }}, + }}, + }) + case r.Method == http.MethodPost && r.URL.Path == "/v1/agents": + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode rewritten create body: %v", err) + } + createdBody <- body + _ = json.NewEncoder(w).Encode(map[string]any{ + "agent": map[string]any{ + "id": "bc-rewritten", "status": "ACTIVE", + "url": "https://cursor.com/agents/bc-rewritten", "latestRunId": "run-rewritten", + }, + "run": map[string]any{ + "id": "run-rewritten", "agentId": "bc-rewritten", "status": "CREATING", + }, + }) + default: + t.Errorf("rewritten request = %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + })) + defer upstream.Close() + + a := cursorApprovalTestAgent("auto", upstream.URL) + rewrittenArgs, _ := json.Marshal(map[string]any{ + "action": "start", + "prompt": "rewritten private prompt", + "model": "gpt-5.6-sol", + "model_params": []any{ + map[string]any{"id": "context", "value": "1m"}, + map[string]any{"id": "reasoning", "value": "max"}, + }, + "wait": false, + }) + pluginReply, _ := json.Marshal(map[string]string{"arguments": string(rewrittenArgs)}) + a.plugins = cursorApprovalTestPlugin(t, string(pluginReply)) + cursorTool, ok := tools.Default().Get("cursor_agent") + if !ok { + t.Fatal("cursor_agent is not registered") + } + + approvalEvent := make(chan Event, 1) + done := make(chan []toolOutcome, 1) + workspace := t.TempDir() + go func() { + done <- a.executeTools( + context.Background(), + []llm.ToolCall{{ + ID: "call-one", + Name: "cursor_agent", + Arguments: `{"action":"start","prompt":"original operation","wait":false}`, + }}, + map[string]tools.Tool{"cursor_agent": cursorTool}, + Request{}, + &store.Session{ID: "ses-one", Workspace: workspace}, + func(e Event) error { + if e.Type == EventApproval { + approvalEvent <- e + } + return nil + }, + ) + }() + + var event Event + select { + case event = <-approvalEvent: + case outcomes := <-done: + t.Fatalf("rewritten operation finished before approval: %+v", outcomes) + case <-time.After(3 * time.Second): + t.Fatal("rewritten operation did not request approval") + } + pending := a.PendingApprovals() + eventShowsRewrite := strings.Contains(event.Arguments, `"action":"start"`) && + strings.Contains(event.Arguments, `"model":"gpt-5.6-sol"`) && + strings.Contains(event.Arguments, `"id":"context"`) && + strings.Contains(event.Arguments, `"value":"1m"`) && + !strings.Contains(event.Arguments, "rewritten private prompt") + pendingShowsRewrite := len(pending) == 1 && + pending[0].Arguments == event.Arguments + if got := upstreamCalls.Load(); got != 0 { + t.Fatalf("rewritten operation made %d upstream request(s) before approval", got) + } + if !a.ResolveApproval(event.ID, true) { + t.Fatal("rewritten operation approval could not be resolved") + } + outcomes := <-done + if len(outcomes) != 1 || outcomes[0].isError { + t.Fatalf("rewritten operation outcome = %+v", outcomes) + } + if !eventShowsRewrite || !pendingShowsRewrite { + t.Fatalf("approval did not retain plugin rewrite: event=%s pending=%+v", event.Arguments, pending) + } + if got := upstreamCalls.Load(); got != 2 { + t.Fatalf("rewritten operation made %d upstream request(s), want catalog plus create", got) + } + var body map[string]any + select { + case body = <-createdBody: + case <-time.After(time.Second): + t.Fatal("rewritten operation made no create request") + } + if body["prompt"].(map[string]any)["text"] != "rewritten private prompt" { + t.Fatalf("rewritten prompt did not execute: %#v", body) + } + params := body["model"].(map[string]any)["params"].([]any) + if len(params) != 2 || + params[0].(map[string]any)["id"] != "context" || + params[0].(map[string]any)["value"] != "1m" || + params[1].(map[string]any)["id"] != "reasoning" || + params[1].(map[string]any)["value"] != "max" { + t.Fatalf("rewritten model params did not execute exactly: %#v", params) + } +} + +func TestCursorOperationToolCallUsesApprovalProjection(t *testing.T) { + const privatePrompt = "private prompt must stay out of outward events" + var upstreamCalls atomic.Int32 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamCalls.Add(1) + http.Error(w, "denied operation must not reach upstream", http.StatusInternalServerError) + })) + defer upstream.Close() + + a := cursorApprovalTestAgent("auto", upstream.URL) + cursorTool, ok := tools.Default().Get("cursor_agent") + if !ok { + t.Fatal("cursor_agent is not registered") + } + raw := `{ + "action":"start", + "prompt":"` + privatePrompt + `", + "model":"composer-2", + "wait":false, + "images":["private-image"], + "api_key":"private-api-key", + "unknown":{"instructions":"private-unknown"} + }` + + events := make(chan Event, 16) + done := make(chan []toolOutcome, 1) + workspace := t.TempDir() + go func() { + done <- a.executeTools( + context.Background(), + []llm.ToolCall{{ID: "call-one", Name: "cursor_agent", Arguments: raw}}, + map[string]tools.Tool{"cursor_agent": cursorTool}, + Request{}, + &store.Session{ID: "ses-one", Workspace: workspace}, + func(e Event) error { + events <- e + return nil + }, + ) + }() + + var toolCall, approvalEvent Event + for toolCall.Type == "" || approvalEvent.Type == "" { + select { + case event := <-events: + switch event.Type { + case EventToolCall: + toolCall = event + case EventApproval: + approvalEvent = event + } + case outcomes := <-done: + t.Fatalf("operation finished before projection and approval events: %+v", outcomes) + case <-time.After(3 * time.Second): + t.Fatal("projection and approval events were not emitted") + } + } + pending := a.PendingApprovals() + if !a.ResolveApproval(approvalEvent.ID, false) { + t.Fatal("approval could not be denied") + } + if outcomes := <-done; len(outcomes) != 1 || !outcomes[0].isError { + t.Fatalf("denied operation outcome = %+v", outcomes) + } + + if toolCall.Arguments != approvalEvent.Arguments { + t.Errorf("tool call projection %s differs from approval %s", toolCall.Arguments, approvalEvent.Arguments) + } + if len(pending) != 1 || pending[0].Arguments != approvalEvent.Arguments { + t.Errorf("pending projection = %+v, approval = %s", pending, approvalEvent.Arguments) + } + for _, forbidden := range []string{ + privatePrompt, "prompt", "images", "api_key", "unknown", "private-image", + "private-api-key", "private-unknown", + } { + if strings.Contains(toolCall.Arguments, forbidden) { + t.Errorf("tool call projection leaked %q: %s", forbidden, toolCall.Arguments) + } + } + if got := upstreamCalls.Load(); got != 0 { + t.Fatalf("denied operation made %d upstream request(s)", got) + } +} + +func TestCursorApprovalSurfacesRedactKeyLikeTokensButExecutionRetainsThem(t *testing.T) { + const ( + modelToken = "prefix-crsr_model_secret_123456789.tail-model" + modelParamToken = "max-crsr_param_secret_123456789.tail-param" + refToken = "feature/crsr_ref_secret_123456789/tail-ref" + promptToken = "crsr_prompt_secret_123456789" + ) + bodyCh := make(chan map[string]any, 1) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/v1/models" { + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []any{map[string]any{ + "id": modelToken, + "variants": []any{map[string]any{ + "params": []any{map[string]any{ + "id": "reasoning", "value": modelParamToken, + }}, + }}, + }}, + }) + return + } + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode create body: %v", err) + } + bodyCh <- body + _ = json.NewEncoder(w).Encode(map[string]any{ + "agent": map[string]any{ + "id": "bc-one", "status": "ACTIVE", + "url": "https://cursor.com/agents/bc-one", "latestRunId": "run-one", + }, + "run": map[string]any{ + "id": "run-one", "agentId": "bc-one", "status": "CREATING", + }, + }) + })) + defer upstream.Close() + + a := cursorApprovalTestAgent("auto", upstream.URL) + cursorTool, ok := tools.Default().Get("cursor_agent") + if !ok { + t.Fatal("cursor_agent is not registered") + } + raw, _ := json.Marshal(map[string]any{ + "action": "start", + "prompt": "use " + promptToken, + "model": modelToken, + "model_params": []any{map[string]any{"id": "reasoning", "value": modelParamToken}}, + "repository_url": "https://github.com/acme/repo", + "starting_ref": refToken, + "wait": false, + "api_key": "crsr_unknown_secret_123456789", + }) + + toolCallCh := make(chan Event, 1) + approvalCh := make(chan Event, 1) + done := make(chan []toolOutcome, 1) + workspace := t.TempDir() + go func() { + done <- a.executeTools( + context.Background(), + []llm.ToolCall{{ID: "call-one", Name: "cursor_agent", Arguments: string(raw)}}, + map[string]tools.Tool{"cursor_agent": cursorTool}, + Request{}, + &store.Session{ID: "ses-one", Workspace: workspace}, + func(e Event) error { + switch e.Type { + case EventToolCall: + toolCallCh <- e + case EventApproval: + approvalCh <- e + } + return nil + }, + ) + }() + + var toolCall, approvalEvent Event + select { + case toolCall = <-toolCallCh: + case <-time.After(3 * time.Second): + t.Fatal("no projected tool-call event") + } + select { + case approvalEvent = <-approvalCh: + case <-time.After(3 * time.Second): + t.Fatal("no approval event") + } + pending := a.PendingApprovals() + pendingJSON, _ := json.Marshal(pending) + outward := toolCall.Arguments + approvalEvent.Arguments + approvalEvent.Content + string(pendingJSON) + leaked := "" + for _, token := range []string{ + "prefix-", "tail-model", "max-", "tail-param", "feature/", "tail-ref", + "crsr_model_secret_123456789", "crsr_ref_secret_123456789", + "crsr_param_secret_123456789", promptToken, "crsr_unknown_secret_123456789", + } { + if strings.Contains(outward, token) { + leaked = token + break + } + } + var projection map[string]any + if err := json.Unmarshal([]byte(toolCall.Arguments), &projection); err != nil { + t.Fatalf("decode tool-call projection: %v", err) + } + projectedParams := projection["model_params"].([]any) + redacted := projection["model"] == "[REDACTED]" && + len(projectedParams) == 1 && + projectedParams[0].(map[string]any)["value"] == "[REDACTED]" && + projection["starting_ref"] == "[REDACTED]" && + approvalEvent.Arguments == toolCall.Arguments && + len(pending) == 1 && pending[0].Arguments == toolCall.Arguments + + if !a.ResolveApproval(approvalEvent.ID, true) { + t.Fatal("approval could not be resolved") + } + outcomes := <-done + if len(outcomes) != 1 || outcomes[0].isError { + t.Fatalf("approved operation outcome = %+v", outcomes) + } + var executed map[string]any + select { + case executed = <-bodyCh: + case <-time.After(time.Second): + t.Fatal("approved operation made no upstream request") + } + model := executed["model"].(map[string]any) + repo := executed["repos"].([]any)[0].(map[string]any) + params := model["params"].([]any) + if model["id"] != modelToken || + len(params) != 1 || + params[0].(map[string]any)["value"] != modelParamToken || + repo["startingRef"] != refToken { + t.Fatalf("execution did not retain original values: model=%#v repo=%#v", model, repo) + } + if leaked != "" { + t.Fatalf("approval surfaces leaked key-like token %q: %s", leaked, outward) + } + if !redacted { + t.Fatalf("approval surfaces lacked redaction markers: %s", outward) + } +} + +func TestInvalidCursorOperationDoesNotEchoRawToolCall(t *testing.T) { + var upstreamCalls atomic.Int32 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamCalls.Add(1) + http.Error(w, "invalid operation must not reach upstream", http.StatusInternalServerError) + })) + defer upstream.Close() + + a := cursorApprovalTestAgent("auto", upstream.URL) + cursorTool, ok := tools.Default().Get("cursor_agent") + if !ok { + t.Fatal("cursor_agent is not registered") + } + raw := `{ + "action":"start", + "api_key":"invalid-private-key", + "images":["invalid-private-image"], + "unknown":"invalid-private-payload" + }` + events := make(chan Event, 16) + workspace := t.TempDir() + outcomes := a.executeTools( + context.Background(), + []llm.ToolCall{{ID: "call-one", Name: "cursor_agent", Arguments: raw}}, + map[string]tools.Tool{"cursor_agent": cursorTool}, + Request{}, + &store.Session{ID: "ses-one", Workspace: workspace}, + func(e Event) error { + events <- e + return nil + }, + ) + + if len(outcomes) != 1 || !outcomes[0].isError || + !strings.Contains(outcomes[0].message.Content, "prompt is required") { + t.Fatalf("invalid operation outcome = %+v", outcomes) + } + var emitted []Event + for { + select { + case event := <-events: + emitted = append(emitted, event) + if event.Type == EventApproval { + t.Fatalf("invalid operation requested approval: %+v", event) + } + for _, forbidden := range []string{"invalid-private-key", "invalid-private-image", "invalid-private-payload"} { + if strings.Contains(event.Arguments, forbidden) || strings.Contains(event.Content, forbidden) { + t.Errorf("invalid operation echoed %q in event %+v", forbidden, event) + } + } + default: + if len(emitted) != 2 || + emitted[0].Type != EventToolCall || + emitted[1].Type != EventToolResult || + emitted[0].ID != "call-one" || + emitted[1].ID != "call-one" { + t.Fatalf("invalid operation events = %+v, want safe tool_call then tool_result", emitted) + } + if emitted[0].Arguments != `{"operation":"unavailable"}` { + t.Fatalf("invalid operation placeholder = %q", emitted[0].Arguments) + } + if pending := a.PendingApprovals(); len(pending) != 0 { + t.Fatalf("invalid operation left pending approvals: %+v", pending) + } + if got := upstreamCalls.Load(); got != 0 { + t.Fatalf("invalid operation made %d upstream request(s)", got) + } + return + } + } +} + func TestDangerDetection(t *testing.T) { dangerous := []string{ "rm -rf ~", diff --git a/internal/agent/client.go b/internal/agent/client.go index c29e222..b1de216 100644 --- a/internal/agent/client.go +++ b/internal/agent/client.go @@ -8,6 +8,7 @@ import ( "log/slog" + "github.com/enowdev/antares/internal/config" "github.com/enowdev/antares/internal/llm" ) @@ -16,6 +17,14 @@ import ( // models are configured, the returned client tries each in turn on a hard // failure. sessionID pins gateway sticky routing (Gemini CLI–compatible) when set. func (a *Agent) newClient(modelOverride, sessionID string) (client llm.Client, model, provider string, err error) { + return a.buildClient(context.Background(), modelOverride, sessionID, false) +} + +func (a *Agent) newClientContext(ctx context.Context, modelOverride, sessionID string) (client llm.Client, model, provider string, err error) { + return a.buildClient(ctx, modelOverride, sessionID, true) +} + +func (a *Agent) buildClient(ctx context.Context, modelOverride, sessionID string, withReasoningCapabilities bool) (client llm.Client, model, provider string, err error) { primary, model, provider, err := a.resolveClient(modelOverride, sessionID) if err != nil { return nil, "", "", err @@ -23,24 +32,43 @@ func (a *Agent) newClient(modelOverride, sessionID string) (client llm.Client, m // Only the default path (no explicit override) uses the fallback chain, so // a deliberately chosen model is honoured exactly. - entries := []llm.FallbackEntry{{Client: primary, Model: model}} + entries := []llm.FallbackEntry{{ + Client: primary, + Model: model, + }} if modelOverride == "" { for _, spec := range a.config().Model.Fallback { spec = strings.TrimSpace(spec) if spec == "" { continue } - fc, fm, _, ferr := a.resolveClient(spec, sessionID) + fc, fm, fp, ferr := a.resolveClient(spec, sessionID) if ferr != nil { slog.Debug("fallback model unavailable", "spec", spec, "error", ferr) continue } - entries = append(entries, llm.FallbackEntry{Client: fc, Model: fm}) + if withReasoningCapabilities && len(entries) == 1 { + entries[0].ReasoningCapability = a.reasoningCapabilityForResolved(ctx, provider, model) + } + entry := llm.FallbackEntry{Client: fc, Model: fm} + if withReasoningCapabilities { + entry.ReasoningCapability = a.reasoningCapabilityForResolved(ctx, fp, fm) + } + entries = append(entries, entry) } } return llm.NewFallback(entries), model, provider, nil } +func (a *Agent) reasoningCapabilityForResolved(ctx context.Context, provider, model string) *llm.ReasoningCapability { + capability, err := a.ReasoningCapability(ctx, provider+"/"+model) + if err != nil { + slog.Debug("reasoning metadata unavailable", "provider", provider, "model", model, "error", err) + return nil + } + return capability +} + // resolveClient builds one provider adapter for a model spec. func (a *Agent) resolveClient(modelOverride, sessionID string) (client llm.Client, model, provider string, err error) { cfg := a.config() @@ -132,48 +160,92 @@ func (a *Agent) Probe(ctx context.Context) (bool, string) { // Models lists the models a provider offers. // -// If providers..models is non-empty it is treated as a whitelist: only -// those ids are returned (no live /models merge). This keeps curated local -// gateways (e.g. Sub2API antigravity) from flooding the UI with broken or -// deprecated upstream catalog entries. +// If providers..models is non-empty it is treated as a whitelist: live +// metadata may enrich those ids, but unlisted live models are never appended. +// This keeps curated local gateways (e.g. Sub2API antigravity) from flooding +// the UI with broken or deprecated upstream catalog entries. // // If the list is empty, the provider's /models endpoint is queried live. // A live fetch that fails still yields any manual list rather than nothing. func (a *Agent) Models(ctx context.Context, providerID string) ([]llm.ModelInfo, error) { id, p := a.config().ResolveProvider(providerID) - - // Curated whitelist: skip live catalog entirely. - if len(p.Models) > 0 { - out := make([]llm.ModelInfo, 0, len(p.Models)) - seen := make(map[string]bool, len(p.Models)) - for _, mid := range p.Models { - if mid == "" || seen[mid] { - continue - } - seen[mid] = true - out = append(out, llm.ModelInfo{ - ID: mid, - Name: mid, - Provider: id, - ContextWindow: p.ModelMeta[mid].ContextWindow, - }) - } - return out, nil + models, err := a.modelsForProvider(ctx, id, p) + if err != nil && len(p.Models) > 0 { + return models, nil } + return models, err +} - client, err := llm.New(llm.Options{ - Kind: p.Kind, BaseURL: p.BaseURL, APIKey: p.APIKey, - Headers: p.Headers, ProviderID: id, Timeout: 60 * time.Second, APIVersion: p.APIVersion, Region: p.Region, +func (a *Agent) modelsForProvider(ctx context.Context, id string, p config.Provider) ([]llm.ModelInfo, error) { + live, adapterKind, ferr := a.cachedProviderCatalog(ctx, id, p, func(fetchCtx context.Context) ([]llm.ModelInfo, string, error) { + client, err := llm.New(llm.Options{ + Kind: p.Kind, BaseURL: p.BaseURL, APIKey: p.APIKey, + Headers: p.Headers, ProviderID: id, Timeout: 60 * time.Second, APIVersion: p.APIVersion, Region: p.Region, + }) + if err != nil { + return nil, "", err + } + models, err := client.Models(fetchCtx) + return models, client.Kind(), err }) - if err != nil { - return nil, err + reasoningKind := reasoningFamilyForAdapter(p.Kind, adapterKind) + if len(p.Models) > 0 { + return curatedModelsWithReasoning(id, p, live, reasoningKind), ferr } - fetchCtx, cancel := context.WithTimeout(ctx, 45*time.Second) - defer cancel() - - live, ferr := client.Models(fetchCtx) if ferr != nil && len(live) == 0 { return nil, ferr } + for i := range live { + live[i].Provider = id + if live[i].ReasoningCapability != nil { + continue + } + target := reasoningTarget{providerID: id, model: live[i].ID, provider: p} + target.provider.Kind = reasoningKind + live[i] = live[i].WithReasoningCapability(staticReasoningCapability(target)) + } return live, nil } + +func reasoningFamilyForAdapter(configuredKind, adapterKind string) string { + // Codex/Responses intentionally owns a narrower static table even though + // codexClient.Kind reports "openai" for its shared transport family. + switch strings.ToLower(strings.TrimSpace(configuredKind)) { + case "codex", "responses", "openai-responses": + return "codex" + } + if adapterKind == "" { + return normalizedProviderKind(configuredKind) + } + return normalizedProviderKind(adapterKind) +} + +func curatedModelsWithReasoning(id string, p config.Provider, live []llm.ModelInfo, kind string) []llm.ModelInfo { + liveByID := make(map[string]llm.ModelInfo, len(live)) + for _, model := range live { + liveByID[model.ID] = model + } + + out := make([]llm.ModelInfo, 0, len(p.Models)) + seen := make(map[string]bool, len(p.Models)) + for _, mid := range p.Models { + if mid == "" || seen[mid] { + continue + } + seen[mid] = true + info := llm.ModelInfo{ + ID: mid, + Name: mid, + Provider: id, + ContextWindow: p.ModelMeta[mid].ContextWindow, + } + capability := liveByID[mid].ReasoningCapability + if capability == nil { + target := reasoningTarget{providerID: id, model: mid, provider: p} + target.provider.Kind = kind + capability = staticReasoningCapability(target) + } + out = append(out, info.WithReasoningCapability(capability)) + } + return out +} diff --git a/internal/agent/cursor_runner_test.go b/internal/agent/cursor_runner_test.go new file mode 100644 index 0000000..473c7c1 --- /dev/null +++ b/internal/agent/cursor_runner_test.go @@ -0,0 +1,129 @@ +package agent + +import ( + "context" + "errors" + "sync/atomic" + "testing" + + "github.com/enowdev/antares/internal/config" + "github.com/enowdev/antares/internal/cursor" + "github.com/enowdev/antares/internal/cursorrun" + "github.com/enowdev/antares/internal/llm" + "github.com/enowdev/antares/internal/store" + "github.com/enowdev/antares/internal/tools" +) + +type agentCursorRunnerStub struct { + invalidations atomic.Int32 +} + +func (*agentCursorRunnerStub) Catalog(context.Context, bool) (*cursor.ModelCatalog, error) { + return nil, errors.New("unexpected Catalog call") +} + +func (f *agentCursorRunnerStub) InvalidateCatalog() { + f.invalidations.Add(1) +} + +func (*agentCursorRunnerStub) ValidateModel( + context.Context, + *cursor.ModelSelection, + cursorrun.SelectionPolicy, +) (*cursor.ModelSelection, error) { + return nil, errors.New("unexpected ValidateModel call") +} + +func (*agentCursorRunnerStub) CreateAgent( + context.Context, + cursor.CreateAgentRequest, +) (*cursor.CreateAgentResponse, error) { + return nil, errors.New("unexpected CreateAgent call") +} + +func (*agentCursorRunnerStub) CreateRun( + context.Context, + string, + cursor.CreateRunRequest, +) (*cursor.Run, error) { + return nil, errors.New("unexpected CreateRun call") +} + +func (*agentCursorRunnerStub) GetAgent(context.Context, string) (*cursor.Agent, error) { + return nil, errors.New("unexpected GetAgent call") +} + +func (*agentCursorRunnerStub) GetRun(context.Context, string, string) (*cursor.Run, error) { + return nil, errors.New("unexpected GetRun call") +} + +func (*agentCursorRunnerStub) CancelRun(context.Context, string, string) error { + return errors.New("unexpected CancelRun call") +} + +func (*agentCursorRunnerStub) StreamRun( + context.Context, + string, + string, + string, + func() error, + func(cursor.StreamEvent) error, +) (*cursor.Run, error) { + return nil, errors.New("unexpected StreamRun call") +} + +func (*agentCursorRunnerStub) Progress(cursor.StreamEvent) cursorrun.Progress { + return cursorrun.Progress{} +} + +type cursorDependencyProbeTool struct { + want cursorrun.Runner + seen atomic.Bool +} + +func (*cursorDependencyProbeTool) Name() string { return "cursor_dependency_probe" } +func (*cursorDependencyProbeTool) Description() string { return "test Cursor dependency injection" } +func (*cursorDependencyProbeTool) Schema() map[string]any { + return map[string]any{"type": "object"} +} + +func (t *cursorDependencyProbeTool) Execute(_ context.Context, in tools.Input) tools.Result { + if in.Deps != nil && in.Deps.Cursor == t.want { + t.seen.Store(true) + return tools.Result{Content: "ok"} + } + return tools.Errorf("wrong Cursor runner dependency") +} + +func TestAgentInjectsSharedCursorRunnerIntoToolDependencies(t *testing.T) { + runner := &agentCursorRunnerStub{} + a := New(config.Default(), nil, tools.NewRegistry(), nil, nil) + a.SetCursorRunner(runner) + probe := &cursorDependencyProbeTool{want: runner} + + outcomes := a.executeTools( + context.Background(), + []llm.ToolCall{{ID: "call-one", Name: probe.Name(), Arguments: `{}`}}, + map[string]tools.Tool{probe.Name(): probe}, + Request{}, + &store.Session{ID: "ses-one", Workspace: t.TempDir()}, + noEmit, + ) + + if len(outcomes) != 1 || outcomes[0].isError || !probe.seen.Load() { + t.Fatalf("probe outcome = %+v, runner seen = %v", outcomes, probe.seen.Load()) + } +} + +func TestAgentConfigReloadInvalidatesSharedCursorRunner(t *testing.T) { + runner := &agentCursorRunnerStub{} + a := New(config.Default(), nil, tools.NewRegistry(), nil, nil) + a.SetCursorRunner(runner) + + next := config.Default() + a.SetConfig(next) + + if got := runner.invalidations.Load(); got != 1 { + t.Fatalf("runner invalidations = %d, want 1", got) + } +} diff --git a/internal/agent/harness.go b/internal/agent/harness.go index 94b4803..a430431 100644 --- a/internal/agent/harness.go +++ b/internal/agent/harness.go @@ -783,14 +783,22 @@ func (a *Agent) applyRole(req *Request) { if req.Model == "" && role.Model != "" { req.Model = role.Model } - if req.ReasoningEffort == "" && role.Effort != "" { - req.ReasoningEffort = role.Effort - } if req.MaxTurns == 0 && role.MaxTurns > 0 { req.MaxTurns = role.MaxTurns } } +func (a *Agent) roleReasoningEffort(name string) string { + if a.roles == nil || strings.TrimSpace(name) == "" { + return "" + } + role, ok := a.roles.Get(name) + if !ok { + return "" + } + return role.Effort +} + // roleInfos exposes the roles to the tools layer. func (a *Agent) roleInfos() []tools.RoleInfo { if a.roles == nil { diff --git a/internal/agent/model_cache.go b/internal/agent/model_cache.go new file mode 100644 index 0000000..f25c8c2 --- /dev/null +++ b/internal/agent/model_cache.go @@ -0,0 +1,234 @@ +package agent + +import ( + "context" + "crypto/sha256" + "encoding/binary" + "hash" + "net/url" + "sort" + "strings" + "time" + + "github.com/enowdev/antares/internal/config" + "github.com/enowdev/antares/internal/llm" +) + +type providerCatalogScope struct { + providerID string + kind string + baseURLFingerprint [sha256.Size]byte + credentialFingerprint [sha256.Size]byte + apiVersion string + region string +} + +type providerCatalogEntry struct { + done chan struct{} + ready bool + hasSuccess bool + expiresAt time.Time + models []llm.ModelInfo + adapterKind string + err error +} + +const ( + providerCatalogTTL = 5 * time.Minute + providerCatalogFetchTimeout = 45 * time.Second +) + +func (a *Agent) cachedProviderCatalog( + ctx context.Context, + providerID string, + provider config.Provider, + fetch func(context.Context) ([]llm.ModelInfo, string, error), +) ([]llm.ModelInfo, string, error) { + scope := providerCatalogScopeFor(providerID, provider) + for { + if err := ctx.Err(); err != nil { + return nil, "", err + } + + var startFetch bool + a.catalogMu.Lock() + if a.catalogCache == nil { + a.catalogCache = make(map[providerCatalogScope]*providerCatalogEntry) + } + if entry, ok := a.catalogCache[scope]; ok { + if entry.ready { + if a.providerCatalogTime().Before(entry.expiresAt) { + models, adapterKind, err := cloneModelInfo(entry.models), entry.adapterKind, entry.err + a.catalogMu.Unlock() + return models, adapterKind, err + } + entry.ready = false + entry.done = make(chan struct{}) + startFetch = true + } + done := entry.done + a.catalogMu.Unlock() + if startFetch { + go a.refreshProviderCatalog(entry, fetch) + } + select { + case <-done: + continue + case <-ctx.Done(): + return nil, "", ctx.Err() + } + } + + entry := &providerCatalogEntry{done: make(chan struct{})} + a.catalogCache[scope] = entry + done := entry.done + a.catalogMu.Unlock() + go a.refreshProviderCatalog(entry, fetch) + select { + case <-done: + continue + case <-ctx.Done(): + return nil, "", ctx.Err() + } + } +} + +func (a *Agent) refreshProviderCatalog( + entry *providerCatalogEntry, + fetch func(context.Context) ([]llm.ModelInfo, string, error), +) { + // The shared fetch belongs to the cache entry, not to whichever caller won + // the miss race. Individual waiters may cancel without aborting or poisoning + // the provider scope; this independent context bounds orphaned work. + ctx, cancel := context.WithTimeout(context.Background(), providerCatalogFetchTimeout) + defer cancel() + models, adapterKind, err := fetch(ctx) + + a.catalogMu.Lock() + if err == nil { + entry.models = cloneModelInfo(models) + entry.adapterKind = adapterKind + entry.err = nil + entry.hasSuccess = true + } else if entry.hasSuccess { + models = cloneModelInfo(entry.models) + adapterKind = entry.adapterKind + err = nil + entry.err = nil + } else if len(models) > 0 { + entry.models = cloneModelInfo(models) + entry.adapterKind = adapterKind + entry.err = nil + entry.hasSuccess = true + err = nil + } else { + entry.models = nil + entry.adapterKind = adapterKind + entry.err = err + } + entry.expiresAt = a.providerCatalogTime().Add(providerCatalogTTL) + entry.ready = true + close(entry.done) + a.catalogMu.Unlock() +} + +func providerCatalogScopeFor(providerID string, provider config.Provider) providerCatalogScope { + kind := normalizedProviderKind(provider.Kind) + baseURL := normalizedProviderBaseURL(kind, provider.BaseURL) + return providerCatalogScope{ + providerID: strings.ToLower(strings.TrimSpace(providerID)), + kind: kind, + baseURLFingerprint: sha256.Sum256([]byte(baseURL)), + credentialFingerprint: providerCredentialFingerprint(provider), + apiVersion: strings.TrimSpace(provider.APIVersion), + region: strings.ToLower(strings.TrimSpace(provider.Region)), + } +} + +func normalizedProviderKind(kind string) string { + switch strings.ToLower(strings.TrimSpace(kind)) { + case "google": + return "gemini" + case "claude": + return "anthropic" + case "responses", "openai-responses": + return "codex" + default: + return strings.ToLower(strings.TrimSpace(kind)) + } +} + +func normalizedProviderBaseURL(kind, baseURL string) string { + baseURL = strings.TrimSpace(baseURL) + if parsed, err := url.Parse(baseURL); err == nil && parsed.Scheme != "" && parsed.Host != "" { + parsed.Scheme = strings.ToLower(parsed.Scheme) + parsed.Host = strings.ToLower(parsed.Host) + parsed.Path = strings.TrimRight(parsed.Path, "/") + baseURL = parsed.String() + } else { + baseURL = strings.TrimRight(baseURL, "/") + } + if baseURL != "" { + if kind == "gemini" { + lower := strings.ToLower(baseURL) + if (strings.HasSuffix(lower, "/antigravity") || strings.Contains(lower, "/antigravity/")) && + !strings.Contains(lower, "/v1beta") { + return baseURL + "/v1beta" + } + } + return baseURL + } + switch kind { + case "openai", "codex": + return "https://api.openai.com/v1" + case "anthropic": + return "https://api.anthropic.com/v1" + case "gemini": + return "https://generativelanguage.googleapis.com/v1beta" + default: + return "" + } +} + +func providerCredentialFingerprint(provider config.Provider) [sha256.Size]byte { + h := sha256.New() + writeFingerprintValue(h, provider.APIKey) + + keys := make([]string, 0, len(provider.Headers)) + for key := range provider.Headers { + keys = append(keys, key) + } + sort.Slice(keys, func(i, j int) bool { + left, right := strings.ToLower(keys[i]), strings.ToLower(keys[j]) + if left == right { + return keys[i] < keys[j] + } + return left < right + }) + for _, key := range keys { + writeFingerprintValue(h, strings.ToLower(strings.TrimSpace(key))) + writeFingerprintValue(h, provider.Headers[key]) + } + + var fingerprint [sha256.Size]byte + copy(fingerprint[:], h.Sum(nil)) + return fingerprint +} + +func writeFingerprintValue(h hash.Hash, value string) { + var size [8]byte + binary.BigEndian.PutUint64(size[:], uint64(len(value))) + _, _ = h.Write(size[:]) + _, _ = h.Write([]byte(value)) +} + +func cloneModelInfo(models []llm.ModelInfo) []llm.ModelInfo { + return append([]llm.ModelInfo(nil), models...) +} + +func (a *Agent) providerCatalogTime() time.Time { + if a.catalogNow != nil { + return a.catalogNow() + } + return time.Now() +} diff --git a/internal/agent/model_cache_test.go b/internal/agent/model_cache_test.go new file mode 100644 index 0000000..f470c3f --- /dev/null +++ b/internal/agent/model_cache_test.go @@ -0,0 +1,556 @@ +package agent + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/enowdev/antares/internal/config" + "github.com/enowdev/antares/internal/llm" +) + +func TestReasoningCapabilityAndModelsShareProviderCatalogueFetch(t *testing.T) { + var fetches atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/models") { + http.NotFound(w, r) + return + } + fetches.Add(1) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "data": [ + {"id": "model-a", "reasoning": {"supported_efforts": ["Exact"], "default_effort": "Exact"}} + ] + }`)) + })) + defer srv.Close() + + a := agentWithConfig(reasoningTestConfig(srv.URL, nil)) + if _, err := a.ReasoningCapability(context.Background(), "router/model-a"); err != nil { + t.Fatal(err) + } + if _, err := a.Models(context.Background(), "router"); err != nil { + t.Fatal(err) + } + if _, err := a.ReasoningCapability(context.Background(), "router/model-a"); err != nil { + t.Fatal(err) + } + if got := fetches.Load(); got != 1 { + t.Fatalf("provider catalogue fetches = %d, want one shared fetch", got) + } +} + +func TestModelsCachesCuratedProviderWithoutBroadeningWhitelist(t *testing.T) { + var fetches atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/models") { + http.NotFound(w, r) + return + } + fetches.Add(1) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "data": [ + {"id": "listed", "reasoning": {"supported_efforts": ["LOW"], "default_effort": "LOW"}}, + {"id": "unlisted", "reasoning": {"supported_efforts": ["HIGH"], "default_effort": "HIGH"}} + ] + }`)) + })) + defer srv.Close() + + cfg := reasoningTestConfig(srv.URL, []string{"listed"}) + a := agentWithConfig(cfg) + for i := 0; i < 2; i++ { + models, err := a.Models(context.Background(), "router") + if err != nil { + t.Fatal(err) + } + if len(models) != 1 || models[0].ID != "listed" { + t.Fatalf("models = %#v, want only listed", models) + } + } + if got := fetches.Load(); got != 1 { + t.Fatalf("curated provider catalogue fetches = %d, want one", got) + } +} + +func TestModelsConcurrentMissesUseSingleProviderFetch(t *testing.T) { + var fetches atomic.Int32 + arrived := make(chan struct{}, 16) + release := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/models") { + http.NotFound(w, r) + return + } + fetches.Add(1) + arrived <- struct{}{} + <-release + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"id":"model-a"}]}`)) + })) + defer srv.Close() + + a := agentWithConfig(reasoningTestConfig(srv.URL, nil)) + const callers = 16 + start := make(chan struct{}) + errs := make(chan error, callers) + var wg sync.WaitGroup + for i := 0; i < callers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + <-start + _, err := a.Models(context.Background(), "router") + errs <- err + }() + } + close(start) + select { + case <-arrived: + case <-time.After(2 * time.Second): + t.Fatal("provider catalogue fetch did not start") + } + time.Sleep(50 * time.Millisecond) + close(release) + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatal(err) + } + } + if got := fetches.Load(); got != 1 { + t.Fatalf("concurrent provider catalogue fetches = %d, want one", got) + } +} + +func TestProviderCatalogueLeaderCancellationDoesNotPoisonSharedFetch(t *testing.T) { + var ( + fetches atomic.Int32 + startedOnce sync.Once + releaseOnce sync.Once + ) + started := make(chan struct{}) + release := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/models") { + http.NotFound(w, r) + return + } + fetches.Add(1) + startedOnce.Do(func() { close(started) }) + select { + case <-release: + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"id":"model-a"}]}`)) + case <-r.Context().Done(): + } + })) + defer srv.Close() + defer releaseOnce.Do(func() { close(release) }) + + a := agentWithConfig(reasoningTestConfig(srv.URL, nil)) + leaderCtx, cancelLeader := context.WithCancel(context.Background()) + leaderResult := make(chan error, 1) + go func() { + _, err := a.Models(leaderCtx, "router") + leaderResult <- err + }() + select { + case <-started: + case <-time.After(2 * time.Second): + t.Fatal("leader provider catalogue fetch did not start") + } + + waiterCalling := make(chan struct{}) + waiterResult := make(chan struct { + models []llm.ModelInfo + err error + }, 1) + go func() { + close(waiterCalling) + models, err := a.Models(context.Background(), "router") + waiterResult <- struct { + models []llm.ModelInfo + err error + }{models: models, err: err} + }() + <-waiterCalling + + cancelLeader() + select { + case err := <-leaderResult: + if !errors.Is(err, context.Canceled) { + t.Fatalf("leader error = %v, want context.Canceled", err) + } + case <-time.After(time.Second): + t.Fatal("canceled leader did not return promptly") + } + + releaseOnce.Do(func() { close(release) }) + select { + case got := <-waiterResult: + if got.err != nil { + t.Fatalf("healthy waiter inherited leader cancellation: %v", got.err) + } + if len(got.models) != 1 || got.models[0].ID != "model-a" { + t.Fatalf("healthy waiter models = %#v, want model-a", got.models) + } + case <-time.After(2 * time.Second): + t.Fatal("healthy waiter did not receive shared catalogue") + } + + models, err := a.Models(context.Background(), "router") + if err != nil { + t.Fatalf("healthy successor inherited leader cancellation: %v", err) + } + if len(models) != 1 || models[0].ID != "model-a" { + t.Fatalf("healthy successor models = %#v, want cached model-a", models) + } + if got := fetches.Load(); got != 1 { + t.Fatalf("provider catalogue fetches = %d, want one shared fetch", got) + } +} + +func TestProviderCatalogueCacheDoesNotShareAcrossCredentials(t *testing.T) { + var fetches atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/models") { + http.NotFound(w, r) + return + } + fetches.Add(1) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"id":"model-a"}]}`)) + })) + defer srv.Close() + + cfg := reasoningTestConfig(srv.URL, nil) + provider := cfg.Providers["router"] + provider.APIKey = "credential-one" + cfg.Providers["router"] = provider + a := agentWithConfig(cfg) + if _, err := a.Models(context.Background(), "router"); err != nil { + t.Fatal(err) + } + + changed := *cfg + changed.Providers = make(map[string]config.Provider, len(cfg.Providers)) + for id, configured := range cfg.Providers { + changed.Providers[id] = configured + } + provider = changed.Providers["router"] + provider.APIKey = "credential-two" + changed.Providers["router"] = provider + a.SetConfig(&changed) + if _, err := a.Models(context.Background(), "router"); err != nil { + t.Fatal(err) + } + + if got := fetches.Load(); got != 2 { + t.Fatalf("provider catalogue fetches after credential change = %d, want two", got) + } +} + +func TestProviderCatalogueCacheExpiresAfterFiveMinutes(t *testing.T) { + var fetches atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/models") { + http.NotFound(w, r) + return + } + fetches.Add(1) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"id":"model-a"}]}`)) + })) + defer srv.Close() + + now := time.Date(2026, 8, 13, 0, 0, 0, 0, time.UTC) + a := agentWithConfig(reasoningTestConfig(srv.URL, nil)) + a.catalogNow = func() time.Time { return now } + + if _, err := a.Models(context.Background(), "router"); err != nil { + t.Fatal(err) + } + now = now.Add(4*time.Minute + 59*time.Second) + if _, err := a.Models(context.Background(), "router"); err != nil { + t.Fatal(err) + } + if got := fetches.Load(); got != 1 { + t.Fatalf("provider catalogue fetches before TTL = %d, want one", got) + } + + now = now.Add(2 * time.Second) + if _, err := a.Models(context.Background(), "router"); err != nil { + t.Fatal(err) + } + if got := fetches.Load(); got != 2 { + t.Fatalf("provider catalogue fetches after TTL = %d, want two", got) + } +} + +func TestProviderCatalogueCacheScopesNormalizedBaseURLAndProviderIdentity(t *testing.T) { + var firstFetches, secondFetches atomic.Int32 + newServer := func(fetches *atomic.Int32) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/models") { + http.NotFound(w, r) + return + } + fetches.Add(1) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"id":"model-a"}]}`)) + })) + } + first := newServer(&firstFetches) + defer first.Close() + second := newServer(&secondFetches) + defer second.Close() + + cfg := reasoningTestConfig(first.URL, nil) + a := agentWithConfig(cfg) + if _, err := a.Models(context.Background(), "router"); err != nil { + t.Fatal(err) + } + + withTrailingSlash := *cfg + withTrailingSlash.Providers = map[string]config.Provider{} + provider := cfg.Providers["router"] + provider.BaseURL = first.URL + "/" + withTrailingSlash.Providers["router"] = provider + a.SetConfig(&withTrailingSlash) + if _, err := a.Models(context.Background(), "router"); err != nil { + t.Fatal(err) + } + if got := firstFetches.Load(); got != 1 { + t.Fatalf("normalized equivalent base URL fetched %d times, want one", got) + } + + changedBase := withTrailingSlash + changedBase.Providers = map[string]config.Provider{} + provider.BaseURL = second.URL + changedBase.Providers["router"] = provider + a.SetConfig(&changedBase) + if _, err := a.Models(context.Background(), "router"); err != nil { + t.Fatal(err) + } + + changedIdentity := changedBase + changedIdentity.Providers = map[string]config.Provider{ + "alternate": provider, + } + a.SetConfig(&changedIdentity) + if _, err := a.Models(context.Background(), "alternate"); err != nil { + t.Fatal(err) + } + if got := secondFetches.Load(); got != 2 { + t.Fatalf("changed base/identity fetches = %d, want one per distinct scope", got) + } +} + +func TestProviderCatalogueScopeDoesNotRetainRawCredentials(t *testing.T) { + const ( + apiSecret = "RAW-API-SECRET" + headerSecret = "RAW-HEADER-SECRET" + urlSecret = "RAW-URL-SECRET" + ) + scope := providerCatalogScopeFor("router", config.Provider{ + Kind: "openai-compatible", + BaseURL: "https://example.test/v1?token=" + urlSecret, + APIKey: apiSecret, + Headers: map[string]string{"Authorization": "Bearer " + headerSecret}, + }) + rendered := fmt.Sprintf("%#v", scope) + for _, secret := range []string{apiSecret, headerSecret, urlSecret} { + if strings.Contains(rendered, secret) { + t.Fatalf("cache scope retained raw credential %q", secret) + } + } +} + +func TestReasoningCapabilityUsesStaleCatalogueWhenRefreshFails(t *testing.T) { + var ( + fetches atomic.Int32 + outage atomic.Bool + ) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/models") { + http.NotFound(w, r) + return + } + fetches.Add(1) + w.Header().Set("Content-Type", "application/json") + if outage.Load() { + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte(`{"error":{"message":"temporary catalogue outage"}}`)) + return + } + _, _ = w.Write([]byte(`{ + "data": [ + {"id": "model-a", "reasoning": {"supported_efforts": ["MiXeD"], "default_effort": "MiXeD"}} + ] + }`)) + })) + defer srv.Close() + + now := time.Date(2026, 8, 13, 0, 0, 0, 0, time.UTC) + a := agentWithConfig(reasoningTestConfig(srv.URL, nil)) + a.catalogNow = func() time.Time { return now } + + if err := a.ValidateReasoningEffort(context.Background(), "router/model-a", "MiXeD"); err != nil { + t.Fatal(err) + } + now = now.Add(5*time.Minute + time.Second) + outage.Store(true) + if err := a.ValidateReasoningEffort(context.Background(), "router/model-a", "MiXeD"); err != nil { + t.Fatalf("stale live value rejected after refresh outage: %v", err) + } + if err := a.ValidateReasoningEffort(context.Background(), "router/model-a", "MiXeD"); err != nil { + t.Fatalf("cached stale live value rejected: %v", err) + } + if got := fetches.Load(); got != 2 { + t.Fatalf("provider catalogue fetches = %d, want initial load plus one failed refresh", got) + } +} + +type metadataUnavailableMarker interface { + ReasoningMetadataUnavailable() bool +} + +func TestExplicitReasoningReturnsDistinctBoundedErrorOnFirstCatalogueOutage(t *testing.T) { + for _, curated := range []bool{false, true} { + t.Run(map[bool]string{false: "live", true: "curated"}[curated], func(t *testing.T) { + var fetches atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/models") { + http.NotFound(w, r) + return + } + fetches.Add(1) + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte(`{"error":{"message":"SECRET-UPSTREAM-DIAGNOSTIC"}}`)) + })) + defer srv.Close() + + var models []string + if curated { + models = []string{"model-a"} + } + cfg := reasoningTestConfig(srv.URL, models) + cfg.Agent.ReasoningEffort = "LEGACY-STORED" + a := agentWithConfig(cfg) + + const submitted = "SECRET-SUBMITTED-EFFORT" + for i := 0; i < 2; i++ { + err := a.ValidateReasoningEffort(context.Background(), "router/model-a", submitted) + if err == nil { + t.Fatal("expected metadata-unavailable error") + } + if llm.IsUnsupportedReasoningEffort(err) { + t.Fatalf("first catalogue outage misreported as unsupported: %v", err) + } + var unavailable metadataUnavailableMarker + if !errors.As(err, &unavailable) || !unavailable.ReasoningMetadataUnavailable() { + t.Fatalf("error = %T %v, want distinct metadata-unavailable error", err, err) + } + if len(err.Error()) > 200 { + t.Fatalf("metadata error is unbounded (%d bytes)", len(err.Error())) + } + if strings.Contains(err.Error(), submitted) || strings.Contains(err.Error(), "SECRET-UPSTREAM-DIAGNOSTIC") { + t.Fatalf("metadata error exposes submitted or upstream value: %v", err) + } + } + if got := fetches.Load(); got != 1 { + t.Fatalf("first-outage provider catalogue fetches = %d, want one cached failure", got) + } + + got, err := a.resolveReasoning(context.Background(), reasoningInput{ModelRef: "router/model-a"}) + if err != nil { + t.Fatalf("stored legacy value returned metadata error: %v", err) + } + if got.Value != "" || got.DiscardedLegacy != "LEGACY-STORED" { + t.Fatalf("stored resolution = %+v, want Auto with legacy notice", got) + } + }) + } +} + +func TestRunFirstCatalogueOutageRejectsExplicitButAllowsStoredAutoFallback(t *testing.T) { + var ( + catalogFetches atomic.Int32 + chatCalls atomic.Int32 + ) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/models"): + catalogFetches.Add(1) + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte(`{"error":{"message":"SECRET-UPSTREAM-DIAGNOSTIC"}}`)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/chat/completions"): + chatCalls.Add(1) + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + cfg := reasoningTestConfig(srv.URL, nil) + cfg.Agent.ReasoningEffort = "LEGACY-STORED" + cfg.Streaming.Enabled = false + a := newReasoningRunAgent(t, cfg) + + const submitted = "SECRET-SUBMITTED-EFFORT" + _, err := a.Run(context.Background(), Request{ + Message: "explicit", + Quiet: true, + MaxTurns: 1, + ReasoningEffort: submitted, + }, nil) + if err == nil || !IsReasoningMetadataUnavailable(err) { + t.Fatalf("explicit run error = %T %v, want metadata unavailable", err, err) + } + if strings.Contains(err.Error(), submitted) || strings.Contains(err.Error(), "SECRET-UPSTREAM-DIAGNOSTIC") { + t.Fatalf("explicit run error exposes submitted or upstream value: %v", err) + } + if got := chatCalls.Load(); got != 0 { + t.Fatalf("chat calls after explicit metadata outage = %d, want zero", got) + } + + var discardedNotices int + result, err := a.Run(context.Background(), Request{ + Message: "stored", + Quiet: true, + MaxTurns: 1, + }, func(event Event) error { + if event.Type == EventNotice && strings.Contains(event.Message, "LEGACY-STORED") { + discardedNotices++ + } + return nil + }) + if err != nil { + t.Fatal(err) + } + if result.Reply != "ok" { + t.Fatalf("stored fallback reply = %q", result.Reply) + } + if discardedNotices != 1 { + t.Fatalf("stored fallback notices = %d, want one", discardedNotices) + } + if got := chatCalls.Load(); got != 1 { + t.Fatalf("chat calls after stored metadata outage = %d, want one Auto request", got) + } + if got := catalogFetches.Load(); got != 1 { + t.Fatalf("catalogue fetches across explicit and stored runs = %d, want one cached failure", got) + } +} diff --git a/internal/agent/reasoning.go b/internal/agent/reasoning.go new file mode 100644 index 0000000..1c7f404 --- /dev/null +++ b/internal/agent/reasoning.go @@ -0,0 +1,226 @@ +package agent + +import ( + "context" + "errors" + "strings" + + "github.com/enowdev/antares/internal/config" + "github.com/enowdev/antares/internal/llm" +) + +type reasoningInput struct { + ModelRef string + Explicit string + Role string + Agent string + Model string +} + +type reasoningResolution struct { + Value string + Capability *llm.ReasoningCapability + DiscardedLegacy string +} + +// ReasoningMetadataUnavailableError distinguishes an unavailable provider +// catalogue from a model that is known not to support a submitted value. Its +// message is deliberately constant and bounded: upstream diagnostics and the +// submitted value are never exposed. +type ReasoningMetadataUnavailableError struct{} + +func (*ReasoningMetadataUnavailableError) Error() string { + return "reasoning metadata is temporarily unavailable; use Auto or retry" +} + +func (*ReasoningMetadataUnavailableError) ReasoningMetadataUnavailable() bool { return true } + +func IsReasoningMetadataUnavailable(err error) bool { + var unavailable *ReasoningMetadataUnavailableError + return errors.As(err, &unavailable) +} + +type reasoningTarget struct { + providerID string + model string + provider config.Provider +} + +// ReasoningCapability returns the best model-specific reasoning metadata the +// configured provider can supply. Documented direct-provider metadata avoids a +// network dependency; dynamic providers use the Agent-owned cached catalogue. +func (a *Agent) ReasoningCapability(ctx context.Context, modelRef string) (*llm.ReasoningCapability, error) { + return a.reasoningCapabilityForConfig(ctx, a.config(), modelRef) +} + +func (a *Agent) reasoningCapabilityForConfig( + ctx context.Context, + cfg *config.Config, + modelRef string, +) (*llm.ReasoningCapability, error) { + target := reasoningTargetForConfig(cfg, modelRef) + if capability := staticReasoningCapability(target); capability != nil { + return capability, nil + } + + models, err := a.modelsForProvider(ctx, target.providerID, target.provider) + if err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, &ReasoningMetadataUnavailableError{} + } + for _, model := range models { + if model.ID == target.model && model.ReasoningCapability != nil { + return model.ReasoningCapability, nil + } + } + return nil, nil +} + +// ValidateReasoningEffort validates an explicit value without including the +// submitted value in any error. +func (a *Agent) ValidateReasoningEffort(ctx context.Context, modelRef, effort string) error { + return a.ValidateReasoningEffortForConfig(ctx, a.config(), modelRef, effort) +} + +// ValidateReasoningEffortForConfig validates against an immutable candidate +// config while retaining the Agent-owned provider catalogue cache. It does not +// publish the candidate as the live Agent configuration. +func (a *Agent) ValidateReasoningEffortForConfig( + ctx context.Context, + cfg *config.Config, + modelRef string, + effort string, +) error { + if effort == "" { + return nil + } + capability, err := a.reasoningCapabilityForConfig(ctx, cfg, modelRef) + if err != nil { + return err + } + return llm.ValidateReasoningEffort(reasoningTargetForConfig(cfg, modelRef).model, capability, effort) +} + +// resolveReasoning distinguishes a new explicit override from stored legacy +// values. An invalid explicit override is an error; invalid stored values are +// skipped in role, agent, model order so old configuration degrades to Auto. +func (a *Agent) resolveReasoning(ctx context.Context, in reasoningInput) (reasoningResolution, error) { + agentValue := in.Agent + if agentValue == "" { + agentValue = a.config().Agent.ReasoningEffort + } + modelValue := in.Model + if modelValue == "" { + modelValue = a.config().Model.ReasoningEffort + } + storedValues := []string{in.Role, agentValue, modelValue} + + capability, err := a.ReasoningCapability(ctx, in.ModelRef) + if err != nil { + if IsReasoningMetadataUnavailable(err) && in.Explicit == "" { + resolution := reasoningResolution{} + for _, stored := range storedValues { + if stored != "" { + resolution.DiscardedLegacy = stored + break + } + } + return resolution, nil + } + return reasoningResolution{}, err + } + resolution := reasoningResolution{Capability: capability} + model := a.reasoningTarget(in.ModelRef).model + + if in.Explicit != "" { + if err := llm.ValidateReasoningEffort(model, capability, in.Explicit); err != nil { + return reasoningResolution{}, err + } + resolution.Value = in.Explicit + return resolution, nil + } + + for _, stored := range storedValues { + if stored == "" { + continue + } + if err := llm.ValidateReasoningEffort(model, capability, stored); err == nil { + resolution.Value = stored + return resolution, nil + } + if resolution.DiscardedLegacy == "" { + resolution.DiscardedLegacy = stored + } + } + return resolution, nil +} + +func (a *Agent) reasoningTarget(modelRef string) reasoningTarget { + cfg := a.config() + return reasoningTargetForConfig(cfg, modelRef) +} + +func reasoningTargetForConfig(cfg *config.Config, modelRef string) reasoningTarget { + if cfg == nil { + return reasoningTarget{model: modelRef} + } + providerID := cfg.Model.Provider + model := modelRef + if model == "" { + model = cfg.Model.Default + } + if modelRef != "" { + if candidate, rest, ok := strings.Cut(modelRef, "/"); ok && rest != "" { + if _, configured := cfg.Providers[candidate]; configured { + providerID, model = candidate, rest + } else if candidate == cfg.Model.Provider || candidate == "google" { + // "google/model" is the canonical direct-Gemini reference even + // though the shipped provider map uses the key "gemini". + providerID, model = candidate, rest + } + } + } + + id, provider := cfg.ResolveProvider(providerID) + if id == "google" { + if _, configured := cfg.Providers[id]; !configured { + provider = config.Provider{ + Kind: "gemini", + BaseURL: "https://generativelanguage.googleapis.com/v1beta", + Enabled: true, + } + } + } + return reasoningTarget{providerID: id, model: model, provider: provider} +} + +func staticReasoningCapability(target reasoningTarget) *llm.ReasoningCapability { + kind := strings.ToLower(strings.TrimSpace(target.provider.Kind)) + switch kind { + case "google": + kind = "gemini" + case "claude": + kind = "anthropic" + case "responses", "openai-responses": + kind = "codex" + } + baseURL := strings.TrimRight(strings.TrimSpace(target.provider.BaseURL), "/") + if baseURL == "" { + switch kind { + case "openai", "codex": + baseURL = "https://api.openai.com/v1" + case "anthropic": + baseURL = "https://api.anthropic.com/v1" + case "gemini": + baseURL = "https://generativelanguage.googleapis.com/v1beta" + } + } + return llm.StaticReasoningCapability( + kind, + target.providerID, + baseURL, + target.model, + ) +} diff --git a/internal/agent/reasoning_defaults_test.go b/internal/agent/reasoning_defaults_test.go new file mode 100644 index 0000000..537b72c --- /dev/null +++ b/internal/agent/reasoning_defaults_test.go @@ -0,0 +1,85 @@ +package agent + +import ( + "context" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "github.com/enowdev/antares/internal/config" + "github.com/enowdev/antares/internal/llm" +) + +// TestDefaultConfigReasoningEffortReachesOpenRouterCompatibleProvider guards +// the exact regression a review found in Task 2's provider-adapter validation: +// with the old "medium" default and no attached llm.ReasoningCapability, every +// default chat through an openai-compatible provider (OpenRouter's kind) was +// rejected before any network call, because the static catalog deliberately +// returns nil for unknown compatible endpoints. It reproduces the precedence +// agent.go's Run loop uses (firstNonEmpty(explicit, agent, model)) with the +// real default config against a real openAIClient. +func TestDefaultConfigReasoningEffortReachesOpenRouterCompatibleProvider(t *testing.T) { + cfg := config.Default() + effort := firstNonEmpty("", cfg.Agent.ReasoningEffort, cfg.Model.ReasoningEffort) + + var hits int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&hits, 1) + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`)) + })) + defer srv.Close() + + client, err := llm.New(llm.Options{Kind: "openai-compatible", BaseURL: srv.URL, HTTPClient: srv.Client(), ProviderID: "openrouter"}) + if err != nil { + t.Fatalf("llm.New: %v", err) + } + + a := &Agent{} + _, err = a.callModel(context.Background(), client, llm.Request{ + Model: "vendor/model", + Messages: []llm.Message{{Role: llm.RoleUser, Content: "hi"}}, + ReasoningEffort: effort, + }, false, func(Event) error { return nil }) + if err != nil { + t.Fatalf("default config reasoning effort blocked an OpenRouter-shaped chat before it reached the network: %v", err) + } + if got := atomic.LoadInt32(&hits); got != 1 { + t.Fatalf("requests reaching the provider = %d, want 1", got) + } +} + +// TestDefaultConfigReasoningEffortReachesDirectAnthropicProvider is the direct +// Anthropic half of the same finding: even a model the static catalog knows +// (claude-sonnet-5) was previously rejected pre-request because the runtime +// default base URL ("https://api.anthropic.com/v1") didn't match the +// catalog's bare-host check. +func TestDefaultConfigReasoningEffortReachesDirectAnthropicProvider(t *testing.T) { + cfg := config.Default() + effort := firstNonEmpty("", cfg.Agent.ReasoningEffort, cfg.Model.ReasoningEffort) + + var hits int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&hits, 1) + _, _ = w.Write([]byte(`{"content":[{"type":"text","text":"ok"}]}`)) + })) + defer srv.Close() + + client, err := llm.New(llm.Options{Kind: "anthropic", BaseURL: srv.URL, HTTPClient: srv.Client(), ProviderID: "anthropic"}) + if err != nil { + t.Fatalf("llm.New: %v", err) + } + + a := &Agent{} + _, err = a.callModel(context.Background(), client, llm.Request{ + Model: "claude-sonnet-5", + Messages: []llm.Message{{Role: llm.RoleUser, Content: "hi"}}, + ReasoningEffort: effort, + }, false, func(Event) error { return nil }) + if err != nil { + t.Fatalf("default config reasoning effort blocked a direct-Anthropic chat before it reached the network: %v", err) + } + if got := atomic.LoadInt32(&hits); got != 1 { + t.Fatalf("requests reaching the provider = %d, want 1", got) + } +} diff --git a/internal/agent/reasoning_test.go b/internal/agent/reasoning_test.go new file mode 100644 index 0000000..370624d --- /dev/null +++ b/internal/agent/reasoning_test.go @@ -0,0 +1,592 @@ +package agent + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + + "github.com/enowdev/antares/internal/config" + "github.com/enowdev/antares/internal/llm" + "github.com/enowdev/antares/internal/store" + "github.com/enowdev/antares/internal/tools" +) + +func TestResolveReasoningExplicitUnsupportedReturnsError(t *testing.T) { + a := agentWithConfig(config.Default()) + _, err := a.resolveReasoning(context.Background(), reasoningInput{ + ModelRef: "google/gemini-3.6-flash", + Explicit: "max", + }) + if err == nil || !llm.IsUnsupportedReasoningEffort(err) { + t.Fatalf("err = %v", err) + } +} + +func TestResolveReasoningUnsupportedStoredValueFallsBackToAuto(t *testing.T) { + cfg := config.Default() + cfg.Model.Provider = "google" + cfg.Model.Default = "gemini-3.6-flash" + cfg.Agent.ReasoningEffort = "max" + a := agentWithConfig(cfg) + got, err := a.resolveReasoning(context.Background(), reasoningInput{ModelRef: cfg.Model.Default}) + if err != nil || got.Value != "" || got.DiscardedLegacy != "max" { + t.Fatalf("got=%+v err=%v", got, err) + } +} + +func TestResolveReasoningUsesRoleAgentModelPrecedence(t *testing.T) { + cfg := config.Default() + cfg.Model.Provider = "gemini" + cfg.Model.Default = "gemini-3.6-flash" + a := agentWithConfig(cfg) + + got, err := a.resolveReasoning(context.Background(), reasoningInput{ + ModelRef: "gemini/gemini-3.6-flash", + Role: "high", + Agent: "medium", + Model: "low", + }) + if err != nil { + t.Fatal(err) + } + if got.Value != "high" || got.DiscardedLegacy != "" { + t.Fatalf("got = %+v, want role value high", got) + } +} + +func TestResolveReasoningSkipsUnsupportedStoredValuesInPrecedenceOrder(t *testing.T) { + cfg := config.Default() + cfg.Model.Provider = "gemini" + cfg.Model.Default = "gemini-3.6-flash" + a := agentWithConfig(cfg) + + got, err := a.resolveReasoning(context.Background(), reasoningInput{ + ModelRef: "gemini/gemini-3.6-flash", + Role: "MAX", + Agent: "medium", + Model: "low", + }) + if err != nil { + t.Fatal(err) + } + if got.Value != "medium" || got.DiscardedLegacy != "MAX" { + t.Fatalf("got = %+v, want agent value medium after discarding exact role value MAX", got) + } +} + +func TestResolveReasoningPreservesOpaqueLiveValueAndCase(t *testing.T) { + srv := newReasoningModelsServer(t, `{ + "data": [ + {"id": "model-a", "reasoning": {"supported_efforts": ["low"], "default_effort": "low"}}, + {"id": "model-b", "reasoning": {"supported_efforts": ["MiXeD"], "default_effort": "MiXeD"}} + ] + }`) + cfg := reasoningTestConfig(srv.URL, nil) + a := agentWithConfig(cfg) + + got, err := a.resolveReasoning(context.Background(), reasoningInput{ + ModelRef: "router/model-b", + Explicit: "MiXeD", + }) + if err != nil { + t.Fatal(err) + } + if got.Value != "MiXeD" || got.Capability == nil || got.Capability.Source != llm.ReasoningCapabilityLive { + t.Fatalf("got = %+v", got) + } + + _, err = a.resolveReasoning(context.Background(), reasoningInput{ + ModelRef: "router/model-b", + Explicit: "mixed", + }) + if err == nil || !llm.IsUnsupportedReasoningEffort(err) { + t.Fatalf("case-changed value err = %v", err) + } +} + +func TestValidateReasoningEffortDoesNotExposeSubmittedValue(t *testing.T) { + cfg := config.Default() + cfg.Model.Provider = "gemini" + a := agentWithConfig(cfg) + const submitted = "secret-invalid-effort" + + err := a.ValidateReasoningEffort(context.Background(), "gemini/gemini-3.6-flash", submitted) + if err == nil || !llm.IsUnsupportedReasoningEffort(err) { + t.Fatalf("err = %v", err) + } + if strings.Contains(err.Error(), submitted) { + t.Fatalf("error exposes submitted value: %v", err) + } +} + +func TestReasoningCapabilityUsesMatchingModelLiveMetadata(t *testing.T) { + srv := newReasoningModelsServer(t, `{ + "data": [ + {"id": "model-a", "reasoning": {"supported_efforts": ["low"], "default_effort": "low"}}, + {"id": "model-b", "reasoning": {"supported_efforts": ["MiXeD"], "default_effort": "MiXeD"}} + ] + }`) + a := agentWithConfig(reasoningTestConfig(srv.URL, nil)) + + capability, err := a.ReasoningCapability(context.Background(), "router/model-b") + if err != nil { + t.Fatal(err) + } + if capability == nil || capability.Source != llm.ReasoningCapabilityLive { + t.Fatalf("capability = %#v", capability) + } + if len(capability.Values) != 1 || capability.Values[0].Value != "MiXeD" { + t.Fatalf("values = %#v, want exact model-b metadata", capability.Values) + } +} + +func TestReasoningCapabilityResolvesInlineActiveProviderModelRef(t *testing.T) { + srv := newReasoningModelsServer(t, `{ + "data": [ + {"id": "model-a", "reasoning": {"supported_efforts": ["Exact"], "default_effort": "Exact"}} + ] + }`) + cfg := config.Default() + cfg.Providers = map[string]config.Provider{} + cfg.Model.Provider = "inline" + cfg.Model.Default = "model-a" + cfg.Model.BaseURL = srv.URL + a := agentWithConfig(cfg) + + capability, err := a.ReasoningCapability(context.Background(), "inline/model-a") + if err != nil { + t.Fatal(err) + } + if capability == nil || len(capability.Values) != 1 || capability.Values[0].Value != "Exact" { + t.Fatalf("capability = %#v", capability) + } +} + +func TestModelsKeepsCuratedWhitelistWhileUsingMatchingLiveCapability(t *testing.T) { + srv := newReasoningModelsServer(t, `{ + "data": [ + {"id": "listed", "reasoning": {"supported_efforts": ["HIGH"], "default_effort": "HIGH"}}, + {"id": "unlisted", "reasoning": {"supported_efforts": ["low"], "default_effort": "low"}} + ] + }`) + cfg := reasoningTestConfig(srv.URL, []string{"listed"}) + a := agentWithConfig(cfg) + + models, err := a.Models(context.Background(), "router") + if err != nil { + t.Fatal(err) + } + if len(models) != 1 || models[0].ID != "listed" { + t.Fatalf("models = %#v, want only curated model", models) + } + capability := models[0].ReasoningCapability + if capability == nil || capability.Source != llm.ReasoningCapabilityLive || + len(capability.Values) != 1 || capability.Values[0].Value != "HIGH" { + t.Fatalf("capability = %#v", capability) + } +} + +func TestModelsFallsBackToStaticCapabilityForCuratedModel(t *testing.T) { + cfg := config.Default() + cfg.Providers["gemini"] = config.Provider{ + Kind: "gemini", + Enabled: true, + Models: []string{"gemini-3.6-flash"}, + } + a := agentWithConfig(cfg) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + models, err := a.Models(ctx, "gemini") + if err != nil { + t.Fatal(err) + } + if len(models) != 1 { + t.Fatalf("models = %#v", models) + } + capability := models[0].ReasoningCapability + if capability == nil || capability.Source != llm.ReasoningCapabilityStatic { + t.Fatalf("capability = %#v, want static fallback", capability) + } +} + +type modelCatalogueRoundTripFunc func(*http.Request) (*http.Response, error) + +func (f modelCatalogueRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func installStaticFamilyModelCatalogue(t *testing.T) { + t.Helper() + previous := http.DefaultTransport + http.DefaultTransport = modelCatalogueRoundTripFunc(func(req *http.Request) (*http.Response, error) { + status := http.StatusOK + body := `{"data":[{"id":"gpt-5"},{"id":"gpt-5.3-codex"}]}` + if req.Method != http.MethodGet || !strings.HasSuffix(req.URL.Path, "/models") { + status = http.StatusNotFound + body = `{"error":{"message":"not found"}}` + } + return &http.Response{ + StatusCode: status, + Status: http.StatusText(status), + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(body)), + Request: req, + }, nil + }) + t.Cleanup(func() { + http.DefaultTransport = previous + }) +} + +func assertReasoningValues(t *testing.T, capability *llm.ReasoningCapability, want ...string) { + t.Helper() + if capability == nil { + t.Fatalf("capability = nil, want values %v", want) + } + if len(capability.Values) != len(want) { + t.Fatalf("values = %#v, want %v", capability.Values, want) + } + for i, value := range capability.Values { + if value.Value != want[i] { + t.Fatalf("values = %#v, want %v", capability.Values, want) + } + } +} + +func modelInfoByID(t *testing.T, models []llm.ModelInfo, id string) llm.ModelInfo { + t.Helper() + for _, model := range models { + if model.ID == id { + return model + } + } + t.Fatalf("models = %#v, want %q", models, id) + return llm.ModelInfo{} +} + +func TestModelsUsesResolvedOpenAIAdapterFamilyForDirectOpenAI(t *testing.T) { + installStaticFamilyModelCatalogue(t) + for _, configuredKind := range []string{"openai", "openai-compatible"} { + t.Run(configuredKind, func(t *testing.T) { + cfg := config.Default() + cfg.Model.Provider = "openai" + cfg.Model.Default = "gpt-5" + cfg.Providers = map[string]config.Provider{ + "openai": { + Kind: configuredKind, + BaseURL: "https://api.openai.com/v1", + Enabled: true, + }, + } + + a := agentWithConfig(cfg) + models, err := a.Models(context.Background(), "openai") + if err != nil { + t.Fatal(err) + } + model := modelInfoByID(t, models, "gpt-5") + assertReasoningValues(t, model.ReasoningCapability, "minimal", "low", "medium", "high") + capability, err := a.ReasoningCapability(context.Background(), "openai/gpt-5") + if err != nil { + t.Fatal(err) + } + assertReasoningValues(t, capability, "minimal", "low", "medium", "high") + }) + } +} + +func TestModelsKeepsExplicitCodexFamilyForResponsesAlias(t *testing.T) { + installStaticFamilyModelCatalogue(t) + for _, configuredKind := range []string{"codex", "responses", "openai-responses"} { + t.Run(configuredKind, func(t *testing.T) { + cfg := config.Default() + cfg.Model.Provider = "openai" + cfg.Model.Default = "gpt-5.3-codex" + cfg.Providers = map[string]config.Provider{ + "openai": { + Kind: configuredKind, + BaseURL: "https://api.openai.com/v1", + Enabled: true, + Models: []string{"gpt-5.3-codex", "gpt-5"}, + }, + } + + a := agentWithConfig(cfg) + models, err := a.Models(context.Background(), "openai") + if err != nil { + t.Fatal(err) + } + if len(models) != 2 { + t.Fatalf("models = %#v, want two curated models", models) + } + codex := modelInfoByID(t, models, "gpt-5.3-codex") + assertReasoningValues(t, codex.ReasoningCapability, "low", "medium", "high", "xhigh") + openAI := modelInfoByID(t, models, "gpt-5") + if openAI.ReasoningCapability != nil { + t.Fatalf("%s alias broadened gpt-5 to OpenAI capability: %#v", configuredKind, openAI.ReasoningCapability) + } + capability, err := a.ReasoningCapability(context.Background(), "openai/gpt-5.3-codex") + if err != nil { + t.Fatal(err) + } + assertReasoningValues(t, capability, "low", "medium", "high", "xhigh") + }) + } +} + +func TestModelsKeepsUnknownCompatibleEndpointAutoOnly(t *testing.T) { + installStaticFamilyModelCatalogue(t) + cfg := config.Default() + cfg.Model.Provider = "custom" + cfg.Model.Default = "gpt-5" + cfg.Providers = map[string]config.Provider{ + "custom": { + Kind: "custom", + BaseURL: "https://gateway.example.test/v1", + Enabled: true, + }, + } + + a := agentWithConfig(cfg) + models, err := a.Models(context.Background(), "custom") + if err != nil { + t.Fatal(err) + } + model := modelInfoByID(t, models, "gpt-5") + if model.ReasoningCapability != nil { + t.Fatalf("capability = %#v, want Auto-only", model.ReasoningCapability) + } + capability, err := a.ReasoningCapability(context.Background(), "custom/gpt-5") + if err != nil { + t.Fatal(err) + } + if capability != nil { + t.Fatalf("resolved capability = %#v, want Auto-only", capability) + } +} + +func TestRunResolvesStoredRoleReasoningOnceBeforeTurnLoop(t *testing.T) { + var ( + mu sync.Mutex + chatBodies []map[string]any + chatCalls int + ) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/models"): + _, _ = w.Write([]byte(`{ + "data": [ + {"id": "model-a", "reasoning": {"supported_efforts": ["low"], "default_effort": "low"}} + ] + }`)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/chat/completions"): + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode chat body: %v", err) + } + mu.Lock() + chatBodies = append(chatBodies, body) + chatCalls++ + call := chatCalls + mu.Unlock() + if call == 1 { + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":""}}]}`)) + return + } + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + cfg := reasoningTestConfig(srv.URL, nil) + cfg.Agent.ReasoningEffort = "low" + cfg.Model.ReasoningEffort = "low" + cfg.Streaming.Enabled = false + a := newReasoningRunAgent(t, cfg) + + var discardedNotices int + result, err := a.Run(context.Background(), Request{ + Message: "test", + Role: "reviewer", + Quiet: true, + MaxTurns: 2, + }, func(event Event) error { + if event.Type == EventNotice && strings.Contains(event.Message, "high") { + discardedNotices++ + } + return nil + }) + if err != nil { + t.Fatal(err) + } + if result.Reply != "ok" { + t.Fatalf("reply = %q", result.Reply) + } + if discardedNotices != 1 { + t.Fatalf("discarded-role notices = %d, want one", discardedNotices) + } + mu.Lock() + defer mu.Unlock() + if len(chatBodies) != 2 { + t.Fatalf("chat calls = %d, want two", len(chatBodies)) + } + for i, body := range chatBodies { + if body["reasoning_effort"] != "low" { + t.Fatalf("chat body %d reasoning_effort = %#v, want low", i+1, body["reasoning_effort"]) + } + } +} + +func TestRunCarriesMatchingReasoningCapabilityThroughFallbackEntries(t *testing.T) { + var ( + primaryModels atomic.Int32 + primaryChats atomic.Int32 + ) + primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/models"): + primaryModels.Add(1) + _, _ = w.Write([]byte(`{ + "data": [ + {"id": "primary-model", "reasoning": {"supported_efforts": ["HIGH"], "default_effort": "HIGH"}} + ] + }`)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/chat/completions"): + primaryChats.Add(1) + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":{"message":"primary unavailable"}}`)) + default: + http.NotFound(w, r) + } + })) + defer primary.Close() + + var ( + mu sync.Mutex + fallbackEffort any + fallbackModels atomic.Int32 + ) + fallback := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/models"): + fallbackModels.Add(1) + _, _ = w.Write([]byte(`{ + "data": [ + {"id": "fallback-model", "reasoning": {"supported_efforts": ["HIGH"], "default_effort": "HIGH"}} + ] + }`)) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/chat/completions"): + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode fallback body: %v", err) + } + mu.Lock() + fallbackEffort = body["reasoning_effort"] + mu.Unlock() + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"fallback ok"}}]}`)) + default: + http.NotFound(w, r) + } + })) + defer fallback.Close() + + cfg := config.Default() + cfg.Model.Provider = "primary" + cfg.Model.Default = "primary-model" + cfg.Model.Fallback = []string{"backup/fallback-model"} + cfg.Model.MaxRetries = -1 + cfg.Model.ReasoningEffort = "" + cfg.Agent.ReasoningEffort = "HIGH" + cfg.Streaming.Enabled = false + cfg.Providers = map[string]config.Provider{ + "primary": { + Kind: "openai-compatible", + BaseURL: primary.URL, + Enabled: true, + }, + "backup": { + Kind: "openai-compatible", + BaseURL: fallback.URL, + Enabled: true, + }, + } + a := newReasoningRunAgent(t, cfg) + + result, err := a.Run(context.Background(), Request{ + Message: "test fallback", + Quiet: true, + MaxTurns: 1, + }, nil) + if err != nil { + t.Fatal(err) + } + if result.Reply != "fallback ok" { + t.Fatalf("reply = %q", result.Reply) + } + if got := primaryChats.Load(); got != 1 { + t.Fatalf("primary chat calls = %d, want one", got) + } + if got := primaryModels.Load(); got != 1 { + t.Fatalf("primary catalogue fetches = %d, want one shared by fallback setup and run resolution", got) + } + if got := fallbackModels.Load(); got != 1 { + t.Fatalf("fallback catalogue fetches = %d, want one", got) + } + mu.Lock() + defer mu.Unlock() + if fallbackEffort != "HIGH" { + t.Fatalf("fallback reasoning_effort = %#v, want exact live value HIGH", fallbackEffort) + } +} + +func reasoningTestConfig(baseURL string, curated []string) *config.Config { + cfg := config.Default() + cfg.Model.Provider = "router" + cfg.Model.Default = "model-a" + cfg.Model.MaxRetries = -1 + cfg.Model.ReasoningEffort = "" + cfg.Agent.ReasoningEffort = "" + cfg.Providers = map[string]config.Provider{ + "router": { + Kind: "openai-compatible", + BaseURL: baseURL, + Enabled: true, + Models: curated, + }, + } + return cfg +} + +func newReasoningModelsServer(t *testing.T, response string) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/models") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(response)) + })) +} + +func newReasoningRunAgent(t *testing.T, cfg *config.Config) *Agent { + t.Helper() + db, err := store.Open(context.Background(), "memory", "", 1, 5000, false) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + return New(cfg, db, tools.NewRegistry(), nil, nil) +} diff --git a/internal/approval/gate.go b/internal/approval/gate.go new file mode 100644 index 0000000..c535212 --- /dev/null +++ b/internal/approval/gate.go @@ -0,0 +1,196 @@ +// Package approval provides explicit, instance-owned operation approval gates. +package approval + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "sort" + "strings" + "sync" + "sync/atomic" + "time" +) + +// Operation is the immutable human-facing description of work awaiting +// approval. +type Operation struct { + SessionID string + Tool string + Arguments string + Message string + Reason string +} + +// ErrTimeout distinguishes the gate's own deadline from a deadline inherited +// through the caller's context. +var ErrTimeout = fmt.Errorf("approval timed out: %w", context.DeadlineExceeded) + +// Request is one pending operation approval. +type Request struct { + ID string `json:"id"` + SessionID string `json:"session_id"` + Tool string `json:"tool"` + Arguments string `json:"arguments"` + Message string `json:"message,omitempty"` + Reason string `json:"reason,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type decision struct { + allow bool + err error +} + +type pendingRequest struct { + request Request + done chan struct{} + decision decision +} + +// Gate owns the approval requests for one agent instance. +type Gate struct { + mu sync.Mutex + pending map[string]*pendingRequest + timeout time.Duration +} + +// NewGate constructs an empty operation approval gate. +func NewGate(timeout time.Duration) *Gate { + return &Gate{ + pending: make(map[string]*pendingRequest), + timeout: timeout, + } +} + +// Await publishes an immutable operation and blocks until it is allowed, +// denied, timed out, or cancelled. The first terminal outcome wins. +func (g *Gate) Await(ctx context.Context, op Operation, emit func(Request) error) (bool, error) { + if err := ctx.Err(); err != nil { + return false, err + } + + op = cloneOperation(op) + pending := &pendingRequest{done: make(chan struct{})} + + g.mu.Lock() + for { + pending.request = Request{ + ID: newRequestID(), + SessionID: op.SessionID, + Tool: op.Tool, + Arguments: op.Arguments, + Message: op.Message, + Reason: op.Reason, + CreatedAt: time.Now(), + } + if _, exists := g.pending[pending.request.ID]; !exists { + break + } + } + g.pending[pending.request.ID] = pending + g.mu.Unlock() + + timer := time.NewTimer(g.timeout) + defer timer.Stop() + + if emit != nil { + if err := emit(cloneRequest(pending.request)); err != nil { + return g.finishOrAwait(pending, decision{err: err}) + } + } + + select { + case <-pending.done: + return pending.decision.allow, pending.decision.err + case <-timer.C: + return g.finishOrAwait(pending, decision{err: ErrTimeout}) + case <-ctx.Done(): + return g.finishOrAwait(pending, decision{err: ctx.Err()}) + } +} + +// Pending lists immutable request snapshots, oldest first. +func (g *Gate) Pending() []Request { + g.mu.Lock() + defer g.mu.Unlock() + + out := make([]Request, 0, len(g.pending)) + for _, pending := range g.pending { + out = append(out, cloneRequest(pending.request)) + } + sort.Slice(out, func(i, j int) bool { + if out[i].CreatedAt.Equal(out[j].CreatedAt) { + return out[i].ID < out[j].ID + } + return out[i].CreatedAt.Before(out[j].CreatedAt) + }) + return out +} + +// Resolve allows or denies a request. It reports false when another terminal +// outcome already removed the request or the ID was never pending. +func (g *Gate) Resolve(id string, allow bool) bool { + g.mu.Lock() + defer g.mu.Unlock() + + pending, ok := g.pending[id] + if !ok { + return false + } + g.finishLocked(pending, decision{allow: allow}) + return true +} + +func (g *Gate) finishOrAwait(pending *pendingRequest, result decision) (bool, error) { + g.mu.Lock() + current, ok := g.pending[pending.request.ID] + if ok && current == pending { + g.finishLocked(pending, result) + g.mu.Unlock() + return result.allow, result.err + } + g.mu.Unlock() + + <-pending.done + return pending.decision.allow, pending.decision.err +} + +func (g *Gate) finishLocked(pending *pendingRequest, result decision) { + delete(g.pending, pending.request.ID) + pending.decision = result + close(pending.done) +} + +func cloneOperation(op Operation) Operation { + return Operation{ + SessionID: strings.Clone(op.SessionID), + Tool: strings.Clone(op.Tool), + Arguments: strings.Clone(op.Arguments), + Message: strings.Clone(op.Message), + Reason: strings.Clone(op.Reason), + } +} + +func cloneRequest(req Request) Request { + return Request{ + ID: strings.Clone(req.ID), + SessionID: strings.Clone(req.SessionID), + Tool: strings.Clone(req.Tool), + Arguments: strings.Clone(req.Arguments), + Message: strings.Clone(req.Message), + Reason: strings.Clone(req.Reason), + CreatedAt: req.CreatedAt, + } +} + +var fallbackRequestID atomic.Uint64 + +func newRequestID() string { + var random [10]byte + if _, err := rand.Read(random[:]); err == nil { + return "apr_" + hex.EncodeToString(random[:]) + } + return fmt.Sprintf("apr_%020d", fallbackRequestID.Add(1)) +} diff --git a/internal/approval/gate_test.go b/internal/approval/gate_test.go new file mode 100644 index 0000000..fd136e6 --- /dev/null +++ b/internal/approval/gate_test.go @@ -0,0 +1,218 @@ +package approval + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" +) + +func TestGateRetainsImmutableOperation(t *testing.T) { + g := NewGate(time.Minute) + op := Operation{SessionID: "ses-1", Tool: "cursor_agent", Arguments: `{"model":"a"}`, Message: "Start Cursor"} + emitted := make(chan Request, 1) + done := make(chan bool, 1) + go func() { + ok, _ := g.Await(context.Background(), op, func(r Request) error { + emitted <- r + return nil + }) + done <- ok + }() + req := <-emitted + op.Arguments = `{"model":"b"}` + if !g.Resolve(req.ID, true) || !<-done { + t.Fatal("approval did not resolve") + } + if got := req.Arguments; got != `{"model":"a"}` { + t.Fatalf("arguments mutated: %s", got) + } +} + +func TestGatePendingIsOldestFirstAndCopied(t *testing.T) { + g := NewGate(time.Minute) + first := make(chan Request, 1) + second := make(chan Request, 1) + + go func() { + _, _ = g.Await(context.Background(), Operation{Tool: "first", Arguments: `{"n":1}`}, func(r Request) error { + first <- r + return nil + }) + }() + firstReq := <-first + + go func() { + _, _ = g.Await(context.Background(), Operation{Tool: "second", Arguments: `{"n":2}`}, func(r Request) error { + second <- r + return nil + }) + }() + secondReq := <-second + + pending := g.Pending() + if len(pending) != 2 { + t.Fatalf("pending length = %d, want 2", len(pending)) + } + if pending[0].ID != firstReq.ID || pending[1].ID != secondReq.ID { + t.Fatalf("pending order = [%s %s], want [%s %s]", + pending[0].ID, pending[1].ID, firstReq.ID, secondReq.ID) + } + + pending[0].Arguments = "changed" + again := g.Pending() + if again[0].Arguments != `{"n":1}` { + t.Fatalf("Pending exposed mutable state: %q", again[0].Arguments) + } + + if !g.Resolve(firstReq.ID, false) || !g.Resolve(secondReq.ID, false) { + t.Fatal("cleanup resolutions failed") + } +} + +func TestGateDenyRemovesRequest(t *testing.T) { + g := NewGate(time.Minute) + emitted := make(chan Request, 1) + done := make(chan struct { + ok bool + err error + }, 1) + + go func() { + ok, err := g.Await(context.Background(), Operation{Tool: "write_file"}, func(r Request) error { + emitted <- r + return nil + }) + done <- struct { + ok bool + err error + }{ok: ok, err: err} + }() + + req := <-emitted + if !g.Resolve(req.ID, false) { + t.Fatal("deny did not resolve") + } + result := <-done + if result.ok || result.err != nil { + t.Fatalf("deny result = (%v, %v), want (false, nil)", result.ok, result.err) + } + if pending := g.Pending(); len(pending) != 0 { + t.Fatalf("denied request remained pending: %+v", pending) + } + if g.Resolve(req.ID, true) { + t.Fatal("denied request resolved twice") + } +} + +func TestGateTimeoutRemovesRequest(t *testing.T) { + g := NewGate(20 * time.Millisecond) + emitted := make(chan Request, 1) + done := make(chan error, 1) + + go func() { + ok, err := g.Await(context.Background(), Operation{Tool: "write_file"}, func(r Request) error { + emitted <- r + return nil + }) + if ok { + done <- errors.New("timed-out request was allowed") + return + } + done <- err + }() + + req := <-emitted + if err := <-done; !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("timeout error = %v, want context deadline exceeded", err) + } + if pending := g.Pending(); len(pending) != 0 { + t.Fatalf("timed-out request remained pending: %+v", pending) + } + if g.Resolve(req.ID, true) { + t.Fatal("timed-out request resolved") + } +} + +func TestGateContextCancellationRemovesRequest(t *testing.T) { + g := NewGate(time.Minute) + ctx, cancel := context.WithCancel(context.Background()) + emitted := make(chan Request, 1) + done := make(chan error, 1) + + go func() { + ok, err := g.Await(ctx, Operation{Tool: "write_file"}, func(r Request) error { + emitted <- r + return nil + }) + if ok { + done <- errors.New("cancelled request was allowed") + return + } + done <- err + }() + + req := <-emitted + cancel() + if err := <-done; !errors.Is(err, context.Canceled) { + t.Fatalf("cancellation error = %v, want context canceled", err) + } + if pending := g.Pending(); len(pending) != 0 { + t.Fatalf("cancelled request remained pending: %+v", pending) + } + if g.Resolve(req.ID, true) { + t.Fatal("cancelled request resolved") + } +} + +func TestGateResolveUnknownID(t *testing.T) { + if NewGate(time.Minute).Resolve("apr_missing", true) { + t.Fatal("unknown request resolved") + } +} + +func TestGateConcurrentResolutionOnlyWinsOnce(t *testing.T) { + g := NewGate(time.Minute) + emitted := make(chan Request, 1) + done := make(chan bool, 1) + go func() { + ok, _ := g.Await(context.Background(), Operation{Tool: "cursor_agent"}, func(r Request) error { + emitted <- r + return nil + }) + done <- ok + }() + req := <-emitted + + const contenders = 64 + start := make(chan struct{}) + var wg sync.WaitGroup + var wins atomic.Int32 + winner := make(chan bool, 1) + for i := 0; i < contenders; i++ { + allow := i%2 == 0 + wg.Add(1) + go func() { + defer wg.Done() + <-start + if g.Resolve(req.ID, allow) { + wins.Add(1) + winner <- allow + } + }() + } + close(start) + wg.Wait() + + if got := wins.Load(); got != 1 { + t.Fatalf("successful resolutions = %d, want 1", got) + } + if got, want := <-done, <-winner; got != want { + t.Fatalf("await result = %v, winning resolution = %v", got, want) + } + if pending := g.Pending(); len(pending) != 0 { + t.Fatalf("resolved request remained pending: %+v", pending) + } +} diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 6a4dd37..cadb6b3 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -7,13 +7,18 @@ import "path/filepath" func Default() *Config { return &Config{ Model: Model{ - Default: "anthropic/claude-sonnet-4.5", - Provider: "openrouter", - Temperature: 0.7, - TopP: 1.0, - MaxTokens: 8192, - ContextWindow: 200000, - ReasoningEffort: "medium", + Default: "anthropic/claude-sonnet-4.5", + Provider: "openrouter", + Temperature: 0.7, + TopP: 1.0, + MaxTokens: 8192, + ContextWindow: 200000, + // Auto ("") by default: reasoning effort is a provider/model-specific + // opaque value now, and OpenRouter can route to thousands of backing + // models with different (or no) reasoning ladders. A non-empty + // default here would fail pre-request validation for any model + // that doesn't advertise it. + ReasoningEffort: "", ParallelToolCall: true, }, Providers: map[string]Provider{ @@ -65,7 +70,7 @@ func Default() *Config { CORSOrigins: []string{}, }, Agent: Agent{ - MaxTurns: 200, MaxToolCalls: 32, ReasoningEffort: "medium", + MaxTurns: 200, MaxToolCalls: 32, ReasoningEffort: "", Personality: "default", Workspace: "~/antares-workspace", Timezone: "Local", Language: "auto", IdleTimeoutSecs: 900, RepeatLimit: 3, VerifyReplies: false, VerifyMax: 2, GoalMaxIterations: 10, diff --git a/internal/config/defaults_test.go b/internal/config/defaults_test.go new file mode 100644 index 0000000..165605c --- /dev/null +++ b/internal/config/defaults_test.go @@ -0,0 +1,20 @@ +package config + +import "testing" + +// TestDefaultReasoningEffortIsAuto guards against a regression where the +// fresh-install default carried a non-empty reasoning effort ("medium"). +// Reasoning effort is now a provider/model-specific opaque value validated +// pre-request; OpenRouter alone can route to thousands of backing models +// with unknown or absent reasoning ladders, so any non-empty default here +// would fail validation before every default chat reached the network. +// Auto ("") is the only value guaranteed to be valid everywhere. +func TestDefaultReasoningEffortIsAuto(t *testing.T) { + cfg := Default() + if cfg.Agent.ReasoningEffort != "" { + t.Fatalf("Agent.ReasoningEffort = %q, want empty (Auto)", cfg.Agent.ReasoningEffort) + } + if cfg.Model.ReasoningEffort != "" { + t.Fatalf("Model.ReasoningEffort = %q, want empty (Auto)", cfg.Model.ReasoningEffort) + } +} diff --git a/internal/config/load.go b/internal/config/load.go index 955be60..806686e 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -107,11 +107,32 @@ func Raw() (string, error) { return string(b), err } -// SaveRaw validates then writes YAML text supplied by the dashboard editor. -func SaveRaw(text string) error { +// ParseRaw validates YAML text supplied by the dashboard editor without +// changing either the active config file or the in-memory config cache. +func ParseRaw(text string) (*Config, error) { cfg := Default() if err := yaml.Unmarshal([]byte(text), cfg); err != nil { - return fmt.Errorf("invalid YAML: %w", err) + return nil, fmt.Errorf("invalid YAML: %w", err) + } + return cfg, nil +} + +// ParseRawWithEnv returns a write-free validation candidate with the current +// process-environment overlays applied exactly as Reload applies them. It does +// not load dotenv files, update the config cache, or persist derived secrets. +func ParseRawWithEnv(text string) (*Config, error) { + cfg, err := ParseRaw(text) + if err != nil { + return nil, err + } + applyEnv(cfg) + return cfg, nil +} + +// SaveRaw validates then writes YAML text supplied by the dashboard editor. +func SaveRaw(text string) error { + if _, err := ParseRaw(text); err != nil { + return err } if err := os.MkdirAll(filepath.Dir(ConfigFile()), 0o700); err != nil { return err diff --git a/internal/config/schema.go b/internal/config/schema.go index 6a98964..7dc1b33 100644 --- a/internal/config/schema.go +++ b/internal/config/schema.go @@ -21,7 +21,11 @@ type Field struct { Default any `json:"default"` Secret bool `json:"secret"` Enum []string `json:"enum,omitempty"` - Help string `json:"help,omitempty"` + // OptionsSource names dynamic option metadata supplied outside the static + // schema. Reasoning values are model-specific, so the dashboard resolves + // them from the selected model's capability instead of a fixed enum. + OptionsSource string `json:"options_source,omitempty"` + Help string `json:"help,omitempty"` } // Tiers a field can belong to. @@ -48,43 +52,43 @@ var essential = map[string]bool{ // common holds the settings people actually revisit. Everything not listed // here or above is treated as advanced. var common = map[string]bool{ - "model.temperature": true, - "model.max_tokens": true, - "model.context_window": true, - "model.reasoning_effort": true, - "model.auxiliary": true, - "agent.max_turns": true, - "agent.personality": true, - "agent.system_prompt_extra": true, - "agent.timezone": true, - "tools.approval_mode": true, - "tools.web_search.provider": true, - "tools.web_search.api_key": true, - "terminal.backend": true, - "terminal.cwd": true, - "terminal.timeout": true, - "memory.memory_enabled": true, - "memory.user_profile_enabled": true, - "rag.embed_model": true, - "rag.embed_provider": true, - "rag.rerank_mode": true, - "rag.per_user": true, - "skills.enabled": true, - "skills.auto_create": true, - "cron.enabled": true, - "cron.timezone": true, - "gateway.enabled": true, - "gateway.telegram.enabled": true, - "gateway.discord.enabled": true, - "mcp.enabled": true, - "compression.enabled": true, - "streaming.enabled": true, - "delegation.enabled": true, - "display.show_reasoning": true, - "display.tool_progress": true, - "display.max_live_reasoning_chars": true, - "logging.level": true, - "server.host": true, + "model.temperature": true, + "model.max_tokens": true, + "model.context_window": true, + "model.reasoning_effort": true, + "model.auxiliary": true, + "agent.max_turns": true, + "agent.personality": true, + "agent.system_prompt_extra": true, + "agent.timezone": true, + "tools.approval_mode": true, + "tools.web_search.provider": true, + "tools.web_search.api_key": true, + "terminal.backend": true, + "terminal.cwd": true, + "terminal.timeout": true, + "memory.memory_enabled": true, + "memory.user_profile_enabled": true, + "rag.embed_model": true, + "rag.embed_provider": true, + "rag.rerank_mode": true, + "rag.per_user": true, + "skills.enabled": true, + "skills.auto_create": true, + "cron.enabled": true, + "cron.timezone": true, + "gateway.enabled": true, + "gateway.telegram.enabled": true, + "gateway.discord.enabled": true, + "mcp.enabled": true, + "compression.enabled": true, + "streaming.enabled": true, + "delegation.enabled": true, + "display.show_reasoning": true, + "display.tool_progress": true, + "display.max_live_reasoning_chars": true, + "logging.level": true, + "server.host": true, } func tierFor(path string) string { @@ -115,34 +119,37 @@ var enums = map[string][]string{ "session_reset.mode": {"never", "idle", "daily"}, "display.theme": {"system", "light", "dark"}, "logging.level": {"debug", "info", "warn", "error"}, - "agent.reasoning_effort": {"none", "low", "medium", "high"}, - "model.reasoning_effort": {"none", "low", "medium", "high"}, "tools.web_search.provider": {"browser", "brave", "tavily", "searxng", "none"}, } +var optionsSources = map[string]string{ + "agent.reasoning_effort": "reasoning_capability", + "model.reasoning_effort": "reasoning_capability", +} + var help = map[string]string{ - "model.default": "Model id as your provider spells it, e.g. anthropic/claude-sonnet-4.5.", - "model.provider": "Which entry under providers to call.", - "model.auxiliary": "Cheaper model used for summarising and other background work.", - "model.context_window": "Used to decide when to compact; set it to match your model.", - "database.driver": "sqlite for a single node, postgres when you share state.", - "database.dsn": "sqlite: a file path. postgres: postgres://user:pass@host:5432/db?sslmode=disable", - "server.auth_token": "Leave empty to keep the dashboard open — sensible behind a private network.", - "server.host": "0.0.0.0 exposes it on every interface; 127.0.0.1 keeps it local.", - "agent.workspace": "The only directory file tools may read or write.", - "agent.system_prompt_extra": "Appended to the system prompt on every turn.", - "tools.toolset": "Preset deciding which tools reach the model.", - "tools.approval_mode": "auto runs mutating tools directly; deny blocks them.", - "rag.rerank_mode": "How to reorder results: llm (an auxiliary model scores them), api (an external reranker), or off.", - "rag.embed_model": "The embedding model for indexing and search, e.g. text-embedding-3-small.", - "rag.per_user": "Keep a separate memory per chat user (Discord/Telegram), so the agent can recall topics and facts about each specific person. Stores cross-conversation data about individuals; off by default.", - "compression.threshold": "Fraction of the context window that triggers automatic compaction.", - "terminal.backend": "local runs on this machine; docker and ssh sandbox it elsewhere.", - "memory.memory_enabled": "Lets the agent store durable facts between sessions.", - "skills.auto_create": "Allows the agent to write new skills on its own.", - "osint.google_cookie": "Optional. A logged-in Google Cookie header enables osint_google to resolve an email to its public profile. ToS-sensitive; uses your own session. Leave empty to disable.", - "display.show_reasoning": "Stream and show model reasoning/thinking in the dashboard (and TUI). Off skips emitting reasoning events so long thinking traces never hit the UI.", - "display.tool_progress": "Show live tool progress lines while a tool runs.", + "model.default": "Model id as your provider spells it, e.g. anthropic/claude-sonnet-4.5.", + "model.provider": "Which entry under providers to call.", + "model.auxiliary": "Cheaper model used for summarising and other background work.", + "model.context_window": "Used to decide when to compact; set it to match your model.", + "database.driver": "sqlite for a single node, postgres when you share state.", + "database.dsn": "sqlite: a file path. postgres: postgres://user:pass@host:5432/db?sslmode=disable", + "server.auth_token": "Leave empty to keep the dashboard open — sensible behind a private network.", + "server.host": "0.0.0.0 exposes it on every interface; 127.0.0.1 keeps it local.", + "agent.workspace": "The only directory file tools may read or write.", + "agent.system_prompt_extra": "Appended to the system prompt on every turn.", + "tools.toolset": "Preset deciding which tools reach the model.", + "tools.approval_mode": "auto runs mutating tools directly; deny blocks them.", + "rag.rerank_mode": "How to reorder results: llm (an auxiliary model scores them), api (an external reranker), or off.", + "rag.embed_model": "The embedding model for indexing and search, e.g. text-embedding-3-small.", + "rag.per_user": "Keep a separate memory per chat user (Discord/Telegram), so the agent can recall topics and facts about each specific person. Stores cross-conversation data about individuals; off by default.", + "compression.threshold": "Fraction of the context window that triggers automatic compaction.", + "terminal.backend": "local runs on this machine; docker and ssh sandbox it elsewhere.", + "memory.memory_enabled": "Lets the agent store durable facts between sessions.", + "skills.auto_create": "Allows the agent to write new skills on its own.", + "osint.google_cookie": "Optional. A logged-in Google Cookie header enables osint_google to resolve an email to its public profile. ToS-sensitive; uses your own session. Leave empty to disable.", + "display.show_reasoning": "Stream and show model reasoning/thinking in the dashboard (and TUI). Off skips emitting reasoning events so long thinking traces never hit the UI.", + "display.tool_progress": "Show live tool progress lines while a tool runs.", "display.max_live_reasoning_chars": "Max characters of reasoning kept in the browser while a turn streams (trailing window). Prevents tab freezes on long thinking. Default 48000. 0 = unlimited. Full text is still saved server-side and restored after the turn.", } @@ -220,14 +227,15 @@ func walk(v reflect.Value, prefix, group string, out *[]Field) { } f := Field{ - Path: path, - Label: humanize(name), - Group: grp, - Tier: tierFor(path), - Default: fv.Interface(), - Secret: secretKey(path), - Enum: enums[path], - Help: help[path], + Path: path, + Label: humanize(name), + Group: grp, + Tier: tierFor(path), + Default: fv.Interface(), + Secret: secretKey(path), + Enum: enums[path], + OptionsSource: optionsSources[path], + Help: help[path], } switch fv.Kind() { case reflect.Bool: diff --git a/internal/config/schema_reasoning_test.go b/internal/config/schema_reasoning_test.go new file mode 100644 index 0000000..c823c4f --- /dev/null +++ b/internal/config/schema_reasoning_test.go @@ -0,0 +1,118 @@ +package config + +import ( + "os" + "strings" + "testing" +) + +func TestSchemaMarksReasoningFieldsModelAware(t *testing.T) { + schema := Schema() + for _, path := range []string{"agent.reasoning_effort", "model.reasoning_effort"} { + field := fieldByPath(t, schema, path) + if len(field.Enum) != 0 || field.OptionsSource != "reasoning_capability" { + t.Fatalf("%s = %+v", path, field) + } + } +} + +func TestParseRawDoesNotWriteConfiguration(t *testing.T) { + t.Setenv("ANTARES_HOME", t.TempDir()) + t.Setenv("ANTARES_CONFIG", "") + t.Setenv("ANTARES_PROFILE", "default") + if err := Save(Default()); err != nil { + t.Fatal(err) + } + before := mustReadConfigFile(t) + if _, err := ParseRaw("model:\n default: gpt-5\n"); err != nil { + t.Fatal(err) + } + if after := mustReadConfigFile(t); after != before { + t.Fatal("ParseRaw changed the config file") + } +} + +func TestParseRawWithEnvAppliesProviderOverlaysWithoutWriting(t *testing.T) { + t.Setenv("ANTARES_HOME", t.TempDir()) + t.Setenv("ANTARES_CONFIG", "") + t.Setenv("ANTARES_PROFILE", "default") + t.Setenv("ANTARES_MODEL", "") + t.Setenv("ANTARES_PROVIDER", "") + t.Setenv("ANTARES_BASE_URL", "") + t.Setenv("ANTARES_API_KEY", "") + t.Setenv("ROUND2_DECLARED_KEY", "declared-secret") + t.Setenv("ANTARES_PROVIDER_DECLARED_API_KEY", "") + t.Setenv("ANTARES_PROVIDER_DECLARED_BASE_URL", "http://env-declared.example/v1") + t.Setenv("ANTARES_PROVIDER_EXPLICIT_API_KEY", "explicit-secret") + t.Setenv("ANTARES_PROVIDER_EXPLICIT_BASE_URL", "http://env-explicit.example/v1") + + if err := Save(Default()); err != nil { + t.Fatal(err) + } + liveBefore := Get() + fileBefore := mustReadConfigFile(t) + raw := "model:\n" + + " provider: declared\n" + + " default: model-a\n" + + "providers:\n" + + " declared:\n" + + " kind: openai-compatible\n" + + " base_url: http://raw-declared.example/v1\n" + + " api_key_env: ROUND2_DECLARED_KEY\n" + + " enabled: true\n" + + " explicit:\n" + + " kind: openai-compatible\n" + + " base_url: http://raw-explicit.example/v1\n" + + " enabled: true\n" + + candidate, err := ParseRawWithEnv(raw) + if err != nil { + t.Fatal(err) + } + declared := candidate.Providers["declared"] + if declared.APIKey != "declared-secret" || + declared.BaseURL != "http://env-declared.example/v1" { + t.Fatalf("declared provider = %+v", declared) + } + explicit := candidate.Providers["explicit"] + if explicit.APIKey != "explicit-secret" || + explicit.BaseURL != "http://env-explicit.example/v1" { + t.Fatalf("explicit provider = %+v", explicit) + } + if after := mustReadConfigFile(t); after != fileBefore { + t.Fatal("ParseRawWithEnv changed the config file") + } + if Get() != liveBefore { + t.Fatal("ParseRawWithEnv replaced the live config") + } + if strings.Contains(fileBefore, "declared-secret") || + strings.Contains(fileBefore, "explicit-secret") { + t.Fatal("environment credential was persisted") + } +} + +func fieldByPath(t *testing.T, fields []Field, path string) Field { + t.Helper() + for _, field := range fields { + if field.Path == path { + return field + } + } + t.Fatalf("field %q not found", path) + return Field{} +} + +func mustReadConfigFile(t *testing.T) string { + t.Helper() + path := ConfigFile() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := os.WriteFile(path, raw, 0o600); err != nil { + t.Errorf("restore config: %v", err) + } + }) + return string(raw) +} diff --git a/internal/cursor/client_test.go b/internal/cursor/client_test.go index 3013c12..26c9516 100644 --- a/internal/cursor/client_test.go +++ b/internal/cursor/client_test.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "net/http/httptest" + "reflect" "strconv" "strings" "sync/atomic" @@ -151,6 +152,115 @@ func TestCreateAgentRepoAndFollowUpPayloads(t *testing.T) { } } +// The Cursor Cloud Agents API accepts a stable envelope of hidden variant +// params alongside optional prompt images; both must round-trip byte-exact +// so a chosen model variant and any attachments are never silently altered. +func TestCreateAgentEncodesPromptImagesAndExactModelParams(t *testing.T) { + var gotBody []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var err error + gotBody, err = io.ReadAll(r.Body) + if err != nil { + t.Fatal(err) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "agent": map[string]any{ + "id": "a1", "status": "ACTIVE", "url": "https://cursor.com/agents/a1", "latestRunId": "r1", + }, + "run": map[string]any{"id": "r1", "agentId": "a1", "status": "CREATING"}, + }) + })) + defer srv.Close() + + want := CreateAgentRequest{ + Prompt: Prompt{ + Text: "inspect this", + Images: []PromptImage{{Data: "aGVsbG8=", MimeType: "image/png"}}, + }, + Model: &ModelSelection{ + ID: "gpt-5.6-sol", + Params: []ModelParameterSelection{ + {ID: "context", Value: "1m"}, + {ID: "reasoning", Value: "max"}, + {ID: "fast", Value: "true"}, + }, + }, + } + + client, _ := New(Options{BaseURL: srv.URL, APIKey: "synthetic-key", HTTPClient: srv.Client()}) + if _, err := client.CreateAgent(context.Background(), want); err != nil { + t.Fatalf("CreateAgent error = %v", err) + } + + // Round-tripping through the same struct type would not catch a wrong + // JSON tag: an incorrect tag on the encode side decodes back to the same + // Go value through the identical (equally wrong) tag. Decoding into a + // schemaless map instead pins the exact wire keys Cursor's API expects. + var got map[string]any + if err := json.Unmarshal(gotBody, &got); err != nil { + t.Fatalf("decode request body: %v (body=%s)", err, gotBody) + } + + prompt, ok := got["prompt"].(map[string]any) + if !ok { + t.Fatalf("prompt = %#v, want object", got["prompt"]) + } + if prompt["text"] != "inspect this" { + t.Fatalf("prompt.text = %#v, want %q", prompt["text"], "inspect this") + } + images, ok := prompt["images"].([]any) + if !ok || len(images) != 1 { + t.Fatalf("prompt.images = %#v, want exactly one image", prompt["images"]) + } + image, ok := images[0].(map[string]any) + if !ok { + t.Fatalf("prompt.images[0] = %#v, want object", images[0]) + } + if wantImage := map[string]any{"data": "aGVsbG8=", "mimeType": "image/png"}; !reflect.DeepEqual(image, wantImage) { + t.Fatalf("prompt.images[0] = %#v, want %#v (exact keys; empty url must stay omitted)", image, wantImage) + } + + model, ok := got["model"].(map[string]any) + if !ok { + t.Fatalf("model = %#v, want object", got["model"]) + } + if model["id"] != "gpt-5.6-sol" { + t.Fatalf("model.id = %#v, want %q", model["id"], "gpt-5.6-sol") + } + paramsRaw, ok := model["params"].([]any) + if !ok { + t.Fatalf("model.params = %#v, want array", model["params"]) + } + wantParams := []map[string]any{ + {"id": "context", "value": "1m"}, + {"id": "reasoning", "value": "max"}, + {"id": "fast", "value": "true"}, + } + if len(paramsRaw) != len(wantParams) { + t.Fatalf("model.params = %#v, want %d entries in order", paramsRaw, len(wantParams)) + } + for i, raw := range paramsRaw { + param, ok := raw.(map[string]any) + if !ok { + t.Fatalf("model.params[%d] = %#v, want object", i, raw) + } + if !reflect.DeepEqual(param, wantParams[i]) { + t.Fatalf("model.params[%d] = %#v, want %#v (exact id/value, in Cursor's original order)", i, param, wantParams[i]) + } + } + + // The struct-level round trip still catches selection/order mistakes + // (e.g. a dropped or reordered param, or an extra field) that the exact + // key assertions above cannot see through Go's flexible unmarshaling. + var gotStruct CreateAgentRequest + if err := json.Unmarshal(gotBody, &gotStruct); err != nil { + t.Fatalf("decode request body into CreateAgentRequest: %v (body=%s)", err, gotBody) + } + if !reflect.DeepEqual(gotStruct, want) { + t.Fatalf("request body = %+v, want %+v", gotStruct, want) + } +} + func TestCreateAgentOmitsOptionalFieldsWhenUnset(t *testing.T) { var body map[string]any srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/cursor/stream.go b/internal/cursor/stream.go index 7fdccae..ef62b58 100644 --- a/internal/cursor/stream.go +++ b/internal/cursor/stream.go @@ -102,14 +102,31 @@ func parseSSE(r io.Reader, emit func(StreamEvent) error) (lastID string, termina Status string `json:"status"` } decodeErr = json.Unmarshal(raw, &payload) + out.RunID = payload.RunID out.Status = payload.Status case "tool_call": + // Cursor Cloud Agents API, "Stream A Run" — tool call payloads + // (https://cursor.com/docs/cloud-agent/api/endpoints#stream-a-run): + // { callId, name, status, args?, result?, truncated?: {args?, result?} } var payload struct { - Name string `json:"name"` - Status string `json:"status"` + CallID string `json:"callId"` + Name string `json:"name"` + Status string `json:"status"` + Args json.RawMessage `json:"args,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + Truncated struct { + Args bool `json:"args"` + Result bool `json:"result"` + } `json:"truncated"` } decodeErr = json.Unmarshal(raw, &payload) - out.ToolName, out.Status = payload.Name, payload.Status + out.CallID = payload.CallID + out.ToolName = payload.Name + out.Status = payload.Status + out.ToolArgs = payload.Args + out.ToolResult = payload.Result + out.ArgsTruncated = payload.Truncated.Args + out.ResultTruncated = payload.Truncated.Result case "result": var payload struct { RunID string `json:"runId"` @@ -127,6 +144,7 @@ func parseSSE(r io.Reader, emit func(StreamEvent) error) (lastID string, termina DurationMS: payload.DurationMS, Git: payload.Git, } + out.RunID = payload.RunID out.Status = payload.Status out.Text = payload.Text } @@ -246,14 +264,40 @@ func (c *Client) streamOnce( return lastID, terminal, false, nil } +// StreamOptions configures StreamRunWithOptions. LastEventID lets a caller +// resume a stream from a token persisted before this process started, +// instead of always replaying from the beginning of the run. +// +// OnReset is invoked at most once per call, before Antares discards the +// current Last-Event-ID following a 400 invalid_last_event_id response or a +// 410 stream_expired response. It lets a caller drop its own persisted copy +// of that token so a dead resume point is never reused on a later call. An +// error returned from OnReset aborts the stream immediately. +type StreamOptions struct { + LastEventID string + OnReset func() error +} + // StreamRun streams a run's events, transparently reconnecting on ordinary -// disconnects while preserving Last-Event-ID. The retry budget applies only -// to consecutive disconnects that make no event-ID progress. +// disconnects while preserving Last-Event-ID. It is a compatibility wrapper +// over StreamRunWithOptions with no caller-supplied resume token. +func (c *Client) StreamRun( + ctx context.Context, + agentID, runID string, + emit func(StreamEvent) error, +) (*Run, error) { + return c.StreamRunWithOptions(ctx, agentID, runID, StreamOptions{}, emit) +} + +// StreamRunWithOptions streams a run's events, transparently reconnecting on +// ordinary disconnects while preserving Last-Event-ID. The retry budget +// applies only to consecutive disconnects that make no event-ID progress. // It returns the run's terminal state once a "result" event is decoded, or // once the stream ends and GetRun confirms completion. -func (c *Client) StreamRun( +func (c *Client) StreamRunWithOptions( ctx context.Context, agentID, runID string, + options StreamOptions, emit func(StreamEvent) error, ) (*Run, error) { if strings.TrimSpace(agentID) == "" { @@ -266,8 +310,18 @@ func (c *Client) StreamRun( backoffs := [3]time.Duration{250 * time.Millisecond, 500 * time.Millisecond, 1 * time.Second} const maxNoProgress = 4 - var lastID string + lastID := options.LastEventID var resetUsed bool + resetOnce := func() error { + if resetUsed { + return nil + } + resetUsed = true + if options.OnReset == nil { + return nil + } + return options.OnReset() + } noProgress := 0 var retryDelay time.Duration @@ -294,12 +348,25 @@ func (c *Client) StreamRun( return nil, ctxErr } if IsStatus(err, http.StatusGone) { + // Unlike a transport read error racing a decoded "result" + // event (where the terminal value already in hand wins), + // GetRun has not run yet here: nothing terminal exists to + // prefer. OnReset is a persistence invariant, not a + // best-effort notification, so a failure aborts immediately + // instead of calling GetRun, which would let a stale or + // unrecorded resume token be finalized or replayed + // inconsistently. + if rerr := resetOnce(); rerr != nil { + return nil, rerr + } return c.GetRun(ctx, agentID, runID) } var apiErr *APIError if errors.As(err, &apiErr) && apiErr.Status == http.StatusBadRequest && apiErr.Code == "invalid_last_event_id" && !resetUsed { - resetUsed = true + if rerr := resetOnce(); rerr != nil { + return nil, rerr + } lastID = "" continue } diff --git a/internal/cursor/stream_test.go b/internal/cursor/stream_test.go index 8dbfbb7..e7eac28 100644 --- a/internal/cursor/stream_test.go +++ b/internal/cursor/stream_test.go @@ -9,6 +9,7 @@ import ( "net" "net/http" "net/http/httptest" + "reflect" "strings" "sync/atomic" "testing" @@ -605,6 +606,304 @@ func TestStreamRunReturnsEmitErrorImmediately(t *testing.T) { } } +// StreamRunWithOptions is the real implementation; StreamRun must remain a +// thin wrapper that calls it with an empty StreamOptions. +func TestStreamRunWithOptionsUsesSuppliedLastEventID(t *testing.T) { + var gotHeader string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotHeader = r.Header.Get("Last-Event-ID") + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, + "id: evt-9\nevent: result\ndata: {\"runId\":\"run-one\",\"status\":\"FINISHED\",\"text\":\"done\"}\n\n"+ + "id: evt-10\nevent: done\ndata: {}\n\n") + })) + defer srv.Close() + + client, _ := New(Options{BaseURL: srv.URL, APIKey: "synthetic-key", HTTPClient: srv.Client()}) + run, err := client.StreamRunWithOptions(context.Background(), "bc-agent", "run-one", + StreamOptions{LastEventID: "evt-8"}, + func(StreamEvent) error { return nil }) + if err != nil || run.Status != "FINISHED" { + t.Fatalf("StreamRunWithOptions = %+v, %v", run, err) + } + if gotHeader != "evt-8" { + t.Fatalf("initial Last-Event-ID = %q, want evt-8", gotHeader) + } +} + +// A stale caller-supplied resume token must trigger OnReset before Antares +// replays the run with a cleared token, so a persisted copy of the token +// (e.g. across process restarts) does not keep resurrecting a dead cursor. +func TestStreamRunWithOptionsInvalidLastEventIDCallsOnResetBeforeReplay(t *testing.T) { + var calls atomic.Int32 + order := make(chan string, 4) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + switch calls.Add(1) { + case 1: + if got := r.Header.Get("Last-Event-ID"); got != "stale-evt" { + t.Errorf("first Last-Event-ID = %q, want stale-evt", got) + } + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]any{"code": "invalid_last_event_id", "message": "unknown event id"}) + case 2: + order <- "reconnect" + if got := r.Header.Get("Last-Event-ID"); got != "" { + t.Errorf("reconnect Last-Event-ID = %q, want reset to empty", got) + } + _, _ = io.WriteString(w, + "id: evt-1\nevent: result\ndata: {\"runId\":\"run-one\",\"status\":\"FINISHED\",\"text\":\"done\"}\n\n"+ + "id: evt-2\nevent: done\ndata: {}\n\n") + default: + t.Errorf("unexpected extra call %d", calls.Load()) + } + })) + defer srv.Close() + + var resetCalls atomic.Int32 + client, _ := New(Options{BaseURL: srv.URL, APIKey: "synthetic-key", HTTPClient: srv.Client()}) + run, err := client.StreamRunWithOptions(context.Background(), "bc-agent", "run-one", + StreamOptions{ + LastEventID: "stale-evt", + OnReset: func() error { + resetCalls.Add(1) + order <- "reset" + return nil + }, + }, + func(StreamEvent) error { return nil }) + if err != nil || run.Status != "FINISHED" || run.Result != "done" { + t.Fatalf("StreamRunWithOptions = %+v, %v", run, err) + } + if resetCalls.Load() != 1 { + t.Fatalf("OnReset calls = %d, want exactly 1", resetCalls.Load()) + } + close(order) + var got []string + for v := range order { + got = append(got, v) + } + if want := []string{"reset", "reconnect"}; !reflect.DeepEqual(got, want) { + t.Fatalf("call order = %v, want %v (OnReset must run before replay)", got, want) + } +} + +// A run whose retention window elapsed (410) is a signal the resume token is +// dead too, even though Antares does not retry the stream itself: OnReset +// must still fire before the GetRun fallback so a persisted token is +// dropped instead of being reused on the next call. +func TestStreamRunWithOptions410CallsOnResetBeforeGetRunFallback(t *testing.T) { + var streamCalls, statusCalls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/stream"): + streamCalls.Add(1) + w.WriteHeader(http.StatusGone) + _ = json.NewEncoder(w).Encode(map[string]any{"code": "stream_expired", "message": "stream expired"}) + case r.URL.Path == "/v1/agents/bc-agent/runs/run-one": + statusCalls.Add(1) + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "run-one", "agentId": "bc-agent", "status": "FINISHED", "result": "done", + }) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + var resetCalls atomic.Int32 + client, _ := New(Options{BaseURL: srv.URL, APIKey: "synthetic-key", HTTPClient: srv.Client()}) + run, err := client.StreamRunWithOptions(context.Background(), "bc-agent", "run-one", + StreamOptions{ + LastEventID: "evt-old", + OnReset: func() error { + resetCalls.Add(1) + return nil + }, + }, + func(StreamEvent) error { return nil }) + if err != nil || run.Status != "FINISHED" || run.Result != "done" { + t.Fatalf("StreamRunWithOptions = %+v, %v", run, err) + } + if resetCalls.Load() != 1 { + t.Fatalf("OnReset calls = %d, want exactly 1", resetCalls.Load()) + } + if streamCalls.Load() != 1 || statusCalls.Load() != 1 { + t.Fatalf("stream calls = %d, status calls = %d; want 1 and 1 (no stream retry after 410)", + streamCalls.Load(), statusCalls.Load()) + } +} + +// A failed OnReset on the 410 path is a persistence invariant violation, not +// a soft warning: Antares must abort before ever calling GetRun, so a resume +// token that could not be dropped is never used to finalize or later replay +// state inconsistently. This is deliberately stricter than the terminal- +// result-wins policy for a racing read error, where a result already +// decoded on the wire is trusted over a later transport failure. +func TestStreamRunWithOptions410AbortsBeforeGetRunWhenOnResetFails(t *testing.T) { + var streamCalls, statusCalls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/stream"): + streamCalls.Add(1) + w.WriteHeader(http.StatusGone) + _ = json.NewEncoder(w).Encode(map[string]any{"code": "stream_expired", "message": "stream expired"}) + case r.URL.Path == "/v1/agents/bc-agent/runs/run-one": + statusCalls.Add(1) + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "run-one", "agentId": "bc-agent", "status": "FINISHED", "result": "done", + }) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + var resetCalls atomic.Int32 + wantErr := errors.New("synthetic persistence failure") + client, _ := New(Options{BaseURL: srv.URL, APIKey: "synthetic-key", HTTPClient: srv.Client()}) + run, err := client.StreamRunWithOptions(context.Background(), "bc-agent", "run-one", + StreamOptions{ + LastEventID: "evt-old", + OnReset: func() error { + resetCalls.Add(1) + return wantErr + }, + }, + func(StreamEvent) error { return nil }) + if !errors.Is(err, wantErr) { + t.Fatalf("err = %v, want %v", err, wantErr) + } + if run != nil { + t.Fatalf("run = %+v, want nil (no terminal result surfaced when OnReset fails)", run) + } + if resetCalls.Load() != 1 { + t.Fatalf("OnReset calls = %d, want exactly 1", resetCalls.Load()) + } + if streamCalls.Load() != 1 { + t.Fatalf("stream calls = %d, want 1", streamCalls.Load()) + } + if statusCalls.Load() != 0 { + t.Fatalf("status calls = %d, want 0 (GetRun must not run when OnReset fails)", statusCalls.Load()) + } +} + +// An OnReset failure must abort the stream immediately instead of +// proceeding to reconnect with a cleared token the caller could not record. +func TestStreamRunWithOptionsAbortsWhenOnResetFails(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + calls.Add(1) + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]any{"code": "invalid_last_event_id", "message": "unknown event id"}) + })) + defer srv.Close() + + wantErr := errors.New("synthetic persistence failure") + client, _ := New(Options{BaseURL: srv.URL, APIKey: "synthetic-key", HTTPClient: srv.Client()}) + _, err := client.StreamRunWithOptions(context.Background(), "bc-agent", "run-one", + StreamOptions{ + LastEventID: "stale-evt", + OnReset: func() error { return wantErr }, + }, + func(StreamEvent) error { return nil }) + if !errors.Is(err, wantErr) { + t.Fatalf("err = %v, want %v", err, wantErr) + } + if calls.Load() != 1 { + t.Fatalf("stream calls = %d, want 1 (no reconnect after OnReset failure)", calls.Load()) + } +} + +// The terminal-result-wins guarantee must hold through the new entry point +// directly, not only through the StreamRun compatibility wrapper. +func TestStreamRunWithOptionsTerminalResultWinsOverLaterReadError(t *testing.T) { + client, calls := truncatedStreamClient(t, func(int32, *http.Request) (string, error) { + return "id: evt-1\nevent: result\ndata: {\"runId\":\"run-one\",\"status\":\"FINISHED\",\"text\":\"done\"}\n\n", + io.ErrUnexpectedEOF + }) + + run, err := client.StreamRunWithOptions(context.Background(), "bc-agent", "run-one", StreamOptions{}, + func(StreamEvent) error { return nil }) + if err != nil || run == nil || run.Status != "FINISHED" || run.Result != "done" { + t.Fatalf("StreamRunWithOptions = %+v, %v; want the decoded result to outrank the read error", run, err) + } + if calls.Load() != 1 { + t.Fatalf("stream calls = %d, want 1", calls.Load()) + } +} + +// Full tool-call identity, args, result, and truncation flags must survive +// decoding exactly as Cursor documents the tool_call envelope, and status +// events must carry their run id too. +func TestCompleteToolCallEventDecodesIDArgsResultAndTruncation(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, + "event: status\ndata: {\"runId\":\"run-one\",\"status\":\"RUNNING\"}\n\n"+ + "id: evt-1\nevent: tool_call\ndata: {\"callId\":\"call-1\",\"name\":\"read_file\",\"status\":\"running\","+ + "\"args\":{\"path\":\"README.md\"}}\n\n"+ + "id: evt-2\nevent: tool_call\ndata: {\"callId\":\"call-1\",\"name\":\"read_file\",\"status\":\"completed\","+ + "\"args\":{\"path\":\"README.md\"},\"result\":{\"content\":\"# Project\"},"+ + "\"truncated\":{\"args\":true,\"result\":true}}\n\n"+ + "id: evt-3\nevent: result\ndata: {\"runId\":\"run-one\",\"status\":\"FINISHED\",\"text\":\"done\"}\n\n"+ + "id: evt-4\nevent: done\ndata: {}\n\n") + })) + defer srv.Close() + + client, _ := New(Options{BaseURL: srv.URL, APIKey: "synthetic-key", HTTPClient: srv.Client()}) + var events []StreamEvent + run, err := client.StreamRun(context.Background(), "bc-agent", "run-one", func(e StreamEvent) error { + events = append(events, e) + return nil + }) + if err != nil || run.Status != "FINISHED" { + t.Fatalf("StreamRun = %+v, %v", run, err) + } + if len(events) != 4 { + t.Fatalf("events = %#v, want status, two tool_call events, and result", events) + } + + status := events[0] + if status.Type != "status" || status.RunID != "run-one" || status.Status != "RUNNING" { + t.Fatalf("status event = %+v", status) + } + + running := events[1] + if running.CallID != "call-1" || running.ToolName != "read_file" || running.Status != "running" { + t.Fatalf("running tool_call = %+v", running) + } + if string(running.ToolArgs) != `{"path":"README.md"}` { + t.Fatalf("running args = %s, want {\"path\":\"README.md\"}", running.ToolArgs) + } + if running.ToolResult != nil { + t.Fatalf("running result = %s, want nil (tool still running)", running.ToolResult) + } + if running.ArgsTruncated || running.ResultTruncated { + t.Fatalf("running truncation = args=%v result=%v, want both false", running.ArgsTruncated, running.ResultTruncated) + } + + completed := events[2] + if completed.CallID != "call-1" || completed.ToolName != "read_file" || completed.Status != "completed" { + t.Fatalf("completed tool_call = %+v", completed) + } + if string(completed.ToolArgs) != `{"path":"README.md"}` { + t.Fatalf("completed args = %s", completed.ToolArgs) + } + if string(completed.ToolResult) != `{"content":"# Project"}` { + t.Fatalf("completed result = %s", completed.ToolResult) + } + if !completed.ArgsTruncated || !completed.ResultTruncated { + t.Fatalf("completed truncation = args=%v result=%v, want both true", completed.ArgsTruncated, completed.ResultTruncated) + } + + resultEvt := events[3] + if resultEvt.Type != "result" || resultEvt.RunID != "run-one" { + t.Fatalf("result event = %+v", resultEvt) + } +} + func TestStreamRunIgnoresClientTimeoutDuringStream(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") diff --git a/internal/cursor/types.go b/internal/cursor/types.go index cf2c688..e876a7a 100644 --- a/internal/cursor/types.go +++ b/internal/cursor/types.go @@ -48,16 +48,39 @@ type ModelCatalog struct { } type StreamEvent struct { - ID string - Type string - Status string - Text string - ToolName string - Raw json.RawMessage + ID string + Type string + Status string + Text string + RunID string + Raw json.RawMessage + + // Tool call fields, populated only for Type == "tool_call". CallID + // identifies one tool invocation across its "running" and "completed" + // updates. ToolArgs/ToolResult are the raw JSON values Cursor sent; + // either is nil when Cursor omitted it (not yet available, or dropped + // because it exceeded the stream's inline size limit, in which case the + // matching *Truncated flag is set). + ToolName string + CallID string + ToolArgs json.RawMessage + ToolResult json.RawMessage + ArgsTruncated bool + ResultTruncated bool +} + +// PromptImage is an image attachment on a prompt. Exactly one of Data (a +// base64-encoded payload) or URL should be set, per the Cursor Cloud Agents +// API; MimeType is required alongside Data. +type PromptImage struct { + Data string `json:"data,omitempty"` + URL string `json:"url,omitempty"` + MimeType string `json:"mimeType,omitempty"` } type Prompt struct { - Text string `json:"text"` + Text string `json:"text"` + Images []PromptImage `json:"images,omitempty"` } type ModelSelection struct { @@ -65,6 +88,21 @@ type ModelSelection struct { Params []ModelParameterSelection `json:"params,omitempty"` } +func (m ModelSelection) MarshalJSON() ([]byte, error) { + if m.Params == nil { + return json.Marshal(struct { + ID string `json:"id"` + }{ID: m.ID}) + } + return json.Marshal(struct { + ID string `json:"id"` + Params []ModelParameterSelection `json:"params"` + }{ + ID: m.ID, + Params: m.Params, + }) +} + type Repository struct { URL string `json:"url"` StartingRef string `json:"startingRef,omitempty"` diff --git a/internal/cursorrun/catalog.go b/internal/cursorrun/catalog.go new file mode 100644 index 0000000..bfc1855 --- /dev/null +++ b/internal/cursorrun/catalog.go @@ -0,0 +1,385 @@ +package cursorrun + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "net/url" + "sort" + "strings" + "time" + + "github.com/enowdev/antares/internal/cursor" +) + +// Catalogue collection limits cap every nesting level before allocation. They +// comfortably exceed the current upstream catalogue while preventing a valid +// but adversarial response from being retained without bound. +const ( + maxCatalogModels = 256 + maxCatalogAliases = 64 + maxCatalogParameters = 64 + maxCatalogParameterValues = 128 + maxCatalogVariants = 256 + maxCatalogVariantParams = 64 +) + +type catalogCacheKey struct { + baseURL string + credential [sha256.Size]byte +} + +type catalogCacheEntry struct { + catalog *cursor.ModelCatalog + expiresAt time.Time +} + +type catalogFetchKey struct { + cacheKey catalogCacheKey + generation uint64 +} + +type catalogFetch struct { + done chan struct{} + catalog *cursor.ModelCatalog + err error +} + +var errCatalogFetchPanicked = errors.New("cursor: catalogue fetch failed") + +func (s *service) Catalog(ctx context.Context, force bool) (*cursor.ModelCatalog, error) { + catalog, _, err := s.catalog(ctx, force) + return catalog, err +} + +// catalog reports whether the returned value was served from the TTL cache. +// Validation uses that provenance to refresh a potentially stale selection +// once without issuing an immediate duplicate request after a cold live fetch. +func (s *service) catalog( + ctx context.Context, + force bool, +) (*cursor.ModelCatalog, bool, error) { + client, options, secret, err := s.clientWithOptions() + if err != nil { + return nil, false, err + } + cacheKey := catalogCacheKey{ + baseURL: normalizeBaseURL(options.BaseURL), + credential: sha256.Sum256([]byte(secret)), + } + now := s.now() + + s.catalogMu.Lock() + generation := s.catalogGeneration + if !force { + if cached, ok := s.catalogs[cacheKey]; ok && now.Before(cached.expiresAt) { + catalog := cloneCatalog(cached.catalog) + s.catalogMu.Unlock() + return catalog, true, nil + } + } + + fetchKey := catalogFetchKey{cacheKey: cacheKey, generation: generation} + if pending, ok := s.catalogFetches[fetchKey]; ok { + done := pending.done + s.catalogMu.Unlock() + select { + case <-ctx.Done(): + return nil, false, ctx.Err() + case <-done: + return cloneCatalog(pending.catalog), false, pending.err + } + } + + pending := &catalogFetch{done: make(chan struct{})} + s.catalogFetches[fetchKey] = pending + s.catalogMu.Unlock() + + catalog, err := s.fetchCatalog( + ctx, client, secret, cacheKey, fetchKey, generation, pending, + ) + return catalog, false, err +} + +func (s *service) fetchCatalog( + ctx context.Context, + client *cursor.Client, + secret string, + cacheKey catalogCacheKey, + fetchKey catalogFetchKey, + generation uint64, + pending *catalogFetch, +) (catalog *cursor.ModelCatalog, fetchErr error) { + var expiresAt time.Time + defer func() { + if panicValue := recover(); panicValue != nil { + s.completeCatalogFetch( + cacheKey, fetchKey, generation, pending, nil, errCatalogFetchPanicked, time.Time{}, + ) + panic(panicValue) + } + s.completeCatalogFetch( + cacheKey, fetchKey, generation, pending, catalog, fetchErr, expiresAt, + ) + catalog = cloneCatalog(catalog) + }() + + catalog, fetchErr = client.Models(ctx) + if fetchErr != nil { + fetchErr = sanitizeError(fetchErr, secret) + } else { + catalog = sanitizeCatalog(catalog, secret) + expiresAt = s.now().Add(s.catalogTTL) + } + return catalog, fetchErr +} + +func (s *service) completeCatalogFetch( + cacheKey catalogCacheKey, + fetchKey catalogFetchKey, + generation uint64, + pending *catalogFetch, + catalog *cursor.ModelCatalog, + fetchErr error, + expiresAt time.Time, +) { + s.catalogMu.Lock() + pending.catalog = catalog + pending.err = fetchErr + if fetchErr == nil && s.catalogGeneration == generation { + s.catalogs[cacheKey] = catalogCacheEntry{ + catalog: catalog, + expiresAt: expiresAt, + } + } + delete(s.catalogFetches, fetchKey) + close(pending.done) + s.catalogMu.Unlock() +} + +func (s *service) InvalidateCatalog() { + s.catalogMu.Lock() + s.catalogGeneration++ + s.catalogs = make(map[catalogCacheKey]catalogCacheEntry) + s.catalogMu.Unlock() +} + +func (s *service) ValidateModel( + ctx context.Context, + selection *cursor.ModelSelection, + policy SelectionPolicy, +) (*cursor.ModelSelection, error) { + if selection == nil { + return nil, nil + } + if policy != PreserveUpstreamDefault && policy != RequireExactVariant { + return nil, fmt.Errorf("cursor: unknown model selection policy") + } + modelID := strings.TrimSpace(selection.ID) + if modelID == "" { + return nil, fmt.Errorf("cursor: model id is required") + } + if _, err := canonicalParams(selection.Params); err != nil { + return nil, err + } + requested := &cursor.ModelSelection{ + ID: modelID, + Params: append([]cursor.ModelParameterSelection(nil), selection.Params...), + } + + catalog, fromCache, err := s.catalog(ctx, false) + if err != nil { + return nil, err + } + validated, validationErr := matchSelection(catalog, requested, policy) + if validationErr == nil { + return validated, nil + } + if !fromCache { + return nil, unavailableSelectionError(validationErr) + } + + refreshed, _, err := s.catalog(ctx, true) + if err != nil { + return nil, err + } + validated, validationErr = matchSelection(refreshed, requested, policy) + if validationErr == nil { + return validated, nil + } + return nil, unavailableSelectionError(validationErr) +} + +func unavailableSelectionError(validationErr error) error { + return fmt.Errorf( + "cursor model selection is no longer available; refresh and reselect: %w", + validationErr, + ) +} + +func matchSelection( + catalog *cursor.ModelCatalog, + selection *cursor.ModelSelection, + policy SelectionPolicy, +) (*cursor.ModelSelection, error) { + if catalog == nil { + return nil, fmt.Errorf("cursor: model catalogue is empty") + } + var model *cursor.Model + for i := range catalog.Items { + if catalog.Items[i].ID == selection.ID { + model = &catalog.Items[i] + break + } + } + if model == nil { + for i := range catalog.Items { + for _, alias := range catalog.Items[i].Aliases { + if alias != selection.ID { + continue + } + if model != nil { + return nil, fmt.Errorf("cursor: model alias is ambiguous") + } + model = &catalog.Items[i] + break + } + } + } + if model == nil { + return nil, fmt.Errorf("cursor: model was not found") + } + + if len(selection.Params) == 0 { + if len(model.Variants) == 0 || policy == PreserveUpstreamDefault { + return &cursor.ModelSelection{ID: model.ID}, nil + } + return nil, fmt.Errorf("cursor: model requires an exact variant") + } + if len(model.Variants) == 0 { + return nil, fmt.Errorf("cursor: model does not accept parameters") + } + + requested, err := canonicalParams(selection.Params) + if err != nil { + return nil, err + } + for _, variant := range model.Variants { + candidate, err := canonicalParams(variant.Params) + if err != nil { + return nil, fmt.Errorf("cursor: model has an invalid variant: %w", err) + } + if equalParams(requested, candidate) { + return &cursor.ModelSelection{ + ID: model.ID, + Params: append([]cursor.ModelParameterSelection(nil), variant.Params...), + }, nil + } + } + return nil, fmt.Errorf("cursor: parameters do not match a model variant") +} + +func canonicalParams( + params []cursor.ModelParameterSelection, +) ([]cursor.ModelParameterSelection, error) { + canonical := append([]cursor.ModelParameterSelection(nil), params...) + sort.Slice(canonical, func(i, j int) bool { + return canonical[i].ID < canonical[j].ID + }) + for i := 1; i < len(canonical); i++ { + if canonical[i-1].ID == canonical[i].ID { + return nil, fmt.Errorf("cursor: duplicate model parameter id") + } + } + return canonical, nil +} + +func equalParams(left, right []cursor.ModelParameterSelection) bool { + if len(left) != len(right) { + return false + } + for i := range left { + if left[i] != right[i] { + return false + } + } + return true +} + +func normalizeBaseURL(raw string) string { + baseURL := strings.TrimRight(strings.TrimSpace(raw), "/") + if baseURL == "" { + return "https://api.cursor.com" + } + parsed, err := url.Parse(baseURL) + if err != nil { + return baseURL + } + parsed.Scheme = strings.ToLower(parsed.Scheme) + parsed.Host = strings.ToLower(parsed.Host) + parsed.Path = strings.TrimRight(parsed.Path, "/") + parsed.RawPath = strings.TrimRight(parsed.RawPath, "/") + return parsed.String() +} + +func sanitizeCatalog( + catalog *cursor.ModelCatalog, + secret string, +) *cursor.ModelCatalog { + if catalog == nil { + return nil + } + modelCount := min(len(catalog.Items), maxCatalogModels) + safe := &cursor.ModelCatalog{Items: make([]cursor.Model, modelCount)} + for i, model := range catalog.Items[:modelCount] { + aliasCount := min(len(model.Aliases), maxCatalogAliases) + parameterCount := min(len(model.Parameters), maxCatalogParameters) + variantCount := min(len(model.Variants), maxCatalogVariants) + safe.Items[i] = cursor.Model{ + ID: sanitizeString(model.ID, secret, maxIdentifierRunes), + DisplayName: sanitizeString(model.DisplayName, secret, maxMetadataRunes), + Description: sanitizeString(model.Description, secret, maxMetadataRunes), + Aliases: make([]string, aliasCount), + Parameters: make([]cursor.ModelParameter, parameterCount), + Variants: make([]cursor.ModelVariant, variantCount), + } + for j, alias := range model.Aliases[:aliasCount] { + safe.Items[i].Aliases[j] = sanitizeString(alias, secret, maxMetadataRunes) + } + for j, parameter := range model.Parameters[:parameterCount] { + valueCount := min(len(parameter.Values), maxCatalogParameterValues) + safe.Items[i].Parameters[j] = cursor.ModelParameter{ + ID: sanitizeString(parameter.ID, secret, maxIdentifierRunes), + DisplayName: sanitizeString(parameter.DisplayName, secret, maxMetadataRunes), + Values: make([]cursor.ModelParameterValue, valueCount), + } + for k, value := range parameter.Values[:valueCount] { + safe.Items[i].Parameters[j].Values[k] = cursor.ModelParameterValue{ + Value: sanitizeString(value.Value, secret, maxIdentifierRunes), + DisplayName: sanitizeString(value.DisplayName, secret, maxMetadataRunes), + } + } + } + for j, variant := range model.Variants[:variantCount] { + paramCount := min(len(variant.Params), maxCatalogVariantParams) + safe.Items[i].Variants[j] = cursor.ModelVariant{ + Params: make([]cursor.ModelParameterSelection, paramCount), + DisplayName: sanitizeString(variant.DisplayName, secret, maxMetadataRunes), + Description: sanitizeString(variant.Description, secret, maxMetadataRunes), + IsDefault: variant.IsDefault, + } + for k, parameter := range variant.Params[:paramCount] { + safe.Items[i].Variants[j].Params[k] = cursor.ModelParameterSelection{ + ID: sanitizeString(parameter.ID, secret, maxIdentifierRunes), + Value: sanitizeString(parameter.Value, secret, maxIdentifierRunes), + } + } + } + } + return safe +} + +func cloneCatalog(catalog *cursor.ModelCatalog) *cursor.ModelCatalog { + return sanitizeCatalog(catalog, "") +} diff --git a/internal/cursorrun/repository.go b/internal/cursorrun/repository.go new file mode 100644 index 0000000..094628c --- /dev/null +++ b/internal/cursorrun/repository.go @@ -0,0 +1,332 @@ +package cursorrun + +import ( + "context" + "errors" + "fmt" + "net/url" + "os" + "os/exec" + "strconv" + "strings" + "unicode" +) + +// RepositoryInfo is the local-only repository preflight shown before a Cursor +// Cloud Agent run. Inspection never fetches or changes repository state. +type RepositoryInfo struct { + Repository bool `json:"repository"` + URL string `json:"url,omitempty"` + StartingRef string `json:"starting_ref,omitempty"` + Dirty bool `json:"dirty"` + LocalOnlyCommits int `json:"local_only_commits"` + RemoteRefKnown bool `json:"remote_ref_known"` + Warning string `json:"warning,omitempty"` +} + +var errInvalidGitHubRepository = errors.New( + "cursor repository must be an HTTPS or git SSH URL for exactly one github.com owner/repository", +) + +const unsupportedOriginWarning = "origin remote is not a supported credential-free GitHub repository" + +// NormalizeGitHubRepository accepts the common GitHub HTTPS and git SSH remote +// forms and returns the credential-free HTTPS repository URL Cursor expects. +func NormalizeGitHubRepository(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" || strings.IndexFunc(raw, unicode.IsControl) >= 0 { + return "", errInvalidGitHubRepository + } + + const scpPrefix = "git@github.com:" + if strings.HasPrefix(strings.ToLower(raw), scpPrefix) { + path := raw[len(scpPrefix):] + owner, repository, ok := splitGitHubRepositoryPath(path) + if !ok { + return "", errInvalidGitHubRepository + } + return "https://github.com/" + owner + "/" + repository, nil + } + + parsed, err := url.ParseRequestURI(raw) + if err != nil || parsed == nil || !parsed.IsAbs() || parsed.Opaque != "" || + parsed.RawQuery != "" || parsed.ForceQuery || parsed.Fragment != "" || + parsed.RawPath != "" { + return "", errInvalidGitHubRepository + } + if !strings.EqualFold(parsed.Hostname(), "github.com") || parsed.Port() != "" { + return "", errInvalidGitHubRepository + } + + switch { + case strings.EqualFold(parsed.Scheme, "https"): + if parsed.User != nil { + return "", errInvalidGitHubRepository + } + case strings.EqualFold(parsed.Scheme, "ssh"): + if parsed.User == nil || parsed.User.Username() != "git" { + return "", errInvalidGitHubRepository + } + if _, hasPassword := parsed.User.Password(); hasPassword { + return "", errInvalidGitHubRepository + } + default: + return "", errInvalidGitHubRepository + } + + path := strings.TrimPrefix(parsed.Path, "/") + owner, repository, ok := splitGitHubRepositoryPath(path) + if !ok { + return "", errInvalidGitHubRepository + } + return "https://github.com/" + owner + "/" + repository, nil +} + +func splitGitHubRepositoryPath(path string) (string, string, bool) { + if path == "" || strings.HasPrefix(path, "/") || strings.HasSuffix(path, "/") || + strings.ContainsAny(path, `\?#%`) { + return "", "", false + } + parts := strings.Split(path, "/") + if len(parts) != 2 { + return "", "", false + } + owner := parts[0] + repository := strings.TrimSuffix(parts[1], ".git") + if !validGitHubOwner(owner) || !validGitHubRepositoryName(repository) { + return "", "", false + } + return owner, repository, true +} + +func validGitHubOwner(value string) bool { + if value == "" || strings.HasPrefix(value, "-") || strings.HasSuffix(value, "-") { + return false + } + for _, r := range value { + if (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') && + (r < '0' || r > '9') && r != '-' { + return false + } + } + return true +} + +func validGitHubRepositoryName(value string) bool { + if value == "" || value == "." || value == ".." { + return false + } + for _, r := range value { + if (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') && + (r < '0' || r > '9') && r != '-' && r != '_' && r != '.' { + return false + } + } + return true +} + +// InspectRepository reads repository metadata exclusively through non-mutating +// Git commands. GIT_OPTIONAL_LOCKS=0 prevents status inspection from refreshing +// the index on disk, and no command contacts a remote. +func InspectRepository(ctx context.Context, dir string) (RepositoryInfo, error) { + var info RepositoryInfo + if ctx == nil { + ctx = context.Background() + } + + inside, found, err := optionalGitOutput(ctx, dir, + "rev-parse", "--is-inside-work-tree") + if err != nil { + return info, fmt.Errorf("inspect cursor repository: %w", err) + } + if !found || inside != "true" { + return info, nil + } + info.Repository = true + + branch, onBranch, err := optionalGitOutput(ctx, dir, + "symbolic-ref", "--quiet", "--short", "HEAD") + if err != nil { + return info, fmt.Errorf("inspect cursor repository ref: %w", err) + } + if onBranch && branch != "" { + info.StartingRef = branch + } else { + info.StartingRef, err = requiredGitOutput(ctx, dir, + "rev-parse", "--verify", "HEAD") + if err != nil { + return info, fmt.Errorf("inspect cursor repository detached ref: %w", err) + } + } + + status, err := requiredGitOutput(ctx, dir, + "status", "--porcelain=v1", "--untracked-files=normal") + if err != nil { + return info, fmt.Errorf("inspect cursor repository status: %w", err) + } + info.Dirty = status != "" + + origin, hasOrigin, err := optionalGitOutput(ctx, dir, + "config", "--get", "remote.origin.url") + if err != nil { + return info, fmt.Errorf("inspect cursor repository origin: %w", err) + } + originPresent := hasOrigin && origin != "" + unsupportedOrigin := false + if originPresent { + normalized, normalizeErr := NormalizeGitHubRepository(origin) + if normalizeErr != nil { + unsupportedOrigin = true + } else { + info.URL = normalized + } + } + + if originPresent { + if onBranch && branch != "" { + info.RemoteRefKnown, info.LocalOnlyCommits, err = + inspectBranchRemoteState(ctx, dir, branch) + } else { + info.RemoteRefKnown, info.LocalOnlyCommits, err = + inspectDetachedRemoteState(ctx, dir) + } + if err != nil { + return info, err + } + } + + var warnings []string + switch { + case !originPresent: + warnings = append(warnings, "origin remote is not configured") + case unsupportedOrigin: + warnings = append(warnings, unsupportedOriginWarning) + } + if originPresent && !info.RemoteRefKnown { + warnings = append(warnings, "origin remote-tracking ref is not available locally") + } + if info.Dirty { + warnings = append(warnings, "uncommitted changes are not available to Cursor") + } + if info.LocalOnlyCommits > 0 { + warnings = append(warnings, fmt.Sprintf( + "%d local commit(s) are not available to Cursor", info.LocalOnlyCommits, + )) + } + info.Warning = strings.Join(warnings, "; ") + return info, nil +} + +func inspectBranchRemoteState( + ctx context.Context, + dir string, + branch string, +) (bool, int, error) { + upstream, hasUpstream, err := optionalGitOutput(ctx, dir, + "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}") + if err != nil { + return false, 0, fmt.Errorf("inspect cursor repository upstream: %w", err) + } + + remoteRef := "refs/remotes/origin/" + branch + if hasUpstream && strings.HasPrefix(upstream, "origin/") { + remoteRef = "refs/remotes/" + upstream + } + _, known, err := optionalGitOutput(ctx, dir, + "rev-parse", "--verify", "--quiet", remoteRef+"^{commit}") + if err != nil { + return false, 0, fmt.Errorf("inspect cursor repository remote ref: %w", err) + } + if !known { + return false, 0, nil + } + count, err := countLocalOnlyCommits(ctx, dir, remoteRef+"..HEAD") + return true, count, err +} + +func inspectDetachedRemoteState( + ctx context.Context, + dir string, +) (bool, int, error) { + rawRefs, err := requiredGitOutput(ctx, dir, + "for-each-ref", "--format=%(refname)", "refs/remotes/origin") + if err != nil { + return false, 0, fmt.Errorf("inspect cursor repository remote refs: %w", err) + } + refs := strings.Fields(rawRefs) + if len(refs) == 0 { + return false, 0, nil + } + args := []string{"rev-list", "--count", "HEAD", "--not"} + args = append(args, refs...) + rawCount, err := requiredGitOutput(ctx, dir, args...) + if err != nil { + return false, 0, fmt.Errorf("inspect cursor repository local commits: %w", err) + } + count, err := parseCommitCount(rawCount) + if err != nil { + return false, 0, err + } + return true, count, nil +} + +func countLocalOnlyCommits( + ctx context.Context, + dir string, + revisionRange string, +) (int, error) { + rawCount, err := requiredGitOutput(ctx, dir, + "rev-list", "--count", revisionRange) + if err != nil { + return 0, fmt.Errorf("inspect cursor repository local commits: %w", err) + } + return parseCommitCount(rawCount) +} + +func parseCommitCount(raw string) (int, error) { + count, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil || count < 0 { + return 0, errors.New("inspect cursor repository: invalid local commit count") + } + return count, nil +} + +func requiredGitOutput( + ctx context.Context, + dir string, + args ...string, +) (string, error) { + out, found, err := optionalGitOutput(ctx, dir, args...) + if err != nil { + return "", err + } + if !found { + return "", errors.New("git command failed") + } + return out, nil +} + +func optionalGitOutput( + ctx context.Context, + dir string, + args ...string, +) (string, bool, error) { + cmd := exec.CommandContext(ctx, "git", append([]string{"-C", dir}, args...)...) + cmd.Env = append(os.Environ(), + "GIT_OPTIONAL_LOCKS=0", + "GIT_TERMINAL_PROMPT=0", + "LC_ALL=C", + ) + out, err := cmd.Output() + if err == nil { + return strings.TrimSpace(string(out)), true, nil + } + if ctxErr := ctx.Err(); ctxErr != nil { + return "", false, ctxErr + } + var exitError *exec.ExitError + if errors.As(err, &exitError) { + return "", false, nil + } + return "", false, err +} diff --git a/internal/cursorrun/repository_test.go b/internal/cursorrun/repository_test.go new file mode 100644 index 0000000..71b0238 --- /dev/null +++ b/internal/cursorrun/repository_test.go @@ -0,0 +1,341 @@ +package cursorrun + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "testing" + "time" +) + +func TestNormalizeGitHubRepository(t *testing.T) { + tests := map[string]string{ + "git@github.com:owner/repo.git": "https://github.com/owner/repo", + "ssh://git@github.com/owner/repo.git": "https://github.com/owner/repo", + "https://github.com/owner/repo.git": "https://github.com/owner/repo", + "https://github.com/owner/repo": "https://github.com/owner/repo", + "https://GITHUB.COM/Owner/Repo.git": "https://github.com/Owner/Repo", + } + for in, want := range tests { + got, err := NormalizeGitHubRepository(in) + if err != nil || got != want { + t.Errorf("%q => %q, %v; want %q", in, got, err, want) + } + } +} + +func TestNormalizeGitHubRepositoryRejectsUnsafeRemotes(t *testing.T) { + tests := []string{ + "", + "/tmp/repo", + "./repo", + "file:///tmp/repo", + "github.com/owner/repo", + "http://github.com/owner/repo", + "https://gitlab.com/owner/repo", + "git@gitlab.com:owner/repo.git", + "https://github.com/owner", + "https://github.com/owner/repo/extra", + "https://github.com/owner/repo/", + "https://github.com/owner%2Frepo", + "https://github.com/owner/../repo", + "https://user:secret@github.com/owner/repo", + "ssh://token@github.com/owner/repo.git", + "ssh://git:secret@github.com/owner/repo.git", + "ssh://git@github.com:22/owner/repo.git", + "https://github.com/owner/repo?token=secret", + "https://github.com/owner/repo#secret", + "git@github.com:owner/repo.git?token=secret", + } + for _, in := range tests { + t.Run(in, func(t *testing.T) { + if got, err := NormalizeGitHubRepository(in); err == nil { + t.Fatalf("%q => %q, want rejection", in, got) + } + }) + } +} + +func TestInspectRepositoryReportsLinkedWorktreeStateWithoutMutation(t *testing.T) { + requireGit(t) + root := t.TempDir() + bare := filepath.Join(root, "remote.git") + main := filepath.Join(root, "main") + linked := filepath.Join(root, "linked") + + runCommand(t, root, "git", "init", "--bare", "--initial-branch=main", bare) + runCommand(t, root, "git", "init", "--initial-branch=main", main) + configureTestGit(t, main) + writeTestFile(t, filepath.Join(main, "tracked.txt"), "main\n") + runGit(t, main, "add", "tracked.txt") + runGit(t, main, "commit", "-m", "main") + runGit(t, main, "remote", "add", "origin", bare) + runGit(t, main, "push", "-u", "origin", "main") + + runGit(t, main, "worktree", "add", "-b", "feature", linked) + writeTestFile(t, filepath.Join(linked, "tracked.txt"), "feature\n") + runGit(t, linked, "add", "tracked.txt") + runGit(t, linked, "commit", "-m", "remote feature") + runGit(t, linked, "push", "-u", "origin", "feature") + runGit(t, main, "remote", "set-url", "origin", "git@github.com:owner/repo.git") + + writeTestFile(t, filepath.Join(linked, "tracked.txt"), "local-only\n") + runGit(t, linked, "add", "tracked.txt") + runGit(t, linked, "commit", "-m", "local only") + writeTestFile(t, filepath.Join(linked, "dirty.txt"), "not committed\n") + + gitFile, err := os.Stat(filepath.Join(linked, ".git")) + if err != nil || !gitFile.Mode().IsRegular() { + t.Fatalf("linked worktree .git is not a file: info=%v err=%v", gitFile, err) + } + before := repositorySnapshot(t, linked) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + info, err := InspectRepository(ctx, linked) + if err != nil { + t.Fatal(err) + } + if !info.Repository { + t.Fatal("linked worktree was not detected as a repository") + } + if info.URL != "https://github.com/owner/repo" { + t.Fatalf("url=%q", info.URL) + } + if info.StartingRef != "feature" { + t.Fatalf("starting_ref=%q, want feature", info.StartingRef) + } + if !info.Dirty { + t.Fatal("dirty=false, want true") + } + if !info.RemoteRefKnown { + t.Fatal("remote_ref_known=false despite refs/remotes/origin/feature") + } + if info.LocalOnlyCommits != 1 { + t.Fatalf("local_only_commits=%d, want 1", info.LocalOnlyCommits) + } + if info.Warning == "" { + t.Fatal("warning is empty for dirty, locally-ahead worktree") + } + if after := repositorySnapshot(t, linked); !reflect.DeepEqual(after, before) { + t.Fatalf("inspection mutated repository state:\nbefore=%q\nafter=%q", before, after) + } +} + +func TestInspectRepositorySupportsDetachedHEAD(t *testing.T) { + requireGit(t) + root := t.TempDir() + bare := filepath.Join(root, "remote.git") + repo := filepath.Join(root, "repo") + + runCommand(t, root, "git", "init", "--bare", "--initial-branch=main", bare) + runCommand(t, root, "git", "init", "--initial-branch=main", repo) + configureTestGit(t, repo) + writeTestFile(t, filepath.Join(repo, "tracked.txt"), "main\n") + runGit(t, repo, "add", "tracked.txt") + runGit(t, repo, "commit", "-m", "main") + runGit(t, repo, "remote", "add", "origin", bare) + runGit(t, repo, "push", "-u", "origin", "main") + wantSHA := runGit(t, repo, "rev-parse", "HEAD") + runGit(t, repo, "switch", "--detach", wantSHA) + runGit(t, repo, "remote", "set-url", "origin", "ssh://git@github.com/owner/repo.git") + + info, err := InspectRepository(context.Background(), repo) + if err != nil { + t.Fatal(err) + } + if !info.Repository || info.URL != "https://github.com/owner/repo" { + t.Fatalf("repository identity = %+v", info) + } + if info.StartingRef != wantSHA { + t.Fatalf("starting_ref=%q, want detached SHA %q", info.StartingRef, wantSHA) + } + if info.Dirty || info.LocalOnlyCommits != 0 || !info.RemoteRefKnown { + t.Fatalf("unexpected detached state: %+v", info) + } +} + +func TestInspectRepositoryReturnsNonRepositoryWithoutError(t *testing.T) { + requireGit(t) + info, err := InspectRepository(context.Background(), t.TempDir()) + if err != nil { + t.Fatal(err) + } + if info.Repository { + t.Fatalf("plain directory reported as repository: %+v", info) + } +} + +func TestInspectRepositoryDegradesUnsupportedOriginAndPreservesLocalState(t *testing.T) { + requireGit(t) + tests := []struct { + name string + remote string + forbidden []string + }{ + { + name: "non-GitHub", + remote: "https://gitlab.com/owner/repo.git", + forbidden: []string{"gitlab.com"}, + }, + { + name: "self-hosted", + remote: "ssh://git@git.example.test/owner/repo.git", + forbidden: []string{"git.example.test"}, + }, + { + name: "local", + remote: "/tmp/task8-local-origin.git", + forbidden: []string{"task8-local-origin"}, + }, + { + name: "credential-bearing", + remote: "https://user:review-secret@github.com/owner/repo.git", + forbidden: []string{"user", "review-secret"}, + }, + { + name: "malformed", + remote: "::not a valid remote::", + forbidden: []string{"not a valid remote"}, + }, + } + var fixedWarning string + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + repo := initTestRepository(t) + bare := filepath.Join(t.TempDir(), "remote.git") + runCommand(t, filepath.Dir(bare), "git", "init", "--bare", "--initial-branch=main", bare) + runGit(t, repo, "remote", "add", "origin", bare) + runGit(t, repo, "push", "-u", "origin", "main") + runGit(t, repo, "remote", "set-url", "origin", tc.remote) + + writeTestFile(t, filepath.Join(repo, "tracked.txt"), "local-only\n") + runGit(t, repo, "add", "tracked.txt") + runGit(t, repo, "commit", "-m", "local only") + writeTestFile(t, filepath.Join(repo, "dirty.txt"), "dirty\n") + + if normalized, err := NormalizeGitHubRepository(tc.remote); err == nil { + t.Fatalf("strict normalization accepted %q as %q", tc.remote, normalized) + } + info, err := InspectRepository(context.Background(), repo) + if err != nil { + t.Fatalf("degraded preflight returned an error: %v", err) + } + if !info.Repository || info.StartingRef != "main" || !info.Dirty { + t.Fatalf("local repository state was lost: %+v", info) + } + if info.URL != "" { + t.Fatalf("unsupported origin became a repository URL: %q", info.URL) + } + if !info.RemoteRefKnown || info.LocalOnlyCommits != 1 { + t.Fatalf("locally known ahead state was lost: %+v", info) + } + if info.Warning == "" || len(info.Warning) > 512 { + t.Fatalf("warning is empty or unbounded: %q", info.Warning) + } + if strings.Contains(info.Warning, tc.remote) { + t.Fatalf("warning echoed remote %q: %q", tc.remote, info.Warning) + } + for _, forbidden := range tc.forbidden { + if strings.Contains(info.Warning, forbidden) { + t.Fatalf("warning leaked %q: %q", forbidden, info.Warning) + } + } + if fixedWarning == "" { + fixedWarning = info.Warning + } else if info.Warning != fixedWarning { + t.Fatalf("warning depends on unsupported remote:\ngot %q\nwant %q", + info.Warning, fixedWarning) + } + }) + } +} + +func TestInspectRepositoryWarnsWhenOriginTrackingRefIsUnknown(t *testing.T) { + requireGit(t) + repo := initTestRepository(t) + runGit(t, repo, "remote", "add", "origin", "https://github.com/owner/repo.git") + + info, err := InspectRepository(context.Background(), repo) + if err != nil { + t.Fatal(err) + } + if !info.Repository || info.StartingRef != "main" { + t.Fatalf("unexpected repository state: %+v", info) + } + if info.RemoteRefKnown || info.LocalOnlyCommits != 0 || info.Warning == "" { + t.Fatalf("unknown remote ref not reported safely: %+v", info) + } +} + +type repoSnapshot struct { + Head string + Status string + Refs string + Config string +} + +func repositorySnapshot(t *testing.T, dir string) repoSnapshot { + t.Helper() + return repoSnapshot{ + Head: runGit(t, dir, "rev-parse", "HEAD"), + Status: runGit(t, dir, "status", "--porcelain=v1", "--untracked-files=all"), + Refs: runGit(t, dir, "for-each-ref", "--format=%(refname) %(objectname)"), + Config: runGit(t, dir, "config", "--local", "--list"), + } +} + +func initTestRepository(t *testing.T) string { + t.Helper() + repo := filepath.Join(t.TempDir(), "repo") + runCommand(t, filepath.Dir(repo), "git", "init", "--initial-branch=main", repo) + configureTestGit(t, repo) + writeTestFile(t, filepath.Join(repo, "tracked.txt"), "initial\n") + runGit(t, repo, "add", "tracked.txt") + runGit(t, repo, "commit", "-m", "initial") + return repo +} + +func configureTestGit(t *testing.T, dir string) { + t.Helper() + runGit(t, dir, "config", "user.name", "Task 8 Test") + runGit(t, dir, "config", "user.email", "task8@example.invalid") + runGit(t, dir, "config", "commit.gpgsign", "false") +} + +func writeTestFile(t *testing.T, path, contents string) { + t.Helper() + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + t.Fatal(err) + } +} + +func requireGit(t *testing.T) { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git executable is required") + } +} + +func runGit(t *testing.T, dir string, args ...string) string { + t.Helper() + return runCommand(t, dir, "git", append([]string{"-C", dir}, args...)...) +} + +func runCommand(t *testing.T, dir, name string, args ...string) string { + t.Helper() + cmd := exec.Command(name, args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "GIT_CONFIG_NOSYSTEM=1", + "GIT_TERMINAL_PROMPT=0", + ) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("%s %s: %v\n%s", name, strings.Join(args, " "), err, out) + } + return strings.TrimSpace(string(out)) +} diff --git a/internal/cursorrun/service.go b/internal/cursorrun/service.go new file mode 100644 index 0000000..a67b362 --- /dev/null +++ b/internal/cursorrun/service.go @@ -0,0 +1,387 @@ +// Package cursorrun provides the shared Cursor catalogue and remote-run +// lifecycle used by both HTTP and tool adapters. +package cursorrun + +import ( + "context" + "encoding/json" + "errors" + "strings" + "sync" + "time" + "unicode/utf8" + + "github.com/enowdev/antares/internal/cursor" +) + +// Upstream payload limits are measured in Unicode runes so truncation never +// splits UTF-8. They are intentionally generous for normal Cursor responses +// while making every cached or returned value finite. +const ( + maxIdentifierRunes = 1024 + maxMetadataRunes = 16 * 1024 + maxContentRunes = 1 << 20 + maxStreamRawRunes = 1 << 20 + maxGenericErrorRunes = 4096 + maxProgressRunes = 2000 + maxAgentRepositories = 64 + maxGitBranches = 256 +) + +type SelectionPolicy uint8 + +// ErrNotConfigured reports that Cursor is disabled or has no resolved API key. +var ErrNotConfigured = errors.New("connect Cursor in Providers or set CURSOR_API_KEY") + +const ( + PreserveUpstreamDefault SelectionPolicy = iota + RequireExactVariant +) + +type Runner interface { + Catalog(ctx context.Context, force bool) (*cursor.ModelCatalog, error) + InvalidateCatalog() + ValidateModel(ctx context.Context, selection *cursor.ModelSelection, policy SelectionPolicy) (*cursor.ModelSelection, error) + CreateAgent(ctx context.Context, req cursor.CreateAgentRequest) (*cursor.CreateAgentResponse, error) + CreateRun(ctx context.Context, agentID string, req cursor.CreateRunRequest) (*cursor.Run, error) + GetAgent(ctx context.Context, agentID string) (*cursor.Agent, error) + GetRun(ctx context.Context, agentID, runID string) (*cursor.Run, error) + CancelRun(ctx context.Context, agentID, runID string) error + StreamRun(ctx context.Context, agentID, runID, lastEventID string, onReset func() error, emit func(cursor.StreamEvent) error) (*cursor.Run, error) + Progress(cursor.StreamEvent) Progress +} + +type ClientResolver func() (cursor.Options, error) + +type Options struct { + ResolveClient ClientResolver + Now func() time.Time + CatalogTTL time.Duration +} + +type Progress struct { + Message string + Chunk string +} + +type service struct { + resolveClient ClientResolver + now func() time.Time + catalogTTL time.Duration + + catalogMu sync.Mutex + catalogGeneration uint64 + catalogs map[catalogCacheKey]catalogCacheEntry + catalogFetches map[catalogFetchKey]*catalogFetch +} + +func New(options Options) Runner { + now := options.Now + if now == nil { + now = time.Now + } + ttl := options.CatalogTTL + if ttl <= 0 { + ttl = 5 * time.Minute + } + return &service{ + resolveClient: options.ResolveClient, + now: now, + catalogTTL: ttl, + catalogs: make(map[catalogCacheKey]catalogCacheEntry), + catalogFetches: make(map[catalogFetchKey]*catalogFetch), + } +} + +func (s *service) client() (*cursor.Client, string, error) { + client, _, secret, err := s.clientWithOptions() + return client, secret, err +} + +func (s *service) clientWithOptions() (*cursor.Client, cursor.Options, string, error) { + if s.resolveClient == nil { + return nil, cursor.Options{}, "", errors.New("cursor: client resolver is required") + } + options, err := s.resolveClient() + secret := strings.TrimSpace(options.APIKey) + if err != nil { + return nil, options, secret, sanitizeError(err, secret) + } + client, err := cursor.New(options) + if err != nil { + return nil, options, secret, sanitizeError(err, secret) + } + return client, options, secret, nil +} + +func (s *service) CreateAgent( + ctx context.Context, + req cursor.CreateAgentRequest, +) (*cursor.CreateAgentResponse, error) { + client, secret, err := s.client() + if err != nil { + return nil, err + } + created, err := client.CreateAgent(ctx, req) + if err != nil { + return nil, sanitizeError(err, secret) + } + return sanitizeCreateAgentResponse(created, secret), nil +} + +func (s *service) CreateRun( + ctx context.Context, + agentID string, + req cursor.CreateRunRequest, +) (*cursor.Run, error) { + client, secret, err := s.client() + if err != nil { + return nil, err + } + run, err := client.CreateRun(ctx, agentID, req) + if err != nil { + return nil, sanitizeError(err, secret) + } + return sanitizeRun(run, secret), nil +} + +func (s *service) GetAgent(ctx context.Context, agentID string) (*cursor.Agent, error) { + client, secret, err := s.client() + if err != nil { + return nil, err + } + agent, err := client.GetAgent(ctx, agentID) + if err != nil { + return nil, sanitizeError(err, secret) + } + return sanitizeAgent(agent, secret), nil +} + +func (s *service) GetRun(ctx context.Context, agentID, runID string) (*cursor.Run, error) { + client, secret, err := s.client() + if err != nil { + return nil, err + } + run, err := client.GetRun(ctx, agentID, runID) + if err != nil { + return nil, sanitizeError(err, secret) + } + return sanitizeRun(run, secret), nil +} + +func (s *service) CancelRun(ctx context.Context, agentID, runID string) error { + client, secret, err := s.client() + if err != nil { + return err + } + return sanitizeError(client.CancelRun(ctx, agentID, runID), secret) +} + +func (s *service) StreamRun( + ctx context.Context, + agentID, runID, lastEventID string, + onReset func() error, + emit func(cursor.StreamEvent) error, +) (*cursor.Run, error) { + client, secret, err := s.client() + if err != nil { + return nil, err + } + if emit == nil { + emit = func(cursor.StreamEvent) error { return nil } + } + run, err := client.StreamRunWithOptions( + ctx, + agentID, + runID, + cursor.StreamOptions{LastEventID: lastEventID, OnReset: onReset}, + func(event cursor.StreamEvent) error { + return emit(sanitizeStreamEvent(event, secret)) + }, + ) + if err != nil { + return nil, sanitizeError(err, secret) + } + return sanitizeRun(run, secret), nil +} + +func (s *service) Progress(event cursor.StreamEvent) Progress { + secret := "" + if s.resolveClient != nil { + options, _ := s.resolveClient() + secret = strings.TrimSpace(options.APIKey) + } + message := "Cursor " + sanitizeString(event.Type, secret, maxProgressRunes) + if event.ToolName != "" { + message = "Cursor tool " + + sanitizeString(event.ToolName, secret, maxProgressRunes) + " " + + sanitizeString(event.Status, secret, maxProgressRunes) + } + return Progress{ + Message: boundProgress(message), + Chunk: boundProgress(redact(event.Text, secret)), + } +} + +func boundProgress(value string) string { + value = strings.ToValidUTF8(value, "\uFFFD") + if utf8.RuneCountInString(value) <= maxProgressRunes { + return value + } + runes := []rune(value) + return string(runes[:maxProgressRunes]) + "…" +} + +func sanitizeCreateAgentResponse( + response *cursor.CreateAgentResponse, + secret string, +) *cursor.CreateAgentResponse { + if response == nil { + return nil + } + safe := *response + safe.Agent = *sanitizeAgent(&response.Agent, secret) + safe.Run = *sanitizeRun(&response.Run, secret) + return &safe +} + +func sanitizeAgent(agent *cursor.Agent, secret string) *cursor.Agent { + if agent == nil { + return nil + } + safe := *agent + safe.ID = sanitizeString(agent.ID, secret, maxIdentifierRunes) + safe.Name = sanitizeString(agent.Name, secret, maxMetadataRunes) + safe.Status = sanitizeString(agent.Status, secret, maxMetadataRunes) + safe.URL = sanitizeString(agent.URL, secret, maxMetadataRunes) + safe.LatestRunID = sanitizeString(agent.LatestRunID, secret, maxIdentifierRunes) + safe.Git = sanitizeGit(agent.Git, secret) + repositoryCount := min(len(agent.Repos), maxAgentRepositories) + safe.Repos = make([]cursor.Repository, repositoryCount) + for i, repo := range agent.Repos[:repositoryCount] { + safe.Repos[i] = cursor.Repository{ + URL: sanitizeString(repo.URL, secret, maxMetadataRunes), + StartingRef: sanitizeString(repo.StartingRef, secret, maxMetadataRunes), + PRURL: sanitizeString(repo.PRURL, secret, maxMetadataRunes), + } + } + return &safe +} + +func sanitizeRun(run *cursor.Run, secret string) *cursor.Run { + if run == nil { + return nil + } + safe := *run + safe.ID = sanitizeString(run.ID, secret, maxIdentifierRunes) + safe.AgentID = sanitizeString(run.AgentID, secret, maxIdentifierRunes) + safe.Status = sanitizeString(run.Status, secret, maxMetadataRunes) + safe.CreatedAt = sanitizeString(run.CreatedAt, secret, maxMetadataRunes) + safe.UpdatedAt = sanitizeString(run.UpdatedAt, secret, maxMetadataRunes) + safe.Result = sanitizeString(run.Result, secret, maxContentRunes) + safe.Git = sanitizeGit(run.Git, secret) + return &safe +} + +func sanitizeGit(git *cursor.GitState, secret string) *cursor.GitState { + if git == nil { + return nil + } + branchCount := min(len(git.Branches), maxGitBranches) + safe := &cursor.GitState{Branches: make([]cursor.GitBranch, branchCount)} + for i, branch := range git.Branches[:branchCount] { + safe.Branches[i] = cursor.GitBranch{ + RepoURL: sanitizeString(branch.RepoURL, secret, maxMetadataRunes), + Branch: sanitizeString(branch.Branch, secret, maxMetadataRunes), + PRURL: sanitizeString(branch.PRURL, secret, maxMetadataRunes), + } + } + return safe +} + +func sanitizeStreamEvent(event cursor.StreamEvent, secret string) cursor.StreamEvent { + event.ID = sanitizeString(event.ID, secret, maxIdentifierRunes) + event.Type = sanitizeString(event.Type, secret, maxMetadataRunes) + event.Status = sanitizeString(event.Status, secret, maxMetadataRunes) + event.Text = sanitizeString(event.Text, secret, maxContentRunes) + event.RunID = sanitizeString(event.RunID, secret, maxIdentifierRunes) + event.Raw, _ = sanitizeRaw(event.Raw, secret) + event.ToolName = sanitizeString(event.ToolName, secret, maxMetadataRunes) + event.CallID = sanitizeString(event.CallID, secret, maxIdentifierRunes) + var argsTruncated, resultTruncated bool + event.ToolArgs, argsTruncated = sanitizeRaw(event.ToolArgs, secret) + event.ToolResult, resultTruncated = sanitizeRaw(event.ToolResult, secret) + event.ArgsTruncated = event.ArgsTruncated || argsTruncated + event.ResultTruncated = event.ResultTruncated || resultTruncated + return event +} + +func sanitizeRaw(value json.RawMessage, secret string) (json.RawMessage, bool) { + if value == nil { + return nil, false + } + safe := redact(string(value), secret) + if secret != "" { + quoted, err := json.Marshal(secret) + if err == nil && len(quoted) >= 2 { + escaped := string(quoted[1 : len(quoted)-1]) + safe = strings.ReplaceAll(safe, escaped, "[REDACTED]") + } + } + if utf8.RuneCountInString(safe) > maxStreamRawRunes { + return json.RawMessage(`{"truncated":true}`), true + } + return json.RawMessage(safe), false +} + +func sanitizeError(err error, secret string) error { + if err == nil { + return nil + } + var apiErr *cursor.APIError + if errors.As(err, &apiErr) { + safe := *apiErr + safe.Code = sanitizeString(apiErr.Code, secret, 120) + safe.Message = sanitizeString(apiErr.Message, secret, 240) + return &safe + } + safe := sanitizeString(err.Error(), secret, maxGenericErrorRunes) + if safe == err.Error() { + return err + } + switch { + case errors.Is(err, context.Canceled): + return &sanitizedWrappedError{message: safe, cause: context.Canceled} + case errors.Is(err, context.DeadlineExceeded): + return &sanitizedWrappedError{message: safe, cause: context.DeadlineExceeded} + } + return errors.New(safe) +} + +type sanitizedWrappedError struct { + message string + cause error +} + +func (e *sanitizedWrappedError) Error() string { return e.message } +func (e *sanitizedWrappedError) Unwrap() error { return e.cause } + +func sanitizeString(value, secret string, maxRunes int) string { + return truncate(redact(value, secret), maxRunes) +} + +func redact(value, secret string) string { + value = strings.ToValidUTF8(value, "\uFFFD") + if secret == "" { + return value + } + return strings.ReplaceAll(value, secret, "[REDACTED]") +} + +func truncate(value string, maxRunes int) string { + if utf8.RuneCountInString(value) <= maxRunes { + return value + } + runes := []rune(value) + return string(runes[:maxRunes]) +} diff --git a/internal/cursorrun/service_test.go b/internal/cursorrun/service_test.go new file mode 100644 index 0000000..dfff28c --- /dev/null +++ b/internal/cursorrun/service_test.go @@ -0,0 +1,1166 @@ +package cursorrun + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/enowdev/antares/internal/cursor" +) + +func TestValidateModelAcceptsHiddenVariantParams(t *testing.T) { + model := cursor.Model{ + ID: "claude-opus-5", + Parameters: []cursor.ModelParameter{{ID: "effort"}}, + Variants: []cursor.ModelVariant{{ + Params: []cursor.ModelParameterSelection{ + {ID: "cyber", Value: "false"}, + {ID: "effort", Value: "max"}, + }, + IsDefault: true, + }}, + } + runner := newTestRunner(t, cursor.ModelCatalog{Items: []cursor.Model{model}}) + got, err := runner.ValidateModel(context.Background(), &cursor.ModelSelection{ + ID: model.ID, + Params: []cursor.ModelParameterSelection{ + {ID: "effort", Value: "max"}, + {ID: "cyber", Value: "false"}, + }, + }, RequireExactVariant) + if err != nil || !reflect.DeepEqual(got.Params, model.Variants[0].Params) { + t.Fatalf("got=%+v err=%v", got, err) + } +} + +func TestValidateModelAcceptsUniqueAlias(t *testing.T) { + runner := newTestRunner(t, cursor.ModelCatalog{Items: []cursor.Model{{ + ID: "composer-2", + Aliases: []string{"composer"}, + }}}) + + got, err := runner.ValidateModel(context.Background(), + &cursor.ModelSelection{ID: "composer"}, PreserveUpstreamDefault) + if err != nil { + t.Fatal(err) + } + if got.ID != "composer-2" || got.Params != nil { + t.Fatalf("alias selection = %+v, want canonical model authorization", got) + } +} + +func TestValidateModelPrefersExactIDOverAlias(t *testing.T) { + runner := newTestRunner(t, cursor.ModelCatalog{Items: []cursor.Model{ + {ID: "composer", Aliases: []string{"legacy-composer"}}, + {ID: "composer-2", Aliases: []string{"composer"}}, + }}) + + got, err := runner.ValidateModel(context.Background(), + &cursor.ModelSelection{ID: "composer"}, PreserveUpstreamDefault) + if err != nil { + t.Fatal(err) + } + if got.ID != "composer" { + t.Fatalf("exact ID resolved to %q, want composer", got.ID) + } +} + +func TestValidateModelRejectsAmbiguousAliasDeterministically(t *testing.T) { + first := cursor.Model{ID: "model-a", Aliases: []string{"shared"}} + second := cursor.Model{ID: "model-b", Aliases: []string{"shared"}} + var errorsByOrder []string + for _, items := range [][]cursor.Model{{first, second}, {second, first}} { + runner := newTestRunner(t, cursor.ModelCatalog{Items: items}) + _, err := runner.ValidateModel(context.Background(), + &cursor.ModelSelection{ID: "shared"}, PreserveUpstreamDefault) + if err == nil || !strings.Contains(err.Error(), "model alias is ambiguous") { + t.Fatalf("ambiguous alias error = %v", err) + } + errorsByOrder = append(errorsByOrder, err.Error()) + } + if errorsByOrder[0] != errorsByOrder[1] { + t.Fatalf("ambiguous alias errors depend on catalogue order: %q != %q", + errorsByOrder[0], errorsByOrder[1]) + } +} + +func TestCatalogCachesForFiveMinutes(t *testing.T) { + var nowMu sync.Mutex + now := time.Unix(1_700_000_000, 0) + clock := func() time.Time { + nowMu.Lock() + defer nowMu.Unlock() + return now + } + advance := func(d time.Duration) { + nowMu.Lock() + now = now.Add(d) + nowMu.Unlock() + } + + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + writeCatalog(t, w, cursor.ModelCatalog{Items: []cursor.Model{{ID: "composer-2"}}}) + })) + t.Cleanup(srv.Close) + runner := New(Options{ + ResolveClient: func() (cursor.Options, error) { + return cursor.Options{BaseURL: " " + srv.URL + "/ ", APIKey: "synthetic-key", HTTPClient: srv.Client()}, nil + }, + Now: clock, CatalogTTL: 5 * time.Minute, + }) + + if _, err := runner.Catalog(context.Background(), false); err != nil { + t.Fatal(err) + } + advance(5*time.Minute - time.Nanosecond) + if _, err := runner.Catalog(context.Background(), false); err != nil { + t.Fatal(err) + } + if got := calls.Load(); got != 1 { + t.Fatalf("catalog requests before expiry = %d, want 1", got) + } + + advance(time.Nanosecond) + if _, err := runner.Catalog(context.Background(), false); err != nil { + t.Fatal(err) + } + if got := calls.Load(); got != 2 { + t.Fatalf("catalog requests at expiry = %d, want 2", got) + } +} + +func TestCatalogCacheIsolatesCredentialFingerprints(t *testing.T) { + var calls atomic.Int32 + var keysMu sync.Mutex + var keys []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + keysMu.Lock() + keys = append(keys, strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) + keysMu.Unlock() + writeCatalog(t, w, cursor.ModelCatalog{Items: []cursor.Model{{ID: "composer-2"}}}) + })) + t.Cleanup(srv.Close) + + key := "credential-one" + baseURL := srv.URL + "/" + runner := New(Options{ + ResolveClient: func() (cursor.Options, error) { + return cursor.Options{BaseURL: baseURL, APIKey: key, HTTPClient: srv.Client()}, nil + }, + Now: time.Now, CatalogTTL: 5 * time.Minute, + }) + + if _, err := runner.Catalog(context.Background(), false); err != nil { + t.Fatal(err) + } + baseURL = " " + strings.TrimSuffix(srv.URL, "/") + " " + if _, err := runner.Catalog(context.Background(), false); err != nil { + t.Fatal(err) + } + if got := calls.Load(); got != 1 { + t.Fatalf("normalized base URL split the cache: requests=%d", got) + } + + key = "credential-two" + if _, err := runner.Catalog(context.Background(), false); err != nil { + t.Fatal(err) + } + if got := calls.Load(); got != 2 { + t.Fatalf("changed credential reused old catalogue: requests=%d", got) + } + keysMu.Lock() + defer keysMu.Unlock() + if !reflect.DeepEqual(keys, []string{"credential-one", "credential-two"}) { + t.Fatalf("authorization keys = %q", keys) + } +} + +func TestInvalidateCatalogForcesNextFetch(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + writeCatalog(t, w, cursor.ModelCatalog{}) + })) + t.Cleanup(srv.Close) + runner := testRunnerForServer(srv) + + if _, err := runner.Catalog(context.Background(), false); err != nil { + t.Fatal(err) + } + runner.InvalidateCatalog() + if _, err := runner.Catalog(context.Background(), false); err != nil { + t.Fatal(err) + } + if got := calls.Load(); got != 2 { + t.Fatalf("catalog requests = %d, want 2", got) + } +} + +func TestValidateModelRejectsDuplicateParamIDs(t *testing.T) { + runner := newTestRunner(t, cursor.ModelCatalog{Items: []cursor.Model{{ + ID: "composer-2", + Variants: []cursor.ModelVariant{{Params: []cursor.ModelParameterSelection{ + {ID: "effort", Value: "high"}, + }}}, + }}}) + _, err := runner.ValidateModel(context.Background(), &cursor.ModelSelection{ + ID: "composer-2", + Params: []cursor.ModelParameterSelection{ + {ID: "effort", Value: "high"}, + {ID: "effort", Value: "low"}, + }, + }, RequireExactVariant) + if err == nil || !strings.Contains(err.Error(), "duplicate") { + t.Fatalf("err=%v, want duplicate-param rejection", err) + } +} + +func TestValidateModelDoesNotSynthesizeVariantCombinations(t *testing.T) { + runner := newTestRunner(t, cursor.ModelCatalog{Items: []cursor.Model{{ + ID: "composer-2", + Variants: []cursor.ModelVariant{ + {Params: []cursor.ModelParameterSelection{ + {ID: "effort", Value: "high"}, + {ID: "speed", Value: "slow"}, + }}, + {Params: []cursor.ModelParameterSelection{ + {ID: "effort", Value: "low"}, + {ID: "speed", Value: "fast"}, + }}, + }, + }}}) + _, err := runner.ValidateModel(context.Background(), &cursor.ModelSelection{ + ID: "composer-2", + Params: []cursor.ModelParameterSelection{ + {ID: "effort", Value: "high"}, + {ID: "speed", Value: "fast"}, + }, + }, RequireExactVariant) + if err == nil { + t.Fatal("synthetic cross-variant combination was accepted") + } +} + +func TestValidateModelEmptyParamsPolicy(t *testing.T) { + catalog := cursor.ModelCatalog{Items: []cursor.Model{ + {ID: "variant-model", Variants: []cursor.ModelVariant{{ + Params: []cursor.ModelParameterSelection{{ID: "effort", Value: "high"}}, + }}}, + {ID: "plain-model"}, + }} + runner := newTestRunner(t, catalog) + + got, err := runner.ValidateModel(context.Background(), + &cursor.ModelSelection{ID: "variant-model"}, PreserveUpstreamDefault) + if err != nil { + t.Fatalf("tool omission rejected: %v", err) + } + if got.ID != "variant-model" || len(got.Params) != 0 { + t.Fatalf("tool omission changed selection: %+v", got) + } + + if _, err := runner.ValidateModel(context.Background(), + &cursor.ModelSelection{ID: "variant-model"}, RequireExactVariant); err == nil { + t.Fatal("exact policy accepted empty params for a model with variants") + } + + got, err = runner.ValidateModel(context.Background(), + &cursor.ModelSelection{ID: "plain-model"}, RequireExactVariant) + if err != nil || got.ID != "plain-model" || len(got.Params) != 0 { + t.Fatalf("plain model got=%+v err=%v", got, err) + } +} + +func TestValidateModelRefreshesStaleCatalogExactlyOnce(t *testing.T) { + var calls atomic.Int32 + oldCatalog := cursor.ModelCatalog{Items: []cursor.Model{{ + ID: "composer-2", + Variants: []cursor.ModelVariant{{Params: []cursor.ModelParameterSelection{ + {ID: "effort", Value: "low"}, + }}}, + }}} + newCatalog := cursor.ModelCatalog{Items: []cursor.Model{{ + ID: "composer-2", + Variants: []cursor.ModelVariant{{Params: []cursor.ModelParameterSelection{ + {ID: "effort", Value: "high"}, + }}}, + }}} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if calls.Add(1) == 1 { + writeCatalog(t, w, oldCatalog) + return + } + writeCatalog(t, w, newCatalog) + })) + t.Cleanup(srv.Close) + runner := testRunnerForServer(srv) + + if _, err := runner.Catalog(context.Background(), false); err != nil { + t.Fatal(err) + } + got, err := runner.ValidateModel(context.Background(), &cursor.ModelSelection{ + ID: "composer-2", + Params: []cursor.ModelParameterSelection{{ID: "effort", Value: "high"}}, + }, RequireExactVariant) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got.Params, newCatalog.Items[0].Variants[0].Params) { + t.Fatalf("selection = %+v", got) + } + if got := calls.Load(); got != 2 { + t.Fatalf("catalog requests = %d, want one initial and one refresh", got) + } +} + +func TestValidateModelColdInvalidSelectionDoesNotRefetch(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + writeCatalog(t, w, cursor.ModelCatalog{Items: []cursor.Model{{ + ID: "composer-2", + Variants: []cursor.ModelVariant{{Params: []cursor.ModelParameterSelection{ + {ID: "effort", Value: "low"}, + }}}, + }}}) + })) + t.Cleanup(srv.Close) + runner := testRunnerForServer(srv) + + _, err := runner.ValidateModel(context.Background(), &cursor.ModelSelection{ + ID: "composer-2", + Params: []cursor.ModelParameterSelection{{ID: "effort", Value: "not-upstream"}}, + }, RequireExactVariant) + if err == nil || !strings.Contains(err.Error(), "refresh and reselect") { + t.Fatalf("err=%v, want actionable reselect error", err) + } + if got := calls.Load(); got != 1 { + t.Fatalf("cold invalid selection fetched catalogue %d times, want 1", got) + } +} + +func TestCatalogCoalescesConcurrentRefresh(t *testing.T) { + var calls atomic.Int32 + started := make(chan struct{}) + release := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if calls.Add(1) == 1 { + close(started) + } + <-release + writeCatalog(t, w, cursor.ModelCatalog{}) + })) + t.Cleanup(srv.Close) + runner := testRunnerForServer(srv) + + const workers = 12 + errs := make(chan error, workers) + for range workers { + go func() { + _, err := runner.Catalog(context.Background(), true) + errs <- err + }() + } + <-started + time.Sleep(20 * time.Millisecond) + close(release) + for range workers { + if err := <-errs; err != nil { + t.Fatal(err) + } + } + if got := calls.Load(); got != 1 { + t.Fatalf("concurrent refresh requests = %d, want 1", got) + } +} + +func TestCatalogPanicReleasesWaitersAndAllowsRetry(t *testing.T) { + secret := "panic-secret" + panicValue := "transport panic " + secret + started := make(chan struct{}) + release := make(chan struct{}) + var calls atomic.Int32 + httpClient := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if calls.Add(1) == 1 { + close(started) + <-release + panic(panicValue) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"items":[]}`)), + Request: req, + }, nil + })} + runner := New(Options{ + ResolveClient: func() (cursor.Options, error) { + return cursor.Options{ + BaseURL: "https://cursor.invalid", APIKey: secret, HTTPClient: httpClient, + }, nil + }, + }) + + leaderPanic := make(chan any, 1) + go func() { + var recovered any + func() { + defer func() { recovered = recover() }() + _, _ = runner.Catalog(context.Background(), false) + }() + leaderPanic <- recovered + }() + <-started + + waiterCtx, cancelWaiter := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancelWaiter() + waiterErr := make(chan error, 1) + go func() { + _, err := runner.Catalog(waiterCtx, false) + waiterErr <- err + }() + time.Sleep(20 * time.Millisecond) + close(release) + + if recovered := <-leaderPanic; recovered != panicValue { + t.Fatalf("leader panic = %#v, want %#v", recovered, panicValue) + } + select { + case err := <-waiterErr: + if err == nil { + t.Fatal("waiter unexpectedly succeeded after leader panic") + } + if errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("waiter remained wedged until its deadline: %v", err) + } + if strings.Contains(err.Error(), secret) || len([]rune(err.Error())) > 512 { + t.Fatalf("waiter failure was not bounded and sanitized: %q", err) + } + case <-time.After(250 * time.Millisecond): + t.Fatal("waiter was not released after leader panic") + } + + retryCtx, cancelRetry := context.WithTimeout(context.Background(), 250*time.Millisecond) + defer cancelRetry() + catalog, err := runner.Catalog(retryCtx, false) + if err != nil { + t.Fatalf("retry after panic: %v", err) + } + if catalog == nil || catalog.Items == nil { + t.Fatalf("retry catalogue was not normalized: %+v", catalog) + } + if got := calls.Load(); got != 2 { + t.Fatalf("transport calls = %d, want panicking call plus retry", got) + } +} + +func TestCatalogReturnsIndependentSanitizedCopies(t *testing.T) { + secret := "catalogue-secret" + catalog := cursor.ModelCatalog{Items: []cursor.Model{{ + ID: "model-" + secret, + DisplayName: "name-" + secret, + Description: "description-" + secret, + Aliases: []string{"alias-" + secret}, + Parameters: []cursor.ModelParameter{{ + ID: "param-" + secret, + DisplayName: "parameter-" + secret, + Values: []cursor.ModelParameterValue{{ + Value: "value-" + secret, DisplayName: "value-name-" + secret, + }}, + }}, + Variants: []cursor.ModelVariant{{ + DisplayName: "variant-" + secret, + Description: "variant-description-" + secret, + Params: []cursor.ModelParameterSelection{{ + ID: "param-" + secret, Value: "value-" + secret, + }}, + }}, + }}} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeCatalog(t, w, catalog) + })) + t.Cleanup(srv.Close) + runner := New(Options{ + ResolveClient: func() (cursor.Options, error) { + return cursor.Options{BaseURL: srv.URL, APIKey: secret, HTTPClient: srv.Client()}, nil + }, + Now: time.Now, CatalogTTL: 5 * time.Minute, + }) + + first, err := runner.Catalog(context.Background(), false) + if err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(first) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), secret) { + t.Fatalf("catalogue leaked credential: %s", raw) + } + first.Items[0].ID = "mutated" + second, err := runner.Catalog(context.Background(), false) + if err != nil { + t.Fatal(err) + } + if second.Items[0].ID == "mutated" { + t.Fatal("caller mutation changed cached catalogue") + } + if second.Items[0].Aliases == nil || second.Items[0].Parameters == nil || + second.Items[0].Variants == nil { + t.Fatalf("catalogue arrays were not normalized: %+v", second.Items[0]) + } +} + +func TestCatalogBoundsNestedCollectionsAndUnicodeStrings(t *testing.T) { + const ( + wantMaxIDRunes = 1024 + wantMaxMetadataRunes = 16 * 1024 + wantMaxModels = 256 + wantMaxAliases = 64 + wantMaxParameters = 64 + wantMaxParameterValues = 128 + wantMaxVariants = 256 + wantMaxVariantParams = 64 + ) + secret := "catalog-bound-secret" + longID := secret + strings.Repeat("界", wantMaxIDRunes+10) + longMetadata := secret + strings.Repeat("語", wantMaxMetadataRunes+10) + + aliases := make([]string, wantMaxAliases+1) + aliases[0] = longMetadata + parameters := make([]cursor.ModelParameter, wantMaxParameters+1) + parameters[0] = cursor.ModelParameter{ + ID: longID, + DisplayName: longMetadata, + Values: make([]cursor.ModelParameterValue, wantMaxParameterValues+1), + } + parameters[0].Values[0] = cursor.ModelParameterValue{ + Value: longID, DisplayName: longMetadata, + } + variants := make([]cursor.ModelVariant, wantMaxVariants+1) + variants[0] = cursor.ModelVariant{ + DisplayName: longMetadata, + Description: longMetadata, + Params: make([]cursor.ModelParameterSelection, wantMaxVariantParams+1), + } + variants[0].Params[0] = cursor.ModelParameterSelection{ID: longID, Value: longID} + models := make([]cursor.Model, wantMaxModels+1) + models[0] = cursor.Model{ + ID: longID, + DisplayName: longMetadata, + Description: longMetadata, + Aliases: aliases, + Parameters: parameters, + Variants: variants, + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeCatalog(t, w, cursor.ModelCatalog{Items: models}) + })) + t.Cleanup(srv.Close) + runner := New(Options{ + ResolveClient: func() (cursor.Options, error) { + return cursor.Options{BaseURL: srv.URL, APIKey: secret, HTTPClient: srv.Client()}, nil + }, + }) + + catalog, err := runner.Catalog(context.Background(), false) + if err != nil { + t.Fatal(err) + } + if len(catalog.Items) != wantMaxModels { + t.Fatalf("models=%d, want cap %d", len(catalog.Items), wantMaxModels) + } + model := catalog.Items[0] + if len(model.Aliases) != wantMaxAliases || + len(model.Parameters) != wantMaxParameters || + len(model.Parameters[0].Values) != wantMaxParameterValues || + len(model.Variants) != wantMaxVariants || + len(model.Variants[0].Params) != wantMaxVariantParams { + t.Fatalf("nested caps not applied: aliases=%d params=%d values=%d variants=%d variant_params=%d", + len(model.Aliases), len(model.Parameters), len(model.Parameters[0].Values), + len(model.Variants), len(model.Variants[0].Params)) + } + if len([]rune(model.ID)) > wantMaxIDRunes || + len([]rune(model.DisplayName)) > wantMaxMetadataRunes || + len([]rune(model.Description)) > wantMaxMetadataRunes || + len([]rune(model.Aliases[0])) > wantMaxMetadataRunes || + len([]rune(model.Parameters[0].ID)) > wantMaxIDRunes || + len([]rune(model.Parameters[0].DisplayName)) > wantMaxMetadataRunes || + len([]rune(model.Parameters[0].Values[0].Value)) > wantMaxIDRunes || + len([]rune(model.Parameters[0].Values[0].DisplayName)) > wantMaxMetadataRunes || + len([]rune(model.Variants[0].DisplayName)) > wantMaxMetadataRunes || + len([]rune(model.Variants[0].Description)) > wantMaxMetadataRunes || + len([]rune(model.Variants[0].Params[0].ID)) > wantMaxIDRunes || + len([]rune(model.Variants[0].Params[0].Value)) > wantMaxIDRunes { + t.Fatal("one or more catalogue strings exceeded its Unicode rune limit") + } + raw, err := json.Marshal(catalog) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), secret) { + t.Fatalf("bounded catalogue leaked credential: %s", raw) + } + if catalog.Items == nil || model.Aliases == nil || model.Parameters == nil || + model.Parameters[0].Values == nil || model.Variants == nil || + model.Variants[0].Params == nil || catalog.Items[1].Aliases == nil || + catalog.Items[1].Parameters == nil || catalog.Items[1].Variants == nil { + t.Fatal("bounded catalogue contains a nil collection") + } +} + +func TestLifecycleMutationsAreSingleAttempt(t *testing.T) { + tests := []struct { + name string + method string + path string + call func(Runner) error + }{ + { + name: "create agent", method: http.MethodPost, path: "/v1/agents", + call: func(r Runner) error { + _, err := r.CreateAgent(context.Background(), cursor.CreateAgentRequest{ + Prompt: cursor.Prompt{Text: "do work"}, + }) + return err + }, + }, + { + name: "create run", method: http.MethodPost, path: "/v1/agents/bc-1/runs", + call: func(r Runner) error { + _, err := r.CreateRun(context.Background(), "bc-1", cursor.CreateRunRequest{ + Prompt: cursor.Prompt{Text: "continue"}, + }) + return err + }, + }, + { + name: "cancel run", method: http.MethodPost, path: "/v1/agents/bc-1/runs/run-1/cancel", + call: func(r Runner) error { + return r.CancelRun(context.Background(), "bc-1", "run-1") + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != tc.method || r.URL.Path != tc.path { + t.Errorf("request = %s %s, want %s %s", r.Method, r.URL.Path, tc.method, tc.path) + } + calls.Add(1) + http.Error(w, `{"message":"temporary failure"}`, http.StatusInternalServerError) + })) + t.Cleanup(srv.Close) + + err := tc.call(testRunnerForServer(srv)) + if err == nil { + t.Fatal("expected upstream failure") + } + if got := calls.Load(); got != 1 { + t.Fatalf("requests = %d, want exactly 1", got) + } + }) + } +} + +func TestLifecycleSanitizesErrorsAndGitText(t *testing.T) { + secret := "lifecycle-secret" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/agents/bc-1": + _ = json.NewEncoder(w).Encode(cursor.Agent{ + ID: "bc-" + secret, + Name: "name-" + secret, + Status: "status-" + secret, + URL: "https://example.test/" + secret, + LatestRunID: "run-" + secret, + Git: &cursor.GitState{Branches: []cursor.GitBranch{{ + RepoURL: "https://github.com/" + secret, + Branch: "branch-" + secret, + PRURL: "https://github.com/pr/" + secret, + }}}, + Repos: []cursor.Repository{{ + URL: "https://github.com/" + secret, StartingRef: "ref-" + secret, PRURL: "pr-" + secret, + }}, + }) + case "/v1/agents/bc-error": + w.WriteHeader(http.StatusConflict) + _, _ = fmt.Fprintf(w, `{"error":{"code":"code-%s","message":"message-%s"}}`, secret, secret) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + runner := New(Options{ + ResolveClient: func() (cursor.Options, error) { + return cursor.Options{BaseURL: srv.URL, APIKey: secret, HTTPClient: srv.Client()}, nil + }, + Now: time.Now, CatalogTTL: 5 * time.Minute, + }) + + agent, err := runner.GetAgent(context.Background(), "bc-1") + if err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(agent) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), secret) { + t.Fatalf("agent response leaked credential: %s", raw) + } + + _, err = runner.GetAgent(context.Background(), "bc-error") + if err == nil { + t.Fatal("expected API error") + } + var apiErr *cursor.APIError + if !errors.As(err, &apiErr) || apiErr.Status != http.StatusConflict { + t.Fatalf("typed API error lost: %T %v", err, err) + } + if strings.Contains(err.Error(), secret) || strings.Contains(apiErr.Code, secret) { + t.Fatalf("error leaked credential: %#v", apiErr) + } +} + +func TestLifecycleBoundsResultsRepositoriesAndGit(t *testing.T) { + const ( + wantMaxIDRunes = 1024 + wantMaxMetadataRunes = 16 * 1024 + wantMaxContentRunes = 1 << 20 + wantMaxRepositories = 64 + wantMaxGitBranches = 256 + ) + secret := "lifecycle-bound-secret" + longID := secret + strings.Repeat("界", wantMaxIDRunes+10) + longMetadata := secret + strings.Repeat("語", wantMaxMetadataRunes+10) + longContent := secret + strings.Repeat("文", wantMaxContentRunes+10) + repositories := make([]cursor.Repository, wantMaxRepositories+1) + repositories[0] = cursor.Repository{ + URL: longMetadata, StartingRef: longMetadata, PRURL: longMetadata, + } + branches := make([]cursor.GitBranch, wantMaxGitBranches+1) + branches[0] = cursor.GitBranch{ + RepoURL: longMetadata, Branch: longMetadata, PRURL: longMetadata, + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/agents/bc-bounds": + _ = json.NewEncoder(w).Encode(cursor.Agent{ + ID: longID, + Name: longMetadata, + Status: longMetadata, + URL: longMetadata, + LatestRunID: longID, + Repos: repositories, + Git: &cursor.GitState{Branches: branches}, + }) + case "/v1/agents/bc-empty": + _ = json.NewEncoder(w).Encode(cursor.Agent{Git: &cursor.GitState{}}) + case "/v1/agents/bc-bounds/runs/run-bounds": + _ = json.NewEncoder(w).Encode(cursor.Run{ + ID: longID, + AgentID: longID, + Status: longMetadata, + CreatedAt: longMetadata, + UpdatedAt: longMetadata, + Result: longContent, + Git: &cursor.GitState{Branches: branches}, + }) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + runner := New(Options{ + ResolveClient: func() (cursor.Options, error) { + return cursor.Options{BaseURL: srv.URL, APIKey: secret, HTTPClient: srv.Client()}, nil + }, + }) + + agent, err := runner.GetAgent(context.Background(), "bc-bounds") + if err != nil { + t.Fatal(err) + } + if len(agent.Repos) != wantMaxRepositories || len(agent.Git.Branches) != wantMaxGitBranches { + t.Fatalf("agent collection caps: repos=%d branches=%d", len(agent.Repos), len(agent.Git.Branches)) + } + if len([]rune(agent.ID)) > wantMaxIDRunes || + len([]rune(agent.Name)) > wantMaxMetadataRunes || + len([]rune(agent.Status)) > wantMaxMetadataRunes || + len([]rune(agent.URL)) > wantMaxMetadataRunes || + len([]rune(agent.LatestRunID)) > wantMaxIDRunes || + len([]rune(agent.Repos[0].URL)) > wantMaxMetadataRunes || + len([]rune(agent.Repos[0].StartingRef)) > wantMaxMetadataRunes || + len([]rune(agent.Repos[0].PRURL)) > wantMaxMetadataRunes || + len([]rune(agent.Git.Branches[0].RepoURL)) > wantMaxMetadataRunes || + len([]rune(agent.Git.Branches[0].Branch)) > wantMaxMetadataRunes || + len([]rune(agent.Git.Branches[0].PRURL)) > wantMaxMetadataRunes { + t.Fatal("one or more agent strings exceeded its Unicode rune limit") + } + + run, err := runner.GetRun(context.Background(), "bc-bounds", "run-bounds") + if err != nil { + t.Fatal(err) + } + if len(run.Git.Branches) != wantMaxGitBranches || + len([]rune(run.ID)) > wantMaxIDRunes || + len([]rune(run.AgentID)) > wantMaxIDRunes || + len([]rune(run.Status)) > wantMaxMetadataRunes || + len([]rune(run.CreatedAt)) > wantMaxMetadataRunes || + len([]rune(run.UpdatedAt)) > wantMaxMetadataRunes || + len([]rune(run.Result)) > wantMaxContentRunes { + t.Fatal("run bounds were not applied") + } + raw, err := json.Marshal(struct { + Agent *cursor.Agent + Run *cursor.Run + }{Agent: agent, Run: run}) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), secret) { + t.Fatalf("bounded lifecycle result leaked credential") + } + + empty, err := runner.GetAgent(context.Background(), "bc-empty") + if err != nil { + t.Fatal(err) + } + if empty.Repos == nil || empty.Git == nil || empty.Git.Branches == nil { + t.Fatalf("empty lifecycle slices were not normalized: %+v", empty) + } +} + +func TestGenericErrorsAreUnicodeBoundedAndRedacted(t *testing.T) { + const wantMaxGenericErrorRunes = 4096 + secret := "generic-error-secret" + runner := New(Options{ + ResolveClient: func() (cursor.Options, error) { + return cursor.Options{APIKey: secret}, errors.New( + secret + strings.Repeat("界", wantMaxGenericErrorRunes+10), + ) + }, + }) + + _, err := runner.GetAgent(context.Background(), "bc-1") + if err == nil { + t.Fatal("expected resolver error") + } + if strings.Contains(err.Error(), secret) { + t.Fatalf("generic error leaked credential: %q", err) + } + if got := len([]rune(err.Error())); got > wantMaxGenericErrorRunes { + t.Fatalf("generic error runes=%d, want <= %d", got, wantMaxGenericErrorRunes) + } +} + +func TestStreamRunSanitizesEventsTerminalAndProgress(t *testing.T) { + secret := "stream-secret" + longText := strings.Repeat("界", 2100) + secret + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, "/stream") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprintf(w, "id: event-%s\n", secret) + fmt.Fprintln(w, "event: tool_call") + fmt.Fprintf(w, "data: {\"callId\":\"call-%s\",\"name\":\"tool-%s\",\"status\":\"running-%s\",\"args\":{\"secret\":\"%s\"}}\n\n", + secret, secret, secret, secret) + fmt.Fprintln(w, "event: assistant") + payload, _ := json.Marshal(map[string]string{"text": longText}) + fmt.Fprintf(w, "data: %s\n\n", payload) + fmt.Fprintln(w, "event: result") + result, _ := json.Marshal(map[string]any{ + "runId": "run-" + secret, + "status": "FINISHED-" + secret, + "text": "result-" + secret, + "git": map[string]any{"branches": []map[string]string{{ + "repoUrl": "repo-" + secret, "branch": "branch-" + secret, "prUrl": "pr-" + secret, + }}}, + }) + fmt.Fprintf(w, "data: %s\n\n", result) + })) + t.Cleanup(srv.Close) + runner := New(Options{ + ResolveClient: func() (cursor.Options, error) { + return cursor.Options{BaseURL: srv.URL, APIKey: secret, HTTPClient: srv.Client()}, nil + }, + Now: time.Now, CatalogTTL: 5 * time.Minute, + }) + + var events []cursor.StreamEvent + terminal, err := runner.StreamRun(context.Background(), "bc-1", "run-1", "", nil, + func(event cursor.StreamEvent) error { + events = append(events, event) + raw, _ := json.Marshal(event) + if strings.Contains(string(raw), secret) { + t.Fatalf("stream event leaked credential: %s", raw) + } + return nil + }) + if err != nil { + t.Fatal(err) + } + if len(events) != 3 { + t.Fatalf("events=%d, want 3", len(events)) + } + raw, err := json.Marshal(terminal) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), secret) { + t.Fatalf("terminal run leaked credential: %s", raw) + } + + progress := runner.Progress(events[0]) + if progress.Message != "Cursor tool tool-[REDACTED] running-[REDACTED]" { + t.Fatalf("tool progress message = %q", progress.Message) + } + progress = runner.Progress(events[1]) + if len([]rune(progress.Chunk)) != 2001 || !strings.HasSuffix(progress.Chunk, "…") { + t.Fatalf("bounded chunk runes=%d suffix=%q", len([]rune(progress.Chunk)), progress.Chunk[len(progress.Chunk)-3:]) + } + if strings.Contains(progress.Chunk, secret) { + t.Fatal("progress leaked credential") + } +} + +func TestStreamRunResetErrorAbortsBeforeGetRun(t *testing.T) { + var streamCalls atomic.Int32 + var getCalls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/stream") { + streamCalls.Add(1) + w.WriteHeader(http.StatusGone) + _, _ = w.Write([]byte(`{"message":"expired"}`)) + return + } + getCalls.Add(1) + _ = json.NewEncoder(w).Encode(cursor.Run{ID: "run-1", Status: "FINISHED"}) + })) + t.Cleanup(srv.Close) + runner := testRunnerForServer(srv) + resetErr := errors.New("persist reset failed") + + _, err := runner.StreamRun(context.Background(), "bc-1", "run-1", "", + func() error { return resetErr }, + func(cursor.StreamEvent) error { return nil }) + if !errors.Is(err, resetErr) { + t.Fatalf("err=%v, want reset error identity", err) + } + if got := streamCalls.Load(); got != 1 { + t.Fatalf("stream requests=%d, want 1", got) + } + if got := getCalls.Load(); got != 0 { + t.Fatalf("GetRun requests=%d, want 0", got) + } +} + +func TestResolverErrorRedactsCredential(t *testing.T) { + secret := "resolver-secret" + runner := New(Options{ + ResolveClient: func() (cursor.Options, error) { + return cursor.Options{APIKey: secret}, fmt.Errorf("could not use %s", secret) + }, + }) + _, err := runner.Catalog(context.Background(), false) + if err == nil { + t.Fatal("expected resolver error") + } + if strings.Contains(err.Error(), secret) { + t.Fatalf("resolver error leaked credential: %v", err) + } +} + +func TestValidateModelErrorsNeverEchoCredentialLikeSelections(t *testing.T) { + secret := "selection-secret" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeCatalog(t, w, cursor.ModelCatalog{Items: []cursor.Model{{ID: "composer-2"}}}) + })) + t.Cleanup(srv.Close) + runner := New(Options{ + ResolveClient: func() (cursor.Options, error) { + return cursor.Options{BaseURL: srv.URL, APIKey: secret, HTTPClient: srv.Client()}, nil + }, + }) + + tests := []*cursor.ModelSelection{ + {ID: secret}, + { + ID: "composer-2", + Params: []cursor.ModelParameterSelection{ + {ID: secret, Value: "one"}, + {ID: secret, Value: "two"}, + }, + }, + } + for _, selection := range tests { + _, err := runner.ValidateModel(context.Background(), selection, RequireExactVariant) + if err == nil { + t.Fatalf("selection %+v unexpectedly passed", selection) + } + if strings.Contains(err.Error(), secret) { + t.Fatalf("validation error leaked credential-like input: %v", err) + } + } +} + +func TestStreamSanitizationRedactsJSONEscapedCredential(t *testing.T) { + secret := "quote\"slash\\secret" + raw, err := json.Marshal(map[string]string{"value": secret}) + if err != nil { + t.Fatal(err) + } + event := sanitizeStreamEvent(cursor.StreamEvent{ + Raw: raw, + ToolArgs: append(json.RawMessage(nil), raw...), + ToolResult: append(json.RawMessage(nil), raw...), + }, secret) + for name, value := range map[string]json.RawMessage{ + "raw": event.Raw, "args": event.ToolArgs, "result": event.ToolResult, + } { + var decoded map[string]string + if err := json.Unmarshal(value, &decoded); err != nil { + t.Fatalf("%s is no longer valid JSON: %v (%s)", name, err, value) + } + if decoded["value"] != "[REDACTED]" { + t.Fatalf("%s returned credential after JSON decoding: %q", name, decoded["value"]) + } + } +} + +func TestStreamSanitizationBoundsUnicodeFieldsAndRawJSON(t *testing.T) { + const ( + wantMaxIDRunes = 1024 + wantMaxMetadataRunes = 16 * 1024 + wantMaxContentRunes = 1 << 20 + wantMaxRawRunes = 1 << 20 + ) + secret := "stream-bound-secret" + longID := secret + strings.Repeat("界", wantMaxIDRunes+10) + longMetadata := secret + strings.Repeat("語", wantMaxMetadataRunes+10) + longContent := secret + strings.Repeat("文", wantMaxContentRunes+10) + longRaw, err := json.Marshal(map[string]string{ + "value": secret + strings.Repeat("生", wantMaxRawRunes+10), + }) + if err != nil { + t.Fatal(err) + } + + event := sanitizeStreamEvent(cursor.StreamEvent{ + ID: longID, + Type: longMetadata, + Status: longMetadata, + Text: longContent, + RunID: longID, + Raw: longRaw, + ToolName: longMetadata, + CallID: longID, + ToolArgs: append(json.RawMessage(nil), longRaw...), + ToolResult: append(json.RawMessage(nil), longRaw...), + }, secret) + + if len([]rune(event.ID)) > wantMaxIDRunes || + len([]rune(event.Type)) > wantMaxMetadataRunes || + len([]rune(event.Status)) > wantMaxMetadataRunes || + len([]rune(event.Text)) > wantMaxContentRunes || + len([]rune(event.RunID)) > wantMaxIDRunes || + len([]rune(event.ToolName)) > wantMaxMetadataRunes || + len([]rune(event.CallID)) > wantMaxIDRunes || + len([]rune(string(event.Raw))) > wantMaxRawRunes || + len([]rune(string(event.ToolArgs))) > wantMaxRawRunes || + len([]rune(string(event.ToolResult))) > wantMaxRawRunes { + t.Fatal("one or more stream fields exceeded its Unicode rune limit") + } + if !event.ArgsTruncated || !event.ResultTruncated { + t.Fatalf("service truncation flags were not set: %+v", event) + } + for name, value := range map[string]json.RawMessage{ + "raw": event.Raw, "args": event.ToolArgs, "result": event.ToolResult, + } { + if !json.Valid(value) { + t.Fatalf("%s is not valid JSON after bounding: %q", name, value) + } + if strings.Contains(string(value), secret) { + t.Fatalf("%s leaked credential", name) + } + } +} + +func TestProgressDirectlyRedactsAndBoundsUnicode(t *testing.T) { + secret := "progress-bound-secret" + runner := New(Options{ + ResolveClient: func() (cursor.Options, error) { + return cursor.Options{APIKey: secret}, nil + }, + }) + progress := runner.Progress(cursor.StreamEvent{ + ToolName: secret + strings.Repeat("界", 2100), + Status: secret + strings.Repeat("語", 2100), + Text: secret + strings.Repeat("文", 2100), + }) + if strings.Contains(progress.Message, secret) || strings.Contains(progress.Chunk, secret) { + t.Fatalf("progress leaked credential: %+v", progress) + } + if len([]rune(progress.Message)) > 2001 || len([]rune(progress.Chunk)) > 2001 { + t.Fatalf("progress was not Unicode bounded: message=%d chunk=%d", + len([]rune(progress.Message)), len([]rune(progress.Chunk))) + } +} + +func newTestRunner(t *testing.T, catalog cursor.ModelCatalog) Runner { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/models" { + http.NotFound(w, r) + return + } + writeCatalog(t, w, catalog) + })) + t.Cleanup(srv.Close) + return testRunnerForServer(srv) +} + +func testRunnerForServer(srv *httptest.Server) Runner { + return New(Options{ + ResolveClient: func() (cursor.Options, error) { + return cursor.Options{ + BaseURL: srv.URL, APIKey: "synthetic-key", HTTPClient: srv.Client(), + }, nil + }, + Now: time.Now, CatalogTTL: 5 * time.Minute, + }) +} + +func writeCatalog(t *testing.T, w http.ResponseWriter, catalog cursor.ModelCatalog) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(catalog); err != nil { + t.Errorf("encode catalog: %v", err) + } +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} diff --git a/internal/llm/anthropic.go b/internal/llm/anthropic.go index f112fde..c4a4dcd 100644 --- a/internal/llm/anthropic.go +++ b/internal/llm/anthropic.go @@ -133,6 +133,88 @@ func toAnthropic(req Request) []antMessage { return out } +// anthropicSupportsAdaptiveThinking reports whether model belongs to the +// adaptive-thinking generation (Claude Sonnet 5 and later dated snapshots), +// which uses "thinking":{"type":"adaptive"} plus output_config.effort instead +// of a fixed token budget. +func anthropicSupportsAdaptiveThinking(model string) bool { + return exactModelOrDatedSnapshot("claude-sonnet-5", model) +} + +// anthropicLegacyThinkingBudgets lists pre-adaptive extended-thinking model +// families and the token budgets published for their effort ladder. +// https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking +var anthropicLegacyThinkingBudgets = []struct { + family string + budgets map[string]int +}{ + {family: "claude-3-7-sonnet", budgets: map[string]int{"low": 2048, "medium": 8192, "high": 16384}}, +} + +// anthropicLegacyBudget resolves the fixed token budget for a catalogued +// legacy model and effort value. It never guesses a budget for an +// unrecognised model or an effort value outside that model's documented +// ladder. +func anthropicLegacyBudget(model, effort string) (int, bool) { + for _, entry := range anthropicLegacyThinkingBudgets { + if !exactModelOrDatedSnapshot(entry.family, model) { + continue + } + budget, ok := entry.budgets[effort] + return budget, ok + } + return 0, false +} + +// anthropicThinkingBody resolves the "thinking"/"output_config" fields for a +// validated, non-empty reasoning effort. ok is false when the model cannot +// actually honor this specific value: either it is a pre-adaptive model whose +// hardcoded budget table has no entry for effort (e.g. an attached capability +// advertised a value this local catalogue does not know how to map). Callers +// must treat ok == false as a hard failure rather than silently sending the +// request without the override — see anthropicClient.validateReasoning. +func anthropicThinkingBody(model, effort string, capability *ReasoningCapability) (thinking map[string]any, outputConfig map[string]any, ok bool) { + if anthropicSupportsAdaptiveThinking(model) { + if disable := reasoningDisableValue(capability); disable != "" && effort == disable { + return map[string]any{"type": "disabled"}, nil, true + } + return map[string]any{"type": "adaptive"}, map[string]any{"effort": effort}, true + } + // Pre-adaptive models only understand fixed token budgets, and only for + // the catalogued legacy families and their documented effort ladders. + if budget, ok := anthropicLegacyBudget(model, effort); ok { + return map[string]any{"type": "enabled", "budget_tokens": budget}, nil, true + } + return nil, nil, false +} + +// validateReasoning fails before any network call when the request's +// reasoning effort cannot be honored: either the value itself is not +// advertised by the model's capability (delegated to reasoningValue), or — +// for pre-adaptive Anthropic models — the value is validated by an attached +// capability but has no entry in the local fixed-budget table, which would +// otherwise silently vanish from the outgoing request. +func (c *anthropicClient) validateReasoning(req Request) error { + value, err := reasoningValue(req, "anthropic", c.opts.ProviderID, c.opts.BaseURL) + if err != nil { + return err + } + if value == "" { + return nil + } + capability := resolvedReasoningCapability(req, "anthropic", c.opts.ProviderID, c.opts.BaseURL) + if _, _, ok := anthropicThinkingBody(req.Model, value, capability); !ok { + var allowed []string + if capability != nil { + for _, v := range capability.Values { + allowed = append(allowed, v.Value) + } + } + return &UnsupportedReasoningEffortError{Model: req.Model, Allowed: allowed} + } + return nil +} + func (c *anthropicClient) buildBody(req Request, stream bool) map[string]any { maxTokens := req.MaxTokens if maxTokens <= 0 { @@ -194,21 +276,28 @@ func (c *anthropicClient) buildBody(req Request, stream bool) map[string]any { } } } - switch strings.ToLower(req.ReasoningEffort) { - case "low": - body["thinking"] = map[string]any{"type": "enabled", "budget_tokens": 2048} - case "medium": - body["thinking"] = map[string]any{"type": "enabled", "budget_tokens": 8192} - case "high": - body["thinking"] = map[string]any{"type": "enabled", "budget_tokens": 16384} - } - if _, ok := body["thinking"]; ok { - // Thinking requires headroom beyond the budget. - if maxTokens < 16384 { - body["max_tokens"] = 16384 + if value, err := reasoningValue(req, "anthropic", c.opts.ProviderID, c.opts.BaseURL); err == nil && value != "" { + capability := resolvedReasoningCapability(req, "anthropic", c.opts.ProviderID, c.opts.BaseURL) + // A value that cannot be mapped (ok == false) is left out of the body + // here; callers reach this only via Chat/Stream, which fail the + // request first via validateReasoning so that case is unreachable in + // practice. buildBody stays a pure, non-erroring body builder. + if thinking, outputConfig, ok := anthropicThinkingBody(req.Model, value, capability); ok { + body["thinking"] = thinking + if outputConfig != nil { + body["output_config"] = outputConfig + } } + } + if th, ok := body["thinking"].(map[string]any); ok { + // Thinking (adaptive or fixed-budget) is incompatible with + // temperature/top_p sampling controls. delete(body, "top_p") delete(body, "temperature") + if _, hasBudget := th["budget_tokens"]; hasBudget && maxTokens < 16384 { + // Fixed-budget thinking requires headroom beyond the budget. + body["max_tokens"] = 16384 + } } for k, v := range req.Extra { body[k] = v @@ -233,6 +322,9 @@ type antResponse struct { } func (c *anthropicClient) Chat(ctx context.Context, req Request) (*Response, error) { + if err := c.validateReasoning(req); err != nil { + return nil, err + } var raw antResponse if err := c.opts.doJSON(ctx, "POST", c.opts.BaseURL+"/messages", c.buildBody(req, false), c.headers(), &raw); err != nil { return nil, err @@ -275,6 +367,9 @@ func (c *anthropicClient) fromResponse(raw *antResponse) (*Response, error) { } func (c *anthropicClient) Stream(ctx context.Context, req Request, emit func(Event) error) (*Response, error) { + if err := c.validateReasoning(req); err != nil { + return nil, err + } httpResp, err := c.opts.doStream(ctx, "POST", c.opts.BaseURL+"/messages", c.buildBody(req, true), c.headers()) if err != nil { return nil, err diff --git a/internal/llm/bedrock.go b/internal/llm/bedrock.go index 683e04e..389f069 100644 --- a/internal/llm/bedrock.go +++ b/internal/llm/bedrock.go @@ -71,6 +71,14 @@ func (c *bedrockClient) Chat(ctx context.Context, req Request) (*Response, error if req.Model == "" { return nil, errors.New("bedrock needs a model id, e.g. anthropic.claude-3-5-sonnet-20241022-v2:0") } + // Bedrock reuses anthropicClient.buildBody directly rather than going + // through anthropicClient.Chat, so it must run the same pre-request + // reasoning validation itself. Otherwise an invalid explicit effort is + // silently dropped by buildBody and a signed, billable request still goes + // upstream. + if err := c.inner.validateReasoning(req); err != nil { + return nil, err + } payload, err := json.Marshal(c.bedrockBody(req)) if err != nil { return nil, err diff --git a/internal/llm/bedrock_test.go b/internal/llm/bedrock_test.go index cb4c208..ac84f14 100644 --- a/internal/llm/bedrock_test.go +++ b/internal/llm/bedrock_test.go @@ -1,9 +1,13 @@ package llm import ( + "context" "encoding/hex" + "errors" "net/http" + "net/http/httptest" "strings" + "sync/atomic" "testing" "time" ) @@ -54,3 +58,40 @@ func TestNewBedrockNeedsRegion(t *testing.T) { t.Fatalf("expected a bedrock client, got %v %v", c, err) } } + +// TestBedrockReasoningValidationBlocksRequestBeforeSigning guards a bypass: +// bedrockClient.Chat builds its body via anthropicClient.buildBody directly +// rather than anthropicClient.Chat, so it must run the same pre-request +// reasoning validation itself. Without it, an invalid explicit reasoning +// value was silently dropped by buildBody and a signed, billable request +// still went upstream. +func TestBedrockReasoningValidationBlocksRequestBeforeSigning(t *testing.T) { + var hits int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&hits, 1) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{}`)) + })) + defer srv.Close() + + cap, err := NewReasoningCapability([]ReasoningValue{{Value: "low", Label: "Low"}}, "low", false, ReasoningCapabilityStatic) + if err != nil { + t.Fatal(err) + } + + opts := Options{BaseURL: srv.URL, HTTPClient: srv.Client()} + c := &bedrockClient{opts: opts, region: "us-east-1", inner: &anthropicClient{opts: opts}, endpoint: srv.URL} + + _, err = c.Chat(context.Background(), Request{ + Model: "anthropic.claude-3-5-sonnet-20241022-v2:0", + ReasoningEffort: "max", + ReasoningCapability: cap, + }) + var unsupported *UnsupportedReasoningEffortError + if !errors.As(err, &unsupported) { + t.Fatalf("err = %v, want UnsupportedReasoningEffortError", err) + } + if got := atomic.LoadInt32(&hits); got != 0 { + t.Fatalf("requests sent = %d, want 0 (must fail before signing/sending)", got) + } +} diff --git a/internal/llm/codex.go b/internal/llm/codex.go index ba07727..9ba317d 100644 --- a/internal/llm/codex.go +++ b/internal/llm/codex.go @@ -42,8 +42,8 @@ func (c *codexClient) buildBody(req Request, stream bool) map[string]any { if req.Temperature > 0 { body["temperature"] = req.Temperature } - if e := strings.ToLower(req.ReasoningEffort); e != "" && e != "none" { - body["reasoning"] = map[string]any{"effort": e} + if value, err := reasoningValue(req, "codex", c.opts.ProviderID, c.opts.BaseURL); err == nil && value != "" { + body["reasoning"] = map[string]any{"effort": value} } if len(req.Tools) > 0 { tools := make([]map[string]any, 0, len(req.Tools)) @@ -143,6 +143,9 @@ type responsesReply struct { } func (c *codexClient) Chat(ctx context.Context, req Request) (*Response, error) { + if _, err := reasoningValue(req, "codex", c.opts.ProviderID, c.opts.BaseURL); err != nil { + return nil, err + } var raw responsesReply if err := c.opts.doJSON(ctx, "POST", c.opts.BaseURL+"/responses", c.buildBody(req, false), c.headers(), &raw); err != nil { return nil, err diff --git a/internal/llm/fallback.go b/internal/llm/fallback.go index 7f0e219..8a1f627 100644 --- a/internal/llm/fallback.go +++ b/internal/llm/fallback.go @@ -7,8 +7,9 @@ import ( // FallbackEntry is one client and the model to ask it for. type FallbackEntry struct { - Client Client - Model string + Client Client + Model string + ReasoningCapability *ReasoningCapability } // fallbackClient tries each entry in order, moving to the next when one fails @@ -37,9 +38,8 @@ func (c *fallbackClient) Kind() string { func (c *fallbackClient) Chat(ctx context.Context, req Request) (*Response, error) { var lastErr error - for _, e := range c.entries { - r := req - r.Model = e.Model + for i, e := range c.entries { + r := fallbackRequest(req, e, i > 0) resp, err := e.Client.Chat(ctx, r) if err == nil { return resp, nil @@ -62,8 +62,7 @@ func (c *fallbackClient) Stream(ctx context.Context, req Request, emit func(Even emitted = true return emit(ev) } - r := req - r.Model = e.Model + r := fallbackRequest(req, e, i > 0) resp, err := e.Client.Stream(ctx, r, wrapped) if err == nil { return resp, nil @@ -76,6 +75,15 @@ func (c *fallbackClient) Stream(ctx context.Context, req Request, emit func(Even return nil, lastErr } +func fallbackRequest(req Request, entry FallbackEntry, isFallback bool) Request { + req.Model = entry.Model + req.ReasoningCapability = entry.ReasoningCapability + if isFallback && ValidateReasoningEffort(entry.Model, entry.ReasoningCapability, req.ReasoningEffort) != nil { + req.ReasoningEffort = "" + } + return req +} + // Models and Embed use the primary only — a fallback for enumeration or // embeddings would silently change the vector space. func (c *fallbackClient) Models(ctx context.Context) ([]ModelInfo, error) { diff --git a/internal/llm/fallback_test.go b/internal/llm/fallback_test.go index b8c399c..209b355 100644 --- a/internal/llm/fallback_test.go +++ b/internal/llm/fallback_test.go @@ -41,17 +41,71 @@ func TestFallbackOverridesModel(t *testing.T) { } } +func TestFallbackReplacesPrimaryReasoningCapability(t *testing.T) { + primaryCapability, err := NewReasoningCapability( + []ReasoningValue{{Value: "high", Label: "HIGH"}}, + "high", false, ReasoningCapabilityLive, + ) + if err != nil { + t.Fatal(err) + } + fallbackCapability, err := NewReasoningCapability( + []ReasoningValue{{Value: "low", Label: "LOW"}}, + "low", false, ReasoningCapabilityLive, + ) + if err != nil { + t.Fatal(err) + } + + rec := &modelRecorder{} + c := NewFallback([]FallbackEntry{ + { + Client: &fakeClient{failN: 10, failErr: &apiError{Status: 500}}, + Model: "primary", + ReasoningCapability: primaryCapability, + }, + { + Client: rec, + Model: "fallback", + ReasoningCapability: fallbackCapability, + }, + }) + _, err = c.Chat(context.Background(), Request{ + Model: "original", + ReasoningEffort: "high", + ReasoningCapability: primaryCapability, + }) + if err != nil { + t.Fatal(err) + } + if rec.gotModel != "fallback" { + t.Fatalf("model = %q, want fallback", rec.gotModel) + } + if rec.gotCapability != fallbackCapability { + t.Fatalf("capability = %#v, want fallback entry capability %#v", rec.gotCapability, fallbackCapability) + } + if rec.gotEffort != "" { + t.Fatalf("reasoning effort = %q, want Auto for unsupported legacy value", rec.gotEffort) + } +} + type modelRecorder struct { - gotModel string + gotModel string + gotEffort string + gotCapability *ReasoningCapability } func (m *modelRecorder) Kind() string { return "rec" } func (m *modelRecorder) Chat(ctx context.Context, req Request) (*Response, error) { m.gotModel = req.Model + m.gotEffort = req.ReasoningEffort + m.gotCapability = req.ReasoningCapability return &Response{Content: "ok"}, nil } func (m *modelRecorder) Stream(ctx context.Context, req Request, emit func(Event) error) (*Response, error) { m.gotModel = req.Model + m.gotEffort = req.ReasoningEffort + m.gotCapability = req.ReasoningCapability return &Response{Content: "ok"}, nil } func (m *modelRecorder) Models(context.Context) ([]ModelInfo, error) { return nil, nil } diff --git a/internal/llm/gemini.go b/internal/llm/gemini.go index 14ccb18..fe7eafe 100644 --- a/internal/llm/gemini.go +++ b/internal/llm/gemini.go @@ -306,8 +306,14 @@ func (c *geminiClient) buildBody(req Request) map[string]any { if len(req.StopSequences) > 0 { gen["stopSequences"] = req.StopSequences } - if tc := geminiThinkingConfig(req.Model, req.ReasoningEffort); tc != nil { - gen["thinkingConfig"] = tc + if value, err := reasoningValue(req, c.Kind(), c.opts.ProviderID, c.opts.BaseURL); err == nil { + // An unmappable value (ok == false) is left out of the body here; + // callers reach this only via Chat/Stream, which fail the request + // first via validateReasoning, so that case is unreachable in + // practice. buildBody stays a pure, non-erroring body builder. + if tc, ok := geminiThinkingConfig(req.Model, value); ok && tc != nil { + gen["thinkingConfig"] = tc + } } if len(gen) > 0 { body["generationConfig"] = gen @@ -374,36 +380,80 @@ func (c *geminiClient) endpoint(model, method string, stream bool) string { // Gemini 3 series prefer thinkingLevel (MINIMAL/LOW/MEDIUM/HIGH); 2.5 series // use thinkingBudget token counts. includeThoughts requests thought summaries // when the endpoint exposes them (not all reverse proxies return thought text). -func geminiThinkingConfig(model, effort string) map[string]any { +// +// Minimal is a real, distinct thinking level for Gemini 3 — not an Off +// synonym. Gemini 3 has no true Off; its static capability never advertises +// "none", so the "none" case below only guards a stray legacy value. +// +// ok is false when effort cannot actually be honored on model: an +// unrecognised keyword, or "minimal" requested against a legacy +// thinkingBudget model that has no such level. config == nil with ok == true +// is a real, intentional mapping (Auto, or Gemini 3's documented lack of a +// true Off) — callers must not conflate the two. See +// geminiClient.validateReasoning, which fails the request before any network +// call when ok is false rather than silently omitting the override. +func geminiThinkingConfig(model, effort string) (config map[string]any, ok bool) { e := strings.ToLower(strings.TrimSpace(effort)) if e == "" { - return nil + return nil, true } useLevel := geminiModelUsesThinkingLevel(model) switch e { case "none": if useLevel { - return map[string]any{"thinkingLevel": "MINIMAL", "includeThoughts": false} + return nil, true } - return map[string]any{"thinkingBudget": 0} + return map[string]any{"thinkingBudget": 0}, true + case "minimal": + if !useLevel { + return nil, false + } + return map[string]any{"thinkingLevel": "MINIMAL", "includeThoughts": true}, true case "low": if useLevel { - return map[string]any{"thinkingLevel": "LOW", "includeThoughts": true} + return map[string]any{"thinkingLevel": "LOW", "includeThoughts": true}, true } - return map[string]any{"thinkingBudget": 2048, "includeThoughts": true} + return map[string]any{"thinkingBudget": 2048, "includeThoughts": true}, true case "medium": if useLevel { - return map[string]any{"thinkingLevel": "MEDIUM", "includeThoughts": true} + return map[string]any{"thinkingLevel": "MEDIUM", "includeThoughts": true}, true } - return map[string]any{"thinkingBudget": 8192, "includeThoughts": true} + return map[string]any{"thinkingBudget": 8192, "includeThoughts": true}, true case "high": if useLevel { - return map[string]any{"thinkingLevel": "HIGH", "includeThoughts": true} + return map[string]any{"thinkingLevel": "HIGH", "includeThoughts": true}, true } - return map[string]any{"thinkingBudget": 24576, "includeThoughts": true} + return map[string]any{"thinkingBudget": 24576, "includeThoughts": true}, true default: + return nil, false + } +} + +// validateReasoning fails before any network call when the request's +// reasoning effort cannot be honored: either the value itself is not +// advertised by the model's capability (delegated to reasoningValue), or the +// value is validated by an attached capability but geminiThinkingConfig has +// no mapping for it on this model — which would otherwise silently vanish +// from the outgoing request (buildBody just omits thinkingConfig). +func (c *geminiClient) validateReasoning(req Request) error { + value, err := reasoningValue(req, c.Kind(), c.opts.ProviderID, c.opts.BaseURL) + if err != nil { + return err + } + if value == "" { return nil } + if _, ok := geminiThinkingConfig(req.Model, value); !ok { + capability := resolvedReasoningCapability(req, c.Kind(), c.opts.ProviderID, c.opts.BaseURL) + var allowed []string + if capability != nil { + for _, v := range capability.Values { + allowed = append(allowed, v.Value) + } + } + return &UnsupportedReasoningEffortError{Model: req.Model, Allowed: allowed} + } + return nil } func geminiModelUsesThinkingLevel(model string) bool { @@ -452,6 +502,9 @@ func parseGeminiParts(parts []gemPart) (content, reasoning string, calls []ToolC } func (c *geminiClient) Chat(ctx context.Context, req Request) (*Response, error) { + if err := c.validateReasoning(req); err != nil { + return nil, err + } // Prefer stream collection: some Gemini-compatible reverse proxies aggregate // non-stream generateContent by keeping only the final STOP chunk, which is // often empty text after a functionCall chunk. streamGenerateContent preserves @@ -485,6 +538,9 @@ func (c *geminiClient) Chat(ctx context.Context, req Request) (*Response, error) } func (c *geminiClient) Stream(ctx context.Context, req Request, emit func(Event) error) (*Response, error) { + if err := c.validateReasoning(req); err != nil { + return nil, err + } httpResp, err := c.opts.doStream(ctx, "POST", c.endpoint(req.Model, "streamGenerateContent", true), c.buildBody(req), c.headers()) if err != nil { return nil, err diff --git a/internal/llm/gemini_test.go b/internal/llm/gemini_test.go index cbd122b..7468688 100644 --- a/internal/llm/gemini_test.go +++ b/internal/llm/gemini_test.go @@ -1,14 +1,20 @@ package llm import ( + "context" "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "reflect" "strings" + "sync/atomic" "testing" ) func TestGeminiThinkingConfigUsesLevelForGemini3(t *testing.T) { - tc := geminiThinkingConfig("gemini-3.6-flash-high", "high") - if tc == nil { + tc, ok := geminiThinkingConfig("gemini-3.6-flash-high", "high") + if !ok || tc == nil { t.Fatal("expected thinkingConfig") } if tc["thinkingLevel"] != "HIGH" { @@ -23,8 +29,8 @@ func TestGeminiThinkingConfigUsesLevelForGemini3(t *testing.T) { } func TestGeminiThinkingConfigUsesBudgetFor25(t *testing.T) { - tc := geminiThinkingConfig("gemini-2.5-flash", "medium") - if tc == nil { + tc, ok := geminiThinkingConfig("gemini-2.5-flash", "medium") + if !ok || tc == nil { t.Fatal("expected thinkingConfig") } if tc["thinkingBudget"] != 8192 { @@ -35,6 +41,140 @@ func TestGeminiThinkingConfigUsesBudgetFor25(t *testing.T) { } } +func TestGemini3MinimalIsNotDisable(t *testing.T) { + got, ok := geminiThinkingConfig("gemini-3.6-flash", "minimal") + want := map[string]any{"thinkingLevel": "MINIMAL", "includeThoughts": true} + if !ok || !reflect.DeepEqual(got, want) { + t.Fatalf("got %#v, ok=%v, want %#v, ok=true", got, ok, want) + } +} + +func TestGeminiThinkingConfigGemini3HasNoTrueOff(t *testing.T) { + got, ok := geminiThinkingConfig("gemini-3.6-flash", "none") + if !ok || got != nil { + t.Fatalf("got %#v, ok=%v, want nil, ok=true (Gemini 3 has no true Off, only Minimal — that's a valid mapping, not a failure)", got, ok) + } +} + +// TestGeminiThinkingConfigMinimalUnmappableForLegacyBudgetModel guards the +// second half of geminiThinkingConfig's ok contract: "minimal" has no +// documented meaning for a pre-Gemini-3 thinkingBudget model, so it must be +// reported as unmappable (ok == false) rather than silently treated as a +// no-op, matching how an entirely unrecognised keyword is handled. +func TestGeminiThinkingConfigMinimalUnmappableForLegacyBudgetModel(t *testing.T) { + if _, ok := geminiThinkingConfig("gemini-2.5-flash", "minimal"); ok { + t.Fatal("got ok=true, want ok=false: legacy budget models have no minimal thinking level") + } +} + +// TestGeminiThinkingConfigUnrecognisedEffortIsUnmappable guards the general +// case behind the review finding: an effort value this switch has never +// heard of (as could be attached via a live/static ReasoningCapability that +// advertises more values than this local mapping knows) must report +// ok == false rather than silently mapping to no override. +func TestGeminiThinkingConfigUnrecognisedEffortIsUnmappable(t *testing.T) { + if _, ok := geminiThinkingConfig("gemini-2.5-flash", "xhigh"); ok { + t.Fatal("got ok=true, want ok=false for an unrecognised effort keyword") + } + if _, ok := geminiThinkingConfig("gemini-3.6-flash", "xhigh"); ok { + t.Fatal("got ok=true, want ok=false for an unrecognised effort keyword on a Gemini 3 model") + } +} + +func TestGeminiLegacyBudgetZeroWhenOffSupported(t *testing.T) { + cap, err := NewReasoningCapability( + []ReasoningValue{ + {Value: "none", Label: "Off", Kind: ReasoningValueDisable}, + {Value: "low", Label: "Low"}, + {Value: "medium", Label: "Medium"}, + {Value: "high", Label: "High"}, + }, + "medium", false, ReasoningCapabilityStatic, + ) + if err != nil { + t.Fatal(err) + } + c := &geminiClient{} + body := c.buildBody(Request{ + Model: "gemini-2.5-flash", ReasoningEffort: "none", ReasoningCapability: cap, + Messages: []Message{{Role: RoleUser, Content: "hi"}}, + }) + gen, _ := body["generationConfig"].(map[string]any) + tc, _ := gen["thinkingConfig"].(map[string]any) + want := map[string]any{"thinkingBudget": 0} + if !reflect.DeepEqual(tc, want) { + t.Fatalf("thinkingConfig = %#v, want %#v", tc, want) + } +} + +func TestGeminiRejectsUnsupportedOffBeforeRequest(t *testing.T) { + cap, err := NewReasoningCapability( + []ReasoningValue{{Value: "low", Label: "Low"}, {Value: "high", Label: "High"}}, + "low", false, ReasoningCapabilityStatic, + ) + if err != nil { + t.Fatal(err) + } + c := &geminiClient{} + _, err = c.Chat(context.Background(), Request{ + Model: "gemini-2.5-flash", ReasoningEffort: "none", ReasoningCapability: cap, + }) + var unsupported *UnsupportedReasoningEffortError + if !errors.As(err, &unsupported) { + t.Fatalf("err = %v, want UnsupportedReasoningEffortError", err) + } +} + +// TestGeminiUnmappableLegacyEffortFailsBeforeRequest guards a silent-drop +// bug: an effort can pass validation against an *attached* capability (e.g. a +// live capability advertising a value this local geminiThinkingConfig switch +// does not recognise for the model) yet have no mapping at all. +// geminiThinkingConfig previously returned nil in that case and buildBody +// just omitted thinkingConfig, silently downgrading the turn to Auto. Chat +// must now fail before any request is sent. +func TestGeminiUnmappableLegacyEffortFailsBeforeRequest(t *testing.T) { + cap, err := NewReasoningCapability( + []ReasoningValue{{Value: "low", Label: "Low"}, {Value: "xhigh", Label: "Extra High"}}, + "low", false, ReasoningCapabilityStatic, + ) + if err != nil { + t.Fatal(err) + } + + var hits int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&hits, 1) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{}`)) + })) + defer srv.Close() + + c := &geminiClient{opts: Options{BaseURL: srv.URL, HTTPClient: srv.Client()}} + _, err = c.Chat(context.Background(), Request{ + Model: "gemini-2.5-flash", ReasoningEffort: "xhigh", ReasoningCapability: cap, + }) + var unsupported *UnsupportedReasoningEffortError + if !errors.As(err, &unsupported) { + t.Fatalf("err = %v, want UnsupportedReasoningEffortError", err) + } + if got := atomic.LoadInt32(&hits); got != 0 { + t.Fatalf("requests sent = %d, want 0", got) + } + + // buildBody alone (bypassing Chat) must also stay silent-safe: it must + // not fabricate a thinkingConfig it cannot actually honor. + body := (&geminiClient{}).buildBody(Request{ + Model: "gemini-2.5-flash", ReasoningEffort: "xhigh", ReasoningCapability: cap, + Messages: []Message{{Role: RoleUser, Content: "hi"}}, + }) + gen, _ := body["generationConfig"].(map[string]any) + if gen != nil { + if _, ok := gen["thinkingConfig"]; ok { + t.Fatalf("buildBody emitted a thinkingConfig for an unmappable effort: %#v", gen["thinkingConfig"]) + } + } +} + func TestToGeminiPreservesThoughtSignatureOnFunctionCall(t *testing.T) { req := Request{ Messages: []Message{{ @@ -108,11 +248,19 @@ func TestParseGeminiPartsCapturesSignature(t *testing.T) { } func TestBuildBodyGemini3HighThinking(t *testing.T) { + cap, err := NewReasoningCapability( + []ReasoningValue{{Value: "low", Label: "Low"}, {Value: "high", Label: "High"}}, + "low", false, ReasoningCapabilityStatic, + ) + if err != nil { + t.Fatal(err) + } c := &geminiClient{} body := c.buildBody(Request{ - Model: "gemini-3.6-flash-high", - ReasoningEffort: "high", - Messages: []Message{{Role: RoleUser, Content: "hi"}}, + Model: "gemini-3.6-flash-high", + ReasoningEffort: "high", + ReasoningCapability: cap, + Messages: []Message{{Role: RoleUser, Content: "hi"}}, }) gen, _ := body["generationConfig"].(map[string]any) tc, _ := gen["thinkingConfig"].(map[string]any) @@ -124,11 +272,11 @@ func TestBuildBodyGemini3HighThinking(t *testing.T) { func TestNormalizeGeminiBaseURLMatchesCLI(t *testing.T) { cases := map[string]string{ - "http://127.0.0.1:8080/antigravity": "http://127.0.0.1:8080/antigravity/v1beta", - "http://127.0.0.1:8080/antigravity/": "http://127.0.0.1:8080/antigravity/v1beta", - "http://127.0.0.1:8080/antigravity/v1beta": "http://127.0.0.1:8080/antigravity/v1beta", + "http://127.0.0.1:8080/antigravity": "http://127.0.0.1:8080/antigravity/v1beta", + "http://127.0.0.1:8080/antigravity/": "http://127.0.0.1:8080/antigravity/v1beta", + "http://127.0.0.1:8080/antigravity/v1beta": "http://127.0.0.1:8080/antigravity/v1beta", "https://generativelanguage.googleapis.com/v1beta": "https://generativelanguage.googleapis.com/v1beta", - "http://localhost:8080/v1": "http://localhost:8080/v1", + "http://localhost:8080/v1": "http://localhost:8080/v1", } for in, want := range cases { if got := normalizeGeminiBaseURL(in); got != want { diff --git a/internal/llm/openai.go b/internal/llm/openai.go index 410e329..0819ebd 100644 --- a/internal/llm/openai.go +++ b/internal/llm/openai.go @@ -19,6 +19,17 @@ type openAIClient struct { func (c *openAIClient) Kind() string { return "openai" } +// reasoningKind reports the static-catalogue key for this vendor. Only the +// direct OpenAI API has a documented per-model effort ladder; every other +// vendor (compat, Azure, Copilot, OpenRouter) is Auto-only unless the caller +// attaches a resolved capability explicitly. +func (c *openAIClient) reasoningKind() string { + if c.vendor == "openai" { + return "openai" + } + return "openai-compatible" +} + func (c *openAIClient) headers() map[string]string { if c.vendor == "copilot" { // Copilot needs a freshly-exchanged token plus editor headers. @@ -189,12 +200,15 @@ func (c *openAIClient) buildBody(req Request, stream bool) map[string]any { body["parallel_tool_calls"] = false } } - if e := strings.ToLower(req.ReasoningEffort); e != "" && e != "none" { + if value, err := reasoningValue(req, c.reasoningKind(), c.opts.ProviderID, c.opts.BaseURL); err == nil && value != "" { // OpenAI uses reasoning_effort; OpenRouter accepts a reasoning object. - body["reasoning_effort"] = e + // Every validated value is sent as-is, including a marked disable + // value: omitting it would leave reasoning enabled at the model's + // default instead of honoring the user's explicit Off choice. if strings.Contains(c.opts.BaseURL, "openrouter.ai") { - delete(body, "reasoning_effort") - body["reasoning"] = map[string]any{"effort": e} + body["reasoning"] = map[string]any{"effort": value} + } else { + body["reasoning_effort"] = value } } for k, v := range req.Extra { @@ -254,6 +268,9 @@ func (u *oaUsage) normalise() Usage { } func (c *openAIClient) Chat(ctx context.Context, req Request) (*Response, error) { + if _, err := reasoningValue(req, c.reasoningKind(), c.opts.ProviderID, c.opts.BaseURL); err != nil { + return nil, err + } var raw oaResponse if err := c.opts.doJSON(ctx, "POST", c.endpoint("/chat/completions", req.Model), c.buildBody(req, false), c.headers(), &raw); err != nil { return nil, err @@ -283,6 +300,9 @@ func (c *openAIClient) Chat(ctx context.Context, req Request) (*Response, error) } func (c *openAIClient) Stream(ctx context.Context, req Request, emit func(Event) error) (*Response, error) { + if _, err := reasoningValue(req, c.reasoningKind(), c.opts.ProviderID, c.opts.BaseURL); err != nil { + return nil, err + } httpResp, err := c.opts.doStream(ctx, "POST", c.endpoint("/chat/completions", req.Model), c.buildBody(req, true), c.headers()) if err != nil { return nil, err @@ -418,6 +438,7 @@ func (c *openAIClient) Models(ctx context.Context) ([]ModelInfo, error) { Architecture *struct { InputModalities []string `json:"input_modalities"` } `json:"architecture"` + Reasoning *openRouterReasoningMetadata `json:"reasoning"` } `json:"data"` } if err := c.opts.doJSON(ctx, "GET", c.opts.BaseURL+"/models", nil, c.headers(), &raw); err != nil { @@ -443,6 +464,9 @@ func (c *openAIClient) Models(ctx context.Context) ([]ModelInfo, error) { } } } + if capability := openRouterReasoningCapability(m.Reasoning); capability != nil { + info = info.WithReasoningCapability(capability) + } out = append(out, info) } return out, nil diff --git a/internal/llm/reasoning.go b/internal/llm/reasoning.go new file mode 100644 index 0000000..8b62da2 --- /dev/null +++ b/internal/llm/reasoning.go @@ -0,0 +1,114 @@ +package llm + +import ( + "errors" + "fmt" +) + +// ReasoningValueKind describes special behavior associated with a reasoning +// effort value. +type ReasoningValueKind string + +const ReasoningValueDisable ReasoningValueKind = "disable" + +// ReasoningValue is one provider-defined reasoning effort. Value is opaque: +// callers must retain its spelling and case when sending it to a provider. +type ReasoningValue struct { + Value string `json:"value"` + Label string `json:"label"` + Kind ReasoningValueKind `json:"kind,omitempty"` +} + +// ReasoningCapabilitySource identifies how the capability was obtained. +type ReasoningCapabilitySource string + +const ( + ReasoningCapabilityLive ReasoningCapabilitySource = "live" + ReasoningCapabilityStatic ReasoningCapabilitySource = "static" +) + +// ReasoningCapability describes the effort values supported by one model. +type ReasoningCapability struct { + Values []ReasoningValue `json:"values"` + Default string `json:"default,omitempty"` + Mandatory bool `json:"mandatory"` + CanDisable bool `json:"can_disable"` + Source ReasoningCapabilitySource `json:"source"` +} + +// NewReasoningCapability constructs a valid immutable-by-convention capability. +func NewReasoningCapability(values []ReasoningValue, defaultValue string, mandatory bool, source ReasoningCapabilitySource) (*ReasoningCapability, error) { + allowed := make(map[string]struct{}, len(values)) + disableCount := 0 + for _, value := range values { + if value.Value == "" { + return nil, fmt.Errorf("reasoning capability contains an empty value") + } + if _, exists := allowed[value.Value]; exists { + return nil, fmt.Errorf("reasoning capability contains duplicate value %q", value.Value) + } + allowed[value.Value] = struct{}{} + switch value.Kind { + case "": + case ReasoningValueDisable: + disableCount++ + default: + return nil, fmt.Errorf("reasoning capability value %q has unknown kind %q", value.Value, value.Kind) + } + } + if disableCount > 1 { + return nil, fmt.Errorf("reasoning capability contains multiple disable values") + } + if mandatory && disableCount != 0 { + return nil, fmt.Errorf("mandatory reasoning capability cannot include a disable value") + } + if defaultValue == "" { + return nil, fmt.Errorf("reasoning capability default is required") + } + if _, ok := allowed[defaultValue]; !ok { + return nil, fmt.Errorf("reasoning capability default %q is not allowed", defaultValue) + } + + return &ReasoningCapability{ + Values: append([]ReasoningValue(nil), values...), + Default: defaultValue, + Mandatory: mandatory, + CanDisable: disableCount == 1, + Source: source, + }, nil +} + +// UnsupportedReasoningEffortError reports an override not advertised by a model. +type UnsupportedReasoningEffortError struct { + Model string + Allowed []string +} + +func (e *UnsupportedReasoningEffortError) Error() string { + return fmt.Sprintf("unsupported reasoning override for model %q (allowed: %v)", e.Model, e.Allowed) +} + +// IsUnsupportedReasoningEffort reports whether err is an unsupported +// reasoning override without requiring callers to inspect or expose its value. +func IsUnsupportedReasoningEffort(err error) bool { + var unsupported *UnsupportedReasoningEffortError + return errors.As(err, &unsupported) +} + +// ValidateReasoningEffort accepts Auto (an empty effort) for every model and +// otherwise requires an exact, advertised opaque value. +func ValidateReasoningEffort(model string, capability *ReasoningCapability, effort string) error { + if effort == "" { + return nil + } + allowed := make([]string, 0) + if capability != nil { + for _, value := range capability.Values { + allowed = append(allowed, value.Value) + if effort == value.Value { + return nil + } + } + } + return &UnsupportedReasoningEffortError{Model: model, Allowed: allowed} +} diff --git a/internal/llm/reasoning_catalog.go b/internal/llm/reasoning_catalog.go new file mode 100644 index 0000000..dc185c4 --- /dev/null +++ b/internal/llm/reasoning_catalog.go @@ -0,0 +1,101 @@ +package llm + +import ( + "strings" + "time" +) + +type reasoningCapabilityEntry struct { + model string + values []ReasoningValue +} + +var openAIReasoningCapabilities = []reasoningCapabilityEntry{ + // https://platform.openai.com/docs/guides/reasoning + {model: "gpt-5", values: reasoningValuesFor("minimal", "low", "medium", "high")}, +} + +var codexReasoningCapabilities = []reasoningCapabilityEntry{ + // https://platform.openai.com/docs/models/gpt-5.3-codex + {model: "gpt-5.3-codex", values: reasoningValuesFor("low", "medium", "high", "xhigh")}, +} + +var anthropicReasoningCapabilities = []reasoningCapabilityEntry{ + // https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking + {model: "claude-sonnet-5", values: reasoningValuesFor("low", "medium", "high", "xhigh", "max")}, +} + +var geminiReasoningCapabilities = []reasoningCapabilityEntry{ + // https://ai.google.dev/gemini-api/docs/thinking + {model: "gemini-3.6-flash", values: reasoningValuesFor("minimal", "low", "medium", "high")}, +} + +func reasoningValuesFor(values ...string) []ReasoningValue { + out := make([]ReasoningValue, 0, len(values)) + for _, value := range values { + out = append(out, ReasoningValue{Value: value, Label: strings.ToUpper(value)}) + } + return out +} + +// StaticReasoningCapability resolves documented model capabilities when a +// provider cannot supply live metadata. It deliberately recognises only direct +// vendor adapters and explicit model families; OpenAI-compatible endpoints +// remain Auto-only because their upstream capabilities are unknowable. +func StaticReasoningCapability(kind, provider, baseURL, model string) *ReasoningCapability { + var entries []reasoningCapabilityEntry + switch { + case kind == "openai" && provider == "openai" && baseURL == "https://api.openai.com/v1": + entries = openAIReasoningCapabilities + case kind == "codex" && provider == "openai" && baseURL == "https://api.openai.com/v1": + entries = codexReasoningCapabilities + case kind == "anthropic" && provider == "anthropic" && isAnthropicDirectBaseURL(baseURL): + entries = anthropicReasoningCapabilities + case kind == "gemini" && isGeminiDirectProvider(provider) && baseURL == "https://generativelanguage.googleapis.com/v1beta": + entries = geminiReasoningCapabilities + default: + return nil + } + + for _, entry := range entries { + if exactModelOrDatedSnapshot(entry.model, model) { + capability, err := NewReasoningCapability(entry.values, entry.values[0].Value, false, ReasoningCapabilityStatic) + if err != nil { + panic(err) + } + return capability + } + } + return nil +} + +// isAnthropicDirectBaseURL recognises both forms of Anthropic's own base URL +// seen at runtime: the bare host (as documented) and the "/v1" form that this +// codebase's provider defaults actually configure. Both are exact, canonical +// Anthropic hosts — this is not a broad prefix match, so a custom or +// Anthropic-compatible endpoint still falls back to Auto-only. +func isAnthropicDirectBaseURL(baseURL string) bool { + return baseURL == "https://api.anthropic.com" || baseURL == "https://api.anthropic.com/v1" +} + +// isGeminiDirectProvider recognises both the documented canonical provider id +// ("google") and the shipped provider id this codebase actually configures +// for direct Gemini ("gemini" — internal/config/defaults.go's providers map +// key). This is an exact two-value allowlist, not a broad match: any other +// provider id (a custom or Gemini-compatible reverse proxy) still falls back +// to Auto-only, matching direct-request behavior for other kinds. +func isGeminiDirectProvider(provider string) bool { + return provider == "google" || provider == "gemini" +} + +func exactModelOrDatedSnapshot(family, model string) bool { + if model == family { + return true + } + snapshot, ok := strings.CutPrefix(model, family+"-") + if !ok { + return false + } + _, err := time.Parse("2006-01-02", snapshot) + return err == nil +} diff --git a/internal/llm/reasoning_request.go b/internal/llm/reasoning_request.go new file mode 100644 index 0000000..594f4e9 --- /dev/null +++ b/internal/llm/reasoning_request.go @@ -0,0 +1,94 @@ +package llm + +import "strings" + +// reasoningValue resolves and validates the effort value to send upstream. +// Auto (an empty ReasoningEffort) is always valid and produces no override. A +// non-empty value is validated against the request's attached capability, +// falling back to the static catalogue when the caller did not attach one. +// Callers wired through the agent layer normally attach an already-resolved +// capability; the fallback is defense in depth for direct/test callers. +func reasoningValue(req Request, kind, providerID, baseURL string) (string, error) { + value := req.ReasoningEffort + if value == "" { + return "", nil + } + capability := req.ReasoningCapability + if capability == nil { + capability = StaticReasoningCapability(kind, providerID, baseURL, req.Model) + } + if err := ValidateReasoningEffort(req.Model, capability, value); err != nil { + return "", err + } + return value, nil +} + +// resolvedReasoningCapability mirrors reasoningValue's capability resolution +// so adapters can inspect capability metadata (such as the marked disable +// value) once a value has already been validated. +func resolvedReasoningCapability(req Request, kind, providerID, baseURL string) *ReasoningCapability { + if req.ReasoningCapability != nil { + return req.ReasoningCapability + } + return StaticReasoningCapability(kind, providerID, baseURL, req.Model) +} + +// reasoningDisableValue returns the capability's marked disable value, or "" +// when the capability cannot disable reasoning. +func reasoningDisableValue(capability *ReasoningCapability) string { + if capability == nil { + return "" + } + for _, v := range capability.Values { + if v.Kind == ReasoningValueDisable { + return v.Value + } + } + return "" +} + +// openRouterReasoningMetadata is OpenRouter's per-model reasoning metadata +// returned from GET /models. +// https://openrouter.ai/docs/guides/best-practices/reasoning-tokens +type openRouterReasoningMetadata struct { + SupportedEfforts []string `json:"supported_efforts"` + DefaultEffort string `json:"default_effort"` + DefaultEnabled *bool `json:"default_enabled"` + Mandatory bool `json:"mandatory"` + SupportsMaxTokens bool `json:"supports_max_tokens"` +} + +// openRouterReasoningCapability builds a live capability from OpenRouter's +// documented reasoning metadata. Only the documented "none" value is marked +// as a disable choice. Contradictory metadata — a mandatory model that also +// lists "none", or a shape NewReasoningCapability otherwise rejects — yields +// no capability rather than being silently repaired. +func openRouterReasoningCapability(meta *openRouterReasoningMetadata) *ReasoningCapability { + if meta == nil || len(meta.SupportedEfforts) == 0 { + return nil + } + if meta.Mandatory { + for _, effort := range meta.SupportedEfforts { + if effort == "none" { + return nil + } + } + } + values := make([]ReasoningValue, 0, len(meta.SupportedEfforts)) + for _, effort := range meta.SupportedEfforts { + value := ReasoningValue{Value: effort, Label: strings.ToUpper(effort)} + if effort == "none" { + value.Kind = ReasoningValueDisable + } + values = append(values, value) + } + def := meta.DefaultEffort + if def == "" { + def = meta.SupportedEfforts[0] + } + capability, err := NewReasoningCapability(values, def, meta.Mandatory, ReasoningCapabilityLive) + if err != nil { + return nil + } + return capability +} diff --git a/internal/llm/reasoning_request_test.go b/internal/llm/reasoning_request_test.go new file mode 100644 index 0000000..cfa2fa5 --- /dev/null +++ b/internal/llm/reasoning_request_test.go @@ -0,0 +1,269 @@ +package llm + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "reflect" + "sync/atomic" + "testing" +) + +func TestOpenRouterReasoningBodySendsExplicitDisable(t *testing.T) { + cap, err := NewReasoningCapability( + []ReasoningValue{ + {Value: "none", Label: "Off", Kind: ReasoningValueDisable}, + {Value: "high", Label: "High"}, + }, + "high", false, ReasoningCapabilityLive, + ) + if err != nil { + t.Fatal(err) + } + c := &openAIClient{opts: Options{BaseURL: "https://openrouter.ai/api/v1"}} + body := c.buildBody(Request{ + Model: "vendor/model", ReasoningEffort: "none", ReasoningCapability: cap, + }, false) + reasoning, ok := body["reasoning"].(map[string]any) + if !ok || reasoning["effort"] != "none" { + t.Fatalf("reasoning = %#v", body["reasoning"]) + } + if _, ok := body["reasoning_effort"]; ok { + t.Fatalf("unexpected reasoning_effort alongside reasoning: %#v", body["reasoning_effort"]) + } +} + +func TestReasoningAutoOmitsProviderFields(t *testing.T) { + req := Request{Model: "gpt-5", ReasoningEffort: ""} + if body := (&openAIClient{}).buildBody(req, false); body["reasoning_effort"] != nil { + t.Fatalf("OpenAI body = %#v", body) + } + if body := (&codexClient{}).buildBody(req, false); body["reasoning"] != nil { + t.Fatalf("Codex body = %#v", body) + } +} + +func TestOpenAIReasoningEffortBody(t *testing.T) { + cap := StaticReasoningCapability("openai", "openai", "https://api.openai.com/v1", "gpt-5") + if cap == nil { + t.Fatal("expected a static capability for gpt-5") + } + c := &openAIClient{opts: Options{BaseURL: "https://api.openai.com/v1", ProviderID: "openai"}, vendor: "openai"} + body := c.buildBody(Request{Model: "gpt-5", ReasoningEffort: "high", ReasoningCapability: cap}, false) + if body["reasoning_effort"] != "high" { + t.Fatalf("reasoning_effort = %#v", body["reasoning_effort"]) + } + if _, ok := body["reasoning"]; ok { + t.Fatalf("unexpected reasoning field: %#v", body["reasoning"]) + } +} + +func TestOpenAIReasoningEffortFallsBackToStaticCapability(t *testing.T) { + c := &openAIClient{opts: Options{BaseURL: "https://api.openai.com/v1", ProviderID: "openai"}, vendor: "openai"} + body := c.buildBody(Request{Model: "gpt-5", ReasoningEffort: "minimal"}, false) + if body["reasoning_effort"] != "minimal" { + t.Fatalf("reasoning_effort = %#v, want fallback to static capability to validate it", body["reasoning_effort"]) + } +} + +func TestCodexReasoningEffortBody(t *testing.T) { + c := &codexClient{opts: Options{BaseURL: "https://api.openai.com/v1", ProviderID: "openai"}} + body := c.buildBody(Request{Model: "gpt-5.3-codex", ReasoningEffort: "xhigh"}, false) + want := map[string]any{"effort": "xhigh"} + if got := body["reasoning"]; !reflect.DeepEqual(got, want) { + t.Fatalf("reasoning = %#v, want %#v", got, want) + } +} + +func TestAnthropicAdaptiveThinkingBody(t *testing.T) { + // The upstream base URL is corrected to the exact static-catalogue key + // (https://api.anthropic.com) so this capability actually resolves; + // see task-2-report.md for why the brief's literal "" argument is a + // vacuous match against the Task 1 catalogue. + cap := StaticReasoningCapability("anthropic", "anthropic", "https://api.anthropic.com", "claude-sonnet-5") + if cap == nil { + t.Fatal("expected a static capability for claude-sonnet-5") + } + body := (&anthropicClient{}).buildBody(Request{ + Model: "claude-sonnet-5", ReasoningEffort: "xhigh", ReasoningCapability: cap, + }, false) + if got, want := body["thinking"], map[string]any{"type": "adaptive"}; !reflect.DeepEqual(got, want) { + t.Fatalf("thinking = %#v, want %#v", got, want) + } + if got, want := body["output_config"], map[string]any{"effort": "xhigh"}; !reflect.DeepEqual(got, want) { + t.Fatalf("output_config = %#v, want %#v", got, want) + } +} + +func TestAnthropicLegacyThinkingBudgetBody(t *testing.T) { + cap, err := NewReasoningCapability( + []ReasoningValue{{Value: "low", Label: "Low"}, {Value: "medium", Label: "Medium"}, {Value: "high", Label: "High"}}, + "low", false, ReasoningCapabilityStatic, + ) + if err != nil { + t.Fatal(err) + } + body := (&anthropicClient{}).buildBody(Request{ + Model: "claude-3-7-sonnet", ReasoningEffort: "medium", ReasoningCapability: cap, + }, false) + if got, want := body["thinking"], map[string]any{"type": "enabled", "budget_tokens": 8192}; !reflect.DeepEqual(got, want) { + t.Fatalf("thinking = %#v, want %#v", got, want) + } + if _, ok := body["output_config"]; ok { + t.Fatalf("unexpected output_config on a legacy model: %#v", body["output_config"]) + } +} + +// TestAnthropicLegacyUnmappableEffortFailsBeforeRequest guards a silent-drop +// bug: a value can pass validation against an *attached* capability (e.g. a +// hypothetical live capability advertising more values than this codebase's +// hardcoded legacy budget table knows) yet have no entry in +// anthropicLegacyThinkingBudgets for that model family. buildBody previously +// just omitted "thinking" in that case, silently downgrading the turn to +// Auto. Chat/Stream must now fail before any request is sent. +func TestAnthropicLegacyUnmappableEffortFailsBeforeRequest(t *testing.T) { + cap, err := NewReasoningCapability( + []ReasoningValue{{Value: "low", Label: "Low"}, {Value: "xhigh", Label: "Extra High"}}, + "low", false, ReasoningCapabilityStatic, + ) + if err != nil { + t.Fatal(err) + } + + var hits int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&hits, 1) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{}`)) + })) + defer srv.Close() + + c := &anthropicClient{opts: Options{BaseURL: srv.URL, HTTPClient: srv.Client()}} + _, err = c.Chat(context.Background(), Request{ + Model: "claude-3-7-sonnet", ReasoningEffort: "xhigh", ReasoningCapability: cap, + }) + var unsupported *UnsupportedReasoningEffortError + if !errors.As(err, &unsupported) { + t.Fatalf("err = %v, want UnsupportedReasoningEffortError", err) + } + if got := atomic.LoadInt32(&hits); got != 0 { + t.Fatalf("requests sent = %d, want 0", got) + } + + // buildBody alone (bypassing Chat) must also stay silent-safe: it must not + // fabricate a "thinking" override it cannot actually honor. + body := (&anthropicClient{}).buildBody(Request{ + Model: "claude-3-7-sonnet", ReasoningEffort: "xhigh", ReasoningCapability: cap, + }, false) + if _, ok := body["thinking"]; ok { + t.Fatalf("buildBody emitted a thinking override for an unmappable legacy effort: %#v", body["thinking"]) + } +} + +func TestAnthropicAdaptiveDisableBody(t *testing.T) { + cap, err := NewReasoningCapability( + []ReasoningValue{ + {Value: "off", Label: "Off", Kind: ReasoningValueDisable}, + {Value: "low", Label: "Low"}, + }, + "low", false, ReasoningCapabilityLive, + ) + if err != nil { + t.Fatal(err) + } + body := (&anthropicClient{}).buildBody(Request{ + Model: "claude-sonnet-5", ReasoningEffort: "off", ReasoningCapability: cap, + }, false) + if got, want := body["thinking"], map[string]any{"type": "disabled"}; !reflect.DeepEqual(got, want) { + t.Fatalf("thinking = %#v, want %#v", got, want) + } + if _, ok := body["output_config"]; ok { + t.Fatalf("unexpected output_config for a disabled turn: %#v", body["output_config"]) + } +} + +func TestReasoningValidationBlocksRequestBeforeNetworkIO(t *testing.T) { + var count int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&count, 1) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{}`)) + })) + defer srv.Close() + + cap, err := NewReasoningCapability([]ReasoningValue{{Value: "low", Label: "Low"}}, "low", false, ReasoningCapabilityStatic) + if err != nil { + t.Fatal(err) + } + req := Request{Model: "test-model", ReasoningEffort: "max", ReasoningCapability: cap} + + clients := map[string]Client{ + "openai": &openAIClient{opts: Options{BaseURL: srv.URL, HTTPClient: srv.Client()}, vendor: "openai"}, + "codex": &codexClient{opts: Options{BaseURL: srv.URL, HTTPClient: srv.Client()}}, + "anthropic": &anthropicClient{opts: Options{BaseURL: srv.URL, HTTPClient: srv.Client()}}, + "gemini": &geminiClient{opts: Options{BaseURL: srv.URL, HTTPClient: srv.Client()}}, + } + for name, c := range clients { + _, err := c.Chat(context.Background(), req) + var unsupported *UnsupportedReasoningEffortError + if !errors.As(err, &unsupported) { + t.Fatalf("%s: err = %v, want UnsupportedReasoningEffortError", name, err) + } + } + if got := atomic.LoadInt32(&count); got != 0 { + t.Fatalf("requests sent = %d, want 0", got) + } +} + +func TestOpenRouterModelsBuildsLiveReasoningCapability(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":[{"id":"vendor/model","reasoning":{"supported_efforts":["none","low","high"],"default_effort":"high","mandatory":false,"supports_max_tokens":false}}]}`)) + })) + defer srv.Close() + c := &openAIClient{opts: Options{BaseURL: srv.URL, HTTPClient: srv.Client()}} + models, err := c.Models(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(models) != 1 { + t.Fatalf("models = %+v", models) + } + cap := models[0].ReasoningCapability + if cap == nil { + t.Fatal("expected a live reasoning capability") + } + if cap.Source != ReasoningCapabilityLive || cap.Default != "high" { + t.Fatalf("capability = %#v", cap) + } + if !models[0].Reasoning { + t.Fatal("expected legacy Reasoning boolean to be set") + } + var disableFound bool + for _, v := range cap.Values { + if v.Value == "none" { + disableFound = v.Kind == ReasoningValueDisable + } + } + if !disableFound { + t.Fatal(`expected "none" marked as disable`) + } +} + +func TestOpenRouterModelsRejectsContradictoryReasoningMetadata(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data":[{"id":"vendor/model","reasoning":{"supported_efforts":["none","low"],"default_effort":"low","mandatory":true}}]}`)) + })) + defer srv.Close() + c := &openAIClient{opts: Options{BaseURL: srv.URL, HTTPClient: srv.Client()}} + models, err := c.Models(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(models) != 1 { + t.Fatalf("models = %+v", models) + } + if got := models[0].ReasoningCapability; got != nil { + t.Fatalf("expected no capability for contradictory metadata, got %#v", got) + } +} diff --git a/internal/llm/reasoning_test.go b/internal/llm/reasoning_test.go new file mode 100644 index 0000000..ca9eac0 --- /dev/null +++ b/internal/llm/reasoning_test.go @@ -0,0 +1,207 @@ +package llm + +import ( + "errors" + "reflect" + "slices" + "strings" + "testing" +) + +func TestReasoningCapabilityRejectsInconsistentDisableMetadata(t *testing.T) { + _, err := NewReasoningCapability( + []ReasoningValue{{Value: "none", Label: "Off", Kind: ReasoningValueDisable}}, + "", true, ReasoningCapabilityStatic, + ) + if err == nil || !strings.Contains(err.Error(), "mandatory") { + t.Fatalf("err = %v, want mandatory/disable conflict", err) + } +} + +func TestValidateReasoningEffortPreservesOpaqueValues(t *testing.T) { + cap, err := NewReasoningCapability( + []ReasoningValue{ + {Value: "extra-high", Label: "Extra High"}, + {Value: "xhigh", Label: "Extra High (new)"}, + }, + "extra-high", false, ReasoningCapabilityLive, + ) + if err != nil { + t.Fatal(err) + } + if err := ValidateReasoningEffort("gpt-example", cap, "extra-high"); err != nil { + t.Fatal(err) + } + if err := ValidateReasoningEffort("gpt-example", cap, "EXTRA-HIGH"); err == nil { + t.Fatal("case-normalized value was accepted") + } +} + +func TestValidateReasoningEffortAcceptsAuto(t *testing.T) { + cap, err := NewReasoningCapability( + []ReasoningValue{{Value: "low", Label: "Low"}}, + "low", false, ReasoningCapabilityStatic, + ) + if err != nil { + t.Fatal(err) + } + if err := ValidateReasoningEffort("gpt-example", cap, ""); err != nil { + t.Fatalf("err = %v, want Auto accepted", err) + } +} + +func TestValidateReasoningEffortRejectsUnknownOverride(t *testing.T) { + cap, err := NewReasoningCapability( + []ReasoningValue{{Value: "low", Label: "Low"}}, + "low", false, ReasoningCapabilityStatic, + ) + if err != nil { + t.Fatal(err) + } + err = ValidateReasoningEffort("gpt-example", cap, "high") + var unsupported *UnsupportedReasoningEffortError + if !errors.As(err, &unsupported) { + t.Fatalf("err = %v, want UnsupportedReasoningEffortError", err) + } + if unsupported.Model != "gpt-example" || !slices.Equal(unsupported.Allowed, []string{"low"}) { + t.Fatalf("error = %#v", unsupported) + } +} + +func TestValidateReasoningEffortDoesNotExposeSubmittedOverride(t *testing.T) { + const submitted = "reasoning-override-secret" + cap, err := NewReasoningCapability( + []ReasoningValue{{Value: "low", Label: "Low"}}, + "low", false, ReasoningCapabilityStatic, + ) + if err != nil { + t.Fatal(err) + } + err = ValidateReasoningEffort("gpt-example", cap, submitted) + var unsupported *UnsupportedReasoningEffortError + if !errors.As(err, &unsupported) { + t.Fatalf("err = %v, want UnsupportedReasoningEffortError", err) + } + if strings.Contains(err.Error(), submitted) { + t.Fatalf("error leaks submitted override: %q", err) + } + if _, found := reflect.TypeOf(*unsupported).FieldByName("Effort"); found { + t.Fatal("UnsupportedReasoningEffortError exposes the submitted override") + } +} + +func TestModelInfoWithReasoningCapabilityEnablesReasoning(t *testing.T) { + cap, err := NewReasoningCapability( + []ReasoningValue{{Value: "low", Label: "Low"}}, + "low", false, ReasoningCapabilityStatic, + ) + if err != nil { + t.Fatal(err) + } + info := (ModelInfo{ID: "gpt-example"}).WithReasoningCapability(cap) + if !info.Reasoning { + t.Fatal("Reasoning = false, want true when capability is attached") + } + if info.ReasoningCapability != cap { + t.Fatalf("ReasoningCapability = %#v, want %#v", info.ReasoningCapability, cap) + } +} + +func TestReasoningCapabilityRequiresUniqueValues(t *testing.T) { + _, err := NewReasoningCapability( + []ReasoningValue{{Value: "low", Label: "Low"}, {Value: "low", Label: "Low again"}}, + "low", false, ReasoningCapabilityStatic, + ) + if err == nil || !strings.Contains(err.Error(), "duplicate") { + t.Fatalf("err = %v, want duplicate value error", err) + } +} + +func TestReasoningCapabilityDefaultMustBeAllowed(t *testing.T) { + _, err := NewReasoningCapability( + []ReasoningValue{{Value: "low", Label: "Low"}}, + "high", false, ReasoningCapabilityStatic, + ) + if err == nil || !strings.Contains(err.Error(), "default") { + t.Fatalf("err = %v, want unsupported default error", err) + } +} + +func TestStaticReasoningCapabilityRepresentativeFamilies(t *testing.T) { + tests := []struct { + kind, provider, baseURL, model string + want []string + disable bool + }{ + {"openai", "openai", "https://api.openai.com/v1", "gpt-5", []string{"minimal", "low", "medium", "high"}, false}, + {"codex", "openai", "https://api.openai.com/v1", "gpt-5.3-codex", []string{"low", "medium", "high", "xhigh"}, false}, + {"anthropic", "anthropic", "https://api.anthropic.com", "claude-sonnet-5", []string{"low", "medium", "high", "xhigh", "max"}, false}, + {"gemini", "google", "https://generativelanguage.googleapis.com/v1beta", "gemini-3.6-flash", []string{"minimal", "low", "medium", "high"}, false}, + } + for _, tt := range tests { + cap := StaticReasoningCapability(tt.kind, tt.provider, tt.baseURL, tt.model) + if got := reasoningValues(cap); !slices.Equal(got, tt.want) { + t.Errorf("%s: got %v, want %v", tt.model, got, tt.want) + } + if cap == nil { + t.Errorf("%s: got nil capability", tt.model) + } else if cap.CanDisable != tt.disable { + t.Errorf("%s: can_disable=%v", tt.model, cap.CanDisable) + } + } +} + +// TestStaticReasoningCapabilityAcceptsRuntimeAnthropicBaseURL guards against a +// regression where every default-config, direct-Anthropic chat request failed +// pre-request validation: the provider default base URL configured in +// internal/config/defaults.go is "https://api.anthropic.com/v1", but the +// catalog originally matched only the bare "https://api.anthropic.com" host. +func TestStaticReasoningCapabilityAcceptsRuntimeAnthropicBaseURL(t *testing.T) { + cap := StaticReasoningCapability("anthropic", "anthropic", "https://api.anthropic.com/v1", "claude-sonnet-5") + if cap == nil { + t.Fatal("got nil capability for the runtime default Anthropic base URL (with /v1)") + } +} + +func TestStaticReasoningCapabilityDoesNotGuessUnknownCompatibleModels(t *testing.T) { + if got := StaticReasoningCapability("openai-compatible", "custom", "https://example.test/v1", "gpt-5"); got != nil { + t.Fatalf("got %#v, want Auto-only", got) + } +} + +// TestStaticReasoningCapabilityAcceptsShippedGeminiProviderID guards against a +// regression where direct Gemini's static catalog only matched the documented +// canonical provider id "google", but internal/config/defaults.go's shipped +// provider map key (and therefore the real runtime llm.Options.ProviderID) is +// "gemini". Without this, the static fallback never fired for the actual +// default Gemini provider. +func TestStaticReasoningCapabilityAcceptsShippedGeminiProviderID(t *testing.T) { + for _, provider := range []string{"google", "gemini"} { + cap := StaticReasoningCapability("gemini", provider, "https://generativelanguage.googleapis.com/v1beta", "gemini-3.6-flash") + if cap == nil { + t.Errorf("provider %q: got nil capability, want a match", provider) + } + } +} + +// TestStaticReasoningCapabilityRejectsUnknownGeminiCompatibleProvider ensures +// widening the Gemini provider match to also accept "gemini" stayed an exact +// two-value allowlist rather than a broad match: an arbitrary custom or +// Gemini-compatible reverse-proxy provider id must still resolve to nil +// (Auto-only), exactly like every other kind's unknown-provider case. +func TestStaticReasoningCapabilityRejectsUnknownGeminiCompatibleProvider(t *testing.T) { + if got := StaticReasoningCapability("gemini", "my-gemini-proxy", "https://generativelanguage.googleapis.com/v1beta", "gemini-3.6-flash"); got != nil { + t.Fatalf("got %#v, want Auto-only for an unrecognised Gemini-compatible provider id", got) + } +} + +func reasoningValues(cap *ReasoningCapability) []string { + if cap == nil { + return nil + } + out := make([]string, 0, len(cap.Values)) + for _, value := range cap.Values { + out = append(out, value.Value) + } + return out +} diff --git a/internal/llm/types.go b/internal/llm/types.go index f36a426..c485c95 100644 --- a/internal/llm/types.go +++ b/internal/llm/types.go @@ -68,19 +68,20 @@ type Tool struct { // Request is a normalised completion request. type Request struct { - Model string - System string - Messages []Message - Tools []Tool - ToolChoice string // auto|none|required| - Temperature float64 - TopP float64 - MaxTokens int - StopSequences []string - ReasoningEffort string // none|low|medium|high - ParallelToolCalls bool - PromptCache bool - Extra map[string]any + Model string + System string + Messages []Message + Tools []Tool + ToolChoice string // auto|none|required| + Temperature float64 + TopP float64 + MaxTokens int + StopSequences []string + ReasoningEffort string // provider-defined opaque value; empty means Auto + ReasoningCapability *ReasoningCapability + ParallelToolCalls bool + PromptCache bool + Extra map[string]any } // Usage reports token accounting for one call. @@ -113,9 +114,9 @@ func (u Usage) ContextSize() int { // Response is the final result of a completion. type Response struct { - Content string `json:"content"` - Reasoning string `json:"reasoning,omitempty"` - ToolCalls []ToolCall `json:"tool_calls,omitempty"` + Content string `json:"content"` + Reasoning string `json:"reasoning,omitempty"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` // ThoughtSignature is Gemini part-level metadata for the final text turn. // Agent history must copy this onto the assistant Message for multi-turn // continuity when the model is not making tool calls. @@ -153,16 +154,27 @@ type Event struct { // ModelInfo describes one model offered by a provider. type ModelInfo struct { - ID string `json:"id"` - Name string `json:"name"` - Provider string `json:"provider"` - ContextWindow int `json:"context_window"` - MaxOutput int `json:"max_output"` - InputCost float64 `json:"input_cost"` // USD per 1M tokens - OutputCost float64 `json:"output_cost"` // USD per 1M tokens - Vision bool `json:"vision"` - Tools bool `json:"tools"` - Reasoning bool `json:"reasoning"` + ID string `json:"id"` + Name string `json:"name"` + Provider string `json:"provider"` + ContextWindow int `json:"context_window"` + MaxOutput int `json:"max_output"` + InputCost float64 `json:"input_cost"` // USD per 1M tokens + OutputCost float64 `json:"output_cost"` // USD per 1M tokens + Vision bool `json:"vision"` + Tools bool `json:"tools"` + Reasoning bool `json:"reasoning"` + ReasoningCapability *ReasoningCapability `json:"reasoning_capability,omitempty"` +} + +// WithReasoningCapability returns a copy enriched with model-specific +// reasoning metadata. A capability implies the legacy Reasoning marker. +func (m ModelInfo) WithReasoningCapability(capability *ReasoningCapability) ModelInfo { + m.ReasoningCapability = capability + if capability != nil { + m.Reasoning = true + } + return m } // Client is a provider adapter. diff --git a/internal/server/cursor_attachments.go b/internal/server/cursor_attachments.go new file mode 100644 index 0000000..dbac0e9 --- /dev/null +++ b/internal/server/cursor_attachments.go @@ -0,0 +1,112 @@ +package server + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + + "github.com/enowdev/antares/internal/cursor" +) + +const ( + maxCursorImages = 5 + maxCursorImageBytes = 15 << 20 + cursorChatBodyLimit = 105 << 20 +) + +// decodeCursorImages validates Cursor's stricter attachment contract and +// returns the original base64 payloads. The single decoded buffer used for +// size and signature validation becomes unreachable before the function +// returns; PromptImage retains no duplicate decoded bytes. +func decodeCursorImages(dataURLs []string) ([]cursor.PromptImage, error) { + if len(dataURLs) > maxCursorImages { + return nil, fmt.Errorf("cursor accepts at most %d images", maxCursorImages) + } + images := make([]cursor.PromptImage, 0, len(dataURLs)) + for i, dataURL := range dataURLs { + dataURL = strings.TrimSpace(dataURL) + if !strings.HasPrefix(dataURL, "data:") { + return nil, fmt.Errorf("cursor image %d must be a base64 data URL", i+1) + } + metadata, payload, ok := strings.Cut(strings.TrimPrefix(dataURL, "data:"), ",") + if !ok || payload == "" { + return nil, fmt.Errorf("cursor image %d must be a base64 data URL", i+1) + } + mimeType, encoding, ok := strings.Cut(metadata, ";") + if !ok || encoding != "base64" || !cursorImageMIMETypeSupported(mimeType) { + return nil, fmt.Errorf("cursor image %d has an unsupported MIME type or encoding", i+1) + } + if strings.ContainsAny(payload, " \t\r\n") { + return nil, fmt.Errorf("cursor image %d contains invalid base64", i+1) + } + + decoder := base64.NewDecoder(base64.StdEncoding.Strict(), strings.NewReader(payload)) + decoded, err := io.ReadAll(io.LimitReader(decoder, maxCursorImageBytes+1)) + if err != nil { + return nil, fmt.Errorf("cursor image %d contains invalid base64: %w", i+1, err) + } + if len(decoded) > maxCursorImageBytes { + return nil, fmt.Errorf("cursor image %d exceeds the 15 MiB decoded limit", i+1) + } + if detected := http.DetectContentType(decoded); detected != mimeType { + return nil, fmt.Errorf( + "cursor image %d MIME type does not match its decoded signature", i+1, + ) + } + images = append(images, cursor.PromptImage{ + Data: payload, + MimeType: mimeType, + }) + } + return images, nil +} + +func cursorImageMIMETypeSupported(mimeType string) bool { + switch mimeType { + case "image/png", "image/jpeg", "image/gif", "image/webp": + return true + default: + return false + } +} + +// decodeCursorChatBody applies Cursor's route-specific request allowance. It +// remains bounded while leaving enough room for five 15 MiB decoded images +// after base64 expansion. +func decodeCursorChatBody(w http.ResponseWriter, r *http.Request, dst any) error { + return decodeCursorChatBodyWithLimit(w, r, dst, cursorChatBodyLimit) +} + +func decodeCursorChatBodyWithLimit( + w http.ResponseWriter, + r *http.Request, + dst any, + limit int64, +) error { + if r == nil || r.Body == nil { + return errors.New("invalid JSON body: body is required") + } + if r.ContentLength > limit { + return fmt.Errorf("invalid JSON body: %w", &http.MaxBytesError{Limit: limit}) + } + + body := http.MaxBytesReader(w, r.Body, limit) + defer body.Close() + decoder := json.NewDecoder(body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(dst); err != nil { + return fmt.Errorf("invalid JSON body: %w", err) + } + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + if err == nil { + return errors.New("invalid JSON body: multiple JSON values") + } + return fmt.Errorf("invalid JSON body: %w", err) + } + return nil +} diff --git a/internal/server/cursor_attachments_test.go b/internal/server/cursor_attachments_test.go new file mode 100644 index 0000000..59a6e7c --- /dev/null +++ b/internal/server/cursor_attachments_test.go @@ -0,0 +1,373 @@ +package server + +import ( + "encoding/base64" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/enowdev/antares/internal/config" + "github.com/enowdev/antares/internal/cursor" + "github.com/enowdev/antares/internal/cursorrun" +) + +func TestDecodeCursorImagesAcceptsExactlyFiveSupportedImages(t *testing.T) { + dataURLs := []string{ + cursorImageDataURL("image/png", cursorImageSignature("image/png")), + cursorImageDataURL("image/jpeg", cursorImageSignature("image/jpeg")), + cursorImageDataURL("image/gif", cursorImageSignature("image/gif")), + cursorImageDataURL("image/webp", cursorImageSignature("image/webp")), + cursorImageDataURL("image/png", cursorImageSignature("image/png")), + } + + images, err := decodeCursorImages(dataURLs) + if err != nil { + t.Fatal(err) + } + if len(images) != 5 { + t.Fatalf("images=%d, want 5", len(images)) + } + for i, image := range images { + mimeType, payload := splitCursorImageDataURL(t, dataURLs[i]) + if image.MimeType != mimeType || image.Data != payload || image.URL != "" { + t.Fatalf("image %d = %+v, want mime=%q original base64 payload", i, image, mimeType) + } + } +} + +func TestDecodeCursorImagesRejectsBeforeDownstreamCallback(t *testing.T) { + validPNG := cursorImageDataURL("image/png", cursorImageSignature("image/png")) + oversized := make([]byte, (15<<20)+1) + copy(oversized, cursorImageSignature("image/png")) + + tests := map[string][]string{ + "six images": { + validPNG, validPNG, validPNG, validPNG, validPNG, validPNG, + }, + "unsupported MIME": { + cursorImageDataURL("image/bmp", cursorImageSignature("image/png")), + }, + "MIME signature mismatch": { + cursorImageDataURL("image/jpeg", cursorImageSignature("image/png")), + }, + "decoded payload above 15 MiB": { + cursorImageDataURL("image/png", oversized), + }, + "remote URL": { + "https://example.invalid/image.png", + }, + "bare base64": { + base64.StdEncoding.EncodeToString(cursorImageSignature("image/png")), + }, + "malformed base64": { + "data:image/png;base64,not-base64!", + }, + } + for name, dataURLs := range tests { + t.Run(name, func(t *testing.T) { + called := false + err := decodeCursorImagesThen(dataURLs, func([]cursor.PromptImage) { + called = true + }) + if err == nil { + t.Fatal("invalid images were accepted") + } + if called { + t.Fatal("downstream approval/upstream callback ran before image validation") + } + }) + } +} + +func TestDecodeCursorImagesAllowsExactDecodedLimit(t *testing.T) { + exact := make([]byte, 15<<20) + copy(exact, cursorImageSignature("image/png")) + images, err := decodeCursorImages([]string{cursorImageDataURL("image/png", exact)}) + if err != nil { + t.Fatalf("exact 15 MiB image rejected: %v", err) + } + if len(images) != 1 { + t.Fatalf("images=%d, want 1", len(images)) + } +} + +func TestCursorImagesBodyUsesStrictJSONDecoding(t *testing.T) { + t.Run("valid", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/chat/cursor", + strings.NewReader(`{"message":"hello","images":[]}`)) + rec := httptest.NewRecorder() + var dst struct { + Message string `json:"message"` + Images []string `json:"images"` + } + if err := decodeCursorChatBody(rec, req, &dst); err != nil { + t.Fatal(err) + } + if dst.Message != "hello" || dst.Images == nil { + t.Fatalf("decoded body = %+v", dst) + } + }) + + t.Run("unknown field", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/chat/cursor", + strings.NewReader(`{"message":"hello","unexpected":true}`)) + rec := httptest.NewRecorder() + var dst struct { + Message string `json:"message"` + } + err := decodeCursorChatBody(rec, req, &dst) + if err == nil || !strings.Contains(err.Error(), "unknown field") { + t.Fatalf("err=%v, want unknown-field rejection", err) + } + }) + + t.Run("trailing JSON", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/chat/cursor", + strings.NewReader(`{"message":"hello"} {"message":"again"}`)) + rec := httptest.NewRecorder() + var dst struct { + Message string `json:"message"` + } + if err := decodeCursorChatBody(rec, req, &dst); err == nil { + t.Fatal("multiple JSON values were accepted") + } + }) +} + +func TestCursorImagesBodyCapAllowsFiveMaximumImagesAndMapsLargerBodiesTo413(t *testing.T) { + const ( + maxImageBytes = 15 << 20 + wantBodyLimit = 105 << 20 + ) + if cursorChatBodyLimit != wantBodyLimit { + t.Fatalf("cursor body limit=%d, want %d", cursorChatBodyLimit, wantBodyLimit) + } + encodedImageBytes := base64.StdEncoding.EncodedLen(maxImageBytes) + fiveImageBodyBytes := len(`{"images":[]}`) + + 5*(len(`data:image/png;base64,`)+encodedImageBytes+2) + 4 + if fiveImageBodyBytes > cursorChatBodyLimit { + t.Fatalf("five legal maximum images need %d bytes, body cap is %d", + fiveImageBodyBytes, cursorChatBodyLimit) + } + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var dst struct{} + if err := decodeCursorChatBody(w, r, &dst); err != nil { + status := http.StatusBadRequest + var maxBytesError *http.MaxBytesError + if errors.As(err, &maxBytesError) { + status = http.StatusRequestEntityTooLarge + } + writeError(w, status, err) + return + } + w.WriteHeader(http.StatusNoContent) + }) + req := httptest.NewRequest(http.MethodPost, "/api/chat/cursor", strings.NewReader(`{}`)) + req.ContentLength = cursorChatBodyLimit + 1 + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("status=%d body=%s, want 413", rec.Code, rec.Body.String()) + } +} + +func TestCursorImagesBodyCapUsesMaxBytesReaderForUnknownLength(t *testing.T) { + body := `{"message":"` + strings.Repeat("a", 80) + `"}` + req := httptest.NewRequest(http.MethodPost, "/api/chat/cursor", strings.NewReader(body)) + req.ContentLength = -1 + rec := httptest.NewRecorder() + var dst struct { + Message string `json:"message"` + } + err := decodeCursorChatBodyWithLimit(rec, req, &dst, 64) + var maxBytesError *http.MaxBytesError + if !errors.As(err, &maxBytesError) { + t.Fatalf("err=%T %v, want *http.MaxBytesError", err, err) + } +} + +func TestCursorRepositoryRouteRequiresDashboardAuthentication(t *testing.T) { + t.Setenv("ANTARES_HOME", t.TempDir()) + dir := t.TempDir() + + t.Run("unprotected dashboard", func(t *testing.T) { + s := New(Options{Config: config.Default()}) + req := httptest.NewRequest(http.MethodGet, + "/api/project/cursor-repository?dir="+url.QueryEscape(dir), nil) + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusPreconditionRequired { + t.Fatalf("status=%d body=%s, want 428", rec.Code, rec.Body.String()) + } + }) + + t.Run("locked dashboard without login", func(t *testing.T) { + cfg := config.Default() + cfg.Server.DashboardPasswordHash = "configured" + s := New(Options{Config: cfg}) + req := httptest.NewRequest(http.MethodGet, + "/api/project/cursor-repository?dir="+url.QueryEscape(dir), nil) + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status=%d body=%s, want 401", rec.Code, rec.Body.String()) + } + }) +} + +func TestCursorRepositoryRouteUsesProjectPathGuardAndReturnsRepositoryInfo(t *testing.T) { + requireServerGit(t) + t.Setenv("ANTARES_HOME", t.TempDir()) + repo := initServerTestRepository(t) + serverRunGit(t, repo, "remote", "add", "origin", "git@github.com:owner/repo.git") + + cfg := config.Default() + cfg.Server.AuthToken = "test-token" + s := New(Options{Config: cfg}) + + req := httptest.NewRequest(http.MethodGet, + "/api/project/cursor-repository?dir="+url.QueryEscape(repo), nil) + req.Header.Set("Authorization", "Bearer test-token") + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + var info cursorrun.RepositoryInfo + if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil { + t.Fatal(err) + } + if !info.Repository || info.URL != "https://github.com/owner/repo" || + info.StartingRef != "main" { + t.Fatalf("repository info = %+v", info) + } + + req = httptest.NewRequest(http.MethodGet, + "/api/project/cursor-repository?dir="+url.QueryEscape("relative/path"), nil) + req.Header.Set("Authorization", "Bearer test-token") + rec = httptest.NewRecorder() + s.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("relative path status=%d body=%s, want 400", rec.Code, rec.Body.String()) + } +} + +func TestCursorRepositoryRouteReturnsDegradedPreflightForUnsafeOrigin(t *testing.T) { + requireServerGit(t) + t.Setenv("ANTARES_HOME", t.TempDir()) + repo := initServerTestRepository(t) + serverRunGit(t, repo, "remote", "add", "origin", + "https://user:secret@github.com/owner/repo.git") + + cfg := config.Default() + cfg.Server.AuthToken = "test-token" + s := New(Options{Config: cfg}) + req := httptest.NewRequest(http.MethodGet, + "/api/project/cursor-repository?dir="+url.QueryEscape(repo), nil) + req.Header.Set("Authorization", "Bearer test-token") + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s, want 200", rec.Code, rec.Body.String()) + } + var info cursorrun.RepositoryInfo + if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil { + t.Fatal(err) + } + if !info.Repository || info.StartingRef != "main" || info.URL != "" || + info.Warning == "" { + t.Fatalf("degraded repository info = %+v", info) + } + if len(info.Warning) > 512 || strings.Contains(rec.Body.String(), "secret") || + strings.Contains(rec.Body.String(), "user") { + t.Fatalf("unsafe origin leaked in response: %s", rec.Body.String()) + } +} + +func decodeCursorImagesThen(dataURLs []string, callback func([]cursor.PromptImage)) error { + images, err := decodeCursorImages(dataURLs) + if err != nil { + return err + } + callback(images) + return nil +} + +func cursorImageDataURL(mimeType string, decoded []byte) string { + return "data:" + mimeType + ";base64," + base64.StdEncoding.EncodeToString(decoded) +} + +func cursorImageSignature(mimeType string) []byte { + switch mimeType { + case "image/png": + return []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'} + case "image/jpeg": + return []byte{0xff, 0xd8, 0xff, 0xdb} + case "image/gif": + return []byte("GIF89a") + case "image/webp": + return []byte{'R', 'I', 'F', 'F', 0, 0, 0, 0, 'W', 'E', 'B', 'P', 'V', 'P'} + default: + return nil + } +} + +func splitCursorImageDataURL(t *testing.T, dataURL string) (string, string) { + t.Helper() + meta, payload, ok := strings.Cut(strings.TrimPrefix(dataURL, "data:"), ",") + if !ok { + t.Fatalf("invalid test data URL: %q", dataURL) + } + mimeType, encoding, ok := strings.Cut(meta, ";") + if !ok || encoding != "base64" { + t.Fatalf("invalid test data URL metadata: %q", meta) + } + return mimeType, payload +} + +func initServerTestRepository(t *testing.T) string { + t.Helper() + repo := filepath.Join(t.TempDir(), "repo") + cmd := exec.Command("git", "init", "--initial-branch=main", repo) + cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git init: %v\n%s", err, out) + } + serverRunGit(t, repo, "config", "user.name", "Task 8 Test") + serverRunGit(t, repo, "config", "user.email", "task8@example.invalid") + if err := os.WriteFile(filepath.Join(repo, "tracked.txt"), []byte("initial\n"), 0o600); err != nil { + t.Fatal(err) + } + serverRunGit(t, repo, "add", "tracked.txt") + serverRunGit(t, repo, "commit", "-m", "initial") + return repo +} + +func serverRunGit(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + cmd.Env = append(os.Environ(), + "GIT_CONFIG_NOSYSTEM=1", + "GIT_TERMINAL_PROMPT=0", + ) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out) + } + return strings.TrimSpace(string(out)) +} + +func requireServerGit(t *testing.T) { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git executable is required") + } +} diff --git a/internal/server/cursor_events.go b/internal/server/cursor_events.go new file mode 100644 index 0000000..7e6e54b --- /dev/null +++ b/internal/server/cursor_events.go @@ -0,0 +1,1040 @@ +package server + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "unicode/utf8" + + "github.com/enowdev/antares/internal/agent" + "github.com/enowdev/antares/internal/approval" + "github.com/enowdev/antares/internal/cursor" + "github.com/enowdev/antares/internal/cursorrun" + "github.com/enowdev/antares/internal/store" +) + +var ( + errCursorStateChanged = errors.New("cursor session state changed") + errCursorAlreadyTerminal = errors.New("cursor session is already terminal") +) + +const ( + cursorCancelInFlight = "ANTARES_CANCEL_IN_FLIGHT" + cursorCancelRequested = "ANTARES_CANCEL_REQUESTED" + cursorCancelAmbiguous = "ANTARES_CANCEL_OUTCOME_AMBIGUOUS" + cursorCancelNoActive = "ANTARES_CANCEL_NO_ACTIVE_RUN" +) + +func cursorCancelState(status string) bool { + switch status { + case cursorCancelInFlight, cursorCancelRequested, cursorCancelAmbiguous: + return true + default: + return false + } +} + +func (s *Server) reconcileCursorCancelCrashMarker( + ctx context.Context, + state *store.CursorSessionState, +) (*store.CursorSessionState, error) { + if state == nil || state.OperationState != store.CursorOperationRunInFlight || + state.RemoteStatus != cursorCancelInFlight { + return state, nil + } + s.cursorCancelMu.Lock() + _, locallyInFlight := s.cursorCancels[state.SessionID] + s.cursorCancelMu.Unlock() + if locallyInFlight { + return state, nil + } + next, err := s.mutateCursorState(ctx, state.SessionID, + func(current *store.CursorSessionState) error { + if current.OperationState != store.CursorOperationRunInFlight || + current.AgentID != state.AgentID || current.RunID != state.RunID || + current.RemoteStatus != cursorCancelInFlight { + return errCursorStateChanged + } + current.RemoteStatus = cursorCancelAmbiguous + return nil + }) + if errors.Is(err, errCursorStateChanged) { + return s.db.GetCursorSessionState(ctx, state.SessionID) + } + return next, err +} + +// mutateCursorState is the coordinator's only durable update primitive. Every +// write reloads and CASes the current revision so stream persistence, edits, +// cancellation, and recovery cannot silently overwrite one another. +func (s *Server) mutateCursorState( + ctx context.Context, + sessionID string, + mutate func(*store.CursorSessionState) error, +) (*store.CursorSessionState, error) { + for attempt := 0; attempt < 32; attempt++ { + if err := ctx.Err(); err != nil { + return nil, err + } + current, err := s.db.GetCursorSessionState(ctx, sessionID) + if err != nil { + return nil, err + } + next := *current + if err := mutate(&next); err != nil { + return nil, err + } + swapped, err := s.db.CompareAndSwapCursorSessionState( + ctx, &next, current.Revision, + ) + if err != nil { + return nil, err + } + if swapped { + return &next, nil + } + } + return nil, errors.New("cursor session state remained contended") +} + +func (s *Server) watchCursorRun( + ctx context.Context, + initial *store.CursorSessionState, + live *liveRun, +) error { + if initial == nil || initial.AgentID == "" || initial.RunID == "" { + return errors.New("Cursor recovery IDs are missing") + } + agentID, runID := initial.AgentID, initial.RunID + partialText := truncateCursorRunes( + s.redactCursorString(initial.PartialText), maxCursorPartialRunes, + ) + partialReasoning := truncateCursorRunes( + s.redactCursorString(initial.PartialReasoning), maxCursorPartialRunes, + ) + + onReset := func() error { + _, err := s.mutateCursorState(context.Background(), initial.SessionID, + func(state *store.CursorSessionState) error { + if state.AgentID != agentID || state.RunID != runID || + state.OperationState != store.CursorOperationRunInFlight { + return errCursorStateChanged + } + state.LastEventID = "" + state.PartialText = "" + state.PartialReasoning = "" + return nil + }) + if err != nil { + return err + } + // Clear memory only after the durable reset succeeded and before Cursor + // replays from the beginning. + partialText = "" + partialReasoning = "" + live.publish(agent.Event{Type: agent.EventReset}) + return nil + } + + emit := func(event cursor.StreamEvent) error { + event.ID = truncateCursorRunes(event.ID, maxCursorIdentifierRunes) + event.Status = truncateCursorRunes( + s.redactCursorString(event.Status), maxCursorRemoteStatusRunes, + ) + event.Text = truncateCursorRunes( + s.redactCursorString(event.Text), maxCursorPartialRunes, + ) + previousText := partialText + previousReasoning := partialReasoning + nextText, nextReasoning := partialText, partialReasoning + switch event.Type { + case "assistant": + nextText = truncateCursorRunes( + s.redactCursorString(nextText+event.Text), maxCursorPartialRunes, + ) + case "thinking": + nextReasoning = truncateCursorRunes( + s.redactCursorString(nextReasoning+event.Text), maxCursorPartialRunes, + ) + case "result": + // Result text is a whole canonical answer, not one more delta. + if event.Text != "" { + nextText = event.Text + } + } + + _, err := s.mutateCursorState(context.Background(), initial.SessionID, + func(state *store.CursorSessionState) error { + if state.AgentID != agentID || state.RunID != runID || + state.OperationState != store.CursorOperationRunInFlight { + return errCursorStateChanged + } + if event.ID != "" { + state.LastEventID = event.ID + } + state.PartialText = nextText + state.PartialReasoning = nextReasoning + if event.Status != "" && !cursorCancelState(state.RemoteStatus) { + state.RemoteStatus = event.Status + } + return nil + }) + if err != nil { + return err + } + partialText, partialReasoning = nextText, nextReasoning + + // Every corresponding durable field above is committed before its live + // event is visible. Tool activity deliberately remains live-only. + switch event.Type { + case "assistant": + if nextText != previousText { + if strings.HasPrefix(nextText, previousText) { + live.publish(agent.Event{ + Type: agent.EventText, + Delta: strings.TrimPrefix(nextText, previousText), + }) + } else { + live.publish(agent.Event{Type: agent.EventReset}) + if nextReasoning != "" { + live.publish(agent.Event{ + Type: agent.EventReasoning, Delta: nextReasoning, + }) + } + if nextText != "" { + live.publish(agent.Event{Type: agent.EventText, Delta: nextText}) + } + } + } + case "thinking": + if nextReasoning != previousReasoning { + if strings.HasPrefix(nextReasoning, previousReasoning) { + live.publish(agent.Event{ + Type: agent.EventReasoning, + Delta: strings.TrimPrefix(nextReasoning, previousReasoning), + }) + } else { + live.publish(agent.Event{Type: agent.EventReset}) + if nextReasoning != "" { + live.publish(agent.Event{ + Type: agent.EventReasoning, Delta: nextReasoning, + }) + } + if nextText != "" { + live.publish(agent.Event{Type: agent.EventText, Delta: nextText}) + } + } + } + case "status": + progress := s.cursorRunner.Progress(event) + live.publish(agent.Event{ + Type: agent.EventToolProgress, + ID: truncateCursorRunes( + s.redactCursorString(event.ID), maxCursorIdentifierRunes, + ), + Name: "cursor", + Message: truncateCursorRunes(s.redactCursorString(progress.Message), 4096), + Chunk: truncateCursorRunes(s.redactCursorString(progress.Chunk), 4096), + }) + case "tool_call": + progress := s.cursorRunner.Progress(event) + live.publish(agent.Event{ + Type: agent.EventToolProgress, + ID: truncateCursorRunes( + s.redactCursorString(event.CallID), maxCursorIdentifierRunes, + ), + Name: truncateCursorRunes( + s.redactCursorString(event.ToolName), maxCursorIdentifierRunes, + ), + Message: truncateCursorRunes(s.redactCursorString(progress.Message), 4096), + Chunk: truncateCursorRunes(s.redactCursorString(progress.Chunk), 4096), + }) + case "result": + if event.Text != "" && event.Text != previousText { + if previousText != "" { + live.publish(agent.Event{Type: agent.EventReset}) + if nextReasoning != "" { + live.publish(agent.Event{ + Type: agent.EventReasoning, Delta: nextReasoning, + }) + } + } + live.publish(agent.Event{Type: agent.EventText, Delta: event.Text}) + } + } + return nil + } + + terminal, err := s.cursorRunner.StreamRun( + ctx, agentID, runID, initial.LastEventID, onReset, emit, + ) + if err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + // A terminal metadata snapshot wins over a later stream failure. + snapshot, snapshotErr := s.cursorRunner.GetRun( + context.Background(), agentID, runID, + ) + if snapshotErr == nil && snapshot != nil && cursorRunTerminal(snapshot.Status) { + return s.finalizeCursorRun( + context.Background(), initial.SessionID, snapshot, live, + ) + } + return err + } + if terminal == nil { + return errors.New("Cursor stream returned no run snapshot") + } + if !cursorRunTerminal(terminal.Status) { + _, _ = s.mutateCursorState(context.Background(), initial.SessionID, + func(state *store.CursorSessionState) error { + if state.AgentID == agentID && state.RunID == runID { + state.RemoteStatus = truncateCursorRunes( + s.redactCursorString(terminal.Status), maxCursorRemoteStatusRunes, + ) + } + return nil + }) + return errors.New("Cursor stream ended before the run became terminal") + } + return s.finalizeCursorRun( + context.Background(), initial.SessionID, terminal, live, + ) +} + +func cursorRunTerminal(status string) bool { + switch strings.ToUpper(strings.TrimSpace(status)) { + case "FINISHED", "ERROR", "CANCELLED", "EXPIRED": + return true + default: + return false + } +} + +func (s *Server) finalizeCursorRun( + ctx context.Context, + sessionID string, + run *cursor.Run, + live *liveRun, +) error { + unlock := s.cursorLifecycles.Lock(sessionID) + defer unlock() + if run == nil || strings.TrimSpace(run.ID) == "" || + strings.TrimSpace(run.AgentID) == "" { + return errors.New("Cursor terminal snapshot is missing IDs") + } + if !cursorRunTerminal(run.Status) { + return fmt.Errorf("Cursor run is not terminal: %s", run.Status) + } + + current, err := s.db.GetCursorSessionState(ctx, sessionID) + if err != nil { + return err + } + if current.RunID != run.ID || current.AgentID != run.AgentID { + return errCursorStateChanged + } + if current.OperationState == store.CursorOperationCommitted { + return nil + } + oldText := current.PartialText + + gitState := "" + if run.Git != nil { + gitState, err = s.marshalCursorGitState(run.Git) + if err != nil { + return err + } + } + canonicalText := truncateCursorRunes( + s.redactCursorString(current.PartialText), maxCursorPartialRunes, + ) + if run.Result != "" { + canonicalText = truncateCursorRunes( + s.redactCursorString(run.Result), maxCursorPartialRunes, + ) + } + canonicalReasoning := truncateCursorRunes( + s.redactCursorString(current.PartialReasoning), maxCursorPartialRunes, + ) + assistantID := current.AssistantMessageID + if assistantID == "" { + assistantID = deterministicCursorAssistantID(current.SessionID, run.ID) + } + + terminalState := current + if current.OperationState != store.CursorOperationTerminal { + terminalState, err = s.mutateCursorState(ctx, current.SessionID, + func(state *store.CursorSessionState) error { + if state.AgentID != run.AgentID || state.RunID != run.ID { + return errCursorStateChanged + } + if state.OperationState == store.CursorOperationCommitted || + state.OperationState == store.CursorOperationTerminal { + // Another finalizer won the terminal CAS. Reuse its + // revision so CommitCursorAssistant arbitrates exactly once. + return errCursorAlreadyTerminal + } + if state.OperationState != store.CursorOperationRunInFlight { + return errCursorStateChanged + } + state.RemoteStatus = truncateCursorRunes( + s.redactCursorString(run.Status), maxCursorRemoteStatusRunes, + ) + state.PartialText = canonicalText + state.PartialReasoning = canonicalReasoning + state.GitState = gitState + state.AssistantMessageID = assistantID + state.OperationState = store.CursorOperationTerminal + state.ReuseValid = state.AgentID != "" + return nil + }) + if errors.Is(err, errCursorAlreadyTerminal) { + terminalState, err = s.db.GetCursorSessionState(ctx, current.SessionID) + } + if err != nil { + return err + } + } + if terminalState.OperationState == store.CursorOperationCommitted { + return nil + } + + message := &store.Message{ + ID: terminalState.AssistantMessageID, SessionID: terminalState.SessionID, + Role: store.RoleAssistant, Content: terminalState.PartialText, + Reasoning: terminalState.PartialReasoning, Model: terminalState.ModelID, + Meta: store.Meta{ + "cursor_remote_status": terminalState.RemoteStatus, + "cursor_git_state": terminalState.GitState, + }, + } + if err := s.db.CommitCursorAssistant(ctx, terminalState, message); err != nil { + latest, getErr := s.db.GetCursorSessionState(ctx, terminalState.SessionID) + if getErr == nil && latest.OperationState == store.CursorOperationCommitted && + latest.RunID == terminalState.RunID && + latest.AssistantMessageID == terminalState.AssistantMessageID { + return nil + } + return err + } + + if live != nil && oldText != canonicalText { + if oldText != "" { + live.publish(agent.Event{Type: agent.EventReset}) + if terminalState.PartialReasoning != "" { + live.publish(agent.Event{ + Type: agent.EventReasoning, Delta: terminalState.PartialReasoning, + }) + } + } + if canonicalText != "" { + live.publish(agent.Event{Type: agent.EventText, Delta: canonicalText}) + } + } + if live != nil && strings.ToUpper(strings.TrimSpace(run.Status)) != "FINISHED" { + live.publish(agent.Event{ + Type: agent.EventError, + Err: "Cursor run ended with status " + + truncateCursorRunes(s.redactCursorString(run.Status), 120), + }) + } + return nil +} + +func (s *Server) marshalCursorGitState(git *cursor.GitState) (string, error) { + if git == nil { + return "", nil + } + safe := cursor.GitState{Branches: []cursor.GitBranch{}} + for _, branch := range git.Branches { + candidate := cursor.GitBranch{ + RepoURL: truncateCursorRunes( + s.redactCursorString(branch.RepoURL), maxCursorRepositoryRunes, + ), + Branch: truncateCursorRunes( + s.redactCursorString(branch.Branch), maxCursorStartingRefRunes, + ), + PRURL: truncateCursorRunes( + s.redactCursorString(branch.PRURL), maxCursorRepositoryRunes, + ), + } + safe.Branches = append(safe.Branches, candidate) + raw, err := json.Marshal(safe) + if err != nil { + return "", err + } + if utf8.RuneCount(raw) > maxCursorGitStateRunes { + safe.Branches = safe.Branches[:len(safe.Branches)-1] + break + } + } + raw, err := json.Marshal(safe) + if err != nil { + return "", err + } + return string(raw), nil +} + +func deterministicCursorAssistantID(sessionID, runID string) string { + sum := sha256.Sum256([]byte(sessionID + "\x00" + runID)) + return "msg_cursor_" + hex.EncodeToString(sum[:12]) +} + +// cursorRecoveryRun first returns an in-memory run. If none exists, it +// atomically reserves one watcher for durable Cursor state. +func (s *Server) cursorRecoveryRun(sessionID string) *liveRun { + if sessionID == "" || s.db == nil || s.cursorRunner == nil { + return nil + } + unlock := s.cursorLifecycles.Lock(sessionID) + defer unlock() + if live := s.hub.get(sessionID); live != nil { + return live + } + state, err := s.db.GetCursorSessionState(context.Background(), sessionID) + if err != nil || !cursorStateNeedsRecovery(state) { + return nil + } + + ctx, stop := context.WithCancel(context.Background()) + live := newCursorLiveRun(liveRunCursorRecovery) + live.beginCursorWatch(stop) + if !s.hub.putIfAbsent(sessionID, live) { + stop() + return s.hub.get(sessionID) + } + // Rehydrate durable partials before resuming after Last-Event-ID. This closes + // the crash window where an event was committed but the process died before + // publishing it to the old in-memory run. + live.publish(agent.Event{Type: agent.EventReset}) + if state.PartialReasoning != "" { + live.publish(agent.Event{ + Type: agent.EventReasoning, + Delta: truncateCursorRunes( + s.redactCursorString(state.PartialReasoning), maxCursorPartialRunes, + ), + }) + } + if state.PartialText != "" { + live.publish(agent.Event{ + Type: agent.EventText, + Delta: truncateCursorRunes( + s.redactCursorString(state.PartialText), maxCursorPartialRunes, + ), + }) + } + go func() { + defer func() { + if recovered := recover(); recovered != nil { + live.publish(agent.Event{ + Type: agent.EventError, Err: "Cursor recovery failed", + }) + } + live.publish(agent.Event{Type: agent.EventDone}) + live.finish() + stop() + s.hub.remove(sessionID, live) + }() + if err := s.recoverCursorSession(ctx, sessionID, live); err != nil { + if errors.Is(err, context.Canceled) { + live.publish(agent.Event{ + Type: agent.EventNotice, + Message: "stopped watching Cursor; the remote run may still be active", + }) + return + } + live.publish(agent.Event{Type: agent.EventError, Err: s.cursorEventError(err)}) + } + }() + return live +} + +func cursorStateNeedsRecovery(state *store.CursorSessionState) bool { + if state == nil { + return false + } + switch state.OperationState { + case store.CursorOperationAwaitingApproval, + store.CursorOperationCreateInFlight, + store.CursorOperationRunInFlight, + store.CursorOperationTerminal, + store.CursorOperationAmbiguous: + return true + default: + return false + } +} + +func (s *Server) recoverCursorSession( + ctx context.Context, + sessionID string, + live *liveRun, +) error { + state, err := s.db.GetCursorSessionState(ctx, sessionID) + if err != nil { + return err + } + if state.OperationState == store.CursorOperationRunInFlight && + state.RemoteStatus == cursorCancelInFlight { + state, err = s.reconcileCursorCancelCrashMarker( + context.Background(), state, + ) + if err != nil { + return err + } + if live != nil { + live.publish(agent.Event{ + Type: agent.EventNotice, + Message: "Cursor cancellation outcome is ambiguous after restart; it will not be retried. Delete this local session to discard it.", + }) + } + } + switch state.OperationState { + case store.CursorOperationAwaitingApproval: + _, err := s.mutateCursorState(context.Background(), sessionID, + func(current *store.CursorSessionState) error { + if current.OperationState != store.CursorOperationAwaitingApproval { + return errCursorStateChanged + } + current.OperationState = store.CursorOperationIdle + current.ReuseValid = false + current.RemoteStatus = "APPROVAL_LOST_AFTER_RESTART" + return nil + }) + if err != nil { + return err + } + return errors.New("Cursor approval was interrupted by a server restart; no remote request was sent") + + case store.CursorOperationAmbiguous: + return errors.New( + "Cursor create outcome is ambiguous and will not be retried automatically; delete this local session to discard it without retrying or cancelling remote work", + ) + + case store.CursorOperationCreateInFlight, store.CursorOperationRunInFlight: + if state.AgentID == "" || state.RunID == "" { + _, updateErr := s.mutateCursorState(context.Background(), sessionID, + func(current *store.CursorSessionState) error { + if current.OperationState != state.OperationState { + return errCursorStateChanged + } + current.OperationState = store.CursorOperationAmbiguous + current.ReuseValid = false + current.RemoteStatus = "AMBIGUOUS_CREATE_OUTCOME" + return nil + }) + if updateErr != nil { + return updateErr + } + return errors.New( + "Cursor create outcome is ambiguous and will not be retried automatically; delete this local session to discard it without retrying or cancelling remote work", + ) + } + if state.OperationState == store.CursorOperationCreateInFlight { + state, err = s.mutateCursorState(context.Background(), sessionID, + func(current *store.CursorSessionState) error { + if current.OperationState != store.CursorOperationCreateInFlight || + current.AgentID != state.AgentID || current.RunID != state.RunID { + return errCursorStateChanged + } + current.OperationState = store.CursorOperationRunInFlight + return nil + }) + if err != nil { + return err + } + } + snapshot, snapshotErr := s.cursorRunner.GetRun( + ctx, state.AgentID, state.RunID, + ) + if snapshotErr == nil && snapshot != nil && cursorRunTerminal(snapshot.Status) { + return s.finalizeCursorRun( + context.Background(), sessionID, snapshot, live, + ) + } + if snapshotErr != nil && ctx.Err() != nil { + return ctx.Err() + } + if snapshotErr == nil && snapshot != nil && snapshot.Status != "" { + updated, updateErr := s.mutateCursorState(context.Background(), sessionID, + func(current *store.CursorSessionState) error { + if current.AgentID == state.AgentID && current.RunID == state.RunID { + if !cursorCancelState(current.RemoteStatus) { + current.RemoteStatus = truncateCursorRunes( + s.redactCursorString(snapshot.Status), + maxCursorRemoteStatusRunes, + ) + } + } + return nil + }) + if updateErr != nil { + return updateErr + } + state = updated + } + return s.watchCursorRun(ctx, state, live) + + case store.CursorOperationTerminal: + if state.AgentID != "" && state.RunID != "" { + snapshot, snapshotErr := s.cursorRunner.GetRun( + ctx, state.AgentID, state.RunID, + ) + if snapshotErr == nil && snapshot != nil && cursorRunTerminal(snapshot.Status) { + return s.finalizeCursorRun( + context.Background(), sessionID, snapshot, live, + ) + } + } + status := state.RemoteStatus + if !cursorRunTerminal(status) { + status = "FINISHED" + } + return s.finalizeCursorRun( + context.Background(), + sessionID, + &cursor.Run{ + ID: state.RunID, AgentID: state.AgentID, + Status: status, Result: state.PartialText, + }, + live, + ) + } + return nil +} + +type cursorCancelRequest struct { + SessionID string `json:"session_id"` +} + +func (s *Server) handleCursorCancel(w http.ResponseWriter, r *http.Request) { + if s.requireDashboardPassword(w, r) { + return + } + if s.agent == nil || s.db == nil || s.cursorRunner == nil { + writeError(w, http.StatusServiceUnavailable, errors.New("Cursor cancellation is unavailable")) + return + } + var request cursorCancelRequest + if err := decodeBody(r, &request); err != nil { + writeError(w, http.StatusBadRequest, s.cursorSafeError(err)) + return + } + request.SessionID = strings.TrimSpace(request.SessionID) + if request.SessionID == "" { + writeError(w, http.StatusBadRequest, errors.New("session_id is required")) + return + } + state, err := s.db.GetCursorSessionState(r.Context(), request.SessionID) + if err != nil { + status := http.StatusInternalServerError + if errors.Is(err, store.ErrNotFound) { + status = http.StatusNotFound + } + writeError(w, status, s.cursorSafeError(err)) + return + } + state, err = s.reconcileCursorCancelCrashMarker(r.Context(), state) + if err != nil { + writeError(w, http.StatusInternalServerError, s.cursorSafeError(err)) + return + } + if state.OperationState != store.CursorOperationRunInFlight || + state.AgentID == "" || state.RunID == "" { + writeError(w, http.StatusConflict, errors.New("there is no active Cursor run to cancel")) + return + } + if state.RemoteStatus == cursorCancelAmbiguous { + writeError(w, http.StatusConflict, errors.New( + "Cursor cancellation outcome is ambiguous and will not be retried; delete this local session to discard it without another remote request", + )) + return + } + if cursorCancelState(state.RemoteStatus) { + writeError(w, http.StatusConflict, errors.New("Cursor cancellation was already requested")) + return + } + if !s.reserveCursorCancel(state.SessionID, state.RunID) { + writeError(w, http.StatusConflict, errors.New("Cursor cancellation was already requested")) + return + } + defer s.releaseCursorCancel(state.SessionID, state.RunID) + + live := s.hub.get(state.SessionID) + if live == nil { + live = s.cursorRecoveryRun(state.SessionID) + } + display, _ := json.Marshal(map[string]string{ + "operation": "cancel", + "agent_id": truncateCursorRunes(s.redactCursorString(state.AgentID), 128), + "run_id": truncateCursorRunes(s.redactCursorString(state.RunID), 128), + }) + allowed, err := s.agent.AwaitOperationApproval( + context.Background(), + approval.Operation{ + SessionID: state.SessionID, + Tool: "cursor_direct_cancel", + Arguments: string(display), + Message: "Cancel Cursor Cloud Agent run", + Reason: "remote cancellation changes Cursor state", + }, + func(event agent.Event) error { + if live != nil { + live.publish(event) + } + return nil + }, + ) + if err != nil { + writeError(w, http.StatusRequestTimeout, s.cursorSafeError(err)) + return + } + if !allowed { + writeError(w, http.StatusForbidden, errors.New("Cursor cancellation was refused")) + return + } + + latest, err := s.db.GetCursorSessionState(context.Background(), state.SessionID) + if err != nil || latest.OperationState != store.CursorOperationRunInFlight || + latest.AgentID != state.AgentID || latest.RunID != state.RunID { + writeError(w, http.StatusConflict, errors.New("Cursor run changed before cancellation")) + return + } + priorStatus := state.RemoteStatus + _, err = s.mutateCursorState(context.Background(), state.SessionID, + func(current *store.CursorSessionState) error { + if current.OperationState != store.CursorOperationRunInFlight || + current.AgentID != state.AgentID || current.RunID != state.RunID || + cursorCancelState(current.RemoteStatus) { + return errCursorStateChanged + } + // This durable marker closes the crash window before the + // non-idempotent cancellation POST. + priorStatus = current.RemoteStatus + current.RemoteStatus = cursorCancelInFlight + return nil + }) + if err != nil { + writeError(w, http.StatusConflict, errors.New("Cursor run changed before cancellation")) + return + } + if err := s.cursorRunner.CancelRun( + context.Background(), state.AgentID, state.RunID, + ); err != nil { + if cursorCancelNotFound(err) { + _, updateErr := s.mutateCursorState(context.Background(), state.SessionID, + func(current *store.CursorSessionState) error { + if current.AgentID != state.AgentID || current.RunID != state.RunID || + current.RemoteStatus != cursorCancelInFlight { + return errCursorStateChanged + } + current.RemoteStatus = cursorCancelNoActive + current.OperationState = store.CursorOperationIdle + current.ReuseValid = false + return nil + }) + if updateErr != nil && !errors.Is(updateErr, errCursorStateChanged) { + writeError(w, http.StatusInternalServerError, s.cursorSafeError(updateErr)) + return + } + if live != nil { + live.publish(agent.Event{ + Type: agent.EventToolProgress, Name: "cursor", + Message: "Cursor run is no longer active", + }) + } + writeJSON(w, http.StatusOK, map[string]bool{ + "cancel_requested": false, + "no_active_run": true, + }) + return + } + ambiguous := cursorCancelCouldBeAmbiguous(err) + nextStatus := priorStatus + if ambiguous { + nextStatus = cursorCancelAmbiguous + } + _, updateErr := s.mutateCursorState(context.Background(), state.SessionID, + func(current *store.CursorSessionState) error { + if current.AgentID != state.AgentID || current.RunID != state.RunID || + current.RemoteStatus != cursorCancelInFlight { + return errCursorStateChanged + } + current.RemoteStatus = nextStatus + return nil + }) + if updateErr != nil && !errors.Is(updateErr, errCursorStateChanged) { + writeError(w, http.StatusInternalServerError, s.cursorSafeError(updateErr)) + return + } + if ambiguous { + writeError(w, http.StatusBadGateway, errors.New( + "Cursor cancellation outcome is ambiguous and will not be retried automatically; delete this local session to discard it without another remote request", + )) + return + } + s.writeCursorUpstreamError(w, err, http.StatusBadGateway) + return + } + _, _ = s.mutateCursorState(context.Background(), state.SessionID, + func(current *store.CursorSessionState) error { + if current.AgentID == state.AgentID && current.RunID == state.RunID { + current.RemoteStatus = cursorCancelRequested + } + return nil + }) + if live != nil { + live.publish(agent.Event{ + Type: agent.EventToolProgress, Name: "cursor", + Message: "Cursor cancellation requested", + }) + } + writeJSON(w, http.StatusOK, map[string]bool{"cancel_requested": true}) +} + +func cursorCancelNotFound(err error) bool { + var apiError *cursor.APIError + return errors.As(err, &apiError) && apiError.Status == http.StatusNotFound +} + +func cursorCancelCouldBeAmbiguous(err error) bool { + if err == nil { + return false + } + if errors.Is(err, cursorrun.ErrNotConfigured) { + return false + } + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return true + } + var apiError *cursor.APIError + if errors.As(err, &apiError) { + return apiError.Status == 0 || + apiError.Status == http.StatusRequestTimeout || + apiError.Status >= http.StatusInternalServerError + } + return true +} + +func (s *Server) reserveCursorCancel(sessionID, runID string) bool { + s.cursorCancelMu.Lock() + defer s.cursorCancelMu.Unlock() + if s.cursorCancels == nil { + s.cursorCancels = make(map[string]string) + } + if _, ok := s.cursorCancels[sessionID]; ok { + return false + } + s.cursorCancels[sessionID] = runID + return true +} + +func (s *Server) releaseCursorCancel(sessionID, runID string) { + s.cursorCancelMu.Lock() + if s.cursorCancels[sessionID] == runID { + delete(s.cursorCancels, sessionID) + } + s.cursorCancelMu.Unlock() +} + +func (s *Server) cursorCancelReserved(sessionID string) bool { + s.cursorCancelMu.Lock() + _, reserved := s.cursorCancels[sessionID] + s.cursorCancelMu.Unlock() + return reserved +} + +func (s *Server) cursorSessionHasActiveRemoteState( + ctx context.Context, + sessionID string, +) (bool, error) { + state, err := s.db.GetCursorSessionState(ctx, sessionID) + if errors.Is(err, store.ErrNotFound) { + return false, nil + } + if err != nil { + return false, err + } + return s.cursorStateHasActiveRemoteState(sessionID, state), nil +} + +func (s *Server) cursorSessionBlocksAutomaticCleanup( + ctx context.Context, + sessionID string, +) (bool, error) { + state, err := s.db.GetCursorSessionState(ctx, sessionID) + if errors.Is(err, store.ErrNotFound) { + return false, nil + } + if err != nil { + return false, err + } + // Terminal is a durable recovery checkpoint before the assistant message is + // committed. Explicit operator deletion may discard it, but automatic + // cleanup must preserve it even when this process has no recovery watcher. + if state.OperationState == store.CursorOperationTerminal { + return true, nil + } + return s.cursorStateHasActiveRemoteState(sessionID, state), nil +} + +func (s *Server) cursorStateHasActiveRemoteState( + sessionID string, + state *store.CursorSessionState, +) bool { + if state.OperationState == store.CursorOperationAmbiguous { + return false + } + if state.OperationState == store.CursorOperationRunInFlight { + switch state.RemoteStatus { + case cursorCancelRequested, cursorCancelAmbiguous: + return false + case cursorCancelInFlight: + return s.cursorCancelReserved(sessionID) + } + } + if state.OperationState == store.CursorOperationTerminal { + if live := s.hub.get(sessionID); live != nil && live.isCursor() { + return true + } + } + return cursorOperationActive(state.OperationState) +} + +func (s *Server) cursorSessionHasUnfinishedState( + ctx context.Context, + sessionID string, +) (bool, error) { + state, err := s.db.GetCursorSessionState(ctx, sessionID) + if errors.Is(err, store.ErrNotFound) { + return false, nil + } + if err != nil { + return false, err + } + return cursorOperationUnfinished(state.OperationState), nil +} + +func (s *Server) invalidateCursorTarget( + ctx context.Context, + sessionID string, + targetActive bool, +) error { + _, err := s.mutateCursorState(ctx, sessionID, + func(state *store.CursorSessionState) error { + state.ReuseValid = false + state.TargetActive = targetActive + return nil + }) + if errors.Is(err, store.ErrNotFound) { + return nil + } + return err +} diff --git a/internal/server/cursor_lifecycle.go b/internal/server/cursor_lifecycle.go new file mode 100644 index 0000000..e85d65d --- /dev/null +++ b/internal/server/cursor_lifecycle.go @@ -0,0 +1,71 @@ +package server + +import ( + "sort" + "sync" +) + +type sessionLockEntry struct { + mu sync.Mutex + refs int +} + +// sessionLocker serializes Cursor lifecycle decisions per Antares session. +// Entries are reference-counted so completed sessions do not accumulate locks. +type sessionLocker struct { + mu sync.Mutex + entries map[string]*sessionLockEntry +} + +func (l *sessionLocker) Lock(sessionID string) func() { + l.mu.Lock() + if l.entries == nil { + l.entries = make(map[string]*sessionLockEntry) + } + entry := l.entries[sessionID] + if entry == nil { + entry = &sessionLockEntry{} + l.entries[sessionID] = entry + } + entry.refs++ + l.mu.Unlock() + + entry.mu.Lock() + var once sync.Once + return func() { + once.Do(func() { + entry.mu.Unlock() + l.mu.Lock() + entry.refs-- + if entry.refs == 0 && l.entries[sessionID] == entry { + delete(l.entries, sessionID) + } + l.mu.Unlock() + }) + } +} + +// LockMany acquires unique session IDs in sorted order, preventing two bulk +// lifecycle operations from deadlocking when their input orders differ. +func (l *sessionLocker) LockMany(sessionIDs []string) func() { + ids := append([]string(nil), sessionIDs...) + sort.Strings(ids) + unique := ids[:0] + for _, id := range ids { + if len(unique) == 0 || unique[len(unique)-1] != id { + unique = append(unique, id) + } + } + unlocks := make([]func(), 0, len(unique)) + for _, id := range unique { + unlocks = append(unlocks, l.Lock(id)) + } + var once sync.Once + return func() { + once.Do(func() { + for i := len(unlocks) - 1; i >= 0; i-- { + unlocks[i]() + } + }) + } +} diff --git a/internal/server/cursor_lifecycle_test.go b/internal/server/cursor_lifecycle_test.go new file mode 100644 index 0000000..a490a44 --- /dev/null +++ b/internal/server/cursor_lifecycle_test.go @@ -0,0 +1,63 @@ +package server + +import ( + "testing" + "time" +) + +func TestSessionLockerDoesNotBlockUnrelatedSessions(t *testing.T) { + var locker sessionLocker + unlockA := locker.Lock("session-a") + + acquiredB := make(chan func(), 1) + go func() { + acquiredB <- locker.Lock("session-b") + }() + select { + case unlockB := <-acquiredB: + unlockB() + case <-time.After(250 * time.Millisecond): + t.Fatal("session-a lock blocked unrelated session-b") + } + + acquiredA := make(chan func(), 1) + go func() { + acquiredA <- locker.Lock("session-a") + }() + select { + case unlock := <-acquiredA: + unlock() + t.Fatal("same-session lock did not serialize") + case <-time.After(50 * time.Millisecond): + } + unlockA() + select { + case unlock := <-acquiredA: + unlock() + case <-time.After(time.Second): + t.Fatal("same-session waiter did not resume") + } +} + +func TestSessionLockerLockManyUsesDeterministicOrder(t *testing.T) { + var locker sessionLocker + unlockFirst := locker.LockMany([]string{"session-b", "session-a", "session-a"}) + + acquired := make(chan func(), 1) + go func() { + acquired <- locker.LockMany([]string{"session-a", "session-b"}) + }() + select { + case unlock := <-acquired: + unlock() + t.Fatal("overlapping multi-session lock was not serialized") + case <-time.After(50 * time.Millisecond): + } + unlockFirst() + select { + case unlock := <-acquired: + unlock() + case <-time.After(time.Second): + t.Fatal("deterministically ordered multi-session waiter deadlocked") + } +} diff --git a/internal/server/cursor_provider_test.go b/internal/server/cursor_provider_test.go index d18b9ac..18f8220 100644 --- a/internal/server/cursor_provider_test.go +++ b/internal/server/cursor_provider_test.go @@ -7,12 +7,16 @@ import ( "net" "net/http" "net/http/httptest" + "reflect" "strings" + "sync/atomic" "testing" + "time" "github.com/enowdev/antares/internal/agent" "github.com/enowdev/antares/internal/config" "github.com/enowdev/antares/internal/cursor" + "github.com/enowdev/antares/internal/cursorrun" ) // trackingIPResolver counts LookupIP calls so tests can prove a handler @@ -116,6 +120,78 @@ func newCursorTestServer(t *testing.T, seed func(*config.Config)) *Server { return s } +func installCursorCatalogRunner(s *Server, httpClient *http.Client) { + s.cursorRunner = cursorrun.New(cursorrun.Options{ + ResolveClient: func() (cursor.Options, error) { + _, provider := s.config().ResolveProvider("cursor") + return cursor.Options{ + BaseURL: provider.BaseURL, APIKey: provider.APIKey, HTTPClient: httpClient, + }, nil + }, + Now: time.Now, CatalogTTL: 5 * time.Minute, + }) +} + +func requestCursorModels(s *Server) *httptest.ResponseRecorder { + r := httptest.NewRequest(http.MethodGet, "/api/providers/cursor/models", nil) + r.Header.Set("Authorization", "Bearer test-token") + r.SetPathValue("id", "cursor") + rec := httptest.NewRecorder() + s.handleProviderModels(rec, r) + return rec +} + +func TestServerUsesInjectedSharedCursorRunner(t *testing.T) { + runner := cursorrun.New(cursorrun.Options{ + ResolveClient: func() (cursor.Options, error) { + return cursor.Options{}, errors.New("runner identity test") + }, + }) + s := New(Options{Config: config.Default(), Cursor: runner}) + if s.cursorRunner != runner { + t.Fatal("Server.New replaced the injected Cursor runner") + } +} + +func TestServerFallbackCursorRunnerRejectsUnavailableConfigLocally(t *testing.T) { + var calls atomic.Int32 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + _ = json.NewEncoder(w).Encode(cursor.ModelCatalog{}) + })) + defer upstream.Close() + + for _, tc := range []struct { + name string + enabled bool + apiKey string + }{ + {name: "disabled", enabled: false, apiKey: "must-not-be-sent"}, + {name: "missing key", enabled: true}, + } { + t.Run(tc.name, func(t *testing.T) { + before := calls.Load() + cfg := config.Default() + provider := cfg.Providers["cursor"] + provider.Enabled = tc.enabled + provider.APIKey = tc.apiKey + provider.APIKeyEnv = "" + provider.BaseURL = upstream.URL + cfg.Providers["cursor"] = provider + + s := New(Options{Config: cfg}) + _, err := s.cursorRunner.Catalog(context.Background(), false) + if err == nil || + err.Error() != "connect Cursor in Providers or set CURSOR_API_KEY" { + t.Fatalf("fallback error = %v", err) + } + if got := calls.Load(); got != before { + t.Fatalf("fallback made %d upstream request(s), want none", got-before) + } + }) + } +} + // TestConnectCursorPreservesActiveModel guards the primary model boundary: // connecting Cursor must never touch cfg.Model, even on success. func TestConnectCursorPreservesActiveModel(t *testing.T) { @@ -310,44 +386,85 @@ func TestModelOptionsReportsCursorAgentCapabilityAndEnvKey(t *testing.T) { } // TestProviderModelsReturnsCursorCatalog covers the provider-specific model -// endpoint's response shape (ids + display names). +// endpoint's complete response shape and its shared five-minute cache. func TestProviderModelsReturnsCursorCatalog(t *testing.T) { + var calls atomic.Int32 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + if r.URL.Path != "/v1/models" { + http.NotFound(w, r) + return + } + _ = json.NewEncoder(w).Encode(cursor.ModelCatalog{Items: []cursor.Model{{ + ID: "composer-2", + DisplayName: "Composer 2", + Description: "Cloud agent", + Aliases: []string{"composer"}, + Parameters: []cursor.ModelParameter{{ + ID: "effort", + Values: []cursor.ModelParameterValue{ + {Value: "high", DisplayName: "High"}, + }, + }}, + Variants: []cursor.ModelVariant{{ + Params: []cursor.ModelParameterSelection{{ID: "effort", Value: "high"}}, + DisplayName: "High effort", + IsDefault: true, + }}, + }}}) + })) + t.Cleanup(upstream.Close) + s := newCursorTestServer(t, func(cfg *config.Config) { p := cfg.Providers["cursor"] p.APIKey = "synthetic-key" + p.BaseURL = upstream.URL cfg.Providers["cursor"] = p }) - s.cursorFactory = func(cursor.Options) (cursorMetadataClient, error) { - return &fakeCursorMetadata{ - models: cursor.ModelCatalog{Items: []cursor.Model{ - {ID: "composer-2", DisplayName: "Composer 2"}, - }}, - }, nil - } + installCursorCatalogRunner(s, upstream.Client()) - r := httptest.NewRequest(http.MethodGet, "/api/providers/cursor/models", nil) - r.Header.Set("Authorization", "Bearer test-token") - r.SetPathValue("id", "cursor") - rec := httptest.NewRecorder() - s.handleProviderModels(rec, r) + rec := requestCursorModels(s) if rec.Code != http.StatusOK { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } var body struct { Models []struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Parameters []string `json:"parameters"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Aliases []string `json:"aliases"` + Parameters []cursor.ModelParameter `json:"parameters"` + Variants []cursor.ModelVariant `json:"variants"` } `json:"models"` } if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatal(err) } - if len(body.Models) != 1 || body.Models[0].ID != "composer-2" || body.Models[0].Name != "Composer 2" { + if len(body.Models) != 1 || body.Models[0].ID != "composer-2" || + body.Models[0].Name != "Composer 2" || + !reflect.DeepEqual(body.Models[0].Aliases, []string{"composer"}) || + len(body.Models[0].Parameters) != 1 || + len(body.Models[0].Variants) != 1 { t.Fatalf("unexpected models: %+v", body.Models) } + + second := requestCursorModels(s) + if second.Code != http.StatusOK { + t.Fatalf("second status=%d body=%s", second.Code, second.Body.String()) + } + if got := calls.Load(); got != 1 { + t.Fatalf("provider endpoint bypassed shared cache: requests=%d", got) + } + + s.SetConfig(s.config()) + third := requestCursorModels(s) + if third.Code != http.StatusOK { + t.Fatalf("third status=%d body=%s", third.Code, third.Body.String()) + } + if got := calls.Load(); got != 2 { + t.Fatalf("SetConfig did not invalidate shared cache: requests=%d", got) + } } // TestProviderModelsNeedsKeyWithoutNetworkCall covers the "no resolved key -> @@ -359,21 +476,19 @@ func TestCursorProviderModelsNeedsKeyWithoutNetworkCall(t *testing.T) { p.APIKeyEnv = "" cfg.Providers["cursor"] = p }) - called := false - s.cursorFactory = func(cursor.Options) (cursorMetadataClient, error) { - called = true - return &fakeCursorMetadata{}, nil - } + var called atomic.Bool + s.cursorRunner = cursorrun.New(cursorrun.Options{ + ResolveClient: func() (cursor.Options, error) { + called.Store(true) + return cursor.Options{}, errors.New("resolver must not be called") + }, + }) - r := httptest.NewRequest(http.MethodGet, "/api/providers/cursor/models", nil) - r.Header.Set("Authorization", "Bearer test-token") - r.SetPathValue("id", "cursor") - rec := httptest.NewRecorder() - s.handleProviderModels(rec, r) + rec := requestCursorModels(s) if rec.Code != http.StatusOK { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } - if called { + if called.Load() { t.Fatal("handleProviderModels reached the network without a resolved key") } @@ -388,6 +503,41 @@ func TestCursorProviderModelsNeedsKeyWithoutNetworkCall(t *testing.T) { } } +func TestCursorProviderModelsAuthErrorRemainsSafeFallback(t *testing.T) { + secret := "provider-auth-secret" + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":{"code":"unauthorized","message":"rejected ` + secret + `"}}`)) + })) + t.Cleanup(upstream.Close) + + s := newCursorTestServer(t, func(cfg *config.Config) { + p := cfg.Providers["cursor"] + p.APIKey = secret + p.BaseURL = upstream.URL + cfg.Providers["cursor"] = p + }) + installCursorCatalogRunner(s, upstream.Client()) + + rec := requestCursorModels(s) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + var body struct { + Models []any `json:"models"` + Error string `json:"error"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if len(body.Models) != 0 || body.Error == "" { + t.Fatalf("unexpected auth fallback: %+v", body) + } + if strings.Contains(rec.Body.String(), secret) { + t.Fatalf("auth fallback leaked credential: %s", rec.Body.String()) + } +} + // TestModelListAllExcludesCursor guards model isolation: list-all must never // call or include Cursor, even when it has a usable (env) credential. func TestModelListAllExcludesCursor(t *testing.T) { diff --git a/internal/server/cursor_session_view.go b/internal/server/cursor_session_view.go new file mode 100644 index 0000000..5c480ce --- /dev/null +++ b/internal/server/cursor_session_view.go @@ -0,0 +1,239 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "strings" + "unicode" + "unicode/utf8" + + "github.com/enowdev/antares/internal/cursor" + "github.com/enowdev/antares/internal/store" +) + +const ( + maxCursorProjectionStatusRunes = 512 + maxCursorProjectionBranches = 16 + maxCursorProjectionParams = 64 +) + +type cursorBranchProjection struct { + RepoURL string `json:"repo_url"` + Branch string `json:"branch"` + PRURL string `json:"pr_url"` +} + +type cursorGitProjection struct { + Branches []cursorBranchProjection `json:"branches"` +} + +// cursorSessionProjection is the composer-facing view of durable Cursor state: +// enough to restore the execution target and describe the run's outcome, and +// nothing else. Revision, partial prompt and answer text, recovery identifiers +// (agent, run, last event), and internal message IDs deliberately stay on the +// server — they are recovery machinery, not composer state, and some of them +// carry user content. +type cursorSessionProjection struct { + TargetActive bool `json:"target_active"` + ReuseValid bool `json:"reuse_valid"` + // ModelID and ModelParams are populated together or not at all: a partially + // understood selection must never become a different one. + ModelID string `json:"model_id"` + ModelParams []cursor.ModelParameterSelection `json:"model_params"` + // RepositoryURL is null when the run discovered its repository (or ran with + // none) rather than being given one, so restoring it reproduces the same + // run identity instead of pinning an explicit empty repository. + RepositoryURL *string `json:"repository_url"` + StartingRef string `json:"starting_ref"` + Mode string `json:"mode"` + AutoCreatePR bool `json:"auto_create_pr"` + RemoteStatus string `json:"remote_status"` + OperationState string `json:"operation_state"` + Git cursorGitProjection `json:"git"` +} + +// cursorSessionView loads the durable Cursor state for one session. A session +// that never ran Cursor yields nil; a store failure is returned as an error, so +// a read problem can never be presented as "this session has no Cursor state". +func (s *Server) cursorSessionView( + ctx context.Context, + sessionID string, +) (*cursorSessionProjection, error) { + if s.db == nil { + return nil, nil + } + state, err := s.db.GetCursorSessionState(ctx, sessionID) + if errors.Is(err, store.ErrNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return s.projectCursorState(state), nil +} + +func (s *Server) projectCursorState( + state *store.CursorSessionState, +) *cursorSessionProjection { + if state == nil { + return nil + } + view := &cursorSessionProjection{ + TargetActive: state.TargetActive, + ReuseValid: state.ReuseValid, + ModelParams: []cursor.ModelParameterSelection{}, + AutoCreatePR: state.AutoCreatePR, + RemoteStatus: truncateCursorRunes( + s.redactCursorString(state.RemoteStatus), maxCursorProjectionStatusRunes, + ), + OperationState: state.OperationState, + Git: cursorGitProjection{ + Branches: s.projectCursorGitState(state.GitState), + }, + } + // Only the two modes a turn can be prepared with are meaningful to the + // composer; anything else is reported as no mode at all. + if state.Mode == "agent" || state.Mode == "plan" { + view.Mode = state.Mode + } + + // Everything below identifies the run rather than describing it, so it is + // copied byte for byte or omitted. Rewriting an opaque catalogue value (or + // a Git ref) would resolve to a different selection, or to none at all. + repository, repositoryOK := s.projectCursorRepository(state.RepositoryURL) + if repositoryOK { + view.RepositoryURL = repository + } + referenceOK := s.safeCursorIdentityValue(state.StartingRef, maxCursorStartingRefRunes) + if referenceOK { + view.StartingRef = state.StartingRef + } + params, paramsOK := s.decodeCursorProjectionParams(state.ModelParams) + modelOK := state.ModelID != "" && + s.safeCursorIdentityValue(state.ModelID, maxCursorIdentifierRunes) + // A selection is only restorable when every part of its identity survived + // intact; one unsafe field rejects the whole thing. + if modelOK && paramsOK && repositoryOK && referenceOK { + view.ModelID = state.ModelID + view.ModelParams = params + } + return view +} + +// projectCursorRepository maps a stored repository identity to its wire form: +// null for the internal auto-discovery marker, the value itself when it is +// safe, and "not projectable" when it is not. +func (s *Server) projectCursorRepository(stored string) (*string, bool) { + if stored == cursorAutoNoRepositoryIdentity { + return nil, true + } + if !s.safeCursorIdentityValue(stored, maxCursorRepositoryRunes) { + return nil, false + } + repository := stored + return &repository, true +} + +// safeCursorIdentityValue reports whether a stored identity value may be sent +// to the browser unchanged. It validates structure instead of rewriting +// content: an oversized, malformed, or credential-bearing value is rejected, so +// nothing secret is echoed and nothing legitimate is altered. +func (s *Server) safeCursorIdentityValue(value string, maxRunes int) bool { + if value == "" { + return true + } + if !utf8.ValidString(value) || + utf8.RuneCountInString(value) > maxRunes || + strings.IndexFunc(value, unicode.IsControl) >= 0 { + return false + } + if s.containsCursorSecret(value) { + return false + } + return !cursorCredentialToken.MatchString(value) && + !cursorCredentialAssignment.MatchString(value) && + !cursorBearerCredential.MatchString(value) && + !cursorURLUserinfo.MatchString(value) && + privateKeyMarker(value) < 0 +} + +func (s *Server) containsCursorSecret(value string) bool { + cfg := s.config() + if cfg == nil { + return false + } + _, provider := cfg.ResolveProvider("cursor") + for _, secret := range []string{ + strings.TrimSpace(provider.APIKey), + strings.TrimSpace(cfg.Server.AuthToken), + } { + if secret != "" && strings.Contains(value, secret) { + return true + } + } + return false +} + +// decodeCursorProjectionParams reads a stored selection back into its exact +// ordered parameters. The store only guarantees a JSON array, so anything that +// is not a well-formed, unambiguous, safely projectable parameter list is +// reported as undecodable — never partially restored and never rewritten. +func (s *Server) decodeCursorProjectionParams( + raw string, +) ([]cursor.ModelParameterSelection, bool) { + params := []cursor.ModelParameterSelection{} + if strings.TrimSpace(raw) == "" { + return params, true + } + decoder := json.NewDecoder(bytes.NewReader([]byte(raw))) + decoder.DisallowUnknownFields() + if err := decoder.Decode(¶ms); err != nil { + return nil, false + } + if len(params) > maxCursorProjectionParams { + return nil, false + } + seen := make(map[string]struct{}, len(params)) + for _, param := range params { + if param.ID == "" || + !s.safeCursorIdentityValue(param.ID, maxCursorIdentifierRunes) || + !s.safeCursorIdentityValue(param.Value, maxCursorIdentifierRunes) { + return nil, false + } + if _, duplicate := seen[param.ID]; duplicate { + return nil, false + } + seen[param.ID] = struct{}{} + } + return params, true +} + +func (s *Server) projectCursorGitState(raw string) []cursorBranchProjection { + branches := []cursorBranchProjection{} + if strings.TrimSpace(raw) == "" { + return branches + } + var git cursor.GitState + if err := json.Unmarshal([]byte(raw), &git); err != nil { + return branches + } + for _, branch := range git.Branches { + if len(branches) >= maxCursorProjectionBranches { + break + } + branches = append(branches, cursorBranchProjection{ + RepoURL: truncateCursorRunes( + s.redactCursorString(branch.RepoURL), maxCursorRepositoryRunes, + ), + Branch: truncateCursorRunes( + s.redactCursorString(branch.Branch), maxCursorStartingRefRunes, + ), + PRURL: truncateCursorRunes( + s.redactCursorString(branch.PRURL), maxCursorRepositoryRunes, + ), + }) + } + return branches +} diff --git a/internal/server/handlers_chat.go b/internal/server/handlers_chat.go index d8649ad..796cd56 100644 --- a/internal/server/handlers_chat.go +++ b/internal/server/handlers_chat.go @@ -120,6 +120,27 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, errors.New("message is required")) return } + if err := s.validateExplicitReasoning( + r.Context(), s.config(), req.Model, req.ReasoningEffort, + ); err != nil { + writeReasoningValidationError(w, err) + return + } + lr := newLiveRun() + reservedExisting := false + if s.db != nil && strings.TrimSpace(req.SessionID) != "" { + if err := s.reserveOrdinaryChat(r.Context(), req.SessionID, lr); err != nil { + if errors.Is(err, errCursorSessionBusy) { + writeError(w, http.StatusConflict, errors.New( + "a turn is already active for this session", + )) + return + } + writeError(w, http.StatusInternalServerError, err) + return + } + reservedExisting = true + } // Persist the picked role against the session so a reload reflects it. The // session id is only known once the run assigns one, so a brand-new @@ -135,6 +156,9 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { sse, err := newSSE(w) if err != nil { + if reservedExisting { + s.hub.remove(req.SessionID, lr) + } writeError(w, http.StatusInternalServerError, err) return } @@ -177,8 +201,9 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { // reattach through /chat/attach. Events flow into a liveRun; this request is // just the first follower. Interrupt still stops it (agent.Interrupt keys off // the session id), and the run persists its own messages as it goes. - lr := newLiveRun() - s.hub.put(req.SessionID, lr) // existing session: findable immediately + if req.SessionID != "" && !reservedExisting { + s.hub.put(req.SessionID, lr) + } sessionKey := req.SessionID emit := func(e agent.Event) error { @@ -222,6 +247,30 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { _ = lr.follow(ctx, 0, func(e agent.Event, _ int) error { return sse.send(e) }) } +func (s *Server) reserveOrdinaryChat( + ctx context.Context, + sessionID string, + live *liveRun, +) error { + unlock := s.cursorLifecycles.Lock(sessionID) + defer unlock() + active, err := s.cursorSessionHasUnfinishedState(ctx, sessionID) + if err != nil { + return err + } + if active { + return errCursorSessionBusy + } + if err := s.invalidateCursorTarget(ctx, sessionID, false); err != nil { + return err + } + // Ordinary turns historically supersede the attach log for an older + // ordinary turn. The lifecycle lock still prevents crossing an unfinished + // Cursor reservation while preserving that replacement behavior. + s.hub.put(sessionID, live) + return nil +} + // handleChatAttach reconnects a client to a turn already in flight for a session // (e.g. after navigating away and back), replaying from the given cursor. If no // run is live, it reports done at once so the client falls back to the persisted @@ -230,13 +279,42 @@ func (s *Server) handleChatAttach(w http.ResponseWriter, r *http.Request) { session := r.URL.Query().Get("session_id") cursor, _ := strconv.Atoi(r.URL.Query().Get("cursor")) + unlock := s.cursorLifecycles.Lock(session) + lr := s.hub.get(session) + needsCursorRecovery := false + if lr == nil && s.db != nil && s.cursorRunner != nil { + state, err := s.db.GetCursorSessionState(r.Context(), session) + switch { + case err == nil: + needsCursorRecovery = cursorStateNeedsRecovery(state) + case errors.Is(err, store.ErrNotFound): + default: + unlock() + writeError(w, http.StatusInternalServerError, s.cursorSafeError(err)) + return + } + } + unlock() + + if (lr != nil && lr.isCursor()) || needsCursorRecovery { + if s.requireDashboardPassword(w, r) { + return + } + } + if lr == nil && needsCursorRecovery { + lr = s.cursorRecoveryRun(session) + } + if cursorAttachShouldReset(lr) { + // Every recovery log starts with reset plus durable replay. The caller's + // cursor may belong to the lost process, so it is never valid here. + cursor = 0 + } + sse, err := newSSE(w) if err != nil { writeError(w, http.StatusInternalServerError, err) return } - - lr := s.hub.get(session) if lr == nil { _ = sse.send(agent.Event{Type: agent.EventDone}) return @@ -268,6 +346,10 @@ func (s *Server) handleChatAttach(w http.ResponseWriter, r *http.Request) { }) } +func cursorAttachShouldReset(live *liveRun) bool { + return live != nil && live.runKind() == liveRunCursorRecovery +} + // decodeImages accepts data URLs and bare base64 payloads. func decodeImages(images []string) []llm.Part { out := make([]llm.Part, 0, len(images)) @@ -308,6 +390,10 @@ func (s *Server) handleInterrupt(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, err) return } + if live := s.hub.get(body.SessionID); live != nil && live.stopWatching() { + writeJSON(w, http.StatusOK, map[string]bool{"interrupted": true}) + return + } writeJSON(w, http.StatusOK, map[string]bool{"interrupted": s.agent.Interrupt(body.SessionID)}) } @@ -349,7 +435,17 @@ func (s *Server) handleGetSession(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, err) return } - writeJSON(w, http.StatusOK, map[string]any{"session": sess, "messages": messages}) + // The durable Cursor projection is what lets the composer restore the exact + // execution target after a reload. It is null for every ordinary chat, and + // a failed read is reported rather than shown as "no Cursor state". + cursorState, err := s.cursorSessionView(r.Context(), id) + if err != nil { + writeError(w, http.StatusInternalServerError, s.cursorSafeError(err)) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "session": sess, "messages": messages, "cursor_state": cursorState, + }) } // handleEditPreview lists the files the agent changed at/after a given user @@ -388,6 +484,26 @@ func (s *Server) handleEditMessage(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, errors.New("message_id is required")) return } + unlock := s.cursorLifecycles.Lock(sessionID) + defer unlock() + active, err := s.cursorSessionHasUnfinishedState(r.Context(), sessionID) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + if active { + writeError(w, http.StatusConflict, errors.New( + "a remote Cursor operation is active for this session", + )) + return + } + // Invalidate before rollback or transcript deletion. A later direct send + // must create a fresh remote agent because edited history no longer matches + // the remote conversation. + if err := s.invalidateCursorTarget(r.Context(), sessionID, true); err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } var reverted, skipped []string if body.Revert { @@ -433,7 +549,24 @@ func (s *Server) handleBackgroundActivity(w http.ResponseWriter, r *http.Request } func (s *Server) handleDeleteSession(w http.ResponseWriter, r *http.Request) { - if err := s.db.DeleteSession(r.Context(), r.PathValue("id")); err != nil { + sessionID := r.PathValue("id") + unlock := s.cursorLifecycles.Lock(sessionID) + defer unlock() + active, err := s.cursorSessionHasActiveRemoteState(r.Context(), sessionID) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + if active { + writeError(w, http.StatusConflict, errors.New( + "cannot delete a session with an active remote Cursor operation", + )) + return + } + if live := s.hub.get(sessionID); live != nil { + live.stopWatching() + } + if err := s.db.DeleteSession(r.Context(), sessionID); err != nil { writeError(w, http.StatusInternalServerError, err) return } @@ -459,40 +592,57 @@ func (s *Server) handleDeleteAllSessions(w http.ResponseWriter, r *http.Request) category = "all" } - sessions, _, err := s.db.ListSessions(r.Context(), store.SessionFilter{Limit: 100000}) - if err != nil { - writeError(w, http.StatusInternalServerError, err) - return - } - - var ids []string - for _, sess := range sessions { + ids, err := s.cursorCleanupSessionIDs(r.Context(), func(sess store.Session) bool { isProject := false if sess.Meta != nil { - if v, ok := sess.Meta["project_dir"]; ok { - if s, ok := v.(string); ok && s != "" { + if value, ok := sess.Meta["project_dir"]; ok { + if projectDir, ok := value.(string); ok && projectDir != "" { isProject = true } } } switch category { case "chat": - if !isProject { - ids = append(ids, sess.ID) - } + return !isProject case "project": - if isProject { - ids = append(ids, sess.ID) - } + return isProject case "all": - ids = append(ids, sess.ID) + return true + default: + return false } + }) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return } if len(ids) == 0 { writeJSON(w, http.StatusOK, map[string]any{"deleted": 0}) return } + unlock := s.cursorLifecycles.LockMany(ids) + defer unlock() + // Check every selected session before DeleteSessions mutates the first one, + // so a later active entry cannot produce a partial bulk deletion. + for _, id := range ids { + active, err := s.cursorSessionHasActiveRemoteState(r.Context(), id) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + if active { + writeError(w, http.StatusConflict, errors.New( + "cannot bulk delete while a remote Cursor operation is active", + )) + return + } + } + for _, id := range ids { + if live := s.hub.get(id); live != nil { + live.stopWatching() + } + } n, err := s.db.DeleteSessions(r.Context(), ids) if err != nil { @@ -542,6 +692,31 @@ func (s *Server) handleEmptyCount(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleDeleteEmpty(w http.ResponseWriter, r *http.Request) { + ids, err := s.cursorCleanupSessionIDs(r.Context(), func(session store.Session) bool { + return session.MessageCount == 0 + }) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + if len(ids) == 0 { + writeJSON(w, http.StatusOK, map[string]any{"deleted": int64(0)}) + return + } + unlock, active, err := s.lockCursorCleanupSessions(r.Context(), ids) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + if active { + writeError(w, http.StatusConflict, errors.New( + "cannot delete empty sessions while a remote Cursor operation is active", + )) + return + } + defer unlock() + s.stopCursorCleanupWatchers(ids) + n, err := s.db.DeleteEmptySessions(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, err) @@ -559,6 +734,31 @@ func (s *Server) handlePruneSessions(w http.ResponseWriter, r *http.Request) { body.OlderThanDays = 30 } cutoff := time.Now().AddDate(0, 0, -body.OlderThanDays) + ids, err := s.cursorCleanupSessionIDs(r.Context(), func(session store.Session) bool { + return !session.Pinned && session.UpdatedAt.Before(cutoff) + }) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + if len(ids) == 0 { + writeJSON(w, http.StatusOK, map[string]any{"deleted": int64(0)}) + return + } + unlock, active, err := s.lockCursorCleanupSessions(r.Context(), ids) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + if active { + writeError(w, http.StatusConflict, errors.New( + "cannot prune sessions while a remote Cursor operation is active", + )) + return + } + defer unlock() + s.stopCursorCleanupWatchers(ids) + n, err := s.db.PruneSessions(r.Context(), cutoff) if err != nil { writeError(w, http.StatusInternalServerError, err) @@ -566,3 +766,52 @@ func (s *Server) handlePruneSessions(w http.ResponseWriter, r *http.Request) { } writeJSON(w, http.StatusOK, map[string]any{"deleted": n}) } + +const cursorCleanupPageSize = 500 + +func (s *Server) cursorCleanupSessionIDs( + ctx context.Context, + include func(store.Session) bool, +) ([]string, error) { + var ids []string + for offset := 0; ; { + sessions, total, err := s.db.ListSessions(ctx, store.SessionFilter{ + Limit: cursorCleanupPageSize, Offset: offset, + }) + if err != nil { + return nil, err + } + for _, session := range sessions { + if include(session) { + ids = append(ids, session.ID) + } + } + offset += len(sessions) + if len(sessions) == 0 || int64(offset) >= total { + return ids, nil + } + } +} + +func (s *Server) lockCursorCleanupSessions( + ctx context.Context, + sessionIDs []string, +) (unlock func(), active bool, err error) { + unlock = s.cursorLifecycles.LockMany(sessionIDs) + for _, sessionID := range sessionIDs { + active, err = s.cursorSessionBlocksAutomaticCleanup(ctx, sessionID) + if err != nil || active { + unlock() + return nil, active, err + } + } + return unlock, false, nil +} + +func (s *Server) stopCursorCleanupWatchers(sessionIDs []string) { + for _, sessionID := range sessionIDs { + if live := s.hub.get(sessionID); live != nil { + live.stopWatching() + } + } +} diff --git a/internal/server/handlers_config.go b/internal/server/handlers_config.go index 70935bf..fd454dc 100644 --- a/internal/server/handlers_config.go +++ b/internal/server/handlers_config.go @@ -61,23 +61,32 @@ func (s *Server) handleUpdateConfig(w http.ResponseWriter, r *http.Request) { } sort.Strings(paths) + next := *cfg + invalidateDashSessions := false for _, path := range paths { value := body.Updates[path] // A redacted secret coming back unchanged means "leave it alone". if str, ok := value.(string); ok && strings.Contains(str, "••••") { continue } - if err := cfg.SetPath(path, value); err != nil { + if err := next.SetPath(path, value); err != nil { writeError(w, http.StatusBadRequest, err) return } // Changing (or clearing) the dashboard password must not leave old // logins valid. if path == "server.dashboard_password_hash" { - s.invalidateDashSessions() + invalidateDashSessions = true } } - if err := config.Save(cfg); err != nil { + if err := s.validateChangedReasoning(r.Context(), cfg, &next); err != nil { + writeReasoningValidationError(w, err) + return + } + if invalidateDashSessions { + s.invalidateDashSessions() + } + if err := config.Save(&next); err != nil { writeError(w, http.StatusInternalServerError, err) return } @@ -108,6 +117,19 @@ func (s *Server) handleSaveRawConfig(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, err) return } + next, err := config.ParseRawWithEnv(body.YAML) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + // Compare against the last valid in-memory snapshot. Re-reading here would + // prevent the raw editor from repairing malformed on-disk YAML, and Reload's + // first-run behavior could create a file before a rejected submission. + current := s.config() + if err := s.validateChangedReasoning(r.Context(), current, next); err != nil { + writeReasoningValidationError(w, err) + return + } if err := config.SaveRaw(body.YAML); err != nil { writeError(w, http.StatusBadRequest, err) return diff --git a/internal/server/handlers_cursor.go b/internal/server/handlers_cursor.go new file mode 100644 index 0000000..044b53a --- /dev/null +++ b/internal/server/handlers_cursor.go @@ -0,0 +1,1124 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + "time" + "unicode" + "unicode/utf8" + + "github.com/enowdev/antares/internal/agent" + "github.com/enowdev/antares/internal/approval" + "github.com/enowdev/antares/internal/config" + "github.com/enowdev/antares/internal/cursor" + "github.com/enowdev/antares/internal/cursorrun" + "github.com/enowdev/antares/internal/store" +) + +const ( + cursorAutoNoRepositoryIdentity = "antares://cursor/auto-discovery/no-repository" + maxCursorPromptPreviewRunes = 240 + maxCursorServerErrorRunes = 4096 + maxCursorStartingRefRunes = 1024 + maxCursorPartialRunes = 1 << 20 + maxCursorIdentifierRunes = 1024 + maxCursorRemoteStatusRunes = 16 << 10 + maxCursorRepositoryRunes = 16 << 10 + maxCursorGitStateRunes = 256 << 10 + maxCursorApprovalWarningRunes = 240 + maxCursorApprovalWarnings = 4 +) + +type cursorChatRequest struct { + SessionID string `json:"session_id"` + Message string `json:"message"` + Images []string `json:"images"` + Model cursor.ModelSelection `json:"model"` + Mode string `json:"mode"` + ProjectDir string `json:"project_dir,omitempty"` + RepositoryURL *string `json:"repository_url,omitempty"` + StartingRef *string `json:"starting_ref,omitempty"` + AutoCreatePR bool `json:"auto_create_pr"` +} + +type cursorTurnPlan struct { + sessionID string + sessionTitle string + message string + images []cursor.PromptImage + model cursor.ModelSelection + modelParamsJSON string + mode string + repositoryURL string + repositoryIdentity string + repositorySource string + startingRef string + worktreeDirty bool + localOnlyCommits int + remoteRefKnown bool + repositoryWarnings []string + autoCreatePR bool + reuse bool + agentID string + userMessageID string + assistantMessageID string + approvalArguments string +} + +type cursorRepositoryPlan struct { + url string + identity string + ref string + source string + dirty bool + localOnlyCommits int + remoteRefKnown bool + warnings []string +} + +type cursorApprovalModel struct { + ID string `json:"id"` + Params []cursor.ModelParameterSelection `json:"params"` +} + +type cursorDirectApprovalProjection struct { + Operation string `json:"operation"` + Kind string `json:"kind"` + Model cursorApprovalModel `json:"model"` + RepositoryURL string `json:"repository_url"` + Repository string `json:"repository_source"` + StartingRef string `json:"starting_ref"` + WorktreeDirty bool `json:"worktree_dirty"` + LocalOnly int `json:"local_only_commits"` + RemoteKnown bool `json:"remote_ref_known"` + Warnings []string `json:"warnings"` + Mode string `json:"mode"` + AutoCreatePR bool `json:"auto_create_pr"` + PromptPreview string `json:"prompt_preview"` + ImageCount int `json:"image_count"` +} + +// handleCursorChat prepares one immutable direct Cursor operation and follows +// the shared live-run log. The coordinator itself uses a background context, so +// losing this HTTP follower never cancels or retries a remote mutation. +func (s *Server) handleCursorChat(w http.ResponseWriter, r *http.Request) { + // This check deliberately precedes the 105 MiB decoder and image allocation. + if s.requireDashboardPassword(w, r) { + return + } + if s.agent == nil || s.db == nil || s.cursorRunner == nil { + writeError(w, http.StatusServiceUnavailable, errors.New("Cursor chat is unavailable")) + return + } + + var request cursorChatRequest + if err := decodeCursorChatBody(w, r, &request); err != nil { + status := http.StatusBadRequest + var maxBytesError *http.MaxBytesError + if errors.As(err, &maxBytesError) { + status = http.StatusRequestEntityTooLarge + } + writeError(w, status, s.cursorSafeError(err)) + return + } + + session, newSession, projectDir, err := s.cursorSessionCandidate(r.Context(), request) + if err != nil { + s.writeCursorPreparationError(w, err) + return + } + plan, previous, err := s.prepareCursorTurn(r.Context(), request, session, projectDir) + if err != nil { + s.writeCursorPreparationError(w, err) + return + } + + approvalCtx, stopApproval := context.WithCancel(context.Background()) + live := newCursorLiveRun(liveRunCursorDirect) + live.beginCursorApproval(stopApproval) + unlockLifecycle := s.cursorLifecycles.Lock(plan.sessionID) + if !s.hub.putIfAbsent(plan.sessionID, live) { + unlockLifecycle() + stopApproval() + writeError(w, http.StatusConflict, errors.New("a turn is already active for this session")) + return + } + + if err := s.persistCursorAwaiting( + r.Context(), session, newSession, previous, plan, + ); err != nil { + unlockLifecycle() + stopApproval() + live.finish() + s.hub.remove(plan.sessionID, live) + if errors.Is(err, errCursorSessionBusy) { + writeError(w, http.StatusConflict, errors.New("a turn is already active for this session")) + return + } + writeError(w, http.StatusInternalServerError, s.cursorSafeError(err)) + return + } + unlockLifecycle() + + sse, err := newSSE(w) + if err != nil { + stopApproval() + live.finish() + s.hub.remove(plan.sessionID, live) + _ = s.abandonCursorApproval(context.Background(), plan, "local stream unavailable") + writeError(w, http.StatusInternalServerError, err) + return + } + + // This is in the replay log before the coordinator can publish approval. + live.publish(agent.Event{ + Type: agent.EventSession, ID: plan.sessionID, Title: plan.sessionTitle, + }) + + go func() { + defer func() { + if recovered := recover(); recovered != nil { + live.publish(agent.Event{ + Type: agent.EventError, + Err: s.cursorSafeError(fmt.Errorf("Cursor coordinator failed")).Error(), + }) + } + live.publish(agent.Event{Type: agent.EventDone}) + live.finish() + stopApproval() + s.hub.remove(plan.sessionID, live) + }() + s.coordinateCursorTurn(approvalCtx, plan, live) + }() + + ctx := r.Context() + stopPing := make(chan struct{}) + defer close(stopPing) + go cursorKeepalive(ctx, stopPing, sse) + _ = live.follow(ctx, 0, func(event agent.Event, _ int) error { + return sse.send(event) + }) +} + +func cursorKeepalive(ctx context.Context, stop <-chan struct{}, sse *sseWriter) { + ticker := time.NewTicker(20 * time.Second) + defer ticker.Stop() + for { + select { + case <-stop: + return + case <-ctx.Done(): + return + case <-ticker.C: + sse.comment("keepalive") + } + } +} + +func (s *Server) cursorSessionCandidate( + ctx context.Context, + request cursorChatRequest, +) (*store.Session, bool, string, error) { + if strings.TrimSpace(request.SessionID) != "" { + session, err := s.db.GetSession(ctx, strings.TrimSpace(request.SessionID)) + if err == nil { + projectDir, _ := session.Meta["project_dir"].(string) + return session, false, strings.TrimSpace(projectDir), nil + } + if !errors.Is(err, store.ErrNotFound) { + return nil, false, "", err + } + } + + projectDir, err := validateCursorProjectDir(request.ProjectDir) + if err != nil { + return nil, false, "", err + } + cfg := s.config() + session := &store.Session{ + ID: newID("ses"), + Title: cursorSessionTitle(s.redactCursorString(request.Message)), + Platform: "web", + Model: cfg.Model.Default, + Provider: cfg.Model.Provider, + Workspace: cfg.Agent.Workspace, + Meta: store.Meta{}, + } + if projectDir != "" { + session.Workspace = projectDir + session.Meta["project_dir"] = projectDir + } + return session, true, projectDir, nil +} + +func validateCursorProjectDir(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", nil + } + dir := filepath.Clean(config.Expand(raw)) + if !filepath.IsAbs(dir) { + return "", errors.New("project_dir must be an absolute path") + } + info, err := os.Stat(dir) + if err != nil || !info.IsDir() { + return "", errors.New("project_dir is not a directory") + } + return dir, nil +} + +func cursorSessionTitle(message string) string { + message = strings.TrimSpace(strings.ReplaceAll(message, "\n", " ")) + if message == "" { + return "Percakapan baru" + } + runes := []rune(message) + if len(runes) > 60 { + return string(runes[:60]) + "…" + } + return message +} + +func (s *Server) prepareCursorTurn( + ctx context.Context, + request cursorChatRequest, + session *store.Session, + projectDir string, +) (cursorTurnPlan, *store.CursorSessionState, error) { + var plan cursorTurnPlan + if strings.TrimSpace(request.Message) == "" { + return plan, nil, errors.New("message is required") + } + mode := strings.ToLower(strings.TrimSpace(request.Mode)) + if mode == "" { + mode = "agent" + } + if mode != "agent" && mode != "plan" { + return plan, nil, errors.New("mode must be agent or plan") + } + + validated, err := s.cursorRunner.ValidateModel( + ctx, + &cursor.ModelSelection{ + ID: strings.TrimSpace(request.Model.ID), + Params: append( + []cursor.ModelParameterSelection(nil), request.Model.Params..., + ), + }, + cursorrun.RequireExactVariant, + ) + if err != nil { + return plan, nil, err + } + if validated == nil { + return plan, nil, errors.New("cursor model is required") + } + model := cloneCursorModelSelection(*validated) + params := append([]cursor.ModelParameterSelection(nil), model.Params...) + if params == nil { + params = []cursor.ModelParameterSelection{} + } + paramsJSON, err := json.Marshal(params) + if err != nil { + return plan, nil, fmt.Errorf("encode Cursor model parameters: %w", err) + } + + images, err := decodeCursorImages(append([]string(nil), request.Images...)) + if err != nil { + return plan, nil, err + } + repository, err := s.resolveCursorRepository( + ctx, request.RepositoryURL, request.StartingRef, request.AutoCreatePR, projectDir, + ) + if err != nil { + return plan, nil, err + } + + var previous *store.CursorSessionState + previous, err = s.db.GetCursorSessionState(ctx, session.ID) + if errors.Is(err, store.ErrNotFound) { + previous = nil + } else if err != nil { + return plan, nil, err + } + if previous != nil { + previous, err = s.reconcileCursorCancelCrashMarker(ctx, previous) + if err != nil { + return plan, nil, err + } + } + if previous != nil { + switch { + case previous.OperationState == store.CursorOperationAmbiguous: + return plan, previous, errCursorAmbiguousCreate + case previous.OperationState == store.CursorOperationRunInFlight && + previous.RemoteStatus == cursorCancelAmbiguous: + return plan, previous, errCursorAmbiguousCancel + case cursorOperationUnfinished(previous.OperationState): + return plan, previous, errCursorSessionBusy + } + } + + reuse := previous != nil && + previous.TargetActive && + previous.ReuseValid && + strings.TrimSpace(previous.AgentID) != "" && + previous.ModelID == model.ID && + previous.ModelParams == string(paramsJSON) && + previous.RepositoryURL == repository.identity && + previous.StartingRef == repository.ref && + previous.AutoCreatePR == request.AutoCreatePR + + plan = cursorTurnPlan{ + sessionID: session.ID, + sessionTitle: truncateCursorRunes(s.redactCursorString(session.Title), 240), + message: strings.Clone(request.Message), + images: append([]cursor.PromptImage(nil), images...), + model: model, + modelParamsJSON: string(paramsJSON), + mode: mode, + repositoryURL: repository.url, + repositoryIdentity: repository.identity, + repositorySource: repository.source, + startingRef: repository.ref, + worktreeDirty: repository.dirty, + localOnlyCommits: repository.localOnlyCommits, + remoteRefKnown: repository.remoteRefKnown, + repositoryWarnings: append([]string(nil), repository.warnings...), + autoCreatePR: request.AutoCreatePR, + reuse: reuse, + userMessageID: newID("msg"), + assistantMessageID: newID("msg"), + } + if reuse { + plan.agentID = previous.AgentID + } + plan.approvalArguments, err = s.cursorTurnApprovalArguments(plan) + if err != nil { + return cursorTurnPlan{}, previous, err + } + return plan, previous, nil +} + +func cloneCursorModelSelection(selection cursor.ModelSelection) cursor.ModelSelection { + cloned := cursor.ModelSelection{ + ID: strings.Clone(selection.ID), + Params: append( + []cursor.ModelParameterSelection(nil), selection.Params..., + ), + } + if selection.Params != nil && cloned.Params == nil { + cloned.Params = []cursor.ModelParameterSelection{} + } + for i := range cloned.Params { + cloned.Params[i].ID = strings.Clone(cloned.Params[i].ID) + cloned.Params[i].Value = strings.Clone(cloned.Params[i].Value) + } + return cloned +} + +func (s *Server) resolveCursorRepository( + ctx context.Context, + repositoryURL *string, + startingRef *string, + autoCreatePR bool, + projectDir string, +) (cursorRepositoryPlan, error) { + var result cursorRepositoryPlan + requestedRef := "" + if startingRef != nil { + requestedRef = strings.TrimSpace(*startingRef) + if err := validateCursorStartingRef(requestedRef); err != nil { + return result, err + } + } + + if repositoryURL != nil { + result.source = "explicit" + raw := strings.TrimSpace(*repositoryURL) + if raw == "" { + if requestedRef != "" { + return result, errors.New("repository_url is required when starting_ref is set") + } + if autoCreatePR { + return result, errors.New("repository_url is required when auto_create_pr is true") + } + return result, nil + } + normalized, err := cursorrun.NormalizeGitHubRepository(raw) + if err != nil { + return result, err + } + if utf8.RuneCountInString(normalized) > maxCursorRepositoryRunes { + return result, errors.New("repository_url is too long") + } + result.url = normalized + result.identity = normalized + result.ref = requestedRef + return result, nil + } + + result.source = "auto" + if projectDir == "" { + if requestedRef != "" { + return result, errors.New("repository_url is required when starting_ref is set") + } + if autoCreatePR { + return result, errors.New("repository_url is required when auto_create_pr is true") + } + result.identity = cursorAutoNoRepositoryIdentity + return result, nil + } + info, err := cursorrun.InspectRepository(ctx, projectDir) + if err != nil { + return result, err + } + if !info.Repository { + if requestedRef != "" { + return result, errors.New("project_dir is not a repository for starting_ref") + } + if autoCreatePR { + return result, errors.New("repository_url is required when auto_create_pr is true") + } + result.identity = cursorAutoNoRepositoryIdentity + return result, nil + } + if info.URL == "" { + return result, errors.New( + "project origin is not a supported credential-free GitHub repository; choose an explicit repository or no repository", + ) + } + if utf8.RuneCountInString(info.URL) > maxCursorRepositoryRunes { + return result, errors.New("discovered repository URL is too long") + } + result.url = info.URL + result.identity = info.URL + result.ref = info.StartingRef + result.dirty = info.Dirty + result.localOnlyCommits = max(0, info.LocalOnlyCommits) + result.remoteRefKnown = info.RemoteRefKnown + if !info.RemoteRefKnown { + result.warnings = append(result.warnings, + "The remote-tracking ref is unavailable, so Antares cannot verify which local commits are present in the Cursor cloud VM.") + } + if info.Dirty { + result.warnings = append(result.warnings, + "Local uncommitted changes are absent from the Cursor cloud VM.") + } + if info.LocalOnlyCommits > 0 { + result.warnings = append(result.warnings, + "Local-only commits not present on the remote ref are absent from the Cursor cloud VM.") + } + if startingRef != nil { + result.ref = requestedRef + } + return result, nil +} + +func validateCursorStartingRef(ref string) error { + if ref == "" { + return nil + } + if utf8.RuneCountInString(ref) > maxCursorStartingRefRunes { + return errors.New("starting_ref is too long") + } + if strings.IndexFunc(ref, func(r rune) bool { + return unicode.IsControl(r) || unicode.IsSpace(r) + }) >= 0 { + return errors.New("starting_ref contains whitespace or control characters") + } + if cursorCredentialToken.MatchString(ref) || + cursorCredentialAssignment.MatchString(ref) || + cursorBearerCredential.MatchString(ref) || + privateKeyMarker(ref) >= 0 { + return errors.New("starting_ref contains credential-like data") + } + if ref == "@" || + strings.HasPrefix(ref, "/") || + strings.HasSuffix(ref, "/") || + strings.HasSuffix(ref, ".") || + strings.Contains(ref, "//") || + strings.Contains(ref, "..") || + strings.Contains(ref, "@{") || + strings.ContainsAny(ref, `~^:?*[\`) { + return errors.New("starting_ref is not a valid Git ref or commit") + } + for _, component := range strings.Split(ref, "/") { + if strings.HasPrefix(component, ".") || + strings.HasSuffix(strings.ToLower(component), ".lock") { + return errors.New("starting_ref is not a valid Git ref or commit") + } + } + return nil +} + +var ( + errCursorSessionBusy = errors.New("cursor session is active") + errCursorAmbiguousCreate = errors.New("cursor create outcome is ambiguous") + errCursorAmbiguousCancel = errors.New("cursor cancellation outcome is ambiguous") +) + +func cursorOperationActive(operation string) bool { + switch operation { + case store.CursorOperationAwaitingApproval, + store.CursorOperationCreateInFlight, + store.CursorOperationRunInFlight, + store.CursorOperationAmbiguous: + return true + default: + return false + } +} + +func cursorOperationUnfinished(operation string) bool { + return cursorOperationActive(operation) || + operation == store.CursorOperationTerminal +} + +func (s *Server) persistCursorAwaiting( + ctx context.Context, + session *store.Session, + newSession bool, + previous *store.CursorSessionState, + plan cursorTurnPlan, +) error { + if newSession { + if err := s.db.CreateSession(ctx, session); err != nil { + return err + } + } + + state := &store.CursorSessionState{ + SessionID: session.ID, + TargetActive: true, + ReuseValid: plan.reuse, + ModelID: plan.model.ID, + ModelParams: plan.modelParamsJSON, + RepositoryURL: plan.repositoryIdentity, + StartingRef: plan.startingRef, + Mode: plan.mode, + AutoCreatePR: plan.autoCreatePR, + AgentID: plan.agentID, + RemoteStatus: "AWAITING_APPROVAL", + OperationState: store.CursorOperationAwaitingApproval, + UserMessageID: plan.userMessageID, + AssistantMessageID: plan.assistantMessageID, + } + if previous == nil { + if err := s.db.PutCursorSessionState(ctx, state); err != nil { + if newSession { + _ = s.db.DeleteSession(context.Background(), session.ID) + } + if errors.Is(err, store.ErrCursorRevisionConflict) { + return errCursorSessionBusy + } + return err + } + } else { + state.Revision = previous.Revision + swapped, err := s.db.CompareAndSwapCursorSessionState( + ctx, state, previous.Revision, + ) + if err != nil { + if newSession { + _ = s.db.DeleteSession(context.Background(), session.ID) + } + return err + } + if !swapped { + if newSession { + _ = s.db.DeleteSession(context.Background(), session.ID) + } + return errCursorSessionBusy + } + } + // Append only after winning the durable CAS reservation. A competing server + // therefore cannot leave a losing user message (or inflate session counts). + if err := s.db.AppendMessage(ctx, &store.Message{ + ID: plan.userMessageID, SessionID: session.ID, Role: store.RoleUser, + Content: plan.message, + Meta: store.Meta{"cursor_image_count": len(plan.images)}, + }); err != nil { + if newSession { + _ = s.db.DeleteSession(context.Background(), session.ID) + } else { + _, _ = s.mutateCursorState(context.Background(), session.ID, + func(current *store.CursorSessionState) error { + if current.OperationState == store.CursorOperationAwaitingApproval && + current.UserMessageID == plan.userMessageID { + current.OperationState = store.CursorOperationIdle + current.ReuseValid = false + current.RemoteStatus = "USER_MESSAGE_PERSIST_FAILED" + } + return nil + }) + } + return err + } + return nil +} + +func (s *Server) coordinateCursorTurn( + ctx context.Context, + plan cursorTurnPlan, + live *liveRun, +) { + approvalMessage := "Start Cursor Cloud Agent run" + if plan.reuse { + approvalMessage = "Continue Cursor Cloud Agent run" + } + allowed, approvalErr := s.agent.AwaitOperationApproval( + ctx, + approval.Operation{ + SessionID: plan.sessionID, + Tool: "cursor_direct", + Arguments: plan.approvalArguments, + Message: approvalMessage, + Reason: "Cursor operations are paid and change remote state", + }, + func(event agent.Event) error { + live.publish(event) + return nil + }, + ) + if approvalErr != nil { + _ = s.abandonCursorApproval(context.Background(), plan, "approval interrupted") + live.publish(agent.Event{ + Type: agent.EventError, + Err: s.cursorSafeError(approvalErr).Error(), + }) + return + } + if !allowed { + _ = s.abandonCursorApproval(context.Background(), plan, "approval refused") + live.publish(agent.Event{ + Type: agent.EventError, + Err: "Cursor operation was refused and no remote request was sent", + }) + return + } + if err := ctx.Err(); err != nil { + _ = s.abandonCursorApproval(context.Background(), plan, "local watcher stopped") + live.publish(agent.Event{Type: agent.EventNotice, Message: "stopped watching Cursor"}) + return + } + + operation := store.CursorOperationCreateInFlight + if plan.reuse { + operation = store.CursorOperationRunInFlight + } + _, err := s.mutateCursorState(context.Background(), plan.sessionID, + func(state *store.CursorSessionState) error { + if state.OperationState != store.CursorOperationAwaitingApproval || + state.UserMessageID != plan.userMessageID { + return errCursorStateChanged + } + state.OperationState = operation + state.RunID = "" + state.LastEventID = "" + state.PartialText = "" + state.PartialReasoning = "" + state.GitState = "" + if plan.reuse { + state.AgentID = plan.agentID + state.RemoteStatus = "CREATE_RUN_IN_FLIGHT" + } else { + state.AgentID = "" + state.ReuseValid = false + state.RemoteStatus = "CREATE_AGENT_IN_FLIGHT" + } + return nil + }) + if err != nil { + live.publish(agent.Event{Type: agent.EventError, Err: s.cursorSafeError(err).Error()}) + return + } + if !live.beginCursorCreate() { + stopErr := errors.New("local Stop won before the Cursor create request") + _ = s.recordCursorCreateFailure( + context.Background(), plan, operation, stopErr, false, + ) + live.publish(agent.Event{ + Type: agent.EventNotice, Message: "stopped before starting Cursor", + }) + return + } + + // Once the non-idempotent POST boundary is crossed, local Stop may only + // record detachment. The runner's own timeout still bounds transport hangs. + agentID, run, createErr := s.createCursorRun(context.Background(), plan) + if createErr != nil { + ambiguous := cursorCreateCouldBeAmbiguous(createErr) + _ = s.recordCursorCreateFailure( + context.Background(), plan, operation, createErr, ambiguous, + ) + message := s.cursorEventError(createErr) + if ambiguous { + message = "Cursor may have accepted the create request, but no run IDs were returned; it will not be retried automatically" + } + live.publish(agent.Event{Type: agent.EventError, Err: message}) + return + } + if run == nil || strings.TrimSpace(agentID) == "" || strings.TrimSpace(run.ID) == "" { + createErr = errors.New("Cursor returned no durable agent/run IDs") + _ = s.recordCursorCreateFailure( + context.Background(), plan, operation, createErr, true, + ) + live.publish(agent.Event{ + Type: agent.EventError, + Err: "Cursor create response did not contain durable IDs; it will not be retried automatically", + }) + return + } + + state, err := s.mutateCursorState(context.Background(), plan.sessionID, + func(state *store.CursorSessionState) error { + if state.OperationState != operation || + state.UserMessageID != plan.userMessageID { + return errCursorStateChanged + } + state.AgentID = agentID + state.RunID = run.ID + state.RemoteStatus = truncateCursorRunes( + s.redactCursorString(run.Status), maxCursorRemoteStatusRunes, + ) + state.OperationState = store.CursorOperationRunInFlight + state.ReuseValid = true + return nil + }) + if err != nil { + live.publish(agent.Event{ + Type: agent.EventError, + Err: "Cursor accepted the run, but its IDs could not be persisted safely", + }) + return + } + watchCtx, stopWatch := context.WithCancel(context.Background()) + if !live.beginCursorWatch(stopWatch) { + live.publish(agent.Event{ + Type: agent.EventNotice, + Message: "stopped watching Cursor after its run IDs were saved; attach to recover", + }) + return + } + defer stopWatch() + if err := s.watchCursorRun(watchCtx, state, live); err != nil { + if errors.Is(err, context.Canceled) { + live.publish(agent.Event{ + Type: agent.EventNotice, + Message: "stopped watching Cursor; the remote run may still be active", + }) + return + } + live.publish(agent.Event{Type: agent.EventError, Err: s.cursorEventError(err)}) + } +} + +func (s *Server) cursorTurnApprovalArguments(plan cursorTurnPlan) (string, error) { + params := append([]cursor.ModelParameterSelection(nil), plan.model.Params...) + if params == nil { + params = []cursor.ModelParameterSelection{} + } + kind := "new_agent" + operation := "start" + if plan.reuse { + kind = "follow_up" + operation = "follow_up" + } + warningCount := min(len(plan.repositoryWarnings), maxCursorApprovalWarnings) + warnings := make([]string, 0, warningCount) + for _, warning := range plan.repositoryWarnings[:warningCount] { + warnings = append(warnings, truncateCursorRunes( + s.redactCursorString(warning), maxCursorApprovalWarningRunes, + )) + } + display := cursorDirectApprovalProjection{ + Operation: operation, + Kind: kind, + Model: cursorApprovalModel{ + ID: plan.model.ID, Params: params, + }, + RepositoryURL: plan.repositoryURL, + Repository: plan.repositorySource, + StartingRef: plan.startingRef, + WorktreeDirty: plan.worktreeDirty, + LocalOnly: max(0, plan.localOnlyCommits), + RemoteKnown: plan.remoteRefKnown, + Warnings: warnings, + Mode: plan.mode, + AutoCreatePR: plan.autoCreatePR, + PromptPreview: s.cursorPromptPreview(plan.message), + ImageCount: len(plan.images), + } + raw, err := json.Marshal(display) + if err != nil { + return "", err + } + if len(raw) > 16<<10 { + return "", errors.New("Cursor approval projection exceeds the safe display limit") + } + return string(raw), nil +} + +func (s *Server) createCursorRun( + ctx context.Context, + plan cursorTurnPlan, +) (string, *cursor.Run, error) { + prompt := cursor.Prompt{ + Text: strings.Clone(plan.message), + Images: append([]cursor.PromptImage(nil), plan.images...), + } + if plan.reuse { + run, err := s.cursorRunner.CreateRun(ctx, plan.agentID, cursor.CreateRunRequest{ + Prompt: prompt, + Mode: plan.mode, + }) + return plan.agentID, run, err + } + repositories := []cursor.Repository{} + if plan.repositoryURL != "" { + repositories = append(repositories, cursor.Repository{ + URL: plan.repositoryURL, StartingRef: plan.startingRef, + }) + } + model := cloneCursorModelSelection(plan.model) + created, err := s.cursorRunner.CreateAgent(ctx, cursor.CreateAgentRequest{ + Prompt: prompt, + Model: &model, + Repos: repositories, + AutoCreatePR: plan.autoCreatePR, + SkipReviewerRequest: true, + Mode: plan.mode, + }) + if err != nil { + return "", nil, err + } + if created == nil { + return "", nil, errors.New("Cursor returned an empty create response") + } + return created.Agent.ID, &created.Run, nil +} + +func cursorCreateCouldBeAmbiguous(err error) bool { + if err == nil { + return false + } + if errors.Is(err, cursorrun.ErrNotConfigured) { + // Runner option resolution happens before either create POST. + return false + } + var apiError *cursor.APIError + if errors.As(err, &apiError) { + // A definitive client rejection cannot have created the run. Timeout + // and server/gateway responses can arrive after the mutation committed. + return apiError.Status == 0 || + apiError.Status == http.StatusRequestTimeout || + apiError.Status >= http.StatusInternalServerError + } + return true +} + +func (s *Server) recordCursorCreateFailure( + ctx context.Context, + plan cursorTurnPlan, + operation string, + createErr error, + ambiguous bool, +) error { + _, err := s.mutateCursorState(ctx, plan.sessionID, + func(state *store.CursorSessionState) error { + if state.OperationState != operation || + state.UserMessageID != plan.userMessageID { + return errCursorStateChanged + } + state.ReuseValid = false + state.RemoteStatus = s.cursorSafeError(createErr).Error() + if ambiguous { + state.OperationState = store.CursorOperationAmbiguous + } else { + state.OperationState = store.CursorOperationIdle + } + return nil + }) + return err +} + +func (s *Server) abandonCursorApproval( + ctx context.Context, + plan cursorTurnPlan, + status string, +) error { + _, err := s.mutateCursorState(ctx, plan.sessionID, + func(state *store.CursorSessionState) error { + if state.OperationState != store.CursorOperationAwaitingApproval || + state.UserMessageID != plan.userMessageID { + return nil + } + state.OperationState = store.CursorOperationIdle + state.RemoteStatus = status + state.ReuseValid = false + return nil + }) + return err +} + +func (s *Server) writeCursorPreparationError(w http.ResponseWriter, err error) { + status := http.StatusBadRequest + if errors.Is(err, errCursorAmbiguousCreate) { + status = http.StatusConflict + err = errors.New( + "Cursor create outcome is ambiguous and will not be retried; delete this local session to discard it without retrying or cancelling remote work", + ) + } else if errors.Is(err, errCursorAmbiguousCancel) { + status = http.StatusConflict + err = errors.New( + "Cursor cancellation outcome is ambiguous and will not be retried; delete this local session to discard it without another remote request", + ) + } else if errors.Is(err, errCursorSessionBusy) { + status = http.StatusConflict + err = errors.New("a turn is already active for this session") + } else if errors.Is(err, cursorrun.ErrNotConfigured) { + status = http.StatusPreconditionRequired + } else { + var apiError *cursor.APIError + if errors.As(err, &apiError) { + switch apiError.Status { + case http.StatusUnauthorized, http.StatusForbidden, + http.StatusTooManyRequests: + status = apiError.Status + } + if apiError.RetryAfter > 0 { + w.Header().Set("Retry-After", fmt.Sprintf( + "%d", max(1, int(apiError.RetryAfter/time.Second)), + )) + } + } + } + writeError(w, status, s.cursorSafeError(err)) +} + +func (s *Server) writeCursorUpstreamError( + w http.ResponseWriter, + err error, + defaultStatus int, +) { + status := defaultStatus + var apiError *cursor.APIError + if errors.Is(err, cursorrun.ErrNotConfigured) { + status = http.StatusPreconditionRequired + } else if errors.As(err, &apiError) { + switch apiError.Status { + case http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, + http.StatusNotFound, http.StatusConflict, + http.StatusRequestTimeout, http.StatusTooManyRequests: + status = apiError.Status + default: + if apiError.Status >= http.StatusInternalServerError { + status = apiError.Status + } + } + if apiError.RetryAfter > 0 { + w.Header().Set("Retry-After", fmt.Sprintf( + "%d", max(1, int(apiError.RetryAfter/time.Second)), + )) + } + } + writeError(w, status, errors.New(s.cursorEventError(err))) +} + +var ( + cursorCredentialToken = regexp.MustCompile( + `(?i)\b(?:(?:crsr|github_pat|ghp|gho|ghu|ghs|ghr|sk)[_-][a-z0-9_-]{6,}|AKIA[0-9A-Z]{16})`, + ) + cursorCredentialAssignment = regexp.MustCompile( + `(?i)\b(api[_-]?key|authorization|bearer|password|secret|token)\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s,;]+)`, + ) + cursorBearerCredential = regexp.MustCompile( + `(?i)\bbearer\s+[a-z0-9._~+/=-]{6,}`, + ) + cursorURLUserinfo = regexp.MustCompile(`(?i)(https?://)[^/\s@]+@`) + cursorPrivateKeyHeader = regexp.MustCompile( + `(?i)-----BEGIN[^\r\n]*PRIVATE KEY[^\r\n]*-----`, + ) +) + +func (s *Server) cursorPromptPreview(message string) string { + return truncateCursorRunes(s.redactCursorString(message), maxCursorPromptPreviewRunes) +} + +func (s *Server) cursorSafeError(err error) error { + if err == nil { + return nil + } + return errors.New(truncateCursorRunes( + s.redactCursorString(err.Error()), maxCursorServerErrorRunes, + )) +} + +func (s *Server) cursorEventError(err error) string { + if err == nil { + return "" + } + message := s.cursorSafeError(err).Error() + var apiError *cursor.APIError + if errors.As(err, &apiError) && apiError.RetryAfter > 0 { + message += fmt.Sprintf( + " (retry after %d seconds)", + max(1, int(apiError.RetryAfter/time.Second)), + ) + } + return truncateCursorRunes(message, maxCursorServerErrorRunes) +} + +func (s *Server) redactCursorString(value string) string { + value = strings.ToValidUTF8(value, "\uFFFD") + if cfg := s.config(); cfg != nil { + _, provider := cfg.ResolveProvider("cursor") + for _, secret := range []string{ + strings.TrimSpace(provider.APIKey), + strings.TrimSpace(cfg.Server.AuthToken), + } { + if secret != "" { + value = strings.ReplaceAll(value, secret, "[REDACTED]") + } + } + } + value = cursorURLUserinfo.ReplaceAllString(value, `${1}[REDACTED]@`) + value = cursorCredentialAssignment.ReplaceAllString(value, `${1}=[REDACTED]`) + value = cursorBearerCredential.ReplaceAllString(value, "Bearer [REDACTED]") + value = cursorCredentialToken.ReplaceAllString(value, "[REDACTED]") + if marker := privateKeyMarker(value); marker >= 0 { + value = value[:marker] + "[REDACTED PRIVATE KEY]" + } + return value +} + +func privateKeyMarker(value string) int { + location := cursorPrivateKeyHeader.FindStringIndex(value) + if location == nil { + return -1 + } + return location[0] +} + +func truncateCursorRunes(value string, maximum int) string { + value = strings.ToValidUTF8(value, "\uFFFD") + if maximum <= 0 { + return "" + } + if utf8.RuneCountInString(value) <= maximum { + return value + } + runes := []rune(value) + if maximum == 1 { + return "…" + } + return string(runes[:maximum-1]) + "…" +} diff --git a/internal/server/handlers_cursor_cleanup_test.go b/internal/server/handlers_cursor_cleanup_test.go new file mode 100644 index 0000000..87d648b --- /dev/null +++ b/internal/server/handlers_cursor_cleanup_test.go @@ -0,0 +1,310 @@ +package server + +import ( + "context" + "fmt" + "net/http" + "slices" + "strings" + "testing" + "time" + + "github.com/enowdev/antares/internal/store" +) + +func TestCursorCleanupHandlersPrecheckActiveStateAcrossPagination(t *testing.T) { + tests := []struct { + name string + path string + body string + edit func(*store.Session) + }{ + { + name: "empty", path: "/api/sessions/empty/delete", + edit: func(session *store.Session) { session.MessageCount = 0 }, + }, + { + name: "prune", path: "/api/sessions/prune", + body: `{"older_than_days":1}`, + edit: func(session *store.Session) { + session.MessageCount = 1 + session.UpdatedAt = time.Now().Add(-48 * time.Hour) + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fixture := newCursorDirectTestServer(t) + activeID := "cleanup-active-" + test.name + seedCleanupCursorState( + t, fixture, activeID, store.CursorOperationRunInFlight, "RUNNING", + ) + + sessions := make([]store.Session, 501) + for i := range 500 { + sessions[i] = store.Session{ + ID: fmt.Sprintf("cleanup-page-%s-%03d", test.name, i), + } + test.edit(&sessions[i]) + } + sessions[500] = store.Session{ID: activeID} + test.edit(&sessions[500]) + paged := &pagedCleanupStore{Store: fixture.db, sessions: sessions} + fixture.server.db = paged + + status, _ := postCursorCleanup(t, fixture, test.path, test.body) + if status != http.StatusConflict { + t.Fatalf("cleanup status=%d, want 409", status) + } + if paged.listCalls < 2 { + t.Fatalf("cleanup listed %d page(s), want at least 2", paged.listCalls) + } + if paged.deleteEmptyCalls != 0 || paged.pruneCalls != 0 { + t.Fatal("cleanup mutated storage after finding active remote state") + } + }) + } +} + +func TestCursorAutomaticCleanupBlocksTerminalWithoutLiveWatcher(t *testing.T) { + tests := []struct { + name string + path string + body string + edit func(*store.Session) + }{ + { + name: "empty", path: "/api/sessions/empty/delete", + edit: func(session *store.Session) { session.MessageCount = 0 }, + }, + { + name: "prune", path: "/api/sessions/prune", + body: `{"older_than_days":1}`, + edit: func(session *store.Session) { + session.MessageCount = 1 + session.UpdatedAt = time.Now().Add(-48 * time.Hour) + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fixture := newCursorDirectTestServer(t) + sessionID := "cleanup-terminal-" + test.name + seedCleanupCursorState( + t, fixture, sessionID, store.CursorOperationTerminal, "COMPLETED", + ) + if live := fixture.server.hub.get(sessionID); live != nil { + t.Fatal("terminal cleanup regression requires no live watcher") + } + + session := store.Session{ID: sessionID} + test.edit(&session) + paged := &pagedCleanupStore{ + Store: fixture.db, sessions: []store.Session{session}, + } + fixture.server.db = paged + + status, _ := postCursorCleanup(t, fixture, test.path, test.body) + if status != http.StatusConflict { + t.Fatalf("terminal cleanup status=%d, want 409", status) + } + if paged.deleteEmptyCalls != 0 || paged.pruneCalls != 0 { + t.Fatal("automatic cleanup mutated terminal uncommitted work") + } + }) + } +} + +func TestDeleteAllSessionsPaginatesBeforeCategoryFiltering(t *testing.T) { + fixture := newCursorDirectTestServer(t) + sessions, selected := pagedCategorySessions(1103) + ambiguousID := sessions[1100].ID + seedCleanupCursorState( + t, fixture, ambiguousID, store.CursorOperationAmbiguous, "AMBIGUOUS_CREATE_OUTCOME", + ) + paged := &pagedCleanupStore{Store: fixture.db, sessions: sessions} + fixture.server.db = paged + + status, _ := postCursorCleanup( + t, fixture, "/api/sessions/delete-all", `{"category":"project"}`, + ) + if status != http.StatusOK { + t.Fatalf("paginated category deletion status=%d, want 200", status) + } + if paged.listCalls < 3 { + t.Fatalf("category deletion listed %d page(s), want at least 3", paged.listCalls) + } + if len(selected) <= cursorCleanupPageSize { + t.Fatalf("test selected only %d sessions, need more than one page", len(selected)) + } + if !slices.Equal(paged.deletedSessionIDs, selected) { + t.Fatalf("deleted %d category sessions, want all %d", + len(paged.deletedSessionIDs), len(selected)) + } +} + +func TestDeleteAllSessionsPrechecksActiveStateOnLaterPage(t *testing.T) { + fixture := newCursorDirectTestServer(t) + sessions, _ := pagedCategorySessions(1103) + activeID := sessions[1102].ID + seedCleanupCursorState( + t, fixture, activeID, store.CursorOperationRunInFlight, "RUNNING", + ) + paged := &pagedCleanupStore{Store: fixture.db, sessions: sessions} + fixture.server.db = paged + + status, _ := postCursorCleanup( + t, fixture, "/api/sessions/delete-all", `{"category":"project"}`, + ) + if status != http.StatusConflict { + t.Fatalf("late-page active category deletion status=%d, want 409", status) + } + if len(paged.deletedSessionIDs) != 0 { + t.Fatal("category deletion mutated storage before completing active precheck") + } +} + +func pagedCategorySessions(count int) ([]store.Session, []string) { + sessions := make([]store.Session, count) + var selected []string + for i := range sessions { + session := store.Session{ID: fmt.Sprintf("delete-all-page-%04d", i)} + if i%2 == 0 { + session.Meta = store.Meta{"project_dir": "/tmp/project"} + selected = append(selected, session.ID) + } + sessions[i] = session + } + return sessions, selected +} + +func TestCursorActiveRemotePredicateIsPureForCancelCrashMarker(t *testing.T) { + fixture := newCursorDirectTestServer(t) + sessionID := "cleanup-stale-cancel-marker" + seedCleanupCursorState( + t, fixture, sessionID, store.CursorOperationRunInFlight, cursorCancelInFlight, + ) + + if !fixture.server.reserveCursorCancel(sessionID, "run-"+sessionID) { + t.Fatal("could not reserve process-local cancellation") + } + active, err := fixture.server.cursorSessionHasActiveRemoteState( + context.Background(), sessionID, + ) + if err != nil || !active { + t.Fatalf("locally executing cancellation active=%v err=%v", active, err) + } + fixture.server.releaseCursorCancel(sessionID, "run-"+sessionID) + + active, err = fixture.server.cursorSessionHasActiveRemoteState( + context.Background(), sessionID, + ) + if err != nil || active { + t.Fatalf("stale cancellation marker active=%v err=%v", active, err) + } + state, err := fixture.db.GetCursorSessionState(context.Background(), sessionID) + if err != nil { + t.Fatal(err) + } + if state.RemoteStatus != cursorCancelInFlight { + t.Fatalf("predicate mutated crash marker to %q", state.RemoteStatus) + } +} + +func seedCleanupCursorState( + t *testing.T, + fixture *cursorDirectFixture, + sessionID string, + operation string, + remoteStatus string, +) { + t.Helper() + if err := fixture.db.CreateSession(context.Background(), &store.Session{ + ID: sessionID, Title: sessionID, Platform: "web", Meta: store.Meta{}, + }); err != nil { + t.Fatal(err) + } + if err := fixture.db.PutCursorSessionState(context.Background(), &store.CursorSessionState{ + SessionID: sessionID, + TargetActive: true, + ReuseValid: true, + ModelID: "gpt-5.6-sol", + ModelParams: `[]`, + AgentID: "bc-" + sessionID, + RunID: "run-" + sessionID, + RemoteStatus: remoteStatus, + OperationState: operation, + }); err != nil { + t.Fatal(err) + } +} + +type pagedCleanupStore struct { + store.Store + sessions []store.Session + listCalls int + deleteEmptyCalls int + pruneCalls int + deletedSessionIDs []string +} + +func (s *pagedCleanupStore) ListSessions( + _ context.Context, + filter store.SessionFilter, +) ([]store.Session, int64, error) { + s.listCalls++ + start := filter.Offset + if start > len(s.sessions) { + start = len(s.sessions) + } + limit := filter.Limit + if limit <= 0 || limit > cursorCleanupPageSize { + limit = 50 + } + end := min(start+limit, len(s.sessions)) + return append([]store.Session(nil), s.sessions[start:end]...), + int64(len(s.sessions)), nil +} + +func (s *pagedCleanupStore) DeleteEmptySessions(context.Context) (int64, error) { + s.deleteEmptyCalls++ + return 0, nil +} + +func (s *pagedCleanupStore) PruneSessions(context.Context, time.Time) (int64, error) { + s.pruneCalls++ + return 0, nil +} + +func (s *pagedCleanupStore) DeleteSessions( + _ context.Context, + sessionIDs []string, +) (int64, error) { + s.deletedSessionIDs = append([]string(nil), sessionIDs...) + return int64(len(sessionIDs)), nil +} + +func postCursorCleanup( + t *testing.T, + fixture *cursorDirectFixture, + path string, + body string, +) (int, string) { + t.Helper() + request, err := http.NewRequest( + http.MethodPost, fixture.http.URL+path, strings.NewReader(body), + ) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Authorization", "Bearer test-token") + request.Header.Set("Content-Type", "application/json") + response, err := fixture.http.Client().Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + raw := make([]byte, 16<<10) + n, _ := response.Body.Read(raw) + return response.StatusCode, string(raw[:n]) +} diff --git a/internal/server/handlers_cursor_fix_test.go b/internal/server/handlers_cursor_fix_test.go new file mode 100644 index 0000000..0f10527 --- /dev/null +++ b/internal/server/handlers_cursor_fix_test.go @@ -0,0 +1,954 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/enowdev/antares/internal/agent" + "github.com/enowdev/antares/internal/cursor" + "github.com/enowdev/antares/internal/cursorrun" + "github.com/enowdev/antares/internal/store" +) + +func TestCursorChatAmbiguousStatesCanBeDeletedLocally(t *testing.T) { + tests := []struct { + name string + bulk bool + cancel bool + }{ + {name: "create single"}, + {name: "create bulk", bulk: true}, + {name: "cancel single", cancel: true}, + {name: "cancel bulk", bulk: true, cancel: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fixture := newCursorDirectTestServer(t) + sessionID := seedRecoverableCursorSession(t, fixture) + _, err := fixture.server.mutateCursorState(context.Background(), sessionID, + func(state *store.CursorSessionState) error { + if test.cancel { + state.OperationState = store.CursorOperationRunInFlight + state.RemoteStatus = cursorCancelAmbiguous + } else { + state.OperationState = store.CursorOperationAmbiguous + state.AgentID = "" + state.RunID = "" + state.RemoteStatus = "AMBIGUOUS_CREATE_OUTCOME" + } + return nil + }) + if err != nil { + t.Fatal(err) + } + + request := defaultCursorChatRequest() + request.SessionID = sessionID + status, body := postCursorChatStatus(t, fixture, request) + if status != http.StatusConflict || + !strings.Contains(strings.ToLower(body), "delete") { + t.Fatalf("ambiguous turn status=%d body=%q, want 409 with local-delete escape", status, body) + } + + if test.bulk { + status = deleteAllCursorSessions(t, fixture) + } else { + status = deleteCursorSession(t, fixture, sessionID) + } + if status != http.StatusOK { + t.Fatalf("local reconciliation delete status=%d, want 200", status) + } + if _, err := fixture.db.GetSession(context.Background(), sessionID); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("ambiguous local session survived deletion: %v", err) + } + if fixture.runner.CancelCalls() != 0 || fixture.runner.CreateAgentCalls() != 0 { + t.Fatal("local ambiguous deletion retried or cancelled remote work") + } + }) + } +} + +func TestOrdinaryChatSecondTurnSupersedesLiveRun(t *testing.T) { + fixture := newCursorDirectTestServer(t) + sessionID := "ses-ordinary-supersede" + if err := fixture.db.CreateSession(context.Background(), &store.Session{ + ID: sessionID, Title: "ordinary", Platform: "web", Meta: store.Meta{}, + }); err != nil { + t.Fatal(err) + } + first := newLiveRun() + second := newLiveRun() + if err := fixture.server.reserveOrdinaryChat(context.Background(), sessionID, first); err != nil { + t.Fatal(err) + } + if err := fixture.server.reserveOrdinaryChat(context.Background(), sessionID, second); err != nil { + t.Fatalf("second ordinary turn lost supersede compatibility: %v", err) + } + if got := fixture.server.hub.get(sessionID); got != second { + t.Fatalf("ordinary hub selected %p, want replacement %p", got, second) + } +} + +func TestCursorCancelDefinitiveFailuresRestoreStatusAndAllowRetry(t *testing.T) { + for _, statusCode := range []int{ + http.StatusBadRequest, + http.StatusUnauthorized, + http.StatusForbidden, + http.StatusConflict, + http.StatusTooManyRequests, + } { + t.Run(http.StatusText(statusCode), func(t *testing.T) { + fixture := newCursorDirectTestServer(t) + sessionID := seedRecoverableCursorSession(t, fixture) + fixture.runner.holdStream() + fixture.runner.mu.Lock() + fixture.runner.cancelErr = &cursor.APIError{ + Status: statusCode, Code: "definitive", Message: "rejected", + } + fixture.runner.mu.Unlock() + + if status := approveCursorCancel(t, fixture, sessionID); status != statusCode { + t.Fatalf("definitive cancel status=%d, want %d", status, statusCode) + } + state, err := fixture.db.GetCursorSessionState(context.Background(), sessionID) + if err != nil { + t.Fatal(err) + } + if state.RemoteStatus != "RUNNING" { + t.Fatalf("definitive failure left status=%q, want restored RUNNING", state.RemoteStatus) + } + + fixture.runner.mu.Lock() + fixture.runner.cancelErr = nil + fixture.runner.mu.Unlock() + if status := approveCursorCancel(t, fixture, sessionID); status != http.StatusOK { + t.Fatalf("approved retry status=%d, want 200", status) + } + if fixture.runner.CancelCalls() != 2 { + t.Fatalf("CancelRun calls=%d, want retry call", fixture.runner.CancelCalls()) + } + fixture.runner.releaseStream() + }) + } +} + +func TestCursorCancelNotConfiguredRestoresStatusAndReturnsActionableError(t *testing.T) { + fixture := newCursorDirectTestServer(t) + sessionID := seedRecoverableCursorSession(t, fixture) + fixture.runner.holdStream() + fixture.runner.mu.Lock() + fixture.runner.cancelErr = fmt.Errorf("runner options: %w", cursorrun.ErrNotConfigured) + fixture.runner.mu.Unlock() + + result := approveCursorCancelResponse(t, fixture, sessionID) + if result.status != http.StatusPreconditionRequired { + t.Fatalf("not-configured cancellation status=%d, want 428", result.status) + } + if !strings.Contains(result.body, cursorrun.ErrNotConfigured.Error()) || + strings.Contains(strings.ToLower(result.body), "ambiguous") { + t.Fatalf("not-configured cancellation response is not actionable: %q", result.body) + } + state, err := fixture.db.GetCursorSessionState(context.Background(), sessionID) + if err != nil { + t.Fatal(err) + } + if state.RemoteStatus != "RUNNING" { + t.Fatalf("not-configured cancellation left status=%q, want restored RUNNING", + state.RemoteStatus) + } + + fixture.runner.mu.Lock() + fixture.runner.cancelErr = nil + fixture.runner.mu.Unlock() + if status := approveCursorCancel(t, fixture, sessionID); status != http.StatusOK { + t.Fatalf("configured cancellation retry status=%d, want 200", status) + } + if fixture.runner.CancelCalls() != 2 { + t.Fatalf("CancelRun calls=%d, want one definitive retry", fixture.runner.CancelCalls()) + } + fixture.runner.releaseStream() +} + +func TestCursorCreateNotConfiguredRestoresIdleWithoutLocalDeletion(t *testing.T) { + tests := []struct { + name string + prepare func(*testing.T, *cursorDirectFixture) cursorChatRequest + }{ + { + name: "CreateAgent", + prepare: func(_ *testing.T, fixture *cursorDirectFixture) cursorChatRequest { + fixture.runner.mu.Lock() + fixture.runner.createAgentErr = fmt.Errorf( + "runner options: %w", cursorrun.ErrNotConfigured, + ) + fixture.runner.mu.Unlock() + return defaultCursorChatRequest() + }, + }, + { + name: "CreateRun", + prepare: func(t *testing.T, fixture *cursorDirectFixture) cursorChatRequest { + first := defaultCursorChatRequest() + sessionID := approvedCursorTurn(t, fixture, first) + fixture.runner.mu.Lock() + fixture.runner.createRunErr = fmt.Errorf( + "runner options: %w", cursorrun.ErrNotConfigured, + ) + fixture.runner.mu.Unlock() + first.SessionID = sessionID + first.Message = "continue after configuration was disabled" + return first + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fixture := newCursorDirectTestServer(t) + request := test.prepare(t, fixture) + + stream := postCursorChat(t, fixture, request) + defer stream.Close() + session := stream.NextType(t, agent.EventSession) + approval := stream.NextType(t, agent.EventApproval) + resolveApproval(t, fixture, approval.ID, true) + failure := stream.NextType(t, agent.EventError) + stream.NextType(t, agent.EventDone) + + if !strings.Contains(failure.Err, cursorrun.ErrNotConfigured.Error()) { + t.Fatalf("configuration failure was not actionable: %q", failure.Err) + } + lower := strings.ToLower(failure.Err) + if strings.Contains(lower, "may have accepted") || + strings.Contains(lower, "delete") { + t.Fatalf("configuration failure was reported as remote ambiguity: %q", failure.Err) + } + state, err := fixture.db.GetCursorSessionState( + context.Background(), session.ID, + ) + if err != nil { + t.Fatal(err) + } + if state.OperationState != store.CursorOperationIdle || state.ReuseValid { + t.Fatalf("configuration failure state=%+v, want idle and non-reusable", state) + } + if !strings.Contains(state.RemoteStatus, cursorrun.ErrNotConfigured.Error()) { + t.Fatalf("configuration failure status is not actionable: %q", state.RemoteStatus) + } + }) + } +} + +func TestCursorCreateAmbiguityClassification(t *testing.T) { + tests := []struct { + name string + err error + ambiguous bool + }{ + { + name: "not configured", + err: fmt.Errorf("options: %w", cursorrun.ErrNotConfigured), + }, + {name: "definitive client rejection", err: &cursor.APIError{Status: http.StatusBadRequest}}, + {name: "context", err: context.DeadlineExceeded, ambiguous: true}, + {name: "transport", err: errors.New("connection reset"), ambiguous: true}, + {name: "API transport", err: &cursor.APIError{Status: 0}, ambiguous: true}, + { + name: "request timeout", + err: &cursor.APIError{Status: http.StatusRequestTimeout}, ambiguous: true, + }, + { + name: "server", + err: &cursor.APIError{Status: http.StatusServiceUnavailable}, ambiguous: true, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := cursorCreateCouldBeAmbiguous(test.err); got != test.ambiguous { + t.Fatalf("cursorCreateCouldBeAmbiguous(%v)=%v, want %v", + test.err, got, test.ambiguous) + } + }) + } +} + +func TestCursorCancelNotFoundReconcilesNoActiveRun(t *testing.T) { + fixture := newCursorDirectTestServer(t) + sessionID := seedRecoverableCursorSession(t, fixture) + fixture.runner.holdStream() + fixture.runner.mu.Lock() + fixture.runner.cancelErr = &cursor.APIError{ + Status: http.StatusNotFound, Code: "not_found", Message: "gone", + } + fixture.runner.mu.Unlock() + + if status := approveCursorCancel(t, fixture, sessionID); status != http.StatusOK { + t.Fatalf("404 reconciliation status=%d, want 200", status) + } + state, err := fixture.db.GetCursorSessionState(context.Background(), sessionID) + if err != nil { + t.Fatal(err) + } + if state.OperationState != store.CursorOperationIdle || + state.RemoteStatus != "ANTARES_CANCEL_NO_ACTIVE_RUN" || + state.ReuseValid { + t.Fatalf("404 was not reconciled as no-active success: %+v", state) + } + if status := deleteCursorSession(t, fixture, sessionID); status != http.StatusOK { + t.Fatalf("delete reconciled no-active session status=%d", status) + } + fixture.runner.releaseStream() +} + +func TestCursorCancelUncertainFailuresRemainAmbiguousAndDeletable(t *testing.T) { + tests := []struct { + name string + err error + }{ + {name: "context", err: context.DeadlineExceeded}, + {name: "transport", err: errors.New("connection reset")}, + {name: "api transport", err: &cursor.APIError{Status: 0, Message: "transport failed"}}, + {name: "request timeout", err: &cursor.APIError{Status: http.StatusRequestTimeout}}, + {name: "server", err: &cursor.APIError{Status: http.StatusInternalServerError}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fixture := newCursorDirectTestServer(t) + sessionID := seedRecoverableCursorSession(t, fixture) + fixture.runner.holdStream() + fixture.runner.mu.Lock() + fixture.runner.cancelErr = test.err + fixture.runner.mu.Unlock() + + if status := approveCursorCancel(t, fixture, sessionID); status == http.StatusOK { + t.Fatal("uncertain cancellation unexpectedly reported success") + } + state, err := fixture.db.GetCursorSessionState(context.Background(), sessionID) + if err != nil { + t.Fatal(err) + } + if state.RemoteStatus != cursorCancelAmbiguous { + t.Fatalf("uncertain cancellation status=%q, want %q", + state.RemoteStatus, cursorCancelAmbiguous) + } + if status := postCursorCancel(t, fixture, sessionID); status != http.StatusConflict { + t.Fatalf("ambiguous cancellation retry status=%d, want 409", status) + } + if fixture.runner.CancelCalls() != 1 { + t.Fatalf("ambiguous cancellation calls=%d, want exactly one", fixture.runner.CancelCalls()) + } + if status := deleteCursorSession(t, fixture, sessionID); status != http.StatusOK { + t.Fatalf("delete cancel-ambiguous session status=%d", status) + } + fixture.runner.releaseStream() + }) + } +} + +func TestCursorCancelAmbiguousResponseIsBoundedAndNonRetryable(t *testing.T) { + secret := "round2-cancel-upstream-secret" + tests := []struct { + name string + err error + }{ + { + name: "request timeout", + err: &cursor.APIError{ + Status: http.StatusRequestTimeout, + Message: secret + strings.Repeat("x", 8<<10), + }, + }, + { + name: "server", + err: &cursor.APIError{ + Status: http.StatusServiceUnavailable, + Message: secret + strings.Repeat("x", 8<<10), + }, + }, + {name: "context", err: fmt.Errorf("%s: %w", secret, context.DeadlineExceeded)}, + {name: "transport", err: errors.New(secret + strings.Repeat("x", 8<<10))}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fixture := newCursorDirectTestServer(t) + sessionID := seedRecoverableCursorSession(t, fixture) + fixture.runner.holdStream() + fixture.runner.mu.Lock() + fixture.runner.cancelErr = test.err + fixture.runner.mu.Unlock() + + result := approveCursorCancelResponse(t, fixture, sessionID) + if result.status != http.StatusBadGateway { + t.Fatalf("ambiguous response status=%d, want 502", result.status) + } + lower := strings.ToLower(result.body) + if !strings.Contains(lower, "ambiguous") || + !strings.Contains(lower, "will not be retried") { + t.Fatalf("ambiguous response is not explicit: %q", result.body) + } + if strings.Contains(result.body, secret) || len(result.body) > 1024 { + t.Fatalf("ambiguous response leaked or exceeded bound: bytes=%d body=%q", + len(result.body), result.body) + } + fixture.runner.releaseStream() + }) + } +} + +func TestCursorCancelInFlightRecoveryBecomesAmbiguousWithoutResubmission(t *testing.T) { + fixture := newCursorDirectTestServer(t) + sessionID := seedRecoverableCursorSession(t, fixture) + _, err := fixture.server.mutateCursorState(context.Background(), sessionID, + func(state *store.CursorSessionState) error { + state.RemoteStatus = cursorCancelInFlight + return nil + }) + if err != nil { + t.Fatal(err) + } + fixture.runner.holdStream() + attach := getCursorAttach(t, fixture, sessionID) + defer attach.Close() + + waitCursorState(t, fixture.db, sessionID, func(state *store.CursorSessionState) bool { + return state.RemoteStatus == cursorCancelAmbiguous + }) + if fixture.runner.CancelCalls() != 0 { + t.Fatal("restart recovery resubmitted an in-flight cancellation") + } + if status := deleteCursorSession(t, fixture, sessionID); status != http.StatusOK { + t.Fatalf("delete recovered cancel-ambiguous session status=%d", status) + } + fixture.runner.releaseStream() +} + +func TestCursorCancelInFlightMarkerAfterRestartIsLocallyDeletable(t *testing.T) { + fixture := newCursorDirectTestServer(t) + sessionID := seedRecoverableCursorSession(t, fixture) + _, err := fixture.server.mutateCursorState(context.Background(), sessionID, + func(state *store.CursorSessionState) error { + state.RemoteStatus = cursorCancelInFlight + return nil + }) + if err != nil { + t.Fatal(err) + } + + if status := deleteCursorSession(t, fixture, sessionID); status != http.StatusOK { + t.Fatalf("delete crash-marker session status=%d, want 200", status) + } + if fixture.runner.CancelCalls() != 0 { + t.Fatal("local reconciliation resubmitted the persisted cancellation marker") + } +} + +func TestCursorCancelReservationCoversWholeSession(t *testing.T) { + fixture := newCursorDirectTestServer(t) + if !fixture.server.reserveCursorCancel("ses-one", "run-one") { + t.Fatal("first cancellation reservation failed") + } + defer fixture.server.releaseCursorCancel("ses-one", "run-one") + if fixture.server.reserveCursorCancel("ses-one", "run-two") { + t.Fatal("different run bypassed the session cancellation reservation") + } +} + +func TestCursorApprovalCarriesRepositoryPreflightWarnings(t *testing.T) { + fixture := newCursorDirectTestServer(t) + dir := initCursorWarningRepository(t) + request := defaultCursorChatRequest() + request.ProjectDir = dir + + session, _, projectDir, err := fixture.server.cursorSessionCandidate( + context.Background(), request, + ) + if err != nil { + t.Fatal(err) + } + plan, _, err := fixture.server.prepareCursorTurn( + context.Background(), request, session, projectDir, + ) + if err != nil { + t.Fatal(err) + } + var projection struct { + WorktreeDirty bool `json:"worktree_dirty"` + LocalOnly int `json:"local_only_commits"` + RemoteRefKnown bool `json:"remote_ref_known"` + Warnings []string `json:"warnings"` + } + if err := json.Unmarshal([]byte(plan.approvalArguments), &projection); err != nil { + t.Fatal(err) + } + if strings.Contains(plan.approvalArguments, "warning-secret") { + t.Fatal("approval projection leaked dirty-worktree content") + } + if !projection.WorktreeDirty || projection.LocalOnly != 1 || + !projection.RemoteRefKnown || len(projection.Warnings) != 2 { + t.Fatalf("approval lost repository preflight: %+v", projection) + } + for _, warning := range projection.Warnings { + if len([]rune(warning)) > 240 || + !strings.Contains(warning, "cloud VM") { + t.Fatalf("warning is not fixed and bounded: %q", warning) + } + } +} + +func TestCursorApprovalWarnsWhenRemoteRefCannotBeVerified(t *testing.T) { + fixture := newCursorDirectTestServer(t) + dir := initCursorWarningRepository(t) + runCursorWarningGit(t, dir, "reset", "--hard", "HEAD") + runCursorWarningGit(t, dir, "update-ref", "-d", "refs/remotes/origin/main") + request := defaultCursorChatRequest() + request.ProjectDir = dir + + session, _, projectDir, err := fixture.server.cursorSessionCandidate( + context.Background(), request, + ) + if err != nil { + t.Fatal(err) + } + plan, _, err := fixture.server.prepareCursorTurn( + context.Background(), request, session, projectDir, + ) + if err != nil { + t.Fatal(err) + } + var projection struct { + RemoteRefKnown bool `json:"remote_ref_known"` + Warnings []string `json:"warnings"` + } + if err := json.Unmarshal([]byte(plan.approvalArguments), &projection); err != nil { + t.Fatal(err) + } + if projection.RemoteRefKnown || len(projection.Warnings) != 1 || + !strings.Contains(projection.Warnings[0], "cannot verify") { + t.Fatalf("unknown remote ref warning missing: %+v", projection) + } +} + +func TestCursorAttachRecoveryRequiresProtectionBeforeRunnerCalls(t *testing.T) { + fixture := newCursorDirectTestServer(t) + fixture.cfg.Server.AuthToken = "" + sessionID := seedRecoverableCursorSession(t, fixture) + + status, _ := cursorAttachStatus(t, fixture, sessionID, false) + if status != http.StatusPreconditionRequired { + t.Fatalf("unprotected Cursor recovery attach status=%d, want 428", status) + } + if fixture.runner.GetRunCalls() != 0 || len(fixture.runner.StreamCalls()) != 0 { + t.Fatalf("Cursor runner used before protection: get=%d stream=%d", + fixture.runner.GetRunCalls(), len(fixture.runner.StreamCalls())) + } +} + +func TestCursorAttachProtectionPreservesOrdinaryLiveAndDone(t *testing.T) { + fixture := newCursorDirectTestServer(t) + fixture.cfg.Server.AuthToken = "" + + ordinary := newLiveRun() + ordinary.publish(agent.Event{Type: agent.EventNotice, Message: "ordinary"}) + ordinary.publish(agent.Event{Type: agent.EventDone}) + ordinary.finish() + fixture.server.hub.put("ses-ordinary-live", ordinary) + if status, body := cursorAttachStatus(t, fixture, "ses-ordinary-live", false); status != http.StatusOK || + !strings.Contains(body, "ordinary") { + t.Fatalf("ordinary live attach status=%d body=%q", status, body) + } + if status, body := cursorAttachStatus(t, fixture, "ses-no-live", false); status != http.StatusOK || + !strings.Contains(body, `"done"`) { + t.Fatalf("ordinary done attach status=%d body=%q", status, body) + } + + direct := newCursorLiveRun(liveRunCursorDirect) + direct.publish(agent.Event{Type: agent.EventDone}) + direct.finish() + fixture.server.hub.put("ses-direct-live", direct) + if status, _ := cursorAttachStatus(t, fixture, "ses-direct-live", false); status != http.StatusPreconditionRequired { + t.Fatalf("unprotected direct live attach status=%d, want 428", status) + } +} + +func TestCursorAttachCursorResetTargetsFreshRecoveryLogOnly(t *testing.T) { + tests := []struct { + name string + live *liveRun + want bool + }{ + { + name: "own recovery", + live: newCursorLiveRun(liveRunCursorRecovery), want: true, + }, + { + name: "another recovery winner", + live: newCursorLiveRun(liveRunCursorRecovery), want: true, + }, + { + name: "concurrent direct run", + live: newCursorLiveRun(liveRunCursorDirect), want: false, + }, + { + name: "concurrent ordinary run", + live: newLiveRun(), want: false, + }, + { + name: "existing recovery reconnect", + live: newCursorLiveRun(liveRunCursorRecovery), want: true, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := cursorAttachShouldReset(test.live); got != test.want { + t.Fatalf("cursor reset=%v, want %v", got, test.want) + } + }) + } +} + +func TestCursorAttachEverySequentialRecoveryFollowerStartsAtZero(t *testing.T) { + fixture := newCursorDirectTestServer(t) + sessionID := "ses-sequential-recovery-log" + recovery := newCursorLiveRun(liveRunCursorRecovery) + recovery.publish(agent.Event{Type: agent.EventReset}) + recovery.publish(agent.Event{Type: agent.EventText, Delta: "durable replay"}) + recovery.publish(agent.Event{Type: agent.EventDone}) + recovery.finish() + fixture.server.hub.put(sessionID, recovery) + + for follower := 1; follower <= 2; follower++ { + stream := getCursorAttachAt(t, fixture, sessionID, 999) + stream.NextType(t, agent.EventReset) + if event := stream.NextType(t, agent.EventText); event.Delta != "durable replay" { + t.Fatalf("follower %d replay=%q", follower, event.Delta) + } + stream.NextType(t, agent.EventDone) + stream.Close() + } +} + +func TestCursorAttachConcurrentFollowersResetToRecoveryWinner(t *testing.T) { + fixture := newCursorDirectTestServer(t) + sessionID := seedRecoverableCursorSession(t, fixture) + fixture.runner.holdGetRun() + defer fixture.runner.releaseGetRun() + winner := fixture.server.cursorRecoveryRun(sessionID) + if winner == nil || winner.runKind() != liveRunCursorRecovery { + t.Fatal("did not reserve a recovery winner") + } + select { + case <-fixture.runner.getRunStarted: + case <-time.After(time.Second): + t.Fatal("recovery winner did not reach GetRun") + } + + streams := make(chan *sseTestStream, 2) + for range 2 { + go func() { + streams <- getCursorAttachAt(t, fixture, sessionID, 999) + }() + } + first, second := <-streams, <-streams + fixture.runner.releaseGetRun() + for follower, stream := range []*sseTestStream{first, second} { + stream.NextType(t, agent.EventReset) + if event := stream.NextType(t, agent.EventReasoning); event.Delta != "old reasoning" { + t.Fatalf("follower %d reasoning replay=%q", follower+1, event.Delta) + } + if event := stream.NextType(t, agent.EventText); event.Delta != "old text" { + t.Fatalf("follower %d text replay=%q", follower+1, event.Delta) + } + stream.Close() + } +} + +func TestCursorTerminalRecoveryReservationBlocksConcurrentDelete(t *testing.T) { + fixture := newCursorDirectTestServer(t) + sessionID := seedRecoverableCursorSession(t, fixture) + _, err := fixture.server.mutateCursorState(context.Background(), sessionID, + func(state *store.CursorSessionState) error { + state.OperationState = store.CursorOperationTerminal + state.RemoteStatus = "FINISHED" + return nil + }) + if err != nil { + t.Fatal(err) + } + fixture.runner.mu.Lock() + fixture.runner.getRun = cursor.Run{Status: "FINISHED", Result: "recovered"} + fixture.runner.mu.Unlock() + fixture.runner.holdGetRun() + defer fixture.runner.releaseGetRun() + + attachResult := make(chan int, 1) + go func() { + status, _ := cursorAttachStatus(t, fixture, sessionID, true) + attachResult <- status + }() + select { + case <-fixture.runner.getRunStarted: + case <-time.After(time.Second): + t.Fatal("terminal recovery did not reach GetRun") + } + + if status := deleteCursorSession(t, fixture, sessionID); status != http.StatusConflict { + t.Fatalf("delete racing terminal recovery status=%d, want 409", status) + } + if _, err := fixture.db.GetSession(context.Background(), sessionID); err != nil { + t.Fatalf("terminal recovery race deleted local session: %v", err) + } + fixture.runner.releaseGetRun() + select { + case status := <-attachResult: + if status != http.StatusOK { + t.Fatalf("terminal attach status=%d", status) + } + case <-time.After(2 * time.Second): + t.Fatal("terminal attach did not finish") + } +} + +func TestCursorTerminalRecoveryKeepsEditAtomic(t *testing.T) { + fixture := newCursorDirectTestServer(t) + sessionID := seedRecoverableCursorSession(t, fixture) + _, err := fixture.server.mutateCursorState(context.Background(), sessionID, + func(state *store.CursorSessionState) error { + state.OperationState = store.CursorOperationTerminal + state.RemoteStatus = "FINISHED" + return nil + }) + if err != nil { + t.Fatal(err) + } + state, err := fixture.db.GetCursorSessionState(context.Background(), sessionID) + if err != nil { + t.Fatal(err) + } + userMessageID := state.UserMessageID + fixture.runner.mu.Lock() + fixture.runner.getRun = cursor.Run{Status: "FINISHED", Result: "recovered"} + fixture.runner.mu.Unlock() + fixture.runner.holdGetRun() + defer fixture.runner.releaseGetRun() + + attachResult := make(chan int, 1) + go func() { + status, _ := cursorAttachStatus(t, fixture, sessionID, true) + attachResult <- status + }() + select { + case <-fixture.runner.getRunStarted: + case <-time.After(time.Second): + t.Fatal("terminal recovery did not reach GetRun") + } + if status := editCursorMessage(t, fixture, sessionID, userMessageID); status != http.StatusConflict { + t.Fatalf("edit racing terminal recovery status=%d, want 409", status) + } + fixture.runner.releaseGetRun() + select { + case <-attachResult: + case <-time.After(2 * time.Second): + t.Fatal("terminal recovery did not finish") + } + if status := editCursorMessage(t, fixture, sessionID, userMessageID); status != http.StatusOK { + t.Fatalf("edit after terminal finalization status=%d, want 200", status) + } +} + +func TestCursorLifecycleSlowSessionDoesNotBlockUnrelatedSession(t *testing.T) { + fixture := newCursorDirectTestServer(t) + for _, id := range []string{"session-slow", "session-fast"} { + if err := fixture.db.CreateSession(context.Background(), &store.Session{ + ID: id, Title: id, Platform: "web", Meta: store.Meta{}, + }); err != nil { + t.Fatal(err) + } + } + blocking := &blockingCursorStateStore{ + Store: fixture.db, sessionID: "session-slow", + started: make(chan struct{}), release: make(chan struct{}), + } + fixture.server.db = blocking + + slowResult := make(chan error, 1) + go func() { + slowResult <- fixture.server.reserveOrdinaryChat( + context.Background(), "session-slow", newLiveRun(), + ) + }() + select { + case <-blocking.started: + case <-time.After(time.Second): + t.Fatal("slow session did not enter cursor-state lookup") + } + fastResult := make(chan error, 1) + go func() { + fastResult <- fixture.server.reserveOrdinaryChat( + context.Background(), "session-fast", newLiveRun(), + ) + }() + + var fastErr error + blocked := false + select { + case fastErr = <-fastResult: + case <-time.After(200 * time.Millisecond): + blocked = true + } + close(blocking.release) + if err := <-slowResult; err != nil { + t.Fatalf("slow reservation failed: %v", err) + } + if blocked { + fastErr = <-fastResult + } + if fastErr != nil { + t.Fatalf("unrelated reservation failed: %v", fastErr) + } + if blocked { + t.Fatal("slow session lifecycle blocked an unrelated session") + } +} + +type blockingCursorStateStore struct { + store.Store + sessionID string + started chan struct{} + release chan struct{} + once sync.Once +} + +func (s *blockingCursorStateStore) GetCursorSessionState( + ctx context.Context, + sessionID string, +) (*store.CursorSessionState, error) { + if sessionID == s.sessionID { + s.once.Do(func() { close(s.started) }) + select { + case <-s.release: + case <-ctx.Done(): + return nil, ctx.Err() + } + } + return s.Store.GetCursorSessionState(ctx, sessionID) +} + +func cursorAttachStatus( + t *testing.T, + fixture *cursorDirectFixture, + sessionID string, + authenticated bool, +) (int, string) { + t.Helper() + request, err := http.NewRequest( + http.MethodGet, + fixture.http.URL+"/api/chat/attach?session_id="+sessionID+"&cursor=0", + nil, + ) + if err != nil { + t.Fatal(err) + } + if authenticated { + request.Header.Set("Authorization", "Bearer test-token") + } + response, err := fixture.http.Client().Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + raw, err := io.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + } + return response.StatusCode, string(raw) +} + +func approveCursorCancel( + t *testing.T, + fixture *cursorDirectFixture, + sessionID string, +) int { + t.Helper() + return approveCursorCancelResponse(t, fixture, sessionID).status +} + +type cursorCancelResponse struct { + status int + body string +} + +func approveCursorCancelResponse( + t *testing.T, + fixture *cursorDirectFixture, + sessionID string, +) cursorCancelResponse { + t.Helper() + result := make(chan cursorCancelResponse, 1) + go func() { + status, body := postCursorCancelResponse(t, fixture, sessionID) + result <- cursorCancelResponse{status: status, body: body} + }() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + for _, pending := range fixture.server.agent.PendingApprovals() { + if pending.SessionID == sessionID && pending.Tool == "cursor_direct_cancel" { + if !fixture.server.agent.ResolveApproval(pending.ID, true) { + t.Fatal("could not resolve Cursor cancellation approval") + } + select { + case response := <-result: + return response + case <-time.After(2 * time.Second): + t.Fatal("approved Cursor cancellation did not return") + } + } + } + time.Sleep(5 * time.Millisecond) + } + t.Fatal("Cursor cancellation approval did not appear") + return cursorCancelResponse{} +} + +func initCursorWarningRepository(t *testing.T) string { + t.Helper() + dir := t.TempDir() + runCursorWarningGit(t, dir, "init") + runCursorWarningGit(t, dir, "checkout", "-b", "main") + runCursorWarningGit(t, dir, "config", "user.email", "cursor@example.com") + runCursorWarningGit(t, dir, "config", "user.name", "Cursor Test") + path := filepath.Join(dir, "work.txt") + if err := os.WriteFile(path, []byte("base\n"), 0o600); err != nil { + t.Fatal(err) + } + runCursorWarningGit(t, dir, "add", "work.txt") + runCursorWarningGit(t, dir, "commit", "-m", "base") + runCursorWarningGit(t, dir, "remote", "add", "origin", "https://github.com/acme/repo.git") + runCursorWarningGit(t, dir, "update-ref", "refs/remotes/origin/main", "HEAD") + if err := os.WriteFile(path, []byte("local commit\n"), 0o600); err != nil { + t.Fatal(err) + } + runCursorWarningGit(t, dir, "add", "work.txt") + runCursorWarningGit(t, dir, "commit", "-m", "local") + if err := os.WriteFile(path, []byte("dirty token=warning-secret\n"), 0o600); err != nil { + t.Fatal(err) + } + return dir +} + +func runCursorWarningGit(t *testing.T, dir string, args ...string) { + t.Helper() + command := exec.Command("git", append([]string{"-C", dir}, args...)...) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, output) + } +} diff --git a/internal/server/handlers_cursor_repository.go b/internal/server/handlers_cursor_repository.go new file mode 100644 index 0000000..2b864ee --- /dev/null +++ b/internal/server/handlers_cursor_repository.go @@ -0,0 +1,26 @@ +package server + +import ( + "net/http" + + "github.com/enowdev/antares/internal/cursorrun" +) + +// handleCursorRepository performs the local-only repository preflight used by +// the Cursor options UI. It is protected before resolving or inspecting any +// caller-selected path. +func (s *Server) handleCursorRepository(w http.ResponseWriter, r *http.Request) { + if s.requireDashboardPassword(w, r) { + return + } + dir, ok := resolveProjectDir(w, r) + if !ok { + return + } + info, err := cursorrun.InspectRepository(r.Context(), dir) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + writeJSON(w, http.StatusOK, info) +} diff --git a/internal/server/handlers_cursor_session_test.go b/internal/server/handlers_cursor_session_test.go new file mode 100644 index 0000000..1f3f606 --- /dev/null +++ b/internal/server/handlers_cursor_session_test.go @@ -0,0 +1,426 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/enowdev/antares/internal/agent" + "github.com/enowdev/antares/internal/store" + "github.com/enowdev/antares/internal/tools" +) + +// getSessionDetail reads one session detail document exactly as the dashboard +// does, so the assertions below describe the wire contract rather than an +// internal struct. +func getSessionDetail( + t *testing.T, + fixture *cursorDirectFixture, + sessionID string, +) (int, map[string]any, string) { + t.Helper() + return getSessionDetailAt(t, fixture.http.URL, fixture.http.Client(), sessionID) +} + +func getSessionDetailAt( + t *testing.T, + baseURL string, + client *http.Client, + sessionID string, +) (int, map[string]any, string) { + t.Helper() + request, err := http.NewRequest( + http.MethodGet, + baseURL+"/api/sessions/"+sessionID, + nil, + ) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Authorization", "Bearer test-token") + response, err := client.Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + raw, err := io.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + } + var body map[string]any + if response.StatusCode == http.StatusOK { + if err := json.Unmarshal(raw, &body); err != nil { + t.Fatalf("decode session detail: %v (%s)", err, raw) + } + } + return response.StatusCode, body, string(raw) +} + +func seedCursorSession( + t *testing.T, + fixture *cursorDirectFixture, + sessionID string, + mutate func(*store.CursorSessionState), +) { + t.Helper() + ctx := context.Background() + if err := fixture.db.CreateSession(ctx, &store.Session{ + ID: sessionID, Title: "Cursor session", + }); err != nil { + t.Fatalf("create session: %v", err) + } + state := &store.CursorSessionState{ + SessionID: sessionID, + TargetActive: true, + ReuseValid: true, + ModelID: "gpt-5.6-sol", + ModelParams: `[{"id":"cyber","value":"false"},{"id":"reasoning","value":"max"}]`, + RepositoryURL: "https://github.com/acme/repo", + StartingRef: "main", + Mode: "plan", + AutoCreatePR: true, + AgentID: "bc-secret-agent", + RunID: "run-secret", + RemoteStatus: "RUNNING", + OperationState: store.CursorOperationRunInFlight, + PartialText: "half of a private answer", + UserMessageID: "msg-user-internal", + AssistantMessageID: "msg-assistant-internal", + } + if mutate != nil { + mutate(state) + } + if err := fixture.db.PutCursorSessionState(ctx, state); err != nil { + t.Fatalf("put cursor state: %v", err) + } +} + +func cursorStateOf(t *testing.T, body map[string]any) map[string]any { + t.Helper() + raw, ok := body["cursor_state"] + if !ok { + t.Fatal("session detail has no cursor_state field") + } + if raw == nil { + t.Fatal("cursor_state is null, want a projection") + } + state, ok := raw.(map[string]any) + if !ok { + t.Fatalf("cursor_state = %T, want an object", raw) + } + return state +} + +func TestSessionDetailReportsNoCursorStateAsNull(t *testing.T) { + fixture := newCursorDirectTestServer(t) + if err := fixture.db.CreateSession(context.Background(), &store.Session{ + ID: "ses-plain", Title: "Plain chat", + }); err != nil { + t.Fatal(err) + } + + status, body, raw := getSessionDetail(t, fixture, "ses-plain") + if status != http.StatusOK { + t.Fatalf("status=%d body=%s", status, raw) + } + value, ok := body["cursor_state"] + if !ok { + t.Fatalf("cursor_state is absent for a session without Cursor state: %s", raw) + } + if value != nil { + t.Fatalf("cursor_state = %#v, want null", value) + } +} + +func TestSessionDetailProjectsExactCursorSelection(t *testing.T) { + fixture := newCursorDirectTestServer(t) + seedCursorSession(t, fixture, "ses-active", func(state *store.CursorSessionState) { + state.GitState = `{"branches":[{"repoUrl":"https://github.com/acme/repo","branch":"cursor/x","prUrl":"https://github.com/acme/repo/pull/7"}]}` + }) + + status, body, raw := getSessionDetail(t, fixture, "ses-active") + if status != http.StatusOK { + t.Fatalf("status=%d body=%s", status, raw) + } + state := cursorStateOf(t, body) + + if state["target_active"] != true || state["reuse_valid"] != true { + t.Fatalf("target/reuse = %#v/%#v", state["target_active"], state["reuse_valid"]) + } + if state["model_id"] != "gpt-5.6-sol" { + t.Fatalf("model_id = %#v", state["model_id"]) + } + // The whole stored selection, in order, including a param the catalogue + // never lists as a user-facing dimension. + wantParams := []any{ + map[string]any{"id": "cyber", "value": "false"}, + map[string]any{"id": "reasoning", "value": "max"}, + } + gotParams, _ := state["model_params"].([]any) + if len(gotParams) != len(wantParams) { + t.Fatalf("model_params = %#v, want %#v", state["model_params"], wantParams) + } + for i := range wantParams { + got, _ := gotParams[i].(map[string]any) + want, _ := wantParams[i].(map[string]any) + if got["id"] != want["id"] || got["value"] != want["value"] { + t.Fatalf("model_params[%d] = %#v, want %#v", i, gotParams[i], wantParams[i]) + } + } + if state["repository_url"] != "https://github.com/acme/repo" || + state["starting_ref"] != "main" || state["mode"] != "plan" || + state["auto_create_pr"] != true { + t.Fatalf("repository projection = %#v", state) + } + if state["remote_status"] != "RUNNING" || + state["operation_state"] != store.CursorOperationRunInFlight { + t.Fatalf("status projection = %#v", state) + } + git, _ := state["git"].(map[string]any) + branches, _ := git["branches"].([]any) + if len(branches) != 1 { + t.Fatalf("git projection = %#v", state["git"]) + } + branch, _ := branches[0].(map[string]any) + if branch["repo_url"] != "https://github.com/acme/repo" || + branch["branch"] != "cursor/x" || + branch["pr_url"] != "https://github.com/acme/repo/pull/7" { + t.Fatalf("branch projection = %#v", branch) + } + + // Nothing internal, recoverable-only, or partially generated may travel to + // the browser with the composer's restore data. + for _, forbidden := range []string{ + "revision", "partial_text", "partial_reasoning", "agent_id", "run_id", + "user_message_id", "assistant_message_id", "last_event_id", + "bc-secret-agent", "run-secret", "half of a private answer", + "msg-user-internal", "msg-assistant-internal", + } { + if strings.Contains(raw, forbidden) { + t.Errorf("session detail leaked %q: %s", forbidden, raw) + } + } +} + +func TestSessionDetailKeepsInactiveCursorTargetInactive(t *testing.T) { + fixture := newCursorDirectTestServer(t) + seedCursorSession(t, fixture, "ses-inactive", func(state *store.CursorSessionState) { + state.TargetActive = false + state.ReuseValid = false + state.OperationState = store.CursorOperationCommitted + }) + + _, body, raw := getSessionDetail(t, fixture, "ses-inactive") + state := cursorStateOf(t, body) + if state["target_active"] != false || state["reuse_valid"] != false { + t.Fatalf("inactive projection = %#v (%s)", state, raw) + } + if state["operation_state"] != store.CursorOperationCommitted { + t.Fatalf("operation_state = %#v", state["operation_state"]) + } +} + +func TestSessionDetailNeverExposesAutoNoRepositorySentinel(t *testing.T) { + fixture := newCursorDirectTestServer(t) + seedCursorSession(t, fixture, "ses-auto", func(state *store.CursorSessionState) { + state.RepositoryURL = cursorAutoNoRepositoryIdentity + state.StartingRef = "" + }) + + _, body, raw := getSessionDetail(t, fixture, "ses-auto") + state := cursorStateOf(t, body) + value, ok := state["repository_url"] + if !ok { + t.Fatal("repository_url is absent") + } + // null means "discover it again", which is the identity this run used; an + // empty string would mean the user explicitly chose no repository. + if value != nil { + t.Fatalf("repository_url = %#v, want null for auto-discovery", value) + } + if strings.Contains(raw, "antares://") { + t.Fatalf("session detail leaked the auto-discovery sentinel: %s", raw) + } +} + +func TestSessionDetailDropsUndecodableCursorSelection(t *testing.T) { + fixture := newCursorDirectTestServer(t) + // The store guarantees a JSON array, not that every element is a parameter. + seedCursorSession(t, fixture, "ses-broken", func(state *store.CursorSessionState) { + state.ModelParams = `[{"id":"reasoning","value":42}]` + }) + + _, body, raw := getSessionDetail(t, fixture, "ses-broken") + state := cursorStateOf(t, body) + if state["model_id"] != "" { + t.Fatalf("model_id = %#v, want no selection when its params cannot be decoded (%s)", + state["model_id"], raw) + } + params, _ := state["model_params"].([]any) + if len(params) != 0 { + t.Fatalf("model_params = %#v, want empty", state["model_params"]) + } + if state["target_active"] != true { + t.Fatalf("an undecodable selection must not hide the durable state: %#v", state) + } +} + +func TestSessionDetailCopiesCatalogueValuesExactly(t *testing.T) { + fixture := newCursorDirectTestServer(t) + // Catalogue identifiers are opaque and case-sensitive: a projection that + // rewrites them would resolve to a different variant, or to none at all. + seedCursorSession(t, fixture, "ses-opaque", func(state *store.CursorSessionState) { + state.ModelID = "GPT-5.6-Sol_Max" + state.ModelParams = `[{"id":"Reasoning","value":"MAX"},{"id":"context","value":"1M"}]` + state.StartingRef = "Feature/Big-Change" + state.RepositoryURL = "https://github.com/Acme/Repo" + }) + + _, body, raw := getSessionDetail(t, fixture, "ses-opaque") + state := cursorStateOf(t, body) + if state["model_id"] != "GPT-5.6-Sol_Max" { + t.Fatalf("model_id = %#v, want the stored value unchanged (%s)", state["model_id"], raw) + } + if state["starting_ref"] != "Feature/Big-Change" { + t.Fatalf("starting_ref = %#v, want the stored value unchanged", state["starting_ref"]) + } + if state["repository_url"] != "https://github.com/Acme/Repo" { + t.Fatalf("repository_url = %#v, want the stored value unchanged", state["repository_url"]) + } + params, _ := state["model_params"].([]any) + if len(params) != 2 { + t.Fatalf("model_params = %#v", state["model_params"]) + } + first, _ := params[0].(map[string]any) + second, _ := params[1].(map[string]any) + if first["id"] != "Reasoning" || first["value"] != "MAX" || + second["id"] != "context" || second["value"] != "1M" { + t.Fatalf("model_params = %#v, want the stored values unchanged", params) + } +} + +func TestSessionDetailRejectsUnsafeSelectionInsteadOfRewritingIt(t *testing.T) { + cases := []struct { + name string + apply func(*store.CursorSessionState) + }{ + { + name: "credential-like model id", + apply: func(state *store.CursorSessionState) { + state.ModelID = "sk-live_abcdef123456" + }, + }, + { + name: "credential-like parameter value", + apply: func(state *store.CursorSessionState) { + state.ModelParams = `[{"id":"reasoning","value":"authorization: Bearer abcdef123456"}]` + }, + }, + { + name: "control characters in a parameter id", + apply: func(state *store.CursorSessionState) { + state.ModelParams = "[{\"id\":\"reason\\ning\",\"value\":\"max\"}]" + }, + }, + { + name: "repository carrying credentials", + apply: func(state *store.CursorSessionState) { + state.RepositoryURL = "https://user:secret@github.com/acme/repo" + }, + }, + { + name: "starting ref with control characters", + apply: func(state *store.CursorSessionState) { + state.StartingRef = "main\nrm -rf" + }, + }, + } + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + fixture := newCursorDirectTestServer(t) + seedCursorSession(t, fixture, "ses-unsafe", tt.apply) + + _, body, raw := getSessionDetail(t, fixture, "ses-unsafe") + state := cursorStateOf(t, body) + if state["model_id"] != "" { + t.Fatalf("model_id = %#v, want the whole selection rejected (%s)", + state["model_id"], raw) + } + params, _ := state["model_params"].([]any) + if len(params) != 0 { + t.Fatalf("model_params = %#v, want empty", state["model_params"]) + } + for _, forbidden := range []string{ + "sk-live_abcdef123456", "Bearer abcdef123456", "user:secret", + "rm -rf", "REDACTED", + } { + if strings.Contains(raw, forbidden) { + t.Fatalf("projection kept or rewrote an unsafe value %q: %s", forbidden, raw) + } + } + }) + } +} + +func TestSessionDetailRedactsCursorProjectionStrings(t *testing.T) { + fixture := newCursorDirectTestServer(t) + seedCursorSession(t, fixture, "ses-secret", func(state *store.CursorSessionState) { + state.RemoteStatus = "ERROR: authorization: Bearer test-token" + state.GitState = `{"branches":[{"repoUrl":"https://user:test-token@github.com/acme/repo","branch":"main","prUrl":""}]}` + }) + + _, _, raw := getSessionDetail(t, fixture, "ses-secret") + if strings.Contains(raw, "test-token") { + t.Fatalf("session detail leaked a credential: %s", raw) + } + if !strings.Contains(raw, "REDACTED") { + t.Fatalf("session detail did not redact the projection: %s", raw) + } +} + +type cursorStateErrorStore struct { + store.Store + err error +} + +func (s cursorStateErrorStore) GetCursorSessionState( + context.Context, string, +) (*store.CursorSessionState, error) { + return nil, s.err +} + +func TestSessionDetailStoreFailureIsNotReportedAsNoCursorState(t *testing.T) { + fixture := newCursorDirectTestServer(t) + if err := fixture.db.CreateSession(context.Background(), &store.Session{ + ID: "ses-store-error", Title: "Cursor session", + }); err != nil { + t.Fatal(err) + } + failing := cursorStateErrorStore{ + Store: fixture.db, + err: errors.New("cursor state read failed for test-token"), + } + broken := New(Options{ + Config: fixture.cfg, + Agent: agent.New(fixture.cfg, failing, tools.NewRegistry(), nil, nil), + Store: failing, + Cursor: fixture.runner, + }) + brokenHTTP := httptest.NewServer(broken.Handler()) + defer brokenHTTP.Close() + + status, _, raw := getSessionDetailAt( + t, brokenHTTP.URL, brokenHTTP.Client(), "ses-store-error", + ) + if status != http.StatusInternalServerError { + t.Fatalf("status=%d body=%s, want a reported failure rather than no state", status, raw) + } + if strings.Contains(raw, "test-token") { + t.Fatalf("store failure leaked a credential: %s", raw) + } +} diff --git a/internal/server/handlers_cursor_test.go b/internal/server/handlers_cursor_test.go new file mode 100644 index 0000000..9a7634a --- /dev/null +++ b/internal/server/handlers_cursor_test.go @@ -0,0 +1,1743 @@ +package server + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + "unicode/utf8" + + "github.com/enowdev/antares/internal/agent" + "github.com/enowdev/antares/internal/config" + "github.com/enowdev/antares/internal/cursor" + "github.com/enowdev/antares/internal/cursorrun" + "github.com/enowdev/antares/internal/store" + "github.com/enowdev/antares/internal/tools" +) + +type cursorStreamCall struct { + AgentID string + RunID string + LastEventID string +} + +type fakeCursorRunner struct { + mu sync.Mutex + + validateErr error + validated *cursor.ModelSelection + + createAgentRequests []cursor.CreateAgentRequest + createRunAgentIDs []string + createRunRequests []cursor.CreateRunRequest + cancelCalls [][2]string + streamCalls []cursorStreamCall + + createAgentBlock chan struct{} + createAgentErr error + createRunErr error + cancelErr error + + streamStarted chan struct{} + streamOnce sync.Once + streamRelease chan struct{} + streamEvents []cursor.StreamEvent + streamErr error + afterEmit func(cursor.StreamEvent) + invokeReset bool + terminal cursor.Run + getRun cursor.Run + getRunCalls int + getRunBlock chan struct{} + getRunStarted chan struct{} + getRunOnce sync.Once + cancelHook func() +} + +func newFakeCursorRunner() *fakeCursorRunner { + return &fakeCursorRunner{ + streamStarted: make(chan struct{}), + streamEvents: []cursor.StreamEvent{ + {ID: "evt-status", Type: "status", Status: "RUNNING"}, + {ID: "evt-reasoning", Type: "thinking", Text: "reasoning"}, + {ID: "evt-text", Type: "assistant", Text: "final answer"}, + }, + terminal: cursor.Run{ + ID: "run-test-1", AgentID: "bc-test-1", Status: "FINISHED", + Result: "final answer", + Git: &cursor.GitState{Branches: []cursor.GitBranch{{ + RepoURL: "https://github.com/acme/repo", + Branch: "cursor/task", + PRURL: "https://github.com/acme/repo/pull/1", + }}}, + }, + getRun: cursor.Run{ID: "run-recovery", AgentID: "bc-recovery", Status: "RUNNING"}, + } +} + +func (f *fakeCursorRunner) Catalog(context.Context, bool) (*cursor.ModelCatalog, error) { + return &cursor.ModelCatalog{}, nil +} + +func (f *fakeCursorRunner) InvalidateCatalog() {} + +func (f *fakeCursorRunner) ValidateModel( + _ context.Context, + selection *cursor.ModelSelection, + _ cursorrun.SelectionPolicy, +) (*cursor.ModelSelection, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.validateErr != nil { + return nil, f.validateErr + } + if selection == nil || strings.TrimSpace(selection.ID) == "" { + return nil, errors.New("cursor model is required") + } + validated := &cursor.ModelSelection{ + ID: selection.ID, + Params: append([]cursor.ModelParameterSelection(nil), selection.Params...), + } + if selection.Params != nil && validated.Params == nil { + validated.Params = []cursor.ModelParameterSelection{} + } + f.validated = validated + return validated, nil +} + +func (f *fakeCursorRunner) CreateAgent( + ctx context.Context, + request cursor.CreateAgentRequest, +) (*cursor.CreateAgentResponse, error) { + f.mu.Lock() + f.createAgentRequests = append(f.createAgentRequests, cloneCreateAgentRequest(request)) + index := len(f.createAgentRequests) + block := f.createAgentBlock + createErr := f.createAgentErr + f.mu.Unlock() + + if block != nil { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-block: + } + } + if createErr != nil { + return nil, createErr + } + agentID := "bc-test-" + string(rune('0'+index)) + runID := "run-test-" + string(rune('0'+index)) + return &cursor.CreateAgentResponse{ + Agent: cursor.Agent{ID: agentID, Status: "RUNNING", LatestRunID: runID}, + Run: cursor.Run{ID: runID, AgentID: agentID, Status: "CREATING"}, + }, nil +} + +func (f *fakeCursorRunner) CreateRun( + _ context.Context, + agentID string, + request cursor.CreateRunRequest, +) (*cursor.Run, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.createRunAgentIDs = append(f.createRunAgentIDs, agentID) + f.createRunRequests = append(f.createRunRequests, cloneCreateRunRequest(request)) + if f.createRunErr != nil { + return nil, f.createRunErr + } + index := len(f.createAgentRequests) + len(f.createRunRequests) + return &cursor.Run{ + ID: "run-test-" + string(rune('0'+index)), AgentID: agentID, Status: "CREATING", + }, nil +} + +func (f *fakeCursorRunner) GetAgent(_ context.Context, agentID string) (*cursor.Agent, error) { + return &cursor.Agent{ID: agentID, Status: "RUNNING"}, nil +} + +func (f *fakeCursorRunner) GetRun( + _ context.Context, + agentID string, + runID string, +) (*cursor.Run, error) { + f.mu.Lock() + f.getRunCalls++ + run := f.getRun + run.AgentID = agentID + run.ID = runID + block := f.getRunBlock + started := f.getRunStarted + if block != nil { + f.getRunOnce.Do(func() { close(started) }) + } + f.mu.Unlock() + if block != nil { + <-block + } + return cloneRun(run), nil +} + +func (f *fakeCursorRunner) CancelRun( + _ context.Context, + agentID string, + runID string, +) error { + f.mu.Lock() + f.cancelCalls = append(f.cancelCalls, [2]string{agentID, runID}) + hook := f.cancelHook + cancelErr := f.cancelErr + f.mu.Unlock() + if hook != nil { + hook() + } + return cancelErr +} + +func (f *fakeCursorRunner) StreamRun( + ctx context.Context, + agentID string, + runID string, + lastEventID string, + onReset func() error, + emit func(cursor.StreamEvent) error, +) (*cursor.Run, error) { + f.mu.Lock() + f.streamCalls = append(f.streamCalls, cursorStreamCall{ + AgentID: agentID, RunID: runID, LastEventID: lastEventID, + }) + invokeReset := f.invokeReset + events := append([]cursor.StreamEvent(nil), f.streamEvents...) + release := f.streamRelease + terminal := f.terminal + streamErr := f.streamErr + afterEmit := f.afterEmit + f.streamOnce.Do(func() { close(f.streamStarted) }) + f.mu.Unlock() + + if invokeReset && onReset != nil { + if err := onReset(); err != nil { + return nil, err + } + } + for _, event := range events { + if err := emit(event); err != nil { + return nil, err + } + if afterEmit != nil { + afterEmit(event) + } + } + if release != nil { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-release: + } + } + if streamErr != nil { + return nil, streamErr + } + terminal.AgentID = agentID + terminal.ID = runID + return cloneRun(terminal), nil +} + +func (f *fakeCursorRunner) Progress(event cursor.StreamEvent) cursorrun.Progress { + return cursorrun.Progress{ + Message: "Cursor " + event.Type + " " + event.Status, + Chunk: event.Text, + } +} + +func (f *fakeCursorRunner) holdStream() { + f.mu.Lock() + f.streamRelease = make(chan struct{}) + f.streamStarted = make(chan struct{}) + f.streamOnce = sync.Once{} + f.mu.Unlock() +} + +func (f *fakeCursorRunner) releaseStream() { + f.mu.Lock() + release := f.streamRelease + f.streamRelease = nil + f.mu.Unlock() + if release != nil { + close(release) + } +} + +func (f *fakeCursorRunner) CreateAgentCalls() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.createAgentRequests) +} + +func (f *fakeCursorRunner) CreateRunCalls() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.createRunRequests) +} + +func (f *fakeCursorRunner) CancelCalls() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.cancelCalls) +} + +func (f *fakeCursorRunner) GetRunCalls() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.getRunCalls +} + +func (f *fakeCursorRunner) holdGetRun() { + f.mu.Lock() + f.getRunBlock = make(chan struct{}) + f.getRunStarted = make(chan struct{}) + f.getRunOnce = sync.Once{} + f.mu.Unlock() +} + +func (f *fakeCursorRunner) releaseGetRun() { + f.mu.Lock() + block := f.getRunBlock + f.getRunBlock = nil + f.mu.Unlock() + if block != nil { + close(block) + } +} + +func (f *fakeCursorRunner) StreamCalls() []cursorStreamCall { + f.mu.Lock() + defer f.mu.Unlock() + return append([]cursorStreamCall(nil), f.streamCalls...) +} + +func cloneCreateAgentRequest(request cursor.CreateAgentRequest) cursor.CreateAgentRequest { + cloned := request + cloned.Prompt.Images = append([]cursor.PromptImage(nil), request.Prompt.Images...) + cloned.Repos = append([]cursor.Repository(nil), request.Repos...) + if request.Model != nil { + cloned.Model = &cursor.ModelSelection{ + ID: request.Model.ID, + Params: append( + []cursor.ModelParameterSelection(nil), + request.Model.Params..., + ), + } + } + return cloned +} + +func cloneCreateRunRequest(request cursor.CreateRunRequest) cursor.CreateRunRequest { + cloned := request + cloned.Prompt.Images = append([]cursor.PromptImage(nil), request.Prompt.Images...) + return cloned +} + +func cloneRun(run cursor.Run) *cursor.Run { + cloned := run + if run.Git != nil { + cloned.Git = &cursor.GitState{ + Branches: append([]cursor.GitBranch(nil), run.Git.Branches...), + } + } + return &cloned +} + +type cursorDirectFixture struct { + server *Server + runner *fakeCursorRunner + db store.Store + http *httptest.Server + cfg *config.Config +} + +func newCursorDirectTestServer(t *testing.T) *cursorDirectFixture { + t.Helper() + return newCursorDirectTestServerWithConfig(t, nil) +} + +func newCursorDirectTestServerWithConfig( + t *testing.T, + mutate func(*config.Config), +) *cursorDirectFixture { + t.Helper() + t.Setenv("ANTARES_HOME", t.TempDir()) + cfg := config.Default() + cfg.Server.AuthToken = "test-token" + cfg.Tools.ApprovalMode = "auto" + if mutate != nil { + mutate(cfg) + } + db, err := store.Open(context.Background(), "memory", "", 1, 5000, false) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + a := agent.New(cfg, db, tools.NewRegistry(), nil, nil) + runner := newFakeCursorRunner() + s := New(Options{Config: cfg, Agent: a, Store: db, Cursor: runner}) + httpServer := httptest.NewServer(s.Handler()) + t.Cleanup(httpServer.Close) + return &cursorDirectFixture{ + server: s, runner: runner, db: db, http: httpServer, cfg: cfg, + } +} + +type sseTestStream struct { + t *testing.T + response *http.Response + scanner *bufio.Scanner +} + +func postCursorChat( + t *testing.T, + fixture *cursorDirectFixture, + request cursorChatRequest, +) *sseTestStream { + t.Helper() + body, err := json.Marshal(request) + if err != nil { + t.Fatal(err) + } + httpRequest, err := http.NewRequest( + http.MethodPost, + fixture.http.URL+"/api/chat/cursor", + bytes.NewReader(body), + ) + if err != nil { + t.Fatal(err) + } + httpRequest.Header.Set("Authorization", "Bearer test-token") + httpRequest.Header.Set("Content-Type", "application/json") + response, err := fixture.http.Client().Do(httpRequest) + if err != nil { + t.Fatal(err) + } + if response.StatusCode != http.StatusOK { + defer response.Body.Close() + raw, _ := io.ReadAll(response.Body) + t.Fatalf("cursor chat status=%d body=%s", response.StatusCode, raw) + } + return &sseTestStream{ + t: t, response: response, scanner: bufio.NewScanner(response.Body), + } +} + +func postCursorChatStatus( + t *testing.T, + fixture *cursorDirectFixture, + request cursorChatRequest, +) (int, string) { + t.Helper() + body, err := json.Marshal(request) + if err != nil { + t.Fatal(err) + } + httpRequest, err := http.NewRequest( + http.MethodPost, + fixture.http.URL+"/api/chat/cursor", + bytes.NewReader(body), + ) + if err != nil { + t.Fatal(err) + } + httpRequest.Header.Set("Authorization", "Bearer test-token") + response, err := fixture.http.Client().Do(httpRequest) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + raw, _ := io.ReadAll(response.Body) + return response.StatusCode, string(raw) +} + +func (s *sseTestStream) Next(t *testing.T) agent.Event { + t.Helper() + for s.scanner.Scan() { + line := s.scanner.Text() + if !strings.HasPrefix(line, "data:") { + continue + } + var event agent.Event + if err := json.Unmarshal( + []byte(strings.TrimSpace(strings.TrimPrefix(line, "data:"))), + &event, + ); err != nil { + t.Fatalf("decode SSE %q: %v", line, err) + } + return event + } + if err := s.scanner.Err(); err != nil { + t.Fatalf("read SSE: %v", err) + } + t.Fatal("SSE stream ended before the next event") + return agent.Event{} +} + +func (s *sseTestStream) NextType(t *testing.T, eventType agent.EventType) agent.Event { + t.Helper() + for { + event := s.Next(t) + if event.Type == eventType { + return event + } + } +} + +func (s *sseTestStream) Close() { + _ = s.response.Body.Close() +} + +func resolveApproval( + t *testing.T, + fixture *cursorDirectFixture, + approvalID string, + allow bool, +) { + t.Helper() + raw := `{"allow":false}` + if allow { + raw = `{"allow":true}` + } + request, err := http.NewRequest( + http.MethodPost, + fixture.http.URL+"/api/approvals/"+approvalID, + strings.NewReader(raw), + ) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Authorization", "Bearer test-token") + request.Header.Set("Content-Type", "application/json") + response, err := fixture.http.Client().Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + body, _ := io.ReadAll(response.Body) + t.Fatalf("resolve approval status=%d body=%s", response.StatusCode, body) + } +} + +func approvedCursorTurn( + t *testing.T, + fixture *cursorDirectFixture, + request cursorChatRequest, +) string { + t.Helper() + stream := postCursorChat(t, fixture, request) + defer stream.Close() + session := stream.NextType(t, agent.EventSession) + approvalEvent := stream.NextType(t, agent.EventApproval) + resolveApproval(t, fixture, approvalEvent.ID, true) + stream.NextType(t, agent.EventDone) + return session.ID +} + +func defaultCursorChatRequest() cursorChatRequest { + return cursorChatRequest{ + Message: "fix it", + Model: cursor.ModelSelection{ + ID: "gpt-5.6-sol", + Params: []cursor.ModelParameterSelection{{ + ID: "reasoning", Value: "max", + }}, + }, + Mode: "agent", + } +} + +func waitCursorState( + t *testing.T, + db store.Store, + sessionID string, + predicate func(*store.CursorSessionState) bool, +) *store.CursorSessionState { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + state, err := db.GetCursorSessionState(context.Background(), sessionID) + if err == nil && predicate(state) { + return state + } + time.Sleep(10 * time.Millisecond) + } + state, err := db.GetCursorSessionState(context.Background(), sessionID) + t.Fatalf("cursor state did not reach condition: state=%+v err=%v", state, err) + return nil +} + +func TestCursorChatAuthenticatesBeforeReadingLargeBody(t *testing.T) { + cfg := config.Default() + s := New(Options{Config: cfg}) + body := &readTrackingBody{} + request := httptest.NewRequest(http.MethodPost, "/api/chat/cursor", body) + response := httptest.NewRecorder() + + s.Handler().ServeHTTP(response, request) + + if response.Code != http.StatusPreconditionRequired { + t.Fatalf("status=%d body=%s, want 428", response.Code, response.Body.String()) + } + if body.read { + t.Fatal("unauthenticated Cursor route read the request body") + } +} + +type readTrackingBody struct { + read bool +} + +func (b *readTrackingBody) Read([]byte) (int, error) { + b.read = true + return 0, io.EOF +} + +func (b *readTrackingBody) Close() error { return nil } + +func TestCursorChatSendsNoUpstreamRequestBeforeApproval(t *testing.T) { + fixture := newCursorDirectTestServer(t) + stream := postCursorChat(t, fixture, defaultCursorChatRequest()) + defer stream.Close() + + sessionEvent := stream.Next(t) + if sessionEvent.Type != agent.EventSession || sessionEvent.ID == "" { + t.Fatalf("first event = %+v, want session", sessionEvent) + } + approvalEvent := stream.Next(t) + if approvalEvent.Type != agent.EventApproval { + t.Fatalf("second event = %+v, want approval", approvalEvent) + } + if got := fixture.runner.CreateAgentCalls(); got != 0 { + t.Fatalf("CreateAgent calls before approval = %d", got) + } + state, err := fixture.db.GetCursorSessionState(context.Background(), sessionEvent.ID) + if err != nil { + t.Fatal(err) + } + if state.OperationState != store.CursorOperationAwaitingApproval { + t.Fatalf("operation state=%q, want awaiting_approval", state.OperationState) + } + messages, err := fixture.db.ListMessages(context.Background(), sessionEvent.ID, 0, 0) + if err != nil { + t.Fatal(err) + } + if len(messages) != 1 || messages[0].Role != store.RoleUser || + messages[0].Content != "fix it" { + t.Fatalf("messages before approval = %+v", messages) + } + + resolveApproval(t, fixture, approvalEvent.ID, true) + stream.NextType(t, agent.EventToolProgress) + if got := fixture.runner.CreateAgentCalls(); got != 1 { + t.Fatalf("CreateAgent calls after approval = %d", got) + } + stream.NextType(t, agent.EventDone) + + session, err := fixture.db.GetSession(context.Background(), sessionEvent.ID) + if err != nil { + t.Fatal(err) + } + if session.Provider != fixture.cfg.Model.Provider { + t.Fatalf("session provider=%q, want active chat provider %q", + session.Provider, fixture.cfg.Model.Provider) + } +} + +func TestCursorChatApprovalIsBoundedRedactedAndImmutable(t *testing.T) { + fixture := newCursorDirectTestServer(t) + secret := "crsr_super_secret_value" + prompt := "use " + secret + " " + strings.Repeat("界", 300) + request := defaultCursorChatRequest() + request.Message = prompt + request.Images = []string{ + cursorImageDataURL("image/png", cursorImageSignature("image/png")), + } + stream := postCursorChat(t, fixture, request) + defer stream.Close() + sessionEvent := stream.NextType(t, agent.EventSession) + if strings.Contains(sessionEvent.Title, secret) { + t.Fatalf("session event leaked prompt credential in title: %q", sessionEvent.Title) + } + approvalEvent := stream.NextType(t, agent.EventApproval) + + if strings.Contains(approvalEvent.Arguments, secret) || + strings.Contains(approvalEvent.Content, secret) || + strings.Contains(approvalEvent.Arguments, request.Images[0]) { + t.Fatalf("approval leaked prompt/image credential: %+v", approvalEvent) + } + var projection struct { + Operation string `json:"operation"` + Kind string `json:"kind"` + Model cursor.ModelSelection `json:"model"` + PromptPreview string `json:"prompt_preview"` + ImageCount int `json:"image_count"` + } + if err := json.Unmarshal([]byte(approvalEvent.Arguments), &projection); err != nil { + t.Fatalf("approval projection: %v (%s)", err, approvalEvent.Arguments) + } + if projection.Operation != "start" || projection.Kind != "new_agent" || + projection.ImageCount != 1 || projection.Model.ID != request.Model.ID { + t.Fatalf("approval projection = %+v", projection) + } + if utf8.RuneCountInString(projection.PromptPreview) > 240 { + t.Fatalf("prompt preview has %d runes, want <=240", + utf8.RuneCountInString(projection.PromptPreview)) + } + + fixture.runner.mu.Lock() + fixture.runner.validated.ID = "mutated-model" + fixture.runner.validated.Params[0].Value = "mutated" + fixture.runner.mu.Unlock() + resolveApproval(t, fixture, approvalEvent.ID, true) + stream.NextType(t, agent.EventDone) + + fixture.runner.mu.Lock() + created := fixture.runner.createAgentRequests[0] + fixture.runner.mu.Unlock() + if created.Model == nil || created.Model.ID != request.Model.ID || + created.Model.Params[0].Value != "max" { + t.Fatalf("approved immutable model became %+v", created.Model) + } + if created.Prompt.Text != prompt || len(created.Prompt.Images) != 1 { + t.Fatalf("approved immutable prompt/images were not executed exactly") + } + state, err := fixture.db.GetCursorSessionState(context.Background(), projectionSessionID( + t, fixture.db, prompt, + )) + if err != nil { + t.Fatal(err) + } + rawState, _ := json.Marshal(state) + if strings.Contains(string(rawState), secret) || + strings.Contains(string(rawState), request.Images[0]) { + t.Fatalf("cursor state leaked prompt/image data: %s", rawState) + } +} + +func projectionSessionID(t *testing.T, db store.Store, prompt string) string { + t.Helper() + sessions, _, err := db.ListSessions(context.Background(), store.SessionFilter{Limit: 10}) + if err != nil { + t.Fatal(err) + } + for _, session := range sessions { + messages, err := db.ListMessages(context.Background(), session.ID, 0, 0) + if err == nil && len(messages) > 0 && messages[0].Content == prompt { + return session.ID + } + } + t.Fatal("session for prompt not found") + return "" +} + +func TestCursorChatDenyModeRefusesWithoutPendingApprovalOrUpstream(t *testing.T) { + fixture := newCursorDirectTestServerWithConfig(t, func(cfg *config.Config) { + cfg.Tools.ApprovalMode = "deny" + }) + stream := postCursorChat(t, fixture, defaultCursorChatRequest()) + defer stream.Close() + stream.NextType(t, agent.EventSession) + event := stream.Next(t) + if event.Type != agent.EventError { + t.Fatalf("event after session=%+v, want immediate error", event) + } + stream.NextType(t, agent.EventDone) + if fixture.runner.CreateAgentCalls() != 0 || + len(fixture.server.agent.PendingApprovals()) != 0 { + t.Fatal("deny mode created approval or upstream request") + } +} + +func TestCursorChatRejectsInvalidPlanBeforeMutation(t *testing.T) { + fixture := newCursorDirectTestServer(t) + fixture.runner.validateErr = errors.New("selection is stale") + status, body := postCursorChatStatus(t, fixture, defaultCursorChatRequest()) + if status != http.StatusBadRequest || !strings.Contains(body, "stale") { + t.Fatalf("status=%d body=%s", status, body) + } + if fixture.runner.CreateAgentCalls() != 0 { + t.Fatal("invalid plan reached CreateAgent") + } + sessions, total, err := fixture.db.ListSessions( + context.Background(), store.SessionFilter{Limit: 10}, + ) + if err != nil || total != 0 || len(sessions) != 0 { + t.Fatalf("invalid plan persisted sessions=%+v total=%d err=%v", sessions, total, err) + } +} + +func TestCursorChatIdentityControlsAgentReuse(t *testing.T) { + repo := "https://github.com/acme/repo" + ref := "main" + base := defaultCursorChatRequest() + base.RepositoryURL = &repo + base.StartingRef = &ref + + tests := []struct { + name string + change func(*cursorChatRequest) + wantCreates int + wantRuns int + }{ + { + name: "same identity mode-only change reuses", + change: func(request *cursorChatRequest) { + request.Mode = "plan" + }, + wantCreates: 1, wantRuns: 1, + }, + { + name: "model change creates agent", + change: func(request *cursorChatRequest) { + request.Model.ID = "claude-4.6-opus" + }, + wantCreates: 2, + }, + { + name: "variant change creates agent", + change: func(request *cursorChatRequest) { + request.Model.Params[0].Value = "high" + }, + wantCreates: 2, + }, + { + name: "repository change creates agent", + change: func(request *cursorChatRequest) { + other := "https://github.com/acme/other" + request.RepositoryURL = &other + }, + wantCreates: 2, + }, + { + name: "ref change creates agent", + change: func(request *cursorChatRequest) { + other := "develop" + request.StartingRef = &other + }, + wantCreates: 2, + }, + { + name: "auto PR change creates agent", + change: func(request *cursorChatRequest) { + request.AutoCreatePR = true + }, + wantCreates: 2, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fixture := newCursorDirectTestServer(t) + first := base + first.Model.Params = append( + []cursor.ModelParameterSelection(nil), base.Model.Params..., + ) + sessionID := approvedCursorTurn(t, fixture, first) + + second := first + second.SessionID = sessionID + second.Message = "continue" + second.Model.Params = append( + []cursor.ModelParameterSelection(nil), first.Model.Params..., + ) + test.change(&second) + approvedCursorTurn(t, fixture, second) + + if got := fixture.runner.CreateAgentCalls(); got != test.wantCreates { + t.Fatalf("CreateAgent calls=%d, want %d", got, test.wantCreates) + } + if got := fixture.runner.CreateRunCalls(); got != test.wantRuns { + t.Fatalf("CreateRun calls=%d, want %d", got, test.wantRuns) + } + if test.wantRuns == 1 { + fixture.runner.mu.Lock() + mode := fixture.runner.createRunRequests[0].Mode + agentID := fixture.runner.createRunAgentIDs[0] + fixture.runner.mu.Unlock() + if mode != "plan" || agentID != "bc-test-1" { + t.Fatalf("follow-up mode=%q agent=%q", mode, agentID) + } + } + }) + } +} + +func TestCursorChatExplicitNoRepositoryDiffersFromAutoDiscovery(t *testing.T) { + fixture := newCursorDirectTestServer(t) + first := defaultCursorChatRequest() + sessionID := approvedCursorTurn(t, fixture, first) + + noRepository := "" + second := defaultCursorChatRequest() + second.SessionID = sessionID + second.Message = "continue without a repository" + second.RepositoryURL = &noRepository + approvedCursorTurn(t, fixture, second) + + if fixture.runner.CreateAgentCalls() != 2 || fixture.runner.CreateRunCalls() != 0 { + t.Fatalf("create agent/run calls=%d/%d, want 2/0", + fixture.runner.CreateAgentCalls(), fixture.runner.CreateRunCalls()) + } +} + +func TestCursorChatReservationRejectsConcurrentTurnBeforeApproval(t *testing.T) { + fixture := newCursorDirectTestServer(t) + first := postCursorChat(t, fixture, defaultCursorChatRequest()) + defer first.Close() + session := first.NextType(t, agent.EventSession) + approvalEvent := first.NextType(t, agent.EventApproval) + + secondRequest := defaultCursorChatRequest() + secondRequest.SessionID = session.ID + secondRequest.Message = "competing turn" + status, body := postCursorChatStatus(t, fixture, secondRequest) + if status != http.StatusConflict || !strings.Contains(body, "already") { + t.Fatalf("status=%d body=%s, want 409", status, body) + } + resolveApproval(t, fixture, approvalEvent.ID, false) + first.NextType(t, agent.EventDone) + if fixture.runner.CreateAgentCalls() != 0 { + t.Fatal("concurrent request or refused request reached upstream") + } +} + +func TestCursorChatDisconnectDetachesAndAttachReplaysMemory(t *testing.T) { + fixture := newCursorDirectTestServer(t) + fixture.runner.holdStream() + stream := postCursorChat(t, fixture, defaultCursorChatRequest()) + session := stream.NextType(t, agent.EventSession) + approvalEvent := stream.NextType(t, agent.EventApproval) + resolveApproval(t, fixture, approvalEvent.ID, true) + stream.NextType(t, agent.EventText) + stream.Close() + + select { + case <-fixture.runner.streamStarted: + case <-time.After(time.Second): + t.Fatal("Cursor stream did not start") + } + if fixture.runner.CancelCalls() != 0 { + t.Fatal("HTTP disconnect cancelled the remote run") + } + + attach := getCursorAttach(t, fixture, session.ID) + defer attach.Close() + replayed := attach.NextType(t, agent.EventText) + if replayed.Delta != "final answer" { + t.Fatalf("replayed text=%q", replayed.Delta) + } + fixture.runner.releaseStream() + attach.NextType(t, agent.EventDone) + if fixture.runner.CancelCalls() != 0 { + t.Fatal("reattach path cancelled the remote run") + } +} + +func getCursorAttach( + t *testing.T, + fixture *cursorDirectFixture, + sessionID string, +) *sseTestStream { + t.Helper() + return getCursorAttachAt(t, fixture, sessionID, 0) +} + +func getCursorAttachAt( + t *testing.T, + fixture *cursorDirectFixture, + sessionID string, + cursor int, +) *sseTestStream { + t.Helper() + request, err := http.NewRequest( + http.MethodGet, + fmt.Sprintf( + "%s/api/chat/attach?session_id=%s&cursor=%d", + fixture.http.URL, sessionID, cursor, + ), + nil, + ) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Authorization", "Bearer test-token") + response, err := fixture.http.Client().Do(request) + if err != nil { + t.Fatal(err) + } + if response.StatusCode != http.StatusOK { + defer response.Body.Close() + body, _ := io.ReadAll(response.Body) + t.Fatalf("attach status=%d body=%s", response.StatusCode, body) + } + return &sseTestStream{ + t: t, response: response, scanner: bufio.NewScanner(response.Body), + } +} + +func TestCursorChatAttachRecoversPersistedRunResetAndFinalizesOnce(t *testing.T) { + fixture := newCursorDirectTestServer(t) + sessionID := seedRecoverableCursorSession(t, fixture) + fixture.runner.invokeReset = true + fixture.runner.streamEvents = []cursor.StreamEvent{ + {ID: "evt-new-reasoning", Type: "thinking", Text: "new reasoning"}, + {ID: "evt-new-text", Type: "assistant", Text: "new answer"}, + } + fixture.runner.terminal = cursor.Run{ + Status: "FINISHED", Result: "new answer", + Git: &cursor.GitState{Branches: []cursor.GitBranch{{ + RepoURL: "https://github.com/acme/repo", Branch: "cursor/recovered", + }}}, + } + + var streams [2]*sseTestStream + var wg sync.WaitGroup + for i := range streams { + wg.Add(1) + go func(index int) { + defer wg.Done() + streams[index] = getCursorAttach(t, fixture, sessionID) + }(i) + } + wg.Wait() + for _, stream := range streams { + stream.NextType(t, agent.EventDone) + stream.Close() + } + + calls := fixture.runner.StreamCalls() + if len(calls) != 1 || calls[0].LastEventID != "evt-old" { + t.Fatalf("recovery stream calls=%+v, want one resumed at evt-old", calls) + } + state, err := fixture.db.GetCursorSessionState(context.Background(), sessionID) + if err != nil { + t.Fatal(err) + } + if state.OperationState != store.CursorOperationCommitted || + state.PartialText != "new answer" || + state.PartialReasoning != "new reasoning" || + state.LastEventID != "evt-new-text" || + strings.Contains(state.PartialText, "old") || + strings.Contains(state.PartialReasoning, "old") { + t.Fatalf("recovered state = %+v", state) + } + messages, err := fixture.db.ListMessages(context.Background(), sessionID, 0, 0) + if err != nil { + t.Fatal(err) + } + if len(messages) != 2 || messages[1].Content != "new answer" || + messages[1].Reasoning != "new reasoning" { + t.Fatalf("recovered messages = %+v", messages) + } + + again := getCursorAttach(t, fixture, sessionID) + again.NextType(t, agent.EventDone) + again.Close() + if len(fixture.runner.StreamCalls()) != 1 { + t.Fatal("committed attach started a duplicate recovery watcher") + } + messages, _ = fixture.db.ListMessages(context.Background(), sessionID, 0, 0) + if len(messages) != 2 { + t.Fatalf("terminal finalization appended %d messages, want 2 total", len(messages)) + } +} + +func TestCursorChatPersistsEventsBeforePublishingAndUsesCanonicalFinalText(t *testing.T) { + fixture := newCursorDirectTestServer(t) + fixture.runner.streamEvents = []cursor.StreamEvent{ + {ID: "evt-status", Type: "status", Status: "RUNNING"}, + {ID: "evt-thinking", Type: "thinking", Text: "deep thought"}, + {ID: "evt-partial", Type: "assistant", Text: "partial"}, + { + ID: "evt-tool", Type: "tool_call", CallID: "call-1", + ToolName: "read_file", Status: "completed", + }, + { + ID: "evt-result", Type: "result", Status: "FINISHED", + Text: "canonical whole answer", + }, + } + fixture.runner.terminal = cursor.Run{ + Status: "FINISHED", Result: "canonical whole answer", + Git: &cursor.GitState{Branches: []cursor.GitBranch{{ + RepoURL: "https://github.com/acme/repo", + Branch: "cursor/canonical", + PRURL: "https://github.com/acme/repo/pull/9", + }}}, + } + + var sessionID atomic.Value + persistenceErrors := make(chan string, len(fixture.runner.streamEvents)) + fixture.runner.afterEmit = func(event cursor.StreamEvent) { + id, _ := sessionID.Load().(string) + state, err := fixture.db.GetCursorSessionState(context.Background(), id) + if err != nil { + persistenceErrors <- err.Error() + return + } + if state.LastEventID != event.ID { + persistenceErrors <- fmt.Sprintf( + "event %s published with persisted LastEventID %s", + event.ID, state.LastEventID, + ) + return + } + switch event.Type { + case "thinking": + if state.PartialReasoning != "deep thought" { + persistenceErrors <- "reasoning was not persisted before publish" + } + case "assistant": + if state.PartialText != "partial" { + persistenceErrors <- "text was not persisted before publish" + } + case "result": + if state.PartialText != "canonical whole answer" { + persistenceErrors <- "whole result was appended instead of reconciled" + } + } + } + + stream := postCursorChat(t, fixture, defaultCursorChatRequest()) + defer stream.Close() + session := stream.NextType(t, agent.EventSession) + sessionID.Store(session.ID) + approvalEvent := stream.NextType(t, agent.EventApproval) + resolveApproval(t, fixture, approvalEvent.ID, true) + + rendered := "" + sawTool := false + for { + event := stream.Next(t) + switch event.Type { + case agent.EventReset: + rendered = "" + case agent.EventText: + rendered += event.Delta + case agent.EventToolProgress: + if event.Name == "read_file" { + sawTool = true + } + case agent.EventDone: + goto finished + } + } + +finished: + close(persistenceErrors) + for persistenceError := range persistenceErrors { + if persistenceError != "" { + t.Error(persistenceError) + } + } + if rendered != "canonical whole answer" { + t.Fatalf("rendered text=%q, want canonical whole answer", rendered) + } + if !sawTool { + t.Fatal("Cursor tool event was not published live") + } + state, err := fixture.db.GetCursorSessionState(context.Background(), session.ID) + if err != nil { + t.Fatal(err) + } + if state.OperationState != store.CursorOperationCommitted || + state.PartialText != "canonical whole answer" || + state.PartialReasoning != "deep thought" || + !strings.Contains(state.GitState, "cursor/canonical") { + t.Fatalf("final state=%+v", state) + } + messages, err := fixture.db.ListMessages(context.Background(), session.ID, 0, 0) + if err != nil { + t.Fatal(err) + } + if len(messages) != 2 || messages[1].Content != "canonical whole answer" || + messages[1].Reasoning != "deep thought" { + t.Fatalf("final messages=%+v", messages) + } + for _, message := range messages { + if message.Role == store.RoleTool { + t.Fatal("live-only Cursor tool progress was persisted as chat history") + } + } +} + +func TestCursorChatAttachFinalizesTerminalSnapshotWithoutStreaming(t *testing.T) { + fixture := newCursorDirectTestServer(t) + sessionID := seedRecoverableCursorSession(t, fixture) + fixture.runner.getRun = cursor.Run{ + Status: "FINISHED", Result: "terminal snapshot", + Git: &cursor.GitState{Branches: []cursor.GitBranch{{ + RepoURL: "https://github.com/acme/repo", Branch: "cursor/snapshot", + }}}, + } + + stream := getCursorAttach(t, fixture, sessionID) + stream.NextType(t, agent.EventDone) + stream.Close() + + if calls := fixture.runner.StreamCalls(); len(calls) != 0 { + t.Fatalf("terminal recovery opened %d stream(s), want 0", len(calls)) + } + state, err := fixture.db.GetCursorSessionState(context.Background(), sessionID) + if err != nil { + t.Fatal(err) + } + if state.OperationState != store.CursorOperationCommitted || + state.PartialText != "terminal snapshot" { + t.Fatalf("terminal recovery state=%+v", state) + } + messages, _ := fixture.db.ListMessages(context.Background(), sessionID, 0, 0) + if len(messages) != 2 || messages[1].Content != "terminal snapshot" { + t.Fatalf("terminal recovery messages=%+v", messages) + } +} + +func TestCursorChatAttachReplaysPersistedPartialsBeforeResuming(t *testing.T) { + fixture := newCursorDirectTestServer(t) + sessionID := seedRecoverableCursorSession(t, fixture) + fixture.runner.streamEvents = []cursor.StreamEvent{ + {ID: "evt-next", Type: "assistant", Text: " plus"}, + } + fixture.runner.terminal = cursor.Run{ + Status: "FINISHED", Result: "old text plus", + } + + stream := getCursorAttach(t, fixture, sessionID) + defer stream.Close() + var text, reasoning string + for { + event := stream.Next(t) + switch event.Type { + case agent.EventText: + text += event.Delta + case agent.EventReasoning: + reasoning += event.Delta + case agent.EventDone: + if text != "old text plus" || reasoning != "old reasoning" { + t.Fatalf("recovered live partials text=%q reasoning=%q", text, reasoning) + } + return + } + } +} + +func TestCursorChatRecoveryIgnoresCursorFromLostInMemoryRun(t *testing.T) { + fixture := newCursorDirectTestServer(t) + sessionID := seedRecoverableCursorSession(t, fixture) + fixture.runner.streamEvents = nil + fixture.runner.terminal = cursor.Run{ + Status: "FINISHED", Result: "old text", + } + + stream := getCursorAttachAt(t, fixture, sessionID, 999) + defer stream.Close() + if event := stream.NextType(t, agent.EventText); event.Delta != "old text" { + t.Fatalf("recovered text=%q, want persisted partial", event.Delta) + } + stream.NextType(t, agent.EventDone) +} + +func TestCursorChatBoundsAndRedactsStreamErrors(t *testing.T) { + const secret = "crsr_server_error_secret" + fixture := newCursorDirectTestServerWithConfig(t, func(cfg *config.Config) { + provider := cfg.Providers["cursor"] + provider.Enabled = true + provider.APIKey = secret + cfg.Providers["cursor"] = provider + }) + fixture.runner.streamEvents = nil + fixture.runner.streamErr = errors.New(secret + strings.Repeat("界", 5000)) + + stream := postCursorChat(t, fixture, defaultCursorChatRequest()) + defer stream.Close() + stream.NextType(t, agent.EventSession) + approvalEvent := stream.NextType(t, agent.EventApproval) + resolveApproval(t, fixture, approvalEvent.ID, true) + errorEvent := stream.NextType(t, agent.EventError) + stream.NextType(t, agent.EventDone) + + if strings.Contains(errorEvent.Err, secret) { + t.Fatalf("stream error leaked credential: %q", errorEvent.Err) + } + if utf8.RuneCountInString(errorEvent.Err) > maxCursorServerErrorRunes { + t.Fatalf("stream error has %d runes, want <=%d", + utf8.RuneCountInString(errorEvent.Err), maxCursorServerErrorRunes) + } +} + +func TestCursorChatRedactsUpstreamContentBeforePersistingOrPublishing(t *testing.T) { + fixture := newCursorDirectTestServer(t) + secret := "supersecretvalue" + fixture.runner.streamEvents = []cursor.StreamEvent{ + {ID: "evt-status", Type: "status", Status: "token=" + secret}, + {ID: "evt-reasoning", Type: "thinking", Text: "secret=" + secret}, + {ID: "evt-text", Type: "assistant", Text: "token=" + secret}, + } + fixture.runner.terminal = cursor.Run{ + ID: "run-test-1", AgentID: "bc-test-1", Status: "FINISHED", + Result: "answer token=" + secret, + Git: &cursor.GitState{Branches: []cursor.GitBranch{{ + RepoURL: "https://github.com/acme/repo?token=" + secret, + Branch: "token=" + secret, + PRURL: "https://github.com/acme/repo/pull/1?token=" + secret, + }}}, + } + + stream := postCursorChat(t, fixture, defaultCursorChatRequest()) + defer stream.Close() + session := stream.NextType(t, agent.EventSession) + approvalEvent := stream.NextType(t, agent.EventApproval) + resolveApproval(t, fixture, approvalEvent.ID, true) + var published strings.Builder + for { + event := stream.Next(t) + raw, err := json.Marshal(event) + if err != nil { + t.Fatal(err) + } + published.Write(raw) + if event.Type == agent.EventDone { + break + } + } + if strings.Contains(published.String(), secret) { + t.Fatalf("SSE leaked upstream credential: %s", published.String()) + } + + state := waitCursorState(t, fixture.db, session.ID, func(state *store.CursorSessionState) bool { + return state.OperationState == store.CursorOperationCommitted + }) + stateJSON, _ := json.Marshal(state) + messages, err := fixture.db.ListMessages(context.Background(), session.ID, 0, 0) + if err != nil { + t.Fatal(err) + } + messageJSON, _ := json.Marshal(messages) + if strings.Contains(string(stateJSON), secret) || + strings.Contains(string(messageJSON), secret) { + t.Fatalf("persisted Cursor data leaked upstream credential: state=%s messages=%s", + stateJSON, messageJSON) + } +} + +func TestCursorChatTerminalStateBlocksNextTurnUntilFinalization(t *testing.T) { + fixture := newCursorDirectTestServer(t) + sessionID := seedRecoverableCursorSession(t, fixture) + _, err := fixture.server.mutateCursorState(context.Background(), sessionID, + func(state *store.CursorSessionState) error { + state.OperationState = store.CursorOperationTerminal + state.RemoteStatus = "FINISHED" + return nil + }) + if err != nil { + t.Fatal(err) + } + + request := defaultCursorChatRequest() + request.SessionID = sessionID + status, _ := postCursorChatStatus(t, fixture, request) + if status != http.StatusConflict { + t.Fatalf("status=%d, want 409 while terminal assistant commit is pending", status) + } + if fixture.runner.CreateAgentCalls() != 0 || fixture.runner.CreateRunCalls() != 0 { + t.Fatal("unfinished terminal finalization issued a new Cursor mutation") + } +} + +func seedRecoverableCursorSession( + t *testing.T, + fixture *cursorDirectFixture, +) string { + t.Helper() + sessionID := newID("ses-recovery") + userMessageID := newID("msg-recovery-user") + assistantMessageID := newID("msg-recovery-assistant") + if err := fixture.db.CreateSession(context.Background(), &store.Session{ + ID: sessionID, Title: "recover", Platform: "web", + Model: fixture.cfg.Model.Default, Provider: fixture.cfg.Model.Provider, + Meta: store.Meta{}, + }); err != nil { + t.Fatal(err) + } + if err := fixture.db.AppendMessage(context.Background(), &store.Message{ + ID: userMessageID, SessionID: sessionID, + Role: store.RoleUser, Content: "recover me", + }); err != nil { + t.Fatal(err) + } + state := &store.CursorSessionState{ + SessionID: sessionID, TargetActive: true, ReuseValid: true, + ModelID: "gpt-5.6-sol", + ModelParams: `[{"id":"reasoning","value":"max"}]`, + AgentID: "bc-recovery", RunID: "run-recovery", + RemoteStatus: "RUNNING", LastEventID: "evt-old", + PartialText: "old text", PartialReasoning: "old reasoning", + OperationState: store.CursorOperationRunInFlight, + UserMessageID: userMessageID, + AssistantMessageID: assistantMessageID, + } + if err := fixture.db.PutCursorSessionState(context.Background(), state); err != nil { + t.Fatal(err) + } + return sessionID +} + +func TestCursorChatInterruptStopsWatcherWithoutRemoteCancel(t *testing.T) { + fixture := newCursorDirectTestServer(t) + fixture.runner.holdStream() + stream := postCursorChat(t, fixture, defaultCursorChatRequest()) + defer stream.Close() + session := stream.NextType(t, agent.EventSession) + approvalEvent := stream.NextType(t, agent.EventApproval) + resolveApproval(t, fixture, approvalEvent.ID, true) + waitCursorState(t, fixture.db, session.ID, func(state *store.CursorSessionState) bool { + return state.OperationState == store.CursorOperationRunInFlight && state.RunID != "" + }) + + status := postInterrupt(t, fixture, session.ID) + if status != http.StatusOK { + t.Fatalf("interrupt status=%d", status) + } + stream.NextType(t, agent.EventDone) + state := waitCursorState(t, fixture.db, session.ID, func(state *store.CursorSessionState) bool { + return state.OperationState == store.CursorOperationRunInFlight + }) + if state.RunID == "" || fixture.runner.CancelCalls() != 0 { + t.Fatalf("interrupt state=%+v cancel calls=%d", state, fixture.runner.CancelCalls()) + } +} + +func postInterrupt(t *testing.T, fixture *cursorDirectFixture, sessionID string) int { + t.Helper() + request, err := http.NewRequest( + http.MethodPost, + fixture.http.URL+"/api/chat/interrupt", + strings.NewReader(`{"session_id":"`+sessionID+`"}`), + ) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Authorization", "Bearer test-token") + response, err := fixture.http.Client().Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + return response.StatusCode +} + +func TestCursorChatStopDuringCreatePersistsIDsAndDefersWatching(t *testing.T) { + fixture := newCursorDirectTestServer(t) + fixture.runner.createAgentBlock = make(chan struct{}) + stream := postCursorChat(t, fixture, defaultCursorChatRequest()) + defer stream.Close() + session := stream.NextType(t, agent.EventSession) + approvalEvent := stream.NextType(t, agent.EventApproval) + resolveApproval(t, fixture, approvalEvent.ID, true) + waitCursorState(t, fixture.db, session.ID, func(state *store.CursorSessionState) bool { + return state.OperationState == store.CursorOperationCreateInFlight + }) + + if status := postInterrupt(t, fixture, session.ID); status != http.StatusOK { + t.Fatalf("interrupt status=%d", status) + } + close(fixture.runner.createAgentBlock) + stream.NextType(t, agent.EventDone) + state := waitCursorState(t, fixture.db, session.ID, func(state *store.CursorSessionState) bool { + return state.OperationState == store.CursorOperationRunInFlight && + state.AgentID != "" && state.RunID != "" + }) + if state.OperationState == store.CursorOperationAmbiguous { + t.Fatalf("local stop made a successful create ambiguous: %+v", state) + } + if calls := fixture.runner.StreamCalls(); len(calls) != 0 { + t.Fatalf("detached create opened a watcher: %+v", calls) + } + + fixture.runner.holdStream() + attach := getCursorAttach(t, fixture, session.ID) + select { + case <-fixture.runner.streamStarted: + case <-time.After(time.Second): + t.Fatal("persisted IDs were not recoverable after local stop") + } + fixture.runner.releaseStream() + attach.NextType(t, agent.EventDone) + attach.Close() + if fixture.runner.CreateAgentCalls() != 1 || len(fixture.runner.StreamCalls()) != 1 { + t.Fatalf("recovery recreated instead of watching persisted IDs: creates=%d streams=%d", + fixture.runner.CreateAgentCalls(), len(fixture.runner.StreamCalls())) + } +} + +func TestCursorChatApprovedCancelCallsUpstreamExactlyOnce(t *testing.T) { + fixture := newCursorDirectTestServer(t) + fixture.runner.holdStream() + stream := postCursorChat(t, fixture, defaultCursorChatRequest()) + defer stream.Close() + session := stream.NextType(t, agent.EventSession) + startApproval := stream.NextType(t, agent.EventApproval) + resolveApproval(t, fixture, startApproval.ID, true) + waitCursorState(t, fixture.db, session.ID, func(state *store.CursorSessionState) bool { + return state.OperationState == store.CursorOperationRunInFlight && state.RunID != "" + }) + cancelMarker := make(chan string, 1) + fixture.runner.mu.Lock() + fixture.runner.cancelHook = func() { + state, err := fixture.db.GetCursorSessionState(context.Background(), session.ID) + if err != nil { + cancelMarker <- "error: " + err.Error() + return + } + cancelMarker <- state.RemoteStatus + } + fixture.runner.mu.Unlock() + + cancelResult := make(chan int, 1) + go func() { + cancelResult <- postCursorCancel(t, fixture, session.ID) + }() + cancelApproval := stream.NextType(t, agent.EventApproval) + resolveApproval(t, fixture, cancelApproval.ID, true) + select { + case status := <-cancelResult: + if status != http.StatusOK { + t.Fatalf("cancel status=%d", status) + } + case <-time.After(time.Second): + t.Fatal("approved cancel did not return") + } + select { + case marker := <-cancelMarker: + if marker != cursorCancelInFlight { + t.Fatalf("durable status at CancelRun=%q, want %q", marker, cursorCancelInFlight) + } + case <-time.After(time.Second): + t.Fatal("CancelRun did not observe its durable in-flight marker") + } + // Simulate a daemon restart losing the in-memory reservation. The durable + // pre-call marker must still prevent a duplicate mutation. + fixture.server.cursorCancelMu.Lock() + fixture.server.cursorCancels = map[string]string{} + fixture.server.cursorCancelMu.Unlock() + if status := postCursorCancel(t, fixture, session.ID); status != http.StatusConflict { + t.Fatalf("duplicate cancel status=%d, want 409", status) + } + if fixture.runner.CancelCalls() != 1 { + t.Fatalf("CancelRun calls=%d, want 1", fixture.runner.CancelCalls()) + } + if status := deleteCursorSession(t, fixture, session.ID); status != http.StatusOK { + t.Fatalf("delete after approved cancellation status=%d, want 200", status) + } + stream.NextType(t, agent.EventDone) +} + +func postCursorCancel(t *testing.T, fixture *cursorDirectFixture, sessionID string) int { + t.Helper() + status, _ := postCursorCancelResponse(t, fixture, sessionID) + return status +} + +func postCursorCancelResponse( + t *testing.T, + fixture *cursorDirectFixture, + sessionID string, +) (int, string) { + t.Helper() + request, err := http.NewRequest( + http.MethodPost, + fixture.http.URL+"/api/chat/cursor/cancel", + strings.NewReader(`{"session_id":"`+sessionID+`"}`), + ) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Authorization", "Bearer test-token") + request.Header.Set("Content-Type", "application/json") + response, err := fixture.http.Client().Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + raw, err := io.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + } + return response.StatusCode, string(raw) +} + +func TestCursorChatDeleteRejectsActiveRemoteStateBeforeAnyMutation(t *testing.T) { + fixture := newCursorDirectTestServer(t) + fixture.runner.holdStream() + stream := postCursorChat(t, fixture, defaultCursorChatRequest()) + defer stream.Close() + session := stream.NextType(t, agent.EventSession) + approvalEvent := stream.NextType(t, agent.EventApproval) + resolveApproval(t, fixture, approvalEvent.ID, true) + waitCursorState(t, fixture.db, session.ID, func(state *store.CursorSessionState) bool { + return state.OperationState == store.CursorOperationRunInFlight + }) + + if status := deleteCursorSession(t, fixture, session.ID); status != http.StatusConflict { + t.Fatalf("active single delete status=%d, want 409", status) + } + if status := deleteAllCursorSessions(t, fixture); status != http.StatusConflict { + t.Fatalf("active bulk delete status=%d, want 409", status) + } + if _, err := fixture.db.GetSession(context.Background(), session.ID); err != nil { + t.Fatalf("active delete mutated session: %v", err) + } + + fixture.runner.releaseStream() + stream.NextType(t, agent.EventDone) + if status := deleteCursorSession(t, fixture, session.ID); status != http.StatusOK { + t.Fatalf("terminal single delete status=%d, want 200", status) + } +} + +func deleteCursorSession(t *testing.T, fixture *cursorDirectFixture, sessionID string) int { + t.Helper() + request, err := http.NewRequest( + http.MethodDelete, + fixture.http.URL+"/api/sessions/"+sessionID, + nil, + ) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Authorization", "Bearer test-token") + response, err := fixture.http.Client().Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + return response.StatusCode +} + +func deleteAllCursorSessions(t *testing.T, fixture *cursorDirectFixture) int { + t.Helper() + request, err := http.NewRequest( + http.MethodPost, + fixture.http.URL+"/api/sessions/delete-all", + strings.NewReader(`{"category":"all"}`), + ) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Authorization", "Bearer test-token") + request.Header.Set("Content-Type", "application/json") + response, err := fixture.http.Client().Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + return response.StatusCode +} + +func TestCursorChatEditInvalidatesReuseBeforeNextRun(t *testing.T) { + fixture := newCursorDirectTestServer(t) + request := defaultCursorChatRequest() + sessionID := approvedCursorTurn(t, fixture, request) + messages, err := fixture.db.ListMessages(context.Background(), sessionID, 0, 0) + if err != nil || len(messages) < 1 { + t.Fatalf("messages=%+v err=%v", messages, err) + } + if status := editCursorMessage(t, fixture, sessionID, messages[0].ID); status != http.StatusOK { + t.Fatalf("edit status=%d", status) + } + state, err := fixture.db.GetCursorSessionState(context.Background(), sessionID) + if err != nil { + t.Fatal(err) + } + if state.ReuseValid { + t.Fatal("editing Cursor history left reuse valid") + } + + request.SessionID = sessionID + request.Message = "edited request" + approvedCursorTurn(t, fixture, request) + if fixture.runner.CreateAgentCalls() != 2 || fixture.runner.CreateRunCalls() != 0 { + t.Fatalf("post-edit creates/runs=%d/%d, want 2/0", + fixture.runner.CreateAgentCalls(), fixture.runner.CreateRunCalls()) + } +} + +func TestOrdinaryChatInvalidatesCursorReuseBeforeRunning(t *testing.T) { + fixture := newCursorDirectTestServerWithConfig(t, func(cfg *config.Config) { + // Keep the ordinary turn hermetic: it will fail locally after the reuse + // transition instead of constructing a provider request. + cfg.Model.Default = "" + }) + request := defaultCursorChatRequest() + sessionID := approvedCursorTurn(t, fixture, request) + + body, _ := json.Marshal(map[string]string{ + "session_id": sessionID, + "message": "switch back to ordinary chat", + }) + httpRequest, err := http.NewRequest( + http.MethodPost, + fixture.http.URL+"/api/chat", + bytes.NewReader(body), + ) + if err != nil { + t.Fatal(err) + } + httpRequest.Header.Set("Authorization", "Bearer test-token") + httpRequest.Header.Set("Content-Type", "application/json") + response, err := fixture.http.Client().Do(httpRequest) + if err != nil { + t.Fatal(err) + } + _ = response.Body.Close() + + state, err := fixture.db.GetCursorSessionState(context.Background(), sessionID) + if err != nil { + t.Fatal(err) + } + if state.TargetActive || state.ReuseValid { + t.Fatalf("ordinary chat left Cursor target reusable: %+v", state) + } +} + +func editCursorMessage( + t *testing.T, + fixture *cursorDirectFixture, + sessionID string, + messageID string, +) int { + t.Helper() + body, _ := json.Marshal(map[string]any{"message_id": messageID, "revert": false}) + request, err := http.NewRequest( + http.MethodPost, + fixture.http.URL+"/api/sessions/"+sessionID+"/edit", + bytes.NewReader(body), + ) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Authorization", "Bearer test-token") + request.Header.Set("Content-Type", "application/json") + response, err := fixture.http.Client().Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + return response.StatusCode +} diff --git a/internal/server/handlers_project_env.go b/internal/server/handlers_project_env.go index d3442a4..8caf80e 100644 --- a/internal/server/handlers_project_env.go +++ b/internal/server/handlers_project_env.go @@ -15,8 +15,8 @@ import ( var projectEnvFiles = []string{".env", ".env.local", ".env.development", ".env.dev", ".env.example"} // resolveProjectDir validates the ?dir= query as an absolute path that exists -// and is a directory. It is the shared guard for the project env/plan handlers, -// which read and write inside a chosen project folder. +// and is a directory. It is the shared guard for project handlers that inspect +// or read and write inside a caller-selected project folder. func resolveProjectDir(w http.ResponseWriter, r *http.Request) (string, bool) { dir := strings.TrimSpace(r.URL.Query().Get("dir")) if dir == "" { diff --git a/internal/server/handlers_providers.go b/internal/server/handlers_providers.go index a289680..7ee5f40 100644 --- a/internal/server/handlers_providers.go +++ b/internal/server/handlers_providers.go @@ -19,7 +19,12 @@ import ( // found:false — the caller then asks the user for the value. func (s *Server) handleProviderModelInfo(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") - modelID := strings.TrimSpace(r.URL.Query().Get("id")) + modelID := strings.TrimSpace(r.URL.Query().Get("model")) + if modelID == "" { + // Keep accepting the earlier dashboard parameter while model is rolled + // out; model is the canonical API name. + modelID = strings.TrimSpace(r.URL.Query().Get("id")) + } if modelID == "" { writeError(w, http.StatusBadRequest, errors.New("a model id is required")) return @@ -40,9 +45,12 @@ func (s *Server) handleProviderModelInfo(w http.ResponseWriter, r *http.Request) for _, m := range models { if m.ID == modelID { writeJSON(w, http.StatusOK, map[string]any{ - "found": true, - "context_window": m.ContextWindow, - "name": m.Name, + "found": true, + "id": m.ID, + "context_window": m.ContextWindow, + "name": m.Name, + "reasoning": m.Reasoning, + "reasoning_capability": m.ReasoningCapability, }) return } @@ -74,15 +82,14 @@ func (s *Server) handleProviderModels(w http.ResponseWriter, r *http.Request) { return } - client, err := s.newCursorMetadataClient(cursor.Options{BaseURL: p.BaseURL, APIKey: key}) - if err != nil { - writeError(w, http.StatusBadGateway, err) - return - } ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel() - catalog, err := client.Models(ctx) + if s.cursorRunner == nil { + writeError(w, http.StatusBadGateway, errors.New("Cursor is unavailable")) + return + } + catalog, err := s.cursorRunner.Catalog(ctx, false) if err != nil { if cursor.IsAuthError(err) { writeJSON(w, http.StatusOK, map[string]any{"models": []any{}, "error": err.Error()}) @@ -95,17 +102,29 @@ func (s *Server) handleProviderModels(w http.ResponseWriter, r *http.Request) { type modelOut struct { ID string `json:"id"` Name string `json:"name"` - Description string `json:"description"` + Description string `json:"description,omitempty"` + Aliases []string `json:"aliases"` Parameters []cursor.ModelParameter `json:"parameters"` + Variants []cursor.ModelVariant `json:"variants"` } out := make([]modelOut, 0, len(catalog.Items)) for _, m := range catalog.Items { - params := m.Parameters - if params == nil { - params = []cursor.ModelParameter{} + aliases := append([]string{}, m.Aliases...) + parameters := append([]cursor.ModelParameter{}, m.Parameters...) + for i := range parameters { + parameters[i].Values = append([]cursor.ModelParameterValue{}, parameters[i].Values...) + } + variants := append([]cursor.ModelVariant{}, m.Variants...) + for i := range variants { + variants[i].Params = append([]cursor.ModelParameterSelection{}, variants[i].Params...) } out = append(out, modelOut{ - ID: m.ID, Name: m.DisplayName, Description: m.Description, Parameters: params, + ID: m.ID, + Name: m.DisplayName, + Description: m.Description, + Aliases: aliases, + Parameters: parameters, + Variants: variants, }) } writeJSON(w, http.StatusOK, map[string]any{"models": out}) diff --git a/internal/server/handlers_roles.go b/internal/server/handlers_roles.go index 466cfd0..428aac3 100644 --- a/internal/server/handlers_roles.go +++ b/internal/server/handlers_roles.go @@ -3,6 +3,7 @@ package server import ( "errors" "net/http" + "strings" "github.com/enowdev/antares/internal/roles" "github.com/enowdev/antares/internal/tools" @@ -82,9 +83,22 @@ func (s *Server) handleSaveRole(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, err) return } + effort := strings.TrimSpace(b.Effort) + previousEffort := "" + if previous, ok := reg.Get(b.Name); ok { + previousEffort = previous.Effort + } + if effort != previousEffort { + if err := s.validateExplicitReasoning( + r.Context(), s.config(), strings.TrimSpace(b.Model), effort, + ); err != nil { + writeReasoningValidationError(w, err) + return + } + } saved, err := reg.Save(roles.Role{ Name: b.Name, Title: b.Title, Summary: b.Summary, Category: b.Category, - Toolset: b.Toolset, Model: b.Model, Effort: b.Effort, MaxTurns: b.MaxTurns, + Toolset: b.Toolset, Model: b.Model, Effort: effort, MaxTurns: b.MaxTurns, Tags: b.Tags, Danger: b.Danger, Subrole: b.Subrole, Parent: b.Parent, Prompt: b.Body, }) diff --git a/internal/server/livechat.go b/internal/server/livechat.go index 7ff6596..e4d0692 100644 --- a/internal/server/livechat.go +++ b/internal/server/livechat.go @@ -4,6 +4,7 @@ import ( "context" "strings" "sync" + "unicode/utf8" "github.com/enowdev/antares/internal/agent" ) @@ -13,23 +14,52 @@ import ( // that started it: the run is driven on a background context and publishes here, // so a client that navigates away and comes back can reattach and keep watching // instead of losing the turn. -// maxLiveEvents bounds how many of a turn's most recent events are retained for -// replay. A long-horizon turn can emit tens of thousands of tiny events (text -// deltas, tool progress); keeping them all pins memory and lengthens every -// reconnect replay. We keep a trailing window instead — a reconnecting client -// still has the persisted history for anything older, so it loses nothing. -const maxLiveEvents = 4000 +const ( + // maxLiveEvents bounds how many of a turn's most recent original events are + // retained for replay. Older text and reasoning are folded into a replay + // checkpoint so a cursor behind the window can still reconstruct the turn. + maxLiveEvents = 4000 + + // Cursor partials are bounded to the same limit before publication. Applying + // the limit generically keeps an ordinary run's replay checkpoint bounded too. + maxLiveReplaySnapshotRunes = maxCursorPartialRunes + + liveReplayTrimmedToolsNotice = "Earlier live tool progress was trimmed from this replay." +) + +type liveRunKind uint8 + +const ( + liveRunOrdinary liveRunKind = iota + liveRunCursorDirect + liveRunCursorRecovery +) type liveRun struct { - mu sync.Mutex - events []agent.Event - base int // absolute index of events[0] (count of events already trimmed) - done bool - updated chan struct{} // closed on every change; replaced under the lock + mu sync.Mutex + events []agent.Event + base int // absolute index of events[0] (count of events already trimmed) + // replayText and replayReasoning contain the canonical partials immediately + // before events[0]. They are emitted after a synthetic reset when a follower's + // cursor predates base. Their rune counts avoid repeatedly scanning snapshots. + replayText []byte + replayTextRunes int + replayReasoning []byte + replayReasoningRunes int + replayToolsTrimmed bool + done bool + kind liveRunKind + detached bool + stop context.CancelFunc + updated chan struct{} // closed on every change; replaced under the lock } func newLiveRun() *liveRun { return &liveRun{updated: make(chan struct{})} } +func newCursorLiveRun(kind liveRunKind) *liveRun { + return &liveRun{kind: kind, updated: make(chan struct{})} +} + func (lr *liveRun) signal() { close(lr.updated) lr.updated = make(chan struct{}) @@ -39,37 +69,195 @@ func (lr *liveRun) signal() { func (lr *liveRun) publish(e agent.Event) { lr.mu.Lock() lr.events = append(lr.events, e) - // Trim the oldest events once the window is exceeded, tracking how many were - // dropped in base so follow()'s absolute cursor keeps mapping correctly. + // Fold trimmed events into the checkpoint before releasing their storage. + // base continues to count only original events; synthetic checkpoint frames + // therefore do not disturb an existing follower's absolute cursor. if over := len(lr.events) - maxLiveEvents; over > 0 { - lr.events = append(lr.events[:0], lr.events[over:]...) + for _, dropped := range lr.events[:over] { + lr.foldReplayEvent(dropped) + } + retained := copy(lr.events, lr.events[over:]) + clear(lr.events[retained:]) + lr.events = lr.events[:retained] lr.base += over } lr.signal() lr.mu.Unlock() } +func (lr *liveRun) foldReplayEvent(event agent.Event) { + switch event.Type { + case agent.EventReset: + lr.replayText = nil + lr.replayTextRunes = 0 + lr.replayReasoning = nil + lr.replayReasoningRunes = 0 + case agent.EventText: + lr.replayText, lr.replayTextRunes = appendLiveReplaySnapshot( + lr.replayText, lr.replayTextRunes, event.Delta, + ) + case agent.EventReasoning: + lr.replayReasoning, lr.replayReasoningRunes = appendLiveReplaySnapshot( + lr.replayReasoning, lr.replayReasoningRunes, event.Delta, + ) + case agent.EventToolCall, agent.EventToolProgress, agent.EventToolResult: + // Tool payloads are live-only. Retain only the fact that an evicted card + // existed, never its arguments, chunks, or result. + lr.replayToolsTrimmed = true + } +} + +func appendLiveReplaySnapshot(snapshot []byte, runes int, delta string) ([]byte, int) { + remaining := maxLiveReplaySnapshotRunes - runes + if remaining <= 0 || delta == "" { + return snapshot, runes + } + deltaRunes := utf8.RuneCountInString(delta) + if deltaRunes <= remaining { + return append(snapshot, delta...), runes + deltaRunes + } + end := len(delta) + seen := 0 + for index := range delta { + if seen == remaining { + end = index + break + } + seen++ + } + return append(snapshot, delta[:end]...), runes + seen +} + +func (lr *liveRun) replayAnchor() []agent.Event { + anchor := make([]agent.Event, 0, 4) + anchor = append(anchor, agent.Event{Type: agent.EventReset}) + if lr.replayToolsTrimmed { + anchor = append(anchor, agent.Event{ + Type: agent.EventNotice, Message: liveReplayTrimmedToolsNotice, + }) + } + if len(lr.replayReasoning) > 0 { + anchor = append(anchor, agent.Event{ + Type: agent.EventReasoning, Delta: string(lr.replayReasoning), + }) + } + if len(lr.replayText) > 0 { + anchor = append(anchor, agent.Event{ + Type: agent.EventText, Delta: string(lr.replayText), + }) + } + return anchor +} + // finish marks the run complete so followers return once caught up. func (lr *liveRun) finish() { lr.mu.Lock() if !lr.done { lr.done = true + lr.stop = nil lr.signal() } lr.mu.Unlock() } +func (lr *liveRun) beginCursorApproval(stop context.CancelFunc) { + lr.mu.Lock() + if lr.done { + lr.mu.Unlock() + if stop != nil { + stop() + } + return + } + lr.stop = stop + lr.mu.Unlock() +} + +// beginCursorCreate switches from cancellable approval to a non-cancellable +// mutation. False means local Stop won before the POST boundary. +func (lr *liveRun) beginCursorCreate() bool { + lr.mu.Lock() + defer lr.mu.Unlock() + if lr.done || lr.detached { + return false + } + lr.stop = nil + return true +} + +// beginCursorWatch installs the watcher cancellation only after returned IDs +// are durable. A Stop during create records detachment and makes this return +// false without ever cancelling the non-idempotent create request. +func (lr *liveRun) beginCursorWatch(stop context.CancelFunc) bool { + lr.mu.Lock() + if lr.done || lr.detached { + lr.mu.Unlock() + if stop != nil { + stop() + } + return false + } + lr.stop = stop + lr.mu.Unlock() + return true +} + +func (lr *liveRun) runKind() liveRunKind { + lr.mu.Lock() + defer lr.mu.Unlock() + return lr.kind +} + +func (lr *liveRun) isCursor() bool { + kind := lr.runKind() + return kind == liveRunCursorDirect || kind == liveRunCursorRecovery +} + +// stopWatching cancels approval before a POST, records detachment while a +// create POST is in flight, and cancels only an established local watcher. +func (lr *liveRun) stopWatching() bool { + lr.mu.Lock() + if lr.done || (lr.kind != liveRunCursorDirect && lr.kind != liveRunCursorRecovery) { + lr.mu.Unlock() + return false + } + lr.detached = true + stop := lr.stop + lr.stop = nil + lr.mu.Unlock() + if stop != nil { + stop() + } + return true +} + // follow replays events from cursor, then blocks for new ones until the run // finishes or ctx is cancelled (the client disconnected). send stops the follow // early by returning an error. func (lr *liveRun) follow(ctx context.Context, cursor int, send func(agent.Event, int) error) error { i := cursor // absolute event index +follow: for { lr.mu.Lock() - // If the cursor points at events already trimmed, fast-forward to the - // oldest retained event rather than reading a negative slice index. + // A stale cursor needs a reset plus the canonical state at the retention + // boundary before trailing events can be applied. Completing the anchor + // advances to base, the next original event index. if i < lr.base { i = lr.base + anchor := lr.replayAnchor() + lr.mu.Unlock() + for index, event := range anchor { + next := i + // Until the final checkpoint frame is delivered, report a cursor + // behind base so a mid-anchor reconnect repeats the whole reset. + if index < len(anchor)-1 { + next = i - 1 + } + if err := send(event, next); err != nil { + return err + } + } + continue } for i-lr.base < len(lr.events) { e := lr.events[i-lr.base] @@ -100,6 +288,12 @@ func (lr *liveRun) follow(ctx context.Context, cursor int, send func(agent.Event return err } lr.mu.Lock() + // Publishing can compact the window while send is in progress. + // Restart at the outer loop so the newer checkpoint is emitted. + if i < lr.base { + lr.mu.Unlock() + continue follow + } } if lr.done { lr.mu.Unlock() @@ -134,6 +328,22 @@ func (h *liveHub) put(session string, lr *liveRun) { h.mu.Unlock() } +// putIfAbsent atomically reserves a session for one live turn or recovery +// watcher. It is the paid-run concurrency gate and must be acquired before an +// approval is published. +func (h *liveHub) putIfAbsent(session string, lr *liveRun) bool { + if session == "" || lr == nil { + return false + } + h.mu.Lock() + defer h.mu.Unlock() + if _, exists := h.runs[session]; exists { + return false + } + h.runs[session] = lr + return true +} + func (h *liveHub) get(session string) *liveRun { h.mu.Lock() defer h.mu.Unlock() diff --git a/internal/server/livechat_test.go b/internal/server/livechat_test.go index 8c41a6c..e1aa011 100644 --- a/internal/server/livechat_test.go +++ b/internal/server/livechat_test.go @@ -2,8 +2,12 @@ package server import ( "context" + "errors" + "fmt" + "strings" "testing" "time" + "unicode/utf8" "github.com/enowdev/antares/internal/agent" ) @@ -62,19 +66,26 @@ func TestLiveRun_CoalescesBacklogAndReportsAbsoluteCursor(t *testing.T) { }); err != nil { t.Fatal(err) } - if len(frames) != 2 { - t.Fatalf("4001 backlog events produced %d frames, want 2", len(frames)) + if len(frames) != 4 { + t.Fatalf("4001 backlog events produced %d frames, want 4", len(frames)) } - if got := len(frames[0].Delta); got != 3999 { + if frames[0].Type != agent.EventReset { + t.Fatalf("first compacted frame=%q, want reset", frames[0].Type) + } + if got := len(frames[1].Delta); got != 1 { + t.Fatalf("checkpoint reasoning length = %d, want 1", got) + } + if got := len(frames[2].Delta); got != 3999 { t.Fatalf("coalesced retained reasoning length = %d, want 3999", got) } - if cursors[0] != 4000 || cursors[1] != 4001 { - t.Fatalf("absolute cursors = %v, want [4000 4001]", cursors) + if cursors[0] != 0 || cursors[1] != 1 || + cursors[2] != 4000 || cursors[3] != 4001 { + t.Fatalf("absolute cursors = %v, want [0 1 4000 4001]", cursors) } // Reattaching at the reported cursor must not replay the reasoning backlog. var replayed []agent.Event - if err := lr.follow(context.Background(), cursors[0], func(e agent.Event, _ int) error { + if err := lr.follow(context.Background(), cursors[2], func(e agent.Event, _ int) error { replayed = append(replayed, e) return nil }); err != nil { @@ -85,6 +96,277 @@ func TestLiveRun_CoalescesBacklogAndReportsAbsoluteCursor(t *testing.T) { } } +func TestLiveRun_CompactionRetainsCanonicalReplayCheckpoint(t *testing.T) { + lr := newCursorLiveRun(liveRunCursorRecovery) + all := []agent.Event{{Type: agent.EventReset}} + lr.publish(all[0]) + + var wantText, wantReasoning strings.Builder + for i := 0; i < maxLiveEvents+137; i++ { + var event agent.Event + if i%2 == 0 { + event = agent.Event{ + Type: agent.EventReasoning, + Delta: fmt.Sprintf("r%04d|", i), + } + wantReasoning.WriteString(event.Delta) + } else { + event = agent.Event{ + Type: agent.EventText, + Delta: fmt.Sprintf("t%04d|", i), + } + wantText.WriteString(event.Delta) + } + all = append(all, event) + lr.publish(event) + } + + // This follower has already rendered all but the final ten original events. + // It must continue from its absolute cursor without receiving a reset. + nearCursor := len(all) - 10 + prefixText, prefixReasoning := renderCanonicalLiveEvents(all[:nearCursor]) + + done := agent.Event{Type: agent.EventDone} + all = append(all, done) + lr.publish(done) + lr.finish() + + for reconnect := 1; reconnect <= 2; reconnect++ { + events, cursors := collectLiveReplay(t, lr, 0) + text, reasoning := renderCanonicalLiveEvents(events) + if text != wantText.String() || reasoning != wantReasoning.String() { + t.Fatalf( + "reconnect %d canonical mismatch: text=%d/%d reasoning=%d/%d", + reconnect, len(text), wantText.Len(), len(reasoning), wantReasoning.Len(), + ) + } + if len(events) == 0 || events[0].Type != agent.EventReset { + t.Fatalf("reconnect %d first event=%v, want reset", reconnect, events) + } + if got := cursors[len(cursors)-1]; got != len(all) { + t.Fatalf("reconnect %d final cursor=%d, want %d", reconnect, got, len(all)) + } + } + + nearEvents, nearCursors := collectLiveReplay(t, lr, nearCursor) + for _, event := range nearEvents { + if event.Type == agent.EventReset { + t.Fatal("near-end follower was unnecessarily reset") + } + } + nearText, nearReasoning := renderCanonicalLiveEventsFrom( + prefixText, prefixReasoning, nearEvents, + ) + if nearText != wantText.String() || nearReasoning != wantReasoning.String() { + t.Fatalf("near-end continuation mismatch: text=%d/%d reasoning=%d/%d", + len(nearText), wantText.Len(), len(nearReasoning), wantReasoning.Len()) + } + if got := nearCursors[len(nearCursors)-1]; got != len(all) { + t.Fatalf("near-end final cursor=%d, want %d", got, len(all)) + } + + lr.mu.Lock() + retained := len(lr.events) + lr.mu.Unlock() + if retained > maxLiveEvents { + t.Fatalf("retained events=%d, max=%d", retained, maxLiveEvents) + } +} + +func TestLiveRun_CompactedCheckpointSurvivesMidAnchorReconnect(t *testing.T) { + lr := newCursorLiveRun(liveRunCursorRecovery) + lr.publish(agent.Event{Type: agent.EventReset}) + lr.publish(agent.Event{Type: agent.EventReasoning, Delta: "complete reasoning"}) + lr.publish(agent.Event{Type: agent.EventText, Delta: "complete text"}) + for range maxLiveEvents { + lr.publish(agent.Event{Type: agent.EventNotice, Message: "progress"}) + } + lr.finish() + + stop := errors.New("disconnect during replay checkpoint") + for disconnectAfter := 1; disconnectAfter <= 2; disconnectAfter++ { + var before []agent.Event + lastCursor := 0 + err := lr.follow(context.Background(), 0, func(event agent.Event, cursor int) error { + before = append(before, event) + lastCursor = cursor + if len(before) == disconnectAfter { + return stop + } + return nil + }) + if !errors.Is(err, stop) { + t.Fatalf("disconnect %d follow error=%v, want sentinel", disconnectAfter, err) + } + + after, _ := collectLiveReplay(t, lr, lastCursor) + text, reasoning := renderCanonicalLiveEvents(append(before, after...)) + if text != "complete text" || reasoning != "complete reasoning" { + t.Fatalf("disconnect %d lost checkpoint: text=%q reasoning=%q cursor=%d", + disconnectAfter, text, reasoning, lastCursor) + } + } +} + +func TestLiveRun_CompactedCheckpointMemoryIsBounded(t *testing.T) { + lr := newLiveRun() + lr.publish(agent.Event{ + Type: agent.EventText, + Delta: strings.Repeat("界", maxLiveReplaySnapshotRunes+17), + }) + for range maxLiveEvents { + lr.publish(agent.Event{Type: agent.EventNotice, Message: "progress"}) + } + + lr.mu.Lock() + text := string(lr.replayText) + textRunes := lr.replayTextRunes + retained := len(lr.events) + lr.mu.Unlock() + if textRunes != maxLiveReplaySnapshotRunes || + utf8.RuneCountInString(text) != maxLiveReplaySnapshotRunes { + t.Fatalf("checkpoint snapshot runes=(%d,%d), want bounded %d", + textRunes, utf8.RuneCountInString(text), maxLiveReplaySnapshotRunes) + } + if retained != maxLiveEvents { + t.Fatalf("retained events=%d, want %d", retained, maxLiveEvents) + } +} + +func TestLiveRun_CompactedCheckpointNoticesTrimmedToolActivity(t *testing.T) { + const secret = "raw-tool-secret-must-not-survive" + lr := newCursorLiveRun(liveRunCursorRecovery) + for _, event := range []agent.Event{ + {Type: agent.EventReset}, + {Type: agent.EventToolCall, Name: "shell", Arguments: `{"token":"` + secret + `"}`}, + { + Type: agent.EventToolProgress, Name: "shell", + Message: "using " + secret, Chunk: secret, + }, + {Type: agent.EventToolResult, Name: "shell", Content: secret}, + {Type: agent.EventReasoning, Delta: "complete reasoning"}, + {Type: agent.EventText, Delta: "complete text"}, + } { + lr.publish(event) + } + for range maxLiveEvents { + lr.publish(agent.Event{Type: agent.EventUsage, InputTokens: 1}) + } + lr.finish() + + for reconnect := 1; reconnect <= 2; reconnect++ { + events, _ := collectLiveReplay(t, lr, 0) + if len(events) < 2 || events[0].Type != agent.EventReset || + events[1].Type != agent.EventNotice { + t.Fatalf("reconnect %d anchor prefix=%+v, want reset then notice", + reconnect, events[:min(2, len(events))]) + } + noticeCount := 0 + for _, event := range events { + switch event.Type { + case agent.EventNotice: + noticeCount++ + lower := strings.ToLower(event.Message) + if !strings.Contains(lower, "tool") || !strings.Contains(lower, "trimmed") { + t.Fatalf("reconnect %d notice is not explanatory: %q", + reconnect, event.Message) + } + if utf8.RuneCountInString(event.Message) > 256 { + t.Fatalf("reconnect %d notice is unbounded: %d runes", + reconnect, utf8.RuneCountInString(event.Message)) + } + case agent.EventToolCall, agent.EventToolProgress, agent.EventToolResult: + t.Fatalf("reconnect %d retained raw live-only tool event: %+v", + reconnect, event) + } + } + if noticeCount != 1 { + t.Fatalf("reconnect %d notices=%d, want one", reconnect, noticeCount) + } + if strings.Contains(fmt.Sprintf("%+v", events), secret) { + t.Fatalf("reconnect %d replay leaked evicted tool content", reconnect) + } + text, reasoning := renderCanonicalLiveEvents(events) + if text != "complete text" || reasoning != "complete reasoning" { + t.Fatalf("reconnect %d canonical text=%q reasoning=%q", + reconnect, text, reasoning) + } + } +} + +func TestLiveRun_CompactedCheckpointWithoutToolActivityHasNoTrimNotice(t *testing.T) { + lr := newLiveRun() + lr.publish(agent.Event{Type: agent.EventReset}) + lr.publish(agent.Event{Type: agent.EventText, Delta: "complete text"}) + for range maxLiveEvents { + lr.publish(agent.Event{Type: agent.EventUsage, InputTokens: 1}) + } + lr.finish() + + events, _ := collectLiveReplay(t, lr, 0) + for _, event := range events { + if event.Type == agent.EventNotice { + t.Fatalf("tool-free checkpoint added notice: %+v", event) + } + } +} + +func TestLiveRun_ShortToolLogKeepsOriginalOrderingWithoutTrimNotice(t *testing.T) { + lr := newLiveRun() + lr.publish(agent.Event{ + Type: agent.EventToolProgress, Name: "shell", Message: "working", + }) + lr.publish(agent.Event{Type: agent.EventDone}) + lr.finish() + + events, _ := collectLiveReplay(t, lr, 0) + if len(events) != 2 || + events[0].Type != agent.EventToolProgress || + events[1].Type != agent.EventDone { + t.Fatalf("short tool replay=%+v, want original tool progress then done", events) + } +} + +func collectLiveReplay(t *testing.T, lr *liveRun, cursor int) ([]agent.Event, []int) { + t.Helper() + var events []agent.Event + var cursors []int + if err := lr.follow(context.Background(), cursor, func(event agent.Event, next int) error { + events = append(events, event) + cursors = append(cursors, next) + return nil + }); err != nil { + t.Fatal(err) + } + return events, cursors +} + +func renderCanonicalLiveEvents(events []agent.Event) (text, reasoning string) { + return renderCanonicalLiveEventsFrom("", "", events) +} + +func renderCanonicalLiveEventsFrom( + text string, + reasoning string, + events []agent.Event, +) (string, string) { + var textBuilder, reasoningBuilder strings.Builder + textBuilder.WriteString(text) + reasoningBuilder.WriteString(reasoning) + for _, event := range events { + switch event.Type { + case agent.EventReset: + textBuilder.Reset() + reasoningBuilder.Reset() + case agent.EventText: + textBuilder.WriteString(event.Delta) + case agent.EventReasoning: + reasoningBuilder.WriteString(event.Delta) + } + } + return textBuilder.String(), reasoningBuilder.String() +} + func TestLiveRun_FollowFromCursor(t *testing.T) { lr := newLiveRun() lr.publish(agent.Event{Type: agent.EventText, Delta: "x"}) diff --git a/internal/server/reasoning.go b/internal/server/reasoning.go new file mode 100644 index 0000000..d6c8630 --- /dev/null +++ b/internal/server/reasoning.go @@ -0,0 +1,70 @@ +package server + +import ( + "context" + "net/http" + + "github.com/enowdev/antares/internal/agent" + "github.com/enowdev/antares/internal/config" +) + +// validateExplicitReasoning rejects a newly submitted override before the +// caller opens a stream or mutates persistent state. Empty means Auto and is +// valid for every model. +func (s *Server) validateExplicitReasoning( + ctx context.Context, + cfg *config.Config, + modelRef string, + effort string, +) error { + if effort == "" { + return nil + } + if modelRef == "" { + modelRef = reasoningModelRef(cfg) + } + return s.agent.ValidateReasoningEffortForConfig(ctx, cfg, modelRef, effort) +} + +// validateChangedReasoning validates only values changed by a mutation. +// Persisted values from older releases remain loadable and are handled as +// legacy values by the agent at runtime. +func (s *Server) validateChangedReasoning( + ctx context.Context, + before *config.Config, + after *config.Config, +) error { + modelRef := reasoningModelRef(after) + if before.Agent.ReasoningEffort != after.Agent.ReasoningEffort { + if err := s.validateExplicitReasoning(ctx, after, modelRef, after.Agent.ReasoningEffort); err != nil { + return err + } + } + if before.Model.ReasoningEffort != after.Model.ReasoningEffort { + if err := s.validateExplicitReasoning(ctx, after, modelRef, after.Model.ReasoningEffort); err != nil { + return err + } + } + return nil +} + +func writeReasoningValidationError(w http.ResponseWriter, err error) { + status := http.StatusBadRequest + if agent.IsReasoningMetadataUnavailable(err) { + status = http.StatusServiceUnavailable + } + writeError(w, status, err) +} + +func reasoningModelRef(cfg *config.Config) string { + if cfg == nil { + return "" + } + if cfg.Model.Provider == "" || cfg.Model.Default == "" { + return cfg.Model.Default + } + // Qualifying with the configured provider preserves aggregator model ids: + // openrouter + anthropic/claude becomes openrouter/anthropic/claude, which + // Agent resolves back to provider=openrouter and model=anthropic/claude. + return cfg.Model.Provider + "/" + cfg.Model.Default +} diff --git a/internal/server/reasoning_test.go b/internal/server/reasoning_test.go new file mode 100644 index 0000000..4189774 --- /dev/null +++ b/internal/server/reasoning_test.go @@ -0,0 +1,839 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + + "github.com/enowdev/antares/internal/agent" + "github.com/enowdev/antares/internal/config" + "github.com/enowdev/antares/internal/llm" + "github.com/enowdev/antares/internal/roles" + "github.com/enowdev/antares/internal/store" + "github.com/enowdev/antares/internal/tools" +) + +func TestHandleModelListAllIncludesReasoningCapability(t *testing.T) { + catalog := newServerReasoningCatalog(t, nil) + s, _, _ := newReasoningBoundaryServer(t, func(cfg *config.Config) { + cfg.Model.Provider = "router" + cfg.Model.Default = "model-a" + cfg.Providers = map[string]config.Provider{ + "router": { + Kind: "openai-compatible", + BaseURL: catalog.URL, + Enabled: true, + }, + } + }) + + req := httptest.NewRequest(http.MethodGet, "/api/model/list-all", nil) + rec := httptest.NewRecorder() + s.handleModelListAll(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + + var body struct { + Models []llm.ModelInfo `json:"models"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if len(body.Models) != 1 || body.Models[0].ID != "model-a" { + t.Fatalf("models = %#v", body.Models) + } + assertServerReasoningCapability(t, body.Models[0]) +} + +func TestHandleProviderModelInfoReadsModelQueryAndIncludesCapability(t *testing.T) { + catalog := newServerReasoningCatalog(t, nil) + s, _, _ := newReasoningBoundaryServer(t, func(cfg *config.Config) { + cfg.Providers = map[string]config.Provider{ + "router": { + Kind: "openai-compatible", + BaseURL: catalog.URL, + Enabled: true, + }, + } + }) + + req := httptest.NewRequest(http.MethodGet, "/api/providers/router/model-info?model=model-a", nil) + req.SetPathValue("id", "router") + rec := httptest.NewRecorder() + s.handleProviderModelInfo(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + + var body struct { + Found bool `json:"found"` + ID string `json:"id"` + Reasoning bool `json:"reasoning"` + ReasoningCapability *llm.ReasoningCapability `json:"reasoning_capability"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if !body.Found || body.ID != "model-a" { + t.Fatalf("response = %#v", body) + } + assertServerReasoningCapability(t, llm.ModelInfo{ + ID: body.ID, + Reasoning: body.Reasoning, + ReasoningCapability: body.ReasoningCapability, + }) +} + +func TestHandleChatRejectsUnsupportedReasoningBeforeChatRequest(t *testing.T) { + var chatRequests atomic.Int32 + catalog := newServerReasoningCatalog(t, &chatRequests) + s, _, _ := newReasoningBoundaryServer(t, func(cfg *config.Config) { + cfg.Model.Provider = "router" + cfg.Model.Default = "model-a" + cfg.Providers = map[string]config.Provider{ + "router": { + Kind: "openai-compatible", + BaseURL: catalog.URL, + Enabled: true, + }, + } + }) + + req := httptest.NewRequest(http.MethodPost, "/api/chat", + strings.NewReader(`{"message":"hello","reasoning_effort":"unsupported"}`)) + rec := httptest.NewRecorder() + s.handleChat(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + if strings.HasPrefix(rec.Header().Get("Content-Type"), "text/event-stream") { + t.Fatalf("unsupported request opened SSE: %q", rec.Header().Get("Content-Type")) + } + if got := chatRequests.Load(); got != 0 { + t.Fatalf("chat requests = %d, want 0", got) + } +} + +func TestHandleUpdateConfigRejectsChangedUnsupportedReasoningWithoutSaving(t *testing.T) { + s, _, configPath := newReasoningBoundaryServer(t, nil) + before := mustReadServerFile(t, configPath) + + req := httptest.NewRequest(http.MethodPost, "/api/config", + strings.NewReader(`{"updates":{"model.reasoning_effort":"unsupported"}}`)) + rec := httptest.NewRecorder() + s.handleUpdateConfig(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + if after := mustReadServerFile(t, configPath); !bytes.Equal(after, before) { + t.Fatal("rejected config update changed the config file") + } + if got := s.config().Model.ReasoningEffort; got != "" { + t.Fatalf("rejected config update mutated in-memory effort to %q", got) + } +} + +func TestHandleUpdateConfigAllowsUnrelatedEditWithLegacyUnsupportedReasoning(t *testing.T) { + s, _, _ := newReasoningBoundaryServer(t, func(cfg *config.Config) { + cfg.Model.ReasoningEffort = "legacy-unsupported" + }) + + req := httptest.NewRequest(http.MethodPost, "/api/config", + strings.NewReader(`{"updates":{"display.theme":"dark"}}`)) + rec := httptest.NewRecorder() + s.handleUpdateConfig(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + saved, err := config.Reload() + if err != nil { + t.Fatal(err) + } + if saved.Model.ReasoningEffort != "legacy-unsupported" { + t.Fatalf("legacy reasoning effort = %q", saved.Model.ReasoningEffort) + } + if saved.Display.Theme != "dark" { + t.Fatalf("theme = %q, want dark", saved.Display.Theme) + } +} + +func TestHandleSaveRawConfigRejectsNewUnsupportedReasoningWithoutSaving(t *testing.T) { + s, _, configPath := newReasoningBoundaryServer(t, nil) + before := mustReadServerFile(t, configPath) + raw := "model:\n provider: openai\n default: gpt-5\n reasoning_effort: unsupported\n" + body, err := json.Marshal(map[string]string{"yaml": raw}) + if err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/config/raw", bytes.NewReader(body)) + rec := httptest.NewRecorder() + s.handleSaveRawConfig(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + if after := mustReadServerFile(t, configPath); !bytes.Equal(after, before) { + t.Fatal("rejected raw config changed the config file") + } +} + +func TestHandleSaveRawConfigRepairsMalformedExistingYAML(t *testing.T) { + s, _, configPath := newReasoningBoundaryServer(t, nil) + if err := os.WriteFile(configPath, []byte("model: [\n"), 0o600); err != nil { + t.Fatal(err) + } + raw := "model:\n provider: openai\n default: gpt-5\n" + body, err := json.Marshal(map[string]string{"yaml": raw}) + if err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/config/raw", bytes.NewReader(body)) + rec := httptest.NewRecorder() + s.handleSaveRawConfig(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + if after := mustReadServerFile(t, configPath); string(after) != raw { + t.Fatalf("saved config = %q, want submitted repair %q", after, raw) + } +} + +func TestHandleSaveRawConfigRejectedSubmissionDoesNotCreateMissingFile(t *testing.T) { + tests := []struct { + name string + raw string + }{ + {name: "malformed YAML", raw: "model: [\n"}, + { + name: "unsupported reasoning", + raw: "model:\n provider: openai\n default: gpt-5\n reasoning_effort: unsupported\n", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s, a, configPath := newReasoningBoundaryServer(t, nil) + if err := os.Remove(configPath); err != nil { + t.Fatal(err) + } + serverBefore := s.config() + agentBefore := a.Config() + body, err := json.Marshal(map[string]string{"yaml": tt.raw}) + if err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/config/raw", bytes.NewReader(body)) + rec := httptest.NewRecorder() + s.handleSaveRawConfig(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + if _, err := os.Stat(configPath); !os.IsNotExist(err) { + t.Fatalf("rejected raw save created %s: %v", configPath, err) + } + if s.config() != serverBefore { + t.Fatal("rejected raw save replaced the live server config") + } + if a.Config() != agentBefore { + t.Fatal("rejected raw save replaced the live agent config") + } + }) + } +} + +func TestHandleSaveRawConfigValidatesNewProviderAgainstCandidateConfig(t *testing.T) { + var oldFetches, candidateFetches atomic.Int32 + oldCatalog := newServerCatalogFixture(t, http.StatusOK, `{ + "data": [{ + "id": "model-old", + "reasoning": {"supported_efforts": ["low"], "default_effort": "low"} + }] + }`, &oldFetches) + candidateCatalog := newServerCatalogFixture(t, http.StatusOK, `{ + "data": [{ + "id": "model-a", + "reasoning": {"supported_efforts": ["MiXeD"], "default_effort": "MiXeD"} + }] + }`, &candidateFetches) + s, a, configPath := newReasoningBoundaryServer(t, func(cfg *config.Config) { + cfg.Model.Provider = "old-router" + cfg.Model.Default = "model-old" + cfg.Providers = map[string]config.Provider{ + "old-router": { + Kind: "openai-compatible", + BaseURL: oldCatalog.URL, + Enabled: true, + }, + } + }) + raw := "model:\n" + + " provider: candidate-router\n" + + " default: model-a\n" + + " reasoning_effort: MiXeD\n" + + "providers:\n" + + " candidate-router:\n" + + " kind: openai-compatible\n" + + " base_url: " + candidateCatalog.URL + "\n" + + " enabled: true\n" + body, err := json.Marshal(map[string]string{"yaml": raw}) + if err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/config/raw", bytes.NewReader(body)) + rec := httptest.NewRecorder() + s.handleSaveRawConfig(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + if got := oldFetches.Load(); got != 0 { + t.Fatalf("old live provider catalogue fetches = %d, want 0", got) + } + if got := candidateFetches.Load(); got != 1 { + t.Fatalf("candidate provider catalogue fetches = %d, want 1", got) + } + if after := mustReadServerFile(t, configPath); string(after) != raw { + t.Fatalf("saved config = %q, want candidate text %q", after, raw) + } + if got := s.config(); got.Model.Provider != "candidate-router" || + got.Model.Default != "model-a" || got.Model.ReasoningEffort != "MiXeD" { + t.Fatalf("live server config = %+v", got.Model) + } + if got := a.Config(); got.Model.Provider != "candidate-router" || + got.Model.Default != "model-a" || got.Model.ReasoningEffort != "MiXeD" { + t.Fatalf("live agent config = %+v", got.Model) + } +} + +func TestHandleSaveRawConfigRejectsInvalidEffortAgainstCandidateWithoutMutation(t *testing.T) { + var oldFetches, candidateFetches atomic.Int32 + oldCatalog := newServerCatalogFixture(t, http.StatusOK, `{ + "data": [{ + "id": "model-old", + "reasoning": {"supported_efforts": ["low"], "default_effort": "low"} + }] + }`, &oldFetches) + candidateCatalog := newServerCatalogFixture(t, http.StatusOK, `{ + "data": [{ + "id": "model-a", + "reasoning": {"supported_efforts": ["MiXeD"], "default_effort": "MiXeD"} + }] + }`, &candidateFetches) + s, a, configPath := newReasoningBoundaryServer(t, func(cfg *config.Config) { + cfg.Model.Provider = "old-router" + cfg.Model.Default = "model-old" + cfg.Providers = map[string]config.Provider{ + "old-router": { + Kind: "openai-compatible", + BaseURL: oldCatalog.URL, + Enabled: true, + }, + } + }) + fileBefore := mustReadServerFile(t, configPath) + serverBefore := s.config() + agentBefore := a.Config() + raw := "model:\n" + + " provider: candidate-router\n" + + " default: model-a\n" + + " reasoning_effort: unsupported\n" + + "providers:\n" + + " candidate-router:\n" + + " kind: openai-compatible\n" + + " base_url: " + candidateCatalog.URL + "\n" + + " enabled: true\n" + body, err := json.Marshal(map[string]string{"yaml": raw}) + if err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/config/raw", bytes.NewReader(body)) + rec := httptest.NewRecorder() + s.handleSaveRawConfig(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + if got := oldFetches.Load(); got != 0 { + t.Fatalf("old live provider catalogue fetches = %d, want 0", got) + } + if got := candidateFetches.Load(); got != 1 { + t.Fatalf("candidate provider catalogue fetches = %d, want 1", got) + } + if after := mustReadServerFile(t, configPath); !bytes.Equal(after, fileBefore) { + t.Fatal("rejected candidate config changed the config file") + } + if s.config() != serverBefore { + t.Fatal("rejected candidate config replaced the live server config") + } + if a.Config() != agentBefore { + t.Fatal("rejected candidate config replaced the live agent config") + } +} + +func TestHandleSaveRawConfigAppliesCandidateProviderEnvWithoutPersistingSecrets(t *testing.T) { + const secret = "round2-candidate-secret" + tests := []struct { + name string + declaredEnv string + providerEnv string + wantCredential string + }{ + { + name: "api_key_env", + declaredEnv: secret, + wantCredential: secret, + }, + { + name: "provider-specific API key", + declaredEnv: "wrong-fallback-secret", + providerEnv: secret, + wantCredential: secret, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var accepted, rejected, wrongEndpoint atomic.Int32 + catalog := newAuthenticatedServerCatalogFixture( + t, secret, &accepted, &rejected, + ) + wrongCatalog := newServerCatalogFixture( + t, + http.StatusServiceUnavailable, + `{"error":{"message":"wrong raw endpoint"}}`, + &wrongEndpoint, + ) + s, a, configPath := newReasoningBoundaryServer(t, nil) + t.Setenv("ROUND2_CANDIDATE_API_KEY", tt.declaredEnv) + t.Setenv("ANTARES_PROVIDER_CANDIDATE_ROUTER_API_KEY", tt.providerEnv) + t.Setenv("ANTARES_PROVIDER_CANDIDATE_ROUTER_BASE_URL", catalog.URL) + raw := "model:\n" + + " provider: candidate-router\n" + + " default: model-a\n" + + " reasoning_effort: MiXeD\n" + + "providers:\n" + + " candidate-router:\n" + + " kind: openai-compatible\n" + + " base_url: " + wrongCatalog.URL + "\n" + + " api_key_env: ROUND2_CANDIDATE_API_KEY\n" + + " enabled: true\n" + body, err := json.Marshal(map[string]string{"yaml": raw}) + if err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/config/raw", bytes.NewReader(body)) + rec := httptest.NewRecorder() + s.handleSaveRawConfig(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + if got := accepted.Load(); got != 1 { + t.Fatalf("authenticated catalogue requests = %d, want 1", got) + } + if got := rejected.Load(); got != 0 { + t.Fatalf("rejected catalogue requests = %d, want 0", got) + } + if got := wrongEndpoint.Load(); got != 0 { + t.Fatalf("raw endpoint requests = %d, want 0", got) + } + saved := mustReadServerFile(t, configPath) + if string(saved) != raw { + t.Fatalf("saved config = %q, want submitted text %q", saved, raw) + } + if bytes.Contains(saved, []byte(secret)) || + bytes.Contains(saved, []byte(catalog.URL)) { + t.Fatal("saved config contains environment-derived provider data") + } + if provider := s.config().Providers["candidate-router"]; provider.APIKey != tt.wantCredential || + provider.BaseURL != catalog.URL { + t.Fatalf("live server provider = %+v", provider) + } + if provider := a.Config().Providers["candidate-router"]; provider.APIKey != tt.wantCredential || + provider.BaseURL != catalog.URL { + t.Fatalf("live agent provider = %+v", provider) + } + }) + } +} + +func TestHandleSaveRawConfigRejectsMissingOrWrongCandidateProviderEnvWithoutMutation(t *testing.T) { + const secret = "round2-candidate-secret" + tests := []struct { + name string + declaredEnv string + }{ + {name: "missing credential"}, + {name: "wrong credential", declaredEnv: "wrong-secret"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var accepted, rejected, wrongEndpoint atomic.Int32 + catalog := newAuthenticatedServerCatalogFixture( + t, secret, &accepted, &rejected, + ) + wrongCatalog := newServerCatalogFixture( + t, + http.StatusServiceUnavailable, + `{"error":{"message":"wrong raw endpoint"}}`, + &wrongEndpoint, + ) + s, a, configPath := newReasoningBoundaryServer(t, nil) + t.Setenv("ROUND2_CANDIDATE_API_KEY", tt.declaredEnv) + t.Setenv("ANTARES_PROVIDER_CANDIDATE_ROUTER_API_KEY", "") + t.Setenv("ANTARES_PROVIDER_CANDIDATE_ROUTER_BASE_URL", catalog.URL) + fileBefore := mustReadServerFile(t, configPath) + serverBefore := s.config() + agentBefore := a.Config() + raw := "model:\n" + + " provider: candidate-router\n" + + " default: model-a\n" + + " reasoning_effort: MiXeD\n" + + "providers:\n" + + " candidate-router:\n" + + " kind: openai-compatible\n" + + " base_url: " + wrongCatalog.URL + "\n" + + " api_key_env: ROUND2_CANDIDATE_API_KEY\n" + + " enabled: true\n" + body, err := json.Marshal(map[string]string{"yaml": raw}) + if err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/config/raw", bytes.NewReader(body)) + rec := httptest.NewRecorder() + s.handleSaveRawConfig(rec, req) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + if got := accepted.Load(); got != 0 { + t.Fatalf("authenticated catalogue requests = %d, want 0", got) + } + if got := rejected.Load(); got != 1 { + t.Fatalf("rejected catalogue requests = %d, want 1", got) + } + if got := wrongEndpoint.Load(); got != 0 { + t.Fatalf("raw endpoint requests = %d, want 0", got) + } + if after := mustReadServerFile(t, configPath); !bytes.Equal(after, fileBefore) { + t.Fatal("rejected environment candidate changed the config file") + } + if s.config() != serverBefore { + t.Fatal("rejected environment candidate replaced the live server config") + } + if a.Config() != agentBefore { + t.Fatal("rejected environment candidate replaced the live agent config") + } + }) + } +} + +func TestHandleSaveRawConfigDistinguishesUnavailableMetadataFromAutoOnlyModel(t *testing.T) { + tests := []struct { + name string + catalogStatus int + catalogBody string + wantStatus int + wantError string + forbiddenError string + }{ + { + name: "known Auto-only model", + catalogStatus: http.StatusOK, + catalogBody: `{"data":[{"id":"model-a"}]}`, + wantStatus: http.StatusBadRequest, + wantError: "unsupported reasoning override", + }, + { + name: "metadata unavailable", + catalogStatus: http.StatusServiceUnavailable, + catalogBody: `{"error":{"message":"SECRET-UPSTREAM-DIAGNOSTIC"}}`, + wantStatus: http.StatusServiceUnavailable, + wantError: "use Auto or retry", + forbiddenError: "SECRET-UPSTREAM-DIAGNOSTIC", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var fetches atomic.Int32 + catalog := newServerCatalogFixture( + t, tt.catalogStatus, tt.catalogBody, &fetches, + ) + s, a, configPath := newReasoningBoundaryServer(t, nil) + fileBefore := mustReadServerFile(t, configPath) + serverBefore := s.config() + agentBefore := a.Config() + raw := "model:\n" + + " provider: candidate-router\n" + + " default: model-a\n" + + " reasoning_effort: high\n" + + "providers:\n" + + " candidate-router:\n" + + " kind: openai-compatible\n" + + " base_url: " + catalog.URL + "\n" + + " enabled: true\n" + body, err := json.Marshal(map[string]string{"yaml": raw}) + if err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/config/raw", bytes.NewReader(body)) + rec := httptest.NewRecorder() + s.handleSaveRawConfig(rec, req) + if rec.Code != tt.wantStatus { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), tt.wantError) { + t.Fatalf("body = %s, want %q", rec.Body.String(), tt.wantError) + } + if tt.forbiddenError != "" && strings.Contains(rec.Body.String(), tt.forbiddenError) { + t.Fatalf("response leaked upstream diagnostic: %s", rec.Body.String()) + } + if got := fetches.Load(); got != 1 { + t.Fatalf("catalogue fetches = %d, want 1", got) + } + if after := mustReadServerFile(t, configPath); !bytes.Equal(after, fileBefore) { + t.Fatal("rejected config changed the config file") + } + if s.config() != serverBefore { + t.Fatal("rejected config replaced the live server config") + } + if a.Config() != agentBefore { + t.Fatal("rejected config replaced the live agent config") + } + }) + } +} + +func TestHandleSaveRoleRejectsExplicitUnsupportedReasoning(t *testing.T) { + var roleDir string + s, a, _ := newReasoningBoundaryServer(t, func(cfg *config.Config) { + roleDir = filepath.Join(config.Home(), "roles") + cfg.Roles.Dirs = []string{roleDir} + }) + reg := roles.NewRegistry([]string{roleDir}) + if _, err := reg.Save(roles.Role{ + Name: "custom-reviewer", Title: "Custom Reviewer", Model: "openai/gpt-5", + Effort: "low", Prompt: "before", + }); err != nil { + t.Fatal(err) + } + a.SetRoles(reg) + rolePath := filepath.Join(roleDir, "custom-reviewer.md") + before := mustReadServerFile(t, rolePath) + + req := httptest.NewRequest(http.MethodPost, "/api/roles", strings.NewReader( + `{"name":"custom-reviewer","title":"Custom Reviewer","model":"openai/gpt-5",`+ + `"effort":"unsupported","body":"after"}`, + )) + rec := httptest.NewRecorder() + s.handleSaveRole(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } + if after := mustReadServerFile(t, rolePath); !bytes.Equal(after, before) { + t.Fatal("rejected role save changed the role file") + } + if role, ok := reg.Get("custom-reviewer"); !ok || role.Effort != "low" { + t.Fatalf("rejected role save mutated registry: %#v, found = %v", role, ok) + } +} + +func newReasoningBoundaryServer( + t *testing.T, + seed func(*config.Config), +) (*Server, *agent.Agent, string) { + t.Helper() + t.Setenv("ANTARES_HOME", t.TempDir()) + t.Setenv("ANTARES_CONFIG", "") + t.Setenv("ANTARES_PROFILE", "default") + t.Setenv("ANTARES_MODEL", "") + t.Setenv("ANTARES_PROVIDER", "") + t.Setenv("ANTARES_BASE_URL", "") + t.Setenv("ANTARES_API_KEY", "") + for _, key := range []string{ + "OPENROUTER_API_KEY", + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "GEMINI_API_KEY", + "CURSOR_API_KEY", + } { + t.Setenv(key, "") + } + + cfg := config.Default() + cfg.Server.DashboardPasswordHash = "test-hash" + cfg.Model.Provider = "openai" + cfg.Model.Default = "gpt-5" + cfg.Providers = map[string]config.Provider{ + "openai": { + Kind: "openai", + BaseURL: "https://api.openai.com/v1", + Enabled: true, + }, + } + if seed != nil { + seed(cfg) + } + configPath := config.ConfigFile() + if err := config.SaveAt(configPath, cfg); err != nil { + t.Fatal(err) + } + reloaded, err := config.Reload() + if err != nil { + t.Fatal(err) + } + // Reload overlays YAML onto fresh defaults, including default provider map + // entries absent from the test fixture. Keep only the explicitly seeded + // providers so list-all can never probe real or developer-local endpoints. + reloaded.Providers = make(map[string]config.Provider, len(cfg.Providers)) + for id, provider := range cfg.Providers { + reloaded.Providers[id] = provider + } + db, err := store.Open(context.Background(), "memory", "", 1, 5000, false) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + a := agent.New(reloaded, db, tools.NewRegistry(), nil, nil) + s := New(Options{ + Config: reloaded, + Agent: a, + Store: db, + Reload: func() error { + next, err := config.Reload() + if err == nil { + a.SetConfig(next) + } + return err + }, + }) + return s, a, configPath +} + +func newServerReasoningCatalog(t *testing.T, chatRequests *atomic.Int32) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/models"): + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "data": [{ + "id": "model-a", + "name": "Model A", + "context_length": 128000, + "reasoning": { + "supported_efforts": ["low"], + "default_effort": "low" + } + }] + }`)) + case r.Method == http.MethodPost: + if chatRequests != nil { + chatRequests.Add(1) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"unexpected"}}]}`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + return srv +} + +func newServerCatalogFixture( + t *testing.T, + status int, + response string, + fetches *atomic.Int32, +) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/models") { + http.NotFound(w, r) + return + } + if fetches != nil { + fetches.Add(1) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write([]byte(response)) + })) + t.Cleanup(srv.Close) + return srv +} + +func newAuthenticatedServerCatalogFixture( + t *testing.T, + apiKey string, + accepted *atomic.Int32, + rejected *atomic.Int32, +) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/models") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + if r.Header.Get("Authorization") != "Bearer "+apiKey { + if rejected != nil { + rejected.Add(1) + } + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":{"message":"invalid synthetic credential"}}`)) + return + } + if accepted != nil { + accepted.Add(1) + } + _, _ = w.Write([]byte(`{ + "data": [{ + "id": "model-a", + "reasoning": { + "supported_efforts": ["MiXeD"], + "default_effort": "MiXeD" + } + }] + }`)) + })) + t.Cleanup(srv.Close) + return srv +} + +func assertServerReasoningCapability(t *testing.T, model llm.ModelInfo) { + t.Helper() + if !model.Reasoning { + t.Fatal("legacy reasoning flag = false") + } + capability := model.ReasoningCapability + if capability == nil || capability.Source != llm.ReasoningCapabilityLive { + t.Fatalf("reasoning capability = %#v", capability) + } + if capability.Default != "low" || len(capability.Values) != 1 || + capability.Values[0].Value != "low" { + t.Fatalf("reasoning capability = %#v", capability) + } +} + +func mustReadServerFile(t *testing.T, path string) []byte { + t.Helper() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return raw +} diff --git a/internal/server/routes.go b/internal/server/routes.go index 9b728dc..ef3c70d 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -21,6 +21,8 @@ func (s *Server) routes() { // Chat m.HandleFunc("POST /api/chat", s.handleChat) + m.HandleFunc("POST /api/chat/cursor", s.handleCursorChat) + m.HandleFunc("POST /api/chat/cursor/cancel", s.handleCursorCancel) m.HandleFunc("POST /api/upload", s.handleUpload) m.HandleFunc("GET /api/chat/attach", s.handleChatAttach) m.HandleFunc("POST /api/chat/interrupt", s.handleInterrupt) @@ -135,6 +137,7 @@ func (s *Server) routes() { m.HandleFunc("GET /api/project/env", s.handleProjectEnv) m.HandleFunc("POST /api/project/env", s.handleSaveProjectEnv) m.HandleFunc("GET /api/project/plan", s.handleProjectPlan) + m.HandleFunc("GET /api/project/cursor-repository", s.handleCursorRepository) // Project sidebar: git status, runnable scripts, file tree. m.HandleFunc("POST /api/project/index-rag", s.handleIndexProject) m.HandleFunc("GET /api/project/git", s.handleProjectGit) diff --git a/internal/server/server.go b/internal/server/server.go index 2d10f68..4107efb 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -23,6 +23,7 @@ import ( "github.com/enowdev/antares/internal/config" "github.com/enowdev/antares/internal/cron" "github.com/enowdev/antares/internal/cursor" + "github.com/enowdev/antares/internal/cursorrun" "github.com/enowdev/antares/internal/gateway" "github.com/enowdev/antares/internal/mcp" "github.com/enowdev/antares/internal/skills" @@ -65,6 +66,10 @@ type Server struct { // newCursorMetadataClient. cursorFactory cursorClientFactory + // cursorRunner owns the shared Cursor model catalogue cache and remote-run + // lifecycle. It resolves the current provider config before each operation. + cursorRunner cursorrun.Runner + // providerResolver overrides provider hostname resolution in handler tests. // Production uses net.DefaultResolver. providerResolver providerIPResolver @@ -81,6 +86,15 @@ type Server struct { setupMu sync.Mutex // passwordMu serializes first-password creation and password replacement. passwordMu sync.Mutex + + // cursorCancels serializes explicit cancellation approval and the durable + // pre-POST marker. After that marker commits, durable state prevents repeats. + cursorCancelMu sync.Mutex + cursorCancels map[string]string + + // cursorLifecycles serializes lifecycle decisions per session. Bulk + // operations acquire sorted session keys; unrelated sessions stay independent. + cursorLifecycles sessionLocker } // Options configures a Server. @@ -100,27 +114,50 @@ type Options struct { Gateway *gateway.Manager MCP *mcp.Manager Social *socialbrowser.Manager + // Cursor is the runtime-scoped catalogue and remote-run service shared with + // the Agent. When nil, New constructs a compatibility fallback. + Cursor cursorrun.Runner } // New builds the HTTP server and registers every route. func New(o Options) *Server { s := &Server{ - cfg: o.Config, - agent: o.Agent, - db: o.Store, - skills: o.Skills, - cron: o.Cron, - gateway: o.Gateway, - mcp: o.MCP, - social: o.Social, - mux: http.NewServeMux(), - hub: newLiveHub(), - wake: newWakeQueue(), - started: time.Now(), - distFS: o.Dist, - reloadFn: o.Reload, - - dashSessions: map[string]time.Time{}, + cfg: o.Config, + agent: o.Agent, + db: o.Store, + skills: o.Skills, + cron: o.Cron, + gateway: o.Gateway, + mcp: o.MCP, + social: o.Social, + mux: http.NewServeMux(), + hub: newLiveHub(), + wake: newWakeQueue(), + started: time.Now(), + distFS: o.Dist, + reloadFn: o.Reload, + cursorRunner: o.Cursor, + + dashSessions: map[string]time.Time{}, + cursorCancels: map[string]string{}, + } + if s.cursorRunner == nil { + s.cursorRunner = cursorrun.New(cursorrun.Options{ + ResolveClient: func() (cursor.Options, error) { + _, provider := s.config().ResolveProvider("cursor") + provider.APIKey = strings.TrimSpace(provider.APIKey) + options := cursor.Options{ + BaseURL: provider.BaseURL, + APIKey: provider.APIKey, + } + if !provider.Enabled || provider.APIKey == "" { + return options, cursorrun.ErrNotConfigured + } + return options, nil + }, + Now: time.Now, + CatalogTTL: 5 * time.Minute, + }) } // Restore dashboard logins so a daemon restart does not break EventSource // reattach (/api/chat/attach) for browsers that still hold a valid cookie. @@ -153,6 +190,9 @@ func (s *Server) SetConfig(cfg *config.Config) { s.mu.Lock() s.cfg = cfg s.mu.Unlock() + if s.cursorRunner != nil { + s.cursorRunner.InvalidateCatalog() + } } func (s *Server) config() *config.Config { diff --git a/internal/store/cursor_sessions.go b/internal/store/cursor_sessions.go new file mode 100644 index 0000000..071232d --- /dev/null +++ b/internal/store/cursor_sessions.go @@ -0,0 +1,514 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "io" + "strings" + "time" +) + +const cursorSessionCols = `session_id,target_active,reuse_valid,model_id,model_params,repository_url,starting_ref,mode,auto_create_pr,agent_id,run_id,remote_status,last_event_id,partial_text,partial_reasoning,git_state,operation_state,user_message_id,assistant_message_id,revision,updated_at` + +const cursorSessionUpdateAssignments = `target_active=?, + reuse_valid=?, + model_id=?, + model_params=?, + repository_url=?, + starting_ref=?, + mode=?, + auto_create_pr=?, + agent_id=?, + run_id=?, + remote_status=?, + last_event_id=?, + partial_text=?, + partial_reasoning=?, + git_state=?, + operation_state=?, + user_message_id=?, + assistant_message_id=?, + revision=?, + updated_at=?` + +func validCursorOperationState(operation string) bool { + switch operation { + case CursorOperationIdle, + CursorOperationAwaitingApproval, + CursorOperationCreateInFlight, + CursorOperationRunInFlight, + CursorOperationTerminal, + CursorOperationCommitted, + CursorOperationAmbiguous: + return true + default: + return false + } +} + +func canonicalCursorModelParams(raw string) (string, error) { + if strings.TrimSpace(raw) == "" { + return "[]", nil + } + decoder := json.NewDecoder(strings.NewReader(raw)) + decoder.UseNumber() + var value any + if err := decoder.Decode(&value); err != nil { + return "", fmt.Errorf("cursor model params: %w", err) + } + if _, ok := value.([]any); !ok { + return "", errors.New("cursor model params must be a JSON array") + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + if err == nil { + return "", errors.New("cursor model params contain multiple JSON values") + } + return "", fmt.Errorf("cursor model params: %w", err) + } + canonical, err := json.Marshal(value) + if err != nil { + return "", fmt.Errorf("cursor model params: %w", err) + } + return string(canonical), nil +} + +func prepareCursorSessionState(state *CursorSessionState) error { + if state == nil { + return errors.New("cursor session state is required") + } + if strings.TrimSpace(state.SessionID) == "" { + return errors.New("cursor session id is required") + } + if state.Revision < 0 { + return errors.New("cursor session revision cannot be negative") + } + if !validCursorOperationState(state.OperationState) { + return fmt.Errorf("invalid cursor operation state %q", state.OperationState) + } + params, err := canonicalCursorModelParams(state.ModelParams) + if err != nil { + return err + } + state.ModelParams = params + if state.Revision <= 0 { + state.Revision = 1 + } + state.UpdatedAt = fromMS(ms(time.Now())) + return nil +} + +func cursorSessionArgs(state *CursorSessionState) []any { + return []any{ + state.SessionID, + state.TargetActive, + state.ReuseValid, + state.ModelID, + state.ModelParams, + state.RepositoryURL, + state.StartingRef, + state.Mode, + state.AutoCreatePR, + state.AgentID, + state.RunID, + state.RemoteStatus, + state.LastEventID, + state.PartialText, + state.PartialReasoning, + state.GitState, + state.OperationState, + state.UserMessageID, + state.AssistantMessageID, + state.Revision, + ms(state.UpdatedAt), + } +} + +func cursorSessionUpdateArgs(state *CursorSessionState) []any { + return cursorSessionArgs(state)[1:] +} + +// PutCursorSessionState creates a snapshot at revision one or replaces the +// currently owned revision and advances it atomically. A zero-revision state is +// insert-only; it can never overwrite an existing row. +func (s *sqlStore) PutCursorSessionState(ctx context.Context, state *CursorSessionState) error { + if state == nil { + return errors.New("cursor session state is required") + } + next := *state + expectedRevision := next.Revision + if err := prepareCursorSessionState(&next); err != nil { + return err + } + args := append(cursorSessionArgs(&next), expectedRevision) + var ( + revision int64 + updatedAt int64 + ) + err := s.row(ctx, `INSERT INTO cursor_session_states (`+cursorSessionCols+`) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(session_id) DO UPDATE SET + target_active=EXCLUDED.target_active, + reuse_valid=EXCLUDED.reuse_valid, + model_id=EXCLUDED.model_id, + model_params=EXCLUDED.model_params, + repository_url=EXCLUDED.repository_url, + starting_ref=EXCLUDED.starting_ref, + mode=EXCLUDED.mode, + auto_create_pr=EXCLUDED.auto_create_pr, + agent_id=EXCLUDED.agent_id, + run_id=EXCLUDED.run_id, + remote_status=EXCLUDED.remote_status, + last_event_id=EXCLUDED.last_event_id, + partial_text=EXCLUDED.partial_text, + partial_reasoning=EXCLUDED.partial_reasoning, + git_state=EXCLUDED.git_state, + operation_state=EXCLUDED.operation_state, + user_message_id=EXCLUDED.user_message_id, + assistant_message_id=EXCLUDED.assistant_message_id, + revision=cursor_session_states.revision+1, + updated_at=EXCLUDED.updated_at + WHERE cursor_session_states.revision=EXCLUDED.revision AND ? > 0 + RETURNING revision,updated_at`, + args..., + ).Scan(&revision, &updatedAt) + if errors.Is(err, sql.ErrNoRows) { + return ErrCursorRevisionConflict + } + if err != nil { + return err + } + next.Revision = revision + next.UpdatedAt = fromMS(updatedAt) + *state = next + return nil +} + +func scanCursorSessionState(scanner interface{ Scan(...any) error }) (*CursorSessionState, error) { + var state CursorSessionState + var updatedAt int64 + err := scanner.Scan( + &state.SessionID, + &state.TargetActive, + &state.ReuseValid, + &state.ModelID, + &state.ModelParams, + &state.RepositoryURL, + &state.StartingRef, + &state.Mode, + &state.AutoCreatePR, + &state.AgentID, + &state.RunID, + &state.RemoteStatus, + &state.LastEventID, + &state.PartialText, + &state.PartialReasoning, + &state.GitState, + &state.OperationState, + &state.UserMessageID, + &state.AssistantMessageID, + &state.Revision, + &updatedAt, + ) + if err != nil { + return nil, err + } + state.UpdatedAt = fromMS(updatedAt) + return &state, nil +} + +func (s *sqlStore) GetCursorSessionState(ctx context.Context, sessionID string) (*CursorSessionState, error) { + state, err := scanCursorSessionState(s.row(ctx, + `SELECT `+cursorSessionCols+` FROM cursor_session_states WHERE session_id=?`, + sessionID, + )) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return state, err +} + +// ListRecoverableCursorSessionStates returns interrupted operations in stable +// order. Idle targets need no recovery, and committed work is already durable. +func (s *sqlStore) ListRecoverableCursorSessionStates(ctx context.Context) ([]CursorSessionState, error) { + rows, err := s.query(ctx, `SELECT `+cursorSessionCols+` FROM cursor_session_states + WHERE operation_state NOT IN (?,?) + ORDER BY updated_at ASC, session_id ASC`, + CursorOperationIdle, + CursorOperationCommitted, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + states := []CursorSessionState{} + for rows.Next() { + state, err := scanCursorSessionState(rows) + if err != nil { + return nil, err + } + states = append(states, *state) + } + return states, rows.Err() +} + +// CompareAndSwapCursorSessionState advances the revision only when the caller +// still owns the expected snapshot. +func (s *sqlStore) CompareAndSwapCursorSessionState( + ctx context.Context, + state *CursorSessionState, + expectedRevision int64, +) (bool, error) { + if expectedRevision < 1 { + return false, errors.New("cursor expected revision must be positive") + } + if state == nil { + return false, errors.New("cursor session state is required") + } + next := *state + if err := prepareCursorSessionState(&next); err != nil { + return false, err + } + nextRevision := expectedRevision + 1 + next.Revision = nextRevision + args := append(cursorSessionUpdateArgs(&next), next.SessionID, expectedRevision) + result, err := s.exec(ctx, `UPDATE cursor_session_states SET `+cursorSessionUpdateAssignments+` + WHERE session_id=? AND revision=?`, args...) + if err != nil { + return false, err + } + changed, err := result.RowsAffected() + if err != nil { + return false, err + } + if changed == 0 { + return false, nil + } + *state = next + return true, nil +} + +// InvalidateCursorReuse prevents another turn from reusing the remote agent +// without discarding the IDs needed to observe or recover its current run. +func (s *sqlStore) InvalidateCursorReuse(ctx context.Context, sessionID string) error { + if strings.TrimSpace(sessionID) == "" { + return errors.New("cursor session id is required") + } + result, err := s.exec(ctx, `UPDATE cursor_session_states + SET reuse_valid=FALSE, revision=revision+1, updated_at=? + WHERE session_id=?`, + ms(time.Now()), + sessionID, + ) + if err != nil { + return err + } + changed, err := result.RowsAffected() + if err != nil { + return err + } + if changed == 0 { + return ErrNotFound + } + return nil +} + +func prepareCursorAssistantMessage(state *CursorSessionState, message *Message) (*Message, error) { + if message == nil { + return nil, errors.New("cursor assistant message is required") + } + if strings.TrimSpace(state.AssistantMessageID) == "" { + return nil, errors.New("cursor assistant message id is required") + } + if strings.TrimSpace(state.RunID) == "" { + return nil, errors.New("cursor run id is required") + } + + prepared := *message + switch { + case prepared.ID == "": + prepared.ID = state.AssistantMessageID + case prepared.ID != state.AssistantMessageID: + return nil, errors.New("cursor assistant message id does not match session state") + } + switch { + case prepared.SessionID == "": + prepared.SessionID = state.SessionID + case prepared.SessionID != state.SessionID: + return nil, errors.New("cursor assistant session id does not match session state") + } + switch { + case prepared.Role == "": + prepared.Role = RoleAssistant + case prepared.Role != RoleAssistant: + return nil, errors.New("cursor final message must have the assistant role") + } + if attachments := strings.TrimSpace(prepared.Attachments); attachments != "" && attachments != "[]" { + return nil, errors.New("cursor final assistant message cannot persist attachments") + } + prepared.Attachments = "" + if prepared.CreatedAt.IsZero() { + prepared.CreatedAt = fromMS(ms(time.Now())) + } + prepared.Meta = cloneMeta(prepared.Meta) + prepared.Meta["cursor_agent_id"] = state.AgentID + prepared.Meta["cursor_run_id"] = state.RunID + return &prepared, nil +} + +func cloneMeta(meta Meta) Meta { + cloned := make(Meta, len(meta)+2) + for key, value := range meta { + cloned[key] = value + } + return cloned +} + +// CommitCursorAssistant atomically appends the deterministic final message, +// rolls up session counters, and marks the matching remote run committed. +func (s *sqlStore) CommitCursorAssistant( + ctx context.Context, + state *CursorSessionState, + message *Message, +) error { + if state == nil { + return errors.New("cursor session state is required") + } + if state.OperationState != CursorOperationTerminal && + state.OperationState != CursorOperationCommitted { + return fmt.Errorf("cursor assistant commit requires terminal state, got %q", state.OperationState) + } + + next := *state + if err := prepareCursorSessionState(&next); err != nil { + return err + } + preparedMessage, err := prepareCursorAssistantMessage(&next, message) + if err != nil { + return err + } + expectedRevision := next.Revision + next.OperationState = CursorOperationCommitted + next.Revision = expectedRevision + 1 + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + updateArgs := append(cursorSessionUpdateArgs(&next), + next.SessionID, + expectedRevision, + next.RunID, + next.AssistantMessageID, + CursorOperationTerminal, + ) + result, err := tx.ExecContext(ctx, s.rebind( + `UPDATE cursor_session_states SET `+cursorSessionUpdateAssignments+` + WHERE session_id=? AND revision=? AND run_id=? AND assistant_message_id=? AND operation_state=?`, + ), updateArgs...) + if err != nil { + return err + } + changed, err := result.RowsAffected() + if err != nil { + return err + } + if changed == 0 { + current, err := scanCursorSessionState(tx.QueryRowContext(ctx, s.rebind( + `SELECT `+cursorSessionCols+` FROM cursor_session_states WHERE session_id=?`, + ), next.SessionID)) + if errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return err + } + if current.OperationState != CursorOperationCommitted || + current.RunID != next.RunID || + current.AssistantMessageID != next.AssistantMessageID { + return errors.New("cursor session state changed before assistant commit") + } + currentMessage, err := scanMessage(tx.QueryRowContext(ctx, s.rebind( + `SELECT `+messageCols+` FROM messages WHERE id=?`, + ), next.AssistantMessageID)) + if errors.Is(err, sql.ErrNoRows) { + return errors.New("committed cursor assistant message is missing") + } + if err != nil { + return err + } + if currentMessage.SessionID != current.SessionID || + currentMessage.Role != RoleAssistant || + currentMessage.Meta["cursor_agent_id"] != current.AgentID || + currentMessage.Meta["cursor_run_id"] != current.RunID { + return errors.New("committed cursor assistant message has a different run association") + } + *state = *current + *message = *currentMessage + return nil + } + + var maxSeq sql.NullInt64 + if err := tx.QueryRowContext(ctx, s.rebind( + `SELECT MAX(seq) FROM messages WHERE session_id=?`, + ), preparedMessage.SessionID).Scan(&maxSeq); err != nil { + return err + } + preparedMessage.Seq = maxSeq.Int64 + 1 + meta, err := preparedMessage.Meta.Value() + if err != nil { + return err + } + if _, err := tx.ExecContext(ctx, s.rebind( + `INSERT INTO messages (`+messageCols+`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + ), preparedMessage.ID, + preparedMessage.SessionID, + preparedMessage.Seq, + preparedMessage.Role, + preparedMessage.Content, + preparedMessage.Reasoning, + preparedMessage.ToolCalls, + preparedMessage.ToolCallID, + preparedMessage.ToolName, + preparedMessage.Attachments, + preparedMessage.Model, + preparedMessage.TokensIn, + preparedMessage.TokensOut, + preparedMessage.Hidden, + preparedMessage.Compacted, + ms(preparedMessage.CreatedAt), + meta, + ); err != nil { + return err + } + sessionResult, err := tx.ExecContext(ctx, s.rebind( + `UPDATE sessions SET + message_count=message_count+1, + tokens_in=tokens_in+?, + tokens_out=tokens_out+?, + updated_at=? + WHERE id=?`, + ), preparedMessage.TokensIn, preparedMessage.TokensOut, ms(time.Now()), preparedMessage.SessionID) + if err != nil { + return err + } + sessionChanged, err := sessionResult.RowsAffected() + if err != nil { + return err + } + if sessionChanged != 1 { + return ErrNotFound + } + if err := tx.Commit(); err != nil { + return err + } + + *state = next + *message = *preparedMessage + return nil +} diff --git a/internal/store/cursor_sessions_test.go b/internal/store/cursor_sessions_test.go new file mode 100644 index 0000000..0df99ec --- /dev/null +++ b/internal/store/cursor_sessions_test.go @@ -0,0 +1,1078 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "path/filepath" + "reflect" + "strings" + "testing" + "time" +) + +func TestCursorSessionMigrationCreatesCascadeAndOperationIndex(t *testing.T) { + ctx := context.Background() + s := newTestStore(t).(*sqlStore) + + var tableSQL string + if err := s.row(ctx, `SELECT sql FROM sqlite_master WHERE type='table' AND name='cursor_session_states'`). + Scan(&tableSQL); err != nil { + if err == sql.ErrNoRows { + t.Fatal("cursor_session_states table was not created") + } + t.Fatalf("read cursor session migration: %v", err) + } + if !strings.Contains(strings.ToUpper(tableSQL), "REFERENCES SESSIONS") || + !strings.Contains(strings.ToUpper(tableSQL), "ON DELETE CASCADE") { + t.Fatalf("cursor session foreign key is not cascading:\n%s", tableSQL) + } + + var indexName string + if err := s.row(ctx, `SELECT name FROM sqlite_master + WHERE type='index' AND tbl_name='cursor_session_states' AND name='idx_cursor_session_operation'`). + Scan(&indexName); err != nil { + t.Fatalf("cursor session operation-state index: %v", err) + } + + if err := s.migrate(ctx); err != nil { + t.Fatalf("cursor session migration is not idempotent: %v", err) + } + + if err := s.CreateSession(ctx, &Session{ID: "cursor-fk-cascade"}); err != nil { + t.Fatalf("create cascade session: %v", err) + } + if err := s.PutCursorSessionState(ctx, &CursorSessionState{ + SessionID: "cursor-fk-cascade", + OperationState: CursorOperationIdle, + }); err != nil { + t.Fatalf("put cascade state: %v", err) + } + if _, err := s.exec(ctx, `DELETE FROM sessions WHERE id=?`, "cursor-fk-cascade"); err != nil { + t.Fatalf("delete cascade parent directly: %v", err) + } + if _, err := s.GetCursorSessionState(ctx, "cursor-fk-cascade"); !errors.Is(err, ErrNotFound) { + t.Fatalf("foreign-key cascade left cursor state behind: %v", err) + } +} + +func TestCursorSessionDatabaseCheckRejectsInvalidOperationState(t *testing.T) { + ctx := context.Background() + s := newTestStore(t).(*sqlStore) + if err := s.CreateSession(ctx, &Session{ID: "cursor-invalid-sql-state"}); err != nil { + t.Fatalf("create session: %v", err) + } + + _, err := s.exec(ctx, `INSERT INTO cursor_session_states + (session_id,operation_state,updated_at) VALUES (?,?,?)`, + "cursor-invalid-sql-state", "launching", ms(time.Now())) + if err == nil { + t.Fatal("database accepted an invalid cursor operation state") + } + if _, err := s.GetCursorSessionState(ctx, "cursor-invalid-sql-state"); !errors.Is(err, ErrNotFound) { + t.Fatalf("invalid SQL state was persisted: %v", err) + } +} + +func TestCursorSessionRoundTripCanonicalizesParamsAndCascades(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + if err := s.CreateSession(ctx, &Session{ID: "cursor-round-trip", Title: "Cursor"}); err != nil { + t.Fatalf("create session: %v", err) + } + + state := &CursorSessionState{ + SessionID: "cursor-round-trip", + TargetActive: true, + ReuseValid: true, + ModelID: "gpt-5.6-sol", + ModelParams: `[ { "value": "max", "id": "reasoning" }, { "id": "context", "value": "1m" } ]`, + RepositoryURL: "https://github.com/acme/repo", + StartingRef: "main", + Mode: "agent", + AutoCreatePR: true, + AgentID: "bc-agent", + RunID: "run-one", + RemoteStatus: "RUNNING", + LastEventID: "event-7", + PartialText: "partial answer", + PartialReasoning: "partial reasoning", + GitState: `{"branch":"cursor/work"}`, + OperationState: CursorOperationRunInFlight, + UserMessageID: "msg-user", + AssistantMessageID: "msg-assistant", + } + if err := s.PutCursorSessionState(ctx, state); err != nil { + t.Fatalf("put cursor state: %v", err) + } + + const canonicalParams = `[{"id":"reasoning","value":"max"},{"id":"context","value":"1m"}]` + if state.ModelParams != canonicalParams { + t.Fatalf("caller model params = %q, want canonical %q", state.ModelParams, canonicalParams) + } + if state.Revision != 1 { + t.Fatalf("caller revision = %d, want 1", state.Revision) + } + if state.UpdatedAt.IsZero() { + t.Fatal("caller updated_at was not populated") + } + + got, err := s.GetCursorSessionState(ctx, state.SessionID) + if err != nil { + t.Fatalf("get cursor state: %v", err) + } + if *got != *state { + t.Fatalf("round trip mismatch:\n got: %+v\nwant: %+v", *got, *state) + } + + if err := s.DeleteSession(ctx, state.SessionID); err != nil { + t.Fatalf("delete session: %v", err) + } + if _, err := s.GetCursorSessionState(ctx, state.SessionID); !errors.Is(err, ErrNotFound) { + t.Fatalf("get cursor state after session delete = %v, want ErrNotFound", err) + } +} + +func TestCursorSessionPutAdvancesRevisionAndRejectsStaleSnapshot(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + current := putCursorTestState(t, s, "cursor-put-revision", CursorOperationRunInFlight) + stale := *current + + current.AgentID = "bc-current" + current.RunID = "run-current" + current.PartialText = "first current snapshot" + if err := s.PutCursorSessionState(ctx, current); err != nil { + t.Fatalf("put current snapshot: %v", err) + } + if current.Revision != 2 { + t.Fatalf("first update revision = %d, want 2", current.Revision) + } + current.PartialText = "second current snapshot" + if err := s.PutCursorSessionState(ctx, current); err != nil { + t.Fatalf("put second current snapshot: %v", err) + } + if current.Revision != 3 { + t.Fatalf("second update revision = %d, want 3", current.Revision) + } + + stale.ModelParams = `[ { "id": "reasoning", "value": "max" } ]` + staleBefore := stale + if err := s.PutCursorSessionState(ctx, &stale); err == nil { + t.Fatal("stale full snapshot overwrote newer cursor state") + } + if !reflect.DeepEqual(stale, staleBefore) { + t.Fatalf("conflicted put mutated caller:\n got: %+v\nwant: %+v", stale, staleBefore) + } + fresh := *current + fresh.Revision = 0 + fresh.RunID = "run-zero-revision-bypass" + freshBefore := fresh + if err := s.PutCursorSessionState(ctx, &fresh); err == nil { + t.Fatal("zero-revision create overwrote an existing cursor state") + } + if !reflect.DeepEqual(fresh, freshBefore) { + t.Fatalf("insert conflict mutated caller:\n got: %+v\nwant: %+v", fresh, freshBefore) + } + + persisted, err := s.GetCursorSessionState(ctx, current.SessionID) + if err != nil { + t.Fatalf("get current state: %v", err) + } + if persisted.Revision != 3 || + persisted.AgentID != current.AgentID || + persisted.RunID != current.RunID || + persisted.PartialText != current.PartialText { + t.Fatalf("stale put changed current state: %+v", persisted) + } +} + +func TestCursorSessionStalePutCannotRestoreCASOwnership(t *testing.T) { + ctx := context.Background() + + t.Run("old finalizer and CAS stay rejected", func(t *testing.T) { + s := newTestStore(t) + initial := putCursorTestState(t, s, "cursor-stale-put-cas", CursorOperationTerminal) + stalePut := *initial + + current := *initial + current.AgentID = "bc-new-owner" + current.RunID = "run-new-owner" + current.AssistantMessageID = "assistant-new-owner" + swapped, err := s.CompareAndSwapCursorSessionState(ctx, ¤t, initial.Revision) + if err != nil || !swapped { + t.Fatalf("advance current owner: swapped=%v err=%v", swapped, err) + } + + if err := s.PutCursorSessionState(ctx, &stalePut); err == nil { + t.Error("stale put restored the old revision after CAS") + } + oldFinalizer := *initial + if err := s.CommitCursorAssistant(ctx, &oldFinalizer, &Message{Content: "stale result"}); err == nil { + t.Error("old finalizer won after stale put") + } + oldCAS := *initial + oldCAS.PartialText = "stale update" + swapped, err = s.CompareAndSwapCursorSessionState(ctx, &oldCAS, initial.Revision) + if err != nil { + t.Fatalf("old CAS: %v", err) + } + if swapped { + t.Error("old CAS won after stale put") + } + + persisted, err := s.GetCursorSessionState(ctx, initial.SessionID) + if err != nil { + t.Fatalf("get current owner: %v", err) + } + if persisted.Revision != current.Revision || + persisted.AgentID != current.AgentID || + persisted.RunID != current.RunID { + t.Fatalf("old ownership was restored: %+v", persisted) + } + messages, err := s.ListMessages(ctx, initial.SessionID, 0, 0) + if err != nil { + t.Fatalf("list messages: %v", err) + } + if len(messages) != 0 { + t.Fatalf("old finalizer appended messages: %+v", messages) + } + }) + + t.Run("invalidation remains monotonic", func(t *testing.T) { + s := newTestStore(t) + initial := putCursorTestState(t, s, "cursor-stale-put-invalidate", CursorOperationRunInFlight) + stalePut := *initial + if err := s.InvalidateCursorReuse(ctx, initial.SessionID); err != nil { + t.Fatalf("invalidate reuse: %v", err) + } + + if err := s.PutCursorSessionState(ctx, &stalePut); err == nil { + t.Fatal("stale put undid reuse invalidation") + } + persisted, err := s.GetCursorSessionState(ctx, initial.SessionID) + if err != nil { + t.Fatalf("get invalidated state: %v", err) + } + if persisted.Revision != initial.Revision+1 || persisted.ReuseValid { + t.Fatalf("invalidation was regressed: %+v", persisted) + } + if persisted.AgentID != initial.AgentID || persisted.RunID != initial.RunID { + t.Fatalf("invalidation changed remote IDs: %+v", persisted) + } + }) +} + +func TestCursorSessionPutFailuresDoNotMutateCaller(t *testing.T) { + ctx := context.Background() + + t.Run("validation", func(t *testing.T) { + s := newTestStore(t) + state := CursorSessionState{ + SessionID: "cursor-put-validation", + ModelParams: `[ { "id": "reasoning", "value": "max" } ]`, + OperationState: "launching", + } + before := state + if err := s.PutCursorSessionState(ctx, &state); err == nil { + t.Fatal("invalid state was accepted") + } + if !reflect.DeepEqual(state, before) { + t.Fatalf("validation failure mutated caller:\n got: %+v\nwant: %+v", state, before) + } + }) + + t.Run("database", func(t *testing.T) { + s := newTestStore(t) + state := CursorSessionState{ + SessionID: "missing-parent", + ModelParams: `[ { "id": "reasoning", "value": "max" } ]`, + OperationState: CursorOperationIdle, + } + before := state + if err := s.PutCursorSessionState(ctx, &state); err == nil { + t.Fatal("state without a parent session was accepted") + } + if !reflect.DeepEqual(state, before) { + t.Fatalf("database failure mutated caller:\n got: %+v\nwant: %+v", state, before) + } + }) + + t.Run("revision conflict", func(t *testing.T) { + s := newTestStore(t) + current := putCursorTestState(t, s, "cursor-put-conflict-copy", CursorOperationRunInFlight) + stale := *current + current.PartialText = "new owner" + swapped, err := s.CompareAndSwapCursorSessionState(ctx, current, current.Revision) + if err != nil || !swapped { + t.Fatalf("advance owner: swapped=%v err=%v", swapped, err) + } + stale.ModelParams = `[ { "id": "reasoning", "value": "max" } ]` + before := stale + err = s.PutCursorSessionState(ctx, &stale) + if !errors.Is(err, ErrCursorRevisionConflict) { + t.Fatalf("stale state error=%v, want ErrCursorRevisionConflict", err) + } + if !reflect.DeepEqual(stale, before) { + t.Fatalf("conflict failure mutated caller:\n got: %+v\nwant: %+v", stale, before) + } + }) +} + +func TestCursorSessionDeleteFollowsManualSessionCascadeConvention(t *testing.T) { + ctx := context.Background() + s, err := Open(ctx, "memory", "", 1, 5000, false) + if err != nil { + t.Fatalf("open in-memory store: %v", err) + } + t.Cleanup(func() { _ = s.Close() }) + state := putCursorTestState(t, s, "cursor-manual-cascade", CursorOperationIdle) + + if err := s.DeleteSession(ctx, state.SessionID); err != nil { + t.Fatalf("delete session: %v", err) + } + if _, err := s.GetCursorSessionState(ctx, state.SessionID); !errors.Is(err, ErrNotFound) { + t.Fatalf("get cursor state after manual cascade = %v, want ErrNotFound", err) + } +} + +func newCursorForeignKeyOffStore(t *testing.T) *sqlStore { + t.Helper() + raw, err := Open(context.Background(), "memory", "", 1, 5000, false) + if err != nil { + t.Fatalf("open foreign-key-off store: %v", err) + } + t.Cleanup(func() { _ = raw.Close() }) + s := raw.(*sqlStore) + var enabled int + if err := s.row(context.Background(), `PRAGMA foreign_keys`).Scan(&enabled); err != nil { + t.Fatalf("read foreign_keys pragma: %v", err) + } + if enabled != 0 { + t.Fatalf("foreign_keys = %d, want disabled test store", enabled) + } + return s +} + +func TestCursorSessionDeleteEmptySessionsRemovesStateWithoutForeignKeys(t *testing.T) { + ctx := context.Background() + s := newCursorForeignKeyOffStore(t) + removed := putCursorTestState(t, s, "cursor-empty-remove", CursorOperationIdle) + kept := putCursorTestState(t, s, "cursor-empty-keep", CursorOperationRunInFlight) + if err := s.AppendMessage(ctx, &Message{ + ID: "keep-message", + SessionID: kept.SessionID, + Role: RoleUser, + Content: "keep", + }); err != nil { + t.Fatalf("append keep message: %v", err) + } + + count, err := s.DeleteEmptySessions(ctx) + if err != nil { + t.Fatalf("delete empty sessions: %v", err) + } + if count != 1 { + t.Fatalf("deleted empty sessions = %d, want 1", count) + } + if _, err := s.GetCursorSessionState(ctx, removed.SessionID); !errors.Is(err, ErrNotFound) { + t.Fatalf("empty-session cursor state survived: %v", err) + } + if _, err := s.GetSession(ctx, removed.SessionID); !errors.Is(err, ErrNotFound) { + t.Fatalf("empty session survived: %v", err) + } + if _, err := s.GetCursorSessionState(ctx, kept.SessionID); err != nil { + t.Fatalf("non-empty cursor state was deleted: %v", err) + } +} + +func TestCursorSessionBulkDeleteRollsBackOnChildFailure(t *testing.T) { + ctx := context.Background() + s := newCursorForeignKeyOffStore(t) + state := putCursorTestState(t, s, "cursor-bulk-rollback", CursorOperationIdle) + if _, err := s.exec(ctx, `INSERT INTO messages (id,session_id,seq,role,created_at) + VALUES (?,?,?,?,?)`, "rollback-message", state.SessionID, 1, RoleUser, ms(time.Now())); err != nil { + t.Fatalf("insert rollback message: %v", err) + } + if _, err := s.exec(ctx, `CREATE TRIGGER fail_cursor_bulk_message_delete + BEFORE DELETE ON messages + WHEN OLD.session_id = 'cursor-bulk-rollback' + BEGIN + SELECT RAISE(ABORT, 'blocked child delete'); + END`); err != nil { + t.Fatalf("create failing delete trigger: %v", err) + } + + count, err := s.DeleteEmptySessions(ctx) + if err == nil { + t.Fatal("bulk deletion unexpectedly succeeded") + } + if count != 0 { + t.Fatalf("failed bulk deletion count = %d, want 0", count) + } + if _, err := s.GetSession(ctx, state.SessionID); err != nil { + t.Fatalf("session was not rolled back: %v", err) + } + if _, err := s.GetCursorSessionState(ctx, state.SessionID); err != nil { + t.Fatalf("cursor state was not rolled back: %v", err) + } + messages, err := s.ListMessages(ctx, state.SessionID, 0, 0) + if err != nil { + t.Fatalf("list rolled-back messages: %v", err) + } + if len(messages) != 1 || messages[0].ID != "rollback-message" { + t.Fatalf("messages were not rolled back: %+v", messages) + } +} + +func TestCursorDeleteSessionsRollsBackEveryIDOnLaterFailure(t *testing.T) { + ctx := context.Background() + s := newCursorForeignKeyOffStore(t) + sessionIDs := []string{"cursor-delete-many-first", "cursor-delete-many-second"} + for _, sessionID := range sessionIDs { + seedCursorSessionDeleteGraph(t, s, sessionID) + } + if _, err := s.exec(ctx, `CREATE TRIGGER fail_later_exact_bulk_message_delete + BEFORE DELETE ON messages + WHEN OLD.session_id = 'cursor-delete-many-second' + BEGIN + SELECT RAISE(ABORT, 'blocked later child delete'); + END`); err != nil { + t.Fatalf("create failing later delete trigger: %v", err) + } + + count, err := s.DeleteSessions(ctx, sessionIDs) + if err == nil { + t.Fatal("multi-session deletion unexpectedly succeeded") + } + if count != 0 { + t.Fatalf("failed atomic deletion count=%d, want 0", count) + } + for _, sessionID := range sessionIDs { + assertCursorSessionDeleteGraphPresent(t, s, sessionID) + } +} + +func TestCursorDeleteSessionsDeletesExactSetAndReturnsCount(t *testing.T) { + ctx := context.Background() + s := newCursorForeignKeyOffStore(t) + deleted := []string{"cursor-delete-exact-first", "cursor-delete-exact-second"} + for _, sessionID := range append(deleted, "cursor-delete-exact-kept") { + seedCursorSessionDeleteGraph(t, s, sessionID) + } + + count, err := s.DeleteSessions(ctx, deleted) + if err != nil { + t.Fatalf("delete exact session set: %v", err) + } + if count != int64(len(deleted)) { + t.Fatalf("deleted=%d, want %d", count, len(deleted)) + } + for _, sessionID := range deleted { + if _, err := s.GetSession(ctx, sessionID); !errors.Is(err, ErrNotFound) { + t.Fatalf("deleted session %q survived: %v", sessionID, err) + } + if _, err := s.GetCursorSessionState(ctx, sessionID); !errors.Is(err, ErrNotFound) { + t.Fatalf("deleted cursor state %q survived: %v", sessionID, err) + } + messages, err := s.ListMessages(ctx, sessionID, 0, 0) + if err != nil || len(messages) != 0 { + t.Fatalf("deleted messages for %q=%+v err=%v", sessionID, messages, err) + } + memories, err := s.ListMemories(ctx, "session", sessionID, 10) + if err != nil || len(memories) != 0 { + t.Fatalf("deleted memories for %q=%+v err=%v", sessionID, memories, err) + } + } + assertCursorSessionDeleteGraphPresent(t, s, "cursor-delete-exact-kept") +} + +func seedCursorSessionDeleteGraph(t *testing.T, s *sqlStore, sessionID string) { + t.Helper() + ctx := context.Background() + putCursorTestState(t, s, sessionID, CursorOperationIdle) + if err := s.AppendMessage(ctx, &Message{ + ID: "message-" + sessionID, SessionID: sessionID, + Role: RoleUser, Content: "keep", + }); err != nil { + t.Fatalf("append message for %q: %v", sessionID, err) + } + if err := s.PutMemory(ctx, &Memory{ + ID: "memory-" + sessionID, Scope: "session", ScopeKey: sessionID, + Content: "keep", + }); err != nil { + t.Fatalf("put memory for %q: %v", sessionID, err) + } +} + +func assertCursorSessionDeleteGraphPresent(t *testing.T, s *sqlStore, sessionID string) { + t.Helper() + ctx := context.Background() + if _, err := s.GetSession(ctx, sessionID); err != nil { + t.Fatalf("session %q was not rolled back: %v", sessionID, err) + } + if _, err := s.GetCursorSessionState(ctx, sessionID); err != nil { + t.Fatalf("cursor state %q was not rolled back: %v", sessionID, err) + } + messages, err := s.ListMessages(ctx, sessionID, 0, 0) + if err != nil || len(messages) != 1 { + t.Fatalf("messages for %q=%+v err=%v, want one", sessionID, messages, err) + } + memories, err := s.ListMemories(ctx, "session", sessionID, 10) + if err != nil || len(memories) != 1 { + t.Fatalf("memories for %q=%+v err=%v, want one", sessionID, memories, err) + } +} + +func TestCursorSessionPruneSessionsRemovesStateWithoutForeignKeys(t *testing.T) { + ctx := context.Background() + s := newCursorForeignKeyOffStore(t) + removed := putCursorTestState(t, s, "cursor-prune-remove", CursorOperationIdle) + kept := putCursorTestState(t, s, "cursor-prune-keep", CursorOperationRunInFlight) + if err := s.AppendMessage(ctx, &Message{ + ID: "prune-message", + SessionID: removed.SessionID, + Role: RoleUser, + Content: "remove", + }); err != nil { + t.Fatalf("append pruned message: %v", err) + } + if _, err := s.exec(ctx, `UPDATE sessions SET updated_at=? WHERE id=?`, + ms(time.Now().Add(-2*time.Hour)), removed.SessionID); err != nil { + t.Fatalf("age pruned session: %v", err) + } + + count, err := s.PruneSessions(ctx, time.Now().Add(-time.Hour)) + if err != nil { + t.Fatalf("prune sessions: %v", err) + } + if count != 1 { + t.Fatalf("pruned sessions = %d, want 1", count) + } + if _, err := s.GetCursorSessionState(ctx, removed.SessionID); !errors.Is(err, ErrNotFound) { + t.Fatalf("pruned cursor state survived: %v", err) + } + if _, err := s.GetSession(ctx, removed.SessionID); !errors.Is(err, ErrNotFound) { + t.Fatalf("pruned session survived: %v", err) + } + messages, err := s.ListMessages(ctx, removed.SessionID, 0, 0) + if err != nil { + t.Fatalf("list pruned messages: %v", err) + } + if len(messages) != 0 { + t.Fatalf("pruned messages survived: %+v", messages) + } + if _, err := s.GetCursorSessionState(ctx, kept.SessionID); err != nil { + t.Fatalf("fresh cursor state was deleted: %v", err) + } +} + +func TestCursorCleanupSkipsActiveStatesWithAndWithoutForeignKeys(t *testing.T) { + const ( + cancelInFlight = "ANTARES_CANCEL_IN_FLIGHT" + cancelRequested = "ANTARES_CANCEL_REQUESTED" + cancelAmbiguous = "ANTARES_CANCEL_OUTCOME_AMBIGUOUS" + ) + stores := []struct { + name string + open func(*testing.T) *sqlStore + }{ + { + name: "foreign keys on", + open: func(t *testing.T) *sqlStore { + return newTestStore(t).(*sqlStore) + }, + }, + {name: "foreign keys off", open: newCursorForeignKeyOffStore}, + } + cleanups := []struct { + name string + run func(context.Context, *sqlStore) (int64, error) + }{ + { + name: "delete empty", + run: func(ctx context.Context, s *sqlStore) (int64, error) { + return s.DeleteEmptySessions(ctx) + }, + }, + { + name: "prune", + run: func(ctx context.Context, s *sqlStore) (int64, error) { + return s.PruneSessions(ctx, time.Now().Add(-time.Hour)) + }, + }, + } + + for _, storeCase := range stores { + for _, cleanup := range cleanups { + t.Run(storeCase.name+"/"+cleanup.name, func(t *testing.T) { + ctx := context.Background() + s := storeCase.open(t) + ordinaryID := "cleanup-ordinary" + if err := s.CreateSession(ctx, &Session{ID: ordinaryID}); err != nil { + t.Fatal(err) + } + + activeIDs := []string{ + putCursorTestState(t, s, "cleanup-awaiting", CursorOperationAwaitingApproval).SessionID, + putCursorTestState(t, s, "cleanup-create", CursorOperationCreateInFlight).SessionID, + putCursorTestState(t, s, "cleanup-run", CursorOperationRunInFlight).SessionID, + putCursorTestState(t, s, "cleanup-terminal", CursorOperationTerminal).SessionID, + } + deletableIDs := []string{ + ordinaryID, + putCursorTestState(t, s, "cleanup-ambiguous", CursorOperationAmbiguous).SessionID, + } + for name, status := range map[string]string{ + "requested": cancelRequested, + "ambiguous": cancelAmbiguous, + "stale": cancelInFlight, + } { + state := putCursorTestState( + t, s, "cleanup-cancel-"+name, CursorOperationRunInFlight, + ) + state.RemoteStatus = status + if err := s.PutCursorSessionState(ctx, state); err != nil { + t.Fatal(err) + } + deletableIDs = append(deletableIDs, state.SessionID) + } + + // This state appears after the cleanup caller's enumeration. + // The store-side predicate must still preserve it. + racedID := "cleanup-raced-run" + if err := s.CreateSession(ctx, &Session{ID: racedID}); err != nil { + t.Fatal(err) + } + if _, _, err := s.ListSessions(ctx, SessionFilter{Limit: 500}); err != nil { + t.Fatal(err) + } + if err := s.PutCursorSessionState(ctx, &CursorSessionState{ + SessionID: racedID, + ModelParams: `[]`, + AgentID: "bc-" + racedID, + RunID: "run-" + racedID, + RemoteStatus: "RUNNING", + OperationState: CursorOperationRunInFlight, + }); err != nil { + t.Fatal(err) + } + activeIDs = append(activeIDs, racedID) + + if cleanup.name == "prune" { + if _, err := s.exec(ctx, + `UPDATE sessions SET updated_at=?`, + ms(time.Now().Add(-2*time.Hour)), + ); err != nil { + t.Fatal(err) + } + } + + count, err := cleanup.run(ctx, s) + if err != nil { + t.Fatalf("cleanup: %v", err) + } + if count != int64(len(deletableIDs)) { + t.Fatalf("deleted=%d, want %d", count, len(deletableIDs)) + } + for _, id := range activeIDs { + if _, err := s.GetSession(ctx, id); err != nil { + t.Fatalf("active session %q was deleted: %v", id, err) + } + if _, err := s.GetCursorSessionState(ctx, id); err != nil { + t.Fatalf("active cursor state %q was deleted: %v", id, err) + } + } + for _, id := range deletableIDs { + if _, err := s.GetSession(ctx, id); !errors.Is(err, ErrNotFound) { + t.Fatalf("deletable session %q survived: %v", id, err) + } + if id != ordinaryID { + if _, err := s.GetCursorSessionState(ctx, id); !errors.Is(err, ErrNotFound) { + t.Fatalf("deletable cursor state %q survived: %v", id, err) + } + } + } + }) + } + } +} + +func TestCursorSessionStateSurvivesSQLiteReopen(t *testing.T) { + ctx := context.Background() + dsn := filepath.Join(t.TempDir(), "durable.db") + first, err := Open(ctx, "sqlite", dsn, 2, 5000, true) + if err != nil { + t.Fatalf("open first store: %v", err) + } + state := putCursorTestState(t, first, "cursor-durable", CursorOperationRunInFlight) + if err := first.Close(); err != nil { + t.Fatalf("close first store: %v", err) + } + + reopened, err := Open(ctx, "sqlite", dsn, 2, 5000, true) + if err != nil { + t.Fatalf("reopen store: %v", err) + } + t.Cleanup(func() { _ = reopened.Close() }) + got, err := reopened.GetCursorSessionState(ctx, state.SessionID) + if err != nil { + t.Fatalf("get cursor state after reopen: %v", err) + } + if !reflect.DeepEqual(got, state) { + t.Fatalf("state after reopen:\n got: %+v\nwant: %+v", got, state) + } +} + +func putCursorTestState(t *testing.T, s Store, sessionID, operation string) *CursorSessionState { + t.Helper() + ctx := context.Background() + if err := s.CreateSession(ctx, &Session{ID: sessionID, Title: sessionID}); err != nil { + t.Fatalf("create cursor test session: %v", err) + } + state := &CursorSessionState{ + SessionID: sessionID, + TargetActive: true, + ReuseValid: true, + ModelID: "gpt-5.6-sol", + ModelParams: `[{"id":"reasoning","value":"max"}]`, + RepositoryURL: "https://github.com/acme/repo", + StartingRef: "main", + Mode: "agent", + AutoCreatePR: true, + AgentID: "bc-" + sessionID, + RunID: "run-" + sessionID, + RemoteStatus: "RUNNING", + OperationState: operation, + UserMessageID: "user-" + sessionID, + AssistantMessageID: "assistant-" + sessionID, + } + if err := s.PutCursorSessionState(ctx, state); err != nil { + t.Fatalf("put cursor test state: %v", err) + } + return state +} + +func TestCursorSessionCompareAndSwapRejectsCompetingTurn(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + initial := putCursorTestState(t, s, "cursor-cas", CursorOperationIdle) + + candidates := [2]CursorSessionState{*initial, *initial} + candidates[0].OperationState = CursorOperationCreateInFlight + candidates[0].PartialText = "first" + candidates[1].OperationState = CursorOperationRunInFlight + candidates[1].PartialText = "second" + + type result struct { + index int + swapped bool + err error + } + start := make(chan struct{}) + results := make(chan result, len(candidates)) + for i := range candidates { + go func(index int) { + <-start + swapped, err := s.CompareAndSwapCursorSessionState(ctx, &candidates[index], initial.Revision) + results <- result{index: index, swapped: swapped, err: err} + }(i) + } + close(start) + + successes := 0 + winner := -1 + for range candidates { + got := <-results + if got.err != nil { + t.Fatalf("compare-and-swap candidate %d: %v", got.index, got.err) + } + if got.swapped { + successes++ + winner = got.index + } + } + if successes != 1 { + t.Fatalf("successful competing swaps = %d, want exactly 1", successes) + } + + persisted, err := s.GetCursorSessionState(ctx, initial.SessionID) + if err != nil { + t.Fatalf("get swapped cursor state: %v", err) + } + if persisted.Revision != initial.Revision+1 { + t.Fatalf("persisted revision = %d, want %d", persisted.Revision, initial.Revision+1) + } + if persisted.PartialText != candidates[winner].PartialText || + persisted.OperationState != candidates[winner].OperationState { + t.Fatalf("persisted state = %+v, want candidate %d", persisted, winner) + } +} + +func TestCursorSessionRecoverableListExcludesCommittedTerminalWork(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + putCursorTestState(t, s, "cursor-running", CursorOperationRunInFlight) + putCursorTestState(t, s, "cursor-terminal", CursorOperationTerminal) + putCursorTestState(t, s, "cursor-committed", CursorOperationCommitted) + + states, err := s.ListRecoverableCursorSessionStates(ctx) + if err != nil { + t.Fatalf("list recoverable cursor states: %v", err) + } + got := make(map[string]string, len(states)) + for _, state := range states { + got[state.SessionID] = state.OperationState + } + want := map[string]string{ + "cursor-running": CursorOperationRunInFlight, + "cursor-terminal": CursorOperationTerminal, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("recoverable cursor states = %v, want %v", got, want) + } +} + +func TestCursorSessionInvalidateReusePreservesRemoteIDs(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + before := putCursorTestState(t, s, "cursor-invalidate", CursorOperationTerminal) + + if err := s.InvalidateCursorReuse(ctx, before.SessionID); err != nil { + t.Fatalf("invalidate cursor reuse: %v", err) + } + after, err := s.GetCursorSessionState(ctx, before.SessionID) + if err != nil { + t.Fatalf("get invalidated cursor state: %v", err) + } + if after.ReuseValid { + t.Fatal("reuse remains valid after invalidation") + } + if after.AgentID != before.AgentID || after.RunID != before.RunID { + t.Fatalf("remote IDs changed: before=(%q,%q) after=(%q,%q)", + before.AgentID, before.RunID, after.AgentID, after.RunID) + } + if after.Revision != before.Revision+1 { + t.Fatalf("revision after invalidation = %d, want %d", after.Revision, before.Revision+1) + } +} + +func TestCursorSessionCommitAssistantIsAtomicAndIdempotent(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + state := putCursorTestState(t, s, "cursor-commit", CursorOperationTerminal) + if err := s.AppendMessage(ctx, &Message{ + ID: state.UserMessageID, + SessionID: state.SessionID, + Role: RoleUser, + Content: "fix it", + TokensIn: 7, + }); err != nil { + t.Fatalf("append user message: %v", err) + } + + assistant := &Message{ + Content: "fixed", + Reasoning: "checked the tests", + Model: state.ModelID, + TokensOut: 11, + Meta: Meta{"source": "cursor"}, + } + if err := s.CommitCursorAssistant(ctx, state, assistant); err != nil { + t.Fatalf("commit cursor assistant: %v", err) + } + retry := &Message{Content: "a retry must not replace the committed result"} + if err := s.CommitCursorAssistant(ctx, state, retry); err != nil { + t.Fatalf("repeat cursor assistant commit: %v", err) + } + if retry.Content != assistant.Content || retry.ID != assistant.ID { + t.Fatalf("idempotent retry returned %+v, want persisted assistant %+v", retry, assistant) + } + + if assistant.ID != state.AssistantMessageID || + assistant.SessionID != state.SessionID || + assistant.Role != RoleAssistant { + t.Fatalf("assistant identity was not derived deterministically: %+v", assistant) + } + messages, err := s.ListMessages(ctx, state.SessionID, 0, 0) + if err != nil { + t.Fatalf("list committed messages: %v", err) + } + if len(messages) != 2 { + t.Fatalf("messages after repeated commit = %d, want 2", len(messages)) + } + gotAssistant := messages[1] + if gotAssistant.ID != state.AssistantMessageID || gotAssistant.Seq != 2 { + t.Fatalf("committed assistant identity = (%q,%d), want (%q,2)", + gotAssistant.ID, gotAssistant.Seq, state.AssistantMessageID) + } + if gotAssistant.Meta["cursor_agent_id"] != state.AgentID || + gotAssistant.Meta["cursor_run_id"] != state.RunID { + t.Fatalf("assistant is not associated with Cursor run: meta=%v", gotAssistant.Meta) + } + + persisted, err := s.GetCursorSessionState(ctx, state.SessionID) + if err != nil { + t.Fatalf("get committed cursor state: %v", err) + } + if persisted.OperationState != CursorOperationCommitted { + t.Fatalf("operation state = %q, want committed", persisted.OperationState) + } + if persisted.Revision != 2 { + t.Fatalf("committed revision = %d, want 2", persisted.Revision) + } + session, err := s.GetSession(ctx, state.SessionID) + if err != nil { + t.Fatalf("get committed session: %v", err) + } + if session.MessageCount != 2 || session.TokensIn != 7 || session.TokensOut != 11 { + t.Fatalf("committed session counters = (%d,%d,%d), want (2,7,11)", + session.MessageCount, session.TokensIn, session.TokensOut) + } +} + +func TestCursorSessionCommitAssistantRollsBackOnMessageConflict(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + state := putCursorTestState(t, s, "cursor-conflict", CursorOperationTerminal) + state.AssistantMessageID = "occupied-message" + if err := s.PutCursorSessionState(ctx, state); err != nil { + t.Fatalf("put conflicting assistant ID: %v", err) + } + if err := s.AppendMessage(ctx, &Message{ + ID: state.AssistantMessageID, + SessionID: state.SessionID, + Role: RoleUser, + Content: "already occupied", + }); err != nil { + t.Fatalf("append conflicting message: %v", err) + } + + err := s.CommitCursorAssistant(ctx, state, &Message{Content: "must not commit"}) + if err == nil { + t.Fatal("commit with occupied deterministic message ID succeeded") + } + persisted, getErr := s.GetCursorSessionState(ctx, state.SessionID) + if getErr != nil { + t.Fatalf("get state after failed commit: %v", getErr) + } + if persisted.OperationState != CursorOperationTerminal { + t.Fatalf("state after failed commit = %q, want terminal", persisted.OperationState) + } + messages, listErr := s.ListMessages(ctx, state.SessionID, 0, 0) + if listErr != nil { + t.Fatalf("list after failed commit: %v", listErr) + } + if len(messages) != 1 || messages[0].Content != "already occupied" { + t.Fatalf("messages changed after failed commit: %+v", messages) + } +} + +func TestCursorSessionConcurrentAssistantCommitAppendsExactlyOnce(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + initial := putCursorTestState(t, s, "cursor-concurrent-commit", CursorOperationTerminal) + + states := [2]CursorSessionState{*initial, *initial} + messages := [2]Message{ + {Content: "one canonical result", TokensOut: 5}, + {Content: "one canonical result", TokensOut: 5}, + } + start := make(chan struct{}) + results := make(chan error, len(states)) + for index := range states { + go func() { + <-start + results <- s.CommitCursorAssistant(ctx, &states[index], &messages[index]) + }() + } + close(start) + for range states { + if err := <-results; err != nil { + t.Fatalf("concurrent cursor assistant commit: %v", err) + } + } + + persistedMessages, err := s.ListMessages(ctx, initial.SessionID, 0, 0) + if err != nil { + t.Fatalf("list concurrently committed messages: %v", err) + } + if len(persistedMessages) != 1 || + persistedMessages[0].ID != initial.AssistantMessageID || + persistedMessages[0].Seq != 1 { + t.Fatalf("concurrently committed messages = %+v, want one deterministic message", persistedMessages) + } + session, err := s.GetSession(ctx, initial.SessionID) + if err != nil { + t.Fatalf("get concurrently committed session: %v", err) + } + if session.MessageCount != 1 || session.TokensOut != 5 { + t.Fatalf("session counters after concurrent commit = (%d,%d), want (1,5)", + session.MessageCount, session.TokensOut) + } +} + +func TestCursorSessionCommitRejectsStaleRunAssociation(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + initial := putCursorTestState(t, s, "cursor-stale-commit", CursorOperationTerminal) + stale := *initial + + current := *initial + current.AgentID = "bc-new" + current.RunID = "run-new" + current.AssistantMessageID = "assistant-new" + swapped, err := s.CompareAndSwapCursorSessionState(ctx, ¤t, initial.Revision) + if err != nil || !swapped { + t.Fatalf("replace current run: swapped=%v err=%v", swapped, err) + } + + err = s.CommitCursorAssistant(ctx, &stale, &Message{Content: "stale result"}) + if err == nil || !strings.Contains(err.Error(), "state changed") { + t.Fatalf("stale run commit error = %v", err) + } + persisted, err := s.GetCursorSessionState(ctx, initial.SessionID) + if err != nil { + t.Fatalf("get state after stale commit: %v", err) + } + if persisted.RunID != current.RunID || + persisted.AssistantMessageID != current.AssistantMessageID || + persisted.OperationState != CursorOperationTerminal { + t.Fatalf("stale commit changed current state: %+v", persisted) + } + messages, err := s.ListMessages(ctx, initial.SessionID, 0, 0) + if err != nil { + t.Fatalf("list messages after stale commit: %v", err) + } + if len(messages) != 0 { + t.Fatalf("stale commit appended messages: %+v", messages) + } +} + +func TestCursorSessionRejectsInvalidPersistedValues(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + if err := s.CreateSession(ctx, &Session{ID: "cursor-invalid"}); err != nil { + t.Fatalf("create session: %v", err) + } + + state := &CursorSessionState{ + SessionID: "cursor-invalid", + ModelParams: "[]", + OperationState: "launching", + } + if err := s.PutCursorSessionState(ctx, state); err == nil || + !strings.Contains(err.Error(), "operation state") { + t.Fatalf("invalid operation-state error = %v", err) + } + + state.OperationState = CursorOperationIdle + state.ModelParams = `{"reasoning":"max"}` + if err := s.PutCursorSessionState(ctx, state); err == nil || + !strings.Contains(err.Error(), "JSON array") { + t.Fatalf("invalid model-params error = %v", err) + } + if _, err := s.GetCursorSessionState(ctx, state.SessionID); !errors.Is(err, ErrNotFound) { + t.Fatalf("invalid cursor state was persisted: %v", err) + } +} diff --git a/internal/store/migrations.go b/internal/store/migrations.go index cd5d30a..2f2da86 100644 --- a/internal/store/migrations.go +++ b/internal/store/migrations.go @@ -47,6 +47,32 @@ var migrations = []string{ `CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, seq)`, `CREATE INDEX IF NOT EXISTS idx_messages_created ON messages(created_at DESC)`, + `CREATE TABLE IF NOT EXISTS cursor_session_states ( + session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE, + target_active BOOLEAN NOT NULL DEFAULT FALSE, + reuse_valid BOOLEAN NOT NULL DEFAULT FALSE, + model_id TEXT NOT NULL DEFAULT '', + model_params TEXT NOT NULL DEFAULT '[]', + repository_url TEXT NOT NULL DEFAULT '', + starting_ref TEXT NOT NULL DEFAULT '', + mode TEXT NOT NULL DEFAULT '', + auto_create_pr BOOLEAN NOT NULL DEFAULT FALSE, + agent_id TEXT NOT NULL DEFAULT '', + run_id TEXT NOT NULL DEFAULT '', + remote_status TEXT NOT NULL DEFAULT '', + last_event_id TEXT NOT NULL DEFAULT '', + partial_text TEXT NOT NULL DEFAULT '', + partial_reasoning TEXT NOT NULL DEFAULT '', + git_state TEXT NOT NULL DEFAULT '', + operation_state TEXT NOT NULL DEFAULT 'idle' + CHECK (operation_state IN ('idle','awaiting_approval','create_in_flight','run_in_flight','terminal','committed','ambiguous')), + user_message_id TEXT NOT NULL DEFAULT '', + assistant_message_id TEXT NOT NULL DEFAULT '', + revision BIGINT NOT NULL DEFAULT 1, + updated_at BIGINT NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS idx_cursor_session_operation ON cursor_session_states(operation_state)`, + `CREATE TABLE IF NOT EXISTS memories ( id TEXT PRIMARY KEY, scope TEXT NOT NULL DEFAULT 'global', diff --git a/internal/store/sessions.go b/internal/store/sessions.go index 461263c..aafcffb 100644 --- a/internal/store/sessions.go +++ b/internal/store/sessions.go @@ -130,7 +130,7 @@ func (s *sqlStore) ListSessions(ctx context.Context, f SessionFilter) ([]Session limit = 50 } rows, err := s.query(ctx, `SELECT `+sessionCols+` FROM sessions`+clause+ - ` ORDER BY pinned DESC, `+order+` LIMIT ? OFFSET ?`, append(args, limit, f.Offset)...) + ` ORDER BY pinned DESC, `+order+`, id ASC LIMIT ? OFFSET ?`, append(args, limit, f.Offset)...) if err != nil { return nil, 0, err } @@ -148,6 +148,9 @@ func (s *sqlStore) ListSessions(ctx context.Context, f SessionFilter) ([]Session } func (s *sqlStore) DeleteSession(ctx context.Context, id string) error { + if _, err := s.exec(ctx, `DELETE FROM cursor_session_states WHERE session_id=?`, id); err != nil { + return err + } if _, err := s.exec(ctx, `DELETE FROM messages WHERE session_id=?`, id); err != nil { return err } @@ -159,14 +162,32 @@ func (s *sqlStore) DeleteSession(ctx context.Context, id string) error { } func (s *sqlStore) DeleteSessions(ctx context.Context, ids []string) (int64, error) { - var n int64 + if len(ids) == 0 { + return 0, nil + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return 0, err + } + defer tx.Rollback() + + deletions := [...]string{ + `DELETE FROM cursor_session_states WHERE session_id=?`, + `DELETE FROM messages WHERE session_id=?`, + `DELETE FROM memories WHERE scope='session' AND scope_key=?`, + `DELETE FROM sessions WHERE id=?`, + } for _, id := range ids { - if err := s.DeleteSession(ctx, id); err != nil { - return n, err + for _, deletion := range deletions { + if _, err := tx.ExecContext(ctx, s.rebind(deletion), id); err != nil { + return 0, err + } } - n++ } - return n, nil + if err := tx.Commit(); err != nil { + return 0, err + } + return int64(len(ids)), nil } func (s *sqlStore) CountEmptySessions(ctx context.Context) (int64, error) { @@ -176,26 +197,94 @@ func (s *sqlStore) CountEmptySessions(ctx context.Context) (int64, error) { } func (s *sqlStore) DeleteEmptySessions(ctx context.Context) (int64, error) { - res, err := s.exec(ctx, `DELETE FROM sessions WHERE message_count=0`) - if err != nil { - return 0, err + return s.deleteSessionsAndChildren(ctx, + `DELETE FROM sessions WHERE message_count=0`+cursorCleanupInactivePredicate+` RETURNING id`, + cursorCleanupInactiveArgs()..., + ) +} + +const cursorCleanupInactivePredicate = ` + AND NOT EXISTS ( + SELECT 1 FROM cursor_session_states AS cursor_cleanup + WHERE cursor_cleanup.session_id=sessions.id + AND ( + cursor_cleanup.operation_state IN (?,?,?) + OR ( + cursor_cleanup.operation_state=? + AND COALESCE(cursor_cleanup.remote_status,'') NOT IN (?,?,?) + ) + ) + )` + +func cursorCleanupInactiveArgs() []any { + // Cancellation markers are local-reconciliation states. The server blocks a + // currently executing CancelRun with its process-local reservation; without + // that reservation these durable markers must remain locally deletable. + return []any{ + CursorOperationAwaitingApproval, + CursorOperationCreateInFlight, + CursorOperationTerminal, + CursorOperationRunInFlight, + "ANTARES_CANCEL_REQUESTED", + "ANTARES_CANCEL_OUTCOME_AMBIGUOUS", + "ANTARES_CANCEL_IN_FLIGHT", } - n, _ := res.RowsAffected() - return n, nil } -func (s *sqlStore) PruneSessions(ctx context.Context, olderThan time.Time) (int64, error) { - cutoff := ms(olderThan) - if _, err := s.exec(ctx, - `DELETE FROM messages WHERE session_id IN (SELECT id FROM sessions WHERE updated_at < ? AND pinned = FALSE)`, cutoff); err != nil { +func (s *sqlStore) deleteSessionsAndChildren(ctx context.Context, deleteQuery string, args ...any) (int64, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { return 0, err } - res, err := s.exec(ctx, `DELETE FROM sessions WHERE updated_at < ? AND pinned = FALSE`, cutoff) + defer tx.Rollback() + + rows, err := tx.QueryContext(ctx, s.rebind(deleteQuery), args...) if err != nil { return 0, err } - n, _ := res.RowsAffected() - return n, nil + var sessionIDs []string + for rows.Next() { + var sessionID string + if err := rows.Scan(&sessionID); err != nil { + rows.Close() + return 0, err + } + sessionIDs = append(sessionIDs, sessionID) + } + if err := rows.Err(); err != nil { + rows.Close() + return 0, err + } + if err := rows.Close(); err != nil { + return 0, err + } + + for _, sessionID := range sessionIDs { + if _, err := tx.ExecContext(ctx, s.rebind( + `DELETE FROM cursor_session_states WHERE session_id=?`, + ), sessionID); err != nil { + return 0, err + } + if _, err := tx.ExecContext(ctx, s.rebind( + `DELETE FROM messages WHERE session_id=?`, + ), sessionID); err != nil { + return 0, err + } + } + if err := tx.Commit(); err != nil { + return 0, err + } + return int64(len(sessionIDs)), nil +} + +func (s *sqlStore) PruneSessions(ctx context.Context, olderThan time.Time) (int64, error) { + cutoff := ms(olderThan) + args := append([]any{cutoff}, cursorCleanupInactiveArgs()...) + return s.deleteSessionsAndChildren(ctx, + `DELETE FROM sessions WHERE updated_at < ? AND pinned = FALSE`+ + cursorCleanupInactivePredicate+` RETURNING id`, + args..., + ) } // ---- messages --------------------------------------------------------------- diff --git a/internal/store/sql.go b/internal/store/sql.go index c9890fe..599ccb2 100644 --- a/internal/store/sql.go +++ b/internal/store/sql.go @@ -23,6 +23,10 @@ import ( // ErrNotFound is returned when a lookup by id yields nothing. var ErrNotFound = errors.New("not found") +// ErrCursorRevisionConflict is returned when a full Cursor snapshot no longer +// owns the revision it attempted to replace. +var ErrCursorRevisionConflict = errors.New("cursor session state revision conflict") + type sqlStore struct { db *sql.DB dialect string // sqlite|postgres diff --git a/internal/store/types.go b/internal/store/types.go index f96623b..be06256 100644 --- a/internal/store/types.go +++ b/internal/store/types.go @@ -65,6 +65,44 @@ type Session struct { Meta Meta `json:"meta"` } +// Cursor operation states persisted across process restarts. +const ( + CursorOperationIdle = "idle" + CursorOperationAwaitingApproval = "awaiting_approval" + CursorOperationCreateInFlight = "create_in_flight" + CursorOperationRunInFlight = "run_in_flight" + CursorOperationTerminal = "terminal" + CursorOperationCommitted = "committed" + CursorOperationAmbiguous = "ambiguous" +) + +// CursorSessionState is the durable recovery snapshot for one Cursor-backed +// conversation. Credentials and prompt image data deliberately do not belong +// in this persistence model. +type CursorSessionState struct { + SessionID string `json:"session_id"` + TargetActive bool `json:"target_active"` + ReuseValid bool `json:"reuse_valid"` + ModelID string `json:"model_id"` + ModelParams string `json:"model_params"` + RepositoryURL string `json:"repository_url"` + StartingRef string `json:"starting_ref"` + Mode string `json:"mode"` + AutoCreatePR bool `json:"auto_create_pr"` + AgentID string `json:"agent_id"` + RunID string `json:"run_id"` + RemoteStatus string `json:"remote_status"` + LastEventID string `json:"last_event_id"` + PartialText string `json:"partial_text"` + PartialReasoning string `json:"partial_reasoning"` + GitState string `json:"git_state"` + OperationState string `json:"operation_state"` + UserMessageID string `json:"user_message_id"` + AssistantMessageID string `json:"assistant_message_id"` + Revision int64 `json:"revision"` + UpdatedAt time.Time `json:"updated_at"` +} + // Role values for Message. const ( RoleSystem = "system" @@ -304,6 +342,13 @@ type Store interface { CountEmptySessions(ctx context.Context) (int64, error) PruneSessions(ctx context.Context, olderThan time.Time) (int64, error) + PutCursorSessionState(ctx context.Context, state *CursorSessionState) error + GetCursorSessionState(ctx context.Context, sessionID string) (*CursorSessionState, error) + ListRecoverableCursorSessionStates(ctx context.Context) ([]CursorSessionState, error) + CompareAndSwapCursorSessionState(ctx context.Context, state *CursorSessionState, expectedRevision int64) (bool, error) + InvalidateCursorReuse(ctx context.Context, sessionID string) error + CommitCursorAssistant(ctx context.Context, state *CursorSessionState, message *Message) error + AppendMessage(ctx context.Context, m *Message) error ListMessages(ctx context.Context, sessionID string, limit, offset int) ([]Message, error) DeleteMessage(ctx context.Context, id string) error diff --git a/internal/tools/cursor_agent.go b/internal/tools/cursor_agent.go index d14320d..826c0f8 100644 --- a/internal/tools/cursor_agent.go +++ b/internal/tools/cursor_agent.go @@ -2,20 +2,29 @@ package tools import ( "context" + "encoding/json" "errors" "fmt" "net/http" "net/url" + "regexp" "strconv" "strings" "time" - "github.com/enowdev/antares/internal/config" + "github.com/enowdev/antares/internal/approval" "github.com/enowdev/antares/internal/cursor" + "github.com/enowdev/antares/internal/cursorrun" ) type cursorAgentTool struct{} +const ( + maxCursorApprovalBytes = 4096 + maxCursorApprovalModelParams = 64 + maxCursorModelErrorRunes = 4096 +) + func (cursorAgentTool) Name() string { return "cursor_agent" } func (cursorAgentTool) Description() string { @@ -25,11 +34,23 @@ func (cursorAgentTool) Description() string { func (cursorAgentTool) Schema() map[string]any { return schema(map[string]any{ - "action": propEnum("Operation to perform.", "start", "follow_up", "cancel"), - "prompt": prop("string", "Task for start/follow_up."), - "agent_id": prop("string", "Cursor bc- agent id for follow_up/cancel."), - "run_id": prop("string", "Cursor run- id for cancel."), - "model": prop("string", "Optional model id returned by Cursor."), + "action": propEnum("Operation to perform.", "start", "follow_up", "cancel"), + "prompt": prop("string", "Task for start/follow_up."), + "agent_id": prop("string", "Cursor bc- agent id for follow_up/cancel."), + "run_id": prop("string", "Cursor run- id for cancel."), + "model": prop("string", "Optional model id returned by Cursor."), + "model_params": map[string]any{ + "type": "array", + "description": "Optional exact model variant parameters returned by Cursor.", + "items": map[string]any{ + "type": "object", + "properties": map[string]any{ + "id": prop("string", "Cursor model parameter id."), + "value": prop("string", "Exact Cursor model parameter value."), + }, + "required": []string{"id", "value"}, + }, + }, "repository_url": prop("string", "Optional HTTPS GitHub repository URL."), "starting_ref": prop("string", "Optional branch or commit SHA."), "pull_request_url": prop("string", "Optional GitHub pull request URL."), @@ -42,19 +63,167 @@ func (cursorAgentTool) Schema() map[string]any { func (cursorAgentTool) RequiresApproval() bool { return true } +func (cursorAgentTool) ApprovalOperation(raw json.RawMessage, sessionID string) (approval.Operation, error) { + var args cursorAgentArgs + if len(raw) > 0 { + if err := json.Unmarshal(raw, &args); err != nil { + return approval.Operation{}, fmt.Errorf("invalid arguments: %w", err) + } + } + args.trim() + if err := validateCursorAgentArgs(args); err != nil { + return approval.Operation{}, err + } + display, err := cursorAgentApprovalDisplay(args) + if err != nil { + return approval.Operation{}, err + } + return approval.Operation{ + SessionID: sessionID, + Tool: "cursor_agent", + Arguments: string(display), + Message: cursorApprovalMessage(args.Action), + Reason: "Cursor operations are paid and change remote state", + }, nil +} + +type cursorAgentApprovalProjection struct { + Action string `json:"action"` + AgentID string `json:"agent_id,omitempty"` + RunID string `json:"run_id,omitempty"` + Model string `json:"model,omitempty"` + ModelParams *[]cursor.ModelParameterSelection `json:"model_params,omitempty"` + RepositoryURL string `json:"repository_url,omitempty"` + StartingRef string `json:"starting_ref,omitempty"` + PullRequestURL string `json:"pull_request_url,omitempty"` + Mode string `json:"mode,omitempty"` + AutoCreatePR *bool `json:"auto_create_pr,omitempty"` + SkipReviewerRequest *bool `json:"skip_reviewer_request,omitempty"` + Wait *bool `json:"wait,omitempty"` +} + +func cursorAgentApprovalDisplay(args cursorAgentArgs) ([]byte, error) { + if len(args.ModelParams) > maxCursorApprovalModelParams { + return nil, errors.New("Cursor approval projection exceeds the safe display limit") + } + projection := cursorAgentApprovalProjection{ + Action: boundCursorApprovalField(args.Action), + AgentID: boundCursorApprovalField(args.AgentID), + RunID: boundCursorApprovalField(args.RunID), + Model: boundCursorApprovalField(args.Model), + RepositoryURL: boundCursorApprovalField(args.RepositoryURL), + StartingRef: boundCursorApprovalField(args.StartingRef), + PullRequestURL: boundCursorApprovalField(args.PullRequestURL), + Mode: boundCursorApprovalField(args.Mode), + } + if args.modelParamsSet { + params := make([]cursor.ModelParameterSelection, 0, len(args.ModelParams)) + for _, parameter := range args.ModelParams { + params = append(params, cursor.ModelParameterSelection{ + ID: boundCursorApprovalField(parameter.ID), + Value: boundCursorApprovalField(parameter.Value), + }) + } + projection.ModelParams = ¶ms + } + switch args.Action { + case "start": + autoCreatePR := args.AutoCreatePR + skipReviewer := true + if args.SkipReviewerRequest != nil { + skipReviewer = *args.SkipReviewerRequest + } + wait := true + if args.Wait != nil { + wait = *args.Wait + } + projection.AutoCreatePR = &autoCreatePR + projection.SkipReviewerRequest = &skipReviewer + projection.Wait = &wait + case "follow_up": + wait := true + if args.Wait != nil { + wait = *args.Wait + } + projection.Wait = &wait + } + display, err := json.Marshal(projection) + if err != nil { + return nil, fmt.Errorf("build approval projection: %w", err) + } + if len(display) > maxCursorApprovalBytes { + return nil, errors.New("Cursor approval projection exceeds the safe display limit") + } + return display, nil +} + +func cursorApprovalMessage(action string) string { + switch action { + case "start": + return "Start Cursor Cloud Agent run" + case "follow_up": + return "Continue Cursor Cloud Agent run" + case "cancel": + return "Cancel Cursor Cloud Agent run" + default: + return "Run Cursor Cloud Agent operation" + } +} + +func boundCursorApprovalField(value string) string { + const maxRunes = 128 + value = strings.ToValidUTF8(value, "\uFFFD") + if cursorKeyLikeToken.MatchString(value) { + return "[REDACTED]" + } + runes := []rune(value) + if len(runes) > maxRunes { + return string(runes[:maxRunes]) + "…" + } + return value +} + +var cursorKeyLikeToken = regexp.MustCompile(`(?i)crsr_[a-z0-9_-]+`) + type cursorAgentArgs struct { - Action string `json:"action"` - Prompt string `json:"prompt"` - AgentID string `json:"agent_id"` - RunID string `json:"run_id"` - Model string `json:"model"` - RepositoryURL string `json:"repository_url"` - StartingRef string `json:"starting_ref"` - PullRequestURL string `json:"pull_request_url"` - Mode string `json:"mode"` - AutoCreatePR bool `json:"auto_create_pr"` - SkipReviewerRequest *bool `json:"skip_reviewer_request"` - Wait *bool `json:"wait"` + Action string `json:"action"` + Prompt string `json:"prompt"` + AgentID string `json:"agent_id"` + RunID string `json:"run_id"` + Model string `json:"model"` + ModelParams []cursor.ModelParameterSelection `json:"model_params"` + RepositoryURL string `json:"repository_url"` + StartingRef string `json:"starting_ref"` + PullRequestURL string `json:"pull_request_url"` + Mode string `json:"mode"` + AutoCreatePR bool `json:"auto_create_pr"` + SkipReviewerRequest *bool `json:"skip_reviewer_request"` + Wait *bool `json:"wait"` + modelParamsSet bool +} + +func (a *cursorAgentArgs) UnmarshalJSON(data []byte) error { + type wireArgs cursorAgentArgs + var decoded wireArgs + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return err + } + for key := range fields { + if key != "model_params" && strings.EqualFold(key, "model_params") { + return errors.New("model_params must use its canonical field name") + } + } + rawParams, modelParamsSet := fields["model_params"] + if modelParamsSet && strings.TrimSpace(string(rawParams)) == "null" { + return errors.New("model_params must be an array") + } + *a = cursorAgentArgs(decoded) + a.modelParamsSet = modelParamsSet + return nil } func (a *cursorAgentArgs) trim() { @@ -79,23 +248,23 @@ func (cursorAgentTool) Execute(ctx context.Context, in Input) Result { return Errorf("%v", err) } - client, provider, err := cursorClientFromInput(in) + runner, err := cursorRunnerFromInput(in) if err != nil { - return safeCursorResultError(err, args.AgentID, args.RunID, provider.APIKey) + return safeCursorResultError(err, args.AgentID, args.RunID, in) } switch args.Action { case "start": - return startCursorAgent(ctx, in, client, provider, args) + return startCursorAgent(ctx, in, runner, args) case "follow_up": - return followUpCursorAgent(ctx, in, client, provider, args) + return followUpCursorAgent(ctx, in, runner, args) case "cancel": - err := client.CancelRun(ctx, args.AgentID, args.RunID) + err := runner.CancelRun(ctx, args.AgentID, args.RunID) if err != nil { - return cursorOperationError(err, "run", args.AgentID, args.RunID, provider.APIKey) + return cursorOperationError(err, "run", args.AgentID, args.RunID, in) } - agentID := redactCursorString(args.AgentID, provider.APIKey) - runID := redactCursorString(args.RunID, provider.APIKey) + agentID := redactCursorString(args.AgentID, cursorSecretFromInput(in)) + runID := redactCursorString(args.RunID, cursorSecretFromInput(in)) return Result{ Content: fmt.Sprintf("Cursor cancellation requested.\nagent_id: %s\nrun_id: %s", agentID, runID), Meta: map[string]any{ @@ -112,8 +281,7 @@ func (cursorAgentTool) Execute(ctx context.Context, in Input) Result { func startCursorAgent( ctx context.Context, in Input, - client *cursor.Client, - provider config.Provider, + runner cursorrun.Runner, args cursorAgentArgs, ) Result { wait := true @@ -135,10 +303,23 @@ func startCursorAgent( } var model *cursor.ModelSelection if args.Model != "" { - model = &cursor.ModelSelection{ID: args.Model} + model = &cursor.ModelSelection{ + ID: args.Model, + Params: append([]cursor.ModelParameterSelection(nil), args.ModelParams...), + } + if args.modelParamsSet && model.Params == nil { + model.Params = []cursor.ModelParameterSelection{} + } + policy := cursorrun.PreserveUpstreamDefault + if args.modelParamsSet { + policy = cursorrun.RequireExactVariant + } + if _, err := runner.ValidateModel(ctx, model, policy); err != nil { + return cursorModelSelectionError(err, in) + } } - created, err := client.CreateAgent(ctx, cursor.CreateAgentRequest{ + created, err := runner.CreateAgent(ctx, cursor.CreateAgentRequest{ Prompt: cursor.Prompt{Text: args.Prompt}, Model: model, Repos: repos, @@ -147,44 +328,43 @@ func startCursorAgent( Mode: args.Mode, }) if err != nil { - return cursorOperationError(err, "agent", "", "", provider.APIKey) + return cursorOperationError(err, "agent", "", "", in) } if created == nil { return cursorResultError(errors.New("Cursor returned an empty create response"), "", "") } if !wait { - return cursorRunResult(created.Agent, created.Run, provider.APIKey, true) + return cursorRunResult(created.Agent, created.Run, true) } - waitCtx, cancel := cursorWaitContext(ctx, provider) + waitCtx, cancel := cursorWaitContext(ctx, in) defer cancel() - return waitCursorRun(waitCtx, in, client, created.Agent, created.Run) + return waitCursorRun(waitCtx, in, runner, created.Agent, created.Run) } func followUpCursorAgent( ctx context.Context, in Input, - client *cursor.Client, - provider config.Provider, + runner cursorrun.Runner, args cursorAgentArgs, ) Result { - agent, err := client.GetAgent(ctx, args.AgentID) + agent, err := runner.GetAgent(ctx, args.AgentID) if err != nil { - return cursorOperationError(err, "agent", args.AgentID, "", provider.APIKey) + return cursorOperationError(err, "agent", args.AgentID, "", in) } - run, err := client.CreateRun(ctx, args.AgentID, cursor.CreateRunRequest{ + run, err := runner.CreateRun(ctx, args.AgentID, cursor.CreateRunRequest{ Prompt: cursor.Prompt{Text: args.Prompt}, Mode: args.Mode, }) if err != nil { - return cursorOperationError(err, "agent", args.AgentID, "", provider.APIKey) + return cursorOperationError(err, "agent", args.AgentID, "", in) } if agent == nil || run == nil { return safeCursorResultError( errors.New("Cursor returned an empty follow-up response"), args.AgentID, "", - provider.APIKey, + in, ) } @@ -193,12 +373,12 @@ func followUpCursorAgent( wait = *args.Wait } if !wait { - return cursorRunResult(*agent, *run, provider.APIKey, true) + return cursorRunResult(*agent, *run, true) } - waitCtx, cancel := cursorWaitContext(ctx, provider) + waitCtx, cancel := cursorWaitContext(ctx, in) defer cancel() - return waitCursorRun(waitCtx, in, client, *agent, *run) + return waitCursorRun(waitCtx, in, runner, *agent, *run) } func validateCursorAgentArgs(args cursorAgentArgs) error { @@ -235,7 +415,10 @@ func validateCursorAgentArgs(args cursorAgentArgs) error { if args.AutoCreatePR && args.RepositoryURL == "" { return errors.New("repository_url is required when auto_create_pr is true") } - return nil + if args.modelParamsSet && args.Model == "" { + return errors.New("model is required when model_params is set") + } + return validateCursorAgentApprovalBounds(args) case "follow_up": if args.AgentID == "" { return errors.New("agent_id is required for follow_up") @@ -252,7 +435,10 @@ func validateCursorAgentArgs(args cursorAgentArgs) error { if err := rejectCursorStartFields(args, "follow_up"); err != nil { return err } - return validateCursorMode(args.Mode) + if err := validateCursorMode(args.Mode); err != nil { + return err + } + return validateCursorAgentApprovalBounds(args) case "cancel": if args.AgentID == "" { return errors.New("agent_id is required for cancel") @@ -278,16 +464,23 @@ func validateCursorAgentArgs(args cursorAgentArgs) error { if err := rejectCursorStartFields(args, "cancel"); err != nil { return err } - return nil + return validateCursorAgentApprovalBounds(args) default: return errors.New("action must be start, follow_up, or cancel") } } +func validateCursorAgentApprovalBounds(args cursorAgentArgs) error { + _, err := cursorAgentApprovalDisplay(args) + return err +} + func rejectCursorStartFields(args cursorAgentArgs, action string) error { switch { case args.Model != "": return fmt.Errorf("model is not allowed for %s", action) + case args.modelParamsSet: + return fmt.Errorf("model_params is not allowed for %s", action) case args.RepositoryURL != "": return fmt.Errorf("repository_url is not allowed for %s", action) case args.StartingRef != "": @@ -317,20 +510,11 @@ func validateCursorID(value, prefix, field string) error { return nil } -func cursorClientFromInput(in Input) (*cursor.Client, config.Provider, error) { - if in.Deps == nil || in.Deps.Config == nil { - return nil, config.Provider{}, errors.New("Cursor is unavailable in this runtime") - } - _, provider := in.Deps.Config.ResolveProvider("cursor") - provider.APIKey = strings.TrimSpace(provider.APIKey) - if !provider.Enabled || provider.APIKey == "" { - return nil, provider, errors.New("connect Cursor in Providers or set CURSOR_API_KEY") +func cursorRunnerFromInput(in Input) (cursorrun.Runner, error) { + if in.Deps == nil || in.Deps.Cursor == nil { + return nil, errors.New("Cursor is unavailable in this runtime") } - client, err := cursor.New(cursor.Options{ - BaseURL: provider.BaseURL, - APIKey: provider.APIKey, - }) - return client, provider, err + return in.Deps.Cursor, nil } func validateCursorRepository(raw string) error { @@ -370,8 +554,12 @@ func parseCursorGitHubURL(raw string) (*url.URL, error) { return parsed, nil } -func cursorWaitContext(ctx context.Context, provider config.Provider) (context.Context, context.CancelFunc) { - timeout := time.Duration(provider.TimeoutSecs) * time.Second +func cursorWaitContext(ctx context.Context, in Input) (context.Context, context.CancelFunc) { + var timeout time.Duration + if in.Deps != nil && in.Deps.Config != nil { + _, provider := in.Deps.Config.ResolveProvider("cursor") + timeout = time.Duration(provider.TimeoutSecs) * time.Second + } if timeout <= 0 { timeout = 15 * time.Minute } @@ -381,7 +569,7 @@ func cursorWaitContext(ctx context.Context, provider config.Provider) (context.C func waitCursorRun( ctx context.Context, in Input, - client *cursor.Client, + runner cursorrun.Runner, agent cursor.Agent, run cursor.Run, ) Result { @@ -390,16 +578,15 @@ func waitCursorRun( agentID = run.AgentID } runID := run.ID - terminal, err := client.StreamRun(ctx, agentID, runID, func(event cursor.StreamEvent) error { - emitCursorEvent(in, event) + terminal, err := runner.StreamRun(ctx, agentID, runID, "", nil, func(event cursor.StreamEvent) error { + emitCursorEvent(in, runner, event) return nil }) - secret := cursorSecretFromInput(in) if err != nil { - return cursorOperationError(err, "run", agentID, runID, secret) + return cursorOperationError(err, "run", agentID, runID, in) } if terminal == nil { - return safeCursorResultError(errors.New("Cursor stream returned no run"), agentID, runID, secret) + return safeCursorResultError(errors.New("Cursor stream returned no run"), agentID, runID, in) } if terminal.ID == "" { terminal.ID = runID @@ -407,65 +594,47 @@ func waitCursorRun( if terminal.AgentID == "" { terminal.AgentID = agentID } - return cursorRunResult(agent, *terminal, secret, false) + return cursorRunResult(agent, *terminal, false) } -func emitCursorEvent(in Input, event cursor.StreamEvent) { - secret := cursorSecretFromInput(in) - message := "Cursor " + redactCursorString(event.Type, secret) - chunk := redactCursorString(event.Text, secret) - if event.ToolName != "" { - message = "Cursor tool " + - redactCursorString(event.ToolName, secret) + " " + - redactCursorString(event.Status, secret) - } - message = boundCursorProgress(message) - chunk = boundCursorProgress(chunk) +func emitCursorEvent(in Input, runner cursorrun.Runner, event cursor.StreamEvent) { + progress := runner.Progress(event) if in.Emit != nil { - in.Emit(Progress{Tool: "cursor_agent", Message: message, Chunk: chunk}) - } -} - -func boundCursorProgress(value string) string { - const maxRunes = 2000 - value = strings.ToValidUTF8(value, "\uFFFD") - runes := []rune(value) - if len(runes) > maxRunes { - return string(runes[:maxRunes]) + "…" + in.Emit(Progress{ + Tool: "cursor_agent", Message: progress.Message, Chunk: progress.Chunk, + }) } - return value } func cursorAgentStatusResult( ctx context.Context, in Input, - client *cursor.Client, - provider config.Provider, + runner cursorrun.Runner, agent cursor.Agent, runID string, wait bool, ) Result { if wait { - waitCtx, cancel := cursorWaitContext(ctx, provider) + waitCtx, cancel := cursorWaitContext(ctx, in) defer cancel() - return waitCursorRun(waitCtx, in, client, agent, cursor.Run{ID: runID, AgentID: agent.ID}) + return waitCursorRun(waitCtx, in, runner, agent, cursor.Run{ID: runID, AgentID: agent.ID}) } - run, err := client.GetRun(ctx, agent.ID, runID) + run, err := runner.GetRun(ctx, agent.ID, runID) if err != nil { - return cursorOperationError(err, "run", agent.ID, runID, provider.APIKey) + return cursorOperationError(err, "run", agent.ID, runID, in) } if run == nil { return safeCursorResultError( errors.New("Cursor returned an empty run response"), agent.ID, runID, - provider.APIKey, + in, ) } - return cursorRunResult(agent, *run, provider.APIKey, true) + return cursorRunResult(agent, *run, true) } -func cursorRunResult(agent cursor.Agent, run cursor.Run, secret string, discouragePolling bool) Result { +func cursorRunResult(agent cursor.Agent, run cursor.Run, discouragePolling bool) Result { agentID := agent.ID if agentID == "" { agentID = run.AgentID @@ -479,12 +648,9 @@ func cursorRunResult(agent cursor.Agent, run cursor.Run, secret string, discoura git = agent.Git } - agentID = redactCursorString(agentID, secret) - runID := redactCursorString(run.ID, secret) - status = redactCursorString(status, secret) - cursorURL := redactCursorString(agent.URL, secret) - resultText := redactCursorString(run.Result, secret) - safeGit := sanitizeCursorGit(git, secret) + runID := run.ID + cursorURL := agent.URL + resultText := run.Result meta := map[string]any{ "agent_id": agentID, @@ -494,8 +660,8 @@ func cursorRunResult(agent cursor.Agent, run cursor.Run, secret string, discoura "duration_ms": run.DurationMS, "result": resultText, } - if safeGit != nil { - meta["git"] = safeGit + if git != nil { + meta["git"] = git } var content strings.Builder @@ -508,9 +674,9 @@ func cursorRunResult(agent cursor.Agent, run cursor.Run, secret string, discoura if resultText != "" { fmt.Fprintf(&content, "\n\nResult:\n%s", resultText) } - if safeGit != nil && len(safeGit.Branches) > 0 { + if git != nil && len(git.Branches) > 0 { content.WriteString("\n\nGit:") - for _, branch := range safeGit.Branches { + for _, branch := range git.Branches { fmt.Fprintf(&content, "\n- %s", branch.RepoURL) if branch.Branch != "" { fmt.Fprintf(&content, " — %s", branch.Branch) @@ -526,21 +692,6 @@ func cursorRunResult(agent cursor.Agent, run cursor.Run, secret string, discoura return Result{Content: content.String(), Meta: meta} } -func sanitizeCursorGit(git *cursor.GitState, secret string) *cursor.GitState { - if git == nil { - return nil - } - safe := &cursor.GitState{Branches: make([]cursor.GitBranch, len(git.Branches))} - for i, branch := range git.Branches { - safe.Branches[i] = cursor.GitBranch{ - RepoURL: redactCursorString(branch.RepoURL, secret), - Branch: redactCursorString(branch.Branch, secret), - PRURL: redactCursorString(branch.PRURL, secret), - } - } - return safe -} - func cursorSecretFromInput(in Input) string { if in.Deps == nil || in.Deps.Config == nil { return "" @@ -556,23 +707,6 @@ func redactCursorString(value, secret string) string { return strings.ReplaceAll(value, secret, "[REDACTED]") } -func sanitizeCursorError(err error, secret string) error { - if err == nil || secret == "" { - return err - } - if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { - return err - } - var apiErr *cursor.APIError - if errors.As(err, &apiErr) { - safe := *apiErr - safe.Code = redactCursorString(safe.Code, secret) - safe.Message = redactCursorString(safe.Message, secret) - return &safe - } - return errors.New(redactCursorString(err.Error(), secret)) -} - func cursorResultError(err error, agentID, runID string) Result { meta := map[string]any{"agent_id": agentID, "run_id": runID} switch { @@ -596,14 +730,44 @@ func cursorResultError(err error, agentID, runID string) Result { } } -func safeCursorResultError(err error, agentID, runID, secret string) Result { +func safeCursorResultError(err error, agentID, runID string, in Input) Result { + secret := cursorSecretFromInput(in) return cursorResultError( - sanitizeCursorError(err, secret), + err, redactCursorString(agentID, secret), redactCursorString(runID, secret), ) } +func cursorModelSelectionError(err error, in Input) Result { + meta := map[string]any{"agent_id": "", "run_id": ""} + var content string + switch { + case cursor.IsAuthError(err): + content = "Cursor model selection failed: API key was rejected." + case cursor.IsRateLimit(err): + var apiErr *cursor.APIError + _ = errors.As(err, &apiErr) + if apiErr != nil && apiErr.RetryAfter > 0 { + meta["retry_after_seconds"] = int(apiErr.RetryAfter.Seconds()) + } + content = "Cursor model selection failed: rate limit reached; retry later." + case errors.Is(err, context.DeadlineExceeded): + content = "Cursor model selection timed out before a run was created." + case errors.Is(err, context.Canceled): + content = "Cursor model selection was cancelled before a run was created." + default: + detail := redactCursorString(err.Error(), cursorSecretFromInput(in)) + content = "Cursor model selection failed: " + detail + } + content = strings.ToValidUTF8(content, "\uFFFD") + runes := []rune(content) + if len(runes) > maxCursorModelErrorRunes { + content = string(runes[:maxCursorModelErrorRunes-1]) + "…" + } + return Result{Content: content, Meta: meta, IsError: true} +} + // cursorNotFoundLabel prefers Cursor's typed code over the caller's guess: // cancelling a run whose agent is gone reports the agent, not the run. Only // these fixed labels are returned, never the upstream code itself. @@ -623,8 +787,8 @@ func cursorNotFoundLabel(err error, missingKind string) string { return "Cursor run not found" } -func cursorOperationError(err error, missingKind, agentID, runID, secret string) Result { - err = sanitizeCursorError(err, secret) +func cursorOperationError(err error, missingKind, agentID, runID string, in Input) Result { + secret := cursorSecretFromInput(in) agentID = redactCursorString(agentID, secret) runID = redactCursorString(runID, secret) meta := map[string]any{"agent_id": agentID, "run_id": runID} @@ -676,20 +840,20 @@ func (cursorAgentStatusTool) Execute(ctx context.Context, in Input) Result { } } - client, provider, err := cursorClientFromInput(in) + runner, err := cursorRunnerFromInput(in) if err != nil { - return safeCursorResultError(err, args.AgentID, args.RunID, provider.APIKey) + return safeCursorResultError(err, args.AgentID, args.RunID, in) } - agent, err := client.GetAgent(ctx, args.AgentID) + agent, err := runner.GetAgent(ctx, args.AgentID) if err != nil { - return cursorOperationError(err, "agent", args.AgentID, args.RunID, provider.APIKey) + return cursorOperationError(err, "agent", args.AgentID, args.RunID, in) } if agent == nil { return safeCursorResultError( errors.New("Cursor returned an empty agent response"), args.AgentID, args.RunID, - provider.APIKey, + in, ) } @@ -701,16 +865,16 @@ func (cursorAgentStatusTool) Execute(ctx context.Context, in Input) Result { errors.New("Cursor agent has no latest run"), args.AgentID, "", - provider.APIKey, + in, ) } if err := validateCursorID(runID, "run-", "run_id"); err != nil { - return safeCursorResultError(err, args.AgentID, runID, provider.APIKey) + return safeCursorResultError(err, args.AgentID, runID, in) } } wait := false if args.Wait != nil { wait = *args.Wait } - return cursorAgentStatusResult(ctx, in, client, provider, *agent, runID, wait) + return cursorAgentStatusResult(ctx, in, runner, *agent, runID, wait) } diff --git a/internal/tools/cursor_agent_test.go b/internal/tools/cursor_agent_test.go index 4965231..dde8bd2 100644 --- a/internal/tools/cursor_agent_test.go +++ b/internal/tools/cursor_agent_test.go @@ -14,6 +14,8 @@ import ( "unicode/utf8" "github.com/enowdev/antares/internal/config" + "github.com/enowdev/antares/internal/cursor" + "github.com/enowdev/antares/internal/cursorrun" ) const cursorToolTestKey = "synthetic-key" @@ -30,10 +32,30 @@ func cursorToolTestConfig(baseURL string) *config.Config { return cfg } +func cursorToolTestRunner(cfg *config.Config) cursorrun.Runner { + return cursorrun.New(cursorrun.Options{ + ResolveClient: func() (cursor.Options, error) { + if cfg == nil { + return cursor.Options{}, fmt.Errorf("Cursor is unavailable in this runtime") + } + _, provider := cfg.ResolveProvider("cursor") + provider.APIKey = strings.TrimSpace(provider.APIKey) + options := cursor.Options{ + BaseURL: provider.BaseURL, + APIKey: provider.APIKey, + } + if !provider.Enabled || provider.APIKey == "" { + return options, fmt.Errorf("connect Cursor in Providers or set CURSOR_API_KEY") + } + return options, nil + }, + }) +} + func cursorToolTestInput(cfg *config.Config, args string, progress *[]Progress) Input { return Input{ Args: []byte(args), - Deps: &Deps{Config: cfg}, + Deps: &Deps{Config: cfg, Cursor: cursorToolTestRunner(cfg)}, Emit: func(p Progress) { if progress != nil { *progress = append(*progress, p) @@ -70,6 +92,13 @@ func TestCursorToolSchemasAndApprovalClassification(t *testing.T) { if got := agentProps["auto_create_pr"].(map[string]any)["default"]; got != false { t.Fatalf("auto_create_pr default = %#v, want false", got) } + modelParams, ok := agentProps["model_params"].(map[string]any) + if !ok { + t.Fatal("cursor_agent schema does not expose model_params") + } + if got := modelParams["type"]; got != "array" { + t.Fatalf("model_params type = %#v, want array", got) + } statusSchema := cursorAgentStatusTool{}.Schema() statusProps := statusSchema["properties"].(map[string]any) @@ -82,6 +111,628 @@ func TestCursorToolSchemasAndApprovalClassification(t *testing.T) { } } +func TestCursorAgentApprovalProjectionIsBoundedAndRedacted(t *testing.T) { + const secret = "must-not-appear-in-approval" + raw, err := json.Marshal(map[string]any{ + "action": "start", + "prompt": "Fix the issue using " + secret, + "model": "composer-2", + "model_params": []map[string]string{ + {"id": "context", "value": "1m"}, + {"id": "reasoning", "value": "max"}, + }, + "repository_url": "https://github.com/acme/repo", + "starting_ref": "main", + "pull_request_url": "https://github.com/acme/repo/pull/7", + "mode": "plan", + "auto_create_pr": true, + "skip_reviewer_request": false, + "wait": false, + "images": []string{"data:image/png;base64," + secret}, + "api_key": secret, + "untrusted": map[string]any{"instructions": secret}, + }) + if err != nil { + t.Fatal(err) + } + + op, err := (cursorAgentTool{}).ApprovalOperation(raw, "ses-one") + if err != nil { + t.Fatalf("ApprovalOperation: %v", err) + } + if op.SessionID != "ses-one" || op.Tool != "cursor_agent" { + t.Fatalf("operation identity = %+v", op) + } + if op.Message != "Start Cursor Cloud Agent run" { + t.Fatalf("operation message = %q", op.Message) + } + if len(op.Arguments) > 4096 { + t.Fatalf("approval projection has %d bytes, want at most 4096", len(op.Arguments)) + } + for _, forbidden := range []string{secret, "prompt", "images", "api_key", "untrusted", "instructions"} { + if strings.Contains(op.Arguments, forbidden) { + t.Errorf("approval projection contains forbidden %q: %s", forbidden, op.Arguments) + } + } + + var projection map[string]any + if err := json.Unmarshal([]byte(op.Arguments), &projection); err != nil { + t.Fatalf("approval projection is not JSON: %v", err) + } + want := map[string]any{ + "action": "start", + "model": "composer-2", + "model_params": []any{ + map[string]any{"id": "context", "value": "1m"}, + map[string]any{"id": "reasoning", "value": "max"}, + }, + "repository_url": "https://github.com/acme/repo", + "starting_ref": "main", + "pull_request_url": "https://github.com/acme/repo/pull/7", + "mode": "plan", + "auto_create_pr": true, + "skip_reviewer_request": false, + "wait": false, + } + for key, value := range want { + if key == "model_params" { + got, _ := json.Marshal(projection[key]) + expected, _ := json.Marshal(value) + if string(got) != string(expected) { + t.Errorf("projection[%q] = %s, want %s", key, got, expected) + } + continue + } + if got := projection[key]; got != value { + t.Errorf("projection[%q] = %#v, want %#v", key, got, value) + } + } + + longValue := strings.Repeat("界", 10_000) + longRaw, _ := json.Marshal(map[string]any{ + "action": "start", + "prompt": secret, + "model": longValue, + "model_params": []map[string]string{{ + "id": longValue, "value": "crsr_projection_secret_123.tail", + }}, + "repository_url": "https://github.com/acme/repo", + "starting_ref": longValue, + }) + longOp, err := (cursorAgentTool{}).ApprovalOperation(longRaw, "ses-long") + if err != nil { + t.Fatalf("long ApprovalOperation: %v", err) + } + if len(longOp.Arguments) > 4096 { + t.Fatalf("long approval projection has %d bytes, want at most 4096", len(longOp.Arguments)) + } + if strings.Contains(longOp.Arguments, longValue) { + t.Fatal("long approval field was not bounded") + } + if strings.Contains(longOp.Arguments, "tail") { + t.Fatal("model parameter key-like value was not whole-field redacted") + } + + manyParams := make([]map[string]string, maxCursorApprovalModelParams) + for i := range manyParams { + manyParams[i] = map[string]string{ + "id": fmt.Sprintf("parameter-%03d-%s", i, longValue), + "value": fmt.Sprintf("value-%03d-%s", i, longValue), + } + } + manyRaw, _ := json.Marshal(map[string]any{ + "action": "start", + "prompt": secret, + "model": "gpt-5.6-sol", + "model_params": manyParams, + }) + if _, err := (cursorAgentTool{}).ApprovalOperation(manyRaw, "ses-many"); err == nil || + !strings.Contains(err.Error(), "safe display limit") { + t.Fatalf("oversized byte projection error = %v", err) + } + + tooManyParams := make([]map[string]string, maxCursorApprovalModelParams+1) + for i := range tooManyParams { + tooManyParams[i] = map[string]string{ + "id": fmt.Sprintf("parameter-%03d", i), + "value": fmt.Sprintf("value-%03d", i), + } + } + tooManyRaw, _ := json.Marshal(map[string]any{ + "action": "start", + "prompt": secret, + "model": "gpt-5.6-sol", + "model_params": tooManyParams, + }) + if _, err := (cursorAgentTool{}).ApprovalOperation(tooManyRaw, "ses-too-many"); err == nil || + !strings.Contains(err.Error(), "safe display limit") { + t.Fatalf("oversized parameter-count projection error = %v", err) + } + + mediumParams := make([]map[string]string, 20) + for i := range mediumParams { + mediumParams[i] = map[string]string{ + "id": fmt.Sprintf("parameter-%02d", i), + "value": fmt.Sprintf("value-%02d", i), + } + } + mediumRaw, _ := json.Marshal(map[string]any{ + "action": "start", + "prompt": secret, + "model": "gpt-5.6-sol", + "model_params": mediumParams, + }) + mediumOp, err := (cursorAgentTool{}).ApprovalOperation(mediumRaw, "ses-medium") + if err != nil { + t.Fatalf("medium-param ApprovalOperation: %v", err) + } + if len(mediumOp.Arguments) > 4096 { + t.Fatalf("medium-param approval projection has %d bytes, want at most 4096", len(mediumOp.Arguments)) + } + var mediumProjection struct { + ModelParams []cursor.ModelParameterSelection `json:"model_params"` + } + if err := json.Unmarshal([]byte(mediumOp.Arguments), &mediumProjection); err != nil { + t.Fatal(err) + } + if len(mediumProjection.ModelParams) != len(mediumParams) { + t.Fatalf("approval retained %d model params, want all %d", + len(mediumProjection.ModelParams), len(mediumParams)) + } + for i, parameter := range mediumProjection.ModelParams { + if parameter.ID != mediumParams[i]["id"] || parameter.Value != mediumParams[i]["value"] { + t.Fatalf("approval param %d = %+v, want %+v", i, parameter, mediumParams[i]) + } + } +} + +type cursorToolRunnerStub struct { + validateCalls int + validated *cursor.ModelSelection + validationPolicy cursorrun.SelectionPolicy + validationErr error + validationResult *cursor.ModelSelection + createAgentCalls int + createAgentRequest cursor.CreateAgentRequest +} + +func (f *cursorToolRunnerStub) Catalog(context.Context, bool) (*cursor.ModelCatalog, error) { + return nil, fmt.Errorf("unexpected Catalog call") +} + +func (f *cursorToolRunnerStub) InvalidateCatalog() {} + +func (f *cursorToolRunnerStub) ValidateModel( + _ context.Context, + selection *cursor.ModelSelection, + policy cursorrun.SelectionPolicy, +) (*cursor.ModelSelection, error) { + f.validateCalls++ + f.validationPolicy = policy + if selection != nil { + params := append([]cursor.ModelParameterSelection(nil), selection.Params...) + if selection.Params != nil && params == nil { + params = []cursor.ModelParameterSelection{} + } + f.validated = &cursor.ModelSelection{ + ID: selection.ID, + Params: params, + } + } + if f.validationErr != nil { + return nil, f.validationErr + } + if f.validationResult != nil { + return &cursor.ModelSelection{ + ID: f.validationResult.ID, + Params: append([]cursor.ModelParameterSelection(nil), f.validationResult.Params...), + }, nil + } + if selection == nil { + return nil, nil + } + params := append([]cursor.ModelParameterSelection(nil), selection.Params...) + if selection.Params != nil && params == nil { + params = []cursor.ModelParameterSelection{} + } + return &cursor.ModelSelection{ + ID: selection.ID, + Params: params, + }, nil +} + +func (f *cursorToolRunnerStub) CreateAgent( + _ context.Context, + req cursor.CreateAgentRequest, +) (*cursor.CreateAgentResponse, error) { + f.createAgentCalls++ + f.createAgentRequest = req + return &cursor.CreateAgentResponse{ + Agent: cursor.Agent{ + ID: "bc-one", Status: "ACTIVE", + URL: "https://cursor.com/agents/bc-one", LatestRunID: "run-one", + }, + Run: cursor.Run{ID: "run-one", AgentID: "bc-one", Status: "CREATING"}, + }, nil +} + +func (f *cursorToolRunnerStub) CreateRun( + context.Context, + string, + cursor.CreateRunRequest, +) (*cursor.Run, error) { + return nil, fmt.Errorf("unexpected CreateRun call") +} + +func (f *cursorToolRunnerStub) GetAgent(context.Context, string) (*cursor.Agent, error) { + return nil, fmt.Errorf("unexpected GetAgent call") +} + +func (f *cursorToolRunnerStub) GetRun(context.Context, string, string) (*cursor.Run, error) { + return nil, fmt.Errorf("unexpected GetRun call") +} + +func (f *cursorToolRunnerStub) CancelRun(context.Context, string, string) error { + return fmt.Errorf("unexpected CancelRun call") +} + +func (f *cursorToolRunnerStub) StreamRun( + context.Context, + string, + string, + string, + func() error, + func(cursor.StreamEvent) error, +) (*cursor.Run, error) { + return nil, fmt.Errorf("unexpected StreamRun call") +} + +func (f *cursorToolRunnerStub) Progress(cursor.StreamEvent) cursorrun.Progress { + return cursorrun.Progress{} +} + +func TestCursorAgentStartAcceptsExactModelParams(t *testing.T) { + runner := &cursorToolRunnerStub{ + validationResult: &cursor.ModelSelection{ + ID: "gpt-5.6-sol", + Params: []cursor.ModelParameterSelection{ + {ID: "reasoning", Value: "max"}, + {ID: "context", Value: "1m"}, + }, + }, + } + args := `{ + "action":"start", + "prompt":"fix it", + "model":"gpt-5.6-sol", + "model_params":[ + {"id":"context","value":"1m"}, + {"id":"reasoning","value":"max"} + ], + "wait":false + }` + + got := (cursorAgentTool{}).Execute(context.Background(), Input{ + Args: []byte(args), + Deps: &Deps{Config: config.Default(), Cursor: runner}, + }) + if got.IsError { + t.Fatalf("start result = %+v", got) + } + if runner.validateCalls != 1 || runner.validationPolicy != cursorrun.RequireExactVariant { + t.Fatalf("validation calls/policy = %d/%v, want 1/RequireExactVariant", + runner.validateCalls, runner.validationPolicy) + } + want := []cursor.ModelParameterSelection{ + {ID: "context", Value: "1m"}, + {ID: "reasoning", Value: "max"}, + } + if runner.createAgentCalls != 1 || runner.createAgentRequest.Model == nil { + t.Fatalf("CreateAgent calls/request = %d/%+v", runner.createAgentCalls, runner.createAgentRequest) + } + gotParams := runner.createAgentRequest.Model.Params + if len(gotParams) != len(want) { + t.Fatalf("executed params = %+v, want %+v", gotParams, want) + } + for i := range want { + if gotParams[i] != want[i] { + t.Fatalf("executed params = %+v, want exact order %+v", gotParams, want) + } + } +} + +func TestCursorAgentModelWithoutParamsPreservesUpstreamDefault(t *testing.T) { + runner := &cursorToolRunnerStub{} + got := (cursorAgentTool{}).Execute(context.Background(), Input{ + Args: []byte(`{"action":"start","prompt":"fix it","model":"gpt-5.6-sol","wait":false}`), + Deps: &Deps{Config: config.Default(), Cursor: runner}, + }) + if got.IsError { + t.Fatalf("start result = %+v", got) + } + if runner.validateCalls != 1 || + runner.validationPolicy != cursorrun.PreserveUpstreamDefault || + runner.validated == nil || + runner.validated.ID != "gpt-5.6-sol" || + runner.validated.Params != nil { + t.Fatalf("default validation = calls %d policy %v selection %+v", + runner.validateCalls, runner.validationPolicy, runner.validated) + } + if runner.createAgentRequest.Model == nil || + runner.createAgentRequest.Model.ID != "gpt-5.6-sol" || + runner.createAgentRequest.Model.Params != nil { + t.Fatalf("executed default model = %+v", runner.createAgentRequest.Model) + } + wire, err := json.Marshal(runner.createAgentRequest) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(wire), `"params"`) { + t.Fatalf("omitted params changed the upstream default wire payload: %s", wire) + } +} + +func TestCursorAgentSuppliedEmptyParamsRequireExactVariant(t *testing.T) { + runner := &cursorToolRunnerStub{} + got := (cursorAgentTool{}).Execute(context.Background(), Input{ + Args: []byte(`{"action":"start","prompt":"fix it","model":"plain-model","model_params":[],"wait":false}`), + Deps: &Deps{Config: config.Default(), Cursor: runner}, + }) + if got.IsError { + t.Fatalf("start result = %+v", got) + } + if runner.validationPolicy != cursorrun.RequireExactVariant || + runner.validated == nil || runner.validated.Params == nil { + t.Fatalf("empty supplied params lost presence: policy=%v selection=%+v", + runner.validationPolicy, runner.validated) + } + if runner.createAgentRequest.Model == nil || + runner.createAgentRequest.Model.Params == nil || + len(runner.createAgentRequest.Model.Params) != 0 { + t.Fatalf("executed empty params = %+v, want present empty slice", runner.createAgentRequest.Model) + } + wire, err := json.Marshal(runner.createAgentRequest) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(wire), `"params":[]`) { + t.Fatalf("executed empty params were omitted from wire payload: %s", wire) + } +} + +func TestCursorAgentRejectsNullModelParams(t *testing.T) { + raw := []byte(`{ + "action":"start", + "prompt":"fix it", + "model":"plain-model", + "model_params":null, + "wait":false + }`) + runner := &cursorToolRunnerStub{} + got := (cursorAgentTool{}).Execute(context.Background(), Input{ + Args: raw, + Deps: &Deps{Config: config.Default(), Cursor: runner}, + }) + if !got.IsError || !strings.Contains(got.Content, "model_params must be an array") { + t.Fatalf("null model_params result = %+v", got) + } + if runner.validateCalls != 0 || runner.createAgentCalls != 0 { + t.Fatalf("null model_params calls = validate %d create %d, want 0/0", + runner.validateCalls, runner.createAgentCalls) + } + if _, err := (cursorAgentTool{}).ApprovalOperation(raw, "ses-null"); err == nil || + !strings.Contains(err.Error(), "model_params must be an array") { + t.Fatalf("null model_params approval error = %v", err) + } +} + +func TestCursorAgentRejectsNonCanonicalModelParamsKey(t *testing.T) { + raw := []byte(`{ + "action":"start", + "prompt":"fix it", + "model":"plain-model", + "MODEL_PARAMS":[{"id":"reasoning","value":"max"}], + "wait":false + }`) + runner := &cursorToolRunnerStub{} + got := (cursorAgentTool{}).Execute(context.Background(), Input{ + Args: raw, + Deps: &Deps{Config: config.Default(), Cursor: runner}, + }) + if !got.IsError || !strings.Contains(got.Content, `model_params must use its canonical field name`) { + t.Fatalf("noncanonical model_params result = %+v", got) + } + if runner.validateCalls != 0 || runner.createAgentCalls != 0 { + t.Fatalf("noncanonical model_params calls = validate %d create %d, want 0/0", + runner.validateCalls, runner.createAgentCalls) + } + if _, err := (cursorAgentTool{}).ApprovalOperation(raw, "ses-case"); err == nil || + !strings.Contains(err.Error(), `model_params must use its canonical field name`) { + t.Fatalf("noncanonical model_params approval error = %v", err) + } +} + +func TestCursorAgentRejectsInvalidParamsBeforeRunnerMutation(t *testing.T) { + runner := &cursorToolRunnerStub{ + validationErr: fmt.Errorf("cursor: parameters do not match a model variant"), + } + got := (cursorAgentTool{}).Execute(context.Background(), Input{ + Args: []byte(`{ + "action":"start", + "prompt":"fix it", + "model":"gpt-5.6-sol", + "model_params":[ + {"id":"context","value":"1m"}, + {"id":"reasoning","value":"synthetic"} + ], + "wait":false + }`), + Deps: &Deps{Config: config.Default(), Cursor: runner}, + }) + if !got.IsError || !strings.Contains(got.Content, "do not match") { + t.Fatalf("invalid variant result = %+v", got) + } + if runner.validateCalls != 1 || runner.createAgentCalls != 0 { + t.Fatalf("invalid variant calls = validate %d create %d, want 1/0", + runner.validateCalls, runner.createAgentCalls) + } +} + +func TestCursorAgentDirectExecutionEnforcesApprovalModelParamBounds(t *testing.T) { + longValue := strings.Repeat("界", 10_000) + oversizedParams := make([]map[string]string, maxCursorApprovalModelParams) + for i := range oversizedParams { + oversizedParams[i] = map[string]string{ + "id": fmt.Sprintf("parameter-%03d-%s", i, longValue), + "value": fmt.Sprintf("value-%03d-%s", i, longValue), + } + } + tooManyParams := make([]map[string]string, maxCursorApprovalModelParams+1) + for i := range tooManyParams { + tooManyParams[i] = map[string]string{ + "id": fmt.Sprintf("parameter-%03d", i), + "value": fmt.Sprintf("value-%03d", i), + } + } + + for _, tc := range []struct { + name string + params []map[string]string + }{ + {name: "encoded size", params: oversizedParams}, + {name: "parameter count", params: tooManyParams}, + } { + t.Run(tc.name, func(t *testing.T) { + raw, err := json.Marshal(map[string]any{ + "action": "start", + "prompt": "fix it", + "model": "gpt-5.6-sol", + "model_params": tc.params, + "wait": false, + }) + if err != nil { + t.Fatal(err) + } + runner := &cursorToolRunnerStub{} + got := (cursorAgentTool{}).Execute(context.Background(), Input{ + Args: raw, + Deps: &Deps{Config: config.Default(), Cursor: runner}, + }) + _, approvalErr := (cursorAgentTool{}).ApprovalOperation(raw, "ses-bounds") + if !got.IsError || approvalErr == nil || + got.Content != approvalErr.Error() || + !strings.Contains(got.Content, "safe display limit") { + t.Fatalf("direct/approval bounds = result %+v approval %v", got, approvalErr) + } + if runner.validateCalls != 0 || runner.createAgentCalls != 0 { + t.Fatalf("bounded args calls = validate %d create %d, want 0/0", + runner.validateCalls, runner.createAgentCalls) + } + }) + } +} + +func TestCursorAgentModelValidationErrorIsBoundedAndNotAnAgent404(t *testing.T) { + runner := &cursorToolRunnerStub{ + validationErr: &cursor.APIError{ + Status: http.StatusNotFound, + Code: "model_not_found", + Message: strings.Repeat("界", 10_000), + }, + } + got := (cursorAgentTool{}).Execute(context.Background(), Input{ + Args: []byte(`{"action":"start","prompt":"fix it","model":"missing-alias","wait":false}`), + Deps: &Deps{Config: config.Default(), Cursor: runner}, + }) + if !got.IsError || !strings.HasPrefix(got.Content, "Cursor model selection failed: ") { + t.Fatalf("model validation result = %+v", got) + } + if strings.Contains(got.Content, "Cursor agent not found") { + t.Fatalf("model validation was misclassified as agent 404: %q", got.Content) + } + if utf8.RuneCountInString(got.Content) > 4096 { + t.Fatalf("model validation error has %d runes, want at most 4096", + utf8.RuneCountInString(got.Content)) + } + if runner.createAgentCalls != 0 { + t.Fatalf("model validation failure made %d create calls", runner.createAgentCalls) + } +} + +func TestCursorApprovalFieldNormalizesBeforeWholeFieldTokenRedaction(t *testing.T) { + value := "visible-prefix-" + string([]byte{0xff}) + + "-crsr_synthetic_key_123.tail-must-not-leak" + + if got := boundCursorApprovalField(value); got != "[REDACTED]" { + t.Fatalf("key-like approval field = %q, want whole-field redaction", got) + } +} + +func TestCursorAgentApprovalProjectionIncludesFollowUpAndCancelIDs(t *testing.T) { + tests := []struct { + name string + raw string + wantMessage string + want map[string]any + }{ + { + name: "follow up", + raw: `{"action":"follow_up","agent_id":"bc-one","prompt":"continue privately","mode":"agent","wait":false}`, + wantMessage: "Continue Cursor Cloud Agent run", + want: map[string]any{ + "action": "follow_up", + "agent_id": "bc-one", + "mode": "agent", + "wait": false, + }, + }, + { + name: "cancel", + raw: `{"action":"cancel","agent_id":"bc-one","run_id":"run-one"}`, + wantMessage: "Cancel Cursor Cloud Agent run", + want: map[string]any{ + "action": "cancel", + "agent_id": "bc-one", + "run_id": "run-one", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + op, err := (cursorAgentTool{}).ApprovalOperation(json.RawMessage(tt.raw), "ses-one") + if err != nil { + t.Fatalf("ApprovalOperation: %v", err) + } + if op.Message != tt.wantMessage { + t.Fatalf("message = %q, want %q", op.Message, tt.wantMessage) + } + if strings.Contains(op.Arguments, "continue privately") || + strings.Contains(op.Arguments, "prompt") { + t.Fatalf("projection leaked prompt: %s", op.Arguments) + } + var got map[string]any + if err := json.Unmarshal([]byte(op.Arguments), &got); err != nil { + t.Fatal(err) + } + for key, want := range tt.want { + if got[key] != want { + t.Errorf("projection[%q] = %#v, want %#v", key, got[key], want) + } + } + }) + } +} + +func TestCursorAgentApprovalRejectsInvalidOperation(t *testing.T) { + if _, err := (cursorAgentTool{}).ApprovalOperation( + json.RawMessage(`{"action":"follow_up","agent_id":"bc-one"}`), + "ses-one", + ); err == nil || !strings.Contains(err.Error(), "prompt is required") { + t.Fatalf("invalid operation error = %v", err) + } +} + func TestCursorAgentRejectsMissingConfigAndInvalidRepository(t *testing.T) { in := cursorToolTestInput(config.Default(), `{"action":"start","prompt":"fix it"}`, nil) result := (cursorAgentTool{}).Execute(context.Background(), in) @@ -163,6 +814,7 @@ func TestCursorAgentValidatesActionSpecificArguments(t *testing.T) { {"start rejects agent id", `{"action":"start","prompt":"x","agent_id":"bc-one"}`, "agent_id is not allowed for start"}, {"start rejects run id", `{"action":"start","prompt":"x","run_id":"run-one"}`, "run_id is not allowed for start"}, {"start rejects mode", `{"action":"start","prompt":"x","mode":"ask"}`, "mode must be"}, + {"model params need model", `{"action":"start","prompt":"x","model_params":[{"id":"reasoning","value":"max"}]}`, "model is required when model_params is set"}, {"pull request needs repository", `{"action":"start","prompt":"x","pull_request_url":"https://github.com/acme/repo/pull/7"}`, "repository_url is required"}, {"auto PR needs repository", `{"action":"start","prompt":"x","auto_create_pr":true}`, "repository_url is required"}, {"pull request path", `{"action":"start","prompt":"x","repository_url":"https://github.com/acme/repo","pull_request_url":"https://github.com/acme/repo/issues/7"}`, "pull_request_url must be"}, @@ -171,6 +823,7 @@ func TestCursorAgentValidatesActionSpecificArguments(t *testing.T) { {"follow-up missing prompt", `{"action":"follow_up","agent_id":"bc-one","wait":false}`, "prompt is required"}, {"follow-up rejects run id", `{"action":"follow_up","agent_id":"bc-one","run_id":"run-one","prompt":"x","wait":false}`, "run_id is not allowed for follow_up"}, {"follow-up rejects model", `{"action":"follow_up","agent_id":"bc-one","prompt":"x","model":"composer-2","wait":false}`, "model is not allowed for follow_up"}, + {"follow-up rejects model params", `{"action":"follow_up","agent_id":"bc-one","prompt":"x","model_params":[],"wait":false}`, "model_params is not allowed for follow_up"}, {"follow-up rejects repository", `{"action":"follow_up","agent_id":"bc-one","prompt":"x","repository_url":"https://github.com/acme/repo","wait":false}`, "repository_url is not allowed for follow_up"}, {"follow-up rejects ref", `{"action":"follow_up","agent_id":"bc-one","prompt":"x","starting_ref":"main","wait":false}`, "starting_ref is not allowed for follow_up"}, {"follow-up rejects pull request", `{"action":"follow_up","agent_id":"bc-one","prompt":"x","pull_request_url":"https://github.com/acme/repo/pull/7","wait":false}`, "pull_request_url is not allowed for follow_up"}, @@ -184,6 +837,7 @@ func TestCursorAgentValidatesActionSpecificArguments(t *testing.T) { {"cancel rejects prompt", `{"action":"cancel","agent_id":"bc-one","run_id":"run-one","prompt":"x"}`, "prompt is not allowed for cancel"}, {"cancel rejects mode", `{"action":"cancel","agent_id":"bc-one","run_id":"run-one","mode":"agent"}`, "mode is not allowed for cancel"}, {"cancel rejects wait", `{"action":"cancel","agent_id":"bc-one","run_id":"run-one","wait":false}`, "wait is not allowed for cancel"}, + {"cancel rejects model params", `{"action":"cancel","agent_id":"bc-one","run_id":"run-one","model_params":[]}`, "model_params is not allowed for cancel"}, {"cancel rejects repository", `{"action":"cancel","agent_id":"bc-one","run_id":"run-one","repository_url":"https://github.com/acme/repo"}`, "repository_url is not allowed for cancel"}, {"cancel rejects reviewer option", `{"action":"cancel","agent_id":"bc-one","run_id":"run-one","skip_reviewer_request":true}`, "skip_reviewer_request is not allowed for cancel"}, } @@ -223,10 +877,18 @@ func TestCursorAgentStartPostsExpectedPayloadAndReturnsImmediately(t *testing.T) var calls atomic.Int32 var body map[string]any srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - calls.Add(1) + if r.Method == http.MethodGet && r.URL.Path == "/v1/models" { + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []any{map[string]any{"id": "composer-2"}}, + }) + return + } if r.Method != http.MethodPost || r.URL.Path != "/v1/agents" { t.Errorf("method/path = %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + return } + calls.Add(1) if got := r.Header.Get("Authorization"); got != "Bearer "+cursorToolTestKey { t.Errorf("Authorization = %q", got) } @@ -290,6 +952,69 @@ func TestCursorAgentStartPostsExpectedPayloadAndReturnsImmediately(t *testing.T) } } +func TestCursorAgentModelOnlyAliasValidatesAndExecutesOriginalAlias(t *testing.T) { + var catalogCalls, createCalls atomic.Int32 + var body map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v1/models": + catalogCalls.Add(1) + _ = json.NewEncoder(w).Encode(cursor.ModelCatalog{Items: []cursor.Model{{ + ID: "composer-2", + Aliases: []string{"composer"}, + }}}) + case r.Method == http.MethodPost && r.URL.Path == "/v1/agents": + createCalls.Add(1) + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode body: %v", err) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "agent": map[string]any{ + "id": "bc-alias", "status": "ACTIVE", + "url": "https://cursor.com/agents/bc-alias", "latestRunId": "run-alias", + }, + "run": map[string]any{ + "id": "run-alias", "agentId": "bc-alias", "status": "CREATING", + }, + }) + default: + t.Errorf("request = %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + })) + defer srv.Close() + + raw := json.RawMessage(`{ + "action":"start", + "prompt":"fix it", + "model":"composer", + "wait":false + }`) + op, err := (cursorAgentTool{}).ApprovalOperation(raw, "ses-alias") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(op.Arguments, `"model":"composer"`) { + t.Fatalf("approval changed model alias: %s", op.Arguments) + } + got := (cursorAgentTool{}).Execute(context.Background(), + cursorToolTestInput(cursorToolTestConfig(srv.URL), string(raw), nil)) + if got.IsError { + t.Fatalf("alias start result = %+v", got) + } + if catalogCalls.Load() != 1 || createCalls.Load() != 1 { + t.Fatalf("calls = catalog %d create %d, want 1/1", + catalogCalls.Load(), createCalls.Load()) + } + model := body["model"].(map[string]any) + if model["id"] != "composer" { + t.Fatalf("executed model = %#v, want approved alias", model) + } + if _, exists := model["params"]; exists { + t.Fatalf("model-only alias unexpectedly sent params: %#v", model) + } +} + func TestCursorAgentStartSupportsNoRepository(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var body map[string]any diff --git a/internal/tools/deps.go b/internal/tools/deps.go index 4e553af..09299de 100644 --- a/internal/tools/deps.go +++ b/internal/tools/deps.go @@ -6,6 +6,7 @@ import ( "github.com/enowdev/antares/internal/board" "github.com/enowdev/antares/internal/config" + "github.com/enowdev/antares/internal/cursorrun" "github.com/enowdev/antares/internal/engagement" "github.com/enowdev/antares/internal/findings" "github.com/enowdev/antares/internal/store" @@ -147,6 +148,9 @@ type Deps struct { // SocialBrowser is the persistent stealth Chromium for social media. Nil // when the social feature is not configured. SocialBrowser SocialBrowserManager + // Cursor owns the shared Cursor catalogue and remote-run lifecycle. It may + // be nil in runtimes that do not wire the Cursor integration. + Cursor cursorrun.Runner } // SocialBrowserManager is the minimal interface the social_browser tool needs diff --git a/internal/tools/registry.go b/internal/tools/registry.go index f89627f..d3be6f6 100644 --- a/internal/tools/registry.go +++ b/internal/tools/registry.go @@ -9,6 +9,8 @@ import ( "sort" "strings" "sync" + + "github.com/enowdev/antares/internal/approval" ) // Progress is an incremental update emitted while a tool runs. @@ -98,6 +100,12 @@ type Approval interface { RequiresApproval() bool } +// OperationApproval marks paid or mutating operations that must always stop +// for an explicit decision, independently of the general approval mode. +type OperationApproval interface { + ApprovalOperation(args json.RawMessage, sessionID string) (approval.Operation, error) +} + // Registry holds the process-wide tool set. type Registry struct { mu sync.RWMutex diff --git a/web/src/components/chat/ApprovalCard.tsx b/web/src/components/chat/ApprovalCard.tsx index fa5d5b8..77ade94 100644 --- a/web/src/components/chat/ApprovalCard.tsx +++ b/web/src/components/chat/ApprovalCard.tsx @@ -1,17 +1,11 @@ import { useState } from 'react' -import { Check, ShieldWarning, X } from '@phosphor-icons/react' +import { Check, ShieldWarning, Warning, X } from '@phosphor-icons/react' import { post } from '@/lib/api' +import { parseCursorApproval, type ApprovalView, type CursorApprovalDetails } from '@/lib/chatEvents' import { useI18n } from '@/lib/i18n' import { Button } from '@/components/ui/button' -export interface ApprovalView { - id: string - tool: string - arguments: string - message: string - /** Set once answered, so the card shows the outcome instead of buttons. */ - decided?: 'allowed' | 'refused' | 'expired' -} +export type { ApprovalView } from '@/lib/chatEvents' /** * A tool is waiting on a decision. The run is blocked until this is answered @@ -42,7 +36,10 @@ export function ApprovalCard({ } } - const pretty = formatArguments(approval.arguments) + // Cursor operations are paid and change remote state, so they get the full + // projection the server published rather than a JSON blob. + const cursor = parseCursorApproval(approval) + const pretty = cursor ? '' : formatArguments(approval.arguments) return (
@@ -50,6 +47,7 @@ export function ApprovalCard({

{approval.message || t('approval.title')}

+ {cursor ? : null} {pretty ? (
               {pretty}
@@ -91,6 +89,88 @@ export function ApprovalCard({
   )
 }
 
+/** What an approved Cursor operation will do, exactly as the server retained it. */
+function CursorApprovalDetail({ details }: { details: CursorApprovalDetails }) {
+  const { t } = useI18n()
+  const operation =
+    details.operation === 'cancel'
+      ? t('cursorApproval.cancel')
+      : details.newAgent
+        ? t('cursorApproval.start')
+        : t('cursorApproval.followUp')
+
+  const rows: Array<{ label: string; value: string }> = [
+    { label: t('cursorApproval.operation'), value: operation },
+  ]
+  if (details.model) rows.push({ label: t('cursorApproval.model'), value: details.model })
+  if (details.params.length > 0) {
+    rows.push({
+      label: t('cursorApproval.params'),
+      value: details.params.map((param) => `${param.id}=${param.value}`).join(' · '),
+    })
+  }
+  if (details.operation === 'cancel') {
+    if (details.agentId) {
+      rows.push({ label: t('cursorApproval.agent'), value: details.agentId })
+    }
+    if (details.runId) {
+      rows.push({ label: t('cursorApproval.run'), value: details.runId })
+    }
+  } else {
+    rows.push({
+      label: t('cursorApproval.repository'),
+      value: details.repositoryUrl || t('cursorApproval.noRepository'),
+    })
+    if (details.startingRef) {
+      rows.push({ label: t('cursorApproval.startingRef'), value: details.startingRef })
+    }
+    if (details.mode) {
+      rows.push({
+        label: t('cursorApproval.mode'),
+        value: details.mode === 'plan' ? t('cursor.modePlan') : t('cursor.modeAgent'),
+      })
+    }
+    rows.push({
+      label: t('cursorApproval.autoPR'),
+      value: details.autoCreatePR ? t('common.yes') : t('common.no'),
+    })
+    if (details.imageCount > 0) {
+      rows.push({
+        label: t('cursorApproval.images'),
+        value: String(details.imageCount),
+      })
+    }
+  }
+
+  return (
+    
+
+ {rows.map((row) => ( +
+
{row.label}
+
{row.value}
+
+ ))} +
+ {details.promptPreview ? ( +

+ {details.promptPreview} +

+ ) : null} + {details.warnings.length > 0 ? ( +
    + {details.warnings.map((warning) => ( +
  • + + {warning} +
  • + ))} +
+ ) : null} +
+ ) +} + /** Show the command or path rather than the raw JSON envelope around it. */ function formatArguments(raw: string): string { if (!raw) return '' diff --git a/web/src/components/chat/CursorOptions.tsx b/web/src/components/chat/CursorOptions.tsx new file mode 100644 index 0000000..d2fe958 --- /dev/null +++ b/web/src/components/chat/CursorOptions.tsx @@ -0,0 +1,355 @@ +import { useEffect, useRef, useState } from 'react' +import { CaretDown, Cloud, Warning } from '@phosphor-icons/react' +import { get } from '@/lib/api' +import type { CursorMode, CursorOptionsValue, CursorRunBaseline } from '@/lib/composerTargets' +import { startsNewCursorAgent } from '@/lib/composerTargets' +import { + cursorFilterCommit, + cursorFilterFromVariant, + cursorFilterMatches, + cursorOtherDimensions, + cursorReasoningDimension, + cursorVariantSummary, + withCursorFilter, + type CursorDimension, + type CursorFilterEntry, +} from '@/lib/cursorModels' +import { useI18n } from '@/lib/i18n' +import { cn } from '@/lib/utils' +import { Input, Label, Switch } from '@/components/ui/primitives' + +interface RepositoryPreflight { + repository: boolean + url?: string + starting_ref?: string + dirty: boolean + local_only_commits: number + remote_ref_known: boolean + warning?: string +} + +/** + * The Cursor half of the composer: the exact variant to run, the conversation + * mode, and the repository the cloud VM will clone. Every control filters the + * catalogue's own variants, so a selection is always one Cursor returned. + */ +export function CursorOptions({ + value, + onChange, + projectDir, + lastStarted, + disabled, +}: { + value: CursorOptionsValue + onChange: (value: CursorOptionsValue) => void + projectDir?: string + /** The run a follow-up would continue, if this session has one. */ + lastStarted: CursorRunBaseline | null + disabled?: boolean +}) { + const { t } = useI18n() + const [open, setOpen] = useState(false) + const [preflight, setPreflight] = useState() + // The controls narrow the catalogue rather than editing a selection: what + // runs only changes once the filter leaves exactly one upstream variant. + const [filter, setFilter] = useState(() => + cursorFilterFromVariant(value.model, value.variant), + ) + const ref = useRef(null) + + // A newly committed variant, a different model, and opening or closing the + // popover all restart the filter from what is actually selected. Staging an + // ambiguous filter changes none of those, so work in progress survives until + // it either commits or is abandoned. + useEffect(() => { + setFilter(cursorFilterFromVariant(value.model, value.variant)) + }, [open, value.model, value.variant]) + + useEffect(() => { + if (!open) return + const onClick = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) + } + document.addEventListener('mousedown', onClick) + return () => document.removeEventListener('mousedown', onClick) + }, [open]) + + // The repository preflight is local-only and cheap, but it shells out to git; + // run it when the popover opens rather than on every keystroke elsewhere. + useEffect(() => { + if (!open || !projectDir) { + if (!projectDir) setPreflight(undefined) + return + } + let cancelled = false + get( + `/project/cursor-repository?dir=${encodeURIComponent(projectDir)}`, + ) + .then((info) => { + if (!cancelled) setPreflight(info) + }) + .catch(() => { + if (!cancelled) setPreflight(undefined) + }) + return () => { + cancelled = true + } + }, [open, projectDir]) + + const reasoning = cursorReasoningDimension(value.model) + const others = cursorOtherDimensions(value.model) + const summary = cursorVariantSummary(value.model, value.variant) + const newAgent = startsNewCursorAgent(lastStarted, value) + + const selected = filterSelectionOf(filter) + const remaining = cursorFilterMatches(value.model, filter) + + const pickDimension = (dimension: CursorDimension, option: string) => { + const next = withCursorFilter(value.model, filter, dimension.id, option) + setFilter(next) + // Only a filter that leaves one upstream variant changes what will run; + // while several remain, the current selection stands untouched. + const variant = cursorFilterCommit(value.model, next) + if (variant && variant !== value.variant) onChange({ ...value, variant }) + } + + const discoveredRepo = preflight?.repository ? (preflight.url ?? '') : '' + const discoveredRef = preflight?.repository ? (preflight.starting_ref ?? '') : '' + const warnings: string[] = [] + if (preflight?.repository && !preflight.remote_ref_known) { + warnings.push(t('cursor.warnRemoteUnknown')) + } + if (preflight?.dirty) warnings.push(t('cursor.warnDirty')) + if ((preflight?.local_only_commits ?? 0) > 0) { + warnings.push(t('cursor.warnLocalOnly', { n: preflight?.local_only_commits ?? 0 })) + } + if (preflight?.repository && !preflight.url) { + warnings.push(t('cursor.warnUnsupportedOrigin')) + } + + return ( +
+ + + {open ? ( +
+
+

{value.model.name}

+

+ {value.model.id} +

+
+ + {[...(reasoning ? [reasoning] : []), ...others].map((dimension) => ( + pickDimension(dimension, option)} + disabled={disabled} + /> + ))} + {remaining.length > 1 ? ( +

+ {t('cursor.variantPending', { n: remaining.length })} +

+ ) : remaining.length === 0 ? ( +

+ {t('cursor.variantUnavailable')} +

+ ) : null} + +
+ +
+ {(['agent', 'plan'] as CursorMode[]).map((mode) => ( + onChange({ ...value, mode })} + label={mode === 'agent' ? t('cursor.modeAgent') : t('cursor.modePlan')} + /> + ))} +
+

+ {t('cursor.modeHint')} +

+
+ +
+ + onChange({ ...value, repositoryUrl: e.target.value })} + className="h-8 text-xs" + autoComplete="off" + spellCheck={false} + /> + + onChange({ ...value, startingRef: e.target.value })} + className="h-8 text-xs" + autoComplete="off" + spellCheck={false} + /> + {value.repositoryUrl !== null || value.startingRef !== null ? ( + + ) : ( +

+ {projectDir ? t('cursor.repositoryAuto') : t('cursor.repositoryNoProject')} +

+ )} +
+ + + + {warnings.length > 0 ? ( +
+ {warnings.map((warning) => ( +

+ + {warning} +

+ ))} +
+ ) : null} + + {newAgent ? ( +

+ {t('cursor.newAgentNotice')} +

+ ) : null} +
+ ) : null} +
+ ) +} + +/** The dimensions a filter currently pins, as an id → value map. */ +function filterSelectionOf(filter: CursorFilterEntry[]): Record { + const selection: Record = {} + for (const entry of filter) selection[entry.id] = entry.value + return selection +} + +function DimensionRow({ + dimension, + selected, + onPick, + disabled, +}: { + dimension: CursorDimension + selected?: string + onPick: (value: string) => void + disabled?: boolean +}) { + return ( +
+ +
+ {dimension.values.map((option) => ( + onPick(option.value)} + label={option.label} + /> + ))} +
+
+ ) +} + +function OptionChip({ + active, + label, + onClick, + disabled, + title, +}: { + active: boolean + label: string + onClick: () => void + disabled?: boolean + title?: string +}) { + return ( + + ) +} diff --git a/web/src/components/chat/ModelPicker.tsx b/web/src/components/chat/ModelPicker.tsx index 3bdcc2d..1fecdf5 100644 --- a/web/src/components/chat/ModelPicker.tsx +++ b/web/src/components/chat/ModelPicker.tsx @@ -1,76 +1,174 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link } from "react-router-dom"; import { CaretDown, CircleNotch, + Cloud, Cpu, MagnifyingGlass, } from "@phosphor-icons/react"; import { get, isDashboardPasswordRequired, post } from "@/lib/api"; +import { + chatTargetFromModel, + composerTargetKey, + composerTargetLabel, + cursorCatalogueState, + searchComposerTargets, + type ChatCatalogueModel, + type ChatTarget, + type ComposerTarget, + type CursorTarget, +} from "@/lib/composerTargets"; +import type { CursorModel } from "@/lib/cursorModels"; +import { cursorVariantSummary, defaultCursorVariant } from "@/lib/cursorModels"; import { useI18n } from "@/lib/i18n"; +import type { ReasoningCapability } from "@/lib/models"; import { cn } from "@/lib/utils"; -interface AllModel { - id: string; - name: string; - provider: string; - provider_label: string; +interface ListAll { + active: { model: string; provider: string }; + models: ChatCatalogueModel[]; } -interface ListAll { +interface CursorCatalogue { + models: CursorModel[]; + needs_key?: boolean; + error?: string; +} + +interface ModelOptions { active: { model: string; provider: string }; - models: AllModel[]; + providers?: Array<{ id: string; label: string }>; +} + +interface ModelInfo { + found: boolean; + id?: string; + name?: string; + reasoning_capability?: ReasoningCapability; } /** - * Switch the active model straight from the composer, without leaving the chat. - * Lists every connected provider's models (same source as the Models page) and - * sets provider+model together, since a model always knows its provider. + * Pick where the next message runs, without leaving the chat. Chat models and + * Cursor Cloud Agents are searched together but stay separate targets: picking + * a chat model sets the active Antares model, while picking a Cursor model only + * routes this conversation's turns to Cursor and never touches `/model/set`. + * + * `onChange` reports how the target was chosen. The mount-time active-model + * lookup is a `default`, not an edit: only the composer knows whether a session + * being restored has a better claim on the target, so it decides what to do + * with it. */ export function ModelPicker({ - onModelChange, + value, + onChange, + disabled, }: { - onModelChange?: (model: string) => void; + value: ComposerTarget | null; + onChange: (target: ComposerTarget, origin: "user" | "default") => void; + /** Locked while a turn streams: the running stream owns the target. */ + disabled?: boolean; }) { const { t } = useI18n(); const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const [data, setData] = useState(null); + const [cursorData, setCursorData] = useState(); + const [cursorError, setCursorError] = useState(); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(""); const [pickError, setPickError] = useState(); const [pickGate, setPickGate] = useState(false); const ref = useRef(null); + const resolutionRef = useRef(0); const [activeConfig, setActiveConfig] = useState<{ model: string; provider: string; } | null>(null); + // Read the callback through a ref so the mount resolution below does not + // re-run (and re-fetch) every time the composer hands down a new target. + const onChangeRef = useRef(onChange); + onChangeRef.current = onChange; + const adoptChatDefault = useCallback((target: ChatTarget) => { + onChangeRef.current(target, "default"); + }, []); + const load = () => { setLoading(true); - return get("/model/list-all") - .then((d) => setData(d)) - .catch(() => {}) - .finally(() => setLoading(false)); + const chat = get("/model/list-all") + .then((d) => { + setData(d); + const activeModel = d.models.find( + (model) => + model.id === d.active?.model && + model.provider === d.active?.provider, + ); + if (activeModel) adoptChatDefault(chatTargetFromModel(activeModel)); + }) + .catch(() => {}); + // Cursor's catalogue is a separate call on purpose: it is never merged into + // the chat model list, and a Cursor failure must not hide chat models. + const cursor = get("/providers/cursor/models") + .then((d) => { + setCursorData(d); + setCursorError(undefined); + }) + .catch((e: Error) => { + setCursorData(undefined); + setCursorError(e); + }); + return Promise.all([chat, cursor]).finally(() => setLoading(false)); }; - // On mount, fetch just the active model from the cheap /model/options endpoint - // (config only, no per-provider probing) so the trigger shows the persisted - // last-used model immediately instead of the "pick a model" placeholder — - // otherwise it looks like the selection resets on every reload. - const loadActive = () => - get<{ active: { model: string; provider: string } }>("/model/options") - .then((d) => { - setActiveConfig(d.active); - if (d.active?.model && d.active?.provider) { - onModelChange?.(`${d.active.provider}/${d.active.model}`); + // The cheap options call identifies the persisted active pair. Resolve that + // one model through model-info so the composer has capability metadata before + // it restores a model-scoped reasoning preference. + useEffect(() => { + let cancelled = false; + const sequence = ++resolutionRef.current; + get("/model/options") + .then(async (options) => { + if (cancelled || sequence !== resolutionRef.current) return; + const active = options.active; + setActiveConfig(active); + if (!active?.model || !active?.provider) return; + + const providerLabel = + options.providers?.find((provider) => provider.id === active.provider) + ?.label ?? active.provider; + const fallback: ChatTarget = { + kind: "chat", + provider: active.provider, + model: active.model, + name: active.model, + providerLabel, + }; + + try { + const info = await get( + `/providers/${encodeURIComponent(active.provider)}/model-info?model=${encodeURIComponent(active.model)}`, + ); + if (cancelled || sequence !== resolutionRef.current) return; + adoptChatDefault({ + ...fallback, + name: info.found ? info.name || active.model : active.model, + reasoningCapability: info.found + ? info.reasoning_capability + : undefined, + }); + } catch { + if (!cancelled && sequence === resolutionRef.current) { + adoptChatDefault(fallback); + } } }) .catch(() => {}); - useEffect(() => { - loadActive(); - }, []); + return () => { + cancelled = true; + }; + }, [adoptChatDefault]); // The full model list (which probes every provider) is fetched lazily on open, // and refreshed each open so a newly connected provider's models appear. @@ -90,30 +188,38 @@ export function ModelPicker({ // Prefer the freshly-probed list's active; fall back to the cheap mount fetch. const active = data?.active ?? activeConfig; - const activeLabel = active?.model || t("models.pickModel"); - - const shown = useMemo(() => { - const list = data?.models ?? []; - const q = query.trim().toLowerCase(); - if (!q) return list; - return list.filter( - (m) => - m.id.toLowerCase().includes(q) || - m.name.toLowerCase().includes(q) || - m.provider_label.toLowerCase().includes(q), - ); - }, [data, query]); - - const pick = async (m: AllModel) => { - setSaving(`${m.provider}/${m.id}`); + const chipLabel = + composerTargetLabel(value) || active?.model || t("models.pickModel"); + const cursorState = cursorCatalogueState(cursorData, cursorError); + const cursorMessage = cursorData?.error ?? cursorError?.message; + + const shown = useMemo( + () => + searchComposerTargets({ + chatModels: data?.models ?? [], + cursorModels: cursorData?.models ?? [], + query, + }), + [data, cursorData, query], + ); + const selectedKey = value ? composerTargetKey(value) : ""; + + const pickChat = async (target: ChatTarget) => { + ++resolutionRef.current; + setSaving(composerTargetKey(target)); setPickError(undefined); try { - await post("/model/set", { model: m.id, provider: m.provider }); - setActiveConfig({ model: m.id, provider: m.provider }); + await post("/model/set", { + model: target.model, + provider: target.provider, + }); + setActiveConfig({ model: target.model, provider: target.provider }); setData((d) => - d ? { ...d, active: { model: m.id, provider: m.provider } } : d, + d + ? { ...d, active: { model: target.model, provider: target.provider } } + : d, ); - onModelChange?.(`${m.provider}/${m.id}`); + onChange(target, "user"); setOpen(false); setQuery(""); } catch (e) { @@ -131,6 +237,18 @@ export function ModelPicker({ } }; + // Cursor is an execution target, not a chat provider: selecting one changes + // only this composer, so there is nothing to save and nothing to fail. + const pickCursor = (target: CursorTarget) => { + setPickError(undefined); + onChange(target, "user"); + setOpen(false); + setQuery(""); + }; + + const empty = + shown.chat.length === 0 && shown.cursor.length === 0 && cursorState !== "connect"; + return (
{open ? ( -
+
setQuery(e.target.value)} placeholder={t("models.searchAll")} + aria-label={t("models.searchAll")} className="h-8 w-full rounded-[var(--radius-sm)] border border-border bg-background pl-8 pr-2 text-xs outline-none focus:border-ring" />
@@ -177,49 +305,141 @@ export function ModelPicker({

) : null}
- {shown.length === 0 && loading ? ( + {empty && loading ? (
{t("models.loading")}
- ) : shown.length === 0 ? ( + ) : empty ? (

{t("models.none")}

- ) : ( - shown.map((m) => { - const isActive = - m.id === active?.model && m.provider === active?.provider; - return ( - + ); + })} + + {cursorState === "connect" || shown.cursor.length > 0 ? ( + } + label={t("target.cursorGroup")} + /> + ) : null} + {cursorState === "connect" ? ( +
+

{t("target.cursorNeedsKey")}

+ setOpen(false)} + className="mt-1 inline-block font-medium text-primary underline underline-offset-2" + > + {t("target.cursorConnect")} + +
+ ) : null} + {cursorState === "error" && cursorMessage ? ( +

+ {cursorMessage} +

+ ) : null} + {shown.cursor.map(({ model, target }) => { + const key = `cursor:${model.id}`; + const variant = defaultCursorVariant(model); + const summary = variant ? cursorVariantSummary(model, variant) : ""; + return ( + - ); - }) - )} + )} + + + ); + })}
) : null}
); } + +function GroupHeading({ + icon, + label, +}: { + icon: React.ReactNode; + label: string; +}) { + return ( +
+ {icon} + {label} +
+ ); +} diff --git a/web/src/components/chat/ReasoningPicker.tsx b/web/src/components/chat/ReasoningPicker.tsx index 0ece110..e12bc6f 100644 --- a/web/src/components/chat/ReasoningPicker.tsx +++ b/web/src/components/chat/ReasoningPicker.tsx @@ -1,33 +1,29 @@ import { useEffect, useRef, useState } from 'react' import { Brain, CaretDown, Check } from '@phosphor-icons/react' +import { useI18n } from '@/lib/i18n' +import type { ReasoningCapability, ReasoningValue } from '@/lib/models' +import { reasoningOptions } from '@/lib/reasoning' import { cn } from '@/lib/utils' -// Reasoning effort options. Empty value means "use the configured default" -// (agent.reasoning_effort, then model.reasoning_effort). The rest map to the -// provider's thinking budget: none disables thinking, low/medium/high raise it. -const OPTIONS: { value: string; label: string; hint: string }[] = [ - { value: '', label: 'Default', hint: 'Use the configured effort' }, - { value: 'none', label: 'Off', hint: 'No reasoning' }, - { value: 'low', label: 'Low', hint: 'Brief reasoning' }, - { value: 'medium', label: 'Medium', hint: 'Balanced reasoning' }, - { value: 'high', label: 'High', hint: 'Deep reasoning' }, -] +export interface ReasoningPickerProps { + value: string + capability?: ReasoningCapability + onChange(value: string): void + compact?: boolean +} /** - * Pick the reasoning effort for the next turn straight from the composer. The - * choice rides on the message body (reasoning_effort) and overrides the - * configured default for that turn only; it is remembered in localStorage so it - * survives a reload. Mirrors RolePicker's compact chip style. + * Present the exact reasoning values supported by the selected model. Storage + * and model changes are owned by the composer; this component only renders the + * supplied value and capability. */ export function ReasoningPicker({ value, + capability, onChange, compact = false, -}: { - value: string - onChange: (effort: string) => void - compact?: boolean -}) { +}: ReasoningPickerProps) { + const { t } = useI18n() const [open, setOpen] = useState(false) const ref = useRef(null) @@ -40,18 +36,26 @@ export function ReasoningPicker({ return () => document.removeEventListener('mousedown', onClick) }, [open]) - const current = OPTIONS.find((o) => o.value === value) ?? OPTIONS[0] + const options = reasoningOptions(capability) + const current = options.find((option) => option.value === value) ?? options[0] + const label = (option: ReasoningValue) => + option.value === '' ? t('reasoning.auto') : option.label const pick = (v: string) => { onChange(v) setOpen(false) } + // Unknown/Auto-only models expose no trustworthy override values. Auto is + // still the behavior, but there is no useful composer control to show. + if (!capability) return null + return (
{open ? (
- {OPTIONS.map((o) => ( + {options.map((option) => ( ))} + {capability.mandatory || capability.values.length === 0 ? ( +

+ {capability.mandatory + ? t('reasoning.mandatory') + : t('reasoning.providerControlled')} +

+ ) : null}
) : null}
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index ff97042..e11a13a 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1,8 +1,15 @@ /** Typed client for the Antares HTTP API. */ -/** True when an error is the "set a dashboard password first" 428 gate. */ +/** + * True when an error is the "set a dashboard password first" gate. The marker + * in the body distinguishes it from other 428 answers — Cursor reports a + * missing integration credential with the same status but its own message. + */ export function isDashboardPasswordRequired(e: unknown): boolean { - return e instanceof ApiError && e.status === 428 + if (!(e instanceof ApiError) || e.status !== 428) return false + const body = e.body + if (typeof body !== 'object' || body === null || !('error' in body)) return true + return (body as { error: unknown }).error === 'dashboard_password_required' } export class ApiError extends Error { @@ -33,6 +40,30 @@ function authHeaders(): Record { return token ? { Authorization: `Bearer ${token}` } : {} } +/** + * Turn a non-2xx response into an ApiError that keeps the parsed body. The + * status and the server's own `error` string are what the UI needs to explain a + * 409 busy session, a 429 with retry-after, an auth failure, or a stale model. + */ +async function responseError(res: Response): Promise { + const text = await res.text().catch(() => '') + let body: unknown = text + if (text) { + try { + body = JSON.parse(text) + } catch { + /* keep raw text */ + } + } + const message = + typeof body === 'object' && body !== null && 'error' in body + ? String((body as { error: unknown }).error) + : typeof body === 'string' && body.trim() !== '' + ? body + : res.statusText || `HTTP ${res.status}` + return new ApiError(res.status, message, body) +} + export async function api(path: string, init: RequestInit = {}): Promise { const res = await fetch(`/api${path}`, { ...init, @@ -53,6 +84,8 @@ export async function api(path: string, init: RequestInit = {}): Promise { } } + if (!res.ok) throw await responseError(res) + const text = await res.text() let body: unknown = text if (text) { @@ -62,14 +95,6 @@ export async function api(path: string, init: RequestInit = {}): Promise { /* keep raw text */ } } - - if (!res.ok) { - const msg = - typeof body === 'object' && body !== null && 'error' in body - ? String((body as { error: unknown }).error) - : res.statusText || `HTTP ${res.status}` - throw new ApiError(res.status, msg, body) - } return body as T } @@ -138,10 +163,11 @@ export function streamPost( body: JSON.stringify(data), signal: controller.signal, }) - if (!res.ok || !res.body) { - const text = await res.text().catch(() => '') - throw new ApiError(res.status, text || res.statusText) - } + // A refused turn answers with the same JSON error envelope as `api`, so + // the composer can tell a busy session from a rate limit or a stale + // model instead of showing a bare status line. + if (!res.ok) throw await responseError(res) + if (!res.body) throw new ApiError(res.status, 'the response had no body') const reader = res.body.getReader() const decoder = new TextDecoder() @@ -211,10 +237,8 @@ export function streamGet( headers: { ...authHeaders(), Accept: 'text/event-stream' }, signal: controller.signal, }) - if (!res.ok || !res.body) { - const text = await res.text().catch(() => '') - throw new ApiError(res.status, text || res.statusText) - } + if (!res.ok) throw await responseError(res) + if (!res.body) throw new ApiError(res.status, 'the response had no body') const reader = res.body.getReader() const decoder = new TextDecoder() diff --git a/web/src/lib/chatEvents.test.mjs b/web/src/lib/chatEvents.test.mjs new file mode 100644 index 0000000..835b6bb --- /dev/null +++ b/web/src/lib/chatEvents.test.mjs @@ -0,0 +1,362 @@ +import { describe, expect, test } from 'bun:test' +import { + approvalFromEvent, + cursorHydrationFromDetail, + cursorSessionHydration, + mergeApprovals, + parseCursorApproval, + pendingApprovalsForSession, + shouldReconnectAttach, + stopBehavior, +} from './chatEvents.ts' + +const startArguments = JSON.stringify({ + operation: 'start', + kind: 'new_agent', + model: { + id: 'gpt-5.6-sol', + params: [ + { id: 'reasoning', value: 'max' }, + { id: 'internal', value: 'off' }, + ], + }, + repository_url: 'https://github.com/acme/repo', + repository_source: 'auto', + starting_ref: 'main', + worktree_dirty: true, + local_only_commits: 2, + remote_ref_known: false, + warnings: ['Local uncommitted changes are absent from the Cursor cloud VM.'], + mode: 'agent', + auto_create_pr: false, + prompt_preview: 'ship the release', + image_count: 1, +}) + +describe('approval events', () => { + test('an approval event becomes a card view', () => { + expect( + approvalFromEvent({ + type: 'approval', + id: 'apr_1', + name: 'cursor_direct', + arguments: startArguments, + message: 'Start Cursor Cloud Agent run', + }), + ).toEqual({ + id: 'apr_1', + tool: 'cursor_direct', + arguments: startArguments, + message: 'Start Cursor Cloud Agent run', + }) + }) + + test('non-approval and id-less events are ignored', () => { + expect(approvalFromEvent({ type: 'text', delta: 'hi' })).toBeNull() + expect(approvalFromEvent({ type: 'approval', name: 'cursor_direct' })).toBeNull() + }) + + test('the same approval never appears twice and keeps its decision', () => { + const first = { id: 'apr_1', tool: 'cursor_direct', arguments: '{}', message: 'Start' } + const decided = mergeApprovals( + mergeApprovals([], first).map((a) => ({ ...a, decided: 'allowed' })), + { ...first, message: 'Start again' }, + ) + expect(decided).toHaveLength(1) + expect(decided[0].decided).toBe('allowed') + expect(mergeApprovals(decided, { id: 'apr_2', tool: 'cursor_direct_cancel', arguments: '{}', message: 'Cancel' })).toHaveLength(2) + }) + + test('pending approvals are scoped to the open session and de-duplicated', () => { + const existing = [{ id: 'apr_1', tool: 'cursor_direct', arguments: '{}', message: 'Start', decided: 'allowed' }] + const merged = pendingApprovalsForSession( + existing, + [ + { id: 'apr_1', session_id: 'ses_1', tool: 'cursor_direct', arguments: '{}', message: 'Start' }, + { id: 'apr_2', session_id: 'ses_1', tool: 'cursor_direct_cancel', arguments: '{}', message: 'Cancel' }, + { id: 'apr_3', session_id: 'ses_2', tool: 'terminal', arguments: '{}', message: 'Other session' }, + ], + 'ses_1', + ) + expect(merged.map((a) => a.id)).toEqual(['apr_1', 'apr_2']) + expect(merged[0].decided).toBe('allowed') + }) + + test('no open session shows no pending approvals', () => { + expect( + pendingApprovalsForSession([], [{ id: 'apr_1', session_id: 'ses_1', tool: 'x', arguments: '{}', message: '' }], undefined), + ).toEqual([]) + }) +}) + +describe('Cursor approval details', () => { + test('parses the immutable Cursor projection', () => { + const details = parseCursorApproval({ + id: 'apr_1', + tool: 'cursor_direct', + arguments: startArguments, + message: 'Start Cursor Cloud Agent run', + }) + expect(details).toEqual({ + operation: 'start', + newAgent: true, + model: 'gpt-5.6-sol', + params: [ + { id: 'reasoning', value: 'max' }, + { id: 'internal', value: 'off' }, + ], + repositoryUrl: 'https://github.com/acme/repo', + repositorySource: 'auto', + startingRef: 'main', + worktreeDirty: true, + localOnlyCommits: 2, + remoteRefKnown: false, + warnings: ['Local uncommitted changes are absent from the Cursor cloud VM.'], + mode: 'agent', + autoCreatePR: false, + promptPreview: 'ship the release', + imageCount: 1, + agentId: '', + runId: '', + }) + }) + + test('a follow-up is not a new agent', () => { + const details = parseCursorApproval({ + id: 'apr_2', + tool: 'cursor_direct', + arguments: JSON.stringify({ operation: 'follow_up', kind: 'follow_up', model: { id: 'x', params: [] } }), + message: 'Continue', + }) + expect(details?.newAgent).toBe(false) + expect(details?.operation).toBe('follow_up') + }) + + test('a cancellation names the remote run and carries no model params', () => { + const details = parseCursorApproval({ + id: 'apr_3', + tool: 'cursor_direct_cancel', + arguments: JSON.stringify({ operation: 'cancel', agent_id: 'bc-1', run_id: 'run-1' }), + message: 'Cancel Cursor Cloud Agent run', + }) + expect(details?.operation).toBe('cancel') + expect(details?.params).toEqual([]) + expect(details?.agentId).toBe('bc-1') + expect(details?.runId).toBe('run-1') + }) + + test('other tools and malformed payloads have no Cursor details', () => { + expect(parseCursorApproval({ id: 'a', tool: 'terminal', arguments: startArguments, message: '' })).toBeNull() + expect(parseCursorApproval({ id: 'a', tool: 'cursor_direct', arguments: 'not json', message: '' })).toBeNull() + }) +}) + +describe('stream lifecycle', () => { + test('Cursor Stop detaches locally while chat Stop interrupts the turn', () => { + expect(stopBehavior('cursor')).toEqual({ interrupt: false, detach: true }) + expect(stopBehavior('chat')).toEqual({ interrupt: true, detach: false }) + }) + + test('an intentional detach stops the standing attach loop from reconnecting', () => { + expect(shouldReconnectAttach({ alive: true, detached: false })).toBe(true) + expect(shouldReconnectAttach({ alive: true, detached: true })).toBe(false) + expect(shouldReconnectAttach({ alive: false, detached: false })).toBe(false) + }) +}) + +describe('Cursor session hydration', () => { + test('restores the Cursor target, status, and branches from persisted messages', () => { + const state = cursorSessionHydration([ + { id: 'm1', role: 'user', content: 'hi', meta: { cursor_image_count: 1 } }, + { + id: 'm2', + role: 'assistant', + content: 'done', + model: 'gpt-5.6-sol', + meta: { + cursor_remote_status: 'FINISHED', + cursor_git_state: JSON.stringify({ + branches: [ + { repoUrl: 'https://github.com/acme/repo', branch: 'cursor/x', prUrl: 'https://github.com/acme/repo/pull/7' }, + ], + }), + }, + }, + ]) + expect(state).toEqual({ + active: true, + modelId: 'gpt-5.6-sol', + remoteStatus: 'FINISHED', + branches: [ + { + repoUrl: 'https://github.com/acme/repo', + branch: 'cursor/x', + prUrl: 'https://github.com/acme/repo/pull/7', + }, + ], + }) + }) + + test('an ordinary chat session is not a Cursor session', () => { + expect( + cursorSessionHydration([{ id: 'm1', role: 'assistant', content: 'hi', model: 'gpt-5.6' }]), + ).toEqual({ active: false, branches: [] }) + }) + + test('malformed Cursor git state never breaks hydration', () => { + expect( + cursorSessionHydration([ + { id: 'm1', role: 'assistant', content: 'x', model: 'sol', meta: { cursor_remote_status: 'ERROR', cursor_git_state: '{' } }, + ]), + ).toEqual({ active: true, modelId: 'sol', remoteStatus: 'ERROR', branches: [] }) + }) +}) + +const cursorTranscript = [ + { + id: 'm1', + role: 'assistant', + content: 'done', + model: 'gpt-5.6-sol', + meta: { cursor_remote_status: 'FINISHED' }, + }, +] + +const activeProjection = { + target_active: true, + reuse_valid: true, + model_id: 'gpt-5.6-sol', + model_params: [ + { id: 'cyber', value: 'false' }, + { id: 'reasoning', value: 'max' }, + ], + repository_url: 'https://github.com/acme/repo', + starting_ref: 'main', + mode: 'plan', + auto_create_pr: true, + remote_status: 'RUNNING', + operation_state: 'run_in_flight', + git: { + branches: [ + { + repo_url: 'https://github.com/acme/repo', + branch: 'cursor/x', + pr_url: 'https://github.com/acme/repo/pull/7', + }, + ], + }, +} + +describe('durable Cursor hydration', () => { + test('the durable projection restores the exact target and its identity', () => { + expect( + cursorHydrationFromDetail({ cursor_state: activeProjection, messages: cursorTranscript }), + ).toEqual({ + active: true, + modelId: 'gpt-5.6-sol', + params: [ + { id: 'cyber', value: 'false' }, + { id: 'reasoning', value: 'max' }, + ], + mode: 'plan', + repositoryUrl: 'https://github.com/acme/repo', + startingRef: 'main', + autoCreatePR: true, + reuseValid: true, + remoteStatus: 'RUNNING', + operationState: 'run_in_flight', + running: true, + branches: [ + { + repoUrl: 'https://github.com/acme/repo', + branch: 'cursor/x', + prUrl: 'https://github.com/acme/repo/pull/7', + }, + ], + }) + }) + + test('an inactive target is not restored even with old Cursor messages', () => { + const state = cursorHydrationFromDetail({ + cursor_state: { ...activeProjection, target_active: false, reuse_valid: false, operation_state: 'committed' }, + messages: cursorTranscript, + }) + expect(state.active).toBe(false) + expect(state.modelId).toBeUndefined() + expect(state.params).toBeUndefined() + expect(state.running).toBe(false) + // The finished run's outcome is still worth showing. + expect(state.remoteStatus).toBe('RUNNING') + expect(state.branches).toHaveLength(1) + }) + + test('a session the server says has no Cursor state ignores old transcript metadata', () => { + expect( + cursorHydrationFromDetail({ cursor_state: null, messages: cursorTranscript }), + ).toEqual({ active: false, branches: [] }) + }) + + test('a server that omits the projection still hydrates from the transcript', () => { + const state = cursorHydrationFromDetail({ messages: cursorTranscript }) + expect(state.active).toBe(true) + expect(state.modelId).toBe('gpt-5.6-sol') + expect(state.remoteStatus).toBe('FINISHED') + // A transcript cannot prove the exact variant, so nothing claims to know it. + expect(state.params).toBeUndefined() + expect(state.reuseValid).toBe(false) + expect(state.running).toBe(false) + }) + + test('auto-discovery stays auto and an explicit empty repository stays explicit', () => { + expect( + cursorHydrationFromDetail({ + cursor_state: { ...activeProjection, repository_url: null }, + messages: [], + }).repositoryUrl, + ).toBeNull() + expect( + cursorHydrationFromDetail({ + cursor_state: { ...activeProjection, repository_url: '' }, + messages: [], + }).repositoryUrl, + ).toBe('') + }) + + test('a selection the server could not decode restores no model', () => { + const state = cursorHydrationFromDetail({ + cursor_state: { ...activeProjection, model_id: '', model_params: [] }, + messages: cursorTranscript, + }) + expect(state.active).toBe(true) + expect(state.modelId).toBeUndefined() + expect(state.params).toBeUndefined() + }) + + test('an awaiting-approval or creating run counts as running', () => { + for (const operation of ['awaiting_approval', 'create_in_flight', 'run_in_flight']) { + expect( + cursorHydrationFromDetail({ + cursor_state: { ...activeProjection, operation_state: operation }, + messages: [], + }).running, + ).toBe(true) + } + for (const operation of ['idle', 'terminal', 'committed', 'ambiguous']) { + expect( + cursorHydrationFromDetail({ + cursor_state: { ...activeProjection, operation_state: operation }, + messages: [], + }).running, + ).toBe(false) + } + }) + + test('an unknown stored mode never becomes a Cursor mode', () => { + expect( + cursorHydrationFromDetail({ + cursor_state: { ...activeProjection, mode: 'chaos' }, + messages: [], + }).mode, + ).toBeUndefined() + }) +}) diff --git a/web/src/lib/chatEvents.ts b/web/src/lib/chatEvents.ts new file mode 100644 index 0000000..d33ffe9 --- /dev/null +++ b/web/src/lib/chatEvents.ts @@ -0,0 +1,332 @@ +/** + * Pure stream-lifecycle and approval helpers shared by the chat page. + * + * The approval payload the server publishes is an immutable display + * projection: the pending operation itself stays on the server behind an + * opaque id, so nothing parsed here can change what a decision executes. + */ + +export interface ApprovalView { + id: string + tool: string + arguments: string + message: string + /** Set once answered, so the card shows the outcome instead of buttons. */ + decided?: 'allowed' | 'refused' | 'expired' +} + +export interface PendingApproval { + id: string + session_id: string + tool: string + arguments: string + message?: string +} + +export const CURSOR_APPROVAL_TOOLS = ['cursor_direct', 'cursor_direct_cancel'] as const + +/** An `approval` stream event as a card view, or null for anything else. */ +export function approvalFromEvent( + event: Record, +): ApprovalView | null { + if (event.type !== 'approval') return null + const id = typeof event.id === 'string' ? event.id : '' + if (!id) return null + return { + id, + tool: String(event.name ?? ''), + arguments: String(event.arguments ?? ''), + message: String(event.message ?? ''), + } +} + +/** Add an approval once. A decision already shown to the user is never reset. */ +export function mergeApprovals( + current: ApprovalView[], + incoming: ApprovalView, +): ApprovalView[] { + if (current.some((approval) => approval.id === incoming.id)) return current + return [...current, incoming] +} + +/** + * The approvals waiting on this session, merged into what is already on screen. + * Used after (re)opening a session, where the `approval` event that announced a + * still-pending decision was published before this page attached. + */ +export function pendingApprovalsForSession( + current: ApprovalView[], + pending: PendingApproval[], + sessionId: string | undefined, +): ApprovalView[] { + if (!sessionId) return current + let merged = current + for (const request of pending ?? []) { + if (request.session_id !== sessionId) continue + merged = mergeApprovals(merged, { + id: request.id, + tool: request.tool, + arguments: request.arguments, + message: request.message ?? '', + }) + } + return merged +} + +export interface CursorApprovalDetails { + operation: string + newAgent: boolean + model: string + params: Array<{ id: string; value: string }> + repositoryUrl: string + repositorySource: string + startingRef: string + worktreeDirty: boolean + localOnlyCommits: number + remoteRefKnown: boolean + warnings: string[] + mode: string + autoCreatePR: boolean + promptPreview: string + imageCount: number + /** Populated for a cancellation, which names the run it would stop. */ + agentId: string + runId: string +} + +/** The Cursor projection behind an approval, or null for any other tool. */ +export function parseCursorApproval( + approval: Pick, +): CursorApprovalDetails | null { + if (!CURSOR_APPROVAL_TOOLS.includes(approval.tool as (typeof CURSOR_APPROVAL_TOOLS)[number])) { + return null + } + let parsed: Record + try { + const decoded: unknown = JSON.parse(approval.arguments) + if (typeof decoded !== 'object' || decoded === null) return null + parsed = decoded as Record + } catch { + return null + } + + const model = (parsed.model ?? {}) as { id?: unknown; params?: unknown } + const params = Array.isArray(model.params) + ? (model.params as Array>).map((param) => ({ + id: String(param.id ?? ''), + value: String(param.value ?? ''), + })) + : [] + return { + operation: String(parsed.operation ?? ''), + newAgent: parsed.kind === 'new_agent', + model: String(model.id ?? ''), + params, + repositoryUrl: String(parsed.repository_url ?? ''), + repositorySource: String(parsed.repository_source ?? ''), + startingRef: String(parsed.starting_ref ?? ''), + worktreeDirty: parsed.worktree_dirty === true, + localOnlyCommits: Number(parsed.local_only_commits ?? 0), + remoteRefKnown: parsed.remote_ref_known === true, + warnings: Array.isArray(parsed.warnings) ? parsed.warnings.map(String) : [], + mode: String(parsed.mode ?? ''), + autoCreatePR: parsed.auto_create_pr === true, + promptPreview: String(parsed.prompt_preview ?? ''), + imageCount: Number(parsed.image_count ?? 0), + agentId: String(parsed.agent_id ?? ''), + runId: String(parsed.run_id ?? ''), + } +} + +/** + * What the composer's Stop button does. A Cursor run lives on Cursor's side, so + * Stop only closes this browser's stream; cancelling it remotely is a separate, + * approved action. + */ +export function stopBehavior(kind: 'chat' | 'cursor'): { + interrupt: boolean + detach: boolean +} { + return kind === 'cursor' + ? { interrupt: false, detach: true } + : { interrupt: true, detach: false } +} + +/** + * Whether the standing attach loop may reconnect. After an intentional detach + * it must not, or Stop would immediately re-follow the run it just left. + */ +export function shouldReconnectAttach(state: { + alive: boolean + detached: boolean +}): boolean { + return state.alive && !state.detached +} + +export interface CursorBranch { + repoUrl: string + branch: string + prUrl: string +} + +export interface CursorSessionHydration { + active: boolean + modelId?: string + remoteStatus?: string + branches: CursorBranch[] +} + +/** The durable Cursor state `GET /api/sessions/{id}` projects for the composer. */ +export interface CursorStateProjection { + target_active: boolean + reuse_valid: boolean + model_id: string + model_params: Array<{ id: string; value: string }> + /** null when the run discovered its repository (or ran without one). */ + repository_url: string | null + starting_ref: string + mode: string + auto_create_pr: boolean + remote_status: string + operation_state: string + git?: { branches?: Array<{ repo_url: string; branch: string; pr_url: string }> } +} + +export interface CursorHydration { + /** Whether this conversation's execution target is still Cursor. */ + active: boolean + modelId?: string + params?: Array<{ id: string; value: string }> + mode?: 'agent' | 'plan' + repositoryUrl?: string | null + startingRef?: string + autoCreatePR?: boolean + reuseValid?: boolean + remoteStatus?: string + operationState?: string + /** A remote run that has not reached a terminal state yet. */ + running?: boolean + branches: CursorBranch[] +} + +/** Operation states in which Cursor still owns unfinished remote work. */ +const CURSOR_RUNNING_OPERATIONS = [ + 'awaiting_approval', + 'create_in_flight', + 'run_in_flight', +] + +/** + * Restore the Cursor half of a session. The durable projection is + * authoritative: when the server reports no state, or a target that is no + * longer Cursor, old transcript metadata must not resurrect Cursor mode. + * Transcript parsing survives only for a server that predates the projection, + * which is the one case where the field is absent rather than null. + */ +export function cursorHydrationFromDetail(detail: { + cursor_state?: CursorStateProjection | null + messages: HydrationMessage[] +}): CursorHydration { + if (detail.cursor_state === undefined) { + const legacy = cursorSessionHydration(detail.messages) + return { + active: legacy.active, + modelId: legacy.modelId, + remoteStatus: legacy.remoteStatus, + // A transcript proves neither the exact variant nor that a follow-up + // would reuse the same agent. + reuseValid: false, + running: false, + branches: legacy.branches, + } + } + + const state = detail.cursor_state + if (!state) return { active: false, branches: [] } + + const branches: CursorBranch[] = (state.git?.branches ?? []).map((branch) => ({ + repoUrl: String(branch.repo_url ?? ''), + branch: String(branch.branch ?? ''), + prUrl: String(branch.pr_url ?? ''), + })) + const remoteStatus = state.remote_status || undefined + const operationState = state.operation_state || undefined + + if (!state.target_active) { + return { + active: false, + reuseValid: false, + running: false, + remoteStatus, + operationState, + branches, + } + } + + const hydration: CursorHydration = { + active: true, + mode: state.mode === 'agent' || state.mode === 'plan' ? state.mode : undefined, + repositoryUrl: state.repository_url ?? null, + startingRef: typeof state.starting_ref === 'string' ? state.starting_ref : '', + autoCreatePR: state.auto_create_pr === true, + reuseValid: state.reuse_valid === true, + remoteStatus, + operationState, + running: CURSOR_RUNNING_OPERATIONS.includes(state.operation_state), + branches, + } + // The server drops both halves of a selection it could not decode exactly. + if (state.model_id) { + hydration.modelId = state.model_id + hydration.params = (state.model_params ?? []).map((param) => ({ + id: String(param.id ?? ''), + value: String(param.value ?? ''), + })) + } + return hydration +} + +interface HydrationMessage { + role: string + model?: string + meta?: Record | null +} + +/** + * Recover the Cursor side of a persisted session: only a Cursor turn records a + * remote status, so the newest one identifies the model, its outcome, and the + * branches or pull requests the run produced. + */ +export function cursorSessionHydration( + messages: HydrationMessage[], +): CursorSessionHydration { + for (let i = (messages ?? []).length - 1; i >= 0; i--) { + const message = messages[i] + if (message.role !== 'assistant') continue + const status = message.meta?.cursor_remote_status + if (typeof status !== 'string' || !status) continue + return { + active: true, + modelId: message.model || undefined, + remoteStatus: status, + branches: parseCursorBranches(message.meta?.cursor_git_state), + } + } + return { active: false, branches: [] } +} + +function parseCursorBranches(raw: unknown): CursorBranch[] { + if (typeof raw !== 'string' || !raw) return [] + try { + const parsed: unknown = JSON.parse(raw) + const branches = (parsed as { branches?: unknown })?.branches + if (!Array.isArray(branches)) return [] + return branches.map((branch: Record) => ({ + repoUrl: String(branch.repoUrl ?? ''), + branch: String(branch.branch ?? ''), + prUrl: String(branch.prUrl ?? ''), + })) + } catch { + return [] + } +} diff --git a/web/src/lib/composerRestore.test.mjs b/web/src/lib/composerRestore.test.mjs new file mode 100644 index 0000000..4574176 --- /dev/null +++ b/web/src/lib/composerRestore.test.mjs @@ -0,0 +1,389 @@ +import { describe, expect, test } from 'bun:test' +import { + baselineAfterSend, + composerCanSend, + ownershipResolutionAfterCompletion, + restoreIsCurrent, + sessionOpenIsCurrent, + sessionTargetOwner, + shouldAdoptDefaultTarget, + stopStreamKind, + targetAfterCursorHydration, + targetChangeAllowed, +} from './composerRestore.ts' + +const chatTarget = { + kind: 'chat', + provider: 'openai', + model: 'gpt-5.6', + name: 'GPT 5.6', + providerLabel: 'OpenAI', +} + +const otherChatTarget = { ...chatTarget, model: 'gpt-5.5', name: 'GPT 5.5' } + +const cursorTarget = (id) => ({ + kind: 'cursor', + model: { id, name: id, aliases: [], parameters: [], variants: [] }, + variant: { params: [], displayName: id }, +}) + +describe('session-scoped restoration', () => { + test('only the newest hydration may apply its result', () => { + expect(restoreIsCurrent(4, 4)).toBe(true) + // Session A's catalogue answer arriving after session B opened. + expect(restoreIsCurrent(4, 5)).toBe(false) + }) +}) + +describe('automatic chat defaults', () => { + test('a default never lands while a session is still hydrating', () => { + expect(shouldAdoptDefaultTarget({ owner: 'pending', hasTarget: false })).toBe(false) + }) + + test('a default never replaces a restored Cursor target', () => { + expect(shouldAdoptDefaultTarget({ owner: 'restored', hasTarget: true })).toBe(false) + expect(shouldAdoptDefaultTarget({ owner: 'restored', hasTarget: false })).toBe(false) + }) + + test('a default fills an empty composer once hydration is done', () => { + expect(shouldAdoptDefaultTarget({ owner: 'free', hasTarget: false })).toBe(true) + }) + + test('a default never overwrites a target that is already chosen', () => { + expect(shouldAdoptDefaultTarget({ owner: 'free', hasTarget: true })).toBe(false) + }) +}) + +describe('ownership derived from the exact route-open occurrence', () => { + const opened = (sessionId, routerKey) => ({ + sessionId, + // The object identity is the epoch. routerKey is diagnostic only: browser + // POP may emit a fresh open occurrence for the same history entry/key. + epoch: { routerKey }, + }) + const unresolved = { open: null, owner: 'free' } + + test('an existing session is pending on initial load before effects run', () => { + const initialA = opened('A', 'default') + const owner = sessionTargetOwner({ open: initialA, resolved: unresolved }) + expect(owner).toBe('pending') + expect(composerCanSend({ owner, streaming: false })).toBe(false) + }) + + test('a normal A to B navigation is pending before B hydration runs', () => { + const openA = opened('A', 'a-entry') + const openB = opened('B', 'b-entry') + expect( + sessionTargetOwner({ + open: openB, + resolved: { open: openA, owner: 'restored' }, + }), + ).toBe('pending') + expect(sessionOpenIsCurrent(openA, openB)).toBe(false) + }) + + test('B1 to A to B2 stays pending even though both B visits have the same id', () => { + const openB1 = opened('B', 'b-entry') + const openA = opened('A', 'a-entry') + // A POP can reopen the same router entry, so even the diagnostic key may + // repeat; the emitted location object still gives B2 a distinct epoch. + const openB2 = opened('B', 'b-entry') + const resolvedB1 = { open: openB1, owner: 'restored' } + + expect(sessionTargetOwner({ open: openA, resolved: resolvedB1 })).toBe('pending') + expect(sessionTargetOwner({ open: openB2, resolved: resolvedB1 })).toBe('pending') + }) + + test('stale B1 and intervening A completions cannot resolve or mutate B2', () => { + const openB1 = opened('B', 'b-entry') + const openA = opened('A', 'a-entry') + const openB2 = opened('B', 'b-entry') + const resolvedB2 = { open: openB2, owner: 'restored' } + + expect(sessionOpenIsCurrent(openB1, openB2)).toBe(false) + expect(sessionOpenIsCurrent(openA, openB2)).toBe(false) + expect( + ownershipResolutionAfterCompletion({ + current: openB2, + previous: resolvedB2, + completed: openB1, + owner: 'free', + }), + ).toBe(resolvedB2) + expect( + ownershipResolutionAfterCompletion({ + current: openB2, + previous: resolvedB2, + completed: openA, + owner: 'free', + }), + ).toBe(resolvedB2) + }) + + test('only the current B2 completion resolves B2', () => { + const openB2 = opened('B', 'b-entry') + const resolvedB2 = ownershipResolutionAfterCompletion({ + current: openB2, + previous: unresolved, + completed: openB2, + owner: 'restored', + }) + expect(sessionOpenIsCurrent(openB2, openB2)).toBe(true) + expect(resolvedB2).toEqual({ open: openB2, owner: 'restored' }) + expect( + sessionTargetOwner({ + open: openB2, + resolved: resolvedB2, + }), + ).toBe('restored') + expect( + sessionTargetOwner({ + open: openB2, + resolved: { open: openB2, owner: 'free' }, + }), + ).toBe('free') + }) + + test('a no-session new chat remains free despite a stale resolution', () => { + const newChat = opened('', 'new-chat') + const staleA = opened('A', 'a-entry') + expect(sessionTargetOwner({ open: newChat, resolved: unresolved })).toBe('free') + expect( + sessionTargetOwner({ + open: newChat, + resolved: { open: staleA, owner: 'restored' }, + }), + ).toBe('free') + }) + + test('mid-stream server-id adoption waits for the adopted route occurrence', () => { + const newChat = opened('', 'draft-entry') + const preNavigationAdoption = { + open: { sessionId: 'B', epoch: newChat.epoch }, + owner: 'restored', + } + const adoptedB = opened('B', 'assigned-entry') + + expect(sessionTargetOwner({ open: newChat, resolved: unresolved })).toBe('free') + expect( + sessionTargetOwner({ open: adoptedB, resolved: preNavigationAdoption }), + ).toBe('pending') + expect(sessionOpenIsCurrent(preNavigationAdoption.open, adoptedB)).toBe(false) + expect( + sessionTargetOwner({ + open: adoptedB, + resolved: { open: adoptedB, owner: 'restored' }, + }), + ).toBe('restored') + }) +}) + +describe('sending while a session is still hydrating', () => { + test('a session whose target is not yet known cannot submit at all', () => { + // Both the send button and Enter go through this gate, so neither route + // can post a Cursor conversation's turn to /chat. + expect(composerCanSend({ owner: 'pending', streaming: false })).toBe(false) + }) + + test('a resolved session may submit', () => { + expect(composerCanSend({ owner: 'free', streaming: false })).toBe(true) + expect(composerCanSend({ owner: 'restored', streaming: false })).toBe(true) + }) + + test('a streaming turn still blocks a second submit', () => { + expect(composerCanSend({ owner: 'free', streaming: true })).toBe(false) + expect(composerCanSend({ owner: 'restored', streaming: true })).toBe(false) + }) +}) + +describe('the target a hydrated session should hold', () => { + test('an active Cursor session keeps the composer while its exact variant loads', () => { + expect( + targetAfterCursorHydration({ + active: true, + modelId: 'gpt-5.6-sol', + current: cursorTarget('gpt-5.6-sol'), + pendingDefault: chatTarget, + lastChat: chatTarget, + }), + ).toEqual({ owner: 'restored', action: 'keep' }) + }) + + test('a Cursor target from another session is dropped before its replacement loads', () => { + expect( + targetAfterCursorHydration({ + active: true, + modelId: 'claude-opus-5', + current: cursorTarget('gpt-5.6-sol'), + pendingDefault: null, + lastChat: null, + }), + ).toEqual({ owner: 'restored', action: 'set', target: null }) + }) + + test('an ordinary session replaces a leftover Cursor target with a chat one', () => { + expect( + targetAfterCursorHydration({ + active: false, + modelId: undefined, + current: cursorTarget('gpt-5.6-sol'), + pendingDefault: chatTarget, + lastChat: otherChatTarget, + }), + ).toEqual({ owner: 'free', action: 'set', target: chatTarget }) + }) + + test('the last chat target is used when no default has arrived yet', () => { + expect( + targetAfterCursorHydration({ + active: false, + modelId: undefined, + current: cursorTarget('gpt-5.6-sol'), + pendingDefault: null, + lastChat: otherChatTarget, + }), + ).toEqual({ owner: 'free', action: 'set', target: otherChatTarget }) + }) + + test('with no chat target known the Cursor target is still cleared', () => { + expect( + targetAfterCursorHydration({ + active: false, + modelId: undefined, + current: cursorTarget('gpt-5.6-sol'), + pendingDefault: null, + lastChat: null, + }), + ).toEqual({ owner: 'free', action: 'set', target: null }) + }) + + test('durable state that names no decodable model does not keep Cursor selected', () => { + expect( + targetAfterCursorHydration({ + active: true, + modelId: '', + current: cursorTarget('gpt-5.6-sol'), + pendingDefault: chatTarget, + lastChat: null, + }), + ).toEqual({ owner: 'free', action: 'set', target: chatTarget }) + }) + + test('an ordinary session leaves an existing chat target alone', () => { + expect( + targetAfterCursorHydration({ + active: false, + modelId: undefined, + current: chatTarget, + pendingDefault: otherChatTarget, + lastChat: otherChatTarget, + }), + ).toEqual({ owner: 'free', action: 'keep' }) + }) + + test('an ordinary session installs a chat target when the composer holds none', () => { + // The session switch already cleared the previous Cursor target, so there + // is nothing left to replace — the fallback still has to be installed. + expect( + targetAfterCursorHydration({ + active: false, + modelId: undefined, + current: null, + pendingDefault: chatTarget, + lastChat: otherChatTarget, + }), + ).toEqual({ owner: 'free', action: 'set', target: chatTarget }) + expect( + targetAfterCursorHydration({ + active: false, + modelId: undefined, + current: null, + pendingDefault: null, + lastChat: otherChatTarget, + }), + ).toEqual({ owner: 'free', action: 'set', target: otherChatTarget }) + }) + + test('an undecodable selection installs a chat target over an empty composer', () => { + expect( + targetAfterCursorHydration({ + active: true, + modelId: '', + current: null, + pendingDefault: chatTarget, + lastChat: null, + }), + ).toEqual({ owner: 'free', action: 'set', target: chatTarget }) + }) + + test('an empty composer with nothing to fall back on stays empty', () => { + expect( + targetAfterCursorHydration({ + active: false, + modelId: undefined, + current: null, + pendingDefault: null, + lastChat: null, + }), + ).toEqual({ owner: 'free', action: 'keep' }) + }) + + test('a choice made after hydration began is never overwritten', () => { + const chosen = cursorTarget('claude-opus-5') + expect( + targetAfterCursorHydration({ + active: false, + modelId: undefined, + current: chosen, + pendingDefault: chatTarget, + lastChat: chatTarget, + userChose: true, + }), + ).toEqual({ owner: 'free', action: 'keep' }) + expect( + targetAfterCursorHydration({ + active: true, + modelId: 'gpt-5.6-sol', + current: chatTarget, + pendingDefault: null, + lastChat: chatTarget, + userChose: true, + }), + ).toEqual({ owner: 'free', action: 'keep' }) + }) +}) + +describe('stop semantics', () => { + test('the stream that was started decides, not the picker', () => { + expect(stopStreamKind('cursor', false)).toBe('cursor') + expect(stopStreamKind('chat', true)).toBe('chat') + }) + + test('an attached run falls back to the session durable target', () => { + expect(stopStreamKind(null, true)).toBe('cursor') + expect(stopStreamKind(null, false)).toBe('chat') + }) + + test('the target cannot be changed while a turn is streaming', () => { + expect(targetChangeAllowed(true)).toBe(false) + expect(targetChangeAllowed(false)).toBe(true) + }) +}) + +describe('follow-up baseline', () => { + const options = { model: { id: 'sol' }, variant: { params: [] }, mode: 'agent' } + const previous = { options: { model: { id: 'opus' }, variant: { params: [] } }, reuseValid: true } + + test('a refused request never becomes the run a follow-up would continue', () => { + expect(baselineAfterSend({ previous, attempted: options, accepted: false })).toBe(previous) + expect(baselineAfterSend({ previous: null, attempted: options, accepted: false })).toBeNull() + }) + + test('an accepted stream adopts what it was sent with', () => { + expect(baselineAfterSend({ previous, attempted: options, accepted: true })).toEqual({ + options, + reuseValid: true, + }) + }) +}) diff --git a/web/src/lib/composerRestore.ts b/web/src/lib/composerRestore.ts new file mode 100644 index 0000000..e47d76a --- /dev/null +++ b/web/src/lib/composerRestore.ts @@ -0,0 +1,192 @@ +/** + * The composer's restoration decisions, kept away from React so the rules that + * protect a send from targeting the wrong place are testable on their own. + * + * Three asynchronous sources race for one execution target: the picker's + * mount-time active-model lookup, a session's durable Cursor state, and the + * user. Only the user always wins; the other two are ordered by which session + * is open and whether that session's state has been read yet. + */ + +import type { ChatTarget, ComposerTarget, CursorOptionsValue, CursorRunBaseline } from '@/lib/composerTargets' + +/** + * Who owns the composer's target right now: + * - `pending`: a session is being hydrated and its state has the final say; + * - `restored`: durable state named the target, so no default may replace it; + * - `free`: nothing owns it, so an automatic chat default may fill it. + */ +export type TargetOwner = 'pending' | 'restored' | 'free' + +/** Whether an asynchronous restoration still belongs to the open session. */ +export function restoreIsCurrent(captured: number, current: number): boolean { + return captured === current +} + +/** + * One activation of a chat route. The epoch is an opaque identity token, not a + * textual router key: browser POP can activate the same history entry twice. + */ +export interface SessionOpenOccurrence { + sessionId: string + epoch: object +} + +/** Whether a completion still belongs to the route-open occurrence on screen. */ +export function sessionOpenIsCurrent( + captured: SessionOpenOccurrence, + current: SessionOpenOccurrence, +): boolean { + return captured.sessionId === current.sessionId && captured.epoch === current.epoch +} + +/** The exact route-open occurrence ownership was last resolved for. */ +export interface SessionOwnershipResolution { + open: SessionOpenOccurrence | null + owner: TargetOwner +} + +/** + * Record a completion only when it belongs to the occurrence currently open. + * Returning the previous object for stale work also prevents a harmless-looking + * stale write from closing a newer occurrence that has already resolved. + */ +export function ownershipResolutionAfterCompletion(input: { + current: SessionOpenOccurrence + previous: SessionOwnershipResolution + completed: SessionOpenOccurrence + owner: TargetOwner +}): SessionOwnershipResolution { + if (!sessionOpenIsCurrent(input.completed, input.current)) return input.previous + if ( + input.previous.open && + sessionOpenIsCurrent(input.previous.open, input.completed) && + input.previous.owner === input.owner + ) { + return input.previous + } + return { open: input.completed, owner: input.owner } +} + +/** + * Who owns the target for the route occurrence currently open, derived rather + * than remembered. An existing conversation is pending until something has + * resolved ownership for that exact occurrence, including when the same + * session id is revisited. A new chat owns itself, so it is never blocked. + */ +export function sessionTargetOwner(input: { + open: SessionOpenOccurrence + resolved: SessionOwnershipResolution +}): TargetOwner { + if (!input.open.sessionId) return 'free' + if (!input.resolved.open || !sessionOpenIsCurrent(input.resolved.open, input.open)) { + return 'pending' + } + return input.resolved.owner +} + +/** + * Whether the picker's automatic active-model default may be adopted. It never + * competes with a session's own state, and never replaces a chosen target. + */ +export function shouldAdoptDefaultTarget(state: { + owner: TargetOwner + hasTarget: boolean +}): boolean { + return state.owner === 'free' && !state.hasTarget +} + +/** + * Whether the composer may submit. A session whose target is still being + * resolved has no answer to "where does this go?", and guessing would post a + * Cursor conversation's turn to the chat model instead. + */ +export function composerCanSend(state: { + owner: TargetOwner + streaming: boolean +}): boolean { + return state.owner !== 'pending' && !state.streaming +} + +export interface HydrationTargetInput { + /** Whether durable state still points this session at Cursor. */ + active: boolean + /** The model durable state names, if it names a usable one. */ + modelId?: string + current: ComposerTarget | null + /** A picker default that arrived while the session was hydrating. */ + pendingDefault: ChatTarget | null + /** The last chat target this tab used, if any. */ + lastChat: ChatTarget | null + /** Whether the user picked the current target after hydration began. */ + userChose?: boolean +} + +export type HydrationTargetDecision = + | { owner: TargetOwner; action: 'keep' } + | { owner: TargetOwner; action: 'set'; target: ComposerTarget | null } + +/** + * What the composer's target should become when a session's durable state + * arrives. A Cursor target left over from another conversation is dropped + * before the replacement loads: sending in that window must never reach a + * Cursor model this session never used. + */ +export function targetAfterCursorHydration( + input: HydrationTargetInput, +): HydrationTargetDecision { + const { active, modelId, current, pendingDefault, lastChat, userChose } = input + // Someone chose deliberately while the session was loading; that outranks + // anything the session itself would have restored. + if (userChose) return { owner: 'free', action: 'keep' } + if (active && modelId) { + // A restore is on its way for this session's own model. + if (current?.kind === 'cursor' && current.model.id !== modelId) { + return { owner: 'restored', action: 'set', target: null } + } + return { owner: 'restored', action: 'keep' } + } + // This session does not run on Cursor, or names nothing exact enough to run. + // Its Cursor target goes, and the composer needs a chat target to fall back + // to — including when the session switch already emptied it. + const fallback = pendingDefault ?? lastChat ?? null + if (current?.kind === 'cursor') { + return { owner: 'free', action: 'set', target: fallback } + } + if (current === null && fallback) { + return { owner: 'free', action: 'set', target: fallback } + } + return { owner: 'free', action: 'keep' } +} + +/** + * Which semantics Stop must use. The stream that is actually running decides — + * the picker may have moved on since it started — and an attached run falls + * back to what the session's durable state says it is. + */ +export function stopStreamKind( + started: 'chat' | 'cursor' | null, + cursorActive: boolean, +): 'chat' | 'cursor' { + return started ?? (cursorActive ? 'cursor' : 'chat') +} + +/** The target may only change while nothing is streaming. */ +export function targetChangeAllowed(streaming: boolean): boolean { + return !streaming +} + +/** + * The run a follow-up would continue after a send attempt. A request the server + * refused (busy session, rate limit, auth, stale model) started nothing, so the + * previous baseline stands and the new-agent warning stays truthful. + */ +export function baselineAfterSend(input: { + previous: CursorRunBaseline | null + attempted: CursorOptionsValue + accepted: boolean +}): CursorRunBaseline | null { + return input.accepted + ? { options: input.attempted, reuseValid: true } + : input.previous +} diff --git a/web/src/lib/composerTargets.test.mjs b/web/src/lib/composerTargets.test.mjs new file mode 100644 index 0000000..0dcf264 --- /dev/null +++ b/web/src/lib/composerTargets.test.mjs @@ -0,0 +1,253 @@ +import { describe, expect, test } from 'bun:test' +import { + chatTargetFromModel, + composerTargetKey, + cursorCatalogueState, + cursorChatRequest, + cursorTargetFromModel, + isCursorTarget, + searchComposerTargets, + startsNewCursorAgent, +} from './composerTargets.ts' + +const chatModels = [ + { + id: 'gpt-5.6', + name: 'GPT 5.6', + provider: 'openai', + provider_label: 'OpenAI', + reasoning_capability: { values: [], mandatory: false, can_disable: false, source: 'live' }, + }, + { + id: 'claude-opus-4-6', + name: 'Claude Opus 4.6', + provider: 'anthropic', + provider_label: 'Anthropic', + }, +] + +const cursorModels = [ + { + id: 'gpt-5.6-sol', + name: 'GPT 5.6 Sol', + aliases: ['sol'], + parameters: [{ id: 'reasoning', values: [{ value: 'low' }, { value: 'max' }] }], + variants: [ + { + params: [ + { id: 'reasoning', value: 'low' }, + { id: 'internal', value: 'off' }, + ], + displayName: 'GPT 5.6 Sol', + isDefault: true, + }, + { + params: [ + { id: 'reasoning', value: 'max' }, + { id: 'internal', value: 'off' }, + ], + displayName: 'GPT 5.6 Sol (max)', + }, + ], + }, + { + id: 'auto-smart', + name: 'Auto (smart)', + aliases: ['auto'], + parameters: [], + variants: [], + }, +] + +describe('composer targets', () => { + test('a chat target carries provider metadata and reasoning capability', () => { + const target = chatTargetFromModel(chatModels[0]) + expect(target).toEqual({ + kind: 'chat', + provider: 'openai', + model: 'gpt-5.6', + name: 'GPT 5.6', + providerLabel: 'OpenAI', + reasoningCapability: chatModels[0].reasoning_capability, + }) + expect(isCursorTarget(target)).toBe(false) + }) + + test('a Cursor target starts from the upstream default variant', () => { + const target = cursorTargetFromModel(cursorModels[0]) + expect(target.kind).toBe('cursor') + expect(target.variant).toBe(cursorModels[0].variants[0]) + expect(isCursorTarget(target)).toBe(true) + }) + + test('a model with no upstream variant cannot become a target', () => { + // cursorModels[1] is the catalogue's variant-less entry. + expect(cursorTargetFromModel(cursorModels[1])).toBeNull() + }) + + test('target keys separate the two execution surfaces', () => { + expect(composerTargetKey(chatTargetFromModel(chatModels[0]))).toBe('chat:openai/gpt-5.6') + expect(composerTargetKey(cursorTargetFromModel(cursorModels[0]))).toBe('cursor:gpt-5.6-sol') + }) +}) + +describe('grouped target search', () => { + test('searches chat id, name, and provider label', () => { + expect(searchComposerTargets({ chatModels, cursorModels, query: 'anthropic' }).chat).toHaveLength(1) + expect(searchComposerTargets({ chatModels, cursorModels, query: 'opus' }).chat[0].model).toBe( + 'claude-opus-4-6', + ) + expect(searchComposerTargets({ chatModels, cursorModels, query: 'gpt-5.6' }).chat[0].model).toBe( + 'gpt-5.6', + ) + }) + + test('searches Cursor id, name, and alias without mixing the groups', () => { + const bySlug = searchComposerTargets({ chatModels, cursorModels, query: 'sol' }) + expect(bySlug.cursor.map((t) => t.model.id)).toEqual(['gpt-5.6-sol']) + expect(bySlug.chat).toHaveLength(0) + + const byAlias = searchComposerTargets({ chatModels, cursorModels, query: 'auto' }) + expect(byAlias.cursor.map((t) => t.model.id)).toEqual(['auto-smart']) + }) + + test('the Cursor group answers a Cursor provider search', () => { + const found = searchComposerTargets({ chatModels, cursorModels, query: 'cursor' }) + expect(found.cursor).toHaveLength(2) + expect(found.chat).toHaveLength(0) + }) + + test('a model with no upstream variant is listed but has no target to select', () => { + const found = searchComposerTargets({ chatModels, cursorModels, query: 'auto' }) + expect(found.cursor.map((row) => row.model.id)).toEqual(['auto-smart']) + expect(found.cursor[0].target).toBeNull() + const usable = searchComposerTargets({ chatModels, cursorModels, query: 'sol' }) + expect(usable.cursor[0].target?.variant).toBe(cursorModels[0].variants[0]) + }) + + test('an empty query keeps both catalogues intact', () => { + const all = searchComposerTargets({ chatModels, cursorModels, query: '' }) + expect(all.chat).toHaveLength(2) + expect(all.cursor).toHaveLength(2) + }) +}) + +describe('Cursor catalogue state', () => { + test('a missing key asks for the Connect action instead of an error', () => { + expect(cursorCatalogueState({ needs_key: true, models: [] })).toBe('connect') + }) + + test('a catalogue error is reported as an error', () => { + expect(cursorCatalogueState({ models: [], error: 'Cursor API key expired' })).toBe('error') + expect(cursorCatalogueState(undefined, new Error('Network unavailable'))).toBe('error') + }) + + test('a connected but empty catalogue is empty, not disconnected', () => { + expect(cursorCatalogueState({ models: [] })).toBe('empty') + expect(cursorCatalogueState({ models: cursorModels })).toBe('ready') + }) +}) + +describe('Cursor run identity', () => { + const base = { + model: cursorModels[0], + variant: cursorModels[0].variants[0], + mode: 'agent', + repositoryUrl: 'https://github.com/acme/repo', + startingRef: 'main', + autoCreatePR: false, + } + const reusable = { options: base, reuseValid: true } + + test('mode-only changes continue the same agent', () => { + expect(startsNewCursorAgent(reusable, { ...base, mode: 'plan' })).toBe(false) + }) + + test('model, variant, repository, ref, and auto-PR changes start a new agent', () => { + expect( + startsNewCursorAgent(reusable, { ...base, variant: cursorModels[0].variants[1] }), + ).toBe(true) + expect( + startsNewCursorAgent(reusable, { ...base, model: cursorModels[1], variant: { params: [] } }), + ).toBe(true) + expect(startsNewCursorAgent(reusable, { ...base, repositoryUrl: '' })).toBe(true) + expect(startsNewCursorAgent(reusable, { ...base, repositoryUrl: null })).toBe(true) + expect(startsNewCursorAgent(reusable, { ...base, startingRef: 'release' })).toBe(true) + expect(startsNewCursorAgent(reusable, { ...base, autoCreatePR: true })).toBe(true) + }) + + test('an identical selection whose reuse was invalidated still starts a new agent', () => { + expect(startsNewCursorAgent({ options: base, reuseValid: false }, { ...base })).toBe(true) + }) + + test('no previous run never warns about a new agent', () => { + expect(startsNewCursorAgent(null, base)).toBe(false) + }) +}) + +describe('Cursor chat request', () => { + const value = { + model: cursorModels[0], + variant: cursorModels[0].variants[1], + mode: 'plan', + repositoryUrl: null, + startingRef: null, + autoCreatePR: false, + } + + test('sends the exact upstream variant params', () => { + const request = cursorChatRequest(value, { + sessionId: 'ses_1', + message: 'ship it', + images: ['data:image/png;base64,AAAA'], + projectDir: '/home/me/project', + }) + expect(request.model).toEqual({ + id: 'gpt-5.6-sol', + params: [ + { id: 'reasoning', value: 'max' }, + { id: 'internal', value: 'off' }, + ], + }) + expect(request.mode).toBe('plan') + expect(request.session_id).toBe('ses_1') + expect(request.images).toEqual(['data:image/png;base64,AAAA']) + expect(request.project_dir).toBe('/home/me/project') + expect(request.auto_create_pr).toBe(false) + }) + + test('omits repository overrides so the server discovers the project repo', () => { + const request = cursorChatRequest(value, { sessionId: '', message: 'hi' }) + expect('repository_url' in request).toBe(false) + expect('starting_ref' in request).toBe(false) + expect('project_dir' in request).toBe(false) + }) + + test('an edited repository and ref are sent verbatim, including a cleared repository', () => { + const edited = cursorChatRequest( + { ...value, repositoryUrl: 'https://github.com/acme/repo', startingRef: 'main' }, + { sessionId: 'ses_1', message: 'hi' }, + ) + expect(edited.repository_url).toBe('https://github.com/acme/repo') + expect(edited.starting_ref).toBe('main') + + const cleared = cursorChatRequest( + { ...value, repositoryUrl: '', startingRef: '' }, + { sessionId: 'ses_1', message: 'hi' }, + ) + expect(cleared.repository_url).toBe('') + expect(cleared.starting_ref).toBe('') + }) + + test('the request never carries composer-only fields', () => { + const request = cursorChatRequest(value, { sessionId: 'ses_1', message: 'hi' }) + expect(Object.keys(request).sort()).toEqual([ + 'auto_create_pr', + 'images', + 'message', + 'mode', + 'model', + 'session_id', + ]) + }) +}) diff --git a/web/src/lib/composerTargets.ts b/web/src/lib/composerTargets.ts new file mode 100644 index 0000000..0484495 --- /dev/null +++ b/web/src/lib/composerTargets.ts @@ -0,0 +1,231 @@ +/** + * The composer's execution target. A chat target runs through `/api/chat` and + * the active Antares provider; a Cursor target runs through `/api/chat/cursor` + * and never becomes the active chat provider. + */ + +import { + cursorModelMatches, + defaultCursorVariant, + type CursorModel, + type CursorVariant, +} from '@/lib/cursorModels' +import type { ReasoningCapability } from '@/lib/models' + +export interface ChatCatalogueModel { + id: string + name: string + provider: string + provider_label: string + reasoning_capability?: ReasoningCapability +} + +export interface ChatTarget { + kind: 'chat' + provider: string + model: string + name: string + providerLabel: string + reasoningCapability?: ReasoningCapability +} + +export interface CursorTarget { + kind: 'cursor' + model: CursorModel + variant: CursorVariant +} + +export type ComposerTarget = ChatTarget | CursorTarget + +export type CursorMode = 'agent' | 'plan' + +/** + * Everything one Cursor turn needs. `repositoryUrl`/`startingRef` are null + * until the user edits them, so the server keeps discovering the project's own + * repository; an empty string is an explicit "no repository". + */ +export interface CursorOptionsValue { + model: CursorModel + variant: CursorVariant + mode: CursorMode + repositoryUrl: string | null + startingRef: string | null + autoCreatePR: boolean +} + +export function isCursorTarget(target: ComposerTarget | null): target is CursorTarget { + return target?.kind === 'cursor' +} + +export function isChatTarget(target: ComposerTarget | null): target is ChatTarget { + return target?.kind === 'chat' +} + +export function chatTargetFromModel(model: ChatCatalogueModel): ChatTarget { + return { + kind: 'chat', + provider: model.provider, + model: model.id, + name: model.name, + providerLabel: model.provider_label, + reasoningCapability: model.reasoning_capability, + } +} + +/** + * A Cursor target for this model, or null when the catalogue offers no variant + * to run it with. Inventing an empty parameter list would send a selection + * Cursor never returned. + */ +export function cursorTargetFromModel( + model: CursorModel, + variant: CursorVariant | null = defaultCursorVariant(model), +): CursorTarget | null { + return variant ? { kind: 'cursor', model, variant } : null +} + +export function composerTargetKey(target: ComposerTarget): string { + return target.kind === 'cursor' + ? `cursor:${target.model.id}` + : `chat:${target.provider}/${target.model}` +} + +/** The composer chip label: the chat model id, or `Cursor · `. */ +export function composerTargetLabel(target: ComposerTarget | null): string { + if (!target) return '' + return target.kind === 'cursor' + ? `Cursor · ${target.model.name || target.model.id}` + : target.model +} + +function chatModelMatches(model: ChatCatalogueModel, query: string): boolean { + const q = query.trim().toLowerCase() + if (!q) return true + return [model.id, model.name, model.provider, model.provider_label].some((entry) => + entry.toLowerCase().includes(q), + ) +} + +/** A Cursor search hit. `target` is null when the model cannot be run at all. */ +export interface CursorSearchRow { + model: CursorModel + target: CursorTarget | null +} + +/** + * One search over both catalogues, presented as two groups. The catalogues stay + * separate: a Cursor hit is never offered as a chat model. A model the + * catalogue gave no variant for is still listed — with nothing to select — so + * its absence from the composer is explained rather than silent. + */ +export function searchComposerTargets(input: { + chatModels: ChatCatalogueModel[] + cursorModels: CursorModel[] + query: string +}): { chat: ChatTarget[]; cursor: CursorSearchRow[] } { + const { chatModels = [], cursorModels = [], query } = input + return { + chat: chatModels + .filter((model) => chatModelMatches(model, query)) + .map(chatTargetFromModel), + cursor: cursorModels + .filter((model) => cursorModelMatches(model, query)) + .map((model) => ({ model, target: cursorTargetFromModel(model) })), + } +} + +export type CursorCatalogueState = 'connect' | 'error' | 'empty' | 'ready' + +/** + * What the Cursor section should show. A missing credential is an invitation to + * connect, not a failure. + */ +export function cursorCatalogueState( + response: { models?: CursorModel[]; needs_key?: boolean; error?: string } | undefined, + requestError?: Error, +): CursorCatalogueState { + if (response?.needs_key) return 'connect' + if (response?.error || requestError) return 'error' + if ((response?.models ?? []).length === 0) return 'empty' + return 'ready' +} + +/** + * The identity Cursor follow-up reuse depends on. Conversation mode is absent + * on purpose: Create Run accepts a mode override, so switching Agent/Plan + * continues the same remote agent. + */ +export function cursorRunIdentity(value: CursorOptionsValue): string { + return JSON.stringify({ + model: value.model.id, + params: (value.variant.params ?? []).map((param) => [param.id, param.value]), + repository: value.repositoryUrl, + ref: value.startingRef, + autoCreatePR: value.autoCreatePR, + }) +} + +/** + * The run a follow-up would continue: what it was configured with, and whether + * the server still considers that agent reusable. + */ +export interface CursorRunBaseline { + options: CursorOptionsValue + reuseValid: boolean +} + +/** Whether sending now would start a new Cursor agent instead of following up. */ +export function startsNewCursorAgent( + previous: CursorRunBaseline | null, + next: CursorOptionsValue, +): boolean { + if (!previous) return false + // An invalidated chain always creates a new agent, even for an identical + // selection — a failed create, a target switch, or an interrupted approval + // all leave nothing to follow up on. + if (!previous.reuseValid) return true + return cursorRunIdentity(previous.options) !== cursorRunIdentity(next) +} + +export interface CursorChatRequest { + session_id: string + message: string + images: string[] + model: { id: string; params: Array<{ id: string; value: string }> } + mode: CursorMode + auto_create_pr: boolean + project_dir?: string + repository_url?: string + starting_ref?: string +} + +/** The exact `POST /api/chat/cursor` body for one turn. */ +export function cursorChatRequest( + value: CursorOptionsValue, + turn: { + sessionId: string + message: string + images?: string[] + projectDir?: string + }, +): CursorChatRequest { + const request: CursorChatRequest = { + session_id: turn.sessionId, + message: turn.message, + images: [...(turn.images ?? [])], + model: { + id: value.model.id, + // The whole upstream variant, hidden params included. + params: (value.variant.params ?? []).map((param) => ({ + id: param.id, + value: param.value, + })), + }, + mode: value.mode, + auto_create_pr: value.autoCreatePR, + } + if (turn.projectDir) request.project_dir = turn.projectDir + if (value.repositoryUrl !== null) request.repository_url = value.repositoryUrl + if (value.startingRef !== null) request.starting_ref = value.startingRef + return request +} diff --git a/web/src/lib/cursorAttachments.test.mjs b/web/src/lib/cursorAttachments.test.mjs new file mode 100644 index 0000000..1a77d13 --- /dev/null +++ b/web/src/lib/cursorAttachments.test.mjs @@ -0,0 +1,79 @@ +import { describe, expect, test } from 'bun:test' +import { + CURSOR_MAX_IMAGES, + composerImageLimit, + dataUrlByteLength, + dataUrlMimeType, + validateCursorAttachments, +} from './cursorAttachments.ts' + +const png = (bytes = 3) => `data:image/png;base64,${'A'.repeat(Math.ceil(bytes / 3) * 4)}` + +describe('Cursor attachment preflight', () => { + test('local documents are rejected, never silently dropped', () => { + const issue = validateCursorAttachments({ + images: [], + docs: [ + { path: '/tmp/a.pdf', name: 'a.pdf' }, + { path: '/tmp/b.csv', name: 'b.csv' }, + ], + }) + expect(issue).toEqual({ code: 'documents', values: { names: 'a.pdf, b.csv' } }) + }) + + test('documents are reported before any image problem', () => { + const issue = validateCursorAttachments({ + images: Array.from({ length: 9 }, () => png()), + docs: [{ path: '/tmp/a.pdf', name: 'a.pdf' }], + }) + expect(issue?.code).toBe('documents') + }) + + test('five images are accepted and a sixth is refused', () => { + expect(CURSOR_MAX_IMAGES).toBe(5) + expect( + validateCursorAttachments({ images: Array.from({ length: 5 }, () => png()), docs: [] }), + ).toBeNull() + expect( + validateCursorAttachments({ images: Array.from({ length: 6 }, () => png()), docs: [] }), + ).toEqual({ code: 'imageCount', values: { max: 5, n: 6 } }) + }) + + test('only the MIME types Cursor accepts pass', () => { + for (const mime of ['image/png', 'image/jpeg', 'image/gif', 'image/webp']) { + expect( + validateCursorAttachments({ images: [`data:${mime};base64,AAAA`], docs: [] }), + ).toBeNull() + } + expect( + validateCursorAttachments({ images: ['data:image/svg+xml;base64,AAAA'], docs: [] }), + ).toEqual({ code: 'imageType', values: { n: 1, type: 'image/svg+xml' } }) + }) + + test('an entry that is not a base64 data URL is refused', () => { + expect( + validateCursorAttachments({ images: ['https://example.com/a.png'], docs: [] }), + ).toEqual({ code: 'imageType', values: { n: 1, type: '' } }) + }) + + test('an image over the 15 MiB decoded limit is refused before approval', () => { + const oversized = png(15 * 1024 * 1024 + 3) + expect(validateCursorAttachments({ images: [oversized], docs: [] })).toEqual({ + code: 'imageSize', + values: { n: 1, max: 15 }, + }) + }) + + test('composer image limits follow the execution target', () => { + expect(composerImageLimit('cursor')).toBe(5) + expect(composerImageLimit('chat')).toBe(4) + }) + + test('data URL helpers read the declared type and decoded size', () => { + expect(dataUrlMimeType('data:image/webp;base64,AAAA')).toBe('image/webp') + expect(dataUrlMimeType('nonsense')).toBe('') + expect(dataUrlByteLength('data:image/png;base64,AAAA')).toBe(3) + expect(dataUrlByteLength('data:image/png;base64,AAA=')).toBe(2) + expect(dataUrlByteLength('data:image/png;base64,AA==')).toBe(1) + }) +}) diff --git a/web/src/lib/cursorAttachments.ts b/web/src/lib/cursorAttachments.ts new file mode 100644 index 0000000..e2b8ed4 --- /dev/null +++ b/web/src/lib/cursorAttachments.ts @@ -0,0 +1,81 @@ +/** + * Cursor's attachment contract, checked in the composer before anything is + * sent. The server validates the same rules authoritatively; this preflight + * exists so a rejection happens before the draft is cleared and long before a + * paid operation is offered for approval. + */ + +export const CURSOR_MAX_IMAGES = 5 +export const CURSOR_MAX_IMAGE_BYTES = 15 << 20 +export const CURSOR_IMAGE_MIME_TYPES = [ + 'image/png', + 'image/jpeg', + 'image/gif', + 'image/webp', +] as const + +const CHAT_MAX_IMAGES = 4 + +/** How many images the composer accepts for the current execution target. */ +export function composerImageLimit(kind: 'chat' | 'cursor'): number { + return kind === 'cursor' ? CURSOR_MAX_IMAGES : CHAT_MAX_IMAGES +} + +/** The declared MIME type of a base64 data URL, or "" when it is not one. */ +export function dataUrlMimeType(dataUrl: string): string { + const match = /^data:([^;,]+);base64,/.exec(dataUrl.trim()) + return match ? match[1] : '' +} + +/** Decoded size of a base64 data URL, from its payload length alone. */ +export function dataUrlByteLength(dataUrl: string): number { + const payload = dataUrl.slice(dataUrl.indexOf(',') + 1) + if (!payload) return 0 + const padding = payload.endsWith('==') ? 2 : payload.endsWith('=') ? 1 : 0 + return Math.max(0, Math.floor((payload.length * 3) / 4) - padding) +} + +export interface CursorAttachmentIssue { + code: 'documents' | 'imageCount' | 'imageType' | 'imageSize' + values: Record +} + +/** + * The first reason this turn cannot be sent to Cursor, or null. Local documents + * are reported first: they are rejected outright rather than silently dropped, + * because a Cursor cloud VM cannot read a path on this machine. + */ +export function validateCursorAttachments(input: { + images: string[] + docs: Array<{ name: string }> +}): CursorAttachmentIssue | null { + const docs = input.docs ?? [] + if (docs.length > 0) { + return { + code: 'documents', + values: { names: docs.map((doc) => doc.name).join(', ') }, + } + } + + const images = input.images ?? [] + if (images.length > CURSOR_MAX_IMAGES) { + return { + code: 'imageCount', + values: { max: CURSOR_MAX_IMAGES, n: images.length }, + } + } + + for (let i = 0; i < images.length; i++) { + const mimeType = dataUrlMimeType(images[i]) + if (!CURSOR_IMAGE_MIME_TYPES.includes(mimeType as (typeof CURSOR_IMAGE_MIME_TYPES)[number])) { + return { code: 'imageType', values: { n: i + 1, type: mimeType } } + } + if (dataUrlByteLength(images[i]) > CURSOR_MAX_IMAGE_BYTES) { + return { + code: 'imageSize', + values: { n: i + 1, max: CURSOR_MAX_IMAGE_BYTES >> 20 }, + } + } + } + return null +} diff --git a/web/src/lib/cursorModels.test.mjs b/web/src/lib/cursorModels.test.mjs new file mode 100644 index 0000000..40a3d03 --- /dev/null +++ b/web/src/lib/cursorModels.test.mjs @@ -0,0 +1,448 @@ +import { describe, expect, test } from 'bun:test' +import { + cursorFilterCommit, + cursorFilterFromVariant, + cursorFilterMatches, + cursorModelMatches, + cursorModelSelectable, + cursorReasoningDimension, + cursorVariantDimensions, + cursorVariantSummary, + defaultCursorVariant, + matchingCursorVariants, + resolveCursorVariant, + selectExactVariant, + variantSelection, + withCursorFilter, +} from './cursorModels.ts' + +const modelFixture = { + id: 'gpt-test', + name: 'GPT Test', + aliases: [], + parameters: [ + { id: 'context', values: [{ value: '272k' }, { value: '1m' }] }, + { id: 'reasoning', values: [{ value: 'low' }, { value: 'max' }] }, + { id: 'fast', values: [{ value: 'false' }, { value: 'true' }] }, + ], + variants: [ + { + params: [ + { id: 'context', value: '272k' }, + { id: 'reasoning', value: 'max' }, + { id: 'fast', value: 'true' }, + ], + displayName: 'GPT Test', + isDefault: true, + }, + ], +} + +// Two reachable context values, each with its own reasoning ladder, so a filter +// can be proven to land on a real upstream variant instead of a synthesized one. +const multiVariantFixture = { + id: 'gpt-5.6-sol', + name: 'GPT 5.6 Sol', + aliases: ['sol'], + parameters: [ + { + id: 'context', + displayName: 'Context', + values: [ + { value: '272k', displayName: '272K' }, + { value: '1m', displayName: '1M' }, + ], + }, + { + id: 'reasoning', + displayName: 'Reasoning', + values: [{ value: 'low' }, { value: 'max' }], + }, + ], + variants: [ + { + params: [ + { id: 'context', value: '272k' }, + { id: 'reasoning', value: 'low' }, + { id: 'internal', value: 'off' }, + ], + displayName: 'GPT 5.6 Sol', + isDefault: true, + }, + { + params: [ + { id: 'context', value: '272k' }, + { id: 'reasoning', value: 'max' }, + { id: 'internal', value: 'off' }, + ], + displayName: 'GPT 5.6 Sol (max)', + }, + { + params: [ + { id: 'context', value: '1m' }, + { id: 'reasoning', value: 'max' }, + { id: 'internal', value: 'on' }, + ], + displayName: 'GPT 5.6 Sol (1M)', + }, + ], +} + +const autoSmartFixture = { + id: 'auto-smart', + name: 'Auto (smart)', + aliases: ['auto'], + parameters: [ + { + id: 'optimize_for', + displayName: 'Optimize for', + values: [ + { value: 'speed', displayName: 'Speed' }, + { value: 'quality', displayName: 'Quality' }, + ], + }, + ], + variants: [ + { + params: [{ id: 'optimize_for', value: 'speed' }], + displayName: 'Auto (speed)', + isDefault: true, + }, + { + params: [{ id: 'optimize_for', value: 'quality' }], + displayName: 'Auto (quality)', + }, + ], +} + +describe('exact Cursor variants', () => { + test('default variant keeps hidden params', () => { + const model = { + id: 'claude-opus-5', + name: 'Claude Opus 5', + aliases: [], + parameters: [{ id: 'effort', values: [{ value: 'max' }] }], + variants: [ + { + params: [ + { id: 'cyber', value: 'false' }, + { id: 'effort', value: 'max' }, + ], + displayName: 'Claude Opus 5', + isDefault: true, + }, + ], + } + expect(defaultCursorVariant(model).params).toEqual(model.variants[0].params) + }) + + test('filters never synthesize a missing combination', () => { + expect( + selectExactVariant(modelFixture, { context: '1m', reasoning: 'max', fast: 'true' }), + ).toBeNull() + }) + + test('prefers the upstream default variant, then the first one', () => { + expect(defaultCursorVariant(multiVariantFixture)).toBe(multiVariantFixture.variants[0]) + const noDefault = { ...multiVariantFixture, variants: multiVariantFixture.variants.slice(1) } + expect(defaultCursorVariant(noDefault)).toBe(noDefault.variants[0]) + }) + + test('a model the catalogue gave no variant for is not selectable', () => { + const bare = { id: 'bare', name: 'Bare', aliases: [], parameters: [], variants: [] } + // Sending an invented empty params array would be a selection Cursor never + // offered, so there is nothing to select at all. + expect(defaultCursorVariant(bare)).toBeNull() + expect(selectExactVariant(bare, {})).toBeNull() + expect(cursorModelSelectable(bare)).toBe(false) + expect(cursorModelSelectable(multiVariantFixture)).toBe(true) + }) + + test('an exact match returns the upstream variant object itself', () => { + const variant = selectExactVariant(multiVariantFixture, { context: '1m', reasoning: 'max' }) + expect(variant).toBe(multiVariantFixture.variants[2]) + expect(variant.params).toEqual([ + { id: 'context', value: '1m' }, + { id: 'reasoning', value: 'max' }, + { id: 'internal', value: 'on' }, + ]) + }) + + test('an ambiguous filter commits nothing', () => { + expect(matchingCursorVariants(multiVariantFixture, { context: '272k' })).toHaveLength(2) + expect(selectExactVariant(multiVariantFixture, { context: '272k' })).toBeNull() + }) + +}) + +// Two variants that share no axis value: reaching one from the other means +// moving both dimensions, which one-axis-at-a-time filtering cannot express. +const diagonalFixture = { + id: 'diagonal', + name: 'Diagonal', + aliases: [], + parameters: [ + { id: 'context', values: [{ value: 'short' }, { value: 'long' }] }, + { id: 'reasoning', values: [{ value: 'low' }, { value: 'max' }] }, + ], + variants: [ + { + params: [ + { id: 'context', value: 'short' }, + { id: 'reasoning', value: 'low' }, + { id: 'internal', value: 'cheap' }, + ], + displayName: 'Short · low', + isDefault: true, + }, + { + params: [ + { id: 'context', value: 'long' }, + { id: 'reasoning', value: 'max' }, + { id: 'internal', value: 'rich' }, + ], + displayName: 'Long · max', + }, + ], +} + +const tiedFixture = { + id: 'tied', + name: 'Tied', + aliases: [], + parameters: [{ id: 'fast', values: [{ value: 'off' }, { value: 'on' }] }], + variants: [ + { params: [{ id: 'fast', value: 'off' }], displayName: 'off', isDefault: true }, + { + params: [ + { id: 'fast', value: 'on' }, + { id: 'internal', value: 'a' }, + ], + displayName: 'on a', + }, + { + params: [ + { id: 'fast', value: 'on' }, + { id: 'internal', value: 'b' }, + ], + displayName: 'on b', + }, + ], +} + +describe('filtering variants with staged controls', () => { + test('a filter starts as the committed variant, hidden params excluded', () => { + expect( + cursorFilterFromVariant(multiVariantFixture, multiVariantFixture.variants[2]), + ).toEqual([ + { id: 'context', value: '1m' }, + { id: 'reasoning', value: 'max' }, + ]) + }) + + test('a diagonal variant is reachable, and keeps its hidden params exactly', () => { + const from = cursorFilterFromVariant(diagonalFixture, diagonalFixture.variants[0]) + // The older filter entry gives way to the choice just made, rather than + // making the only other configuration unreachable. + const next = withCursorFilter(diagonalFixture, from, 'context', 'long') + expect(next).toEqual([{ id: 'context', value: 'long' }]) + const committed = cursorFilterCommit(diagonalFixture, next) + expect(committed).toBe(diagonalFixture.variants[1]) + expect(variantSelection(committed)).toEqual({ + context: 'long', + reasoning: 'max', + internal: 'rich', + }) + }) + + test('the other axis is reachable from the same starting point', () => { + const from = cursorFilterFromVariant(diagonalFixture, diagonalFixture.variants[0]) + const next = withCursorFilter(diagonalFixture, from, 'reasoning', 'max') + expect(cursorFilterCommit(diagonalFixture, next)).toBe(diagonalFixture.variants[1]) + }) + + test('a filter that still matches several variants commits nothing yet', () => { + const partial = [{ id: 'context', value: '272k' }] + expect(cursorFilterMatches(multiVariantFixture, partial)).toHaveLength(2) + expect(cursorFilterCommit(multiVariantFixture, partial)).toBeNull() + + const narrowed = withCursorFilter(multiVariantFixture, partial, 'reasoning', 'max') + expect(narrowed).toEqual([ + { id: 'context', value: '272k' }, + { id: 'reasoning', value: 'max' }, + ]) + expect(cursorFilterCommit(multiVariantFixture, narrowed)).toBe( + multiVariantFixture.variants[1], + ) + }) + + test('variants that differ only in a hidden param are never broken by a guess', () => { + const filter = withCursorFilter(tiedFixture, [], 'fast', 'on') + expect(cursorFilterMatches(tiedFixture, filter)).toHaveLength(2) + expect(cursorFilterCommit(tiedFixture, filter)).toBeNull() + }) + + test('a filter no variant satisfies matches nothing and commits nothing', () => { + const impossible = [ + { id: 'context', value: '1m' }, + { id: 'reasoning', value: 'low' }, + ] + expect(cursorFilterMatches(multiVariantFixture, impossible)).toEqual([]) + expect(cursorFilterCommit(multiVariantFixture, impossible)).toBeNull() + }) + + test('staging never produces a filter that matches nothing', () => { + const from = cursorFilterFromVariant(multiVariantFixture, multiVariantFixture.variants[0]) + for (const dimension of ['context', 'reasoning']) { + for (const value of ['272k', '1m', 'low', 'max']) { + const next = withCursorFilter(multiVariantFixture, from, dimension, value) + if (next.some((entry) => entry.id === dimension && entry.value === value)) { + expect(cursorFilterMatches(multiVariantFixture, next).length).toBeGreaterThan(0) + } + } + } + }) + + test('choosing the same dimension twice replaces rather than repeats it', () => { + const first = withCursorFilter(multiVariantFixture, [], 'reasoning', 'low') + const second = withCursorFilter(multiVariantFixture, first, 'reasoning', 'max') + expect(second).toEqual([{ id: 'reasoning', value: 'max' }]) + }) + + test('a value no variant offers is refused instead of emptying the filter', () => { + const from = cursorFilterFromVariant(modelFixture, modelFixture.variants[0]) + expect(withCursorFilter(modelFixture, from, 'context', '1m')).toEqual(from) + }) +}) + +describe('Cursor variant dimensions', () => { + test('exposes only declared parameters that real variants use', () => { + expect(cursorVariantDimensions(multiVariantFixture).map((d) => d.id)).toEqual([ + 'context', + 'reasoning', + ]) + }) + + test('drops declared values no variant offers', () => { + const dimensions = cursorVariantDimensions(modelFixture) + expect(dimensions.map((d) => d.id)).toEqual(['context', 'reasoning', 'fast']) + expect(dimensions[0].values).toEqual([{ value: '272k', label: '272k' }]) + }) + + test('uses catalogue display names for dimensions and values', () => { + const [context] = cursorVariantDimensions(multiVariantFixture) + expect(context.label).toBe('Context') + expect(context.values).toEqual([ + { value: '272k', label: '272K' }, + { value: '1m', label: '1M' }, + ]) + }) + + test('optimize_for appears only when the connected catalogue returns it', () => { + expect(cursorVariantDimensions(autoSmartFixture).map((d) => d.id)).toEqual(['optimize_for']) + expect(cursorVariantDimensions(multiVariantFixture).map((d) => d.id)).not.toContain( + 'optimize_for', + ) + }) + + test('finds the reasoning-like axis and leaves the rest as plain dimensions', () => { + expect(cursorReasoningDimension(multiVariantFixture)?.id).toBe('reasoning') + expect(cursorReasoningDimension(autoSmartFixture)).toBeNull() + expect( + cursorReasoningDimension({ + ...autoSmartFixture, + parameters: [{ id: 'effort', values: [{ value: 'max' }] }], + variants: [{ params: [{ id: 'effort', value: 'max' }], displayName: 'e' }], + })?.id, + ).toBe('effort') + }) + + test('summarizes a variant from its visible params', () => { + expect(cursorVariantSummary(multiVariantFixture, multiVariantFixture.variants[2])).toBe( + 'Context 1M · Reasoning max', + ) + }) +}) + +describe('restoring a stored selection', () => { + test('matches the one variant carrying exactly those params', () => { + const variant = resolveCursorVariant(multiVariantFixture, [ + { id: 'context', value: '1m' }, + { id: 'reasoning', value: 'max' }, + { id: 'internal', value: 'on' }, + ]) + expect(variant).toBe(multiVariantFixture.variants[2]) + }) + + test('ignores the order the params were stored in', () => { + const variant = resolveCursorVariant(multiVariantFixture, [ + { id: 'internal', value: 'off' }, + { id: 'reasoning', value: 'low' }, + { id: 'context', value: '272k' }, + ]) + expect(variant).toBe(multiVariantFixture.variants[0]) + }) + + test('a partial or unknown selection never falls back to the default variant', () => { + expect( + resolveCursorVariant(multiVariantFixture, [{ id: 'context', value: '272k' }]), + ).toBeNull() + expect(resolveCursorVariant(multiVariantFixture, [])).toBeNull() + expect( + resolveCursorVariant(multiVariantFixture, [ + { id: 'context', value: '1m' }, + { id: 'reasoning', value: 'low' }, + { id: 'internal', value: 'on' }, + ]), + ).toBeNull() + expect( + resolveCursorVariant(multiVariantFixture, [ + { id: 'context', value: '1m' }, + { id: 'reasoning', value: 'max' }, + { id: 'internal', value: 'on' }, + { id: 'extra', value: 'yes' }, + ]), + ).toBeNull() + }) + + test('a duplicated parameter id is not a resolvable selection', () => { + expect( + resolveCursorVariant(multiVariantFixture, [ + { id: 'context', value: '272k' }, + { id: 'context', value: '1m' }, + { id: 'reasoning', value: 'low' }, + { id: 'internal', value: 'off' }, + ]), + ).toBeNull() + }) + + test('a variant-less model resolves nothing at all', () => { + const bare = { id: 'bare', name: 'Bare', aliases: [], parameters: [], variants: [] } + expect(resolveCursorVariant(bare, [])).toBeNull() + expect(resolveCursorVariant(bare, [{ id: 'reasoning', value: 'max' }])).toBeNull() + }) + + test('stored values are matched case-sensitively', () => { + expect( + resolveCursorVariant(multiVariantFixture, [ + { id: 'context', value: '272K' }, + { id: 'reasoning', value: 'low' }, + { id: 'internal', value: 'off' }, + ]), + ).toBeNull() + }) +}) + +describe('Cursor model search', () => { + test('matches id, display name, alias, and the Cursor provider label', () => { + expect(cursorModelMatches(multiVariantFixture, 'sol')).toBe(true) + expect(cursorModelMatches(multiVariantFixture, 'GPT 5.6')).toBe(true) + expect(cursorModelMatches(multiVariantFixture, 'gpt-5.6-SOL')).toBe(true) + expect(cursorModelMatches(multiVariantFixture, 'cursor')).toBe(true) + expect(cursorModelMatches(multiVariantFixture, 'claude')).toBe(false) + }) + + test('an empty query matches everything', () => { + expect(cursorModelMatches(autoSmartFixture, ' ')).toBe(true) + }) +}) diff --git a/web/src/lib/cursorModels.ts b/web/src/lib/cursorModels.ts new file mode 100644 index 0000000..3c1a30d --- /dev/null +++ b/web/src/lib/cursorModels.ts @@ -0,0 +1,309 @@ +/** + * Pure helpers over the Cursor model catalogue. + * + * Cursor returns whole variants, and a variant's `params` array is the only + * shape the API accepts. Every helper here therefore hands back a concrete + * upstream variant (including params Cursor never lists in `parameters`) or + * nothing at all — a combination Cursor did not return is never assembled from + * the individual parameter values. + */ + +export interface CursorParameterValue { + value: string + displayName?: string +} + +export interface CursorParameter { + id: string + displayName?: string + values: CursorParameterValue[] +} + +export interface CursorVariantParam { + id: string + value: string +} + +export interface CursorVariant { + params: CursorVariantParam[] + displayName: string + description?: string + isDefault?: boolean +} + +export interface CursorModel { + id: string + name: string + description?: string + aliases: string[] + parameters: CursorParameter[] + variants: CursorVariant[] +} + +export interface CursorDimensionValue { + value: string + label: string +} + +export interface CursorDimension { + id: string + label: string + values: CursorDimensionValue[] +} + +/** Parameter ids Cursor uses for the reasoning-like axis, in priority order. */ +export const REASONING_DIMENSION_IDS = ['reasoning', 'effort', 'thinking'] as const + +/** + * The upstream default variant, or null when the catalogue returned none. An + * empty parameter list is not a substitute: it would be a selection Cursor + * never offered. + */ +export function defaultCursorVariant(model: CursorModel): CursorVariant | null { + const variants = model.variants ?? [] + return variants.find((variant) => variant.isDefault) ?? variants[0] ?? null +} + +/** Whether the catalogue gives this model anything that can actually be run. */ +export function cursorModelSelectable(model: CursorModel): boolean { + return defaultCursorVariant(model) !== null +} + +/** A variant's params as an id → value map, hidden params included. */ +export function variantSelection(variant: CursorVariant): Record { + const selection: Record = {} + for (const param of variant.params ?? []) selection[param.id] = param.value + return selection +} + +export function variantParamValue( + variant: CursorVariant, + id: string, +): string | undefined { + return (variant.params ?? []).find((param) => param.id === id)?.value +} + +/** Every upstream variant whose params satisfy the given filter. */ +export function matchingCursorVariants( + model: CursorModel, + selection: Record, +): CursorVariant[] { + const entries = Object.entries(selection) + return (model.variants ?? []).filter((variant) => { + const params = variantSelection(variant) + return entries.every(([id, value]) => params[id] === value) + }) +} + +/** + * The single upstream variant a filter resolves to. Zero matches and ambiguous + * matches both commit nothing, so a control can only ever apply a real variant. + */ +export function selectExactVariant( + model: CursorModel, + selection: Record, +): CursorVariant | null { + const matches = matchingCursorVariants(model, selection) + return matches.length === 1 ? matches[0] : null +} + +/** A selection as an id → value map, or null when an id repeats. */ +function canonicalParamMap( + params: CursorVariantParam[], +): Map | null { + const canonical = new Map() + for (const param of params ?? []) { + if (!param.id || canonical.has(param.id)) return null + canonical.set(param.id, param.value) + } + return canonical +} + +/** + * The upstream variant that carries exactly this stored selection, whatever + * order it was stored in. A selection that no longer matches any variant + * resolves to nothing: falling back to the default would silently run a + * different model configuration than the conversation used. + */ +export function resolveCursorVariant( + model: CursorModel, + params: CursorVariantParam[], +): CursorVariant | null { + const wanted = canonicalParamMap(params) + if (!wanted) return null + for (const variant of model.variants ?? []) { + const candidate = canonicalParamMap(variant.params ?? []) + if (!candidate || candidate.size !== wanted.size) continue + let equal = true + for (const [id, value] of wanted) { + if (candidate.get(id) !== value) { + equal = false + break + } + } + if (equal) return variant + } + return null +} + +/** + * One dimension the controls have narrowed, in the order it was chosen. The + * filter is a view over the catalogue, never a selection in its own right: only + * a filter that leaves exactly one upstream variant changes what will run. + */ +export interface CursorFilterEntry { + id: string + value: string +} + +function filterSelection(filter: CursorFilterEntry[]): Record { + const selection: Record = {} + for (const entry of filter ?? []) selection[entry.id] = entry.value + return selection +} + +/** The upstream variants a filter still allows. */ +export function cursorFilterMatches( + model: CursorModel, + filter: CursorFilterEntry[], +): CursorVariant[] { + return matchingCursorVariants(model, filterSelection(filter)) +} + +/** + * The one variant a filter identifies, or null while it still allows several + * (or none). Variants that differ only in params the catalogue does not show + * therefore never get chosen for the user. + */ +export function cursorFilterCommit( + model: CursorModel, + filter: CursorFilterEntry[], +): CursorVariant | null { + const matches = cursorFilterMatches(model, filter) + return matches.length === 1 ? matches[0] : null +} + +/** The filter a committed variant corresponds to: its visible dimensions. */ +export function cursorFilterFromVariant( + model: CursorModel, + variant: CursorVariant, +): CursorFilterEntry[] { + const params = variantSelection(variant) + const filter: CursorFilterEntry[] = [] + for (const dimension of cursorVariantDimensions(model)) { + const value = params[dimension.id] + if (value !== undefined) filter.push({ id: dimension.id, value }) + } + return filter +} + +/** + * Narrow a filter with one more choice. The newest choice always survives; + * older ones give way to it when they cannot hold together, which is what makes + * a variant that shares no value with the current one reachable without ever + * inventing a combination the catalogue does not offer. A value no variant + * carries at all changes nothing. + */ +export function withCursorFilter( + model: CursorModel, + filter: CursorFilterEntry[], + dimensionId: string, + value: string, +): CursorFilterEntry[] { + if (cursorFilterMatches(model, [{ id: dimensionId, value }]).length === 0) { + return filter + } + // Oldest first, with the new choice last and any older take on the same + // dimension removed. + let staged = [ + ...(filter ?? []).filter((entry) => entry.id !== dimensionId), + { id: dimensionId, value }, + ] + // Drop the least recent choices until the catalogue can satisfy the rest. + while (staged.length > 1 && cursorFilterMatches(model, staged).length === 0) { + staged = staged.slice(1) + } + return staged +} + +/** + * The controls a model can offer: catalogue-declared parameters, narrowed to + * the values real variants use. Params a variant carries but the catalogue does + * not declare stay hidden and travel with the variant. + */ +export function cursorVariantDimensions(model: CursorModel): CursorDimension[] { + const used = new Map>() + for (const variant of model.variants ?? []) { + for (const param of variant.params ?? []) { + const values = used.get(param.id) ?? new Set() + values.add(param.value) + used.set(param.id, values) + } + } + + const dimensions: CursorDimension[] = [] + for (const parameter of model.parameters ?? []) { + const available = used.get(parameter.id) + if (!available) continue + const values = (parameter.values ?? []) + .filter((value) => available.has(value.value)) + .map((value) => ({ value: value.value, label: value.displayName || value.value })) + if (values.length === 0) continue + dimensions.push({ + id: parameter.id, + label: parameter.displayName || parameter.id, + values, + }) + } + return dimensions +} + +/** The reasoning-like axis, when the connected catalogue exposes one. */ +export function cursorReasoningDimension(model: CursorModel): CursorDimension | null { + const dimensions = cursorVariantDimensions(model) + for (const id of REASONING_DIMENSION_IDS) { + const found = dimensions.find((dimension) => dimension.id === id) + if (found) return found + } + return null +} + +/** Every dimension except the reasoning-like one (Context, Fast, …). */ +export function cursorOtherDimensions(model: CursorModel): CursorDimension[] { + const reasoning = cursorReasoningDimension(model) + return cursorVariantDimensions(model).filter( + (dimension) => dimension.id !== reasoning?.id, + ) +} + +/** A compact "Context 1M · Reasoning max" line for the visible dimensions. */ +export function cursorVariantSummary( + model: CursorModel, + variant: CursorVariant, +): string { + const selection = variantSelection(variant) + return cursorVariantDimensions(model) + .map((dimension) => { + const value = selection[dimension.id] + if (value === undefined) return '' + const label = + dimension.values.find((option) => option.value === value)?.label ?? value + return `${dimension.label} ${label}` + }) + .filter(Boolean) + .join(' · ') +} + +/** Search a Cursor model by id, display name, alias, or its provider label. */ +export function cursorModelMatches(model: CursorModel, query: string): boolean { + const q = query.trim().toLowerCase() + if (!q) return true + const haystack = [ + model.id, + model.name, + ...(model.aliases ?? []), + 'cursor', + 'cursor cloud agent', + ] + return haystack.some((entry) => entry.toLowerCase().includes(q)) +} diff --git a/web/src/lib/i18n.tsx b/web/src/lib/i18n.tsx index 6acaaa8..0d5f42a 100644 --- a/web/src/lib/i18n.tsx +++ b/web/src/lib/i18n.tsx @@ -451,6 +451,72 @@ const en = { 'ask.add': 'Add answer', 'chat.nothingToCopy': 'There is no reply to copy yet.', 'chat.reasoning': 'Reasoning', + 'reasoning.auto': 'Auto', + 'reasoning.unsupported': 'Unsupported legacy value: {value}. Choose Auto to replace it.', + 'reasoning.autoHint': 'Auto keeps reasoning adaptive or uses the model/provider default.', + 'reasoning.loading': 'Loading reasoning options…', + 'reasoning.unavailable': 'Reasoning options are unavailable; the current value is preserved.', + 'reasoning.mandatory': 'This model always reasons; Auto keeps its required behavior.', + 'reasoning.providerControlled': 'This model exposes no reasoning overrides; the provider controls it.', + 'common.yes': 'Yes', + 'common.no': 'No', + 'target.chatGroup': 'Chat models', + 'target.cursorGroup': 'Cursor Cloud Agents', + 'target.cursorRow': 'Cursor Cloud Agent', + 'target.cursorNeedsKey': 'Connect a Cursor API key to run Cloud Agents from the composer.', + 'target.cursorConnect': 'Connect Cursor', + 'target.cursorNoVariant': 'Cursor returned no runnable configuration for this model, so it cannot be selected.', + 'target.lockedWhileStreaming': 'The target cannot change while a turn is running.', + 'cursor.variantUnavailable': 'Cursor has no configuration matching those options.', + 'cursor.variantPending': '{n} Cursor configurations still match. Choose another option to settle on one.', + 'target.resolving': 'Restoring where this conversation runs…', + 'cursor.options': 'Cursor options', + 'cursor.mode': 'Conversation mode', + 'cursor.modeAgent': 'Agent', + 'cursor.modePlan': 'Plan', + 'cursor.modeHint': 'Changing only the mode keeps following up on the same Cursor agent.', + 'cursor.repository': 'Repository', + 'cursor.repositoryNone': 'No repository', + 'cursor.repositoryAuto': 'Discovered from the project folder.', + 'cursor.repositoryNoProject': 'No project folder is bound, so the run starts without a repository.', + 'cursor.repositoryReset': 'Use the discovered repository again', + 'cursor.startingRef': 'Starting ref', + 'cursor.startingRefAuto': 'Default branch', + 'cursor.autoPR': 'Open a pull request', + 'cursor.autoPRHint': 'Cursor opens a pull request when the run finishes.', + 'cursor.newAgentNotice': 'Sending now starts a new Cursor agent; the current one keeps its own history.', + 'cursor.staleSelection': 'The Cursor model and variant this conversation used are no longer in the catalogue. Choose a model again before sending.', + 'cursor.warnDirty': 'Uncommitted local changes are not present in the Cursor cloud VM.', + 'cursor.warnLocalOnly': '{n} local commit(s) are missing from the remote ref, so the cloud VM will not have them.', + 'cursor.warnRemoteUnknown': 'The remote-tracking ref is unavailable, so Antares cannot tell which local commits the cloud VM has.', + 'cursor.warnUnsupportedOrigin': 'This project’s origin is not a credential-free GitHub repository. Enter one, or run without a repository.', + 'cursor.runLabel': 'Cursor Cloud Agent', + 'cursor.detachedNotice': 'Stopped following this run. It may still be running in Cursor.', + 'cursor.reattach': 'Follow again', + 'cursor.cancel': 'Cancel run', + 'cursor.cancelHint': 'Asks Cursor to cancel the remote run, once you approve it.', + 'cursorAttach.documents': 'Cursor runs in a cloud VM and cannot read local files: {names}. Remove them, or paste their content into the message.', + 'cursorAttach.imageCount': 'Cursor accepts at most {max} images, but {n} are attached.', + 'cursorAttach.imageType': 'Image {n} is not one of the PNG, JPEG, GIF, or WebP images Cursor accepts.', + 'cursorAttach.imageSize': 'Image {n} is larger than {max} MiB once decoded.', + 'cursorApproval.operation': 'Operation', + 'cursorApproval.start': 'Start a new Cursor agent', + 'cursorApproval.followUp': 'Follow up on the current Cursor agent', + 'cursorApproval.cancel': 'Cancel the Cursor run', + 'cursorApproval.model': 'Model', + 'cursorApproval.params': 'Variant', + 'cursorApproval.repository': 'Repository', + 'cursorApproval.noRepository': 'No repository', + 'cursorApproval.startingRef': 'Starting ref', + 'cursorApproval.mode': 'Mode', + 'cursorApproval.autoPR': 'Pull request', + 'cursorApproval.images': 'Images', + 'cursorApproval.agent': 'Agent', + 'cursorApproval.run': 'Run', + 'providers.searchModels': 'Search models…', + 'providers.aliases': 'Aliases: {list}', + 'providers.variantCount': '{n} variant(s)', + 'providers.defaultVariant': 'default: {summary}', 'chat.working': 'Working…', 'chat.attachAuthFailed': 'Dashboard login expired — refresh and sign in again.', 'chat.waitingAnswer': 'Paused — waiting for your answer', @@ -1469,6 +1535,72 @@ const id: Dict = { 'ask.add': 'Tambah jawaban', 'chat.nothingToCopy': 'Belum ada balasan untuk disalin.', 'chat.reasoning': 'Penalaran', + 'reasoning.auto': 'Otomatis', + 'reasoning.unsupported': 'Nilai lama tidak didukung: {value}. Pilih Otomatis untuk menggantinya.', + 'reasoning.autoHint': 'Otomatis mempertahankan penalaran adaptif atau memakai default model/provider.', + 'reasoning.loading': 'Memuat opsi penalaran…', + 'reasoning.unavailable': 'Opsi penalaran tidak tersedia; nilai saat ini tetap dipertahankan.', + 'reasoning.mandatory': 'Model ini selalu memakai penalaran; Otomatis mempertahankan perilaku wajibnya.', + 'reasoning.providerControlled': 'Model ini tidak menyediakan override penalaran; provider yang mengaturnya.', + 'common.yes': 'Ya', + 'common.no': 'Tidak', + 'target.chatGroup': 'Model obrolan', + 'target.cursorGroup': 'Cursor Cloud Agent', + 'target.cursorRow': 'Cursor Cloud Agent', + 'target.cursorNeedsKey': 'Hubungkan API key Cursor untuk menjalankan Cloud Agent dari kolom pesan.', + 'target.cursorConnect': 'Hubungkan Cursor', + 'target.cursorNoVariant': 'Cursor tidak mengembalikan konfigurasi yang bisa dijalankan untuk model ini, jadi model ini tidak bisa dipilih.', + 'target.lockedWhileStreaming': 'Target tidak bisa diubah selama satu giliran masih berjalan.', + 'cursor.variantUnavailable': 'Cursor tidak punya konfigurasi yang cocok dengan opsi itu.', + 'cursor.variantPending': 'Masih ada {n} konfigurasi Cursor yang cocok. Pilih opsi lain untuk menentukan satu.', + 'target.resolving': 'Memulihkan tempat percakapan ini berjalan…', + 'cursor.options': 'Opsi Cursor', + 'cursor.mode': 'Mode percakapan', + 'cursor.modeAgent': 'Agent', + 'cursor.modePlan': 'Plan', + 'cursor.modeHint': 'Mengubah mode saja tetap melanjutkan agent Cursor yang sama.', + 'cursor.repository': 'Repositori', + 'cursor.repositoryNone': 'Tanpa repositori', + 'cursor.repositoryAuto': 'Ditemukan dari folder proyek.', + 'cursor.repositoryNoProject': 'Tidak ada folder proyek yang terikat, jadi run dimulai tanpa repositori.', + 'cursor.repositoryReset': 'Pakai lagi repositori hasil deteksi', + 'cursor.startingRef': 'Ref awal', + 'cursor.startingRefAuto': 'Branch default', + 'cursor.autoPR': 'Buka pull request', + 'cursor.autoPRHint': 'Cursor membuka pull request setelah run selesai.', + 'cursor.newAgentNotice': 'Mengirim sekarang memulai agent Cursor baru; agent saat ini menyimpan riwayatnya sendiri.', + 'cursor.staleSelection': 'Model dan varian Cursor yang dipakai percakapan ini sudah tidak ada di katalog. Pilih model lagi sebelum mengirim.', + 'cursor.warnDirty': 'Perubahan lokal yang belum di-commit tidak ada di VM cloud Cursor.', + 'cursor.warnLocalOnly': '{n} commit lokal belum ada di ref remote, jadi VM cloud tidak memilikinya.', + 'cursor.warnRemoteUnknown': 'Ref remote-tracking tidak tersedia, jadi Antares tidak bisa memastikan commit lokal mana yang ada di VM cloud.', + 'cursor.warnUnsupportedOrigin': 'Origin proyek ini bukan repositori GitHub tanpa kredensial. Isi satu repositori, atau jalankan tanpa repositori.', + 'cursor.runLabel': 'Cursor Cloud Agent', + 'cursor.detachedNotice': 'Berhenti mengikuti run ini. Run mungkin masih berjalan di Cursor.', + 'cursor.reattach': 'Ikuti lagi', + 'cursor.cancel': 'Batalkan run', + 'cursor.cancelHint': 'Meminta Cursor membatalkan run jarak jauh setelah kamu menyetujuinya.', + 'cursorAttach.documents': 'Cursor berjalan di VM cloud dan tidak bisa membaca berkas lokal: {names}. Hapus berkasnya, atau tempel isinya ke pesan.', + 'cursorAttach.imageCount': 'Cursor menerima maksimal {max} gambar, tetapi ada {n} terlampir.', + 'cursorAttach.imageType': 'Gambar {n} bukan PNG, JPEG, GIF, atau WebP yang diterima Cursor.', + 'cursorAttach.imageSize': 'Gambar {n} lebih besar dari {max} MiB setelah didekode.', + 'cursorApproval.operation': 'Operasi', + 'cursorApproval.start': 'Mulai agent Cursor baru', + 'cursorApproval.followUp': 'Lanjutkan agent Cursor saat ini', + 'cursorApproval.cancel': 'Batalkan run Cursor', + 'cursorApproval.model': 'Model', + 'cursorApproval.params': 'Varian', + 'cursorApproval.repository': 'Repositori', + 'cursorApproval.noRepository': 'Tanpa repositori', + 'cursorApproval.startingRef': 'Ref awal', + 'cursorApproval.mode': 'Mode', + 'cursorApproval.autoPR': 'Pull request', + 'cursorApproval.images': 'Gambar', + 'cursorApproval.agent': 'Agent', + 'cursorApproval.run': 'Run', + 'providers.searchModels': 'Cari model…', + 'providers.aliases': 'Alias: {list}', + 'providers.variantCount': '{n} varian', + 'providers.defaultVariant': 'default: {summary}', 'chat.working': 'Sedang bekerja…', 'chat.attachAuthFailed': 'Login dashboard kedaluwarsa — muat ulang dan masuk lagi.', 'chat.waitingAnswer': 'Dijeda — menunggu jawabanmu', @@ -2254,6 +2386,72 @@ const ja: Dict = { 'approval.expired': 'その要求はすでに期限切れです。', 'chat.nothingToCopy': 'コピーできる返信がまだありません。', 'chat.reasoning': '推論', + 'reasoning.auto': '自動', + 'reasoning.unsupported': '未対応の従来値: {value}。置き換えるには「自動」を選んでください。', + 'reasoning.autoHint': '自動では、適応型推論またはモデル/プロバイダーの既定値を使用します。', + 'reasoning.loading': '推論オプションを読み込み中…', + 'reasoning.unavailable': '推論オプションを取得できません。現在の値は保持されます。', + 'reasoning.mandatory': 'このモデルでは推論が必須です。「自動」は必須の動作を維持します。', + 'reasoning.providerControlled': 'このモデルは推論の上書きを公開していません。プロバイダーが制御します。', + 'common.yes': 'はい', + 'common.no': 'いいえ', + 'target.chatGroup': 'チャットモデル', + 'target.cursorGroup': 'Cursor Cloud Agent', + 'target.cursorRow': 'Cursor Cloud Agent', + 'target.cursorNeedsKey': 'Cursor の API キーを接続すると、入力欄から Cloud Agent を実行できます。', + 'target.cursorConnect': 'Cursor を接続', + 'target.cursorNoVariant': 'Cursor はこのモデルの実行可能な構成を返していないため、選択できません。', + 'target.lockedWhileStreaming': 'ターンの実行中は実行先を変更できません。', + 'cursor.variantUnavailable': 'その組み合わせに一致する構成は Cursor にありません。', + 'cursor.variantPending': '一致する Cursor の構成がまだ {n} 件あります。別のオプションを選んで 1 つに絞ってください。', + 'target.resolving': 'この会話の実行先を復元しています…', + 'cursor.options': 'Cursor のオプション', + 'cursor.mode': '会話モード', + 'cursor.modeAgent': 'Agent', + 'cursor.modePlan': 'Plan', + 'cursor.modeHint': 'モードだけを変えた場合は、同じ Cursor エージェントを継続します。', + 'cursor.repository': 'リポジトリ', + 'cursor.repositoryNone': 'リポジトリなし', + 'cursor.repositoryAuto': 'プロジェクトフォルダーから検出しました。', + 'cursor.repositoryNoProject': 'プロジェクトフォルダーが紐づいていないため、リポジトリなしで実行します。', + 'cursor.repositoryReset': '検出したリポジトリに戻す', + 'cursor.startingRef': '開始 ref', + 'cursor.startingRefAuto': '既定のブランチ', + 'cursor.autoPR': 'プルリクエストを作成', + 'cursor.autoPRHint': '実行が完了すると Cursor がプルリクエストを作成します。', + 'cursor.newAgentNotice': 'このまま送信すると新しい Cursor エージェントを開始します。現在のエージェントの履歴はそのまま残ります。', + 'cursor.staleSelection': 'この会話が使っていた Cursor のモデルとバリアントはカタログにありません。送信する前にモデルを選び直してください。', + 'cursor.warnDirty': '未コミットのローカル変更は Cursor のクラウド VM にはありません。', + 'cursor.warnLocalOnly': 'ローカルの {n} 件のコミットがリモート ref に無いため、クラウド VM にも存在しません。', + 'cursor.warnRemoteUnknown': 'リモート追跡 ref を取得できないため、どのローカルコミットがクラウド VM にあるか確認できません。', + 'cursor.warnUnsupportedOrigin': 'このプロジェクトの origin は認証情報を含まない GitHub リポジトリではありません。リポジトリを指定するか、リポジトリなしで実行してください。', + 'cursor.runLabel': 'Cursor Cloud Agent', + 'cursor.detachedNotice': 'この実行の追従を停止しました。Cursor 側ではまだ実行中の可能性があります。', + 'cursor.reattach': '再び追従する', + 'cursor.cancel': '実行をキャンセル', + 'cursor.cancelHint': '承認後に、リモート実行のキャンセルを Cursor に要求します。', + 'cursorAttach.documents': 'Cursor はクラウド VM で動作するため、ローカルファイルを読めません: {names}。取り外すか、内容をメッセージに貼り付けてください。', + 'cursorAttach.imageCount': 'Cursor が受け付ける画像は最大 {max} 枚ですが、{n} 枚添付されています。', + 'cursorAttach.imageType': '画像 {n} は Cursor が受け付ける PNG・JPEG・GIF・WebP ではありません。', + 'cursorAttach.imageSize': '画像 {n} はデコード後に {max} MiB を超えています。', + 'cursorApproval.operation': '操作', + 'cursorApproval.start': '新しい Cursor エージェントを開始', + 'cursorApproval.followUp': '現在の Cursor エージェントを継続', + 'cursorApproval.cancel': 'Cursor の実行をキャンセル', + 'cursorApproval.model': 'モデル', + 'cursorApproval.params': 'バリアント', + 'cursorApproval.repository': 'リポジトリ', + 'cursorApproval.noRepository': 'リポジトリなし', + 'cursorApproval.startingRef': '開始 ref', + 'cursorApproval.mode': 'モード', + 'cursorApproval.autoPR': 'プルリクエスト', + 'cursorApproval.images': '画像', + 'cursorApproval.agent': 'エージェント', + 'cursorApproval.run': '実行', + 'providers.searchModels': 'モデルを検索…', + 'providers.aliases': 'エイリアス: {list}', + 'providers.variantCount': 'バリアント {n} 件', + 'providers.defaultVariant': '既定: {summary}', 'chat.tokensOut': '出力 {n} トークン', 'chat.welcomeTitle': '会話を始める', 'chat.welcomeDesc': @@ -2956,6 +3154,72 @@ const zh: Dict = { 'approval.expired': '该请求已经超时,不再等待。', 'chat.nothingToCopy': '还没有可复制的回复。', 'chat.reasoning': '推理过程', + 'reasoning.auto': '自动', + 'reasoning.unsupported': '不支持的旧值:{value}。请选择“自动”进行替换。', + 'reasoning.autoHint': '自动会保留自适应推理,或使用模型/提供商的默认值。', + 'reasoning.loading': '正在加载推理选项…', + 'reasoning.unavailable': '推理选项暂不可用;当前值将被保留。', + 'reasoning.mandatory': '此模型必须进行推理;自动会保留其必需行为。', + 'reasoning.providerControlled': '此模型未提供推理覆盖项;由提供商控制。', + 'common.yes': '是', + 'common.no': '否', + 'target.chatGroup': '聊天模型', + 'target.cursorGroup': 'Cursor 云端 Agent', + 'target.cursorRow': 'Cursor 云端 Agent', + 'target.cursorNeedsKey': '连接 Cursor API key 后即可在输入框中运行云端 Agent。', + 'target.cursorConnect': '连接 Cursor', + 'target.cursorNoVariant': 'Cursor 没有为该模型返回可运行的配置,因此无法选择。', + 'target.lockedWhileStreaming': '回合运行期间无法更改执行目标。', + 'cursor.variantUnavailable': 'Cursor 没有与这些选项匹配的配置。', + 'cursor.variantPending': '仍有 {n} 个 Cursor 配置符合条件,请再选择一个选项以确定唯一配置。', + 'target.resolving': '正在恢复该对话的执行位置…', + 'cursor.options': 'Cursor 选项', + 'cursor.mode': '对话模式', + 'cursor.modeAgent': 'Agent', + 'cursor.modePlan': 'Plan', + 'cursor.modeHint': '只更改模式会继续沿用同一个 Cursor agent。', + 'cursor.repository': '仓库', + 'cursor.repositoryNone': '不使用仓库', + 'cursor.repositoryAuto': '来自项目文件夹的自动识别结果。', + 'cursor.repositoryNoProject': '没有绑定项目文件夹,因此本次运行不带仓库。', + 'cursor.repositoryReset': '恢复使用识别到的仓库', + 'cursor.startingRef': '起始 ref', + 'cursor.startingRefAuto': '默认分支', + 'cursor.autoPR': '创建 Pull Request', + 'cursor.autoPRHint': '运行结束后由 Cursor 创建 Pull Request。', + 'cursor.newAgentNotice': '现在发送会启动一个新的 Cursor agent;当前 agent 的历史会保留。', + 'cursor.staleSelection': '该对话使用的 Cursor 模型和变体已不在目录中。发送前请重新选择模型。', + 'cursor.warnDirty': '未提交的本地改动不会出现在 Cursor 云端虚拟机中。', + 'cursor.warnLocalOnly': '有 {n} 个本地提交不在远程 ref 上,云端虚拟机也不会有它们。', + 'cursor.warnRemoteUnknown': '无法获取远程跟踪 ref,因此 Antares 无法确认云端虚拟机拥有哪些本地提交。', + 'cursor.warnUnsupportedOrigin': '该项目的 origin 不是免凭据的 GitHub 仓库。请填写一个仓库,或不带仓库运行。', + 'cursor.runLabel': 'Cursor 云端 Agent', + 'cursor.detachedNotice': '已停止跟随这次运行,它可能仍在 Cursor 中执行。', + 'cursor.reattach': '重新跟随', + 'cursor.cancel': '取消运行', + 'cursor.cancelHint': '在你批准后,请求 Cursor 取消远端运行。', + 'cursorAttach.documents': 'Cursor 运行在云端虚拟机中,无法读取本地文件:{names}。请移除它们,或把内容粘贴到消息里。', + 'cursorAttach.imageCount': 'Cursor 最多接受 {max} 张图片,当前附加了 {n} 张。', + 'cursorAttach.imageType': '图片 {n} 不是 Cursor 接受的 PNG、JPEG、GIF 或 WebP。', + 'cursorAttach.imageSize': '图片 {n} 解码后超过 {max} MiB。', + 'cursorApproval.operation': '操作', + 'cursorApproval.start': '启动新的 Cursor agent', + 'cursorApproval.followUp': '继续当前的 Cursor agent', + 'cursorApproval.cancel': '取消该 Cursor 运行', + 'cursorApproval.model': '模型', + 'cursorApproval.params': '变体', + 'cursorApproval.repository': '仓库', + 'cursorApproval.noRepository': '不使用仓库', + 'cursorApproval.startingRef': '起始 ref', + 'cursorApproval.mode': '模式', + 'cursorApproval.autoPR': 'Pull Request', + 'cursorApproval.images': '图片', + 'cursorApproval.agent': 'Agent', + 'cursorApproval.run': '运行', + 'providers.searchModels': '搜索模型…', + 'providers.aliases': '别名:{list}', + 'providers.variantCount': '{n} 个变体', + 'providers.defaultVariant': '默认:{summary}', 'chat.tokensOut': '输出 {n} 个 token', 'chat.welcomeTitle': '开始对话', 'chat.welcomeDesc': 'Antares 可以访问文件、终端、网页搜索、长期记忆和 RAG 索引。', @@ -3656,6 +3920,72 @@ const ru: Dict = { 'approval.expired': 'Этот запрос больше не ждёт — истёк срок.', 'chat.nothingToCopy': 'Копировать пока нечего.', 'chat.reasoning': 'Рассуждение', + 'reasoning.auto': 'Авто', + 'reasoning.unsupported': 'Неподдерживаемое устаревшее значение: {value}. Выберите «Авто», чтобы заменить его.', + 'reasoning.autoHint': 'Авто оставляет адаптивное рассуждение или использует значение по умолчанию модели/провайдера.', + 'reasoning.loading': 'Загрузка вариантов рассуждения…', + 'reasoning.unavailable': 'Варианты рассуждения недоступны; текущее значение сохранено.', + 'reasoning.mandatory': 'Для этой модели рассуждение обязательно; «Авто» сохраняет это поведение.', + 'reasoning.providerControlled': 'Эта модель не предоставляет переопределения рассуждения; им управляет провайдер.', + 'common.yes': 'Да', + 'common.no': 'Нет', + 'target.chatGroup': 'Чат-модели', + 'target.cursorGroup': 'Облачные агенты Cursor', + 'target.cursorRow': 'Облачный агент Cursor', + 'target.cursorNeedsKey': 'Подключите API-ключ Cursor, чтобы запускать облачных агентов прямо из поля ввода.', + 'target.cursorConnect': 'Подключить Cursor', + 'target.cursorNoVariant': 'Cursor не вернул для этой модели работоспособной конфигурации, поэтому выбрать её нельзя.', + 'target.lockedWhileStreaming': 'Пока идёт ход, цель выполнения изменить нельзя.', + 'cursor.variantUnavailable': 'У Cursor нет конфигурации, подходящей под эти параметры.', + 'cursor.variantPending': 'Подходящих конфигураций Cursor всё ещё {n}. Выберите другой параметр, чтобы осталась одна.', + 'target.resolving': 'Восстанавливаем, где выполняется этот разговор…', + 'cursor.options': 'Параметры Cursor', + 'cursor.mode': 'Режим разговора', + 'cursor.modeAgent': 'Agent', + 'cursor.modePlan': 'Plan', + 'cursor.modeHint': 'Смена только режима продолжает работу того же агента Cursor.', + 'cursor.repository': 'Репозиторий', + 'cursor.repositoryNone': 'Без репозитория', + 'cursor.repositoryAuto': 'Определён по папке проекта.', + 'cursor.repositoryNoProject': 'Папка проекта не привязана, поэтому запуск идёт без репозитория.', + 'cursor.repositoryReset': 'Снова использовать найденный репозиторий', + 'cursor.startingRef': 'Начальный ref', + 'cursor.startingRefAuto': 'Ветка по умолчанию', + 'cursor.autoPR': 'Создать pull request', + 'cursor.autoPRHint': 'Cursor создаст pull request после завершения запуска.', + 'cursor.newAgentNotice': 'Отправка сейчас запустит нового агента Cursor; у текущего останется своя история.', + 'cursor.staleSelection': 'Модель и вариант Cursor, которые использовал этот разговор, больше не значатся в каталоге. Выберите модель заново перед отправкой.', + 'cursor.warnDirty': 'Незакоммиченные локальные изменения отсутствуют в облачной ВМ Cursor.', + 'cursor.warnLocalOnly': 'Локальных коммитов вне удалённого ref: {n}; в облачной ВМ их не будет.', + 'cursor.warnRemoteUnknown': 'Удалённый отслеживаемый ref недоступен, поэтому Antares не может определить, какие локальные коммиты есть в облачной ВМ.', + 'cursor.warnUnsupportedOrigin': 'Origin этого проекта не является GitHub-репозиторием без учётных данных. Укажите репозиторий или запустите без него.', + 'cursor.runLabel': 'Облачный агент Cursor', + 'cursor.detachedNotice': 'Слежение за этим запуском остановлено. В Cursor он может продолжаться.', + 'cursor.reattach': 'Следить снова', + 'cursor.cancel': 'Отменить запуск', + 'cursor.cancelHint': 'После вашего подтверждения попросит Cursor отменить удалённый запуск.', + 'cursorAttach.documents': 'Cursor работает в облачной ВМ и не может читать локальные файлы: {names}. Удалите их или вставьте содержимое в сообщение.', + 'cursorAttach.imageCount': 'Cursor принимает не более {max} изображений, а приложено {n}.', + 'cursorAttach.imageType': 'Изображение {n} не относится к принимаемым Cursor форматам PNG, JPEG, GIF или WebP.', + 'cursorAttach.imageSize': 'Изображение {n} после декодирования превышает {max} МиБ.', + 'cursorApproval.operation': 'Операция', + 'cursorApproval.start': 'Запустить нового агента Cursor', + 'cursorApproval.followUp': 'Продолжить работу текущего агента Cursor', + 'cursorApproval.cancel': 'Отменить запуск Cursor', + 'cursorApproval.model': 'Модель', + 'cursorApproval.params': 'Вариант', + 'cursorApproval.repository': 'Репозиторий', + 'cursorApproval.noRepository': 'Без репозитория', + 'cursorApproval.startingRef': 'Начальный ref', + 'cursorApproval.mode': 'Режим', + 'cursorApproval.autoPR': 'Pull request', + 'cursorApproval.images': 'Изображения', + 'cursorApproval.agent': 'Агент', + 'cursorApproval.run': 'Запуск', + 'providers.searchModels': 'Поиск моделей…', + 'providers.aliases': 'Псевдонимы: {list}', + 'providers.variantCount': 'вариантов: {n}', + 'providers.defaultVariant': 'по умолчанию: {summary}', 'chat.tokensOut': '{n} токенов на выходе', 'chat.welcomeTitle': 'Начните разговор', 'chat.welcomeDesc': diff --git a/web/src/lib/models.ts b/web/src/lib/models.ts new file mode 100644 index 0000000..394d01d --- /dev/null +++ b/web/src/lib/models.ts @@ -0,0 +1,21 @@ +export interface ReasoningValue { + value: string + label: string + kind?: 'disable' +} + +export interface ReasoningCapability { + values: ReasoningValue[] + default?: string + mandatory: boolean + can_disable: boolean + source: 'live' | 'static' +} + +export interface ChatModelSelection { + provider: string + model: string + name: string + providerLabel: string + reasoningCapability?: ReasoningCapability +} diff --git a/web/src/lib/reasoning.test.mjs b/web/src/lib/reasoning.test.mjs new file mode 100644 index 0000000..7418b05 --- /dev/null +++ b/web/src/lib/reasoning.test.mjs @@ -0,0 +1,349 @@ +import { describe, expect, test } from 'bun:test' +import { + loadReasoningPreference, + resolveReasoningControl, + resolveReasoningModelTarget, + reasoningOptions, + reasoningPreferenceKey, + saveReasoningPreference, +} from './reasoning.ts' +import { + createReasoningCapabilityLoader, + createReasoningCapabilityScheduler, +} from './reasoningCapability.ts' + +describe('reasoning options', () => { + test('preserve opaque values and mark only explicit disable', () => { + const cap = { + values: [ + { value: 'none', label: 'Off', kind: 'disable' }, + { value: 'extra-high', label: 'Extra High' }, + ], + default: 'extra-high', + mandatory: false, + can_disable: true, + source: 'live', + } + + expect(reasoningOptions(cap).map((x) => x.value)).toEqual(['', 'none', 'extra-high']) + }) + + test('offer only Auto when a model has no capability metadata', () => { + expect(reasoningOptions(undefined).map((x) => x.value)).toEqual(['']) + }) + + test('omit explicit disable values for mandatory models', () => { + const cap = { + values: [ + { value: 'none', label: 'Off', kind: 'disable' }, + { value: 'MiXeD', label: 'Mixed' }, + ], + mandatory: true, + can_disable: false, + source: 'static', + } + + expect(reasoningOptions(cap).map((x) => x.value)).toEqual(['', 'MiXeD']) + }) +}) + +describe('reasoning preferences', () => { + test('migrate a legacy preference once only when valid', () => { + const storage = memoryStorage({ 'antares:reasoning': 'high' }) + const cap = capability(['low', 'high']) + + expect(loadReasoningPreference(storage, 'openai', 'gpt-5', cap)).toEqual({ + value: 'high', + migrated: true, + }) + expect(storage.getItem('antares:reasoning')).toBeNull() + expect(storage.getItem(reasoningPreferenceKey('openai', 'gpt-5'))).toBe('high') + }) + + test('remove an invalid legacy preference after its single migration attempt', () => { + const storage = memoryStorage({ 'antares:reasoning': 'HIGH' }) + + expect(loadReasoningPreference(storage, 'openai', 'gpt-5', capability(['high']))).toEqual({ + value: '', + migrated: false, + }) + expect(storage.getItem('antares:reasoning')).toBeNull() + expect(storage.getItem(reasoningPreferenceKey('openai', 'gpt-5'))).toBeNull() + }) + + test('sanitize an invalid scoped value without changing case', () => { + const key = reasoningPreferenceKey('openai', 'gpt-5') + const storage = memoryStorage({ [key]: 'HIGH' }) + + expect(loadReasoningPreference(storage, 'openai', 'gpt-5', capability(['high']))).toEqual({ + value: '', + migrated: false, + }) + expect(storage.getItem(key)).toBeNull() + }) + + test('preserve a scoped opaque value while capability metadata is unknown', () => { + const key = reasoningPreferenceKey('openai', 'gpt-5') + const storage = memoryStorage({ + [key]: 'MiXeD', + 'antares:reasoning': 'high', + }) + + expect(loadReasoningPreference(storage, 'openai', 'gpt-5', undefined)).toEqual({ + value: '', + migrated: false, + }) + expect(storage.getItem(key)).toBe('MiXeD') + expect(storage.getItem('antares:reasoning')).toBeNull() + + expect(loadReasoningPreference(storage, 'openai', 'gpt-5', capability(['MiXeD']))).toEqual({ + value: 'MiXeD', + migrated: false, + }) + }) + + test('isolate encoded provider and model storage keys', () => { + const storage = memoryStorage() + const first = reasoningPreferenceKey('provider/one', 'model:alpha') + const second = reasoningPreferenceKey('provider', 'one/model:alpha') + + expect(first).toBe('antares:reasoning:v2:provider%2Fone:model%3Aalpha') + expect(second).toBe('antares:reasoning:v2:provider:one%2Fmodel%3Aalpha') + expect(first).not.toBe(second) + + saveReasoningPreference(storage, 'provider/one', 'model:alpha', 'MiXeD') + saveReasoningPreference(storage, 'provider', 'one/model:alpha', 'extra-high') + + expect(storage.getItem(first)).toBe('MiXeD') + expect(storage.getItem(second)).toBe('extra-high') + }) + + test('store Auto as no explicit scoped override', () => { + const key = reasoningPreferenceKey('openai', 'gpt-5') + const storage = memoryStorage({ [key]: 'high' }) + + saveReasoningPreference(storage, 'openai', 'gpt-5', '') + + expect(storage.getItem(key)).toBeNull() + }) +}) + +describe('reasoning capability state', () => { + test('preserve the current opaque value while metadata is loading or unavailable', () => { + for (const status of ['loading', 'unavailable']) { + expect(resolveReasoningControl('MiXeD', { status })).toEqual({ + options: [ + { value: '', label: 'Auto' }, + { value: 'MiXeD', label: 'MiXeD' }, + ], + unsupported: false, + hint: status, + }) + } + }) + + test('mark a value unsupported only after an authoritative result', () => { + expect(resolveReasoningControl('legacy', { status: 'ready' })).toEqual({ + options: [{ value: '', label: 'Auto' }], + unsupported: true, + hint: 'unsupported', + }) + expect(resolveReasoningControl('MiXeD', { + status: 'ready', + capability: capability(['MiXeD']), + })).toEqual({ + options: [ + { value: '', label: 'Auto' }, + { value: 'MiXeD', label: 'MiXeD' }, + ], + unsupported: false, + hint: 'auto', + }) + }) +}) + +describe('reasoning model targets', () => { + test('preserve aggregator model ids unless the prefix is a configured provider', () => { + const active = { provider: 'openrouter', model: 'anthropic/claude-sonnet' } + const providers = ['openrouter', 'openai'] + + expect(resolveReasoningModelTarget('', active, providers)).toEqual(active) + expect(resolveReasoningModelTarget('anthropic/claude-opus', active, providers)).toEqual({ + provider: 'openrouter', + model: 'anthropic/claude-opus', + }) + expect(resolveReasoningModelTarget('openai/gpt-5', active, providers)).toEqual({ + provider: 'openai', + model: 'gpt-5', + }) + }) +}) + +describe('targeted reasoning capability loader', () => { + test('deduplicates concurrent lookups and caches authoritative results', async () => { + let calls = 0 + let resolveInfo + const pending = new Promise((resolve) => { + resolveInfo = resolve + }) + const load = createReasoningCapabilityLoader(() => { + calls++ + return pending + }) + const target = { provider: 'openrouter', model: 'anthropic/claude-opus' } + + const first = load(target) + const second = load({ ...target }) + expect(calls).toBe(1) + + const cap = capability(['MiXeD']) + resolveInfo({ found: true, reasoning_capability: cap }) + expect(await first).toEqual({ status: 'ready', capability: cap }) + expect(await second).toEqual({ status: 'ready', capability: cap }) + expect(await load(target)).toEqual({ status: 'ready', capability: cap }) + expect(calls).toBe(1) + }) + + test('does not permanently cache unavailable metadata', async () => { + let calls = 0 + const load = createReasoningCapabilityLoader(async () => { + calls++ + return { found: false } + }) + const target = { provider: 'openai', model: 'gpt-5' } + + expect(await load(target)).toEqual({ status: 'unavailable' }) + expect(await load(target)).toEqual({ status: 'unavailable' }) + expect(calls).toBe(2) + }) + + test('bounds authoritative results with least-recently-used eviction', async () => { + const calls = [] + const load = createReasoningCapabilityLoader(async (target) => { + calls.push(target.model) + return { found: true } + }, 2) + const target = (model) => ({ provider: 'openrouter', model }) + + await load(target('model-a')) + await load(target('model-b')) + await load(target('model-a')) + await load(target('model-c')) + await load(target('model-a')) + await load(target('model-b')) + + expect(calls).toEqual(['model-a', 'model-b', 'model-c', 'model-b']) + }) +}) + +describe('targeted reasoning capability scheduler', () => { + test('collapses rapid target changes to the final delayed lookup', async () => { + const timers = manualTimers() + const calls = [] + const results = [] + const schedule = createReasoningCapabilityScheduler( + async (target) => { + calls.push(target.model) + return { status: 'ready' } + }, + 300, + timers, + ) + const target = (model) => ({ provider: 'openrouter', model }) + + schedule(target('g'), (state) => results.push(state)) + timers.advance(100) + schedule(target('gp'), (state) => results.push(state)) + timers.advance(100) + schedule(target('gpt-5'), (state) => results.push(state)) + timers.advance(299) + expect(calls).toEqual([]) + + timers.advance(1) + await flushPromises() + expect(calls).toEqual(['gpt-5']) + expect(results).toEqual([{ status: 'ready' }]) + }) + + test('ignores completion from a superseded in-flight target', async () => { + const timers = manualTimers() + const calls = [] + const pending = new Map() + const results = [] + const schedule = createReasoningCapabilityScheduler( + (target) => { + calls.push(target.model) + return new Promise((resolve) => pending.set(target.model, resolve)) + }, + 300, + timers, + ) + const target = (model) => ({ provider: 'openrouter', model }) + + schedule(target('partial'), (state) => results.push(['partial', state])) + timers.advance(300) + schedule(target('final'), (state) => results.push(['final', state])) + timers.advance(300) + expect(calls).toEqual(['partial', 'final']) + + pending.get('partial')({ status: 'unavailable' }) + await flushPromises() + expect(results).toEqual([]) + + pending.get('final')({ status: 'ready' }) + await flushPromises() + expect(results).toEqual([['final', { status: 'ready' }]]) + }) +}) + +function memoryStorage(initial = {}) { + const values = new Map(Object.entries(initial)) + return { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => values.set(key, value), + removeItem: (key) => values.delete(key), + } +} + +function capability(values) { + return { + values: values.map((value) => ({ value, label: value })), + mandatory: false, + can_disable: false, + source: 'static', + } +} + +function manualTimers() { + let now = 0 + let nextId = 0 + const tasks = new Map() + + return { + setTimeout(callback, delayMs) { + const id = ++nextId + tasks.set(id, { at: now + delayMs, callback }) + return id + }, + clearTimeout(id) { + tasks.delete(id) + }, + advance(ms) { + now += ms + for (;;) { + const due = [...tasks.entries()] + .filter(([, task]) => task.at <= now) + .sort((left, right) => left[1].at - right[1].at || left[0] - right[0]) + if (due.length === 0) return + const [id, task] = due[0] + tasks.delete(id) + task.callback() + } + }, + } +} + +async function flushPromises() { + await Promise.resolve() + await Promise.resolve() +} diff --git a/web/src/lib/reasoning.ts b/web/src/lib/reasoning.ts new file mode 100644 index 0000000..45d166d --- /dev/null +++ b/web/src/lib/reasoning.ts @@ -0,0 +1,139 @@ +import type { ReasoningCapability, ReasoningValue } from '@/lib/models' + +export interface StorageLike { + getItem(key: string): string | null + setItem(key: string, value: string): void + removeItem(key: string): void +} + +const LEGACY_REASONING_KEY = 'antares:reasoning' +const REASONING_KEY_PREFIX = 'antares:reasoning:v2' + +export type ReasoningCapabilityStatus = 'loading' | 'ready' | 'unavailable' + +export interface ReasoningCapabilityState { + status: ReasoningCapabilityStatus + capability?: ReasoningCapability +} + +export interface ReasoningControlResolution { + options: ReasoningValue[] + unsupported: boolean + hint: 'loading' | 'unavailable' | 'unsupported' | 'mandatory' | 'auto' | 'provider-controlled' +} + +export interface ReasoningModelTarget { + provider: string + model: string +} + +export function reasoningOptions(capability?: ReasoningCapability): ReasoningValue[] { + if (!capability) return [{ value: '', label: 'Auto' }] + const values = capability.values.filter( + (option) => + option.kind !== 'disable' || + (capability.can_disable && !capability.mandatory), + ) + return [{ value: '', label: 'Auto' }, ...values] +} + +export function resolveReasoningControl( + value: string, + state: ReasoningCapabilityState, +): ReasoningControlResolution { + if (state.status !== 'ready') { + const options = reasoningOptions() + if (value) options.push({ value, label: value }) + return { options, unsupported: false, hint: state.status } + } + + const options = reasoningOptions(state.capability) + const unsupported = + value !== '' && !options.some((option) => option.value === value) + const hint = unsupported + ? 'unsupported' + : state.capability?.mandatory + ? 'mandatory' + : state.capability + ? 'auto' + : 'provider-controlled' + return { options, unsupported, hint } +} + +export function resolveReasoningModelTarget( + modelRef: string, + active: ReasoningModelTarget, + configuredProviders: readonly string[], +): ReasoningModelTarget { + const model = modelRef.trim() + if (!model) return active + + const slash = model.indexOf('/') + if (slash > 0 && slash < model.length - 1) { + const candidate = model.slice(0, slash) + if ( + configuredProviders.includes(candidate) || + candidate === active.provider || + candidate === 'google' + ) { + return { provider: candidate, model: model.slice(slash + 1) } + } + } + return { provider: active.provider, model } +} + +export function reasoningPreferenceKey(provider: string, model: string): string { + return `${REASONING_KEY_PREFIX}:${encodeURIComponent(provider)}:${encodeURIComponent(model)}` +} + +export function loadReasoningPreference( + storage: StorageLike, + provider: string, + model: string, + capability?: ReasoningCapability, +): { value: string; migrated: boolean } { + const key = reasoningPreferenceKey(provider, model) + const allowed = new Set(reasoningOptions(capability).map((option) => option.value)) + + try { + const scoped = storage.getItem(key) + const legacy = storage.getItem(LEGACY_REASONING_KEY) + if (legacy !== null) storage.removeItem(LEGACY_REASONING_KEY) + + if (scoped !== null) { + // Missing metadata is not proof that an opaque value is invalid. Use Auto + // for this request, but retain the preference so a later authoritative + // capability lookup can restore it. + if (!capability) return { value: '', migrated: false } + if (scoped && allowed.has(scoped)) { + return { value: scoped, migrated: false } + } + storage.removeItem(key) + return { value: '', migrated: false } + } + + if (legacy && allowed.has(legacy)) { + storage.setItem(key, legacy) + return { value: legacy, migrated: true } + } + } catch { + // Storage is best-effort (private browsing and quotas may reject access). + } + + return { value: '', migrated: false } +} + +export function saveReasoningPreference( + storage: StorageLike, + provider: string, + model: string, + value: string, +): void { + const key = reasoningPreferenceKey(provider, model) + try { + if (value) storage.setItem(key, value) + else storage.removeItem(key) + } catch { + // Preference persistence must never prevent composing a message. + } +} diff --git a/web/src/lib/reasoningCapability.ts b/web/src/lib/reasoningCapability.ts new file mode 100644 index 0000000..1cf4a02 --- /dev/null +++ b/web/src/lib/reasoningCapability.ts @@ -0,0 +1,146 @@ +import { useEffect, useState } from 'react' +import { get } from '@/lib/api' +import type { ReasoningCapability } from '@/lib/models' +import type { + ReasoningCapabilityState, + ReasoningModelTarget, +} from '@/lib/reasoning' + +export interface ReasoningModelInfo { + found: boolean + reasoning_capability?: ReasoningCapability +} + +export type FetchReasoningModelInfo = ( + target: ReasoningModelTarget, +) => Promise + +export interface ReasoningCapabilityTimers { + setTimeout(callback: () => void, delayMs: number): unknown + clearTimeout(handle: unknown): void +} + +const reasoningCapabilityTimers: ReasoningCapabilityTimers = { + setTimeout: (callback, delayMs) => globalThis.setTimeout(callback, delayMs), + clearTimeout: (handle) => + globalThis.clearTimeout(handle as ReturnType), +} + +export function createReasoningCapabilityLoader( + fetchInfo: FetchReasoningModelInfo, + maxCacheEntries = 32, +): (target: ReasoningModelTarget) => Promise { + const cacheLimit = Math.max(0, Math.floor(maxCacheEntries)) + const cache = new Map() + const inFlight = new Map>() + + return (target) => { + const key = JSON.stringify([target.provider, target.model]) + const cached = cache.get(key) + if (cached) { + cache.delete(key) + cache.set(key, cached) + return Promise.resolve(cached) + } + const pending = inFlight.get(key) + if (pending) return pending + + const request = fetchInfo(target) + .then((info): ReasoningCapabilityState => { + if (!info.found) return { status: 'unavailable' } + const state: ReasoningCapabilityState = info.reasoning_capability + ? { status: 'ready', capability: info.reasoning_capability } + : { status: 'ready' } + if (cacheLimit > 0) { + cache.set(key, state) + while (cache.size > cacheLimit) { + const oldest = cache.keys().next().value + if (oldest === undefined) break + cache.delete(oldest) + } + } + return state + }) + .catch((): ReasoningCapabilityState => ({ status: 'unavailable' })) + .finally(() => { + inFlight.delete(key) + }) + inFlight.set(key, request) + return request + } +} + +export function createReasoningCapabilityScheduler( + load: (target: ReasoningModelTarget) => Promise, + delayMs = 300, + timers = reasoningCapabilityTimers, +): ( + target: ReasoningModelTarget, + onResult: (state: ReasoningCapabilityState) => void, +) => () => void { + let version = 0 + let pendingHandle: unknown + let hasPendingTimer = false + + return (target, onResult) => { + const requestVersion = ++version + if (hasPendingTimer) { + timers.clearTimeout(pendingHandle) + hasPendingTimer = false + } + + let handle: unknown + handle = timers.setTimeout(() => { + if (hasPendingTimer && pendingHandle === handle) { + hasPendingTimer = false + } + if (requestVersion !== version) return + void load(target).then( + (state) => { + if (requestVersion === version) onResult(state) + }, + () => {}, + ) + }, delayMs) + pendingHandle = handle + hasPendingTimer = true + + return () => { + if (requestVersion !== version) return + version++ + if (hasPendingTimer && pendingHandle === handle) { + timers.clearTimeout(handle) + hasPendingTimer = false + } + } + } +} + +const loadReasoningCapability = createReasoningCapabilityLoader((target) => + get( + `/providers/${encodeURIComponent(target.provider)}/model-info?model=${encodeURIComponent(target.model)}`, + ), +) + +export function useReasoningCapability( + target?: ReasoningModelTarget, +): ReasoningCapabilityState { + const provider = target?.provider ?? '' + const model = target?.model ?? '' + const key = JSON.stringify([provider, model]) + const [snapshot, setSnapshot] = useState<{ + key: string + state: ReasoningCapabilityState + }>() + const [schedule] = useState(() => + createReasoningCapabilityScheduler(loadReasoningCapability), + ) + + useEffect(() => { + if (!provider || !model) return + return schedule({ provider, model }, (state) => setSnapshot({ key, state })) + }, [key, model, provider, schedule]) + + if (!provider || !model) return { status: 'unavailable' } + return snapshot?.key === key ? snapshot.state : { status: 'loading' } +} diff --git a/web/src/lib/sessionScope.test.mjs b/web/src/lib/sessionScope.test.mjs new file mode 100644 index 0000000..46f9709 --- /dev/null +++ b/web/src/lib/sessionScope.test.mjs @@ -0,0 +1,264 @@ +import { describe, expect, test } from 'bun:test' +import { createSessionScope } from './sessionScope.ts' +import { mergeApprovals, pendingApprovalsForSession, shouldReconnectAttach } from './chatEvents.ts' + +/** + * A stand-in for the router the chat page reads. The occurrence identity is the + * emitted location object: a rerender keeps it, and every navigation mints a new + * one — including a return to a session id that was already visited. + */ +function navigation(sessionId) { + let current = { sessionId, epoch: {} } + return { + current: () => current, + open(next) { + current = { sessionId: next, epoch: {} } + return current + }, + rerender: () => ({ sessionId: current.sessionId, epoch: current.epoch }), + } +} + +/** The chat state a deferred callback could reach. */ +const chatState = () => ({ + approvals: [], + messages: [], + title: '', + streaming: false, + error: null, + cursorState: null, + reconnects: 0, +}) + +const pendingFor = (sessionId, id) => ({ + id, + session_id: sessionId, + tool: 'shell', + arguments: '{"cmd":"ls"}', + message: '', +}) + +/** The `/approvals` response landing, as the approval refresh applies it. */ +const landApprovals = (scope, state, sessionId, pending) => + scope.run(() => { + state.approvals = pendingApprovalsForSession(state.approvals, pending, sessionId) + }) + +/** One attach frame landing, as the stream handler applies an approval event. */ +const landFrame = (scope, state, view) => + scope.run(() => { + state.streaming = true + state.messages = [...state.messages, { id: 'live_a', role: 'assistant' }] + state.approvals = mergeApprovals(state.approvals, view) + }) + +/** The end-of-turn history refresh landing. */ +const landRefresh = (scope, state, detail) => + scope.run(() => { + state.messages = detail.messages + state.title = detail.title + state.cursorState = detail.cursorState + }) + +/** The attach error handler landing. */ +const landAttachError = (scope, state, message) => + scope.run(() => { + state.streaming = false + state.error = message + }) + +describe('scoping work to one route-open occurrence', () => { + test('an ordinary rerender keeps work started by the open route alive', () => { + const nav = navigation('A') + const scope = createSessionScope(nav.current(), nav.current) + // The chat page rebuilds the occurrence object on every render; only the + // epoch it carries decides identity. + nav.rerender() + expect(scope.isCurrent()).toBe(true) + expect(scope.run(() => {})).toBe(true) + }) + + test('work started by the previous route opening is stale', () => { + const nav = navigation('A') + const scopeA = createSessionScope(nav.current(), nav.current) + nav.open('B') + expect(scopeA.isCurrent()).toBe(false) + expect(scopeA.run(() => {})).toBe(false) + }) + + test('B1 and A stay stale under B2 even though B1 and B2 name one session', () => { + const nav = navigation('B') + const b1 = nav.current() + const scopeB1 = createSessionScope(b1, nav.current) + const scopeA = createSessionScope(nav.open('A'), nav.current) + const b2 = nav.open('B') + + // A session id alone cannot tell B1 from B2, which is the whole problem. + expect(b1.sessionId).toBe(b2.sessionId) + expect(scopeB1.isCurrent()).toBe(false) + expect(scopeA.isCurrent()).toBe(false) + expect(createSessionScope(b2, nav.current).isCurrent()).toBe(true) + }) + + test('a released scope writes nothing even while its occurrence is open', () => { + const nav = navigation('A') + const scope = createSessionScope(nav.current(), nav.current) + // Effect cleanup and unmount both end the work without a navigation. + scope.release() + expect(scope.isCurrent()).toBe(false) + expect(scope.run(() => {})).toBe(false) + }) + + test('a derived scope ends with its parent but never ends the parent', () => { + const nav = navigation('A') + const scope = createSessionScope(nav.current(), nav.current) + const loop = scope.derive() + loop.release() + expect(loop.isCurrent()).toBe(false) + expect(scope.isCurrent()).toBe(true) + + const other = scope.derive() + scope.release() + expect(other.isCurrent()).toBe(false) + }) + + test('a guarded callback is judged when it fires, not when it was wrapped', () => { + const nav = navigation('A') + const scope = createSessionScope(nav.current(), nav.current) + const seen = [] + const deferred = scope.guard((value) => seen.push(value)) + + deferred('while open') + nav.open('B') + deferred('after navigating') + expect(seen).toEqual(['while open']) + }) +}) + +describe('deferred B1 and A work cannot mutate B2', () => { + /** B1 opens, A intervenes, B2 opens; every scope is captured on the way. */ + const b1ToAToB2 = () => { + const nav = navigation('B') + const scopeB1 = createSessionScope(nav.current(), nav.current) + const scopeA = createSessionScope(nav.open('A'), nav.current) + const scopeB2 = createSessionScope(nav.open('B'), nav.current) + return { nav, scopeB1, scopeA, scopeB2 } + } + + test('a pending-approval response from B1 or A never reaches B2', () => { + const { scopeB1, scopeA, scopeB2 } = b1ToAToB2() + const state = chatState() + landApprovals(scopeB2, state, 'B', [pendingFor('B', 'live')]) + const settled = state.approvals + + // B1 asked for the same session, so a session-id filter would let it in. + expect(landApprovals(scopeB1, state, 'B', [pendingFor('B', 'stale-b1')])).toBe(false) + expect(landApprovals(scopeA, state, 'A', [pendingFor('A', 'stale-a')])).toBe(false) + expect(state.approvals).toBe(settled) + expect(settled.map((a) => a.id)).toEqual(['live']) + }) + + test("B2's own approval refresh and cancel poll still merge", () => { + const { scopeB2 } = b1ToAToB2() + const state = chatState() + expect(landApprovals(scopeB2, state, 'B', [pendingFor('B', 'first')])).toBe(true) + // The cancel poll runs the same refresh every two seconds while the server + // holds the request open. + expect(landApprovals(scopeB2, state, 'B', [pendingFor('B', 'first'), pendingFor('B', 'second')])).toBe(true) + expect(state.approvals.map((a) => a.id)).toEqual(['first', 'second']) + }) + + test('an SSE frame from B1 cannot append to B2 or raise its streaming flag', () => { + const { scopeB1, scopeB2 } = b1ToAToB2() + const state = chatState() + const frame = { id: 'appr_1', tool: 'shell', arguments: '{}', message: '' } + + expect(landFrame(scopeB1.derive(), state, frame)).toBe(false) + expect(state.messages).toEqual([]) + expect(state.streaming).toBe(false) + expect(state.approvals).toEqual([]) + // B2's own attachment still renders. + expect(landFrame(scopeB2.derive(), state, frame)).toBe(true) + expect(state.messages).toHaveLength(1) + expect(state.streaming).toBe(true) + }) + + test("an attach error from B1 cannot set B2's error or clear its streaming", () => { + const { scopeB1, scopeB2 } = b1ToAToB2() + const state = chatState() + landFrame(scopeB2.derive(), state, { id: 'a', tool: 't', arguments: '{}', message: '' }) + + expect(landAttachError(scopeB1.derive(), state, 'login expired')).toBe(false) + expect(state.error).toBeNull() + expect(state.streaming).toBe(true) + }) + + test('a retry timer from B1 cannot restart a reconnect loop over B2', () => { + const { scopeB1, scopeB2 } = b1ToAToB2() + const state = chatState() + const loopB1 = scopeB1.derive() + const loopB2 = scopeB2.derive() + const connect = (loop) => { + if (!shouldReconnectAttach({ alive: loop.isCurrent(), detached: false })) return + state.reconnects += 1 + } + + scopeB1.guard(() => connect(loopB1))() + expect(state.reconnects).toBe(0) + // B2 keeps its standing attachment. + scopeB2.guard(() => connect(loopB2))() + expect(state.reconnects).toBe(1) + // A local Cursor Stop still holds B2's own loop open but idle. + expect(shouldReconnectAttach({ alive: loopB2.isCurrent(), detached: true })).toBe(false) + expect(shouldReconnectAttach({ alive: loopB2.isCurrent(), detached: false })).toBe(true) + }) + + test("a final history refresh from B1 cannot rewrite B2's transcript", () => { + const { scopeB1, scopeB2 } = b1ToAToB2() + const state = chatState() + landRefresh(scopeB2.derive(), state, { + messages: [{ id: 'b2_1' }], + title: 'B2', + cursorState: { active: true }, + }) + + expect( + landRefresh(scopeB1.derive(), state, { + messages: [{ id: 'b1_1' }], + title: 'B1', + cursorState: { active: false }, + }), + ).toBe(false) + expect(state.messages).toEqual([{ id: 'b2_1' }]) + expect(state.title).toBe('B2') + expect(state.cursorState).toEqual({ active: true }) + }) + + test("a cleanup or finally callback from B1 cannot touch B2's run state", () => { + const { scopeB1, scopeB2 } = b1ToAToB2() + const state = chatState() + landFrame(scopeB2.derive(), state, { id: 'a', tool: 't', arguments: '{}', message: '' }) + const loopB1 = scopeB1.derive() + + // The attach's `finally` both stops the spinner and schedules the next + // connection; neither may happen for a route that has been left. + const cleanup = scopeB1.guard(() => { + state.streaming = false + state.reconnects += 1 + }) + loopB1.release() + cleanup() + expect(state.streaming).toBe(true) + expect(state.reconnects).toBe(0) + }) + + test('a new chat that adopts a server id leaves pre-adoption work stale', () => { + const nav = navigation('') + const draft = createSessionScope(nav.current(), nav.current) + expect(draft.isCurrent()).toBe(true) + // The turn streams on, and the page navigates to the id the server assigned. + nav.open('B') + expect(draft.isCurrent()).toBe(false) + expect(createSessionScope(nav.current(), nav.current).isCurrent()).toBe(true) + }) +}) diff --git a/web/src/lib/sessionScope.ts b/web/src/lib/sessionScope.ts new file mode 100644 index 0000000..dfaacdb --- /dev/null +++ b/web/src/lib/sessionScope.ts @@ -0,0 +1,68 @@ +/** + * Scoping asynchronous chat work to the route opening that started it. + * + * A session id is not an opening of that session: leaving conversation B for A + * and returning to B gives two openings that share every textual identifier. + * Work in flight from the first one — a pending-approval response, an attach + * frame, a retry timer, an end-of-turn history refresh, a cleanup callback — + * must therefore be judged by the occurrence it belongs to, at the moment it + * tries to write, rather than by the session id it names, by an `alive` flag + * that effect cleanup flips later, or by a counter another opening can reuse. + */ + +import { sessionOpenIsCurrent, type SessionOpenOccurrence } from './composerRestore' + +export interface SessionScope { + /** The route opening this work was started for. */ + readonly open: SessionOpenOccurrence + /** Whether writes are still allowed: same opening, and not released. */ + isCurrent(): boolean + /** End the scope without a navigation — effect cleanup, or unmount. */ + release(): void + /** Perform a state write only while the scope holds. Reports whether it ran. */ + run(write: () => void): boolean + /** Wrap a deferred callback (promise, stream, timer) in the same guard. */ + guard(callback: (...args: A) => void): (...args: A) => void + /** + * A scope for one operation inside this one, such as a standing attachment. + * It ends when the operation is closed or when this scope ends, so a single + * check covers both the route moving on and the work being torn down. + */ + derive(): SessionScope +} + +/** + * Scope work to `open`, judged against whatever route opening is current when + * a callback finally runs. `currentOpen` is read at call time on purpose: the + * whole point is that the answer changes while the work is in flight. + */ +export function createSessionScope( + open: SessionOpenOccurrence, + currentOpen: () => SessionOpenOccurrence, +): SessionScope { + return scopeWhile(open, () => sessionOpenIsCurrent(open, currentOpen())) +} + +function scopeWhile(open: SessionOpenOccurrence, holds: () => boolean): SessionScope { + let released = false + const isCurrent = () => !released && holds() + const run = (write: () => void): boolean => { + if (!isCurrent()) return false + write() + return true + } + return { + open, + isCurrent, + release: () => { + released = true + }, + run, + guard: + (callback: (...args: A) => void) => + (...args: A) => { + run(() => callback(...args)) + }, + derive: () => scopeWhile(open, isCurrent), + } +} diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 2e188d8..95064fe 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { memo, useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react' import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso' import { useLocation, useNavigate, useParams } from 'react-router-dom' import { @@ -6,18 +6,40 @@ import { Brain, CaretDown, Check, + Cloud, Copy, FileText, + GitBranch, Paperclip, PencilSimple, Plus, + Prohibit, SidebarSimple, Stop, Terminal, Warning, X, } from '@phosphor-icons/react' -import { ApiError, get, post, streamGet, streamPost, type StreamEvent } from '@/lib/api' +import { + ApiError, + get, + isDashboardPasswordRequired, + post, + streamGet, + streamPost, + type StreamEvent, +} from '@/lib/api' +import { + approvalFromEvent, + cursorHydrationFromDetail, + mergeApprovals, + pendingApprovalsForSession, + shouldReconnectAttach, + stopBehavior, + type CursorHydration, + type CursorStateProjection, + type PendingApproval, +} from '@/lib/chatEvents' import { groupStreamPatches, queueStreamDelta, @@ -25,7 +47,44 @@ import { type QueuedStreamPatch, } from '@/lib/chatStreamQueue' import { copyText } from '@/lib/clipboard' +import { + cursorChatRequest, + isCursorTarget, + type ChatTarget, + type ComposerTarget, + type CursorMode, + type CursorOptionsValue, + type CursorRunBaseline, +} from '@/lib/composerTargets' +import { + baselineAfterSend, + composerCanSend, + ownershipResolutionAfterCompletion, + restoreIsCurrent, + sessionOpenIsCurrent, + sessionTargetOwner, + shouldAdoptDefaultTarget, + stopStreamKind, + targetAfterCursorHydration, + targetChangeAllowed, + type SessionOpenOccurrence, + type SessionOwnershipResolution, + type TargetOwner, +} from '@/lib/composerRestore' +import { composerImageLimit, validateCursorAttachments } from '@/lib/cursorAttachments' +import { + defaultCursorVariant, + resolveCursorVariant, + type CursorModel, +} from '@/lib/cursorModels' import { useI18n, useTimeAgo, type MessageKey } from '@/lib/i18n' +import type { ReasoningCapability } from '@/lib/models' +import { + loadReasoningPreference, + reasoningOptions, + saveReasoningPreference, +} from '@/lib/reasoning' +import { createSessionScope, type SessionScope } from '@/lib/sessionScope' import { cn } from '@/lib/utils' import { Button } from '@/components/ui/button' import { Textarea } from '@/components/ui/primitives' @@ -37,6 +96,7 @@ import { ApprovalCard, type ApprovalView } from '@/components/chat/ApprovalCard' import { AskUserCard } from '@/components/chat/AskUserCard' import { RolePicker } from '@/components/chat/RolePicker' import { ModelPicker } from '@/components/chat/ModelPicker' +import { CursorOptions } from '@/components/chat/CursorOptions' import { ReasoningPicker } from '@/components/chat/ReasoningPicker' import { ProjectPicker } from '@/components/chat/ProjectPicker' import { ProjectSidebar } from '@/components/chat/ProjectSidebar' @@ -173,7 +233,14 @@ interface SessionDetail { tokens_in: number tokens_out: number hidden?: boolean + model?: string + meta?: Record | null }> + /** + * Durable Cursor state. Null for an ordinary chat; absent only on a server + * that predates the projection, where the transcript is the last resort. + */ + cursor_state?: CursorStateProjection | null } /** Rebuild view models from the persisted message log. */ @@ -244,6 +311,14 @@ function hydrate(detail: SessionDetail): ChatMessage[] { return out } +/** The part of a Cursor turn that is not the model and its variant. */ +interface CursorTurnSettings { + mode: CursorMode + repositoryUrl: string | null + startingRef: string | null + autoCreatePR: boolean +} + const SUGGESTION_KEYS: MessageKey[] = [ 'chat.suggest1', 'chat.suggest2', @@ -326,6 +401,9 @@ export default function ChatPage() { const [messages, setMessages] = useState([]) const [loading, setLoading] = useState(!!sessionId) const [streaming, setStreaming] = useState(false) + // Read by stable callbacks that must not change identity per render. + const streamingRef = useRef(false) + streamingRef.current = streaming // Live status for the streaming indicator: which step, and what tool (if any) // is running right now. Reset at the start of every send. const [live, setLive] = useState<{ @@ -363,24 +441,357 @@ export default function ChatPage() { if (r) localStorage.setItem('antares:last-role', r) else localStorage.removeItem('antares:last-role') }, []) - // Per-turn reasoning effort picked in the composer. Empty means "use the - // configured default" (agent.reasoning_effort, then model). Persisted so the - // choice survives a reload, mirroring the role picker. - const [reasoning, setReasoning] = useState( - () => localStorage.getItem('antares:reasoning') ?? '', + // Where the next message runs: an Antares chat model, or a Cursor Cloud + // Agent. Only a chat target has an adaptive reasoning override — Cursor's own + // variant controls take that role in Cursor mode. + const [target, setTarget] = useState(null) + const targetRef = useRef(null) + targetRef.current = target + const cursorMode = isCursorTarget(target) + // Who may set the target right now. A session's own durable state outranks + // the picker's automatic active-model default, which is stashed until the + // session turns out not to own the target. + // + // Ownership is derived from the open session on every render rather than + // recorded by an effect: an effect runs after the first paint, which would + // leave the composer of a just-opened conversation briefly claiming it knows + // where a message should go. + // React Router emits a stable Location object for the current navigation and + // a new object for every subsequent navigation, including POP back to a + // history entry whose textual location.key is reused. Its identity is the + // synchronous route-open epoch; no ref or effect has to mint one. + const openSession: SessionOpenOccurrence = { + sessionId: sessionId ?? '', + epoch: location, + } + const [ownershipResolution, setOwnershipResolution] = + useState({ open: null, owner: 'free' }) + const targetOwner = sessionTargetOwner({ open: openSession, resolved: ownershipResolution }) + const targetOwnerRef = useRef(targetOwner) + targetOwnerRef.current = targetOwner + // Effects and their promise callbacks can outlive the render that created + // them. Effect Events read the latest committed route without mutating a ref + // during render, so a stale occurrence can be rejected before any state write. + const isCurrentSessionOpen = useEffectEvent((captured: SessionOpenOccurrence) => + sessionOpenIsCurrent(captured, { + sessionId: sessionId ?? '', + epoch: location, + }), + ) + /** The route opening currently committed. */ + const currentSessionOpen = useEffectEvent( + (): SessionOpenOccurrence => ({ sessionId: sessionId ?? '', epoch: location }), + ) + /** + * Scope an operation to one route opening. Everything it starts — a pending + * approval fetch, an attachment and its retries, an end-of-turn refresh — + * asks this scope again at the instant it would write, so no completion from + * a conversation that has been left can land in the one on screen. + */ + const sessionScopeFor = useCallback( + (open: SessionOpenOccurrence): SessionScope => + // Deferred on purpose: an Effect Event answers for the latest committed + // render, which is exactly the question a late callback has to ask. + createSessionScope(open, () => currentSessionOpen()), + [], + ) + /** Record ownership only if this exact route occurrence is still current. */ + const resolveTargetOwner = useEffectEvent( + (completed: SessionOpenOccurrence, owner: TargetOwner) => { + const current: SessionOpenOccurrence = { + sessionId: sessionId ?? '', + epoch: location, + } + setOwnershipResolution((previous) => + ownershipResolutionAfterCompletion({ current, previous, completed, owner }), + ) + }, + ) + const pendingDefaultRef = useRef(null) + // Whether the target was chosen deliberately since this session opened. + const userChoseRef = useRef(false) + // Bumped for every session hydration, so an answer for the session that was + // open a moment ago can never apply to the one open now. + const hydrationRef = useRef(0) + // The kind of stream that is actually running. The picker is locked while a + // turn streams, but an attach can outlive a target change, so Stop asks this + // rather than the composer. + const streamKindRef = useRef<'chat' | 'cursor' | null>(null) + // The non-model half of a Cursor turn, kept while switching Cursor models. + const [cursorSettings, setCursorSettings] = useState({ + mode: 'agent', + repositoryUrl: null, + startingRef: null, + autoCreatePR: false, + }) + const cursorOptions = useMemo( + () => + isCursorTarget(target) + ? { model: target.model, variant: target.variant, ...cursorSettings } + : null, + [target, cursorSettings], ) - const pickReasoning = useCallback((r: string) => { - setReasoning(r) - if (r) localStorage.setItem('antares:reasoning', r) - else localStorage.removeItem('antares:reasoning') + const cursorOptionsRef = useRef(null) + cursorOptionsRef.current = cursorOptions + // The run a follow-up would continue, and whether the server still considers + // it reusable, so the options popover can warn that sending now starts a new + // agent instead of following up. + const [lastCursorRun, setLastCursorRun] = useState(null) + const lastCursorRunRef = useRef(null) + lastCursorRunRef.current = lastCursorRun + // What this session's Cursor run is doing or produced: remote status, + // operation state, branches, and pull requests. + const [cursorState, setCursorState] = useState(null) + const cursorStateRef = useRef(null) + cursorStateRef.current = cursorState + // Local Stop detaches from a Cursor run that keeps going remotely. The ref is + // what the standing attach loop reads, so it never re-follows immediately. + const [detached, setDetached] = useState(false) + const detachedRef = useRef(false) + const [cancelling, setCancelling] = useState(false) + + // The model, its capability, and its scoped reasoning value move together. + // Updating the ref synchronously prevents a send immediately after switching + // models from carrying the previous model's override. + const [reasoning, setReasoning] = useState('') + const composerReasoningRef = useRef<{ + selection: ChatTarget + capability?: ReasoningCapability + value: string + } | null>(null) + /** + * Set the target and its ref together. Restoration decides in one pass, and + * every step of that pass has to see the target the previous step chose + * rather than the one still rendered. + */ + const commitTarget = useCallback((next: ComposerTarget | null) => { + targetRef.current = next + setTarget(next) + }, []) + /** + * Point the composer at a target. `default` is the picker's automatic + * active-model lookup, which must never outrank a session being restored or + * a choice already made; `user` is an edit, refused while a turn streams. + */ + const selectTarget = useCallback( + (selection: ComposerTarget, origin: 'user' | 'default' | 'restore' = 'user') => { + if (origin === 'default') { + // Keep it either way: this session may turn out not to own the target. + if (selection.kind === 'chat') pendingDefaultRef.current = selection + if ( + !shouldAdoptDefaultTarget({ + owner: targetOwnerRef.current, + hasTarget: targetRef.current !== null, + }) + ) { + return + } + } + if (origin === 'user') { + if (!targetChangeAllowed(streamingRef.current)) return + // A deliberate choice outranks whatever this session would restore. + userChoseRef.current = true + } + commitTarget(selection) + if (selection.kind !== 'chat') return + const capability = selection.reasoningCapability + const { value } = loadReasoningPreference( + localStorage, + selection.provider, + selection.model, + capability, + ) + composerReasoningRef.current = { selection, capability, value } + setReasoning(value) + }, + [commitTarget], + ) + const changeCursorOptions = useCallback((next: CursorOptionsValue) => { + if (!targetChangeAllowed(streamingRef.current)) return + userChoseRef.current = true + commitTarget({ kind: 'cursor', model: next.model, variant: next.variant }) + setCursorSettings({ + mode: next.mode, + repositoryUrl: next.repositoryUrl, + startingRef: next.startingRef, + autoCreatePR: next.autoCreatePR, + }) + }, [commitTarget]) + /** + * Record the run a follow-up would continue, when the composer is already + * pointed at exactly that selection. Reports whether it could. + */ + const applyCursorBaseline = useCallback( + ( + modelId: string, + params: Array<{ id: string; value: string }>, + settings: CursorTurnSettings, + reuseValid: boolean, + ): boolean => { + const current = targetRef.current + if ( + current?.kind !== 'cursor' || + current.model.id !== modelId || + resolveCursorVariant(current.model, params) !== current.variant + ) { + return false + } + setLastCursorRun({ + options: { model: current.model, variant: current.variant, ...settings }, + reuseValid, + }) + return true + }, + [], + ) + /** + * Point the composer back at the exact model and variant a session's durable + * state names. The catalogue is the only place that knows a model's variants, + * and a selection it no longer offers is reported instead of being replaced + * by the default one — that would silently run a different configuration. + */ + const restoreCursorTarget = useCallback( + ( + modelId: string, + /** Null when only a transcript named the model, so no exact selection exists. */ + params: Array<{ id: string; value: string }> | null, + settings: CursorTurnSettings, + reuseValid: boolean, + open: SessionOpenOccurrence, + ) => { + // The composer already holds this exact selection; no catalogue lookup + // can tell us anything new. + if (params && applyCursorBaseline(modelId, params, settings, reuseValid)) return + const generation = hydrationRef.current + const current = targetRef.current + void get<{ models?: CursorModel[]; needs_key?: boolean }>('/providers/cursor/models') + .then((d) => { + // Another route occurrence opened, or the user chose something, + // while this catalogue request was in flight. + if (!isCurrentSessionOpen(open)) return + if (!restoreIsCurrent(generation, hydrationRef.current)) return + if (targetRef.current !== current) return + if (d.needs_key) { + setError(t('target.cursorNeedsKey')) + return + } + const model = (d.models ?? []).find( + (candidate) => + candidate.id === modelId || (candidate.aliases ?? []).includes(modelId), + ) + // With a stored selection only that exact variant may be restored; + // without one (an older server) the model's own default is the + // honest starting point, and nothing claims a run to follow up on. + const variant = model + ? params + ? resolveCursorVariant(model, params) + : defaultCursorVariant(model) + : null + if (!model || !variant) { + setError(t('cursor.staleSelection')) + return + } + commitTarget({ kind: 'cursor', model, variant }) + setLastCursorRun( + params ? { options: { model, variant, ...settings }, reuseValid } : null, + ) + }) + .catch(() => {}) + }, + [applyCursorBaseline, commitTarget, t], + ) + /** + * Apply a session's durable Cursor state. Opening a session restores the + * composer from it; a refresh during or after a turn only updates the run's + * status and reuse baseline, so neither an edit made while the turn ran nor a + * model the user has just switched to is overwritten. + * + * Returns who owns the target now, for the caller to record against the + * session it hydrated — a slower answer must never resolve a different one. + */ + const applyCursorHydration = useCallback( + ( + hydration: CursorHydration, + restoreComposer: boolean, + open?: SessionOpenOccurrence, + ): TargetOwner | null => { + // A session-open hydration is allowed to mutate composer state only while + // that exact route occurrence is still committed. Refreshes after a live + // turn do not restore the composer and keep their generation guard. + if (restoreComposer && (!open || !isCurrentSessionOpen(open))) return null + const worthShowing = + hydration.active || Boolean(hydration.remoteStatus) || hydration.branches.length > 0 + setCursorState(worthShowing ? hydration : null) + const settings: CursorTurnSettings = { + mode: hydration.mode ?? 'agent', + repositoryUrl: hydration.repositoryUrl ?? null, + // The exact stored ref, so a follow-up reproduces the same run identity. + startingRef: hydration.startingRef ?? null, + autoCreatePR: hydration.autoCreatePR === true, + } + const userChose = userChoseRef.current + let owner: TargetOwner | null = null + if (restoreComposer) { + // Whatever this session is, the composer must stop pointing at another + // conversation's Cursor agent — and must end up with somewhere to send + // — before the next message can go out. + const decision = targetAfterCursorHydration({ + active: hydration.active, + modelId: hydration.modelId, + current: targetRef.current, + pendingDefault: pendingDefaultRef.current, + lastChat: composerReasoningRef.current?.selection ?? null, + userChose, + }) + owner = decision.owner + if (decision.action === 'set') { + if (decision.target) selectTarget(decision.target, 'restore') + else commitTarget(null) + } + } + if (!hydration.active || !hydration.modelId) { + // Not a Cursor session, or one whose exact selection is unreadable: + // there is no run a follow-up could continue. + if (restoreComposer && hydration.active) setCursorSettings(settings) + setLastCursorRun(null) + return owner + } + // An absent selection means a server that predates the projection; an + // exact one must be matched exactly. + const params = hydration.params ?? null + const reuseValid = hydration.reuseValid === true + // A deliberate choice keeps the composer; only the follow-up baseline is + // still worth taking from durable state. + if (!restoreComposer || userChose) { + if (!params || !applyCursorBaseline(hydration.modelId, params, settings, reuseValid)) { + setLastCursorRun(null) + } + return owner + } + setCursorSettings(settings) + if (!open) return owner + restoreCursorTarget(hydration.modelId, params, settings, reuseValid, open) + return owner + }, + [applyCursorBaseline, commitTarget, restoreCursorTarget, selectTarget], + ) + const pickReasoning = useCallback((value: string) => { + const current = composerReasoningRef.current + if (!current) return + const next = reasoningOptions(current.capability).some( + (option) => option.value === value, + ) + ? value + : '' + saveReasoningPreference( + localStorage, + current.selection.provider, + current.selection.model, + next, + ) + composerReasoningRef.current = { ...current, value: next } + setReasoning(next) }, []) - // Per-chat model override, chosen via the picker in the composer. Kept in a - // ref so the stream request closure always reads the latest selection. - const [activeModel, setActiveModel] = useState('') - const activeModelRef = useRef('') - useEffect(() => { - activeModelRef.current = activeModel - }, [activeModel]) // Project session: the folder this chat is bound to. Chosen on a NEW chat and // sent with the first message; once the session exists it is fixed (locked). const [projectDir, setProjectDir] = useState('') @@ -641,10 +1052,22 @@ export default function ChatPage() { }, [input]) const stop = useCallback(() => { + // A Cursor run lives in Cursor's cloud: Stop may only close this browser's + // stream. Interrupting the turn, or cancelling it remotely, is never + // implied by leaving — cancellation is a separate, approved action. + // Which semantics apply is decided by the stream that is running, not by + // the composer, which an attached run can outlive. + const behavior = stopBehavior( + stopStreamKind(streamKindRef.current, cursorStateRef.current?.active === true), + ) abortRef.current?.() abortRef.current = null setStreaming(false) - if (sessionId) { + if (behavior.detach) { + detachedRef.current = true + setDetached(true) + } + if (behavior.interrupt && sessionId) { void post<{ interrupted: boolean }>('/chat/interrupt', { session_id: sessionId }).catch(() => { // The stream is already closed locally. A failed interrupt will surface // when the user reattaches instead of leaving the stop button stuck. @@ -652,6 +1075,77 @@ export default function ChatPage() { } }, [sessionId]) + /** + * Follow this session's Cursor run again after an intentional detach. The + * attach stream replays the run from its first event, so the half-finished + * bubble this browser was writing is dropped first — otherwise the replay + * would render the same answer a second time until the turn ends. + */ + const reattach = useCallback(() => { + setMessages((prev) => { + const last = prev[prev.length - 1] + const optimistic = + last?.role === 'assistant' && last.id.startsWith('local_') && last.id.endsWith('_a') + return optimistic ? prev.slice(0, -1) : prev + }) + detachedRef.current = false + setDetached(false) + }, []) + + /** + * Load the decisions already waiting on this session. The pending list is + * global, so the answer is filtered by session id — but a session id cannot + * tell one opening of a conversation from the next, and this request outlives + * both. The scope it was started for decides whether the answer may be shown. + */ + const refreshApprovals = useCallback((scope: SessionScope, sessionOverride?: string) => { + const sid = sessionOverride ?? sessionIdRef.current + if (!sid || !scope.isCurrent()) return Promise.resolve() + return get<{ approvals?: PendingApproval[] }>('/approvals') + .then((d) => { + scope.run(() => { + setApprovals((prev) => pendingApprovalsForSession(prev, d.approvals ?? [], sid)) + }) + }) + .catch(() => {}) + }, []) + + /** + * Ask Cursor to cancel the remote run. The server holds the request until the + * approval card is answered, so the pending list is polled while it waits — + * the card must be reachable even when this browser has detached. + */ + const cancelCursorRun = useCallback(async () => { + const sid = sessionIdRef.current + if (!sid || cancelling) return + // The cancellation belongs to the conversation that was open when it was + // asked for: its poll, its failure message, and its final refresh may not + // surface in whatever is on screen by the time the server answers. + const scope = sessionScopeFor(currentSessionOpen()) + setCancelling(true) + setError(undefined) + const poll = window.setInterval(() => void refreshApprovals(scope), 2000) + try { + await post('/chat/cursor/cancel', { session_id: sid }) + } catch (e) { + scope.run(() => + setError( + isDashboardPasswordRequired(e) + ? t('sensitive.needPasswordDesc') + : e instanceof Error + ? e.message + : String(e), + ), + ) + } finally { + window.clearInterval(poll) + scope.run(() => { + setCancelling(false) + void refreshApprovals(scope) + }) + } + }, [cancelling, refreshApprovals, sessionScopeFor, t]) + // Apply one stream event to the named assistant message. Shared by a fresh // send and a reattach, so both render a turn identically. Session handling // differs between the two (navigate vs. title-only), so it is delegated. @@ -734,6 +1228,14 @@ export default function ChatPage() { setAskId(String(event.id ?? '')) setLive((s) => ({ ...s, tool: undefined, waiting: true, notice: undefined })) break + case 'approval': { + // The run is blocked on a decision. Replay and a reconnect both + // deliver the same id, so the card is added exactly once and keeps + // any decision already shown. + const view = approvalFromEvent(event) + if (view) setApprovals((prev) => mergeApprovals(prev, view)) + break + } case 'usage': patchAssistant((m) => ({ ...m, @@ -788,12 +1290,16 @@ export default function ChatPage() { // when no event came (the previous behaviour) left the chat showing the // pre-turn state — the symptom that looked like "session disappeared". const attachLive = useCallback( - (sid: string) => { + (scope: SessionScope, sid: string) => { // A standing attachment: after a turn ends we reconnect, so a turn the // SERVER starts later — a background sub-agent finishing and waking the - // main agent — streams in live without a refresh. `alive` gates the loop - // so the cleanup truly stops it. - let alive = true + // main agent — streams in live without a refresh. + // + // The loop's own scope ends when this attachment is closed AND when the + // route opening that started it does, so one question — asked again at + // every completion, not once at the start — covers a cleanup, an unmount, + // and a navigation whose cleanup has not been flushed yet. + const loop = scope.derive() let close: (() => void) | undefined let assistantId: string | null = null let cursor = 0 @@ -803,11 +1309,18 @@ export default function ChatPage() { let idleRefreshDone = false const connect = () => { - if (!alive) return + if (!loop.isCurrent()) return // Never run the standing attach while a foreground send is streaming: // that turn already renders via streamPost, and a second follower would - // double-render it. Retry shortly instead. - if (abortRef.current) { + // double-render it. An intentional Cursor detach holds the loop open + // but idle in the same way, so Stop does not instantly re-follow the + // run it just left. Retry shortly instead. + if ( + !shouldReconnectAttach({ + alive: loop.isCurrent(), + detached: abortRef.current !== null || detachedRef.current, + }) + ) { window.setTimeout(connect, 1500) return } @@ -825,6 +1338,12 @@ export default function ChatPage() { close = streamGet( `/chat/attach?session_id=${encodeURIComponent(sid)}&cursor=${cursor}`, (event) => { + // A frame decoded after another conversation opened has nothing + // left to render into, and the connection carrying it is finished. + if (!loop.isCurrent()) { + close?.() + return + } const eventCursor = Number(event.cursor ?? cursor) if (Number.isFinite(eventCursor) && eventCursor >= cursor) cursor = eventCursor if (event.type === 'done') { @@ -840,16 +1359,18 @@ export default function ChatPage() { const refresh = shouldRefresh ? get(`/sessions/${sid}`) .then((d) => { - if (!alive) return - setMessages(hydrate(d)) - setTitle(d.session.title || t('chat.conversation')) + loop.run(() => { + setMessages(hydrate(d)) + setTitle(d.session.title || t('chat.conversation')) + applyCursorHydration(cursorHydrationFromDetail(d), false) + }) }) .catch(() => {}) : Promise.resolve() // Do not overlap canonical hydration with the next attachment: a // stale response could otherwise overwrite fresh live deltas. void refresh.finally(() => { - if (alive) window.setTimeout(connect, 1500) + loop.run(() => window.setTimeout(connect, 1500)) }) return } @@ -858,30 +1379,68 @@ export default function ChatPage() { }) }, (err) => { - setStreaming(false) close?.() + // A failure of the connection this route opened is not news for the + // one on screen now, and clearing its spinner would be a lie. + if (!loop.isCurrent()) return + setStreaming(false) // Auth failure will not fix itself with a retry — stop the 3s 401 // loop that filled the daemon log after every restart. if (err instanceof ApiError && err.status === 401) { setError(t('chat.attachAuthFailed') || 'Dashboard login expired — refresh and sign in again.') return } - if (alive) window.setTimeout(connect, 3000) + window.setTimeout(connect, 3000) }, ) } connect() return () => { - alive = false + loop.release() close?.() } }, - [applyEvent, drainPatches, t], + [applyEvent, applyCursorHydration, drainPatches, t], ) useEffect(() => { + const opened = openSession + // A passive effect from an intervening route may be flushed after a newer + // navigation commits. It must perform no cleanup or completion writes for + // the occurrence now on screen. + if (!isCurrentSessionOpen(opened)) return + // A brand-new chat navigates to its own url mid-stream. The live messages + // are already on screen; re-fetching now would find the turn not yet + // persisted and wipe them, and adopting an id is not a session switch — the + // running turn keeps its target, approvals, and Cursor state. + if (sessionId && sessionId === localSessionRef.current) { + // The running turn already knows where it goes, so this id is resolved. + resolveTargetOwner(opened, targetRef.current ? 'restored' : 'free') + setLoading(false) + return + } + // Every hydration gets its own token, so a slower answer for the session + // that was open a moment ago can never apply to the one open now. + const generation = ++hydrationRef.current + // Approvals, Cursor recovery state, a cancellation in flight, and the + // detach flag all belong to one conversation; carrying them into another + // session would show a decision that no longer blocks anything. + setApprovals([]) + setCursorState(null) + setLastCursorRun(null) + setCancelling(false) + detachedRef.current = false + setDetached(false) + // A Cursor target belongs to the conversation that chose it. Until this + // session's own state says otherwise, the composer holds no Cursor target, + // so a message sent meanwhile cannot reach another session's agent. + if (isCursorTarget(targetRef.current)) commitTarget(null) + userChoseRef.current = false if (!sessionId) { + // A new chat is owned by nobody, so the picker's default may fill it. + const stashed = pendingDefaultRef.current + if (stashed && !targetRef.current) selectTarget(stashed, 'default') setMessages([]) setTitle('') setProjectDir('') @@ -889,19 +1448,22 @@ export default function ChatPage() { setLoading(false) return } - // A brand-new chat navigates to its own url mid-stream. The live messages - // are already on screen; re-fetching now would find the turn not yet - // persisted and wipe them. Skip the hydrate for that one session. - if (sessionId === localSessionRef.current) { - setLoading(false) - return - } setLoading(true) let cancelled = false let closeAttach: (() => void) | undefined + // Everything this opening starts carries its identity, so a completion is + // judged when it arrives rather than when it was launched. Passive cleanup + // is not enough on its own: a navigation commits before its cleanup runs. + const scope = sessionScopeFor(opened) get(`/sessions/${sessionId}`) .then((d) => { - if (cancelled) return + if ( + cancelled || + !isCurrentSessionOpen(opened) || + !restoreIsCurrent(generation, hydrationRef.current) + ) { + return + } const restored = hydrate(d) // Open a restored transcript at its newest message. Set before the list // mounts (it is still `loading`), so Virtuoso reads the final value once. @@ -920,12 +1482,22 @@ export default function ChatPage() { } } setError(undefined) + // Durable Cursor state decides whether this conversation still runs on + // Cursor, and with exactly which model, variant, repository, and mode. + // Only that answer, for this exact id, opens the composer. + resolveTargetOwner( + opened, + applyCursorHydration(cursorHydrationFromDetail(d), true, opened) ?? 'free', + ) + // A decision published before this page attached is still blocking the + // run; the pending list is the only place left to find it. + void refreshApprovals(scope, sessionId) // Once the persisted history is on screen, reconnect to any turn still // in flight for this session so streaming continues where it left off. - closeAttach = attachLive(sessionId) + closeAttach = attachLive(scope, sessionId) }) .catch((e: unknown) => { - if (cancelled) return + if (cancelled || !isCurrentSessionOpen(opened)) return // The session does not exist (e.g. a stale "last conversation" pointer // to a session that was deleted). Forget it and drop to a fresh chat // instead of getting stuck on a blank, dead url. @@ -940,21 +1512,46 @@ export default function ChatPage() { return } setError(e instanceof Error ? e.message : String(e)) + // Nothing will claim the target now, so the composer must not stay + // waiting on a session state that never arrived. + if ( + isCurrentSessionOpen(opened) && + restoreIsCurrent(generation, hydrationRef.current) + ) { + resolveTargetOwner(opened, 'free') + const stashed = pendingDefaultRef.current + if (stashed && !targetRef.current) selectTarget(stashed, 'default') + } }) get<{ role?: string }>(`/sessions/${sessionId}/role`) .then((r) => { // The session's own role wins; if it has none, keep the remembered // last-used role rather than snapping back to the default. - if (r.role) pickRole(r.role) + if (isCurrentSessionOpen(opened) && r.role) pickRole(r.role) }) .catch(() => {}) - .finally(() => setLoading(false)) + .finally(() => { + if (isCurrentSessionOpen(opened)) setLoading(false) + }) // t is stable per language; refetching on language change is harmless. return () => { cancelled = true + // Ends every completion this opening could still produce, including the + // ones an unmount would otherwise leave holding live state. + scope.release() closeAttach?.() } - }, [sessionId, t, attachLive]) + }, [ + sessionId, + location, + t, + attachLive, + refreshApprovals, + applyCursorHydration, + commitTarget, + selectTarget, + sessionScopeFor, + ]) /** Append a locally-produced message without touching the server. */ const pushSystem = useCallback((content: string) => { @@ -1035,7 +1632,11 @@ export default function ChatPage() { const sendText = useCallback( (raw: string, attached: string[] = [], attachedDocs: { path: string; name: string }[] = []) => { const text = raw.trim() - if ((!text && attached.length === 0 && attachedDocs.length === 0) || streaming) return + if (!text && attached.length === 0 && attachedDocs.length === 0) return + // Nothing may be sent before this session's execution target is known: + // a Cursor conversation must not fall through to the chat model while + // its exact selection is still loading. + if (!composerCanSend({ owner: targetOwnerRef.current, streaming })) return if (text.startsWith('/') && text.length > 1) { // Still record slash commands so ↑ recalls them. if (text) { @@ -1052,6 +1653,19 @@ export default function ChatPage() { return } + // Cursor runs in its own cloud VM. Everything it cannot accept is rejected + // here, before the draft and its attachments are cleared, so nothing is + // silently dropped and no paid operation is ever offered for a turn that + // could not have been sent. + const cursor = cursorOptionsRef.current + if (cursor) { + const issue = validateCursorAttachments({ images: attached, docs: attachedDocs }) + if (issue) { + setError(t(`cursorAttach.${issue.code}`, issue.values)) + return + } + } + // Non-image attachments live in a temp dir; the model can't see them until // it reads them. Tell it they're there and how — read_document by path. let message = text @@ -1086,27 +1700,68 @@ export default function ChatPage() { setStreaming(true) setLive({ turn: 1 }) + const composerReasoning = composerReasoningRef.current + // Sending is a deliberate re-attachment: whatever was detached before, this + // session is being followed again. + detachedRef.current = false + setDetached(false) + // Stop must know what it is stopping even if the composer moves on. + streamKindRef.current = cursor ? 'cursor' : 'chat' + // A refused request starts nothing, so the run a follow-up would continue + // only changes once the server has accepted this one. + const baselineBeforeSend = lastCursorRunRef.current + const generation = hydrationRef.current + let accepted = false abortRef.current = streamPost( - '/chat', - { - session_id: sessionIdRef.current ?? '', - message, - images: attached, - role, - // Per-chat model override; omitted when unset so the server falls - // back to the configured default. - ...(activeModelRef.current ? { model: activeModelRef.current } : {}), - // Per-turn reasoning override; omitted when unset so the server falls - // back to the configured default. - ...(reasoning ? { reasoning_effort: reasoning } : {}), - // Only meaningful when starting a new session; the server ignores it once - // the session exists. Read from the ref so an auto-analyze turn fired - // right after binding still carries the project. - ...(projectDirRef.current && !sessionIdRef.current - ? { project_dir: projectDirRef.current, index_rag: indexRagRef.current } - : {}), - }, + cursor ? '/chat/cursor' : '/chat', + cursor + ? cursorChatRequest(cursor, { + sessionId: sessionIdRef.current ?? '', + message, + images: attached, + // Only meaningful when starting a new session; the server binds the + // project once and discovers its repository from there. + projectDir: sessionIdRef.current ? undefined : projectDirRef.current, + }) + : { + session_id: sessionIdRef.current ?? '', + message, + images: attached, + role, + // Per-chat model override; omitted when unset so the server falls + // back to the configured default. + ...(composerReasoning + ? { + model: `${composerReasoning.selection.provider}/${composerReasoning.selection.model}`, + } + : {}), + // Per-turn reasoning override; omitted when unset so the server falls + // back to the configured default. + ...(composerReasoning?.value + ? { reasoning_effort: composerReasoning.value } + : {}), + // Only meaningful when starting a new session; the server ignores it once + // the session exists. Read from the ref so an auto-analyze turn fired + // right after binding still carries the project. + ...(projectDirRef.current && !sessionIdRef.current + ? { project_dir: projectDirRef.current, index_rag: indexRagRef.current } + : {}), + }, (event: StreamEvent) => { + // Events only flow after the server accepted the request, which is the + // first moment this turn is the one a follow-up would continue. + if (!accepted) { + accepted = true + if (cursor) { + setLastCursorRun( + baselineAfterSend({ + previous: baselineBeforeSend, + attempted: cursor, + accepted: true, + }), + ) + } + } // End-of-turn: stop streaming immediately rather than waiting for the // socket to close. A detached run keeps the connection open past the // final event, which otherwise left the indicator and the task bar @@ -1118,6 +1773,7 @@ export default function ChatPage() { setLive((s) => ({ ...s, waiting: false })) abortRef.current?.() abortRef.current = null + streamKindRef.current = null localSessionRef.current = null // The turn may have written project_info — refresh the sidebar. setSidebarRefresh((n) => n + 1) @@ -1129,8 +1785,12 @@ export default function ChatPage() { if (sid) { get(`/sessions/${sid}`) .then((d) => { + if (!restoreIsCurrent(generation, hydrationRef.current)) return setMessages(hydrate(d)) setTitle(d.session.title || t('chat.conversation')) + // The turn that just ended decides whether a follow-up can + // reuse its agent; the composer's own edits are left alone. + applyCursorHydration(cursorHydrationFromDetail(d), false) }) .catch(() => {}) } @@ -1156,26 +1816,63 @@ export default function ChatPage() { }, (err) => { drainPatches() - setError(err.message) + // A refused turn carries the server's own explanation (busy session, + // rate limit, stale Cursor selection); only the password gate answers + // with a marker instead of a sentence. + setError( + isDashboardPasswordRequired(err) ? t('sensitive.needPasswordDesc') : err.message, + ) setStreaming(false) abortRef.current = null + streamKindRef.current = null // The turn is persisted now, so a later revisit should hydrate fresh. localSessionRef.current = null + if (!cursor) return + // Nothing was started, so the previous follow-up baseline still stands. + // Anything that did happen is in the durable state, which decides. + setLastCursorRun( + baselineAfterSend({ previous: baselineBeforeSend, attempted: cursor, accepted: false }), + ) + const sid = sessionIdRef.current + if (sid) { + get(`/sessions/${sid}`) + .then((d) => { + if (!restoreIsCurrent(generation, hydrationRef.current)) return + applyCursorHydration(cursorHydrationFromDetail(d), false) + }) + .catch(() => {}) + } }, () => { drainPatches() setStreaming(false) abortRef.current = null + streamKindRef.current = null localSessionRef.current = null }, ) }, - [role, reasoning, projectDir, streaming, sessionId, navigate, runCommand, applyEvent, drainPatches, t], + [ + role, + projectDir, + streaming, + sessionId, + navigate, + runCommand, + applyEvent, + applyCursorHydration, + drainPatches, + t, + ], ) + // Both composer routes — the send button and Enter — go through here, so the + // hydration gate covers each of them. + const canSend = composerCanSend({ owner: targetOwner, streaming }) const send = useCallback(() => { const text = input.trim() - if ((!text && images.length === 0 && docs.length === 0) || streaming) return + if (!text && images.length === 0 && docs.length === 0) return + if (!composerCanSend({ owner: targetOwnerRef.current, streaming })) return sendText(text, images, docs) }, [input, images, docs, streaming, sendText]) @@ -1263,10 +1960,21 @@ export default function ChatPage() { const all = Array.from(files) const imgs = all.filter((f) => f.type.startsWith('image/')) const others = all.filter((f) => !f.type.startsWith('image/')) + const cursor = isCursorTarget(targetRef.current) + const limit = composerImageLimit(cursor ? 'cursor' : 'chat') if (imgs.length > 0) { - const read = await Promise.all(imgs.slice(0, 4).map(readDataURL)) - setImages((prev) => [...prev, ...read].slice(0, 4)) + const read = await Promise.all(imgs.slice(0, limit).map(readDataURL)) + setImages((prev) => [...prev, ...read].slice(0, limit)) + } + + // A Cursor cloud VM cannot read a path on this machine, so a document is + // refused at the point it is attached rather than uploaded and ignored. + if (cursor && others.length > 0) { + setError( + t('cursorAttach.documents', { names: others.map((file) => file.name).join(', ') }), + ) + return } for (const file of others.slice(0, 4)) { @@ -1282,7 +1990,7 @@ export default function ChatPage() { setError((e as Error).message) } } - }, []) + }, [t]) // Pasting a screenshot is the fastest way to show the agent something. const onPaste = (e: React.ClipboardEvent) => { @@ -1408,6 +2116,13 @@ export default function ChatPage() { lastSession.clear() setMessages([]) setTitle('') + setApprovals([]) + // A new Antares chat is a new Cursor conversation too: the next Cursor turn + // starts a fresh agent rather than following up on the previous one. + setCursorState(null) + setLastCursorRun(null) + detachedRef.current = false + setDetached(false) // Keep the remembered role for the new chat instead of resetting to default. setRole(localStorage.getItem('antares:last-role') ?? '') // A project binding belongs to one session; a new chat starts unbound. @@ -1439,15 +2154,38 @@ export default function ChatPage() { onSend={send} onStop={stop} streaming={streaming} + canSend={canSend} + pendingLabel={t('target.resolving')} placeholder={t('chat.placeholder')} sendLabel={t('chat.send')} stopLabel={t('chat.stop')} attachLabel={t('chat.attach')} roleSlot={
- - - + {/* A Cursor run has no Antares role and no generic reasoning + override — its own variant controls take that place. */} + {cursorMode ? null : } + {/* The running stream owns the target: changing it mid-turn would + leave Stop and the next send disagreeing about where it went. */} + + {cursorMode && cursorOptions ? ( + + ) : ( + + )} { @@ -1664,6 +2402,19 @@ export default function ChatPage() { />
) : null} + {cursorMode || cursorState ? ( +
+ +
+ ) : null} {composerCard(true)}
@@ -1730,6 +2481,9 @@ interface ComposerProps { onSend: () => void onStop: () => void streaming: boolean + /** False while this session's execution target is still being resolved. */ + canSend: boolean + pendingLabel: string placeholder: string sendLabel: string stopLabel: string @@ -1759,6 +2513,8 @@ const Composer = ({ onSend, onStop, streaming, + canSend, + pendingLabel, placeholder, sendLabel, stopLabel, @@ -1880,8 +2636,9 @@ const Composer = ({ + ) : null} + {/* A run the server still owns can be cancelled even when this browser + is neither streaming nor detached — after a reload, for example. */} + {canCancel && (streaming || detached || state?.running === true) ? ( + + ) : null} +
+ + ) +} + function ErrorBanner({ message, className }: { message: string; className?: string }) { return (
(ESSENTIALS) const [showAdvanced, setShowAdvanced] = useState(false) + const configuredProvider = String( + edits['model.provider'] ?? + (data ? readPath(data.values, 'model.provider') : '') ?? + '', + ) + const configuredModel = String( + edits['model.default'] ?? + (data ? readPath(data.values, 'model.default') : '') ?? + '', + ) + const reasoningTarget = useMemo( + () => + configuredProvider.trim() && configuredModel.trim() + ? { + provider: configuredProvider.trim(), + model: configuredModel.trim(), + } + : undefined, + [configuredModel, configuredProvider], + ) + const reasoningState = useReasoningCapability(reasoningTarget) + const query = filter.trim().toLowerCase() const searching = query.length > 0 const dirty = Object.keys(edits).length @@ -205,6 +233,7 @@ export default function ConfigPage() { field={f} showGroup={withGroup} value={valueOf(f)} + reasoningState={reasoningState} dirty={f.path in edits} revealed={!!revealed[f.path]} onReveal={() => setRevealed((r) => ({ ...r, [f.path]: !r[f.path] }))} @@ -411,6 +440,7 @@ function SectionRail({ function FieldRow({ field, value, + reasoningState, dirty, revealed, showGroup, @@ -419,6 +449,7 @@ function FieldRow({ }: { field: Field value: unknown + reasoningState: ReasoningCapabilityState dirty: boolean revealed: boolean showGroup?: boolean @@ -452,7 +483,13 @@ function FieldRow({
- {field.enum ? ( + {field.options_source === 'reasoning_capability' ? ( + + ) : field.enum ? ( onChange(event.target.value)} + className="h-9 w-full rounded-[var(--radius-sm)] border border-input bg-background px-3 text-sm" + > + {control.unsupported ? ( + + ) : null} + {control.options.map((option) => ( + + ))} + +

{hint}

+
+ ) +} + /** * A comma-separated list editor. Keeps the RAW text you type as its own state * so typing is never interrupted — the previous version reparsed to an array on diff --git a/web/src/pages/ModelsPage.tsx b/web/src/pages/ModelsPage.tsx index c3c1311..b5874b8 100644 --- a/web/src/pages/ModelsPage.tsx +++ b/web/src/pages/ModelsPage.tsx @@ -3,6 +3,7 @@ import { Eye, Lightning, MagnifyingGlass, Wrench } from '@phosphor-icons/react' import { post } from '@/lib/api' import { useApi } from '@/lib/hooks' import { useI18n } from '@/lib/i18n' +import type { ReasoningCapability } from '@/lib/models' import { cn } from '@/lib/utils' import { PageLayout } from '@/components/layout/PageLayout' import { Pagination } from '@/components/ui/Pagination' @@ -21,6 +22,7 @@ interface ModelInfo { vision: boolean tools: boolean reasoning: boolean + reasoning_capability?: ReasoningCapability } interface ProviderInfo { @@ -229,8 +231,19 @@ function AllModelsView({ ) : null} {m.reasoning ? ( - - {t('models.reasoning')} + value.label) + .join(', ') || t('reasoning.providerControlled') + : t('reasoning.providerControlled') + } + > + {' '} + {t('models.reasoning')} ) : null} diff --git a/web/src/pages/ProvidersPage.tsx b/web/src/pages/ProvidersPage.tsx index aa69b8b..1da3a89 100644 --- a/web/src/pages/ProvidersPage.tsx +++ b/web/src/pages/ProvidersPage.tsx @@ -7,14 +7,23 @@ import { Eye, EyeSlash, Key, + Lightning, Plugs, ShieldCheck, Trash, } from '@phosphor-icons/react' import { del, get, post } from '@/lib/api' +import { + cursorModelMatches, + cursorVariantDimensions, + cursorVariantSummary, + defaultCursorVariant, + type CursorModel, +} from '@/lib/cursorModels' import { agentModelsErrorText, isAgentProvider, providerModelsPath, type ProviderCapability } from '@/lib/providerCapabilities' import { useApi } from '@/lib/hooks' import { useI18n } from '@/lib/i18n' +import type { ReasoningCapability } from '@/lib/models' import { cn } from '@/lib/utils' import { PageLayout } from '@/components/layout/PageLayout' import { Button } from '@/components/ui/button' @@ -244,21 +253,60 @@ interface AllModel { provider: string provider_label: string context_window: number -} - -interface AgentModel { - id: string - name: string - description?: string - parameters?: unknown[] + reasoning: boolean + reasoning_capability?: ReasoningCapability } interface AgentModelsResponse { - models: AgentModel[] + models: CursorModel[] needs_key?: boolean error?: string } +/** + * One Cursor model as the catalogue describes it: its aliases, the parameter + * values real variants offer, and the variant a run starts from. Choosing a + * model for execution happens in the composer, not here. + */ +function AgentModelRow({ model }: { model: CursorModel }) { + const { t } = useI18n() + const dimensions = cursorVariantDimensions(model) + const variants = model.variants ?? [] + const preferred = defaultCursorVariant(model) + const summary = preferred ? cursorVariantSummary(model, preferred) : '' + + return ( +
+

{model.id}

+

{model.name}

+ {model.description ? ( +

{model.description}

+ ) : null} + {(model.aliases ?? []).length > 0 ? ( +

+ {t('providers.aliases', { list: (model.aliases ?? []).join(', ') })} +

+ ) : null} + {dimensions.map((dimension) => ( +

+ {dimension.label}:{' '} + {dimension.values.map((value) => value.label).join(', ')} +

+ ))} + {variants.length > 0 ? ( +

+ {t('providers.variantCount', { n: variants.length })} + {summary ? ` · ${t('providers.defaultVariant', { summary })}` : ''} +

+ ) : ( +

+ {t('target.cursorNoVariant')} +

+ )} +
+ ) +} + /** * Manage one provider in a modal: credentials, its models (add/remove with an * auto-fetched context window), and advanced settings. Each section saves to @@ -295,6 +343,14 @@ function ProviderModal({ const llmModelsState = useApi<{ models: AllModel[] }>(agentOnly ? null : '/model/list-all') const myModels = (llmModelsState.data?.models ?? []).filter((m) => m.provider === p.id) const agentModelsError = agentModelsErrorText(agentModelsState.data, agentModelsState.error) + const [agentQuery, setAgentQuery] = useState('') + const agentModels = useMemo( + () => + (agentModelsState.data?.models ?? []).filter((model) => + cursorModelMatches(model, agentQuery), + ), + [agentModelsState.data, agentQuery], + ) const [newModel, setNewModel] = useState('') const [newCtx, setNewCtx] = useState('') const [ctxAuto, setCtxAuto] = useState(false) @@ -338,7 +394,7 @@ function ProviderModal({ if (!q) return try { const r = await get<{ found: boolean; context_window?: number }>( - `/providers/${encodeURIComponent(p.id)}/model-info?id=${encodeURIComponent(q)}`, + `/providers/${encodeURIComponent(p.id)}/model-info?model=${encodeURIComponent(q)}`, ) if (r.found && r.context_window) { setNewCtx(String(r.context_window)) @@ -483,15 +539,24 @@ function ProviderModal({ ) : (agentModelsState.data?.models ?? []).length === 0 ? (

{t('models.none')}

) : ( -
- {(agentModelsState.data?.models ?? []).map((m) => ( -
-

{m.id}

-

{m.name}

- {m.description ?

{m.description}

: null} + <> + setAgentQuery(e.target.value)} + placeholder={t('providers.searchModels')} + aria-label={t('providers.searchModels')} + className="h-8 text-xs" + /> + {agentModels.length === 0 ? ( +

{t('models.none')}

+ ) : ( +
+ {agentModels.map((m) => ( + + ))}
- ))} -
+ )} + )} ) : ( @@ -538,6 +603,14 @@ function ProviderModal({ {t('models.ctx', { n: Math.round(m.context_window / 1000) })}

) : null} + {m.reasoning ? ( +

+ + {m.reasoning_capability + ? t('models.reasoning') + : t('reasoning.providerControlled')} +

+ ) : null}