diff --git a/devlog/_plan/260904_priority65_closeout/000_research.md b/devlog/_plan/260904_priority65_closeout/000_research.md new file mode 100644 index 0000000000..17813e3fe0 --- /dev/null +++ b/devlog/_plan/260904_priority65_closeout/000_research.md @@ -0,0 +1,80 @@ +# 260904 우선순위 65+ 종결 — 리서치 + +base `origin/dev` = `2421e44ce` (package 2.43.0), 작업 브랜치 `codex/priority65-closeout`, +워크트리 `/Users/jun/.codex/worktrees/f96c/opencodex`. + +## 선정 근거 + +메인테이너가 이슈/PR 코멘트에 남긴 `## 리뷰 · 우선순위 NN / 80` 점수를 GraphQL로 +전수 수집해 65점 이상만 추렸다. 이슈 8건, PR 13건이 나왔고, 5개 read-only 조사 +레인이 각 항목을 현재 dev 코드에 대조해 판정했다. + +수집 명령(재현 가능): + +``` +gh api graphql --paginate -f query='...issues(states: OPEN)...comments(first: 40)...' \ + --jq '... capture("우선순위 (?[0-9]+) / 80").s | tonumber ...' | sort -rn +``` + +## 판정 결과 + +| ID | 점수 | 판정 | 근거 | +|----|------|------|------| +| #3375-D | 72 | CLEAR_FIX | 원장 완비, 프로덕션 호출자 0개 | +| #3259 | 72 | 경계만 CLEAR | 스키마 loose 폴백이 타입 계약을 깸 | +| #3464 | 71 | CLEAR_BUT_LARGE | `buildPlist`에 launcher 분기 없음 | +| PR #3251 | 71 | 게이트만 | 코드 정확, 스크린샷 누락 | +| PR #3461 | 70 | CLEAR_FIX | 좁은 매처, 기존 단정 보존 | +| PR #3348 | 71 | BLOCKED | 410/413 hop blocker 잔존 | +| PR #3329 | 71 | NEEDS_DESIGN | 쿨다운 우선순위 무단 역전 | +| PR #3389 | 68 | NEEDS_DESIGN | zero-output 전제가 실험으로 반증 | +| PR #3327 | 68 | 게이트만 | 테스트 전용, 스크린샷 게이트 오탐 | +| PR #3332 | 67 | carry 필요 | OUTPUT을 INPUT 필드에 매핑 | +| PR #3061 | 72 | REJECT | #3351이 상위 구현을 이미 랜딩 | +| #3245 | 66 | BLOCKED | 실패 지점이 첫 POST 이전 | +| #3425 | 70 | NEEDS_DESIGN | 이전 REJECT 유효, 새 용의자도 반증됨 | + +## 이번 유닛이 다루는 것 + +판정이 CLEAR이거나, 게이트 하나만 남았거나, CRUD/UX 규칙으로 결정 가능한 것만 +가져간다. 설계 결정이 선행하는 항목(#3329, #3389, #3348, #3425, #3376, #3377, +#3447 Antigravity 절반)은 이 유닛의 범위 밖이고, 각각 근거를 이슈/PR에 남긴다. + +## work-phase 맵 + +골플랜 `.codexclaw/goalplans/65-stacked-pr-close-co-authored-by-exact-head-ci/goalplan.json`의 +work-phase와 이 유닛의 문서가 아래처럼 대응한다. + +| wp | 대상 | 문서 | +|----|------|------| +| wp1 | Phase-0 docs-only 로드맵 | 이 유닛 전체 (이 사이클의 산출물) | +| wp2 | #3259 responses 경계 | `010_wp2_responses_boundary.md` | +| wp3 | #3332 carry | `020_wp3_combo_metadata_carry.md` | +| wp4 | #3461 스쿼시 머지 | `030_wp4_combo_context_cap.md` | +| wp5 | #3375-D reset-credit identity | `040_wp5_reset_credit_identity.md` | +| wp6 | #3251 / #3327 게이트 해제 | `050_wp6_gate_unblock.md` | +| wp7 | #3379 롤백 저널 삭제 CRUD | `060_wp7_rollback_journal_crud.md` | +| wp8 | 처분 기록 + 최종 회귀 증명 | `070_wp8_dispositions_and_regression.md` | + +wp1은 자기 자신이 산출물이므로 decade 문서를 따로 갖지 않는다. 나머지 일곱 개는 +각각 하나의 decade 문서를 소비하는 한 번의 PABCD 사이클이다. + +## 구속 조건 + +- 로컬 전체 스위트 금지. `bun run test`와 인자 없는 `bun test` 모두 이 유닛에서 + 실행하지 않는다. focused `bun test tests/.test.ts`와 라이브 GitHub CI가 + 검증자다. +- push는 `--no-verify`. +- 타 기여자 작업을 carry/재구현/스쿼시할 때 `Co-authored-by` 트레일러가 스쿼시 + 뒤에도 남아야 한다. 산문 크레딧은 등가물이 아니다 (AGENTS.md + `missing_coauthor_credit`). +- `dev`는 이 세션 중에도 움직일 수 있다. 머지 직전마다 `git fetch origin dev`로 + head를 다시 읽고 ancestry를 재확인한다. + +## 검증자 사전 확인 (PLAN-VERIFIER-REAL-01) + +| 커맨드 | exit | 변경 대상을 읽는가 | +|--------|------|--------------------| +| `bun run typecheck` | 0 | 예 — `tsconfig.json`이 `src/`, `tests/` 전체를 include | +| `bun test tests/.test.ts` | 파일별 | 예 — 대상 파일을 직접 인자로 받음 | +| `gh pr checks ` | 상태별 | 예 — PR head SHA의 체크 롤업 | diff --git a/devlog/_plan/260904_priority65_closeout/010_wp2_responses_boundary.md b/devlog/_plan/260904_priority65_closeout/010_wp2_responses_boundary.md new file mode 100644 index 0000000000..cf833917c4 --- /dev/null +++ b/devlog/_plan/260904_priority65_closeout/010_wp2_responses_boundary.md @@ -0,0 +1,641 @@ +# wp2 — 이슈 #3259: responses tool result 경계 하드닝 + +대상 이슈: [#3259](https://github.com/lidge-jun/opencodex/issues/3259) +작업 브랜치: `codex/priority65-closeout` (base `origin/dev` = `2421e44ce`, package 2.43.0) +cwd: `/Users/jun/.codex/worktrees/f96c/opencodex` + +한 줄 요약: `inputItemSchema` 의 loose 폴백이 call_id 없는 tool 아이템을 삼켜 +`toolCallId: undefined` 가 어댑터까지 내려간다. 결함은 **번역 경로에만** 있으므로 +파싱 시점이 아니라 **번역 경로에서** 400으로 끊는다. + +> **개정 이력 (v2).** 초판은 스키마(파싱 시점) 거부를 골랐다. 감사에서 Critical blocker가 +> 나왔고, 재검증 결과 **초판 판정이 틀렸다**. 무엇이 틀렸고 어떻게 바로잡았는지는 §11에 +> 남긴다. 같은 실수를 반복하지 않으려면 §11을 먼저 읽어라. + +--- + +## 1. 결함 재검증 + +### 1.1 스키마가 call_id 없는 tool 아이템을 통과시킨다 + +`src/responses/schema.ts:78-81` 의 엄격한 스키마: + +```ts +const functionCallOutputItemSchema = z.object({ + type: z.literal("function_call_output"), + call_id: z.string().min(1), + output: toolOutputSchema.optional(), +}); +``` + +`src/responses/schema.ts:97-107` 의 union 마지막 대안이 이걸 무력화한다: + +```ts +export const inputItemSchema = z.union([ + userMessageItemSchema, + systemMessageItemSchema, + assistantMessageItemSchema, + reasoningItemSchema, + functionCallItemSchema, + functionCallOutputItemSchema, + customToolCallItemSchema, + customToolCallOutputItemSchema, + z.object({ type: z.string() }).loose(), // ← :106. 모든 실패를 흡수 +]); +``` + +zod union은 첫 성공 대안을 채택한다. `call_id` 가 없으면 엄격한 대안이 실패하고 +마지막 loose 대안이 성공하므로, 아이템은 원본 그대로 `data.input` 에 들어간다. +실측(`inputItemSchema.safeParse`): `function_call`, `function_call_output`, +`custom_tool_call`, `custom_tool_call_output` 네 타입 전부 PASS. `call_id` 가 숫자여도 PASS. + +### 1.2 파서가 무검사 대입한다 + +`src/responses/parser.ts:732-745`: + +```ts + if (effectiveType === "function_call_output") { + const output = item as { call_id: string; output?: string | unknown[] }; // :733 거짓말하는 캐스트 + attachPendingReasoningToCallOwner(messages, output.call_id, pendingReasoning); + pendingReasoning.length = 0; + const toolInfo = findToolById(messages, output.call_id); + messages.push({ + role: "toolResult", toolCallId: output.call_id, // :738 무검사 대입 +``` + +`:747-752` 의 `custom_tool_call_output` 도 동일하다(`:752`). +`as { call_id: string }` 캐스트가 런타임 검증 없이 타입만 주장하므로 tsc는 침묵한다. + +### 1.3 타입이 non-optional인데 undefined가 들어간다 + +`src/types/request.ts:165-175` 는 `toolCallId: string` (:168, non-optional)이다. +실제 파서 출력(실측, 위임 히스토리 모양): + +``` +toolResult= {"role":"toolResult","toolName":"","content":"bootstrap result","isError":false,...} +toolCallId typeof: undefined +``` + +`toolCallId` 키가 아예 사라진다. `string` 선언 필드가 런타임에 `undefined` 다. + +### 1.4 세 어댑터의 실제 관측 결과 (전부 실행함) + +| 어댑터 | 위치 | 실행 결과 | +|---|---|---| +| ollama-native | `src/adapters/ollama-native.ts:334` | `throw` — `ollama-native orphan tool result ` | +| kiro | `src/adapters/kiro-wire.ts:32` (호출부 `kiro.ts:661`) | 실측 `TypeError: undefined is not an object (evaluating 'id.replace')` | +| anthropic | `src/adapters/anthropic.ts:775` → `orphanToolResultText` `:637-643` | 실측: 오염 문자열을 업스트림에 **전송** | + +anthropic이 가장 나쁘다. 던지지 않고 오염된 문자열을 보낸다. 실측: + +``` +contains 'undefined'? true +leak snippet: [tool_result without adjacent tool_use: undefined]\nbootstrap result +``` + +원인(`anthropic.ts:637-643`) — `toolName` 이 빈 문자열이라 label이 곧 `toolCallId` 가 된다: + +```ts +function orphanToolResultText(msg: OcxToolResultMessage): string { + const label = msg.toolName ? `${msg.toolName} (${msg.toolCallId})` : msg.toolCallId; +``` + +`src/adapters/google.ts:269` 의 `geminiOrphanToolResultParts` 도 같은 label 로직이라 동일하게 오염된다. + +### 1.5 **결정적 사실 — 결함은 번역 경로에만 있다** + +이것이 이 문서 전체의 설계 근거다. 직접 실행해서 확인했다. + +passthrough 어댑터(`src/adapters/openai-responses.ts:2287` `createResponsesPassthroughAdapter`)는 +`parsed._rawBody` 만 읽는다. `rg -n 'context.messages' src/adapters/openai-responses.ts` → +**히트 0건**. 파서는 원본 body를 `_rawBody` 로 그대로 실어 보낸다(`parser.ts:850` `_rawBody: body`). + +실측으로 독립성을 증명했다 — `context.messages` 를 **빈 배열로 비운 뒤** 같은 요청을 빌드: + +``` +guard fires on translated path? true +passthrough forwards item verbatim? {"type":"message","role":"user","content":[{"type":"input_text","text":"[tool output for unknown call]\nbootstrap result"}]} +passthrough identical with EMPTY context.messages? true ← 바이트 동일 +anthropic leaks undefined? true +``` + +두 가지가 동시에 확인된다: + +1. passthrough 출력은 `context.messages` 와 **완전히 무관**하다(비워도 바이트 동일). +2. passthrough는 짝 없는 tool output을 **스스로 복구**한다 — `"[tool output for unknown call]"` + 로 바꿔 정상 user 메시지로 내보낸다. `undefined` 오염이 **없다**. + +즉 passthrough는 이 결함의 피해자가 아니라 **이미 올바르게 동작하는 경로**다. +여기에 400을 씌우면 멀쩡한 트래픽을 죽인다. + +--- + +## 2. 스코프 경계 + +### IN + +- **번역 경로**에서 짝 없는(call_id 없는) tool result를 400으로 거부. +- 400 응답의 정확한 형태(status / type / code / message) 확정. +- passthrough / forward / key 모드가 **영향받지 않음**을 실측으로 증명. +- 회귀 피해 전수 조사(표본 아님)와 focused 실행 목록 확정. +- `tests/responses-parser.test.ts` 파서 단위 회귀 + 경계 동작 테스트 추가. +- anthropic `undefined` 누출이 이 가드로 해소되는지 확인 + 명시. + +### OUT + +- **짝 없는 부트스트랩 tool result를 드롭/합성/강등할 것인가** — 제품 판단. 별도 결정. + (passthrough는 이미 `[tool output for unknown call]` 로 강등한다. 번역 경로에 + 같은 정책을 이식할지는 이 work-phase가 정할 문제가 아니다.) +- `orphanToolResultText` / `geminiOrphanToolResultParts` 의 label 폴백 수정. +- `src/types/request.ts:168` 을 `toolCallId?: string` 으로 바꾸는 것. + 타입 선언이 맞고 런타임이 틀렸다. 타입을 느슨하게 하면 결함을 정당화한다. +- ollama-native / kiro 의 throw 자체. +- `parser.ts:721` 의 `toolCallId: out.call_id ?? ""` (`tool_search_output`) — §6.3. +- 스키마(`inputItemSchema`) 변경. §3에서 기각한다. + +--- + +## 3. 설계 결정 — 세 안 비교 + +세 안을 **전부 실제로 패치해서** 동일한 63개 파일 스위트로 돌렸다. 표본이 아니라 같은 조건이다. + +### 안 A — 스키마 거부 (`src/responses/schema.ts` `inputItemSchema`) + +loose 폴백이 call_id 없는 tool 아이템을 삼키지 못하게 해 `parseRequest` 가 던지게 한다. + +**기각한다. 결정적 근거는 실행 순서다.** + +`src/server/responses/core.ts` 에서 두 지점이 같은 `handleResponsesInner` 안에 있고 순서가 고정이다: + +```ts +// :2812 + parsed = parseRequest(body); +... +// :3719 — 훨씬 뒤 + if ("passthrough" in adapter && adapter.passthrough && !routedCompaction) { +``` + +`parseRequest` 가 passthrough 분기보다 **먼저** 실행된다. 따라서 스키마에서 400을 내면 +**번역을 전혀 하지 않는 forward/key passthrough까지 끊는다.** 그런데 §1.5에서 실측했듯 +passthrough는 이 결함의 영향을 받지 않고 오히려 스스로 복구한다. 안 A는 +**결함이 없는 경로를 죽이는 대가로 결함이 있는 경로를 고치는** 교환이다. + +실측 피해(63파일 스위트, 아래 §7 baseline 대비): + +``` +안 A: 1693 pass / 4 fail ← baseline 대비 +1 회귀 +(fail) routed compaction for key-mode openai-responses (#422) > raw input_image never reaches the upstream +``` + +이 실패는 픽스처 결함이 아니다. `routedCompaction` 경로(`core.ts:3691-3712`)는 +key-mode openai-responses 어댑터를 쓰는데, 그 어댑터는 `_rawBody` 에서 빌드한다 +(`core.ts:3703-3706` 주석이 명시: *the key-mode openai-responses adapter builds from _rawBody*). +즉 **번역하지 않는 경로가 스키마 400에 걸린 것**이고, 안 A의 구조적 결함을 그대로 보여준다. + +### 안 B — 번역 경로 거부 (`core.ts`, passthrough 분기 **이후**) + +passthrough 블록이 끝나는 `core.ts:5153` 이후, 즉 번역 경로만 지나는 지점에서 검사한다. + +실측: **baseline과 동일(1694 pass / 3 fail)**. 추가 회귀 0건. +passthrough 테스트 전부 green, routed compaction도 green. + +단, 위치를 잘못 잡으면 실패한다. 처음 `:5153` 직후에 무조건 검사하도록 넣었더니 +routed compaction이 깨졌다(실측 1 fail). `routedCompaction` 은 passthrough 분기를 +`!routedCompaction` 조건으로 **건너뛰고** 내려오지만 여전히 `_rawBody` 기반이기 때문이다. +그래서 위치가 아니라 **어댑터 성질**로 조건을 걸어야 한다: +`if (!("passthrough" in adapter && adapter.passthrough))`. 이렇게 바꾼 뒤 green을 확인했다. + +### 안 C — 어댑터 공통 래퍼 (`src/adapters/registry.ts:155` `createRegisteredAdapter`) + +모든 어댑터의 `buildRequest` 를 감싸는 **단 하나의 래퍼**가 이미 존재한다(`:154-172`). +여기 넣으면 `core.ts` 의 8개 `buildRequest` 호출 지점 +(`:1258, :3775, :4174, :4285, :4392, :5806, :5931, :6373`)을 **전부** 한 번에 덮는다. + +실측: 안 B와 동일하게 **baseline 동일(1694 pass / 3 fail)**, `tsc` exit 0. + +**그런데 안 C는 에러 형태를 통제할 수 없다.** 래퍼는 `buildRequest` 안이므로 throw만 가능하고, +그 throw를 받는 `core.ts:3774-3788` 의 catch는 **두 개의 지정된 에러 클래스만** 400으로 바꾼다: + +```ts + } catch (error) { + releaseCodexAuthContextProbeLease(authCtx); + if (error instanceof NamespaceToolCollisionError || error instanceof XaiToolSchemaCompatibilityError) { + return formatErrorResponse(400, "invalid_request_error", redactSecretString(error.message)); + } + throw error; // ← 그 외는 전부 재throw = 500 + } +``` + +바로 위 주석이 이 함정을 직접 기록해 두었다: *Rethrowing it here escaped every catch up to the +Bun handler, so the same request produced an unstructured 500 — and no request log*. +안 C로 400을 내려면 **새 에러 클래스를 만들어 이 catch에 등록**해야 하고, 그러면 변경 표면이 +`registry.ts` + 새 에러 모듈 + `core.ts` catch 세 곳으로 늘어난다. 경계 하드닝 한 건에 비해 과하다. + +### 결정: **안 B** + +| 기준 | A 스키마 | B 번역 경로 | C 레지스트리 래퍼 | +|---|---|---|---| +| passthrough 계약 보존 | **깨짐** (+1 회귀) | 보존 | 보존 | +| routed compaction 보존 | **깨짐** | 보존 | 보존 | +| 400 형태 통제 | 가능 | **가능 (직접 return)** | 불가 (500이 기본) | +| 변경 파일 수 | 1 | **1** | 3 | +| 결함 없는 경로에 영향 | 있음 | **없음** | 없음 | + +안 B는 결함이 실재하는 경로에만 정확히 걸리고, 400을 직접 반환하며, 파일 하나만 바꾼다. + +--- + +## 4. 파일 변경 맵 + +### 4.1 MODIFY — `src/server/responses/core.ts` (유일한 프로덕션 변경) + +**위치**: passthrough 블록이 닫히는 `:5153` 직후, 사이드카 계획(`:5155` 주석) 직전. + +**before** (현재 `:5147-5158`): + +```ts + } finally { + if (hostAdmissionLease) { + releaseUpstreamHostAdmission(hostAdmissionLease); + releaseCodexAuthContextProbeLease(authCtx); + } + } + } + + // Image / web-search sidecars: plan once, then dispatch with runTurn-aware priority. +``` + +**after**: + +```ts + } finally { + if (hostAdmissionLease) { + releaseUpstreamHostAdmission(hostAdmissionLease); + releaseCodexAuthContextProbeLease(authCtx); + } + } + } + + // Tool results are PAIRED by call_id. parseRequest writes it into OcxToolResultMessage.toolCallId + // (parser.ts:738/752) without validating it, because inputItemSchema's permissive catch-all + // (schema.ts:106) accepts a tool item whose strict schema failed only for a missing call_id. A + // translating adapter then consumes `toolCallId: string` holding undefined: kiro-wire.ts:32 + // TypeErrors, ollama-native.ts:334 throws, and anthropic.ts:775 sends + // "[tool_result without adjacent tool_use: undefined]" upstream (issue #3259). + // + // This CANNOT move into the schema. parseRequest (:2812) runs before the passthrough branch + // (:3719), so a parse-time rejection would also kill forward/key passthrough and routed + // compaction — paths that never read context.messages, build from _rawBody, and already + // degrade an unpaired output to "[tool output for unknown call]" on their own. + // + // Keyed on the adapter, not on position: routedCompaction skips the passthrough branch above + // yet still builds from _rawBody (see the :3703 comment). + if (!("passthrough" in adapter && adapter.passthrough)) { + const unpaired = parsed.context.messages.find( + message => message.role === "toolResult" + && (typeof (message as { toolCallId?: unknown }).toolCallId !== "string" + || (message as { toolCallId: string }).toolCallId.length === 0), + ); + if (unpaired) { + // Never interpolate the tool output: this message reaches the client and the logs. + return formatErrorResponse( + 400, + "invalid_request_error", + "tool result requires a non-empty string call_id", + ); + } + } + + // Image / web-search sidecars: plan once, then dispatch with runTurn-aware priority. +``` + +`formatErrorResponse` 는 이 파일에서 이미 쓰인다(예: `:2875`, `:3785`). **새 import 불필요.** + +### 4.2 MODIFY — `tests/responses-parser.test.ts` (회귀 테스트) + +파일 끝(`:797`, `describe("codex-rs compat surface (260707)")` 가 `:568`~`:797`) 뒤에 추가한다. + +이 테스트가 검증하는 것은 **파서가 결함 상태를 실제로 만들어낸다**는 사실 자체다. +§4.1 가드는 이 상태를 전제로 동작하므로, 이 관측이 무너지면 가드도 무의미해진다. + +```ts +describe("unpaired tool result boundary (#3259)", () => { + // The real delegation-history shape that produced the defect: a subagent bootstrap turn + // whose FIRST tool result has no originating call in the same request. + const delegationHistory = (toolItem: Record) => ({ + model: "test-model", + input: [ + { type: "message", role: "developer", content: [{ type: "input_text", text: "You are a subagent." }] }, + { type: "message", role: "user", content: [{ type: "input_text", text: "do the task" }] }, + toolItem, + ], + }); + + const toolResultOf = (item: Record) => + parseRequest(delegationHistory(item)).context.messages.find(m => m.role === "toolResult") as + | { toolCallId?: unknown } + | undefined; + + test("a function_call_output with no call_id still parses, and yields an unusable toolCallId", () => { + // This is the state src/server/responses/core.ts guards on. `toolCallId` is declared + // `string` (src/types/request.ts:168) but is undefined here — the schema catch-all + // (schema.ts:106) accepted the item and parser.ts:738 assigned it unchecked. + const result = toolResultOf({ type: "function_call_output", output: "bootstrap result" }); + expect(result).toBeDefined(); + expect(typeof result?.toolCallId).not.toBe("string"); + }); + + test("an empty-string call_id is equally unusable", () => { + // findToolById (parser.ts:328) matches by identity, so "" can never pair. The guard + // must treat it exactly like undefined. + const result = toolResultOf({ type: "function_call_output", call_id: "", output: "x" }); + expect(result?.toolCallId).toBe(""); + }); + + test("a well-formed tool result on the same history pairs normally", () => { + const result = toolResultOf({ type: "function_call_output", call_id: "call_1", output: "ok" }); + expect(result).toMatchObject({ toolCallId: "call_1", content: "ok" }); + }); + + test("custom_tool_call_output has the identical hole (parser.ts:752)", () => { + const result = toolResultOf({ type: "custom_tool_call_output", output: "x" }); + expect(result).toBeDefined(); + expect(typeof result?.toolCallId).not.toBe("string"); + }); + + test("tolerances unrelated to call_id stay intact", () => { + // parser.ts:611-621 deliberately tolerates non-JSON arguments; nothing here may 400 it. + expect(() => parseRequest(delegationHistory({ + type: "function_call", call_id: "c1", name: "shell", arguments: "not json", + }))).not.toThrow(); + // Unknown future item types must keep flowing through the catch-all untouched. + expect(() => parseRequest(delegationHistory({ + type: "brand_new_item_2027", foo: 1, + }))).not.toThrow(); + }); +}); +``` + +`describe` / `expect` / `test` / `parseRequest` 는 `:1-4` 에서 이미 import되어 있다. + +### 4.3 서버 레벨 400 회귀 테스트 (권장, 위치 주의) + +`core.ts` 가드의 400을 직접 검증하려면 `handleResponses` 를 통과시켜야 하고, 그건 +완전한 `config` 가 필요하다(실측: config 없이 호출하면 `Object.hasOwn(config.providers, ...)` 에서 +404가 난다). 기존 하네스가 이미 있는 `tests/responses-compaction-routing.test.ts` 의 +`compactionRequest()` / `keyProviderConfig()` 패턴(`:89-115`)을 참고해 +**번역 어댑터(anthropic 등) 라우트로** 케이스를 추가하라. 검증할 것은 두 가지다: + +- 응답이 `400` 이고 message가 `tool result requires a non-empty string call_id` 다. +- `fetch` 스텁이 **한 번도 호출되지 않는다**(업스트림 전송 0회). + +passthrough 라우트로 같은 body를 보내면 **200이 나와야 한다**. 그 대비가 이 설계의 핵심 주장이다. + +### 4.4 NEW / DELETE + +없음. + +### 4.5 변경하지 **않는** 파일 (명시) + +- `src/responses/schema.ts` — 안 A 기각(§3). +- `src/responses/parser.ts` — 파서는 passthrough와 공유된다. 여기서 `call_id` 를 강제하면 + 안 A와 같은 부작용이 난다. +- `src/adapters/` 전부 — 가드가 앞서므로 도달하지 않는다. +- `tests/openai-responses-compaction-routing` 픽스처 — 안 B에서는 **수리가 필요 없다** + (초판이 요구했던 픽스처 수정은 안 A의 부작용이었다). + +--- + +## 5. 조건 분기 activation scenario + +§4.1이 추가하는 조건 분기는 **두 개**다. + +### 분기 1 — `if (!("passthrough" in adapter && adapter.passthrough))` + +- **C(클라이언트)가 어떻게 발화시키는가**: 라우팅 결과가 passthrough 어댑터 + (`openai-responses` key/forward, `azure-openai` — `adapters/azure.ts:5` 가 + `ProviderAdapter & { passthrough: true }` 를 반환)인 요청. 예: xAI grok을 key 모드로, + 또는 ChatGPT forward 모드로 부르는 Codex 턴. +- **무엇이 관측되는가**: 가드 전체를 건너뛴다. call_id 없는 tool output이 있어도 **200**이고, + 업스트림 body에는 passthrough 자신의 복구 결과 + `[tool output for unknown call]` 가 실린다(§1.5 실측). `undefined` 는 나타나지 않는다. +- **검증**: `bun test tests/openai-responses-passthrough.test.ts` 가 green을 유지 + (실측 125/125 상당, 전체 배치에서 baseline 동일). + +### 분기 2 — `if (unpaired)` — `toolCallId` 가 문자열이 아니거나 빈 문자열 + +- **C가 어떻게 발화시키는가**: 번역 어댑터(anthropic / google / kiro / ollama-native / cursor …)로 + 라우팅되는 요청에, 같은 요청 안에 짝이 되는 `function_call` 없이 + `{type: "function_call_output", output: ...}` 만 실려 오고 `call_id` 가 누락/빈문자열/비문자열인 경우. + 위임(subagent) 부트스트랩 히스토리가 이슈 #3259가 보고한 실제 트래픽 모양이다. +- **무엇이 관측되는가**: HTTP `400`, + `{"error":{"message":"tool result requires a non-empty string call_id","type":"invalid_request_error","code":"invalid_request_error"}}`. + **업스트림 provider 호출 0회** — 가드가 `buildRequest` 앞이므로 fetch 스텁이 한 번도 안 불린다. + 이전에는 anthropic이 200과 함께 오염 문자열을 보냈다. +- **검증**: §4.3 서버 레벨 테스트. + +### 분기 미도달 — 정상 번역 턴 + +- **C가 어떻게 발화시키는가**: 짝이 맞는 `function_call` + `function_call_output`. +- **무엇이 관측되는가**: `find` 가 `undefined` 를 반환해 가드를 통과. 동작 변화 0. + `messages` 순회 1회가 추가될 뿐이다(요청당 O(n), n = 메시지 수). + +--- + +## 6. anthropic `undefined` 누출 — 이 가드로 해소되는가 + +### 6.1 결론 + +**번역 경로에서 해소된다. 어댑터는 한 줄도 고치지 않는다.** + +### 6.2 근거 + +§1.5 실측에서 두 사실이 같이 나왔다: + +``` +guard fires on translated path? true ← 가드 조건이 정확히 이 요청에서 참 +anthropic leaks undefined? true ← 가드가 없으면 오염이 실제로 나감 +``` + +가드가 `buildRequest` 이전에 400을 반환하므로 `orphanToolResultText` 가 호출되지 않는다. +`kiro-wire.ts:32` TypeError, `ollama-native.ts:334` throw, `google.ts:269` 의 동일 label 오염도 +모두 같은 이유로 도달 불가가 된다. **네 어댑터 결함이 한 곳의 변경으로 사라진다.** + +### 6.3 남는 구멍 (OUT, 후속 단위) + +`parser.ts:721` 의 `toolCallId: out.call_id ?? ""` (`tool_search_output`) 는 +이 가드에 **잡힌다** — 빈 문자열도 분기 2의 조건이기 때문이다. 즉 지금까지 +`"tool_search ()"` 로 조용히 오염되던 요청이 이제 400이 된다. + +이건 **의도한 부작용이자 동시에 위험**이다. `tool_search_output` 에 `call_id` 가 없는 요청을 +실제로 보내는 클라이언트가 있다면 회귀가 된다. 63파일 전수 스위트에서 그런 픽스처는 +**나오지 않았다**(§7). 그래도 배포 후 400 로그에서 이 메시지가 뜨는지 지켜볼 것. +필요하면 `tool_search_output` 만 예외 처리하는 후속 단위를 연다. + +--- + +## 7. 회귀 전수 조사 + Verifier (PLAN-VERIFIER-REAL-01) + +### 7.1 후보 전수 수집 (표본 아님) + +```bash +rg -n '"function_call_output"|"custom_tool_call_output"|"custom_tool_call"|"function_call"' tests/ -l | sort +``` + +→ **65개 파일**. 이 중 `tests/fixtures/compatibility/openai-codex-forward-gpt56-sol-v1.json` (JSON)과 +`tests/helpers/agent-task-recovery.ts` (헬퍼)는 테스트 파일이 아니므로 실행 대상에서 빠지고, +**63개 `.test.ts`** 가 실행 목록이 된다. 목록은 위 명령으로 재생성 가능하다. + +### 7.2 baseline 먼저 확정 (이게 없으면 판정 불가) + +```bash +git stash push -- src/server/responses/core.ts # 변경 제거 +bun test <63개 파일> +``` + +실측 baseline: `1694 pass / 3 fail`. + +**그 3건은 이 변경과 무관한 기존 실패다.** `tests/server-xai-responses-streaming.test.ts` 의 +`xAI OAuth Responses streaming opt-in` 3개 테스트로, **깨끗한 트리에서도 동일하게 실패한다** +(실측). 단독 실행 시에는 `3 pass / 0 fail` 이므로 63파일 병렬 배치에서만 나타나는 +기존 간섭이다. 이 변경의 책임이 아니고, 이 work-phase에서 고치지 않는다. + +### 7.3 세 안 실측 비교 (같은 63파일, 같은 조건) + +| 안 | 결과 | baseline 대비 | +|---|---|---| +| baseline (변경 없음) | 1694 pass / 3 fail | — | +| **안 B (채택)** | **1694 pass / 3 fail** | **+0 회귀** | +| 안 C (레지스트리) | 1694 pass / 3 fail | +0 회귀 (단 400 불가, §3) | +| 안 A (스키마) | 1693 pass / 4 fail | **+1 회귀** (routed compaction) | + +안 A만 추가 회귀를 낸다. 그 1건이 §3에서 설명한 구조적 결함의 직접 증거다. + +### 7.4 Verifier 표 (전부 실제 실행함) + +| # | 커맨드 | exit | 이 커맨드가 변경 대상을 실제로 읽는가 | +|---|---|---|---| +| V1 | `bun x tsc --noEmit` | 0 | 읽는다 — `src/server/responses/core.ts` 는 tsconfig 대상이고, `message` 좁히기 캐스트가 여기서 판정된다 | +| V2 | `bun test tests/responses-parser.test.ts` | 0 | 읽는다 — §4.2 새 블록이 이 파일에 있고 `parseRequest` 를 직접 호출한다. **단 `core.ts` 가드는 보지 못한다** — 파서 단위 관측 전용이다 | +| V3 | `bun test tests/responses-compaction-routing.test.ts` | 0 | 읽는다 — `handleResponses` 를 통과하므로 §4.1 가드 코드를 **실제로 실행**한다. routedCompaction이 가드에 걸리지 않아야 함을 여기서 판정한다. 안 B 초기 위치(무조건 검사)에서 실제 red를 봤다 | +| V4 | `bun test tests/openai-responses-passthrough.test.ts tests/responses-compaction-routing.test.ts` | 0 | 읽는다 — **V4가 이 변경을 관측하는 커맨드다**. 초판이 지목한 `:2343` 단독 테스트는 `adapter.buildRequest({_rawBody})` 직접 호출이라(`:2368`) `core.ts` 를 우회해 **아무것도 관측하지 못한다**. compaction 파일이 `handleResponses` 경유로 가드를 실행하므로 둘을 **함께** 돌려야 passthrough 보존을 실제로 증명한다. 실측 176 pass / 0 fail | +| V5 | 63파일 전수 (§7.1 목록) | 3 fail = baseline | 읽는다 — 63개 중 **18개**가 `handleResponses` 를 호출하므로 가드 코드가 실제로 실행된다(실측: `xargs rg -l 'handleResponses' < 후보목록 \| wc -l` → 18). 나머지 45개는 어댑터/파서 단위라 가드에 도달하지 않지만, 후보 수집이 표본이 아니라 전수임을 보이는 것이 이 행의 목적이다. baseline과 동일해야 통과 | + +**금지**: `bun run test`, 인자 없는 `bun test`. V5는 rg로 수집한 명시적 파일 목록 실행이지 +전체 스위트가 아니다(전체는 ~850파일, V5는 63파일). + +### 7.5 red 확인 의무 + +V3는 **red를 실제로 봤다**(가드를 위치 기반으로 넣었을 때 1 fail). V2의 파서 관측 테스트는 +현재 코드에서 green이다 — 결함 상태를 기술하는 테스트이기 때문이다. §4.3 서버 테스트를 +추가한다면 가드를 잠시 제거해 red를 확인하라. red를 못 본 테스트는 신뢰할 수 없다. + +--- + +## 8. Accept criteria (testable) + +1. `bun x tsc --noEmit` exit 0. (V1) +2. `bun test tests/responses-parser.test.ts` exit 0, §4.2 블록 5건 pass. (V2) +3. `bun test tests/openai-responses-passthrough.test.ts tests/responses-compaction-routing.test.ts` + exit 0. (V4 — 실측 176 pass / 0 fail) +4. 63파일 전수 실행이 **baseline과 동일**: 1694 pass / 3 fail, 그 3건이 §7.2의 + xAI 기존 실패와 **정확히 일치**. 다른 실패가 하나라도 늘면 실패다. (V5) +5. **번역 어댑터** 라우트 + call_id 없는 `function_call_output` → HTTP `400`, + `type`/`code` = `invalid_request_error`, message = `tool result requires a non-empty string call_id`. +6. 같은 요청에서 업스트림 fetch **0회**. +7. **passthrough 라우트**에 동일 body → **200**, 업스트림 body에 + `[tool output for unknown call]` 포함, `undefined` **미포함**. +8. 400 body가 요청의 `output` 값을 포함하지 않는다(메시지가 상수 문자열이므로 구조적으로 보장). +9. 변경 파일이 정확히 `src/server/responses/core.ts` 와 `tests/responses-parser.test.ts` + (+§4.3 채택 시 서버 테스트 파일) 뿐이다. `src/responses/schema.ts`, `src/responses/parser.ts`, + `src/adapters/` 는 `git diff --name-only` 에 **나오지 않는다**. + +--- + +## 9. 필드 체인 (PLAN-FIELD-CHAIN-01) + +**이 변경은 타입이나 enum에 필드를 추가하지 않는다.** 새 creation → serialization → +deserialization → consumer 체인이 생기지 않는다. + +확인 사항: + +- `OcxToolResultMessage`(`src/types/request.ts:165-175`) 변경 없음. + `toolCallId: string` 유지 — 이 변경의 목적이 **그 선언을 참으로 만드는 것**이다. +- `OcxToolCall`(`:211-227`), `inputItemSchema`, `responsesRequestSchema` 전부 변경 없음. +- 새 에러 클래스 없음. 새 에러 `code` 없음. 따라서 `classifyError`(`src/lib/errors.ts:149`), + `/api/logs` 소비자, GUI 에러 표시 경로 모두 변경 없음. +- 가드는 **읽기 전용**이다. `parsed` 를 변형하지 않으므로 이후 파이프라인 + (사이드카 계획, 툴 브리지, 스트림 처리)의 입력이 달라지지 않는다. + +영향을 받는 유일한 관측 가능 표면은 **번역 경로의 HTTP 400 응답**이다. +`formatErrorResponse(400, "invalid_request_error", ...)` 는 이 파일이 이미 여러 번 쓰는 형태이고 +(`:2875`, `:3785`, `:5824`), `classifyError` 가 `code` 를 `invalid_request_error` 로 확정한다. +커스텀 `code` 를 넘겨도 `isCyberPolicyCode` 가 아니면 무시된다(`src/bridge.ts:2088-2093`). + +--- + +## 10. 구현 순서 + +1. 63파일 목록 재생성(§7.1) 후 **baseline 먼저 측정**. 이걸 건너뛰면 §7.2의 기존 xAI 실패 3건을 + 자기 회귀로 오인한다. +2. `src/server/responses/core.ts` 에 §4.1 적용. +3. `bun x tsc --noEmit` (V1). +4. `bun test tests/openai-responses-passthrough.test.ts tests/responses-compaction-routing.test.ts` (V4). + **여기가 설계의 핵심 주장이 무너지는지 보는 지점이다.** +5. `tests/responses-parser.test.ts` 에 §4.2 적용 후 V2. +6. (선택) §4.3 서버 테스트 추가 — 가드 제거 상태에서 red 확인 후 복구. +7. V5 전수 실행, baseline과 대조. +8. `git diff --name-only` 로 accept criteria 9 확인. + +PR은 `dev` 를 target하고 설명에 `Closes #3259` 를 넣는다. `dev` 는 default branch가 아니므로 +머지 후 이슈를 **수동으로 닫아야 한다**(AGENTS.md). + +--- + +## 11. 초판(v1)이 틀린 지점 — 재발 방지 기록 + +초판은 **안 A(스키마 거부)** 를 골랐다. 감사에서 Critical blocker가 나왔고 재검증 결과 사실이었다. +세 가지가 틀렸다. + +### 11.1 픽스처를 조작한 결과를 근거로 썼다 (가장 심각) + +초판 §3.3은 좁은 스키마 가드가 `openai-responses-passthrough` 의 fail-closed 픽스처를 +관용한다고 적으며 `fco output unknown block PASS` 를 근거로 제시했다. +**그 PASS는 픽스처에 없는 `call_id: "c1"` 을 내가 임의로 덧붙였을 때만 나온다.** +원본 `tests/openai-responses-passthrough.test.ts:2350-2366` 의 6개 픽스처는 **전부 call_id가 없다**: + +```ts + const invalidOutputs = [ + { type: "custom_tool_call_output" }, + { type: "function_call_output", output: [{ type: "bogus", value: "not a tool-output part" }] }, + // … 나머지 4개도 전부 call_id 없음 + ]; +``` + +즉 초판은 **자기가 만든 변형을 원본이라고 착각하고** 안전하다고 결론지었다. +교훈: 픽스처를 인용할 때는 반드시 원본 라인을 다시 열어 대조한다. 프로브 스크립트에서 +값을 채워 넣었다면 그건 근거가 아니다. + +### 11.2 이 변경을 볼 수 없는 커맨드를 verifier로 지목했다 + +초판 V4는 `tests/openai-responses-passthrough.test.ts` 단독이었다. 그런데 그 파일의 해당 +테스트는 `adapter.buildRequest({ ...parsedBase, _rawBody })` 를 **직접** 호출한다(`:2367-2370`). +`handleResponses` 도 `parseRequest` 도 지나지 않으므로 스키마 변경도 core 변경도 관측 못 한다. +실제로 가드를 넣어도 그 파일은 초록이었다 — **초록의 의미가 없는 초록**이었다. +교훈: verifier를 적을 때 "이 커맨드가 변경 지점을 실행하는 호출 경로"를 한 줄로 쓸 수 없으면 +그 커맨드는 verifier가 아니다. v2 §7.4는 각 행에 그 근거를 강제로 적었다. + +### 11.3 결함의 위치를 잘못 짚었다 + +결함은 파싱이 아니라 **번역**에 있었다. passthrough는 `_rawBody` 만 읽고(`context.messages` 참조 +0건), 짝 없는 output을 스스로 강등해 이미 올바르게 동작한다. 초판은 이걸 확인하지 않고 +`parseRequest` 가 passthrough보다 먼저 실행된다는 사실(`core.ts:2812` vs `:3719`)도 놓쳐서, +**멀쩡한 경로까지 끊는 위치**에 가드를 놓았다. 그 대가가 실측 +1 회귀였다. +교훈: "어디서 터지는가"가 아니라 "어떤 코드가 그 필드를 실제로 읽는가"로 경계를 정한다. +`rg -n 'context.messages' src/adapters/.ts` 한 번이면 갈렸다. + +### 11.4 회귀 조사를 표본으로 했다 + +초판은 6개 파일을 골라 돌리고 "추가 피해 0건"이라고 적었다. v2는 rg로 65개 후보를 전수 수집해 +63개를 돌렸고, 그 과정에서 **baseline에 이미 3건의 기존 실패가 있다**는 사실을 발견했다(§7.2). +표본만 돌렸다면 이 3건을 자기 회귀로 오인하거나, 반대로 진짜 회귀를 놓쳤을 것이다. diff --git a/devlog/_plan/260904_priority65_closeout/020_wp3_combo_metadata_carry.md b/devlog/_plan/260904_priority65_closeout/020_wp3_combo_metadata_carry.md new file mode 100644 index 0000000000..ae63ba1408 --- /dev/null +++ b/devlog/_plan/260904_priority65_closeout/020_wp3_combo_metadata_carry.md @@ -0,0 +1,830 @@ +# wp3 — PR #3332 재구현 carry: Claude combo capability + provider output budget + +대상: PR [#3332](https://github.com/lidge-jun/opencodex/pull/3332) (@full999), +head `f05c23e06`, base `dev`, 상태 `OPEN` / `mergeable: CONFLICTING` / `mergeStateStatus: DIRTY`. + +체리픽이 불가능하므로 **재구현(carry)** 한다. 원 diff 5파일 중 4파일을 가져오고, +그중 한 hunk는 결함이 있어 고쳐서 가져온다. + +작업 브랜치: `codex/priority65-closeout` (base `origin/dev` = `2421e44ce`, package 2.43.0). + +--- + +## 1. 왜 재구현인가 + +`gh pr view 3332` 확인 결과: + +``` +{"mergeable":"CONFLICTING","mergeStateStatus":"DIRTY","headRefOid":"f05c23e06fa14534a983472b22e7477e98e135e7","baseRefName":"dev"} +``` + +PR이 작성된 뒤 dev가 같은 영역을 움직였다. 특히 `src/providers/registry.ts` 의 +Anthropic 엔트리에는 PR 작성 시점에 없던 `modelReasoningEfforts: { ...ANTHROPIC_MODEL_REASONING_EFFORTS }` +가 이미 들어와 있다(현재 `registry.ts:1371`, `:1388`, PR #3454로 추가됨). +따라서 PR의 registry hunk는 컨텍스트가 어긋나 그대로 적용되지 않는다. + +--- + +## 2. 확인된 결함 — PR의 `vendorMetadataComboFallback` 이 OUTPUT 상한을 INPUT 상한에 매핑한다 + +### 2.1 결함 hunk + +원 PR `src/codex/catalog/provider-fetch.ts` 의 `vendorMetadataComboFallback` 안: + +```ts + ...(typeof metadata.maxTokens === "number" && metadata.maxTokens > 0 + ? { maxInputTokens: metadata.maxTokens } // ← 결함 + : {}), +``` + +`ModelMetadata.maxTokens` 는 OUTPUT 상한이다. `src/generated/model-metadata.ts:4-13` 의 +타입 정의에서 `contextWindow` 와 `maxTokens` 는 별도 필드이고, 같은 파일 안의 기존 소비자 +`provider-fetch.ts:2516` 은 이미 올바른 방향으로 매핑하고 있다: + +```ts +...(typeof meta.maxTokens === "number" && meta.maxTokens > 0 ? { maxOutputTokens: meta.maxTokens } : {}), +``` + +즉 PR 혼자만 반대 방향으로 쓴다. + +### 2.2 실제 값으로 확인 + +`bun -e` 로 vendor 테이블을 직접 읽었다: + +``` +claude-opus-5 {"contextWindow":1000000,"maxTokens":128000,"input":["text","image"],"reasoning":true} +claude-sonnet-5 {"contextWindow":1000000,"maxTokens":128000,...} +claude-haiku-4-5{"contextWindow":200000, "maxTokens":64000, ...} +claude-fable-5-1 undefined ← 테이블에 없음. point-release 폴백이 실제로 필요하다는 증거 +``` + +### 2.3 결함이 어떻게 전파되는가 + +`aggregation.ts:161-164`: + +```ts + const maxInputTokens = Math.min( + contextWindow, + ...members.map(member => member.maxInputTokens ?? member.contextWindow!), + ); +``` + +`Math.min` 이므로 멤버 하나가 128k를 들고 오면 combo 전체 입력창이 128k로 내려앉는다. +1M 창을 가진 다른 타깃까지 같이 끌려 내려간다. + +**실측(스크래치 프로브, `.tmp/` 에서 실행 후 삭제):** + +| 시나리오 | member ctx | member maxIn | member maxOut | combo maxIn | combo autoCompact | +|---|---|---|---|---|---| +| PR 그대로 (`maxInputTokens`) | 1,000,000 | **128,000** | (없음) | **128,000** | **128,000** | +| 수정안 (`maxOutputTokens`) | 1,000,000 | 1,000,000 | 128,000 | **1,000,000** | **900,000** | + +autoCompact까지 900k → 128k로 무너진다. `clampAutoCompactTokenLimit` +(`src/providers/auto-compact-budget.ts:15-24`)이 후보에 `maxInputTokens` 를 넣고 +`Math.min` 하기 때문이다. 즉 PR을 그대로 머지하면 Claude combo 사용자는 +입력 컨텍스트의 87%를 잃는다. + +### 2.4 수정 + +`maxInputTokens` → `maxOutputTokens`. 슬롯은 이미 존재한다 +(`ComboCatalogMemberFallback.maxOutputTokens`, `provider-fetch.ts:858`), 소비 경로도 +이미 배선되어 있다(`:896-898` 의 `addMaxOutput`, `:918`, `:996-997`). +새 필드를 만들 필요가 없다. + +--- + +## 3. 스코프 + +### IN + +- `src/codex/catalog/provider-fetch.ts` — vendor metadata combo 폴백 신규 추가 (결함 수정본). +- `src/adapters/anthropic.ts` — 생략된 `max_tokens` 에 provider output budget 적용. +- `src/providers/registry.ts` — Anthropic 두 엔트리에 `defaultMaxOutputTokens` 추가. +- `tests/codex-catalog.test.ts` — 폴백 회귀 테스트 + **2.3 결함을 잡는 단정**. +- `tests/anthropic-reasoning.test.ts` — output budget 회귀 테스트. + +### OUT + +- `aggregation.ts:161` 의 `Math.min` 자체는 건드리지 않는다. 그 로직은 옳다 + (combo 입력창은 최소 멤버가 결정). 잘못된 것은 그 자리에 들어가는 값이지 연산이 아니다. +- Anthropic 외 프로바이더의 `defaultMaxOutputTokens` 추가. +- `ROUTED_COMBO_MEMBER_REASONING_EFFORTS` 를 Grok 등 다른 벤더로 확장하는 일반화. +- `devlog/` 외 문서 변경, docs-site 갱신. +- 릴리스, 태그, 푸시. + +--- + +## 4. 파일 변경 맵 + +### 4.1 MODIFY `src/codex/catalog/provider-fetch.ts` + +#### hunk A — import에 `ModelMetadata` 타입 추가 (line 35) + +before: + +```ts +import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, resolveMetadataProvider } from "../../generated/model-metadata"; +``` + +after: + +```ts +import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, resolveMetadataProvider, type ModelMetadata } from "../../generated/model-metadata"; +``` + +#### hunk B — `ComboCatalogMemberFallback` 선언(`:855-863`) 뒤, `resolveComboCatalogMember` JSDoc(`:865`) 앞에 삽입 + +before (현재 `:863-875`): + +```ts + readonly reasoningEfforts?: readonly string[]; +} + +/** + * Resolve a combo target to a catalog member for derivation. +``` + +after: + +```ts + readonly reasoningEfforts?: readonly string[]; +} + +/** + * Ladder advertised for a combo member whose vendor metadata says it reasons but + * carries no explicit ladder (Claude, Grok). Codex needs a non-empty ladder to show + * the effort control; the routed adapters clamp to the real upstream top rung. + */ +const ROUTED_COMBO_MEMBER_REASONING_EFFORTS: readonly string[] = ["low", "medium", "high", "xhigh", "max"]; + +/** + * Vendor-table lookup tolerant of point releases and date pins. Configured combo + * targets often name a variant the table does not carry (`claude-fable-5-1`, + * `claude-opus-4-5-20251101`); the base family row still describes its modality + * and reasoning capability, so fall back to it before giving up. + */ +function comboMemberVendorMetadata(provider: string, modelId: string): ModelMetadata | undefined { + const exact = getModelMetadataCaseInsensitive(provider, modelId); + if (exact) return exact; + let candidate = modelId.replace(/\[[^\]]*\]$/, ""); + while (true) { + const trimmed = candidate.replace(/-\d+$/, ""); + if (trimmed === candidate || !trimmed.includes("-")) return undefined; + const hit = getModelMetadataCaseInsensitive(provider, trimmed); + if (hit) return hit; + candidate = trimmed; + } +} + +/** + * Combo members are usually thin discovery rows (id + context window). Without a + * capability source the combo intersection collapses to text-only / no effort ladder, + * and the Codex app then refuses image attachments and hides the effort picker for + * every Claude combo. The generated vendor table knows both, so use it as the + * last-resort fallback when the caller supplied none. + * + * `ModelMetadata.maxTokens` is the OUTPUT ceiling, so it fills `maxOutputTokens`. + * Mapping it onto `maxInputTokens` would be read by the combo intersection + * (`aggregation.ts` `Math.min` over member input ceilings) as a 128k input limit and + * shrink a 1M Claude combo window to 128k, taking autoCompactTokenLimit down with it. + */ +function vendorMetadataComboFallback(target: { provider: string; model: string }): ComboCatalogMemberFallback | undefined { + const metadataProvider = resolveMetadataProvider(target.provider); + const metadata = metadataProvider ? comboMemberVendorMetadata(metadataProvider, target.model) : undefined; + if (!metadata) return undefined; + return { + ...(typeof metadata.contextWindow === "number" && metadata.contextWindow > 0 + ? { contextWindow: metadata.contextWindow } + : {}), + ...(typeof metadata.maxTokens === "number" && metadata.maxTokens > 0 + ? { maxOutputTokens: metadata.maxTokens } + : {}), + ...(Array.isArray(metadata.input) && metadata.input.length > 0 + ? { inputModalities: [...metadata.input] } + : {}), + ...(metadata.reasoning === true ? { reasoningEfforts: [...ROUTED_COMBO_MEMBER_REASONING_EFFORTS] } : {}), + }; +} + +/** + * Resolve a combo target to a catalog member for derivation. +``` + +**원 PR과의 유일한 차이는 `maxInputTokens:` → `maxOutputTokens:` 한 줄과 그 이유를 적은 JSDoc 문단이다.** + +#### hunk C — `resolveComboCatalogMember` 시그니처와 폴백 결정 (`:875-887`) + +before: + +```ts +export function resolveComboCatalogMember( + target: { provider: string; model: string }, + memberByKey: ReadonlyMap, + providers: ReadonlyMap, + contextCap?: number, + fallback?: ComboCatalogMemberFallback, + metadataModelIdCaseFold?: boolean, +): CatalogModel | undefined { + const existing = memberByKey.get(targetKey(target)); + const prov = providers.get(target.provider); + // Disabled providers never contribute members — even a complete discovery row + // is unusable for catalog derivation while the provider is off. + if (prov?.disabled === true) return undefined; +``` + +after: + +```ts +export function resolveComboCatalogMember( + target: { provider: string; model: string }, + memberByKey: ReadonlyMap, + providers: ReadonlyMap, + contextCap?: number, + callerFallback?: ComboCatalogMemberFallback, + metadataModelIdCaseFold?: boolean, +): CatalogModel | undefined { + const existing = memberByKey.get(targetKey(target)); + const prov = providers.get(target.provider); + const fallback = callerFallback ?? vendorMetadataComboFallback(target); + // Disabled providers never contribute members — even a complete discovery row + // is unusable for catalog derivation while the provider is off. + if (prov?.disabled === true) return undefined; +``` + +파라미터 이름만 바뀌고 함수 본문 나머지는 `fallback` 이라는 지역 상수를 계속 쓰므로 +아래 코드는 손대지 않는다. 유일한 외부 호출부는 `provider-fetch.ts:2153-2160` 이고 +positional 인자를 쓰므로 시그니처 호환이 유지된다. + +`callerFallback ?? ...` 순서가 중요하다. native alias 폴백 +(`:2140-2151` 의 `nativeAliasFallback`)이 넘어오면 그것이 이기고, +vendor 테이블은 caller가 아무것도 주지 않았을 때만 쓰인다. + +### 4.2 MODIFY `src/adapters/anthropic.ts` + +`modelRecordValue` 는 이미 `:30` 에서 import되어 있다. 추가 import 없음. + +#### hunk D — `:898-903` + +before: + +```ts + const tools = toolsToAnthropicFormat(parsed, toolNames); + + const body: Record = { + model: parsed.modelId, + messages, + stream: parsed.stream, + max_tokens: parsed.options.maxOutputTokens ?? DEFAULT_MAX_TOKENS, + }; +``` + +after: + +```ts + const tools = toolsToAnthropicFormat(parsed, toolNames); + + // Codex never sends `max_output_tokens`, so the omitted-limit default decides how + // long a Claude answer may run. Honor the provider's configured output budget + // (`modelMaxOutputTokens` / `defaultMaxOutputTokens`) before falling back to the + // conservative 8192, which truncates long answers with stop_reason=max_tokens. + const configuredMaxOut = modelRecordValue(provider.modelMaxOutputTokens, parsed.modelId) + ?? provider.defaultMaxOutputTokens; + const omittedMaxTokens = typeof configuredMaxOut === "number" && configuredMaxOut > 0 + ? configuredMaxOut + : DEFAULT_MAX_TOKENS; + const body: Record = { + model: parsed.modelId, + messages, + stream: parsed.stream, + max_tokens: parsed.options.maxOutputTokens ?? omittedMaxTokens, + }; +``` + +#### hunk E — adaptive thinking 분기 `:945` + +before: + +```ts + body.max_tokens = explicitMaxOut !== undefined + ? explicitMaxOut + : Math.min(ADAPTIVE_THINKING_CEILING, Math.max(DEFAULT_MAX_TOKENS, floor)); +``` + +after: + +```ts + body.max_tokens = explicitMaxOut !== undefined + ? explicitMaxOut + : Math.max(omittedMaxTokens, Math.min(ADAPTIVE_THINKING_CEILING, Math.max(DEFAULT_MAX_TOKENS, floor))); +``` + +#### hunk F — budget thinking 분기 `:951` + +before: + +```ts + const maxOut = parsed.options.maxOutputTokens ?? DEFAULT_MAX_TOKENS; +``` + +after: + +```ts + const maxOut = parsed.options.maxOutputTokens ?? omittedMaxTokens; +``` + +세 hunk 모두 원 PR 그대로다. 수정 없음. + +### 4.3 MODIFY `src/providers/registry.ts` + +#### hunk G — 상수 추가, `:351` (`ANTHROPIC_MODEL_CONTEXT_WINDOWS`) 바로 뒤 + +before: + +```ts +const ANTHROPIC_MODEL_CONTEXT_WINDOWS: Record = { "claude-fable-5-1": 1_000_000, ... "claude-haiku-4-5": 200_000 }; + +/** + * The effort rungs opencodex exposes for native Anthropic models. +``` + +after: + +```ts +const ANTHROPIC_MODEL_CONTEXT_WINDOWS: Record = { "claude-fable-5-1": 1_000_000, ... "claude-haiku-4-5": 200_000 }; +// Every current Claude family accepts at least 64k output tokens (Haiku 4.5 / Sonnet 4.x +// through Opus 5 and Fable 5). Anthropic caps max_tokens per model server-side, so a +// larger request never over-allocates; it only stops the 8192 truncation. +const ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS = 64_000; + +/** + * The effort rungs opencodex exposes for native Anthropic models. +``` + +#### hunk H — `anthropic` 엔트리 (`:1369-1373`) + +**원 PR과 컨텍스트가 다르다.** dev가 `modelReasoningEfforts` 줄을 추가했으므로 +그 줄을 보존한 채 삽입한다. + +before: + +```ts + models: [...ANTHROPIC_MODELS], + modelContextWindows: { ...ANTHROPIC_MODEL_CONTEXT_WINDOWS }, + modelReasoningEfforts: { ...ANTHROPIC_MODEL_REASONING_EFFORTS }, + defaultModel: "claude-sonnet-5", + }, + { + id: "anthropic-apikey", +``` + +after: + +```ts + models: [...ANTHROPIC_MODELS], + modelContextWindows: { ...ANTHROPIC_MODEL_CONTEXT_WINDOWS }, + modelReasoningEfforts: { ...ANTHROPIC_MODEL_REASONING_EFFORTS }, + // Codex omits max_output_tokens; without a provider budget the Anthropic adapter + // falls back to 8192, which truncates long answers with stop_reason=max_tokens. + defaultMaxOutputTokens: ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, + defaultModel: "claude-sonnet-5", + }, + { + id: "anthropic-apikey", +``` + +#### hunk I — `anthropic-apikey` 엔트리 (`:1385-1390`) + +before: + +```ts + models: [...ANTHROPIC_MODELS], + liveModels: true, + modelContextWindows: { ...ANTHROPIC_MODEL_CONTEXT_WINDOWS }, + modelReasoningEfforts: { ...ANTHROPIC_MODEL_REASONING_EFFORTS }, + defaultModel: "claude-sonnet-5", + }, +``` + +after: + +```ts + models: [...ANTHROPIC_MODELS], + liveModels: true, + modelContextWindows: { ...ANTHROPIC_MODEL_CONTEXT_WINDOWS }, + modelReasoningEfforts: { ...ANTHROPIC_MODEL_REASONING_EFFORTS }, + defaultMaxOutputTokens: ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, + defaultModel: "claude-sonnet-5", + }, +``` + +주의: `registry.ts:1354` 에 이미 `defaultMaxOutputTokens: 64_000` 이 있지만 +그것은 `command-code` 엔트리다. Anthropic과 무관하므로 이 hunk는 여전히 필요하다. + +### 4.4 MODIFY `tests/codex-catalog.test.ts` — 5장 참조 + +### 4.5 MODIFY `tests/anthropic-reasoning.test.ts` — 5장 참조 + +### DELETE + +없음. + +--- + +## 5. 회귀 테스트 + +### 5.1 원 PR 테스트로는 결함이 안 잡힌다 + +원 PR이 `tests/codex-catalog.test.ts` 에 넣은 단정은 이렇다: + +```ts + )).toMatchObject({ + contextWindow: 1_000_000, + inputModalities: ["text", "image"], + reasoningEfforts: ["low", "medium", "high", "xhigh", "max"], + }); +``` + +`toMatchObject` 는 **열거한 키만** 본다. `maxInputTokens` 를 적지 않았으므로 +그 값이 128k로 오염되어도 통과한다. `contextWindow` 는 1M 그대로 살아남기 때문에 +(`Math.min` 붕괴는 `maxInputTokens` 에서 일어난다) 이 단정은 결함을 볼 수 없다. + +실측으로 확인: PR 폴백을 넣은 member는 +`{ctx: 1000000, maxIn: 128000}` 이다. `contextWindow` 단정은 초록이다. + +### 5.2 ADD — 결함 저격 테스트 + +`tests/codex-catalog.test.ts` 의 `describe("combo catalog capability intersection")` +(`:170` 시작) 안, `:1552` 의 `resolveComboCatalogMember ... toBeUndefined()` 를 +닫는 `});` (`:1557`) 뒤에 추가한다. + +```ts + test("resolveComboCatalogMember restores vendor image and effort capabilities for thin Claude rows", () => { + const providers = new Map([["anthropic", { + adapter: "anthropic" as const, + baseUrl: "https://api.anthropic.com", + }]]); + // A discovery row that only carries id + window (the live Anthropic /models shape). + expect(resolveComboCatalogMember( + { provider: "anthropic", model: "claude-opus-5" }, + new Map([["anthropic/claude-opus-5", { provider: "anthropic", id: "claude-opus-5", contextWindow: 1_000_000 }]]), + providers, + )).toMatchObject({ + contextWindow: 1_000_000, + inputModalities: ["text", "image"], + reasoningEfforts: ["low", "medium", "high", "xhigh", "max"], + }); + // Point-release ids fall back to their family row in the vendor table. + expect(resolveComboCatalogMember( + { provider: "anthropic", model: "claude-fable-5-1" }, + new Map([["anthropic/claude-fable-5-1", { provider: "anthropic", id: "claude-fable-5-1", contextWindow: 1_000_000 }]]), + providers, + )).toMatchObject({ + inputModalities: ["text", "image"], + reasoningEfforts: ["low", "medium", "high", "xhigh", "max"], + }); + // An explicit caller fallback still wins over the vendor table. + expect(resolveComboCatalogMember( + { provider: "anthropic", model: "claude-opus-5" }, + new Map([["anthropic/claude-opus-5", { provider: "anthropic", id: "claude-opus-5", contextWindow: 1_000_000 }]]), + providers, + undefined, + { inputModalities: ["text"], reasoningEfforts: [] }, + )).toMatchObject({ inputModalities: ["text"], reasoningEfforts: [] }); + // Unknown ids keep their unknown ladder rather than inventing one. + expect(resolveComboCatalogMember( + { provider: "a", model: "ghost" }, + new Map(), + new Map([["a", { adapter: "openai-chat" as const, baseUrl: "https://a.example/v1" }]]), + )).not.toHaveProperty("reasoningEfforts"); + }); + + // 2.3의 결함을 저격한다. 위 테스트는 toMatchObject라 maxInputTokens를 보지 않으므로 + // 이 테스트가 없으면 OUTPUT 상한을 INPUT 슬롯에 넣는 회귀가 초록으로 통과한다. + test("vendor metadata fills the OUTPUT ceiling and never shrinks the combo input window", () => { + const providers = new Map([["anthropic", { + adapter: "anthropic" as const, + baseUrl: "https://api.anthropic.com", + }]]); + const member = resolveComboCatalogMember( + { provider: "anthropic", model: "claude-opus-5" }, + new Map([["anthropic/claude-opus-5", { provider: "anthropic", id: "claude-opus-5", contextWindow: 1_000_000 }]]), + providers, + ); + // claude-opus-5 vendor row is { contextWindow: 1e6, maxTokens: 128_000 }. + // maxTokens is the OUTPUT ceiling: it must land on maxOutputTokens, never maxInputTokens. + expect(member?.maxOutputTokens).toBe(128_000); + expect(member?.maxInputTokens ?? 1_000_000).toBe(1_000_000); + + // The intersection in deriveComboCatalogModel takes Math.min over member input + // ceilings, so a misplaced 128k would collapse a 1M combo window (and its + // autoCompact budget) for every other target in the group. + const peer: CatalogModel = { + provider: "xai", + id: "grok-4.6", + contextWindow: 1_000_000, + maxInputTokens: 1_000_000, + maxOutputTokens: 128_000, + inputModalities: ["text", "image"], + reasoningEfforts: ["low", "medium", "high", "xhigh", "max"], + }; + const derived = deriveComboCatalogModel("claude-grok", normalizedCombo({ + targets: [ + { provider: "anthropic", model: "claude-opus-5", weight: 1 }, + { provider: "xai", model: "grok-4.6", weight: 1 }, + ], + }), [member!, peer]); + expect(derived).toMatchObject({ + contextWindow: 1_000_000, + maxInputTokens: 1_000_000, + maxOutputTokens: 128_000, + autoCompactTokenLimit: 900_000, + }); + }); +``` + +`deriveComboCatalogModel`, `resolveComboCatalogMember`, `CatalogModel` 은 이미 +`tests/codex-catalog.test.ts:10` 에서 import되어 있고 `normalizedCombo` 헬퍼는 +`:74-89` 에 있다. 추가 import 불필요. + +**이 테스트가 결함을 실제로 잡는지 확인한 근거:** `.tmp/` 프로브에서 PR 원본 매핑을 흉내내 +`maxInputTokens: 128000` 폴백을 주입하면 combo가 +`{ctx: 1000000, maxIn: 128000, acl: 128000}` 로 나온다. +위 단정은 `maxIn: 1000000`, `acl: 900000` 을 요구하므로 **두 줄이 동시에 빨개진다.** +수정본에서는 `{ctx: 1000000, maxIn: 1000000, maxOut: 128000, acl: 900000}` 로 초록이다. + +### 5.3 ADD — output budget 테스트 + +`tests/anthropic-reasoning.test.ts` `:283` (adaptive-thinking 관련 테스트 사이)에 +원 PR 테스트를 그대로 추가한다: + +```ts + test("configured provider output budget replaces the 8192 default when the caller omits max_output_tokens", async () => { + const budgeted = { ...provider, defaultMaxOutputTokens: 64_000, modelMaxOutputTokens: { "claude-fable-5": 32_000 } }; + // No reasoning: the configured budget is the wire max_tokens. + expect((await bodyOf(parsed("none", {}, "claude-opus-5"), budgeted)).max_tokens).toBe(64_000); + expect((await bodyOf(parsed("none", {}, "claude-fable-5"), budgeted)).max_tokens).toBe(32_000); + // Adaptive thinking: the budget still wins over the headroom-derived ceiling. + expect((await bodyOf(parsed("max", {}, "claude-opus-5"), budgeted)).max_tokens).toBe(64_000); + // Budget thinking on an older family keeps max_tokens above the thinking budget. + const legacy = await bodyOf(parsed("high", {}, "claude-haiku-4-5"), budgeted); + expect(legacy.max_tokens as number).toBeGreaterThan((legacy.thinking as { budget_tokens: number }).budget_tokens); + // An explicit caller limit still wins over the configured budget. + expect((await bodyOf(parsed("none", { maxOutputTokens: 512 }, "claude-opus-5"), budgeted)).max_tokens).toBe(512); + }); +``` + +`bodyOf` 는 `:23` 에서 두 번째 인자로 provider를 받도록 이미 정의되어 있다 +(`configuredProvider = provider`). 헬퍼 수정 불필요. + +**현재 코드에서 이 테스트가 빨간지 확인함.** 어댑터를 고치기 전 실측값: + +| 케이스 | 현재 (budget 무시) | 테스트 기대 | +|---|---|---| +| `none` / opus-5 | 8192 | 64000 | +| `none` / fable-5 | 8192 | 32000 | +| `max` / opus-5 | 40192 | 64000 | +| `high` / haiku-4-5 | 24576 (budget 16384) | > budget — 현재도 통과 | +| explicit 512 | 512 | 512 — 현재도 통과 | + +앞의 세 줄이 실패하므로 테스트는 vacuous하지 않다. + +--- + +## 6. 조건 분기 activation scenario + +새로 생기는 분기는 셋이다. + +### 분기 1 — `callerFallback ?? vendorMetadataComboFallback(target)` + +- **발화 조건:** combo 타깃을 resolve할 때 caller가 fallback을 주지 않은 경우. + native alias combo가 아닌 모든 일반 combo가 여기 해당한다 + (`provider-fetch.ts:2144` 의 `nativeAliasFallback` 은 + `combo.nativeAlias && combo.alias && nativeContextWindow !== undefined` 일 때만 값이 있다). +- **C가 어떻게 발화시키는가:** `ocx` 설정에 Claude 타깃을 포함한 combo를 정의하고 + Anthropic 프로바이더를 켠 뒤 카탈로그를 재생성한다. 라이브 Anthropic `/models` 는 + id와 컨텍스트 창만 주므로 thin row가 된다. +- **관측 대상:** `GET /v1/models` 의 해당 combo 항목. + before: `inputModalities: ["text"]`, `reasoningEfforts: []`. + after: `["text","image"]`, `["low","medium","high","xhigh","max"]`, + 그리고 `maxInputTokens` 는 1,000,000 유지. +- **비발화 확인:** native alias combo는 caller fallback이 있으므로 vendor 테이블을 타지 않는다. + 5.2의 세 번째 단정이 이것을 고정한다. + +### 분기 2 — `comboMemberVendorMetadata` 의 point-release 트리밍 루프 + +- **발화 조건:** 정확 일치가 없고 모델 id가 `-<숫자>` 로 끝날 때. +- **C가 어떻게 발화시키는가:** combo 타깃으로 `anthropic/claude-fable-5-1` 을 쓴다. + 이 id는 vendor 테이블에 **없다** (`getModelMetadataCaseInsensitive("anthropic", "claude-fable-5-1")` + → `undefined`, 실측 확인). 한 번 트리밍하면 `claude-fable-5` 가 되고 그 행은 존재한다. +- **관측 대상:** 해당 멤버가 `inputModalities: ["text","image"]` 와 5단 ladder를 얻는다. +- **종료 보장:** `trimmed === candidate` (더 깎을 게 없음) 또는 + `!trimmed.includes("-")` 에서 `undefined` 반환. 매 반복마다 문자열이 짧아지므로 무한루프 없음. +- **비발화 확인:** `ghost` 같은 하이픈 없는 미지의 id는 첫 반복에서 + `trimmed === candidate` 로 즉시 빠진다. 5.2의 네 번째 단정이 고정한다. + +### 분기 3 — `omittedMaxTokens` (adapter) + +- **발화 조건:** `parsed.options.maxOutputTokens` 가 undefined이고 + 프로바이더에 `modelMaxOutputTokens[model]` 또는 `defaultMaxOutputTokens` 가 양수로 있을 때. +- **C가 어떻게 발화시키는가:** Codex는 `max_output_tokens` 를 보내지 않는다. + 따라서 Anthropic 프로바이더로 라우팅되는 **모든 Codex 요청**이 발화 조건이고, + hunk G~I가 registry에 64k를 넣는 순간 기본 발화한다. +- **관측 대상:** 업스트림 요청 body의 `max_tokens`. + 8192 → 64000. 사용자가 보는 증상으로는 긴 답변이 `stop_reason: "max_tokens"` 로 + 잘리던 것이 멈춘다. +- **비발화 확인:** 호출자가 명시적으로 `maxOutputTokens: 512` 를 주면 512가 유지된다 + (5.3의 마지막 단정). 프로바이더에 budget이 없으면 `DEFAULT_MAX_TOKENS` = 8192 그대로다 + (`tests/anthropic-reasoning.test.ts` 의 기존 40192/24576 단정이 이를 고정한다 — + 그 테스트들은 budget 없는 `provider` 를 쓴다). + +--- + +## 7. 필드 체인 (PLAN-FIELD-CHAIN-01) + +**새 필드는 추가하지 않는다.** `ComboCatalogMemberFallback.maxOutputTokens` 는 +`provider-fetch.ts:858` 에 이미 있고, `CatalogModel.maxOutputTokens` 는 +`parsing.ts:116` 에 이미 있다. registry의 `defaultMaxOutputTokens` 도 +`registry.ts:282` 에 이미 선언된 필드다. 이 작업은 **기존 필드에 값을 채우는 일**이다. + +그래도 값이 흐르는 경로 전체를 열거한다. + +### 7.1 `maxOutputTokens` 체인 (combo 폴백 경로) + +| 단계 | 위치 | 하는 일 | +|---|---|---| +| creation | `provider-fetch.ts` `vendorMetadataComboFallback` (신규) | vendor `metadata.maxTokens` → `maxOutputTokens` | +| 주입 | `resolveComboCatalogMember:885` (수정) | `callerFallback ?? vendor` | +| 소비 A (완전한 row) | `:896-898` `addMaxOutput`, `:918` | 멤버에 `maxOutputTokens` 가 없을 때만 채움 | +| 소비 B (합성 row) | `:996-997` | `positiveSafeInteger(hinted, base) ?? fallback` | +| 출력 | `:1025` | `CatalogModel` 에 실림 | +| combo 집계 | `aggregation.ts:165-169` | **모든** 멤버가 값을 가질 때만 `Math.min`, 아니면 `undefined` | +| combo 출력 | `aggregation.ts:189` | `...(maxOutputTokens !== undefined ? { maxOutputTokens } : {})` | +| serialization | `aggregation.ts:330` `normalizedOpenAiApiSignature` | `maxOutputTokens ?? null` — 시그니처에 포함 | +| serialization | `parsing.ts:116` `CatalogModel` | 카탈로그 JSON 필드 | + +**주의점 두 가지.** + +1. `aggregation.ts:168` 은 `knownMaxOutputTokens.length === members.length` 를 요구한다. + Claude 멤버 하나에만 값이 생기고 다른 멤버에 없으면 combo의 `maxOutputTokens` 는 + 여전히 `undefined` 다. 이는 **의도된 보수적 동작**이고 기존 로직이며 이번 변경 대상이 아니다. +2. `comboCatalogWarningSignature` (`aggregation.ts:225-247`)에는 + `maxOutputTokens` 가 **없다**. 반면 `maxInputTokens` 와 + `autoCompactTokenLimit` 은 있다(`:239-240`). + 따라서 PR 원본을 그대로 머지하면 이 경고 시그니처까지 값이 바뀌어 + `warnUncataloguedComboOnce` 의 dedupe 키가 달라진다. 수정본은 + `maxInputTokens` 를 건드리지 않으므로 시그니처가 변하지 않는다 — carry 수정의 부수 이득이다. + +### 7.2 `defaultMaxOutputTokens` 체인 (registry → adapter) + +| 단계 | 위치 | +|---|---| +| creation | `registry.ts:1373, 1389` (신규 값), 타입은 `:282` 에 기존 | +| seed 전파 | `providers/derive.ts:237, 297, 488` — 프로바이더 설정에 없으면 registry 값 주입 | +| 런타임 병합 | `router.ts:452-453` — `provider.defaultMaxOutputTokens === undefined` 일 때 registry 값 | +| login 경로 | `oauth/login-cli.ts:110`, `oauth/index.ts:1190` (허용 키 목록) | +| config 검증 | `config.ts:1388`, `server/auth-cors.ts:688, 808` (editor 권한) | +| rename 마이그레이션 | `providers/model-rename-migration.ts:86` (`modelMaxOutputTokens` 만 해당) | +| **소비 (이번에 신규)** | `adapters/anthropic.ts` hunk D/E/F | +| 기존 소비자 (변경 없음) | `openai-chat.ts:1403-1404`, `ollama-native.ts:1065-1066`, `command-code.ts:510` | +| 진단 표면 | `routing/compatibility/behavior.ts:185`, `provider-fetch.ts:586` (`maxOut` 진단 필드) | + +anthropic 어댑터가 쓰는 `modelRecordValue → defaultMaxOutputTokens` 우선순위는 +`openai-chat.ts:1403-1404` 와 동일한 패턴이다. 새 규약이 아니다. + +--- + +## 8. 건전성 재확인 (요청 항목) + +### 8.1 adapter output budget — 건전함 + +`modelRecordValue` (`src/reasoning-effort.ts:115-127`)는 정확 일치 → `:` 앞 family → +대소문자 무시 순으로 찾는다. `parsed.modelId` 는 어댑터 시점에서 프로바이더 접두사가 벗겨진 +순수 모델 id다(`body.model = parsed.modelId` 로 그대로 업스트림에 나감). 따라서 +registry의 `"claude-opus-5"` 키와 맞는다. + +세 hunk의 우선순위가 일관된다: **명시적 caller 값 > 설정된 budget > 8192**. +hunk E의 `Math.max(omittedMaxTokens, ...)` 는 budget이 adaptive 천장(40192)보다 +작을 때 천장을 유지하므로 thinking headroom을 깎지 않는다. budget 64k면 64k가 이긴다. + +hunk F의 `REASONING_MAX_TOKENS_CEILING = 32_000` 클램프는 그대로 살아있어 +legacy budget-thinking 경로에서 `max_tokens` 가 32k를 넘지 않는다. 실측에서 +`high/haiku-4-5` 가 24576, budget 16384로 부등식이 유지됨을 확인했다. + +### 8.2 registry `defaultMaxOutputTokens = 64_000` — 건전함 + +vendor 테이블 기준 현행 Claude 계열 OUTPUT 상한: opus-5 / sonnet-5 / fable-5 / +opus-4-6 / opus-4-8 / sonnet-4-6 = 128,000, haiku-4-5 = 64,000. +**64k는 전 계열의 최소값 이하**이므로 어떤 모델에서도 서버 상한을 넘지 않는다. +128k로 올리면 haiku-4-5에서 초과하므로 64k 선택이 맞다. + +`command-code` 가 같은 64k를 쓰고 있어(`:1354`) 선례도 있다. + +### 8.3 남는 비대칭 하나 (기록만, 이번 스코프 아님) + +`ANTHROPIC_MODEL_CONTEXT_WINDOWS` 는 `claude-fable-5-1` 을 포함하지만 +vendor 테이블에는 그 행이 없다. 그래서 registry 경유 경로는 창을 알고 +vendor 경유 폴백은 family 트리밍에 의존한다. 분기 2가 이를 흡수하므로 +동작 결함은 아니다. 테이블 갱신은 별도 작업. + +--- + +## 9. Verifier (PLAN-VERIFIER-REAL-01) + +**모두 실제로 실행했다.** 전체 스위트(`bun run test`, bare `bun test`)는 금지이므로 +focused 파일만 쓴다. + +| # | 커맨드 | exit | 이 커맨드가 변경 대상을 실제로 읽는가 | +|---|---|---|---| +| V1 | `bun test tests/codex-catalog.test.ts` | **0** (266 pass / 0 fail, 5.83s) | 읽는다 — `:10` 에서 `resolveComboCatalogMember`, `deriveComboCatalogModel` 을 `src/codex/catalog` 에서 직접 import하므로 hunk B/C를 실행 경로로 통과한다. | +| V2 | `bun test tests/anthropic-reasoning.test.ts` | **0** (66 pass / 0 fail, 111ms) | 읽는다 — `:2` 에서 `createAnthropicAdapter` 를 `src/adapters/anthropic` 에서 import하고 `buildRequest` 로 실제 body를 만든다. hunk D/E/F가 그 안이다. | +| V3 | `bun run typecheck` | 아래 참조 | 읽는다 — `tsc --noEmit` 가 저장소 전체를 검사하므로 `ModelMetadata` 타입 import(hunk A)와 `ComboCatalogMemberFallback` 구조 적합성을 강제한다. | +| V4 | `bun -e` vendor 테이블 조회 | **0** | 읽는다 — `src/generated/model-metadata.ts` 를 직접 로드해 `maxTokens` 가 OUTPUT임을 확인한 근거. | +| V5 | `.tmp/` 스크래치 프로브 | **0** | 읽는다 — `src/codex/catalog` 를 로드해 2.3 표의 실측값을 산출. **검증용이며 커밋하지 않는다.** | + +V1/V2는 **변경 전 baseline**으로 실행했다. 둘 다 초록이므로 이후 실패는 이번 변경이 원인이다. + +V3은 baseline에서 실행하지 않았다 — 변경 후 1회 실행이 필요하다. hunk A의 +`type ModelMetadata` import가 `verbatimModuleSyntax` 계열 설정과 충돌하지 않는지, +그리고 `callerFallback` 리네임이 호출부(`:2153`, positional)와 어긋나지 않는지를 +잡는 유일한 게이트다. + +### 실행 순서 + +```bash +bun run typecheck +bun test tests/codex-catalog.test.ts +bun test tests/anthropic-reasoning.test.ts +``` + +`bun run test:changed` 는 이 변경에 대해 위 두 파일을 포함하는 상위집합이므로 +추가 정보가 없다. 스코프 변경이므로 전체 스위트는 돌리지 않는다. + +### 빨간지 먼저 확인 (vacuous 방지) + +5.2와 5.3의 신규 테스트는 **프로덕션 코드를 고치기 전에** 먼저 추가해서 +빨간 것을 확인한 뒤 코드를 고친다. 5.3의 기대 실패는 8192 vs 64000 / 8192 vs 32000 / +40192 vs 64000 세 줄이다(실측 표 참조). + +--- + +## 10. Accept criteria + +1. `bun run typecheck` exit 0. +2. `bun test tests/codex-catalog.test.ts` exit 0, 신규 테스트 2개 포함해 268 pass 이상. +3. `bun test tests/anthropic-reasoning.test.ts` exit 0, 신규 테스트 1개 포함해 67 pass 이상. +4. `rg -n 'maxInputTokens: metadata.maxTokens' src/` 가 **아무것도 찾지 못한다** — + 결함이 재유입되지 않았다는 기계적 증거. +5. `rg -n 'maxOutputTokens: metadata.maxTokens' src/codex/catalog/provider-fetch.ts` 가 1건 매치. +6. thin Claude row가 `inputModalities: ["text","image"]` 와 5단 ladder를 얻고 + `maxInputTokens` 는 1,000,000을 유지한다 (5.2 단정). +7. combo 집계 결과 `autoCompactTokenLimit` 이 900,000이다 (128,000이 아니다). +8. budget이 설정된 Anthropic 프로바이더에서 caller가 `maxOutputTokens` 를 생략하면 + wire `max_tokens` 가 64,000이다. +9. budget이 **없는** 프로바이더의 기존 단정(40192 / 32768 / 24576)이 전부 그대로 통과한다 — + 회귀 없음. +10. 커밋 본문에 co-author 트레일러가 있다 (11장). +11. `git status` 에 `.tmp/` 산출물이 스테이징되지 않았다. + +--- + +## 11. 커밋 / PR 규약 + +원 저자는 `full999`(PR #3332). 트레일러에 쓸 이메일은 구현 시점에 다음으로 조회한다 — +**이 문서에 평문으로 적지 않는다.** `privacy:scan`이 devlog를 스캔하므로 여기에 이메일을 +박으면 CI가 막는다. + +``` +gh api repos/lidge-jun/opencodex/pulls/3332/commits --jq '.[].commit.author' +``` + +커밋 본문에 반드시 포함(스쿼시 후에도 살아남도록 브랜치 커밋 본문에 넣는다): + +``` +Co-authored-by: full999 <조회한 이메일> +``` + +`AGENTS.md` "Landing another author's work" 규정상 재구현/carry는 트레일러가 필수다. +산문으로 "reimplements #3332"라고만 쓰면 contributor 그래프에 잡히지 않는다 +(`CREDITS.md` 의 27건이 그렇게 누락된 사례). + +PR 설명에는 `.github/PULL_REQUEST_TEMPLATE.md` 의 Summary / Verification / Checklist를 +모두 채우고, 다음을 명시한다: + +- 이 PR이 #3332의 재구현이며 원 PR은 CONFLICTING/DIRTY라 체리픽 불가였다는 점. +- 원 PR 대비 **의도적으로 바꾼 한 줄**(`maxInputTokens` → `maxOutputTokens`)과 + 그것이 없으면 Claude combo 입력창이 1M → 128k로 붕괴한다는 근거. +- `Closes #3332` 가 아니라, dev 타깃이므로 머지 후 #3332를 수동으로 닫는다. +- base는 `dev`. + +--- + +## 12. 실행 순서 요약 + +1. 신규 테스트 2개를 먼저 추가하고 V1/V2를 돌려 **빨간 것을 확인**한다. +2. hunk A/B/C 적용 (`provider-fetch.ts`). +3. hunk D/E/F 적용 (`anthropic.ts`). +4. hunk G/H/I 적용 (`registry.ts`). +5. V3 → V1 → V2 순서로 실행, 전부 exit 0 확인. +6. Accept criteria 4/5의 `rg` 두 줄 확인. +7. `.tmp/` 스크래치 삭제 확인, `git status` 로 의도한 5파일만 변경되었는지 확인. +8. co-author 트레일러 포함해 커밋, `dev` 타깃 PR 생성. diff --git a/devlog/_plan/260904_priority65_closeout/030_wp4_combo_context_cap.md b/devlog/_plan/260904_priority65_closeout/030_wp4_combo_context_cap.md new file mode 100644 index 0000000000..dbceccddc3 --- /dev/null +++ b/devlog/_plan/260904_priority65_closeout/030_wp4_combo_context_cap.md @@ -0,0 +1,97 @@ +# wp4 — PR #3461 provider-specific context-cap failover 스쿼시 머지 + +대상: PR [#3461](https://github.com/lidge-jun/opencodex/pull/3461) (@RHODIZSECURITY), +head `4e16f889b`, +46/-0, 3파일. + +## 결함 + +`src/lib/errors.ts:183-189`가 `"maximum context"` 문자열을 무조건 +`context_length_exceeded`로 remap하고, `src/combos/failover.ts`의 stop 리스트가 +그 코드를 잡는다. 그래서 vendor code 5059를 쓰는 프로바이더가 +`Prompt 346030 > 262144 maximum context length`를 돌려주면, 뒤에 더 큰 컨텍스트 +타깃이 남아 있어도 combo 체인이 첫 타깃에서 끝난다. + +## 왜 이 PR이 맞는가 + +매처가 좁다. `isProviderTargetContextOverflow`는 status 400 **그리고** +(`invalid_request_prompt_too_long` 문자열 **또는** 코드 5059 **및** +`Prompt N > M maximum context length` 정규식)을 요구한다. 5059 단독은 여전히 stop이다. +PR의 테스트가 이 두 방향을 모두 단언한다. + +삽입 위치도 옳다. `isCyberPolicyCode` 뒤, stop 리스트 앞이라 정책 코드가 우선한다. + +`options.code`의 출처는 업스트림 응답 JSON이다 — +`normalizeUpstreamErrorText`(`src/server/responses/core.ts:770`)가 error 객체에서 +뽑는다. 최종 사용자가 주입하는 값은 아니지만, **업스트림 프로바이더는 이 코드를 +의도적으로 세울 수 있다.** 저장소는 이미 그 사실을 알고 판정을 내려 두었다 — +`src/combos/failover.ts:337`의 주석이 "an upstream can still SET this code +deliberately, since both extractors read the upstream error object. That is bounded +rather than dangerous"라고 적는다. + +이 판정을 그대로 승계한다. 최악의 경우가 "다음 combo 타깃으로 한 번 더 넘어간다"이고, +순회는 `pick.attempted`로 유한하므로(`src/combos/resolve.ts:224`) 무한 루프가 없다. + +## 기존 단정 보존 확인 + +`tests/combos.test.ts`의 다음 단정이 그대로 통과해야 한다. 셋 다 구조화 코드가 +없으므로 새 매처가 발화하지 않는다. + +- `comboFailureDecision(400, "context_length_exceeded")` → `stop` +- `comboFailureDecision(413, "request too large")` → `stop` +- `comboFailureDecision(400, "ordinary invalid request", { code: "5059" })` → `stop` + +이것이 #3348과 결정적으로 다른 점이다. #3348은 같은 문제를 풀면서 generic 410/413을 +통째로 hop 리스트에 넣어 위 단정 셋을 뒤집는다. + +## 스코프 + +- IN: PR을 있는 그대로 스쿼시 머지. +- OUT: 코드 수정, 리베이스, 추가 매처 확장. + +## 실행 절차 (P-phase 개정 — 직접 스쿼시에서 carry로) + +**개정 사유.** 계획 수립 시점에는 `gh pr merge 3461 --squash --admin`을 예정했다. +실행 직전 확인에서 전제 하나가 무너졌다: #3461은 **fork PR**이고 +(`head.repo.fork = true`, author `RHODIZSECURITY`), 그 head `4e16f889b`에는 +게이트 4개(enforce-target/hygiene/label/resolve-pr)만 돌았을 뿐 +**Cross-platform CI가 한 번도 실행되지 않았다.** `gh run list --commit 4e16f889b`가 +빈 출력이고, fork PR의 워크플로는 `action_required` 승인 대기로 걸린다. + +AGENTS.md가 적은 그대로다 — "fork contributors cannot start repository CI; a +maintainer has to". 게이트 4개만 초록인 상태를 "CI green"이라고 부르고 머지하면 +이 유닛의 criterion c-2(각 PR이 exact head에서 required CI green)를 위반한다. + +**개정안: 이 유닛의 stacked PR 체인에 carry한다.** + +1. `git fetch origin dev` — dev head를 다시 읽는다(이미 `2421e44ce` → `1a5c9ab23`으로 움직였다). +2. `git fetch origin pull/3461/head` 로 원 커밋을 가져와 `codex/priority65-closeout` + 위에 cherry-pick한다. 코드는 한 줄도 바꾸지 않는다 — 감사에서 그대로 머지 가능 판정이 났다. +3. cherry-pick은 원 author를 보존하지만, 스쿼시 머지에서 살아남는 것은 **트레일러**다. + `--no-commit` 후 트레일러를 붙여 커밋하거나 `git commit --amend`로 본문에 추가한다: + `Co-authored-by: RHODIZ IT <조회한 이메일>` — 이메일은 + `gh api repos/lidge-jun/opencodex/pulls/3461/commits --jq '.[].commit.author'`로 + 구현 시점에 조회한다. `privacy:scan`이 devlog를 읽으므로 여기에 평문으로 적지 않는다. +4. 이 브랜치의 PR에서 Cross-platform CI가 실제로 돌고 green인지 확인한다. +5. 머지 후 `git fetch origin dev && git merge-base --is-ancestor FETCH_HEAD`. +6. `git log -1 --format=%B | rg Co-authored-by` — 스쿼시 후 트레일러 생존 확인. +7. #3461은 `landed-via-maintainer` 라벨과 함께 close하고, 랜딩 SHA를 코멘트로 남긴다. + +## Accept criteria + +- 머지 SHA가 `origin/dev`의 조상이다. +- 스쿼시 커밋 본문에 `Co-authored-by: RHODIZ IT`가 남아 있다. +- 머지 후 `bun test tests/combos.test.ts`가 green (focused, 전체 스위트 아님). + +## Activation scenario + +새 분기 `isProviderTargetContextOverflow`는 조건 분기다. 발화 방법: +PR이 이미 넣은 e2e가 400 + 5059 + 정규식 일치 본문을 내는 가짜 업스트림을 세우고, +두 번째 타깃이 정확히 1회 히트(`backupHits === 1`)하며 200이 나오는 것을 관측한다. +반대로 코드 없는 generic 400은 hop하지 않고 stop한다는 것이 같은 테스트에서 단언된다. + +## Verifier 사전 확인 + +| 커맨드 | exit | 변경 대상을 읽는가 | +|--------|------|--------------------| +| `bun test tests/combos.test.ts` | 0 (머지 전 dev 기준) | 예 — `src/combos/failover.ts`를 import | +| `gh pr checks 3461` | 0 | 예 — head `4e16f889b`의 체크 롤업 | diff --git a/devlog/_plan/260904_priority65_closeout/040_wp5_reset_credit_identity.md b/devlog/_plan/260904_priority65_closeout/040_wp5_reset_credit_identity.md new file mode 100644 index 0000000000..97db7fc90b --- /dev/null +++ b/devlog/_plan/260904_priority65_closeout/040_wp5_reset_credit_identity.md @@ -0,0 +1,822 @@ +# wp5 — #3375 축 D: reset-credit 안정 operation identity + +상태: **READY** (BLOCKED 아님. 반증 검증 결과는 아래 0장 참조) +대상 브랜치: `codex/priority65-closeout` (base `origin/dev` = 2421e44ce, package 2.43.0) +작성 시점 확인 커밋: 2421e44ce + +--- + +## 0. 반증 먼저 — 원장은 "의도적 미배선"인가? + +결론: **의도적 미배선이라는 증거는 없다.** 미완성 배선이다. 근거는 넷이다. + +**(1) 원장은 단일 커밋으로 들어왔고, 배선 유보를 언급하지 않는다.** + +``` +$ git log --oneline -S openManualResetCreditOperation -- src/ +7c68768ca feat(codex): add durable reset-credit operation ledger (#1829) +``` + +7c68768ca 본문 전문: + +``` +feat(codex): add durable reset-credit operation ledger +fix(codex): snapshot reset-credit ledger generations +fix(codex): bound reset-credit ledger recovery validation +test(codex): harden reset-credit ledger invariants +fix(codex): reject partial reset-credit ledger loss +test(codex): prove reset-credit ledger rollback boundaries +``` + +전부 원장 자체의 내구성/불변식 문구다. "배선은 후속", "호출자 없음 의도" 같은 +유보 문장이 없다. 되돌림 커밋도 없다 — `git log -- src/codex/reset-credit-operation-ledger.ts` +는 7c68768ca 한 건뿐이다. + +**(2) manual 경로만 죽은 게 아니라 recovery 경로도 같이 죽어 있다.** + +``` +$ rg -n 'openResetCreditOperation|settleResetCreditOperation|markResetCreditOperationAmbiguous' -g '!node_modules' src/ | grep -v 'reset-credit-operation-ledger.ts' +(출력 없음) + +$ rg -n 'reset-credit-operation-ledger' -g '!node_modules' . +devlog/_plan/260902_nonbug_adoption_backlog/130_wp13_reset_credit_auto_redeem.md:6 +tests/codex-reset-credit-operation-ledger.test.ts:24 +``` + +"manual만 일부러 안 붙였다"면 recovery는 붙어 있어야 한다. 파일 전체가 +테스트에서만 import된다. 이건 특정 분기의 정책적 유보가 아니라 파일 단위 미배선이다. + +**(3) `setResetCreditOperationMigrationFaultForTests`(:485)의 존재 의미는 반대 방향 증거다.** + +이 함수는 `migrateLegacyTable`(:500) — legacy `recovery` 전용 스키마를 +`operation_kind IN ('recovery','manual')` 스키마로 올리는 마이그레이션 — 의 +첫 쓰기 직후에 합성 실패를 주입한다(:479 `failMigrationAfterFirstWriteForTests`). +즉 **디스크에 이미 legacy 원장 행이 존재하는 배포본**을 전제로, 그 행을 manual +지원 스키마로 승격하는 경로의 롤백을 증명한다. 배선할 생각이 없는 코드에 +legacy 행 승격 + 부분 실패 롤백을 넣지 않는다. 이건 "언젠가 켠다"가 아니라 +"켜진 상태의 데이터가 이미 있을 수 있다"를 방어하는 코드다. +또한 `process.env.OCX_TEST_HOME_GUARD !== "1"`이면 throw하므로(:487) 프로덕션 +오염 위험도 차단되어 있다. + +**(4) 별도 devlog가 이 원장을 "unused"로 명시하고 재사용을 계획한다.** + +`devlog/_plan/260902_nonbug_adoption_backlog/130_wp13_reset_credit_auto_redeem.md:6`: + +> An unused #657 ledger (`reset-credit-operation-ledger.ts`, kinds `recovery|manual`) exists. + +같은 문서가 slice 1에서 `"auto-redeem"` kind 추가를 계획한다. 즉 이 원장은 +폐기 대상이 아니라 확장 대상으로 취급되고 있다. + +**wp13과의 충돌 경계**: wp13은 새 kind `"auto-redeem"`을 추가하려 하고, 이 +wp5는 기존 `"manual"` kind를 배선한다. 두 작업은 스키마의 `operation_kind` +CHECK 제약을 공유한다. **wp5는 스키마를 건드리지 않는다**(3장 OUT 참조). +wp13이 먼저 착지하면 CHECK 문자열만 확장되고 wp5 diff는 그대로 적용된다. + +### 현재 결함 (배선 부재의 실제 관측 가능한 증상) + +`src/codex/auth-api.ts:2177`: + +```ts +const idempotencyKey = crypto.randomUUID(); +``` + +이 값이 :2187에서 업스트림으로 나간다: + +```ts +body: JSON.stringify({ redeem_request_id: idempotencyKey }), +``` + +**매 HTTP 요청마다 새 UUID가 생성된다.** 따라서 동일한 논리적 사용자 의도(버튼 +한 번 누름)가 네트워크 타임아웃으로 재시도되면 업스트림은 서로 다른 +`redeem_request_id` 두 개를 보고 **크레딧 2개를 소비한다.** 크레딧은 되돌릴 수 +없는 외부 상태이므로 이 결함의 비용은 비가역이다. + +대조군: `src/codex/reset-credit-auto-redeem.ts:163`은 같은 문제를 이미 +해결했다 — `redeemRequestId`를 저널에 먼저 쓰고 재시작 후 재사용한다. +manual 경로에만 그 보호가 없다. + +--- + +## 1. 스코프 경계 + +### IN + +1. `POST /api/codex-auth/reset-credits/consume` 가 body의 **선택적** `operationId`를 수용. +2. 그 id로 `openManualResetCreditOperation`을 호출해 원장 예약. +3. 예약된 id를 업스트림 `redeem_request_id`로 사용(랜덤 UUID 대체). +4. 업스트림 결과에 따라 `settleManualResetCreditOperation` / 실패 시 `markManualResetCreditOperationAmbiguous`. +5. 원장 분기(`terminal`/`capacity`/`identity-mismatch`/`unavailable`)별 HTTP 응답 확정. +6. CLI `ocx account reset-credits --consume --yes --operation-id `. +7. 회귀 테스트를 **red 우선**으로 추가. +8. **docs-site 관리 API 레퍼런스 8개 로케일 갱신** (감사 지적으로 IN 승격). 근거는 + AGENTS.md 리뷰 규정 "user-facing behavior changes should update `docs-site/`". + 이 유닛은 consume 엔드포인트에 **새 400 사유 + 신규 409 + 신규 503 2종**을 + 추가하므로 명백한 user-facing 계약 변경이다. 대상 줄은 4c 표에 정확히 열거했다. + +### OUT (이번 유닛에서 건드리지 않음) + +- **원장 SQL 스키마 / `CREATE_TABLE` / `operation_kind` CHECK** — wp13과 충돌. +- **recovery 경로 배선** (`openResetCreditOperation` 등). 별도 유닛. +- **GUI 자동 operationId 생성.** 3장 D-4 참조: GUI는 이번에 변경하지 않는다. +- `reset-credit-auto-redeem.ts` 저널을 원장으로 통합하는 작업. +- `GET /api/codex-auth/reset-credits` (inspect 경로). +- 업스트림 재시도 로직 자체 추가. 이 유닛은 **재시도가 안전해지도록** 만들 뿐, + 재시도를 구현하지 않는다. + +### 하위호환 계약 (필수) + +`operationId`가 **없으면 현재 동작과 바이트 단위로 동일해야 한다** — +원장을 열지 않고, `crypto.randomUUID()`로 `redeem_request_id`를 만든다. +기존 GUI/CLI/테스트가 그대로 통과해야 한다. 이것이 회귀 위험을 0으로 만드는 장치다. + +--- + +## 2. 원장 반환 타입 → HTTP 응답 결정표 + +`OpenManualResetCreditOperationResult` (`src/codex/reset-credit-operation-ledger.ts:98`): + +```ts +export type OpenManualResetCreditOperationResult = + | Readonly<{ kind: "execute"; operationId: CodexReservedOperationId; resumed: boolean }> + | Readonly<{ kind: "terminal"; operationId: CodexReservedOperationId; code: CodexResetCreditConsumeCode }> + | Readonly<{ kind: "capacity" | "identity-mismatch" | "unavailable" }>; +``` + +| kind | 의미 | HTTP | 응답 body | 업스트림 호출 | +|---|---|---|---|---| +| `execute` (`resumed:false`) | 신규 예약 성공 | — (계속 진행) | 정상 consume 결과 | **한다** | +| `execute` (`resumed:true`) | 같은 id 재시도, 미정산 | — (계속 진행) | 정상 consume 결과 | **한다** (같은 `redeem_request_id`이므로 업스트림이 멱등 처리) | +| `terminal` | 이미 정산된 id | **200** | `{ code: <저장된 code>, replayed: true }` | **안 한다** | +| `identity-mismatch` | 다른 계정이 소유한 id | **409** | `{ error: "operation_id_owned_by_another_account", code: "identity_mismatch" }` | **안 한다** | +| `capacity` | 원장 용량 초과 | **503** + `Retry-After: 1` | `{ error: "reset_credit_ledger_capacity", code: "capacity" }` | **안 한다** | +| `unavailable` | 원장 IO 실패 | **503** + `Retry-After: 1` | `{ error: "reset_credit_ledger_unavailable", code: "unavailable" }` | **안 한다** | + +### `unavailable`을 503으로 fail-closed 하는 이유 (설계 결정) + +대안은 "원장이 죽으면 랜덤 UUID로 폴백해서 서비스 지속"이다. **채택하지 않는다.** +호출자가 `operationId`를 명시했다는 것은 멱등성을 요구했다는 뜻이고, 폴백은 그 +요구를 조용히 무시하면서 정확히 이 유닛이 막으려는 이중소비를 재현한다. +크레딧은 비가역이므로 가용성보다 정확성이 우선이다. `operationId`를 안 보낸 +호출자는 애초에 이 경로에 들어오지 않으므로 영향받지 않는다. + +### `terminal`에서 업스트림을 재호출하지 않는 이유 + +`terminal`은 원장이 "이 id는 이미 끝났고 결과는 X"를 내구성 있게 아는 상태다. +재호출하면 업스트림 멱등성에만 의존하게 되는데, 그 보장은 우리 것이 아니다. +저장된 code를 그대로 재생하는 편이 강하다. + +**`remaining` 필드 주의**: `terminal` 재생 응답에는 `remaining`을 넣지 않는다. +현재 코드(:2199~2211)는 신선하게 파싱된 `available_count`가 있을 때만 `remaining`을 +넣는 규약이고(:2196-2198 주석), 재생 시점에는 그 신선한 값이 없다. `replayed: true`로 +구분만 준다. + +`UpdateResetCreditOperationResult` (:88) — `settle`/`ambiguous`의 반환: + +```ts +export type UpdateResetCreditOperationResult = + | Readonly<{ kind: "updated" }> + | Readonly<{ kind: "mismatch" | "unavailable" }>; +``` + +정산은 **업스트림 호출이 이미 끝난 뒤**에 일어난다. 즉 크레딧은 이미 쓰였다. +따라서 `mismatch`/`unavailable`이 와도 **사용자 응답을 실패로 바꾸지 않는다** — +실제로 성공한 소비를 실패로 보고하면 사용자가 다시 누르고, 그게 이중소비다. +정산 실패는 서버 로그로만 남긴다. 이 비대칭(열기는 fail-closed, 닫기는 fail-open)이 +이 설계의 핵심이다. + +--- + +## 3. 파일 변경 맵 + +### D-1. `src/codex/auth-api.ts` — MODIFY + +#### D-1a. import 추가 (파일 상단 import 블록) + +**AFTER (추가할 줄):** + +```ts +import { + markManualResetCreditOperationAmbiguous, + openManualResetCreditOperation, + settleManualResetCreditOperation, +} from "./reset-credit-operation-ledger"; +import { isCodexResetCreditOperationId } from "./reset-credit-recovery"; +``` + +주의: `auth-api.ts`가 이미 `./reset-credit-recovery`에서 import하는 게 있으면 +기존 절에 병합할 것. + +#### D-1b. consume 핸들러 — `src/codex/auth-api.ts:2168` 부터 + +**BEFORE (현재 :2168-2171):** + +```ts + if (url.pathname === "/api/codex-auth/reset-credits/consume" && req.method === "POST") { + const body = (await req.json().catch(() => ({}))) as { accountId?: string }; + if (!body.accountId) return jsonResponse({ error: "accountId required" }, 400); + const accountId = body.accountId; +``` + +**AFTER:** + +```ts + if (url.pathname === "/api/codex-auth/reset-credits/consume" && req.method === "POST") { + const body = (await req.json().catch(() => ({}))) as { + accountId?: string; + operationId?: unknown; + }; + if (!body.accountId) return jsonResponse({ error: "accountId required" }, 400); + const accountId = body.accountId; + // Optional caller-owned idempotency identity (#3375 axis D). Absent => legacy + // behavior: a fresh random redeem_request_id and no durable ledger row. + const hasOperationId = body.operationId !== undefined; + if (hasOperationId && !isCodexResetCreditOperationId(body.operationId)) { + return jsonResponse({ error: "Invalid operationId format" }, 400); + } + const requestedOperationId = hasOperationId ? body.operationId as string : undefined; +``` + +**중요 — 검증 순서**: `isCodexResetCreditOperationId`는 UUIDv4 형식만 통과시킨다 +(`src/codex/reset-credit-recovery.ts:36` `CODEX_RESET_CREDIT_OPERATION_ID_PATTERN`). +원장의 `snapshotManualIdentity`(:1154)는 형식 위반 시 **`TypeError`를 throw**하지 +result를 반환하지 않는다. 그러므로 반드시 원장 호출 **전에** 400으로 걷어내야 한다. +이걸 빠뜨리면 500 + 스택트레이스가 난다. + +#### D-1c. 업스트림 호출부 — `src/codex/auth-api.ts:2174-2194` + +**BEFORE (현재 :2174-2194):** + +```ts + const operation = await withResetCreditAuth(getRuntimeConfig(config), accountId, async auth => { + const idempotencyKey = crypto.randomUUID(); + const resp = await fetch( + "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume", + { + method: "POST", + headers: { + Authorization: `Bearer ${auth.accessToken}`, + "ChatGPT-Account-Id": auth.chatgptAccountId, + "Content-Type": "application/json", + }, + body: JSON.stringify({ redeem_request_id: idempotencyKey }), + signal: AbortSignal.timeout(10_000), + }, + ); + if (!resp.ok) { + await resp.body?.cancel().catch(() => {}); + return jsonResponse({ error: `Upstream error ${resp.status}` }, resp.status); + } + const result = safeResetCreditConsumeDto(await resp.json()); +``` + +**AFTER:** + +```ts + const operation = await withResetCreditAuth(getRuntimeConfig(config), accountId, async auth => { + // The ledger keys manual operations by the *physical* ChatGPT account, which is + // only known after the auth wrapper resolves credentials. Open here, not earlier. + const identity = requestedOperationId === undefined + ? undefined + : { + accountId, + chatgptAccountId: auth.chatgptAccountId, + operationId: requestedOperationId, + } as const; + let idempotencyKey: string; + if (identity) { + const opened = openManualResetCreditOperation(identity); + if (opened.kind === "terminal") { + // Durably settled already: replay the recorded outcome instead of + // trusting upstream idempotency for an irreversible spend. + return jsonResponse({ code: opened.code, replayed: true }); + } + if (opened.kind === "identity-mismatch") { + return jsonResponse({ + error: "operation_id_owned_by_another_account", + code: "identity_mismatch", + }, 409); + } + if (opened.kind !== "execute") { + // capacity | unavailable -> fail closed. Falling back to a random id + // would silently reintroduce the double-spend this identity prevents. + const response = jsonResponse({ + error: opened.kind === "capacity" + ? "reset_credit_ledger_capacity" + : "reset_credit_ledger_unavailable", + code: opened.kind, + }, 503); + response.headers.set("Retry-After", "1"); + return response; + } + idempotencyKey = opened.operationId; + } else { + idempotencyKey = crypto.randomUUID(); + } + let resp: Response; + try { + resp = await fetch( + "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume", + { + method: "POST", + headers: { + Authorization: `Bearer ${auth.accessToken}`, + "ChatGPT-Account-Id": auth.chatgptAccountId, + "Content-Type": "application/json", + }, + body: JSON.stringify({ redeem_request_id: idempotencyKey }), + signal: AbortSignal.timeout(10_000), + }, + ); + } catch (error) { + // Dispatch outcome unknown: the credit may or may not have been spent. + // Mark ambiguous so a replay of this same id is never treated as new. + if (identity) markManualResetCreditOperationAmbiguous(identity); + throw error; + } + if (!resp.ok) { + await resp.body?.cancel().catch(() => {}); + if (identity) markManualResetCreditOperationAmbiguous(identity); + return jsonResponse({ error: `Upstream error ${resp.status}` }, resp.status); + } + const result = safeResetCreditConsumeDto(await resp.json()); + if (identity) { + if (result.code === "reset" || result.code === "already_redeemed" + || result.code === "nothing_to_reset" || result.code === "no_credit") { + // Settlement failure never downgrades the user-visible outcome: the + // spend already happened upstream, and reporting failure would invite + // a manual retry -- the exact double-spend this unit removes. + settleManualResetCreditOperation(identity, result.code); + } else { + markManualResetCreditOperationAmbiguous(identity); + } + } +``` + +이후 `if (result.code === "reset" || result.code === "already_redeemed") { ... }` +(현재 :2199) 이하는 **변경 없음**. + +**`safeResetCreditConsumeDto`의 타입 주의** (:450): 반환은 `{ code: string }`이지 +`CodexResetCreditConsumeCode`가 아니다. 알 수 없는 code는 `"unknown"`으로 +정규화된다(:452). 그래서 위에서 네 개 리터럴을 명시적으로 좁혀야 `settle`의 +`code: CodexResetCreditConsumeCode` 파라미터에 타입이 맞는다. `as` 캐스트로 +우회하지 말 것 — `"unknown"`이 원장에 들어가면 `settle`이 `mismatch`를 반환한다. + +### D-2. `src/cli/account-auth.ts` — MODIFY + +#### D-2a. USAGE 문자열 — `src/cli/account-auth.ts:37` + +**BEFORE:** + +``` + ocx account reset-credits [--consume --yes] [--json] +``` + +**AFTER:** + +``` + ocx account reset-credits [--consume --yes [--operation-id ]] [--json] +``` + +#### D-2b. `resetCredits` 함수 — `src/cli/account-auth.ts:249-262` + +**BEFORE:** + +```ts +async function resetCredits(argv: string[], deps: RuntimeApiDeps): Promise { + const args = [...argv]; + const rawId = args.shift()?.trim(); + const wantsJson = takeFlag(args, "--json"); + const consume = takeFlag(args, "--consume"); + const yes = takeFlag(args, "--yes"); + if (!rawId) throw new CliUsageError("account id is required", USAGE); + if (consume && !yes) throw new CliUsageError("consuming a reset credit requires --yes", USAGE); + rejectArgs(args, USAGE); + const accountId = rawId === "main" ? "__main__" : rawId; + const result = consume + ? await runtimeRequest("/api/codex-auth/reset-credits/consume", { method: "POST", body: JSON.stringify({ accountId }) }, deps) + : await runtimeRequest(`/api/codex-auth/reset-credits?accountId=${encodeURIComponent(accountId)}`, {}, deps); + printData(result, wantsJson); +} +``` + +**AFTER:** + +```ts +async function resetCredits(argv: string[], deps: RuntimeApiDeps): Promise { + const args = [...argv]; + const rawId = args.shift()?.trim(); + const wantsJson = takeFlag(args, "--json"); + const consume = takeFlag(args, "--consume"); + const yes = takeFlag(args, "--yes"); + const operationId = takeOption(args, "--operation-id"); + if (!rawId) throw new CliUsageError("account id is required", USAGE); + if (consume && !yes) throw new CliUsageError("consuming a reset credit requires --yes", USAGE); + if (operationId !== undefined && !consume) { + throw new CliUsageError("--operation-id requires --consume", USAGE); + } + if (operationId !== undefined && !isCodexResetCreditOperationId(operationId)) { + throw new CliUsageError("--operation-id must be a UUIDv4", USAGE); + } + rejectArgs(args, USAGE); + const accountId = rawId === "main" ? "__main__" : rawId; + const result = consume + ? await runtimeRequest("/api/codex-auth/reset-credits/consume", { + method: "POST", + body: JSON.stringify({ accountId, ...(operationId === undefined ? {} : { operationId }) }), + }, deps) + : await runtimeRequest(`/api/codex-auth/reset-credits?accountId=${encodeURIComponent(accountId)}`, {}, deps); + printData(result, wantsJson); +} +``` + +import 추가: + +```ts +import { isCodexResetCreditOperationId } from "../codex/reset-credit-recovery"; +``` + +**`takeOption` 확정 사실** (감사 지적으로 재확인 — 이전 초안의 ":227" 인용은 오류였다): + +- 정의처는 `src/cli/account-auth.ts`가 **아니다**. `src/cli/runtime-api.ts:141`에 + 정의되어 있고, `account-auth.ts:11`에서 `takeOptionWithSyntax`(:12)와 함께 import된다. +- 실제 시그니처(`src/cli/runtime-api.ts:141`, 전문): + +```ts +export function takeOption(args: string[], flag: string): string | undefined { + const index = args.indexOf(flag); + if (index === -1) return undefined; + const value = args[index + 1]; + if (value === undefined || value.startsWith("--")) throw new CliUsageError(`${flag} requires a value`); + args.splice(index, 2); + return value; +} +``` + +- 반환 타입은 `string | undefined`로 **확정**. 미지정 시 `undefined`. +- `account-auth.ts` 내 기존 사용처는 :98(`--id`), :208(`--flow`), :238(`--flow`)이다. +- 부작용 3가지가 설계에 영향을 준다: + 1. `args`를 `splice`로 **변형**한다. 그래서 `rejectArgs` 앞에서 호출해야 남은 + 인자가 정확히 계산된다(위 코드가 그 순서다). + 2. 값이 없거나 다음 토큰이 `--`로 시작하면 **`CliUsageError`를 스스로 throw**한다. + 따라서 `ocx account reset-credits main --consume --yes --operation-id` 는 + 우리 코드가 아니라 `takeOption`이 `--operation-id requires a value`로 처리한다. + 3. `--flag=value` 형태는 이해하지 못한다(`runtime-api.ts:180-181` 주석). 즉 + `--operation-id=`는 `rejectArgs`로 떨어져 usage 에러가 된다. 이는 + 기존 `--id`/`--flow`와 동일한 동작이므로 새 예외를 만들지 않는다. +- `takeOptionWithSyntax`(:272)는 `--code` 같은 **비밀값** 전용(값 redaction 목적)이다. + operationId는 비밀이 아니므로 평범한 `takeOption`이 맞다. + +**import 비용 경고**: `src/cli/account-auth.ts`가 `../codex/reset-credit-recovery`를 +새로 import한다. 이 파일은 core-lab boundary 대상(`src/router.ts`, +`src/server/lifecycle.ts`, `src/server/responses/core.ts`)이 아니지만, +`tests/core-lab-boundary.test.ts`를 5장 verifier에 포함해 그래프 변화를 확인한다. +`reset-credit-recovery.ts`는 `./quota-rejection`과 `./account-id`만 import하는 +얕은 모듈이라 위험은 낮다. + +### D-3. `tests/codex-auth-api.test.ts` — MODIFY (테스트 추가) + +6장의 red-first 순서 참조. 기존 테스트는 수정하지 않는다 — 하위호환 계약이 +지켜지면 전부 그대로 통과해야 하고, 통과하지 않으면 그게 회귀 신호다. + +### D-4. `gui/src/components/codex-account-pool-handlers.ts` — **변경 없음 (의도적)** + +GUI에 operationId를 붙이려면 "언제 새 id를 만들고 언제 재사용하는가"를 정해야 +한다. 재사용 창을 잘못 잡으면 **사용자가 의도한 두 번째 소비가 첫 번째의 재생으로 +삼켜진다** — 이중소비의 정반대 방향 버그이고, 크레딧이 안 쓰였는데 쓰였다고 +보고한다. 이 정책은 서버 배선이 착지하고 실제 재시도 로그를 본 뒤 별도 유닛에서 +정한다. 이번 유닛의 GUI 동작은 현행 유지(=operationId 미전송=legacy 경로)다. + +### D-5. `docs-site/**/reference/management-api.md` — MODIFY (8개 로케일) + +정확한 파일/줄과 BEFORE/AFTER 전문은 **4c**에 있다. 중복 기재하지 않는다. +구현 순서상 D-1(서버 계약)이 확정된 뒤에 쓴다 — 상태코드가 바뀌면 8곳을 다시 +고쳐야 하므로 마지막이다. + +### D-6. `src/server/management/route-registry.ts` — **변경 없음 (확인만)** + +근거는 4c. 레지스트리는 body 스키마를 기술하지 않고, method/path/module/mutates가 +모두 그대로다. V6와 `git diff --exit-code`로 무변경을 증명한다. + +--- + +## 4. 필드 체인 (PLAN-FIELD-CHAIN-01) + +### 4a. `operationId` — 요청 body 신규 선택 필드 + +| 단계 | 위치 | 처리 | +|---|---|---| +| creation (CLI) | `src/cli/account-auth.ts` `resetCredits` | `takeOption(args,"--operation-id")`, 미지정 시 body에서 **키 자체를 생략** | +| creation (GUI) | `gui/src/components/codex-account-pool-handlers.ts:19` | **전송 안 함** (D-4) | +| serialization | `JSON.stringify({ accountId, ...(operationId===undefined?{}:{operationId}) })` | 스프레드로 키 부재 보장. `operationId: undefined`를 넣으면 안 됨 — 직렬화 결과는 같지만 부재/무효 구분이 흐려진다 | +| transport | `POST /api/codex-auth/reset-credits/consume` | — | +| deserialization | `auth-api.ts:2169` `await req.json()` | 타입 `{ accountId?: string; operationId?: unknown }`. `unknown`으로 받아 형식검증을 강제 | +| validation | `isCodexResetCreditOperationId` (`reset-credit-recovery.ts:40`) | 실패 → 400. **원장 호출 전** | +| consumer 1 | `openManualResetCreditOperation(identity)` | `identity.operationId` | +| consumer 2 | 업스트림 `redeem_request_id` | `opened.operationId` (원장이 반환한 canonical id — 요청 id와 **다를 수 있다**, 아래 주의) | +| consumer 3 | `settleManualResetCreditOperation(identity, code)` | `identity` (요청 id) | +| consumer 4 | `markManualResetCreditOperationAmbiguous(identity)` | `identity` (요청 id) | +| persistence | `reset_credit_operations` + manual id 히스토리 테이블 | 스키마 변경 없음 | +| consumer 5 (라우트 표면) | `src/server/management/route-registry.ts:97` | `{ method:"POST", path:"/api/codex-auth/reset-credits/consume", module:"codex/auth-api", mutates:true }` — **변경 불필요**, 근거는 4c | +| consumer 6 (docs, 8 로케일) | `docs-site/.../reference/management-api.md` | **변경 필요**. 4c 표 참조 | +| consumer 7 (CLI 인자 파싱) | `src/cli/runtime-api.ts:141` `takeOption` | `args` 배열을 splice로 변형, 값 누락 시 자체 `CliUsageError`. D-2b 참조 | + +### 4c. 라우트 레지스트리 및 docs 표면 (감사 Blocker #3 반영) + +**`src/server/management/route-registry.ts:97` — MODIFY 아님, 확인만 (명시적 OUT).** + +실제 등재 줄을 읽었다: + +```ts +{ method: "POST", path: "/api/codex-auth/reset-credits/consume", module: "codex/auth-api", mutates: true }, +``` + +레지스트리가 고정하는 것은 **method + path + module + mutates** 네 필드뿐이고 +**요청 body 스키마는 기술하지 않는다.** 이 유닛은 path/method/module을 바꾸지 않고, +`mutates`는 이미 `true`다(operationId 추가로 부작용 성격이 바뀌지 않는다 — 여전히 +크레딧을 소비한다). 따라서 이 파일은 **변경하지 않는다.** +다만 표면이 이 경로를 소유하므로 **회귀 검증자로는 포함한다**: +`bun test tests/management-route-registry.test.ts` (5장 V6). 이 표에 넣는 이유는 +"바꿔야 해서"가 아니라 "바뀌지 않았음을 증명해야 해서"다. + +**docs-site — MODIFY (8개 로케일).** 감사 지적은 5개 로케일이라 했으나 실제로 세어 +보니 **8개**다. `rg -n 'reset-credits/consume' docs-site/src/content/docs` 결과: + +| 로케일 | 파일 | 줄 | +|---|---|---| +| en | `docs-site/src/content/docs/reference/management-api.md` | 280 | +| tr | `docs-site/src/content/docs/tr/reference/management-api.md` | 275 | +| fr | `docs-site/src/content/docs/fr/reference/management-api.md` | 261 | +| ru | `docs-site/src/content/docs/ru/reference/management-api.md` | 250 | +| ko | `docs-site/src/content/docs/ko/reference/management-api.md` | 225 | +| zh-cn | `docs-site/src/content/docs/zh-cn/reference/management-api.md` | 224 | +| ja | `docs-site/src/content/docs/ja/reference/management-api.md` | 222 | +| zh-tw | `docs-site/src/content/docs/zh-tw/reference/management-api.md` | 217 | + +**BEFORE (en:280, 현재 전문):** + +``` +| `POST /api/codex-auth/reset-credits/consume` | Consume an eligible reset credit | 400 missing account id; upstream status passthrough; 503 `server_busy`; 500 consume failure | +``` + +**AFTER (en:280):** + +``` +| `POST /api/codex-auth/reset-credits/consume` | Consume an eligible reset credit. Optional `operationId` (UUIDv4) makes the redemption idempotent: the same id replays one durable outcome instead of spending a second credit. | 400 missing account id or invalid `operationId`; 409 `identity_mismatch` when the id belongs to another account; upstream status passthrough; 503 `server_busy`, `capacity`, or `unavailable`; 500 consume failure | +``` + +나머지 7개 로케일은 같은 셀에 **각 언어로** 동일 내용을 반영한다. 영어 원문과 +모순되지 않게 하는 것이 AGENTS.md 요구사항("keep translated locales from +contradicting the English source")이다. 번역 시 `operationId`, `identity_mismatch`, +`capacity`, `unavailable`, `server_busy`는 **식별자이므로 번역하지 않는다.** + +ko:225 예시: + +``` +| `POST /api/codex-auth/reset-credits/consume` | 사용할 수 있는 reset credit을 소비합니다. 선택적 `operationId`(UUIDv4)를 보내면 소비가 멱등해집니다 — 같은 id는 크레딧을 다시 쓰지 않고 저장된 결과 하나를 재생합니다. | 400 누락된 account id 또는 잘못된 `operationId`; id가 다른 계정 소유이면 409 `identity_mismatch`; upstream 상태 전달; 503 `server_busy`/`capacity`/`unavailable`; 500 소비 실패 | +``` + +**canonical id 주의 (놓치기 쉬움)**: `openManualResetCreditOperation`은 같은 +계정에 이미 활성 manual 작업이 있으면 요청 id를 **alias로 join**하고 +`current.operationId`(원래 id)를 반환한다(:1290-1318, "The upstream request keeps +the original durable id"). 그래서: + +- 업스트림에는 반드시 **`opened.operationId`** 를 보낸다 (요청 id 아님). +- `settle`/`ambiguous`에는 반드시 **`identity`(요청 id)** 를 넘긴다. + `settleManualResetCreditOperation`의 `SETTLE_MANUAL_IDS`가 canonical 기준으로 + 히스토리 전체를 정산하도록 설계되어 있다. 원장 테스트 :755-794가 이 join/정산 + 동작을 고정한다. + +반대로 하면 조용히 `mismatch`가 나고 원장이 pending으로 남는다. + +### 4b. `replayed` — 응답 body 신규 선택 필드 + +| 단계 | 위치 | 처리 | +|---|---|---| +| creation | `auth-api.ts` `terminal` 분기 | `{ code, replayed: true }` | +| 그 외 모든 응답 | 기존 경로 | **키 없음** (`replayed`를 false로 넣지 않는다) | +| consumer (CLI) | `printData(result, wantsJson)` | passthrough. `--json`이면 그대로 노출 | +| consumer (GUI) | `readJsonIfOk<{ code: string; remaining?: number }>` (handlers:23) | 타입에 없으므로 무시됨. **GUI 동작 변화 없음** — `code`가 `reset`/`already_redeemed`면 기존 성공 분기를 탄다 | +| consumer (테스트) | `toEqual` 쓰는 기존 assertion | `terminal`은 operationId 없이는 발생 불가하므로 기존 테스트 영향 없음 | + +`enum` / 영속 타입에 새 필드를 추가하지 않는다. `CodexResetCreditConsumeCode`, +`ResetCreditOperationState`, SQL 스키마 전부 불변이다. + +--- + +## 5. Verifier 커맨드 (PLAN-VERIFIER-REAL-01 — 실제 실행 결과) + +아래 V1~V3은 **베이스라인(변경 전) 실측치**다. 이 워크트리에서 실행했다. + +| # | 커맨드 | 실측 exit | 변경 대상을 실제로 읽는가 | +|---|---|---|---| +| V1 | `bun test tests/codex-reset-credit-operation-ledger.test.ts` | **0** (44 pass / 0 fail, 5.26s) | 예 — `src/codex/reset-credit-operation-ledger.ts`를 :24에서 직접 import하며, 배선이 호출할 세 함수를 모두 실행한다 | +| V2 | `bun test tests/codex-auth-api.test.ts` | **0** (199 pass / 0 fail, 1.12s) | 예 — `handleCodexAuthAPI`를 통해 `src/codex/auth-api.ts`의 consume 라우트를 실제 호출한다(consume 요청 26건, 예: :2453, :2488) | +| V3 | `bun run typecheck` | **0** (`bun x tsc --noEmit`, 0.99s) | 예 — 저장소 전체 strict 타입체크. `auth-api.ts`와 `account-auth.ts`의 신규 타입 좁히기를 커버한다 | +| V4 | `bun test tests/cli-account.test.ts` | **0** (110 pass / 0 fail, 396ms) | 예 — **A8의 실제 검증자**. `run()`(:410)이 `cmdAccount`를 직접 호출해 인자 파싱을 실행하고, `requests`(:76) 배열이 `{method, path, search, body}`(:140)를 기록한다. 즉 `--operation-id`가 body에 실렸는지 **관측 가능하다**. 이미 `reset-credits`를 인자로 실행하는 케이스가 있다(:1580) | +| V5 | `bun test tests/core-lab-boundary.test.ts` | (구현 단계에서 실행) | 예 — 런타임 import 그래프를 실제로 걷는다. D-2의 신규 import 파급을 검출한다 | +| V6 | `bun test tests/management-route-registry.test.ts` | (구현 단계에서 실행) | 예 — `route-registry.ts`를 읽어 등재 목록을 고정한다. 4c의 "레지스트리 무변경" 주장을 증명한다 | +| V7 | `bun test tests/cli-capabilities.test.ts` | (구현 단계에서 실행) | 예 — 단 **A8은 검증하지 못한다**. 아래 경고 참조 | + +### V7 경고 — `cli-capabilities.test.ts`는 A8의 검증자가 아니다 (감사 Blocker #3-3) + +이전 초안은 V4로 `tests/cli-capabilities.test.ts`를 지목하면서 A8("CLI +`--operation-id`가 body에 실린다")을 검증한다고 적었다. **틀렸다.** 이 파일의 +:220/:275는 다음과 같은 **라우트 문자열 목록**을 고정한다: + +``` +"POST /api/codex-auth/reset-credits/consume", +``` + +즉 고정 대상은 method+path 문자열이지 CLI 플래그가 아니다. `--operation-id`를 +추가해도 이 목록은 한 글자도 바뀌지 않으므로, 이 테스트는 **통과하든 실패하든 +A8에 대해 아무것도 말해주지 않는다.** 회귀 감시용으로 실행 목록에는 남기되 +(라우트 표면이 실수로 바뀌지 않았음을 확인), A8의 근거로 인용하지 않는다. + +V1/V2/V3 실행 로그 요약: + +``` +$ bun test tests/codex-reset-credit-operation-ledger.test.ts + 44 pass / 0 fail / 201 expect() calls [5.26s] -> exit 0 + +$ bun test tests/codex-auth-api.test.ts + 199 pass / 0 fail / 719 expect() calls [1120.00ms] -> exit 0 + +$ bun run typecheck + $ bun x tsc --noEmit -> exit 0 +``` + +**금지**: `bun run test`, 인자 없는 `bun test`(전체 스위트). 이 유닛의 변경은 +위 focused 집합으로 충분히 덮인다. AGENTS.md의 간접 의존 예외(서브프로세스, +데이터로 읽히는 소스, 골든 파일)에 해당하는 경로를 건드리지 않는다. + +--- + +## 6. 실행 순서 — red 먼저 (비가역 외부 상태이므로 필수) + +크레딧 소비는 되돌릴 수 없다. 구현 후 테스트를 쓰면 "구현이 하는 일"을 그대로 +베끼게 되고, 멱등성 결함은 정확히 그 방식으로 통과한다. 그래서 **테스트가 먼저 +실패하는 것을 눈으로 확인한 뒤** 구현한다. + +### 단계 1 — red 테스트 작성 (프로덕션 코드 손대지 않음) + +`tests/codex-auth-api.test.ts`에 추가. 기존 테스트 패턴(:2463-2500)을 그대로 +따른다: `globalThis.fetch` 스텁 + `handleCodexAuthAPI` 직접 호출 + `finally` 복원. + +R1. **같은 operationId 두 번 = 업스트림 소비 1회** (핵심 회귀) + +``` +let consumeCalls = 0; +const opId = "11111111-1111-4111-8111-111111111111"; +// 1회차: {accountId:"pool-x", operationId:opId} -> 200 {code:"reset", remaining:N} +// 2회차: 동일 body -> 200 {code:"reset", replayed:true} +expect(consumeCalls).toBe(1); +``` + +red 근거: 현재 `operationId`는 무시되므로 2회차도 업스트림을 때려 +`consumeCalls === 2`가 된다. **이 숫자가 이 유닛의 존재 이유다.** + +R2. **동일 operationId면 업스트림 `redeem_request_id`가 동일** + +스텁에서 요청 body를 파싱해 `redeem_request_id`를 수집. +red 근거: 현재는 매번 새 UUID → 두 값이 다르다. + +R3. **operationId 없으면 legacy 동작 그대로** (하위호환 계약) + +`{accountId}`만 보낸 요청이 원장을 만들지 않고 매번 새 `redeem_request_id`를 +쓰는지. 이건 처음부터 green이며 **구현 후에도 green이어야 한다** — red로 +만들지 않는다. 회귀 감시용이다. + +R4. **형식 위반 operationId → 400**, 원장/업스트림 미호출 + +`{accountId, operationId: "not-a-uuid"}` → 400 `Invalid operationId format`, +`consumeCalls === 0`. + +R5. **다른 계정의 operationId → 409** + +계정 A로 opId 예약 후 계정 B(다른 `chatgptAccountId`)로 같은 opId → 409 +`identity_mismatch`, 업스트림 미호출. + +R6. **업스트림 실패 후 재시도가 새 소비를 만들지 않음** + +1회차 스텁이 500 반환 → 응답 500, 원장은 ambiguous. +2회차 같은 opId → `resumed` 경로로 **같은** `redeem_request_id` 재전송. +red 근거: 현재는 2회차가 새 UUID를 만들어 이중소비 창을 연다. + +R7. **CLI 인자 파싱 — `tests/cli-account.test.ts`에 추가** (A8 전용, 감사 Blocker #3-3) + +이 테스트만이 CLI 표면을 실제로 관측한다. 기존 하네스를 그대로 쓴다: +`run()`(:410)이 `cmdAccount`를 호출하고, `requests`(:76)가 `body`(:140)를 기록한다. + +``` +// R7-a: --operation-id가 consume body에 실린다 +const opId = "11111111-1111-4111-8111-111111111111"; +requests.length = 0; +const ok = await run(["reset-credits", "main", "--consume", "--yes", "--operation-id", opId]); +const sent = requests.find(r => r.path === "/api/codex-auth/reset-credits/consume"); +expect((sent?.body as { operationId?: string })?.operationId).toBe(opId); + +// R7-b: --operation-id 없으면 키 자체가 없다 (하위호환) +expect(Object.prototype.hasOwnProperty.call(bodyOf(sent2), "operationId")).toBe(false); + +// R7-c: --consume 없이 쓰면 usage 에러 + 요청 0건 +const bad = await run(["reset-credits", "main", "--operation-id", opId]); +expect(bad.code).toBe(2); +expect(requests.length).toBe(0); + +// R7-d: 형식 위반은 CLI에서 걷힌다 (서버 왕복 없음) +const badId = await run(["reset-credits", "main", "--consume", "--yes", "--operation-id", "nope"]); +expect(badId.code).toBe(2); + +// R7-e: 값 누락은 takeOption 자체가 CliUsageError를 던진다 (runtime-api.ts:145) +const noValue = await run(["reset-credits", "main", "--consume", "--yes", "--operation-id"]); +expect(noValue.code).toBe(2); +``` + +red 근거: 현재 `resetCredits`는 `--operation-id`를 파싱하지 않으므로 R7-a는 +`undefined`가 되어 fail하고, R7-c는 `rejectArgs`가 알 수 없는 인자로 걷어내 +**우연히** exit 2가 될 수 있다 — 그래서 R7-c는 단독 근거로 쓰지 말고 R7-a와 함께 본다. + +### 단계 2 — red 확인 (증거 기록) + +``` +bun test tests/codex-auth-api.test.ts +bun test tests/cli-account.test.ts +``` + +R1, R2, R4, R5, R6, R7-a가 **fail**, R3이 **pass**. 이 출력을 유닛 노트에 붙인다. +R1이 fail하지 않으면 테스트가 결함을 못 잡고 있다는 뜻이니 **구현하지 말고** +테스트를 고친다. + +### 단계 3 — 구현 + +D-1 → D-2 → D-5(docs 8 로케일) 순서. 각 파일 후 `bun run typecheck`. + +### 단계 4 — green 확인 + +``` +bun test tests/codex-auth-api.test.ts +bun test tests/codex-reset-credit-operation-ledger.test.ts +bun test tests/cli-account.test.ts +bun test tests/management-route-registry.test.ts +bun test tests/cli-capabilities.test.ts +bun test tests/core-lab-boundary.test.ts +bun run typecheck +git diff --exit-code src/server/management/route-registry.ts +``` + +기대: R1~R7 전부 pass, `codex-auth-api` 기존 199건 유지, 원장 44건 유지, +`cli-account` 기존 110건 유지, 레지스트리 diff 없음(exit 0). + +--- + +## 7. Accept criteria (검증 가능) + +| # | 기준 | 검증 방법 | +|---|---|---| +| A1 | 같은 `operationId` 재요청이 업스트림 consume을 1회만 호출 | R1, `consumeCalls === 1` | +| A2 | 같은 `operationId`면 `redeem_request_id`가 동일 | R2, 수집값 비교 | +| A3 | `operationId` 부재 시 기존 동작 동일 | R3 + 기존 199건 무수정 통과 | +| A4 | 형식 위반 → 400, 500 아님 | R4 | +| A5 | 타 계정 id → 409, 업스트림 미호출 | R5 | +| A6 | 원장 unavailable/capacity → 503 + `Retry-After`, 랜덤 폴백 없음 | 원장 IO 실패 주입 후 상태코드 및 `consumeCalls===0` | +| A7 | 정산 실패가 성공 응답을 실패로 바꾸지 않음 | settle이 `mismatch`를 반환하도록 유도한 뒤 200 유지 확인 | +| A8 | CLI `--operation-id`가 body에 실린다 / `--consume` 없이 쓰면 usage 에러 | **`tests/cli-account.test.ts`** (R7). `run(["reset-credits","main","--consume","--yes","--operation-id",opId])` 후 `requests`에서 consume 요청을 찾아 `body.operationId === opId` assert. `cli-capabilities`가 아니다 — V7 경고 참조 | +| A9 | typecheck green | V3 | +| A10 | core-lab import 경계 유지 | V5 | +| A11 | docs-site 8개 로케일이 새 400/409/503 계약을 기술 | 4c 표의 8개 줄 수동 확인. 영어 원문과 모순 없음 | +| A12 | 라우트 레지스트리 무변경 | V6 green + `git diff --exit-code src/server/management/route-registry.ts` | + +--- + +## 8. 조건 분기 activation scenario + +분기를 추가하면 "누가 어떻게 발화시키고 무엇이 관측되는가"를 명시한다. + +| 분기 | 누가 발화 | 어떻게 | 관측 결과 | +|---|---|---|---| +| `hasOperationId === false` | 현재 GUI 버튼, `--operation-id` 없는 CLI | body에 키 없음 | 원장 행 0개, 매 요청 새 `redeem_request_id`, 응답에 `replayed` 없음 | +| 형식 위반 400 | 손으로 만든 curl, 잘못된 스크립트 | `operationId:"abc"` | 400 `Invalid operationId format`, 업스트림 0회, 원장 미변경 | +| `execute resumed:false` | `--operation-id <새 uuid>` 첫 호출 | 신규 id | 원장 pending 행 1개, 업스트림 1회, 200 | +| `execute resumed:true` | 타임아웃 후 같은 id 재실행 | 동일 id, 미정산 상태 | 업스트림에 **동일** `redeem_request_id` 재전송, 크레딧 추가 소비 없음 | +| `execute resumed:true` (alias join) | 다른 id로 재시도했는데 이전 작업이 아직 pending | 새 id, 같은 계정 | 업스트림에는 **원래** id가 나감. 새 id는 히스토리에 alias로 기록 | +| `terminal` | 정산 완료 후 같은 id 재실행 | 동일 id | **업스트림 0회**, 200 `{code, replayed:true}`, `remaining` 없음 | +| `identity-mismatch` | 계정 A의 id를 계정 B로 사용 | `chatgptAccountId` 상이 | 409, 업스트림 0회 | +| `capacity` | manual id 4096개 또는 계정 128개 초과 | 장기 운용 축적 | 503 + `Retry-After: 1`, 업스트림 0회 | +| `unavailable` | 원장 DB 파일 손상/권한 상실 | sqlite 열기 실패 | 503 + `Retry-After: 1`, 업스트림 0회, `[opencodex] Reset-credit operation ledger is unavailable.` 로그 | +| `ambiguous` (throw) | fetch 예외/타임아웃 | 네트워크 단절 | 원장 state=ambiguous, 500. 같은 id 재시도는 `resumed`로 복귀 | +| `ambiguous` (!resp.ok) | 업스트림 5xx | 스텁 500 | 원장 ambiguous, 상태코드 passthrough | +| `ambiguous` (unknown code) | 업스트림 스키마 변경 | `{code:"weird"}` → `"unknown"` | settle 아님, ambiguous. 200 + `{code:"unknown"}` | + +--- + +## 9. 리스크 + +1. **alias join의 canonical id 혼동** — 4a 주의 참조. 업스트림엔 `opened.operationId`, + 원장엔 `identity`. 뒤바꾸면 조용한 `mismatch`. R6가 이걸 잡는다. +2. **`snapshotManualIdentity`의 throw** — 형식 사전검증 누락 시 500. R4가 잡는다. +3. **`withResetCreditAuth` 내부에서 원장을 여는 구조** — `chatgptAccountId`가 + auth 해석 이후에만 존재하므로 불가피하다. main 계정 경로는 + `nativeMainLease`를 잡은 상태(:385)이므로 원장 IO가 길어지면 lease 보유가 + 길어진다. 원장은 로컬 sqlite 단일 트랜잭션이라 실측 영향은 무시 가능하지만, + 여기에 네트워크 호출을 추가하지 말 것. +4. **wp13과의 스키마 경합** — 3장 OUT으로 회피. 착지 순서 무관. diff --git a/devlog/_plan/260904_priority65_closeout/050_wp6_gate_unblock.md b/devlog/_plan/260904_priority65_closeout/050_wp6_gate_unblock.md new file mode 100644 index 0000000000..5588404b98 --- /dev/null +++ b/devlog/_plan/260904_priority65_closeout/050_wp6_gate_unblock.md @@ -0,0 +1,82 @@ +# wp6 — PR #3251 / #3327 게이트 해제 후 랜딩 + +두 PR 모두 코드는 정확하고, `enforce-target`이 GUI 스크린샷 게이트 하나 때문에 +빨간 상태다. 이 work-phase는 게이트를 정직하게 푸는 것이지 코드를 고치는 것이 아니다. + +## PR #3251 (@abhisheksharma2411, +194/-2, 12파일) + +요청한 service tier가 실제로 granted 됐는지 로그 UI에 표시한다. + +백엔드는 이미 `tierOutcome`을 GUI로 보낸다 — `src/server/request-log.ts:424`가 +`RequestLogEntry`에 싣고, 타입은 `src/types/provider.ts:129-133`의 +`confirmation: "confirmed" | "assumed" | "downgraded" | "unknown"` + `fastDowngradeReason`. +GUI 소비는 0건이다(`rg tierOutcome gui/src/`). 계산은 되는데 표시가 없는 공백이고, +이 PR은 순수 소비자 추가다. + +저자가 `assumed`를 `confirmed`로 바꾸지 않은 것이 중요하다. 그렇게 하면 #2558을 +재도입한다. 불확실성을 감추지 않고 드러내는 쪽이 맞다. + +### UX 판정 (cxc-dev-uiux-design) + +`UX-STATE-01` 관점에서 이 변경은 **불확실성 표현**이다. `assumed`는 로딩도 에러도 +아닌 제3의 상태이고, 이것을 `confirmed`처럼 보이게 하는 것이 원래 결함이었다. +세 문자열이 서로 구분되고 각각이 무엇을 뜻하는지 툴팁에서 읽히면 계약을 만족한다. + +i18n 키를 새로 만들지 않은 결정도 지지한다. 9개 로케일에 검증 불가능한 번역 8개를 +지어내는 것보다, 이웃 필드가 이미 번역 없이 출력하는 기술 식별자와 같은 취급이 정직하다. + +## PR #3327 (@olddonkey, +70/-10, 2파일) + +`tests/provider-quota.test.ts`(+26/-10)와 `gui/tests/provider-capacity-shell.test.tsx`(+44/-0). +프로덕션 파일 0개. `gui/tests/`를 건드려서 스크린샷 게이트에 걸린 것이고, 테스트만 +바꾼 PR에 UI 스크린샷을 요구하는 것은 게이트의 오탐이다. + +캐시 identity 단정이 실제 계약과 일치한다: `src/providers/quota.ts:2497`의 +`if (!forceRefresh && cacheFresh) return cache!.response`가 클론 없이 같은 객체를 +돌려주므로 `toBe` 비교가 옳다. + +### 남는 지적 하나 + +`expect(JSON.stringify(refreshed)).not.toContain("tier")`는 그물이 너무 넓다. +무관한 필드에 "tier" 문자열이 들어오면 오탐한다. 커밋을 하나 더 얹어 +`aggregation`/`currentAccount` 경로로 좁힌다. 형제 테스트와 스타일이 같아 +판정을 뒤집을 정도는 아니지만, 게이트를 여는 김에 정리한다. + +## 게이트 해제 방법 + +`.github/workflows/enforce-pr-target.yml`에 waiver 경로가 **두 개** 있다(`:755`의 +`screenshotWaivedByLabel || hasGuiOverride({ comments })`). + +1. **라벨 경로** — `gui-screenshot-waived` 라벨이 존재하고, **그 라벨을 붙인 사람이 + MAINTAINERS.md에 등재**되어야 한다(`:738-743`). 등재되지 않은 사람이 붙이면 + 무시하고 경고만 남긴다(`:744-747`). 라벨 provenance는 이벤트 로그의 최신 + `labeled`에서 읽는다. +2. **코멘트 경로** — `hasGuiOverride({ comments })`. 라벨보다 가벼우므로 일회성 + false positive에는 이쪽이 적절할 수 있다. + +`@lidge-jun`은 MAINTAINERS.md `:10`에 Project owner로 등재되어 있으므로 두 경로 모두 +유효하다. + +#3327은 이 waiver가 정확히 의도된 용례다 — 테스트 전용 PR에 대한 false positive. +#3251은 실제 UI 변경이므로 waiver가 아니라 스크린샷이 옳다. 스크린샷은 사람 산출물이라 +이 세션이 만들 수 없다. 따라서 #3251은 리베이스 + 게이트 상태 보고까지만 하고, +스크린샷 첨부 또는 waiver 판단은 메인테이너에게 남긴다. + +## 스코프 + +- IN: #3327 waiver + 그물 좁히는 커밋 + 머지. #3251 리베이스 + 현재 상태 보고. +- OUT: #3251에 가짜 스크린샷을 만들어 붙이는 것. #3379의 나머지 두 항목. + +## Accept criteria + +- #3327이 dev의 조상이 되고, 스쿼시 커밋에 `Co-authored-by: olddonkey` 트레일러가 남는다 + (이메일은 `gh api .../pulls/3327/commits`로 구현 시점 조회 — devlog에 평문 금지). +- 좁힌 단정이 `bun test tests/provider-quota.test.ts`에서 green. +- #3251은 dev 최신 위로 리베이스되고, 남은 차단이 스크린샷 하나임이 PR에 기록된다. + +## Verifier 사전 확인 + +| 커맨드 | exit | 변경 대상을 읽는가 | +|--------|------|--------------------| +| `bun test tests/provider-quota.test.ts` | 0 | 예 — `src/providers/quota.ts`를 import | +| `gh pr checks 3327` | 게이트 상태 | 예 — head SHA 체크 롤업 | diff --git a/devlog/_plan/260904_priority65_closeout/060_wp7_rollback_journal_crud.md b/devlog/_plan/260904_priority65_closeout/060_wp7_rollback_journal_crud.md new file mode 100644 index 0000000000..4e53d787c4 --- /dev/null +++ b/devlog/_plan/260904_priority65_closeout/060_wp7_rollback_journal_crud.md @@ -0,0 +1,1054 @@ +# WP7 — 롤백 저널 항목 삭제 CRUD (#3379 중 1/3) + +대상 이슈: #3379 "dashboard management gaps" (우선순위 57). +base: `origin/dev` = 2421e44ce, package 2.43.0, 브랜치 `codex/priority65-closeout`. + +이 문서는 **copy-paste 실행용 PRD**다. 아래 모든 코드 인용은 위 base에서 실제로 읽은 +현재 라인이다. 라인 번호는 그 시점 기준이며, 구현 시 앵커 문자열로 다시 찾을 것. + +--- + +## 0. 스코프 경계 + +### IN + +1. 롤백 저널 항목 **하나**를 대시보드에서 삭제하는 경로. + - `DELETE /api/client-integrations/journal` (opId 지정) + - `RollbackHistory.tsx` 행 단위 삭제 어포던스 + 확인 다이얼로그 + - 삭제 시 남아 있던 스냅샷 파일도 함께 제거 +2. 위 라우트가 통과해야 하는 기존 게이트 4종: 라우트 레지스트리, CLI capability 패리티, + 저널 불변식, locale 키 패리티. +3. i18n 9개 로케일 신규 키 전량. + +### OUT (이 work-phase에서 건드리지 않음) + +1. **커스텀 사용량 범위** (#2748 흡수분). `gui/src/pages/Usage.tsx`, `src/usage/summary.ts`, + 시간 단위 버킷팅, 범위 스코프 비용 추정 — 전부 다른 work-phase. +2. **계정 셀렉터 이름 변경** (#3017 흡수분). `gui/src/components/CodexAccountPickerSetting.tsx`, + `src/server/management/config-routes.ts`, `PATCH /api/config/codex/accounts/` — 전부 다른 + work-phase. +3. **일괄 삭제 / "전체 비우기"**. 아래 §2에서 설명하는 최신 행 보호 규칙 때문에 일괄 + 삭제는 별도 정책 결정이 필요하다. 이번엔 단건만. +4. 저널 보존 기간(TTL) 정책, 자동 만료. `SNAPSHOT_RETENTION`은 그대로 10. +5. `ocx` CLI 신규 서브커맨드 구현. §4.4에서 capability 게이트를 **exemption으로** 해소하며, + verb 자체는 후속 phase 소유로 명시한다. + +--- + +## 1. 현재 상태 — 실제 코드에서 확인한 공백 + +### 1.1 라우트는 GET 전용 + +`src/server/management/integration-routes.ts:322-323`: + +```ts + if (url.pathname === "/api/client-integrations/journal") { + if (req.method !== "GET") return null; +``` + +`return null`이므로 다른 메서드는 이 핸들러를 그냥 통과해 `management-api.ts:236`의 +`??` 체인 다음 후보로 넘어간다. 405도 아니고 404로 끝난다. + +### 1.2 GUI에 삭제 어포던스 없음 + +`gui/src/pages/integrations/RollbackHistory.tsx:44-57` — 행이 그리는 것은 두 가지뿐이다: +`expired` 배지, 아니면 복원 버튼 하나. + +```tsx + {row.snapshot === "expired" ? ( + // The only genuinely impossible case: the bytes are gone. + {t("integrations.action.snapshotExpired")} + ) : ( + + )} + +``` + +after: + +```tsx + {row.snapshot === "expired" ? ( + // The only genuinely impossible case: the bytes are gone. + {t("integrations.action.snapshotExpired")} + ) : ( + + )} + {/* + Delete sits AFTER restore, and only when the server says so. An expired + row keeps its badge and gains this button — that pairing is the point of + the feature: a row whose bytes are gone was previously a dead entry with + no action at all. + */} + {row.deletable && onDelete && ( + + )} + +``` + +props 확장 — `RollbackRow`와 `RollbackHistory` **양쪽**: + +```tsx + onRestore: (row: IntegrationJournalRow) => void; + /** Optional: a surface that cannot refresh the journal must not offer it. */ + onDelete?: (row: IntegrationJournalRow) => void; +``` + +`RollbackHistory`는 `onDelete`를 두 `RollbackRow` 호출부(현재 82행, 89행) 모두에 전달한다. +82행은 최신 행이라 서버가 `deletable: false`를 주지만, prop을 빠뜨리면 §2.3 규칙이 +*prop 누락*이라는 우연에 의존하게 된다. 규칙은 데이터가 강제한다. + +**CSS 변경 없음.** `gui/src/styles-integrations.css:151`의 모바일 규칙 +`.integration-history-row .btn { margin-left: auto; }`은 버튼이 둘일 때 첫 버튼만 밀어낸다. +420px 이하에서 두 버튼이 나란히 오른쪽 정렬되므로 새 규칙이 필요 없다. +구현 후 좁은 폭에서 눈으로 확인할 것. + +**색상.** `btn-danger`(styles.css:677)를 **쓰지 않는다.** D4-D5 밀도의 운영 도구에서 행마다 +빨간 버튼이 반복되면 목록 전체가 경고처럼 읽힌다. VARIANCE 2-4 범위를 지켜 `btn-ghost`로 두고, +파괴성은 확인 다이얼로그에서 전달한다. 다이얼로그 확인 버튼은 `ConsequenceDialog`가 이미 +`btn-primary`로 렌더한다(ConsequenceDialog.tsx:96) — 그것도 바꾸지 않는다. + +**MOTION 1-2.** 새 트랜지션·애니메이션 없음. 행 제거는 목록 재조회로 자연 반영된다. + +### 4.7 MODIFY `gui/src/pages/integrations/IntegrationsOverview.tsx` + +삭제 확인 상태 추가(179행 `restoring` 옆): + +```tsx + const [deleting, setDeleting] = useState(null); +``` + +646행: + +```tsx + +``` + +`RestoreDialog` 블록(649-655) 뒤에 확인 다이얼로그 추가: + +```tsx + {deleting && ( + setDeleting(null)} + onConfirm={async () => { + // ConsequenceDialog keeps the dialog open and renders a thrown + // message as a Notice (ConsequenceDialog.tsx:54-59), which is the + // error path this feature needs: not a dead end, and the confirm + // button re-enables so the same press is the retry. + await deleteJournalEntry(apiBase, deleting.opId); + setDeleting(null); + await historyResource.refresh(); + }} + /> + )} +``` + +`ConsequenceDialog`는 이 파일이 이미 import한다(pendingToggle/pendingOverwrite에서 사용 중). +`deleteJournalEntry`만 28행 import 블록에 추가한다. + +### 4.8 MODIFY `gui/src/pages/integrations/FileIntegrationPage.tsx` + +동일 패턴. 87행 옆에 `deleting` 상태, 273행에 `onDelete={setDeleting}`, +`RestoreDialog` 블록(276-282) 뒤에 §4.7과 같은 `ConsequenceDialog` 블록. +이 파일도 `ConsequenceDialog`를 이미 import하고 있다(overwriting에서 사용). + +### 4.9 MODIFY `gui/src/pages/integrations/refusal-copy.ts` + +`CODE_KEYS`(refusal-copy.ts:12-14)에 두 코드 등록. 근거는 §5.2. + +before: + +```ts +const CODE_KEYS: Record = { + integration_mutation_busy: "integrations.error.busy", +}; +``` + +after: + +```ts +const CODE_KEYS: Record = { + integration_mutation_busy: "integrations.error.busy", + integration_journal_newest_protected: "integrations.rollback.deleteNewest", + integration_operation_not_found: "integrations.rollback.deleteGone", +}; +``` + +### 4.10 MODIFY `gui/src/i18n/en.ts` + 8개 로케일 + +§5.3 참조. 신규 키 9개 × 로케일 9개 = 81개 항목. + +### 4.11 NEW `tests/management-integration-journal-delete.test.ts` + +§7의 분기 1~6. 기존 `tests/management-integration-routes.test.ts`의 픽스처 패턴 +(`setIntegrationMutationFlightTestHooks`로 임시 store 바인딩, +`setIntegrationPathTestHooks`로 임시 home 바인딩)을 그대로 따른다. + +### 4.12 DELETE + +없음. + +--- + +## 5. UX 명세 (cxc-dev-uiux-design) + +### 5.1 확인 단계 — UX-LAZY-01 STRICT 예외 + +삭제는 비가역이다. 스냅샷 바이트가 사라지고 툼스톤을 되돌리는 UI는 없다. +**magic default로 흡수 금지.** 반드시 확인 다이얼로그를 거친다. + +재사용 컴포넌트: `gui/src/pages/integrations/ConsequenceDialog.tsx`. +이 저장소가 이미 파괴적 액션에 쓰는 컴포넌트이며 4개 슬롯(변경/파급/되돌리기/확인)을 강제한다. +새 모달을 만들면 포커스 트랩·백드롭·pending 처리를 다시 구현하게 된다. + +**정확한 문구:** + +| 슬롯 | English | 한국어 | +|---|---|---| +| title | Delete this rollback entry? | 이 롤백 기록을 삭제할까요? | +| changes | This entry disappears from the rollback list, and any backup it still holds for {path} is deleted from disk. | 이 기록이 롤백 목록에서 사라지고, {path}에 대해 남아 있던 백업 파일도 디스크에서 삭제됩니다. | +| breakage | You will no longer be able to restore the file to this point. Newer entries and the file itself are untouched. | 이 시점으로는 더 이상 파일을 되돌릴 수 없습니다. 더 최근 기록과 파일 자체는 그대로입니다. | +| undo | This cannot be undone. The most recent entry for each client is kept and cannot be deleted. | 되돌릴 수 없습니다. 클라이언트별 가장 최근 기록은 삭제되지 않고 남습니다. | +| confirm | Delete entry | 기록 삭제 | + +`undo` 슬롯이 최신 행 보호를 말하는 것이 중요하다. 사용자가 "그럼 최근 것도 실수로 +지워지나?"를 묻기 전에 답한다. + +행 버튼 라벨: **Delete** / **삭제**. `aria-label`은 스크린리더 사용자가 어느 행인지 +구분할 수 있도록 시각을 포함한다: "Delete the rollback entry from {at}" / +"{at} 롤백 기록 삭제". + +### 5.2 상태별 화면 (UX-STATE-01) + +| 상태 | 무엇을 보여주는가 | 근거 | +|---|---|---| +| **empty** (저널 0행) | 변경 없음. 기존 `integrations.rollback.empty` + `emptyBody`를 그대로 쓴다. Overview는 `integration-empty` 블록(640-644행), 클라이언트 탭은 `page-sub` 한 줄(270-271행). | 삭제 기능은 빈 상태에 새 문구를 요구하지 않는다. 새 문구를 넣으면 "방금 지워서 비었다"와 "원래 비었다"가 구분되는 척하게 되는데, 응답은 그 구분을 담지 않는다. 그리고 최신 행은 삭제 불가이므로 **삭제만으로 목록이 비는 일은 애초에 없다**. | +| **삭제 진행 중** (loading) | `ConsequenceDialog`의 기존 pending 동작: 확인 버튼 `disabled`(96행), 닫기·백드롭 비활성(80·90행). 목록은 그대로 두고 스켈레톤으로 바꾸지 않는다. | optimistic 제거는 실패 시 행을 되살려야 해서 깜빡임을 만든다. 다이얼로그가 열려 있는 동안 목록이 안정적으로 남는 편이 D4-D5 밀도에 맞다. | +| **삭제 실패** (error) | 다이얼로그가 **열린 채로** 남고 `Notice tone="err"`에 사유가 뜬다(ConsequenceDialog.tsx:93). 확인 버튼은 다시 활성화된다(58행 `setPending(false)`). → **재시도 = 같은 버튼 다시 누르기.** 포기하려면 Close. | dead-end 금지. 실패가 다이얼로그를 닫아버리면 사용자는 무엇이 남았는지 모른 채 목록으로 돌아간다. | +| **409 최신 행 보호** | 정상 경로에서는 버튼이 없어 도달 불가. 탭 두 개를 열어두면 도달 가능하며 위 error 상태로 표시된다. 문구는 `integrations.rollback.deleteNewest`. | 서버가 진실이므로 UI가 뒤처져도 안전하게 실패한다. | +| **404 이미 삭제됨** | 같은 error 표시, 문구는 `integrations.rollback.deleteGone`. Close하면 갱신된 목록을 본다. | 탭 두 개 시나리오. 재시도해도 같은 404이므로 문구가 "목록을 새로 불러옵니다"로 다음 행동을 지정한다. | + +**실패 사유 문구 연결.** `describeRefusal`(refusal-copy.ts:78-109)은 `reason` 필드가 있는 +writer refusal만 로컬라이즈한다. 이 라우트의 409/404는 `reason`이 없으므로 refusal이 아니다. +그래서 §4.9에서 `CODE_KEYS`에 두 코드를 등록한다. 등록하지 않으면 서버의 영어 `error` +문자열이 모든 로케일에 그대로 노출된다 — refusal-copy.ts:5-11 주석이 정확히 그 사고를 기록한다. + +단, `ConsequenceDialog`의 catch는 `error.message`를 쓴다(57행). 따라서 둘 중 하나를 택한다: + +- **(권장)** `onConfirm` 안에서 잡아 `new Error(describeRefusal(t, error))`로 다시 던진다. + 다른 다이얼로그 3곳에 영향이 없다. +- `ConsequenceDialog`가 `describeRefusal`을 직접 쓰게 한다. 영향 범위가 넓다. + +선택을 §7 시나리오 10의 테스트로 고정할 것. + +### 5.3 i18n 키 전량 + +이 저장소는 로케일을 `gui/src/i18n/.ts` 9개 파일로 관리하고 +`gui/src/i18n/catalogs.ts:24-34`가 등록한다. `en`이 `TKey`의 원천이고(en.ts:8), +나머지는 `Record`(예: ko.ts:6)이므로 **키를 빠뜨리면 타입 에러**가 난다. +추가로 `gui/tests/i18n-locales.test.ts:33-40`이 키 집합 완전 일치를 강제한다. + +대상 파일 9개: `en.ts`, `ko.ts`, `ja.ts`, `zh.ts`, `zh-TW.ts`, `de.ts`, `fr.ts`, `ru.ts`, `tr.ts`. +삽입 위치는 기존 `integrations.rollback.*` 블록 옆(en.ts:1636-1642 부근). + +신규 키 **9개** (en 값): + +```ts + "integrations.rollback.delete": "Delete", + "integrations.rollback.deleteAria": "Delete the rollback entry from {at}", + "integrations.rollback.deleteNewest": "The most recent entry for this client is kept so you can still undo it.", + "integrations.rollback.deleteGone": "This entry was already deleted. The list will refresh.", + "integrations.dialog.deleteEntry.title": "Delete this rollback entry?", + "integrations.dialog.deleteEntry.changes": "This entry disappears from the rollback list, and any backup it still holds for {path} is deleted from disk.", + "integrations.dialog.deleteEntry.breakage": "You will no longer be able to restore the file to this point. Newer entries and the file itself are untouched.", + "integrations.dialog.deleteEntry.undo": "This cannot be undone. The most recent entry for each client is kept and cannot be deleted.", + "integrations.dialog.deleteEntry.confirm": "Delete entry", +``` + +ko 값: + +```ts + "integrations.rollback.delete": "삭제", + "integrations.rollback.deleteAria": "{at} 롤백 기록 삭제", + "integrations.rollback.deleteNewest": "이 클라이언트의 가장 최근 기록은 되돌리기를 위해 남겨 둡니다.", + "integrations.rollback.deleteGone": "이미 삭제된 기록입니다. 목록을 새로 불러옵니다.", + "integrations.dialog.deleteEntry.title": "이 롤백 기록을 삭제할까요?", + "integrations.dialog.deleteEntry.changes": "이 기록이 롤백 목록에서 사라지고, {path}에 대해 남아 있던 백업 파일도 디스크에서 삭제됩니다.", + "integrations.dialog.deleteEntry.breakage": "이 시점으로는 더 이상 파일을 되돌릴 수 없습니다. 더 최근 기록과 파일 자체는 그대로입니다.", + "integrations.dialog.deleteEntry.undo": "되돌릴 수 없습니다. 클라이언트별 가장 최근 기록은 삭제되지 않고 남습니다.", + "integrations.dialog.deleteEntry.confirm": "기록 삭제", +``` + +나머지 7개 로케일은 같은 의미로 번역한다. 영어를 복사해 넣지 말 것 — +`i18n-locales.test.ts`는 키 존재만 보므로 영어 복붙을 잡지 못한다. +`{path}`와 `{at}` 자리표시자는 모든 로케일에서 유지해야 한다. + +--- + +## 6. 권한 · 감사 · 동시성 + +### 6.1 권한 + +이 저장소에는 admin/editor 같은 **역할(role) 모델이 없다.** 실제 principal 유니온은 +`src/server/management-auth.ts:282-289`: + +```ts +export type ManagementPrincipal = + | "admin-token" + | "gui-session" + | "gui-pair-capability" + | "local-read-capability" + | "local-provider-reload-capability" + | "system-restart-capability"; +``` + +따라서 "admin이냐 editor냐"에 대한 이 저장소에서의 답은 다음과 같다: + +- **DELETE에 별도 권한 게이트를 추가하지 않는다.** `requireManagementAuth` + (management-auth.ts:507-521)가 관리 API 전체를 덮고, 이 라우트는 `admin-token`과 + `gui-session` 둘 다 허용한다 — `PUT /api/client-integrations/{clientId}`(실제 클라이언트 + 설정 파일을 고쳐 쓰는, 훨씬 파괴적인 라우트)와 같은 수준이다. 메타데이터 한 줄 삭제에 + 더 높은 문턱을 두는 것은 일관성이 없다. +- **`session-only`로 좁히지 않는다.** 그 exemption은 사용자의 계정·평판을 쓰는 경계 + (GitHub star, AGENTS.md "User-consent actions")에 쓰인다. 저널 삭제는 로컬 파일 정리이므로 + 해당 없다. +- **`local-read-capability`는 자동으로 배제된다.** 그 principal은 정확한 두 개의 GET + 경로에서만 인정된다(management-auth.ts:277-280, 330행 `req.method !== "GET"`). + DELETE는 도달하지 못한다. +- **CSRF는 자동 적용된다.** `gui-session`은 origin + per-session CSRF 토큰 일치를 요구한다 + (management-auth.ts:274-277). GUI 쪽은 api.ts:199가 GET/HEAD가 아닌 모든 메서드에 헤더를 + 붙이므로 DELETE도 포함된다. 기존 테스트 `management-integration-routes.test.ts`의 + "a GUI-session mutation without CSRF is rejected before integration dispatch"가 이 계층을 + 이미 지킨다. + +### 6.2 감사 로그 + +**전용 감사 로그를 새로 만들지 않는다.** §2.2의 툼스톤 자체가 감사 기록이다: +누가(`by`) 언제(`at`) 어느 opId를 은퇴시켰는지가 저널에 영구히 남는다. 툼스톤은 append-only +파일에 있으므로 이후 삭제 요청으로도 지워지지 않는다 — 삭제는 `JournalEntry`만 대상으로 한다. + +- `by`에는 **principal 이름만** 넣는다(`"gui-session"` / `"admin-token"`). + 토큰·세션 ID·경로는 절대 넣지 않는다. `tests/integrations-invariants.test.ts:123-143`이 + 저널을 "메타데이터일 뿐 파일 사본이 아님"으로 고정하고, `bun run privacy:scan`이 + 자격증명 유출을 막는다. +- 별도 파일을 만들지 않는 이유는 §2.2 표 마지막 행과 같다 — 저장소 루트 엔트리 목록이 + 테스트로 고정되어 있다. +- `console.error` 등 stdout 로깅도 추가하지 않는다. 정상 동작이지 실패가 아니다. + +### 6.3 동시성 + +네 가지 경합을 구분해 다룬다. + +**(a) 다른 프로세스가 저널에 append 중일 때.** +안전하다. 툼스톤도 append이고, `appendFileSync`는 `O_APPEND`로 열어 쓰므로 줄 단위 추가가 +서로를 덮지 않는다 — append-only 설계가 이미 이 경우를 위해 존재한다. 재작성 방식이었다면 +여기서 줄이 유실된다. 이것이 §2.2를 채택한 첫 번째 이유다. + +**(b) 삭제와 복원이 동시에.** +`restoreIntegrationCoordinated`는 `writer.ts:582`에서 `store.findOperation(input.opId)`로 +행을 다시 읽는다. 툼스톤이 먼저 커밋되면 그 조회가 null이 되어 복원은 기존 거절 경로를 탄다. +반대 순서면 복원이 끝난 뒤 삭제된다 — 둘 다 일관된 결과다. +**중요:** 복원이 성공하면 새 `restore` 행이 append되어 그 클라이언트의 최신 행이 바뀐다. +따라서 삭제 대상이 그 사이 최신 행이 될 수 있고, 그때는 409가 정답이다. 핸들러가 +`listOperations(clientId, 1)`을 **툼스톤 쓰기 직전에** 다시 읽는 이유가 이것이다. +다이얼로그가 열려 있던 동안의 상태를 신뢰하지 않는다. + +**(c) 삭제와 apply/disable 토글이 동시에.** +`runIntegrationMutationFlight`(mutation-flight.ts:27-64)에 **넣지 않는다.** +그 비행은 클라이언트당 하나이며 설정 파일 쓰기를 직렬화하기 위한 것이다. +저널 삭제는 설정 파일을 만지지 않으므로 토글을 막을 이유가 없고, 막으면 사용자가 토글 중에 +목록 정리를 못 하게 된다. 대신 (b)의 재조회가 정합성을 책임진다. +**결과적으로 이 라우트는 409 `integration_mutation_busy`를 절대 반환하지 않는다.** + +**(d) 스냅샷 삭제와 prune이 동시에.** +`pruneSnapshots`(journal.ts:222-250)를 그대로 재사용하므로 새 경합이 없다. +`rmSync(..., { force: true })`라 이미 사라진 파일은 성공으로 처리된다. +실패하면 `markPruneFailure` → `retentionDegraded`로 사용자에게 드러난다(state.ts:402-413). + +--- + +## 7. 조건 분기 activation 시나리오 + +이 변경이 추가하는 분기와, 각 분기를 **무엇이 발화시키고 무엇이 관측되는지**. + +| # | 분기 | C(호출자)가 어떻게 발화시키는가 | 관측되는 것 | +|---|---|---|---| +| 1 | `isTombstone(parsed)` = true | 저널에 `{"tombstone":"X",...}` 줄이 존재 | `listOperations`가 opId X 행을 반환하지 않음 | +| 2 | `retired.size === 0` 빠른 경로 | 툼스톤이 하나도 없는 기존 저널 | 반환 배열이 필터 전과 동일 — 기존 동작 무변경 | +| 3 | `!opId` → 400 | `DELETE /api/client-integrations/journal` (쿼리 없음) | `{code:"invalid_op_id"}`, 400 | +| 4 | `!operation` → 404 | 없는 opId, 또는 이미 삭제된 opId로 두 번째 DELETE | `{code:"integration_operation_not_found"}`, 404 | +| 5 | `newest?.opId === opId` → 409 | 클라이언트 최신 행 opId로 DELETE | `{code:"integration_journal_newest_protected"}`, 409, 저널에 툼스톤이 **추가되지 않음** | +| 6 | `pruned.ok === false` | 스냅샷 디렉터리를 읽을 수 없게 만든 뒤 DELETE | 200 + `snapshotRemoved:false`, 이후 상태 응답의 `retentionDegraded:true` | +| 7 | `row.deletable === false` | 최신 행 렌더 | 그 행에 삭제 버튼 DOM 없음 | +| 8 | `row.deletable && onDelete` | 과거 행 렌더 + prop 전달 | 삭제 버튼 존재, aria-label에 시각 포함 | +| 9 | `snapshot === "expired"` + `deletable` | 스냅샷 파일 제거 후 목록 조회 | 만료 배지 **와** 삭제 버튼이 함께 존재 — 이 기능의 핵심 가치 | +| 10 | `ConsequenceDialog` catch 경로 | 서버가 409를 반환하도록 최신 행 삭제 시도 | 다이얼로그 유지 + `Notice tone="err"` + 확인 버튼 재활성, 문구가 영어 원문이 아닌 로케일 문구 | + +분기 1~6은 §4.11의 새 테스트가, 7~10은 GUI 테스트(`gui/tests/`)가 담당한다. + +--- + +## 8. Accept criteria + +전부 관측 가능한 조건으로 쓴다. + +1. `bun run typecheck` exit 0. +2. `bun test tests/integrations-journal.test.ts` exit 0 — 툼스톤 append 후 + `listOperations`가 해당 행만 감추고, 나머지 행 순서(newest first)가 보존됨. +3. `bun test tests/management-integration-journal-delete.test.ts` exit 0 — §7 분기 1~6. +4. `bun test tests/management-integration-routes.test.ts` exit 0 — 기존 30개 회귀 없음. +5. `bun test tests/management-route-registry.test.ts` exit 0 — 신규 DELETE가 레지스트리에 + 선언되고 exemption `why`가 40자 이상, `ownerDoc`이 실존. +6. `bun test tests/cli-capabilities.test.ts` exit 0 — ratchet이 커지지 않음. +7. `bun test tests/integrations-invariants.test.ts` exit 0 — 저장소 루트가 여전히 + `["journal.jsonl","records.json","snapshots"]`. +8. `cd gui && bun test tests/i18n-locales.test.ts` exit 0 — 9개 로케일 키 집합 일치. +9. `bun run lint:gui` exit 0 — 하드코딩 UI 문자열 규칙(`local-i18n/no-hardcoded-ui-strings`) 통과. +10. `bun run privacy:scan` exit 0 — 툼스톤의 `by`가 자격증명을 담지 않음. +11. 수동: 만료된 롤백 행에 삭제 버튼이 보이고, 최신 행에는 보이지 않는다. +12. 수동: 420px 폭에서 복원·삭제 두 버튼이 겹치지 않는다(§4.6). +13. PR 설명에 GUI 스크린샷 첨부 — `enforce-target`이 gui 언급 PR에 요구한다(AGENTS.md). + +--- + +## 9. Verifier 커맨드 (PLAN-VERIFIER-REAL-01) + +아래는 **이 문서를 작성하며 base 2421e44ce에서 실제로 실행한** 결과다. +전부 변경 전 상태이므로 green이며, 구현 후에도 green이어야 한다. +금지된 `bun run test` / bare `bun test`는 사용하지 않았다. + +| # | 커맨드 | exit | 실제 결과 | 이 커맨드가 변경 대상을 실제로 읽는가 | +|---|---|---|---|---| +| 1 | `bun test tests/management-integration-routes.test.ts` | 0 | 30 pass / 0 fail, 5.85s | 읽는다 — `integration-routes.ts`의 저널·복원 라우트를 실서버로 호출하고 CSRF 없는 gui-session 변경 거절까지 통과시킨다(§4.3 수정 대상). | +| 2 | `bun test tests/integrations-journal.test.ts` | 0 | 21 pass / 0 fail, 133ms | 읽는다 — `journal.ts`의 append/list/prune을 직접 호출하고, §3.2 근거인 3-트리 kind 유니온 일치를 소스 파싱으로 검사한다(§4.1 수정 대상). | +| 3 | `bun test tests/management-route-registry.test.ts` | 0 | 13 pass / 0 fail, 72ms | 읽는다 — `route-registry.ts`를 import하고 라우트 소스를 스캔해 대조한다. §4.4에서 DELETE를 선언하지 않으면 여기서 실패한다. | +| 4 | `bun test tests/cli-capabilities.test.ts` | 0 | 17 pass / 0 fail, 122ms | 읽는다 — `MANAGEMENT_ROUTES`를 동적 import해 capability/exemption/ratchet 3자를 대조한다. §4.4의 exemption이 없으면 실패한다. | +| 5 | `bun test tests/integrations-invariants.test.ts` | 0 | 40 pass / 0 fail, 250ms | 읽는다 — 저장소 루트 엔트리 목록을 고정해 §2.2의 "사이드카 파일 금지" 결정을 강제한다. | +| 6 | `cd gui && bun test tests/i18n-locales.test.ts` | 0 | 9 pass / 0 fail, 50ms | 읽는다 — `DICTS`와 `en`을 import해 9개 로케일 키 집합 동일성을 검사한다. §4.10에서 로케일 하나라도 빠지면 실패한다. | + +아직 실행하지 않은 것(구현 후 최초 실행): + +- `bun run typecheck` — 이번 세션 미실행. 변경 전 green을 가정하지 않았다. +- `bun run lint:gui`, `bun run privacy:scan` — 동일. +- `bun test tests/management-integration-journal-delete.test.ts` — §4.11에서 새로 만드는 파일. + +**`bun run test` / bare `bun test`는 이 work-phase에서 실행 금지.** +PR-ready 게이트에서만 유지관리자가 돌린다(AGENTS.md "Commands"). + +--- + +## 10. 구현 순서 + +1. §4.1 `journal.ts` (툼스톤 + listOperations) → verifier 2 +2. §4.2 `store.ts` 시임 +3. §4.3 라우트 + §4.4 레지스트리 → verifier 1, 3, 4 +4. §4.11 신규 테스트 → §7 분기 1~6 고정 +5. §4.5 GUI API + §4.9 refusal-copy + §4.10 i18n → verifier 6 +6. §4.6~4.8 GUI 렌더 → `lint:gui` +7. §8 전체 통과 후 PR (스크린샷 필수, `Closes #3379`는 **쓰지 않는다** — #3379는 셋 중 + 하나만 해결되므로 열린 채로 둔다) + +--- + +## 11. 미해결 — 구현자가 결정해야 할 것 + +1. **`journalDeletePrincipal` 구현** (§4.3). `ManagementContext`에 principal 필드가 + 있는지 먼저 확인하고, 없으면 `managementPrincipal()` 호출로 해석할지 `"management"` + 상수로 둘지 선택. 후자를 고르면 감사 가치가 줄어드므로 §6.2에 그 사실을 남길 것. +2. **`ConsequenceDialog`의 에러 포맷터** (§5.2 마지막). `onConfirm`에서 감싸 던지는 쪽을 + 권한다 — 다른 다이얼로그 3곳에 영향이 없다. diff --git a/devlog/_plan/260904_priority65_closeout/070_wp8_dispositions_and_regression.md b/devlog/_plan/260904_priority65_closeout/070_wp8_dispositions_and_regression.md new file mode 100644 index 0000000000..088413d787 --- /dev/null +++ b/devlog/_plan/260904_priority65_closeout/070_wp8_dispositions_and_regression.md @@ -0,0 +1,97 @@ +# wp8 — 처분 기록과 최종 회귀 증명 + +구현이 끝난 뒤 남는 것을 종결시키고, `dev`가 이번 작업으로 깨지지 않았음을 증명한다. + +## 처분 1 — PR #3061 close + +저자는 메인테이너 본인(@lidge-jun)이고, 이 PR은 이미 무의미하다. + +`78c630a93` (#3351 "test: stop three CI timing flakes from blocking the release train")이 +`origin/dev`의 조상이고, 같은 파일에 같은 세 가지를 더 나은 형태로 이미 적용했다: + +| 항목 | #3061이 하려던 것 | dev에 이미 있는 것 | +|------|------------------|-------------------| +| stdio 캡처 | 추가 | `tests/shutdown-launcher.test.ts:119` | +| 예산 상향 | 하드코딩 | `:84` `process.env.CI ? 60_000 : 20_000` | +| 진단 | `console.error` | `:143-150` exit code/signal/launcher 출력 | +| 테스트 타임아웃 | 하드코딩 `130_000` | `:174` `STARTUP_BUDGET_MS + 40_000` | + +게다가 전제가 반증됐다. macOS job 99447051971에서 SIGINT 케이스가 **90초를 다 쓰고** +실패했다(`[90209.88ms]`). 20초 천장이 원인이라면 90초에서는 통과했어야 한다. +바로 다음 SIGTERM이 1623ms, SIGHUP이 825ms에 통과했으므로 "러너가 바빴다"도 성립하지 않는다. + +@Ingwannu의 CHANGES_REQUESTED가 추가로 지적한 것도 현재 파일에서 확인된다: +`scripts/ci/run-bun-test-batches.sh:6`이 +`BATCH_TIMEOUT_SECONDS="${BUN_TEST_BATCH_TIMEOUT_SECONDS:-120}"`이고 `:113-114`가 +`"${BATCH_TIMEOUT_SECONDS}s"`로 배치를 감싼 뒤 `bun test --isolate --timeout 60000`을 +돌린다. 따라서 #3061이 넣으려던 130초 per-test 예산은 120초 배치 봉투를 초과해 +절대 소진될 수 없다. + +## 처분 3 — 식별된 결함의 배정 + +#3425 조사 중 별도 결함 하나가 나왔다. `tests/codex-routing.test.ts:527`의 +characterization 테스트가 `reconcileCodexRoutingHealth`를 한 번도 호출하지 않아 +`liveHealthAccountIds`가 빈 Set인 상태를 고정한다. 프로덕션에서는 재현되지 않는 +상태이므로, 이 테스트는 도달 불가능한 경로를 초록으로 굳히는 false confidence다. + +이것은 "결정이 필요한 항목"이 아니라 식별된 결함이다. 처분을 미루지 않고 +#3425 코멘트에 별도 항목으로 명시해 기록한다. 이번 유닛에서 테스트를 고치지 않는 +이유는 그 수정이 #3425의 근본 원인 판정과 함께 가야 하기 때문이고, 그 사실 자체를 +코멘트에 적는다. + +처분: 근거를 단 close. 되살릴 이유가 없다. + +## 처분 2 — 설계 결정이 선행하는 항목의 근거 게시 + +아래는 이번 유닛에서 코드를 건드리지 않되, 왜 안 하는지를 해당 PR/이슈에 남긴다. +침묵은 처분이 아니다. + +- **PR #3348** — blocker 잔존 확인. head `928841669`의 `src/combos/failover.ts:627`이 + `[401, 402, 403, 404, 408, 410, 413, 425, 429]`로 generic 410/413을 hop 리스트에 넣는다. + dev는 `[401, 403, 404, 408, 429]`다. 무관한 application 410과 진짜 과대 요청 413이 + 다음 프로바이더로 재전송된다. `devlog/_fin/260703_sse-midstream-reset-tail/00_plan.md:19-23`이 + 중복 완료·중복 과금을 이유로 이미 거부한 동작이다. 안전하게 떼어낼 조각 3개 + (DeepSeek quota 9줄, pacing 503, preflight 502)를 PR에 나열해 저자가 직접 쪼갤 기회를 준다. +- **PR #3389** — 전제 반증. "0바이트면 업스트림이 아무것도 커밋 안 했다"가 안전성 논거인데, + Bun에서 2청크 방출 후 리셋 시 리더가 0바이트를 관측하는 것이 4/4 재현됐다. + 커밋된 비멱등 턴(`store: true`, `mcp`, `web_search`)이 중복 전송될 수 있다. + 게이트를 바이트 수가 아니라 SSE 프로토콜 이벤트 증거로 바꿔야 한다. +- **PR #3329** — 신규 노브에 더해 `coolComboTarget` 쿨다운 우선순위를 무단 역전한다. + `options.cooldownMs ?? parseRetryAfterMs(...)` → `parseRetryAfterMs(..., {preserveImmediate: true}) ?? ...`. + 모든 기존 combo 사용자에게 적용되는 동작 변경이고, "Retry-After가 항상 설정값을 이긴다"는 + 제품 판단이다. 3조각 분할 제안. +- **이슈 #3425** — 이전 REJECT 유효, 새 용의자도 반증. `src/codex/routing.ts:2195`의 + 가드는 `writerGeneration < lastReconciledGeneration && !liveHealthAccountIds.has(accountId)` + 라는 AND 조건인데, `liveHealthAccountIds`는 `:331`에서 `context.codexAccountIds`로 + 채워지고 그 값은 `listLiveCodexAccountIds`(`:282`)가 config 전체 id를 담아 만든다. + 보고자의 계정 A는 등록된 채 살아 있었으므로 두 번째 조건이 false이고 early return이 + 일어나지 않는다. 삭제된 계정에만 발화하는 가드다. + 보고자 질문을 "config reload"에서 **"계정 삭제/재등록 여부"와 + "Codex App이 계정을 고정(fixed)해 보내는 구성인지"**로 교체한다. +- **이슈 #3245** — BLOCKED 유지. 실패가 첫 POST 이전 지점이라 SSE relay/repair/timeout + 코드에 도달조차 하지 않는다. `stale-needs-info`가 타임아웃을 소유한다. + +## 최종 회귀 증명 + +`dev`는 이 세션 중에도 움직인다. 따라서 회귀 확인은 "머지 전 green"이 아니라 +**최종 dev head에서의 green**이어야 한다. + +1. `git fetch origin dev` — 최종 head SHA를 기록. +2. 이번에 랜딩한 모든 머지 SHA에 대해 `git merge-base --is-ancestor FETCH_HEAD`. +3. 최종 head의 CI run을 `gh run list --branch dev --limit 5`로 찾아 결론 확인. +4. **작업 시작 시점 대비 비교**: base `2421e44ce`의 CI 결론과 최종 head의 결론을 + 나란히 놓는다. 시작 시점이 이미 빨간색이었다면 그 실패가 그대로인지(회귀 아님) + 새 실패가 붙었는지(회귀)를 job 이름 단위로 구분한다. +5. 실패가 있으면 그것이 이번 변경에서 온 것인지 focused 테스트로 좁힌다. + 전체 스위트는 여전히 금지 — CI가 3개 OS에서 이미 돌린다. + +시작 시점 확인이 특히 중요하다. base `2421e44ce`의 CI run `33867257170`이 이미 +`failure`였다는 관측이 있으므로, 그 실패의 정체를 먼저 확정하지 않으면 +"내가 깼는지"를 판정할 수 없다. + +## Accept criteria + +- #3061이 근거 코멘트와 함께 closed. +- #3348 / #3389 / #3329 / #3425 / #3245에 각각 판정 코멘트가 게시됨. +- 랜딩한 모든 SHA가 최종 `origin/dev`의 조상임이 증명됨. +- 최종 dev head의 CI 결론이 base 대비 새 실패 0건임이 job 단위로 확인됨. diff --git a/src/combos/failover.ts b/src/combos/failover.ts index ae0c044be2..4cb9cdc55a 100644 --- a/src/combos/failover.ts +++ b/src/combos/failover.ts @@ -307,6 +307,19 @@ function isModelLifecycleGone( ); } +function isProviderTargetContextOverflow( + status: number, + message: string, + code?: string | null, +): boolean { + if (status !== 400) return false; + const normalizedCode = normalizedFailureCode(code); + const text = message.toLowerCase(); + if (text.includes("invalid_request_prompt_too_long")) return true; + return normalizedCode === "5059" + && /\bprompt\s+\d+\s*>\s*\d+\s+maximum context length\b/i.test(message); +} + export function comboFailureDecision( status: number, message: string, @@ -324,6 +337,10 @@ export function comboFailureDecision( if (isModelLifecycleGone(status, message, options?.code)) return "hop"; const error = classifyError(status, "upstream_error", message); if (isCyberPolicyCode(error.code)) return "stop"; + // A provider can expose its own target hard cap with a non-semantic vendor code + // (for example 5059 + invalid_request_prompt_too_long). That is evidence that this + // target is too small, not that every later combo target is incapable of serving it. + if (isProviderTargetContextOverflow(status, message, options?.code)) return "hop"; // A local input-admission refusal (#1524) says "this candidate cannot fit the request", // not "the request is impossible": the next candidate may have a larger context window. // diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index a17702fb90..d0b3f94572 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -5152,6 +5152,36 @@ async function handleResponsesInner( } } + // Tool results are PAIRED by call_id. parseRequest writes it into OcxToolResultMessage.toolCallId + // (parser.ts:738/752) without validating it, because inputItemSchema's permissive catch-all + // (schema.ts:106) accepts a tool item whose strict schema failed only for a missing call_id. A + // translating adapter then consumes `toolCallId: string` holding undefined: kiro-wire.ts:32 + // TypeErrors, ollama-native.ts:334 throws, and anthropic.ts:775 sends + // "[tool_result without adjacent tool_use: undefined]" upstream (issue #3259). + // + // This CANNOT move into the schema. parseRequest (:2812) runs before the passthrough branch + // (:3719), so a parse-time rejection would also kill forward/key passthrough and routed + // compaction — paths that never read context.messages, build from _rawBody, and already + // degrade an unpaired output to "[tool output for unknown call]" on their own. + // + // Keyed on the adapter, not on position: routedCompaction skips the passthrough branch above + // yet still builds from _rawBody (see the :3703 comment). + if (!("passthrough" in adapter && adapter.passthrough)) { + const unpaired = parsed.context.messages.find( + message => message.role === "toolResult" + && (typeof (message as { toolCallId?: unknown }).toolCallId !== "string" + || (message as { toolCallId: string }).toolCallId.length === 0), + ); + if (unpaired) { + // Never interpolate the tool output: this message reaches the client and the logs. + return formatErrorResponse( + 400, + "invalid_request_error", + "tool result requires a non-empty string call_id", + ); + } + } + // Image / web-search sidecars: plan once, then dispatch with runTurn-aware priority. // Routed-compaction turns must NOT hit the image bridge: compaction clears tools/_webSearch but // leaves _imageGeneration, so planImageBridge would activate and return a normal Responses diff --git a/tests/combos.test.ts b/tests/combos.test.ts index 511bdae1a2..51ae4fa1a9 100644 --- a/tests/combos.test.ts +++ b/tests/combos.test.ts @@ -500,6 +500,14 @@ describe("combo failure policy and advancement", () => { // An UPSTREAM context verdict still stops: retrying that elsewhere is guesswork, and a // generic 413 with no structured code keeps its existing conservative handling. expect(comboFailureDecision(400, "context_length_exceeded")).toBe("stop"); + const providerHardCap = JSON.stringify({ error: { + message: "Prompt 346030 > 262144 maximum context length", + type: "invalid_request_prompt_too_long", + code: "5059", + raw_status_code: 400, + }}); + expect(comboFailureDecision(400, providerHardCap, { code: "5059" })).toBe("hop"); + expect(comboFailureDecision(400, "ordinary invalid request", { code: "5059" })).toBe("stop"); expect(comboFailureDecision(413, "request too large")).toBe("stop"); }); diff --git a/tests/responses-compaction-routing.test.ts b/tests/responses-compaction-routing.test.ts index 8dea3094ab..0673f7dcb1 100644 --- a/tests/responses-compaction-routing.test.ts +++ b/tests/responses-compaction-routing.test.ts @@ -1523,3 +1523,131 @@ test("a no-eligible policy compact request persists the evaluation trace", async expect(logCtx.routeDecision!.selected.reason).toBe("no-eligible-candidate"); expect(logCtx.routeDecision!.candidates).toHaveLength(1); }); + +/** + * Issue #3259: a tool result is PAIRED by call_id. inputItemSchema's permissive catch-all + * (schema.ts:106) accepts a tool item whose strict alternative failed only for a missing + * call_id, and parser.ts:738 then assigns it unchecked, so a translating adapter consumes + * `toolCallId: string` holding undefined. Anthropic is the worst case: it does not throw, + * it sends "[tool_result without adjacent tool_use: undefined]" upstream. + * + * The guard cannot live in the schema. parseRequest runs before the passthrough branch, and + * passthrough / routed compaction build from _rawBody, never reading context.messages — they + * already degrade an unpaired output to "[tool output for unknown call]" on their own. + */ +describe("unpaired tool result boundary (#3259)", () => { + function unpairedBody(item: Record): Record { + return { + model: "gw/some-model", + stream: false, + input: [ + { type: "message", role: "user", content: [{ type: "input_text", text: "do the task" }] }, + item, + ], + }; + } + + const anthropicConfig = () => ({ + defaultProvider: "gw", + providers: { + gw: { + adapter: "anthropic", + baseUrl: "https://api.anthropic.com", + authMode: "key", + apiKey: "test-key", + }, + }, + } as unknown as OcxConfig); + + test("a translating adapter rejects a call_id-less tool result with 400 and sends nothing upstream", async () => { + let fetches = 0; + globalThis.fetch = (async () => { + fetches += 1; + throw new Error("the guard must reject before any upstream request"); + }) as typeof fetch; + + const res = await handleResponses( + compactionRequest(unpairedBody({ type: "function_call_output", output: "bootstrap result" })), + anthropicConfig(), + { model: "", provider: "" }, + ); + + expect(res.status).toBe(400); + const json = await res.json() as { error?: { message?: string; type?: string; code?: string } }; + expect(json.error?.message).toBe("tool result requires a non-empty string call_id"); + expect(json.error?.type).toBe("invalid_request_error"); + expect(json.error?.code).toBe("invalid_request_error"); + // The tool output itself must never be interpolated into a client-visible message. + expect(JSON.stringify(json)).not.toContain("bootstrap result"); + expect(fetches).toBe(0); + }); + + test("an empty-string call_id is rejected identically (it can never pair)", async () => { + globalThis.fetch = (async () => { + throw new Error("the guard must reject before any upstream request"); + }) as typeof fetch; + + const res = await handleResponses( + compactionRequest(unpairedBody({ type: "custom_tool_call_output", call_id: "", output: "x" })), + anthropicConfig(), + { model: "", provider: "" }, + ); + expect(res.status).toBe(400); + }); + + test("a paired tool result on the same translating route still reaches the upstream", async () => { + const bodies: string[] = []; + globalThis.fetch = (async (_url: unknown, init?: RequestInit) => { + bodies.push(String(init?.body ?? "")); + return jsonResponse({ + id: "msg_1", + type: "message", + role: "assistant", + model: "claude", + content: [{ type: "text", text: "ok" }], + stop_reason: "end_turn", + usage: { input_tokens: 1, output_tokens: 1 }, + }); + }) as typeof fetch; + + const res = await handleResponses( + compactionRequest({ + model: "gw/some-model", + stream: false, + input: [ + { type: "message", role: "user", content: [{ type: "input_text", text: "do the task" }] }, + { type: "function_call", call_id: "call_1", name: "shell", arguments: "{}" }, + { type: "function_call_output", call_id: "call_1", output: "paired result" }, + ], + }), + anthropicConfig(), + { model: "", provider: "" }, + ); + + expect(res.status).toBe(200); + expect(bodies.length).toBe(1); + expect(bodies[0]).toContain("paired result"); + expect(bodies[0]).not.toContain("undefined"); + }); + + test("the same unpaired body on a passthrough route stays 200 and self-degrades", async () => { + // This contrast is the core claim of the design: passthrough builds from _rawBody, is + // unaffected by the defect, and must not be killed by the guard. + const bodies: string[] = []; + globalThis.fetch = (async (_url: unknown, init?: RequestInit) => { + bodies.push(String(init?.body ?? "")); + return jsonResponse(completedPayload("ok")); + }) as typeof fetch; + + const res = await handleResponses( + compactionRequest(unpairedBody({ type: "function_call_output", output: "bootstrap result" })), + keyProviderConfig(), + { model: "", provider: "" }, + ); + + expect(res.status).toBe(200); + expect(bodies.length).toBe(1); + expect(bodies[0]).toContain("[tool output for unknown call]"); + expect(bodies[0]).not.toContain("undefined"); + }); +}); diff --git a/tests/responses-parser.test.ts b/tests/responses-parser.test.ts index 26d380d541..2b53d2272c 100644 --- a/tests/responses-parser.test.ts +++ b/tests/responses-parser.test.ts @@ -795,3 +795,59 @@ describe("codex-rs compat surface (260707)", () => { expect(call?.namespace).toBeUndefined(); }); }); + +describe("unpaired tool result boundary (#3259)", () => { + // The real delegation-history shape that produced the defect: a subagent bootstrap turn + // whose FIRST tool result has no originating call in the same request. + const delegationHistory = (toolItem: Record) => ({ + model: "test-model", + input: [ + { type: "message", role: "developer", content: [{ type: "input_text", text: "You are a subagent." }] }, + { type: "message", role: "user", content: [{ type: "input_text", text: "do the task" }] }, + toolItem, + ], + }); + + const toolResultOf = (item: Record) => + parseRequest(delegationHistory(item)).context.messages.find(m => m.role === "toolResult") as + | { toolCallId?: unknown; content?: unknown } + | undefined; + + test("a function_call_output with no call_id still parses, and yields an unusable toolCallId", () => { + // This is the state src/server/responses/core.ts guards on. `toolCallId` is declared + // `string` (src/types/request.ts:168) but is undefined here — the schema catch-all + // (schema.ts:106) accepted the item and parser.ts:738 assigned it unchecked. + const result = toolResultOf({ type: "function_call_output", output: "bootstrap result" }); + expect(result).toBeDefined(); + expect(typeof result?.toolCallId).not.toBe("string"); + }); + + test("an empty-string call_id is equally unusable", () => { + // findToolById (parser.ts:328) matches by identity, so "" can never pair. The guard + // must treat it exactly like undefined. + const result = toolResultOf({ type: "function_call_output", call_id: "", output: "x" }); + expect(result?.toolCallId).toBe(""); + }); + + test("a well-formed tool result on the same history pairs normally", () => { + const result = toolResultOf({ type: "function_call_output", call_id: "call_1", output: "ok" }); + expect(result).toMatchObject({ toolCallId: "call_1", content: "ok" }); + }); + + test("custom_tool_call_output has the identical hole (parser.ts:752)", () => { + const result = toolResultOf({ type: "custom_tool_call_output", output: "x" }); + expect(result).toBeDefined(); + expect(typeof result?.toolCallId).not.toBe("string"); + }); + + test("tolerances unrelated to call_id stay intact", () => { + // parser.ts:611-621 deliberately tolerates non-JSON arguments; nothing here may 400 it. + expect(() => parseRequest(delegationHistory({ + type: "function_call", call_id: "c1", name: "shell", arguments: "not json", + }))).not.toThrow(); + // Unknown future item types must keep flowing through the catch-all untouched. + expect(() => parseRequest(delegationHistory({ + type: "brand_new_item_2027", foo: 1, + }))).not.toThrow(); + }); +}); diff --git a/tests/server-combo-failover-e2e.test.ts b/tests/server-combo-failover-e2e.test.ts index 39053f39fb..1c3f69b778 100644 --- a/tests/server-combo-failover-e2e.test.ts +++ b/tests/server-combo-failover-e2e.test.ts @@ -1547,6 +1547,27 @@ describe("server combo failover 030 activation matrix", () => { expect(await exhausted.text()).not.toContain("sk-a-should-redact"); }); + test("provider-specific prompt-too-long 400 hops to a larger-context combo target", async () => { + let backupHits = 0; + const capped = serve(() => Response.json({ error: { + message: "Prompt 346030 > 262144 maximum context length", + type: "invalid_request_prompt_too_long", + code: "5059", + raw_status_code: 400, + } }, { status: 400 })); + const backup = serve(() => { + backupHits += 1; + return chatSuccess("larger context backup", "m2"); + }); + const response = await post(comboConfig({ + a: provider("openai-chat", baseUrl(capped), "key-a"), + b: provider("openai-chat", baseUrl(backup), "key-b"), + })); + expect(response.status).toBe(200); + expect(backupHits).toBe(1); + expect(await response.text()).toContain("larger context backup"); + }); + test("429 Retry-After 120 keeps A cooling at 60 seconds and restores it at 120", async () => { const t0 = Date.parse("2026-07-18T00:00:00.000Z"); let now = t0;