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
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,
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
@@ -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
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
@@ -61,6 +61,14 @@ available skills ... no specific task."
个分片重新盖章。重排已存条目会破坏展示投影,并让已投递的 hook 上下文每轮重复
投递。因此只改请求副本,transcript、resume、去重与 rewind 的契约都不受影响。
**重排作用于每条 user 条目,而不只是待发送的那条。** 下一轮请求会从已存历史里
重新发送上一轮的条目;只重排待发送条目会让一条条目的线上形状取决于它的位置:
第 N 轮发出 `[提醒, hook, 目录, ...正文]`,第 N+1 轮却把同一条目重建成
`[提醒, ...正文, hook, 目录]`。两次请求的公共前缀就只到开头的提醒为止,注入块
每轮都要按全价重新 prefill——这正是 `REATTACH_BOUNDARY_METADATA` 要防止的失效
模式(#11627)。对请求副本里每条 user 条目套用同一条规则,条目的线上形状就与
位置无关,第 N+1 轮请求严格扩展第 N 轮的前缀。已存历史仍然不动。
**在发送边界统一处理,而不是逐个生产者修改。** 提醒、hook 上下文、IDE 上下文和
目录由不同模块在不同前端(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'));
await sendDrain(`question ${index}`, `memory-next-${index}`);
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).at(-1)?.text).toContain(next);
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 () => {
mockConfig.getAutoMemoryContext = () => '';
const toolResult = fnResponse('read_file', { output: 'contents' }, 'c1');
+20 -17
View File
@@ -1907,9 +1907,15 @@ function isInjectedPromptContext(part: Part): boolean {
* before it (#11627).
*/
function movePromptContextAheadOfUserText(contents: Content[]): Content[] {
const last = contents.at(-1);
const parts = last?.parts;
if (last?.role !== 'user' || !parts || parts.length < 2) return contents;
// Every user entry takes the same shape, not only the pending one. The next
// request re-sends earlier entries from history, so a position-dependent
// 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(
(part) => part.partMetadata?.[REATTACH_BOUNDARY_METADATA] === true,
);
@@ -1924,30 +1930,27 @@ function movePromptContextAheadOfUserText(contents: Content[]): Content[] {
leadingEnd !== -1 &&
parts.slice(leadingEnd, movableEnd).some((part) => part.functionResponse)
) {
return contents;
return content;
}
const lastOwnIndex = parts.findLastIndex(
(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
// between two of its own parts bounds the run, so it keeps its position.
let firstOwnIndex = lastOwnIndex;
while (firstOwnIndex > 0 && isOwnPart(parts[firstOwnIndex - 1]!)) {
firstOwnIndex--;
}
return [
...contents.slice(0, -1),
{
...last,
parts: [
...parts.slice(0, firstOwnIndex),
...parts.slice(lastOwnIndex + 1, movableEnd),
...parts.slice(firstOwnIndex, lastOwnIndex + 1),
...parts.slice(movableEnd),
],
},
];
return {
...content,
parts: [
...parts.slice(0, firstOwnIndex),
...parts.slice(lastOwnIndex + 1, movableEnd),
...parts.slice(firstOwnIndex, lastOwnIndex + 1),
...parts.slice(movableEnd),
],
};
}
function stripThoughtPartsFromContent(content: Content): Content | null {