diff --git a/CHANGELOG.md b/CHANGELOG.md index 57a5b003f..1ab9a4f9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,7 @@ - Fix: sanitize user-facing error text + strip `` tags across reply pipelines. (#975) — thanks @ThomsenDrake. - 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) ## 2026.1.14-1 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 d559ce62e..5c2df46dd 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,4 +261,64 @@ 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 () => { + 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 1" }], + }); + sessionManager.appendMessage({ + role: "assistant", + content: [{ type: "text", text: "seed assistant" }], + stopReason: "stop", + api: "openai-responses", + provider: "openai", + model: "mock-1", + usage: { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 2, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + }, + timestamp: Date.now(), + }); + sessionManager.appendMessage({ + role: "user", + content: [{ type: "text", text: "seed user 2" }], + }); + + 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?.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"); + }); }); diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index f05703c95..757c429fa 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -274,6 +274,8 @@ export async function runEmbeddedPiAgent( provider, model: model.id, }, + systemPromptReport: attempt.systemPromptReport, + error: { kind: "role_ordering", message: errorText }, }, }; } diff --git a/src/agents/pi-embedded-runner/types.ts b/src/agents/pi-embedded-runner/types.ts index 3525de004..e2d33047a 100644 --- a/src/agents/pi-embedded-runner/types.ts +++ b/src/agents/pi-embedded-runner/types.ts @@ -20,7 +20,7 @@ export type EmbeddedPiRunMeta = { aborted?: boolean; systemPromptReport?: SessionSystemPromptReport; error?: { - kind: "context_overflow" | "compaction_failure"; + kind: "context_overflow" | "compaction_failure" | "role_ordering"; message: string; }; }; diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index abecf58be..ba6665860 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -63,6 +63,7 @@ export async function runAgentTurnWithFallback(params: { shouldEmitToolResult: () => boolean; pendingToolTasks: Set>; resetSessionAfterCompactionFailure: (reason: string) => Promise; + resetSessionAfterRoleOrderingConflict: (reason: string) => Promise; isHeartbeat: boolean; sessionKey?: string; getActiveSessionEntry: () => SessionEntry | undefined; @@ -376,6 +377,17 @@ export async function runAgentTurnWithFallback(params: { didResetAfterCompactionFailure = true; continue; } + if (embeddedError?.kind === "role_ordering") { + const didReset = await params.resetSessionAfterRoleOrderingConflict(embeddedError.message); + if (didReset) { + return { + kind: "final", + payload: { + text: "⚠️ Message ordering conflict. I've reset the conversation - please try again.", + }, + }; + } + } break; } catch (err) { @@ -395,6 +407,17 @@ export async function runAgentTurnWithFallback(params: { didResetAfterCompactionFailure = true; continue; } + if (isRoleOrderingError) { + const didReset = await params.resetSessionAfterRoleOrderingConflict(message); + if (didReset) { + return { + kind: "final", + payload: { + text: "⚠️ Message ordering conflict. I've reset the conversation - please try again.", + }, + }; + } + } // Auto-recover from Gemini session corruption by resetting the session if ( diff --git a/src/auto-reply/reply/agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.retries-after-compaction-failure-by-resetting-session.test.ts b/src/auto-reply/reply/agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.retries-after-compaction-failure-by-resetting-session.test.ts index cc490f6d1..6978f88fc 100644 --- a/src/auto-reply/reply/agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.retries-after-compaction-failure-by-resetting-session.test.ts +++ b/src/auto-reply/reply/agent-runner.heartbeat-typing.runreplyagent-typing-heartbeat.retries-after-compaction-failure-by-resetting-session.test.ts @@ -212,6 +212,55 @@ describe("runReplyAgent typing (heartbeat)", () => { expect(payload).toMatchObject({ text: "ok" }); expect(sessionStore.main.sessionId).not.toBe(sessionId); + const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(persisted.main.sessionId).toBe(sessionStore.main.sessionId); + } finally { + if (prevStateDir) { + process.env.CLAWDBOT_STATE_DIR = prevStateDir; + } else { + delete process.env.CLAWDBOT_STATE_DIR; + } + } + }); + it("resets the session after role ordering payloads", async () => { + const prevStateDir = process.env.CLAWDBOT_STATE_DIR; + const stateDir = await fs.mkdtemp(path.join(tmpdir(), "clawdbot-session-role-ordering-")); + process.env.CLAWDBOT_STATE_DIR = stateDir; + try { + const sessionId = "session"; + const storePath = path.join(stateDir, "sessions", "sessions.json"); + const sessionEntry = { sessionId, updatedAt: Date.now() }; + const sessionStore = { main: sessionEntry }; + + await fs.mkdir(path.dirname(storePath), { recursive: true }); + await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8"); + + runEmbeddedPiAgentMock.mockImplementationOnce(async () => ({ + payloads: [{ text: "Message ordering conflict - please try again.", isError: true }], + meta: { + durationMs: 1, + error: { + kind: "role_ordering", + message: 'messages: roles must alternate between "user" and "assistant"', + }, + }, + })); + + const { run } = createMinimalRun({ + sessionEntry, + sessionStore, + sessionKey: "main", + storePath, + }); + const res = await run(); + + const payload = Array.isArray(res) ? res[0] : res; + expect(payload).toMatchObject({ + text: expect.stringContaining("Message ordering conflict"), + }); + expect(payload.text?.toLowerCase()).toContain("reset"); + expect(sessionStore.main.sessionId).not.toBe(sessionId); + const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")); expect(persisted.main.sessionId).toBe(sessionStore.main.sessionId); } finally { diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index c23328f58..215756967 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -245,6 +245,42 @@ export async function runReplyAgent(params: { ); return true; }; + const resetSessionAfterRoleOrderingConflict = async (reason: string): Promise => { + if (!sessionKey || !activeSessionStore || !storePath) return false; + const nextSessionId = crypto.randomUUID(); + const nextEntry: SessionEntry = { + ...(activeSessionStore[sessionKey] ?? activeSessionEntry), + sessionId: nextSessionId, + updatedAt: Date.now(), + systemSent: false, + abortedLastRun: false, + }; + const agentId = resolveAgentIdFromSessionKey(sessionKey); + const nextSessionFile = resolveSessionTranscriptPath( + nextSessionId, + agentId, + sessionCtx.MessageThreadId, + ); + nextEntry.sessionFile = nextSessionFile; + activeSessionStore[sessionKey] = nextEntry; + try { + await updateSessionStore(storePath, (store) => { + store[sessionKey] = nextEntry; + }); + } catch (err) { + defaultRuntime.error( + `Failed to persist session reset after role ordering conflict (${sessionKey}): ${String(err)}`, + ); + } + followupRun.run.sessionId = nextSessionId; + followupRun.run.sessionFile = nextSessionFile; + activeSessionEntry = nextEntry; + activeIsNewSession = true; + defaultRuntime.error( + `Role ordering conflict (${reason}). Restarting session ${sessionKey} -> ${nextSessionId}.`, + ); + return true; + }; try { const runOutcome = await runAgentTurnWithFallback({ commandBody, @@ -260,6 +296,7 @@ export async function runReplyAgent(params: { shouldEmitToolResult, pendingToolTasks, resetSessionAfterCompactionFailure, + resetSessionAfterRoleOrderingConflict, isHeartbeat, sessionKey, getActiveSessionEntry: () => activeSessionEntry,