diff --git a/CHANGELOG.md b/CHANGELOG.md index 291feb3fa..7847e2cf5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,7 @@ - Fix: normalize pairing CLI aliases, allow extension channels, and harden Zalo webhook payload parsing. (#991) — thanks @longmaba. - Fix: allow local Tailscale Serve hostnames without treating tailnet clients as direct. (#885) — thanks @oswalpalash. - Fix: reset sessions after role-ordering conflicts to recover from consecutive user turns. (#998) +- Fix: repair orphaned user turns before embedded prompts to avoid role-ordering lockouts. (#1026) — thanks @odrobnik. - Fix: keep background exec aborts from killing backgrounded sessions while honoring timeouts. - Fix: use local auth for gateway security probe unless remote mode has a URL. (#1011) — thanks @ivanrvpereira. - Discord: truncate skill command descriptions for slash command limits. (#1018) — thanks @evalexpr. diff --git a/src/agents/pi-embedded-runner.run-embedded-pi-agent.appends-new-user-assistant-after-existing-transcript.test.ts b/src/agents/pi-embedded-runner.run-embedded-pi-agent.appends-new-user-assistant-after-existing-transcript.test.ts index 5c2df46dd..68bf5a7da 100644 --- a/src/agents/pi-embedded-runner.run-embedded-pi-agent.appends-new-user-assistant-after-existing-transcript.test.ts +++ b/src/agents/pi-embedded-runner.run-embedded-pi-agent.appends-new-user-assistant-after-existing-transcript.test.ts @@ -261,7 +261,7 @@ describe("runEmbeddedPiAgent", () => { expect(secondUserIndex).toBeGreaterThan(firstAssistantIndex); expect(secondAssistantIndex).toBeGreaterThan(secondUserIndex); }, 20_000); - it("returns role ordering error when session ends with a user turn", async () => { + it("repairs orphaned user messages and continues", async () => { const { SessionManager } = await import("@mariozechner/pi-coding-agent"); const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-")); @@ -317,8 +317,40 @@ describe("runEmbeddedPiAgent", () => { agentDir, }); - expect(result.meta.error?.kind).toBe("role_ordering"); - expect(result.meta.error?.message).toMatch(/incorrect role information|roles must alternate/i); - expect(result.payloads?.[0]?.text).toContain("Message ordering conflict"); + expect(result.meta.error).toBeUndefined(); + expect(result.payloads?.length ?? 0).toBeGreaterThan(0); + }); + + it("repairs orphaned single-user sessions and continues", async () => { + const { SessionManager } = await import("@mariozechner/pi-coding-agent"); + + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-")); + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-")); + const sessionFile = path.join(workspaceDir, "session.jsonl"); + + const sessionManager = SessionManager.open(sessionFile); + sessionManager.appendMessage({ + role: "user", + content: [{ type: "text", text: "seed user only" }], + }); + + const cfg = makeOpenAiConfig(["mock-1"]); + await ensureModels(cfg, agentDir); + + const result = await runEmbeddedPiAgent({ + sessionId: "session:test", + sessionKey: "agent:main:main", + sessionFile, + workspaceDir, + config: cfg, + prompt: "hello", + provider: "openai", + model: "mock-1", + timeoutMs: 5_000, + agentDir, + }); + + expect(result.meta.error).toBeUndefined(); + expect(result.payloads?.length ?? 0).toBeGreaterThan(0); }); }); diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index b58ceeea4..2ca91ac7b 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -441,35 +441,30 @@ export async function runEmbeddedAttempt( const promptStartedAt = Date.now(); log.debug(`embedded run prompt start: runId=${params.runId} sessionId=${params.sessionId}`); - // Check if last message is a user message to prevent consecutive user turns - const lastMsg = activeSession.messages[activeSession.messages.length - 1]; - const lastMsgRole = - lastMsg && typeof lastMsg === "object" ? (lastMsg as { role?: unknown }).role : undefined; - - if (lastMsgRole === "user") { - // Last message was a user message. Adding another user message would create - // consecutive user turns, violating Anthropic's role ordering requirement. - // This can happen when: - // 1. A previous heartbeat didn't get a response - // 2. A user message errored before getting an assistant response - // Skip this prompt to prevent "400 Incorrect role information" error. + // Repair orphaned trailing user messages so new prompts don't violate role ordering. + const leafEntry = sessionManager.getLeafEntry(); + if (leafEntry?.type === "message" && leafEntry.message.role === "user") { + if (leafEntry.parentId) { + sessionManager.branch(leafEntry.parentId); + } else { + sessionManager.resetLeaf(); + } + const sessionContext = sessionManager.buildSessionContext(); + activeSession.agent.replaceMessages(sessionContext.messages); log.warn( - `Skipping prompt because last message is a user message (would create consecutive user turns). ` + + `Removed orphaned user message to prevent consecutive user turns. ` + `runId=${params.runId} sessionId=${params.sessionId}`, ); - promptError = new Error( - "Incorrect role information: consecutive user messages would violate role ordering", + } + + try { + await activeSession.prompt(params.prompt, { images: params.images }); + } catch (err) { + promptError = err; + } finally { + log.debug( + `embedded run prompt end: runId=${params.runId} sessionId=${params.sessionId} durationMs=${Date.now() - promptStartedAt}`, ); - } else { - try { - await activeSession.prompt(params.prompt, { images: params.images }); - } catch (err) { - promptError = err; - } finally { - log.debug( - `embedded run prompt end: runId=${params.runId} sessionId=${params.sessionId} durationMs=${Date.now() - promptStartedAt}`, - ); - } } try {