|
| 1 | +# Bug Sweep: request transform / response handler / adapters |
| 2 | + |
| 3 | +Scope reviewed: |
| 4 | +- lib/request/request-transformer.ts |
| 5 | +- lib/request/response-handler.ts |
| 6 | +- lib/request/fetch-helpers.ts |
| 7 | +- lib/request/helpers/{tool-utils,input-utils,model-map}.ts |
| 8 | +- lib/oc-chatgpt-import-adapter.ts |
| 9 | +- lib/oc-chatgpt-orchestrator.ts |
| 10 | +- lib/oc-chatgpt-target-detection.ts |
| 11 | +- lib/prompts/codex.ts |
| 12 | + |
| 13 | +--- |
| 14 | + |
| 15 | +## Finding 1 — `trimInputForFastSession` discards the leading developer/system context it deliberately preserved |
| 16 | + |
| 17 | +**File:** lib/request/request-transformer.ts:646-650 |
| 18 | + |
| 19 | +**Buggy code:** |
| 20 | +```ts |
| 21 | +const trimmed = input.filter((_item, index) => keepIndexes.has(index)); |
| 22 | +if (trimmed.length === 0) return input; |
| 23 | +if (input.length <= maxItems && excludedHeadIndexes.size === 0) return input; |
| 24 | +if (trimmed.length <= safeMax) return trimmed; |
| 25 | +return trimmed.slice(trimmed.length - safeMax); |
| 26 | +``` |
| 27 | + |
| 28 | +**Why it's wrong:** |
| 29 | +Earlier in the function the head loop (lines 623-639) deliberately adds the first one or two |
| 30 | +short `developer`/`system` items to `keepIndexes`, and the tail loop (lines 641-644) adds the |
| 31 | +last `safeMax` items (`safeMax = Math.max(8, Math.floor(maxItems))`). Because `trimmed` is built |
| 32 | +via `input.filter(...has(index))`, the preserved head items appear FIRST in `trimmed`. |
| 33 | + |
| 34 | +When the conversation is longer than `safeMax`, the head indices (0/1) are distinct from the tail |
| 35 | +range (`input.length - safeMax .. end`), so `trimmed.length` becomes `safeMax + 1` or `safeMax + 2`. |
| 36 | +The final `trimmed.slice(trimmed.length - safeMax)` then keeps only the last `safeMax` entries — |
| 37 | +which are exactly the tail items — and slices the leading head items off the front. |
| 38 | + |
| 39 | +The function's own docstring states: "Keeps a small leading developer/system context plus the most |
| 40 | +recent items." The slice undoes that for any history longer than `safeMax`. |
| 41 | + |
| 42 | +**Triggering input -> actual vs expected:** |
| 43 | +- Input: 50 items, `maxItems = 30` (`safeMax = 30`), item 0 = short `developer` instruction, |
| 44 | + `preferLatestUserOnly = false` (non-trivial turn — the common multi-turn fast-session case). |
| 45 | +- keepIndexes = {0, 1, 20..49} -> trimmed.length = 32 -> `trimmed.slice(2)` = items[20..49]. |
| 46 | +- Actual: the leading developer/system instruction (items 0/1) is dropped. |
| 47 | +- Expected: leading developer/system context retained alongside the most recent items. |
| 48 | + |
| 49 | +Reachable in production: `resolveFastSessionInputTrimPlan` only sets `preferLatestUserOnly=true` |
| 50 | +for trivial single-line turns (which return early before this slice). Non-trivial fast-session |
| 51 | +turns hit this path with `preferLatestUserOnly=false`. |
| 52 | + |
| 53 | +**Severity:** MEDIUM (degraded prompt context in fast-session mode; non-host project/developer |
| 54 | +instructions are stripped from the request). |
| 55 | +**Confidence:** HIGH (deterministic; the code adds the head indices then unconditionally removes them). |
| 56 | + |
| 57 | +**Suggested fix:** Reserve room for the head when slicing, e.g. partition `trimmed` into head vs |
| 58 | +tail and cap only the tail, or compute the slice as |
| 59 | +`[...headItems, ...tailItems.slice(tailItems.length - (safeMax - headItems.length))]`, so the |
| 60 | +preserved leading context is never sliced away. |
| 61 | + |
| 62 | +--- |
| 63 | + |
| 64 | +## Finding 2 — `response.output_text.done` (and reasoning-summary `.done`) read text via `getStringField`, which can wipe accumulated deltas on an empty/whitespace final payload |
| 65 | + |
| 66 | +**File:** lib/request/response-handler.ts:630-639 (and 670-677 for reasoning summary) |
| 67 | + |
| 68 | +**Buggy code:** |
| 69 | +```ts |
| 70 | +if (data.type === "response.output_text.done") { |
| 71 | + setOutputTextValue( |
| 72 | + state, |
| 73 | + outputIndex, |
| 74 | + getNumberField(eventRecord, "content_index"), |
| 75 | + getStringField(eventRecord, "text"), // <-- trimmed/non-empty gate |
| 76 | + eventRecord.phase, |
| 77 | + ); |
| 78 | + return; |
| 79 | +} |
| 80 | +``` |
| 81 | +`getStringField` returns `null` when the value is empty or whitespace-only |
| 82 | +(`value.trim().length > 0 ? value : null`). The file's own doc comment (lines 54-59) explicitly |
| 83 | +warns: "For textual payloads where whitespace is meaningful, use a field-specific accessor such as |
| 84 | +`getDeltaField` instead of reusing this helper." The `.delta` handlers correctly use `getDeltaField`, |
| 85 | +but the `.done` handlers use `getStringField`. |
| 86 | + |
| 87 | +**Why it's wrong / wrong behavior:** |
| 88 | +`setOutputTextValue(..., null, ...)` deletes the accumulated key: |
| 89 | +```ts |
| 90 | +if (!text) { |
| 91 | + state.outputText.delete(key); // line 262-265 |
| 92 | + setPhaseTextSegment(state, phase, key, null); |
| 93 | + return; |
| 94 | +} |
| 95 | +``` |
| 96 | +So if deltas accumulated content (e.g. "Hello") and a terminal `response.output_text.done` arrives |
| 97 | +with an empty or whitespace-only `text`, the accumulated text for that `output:content` key is |
| 98 | +deleted instead of finalized, dropping it from the synthesized final response. |
| 99 | + |
| 100 | +**Triggering input -> actual vs expected:** |
| 101 | +- Stream: `output_text.delta` "Hello world", then `output_text.done` with `text: ""` (or `" "`). |
| 102 | +- Actual: accumulated "Hello world" is deleted; final JSON loses that content part's text. |
| 103 | +- Expected: the accumulated delta text is preserved as the final value. |
| 104 | + |
| 105 | +**Severity:** LOW (the OpenAI Responses API normally carries the full text on `.done`; an empty/ |
| 106 | +whitespace `.done` is the edge that triggers loss). |
| 107 | +**Confidence:** MEDIUM (depends on upstream emitting an empty terminal text event). |
| 108 | + |
| 109 | +**Suggested fix:** Use `getDeltaField` (length>0 only, no trim) for the `.done` text fields, and/or |
| 110 | +guard `setOutputTextValue` so an empty `.done` does not delete already-accumulated delta text. |
| 111 | + |
| 112 | +--- |
| 113 | + |
| 114 | +## Notes / examined and considered correct |
| 115 | + |
| 116 | +- `getModelConfig` variant parsing, `coerceReasoningEffort` fallback tables, and `resolveInclude` |
| 117 | + (always re-adds `reasoning.encrypted_content`) behave as documented. |
| 118 | +- Model-family mapping in model-map.ts (codex aliases -> gpt-5.3-codex; gpt-5.4/5.5 -> gpt-5.2 |
| 119 | + prompt family) is internally consistent with `MODEL_PROFILES`. |
| 120 | +- `mergeRecord` / `applyAccumulatedOutputText` / `appendPhaseTextSegment` ordering and the |
| 121 | + delta-append fast path are correct. |
| 122 | +- `filterInput` stripIds gating (stripped only when not background mode) is correct. |
| 123 | +- Import-adapter dedup precedence, `remapActiveIndex`, and `matchDestination` index handling are |
| 124 | + correct; `previewOcChatgptImportMerge` index alignment between `merged.accounts` and |
| 125 | + `destinationAccounts` is sound. |
| 126 | +- Orchestrator atomic write (temp+rename, 0o600/0o700, retry) and target-detection scope/ambiguity |
| 127 | + logic are correct. |
| 128 | +- `convertSseToJson` pre-append size cap, malformed-chunk handling, and `readWithTimeout` cleanup |
| 129 | + are correct. |
0 commit comments