fix(omp): an OMP run with a message Paseo does not render now finishes instead of staying running (#6112)

* fix(omp): finish the turn when OMP's run contains a developer message

OMP 18.5 injects hidden developer messages into a run, such as the
rule-violation reminder after a tool call matches a built-in rule. The
agent_end frame then failed schema validation and was dropped, so the
turn stayed running until it was cancelled.

Closes #6104

* fix(omp): parse every OMP message role so agent_end is never dropped

OMP's RPC AgentMessage also defines pythonExecution, hookMessage,
branchSummary, compactionSummary and fileMention. A prompt that
@-mentions a file puts a fileMention message in agent_end, the frame
failed validation and was dropped, and the turn stayed running.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
paseo-bot[bot]
2026-10-05 15:15:10 +02:00
committed by GitHub
parent 8ced059b4d
commit dfb8a1a2bc
2 changed files with 113 additions and 0 deletions
@@ -227,6 +227,95 @@ describe("OMP CLI runtime", () => {
expect(eventTypes).toEqual(["notice"]);
});
test("emits agent_end for a run that contains an OMP developer message", async () => {
const child = createOmpChild();
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
const eventTypes: string[] = [];
session.onEvent((event) => eventTypes.push(event.type));
// OMP 18.5 injects a developer reminder mid-run when a tool call matches a rule.
const reminder = {
role: "developer",
content: [
{ type: "text", text: '<system-reminder reason="rule_violation">…</system-reminder>' },
],
attribution: "agent",
timestamp: 2,
};
child.stdout.write(`${JSON.stringify({ type: "message_end", message: reminder })}\n`);
child.stdout.write(
`${JSON.stringify({
type: "agent_end",
messages: [
{ role: "user", content: [{ type: "text", text: "Write tiny.ts" }], timestamp: 1 },
reminder,
{
role: "assistant",
content: [{ type: "text", text: "Done" }],
stopReason: "stop",
timestamp: 3,
},
],
})}\n`,
);
expect(eventTypes).toEqual(["message_end", "agent_end"]);
});
test("emits agent_end for a run that contains every OMP message role Paseo does not render", async () => {
const child = createOmpChild();
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
const eventTypes: string[] = [];
session.onEvent((event) => eventTypes.push(event.type));
// A prompt that @-mentions a file makes OMP add a fileMention message to the run.
const fileMention = {
role: "fileMention",
files: [{ path: "notes.md", content: "# Notes\n", lineCount: 1 }],
timestamp: 2,
};
child.stdout.write(`${JSON.stringify({ type: "message_end", message: fileMention })}\n`);
child.stdout.write(
`${JSON.stringify({
type: "agent_end",
messages: [
{ role: "user", content: [{ type: "text", text: "Read @notes.md" }], timestamp: 1 },
fileMention,
{
role: "pythonExecution",
code: "print(1)",
output: "1\n",
cancelled: false,
truncated: false,
timestamp: 3,
},
{
role: "hookMessage",
customType: "hook",
content: "hook output",
display: false,
timestamp: 4,
},
{ role: "branchSummary", summary: "Earlier branch", fromId: "entry-1", timestamp: 5 },
{
role: "compactionSummary",
summary: "Earlier turns",
tokensBefore: 1000,
timestamp: 6,
},
{
role: "assistant",
content: [{ type: "text", text: "Done" }],
stopReason: "stop",
timestamp: 7,
},
],
})}\n`,
);
expect(eventTypes).toEqual(["message_end", "agent_end"]);
});
test("lists commands through get_available_commands", async () => {
const child = createOmpChild();
const commandTypes: string[] = [];
@@ -41,6 +41,13 @@ const OmpUserMessageSchema = z
content: z.union([z.string(), z.array(z.union([OmpTextContentSchema, OmpImageContentSchema]))]),
})
.passthrough();
// OMP injects hidden developer messages, such as rule-violation reminders, into a run.
const OmpDeveloperMessageSchema = z
.object({
role: z.literal("developer"),
content: z.union([z.string(), z.array(z.union([OmpTextContentSchema, OmpImageContentSchema]))]),
})
.passthrough();
const OmpCustomMessageSchema = z
.object({
role: z.literal("custom"),
@@ -79,13 +86,30 @@ const OmpBashExecutionMessageSchema = z
timestamp: z.number(),
})
.passthrough();
// Roles Paseo does not render. They are listed so that a frame carrying one, such as
// agent_end, still parses; every role in OMP's RPC AgentMessage must appear in the union.
const OmpPythonExecutionMessageSchema = z
.object({ role: z.literal("pythonExecution") })
.passthrough();
const OmpHookMessageSchema = z.object({ role: z.literal("hookMessage") }).passthrough();
const OmpBranchSummaryMessageSchema = z.object({ role: z.literal("branchSummary") }).passthrough();
const OmpCompactionSummaryMessageSchema = z
.object({ role: z.literal("compactionSummary") })
.passthrough();
const OmpFileMentionMessageSchema = z.object({ role: z.literal("fileMention") }).passthrough();
export const OmpAgentMessageSchema = z.discriminatedUnion("role", [
OmpUserMessageSchema,
OmpDeveloperMessageSchema,
OmpCustomMessageSchema,
OmpAssistantMessageSchema,
OmpToolResultMessageSchema,
OmpBashExecutionMessageSchema,
OmpPythonExecutionMessageSchema,
OmpHookMessageSchema,
OmpBranchSummaryMessageSchema,
OmpCompactionSummaryMessageSchema,
OmpFileMentionMessageSchema,
]);
export const OmpModelThinkingSchema = z