





















@@ -58,12 +58,15 @@ function openclawTranscriptAssistant(model: "delivery-mirror" | "gateway-injecte
5858}
59596060describe("normalizeAssistantReplayContent", () => {
61-it("converts assistant content: [] to a non-empty sentinel text block when stopReason is error", () => {
62-const messages = [userMessage("hello"), bedrockAssistant([], "error")];
61+it("converts mid-turn assistant content: [] to a non-empty sentinel text block when stopReason is error", () => {
62+const messages = [userMessage("hello"), bedrockAssistant([], "error"), userMessage("retry")];
6363const out = normalizeAssistantReplayContent(messages);
6464expect(out).not.toBe(messages);
6565const repaired = out[1] as AgentMessage & { content: { type: string; text: string }[] };
6666expect(repaired.content).toEqual([{ type: "text", text: FALLBACK_TEXT }]);
67+// Trailing user is preserved so request still ends with user.
68+expect(out).toHaveLength(3);
69+expect((out[2] as { role: string }).role).toBe("user");
6770});
68716972it("drops blank user text messages from replay", () => {
@@ -108,9 +111,9 @@ describe("normalizeAssistantReplayContent", () => {
108111expect(out[1]).toBe(silentStop);
109112});
110113111-it("converts zero-usage empty stop turns to a replay sentinel", () => {
114+it("converts mid-turn zero-usage empty stop turns to a replay sentinel", () => {
112115const falseSuccessStop = bedrockAssistant([], "stop");
113-const messages = [userMessage("hello"), falseSuccessStop];
116+const messages = [userMessage("hello"), falseSuccessStop, userMessage("retry")];
114117const out = normalizeAssistantReplayContent(messages);
115118expect(out).not.toBe(messages);
116119const repaired = out[1] as AgentMessage & { content: { type: string; text: string }[] };
@@ -183,4 +186,117 @@ describe("normalizeAssistantReplayContent", () => {
183186const out = normalizeAssistantReplayContent(messages);
184187expect(out).toBe(messages);
185188});
189+190+it("drops a trailing assistant turn whose content: [] would have been rewritten to the sentinel (#77228)", () => {
191+// The sentinel was synthesized to satisfy Bedrock's non-empty-content
192+// rule for *non-trailing* error turns. As the trailing message it would
193+// make prefill-strict providers (e.g. github-copilot/claude-opus-4.6)
194+// 400 with "conversation must end with a user message". The original
195+// turn carried content:[] and zero usage — drop is lossless.
196+const messages = [userMessage("hello"), bedrockAssistant([], "error")];
197+const out = normalizeAssistantReplayContent(messages);
198+expect(out).not.toBe(messages);
199+expect(out).toHaveLength(1);
200+expect(out[0]).toBe(messages[0]);
201+});
202+203+it("drops a trailing zero-usage empty stop assistant turn (#77228)", () => {
204+const falseSuccessStop = bedrockAssistant([], "stop");
205+const messages = [userMessage("hello"), falseSuccessStop];
206+const out = normalizeAssistantReplayContent(messages);
207+expect(out).toHaveLength(1);
208+expect(out[0]).toBe(messages[0]);
209+});
210+211+it("drops a trailing assistant turn that already carries the persisted sentinel content (#77228)", () => {
212+// Covers the case where session-file-repair persisted the sentinel to
213+// disk; on the next turn the loaded transcript ends with a non-empty
214+// assistant turn whose only content is the sentinel text. Provider
215+// request must still end with user.
216+const persistedSentinel = bedrockAssistant([{ type: "text", text: FALLBACK_TEXT }], "error");
217+const messages = [userMessage("hello"), persistedSentinel];
218+const out = normalizeAssistantReplayContent(messages);
219+expect(out).toHaveLength(1);
220+expect(out[0]).toBe(messages[0]);
221+});
222+223+it("drops several consecutive trailing sentinel/empty-error turns at the tail", () => {
224+const messages = [
225+userMessage("hi"),
226+bedrockAssistant([{ type: "text", text: "real" }]),
227+userMessage("again"),
228+bedrockAssistant([], "error"),
229+bedrockAssistant([{ type: "text", text: FALLBACK_TEXT }], "error"),
230+];
231+const out = normalizeAssistantReplayContent(messages);
232+expect(out).toHaveLength(3);
233+expect((out.at(-1) as { role: string }).role).toBe("user");
234+});
235+236+it("does not drop a trailing assistant turn that has real content", () => {
237+const realReply = bedrockAssistant([{ type: "text", text: "hello back" }], "stop", {
238+input: 1,
239+output: 1,
240+totalTokens: 2,
241+});
242+const messages = [userMessage("hi"), realReply];
243+const out = normalizeAssistantReplayContent(messages);
244+expect(out).toBe(messages);
245+expect(out).toHaveLength(2);
246+});
247+248+it("does not drop a trailing assistant turn with non-error empty content (toolUse / length)", () => {
249+// Boundary lock: only error/zero-usage-empty-stop and the sentinel
250+// shape are droppable. toolUse/length empty turns are real provider
251+// states and must be preserved on the wire.
252+const toolUse = bedrockAssistant([], "toolUse");
253+const messages = [userMessage("hi"), toolUse];
254+const out = normalizeAssistantReplayContent(messages);
255+expect(out).toBe(messages);
256+expect(out).toHaveLength(2);
257+});
258+259+it("preserves a trailing real model reply whose only content happens to be the sentinel text (clawsweeper review on #77287)", () => {
260+// Defensive boundary: even if a model legitimately replies with the
261+// exact sentinel string, the trim must require synthetic provenance
262+// (stopReason: "error" or zero-usage stop) before dropping. Without
263+// this guard the trim would silently delete a real reply on next
264+// replay.
265+const realReplyAsStop = bedrockAssistant([{ type: "text", text: FALLBACK_TEXT }], "stop", {
266+input: 1,
267+output: 1,
268+totalTokens: 2,
269+});
270+const messages = [userMessage("hi"), realReplyAsStop];
271+const out = normalizeAssistantReplayContent(messages);
272+expect(out).toBe(messages);
273+expect(out).toHaveLength(2);
274+expect((out[1] as { content: unknown[] }).content).toEqual([
275+{ type: "text", text: FALLBACK_TEXT },
276+]);
277+});
278+279+it("preserves a trailing turn whose sentinel content is paired with stopReason: toolUse (real provider state, not synthetic)", () => {
280+const toolUseSentinel = bedrockAssistant([{ type: "text", text: FALLBACK_TEXT }], "toolUse");
281+const messages = [userMessage("hi"), toolUseSentinel];
282+const out = normalizeAssistantReplayContent(messages);
283+expect(out).toBe(messages);
284+expect(out).toHaveLength(2);
285+});
286+287+it("still drops a trailing zero-usage stop turn whose content was already lifted to the sentinel block (post-rewrite shape)", () => {
288+// Confirms the sentinel-content branch still recognizes the post-rewrite
289+// shape produced by the in-memory rewrite earlier in the same loop:
290+// stopReason: "stop" + zero usage + sentinel content. Only the synthetic
291+// provenance (zero usage + stop) makes this droppable; a non-zero-usage
292+// version is preserved by the regression test above.
293+const persistedZeroUsageSentinel = bedrockAssistant(
294+[{ type: "text", text: FALLBACK_TEXT }],
295+"stop",
296+);
297+const messages = [userMessage("hi"), persistedZeroUsageSentinel];
298+const out = normalizeAssistantReplayContent(messages);
299+expect(out).toHaveLength(1);
300+expect(out[0]).toBe(messages[0]);
301+});
186302});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。