fix(core): reorder injected context on every user entry

The send boundary reordered only the pending entry, so request N sent
[reminder, hook, catalog, ...prompt] while request N+1 rebuilt the same
entry from stored history as [reminder, ...prompt, hook, catalog]. The
shared provider prefix then ended at the leading reminder and every
injected block was re-prefilled at full input price on every turn - the
failure mode REATTACH_BOUNDARY_METADATA exists to prevent (#11627).

Applying the same rule to every user entry in the request copy keeps an
entry's wire shape position-independent, so request N+1 extends request
N's prefix exactly. Stored history is still untouched, which keeps the
transcript projection and the resume receipt intact.

Pin it with a two-turn test that asserts the first turn's entry is
re-sent byte-identically, and compare each request with the previous one
instead of comparing the request prefix with stored history.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmw25fj8od0
This commit is contained in:
易良andQwen-Coder committed 2026-11-04 21:47:06 +08:00
1 parent dc270e50fb
commit 96e6b0c702
4 files changed
+70 -20

No files matched your search

@@ -71,6 +71,17 @@ re-deliver already-delivered hook context every turn. Only the request copy
changes, so the transcript, resume, deduplication, and rewind contracts are changes, so the transcript, resume, deduplication, and rewind contracts are
untouched. untouched.
**Every user entry, not just the pending one.** The next request re-sends the
previous turn's entry from stored history, so reordering only the pending entry
would make an entry's wire shape depend on its position: request N would send
`[reminder, hook, catalog, ...prompt]` while request N+1 rebuilds the same entry
as `[reminder, ...prompt, hook, catalog]`. The shared provider prefix would then
end at the leading reminder and the injected blocks would be re-prefilled at
full input price on every turn — the failure mode `REATTACH_BOUNDARY_METADATA`
exists to prevent (#11627). Applying the same rule to every user entry in the
request copy keeps each entry's wire shape position-independent, so request N+1
extends request N's prefix exactly. Stored history is still untouched.
**One pass at the send boundary, not per producer.** Reminders, hook context, **One pass at the send boundary, not per producer.** Reminders, hook context,
IDE context and the catalog are injected by different modules on different IDE context and the catalog are injected by different modules on different
frontends (TUI, headless, ACP, the shared agent loop). Fixing the order at the frontends (TUI, headless, ACP, the shared agent loop). Fixing the order at the
@@ -109,7 +120,7 @@ token undercount in the always-on-context warning remain separate work
durable-recovery check now asserts the request holds the same entries and durable-recovery check now asserts the request holds the same entries and
parts as history with the catalog moved ahead of the recovery prompt. parts as history with the catalog moved ahead of the recovery prompt.
`packages/core/src/core/llm-chat.test.ts`: 716 passed. `packages/core/src/core/llm-chat.test.ts`: 720 passed.
## Open questions ## Open questions
@@ -61,6 +61,14 @@ available skills ... no specific task."
个分片重新盖章。重排已存条目会破坏展示投影,并让已投递的 hook 上下文每轮重复 个分片重新盖章。重排已存条目会破坏展示投影,并让已投递的 hook 上下文每轮重复
投递。因此只改请求副本,transcript、resume、去重与 rewind 的契约都不受影响。 投递。因此只改请求副本,transcript、resume、去重与 rewind 的契约都不受影响。
**重排作用于每条 user 条目,而不只是待发送的那条。** 下一轮请求会从已存历史里
重新发送上一轮的条目;只重排待发送条目会让一条条目的线上形状取决于它的位置:
第 N 轮发出 `[提醒, hook, 目录, ...正文]`,第 N+1 轮却把同一条目重建成
`[提醒, ...正文, hook, 目录]`。两次请求的公共前缀就只到开头的提醒为止,注入块
每轮都要按全价重新 prefill——这正是 `REATTACH_BOUNDARY_METADATA` 要防止的失效
模式(#11627)。对请求副本里每条 user 条目套用同一条规则,条目的线上形状就与
位置无关,第 N+1 轮请求严格扩展第 N 轮的前缀。已存历史仍然不动。
**在发送边界统一处理,而不是逐个生产者修改。** 提醒、hook 上下文、IDE 上下文和 **在发送边界统一处理,而不是逐个生产者修改。** 提醒、hook 上下文、IDE 上下文和
目录由不同模块在不同前端(TUI、headless、ACP、共享 agent loop)注入。在每轮主 目录由不同模块在不同前端(TUI、headless、ACP、共享 agent loop)注入。在每轮主
请求组装的唯一位置修正顺序,可以一次覆盖全部路径,避免四处并行修改后各自漂移。 请求组装的唯一位置修正顺序,可以一次覆盖全部路径,避免四处并行修改后各自漂移。
@@ -89,7 +97,7 @@ available skills ... no specific task."
待发送轮次的检查改为断言目录存在且用户正文在最后;持久化恢复的检查改为断言 待发送轮次的检查改为断言目录存在且用户正文在最后;持久化恢复的检查改为断言
请求与历史持有相同的条目和分片,只是目录被移到了恢复提示词之前。 请求与历史持有相同的条目和分片,只是目录被移到了恢复提示词之前。
`packages/core/src/core/llm-chat.test.ts`:716 项通过。 `packages/core/src/core/llm-chat.test.ts`:720 项通过。
## 待定问题 ## 待定问题
+29 -1
View File
@@ -3048,7 +3048,11 @@ describe('LlmChat', async () => {
mockStream(textStream('done')); mockStream(textStream('done'));
await sendDrain(`question ${index}`, `memory-next-${index}`); await sendDrain(`question ${index}`, `memory-next-${index}`);
const request = requestAt(index + 1).contents as Content[]; const request = requestAt(index + 1).contents as Content[];
expect(request.slice(0, firstHistory.length)).toEqual(firstHistory); // Each request extends the previous one: earlier entries are re-sent
// byte-identically, so the injected blocks stay inside the cached
// prefix instead of being re-prefilled every turn (#16259).
const previous = requestAt(index).contents as Content[];
expect(request.slice(0, previous.length)).toEqual(previous);
expect(snapshots(request)).toHaveLength(index + 1); expect(snapshots(request)).toHaveLength(index + 1);
expect(snapshots(request).at(-1)?.text).toContain(next); expect(snapshots(request).at(-1)?.text).toContain(next);
expect(snapshots(request).at(-1)?.text).toContain( expect(snapshots(request).at(-1)?.text).toContain(
@@ -3272,6 +3276,30 @@ describe('LlmChat', async () => {
]); ]);
}); });
it('re-sends a stored entry byte-identically on the next turn (#16259)', async () => {
mockConfig.getAutoMemoryContext = () => 'memory catalog';
const reminder = {
text: '<system-reminder>\nPlan mode is active.\n</system-reminder>',
};
const hookContext = createUserPromptSubmitContextPart('hook context');
mockStream(textStream('first answer'));
await sendDrain(
[reminder, { text: 'step 1' }, hookContext],
'prefix-turn-1',
);
const firstSent = (requestAt(0).contents as Content[]).at(-1)!;
mockStream(textStream('second answer'));
await sendDrain('follow up', 'prefix-turn-2');
const replayed = (requestAt(1).contents as Content[]).find((entry) =>
entry.parts?.some((part) => part.text === 'step 1'),
)!;
// Turn 1's entry is re-sent unchanged, so the injected blocks stay inside
// the shared provider prefix instead of being re-prefilled every turn.
expect(replayed).toEqual(firstSent);
});
it('keeps tool results first and the volatile request tail last', async () => { it('keeps tool results first and the volatile request tail last', async () => {
mockConfig.getAutoMemoryContext = () => ''; mockConfig.getAutoMemoryContext = () => '';
const toolResult = fnResponse('read_file', { output: 'contents' }, 'c1'); const toolResult = fnResponse('read_file', { output: 'contents' }, 'c1');
+20 -17
View File
@@ -1907,9 +1907,15 @@ function isInjectedPromptContext(part: Part): boolean {
* before it (#11627). * before it (#11627).
*/ */
function movePromptContextAheadOfUserText(contents: Content[]): Content[] { function movePromptContextAheadOfUserText(contents: Content[]): Content[] {
const last = contents.at(-1); // Every user entry takes the same shape, not only the pending one. The next
const parts = last?.parts; // request re-sends earlier entries from history, so a position-dependent
if (last?.role !== 'user' || !parts || parts.length < 2) return contents; // shape would cut the shared prefix and re-bill the injected blocks uncached.
return contents.map(movePromptContextAheadInEntry);
}
function movePromptContextAheadInEntry(content: Content): Content {
const parts = content.parts;
if (content.role !== 'user' || !parts || parts.length < 2) return content;
const volatileStart = parts.findIndex( const volatileStart = parts.findIndex(
(part) => part.partMetadata?.[REATTACH_BOUNDARY_METADATA] === true, (part) => part.partMetadata?.[REATTACH_BOUNDARY_METADATA] === true,
); );
@@ -1924,30 +1930,27 @@ function movePromptContextAheadOfUserText(contents: Content[]): Content[] {
leadingEnd !== -1 && leadingEnd !== -1 &&
parts.slice(leadingEnd, movableEnd).some((part) => part.functionResponse) parts.slice(leadingEnd, movableEnd).some((part) => part.functionResponse)
) { ) {
return contents; return content;
} }
const lastOwnIndex = parts.findLastIndex( const lastOwnIndex = parts.findLastIndex(
(part, index) => index < movableEnd && isOwnPart(part), (part, index) => index < movableEnd && isOwnPart(part),
); );
if (lastOwnIndex === -1 || lastOwnIndex === movableEnd - 1) return contents; if (lastOwnIndex === -1 || lastOwnIndex === movableEnd - 1) return content;
// Walk back over the contiguous own run only. A reminder the caller placed // Walk back over the contiguous own run only. A reminder the caller placed
// between two of its own parts bounds the run, so it keeps its position. // between two of its own parts bounds the run, so it keeps its position.
let firstOwnIndex = lastOwnIndex; let firstOwnIndex = lastOwnIndex;
while (firstOwnIndex > 0 && isOwnPart(parts[firstOwnIndex - 1]!)) { while (firstOwnIndex > 0 && isOwnPart(parts[firstOwnIndex - 1]!)) {
firstOwnIndex--; firstOwnIndex--;
} }
return [ return {
...contents.slice(0, -1), ...content,
{ parts: [
...last, ...parts.slice(0, firstOwnIndex),
parts: [ ...parts.slice(lastOwnIndex + 1, movableEnd),
...parts.slice(0, firstOwnIndex), ...parts.slice(firstOwnIndex, lastOwnIndex + 1),
...parts.slice(lastOwnIndex + 1, movableEnd), ...parts.slice(movableEnd),
...parts.slice(firstOwnIndex, lastOwnIndex + 1), ],
...parts.slice(movableEnd), };
],
},
];
} }
function stripThoughtPartsFromContent(content: Content): Content | null { function stripThoughtPartsFromContent(content: Content): Content | null {