diff --git a/devlog/_plan/260826_cursor_responses_gap/025_ultra_k3_research.md b/devlog/_plan/260826_cursor_responses_gap/025_ultra_k3_research.md index cd25d3e6e1..a9592a10b1 100644 --- a/devlog/_plan/260826_cursor_responses_gap/025_ultra_k3_research.md +++ b/devlog/_plan/260826_cursor_responses_gap/025_ultra_k3_research.md @@ -11,7 +11,7 @@ Claim ledger — status per cxc-search discipline: | Some models show 1M "Max Context" in Cursor table (Fable/Opus/Sonnet 5, Gemini) | verified | same (primary) | | Ultra = 20x usage ($400 API-agent allowance), NOT an exclusive catalog | verified | cursor.com/pricing + forum staff (primary) | | Max Mode currently documented for legacy request-based plans | verified | prod.cursor.com/help/ai-features/max-mode (primary) | -| K3 1M specifically unlocked on Ultra | UNVERIFIED — user observation; no primary source; Reddit says the 1M option appeared then disappeared | reddit 2026-08 (lead) | +| K3 1M specifically unlocked on Ultra | user-confirmed 2026-08-26 (operator saw the 1M option live in Cursor on the Ultra plan); public primary source still absent | user observation (authoritative for this deployment) + reddit lead | | Wire: max mode = RequestedModel.max_mode (field 2) AND ModelDetails.max_mode (field 7); missing either can invalid_argument | lead (2 impl sources) | oh-my-pi #4969, cursor-opencode-provider | | 1M exposure pattern: synthetic -1m picker variant w/ limit.context=1M, wire sends original id + maxMode | lead | cursor-opencode-provider README | | In-repo: GetUsableModels ModelDetails.maxMode=true observed on 28 -fast ids (260822); no contextTokenLimit field | verified (own probe) | devlog/_plan/260822_senpi_cursor_transfer/210_maxmode.md | diff --git a/devlog/_plan/260826_cursor_responses_gap/080_stall_corruption_diag.md b/devlog/_plan/260826_cursor_responses_gap/080_stall_corruption_diag.md index c6b63bceb7..688548e09f 100644 --- a/devlog/_plan/260826_cursor_responses_gap/080_stall_corruption_diag.md +++ b/devlog/_plan/260826_cursor_responses_gap/080_stall_corruption_diag.md @@ -22,6 +22,30 @@ subagent. Honest scope: diagnostics capture, not a behavior fix. ## Accept criteria +## Implementation notes (wpF, 2026-08-26) + +- Item 1 (diagnostics) was already satisfied by the baseline: the + run-request diagnostic logs continuationMode + + checkpointInvalidationReason + rootBlobs/rootBytes + (protobuf-request.ts:934-949). No change needed. +- Item 2 landed as a serve-time digest check in native-exec.ts + getBlobArgs: blobs are content-addressed (SHA-256 id), so a served + payload whose digest mismatches its raw 32-byte id is in-store + corruption — the splice signature. Emits + `blob-integrity-mismatch` debug diagnostic (key prefix + byte length + only; no payload). + +## G2 stall capture procedure (next occurrence) + +1. Reproduce with the SAME thread in the Codex app; note wall-clock time. +2. Mirror the request via curl (session log has the request id): + `curl -N http://localhost:10100/v1/responses -H 'Content-Type: application/json' --data-binary @req.json | tee stall.sse` +3. Enable debug diagnostics (OCX debug env) and capture the + run-request + checkpoint-continuation lines for the stalling turn. +4. Evidence to file here: last SSE event before silence, whether + response.completed arrived, continuationMode of the turn, and any + blob-integrity-mismatch lines. + - Diagnostic line appears for cursor turns under debug flag (test with debug seam). - Integrity check triggers on an injected mutated blob (unit test with diff --git a/devlog/_plan/260826_cursor_responses_gap/090_gap8_codex_exec_qa.md b/devlog/_plan/260826_cursor_responses_gap/090_gap8_codex_exec_qa.md new file mode 100644 index 0000000000..65b8e9dfef --- /dev/null +++ b/devlog/_plan/260826_cursor_responses_gap/090_gap8_codex_exec_qa.md @@ -0,0 +1,42 @@ +# 090 — gap-8 silent-redirect + codex exec adversarial QA (w2) + +Service: gap-8 stack, restarted per round (final pid after d9752bc0e). +Instrument: `codex exec -m cursor/grok-4.6` non-interactive, scratch cwds +under /tmp/ocx-qa-JStBBM. Transcripts: s1.log-s5b.log in that scratch. + +## Results + +| Scenario | Round | Result | Evidence | +|---|---|---|---| +| S1 ten tool calls | 1 | PASS | 10/10 separate bridge execs (pwd,ls,date,whoami,hostname,uname-s,ls,id,echo HOME,uname-m); narration grep hits=0 | +| S2 native-tool bait | 1 | INCONCLUSIVE | run produced no agent output (0-byte response; separate G2-class incident) | +| S2b native-tool bait retry | 2 | PASS* | zero 차단/전환 narration; both requests answered via bridge on first attempt. Residual: model duplicated its commentary line + repeated the 2-call batch twice (double-batch echo, no user-visible harm) | +| S3 mixed read/edit | 1 | PASS | notes.txt ALPHA->BETA lifecycle completed; narration=0 | +| S4 5-step chain | 1-2 | FAIL | stalled after step 1-2, files missing; "이전 호출은 출력이 비어 있어... 처음부터" restart loop observed | +| repair: extend empty-result normalization to codex CLI native names (shell/local_shell/container.exec) | commit d9752bc0e | — | root cause: gap-7 fix keyed on bridge tool NAMES; codex exec advertises the native `shell` tool, so empty results were unexplained again | +| S4d 5-step chain re-run | 3 | PASS* | all 5 steps done, data.csv+avg.txt=84 correct; restart-narration grep=0; model still self-recovered from two empty-looking intermediate results but WITHOUT surface-switch framing and completed | +| S5 image via /v1/responses | 1 | PASS | 32x32 red PNG -> "Red", in=12036 | +| S5b image via codex exec -i | 1 | PASS | blue.png -> "파랑", tokens 45489 | + +## Verdicts + +- Silent-redirect (gap-8 core): narration '차단/전환/막혀' = 0 across all + passing rounds; native-bait prompt answered bridge-first without + announcing a switch. Fix effective. +- Empty-result loop: root-caused twice (bridge names at gap-7, codex CLI + native names at gap-8 QA round 2); after d9752bc0e the 5-step chain + completes. Residual: intermediate tool results still occasionally + ARRIVE empty on the wire (model sees nothing and retries once) — that + delivery gap is the remaining G2-class defect, now non-fatal because + the retry succeeds without derailing. +- Image input: healthy at both API and codex exec layers. The reported + app-session failure (repeated "Viewed an image" then giving up) did + not reproduce here; needs an app-session capture with the actual + clipboard file — recorded as UNKNOWN with repro steps pending. + +## Open follow-ups + +1. Wire-level empty tool-result delivery (why some exec outputs arrive + blank upstream) — needs protobuf frame capture of an affected round. +2. App-session image loop repro. +3. S2b double-batch echo (duplicate commentary + repeated batch). diff --git a/devlog/_plan/260826_cursor_responses_gap/100_wire_ndjson_qa.md b/devlog/_plan/260826_cursor_responses_gap/100_wire_ndjson_qa.md new file mode 100644 index 0000000000..12f7648b23 --- /dev/null +++ b/devlog/_plan/260826_cursor_responses_gap/100_wire_ndjson_qa.md @@ -0,0 +1,45 @@ +# 100 — Wire/NDJSON QA (n1, 2026-08-26 오후) + +Service: gap-8 stack (ecc9aad0d). Captures: /tmp/ocx-wire/*.sse. +NDJSON: ~/.opencodex/usage.jsonl (31,466 lines). + +## Scenario table + +| # | Scenario | Result | Wire evidence | +|---|---|---|---| +| W1 | plain stream | PASS | created -> in_progress (gap-1 live) -> deltas -> completed; 29 lines | +| W2 | single tool stream | PASS | function_call args delta/done; single commentary line, no duplicate emission | +| W3 | parallel-10 stream | PASS | 11 output_item.added (1 msg + 10 calls, seq 2..47), each probe exactly once — the earlier "22" was event+data line double-count, NOT a wire duplicate. Double-batch echo NOT reproduced at the wire; 090 S2b echo attributed to model-side commentary repetition, not stream duplication | +| W4 | empty tool-result round trip | PASS* | model received empty output and answered "The tool returned this, verbatim: (blank)" — honest handling; 112 text deltas, no retry spiral at API layer | +| W5 | single image stream | PASS | "Green.", 38 lines | +| W6 | 3x same-color images | PASS* | "lime green, yellow, red" — model hallucinated variety on identical images | +| W6b | 3x distinct images (R,G,B) | PASS | "red, green, blue" correct order; 30.1s wall, in=13575 — slow but correct. App image loop NOT reproduced at API layer | +| W7 | kimi-k3-1m stream | PASS | full clean sequence incl. in_progress | +| W8 | apply_patch custom stream | PASS | custom_tool_call_input delta/done, valid envelope | + +## NDJSON (usage.jsonl) integrity — last 300 rows + +- 0 malformed lines; cursor rows 238. +- usageStatus: estimated 235, unreported 1 (the single 502 row — 10.8s + upstream failure, usage honestly unreported, no fake zeros). +- true zero-input rows: 1 (= the 502). Schema: nested usage{inputTokens...}, + tierOutcome, firstOutputMs, requestedEffort all populated. +- Verdict: NDJSON pipeline healthy; no corruption class found. + +## 090 follow-up dispositions + +1. Wire-level empty exec output: NOT reproduced in W4 (deliberate empty + round-trips handled honestly). Remaining suspicion narrows to the + in-session (checkpoint/replay-depth) path, not the API surface — + signature still open, now bounded to multi-round sessions. +2. Double-batch echo: wire ruled out (W3); model-side commentary + repetition under replay confusion — folds into G1 umbrella. +3. App image loop: API layer healthy (W5/W6b, codex exec -i PASS in 090 + S5b). Bounded to Codex-app-side attachment handling; needs app-session + capture — out of this repo's fix surface for now. + +## Verdict + +No new adapter-fixable defect surfaced in this round; gap-9 not needed. +All three follow-ups bounded with evidence; W6 same-color hallucination is +MODEL-class. Campaign continues to be green on the gap-8 stack. diff --git a/devlog/_plan/260826_cursor_responses_gap/110_app_route_qa.md b/devlog/_plan/260826_cursor_responses_gap/110_app_route_qa.md new file mode 100644 index 0000000000..1b7ebfa125 --- /dev/null +++ b/devlog/_plan/260826_cursor_responses_gap/110_app_route_qa.md @@ -0,0 +1,59 @@ +# 110 — App-route QA session + planning-time narration (m1) + +Instrument: self-created Codex app thread 01a03c74-3d91-74f3-b060-03f521e83c6c +(cursor/grok-4.6 high, projectless cursor-app-qa), created via +codex_app__create_thread from this session — the first app-route QA driven +entirely by the agent. + +## Result + +- Terminal: APP_QA_RESULT: PASS (5 read-only calls + qa.txt LINE1/LINE2 + lifecycle completed). +- Cost signatures: 13 commandExecutions for a ~8-call task; 21 + switch-mentions; 5 empty-output mentions; file writes done via shell + printf instead of apply_patch. + +## Mechanism finding (key) + +The 차단/전환 narration originates at PLANNING time: reasoning contains +"Shell 도구를 사용해 5개의 독립적인 명령을 한 번에 실행할 것이다" and +"Shell이 차단되어 브리지로 전환" BEFORE any denial payload arrives in that +round — the pattern is replayed-history-driven (G1 class), not a reaction +to our gap-8 denial text. Response-layer rewrites therefore reduce +narration in fresh sessions (codex exec rounds: 0 hits) but cannot zero it +in app sessions whose history already contains the pattern. + +User screenshot (13:5x) independently confirms: same-day app session still +narrates 차단/전환 and fills batches via bridge after a native probe. + +## Increment shipped (this commit) + +- Guidance note: "Tool-selection commentary is forbidden — FIRST visible + action is the bridge call itself; 차단/전환/blocked/switching must not + appear for tool-routing reasons." +- Guidance note: shell-redirection file writes forbidden while + apply_patch/structured-edit advertised (printf/echo >, heredoc, sed -i). +- Tests extended (cursor-tool-definitions 26 pass). + +## Honest bound + +Zeroing app-session narration requires the G1 replay-representation line: +tool-suspended checkpoints (gap-3) engaging in real app threads so history +stops replaying the old pattern. Until the stack lands and sessions turn +over, existing threads keep echoing it. Re-probe after service repair +recorded below. + +## Re-probe after service repair (7b3dbdd55) + +- rp1 (10-tool scenario, fresh codex exec): 10 separate bridge execs, + narration grep = 0. Tool-selection-commentary suppression holding in + fresh sessions. +- rp2 (file-edit scenario): content correct, but the model still wrote + result.md via `printf > ` — apply_patch was NOT used. Signature check: + 0 apply_patch mentions in the transcript, meaning the codex exec + session's advertised catalog exposes the bridge exec but the model + never considered the edit path despite the new note. Disposition: + guidance alone insufficient for write-routing in exec-style sessions; + candidate follow-up is server-side detection of redirection-writes with + a redirect-to-apply_patch tool error (deferred — aggressive, needs its + own cycle and risk review; recorded as open). diff --git a/devlog/_plan/260826_cursor_responses_gap/120_repetition_breaker.md b/devlog/_plan/260826_cursor_responses_gap/120_repetition_breaker.md new file mode 100644 index 0000000000..1f72a6675d --- /dev/null +++ b/devlog/_plan/260826_cursor_responses_gap/120_repetition_breaker.md @@ -0,0 +1,42 @@ +# 120 — Repetition breaker + final stack (r1, gap-9) + +## Defect + +User screenshot (kimi-k3 app session): byte-identical commentary "원격 +ocx 상태를 다시 확인합니다" + same ssh probe emitted 6+ consecutive +times. Same class as S2a's 180x tool-call loop and the 차단/전환 echo: +external full-replay presents N identical rounds as N identical lines, +priming line N+1. + +## Fix (gap-9, PR #2667) + +protobuf-request.ts external replay assembly: consecutive duplicate +assistant/tool-result entries collapse to one entry + +"[note: this exact output was produced N times in a row]"; runs >=3 add +one imperative context note ("Repeating it again is a failure. Take a +DIFFERENT action now..."). User messages reset runs; native models and +structured pairing untouched. 5 regression tests; 211-test suite green. + +## Live proof + +Probe: history primed with 5 identical assistant rounds -> model reply: +"이전에 같은 상태 확인만 반복했으니, 이번에는 코드와 원격 OCX 설정을 +직접 찾아서 최신 버전으로 올립니다." — loop broken on first response. +(/tmp/ocx-wire/rep-out.json; service pid 54225 on gap-9.) + +## Final stack (gap-1..gap-9) + +| PR | Branch | Fix | +|---|---|---| +| #2650 | cursor-gap-1 | call_id single-line codec + response.in_progress | +| #2651 | cursor-gap-2 | bare-caller default catalog suppression (token floor) | +| #2652 | cursor-gap-3 | tool-suspended checkpoint commit (external) | +| #2653 | cursor-gap-4 | dead-model catalog quarantine | +| #2654 | cursor-gap-5 | ultra toggle kimi-k3-1m + Max Mode wire flag | +| #2656 | cursor-gap-6 | blob integrity diagnostic + G2 capture procedure | +| #2662 | cursor-gap-7 | empty exec explanation + code-mode native ban | +| #2665 | cursor-gap-8 | silent-redirect denials + commentary/shell-write bans | +| #2667 | cursor-gap-9 | repetition breaker (this) | + +Merge order: #2650 first; each child retargets to dev after its parent +lands (enforce-target skips stacked children). diff --git a/src/adapters/cursor.ts b/src/adapters/cursor.ts index b0cfeca602..18a8e8aecf 100644 --- a/src/adapters/cursor.ts +++ b/src/adapters/cursor.ts @@ -146,15 +146,36 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda let completedNormally = false; let lastTransport: { captured?: Uint8Array } | undefined; let emittedClientTool = false; + // Ordering proof for tool-suspended checkpoints: true only when the newest captured + // checkpoint bytes arrived AFTER the turn emitted a client tool call, i.e. upstream + // serialized its suspended-on-tool-call state. Only that snapshot can safely resume + // with the covered-prefix + trailing-toolResult path (devlog 260826 050). + let capturedAfterClientTool = false; const commitCapturedCheckpoint = (activeRequest: ReturnType): void => { + const toolSuspendedCommit = + emittedClientTool + && capturedAfterClientTool + && isCursorExternalWireModel(activeRequest.modelId); if ( replayUnsafe - || emittedClientTool + || (emittedClientTool && !toolSuspendedCommit) || activeRequest.contextUsageStoreCheckpoints === false || !lastTransport?.captured || lastTransport.captured.byteLength === 0 - ) return; + ) { + // Refusal diagnostics (devlog 260826 050/080): name the exact guard so a live + // missing_ref chain can be attributed without instrumented rebuilds. + debugProviderDiagnostic("cursor", "checkpoint-commit-refused", { + replayUnsafe, + emittedClientTool, + capturedAfterClientTool, + externalModel: isCursorExternalWireModel(activeRequest.modelId), + storeCheckpoints: activeRequest.contextUsageStoreCheckpoints !== false, + capturedBytes: lastTransport?.captured?.byteLength ?? 0, + }); + return; + } const previousRef = _parsed._providerContinuation?.cursor?.checkpointRef; const coveredMessageCount = _parsed.context.messages.length; const checkpointRef = commitCursorCheckpoint({ @@ -173,7 +194,9 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda cursor: { ...(_parsed._providerContinuation?.cursor ?? {}), conversationId: activeRequest.conversationId, - checkpointUsable: true, + // A tool-suspended checkpoint is only usable by the immediate trailing-toolResult + // continuation; the request-builder guard keys on checkpointUsable=false for that. + checkpointUsable: !toolSuspendedCommit, checkpointRef, }, }; @@ -183,6 +206,7 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda checkpointRefHash: cursorCheckpointRefHash(checkpointRef), checkpointBytes: lastTransport.captured.byteLength, wireModel: activeRequest.modelId, + ...(toolSuspendedCommit ? { toolSuspended: true } : {}), }); }; @@ -208,7 +232,10 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda if (message.type === "done") completedNormally = true; if (message.type === "tool_call_end") emittedClientTool = true; const captured = capturedCursorCheckpointBytes(activeTransport); - if (captured) lastTransport = { captured }; + if (captured) { + if (captured !== lastTransport?.captured) capturedAfterClientTool = emittedClientTool; + lastTransport = { captured }; + } const events = mapCursorServerMessage(message, { kv, writeClient: clientMessage => { diff --git a/src/adapters/cursor/discovery.ts b/src/adapters/cursor/discovery.ts index dd77472723..15f529fdb0 100644 --- a/src/adapters/cursor/discovery.ts +++ b/src/adapters/cursor/discovery.ts @@ -154,6 +154,25 @@ export function cursorCodexToWireModelId(modelId: string): string { return cursorWireModelSelection(modelId).modelId; } +/** + * Synthetic ultra/big-context picker marker (devlog 260826 070). A `cursor/-1m` row is a + * picker-only variant: the wire request keeps `` (plus effort suffix) and turns on Cursor + * Max Mode instead. Only ids listed here are treated as synthetic — a real upstream wire id that + * happens to end in `-1m` never collides because it will not be in this set. + */ +export const CURSOR_ULTRA_1M_MODEL_IDS: ReadonlySet = new Set([ + "kimi-k3-1m", +]); + +const CURSOR_ULTRA_1M_SUFFIX = "-1m"; + +/** Resolve a synthetic ultra marker id to its wire base, or undefined for ordinary ids. */ +export function cursorUltraBaseModelId(modelId: string): string | undefined { + const normalized = modelId.startsWith("cursor/") ? modelId.slice("cursor/".length) : modelId; + if (!CURSOR_ULTRA_1M_MODEL_IDS.has(normalized)) return undefined; + return normalized.slice(0, -CURSOR_ULTRA_1M_SUFFIX.length); +} + /** * Cursor-native wire models keep server-side conversation state reliably. * External models (gpt/claude/gemini/grok families and similar) are more brittle on resumeAction. @@ -214,10 +233,27 @@ export function filterCursorConfiguredModelsByLiveDiscovery - isCursorRouterModelId(model.id) || isCursorModelAvailableForAccount(model.id, liveIds), + !CURSOR_KNOWN_UNCALLABLE_MODEL_IDS.has(model.id) + && ( + isCursorRouterModelId(model.id) + // Synthetic ultra rows ride their base model's account availability. + || isCursorModelAvailableForAccount(cursorUltraBaseModelId(model.id) ?? model.id, liveIds) + ), ); } +/** + * Models GetUsableModels advertises but whose every Run returns not_found (catalog honesty, + * devlog 260826_cursor_responses_gap 060). Live probes 2026-08-26: cursor/claude-opus-5 failed + * 100% ("Cursor Connect error not_found") while its -fast and -thinking siblings — separate + * wire families — succeed. Quarantined here, in the shared filter, so live, cached, stale, and + * static serving paths all agree. Custom user provider overrides are not routed through this + * canonical seed and stay untouched. + */ +export const CURSOR_KNOWN_UNCALLABLE_MODEL_IDS: ReadonlySet = new Set([ + "claude-opus-5", +]); + export const CURSOR_STATIC_MODELS: readonly CursorModelInfo[] = normalizeCursorModels([ // Context windows and the model lineup mirror Cursor's public models/pricing docs plus the jawcode // SOT (../jawcode/packages/ai/src/models.json, `cursor` provider), which mirrors the real @@ -245,7 +281,8 @@ export const CURSOR_STATIC_MODELS: readonly CursorModelInfo[] = normalizeCursorM { id: "claude-opus-4-7-fast", contextWindow: CONTEXT_200K, supportsReasoningEffort: true }, { id: "claude-opus-4-8-fast", contextWindow: CONTEXT_200K, supportsReasoningEffort: true }, { id: "claude-opus-4-8", contextWindow: CONTEXT_200K, supportsReasoningEffort: true }, - { id: "claude-opus-5", contextWindow: CONTEXT_200K, supportsReasoningEffort: true }, + // claude-opus-5 (bare) removed from the seed: GetUsableModels lists it but every Run returns + // not_found (quarantined via CURSOR_KNOWN_UNCALLABLE_MODEL_IDS; -fast/-thinking families stay). { id: "claude-opus-5-fast", contextWindow: CONTEXT_200K, supportsReasoningEffort: true }, { id: "claude-fable-5", contextWindow: CONTEXT_200K, supportsReasoningEffort: true }, @@ -308,6 +345,10 @@ export const CURSOR_STATIC_MODELS: readonly CursorModelInfo[] = normalizeCursorM // kimi-k3: cursor.com/docs/models/kimi-k3; account-verified via GetUsableModels (2026-07-28) — // ships only as effort-suffixed kimi-k3-{low,high,max}, so the tier picker is exposed. { id: "kimi-k3", contextWindow: CONTEXT_262K, supportsReasoningEffort: true }, + // kimi-k3-1m: synthetic ultra/Max-Mode picker variant (CURSOR_ULTRA_1M_MODEL_IDS) — wire sends + // kimi-k3- with maxMode=true; 1M context user-verified live on the Ultra plan + // (devlog 260826_cursor_responses_gap/025). inferCursorContextWindow maps "1m" ids to 1M. + { id: "kimi-k3-1m", contextWindow: CONTEXT_1M, supportsReasoningEffort: true }, { id: "grok-4.5", contextWindow: 500_000, supportsReasoningEffort: true }, { id: "grok-4.5-fast", contextWindow: 500_000, supportsReasoningEffort: true }, diff --git a/src/adapters/cursor/effort-map.ts b/src/adapters/cursor/effort-map.ts index 6bc0270f74..29525b4190 100644 --- a/src/adapters/cursor/effort-map.ts +++ b/src/adapters/cursor/effort-map.ts @@ -58,6 +58,9 @@ const CURSOR_MODEL_EFFORT_TIERS: Record = { // GetUsableModels (2026-07-28) lists kimi-k3 only as effort-suffixed kimi-k3-{low,high,max}; // the bare id returns not_found. Tiers mirror the native Kimi provider's K3 ladder. "kimi-k3": ["low", "high", "max"], + // Synthetic ultra picker variant (devlog 260826 070): same tier ladder as kimi-k3; the -1m + // marker is stripped before wire-id composition, so these tiers never form a wire suffix. + "kimi-k3-1m": ["low", "high", "max"], // Cursor renamed the Grok 4.5 slugs to cursor-grok-4.5-{low,medium,high} and // cursor-grok-4.5-{low,medium,high}-fast. The bare Fast id returns not_found. "grok-4.5": ["low", "medium", "high"], diff --git a/src/adapters/cursor/live-models.ts b/src/adapters/cursor/live-models.ts index 32bafe1517..4e4c07a236 100644 --- a/src/adapters/cursor/live-models.ts +++ b/src/adapters/cursor/live-models.ts @@ -43,7 +43,7 @@ export interface CursorUsableModelsOptions { } export type CursorUsableModelsResult = - | { ok: true; models: string[] } + | { ok: true; models: string[]; maxModeModels?: string[] } | { ok: false; error: "auth" | "http" | "policy" | "transport" | "timeout" | "decode" | "empty" | "too_large"; detail?: string }; /** Test-only seam for management connectivity probes; production callers retain the HTTP/2 path. */ @@ -120,6 +120,7 @@ function decodeCursorUsableModels(bytes: Uint8Array): CursorUsableModelsResult { // make stale configured ids such as `composer-2` look activated. const ids: string[] = []; const seenIds = new Set(); + const maxModeIds: string[] = []; for (const model of response.models ?? []) { const rawId = (model as { modelId?: string }).modelId; if (typeof rawId !== "string") continue; @@ -127,9 +128,13 @@ function decodeCursorUsableModels(bytes: Uint8Array): CursorUsableModelsResult { if (!isValidModelDiscoveryModelId(id) || seenIds.has(id)) continue; seenIds.add(id); ids.push(id); + // Preserve Max-Mode capability for ultra/big-context auto-detection (devlog 260826 070). + if ((model as { maxMode?: boolean }).maxMode === true) maxModeIds.push(id); if (ids.length >= CURSOR_MAX_DISCOVERED_MODELS) break; } - return ids.length > 0 ? { ok: true, models: ids } : { ok: false, error: "empty" }; + return ids.length > 0 + ? { ok: true, models: ids, ...(maxModeIds.length > 0 ? { maxModeModels: maxModeIds } : {}) } + : { ok: false, error: "empty" }; } catch { return { ok: false, error: "decode", detail: "Invalid GetUsableModels protobuf response" }; } diff --git a/src/adapters/cursor/live-transport.ts b/src/adapters/cursor/live-transport.ts index ad48ec6713..716547c801 100644 --- a/src/adapters/cursor/live-transport.ts +++ b/src/adapters/cursor/live-transport.ts @@ -415,6 +415,20 @@ export function finalizeAfterDrain(state: ReturnType 1) { + const advertised = request.tools?.length ?? 0; + return Math.max( + baseGraceMs, + Math.min( + GENERIC_TOOL_COUNT_MAX_FINALIZE_GRACE_MS, + Math.max(GENERIC_TOOL_COUNT_MIN_FINALIZE_GRACE_MS, advertised * GENERIC_TOOL_COUNT_PER_TOOL_GRACE_MS), + ), + ); + } if (!cursorRequestHasShellAlias(request.tools) || !isGenericToolUseCountDemoPrompt(text)) return baseGraceMs; const requestedCount = requestedCursorToolUseCount(text); const expandedGraceMs = requestedCount diff --git a/src/adapters/cursor/native-exec-fs.ts b/src/adapters/cursor/native-exec-fs.ts index 18735c3c28..265bfc7b92 100644 --- a/src/adapters/cursor/native-exec-fs.ts +++ b/src/adapters/cursor/native-exec-fs.ts @@ -43,11 +43,11 @@ function codexNativeMutationRefusal(operation: "write" | "delete", structuredEdi const structuredHint = structuredEditAvailable ? " Use the structured edit tools (`edit_file` / `multi_edit`) or the `apply_patch` tool for file edits so Codex can approve the change, enforce sandbox policy, show diffs, and record rollout." : " Use the `apply_patch` tool for file edits so Codex can approve the change, enforce sandbox policy, show diffs, and record rollout."; - return `Cursor-native ${operation} is disabled for this Codex request because apply_patch is available.${structuredHint} No file was changed.`; + return `Make this ${operation} through the Codex edit path instead.${structuredHint} No file was changed. Do NOT narrate this redirect or comment on tool availability — just make the edit call.`; } const NATIVE_LOCAL_EXEC_DISABLED = - "Cursor-native filesystem tools are not executed locally. Use a catalog tool for this work: `shell_command` / `exec_command` (or the listed `mcp_opencodex-responses_*` display alias) with host-shell-safe equivalents: POSIX (`cat`, `head`, `ls`, `rg`, `grep`) or Windows PowerShell (`Get-Content`, `Get-ChildItem`, `Select-String`); use `apply_patch` for file edits."; + "Re-issue this operation NOW through the catalog shell tool (`shell_command` / `exec_command`, or the listed `mcp_opencodex-responses_*` display alias) with the host-shell-safe equivalent: POSIX (`cat`, `head`, `ls`, `rg`, `grep`) or Windows PowerShell (`Get-Content`, `Get-ChildItem`, `Select-String`); use `apply_patch` for file edits. Do NOT narrate this redirect, do NOT comment on tool availability, and do NOT re-announce the task — just make the bridge call."; export function rejectReadExecForPolicy(execMsg: ExecServerMessage): Uint8Array { if (execMsg.message.case !== "readArgs") throw new Error("invalid read exec"); diff --git a/src/adapters/cursor/native-exec-network.ts b/src/adapters/cursor/native-exec-network.ts index 39483987ec..f259f284ef 100644 --- a/src/adapters/cursor/native-exec-network.ts +++ b/src/adapters/cursor/native-exec-network.ts @@ -7,7 +7,7 @@ export interface CursorNativeNetworkDeps { } const NATIVE_FETCH_DISABLED = - "Cursor-native fetch is not executed locally. Use the Codex shell bridge tool `shell_command` (aliases: `exec_command`, `mcp_opencodex-responses_shell_command`, `mcp_opencodex-responses_exec_command`) with curl or wget."; + "Re-issue this fetch NOW through the catalog shell tool `shell_command` (aliases: `exec_command`, `mcp_opencodex-responses_shell_command`, `mcp_opencodex-responses_exec_command`) with curl or wget. Do NOT narrate this redirect or comment on tool availability — just make the bridge call."; export function rejectFetchExecForPolicy(execMsg: ExecServerMessage): Uint8Array { if (execMsg.message.case !== "fetchArgs") throw new Error("invalid fetch exec"); diff --git a/src/adapters/cursor/native-exec-shell.ts b/src/adapters/cursor/native-exec-shell.ts index a708117cb6..6fb7688302 100644 --- a/src/adapters/cursor/native-exec-shell.ts +++ b/src/adapters/cursor/native-exec-shell.ts @@ -87,11 +87,14 @@ export function nativeShellDisabledMessage(): string { // idioms through the Codex bridge on Windows PowerShell 5.1 and looping (#604). // Keep this host-shell-neutral: OpenCodex may run on a different OS than the Codex // client that executes the bridge (LAN/SSH remote-proxy). + // Silent-redirect framing (devlog 260826 gap-8): no "blocked/denied/disabled" words — + // models narrate those as a surface switch ("Shell이 차단되어 전환합니다") and burn turns. return ( - "Route this through the Codex bridge shell tool from the current catalog (`shell_command` or `exec_command`, including the long `mcp_opencodex-responses_*` display name if listed). " + "Re-issue this command NOW through the catalog shell tool (`shell_command` or `exec_command`; the long `mcp_opencodex-responses_*` display name is the same tool). " + "Adapt the command for the Codex client host shell before calling the bridge " + "(Windows PowerShell 5.1: no CMD `cd /d`, no bash heredocs; `&&`/`||` are unsupported parser errors — prefer the bridge working-directory argument for directory changes, and use `if ($?) { ... }` for success-gated follow-up steps; do not treat `;` as a substitute for `&&`). " - + "Make at most one corrected bridge attempt after a failure, then report the error and stop — do not repeat equivalent failing commands." + + "Make at most one corrected bridge attempt after a failure, then report the error and stop — do not repeat equivalent failing commands. " + + "Do NOT narrate this redirect, do NOT comment on tool availability, and do NOT re-announce the task — just make the bridge call." ); } diff --git a/src/adapters/cursor/native-exec.ts b/src/adapters/cursor/native-exec.ts index aee9ddac38..921928c9aa 100644 --- a/src/adapters/cursor/native-exec.ts +++ b/src/adapters/cursor/native-exec.ts @@ -403,6 +403,19 @@ export function storeCursorBlob(data: Uint8Array, requestScope?: CursorBlobReque return blobId; } +/** + * Serve-time integrity for content-addressed blobs (devlog 260826_cursor_responses_gap 080): + * a raw 32-byte blob id IS the SHA-256 of its bytes, so served data whose digest mismatches + * the id means in-store corruption — the splice signature behind garbled replayed tool + * results. Ids longer than 32 bytes (digested-key namespace) and server-minted ids are not + * content-addressed and always pass. + */ +export function cursorBlobServeIntegrityOk(blobId: Uint8Array, served: Uint8Array): boolean { + if (blobId.byteLength !== 32) return true; + const digest = createHash("sha256").update(served).digest(); + return digest.equals(Buffer.from(blobId)); +} + /** * Long-lived pin for blobs referenced by an active Cursor conversation checkpoint. * Unlike a request scope, this lease is not sealed and is not released by getBlob hydration. @@ -622,6 +635,13 @@ export function handleCursorNativeKv( if (kvMsg.message.case === "getBlobArgs") { const blobKey = key(kvMsg.message.value.blobId); const blobData = getBlob(blobKey); + // Splice-class corruption guard (devlog 260826 080): diagnostic only, never blocks serving. + if (blobData && !cursorBlobServeIntegrityOk(kvMsg.message.value.blobId, blobData)) { + debugProviderDiagnostic("cursor", "blob-integrity-mismatch", { + blobKey: blobKey.slice(0, 18), + servedBytes: blobData.byteLength, + }); + } if (blobData && requestScope && blobRequestScopes.get(requestScope)?.kind === "request") { releaseHydratedBlob(blobKey, requestScope); } diff --git a/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index 76e1cc12da..28eb363821 100644 --- a/src/adapters/cursor/protobuf-request.ts +++ b/src/adapters/cursor/protobuf-request.ts @@ -218,6 +218,42 @@ function rootPromptMessages(request: CursorRunRequest, requestScope: CursorBlobR const echoToolResultInRoot = cursorNeedsExternalToolContinuation(request.modelId); const lastRawIsToolResult = messages.at(-1)?.role === "toolResult"; const activeUserIndex = lastRawIsToolResult ? -1 : lastActionIndex(messages); + // Repetition breaker (devlog 260826 gap-9): external full-replay flattens history to text, + // so N identical assistant/tool-result rounds replay as N identical lines and PRIME the model + // to emit the same line again (self-reinforcing loop: S2a 180x, identical-probe repetition). + // Collapse consecutive duplicates into one entry + a count marker, and count collapses so a + // strategy-change note can be appended when the pattern is severe. + let lastReplayText: string | undefined; + let lastReplayEntry: RootBlobCandidate | undefined; + let collapsedRepeats = 0; + let maxRunLength = 1; + let currentRun = 1; + const pushDeduped = ( + payload: { role: string; content: [{ type: "text"; text: string }] }, + role: RootBlobCandidate["role"], + opts: { messageIndex: number; text?: string }, + normalized: string, + ): void => { + if (externalModel && lastReplayText !== undefined && normalized === lastReplayText && lastReplayEntry) { + collapsedRepeats++; + currentRun++; + if (currentRun > maxRunLength) maxRunLength = currentRun; + const marked = `${normalized}\n[note: this exact output was produced ${currentRun} times in a row]`; + const replacement = rootBlobCandidate( + { role: payload.role, content: [{ type: "text", text: marked }] }, + role, + opts, + ); + entries[entries.indexOf(lastReplayEntry)] = replacement; + lastReplayEntry = replacement; + return; + } + currentRun = 1; + const entry = rootBlobCandidate(payload, role, opts); + entries.push(entry); + lastReplayText = normalized; + lastReplayEntry = entry; + }; for (let i = 0; i < messages.length; i++) { if (i === activeUserIndex) break; @@ -229,6 +265,9 @@ function rootPromptMessages(request: CursorRunRequest, requestScope: CursorBlobR // A bare string survives blob hydration but external workers reject the completed replay // before tokenization (`usedTokens: 0`, then invalid_argument). if (text.length > 0) { + lastReplayText = undefined; + lastReplayEntry = undefined; + currentRun = 1; entries.push(rootBlobCandidate({ role: "user", content: [{ type: "text", text }], @@ -239,11 +278,12 @@ function rootPromptMessages(request: CursorRunRequest, requestScope: CursorBlobR // Native Composer state can preserve it through ThinkingMessage/history structures. const text = assistantRootText(message, !externalModel).trim(); if (text.length > 0) { - entries.push(rootBlobCandidate( + pushDeduped( { role: "assistant", content: [{ type: "text", text }] }, "assistant", { messageIndex: i }, - )); + text, + ); } // Assistant tool CALLS are intentionally NOT replayed as visible "[Tool Call]" text here. } else if (message.role === "toolResult") { @@ -255,13 +295,16 @@ function rootPromptMessages(request: CursorRunRequest, requestScope: CursorBlobR // node_repl result is an error even when the runtime said isError=false). const prefix = normalizedToolResult(message, contentToText(message.content)).isError ? "[Tool Error]" : "[Tool Result]"; const text = `${prefix}\n${toolResultToText(message)}`; - entries.push(rootBlobCandidate( - toolResultRootPayload(text), - "toolResult", - { messageIndex: i, text }, - )); + pushDeduped(toolResultRootPayload(text), "toolResult", { messageIndex: i, text }, text); } } + // Severe repetition: tell the model ONCE, imperatively, to change strategy. + if (externalModel && maxRunLength >= 3) { + entries.push(rootBlobCandidate({ + role: "user", + content: [{ type: "text", text: `[context note] The transcript above contains the same output repeated ${maxRunLength} times in a row. Repeating it again is a failure. Take a DIFFERENT action now, or state plainly what is blocking progress.` }], + }, "user", {})); + } let selected = entries; let historyMessageStart = 0; @@ -967,12 +1010,15 @@ function buildPreparedCursorRunRequest( displayName: request.modelId, displayNameShort: request.modelId, aliases: [], + ...(request.maxMode === true ? { maxMode: true } : {}), }), } : {}), - ...(requestedModelParameters.length > 0 ? { + ...(requestedModelParameters.length > 0 || request.maxMode === true ? { requestedModel: create(RequestedModelSchema, { modelId: request.modelId, - maxMode: false, + // Max Mode must be raised on BOTH RequestedModel and ModelDetails; missing either + // can invalid_argument upstream (devlog 260826 070). + maxMode: request.maxMode === true, parameters: requestedModelParameters.map(parameter => create(RequestedModel_ModelParameterbytesSchema, parameter)), }), @@ -990,7 +1036,11 @@ function buildPreparedCursorRunRequest( // the event-state `clientToolNames` use (live-transport.ts). Advertising the raw `request.tools` // here would let mcp_tools expose a tool that the event state does not recognize for a generic // tool-count prompt, so a call to it would be rejected as an unknown Responses tool. - ...(mcpToolDefs.length > 0 ? { mcpTools: create(McpToolsSchema, { mcpTools: mcpToolDefs }) } : {}), + // An explicitly empty McpTools wrapper (bare API callers) suppresses Cursor's default + // native catalog; an absent field lets identified Codex sessions keep it (devlog 260826 040). + ...(mcpToolDefs.length > 0 || request.suppressDefaultCursorToolCatalog === true + ? { mcpTools: create(McpToolsSchema, { mcpTools: mcpToolDefs }) } + : {}), }); const message = create(AgentClientMessageSchema, { diff --git a/src/adapters/cursor/request-builder.ts b/src/adapters/cursor/request-builder.ts index d9785f14c1..e99791134a 100644 --- a/src/adapters/cursor/request-builder.ts +++ b/src/adapters/cursor/request-builder.ts @@ -10,6 +10,7 @@ import type { import { isAllowedToolChoice, namespacedToolName, toolChoiceAliases, type OcxTool, type OcxToolChoice } from "../../types"; import type { CursorRequestMessage, CursorRequestedModelParameter, CursorRunRequest } from "./types"; import { cursorCheckpointModelAffinityId, cursorWireModelSelection, type CursorRoutingLevel } from "./discovery"; +import { cursorUltraBaseModelId } from "./discovery"; import { decodeCursorCallId } from "./call-id"; import { cursorEffortSuffix, cursorRequestWireModelIdWithEffort } from "./effort-map"; import { @@ -189,13 +190,19 @@ function normalizeCursorModelId(modelId: string, reasoning?: string): { modelId: string; requestedModelParameters?: readonly CursorRequestedModelParameter[]; routingLevel?: CursorRoutingLevel; + maxMode?: boolean; } { - const selection = cursorWireModelSelection(modelId); + // Synthetic ultra (-1m) picker rows resolve to their wire base with Max Mode on + // (devlog 260826 070); the marker never reaches the wire. + const ultraBase = cursorUltraBaseModelId(modelId); + const selection = cursorWireModelSelection(ultraBase ?? modelId); + const maxMode = ultraBase !== undefined ? { maxMode: true } : {}; const id = selection.modelId; const suffix = cursorEffortSuffix(id, reasoning); if ((id === "grok-4.5-fast" || id === "grok-4.6-fast") && suffix) { return { ...selection, + ...maxMode, modelId: id.slice(0, -"-fast".length), requestedModelParameters: [ { id: "effort", value: suffix }, @@ -203,7 +210,7 @@ function normalizeCursorModelId(modelId: string, reasoning?: string): { ], }; } - return { ...selection, modelId: suffix ? cursorRequestWireModelIdWithEffort(id, suffix) : id }; + return { ...selection, ...maxMode, modelId: suffix ? cursorRequestWireModelIdWithEffort(id, suffix) : id }; } function contentPartToText(part: OcxContentPart | OcxAssistantContentPart): string | undefined { @@ -446,6 +453,7 @@ export function createCursorRequest( modelId: model.modelId, ...(model.requestedModelParameters ? { requestedModelParameters: model.requestedModelParameters } : {}), ...(model.routingLevel ? { routingLevel: model.routingLevel } : {}), + ...(model.maxMode ? { maxMode: true } : {}), conversationId: resolveCursorConversationId(parsed, model.modelId, options), system: [...(parsed.context.systemPrompt ?? []), ...(limitNote ? [limitNote] : [])], messages, @@ -453,6 +461,11 @@ export function createCursorRequest( ...(parsed._compactionRequest === true || parsed._contextCompactionBoundary === true ? { contextUsageReset: true } : {}), ...(parsed._compactionRequest === true ? { contextUsageStoreCheckpoints: false } : {}), ...(budget.tools.length ? { tools: budget.tools } : {}), + // Bare API caller (no tools, no Codex thread identity): suppress Cursor's default + // native tool catalog instead of paying its ~10-15K token preamble (devlog 260826 040). + ...(budget.tools.length === 0 && !cursorClientThreadOwner(parsed) + ? { suppressDefaultCursorToolCatalog: true } + : {}), ...(parsed.options.toolChoice ? { toolChoice: parsed.options.toolChoice } : {}), ...(parsed.options.parallelToolCalls !== undefined ? { parallelToolCalls: parsed.options.parallelToolCalls } : {}), }; diff --git a/src/adapters/cursor/tool-definitions.ts b/src/adapters/cursor/tool-definitions.ts index d8fe23e4fe..b0f2f26cf6 100644 --- a/src/adapters/cursor/tool-definitions.ts +++ b/src/adapters/cursor/tool-definitions.ts @@ -656,6 +656,9 @@ export function buildCursorToolGuidanceSystemNote( codeMode ? "In code mode the isolate returns nothing on its own: call `text(...)` (or `notify(...)`) on any value you need to see, or the call completes with empty output. There is no `require`, no `module`, and no filesystem or network globals; reach the host only through the nested helpers." : undefined, + codeMode + ? "NEVER attempt Cursor-native Shell, Read, Grep, List, or any tool absent from the catalog — they are not executed in this environment and every probe wastes a turn. The exec code cell (with its nested helpers) is the ONLY execution surface; go to it directly on the FIRST attempt and do not narrate switching surfaces." + : undefined, hasBareExec ? `${shellBridgeLabel} is the Codex Responses shell bridge for this turn, exposed through Cursor's tool protocol; it is not an external MCP server tool. \`shell_command\` and \`exec_command\` are aliases of the same bridge.` : undefined, @@ -663,7 +666,10 @@ export function buildCursorToolGuidanceSystemNote( ? "Your tool list may display it under a longer `mcp_opencodex-responses_shell_command` / `mcp_opencodex-responses_exec_command` name; those are the SAME tool — call whichever your list shows, and do not comment on the naming difference to the user." : undefined, hasBareExec - ? `Prefer the Codex shell bridge over Cursor-native Shell/Read. If a Cursor-native file read, directory listing, grep, or shell operation is rejected, continue with the listed catalog tool ${shellBridgeLabel}.` + ? `NEVER attempt Cursor-native Shell, Read, Grep, List, or any tool not in the catalog above — they are not executed locally in this environment and every attempt wastes a turn and can stall the session. ${shellBridgeLabel} is the ONLY shell surface; go to it directly on the FIRST attempt, never as a fallback after probing a native tool. Do not narrate switching surfaces ("native is blocked, using the bridge instead") — there is exactly one surface.` + : undefined, + hasBareExec + ? "Tool-selection commentary is forbidden: for any shell, read, grep, list, or file operation, your FIRST visible action is the bridge call itself — never a sentence about which tool you will use, which tool was redirected, or switching surfaces. Words like 차단/전환/blocked/switching must not appear in your output for tool-routing reasons." : undefined, hostShellNote, "Cursor product features (Chronicle, screen recording, Notes, Plans, background agents) are available only if this turn's catalog lists a matching tool; do not offer or promise them otherwise.", @@ -675,6 +681,9 @@ export function buildCursorToolGuidanceSystemNote( ? `For file edits, prefer the structured edit tools ${quotedNames(structuredEditNames)} — they take replacements that OpenCodex converts into Codex \`apply_patch\` changes. Include exact leading whitespace in old_string/new_string. Use \`apply_patch\` directly only with a \`*** Begin Patch\` envelope and bare \`@@\` hunks (never git-style \`@@ -n,m +n,m @@\`); never emit patch-like plain text as tool arguments.` : "For file edits, use the `apply_patch` tool, not built-in file write/delete tools." : undefined, + hasApplyPatch + ? "Creating or modifying file CONTENT via shell redirection (`>`, `>>`, `printf`/`echo` into a file, `cat < 0 ? `the structured edit tools (${quotedNames(structuredEditNames)}) or ` : ""}\`apply_patch\` when available.` + ? `For every file read, directory listing, grep, or shell operation use ${shellBridgeLabel} directly with host-shell-safe commands (POSIX: \`cat\`/\`ls\`/\`rg\`; Windows PowerShell: \`Get-Content\`/\`Get-ChildItem\`/\`Select-String\`). For file edits, use ${structuredEditNames.length > 0 ? `the structured edit tools (${quotedNames(structuredEditNames)}) or ` : ""}\`apply_patch\` when available.` : undefined, ].filter((note): note is string => typeof note === "string"); return notes.join(" "); diff --git a/src/adapters/cursor/tool-result-normalize.ts b/src/adapters/cursor/tool-result-normalize.ts index b87ef29854..c89e247e9a 100644 --- a/src/adapters/cursor/tool-result-normalize.ts +++ b/src/adapters/cursor/tool-result-normalize.ts @@ -29,6 +29,31 @@ function isNodeReplOrComputerUseTool(toolName?: string, toolNamespace?: string): return lower.startsWith("mcp__node_repl") || lower.startsWith("mcp__computer_use"); } +/** + * Codex exec / shell-bridge tool names (flat and MCP-prefixed display aliases). An empty result + * here is almost always a code-mode cell that never called text()/notify() — the cursor model + * reads the blank [tool_result], concludes prior results were lost, and spirals into + * re-orientation retries (devlog 260826_cursor_responses_gap, live subagent transcripts). + */ +function isCodexExecBridgeTool(toolName?: string, toolNamespace?: string): boolean { + if (toolNamespace && toolNamespace.includes("opencodex-responses")) return true; + if (!toolName) return false; + const lower = toolName.toLowerCase(); + return ( + lower === "exec" + || lower === "exec_command" + || lower === "shell_command" + // Codex CLI/desktop native tool names: the multi-round "이전 출력이 비어 있어 처음부터" + // restart loop reproduced via codex exec because `shell` was not in this set + // (devlog 260826 gap-8 QA round 2). + || lower === "shell" + || lower === "local_shell" + || lower === "container.exec" + || lower.startsWith("mcp_opencodex-responses_") + || lower.startsWith("mcp__opencodex-responses__") + ); +} + /** Failure states the Computer Use / node_repl runtime reports as PLAIN TEXT inside a non-error result. */ const RUNTIME_FAILURE_GUIDANCE: ReadonlyArray<{ marker: string; guidance: string }> = [ { @@ -80,6 +105,13 @@ export function normalizeCursorToolResultText( changed: true, }; } + if (isCodexExecBridgeTool(options.toolName, options.toolNamespace) && EMPTY_EXEC_OUTPUT_REGEX.test(text.trim())) { + return { + text: "[empty output: the exec cell completed but emitted nothing. This is NOT lost context and NOT a blocked tool — in code mode call text(...) or notify(...) on any value you need to see (a bare await tools.exec_command(...) is not echoed automatically); in shell mode the command simply printed nothing. Do not re-run the same call expecting different output.]", + isError: false, + changed: true, + }; + } if (!isError) { for (const { marker, guidance } of RUNTIME_FAILURE_GUIDANCE) { if (text.includes(marker)) { @@ -89,4 +121,3 @@ export function normalizeCursorToolResultText( } return { text, isError, changed: false }; } - diff --git a/src/adapters/cursor/types.ts b/src/adapters/cursor/types.ts index cbc9a9f3f6..1c642e9b78 100644 --- a/src/adapters/cursor/types.ts +++ b/src/adapters/cursor/types.ts @@ -15,6 +15,19 @@ export interface CursorRunRequest { requestedModelParameters?: readonly CursorRequestedModelParameter[]; /** Cursor Router optimization parameter; valid only while modelId is the `default` wire model. */ routingLevel?: CursorRoutingLevel; + /** + * Cursor Max Mode (ultra/big-context). Set from a synthetic `-1m` picker variant; the wire + * keeps the original model id and raises RequestedModel.maxMode + ModelDetails.maxMode + * (both fields — missing either can invalid_argument upstream). Devlog 260826 070. + */ + maxMode?: boolean; + /** + * Bare API callers (no caller tools, no Codex thread identity) pay a ~10-15K input-token + * preamble because an absent AgentRunRequest.mcp_tools field makes Cursor inject its default + * native tool catalog. When true, an explicitly empty McpTools wrapper is serialized instead, + * suppressing that default. Codex-identified sessions keep the absent-field behavior. + */ + suppressDefaultCursorToolCatalog?: boolean; conversationId: string; system: string[]; messages: CursorRequestMessage[]; diff --git a/tests/cursor-blob-integrity.test.ts b/tests/cursor-blob-integrity.test.ts new file mode 100644 index 0000000000..b97d2e537f --- /dev/null +++ b/tests/cursor-blob-integrity.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { cursorBlobServeIntegrityOk, storeCursorBlob } from "../src/adapters/cursor/native-exec"; + +describe("cursor blob serve-time integrity (devlog 260826 080)", () => { + test("content-addressed blob passes when bytes match the id", () => { + const data = new TextEncoder().encode('{"role":"user","content":"clean"}'); + const id = storeCursorBlob(data); + expect(id.byteLength).toBe(32); + expect(cursorBlobServeIntegrityOk(id, data)).toBe(true); + }); + + test("mutated bytes are detected (splice fault injection)", () => { + const data = new TextEncoder().encode('{"role":"assistant","content":"[tool_result] output"}'); + const id = new Uint8Array(createHash("sha256").update(data).digest()); + const corrupted = new TextEncoder().encode('{"role":"assistant","content":"[ martool_result] output"}'); + expect(cursorBlobServeIntegrityOk(id, corrupted)).toBe(false); + }); + + test("non-content-addressed ids (not 32 bytes) always pass", () => { + const served = new TextEncoder().encode("anything"); + expect(cursorBlobServeIntegrityOk(new Uint8Array(8), served)).toBe(true); + expect(cursorBlobServeIntegrityOk(new Uint8Array(64), served)).toBe(true); + }); +}); diff --git a/tests/cursor-blob.test.ts b/tests/cursor-blob.test.ts index cf37282c80..01ff52d0e5 100644 --- a/tests/cursor-blob.test.ts +++ b/tests/cursor-blob.test.ts @@ -1073,6 +1073,18 @@ describe("Cursor AgentRunRequest.mcp_tools channel", () => { expect(mcpToolNames(bytes)).toBeUndefined(); }); + test("suppression flag serializes an explicitly empty mcp_tools wrapper", () => { + const bytes = encodeCursorRunRequest({ + modelId: "gpt-5.6-luna-high", + conversationId: "c1", + system: ["You are helpful."], + messages: [{ role: "user", content: "hi" }], + tools: [], + suppressDefaultCursorToolCatalog: true, + }); + expect(mcpToolNames(bytes)).toEqual([]); + }); + test("leaves mcp_tools unset when toolChoice is none", () => { const bytes = encodeCursorRunRequest({ modelId: "gpt-5.6-luna-high", diff --git a/tests/cursor-default-catalog-suppression.test.ts b/tests/cursor-default-catalog-suppression.test.ts new file mode 100644 index 0000000000..2d6871076d --- /dev/null +++ b/tests/cursor-default-catalog-suppression.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from "bun:test"; +import { createCursorRequest } from "../src/adapters/cursor/request-builder"; +import type { OcxParsedRequest } from "../src/types/request"; + +function parsedRequest(overrides: Partial = {}): OcxParsedRequest { + return { + modelId: "cursor/grok-4.6", + context: { + systemPrompt: [], + messages: [{ role: "user", content: "hi" }], + tools: undefined, + }, + options: {}, + ...overrides, + } as OcxParsedRequest; +} + +const CALLER_TOOL = { + name: "get_weather", + description: "d", + parameters: { type: "object", properties: {} }, +} as const; + +describe("cursor default-catalog suppression (preamble floor)", () => { + test("bare request with no tools and no thread identity sets the flag", () => { + const request = createCursorRequest(parsedRequest()); + expect(request.suppressDefaultCursorToolCatalog).toBe(true); + }); + + test("_clientThreadId identity keeps the default catalog (flag unset)", () => { + const request = createCursorRequest(parsedRequest({ _clientThreadId: "thread-1" } as Partial)); + expect(request.suppressDefaultCursorToolCatalog).toBeUndefined(); + }); + + test("_cursorClientThreadId identity keeps the default catalog (flag unset)", () => { + const request = createCursorRequest(parsedRequest({ _cursorClientThreadId: "app:x" } as Partial)); + expect(request.suppressDefaultCursorToolCatalog).toBeUndefined(); + }); + + test("caller-supplied tools never set the flag", () => { + const request = createCursorRequest(parsedRequest({ + context: { systemPrompt: [], messages: [{ role: "user", content: "hi" }], tools: [CALLER_TOOL] }, + } as Partial)); + expect(request.suppressDefaultCursorToolCatalog).toBeUndefined(); + expect(request.tools?.length).toBe(1); + }); +}); diff --git a/tests/cursor-exec-empty-result.test.ts b/tests/cursor-exec-empty-result.test.ts new file mode 100644 index 0000000000..096aa01f9c --- /dev/null +++ b/tests/cursor-exec-empty-result.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "bun:test"; +import { normalizeCursorToolResultText } from "../src/adapters/cursor/tool-result-normalize"; + +describe("codex exec bridge empty-result normalization (devlog 260826 gap-7)", () => { + test("empty exec cell output becomes explanatory text, not an error", () => { + const out = normalizeCursorToolResultText("Script completed\nWall time 0.1 seconds\nOutput:\n", { toolName: "exec" }); + expect(out.changed).toBe(true); + expect(out.isError).toBe(false); + expect(out.text).toContain("NOT lost context"); + expect(out.text).toContain("text(...)"); + }); + + test("mcp display alias names route the same way", () => { + const out = normalizeCursorToolResultText("", { toolName: "mcp_opencodex-responses_exec" }); + expect(out.changed).toBe(true); + expect(out.text).toContain("empty output"); + }); + + test("shell_command empty output routes too", () => { + const out = normalizeCursorToolResultText("", { toolName: "shell_command" }); + expect(out.changed).toBe(true); + }); + + test("codex CLI native shell names route too (multi-round restart loop, QA round 2)", () => { + for (const name of ["shell", "local_shell", "container.exec"]) { + const out = normalizeCursorToolResultText("", { toolName: name }); + expect(out.changed).toBe(true); + expect(out.isError).toBe(false); + } + }); + + test("non-empty exec output passes through byte-identical", () => { + const out = normalizeCursorToolResultText("Output:\nhello", { toolName: "exec" }); + expect(out.changed).toBe(false); + expect(out.text).toBe("Output:\nhello"); + }); + + test("computer-use empties keep the original error semantics", () => { + const out = normalizeCursorToolResultText("", { toolName: "screenshot" }); + expect(out.isError).toBe(true); + expect(out.text).toContain("get_app_state"); + }); + + test("unrelated tools with empty output stay untouched", () => { + const out = normalizeCursorToolResultText("", { toolName: "get_weather" }); + expect(out.changed).toBe(false); + }); +}); diff --git a/tests/cursor-repetition-breaker.test.ts b/tests/cursor-repetition-breaker.test.ts new file mode 100644 index 0000000000..63708f7a32 --- /dev/null +++ b/tests/cursor-repetition-breaker.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from "bun:test"; +import { fromBinary } from "@bufbuild/protobuf"; +import { encodeCursorRunRequest } from "../src/adapters/cursor/protobuf-request"; +import { handleCursorNativeKv } from "../src/adapters/cursor/native-exec"; +import { create } from "@bufbuild/protobuf"; +import { + AgentClientMessageSchema, + GetBlobArgsSchema, + KvServerMessageSchema, +} from "../src/adapters/cursor/gen/agent_pb"; +import type { OcxMessage } from "../src/types"; + +function blobData(blobId: Uint8Array): Uint8Array { + const reply = fromBinary(AgentClientMessageSchema, handleCursorNativeKv(create(KvServerMessageSchema, { + id: 1, + message: { case: "getBlobArgs", value: create(GetBlobArgsSchema, { blobId }) }, + }))); + if (reply.message.case !== "kvClientMessage" || reply.message.value.message.case !== "getBlobResult") { + throw new Error("expected getBlobResult"); + } + return reply.message.value.message.value.blobData!; +} + +function rootTexts(bytes: Uint8Array): string[] { + const msg = fromBinary(AgentClientMessageSchema, bytes); + const run = msg.message.case === "runRequest" ? msg.message.value : undefined; + return (run?.conversationState?.rootPromptMessagesJson ?? []).map(blobId => { + const parsed = JSON.parse(new TextDecoder().decode(blobData(blobId))) as { content?: [{ text?: string }] }; + return parsed.content?.[0]?.text ?? ""; + }); +} + +const REPEAT = "원격 ocx 상태를 다시 확인합니다."; + +function repeatedHistory(times: number): OcxMessage[] { + const messages: OcxMessage[] = [{ role: "user", content: "원격 ocx를 최신 버전으로 업데이트해봐", timestamp: 1 }]; + for (let i = 0; i < times; i++) { + messages.push({ role: "assistant", content: REPEAT, timestamp: 2 + i } as OcxMessage); + } + messages.push({ role: "user", content: "계속", timestamp: 100 }); + return messages; +} + +function encode(messages: OcxMessage[], modelId = "grok-4.6-high") { + return encodeCursorRunRequest({ + modelId, + conversationId: "c_rep", + system: [], + messages: [], + rawMessages: messages, + }); +} + +describe("cursor external-replay repetition breaker (devlog 260826 gap-9)", () => { + test("consecutive identical assistant entries collapse into one marked entry", () => { + const texts = rootTexts(encode(repeatedHistory(5))); + const repeats = texts.filter(text => text.startsWith(REPEAT)); + expect(repeats).toHaveLength(1); + expect(repeats[0]).toContain("5 times in a row"); + }); + + test("severe repetition appends exactly one strategy-change note", () => { + const texts = rootTexts(encode(repeatedHistory(4))); + const notes = texts.filter(text => text.includes("Take a DIFFERENT action now")); + expect(notes).toHaveLength(1); + }); + + test("two repeats collapse but do not trigger the note", () => { + const texts = rootTexts(encode(repeatedHistory(2))); + expect(texts.filter(text => text.includes("2 times in a row"))).toHaveLength(1); + expect(texts.filter(text => text.includes("Take a DIFFERENT action now"))).toHaveLength(0); + }); + + test("distinct assistant entries stay untouched", () => { + const messages: OcxMessage[] = [ + { role: "user", content: "hi", timestamp: 1 }, + { role: "assistant", content: "step one done", timestamp: 2 }, + { role: "assistant", content: "step two done", timestamp: 3 }, + { role: "user", content: "continue", timestamp: 4 }, + ] as OcxMessage[]; + const texts = rootTexts(encode(messages)); + expect(texts).toContain("step one done"); + expect(texts).toContain("step two done"); + expect(texts.some(text => text.includes("times in a row"))).toBe(false); + }); + + test("duplicates separated by a user message do not collapse", () => { + const messages: OcxMessage[] = [ + { role: "user", content: "go", timestamp: 1 }, + { role: "assistant", content: REPEAT, timestamp: 2 }, + { role: "user", content: "again", timestamp: 3 }, + { role: "assistant", content: REPEAT, timestamp: 4 }, + { role: "user", content: "final", timestamp: 5 }, + ] as OcxMessage[]; + const texts = rootTexts(encode(messages)); + expect(texts.filter(text => text === REPEAT)).toHaveLength(2); + }); +}); diff --git a/tests/cursor-silent-redirect.test.ts b/tests/cursor-silent-redirect.test.ts new file mode 100644 index 0000000000..8f63daf4b3 --- /dev/null +++ b/tests/cursor-silent-redirect.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "bun:test"; +import { nativeShellDisabledMessage } from "../src/adapters/cursor/native-exec-shell"; + +/** + * Devlog 260826 gap-8: native-tool denial payloads must read as silent redirects. + * Denial framing ("blocked", "disabled", "not executed", "denied") makes cursor + * models narrate a surface switch and re-announce the task, burning turns. + */ +const FORBIDDEN = [/blocked/i, /\bdisabled\b/i, /not executed/i, /\bdenied\b/i, /cannot execute/i, /차단/]; + +describe("cursor native-denial silent-redirect framing (gap-8)", () => { + test("shell denial has no denial framing and forbids narration", () => { + const msg = nativeShellDisabledMessage(); + for (const pattern of FORBIDDEN) expect(msg).not.toMatch(pattern); + expect(msg).toContain("Do NOT narrate"); + expect(msg).toContain("Re-issue this command NOW"); + }); + + test("fs denial constant has no denial framing", async () => { + const src = await Bun.file("src/adapters/cursor/native-exec-fs.ts").text(); + const constant = src.match(/NATIVE_LOCAL_EXEC_DISABLED =\s*"([^"]+)"/)?.[1] ?? ""; + expect(constant.length).toBeGreaterThan(0); + for (const pattern of FORBIDDEN) expect(constant).not.toMatch(pattern); + expect(constant).toContain("Do NOT narrate"); + }); + + test("network denial constant has no denial framing", async () => { + const src = await Bun.file("src/adapters/cursor/native-exec-network.ts").text(); + for (const pattern of FORBIDDEN) expect(src.split("\n").slice(0, 15).join("\n")).not.toMatch(pattern); + }); +}); diff --git a/tests/cursor-tool-definitions.test.ts b/tests/cursor-tool-definitions.test.ts index 54467e41ce..c895091ca1 100644 --- a/tests/cursor-tool-definitions.test.ts +++ b/tests/cursor-tool-definitions.test.ts @@ -334,8 +334,11 @@ describe("Cursor tool definitions", () => { expect(note).toContain("current tool catalog as ground truth"); expect(note).toContain("This turn does not expose neighboring-agent tool names `Read`, `Grep`, `Glob`, `Bash`, `LS`"); expect(note).toContain("not an external MCP server tool"); - expect(note).toContain("Prefer the Codex shell bridge over Cursor-native Shell/Read"); - expect(note).toContain("continue with the listed catalog tool `exec_command`"); + expect(note).toContain("NEVER attempt Cursor-native Shell, Read, Grep, List"); + expect(note).toContain("`exec_command` is the ONLY shell surface"); + expect(note).toContain("never as a fallback after probing a native tool"); + expect(note).toContain("Tool-selection commentary is forbidden"); + expect(note).toContain("FIRST visible action is the bridge call itself"); expect(note).not.toContain("such as `shell_command` / `exec_command`"); expect(note).not.toContain("Never tell the user"); expect(note).not.toContain("silently call"); @@ -353,8 +356,8 @@ describe("Cursor tool definitions", () => { expect(note).toContain("`shell_command`"); expect(note).toContain("`shell_command` and `exec_command` are aliases of the same bridge"); expect(note).toContain("mcp_opencodex-responses_shell_command"); - expect(note).toContain("Prefer the Codex shell bridge over Cursor-native Shell/Read"); - expect(note).toContain("continue with the listed catalog tool `shell_command`"); + expect(note).toContain("NEVER attempt Cursor-native Shell, Read, Grep, List"); + expect(note).toContain("`shell_command` is the ONLY shell surface"); expect(note).not.toContain("Never tell the user"); expect(note).not.toContain("silently call"); }); diff --git a/tests/cursor-tool-suspended-checkpoint.test.ts b/tests/cursor-tool-suspended-checkpoint.test.ts new file mode 100644 index 0000000000..6461d20a48 --- /dev/null +++ b/tests/cursor-tool-suspended-checkpoint.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, test } from "bun:test"; +import { createCursorAdapter as createCursorAdapterProduction } from "../src/adapters/cursor"; +import { clearCursorCheckpointsForTests, getCursorCheckpoint } from "../src/adapters/cursor/checkpoint-store"; +import { create, toBinary } from "@bufbuild/protobuf"; +import { ConversationStateStructureSchema } from "../src/adapters/cursor/gen/agent_pb"; +import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../src/types"; +import type { CursorServerMessage } from "../src/adapters/cursor/types"; +import { withTestTranslatorBudget } from "./helpers/translator-budget"; + +const createCursorAdapter = (...args: Parameters) => + withTestTranslatorBudget(createCursorAdapterProduction(...args)); + +const provider: OcxProviderConfig = { adapter: "cursor", baseUrl: "https://api2.cursor.sh" }; + +const checkpointBytes = toBinary(ConversationStateStructureSchema, create(ConversationStateStructureSchema, { + pendingToolCalls: ["suspended-fixture"], +})); + +/** Transport that emits a client tool call, exposing checkpoint bytes only after it. */ +function toolSuspendedTransport() { + let capturable: Uint8Array | undefined; + return { + async *run() { + yield { type: "tool_call_start", id: "call_x", name: "get_weather" } satisfies CursorServerMessage; + yield { type: "tool_call_delta", arguments: "{}" } satisfies CursorServerMessage; + capturable = checkpointBytes; + yield { type: "tool_call_end" } satisfies CursorServerMessage; + yield { type: "done", usage: { inputTokens: 1, outputTokens: 1 } } satisfies CursorServerMessage; + }, + writeClient() {}, + capturedConversationCheckpoint() { + return capturable; + }, + }; +} + +function body(modelId: string): OcxParsedRequest { + return { + modelId, + context: { messages: [{ role: "user", content: "hi", timestamp: 1 }] }, + stream: false, + options: {}, + _cursorConversationId: "cursor_tool_suspend", + _cursorIdentityScope: "acct-suspend", + } as OcxParsedRequest; +} + +describe("tool-suspended checkpoint commit (devlog 260826 050)", () => { + test("external model commits a tool-suspended checkpoint with checkpointUsable=false", async () => { + clearCursorCheckpointsForTests(); + const adapter = createCursorAdapter({ ...provider, apiKey: "cursor-token" }, { createTransport: toolSuspendedTransport }); + const events: AdapterEvent[] = []; + await adapter.runTurn?.(body("cursor/grok-4.6"), { headers: new Headers() }, event => events.push(event)); + const done = events.find(event => event.type === "done"); + if (done?.type !== "done") throw new Error("expected done"); + expect(done.providerState?.cursor?.checkpointRef).toBeDefined(); + expect(done.providerState?.cursor?.checkpointUsable).toBe(false); + expect(getCursorCheckpoint(done.providerState?.cursor?.checkpointRef)).toBeDefined(); + clearCursorCheckpointsForTests(); + }); + + test("native composer model still refuses the tool-suspended commit", async () => { + clearCursorCheckpointsForTests(); + const adapter = createCursorAdapter({ ...provider, apiKey: "cursor-token" }, { createTransport: toolSuspendedTransport }); + const events: AdapterEvent[] = []; + await adapter.runTurn?.(body("cursor/composer-2.5"), { headers: new Headers() }, event => events.push(event)); + const done = events.find(event => event.type === "done"); + if (done?.type !== "done") throw new Error("expected done"); + expect(done.providerState?.cursor?.checkpointRef).toBeUndefined(); + clearCursorCheckpointsForTests(); + }); + + test("checkpoint captured before the tool call is still refused (ordering guard)", async () => { + clearCursorCheckpointsForTests(); + const transport = { + async *run() { + yield { type: "tool_call_start", id: "call_y", name: "get_weather" } satisfies CursorServerMessage; + yield { type: "tool_call_delta", arguments: "{}" } satisfies CursorServerMessage; + yield { type: "tool_call_end" } satisfies CursorServerMessage; + yield { type: "done", usage: { inputTokens: 1, outputTokens: 1 } } satisfies CursorServerMessage; + }, + writeClient() {}, + capturedConversationCheckpoint() { + // Bytes available from the very first poll — pre-tool capture. + return checkpointBytes; + }, + }; + const adapter = createCursorAdapter({ ...provider, apiKey: "cursor-token" }, { createTransport: () => transport }); + const events: AdapterEvent[] = []; + await adapter.runTurn?.(body("cursor/grok-4.6"), { headers: new Headers() }, event => events.push(event)); + const done = events.find(event => event.type === "done"); + if (done?.type !== "done") throw new Error("expected done"); + expect(done.providerState?.cursor?.checkpointRef).toBeUndefined(); + clearCursorCheckpointsForTests(); + }); +}); diff --git a/tests/cursor-ultra-mode.test.ts b/tests/cursor-ultra-mode.test.ts new file mode 100644 index 0000000000..a6cbf5bb3a --- /dev/null +++ b/tests/cursor-ultra-mode.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, test } from "bun:test"; +import { + CURSOR_STATIC_MODELS, + CURSOR_ULTRA_1M_MODEL_IDS, + cursorUltraBaseModelId, + filterCursorConfiguredModelsByLiveDiscovery, +} from "../src/adapters/cursor/discovery"; +import { createCursorRequest } from "../src/adapters/cursor/request-builder"; +import { encodeCursorRunRequest } from "../src/adapters/cursor/protobuf-request"; +import { fromBinary } from "@bufbuild/protobuf"; +import { AgentClientMessageSchema, type AgentRunRequest } from "../src/adapters/cursor/gen/agent_pb"; +import type { OcxParsedRequest } from "../src/types/request"; + +function decodeRunRequest(bytes: Uint8Array): AgentRunRequest { + const msg = fromBinary(AgentClientMessageSchema, bytes); + if (msg.message.case !== "runRequest") throw new Error("expected runRequest"); + return msg.message.value; +} + +function parsedFor(modelId: string, reasoning?: string): OcxParsedRequest { + return { + modelId, + context: { systemPrompt: [], messages: [{ role: "user", content: "hi" }] }, + options: reasoning ? { reasoning } : {}, + } as OcxParsedRequest; +} + +describe("cursor ultra (-1m / Max Mode) toggle (devlog 260826 070)", () => { + test("static catalog exposes the kimi-k3-1m picker row with 1M context", () => { + const row = CURSOR_STATIC_MODELS.find(model => model.id === "kimi-k3-1m"); + expect(row).toBeDefined(); + expect(row?.contextWindow).toBe(1_000_000); + expect(row?.supportsReasoningEffort).toBe(true); + }); + + test("ultra marker resolves to its wire base and never leaks", () => { + expect(cursorUltraBaseModelId("cursor/kimi-k3-1m")).toBe("kimi-k3"); + expect(cursorUltraBaseModelId("kimi-k3-1m")).toBe("kimi-k3"); + expect(cursorUltraBaseModelId("kimi-k3")).toBeUndefined(); + expect(cursorUltraBaseModelId("claude-4-sonnet-1m")).toBeUndefined(); + }); + + test("kimi-k3-1m + max resolves to wire kimi-k3-max with maxMode on the request", () => { + const request = createCursorRequest(parsedFor("cursor/kimi-k3-1m", "max")); + expect(request.modelId).toBe("kimi-k3-max"); + expect(request.maxMode).toBe(true); + }); + + test("plain kimi-k3 stays maxMode-off", () => { + const request = createCursorRequest(parsedFor("cursor/kimi-k3", "max")); + expect(request.modelId).toBe("kimi-k3-max"); + expect(request.maxMode).toBeUndefined(); + }); + + test("wire raises maxMode on BOTH RequestedModel and ModelDetails", () => { + const bytes = encodeCursorRunRequest({ + modelId: "kimi-k3-max", + maxMode: true, + conversationId: "c1", + system: [], + messages: [{ role: "user", content: "hi" }], + }); + const decoded = decodeRunRequest(bytes); + expect(decoded.requestedModel?.maxMode).toBe(true); + expect(decoded.requestedModel?.modelId).toBe("kimi-k3-max"); + expect(decoded.modelDetails?.maxMode).toBe(true); + }); + + test("non-ultra requests keep maxMode=false wire behavior", () => { + const bytes = encodeCursorRunRequest({ + modelId: "kimi-k3-max", + conversationId: "c1", + system: [], + messages: [{ role: "user", content: "hi" }], + }); + const decoded = decodeRunRequest(bytes); + expect(decoded.requestedModel).toBeUndefined(); + // ModelDetails.maxMode is proto-optional; absent (undefined) means off. + expect(decoded.modelDetails?.maxMode ?? false).toBe(false); + }); + + test("account filter admits the synthetic row through its base availability", () => { + const configured = [{ id: "kimi-k3-1m" }, { id: "kimi-k3" }]; + const live = ["kimi-k3-high", "kimi-k3-max"]; + const filtered = filterCursorConfiguredModelsByLiveDiscovery(configured, live); + expect(filtered.map(model => model.id)).toEqual(["kimi-k3-1m", "kimi-k3"]); + }); + + test("ultra id set stays narrow and every entry has a static row", () => { + for (const id of CURSOR_ULTRA_1M_MODEL_IDS) { + expect(CURSOR_STATIC_MODELS.some(model => model.id === id)).toBe(true); + } + }); +}); diff --git a/tests/cursor-uncallable-quarantine.test.ts b/tests/cursor-uncallable-quarantine.test.ts new file mode 100644 index 0000000000..99bc3957a0 --- /dev/null +++ b/tests/cursor-uncallable-quarantine.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "bun:test"; +import { + CURSOR_KNOWN_UNCALLABLE_MODEL_IDS, + CURSOR_STATIC_MODELS, + filterCursorConfiguredModelsByLiveDiscovery, +} from "../src/adapters/cursor/discovery"; + +describe("cursor uncallable-model quarantine (devlog 260826 060)", () => { + test("static seed no longer carries bare claude-opus-5", () => { + expect(CURSOR_STATIC_MODELS.some(model => model.id === "claude-opus-5")).toBe(false); + }); + + test("siblings from other wire families survive", () => { + expect(CURSOR_STATIC_MODELS.some(model => model.id === "claude-opus-5-fast")).toBe(true); + }); + + test("live filter drops quarantined ids even when GetUsableModels lists them", () => { + const configured = [{ id: "claude-opus-5" }, { id: "claude-opus-5-fast" }, { id: "grok-4.6" }]; + const live = ["claude-opus-5-high", "claude-opus-5-high-fast", "grok-4.6-high"]; + const filtered = filterCursorConfiguredModelsByLiveDiscovery(configured, live); + expect(filtered.map(model => model.id)).toEqual(["claude-opus-5-fast", "grok-4.6"]); + }); + + test("quarantine applies with an empty live list too (stale/static degradation path)", () => { + const configured = [{ id: "claude-opus-5" }, { id: "auto" }]; + const filtered = filterCursorConfiguredModelsByLiveDiscovery(configured, []); + expect(filtered.some(model => model.id === "claude-opus-5")).toBe(false); + }); + + test("quarantine set stays narrow", () => { + expect([...CURSOR_KNOWN_UNCALLABLE_MODEL_IDS]).toEqual(["claude-opus-5"]); + }); +});