fix: reset sessions after role ordering conflicts

This commit is contained in:
Peter Steinberger
2026-01-16 09:03:54 +00:00
parent 6c6bc6ff1c
commit 9838a2850f
7 changed files with 173 additions and 1 deletions

View File

@@ -51,6 +51,7 @@
- Fix: sanitize user-facing error text + strip `<final>` tags across reply pipelines. (#975) — thanks @ThomsenDrake. - Fix: sanitize user-facing error text + strip `<final>` 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: 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: 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 ## 2026.1.14-1

View File

@@ -261,4 +261,64 @@ describe("runEmbeddedPiAgent", () => {
expect(secondUserIndex).toBeGreaterThan(firstAssistantIndex); expect(secondUserIndex).toBeGreaterThan(firstAssistantIndex);
expect(secondAssistantIndex).toBeGreaterThan(secondUserIndex); expect(secondAssistantIndex).toBeGreaterThan(secondUserIndex);
}, 20_000); }, 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");
});
}); });

View File

@@ -274,6 +274,8 @@ export async function runEmbeddedPiAgent(
provider, provider,
model: model.id, model: model.id,
}, },
systemPromptReport: attempt.systemPromptReport,
error: { kind: "role_ordering", message: errorText },
}, },
}; };
} }

View File

@@ -20,7 +20,7 @@ export type EmbeddedPiRunMeta = {
aborted?: boolean; aborted?: boolean;
systemPromptReport?: SessionSystemPromptReport; systemPromptReport?: SessionSystemPromptReport;
error?: { error?: {
kind: "context_overflow" | "compaction_failure"; kind: "context_overflow" | "compaction_failure" | "role_ordering";
message: string; message: string;
}; };
}; };

View File

@@ -63,6 +63,7 @@ export async function runAgentTurnWithFallback(params: {
shouldEmitToolResult: () => boolean; shouldEmitToolResult: () => boolean;
pendingToolTasks: Set<Promise<void>>; pendingToolTasks: Set<Promise<void>>;
resetSessionAfterCompactionFailure: (reason: string) => Promise<boolean>; resetSessionAfterCompactionFailure: (reason: string) => Promise<boolean>;
resetSessionAfterRoleOrderingConflict: (reason: string) => Promise<boolean>;
isHeartbeat: boolean; isHeartbeat: boolean;
sessionKey?: string; sessionKey?: string;
getActiveSessionEntry: () => SessionEntry | undefined; getActiveSessionEntry: () => SessionEntry | undefined;
@@ -376,6 +377,17 @@ export async function runAgentTurnWithFallback(params: {
didResetAfterCompactionFailure = true; didResetAfterCompactionFailure = true;
continue; 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; break;
} catch (err) { } catch (err) {
@@ -395,6 +407,17 @@ export async function runAgentTurnWithFallback(params: {
didResetAfterCompactionFailure = true; didResetAfterCompactionFailure = true;
continue; 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 // Auto-recover from Gemini session corruption by resetting the session
if ( if (

View File

@@ -212,6 +212,55 @@ describe("runReplyAgent typing (heartbeat)", () => {
expect(payload).toMatchObject({ text: "ok" }); expect(payload).toMatchObject({ text: "ok" });
expect(sessionStore.main.sessionId).not.toBe(sessionId); 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")); const persisted = JSON.parse(await fs.readFile(storePath, "utf-8"));
expect(persisted.main.sessionId).toBe(sessionStore.main.sessionId); expect(persisted.main.sessionId).toBe(sessionStore.main.sessionId);
} finally { } finally {

View File

@@ -245,6 +245,42 @@ export async function runReplyAgent(params: {
); );
return true; return true;
}; };
const resetSessionAfterRoleOrderingConflict = async (reason: string): Promise<boolean> => {
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 { try {
const runOutcome = await runAgentTurnWithFallback({ const runOutcome = await runAgentTurnWithFallback({
commandBody, commandBody,
@@ -260,6 +296,7 @@ export async function runReplyAgent(params: {
shouldEmitToolResult, shouldEmitToolResult,
pendingToolTasks, pendingToolTasks,
resetSessionAfterCompactionFailure, resetSessionAfterCompactionFailure,
resetSessionAfterRoleOrderingConflict,
isHeartbeat, isHeartbeat,
sessionKey, sessionKey,
getActiveSessionEntry: () => activeSessionEntry, getActiveSessionEntry: () => activeSessionEntry,