fix: reset sessions after role ordering conflicts
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user