fix: repair orphaned user turns before embedded prompts

This commit is contained in:
Peter Steinberger
2026-01-16 20:52:12 +00:00
parent 56efbce31e
commit 0dcffcd5b0
3 changed files with 57 additions and 29 deletions

View File

@@ -78,6 +78,7 @@
- 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) - 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: 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. - 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. - Discord: truncate skill command descriptions for slash command limits. (#1018) — thanks @evalexpr.

View File

@@ -261,7 +261,7 @@ 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 () => { it("repairs orphaned user messages and continues", async () => {
const { SessionManager } = await import("@mariozechner/pi-coding-agent"); const { SessionManager } = await import("@mariozechner/pi-coding-agent");
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-")); const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
@@ -317,8 +317,40 @@ describe("runEmbeddedPiAgent", () => {
agentDir, agentDir,
}); });
expect(result.meta.error?.kind).toBe("role_ordering"); expect(result.meta.error).toBeUndefined();
expect(result.meta.error?.message).toMatch(/incorrect role information|roles must alternate/i); expect(result.payloads?.length ?? 0).toBeGreaterThan(0);
expect(result.payloads?.[0]?.text).toContain("Message ordering conflict"); });
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);
}); });
}); });

View File

@@ -441,26 +441,22 @@ export async function runEmbeddedAttempt(
const promptStartedAt = Date.now(); const promptStartedAt = Date.now();
log.debug(`embedded run prompt start: runId=${params.runId} sessionId=${params.sessionId}`); 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 // Repair orphaned trailing user messages so new prompts don't violate role ordering.
const lastMsg = activeSession.messages[activeSession.messages.length - 1]; const leafEntry = sessionManager.getLeafEntry();
const lastMsgRole = if (leafEntry?.type === "message" && leafEntry.message.role === "user") {
lastMsg && typeof lastMsg === "object" ? (lastMsg as { role?: unknown }).role : undefined; if (leafEntry.parentId) {
sessionManager.branch(leafEntry.parentId);
if (lastMsgRole === "user") { } else {
// Last message was a user message. Adding another user message would create sessionManager.resetLeaf();
// consecutive user turns, violating Anthropic's role ordering requirement. }
// This can happen when: const sessionContext = sessionManager.buildSessionContext();
// 1. A previous heartbeat didn't get a response activeSession.agent.replaceMessages(sessionContext.messages);
// 2. A user message errored before getting an assistant response
// Skip this prompt to prevent "400 Incorrect role information" error.
log.warn( 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}`, `runId=${params.runId} sessionId=${params.sessionId}`,
); );
promptError = new Error( }
"Incorrect role information: consecutive user messages would violate role ordering",
);
} else {
try { try {
await activeSession.prompt(params.prompt, { images: params.images }); await activeSession.prompt(params.prompt, { images: params.images });
} catch (err) { } catch (err) {
@@ -470,7 +466,6 @@ export async function runEmbeddedAttempt(
`embedded run prompt end: runId=${params.runId} sessionId=${params.sessionId} durationMs=${Date.now() - promptStartedAt}`, `embedded run prompt end: runId=${params.runId} sessionId=${params.sessionId} durationMs=${Date.now() - promptStartedAt}`,
); );
} }
}
try { try {
await waitForCompactionRetry(); await waitForCompactionRetry();