Repository navigation
[Bug][Responses HTTP] 删除 previous_response_id 时未重建增量请求历史,导致上下文静默丢失 #1954
Description
Description
The HTTP Responses pipeline deletes previous_response_id by default, on the assumption that the client replays full history. A valid delta-only continuation instead reaches the upstream with neither the previous response reference nor the previous conversation.
This is a protocol compatibility bug. The reproduction uses a mock HTTP client with Codex UA; it does not establish that Codex CLI 0.160.0 normally sends this shape. All three actual Codex fake-upstream E2E tests passed and did not exercise HTTP delta chaining.
Reproduction / captured upstream result
- First request:
store:true, inputRemember AUDIT_SECRET_4781; upstream returnsid:'resp_audit_first'. - Second request on the same session:
store:true,previous_response_id:'resp_audit_first', input containing onlyRepeat the previous secret. - Capture the second body at a local mock upstream. Compression is not required to trigger this loss; context window is 200000.
RESPONSES_CHAIN {"previousId":null,"hasFullHistory":false}
Both proxy requests return HTTP 200. The upstream therefore cannot resolve the first turn from the forwarded payload.
Root cause
if (process.env.ACP_KEEP_RESPONSE_ID !== "1") delete rebuilt.previous_response_id;Removing a chain reference is only valid once the previous input and output have been expanded into a complete replay. That does not happen for this delta request.
Expected behavior / acceptance
- Preserve a valid reference when safe, or reconstruct complete history before dropping it.
- If the proxy cannot obtain the history, explicitly reject/request a full replay instead of forwarding an apparently successful context-free continuation.
- Account for ACP rewriting: simply keeping an upstream ID after compression may attach incompatible original history and is not automatically a complete fix.
- Add continuation coverage for both ordinary messages and function_call/function_call_output, including histories unavailable after restart.
ACP_KEEP_RESPONSE_ID=1 is an existing explicit override; the reproduction targets default behavior with that variable unset.
Related scope / duplicate screening
Searched open/closed previous_response_id reports. #1866 and #1926 cover the separate WebSocket checkpoint/transport lane; #1052 concerns replay item IDs. This report targets HTTP delta input reaching src/server.ts without its chain reference or expanded history.
Environment
-
bili:
0.1.180, acp-kernel0.0.100; audited mastera863e9352538a35a2fbc099a6bca0d1eb9b92285(still master when preparing this report). -
OS: Windows native / PowerShell; Node.js
24.21.0; Codex CLI0.160.0. -
Typecheck and build passed; isolated focused baseline: 488/488 passed across 36 test files.
-
Real Codex against a deterministic local fake upstream: 3/3 passed, 0 skipped (overflow compression, under-window control, hostile
.envlauncher). -
No paid model calls or live ChatGPT OAuth acceptance were used to establish the bugs below.
-
Usage mode: Proxy HTTP Responses with codex_cli_rs/0.160.0 UA; deterministic local mock upstream, audit-model, window 200000; no real ChatGPT upstream..
Complete isolated reproducer
From a checkout of the audited commit with dependencies installed, use Node 24. Save the following as __tmp/responses-http-delta-history.test.ts inside the repository, then run:
pnpm exec node --import tsx --test '__tmp/responses-http-delta-history.test.ts'
The test asserts the expected behavior and is supposed to fail on the audited commit. File-based fixtures use an isolated __tmp directory; use a fresh scratch checkout/directory for each reproduction. The test does not modify the real user Codex home.
import test from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import { once } from 'node:events';
import { defaultConfig } from 'acp-kernel';
import { startServer } from '../src/server.ts';
import { SessionStore, _setStoreForTest } from '../src/persist.ts';
test('Responses delta chaining preserves either its previous id or the complete history', async () => {
_setStoreForTest(new SessionStore({ enabled: false }));
const captured: Array<Record<string, unknown>> = [];
const upstream = http.createServer((req, res) => {
const chunks: Buffer[] = [];
req.on('data', (chunk: Buffer) => chunks.push(chunk));
req.on('end', () => {
captured.push(JSON.parse(Buffer.concat(chunks).toString('utf8')));
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ id: 'resp_audit_first', status: 'completed', output: [], usage: { input_tokens: 10, output_tokens: 1 } }));
});
});
upstream.listen(0, '127.0.0.1');
await once(upstream, 'listening');
const port = (upstream.address() as { port: number }).port;
const proxy = await startServer({
port: 0, host: '127.0.0.1', upstream: `http://127.0.0.1:${port}`, routes: {},
modelContextLimit: 200000, kernelConfig: defaultConfig(200000),
compress: { injectTool: true, injectNudge: false }, promptCache: { routing: 'auto' },
sessionHeader: 'x-acp-session', log: false, debug: false, passthrough: false,
chainContentDetection: false, autoUpdate: false, mitm: { enabled: false, domains: [] },
});
if (!proxy.listening) await once(proxy, 'listening');
const origin = `http://127.0.0.1:${(proxy.address() as { port: number }).port}`;
try {
for (const body of [
{ model: 'audit-model', store: true, stream: false, input: [{ role: 'user', content: 'Remember AUDIT_SECRET_4781' }] },
{ model: 'audit-model', store: true, stream: false, previous_response_id: 'resp_audit_first', input: [{ role: 'user', content: 'Repeat the previous secret' }] },
]) {
const response = await fetch(`${origin}/bili/http://127.0.0.1:${port}/responses`, {
method: 'POST', headers: { 'content-type': 'application/json', 'session-id': 'audit-chain', 'user-agent': 'codex_cli_rs/0.160.0' }, body: JSON.stringify(body),
});
assert.equal(response.status, 200);
await response.arrayBuffer();
}
const last = captured[1];
const hasFullHistory = JSON.stringify(last.input).includes('AUDIT_SECRET_4781');
console.log('RESPONSES_CHAIN', JSON.stringify({ previousId: last.previous_response_id ?? null, hasFullHistory }));
assert.ok(last.previous_response_id === 'resp_audit_first' || hasFullHistory, 'stripping the id requires rebuilding the full previous conversation');
} finally {
await new Promise<void>((resolve) => proxy.close(() => resolve()));
await new Promise<void>((resolve) => upstream.close(() => resolve()));
}
});
Activity
xiaozhu1337 commentedon Oct 3, 2026
补充本地根因分析与候选修复验证(不是已合并修复)。
验证基线:
a863e9352538a35a2fbc099a6bca0d1eb9b92285,billion-context 0.1.180 / acp-kernel 0.0.100;Windows、mise Node 24.21.0。发布评论前再次核对,master 仍为此提交。实验直接从当前源码生成临时变体,只替换对应决策点,未修改生产源码。清除了继承的 CODEX_HOME / session 环境并隔离 XDG 和临时目录。新增 11 个验证测试,加上针对候选变体运行的现有 SQLite overlay 17 个、Codex model 8 个、Codex official mock transport 2 个,共 38/38 通过,0 skipped。这包括边界/反例断言,不等同于 38 个真实 Codex E2E;之前原始基线 488/488、真实 Codex 0.160.0 + fake upstream E2E 3/3 仍是独立证据。本轮没有调用付费模型。
根因:未证明 full replay,却无条件取消上游历史引用
prepareResponses()删除 previous_response_id 的理由是“input 已包含完整历史”,但增量请求只有新输入。请求处理前没有根据历史 ID 补齐过去 input 与 output,因此这个前提并不成立。OpenAI 官方 conversation state 文档 区分了 full input/output replay 和 previous_response_id 续接。工具调用、推理项目也是历史的一部分,只补过去的用户文本不够。
重新测试了“只保留 ID”这一建议
function_call_output(call_audit, AUDIT_TOOL_RESULT_6577)都能转发。不能声称这个场景仍然丢工具结果。AUDIT_SECRET_4781,bili 没有物化完整历史;localInputEstimate仍根据本轮 processed/originalMessages 估算。因此这个试验没有证明 token budget、压缩和历史引用在 chaining 下正确。建议方案及验证范围
完整支持:在进入 kernel / preflight / history rewrite 前物化原生历史。 按响应 lineage 获取父响应的有效 input + 完整 output,再追加本次 delta;确保函数调用/result、custom tools、reasoning/encrypted content 和 item 顺序保真。只有证明本次已转为完整 replay 后,才允许移除 previous_response_id。
存储应按 provider/凭证边界/会话/response ID 隔离;成功完成 response 后提交,正确处理 SSE、失败/取消、重试、并发分支与 native compaction 重建。缓存设置 TTL 和字节/条目上限,使用父指针或共享段,避免每个 ID 永久复制整段历史产生 O(n²) 占用。找不到历史时明确报错,不能把 delta 当成全量继续发送。当前这套完整历史方案尚未实现和验证。
可验证的临时安全边界:明确拒绝不支持的 chaining。 本地原型在请求解析后、创建/更新会话及任何重写前,对非空 string previous_response_id 返回 HTTP 400、
invalid_request_error/bili_full_history_required,提示客户端去掉 ID 并重发完整 input/output 历史;不是提示去掉 ID 后重发原 delta。原型验证结果:拒绝请求产生 0 个上游请求;普通 full replay 和
previous_response_id: null仍正常 200;当前 Codex official mock transport 的 2 个测试(OAuth/header/body、native compact rebase、text profile)通过。这个边界只是消除静默丢历史的保护措施,不能称为 chaining 支持或根因修复,而且会改变 API 行为,需要明确兼容说明。若必须优先保持 chaining 可用,可单独设计“原样转发并绕过 bili 历史压缩”的降级通道,必须同时保留原生 instructions/input/ID 与路由语义,显式标明该请求不受 bili 上下文管理;仅在现有重写流程末尾保留 ID 不等价于这个通道。此通道也尚未实现。
正式完整支持必须补齐 full/delta、未知/跨凭证 ID、SSE、工具续接、store:false、取消重试、分支、压缩前后,以及 proxy mode / plugin mode 两种模式的 E2E。本轮没有证明全部矩阵,也没有证明当前 Codex CLI 0.160.0 常规 HTTP 请求在使用 chaining。
ranxianglei commentedon Oct 3, 2026
#1954 Triage 结论(已在当前 master 复现)
先给验证结果:我在当前
master(f2b18022,acp-kernel 0.0.100)上用 mock 上游捕获转发体复现了。发两个请求——req1store:true、input"Remember AUDIT_SECRET_4781"(上游返回 id);req2 同 session、store:true、previous_response_id:<resp1>、input 只有增量。req2 转发到上游的 body:previousId:null(id 被删)、hasFullHistory:false(input 里没有第一轮的 secret),input 只剩[developer(compress prompt), user("Repeat the previous secret")]。两次都返回 200。静默丢上下文的机制属实,可稳定复现,与你捕获一致(
{"previousId":null,"hasFullHistory":false})。下面按结构化七问作答。七问
1. 是什么问题
HTTP Responses 路径对「只带
previous_response_id+ 增量 input」的原生续接请求,既删掉链引用又不补全历史 → 该轮及之后所有上下文在上游侧彻底丢失,且全程 200 无任何报错。2. 什么原因导致的(分层)
src/server.ts:4602与src/loop/adapter-responses.ts:314无条件delete rebuilt.previous_response_id(除非ACP_KEEP_RESPONSE_ID=1),前提是「input 已含完整历史」。parsed.input做压缩并重建,没有任何 response-lineage 存储,因此根本无法按previous_response_id物化历史。OpenAI 原生 chaining(store:true+previous_response_id,服务端持有历史)与 bili 的客户端重放模型是两种互斥范式。strip 把「这个 delta 我压不了」放大成「历史全没了」。previous_response_id判定为「非客户端提供 / 按轮变化」(tests/proxy-session-id.test.ts:121-130,source:"previous-response",clientProvided:false)→ 纯 chaining 客户端甚至无法在轮间挂回同一个压缩 session。3. 怎么修复的
尚未动手,因为正确修法是一个产品/架构决策,不是单点 bugfix(见第 6 问)。我的明确建议:现在只做低风险可见性 + 文档边界,真正支持留作 feature 由你拍板,不要上一刀切 400(会误伤)。
4. 是否有回归问题 / 真实影响
src/server.ts:~4595明说 "Empirically codex sends store:false and never sets previous_response_id, so this is a no-op for codex";全部 e2e(codex fake-upstream)都不走 HTTP delta chaining。你自己也声明复现用的是带 Codex UA 的 mock 客户端。但这是颗定时炸弹:codex 本就是 OpenAI 自家 CLI,对接的 Responses API 原生支持store:true+previous_response_id;未来某个版本切到原生 chaining 省 token 是完全合理的演进,届时 bili 会把整段上下文丢掉。5. 是否破坏缓存
不动转发体就零影响。若走「保留 id」路线,前缀缓存语义会被上游原生续接接管,bili 的 prefix-cache 归因会失真——这也是你 floor 1 里指出的「token budget / 压缩 / 引用在 chaining 下未被证明」。
6. 是否改了设计方向(本质问题:bili 要不要支持服务端持有历史的原生 chaining?)
store:true+ 非空 stringprevious_response_id的请求在生产日志打一条 WARN(不再静默),并在 CLIENTS.md / TECHNICAL-NOTES 记一条边界说明,指向真正的修法所在。不改转发字节,诚实且低风险,符合 §7.3「根因在别处时留边界注记、不在错误层补偿」。7. 是否引入新配置
走 (A) 不新增配置,只加一条日志 + 文档;现有逃生阀
ACP_KEEP_RESPONSE_ID=1保持不变。(C) 才会引入新的 lineage 存储配置。我的处置建议(等你拍板)
xiaozhu1337 commentedon Oct 3, 2026
@ranxianglei 同意先做 A 的生产告警与文档,并将 C 的完整 chaining 支持另立 feature issue。但请明确:A 只是可见性改进,delta 请求仍会缺失历史并返回 200,因此不能作为 #1954 已修复的依据。完整支持可以单独规划,本 issue 仍需跟踪“不支持的请求不能在丢失历史后继续以成功响应交付”的处理边界。
请同时评估两种过渡方案:
previous_response_id,并在会话更新及历史重写前拒绝这类请求。这样不需要猜测 payload 是否为 delta;但我同意它会改变包括“full replay + id”在内的兼容行为,必须由维护者明确选择并说明迁移方式。错误提示应要求客户端去掉 ID 后重发完整 input/output 历史,不能引导客户端仅去掉 ID、重发原 delta。这是安全边界,不是 chaining 支持。告警条件请覆盖省略
store的情况,不要仅判断显式store:true:Responses 默认存储响应。请围绕非空previous_response_id及实际处理方式设计,覆盖 HTTP 与 loop adapter 两条路径,并补充对应回归测试。官方说明:https://developers.openai.com/api/docs/guides/migrate-to-responses“当前 Codex 不使用 HTTP chaining”请限定为现有测试与观察结论;已有 E2E 没有覆盖该形态,不能证明所有客户端运行方式都不会触发。
新 feature 建好后请回贴编号,并与 #1954 互链。仅完成告警和文档时,请保持本 issue 的未解决状态,或明确将尚未解决的安全处理验收项转移到可追踪的后续项,不能标为根因已修复。最终应做到:保留有效历史引用、在进入压缩前重建完整历史,或无法安全处理时明确拒绝;不能同时移除引用、不补历史,再返回表面成功的结果。
ranxianglei commentedon Oct 3, 2026
#1954 处置更新:approach A 已落地(可见性),根因修复另立 #1973
按你 floor 4 的指示执行完毕。先给结论:#1954 保持未解决。本次只交付了 (A) 可见性改进(不改变任何转发行为——delta 请求依旧丢历史并返回 200),完整 chaining 支持已另立 feature #1973,两者互链。
已交付(PR #1976)
(A) 生产告警 + 文档 + 回归测试,两条路径都覆盖:
src/server.tsprepareResponsessrc/loop/adapter-responses.tsbuildRequest告警条件按你的要求只判断「非空
previous_response_id」本身,不判断显式store:true—— 因为 Responses 默认存储响应,省略store时上游仍能按 id 解析被引用响应;只看store会漏掉最常见情形。因此省略store的 delta 续接现在同样会触发告警。告警内容点名该 id 与后果(bili 以全量重放压缩、chaining 续接会丢上游历史却仍 200),并给出两个安全出口:重发完整 input/output 历史,或设
ACP_KEEP_RESPONSE_ID=1保留 id。转发字节不变:仍删除 id、仍转发、仍 200。本 PR 唯一新增是一条
warn日志行——让这次丢失不再静默。回归测试(新增
tests/responses-previous-response-id.test.ts):helper 纯单测 + loop adapter(删除→恰好一条点名 id 的 warn / KEEP=1→保留且静默 / 缺省→no-op)+ HTTP 端到端(真实startServer+ mock upstream,req1 seed + req2 带previous_response_id、省略 store → 链引用被剥离、turn-1 secret 未被重建、恰好一条 #1954 warn)。typecheck + 定向测试全绿,build 通过。两个过渡方案的评估(均未实现,交由你拍板)
方案 1 —— 重写前拒绝非空
previous_response_id方案 2 —— 原生语义旁路通道(绕过 bili 压缩)
我的推荐:现在就保留已落地的 (A)(零行为风险、消除静默);两个过渡方案都不要现在半实现,统一放进 #1973 的设计评审里拍板。若你希望合并前先有一道硬性兜底,我倾向方案 1,但需要你先确认接受它对「full replay + id」的兼容影响。
结构化七问(针对本次交付)
previous_response_id+ 增量 input」的原生续接请求,既删链引用又不补历史 → 该轮及之后上下文在上游侧丢失,且全程 200 无报错。(问题未变。)previous_response_id引用的历史;服务端持有历史(原生 chaining)与客户端重放是互斥范式。(根因未变,故 [Bug][Responses HTTP] 删除 previous_response_id 时未重建增量请求历史,导致上下文静默丢失 #1954 不算修复。)warn(两条路径),不改任何转发字节。真正修法(进入压缩前重建完整历史 / 保留有效引用 / 无法安全处理时明确拒绝)在 [Feature][Responses] 支持原生 chaining(previous_response_id + store)——物化引用历史,消除 #1954 上下文丢失根因 #1973。ACP_KEEP_RESPONSE_ID既有语义未变(=1保留 id 且不告警)。#1954 剩余验收项(转交 #1973 跟踪,不在此关闭)
OP 列出的以下验收项尚未满足,现明确挂到 #1973:
收尾判定:approach A(可见性 + 文档 + 回归测试)已完成并提 PR #1976;根因与「不支持的请求不能在丢历史后继续以成功响应交付」的处理边界仍未解决,由 feature #1973 跟踪。#1954 保持 OPEN。
参考:OpenAI migrate-to-responses。
fix(responses): warn when stripping previous_response_id without rebu…
docs: document Responses native-chaining limitation (#1954, #1973)
Merge pull request #1976 from ranxianglei/2026-10-03_responses-chain-…
docs: mirror missing 'Responses native chaining' caveat (ranxianglei#…