


























@@ -1,5 +1,9 @@
11import { beforeEach, describe, expect, it, vi } from "vitest";
22import type { CallGatewayOptions } from "../gateway/call.js";
3+import {
4+buildAnnounceIdFromChildRun,
5+buildAnnounceIdempotencyKey,
6+} from "./announce-idempotency.js";
37import type { SubagentAnnounceDeliveryResult } from "./subagent-announce-dispatch.js";
48import {
59SUBAGENT_ENDED_REASON_COMPLETE,
@@ -154,6 +158,15 @@ function hasDeliveredTaskStatusUpdate(runId: string): boolean {
154158});
155159}
156160161+function buildExpectedAnnounceIdempotencyKey(entry: SubagentRunRecord): string {
162+return buildAnnounceIdempotencyKey(
163+buildAnnounceIdFromChildRun({
164+childSessionKey: entry.childSessionKey,
165+childRunId: entry.runId,
166+}),
167+);
168+}
169+157170function createLifecycleController({
158171 entry,
159172 runs = new Map([[entry.runId, entry]]),
@@ -184,6 +197,62 @@ function createLifecycleController({
184197return createSubagentRegistryLifecycleController(params);
185198}
186199200+async function runNoReplyMirrorScenario(params: {
201+timestamp: number;
202+text?: string;
203+idempotencyKey?: string;
204+idempotencyKeyForEntry?: (entry: SubagentRunRecord) => string;
205+}): Promise<SubagentRunRecord> {
206+const entry = createRunEntry({
207+endedAt: 4_000,
208+expectsCompletionMessage: true,
209+retainAttachmentsOnKeep: true,
210+});
211+const text = params.text ?? "final completion reply";
212+const idempotencyKey =
213+params.idempotencyKeyForEntry?.(entry) ??
214+params.idempotencyKey ??
215+`${buildExpectedAnnounceIdempotencyKey(entry)}:internal-source-reply:0`;
216+const runSubagentAnnounceFlow = vi.fn(
217+async (announceParams: {
218+onDeliveryResult?: (delivery: SubagentAnnounceDeliveryResult) => void;
219+}) => {
220+announceParams.onDeliveryResult?.({
221+delivered: false,
222+path: "direct",
223+error: "completion agent did not produce a visible reply",
224+});
225+return false;
226+},
227+);
228+gatewayMocks.callGateway.mockResolvedValueOnce({
229+messages: [
230+{
231+role: "assistant",
232+provider: "openclaw",
233+model: "delivery-mirror",
234+content: text,
235+timestamp: params.timestamp,
236+ idempotencyKey,
237+},
238+],
239+});
240+241+await createLifecycleController({
242+ entry,
243+captureSubagentCompletionReply: vi.fn(async () => text),
244+persist: vi.fn(),
245+ runSubagentAnnounceFlow,
246+}).completeSubagentRun({
247+runId: entry.runId,
248+endedAt: 4_000,
249+outcome: { status: "ok" },
250+reason: SUBAGENT_ENDED_REASON_COMPLETE,
251+triggerCleanup: true,
252+});
253+return entry;
254+}
255+187256describe("subagent registry lifecycle hardening", () => {
188257beforeEach(() => {
189258vi.clearAllMocks();
@@ -920,6 +989,110 @@ describe("subagent registry lifecycle hardening", () => {
920989expect(persist).toHaveBeenCalled();
921990});
922991992+it("credits only current-run requester delivery mirrors before retrying NO_REPLY", async () => {
993+const entry = await runNoReplyMirrorScenario({ timestamp: 12_345 });
994+995+await vi.waitFor(() => expect(entry.cleanupCompletedAt).toBeTypeOf("number"));
996+expect(gatewayMocks.callGateway).toHaveBeenCalledWith({
997+method: "chat.history",
998+params: { sessionKey: entry.requesterSessionKey, limit: 25, maxChars: 128 * 1024 },
999+timeoutMs: 5_000,
1000+});
1001+expect(entry.completionDeliveredAt).toBe(12_345);
1002+expect(entry.completionAnnouncedAt).toBe(12_345);
1003+expect(entry.lastAnnounceDeliveryError).toBeUndefined();
1004+expect(entry.pendingFinalDelivery).toBeUndefined();
1005+expect(entry.announceRetryCount).toBeUndefined();
1006+expect(hasDeliveredTaskStatusUpdate(entry.runId)).toBe(true);
1007+expect(helperMocks.logAnnounceGiveUp).not.toHaveBeenCalled();
1008+1009+vi.clearAllMocks();
1010+gatewayMocks.callGateway.mockResolvedValue({});
1011+const longMirrorEntry = await runNoReplyMirrorScenario({
1012+timestamp: 12_345,
1013+text: "long completion reply ".repeat(500),
1014+});
1015+1016+await vi.waitFor(() => expect(longMirrorEntry.cleanupCompletedAt).toBeTypeOf("number"));
1017+expect(longMirrorEntry.completionDeliveredAt).toBe(12_345);
1018+expect(gatewayMocks.callGateway).toHaveBeenCalledWith({
1019+method: "chat.history",
1020+params: { sessionKey: longMirrorEntry.requesterSessionKey, limit: 25, maxChars: 128 * 1024 },
1021+timeoutMs: 5_000,
1022+});
1023+1024+vi.clearAllMocks();
1025+gatewayMocks.callGateway.mockResolvedValue({});
1026+const messageToolAnnounceEntry = await runNoReplyMirrorScenario({
1027+timestamp: 12_345,
1028+idempotencyKeyForEntry: (candidate) =>
1029+`${buildExpectedAnnounceIdempotencyKey(candidate)}:message-tool:internal-source-reply:0`,
1030+});
1031+1032+await vi.waitFor(() =>
1033+expect(messageToolAnnounceEntry.cleanupCompletedAt).toBeTypeOf("number"),
1034+);
1035+expect(messageToolAnnounceEntry.completionDeliveredAt).toBe(12_345);
1036+1037+vi.clearAllMocks();
1038+gatewayMocks.callGateway.mockResolvedValue({});
1039+const childRunMirrorEntry = await runNoReplyMirrorScenario({
1040+timestamp: 12_345,
1041+idempotencyKeyForEntry: (candidate) => `${candidate.runId}:message-tool:1`,
1042+});
1043+1044+await vi.waitFor(() => expect(childRunMirrorEntry.cleanupCompletedAt).toBeTypeOf("number"));
1045+expect(childRunMirrorEntry.completionDeliveredAt).toBe(12_345);
1046+1047+vi.clearAllMocks();
1048+taskExecutorMocks.setDetachedTaskDeliveryStatusByRunId.mockReset();
1049+gatewayMocks.callGateway.mockResolvedValue({});
1050+const staleEntry = await runNoReplyMirrorScenario({ timestamp: 1_999 });
1051+1052+await vi.waitFor(() => expect(staleEntry.deliverySuspendedAt).toBeTypeOf("number"));
1053+expect(staleEntry.completionDeliveredAt).toBeUndefined();
1054+expect(staleEntry.completionAnnouncedAt).toBeUndefined();
1055+expect(staleEntry.lastAnnounceDeliveryError).toBe(
1056+"completion agent did not produce a visible reply",
1057+);
1058+expect(hasDeliveredTaskStatusUpdate(staleEntry.runId)).toBe(false);
1059+expectFields(firstCallArg(taskExecutorMocks.setDetachedTaskDeliveryStatusByRunId), {
1060+runId: staleEntry.runId,
1061+runtime: "subagent",
1062+sessionKey: staleEntry.childSessionKey,
1063+deliveryStatus: "failed",
1064+error: "completion agent did not produce a visible reply",
1065+});
1066+expect(helperMocks.logAnnounceGiveUp).toHaveBeenCalledWith(
1067+expect.objectContaining({
1068+runId: staleEntry.runId,
1069+requesterSessionKey: staleEntry.requesterSessionKey,
1070+}),
1071+"retry-limit",
1072+);
1073+1074+vi.clearAllMocks();
1075+taskExecutorMocks.setDetachedTaskDeliveryStatusByRunId.mockReset();
1076+gatewayMocks.callGateway.mockResolvedValue({});
1077+const sameWindowSiblingEntry = await runNoReplyMirrorScenario({
1078+timestamp: 12_345,
1079+idempotencyKey: `${buildAnnounceIdempotencyKey(
1080+ buildAnnounceIdFromChildRun({
1081+ childSessionKey: "agent:main:subagent:sibling",
1082+ childRunId: "run-sibling",
1083+ }),
1084+ )}:internal-source-reply:0`,
1085+});
1086+1087+await vi.waitFor(() => expect(sameWindowSiblingEntry.deliverySuspendedAt).toBeTypeOf("number"));
1088+expect(sameWindowSiblingEntry.completionDeliveredAt).toBeUndefined();
1089+expect(sameWindowSiblingEntry.completionAnnouncedAt).toBeUndefined();
1090+expect(sameWindowSiblingEntry.lastAnnounceDeliveryError).toBe(
1091+"completion agent did not produce a visible reply",
1092+);
1093+expect(hasDeliveredTaskStatusUpdate(sameWindowSiblingEntry.runId)).toBe(false);
1094+});
1095+9231096it("skips browser cleanup when steer restart suppresses cleanup flow", async () => {
9241097const entry = createRunEntry({
9251098expectsCompletionMessage: false,
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。