
























11/** Tests ACP turn terminal results and detached-task progress outcomes. */
2+import type { AcpRuntimeEvent } from "@openclaw/acp-core/runtime/types";
23import { describe, expect, it, vi } from "vitest";
34import {
45requireTaskByRunId,
@@ -955,17 +956,19 @@ describe("AcpSessionManager turn results", () => {
955956}
956957});
957958958-it("retries once with a fresh persistent session after an early missing-session turn failure", async () => {
959+// Drives a thread-bound persistent ACP session whose first turn fails because
960+// the backend can no longer resume the stale session id, then a clean second
961+// turn. Returns observers so each case can assert whether the manager
962+// discarded the stale identity and retried fresh (#87830).
963+function setupStaleResumeScenario(firstTurn: () => AsyncIterable<AcpRuntimeEvent>) {
959964const runtimeState = createRuntime();
960965hoisted.requireAcpRuntimeBackendMock.mockReturnValue({
961966id: "acpx",
962967runtime: runtimeState.runtime,
963968});
964969const sessionKey = "agent:claude:acp:binding:discord:default:retry-no-session";
965970let currentMeta: SessionAcpMeta = {
966- ...readySessionMeta({
967-agent: "claude",
968-}),
971+ ...readySessionMeta({ agent: "claude" }),
969972runtimeSessionName: sessionKey,
970973identity: {
971974state: "resolved",
@@ -976,11 +979,7 @@ describe("AcpSessionManager turn results", () => {
976979};
977980hoisted.readAcpSessionEntryMock.mockImplementation((paramsUnknown: unknown) => {
978981const key = (paramsUnknown as { sessionKey?: string }).sessionKey ?? sessionKey;
979-return {
980-sessionKey: key,
981-storeSessionKey: key,
982-acp: currentMeta,
983-};
982+return { sessionKey: key, storeSessionKey: key, acp: currentMeta };
984983});
985984hoisted.upsertAcpSessionMetaMock.mockImplementation(async (paramsUnknown: unknown) => {
986985const params = paramsUnknown as {
@@ -993,11 +992,7 @@ describe("AcpSessionManager turn results", () => {
993992if (next) {
994993currentMeta = next;
995994}
996-return {
997-sessionId: "session-1",
998-updatedAt: Date.now(),
999-acp: currentMeta,
1000-};
995+return { sessionId: "session-1", updatedAt: Date.now(), acp: currentMeta };
1001996});
1002997runtimeState.ensureSession.mockImplementation(async (inputUnknown: unknown) => {
1003998const input = inputUnknown as {
@@ -1018,44 +1013,90 @@ describe("AcpSessionManager turn results", () => {
10181013details: { status: "alive" },
10191014});
10201015runtimeState.runTurn
1021-.mockImplementationOnce(async function* () {
1022-yield {
1023-type: "error" as const,
1024-code: "NO_SESSION",
1025-message:
1026-"Persistent ACP session acpx-sid-stale could not be resumed: Resource not found: acpx-sid-stale",
1027-};
1028-})
1016+.mockImplementationOnce(firstTurn)
10291017.mockImplementationOnce(async function* () {
10301018yield { type: "done" as const };
10311019});
1032-10331020const manager = new AcpSessionManager();
1034-await expect(
1021+const runTurn = () =>
10351022manager.runTurn({
10361023cfg: baseCfg,
10371024 sessionKey,
10381025text: "do work",
10391026mode: "prompt",
10401027requestId: "run-no-session",
1041-}),
1042-).resolves.toBeUndefined();
1028+});
1029+return { runtimeState, sessionKey, runTurn, getMeta: () => currentMeta };
1030+}
104310311044-expect(runtimeState.prepareFreshSession).toHaveBeenCalledWith({
1045- sessionKey,
1032+function expectFreshRetry(scenario: ReturnType<typeof setupStaleResumeScenario>) {
1033+expect(scenario.runtimeState.prepareFreshSession).toHaveBeenCalledWith({
1034+sessionKey: scenario.sessionKey,
10461035});
1047-expect(runtimeState.ensureSession).toHaveBeenCalledTimes(2);
1048-expectRecordFields(mockCallArg(runtimeState.ensureSession), {
1049- sessionKey,
1036+expect(scenario.runtimeState.ensureSession).toHaveBeenCalledTimes(2);
1037+expectRecordFields(mockCallArg(scenario.runtimeState.ensureSession), {
1038+sessionKey: scenario.sessionKey,
10501039resumeSessionId: "acpx-sid-stale",
10511040});
1052-const retryInput = mockCallArg(runtimeState.ensureSession, 1);
1053-expect(retryInput.resumeSessionId).toBeUndefined();
1054-expect(currentMeta.identity?.acpxSessionId).toBe("acpx-sid-fresh");
1055-expect(currentMeta.identity?.state).toBe("resolved");
1041+expect(mockCallArg(scenario.runtimeState.ensureSession, 1).resumeSessionId).toBeUndefined();
1042+expect(scenario.getMeta().identity?.acpxSessionId).toBe("acpx-sid-fresh");
1043+expect(scenario.getMeta().identity?.state).toBe("resolved");
10561044const states = extractStatesFromUpserts();
10571045expect(states).toContain("running");
10581046expect(states).toContain("idle");
10591047expect(states).not.toContain("error");
1048+}
1049+1050+// The structured SESSION_RESUME_REQUIRED detail code drives recovery
1051+// regardless of the backend's human-readable reason. Claude reports
1052+// "Resource not found"; Kiro reports "Internal error" (RequestError -32603) —
1053+// both must discard the stale persistent id and retry fresh (#87830).
1054+it.each([
1055+["Resource not found", "Resource not found: acpx-sid-stale"],
1056+["Internal error (Kiro RequestError -32603)", "Internal error"],
1057+])(
1058+"retries with a fresh persistent session on a resume-required error: %s",
1059+async (_label, reason) => {
1060+const scenario = setupStaleResumeScenario(async function* () {
1061+yield {
1062+type: "error" as const,
1063+code: "NO_SESSION",
1064+detailCode: "SESSION_RESUME_REQUIRED",
1065+message: `Persistent ACP session acpx-sid-stale could not be resumed: ${reason}`,
1066+};
1067+});
1068+await expect(scenario.runTurn()).resolves.toBeUndefined();
1069+expectFreshRetry(scenario);
1070+},
1071+);
1072+1073+it("recovers when the resume-required error is wrapped as a thrown cause", async () => {
1074+const scenario = setupStaleResumeScenario(
1075+// eslint-disable-next-line require-yield -- an async generator that only throws is a valid empty stream.
1076+async function* () {
1077+const error = new Error(
1078+"Persistent ACP session acpx-sid-stale could not be resumed: Internal error",
1079+) as Error & { detailCode?: string };
1080+error.detailCode = "SESSION_RESUME_REQUIRED";
1081+throw error;
1082+},
1083+);
1084+await expect(scenario.runTurn()).resolves.toBeUndefined();
1085+expectFreshRetry(scenario);
1086+});
1087+1088+it("does not retry a generic Internal error that is not a resume-required failure", async () => {
1089+const scenario = setupStaleResumeScenario(async function* () {
1090+// No SESSION_RESUME_REQUIRED detail code: a generic backend failure must
1091+// surface, not silently discard the persistent session and retry.
1092+yield {
1093+type: "error" as const,
1094+code: "ACP_TURN_FAILED",
1095+message: "Internal error",
1096+};
1097+});
1098+await expect(scenario.runTurn()).rejects.toThrow();
1099+expect(scenario.runtimeState.prepareFreshSession).not.toHaveBeenCalled();
1100+expect(scenario.runtimeState.ensureSession).toHaveBeenCalledTimes(1);
10601101});
10611102});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。