























@@ -26,6 +26,7 @@ import {
2626resolveEmptyResponseRetryInstruction,
2727resolvePlanningOnlyRetryLimit,
2828resolvePlanningOnlyRetryInstruction,
29+isIncompleteTerminalAssistantTurn,
2930resolveIncompleteTurnPayloadText,
3031resolveReasoningOnlyRetryInstruction,
3132STRICT_AGENTIC_BLOCKED_TEXT,
@@ -995,6 +996,136 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => {
995996).toBe("abandoned");
996997});
997998999+it("flags tool-use stop reason as incomplete even when pre-tool text exists (#76477)", () => {
1000+expect(
1001+isIncompleteTerminalAssistantTurn({
1002+hasAssistantVisibleText: true,
1003+lastAssistant: { stopReason: "toolUse" },
1004+}),
1005+).toBe(true);
1006+expect(
1007+isIncompleteTerminalAssistantTurn({
1008+hasAssistantVisibleText: false,
1009+lastAssistant: { stopReason: "toolUse" },
1010+}),
1011+).toBe(true);
1012+expect(
1013+isIncompleteTerminalAssistantTurn({
1014+hasAssistantVisibleText: true,
1015+lastAssistant: { stopReason: "end_turn" },
1016+}),
1017+).toBe(false);
1018+});
1019+1020+it("detects tool-use terminal turn with pre-tool text as incomplete (#76477)", () => {
1021+// When the last assistant message ended with stopReason=toolUse, pre-tool
1022+// text alone must not suppress the incomplete-turn guard. The model
1023+// expected to continue after tool results but the post-tool response was
1024+// never produced.
1025+const incompleteTurnText = resolveIncompleteTurnPayloadText({
1026+payloadCount: 1,
1027+aborted: false,
1028+timedOut: false,
1029+attempt: makeAttemptResult({
1030+assistantTexts: ["Initial analysis of the codebase..."],
1031+toolMetas: [{ toolName: "read", meta: "path=src/index.ts" }],
1032+lastAssistant: {
1033+role: "assistant",
1034+stopReason: "toolUse",
1035+provider: "anthropic",
1036+model: "sonnet-4.6",
1037+content: [
1038+{ type: "text", text: "Initial analysis of the codebase..." },
1039+{ type: "tool_use", id: "tool_1", name: "read", input: { path: "src/index.ts" } },
1040+],
1041+} as unknown as EmbeddedRunAttemptResult["lastAssistant"],
1042+}),
1043+});
1044+1045+expect(incompleteTurnText).not.toBeNull();
1046+expect(incompleteTurnText).toContain("couldn't generate a response");
1047+});
1048+1049+it("surfaces tool-use terminal with pre-tool text and side effects as replay-unsafe (#76477)", () => {
1050+const incompleteTurnText = resolveIncompleteTurnPayloadText({
1051+payloadCount: 1,
1052+aborted: false,
1053+timedOut: false,
1054+attempt: makeAttemptResult({
1055+assistantTexts: ["Let me update the file..."],
1056+toolMetas: [{ toolName: "write" }],
1057+lastAssistant: {
1058+role: "assistant",
1059+stopReason: "toolUse",
1060+provider: "openai",
1061+model: "gpt-5.4",
1062+content: [
1063+{ type: "text", text: "Let me update the file..." },
1064+{ type: "tool_use", id: "tool_1", name: "write", input: {} },
1065+],
1066+} as unknown as EmbeddedRunAttemptResult["lastAssistant"],
1067+}),
1068+});
1069+1070+expect(incompleteTurnText).toContain("verify before retrying");
1071+});
1072+1073+it("does not flag a completed tool-use turn with end_turn as incomplete (#76477)", () => {
1074+// When the model successfully produces post-tool text, lastAssistant has
1075+// stopReason=end_turn. The incomplete-turn guard should not fire.
1076+const incompleteTurnText = resolveIncompleteTurnPayloadText({
1077+payloadCount: 2,
1078+aborted: false,
1079+timedOut: false,
1080+attempt: makeAttemptResult({
1081+assistantTexts: ["Initial analysis...", "Here is the final answer."],
1082+toolMetas: [{ toolName: "read" }],
1083+lastAssistant: {
1084+role: "assistant",
1085+stopReason: "end_turn",
1086+provider: "anthropic",
1087+model: "sonnet-4.6",
1088+content: [{ type: "text", text: "Here is the final answer." }],
1089+} as unknown as EmbeddedRunAttemptResult["lastAssistant"],
1090+}),
1091+});
1092+1093+expect(incompleteTurnText).toBeNull();
1094+});
1095+1096+it("surfaces an error for tool-use terminal turn with pre-tool text via runEmbeddedPiAgent (#76477)", async () => {
1097+mockedClassifyFailoverReason.mockReturnValue(null);
1098+mockedRunEmbeddedAttempt.mockResolvedValueOnce(
1099+makeAttemptResult({
1100+assistantTexts: ["Initial analysis of the issue..."],
1101+toolMetas: [{ toolName: "read", meta: "path=src/index.ts" }],
1102+lastAssistant: {
1103+stopReason: "toolUse",
1104+provider: "anthropic",
1105+model: "sonnet-4.6",
1106+content: [
1107+{ type: "text", text: "Initial analysis of the issue..." },
1108+{ type: "tool_use", id: "tool_1", name: "read", input: { path: "src/index.ts" } },
1109+],
1110+} as unknown as EmbeddedRunAttemptResult["lastAssistant"],
1111+}),
1112+);
1113+1114+const result = await runEmbeddedPiAgent({
1115+ ...overflowBaseRunParams,
1116+provider: "anthropic",
1117+model: "sonnet-4.6",
1118+runId: "run-tool-use-dropped-final-text",
1119+});
1120+1121+expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1);
1122+expect(result.payloads?.[0]?.isError).toBe(true);
1123+expect(result.payloads?.[0]?.text).toContain("couldn't generate a response");
1124+expect(mockedLog.warn).toHaveBeenCalledWith(
1125+expect.stringContaining("incomplete turn detected"),
1126+);
1127+});
1128+9981129it("treats missing replay metadata as replay-invalid", () => {
9991130const attempt = makeAttemptResult();
10001131delete (attempt as Partial<EmbeddedRunAttemptResult>).replayMetadata;
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。