From 10da5ca169dfc205b55b35a12280796cb546bbc8 Mon Sep 17 00:00:00 2001 From: Ethan Greenfeld <39423472+ebg1223@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:25:12 -0700 Subject: [PATCH] Improve OMP state compatibility and advisor rendering (#2219) * Accept OMP session state without thinkingLevel Models whose reasoning effort is encoded in the model ID (e.g. cursor-grok-4.5-high-fast) are marked reasoning: false by OMP, which omits thinkingLevel from get_state. The required schema field made session creation throw a ZodError. Make it optional and resolve the thinking option to null when absent. * Render OMP advisor messages as blocks --- .../agent/providers/omp/advisor-message.ts | 98 +++++++++++++++++++ .../server/agent/providers/omp/agent.test.ts | 44 +++++++++ .../src/server/agent/providers/omp/agent.ts | 6 +- .../agent/providers/omp/cli-runtime.test.ts | 17 ++++ .../agent/providers/omp/history-hooks.ts | 8 +- .../providers/omp/history-mapper.test.ts | 53 ++++++++++ .../agent/providers/omp/message-history.ts | 3 +- .../server/agent/providers/omp/rpc-types.ts | 2 +- .../providers/omp/test-utils/omp-harness.ts | 20 +++- 9 files changed, 243 insertions(+), 8 deletions(-) create mode 100644 packages/server/src/server/agent/providers/omp/advisor-message.ts diff --git a/packages/server/src/server/agent/providers/omp/advisor-message.ts b/packages/server/src/server/agent/providers/omp/advisor-message.ts new file mode 100644 index 000000000..ec46dfc00 --- /dev/null +++ b/packages/server/src/server/agent/providers/omp/advisor-message.ts @@ -0,0 +1,98 @@ +import { createHash } from "node:crypto"; + +import type { AgentTimelineItem } from "../../agent-sdk-types.js"; +import type { OmpAgentMessage } from "./rpc-types.js"; + +type OmpCustomMessage = Extract; +type OmpAdvisorToolCallItem = Extract; +type OmpAdvisorSeverity = "nit" | "concern" | "blocker"; + +interface OmpAdvisorNote { + note: string; + severity?: OmpAdvisorSeverity; + advisor?: string; +} + +const ADVISOR_SEVERITIES: Record = { + nit: true, + concern: true, + blocker: true, +}; + +function readOptionalString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function readAdvisorNotes(message: OmpCustomMessage): OmpAdvisorNote[] { + const details = Reflect.get(message, "details"); + if (!details || typeof details !== "object") return []; + const notes = Reflect.get(details, "notes"); + if (!Array.isArray(notes)) return []; + + return notes.flatMap((value): OmpAdvisorNote[] => { + if (!value || typeof value !== "object") return []; + const note = readOptionalString(Reflect.get(value, "note")); + if (!note) return []; + const rawSeverity = Reflect.get(value, "severity"); + const severity = + typeof rawSeverity === "string" && rawSeverity in ADVISOR_SEVERITIES + ? (rawSeverity as OmpAdvisorSeverity) + : undefined; + const advisor = readOptionalString(Reflect.get(value, "advisor")); + return [{ note, ...(severity ? { severity } : {}), ...(advisor ? { advisor } : {}) }]; + }); +} + +function formatAdvisorNote(note: OmpAdvisorNote): string { + const prefix = [ + note.severity ? `[${note.severity}]` : null, + note.advisor ? `[${note.advisor}]` : null, + ] + .filter(Boolean) + .join(" "); + return prefix ? `${prefix} ${note.note}` : note.note; +} + +function buildAdvisorLabel(noteCount: number, blockerCount: number): string { + if (noteCount === 0) return "Advisor"; + const label = `Advisor · ${noteCount} ${noteCount === 1 ? "note" : "notes"}`; + return blockerCount > 0 + ? `${label} · ${blockerCount} ${blockerCount === 1 ? "blocker" : "blockers"}` + : label; +} + +function buildAdvisorCallId(message: OmpCustomMessage, text: string): string { + const id = readOptionalString(Reflect.get(message, "id")); + if (id) return `omp-advisor:${id}`; + const digest = createHash("sha1").update(text.trim()).digest("hex").slice(0, 12); + return `omp-advisor:${digest}`; +} + +export function mapOmpAdvisorMessageToToolCall( + message: OmpCustomMessage, + text: string, +): OmpAdvisorToolCallItem | null { + if (Reflect.get(message, "customType") !== "advisor") return null; + + const notes = readAdvisorNotes(message); + const blockerCount = notes.filter((note) => note.severity === "blocker").length; + return { + type: "tool_call", + callId: buildAdvisorCallId(message, text), + name: "advisor", + status: "completed", + detail: { + type: "plain_text", + label: buildAdvisorLabel(notes.length, blockerCount), + text: notes.length > 0 ? notes.map(formatAdvisorNote).join("\n\n") : text, + icon: "brain", + }, + metadata: { + synthetic: true, + source: "omp_advisor", + noteCount: notes.length, + blockerCount, + }, + error: null, + }; +} diff --git a/packages/server/src/server/agent/providers/omp/agent.test.ts b/packages/server/src/server/agent/providers/omp/agent.test.ts index 032315696..082b71edc 100644 --- a/packages/server/src/server/agent/providers/omp/agent.test.ts +++ b/packages/server/src/server/agent/providers/omp/agent.test.ts @@ -88,6 +88,50 @@ describe("OMP agent client and session", () => { expect(omp.completedTurnCount()).toBe(1); }); + test("streams OMP advisor messages as distinct tool-call blocks", async () => { + const omp = new OmpHarness(); + await omp.start(); + + await omp.runPromptWithCustomMessage( + "review this", + { + role: "custom", + content: 'Exercise the failure path.', + customType: "advisor", + id: "advisor-live-1", + display: true, + details: { + notes: [{ note: "Exercise the failure path.", severity: "concern" }], + }, + }, + "fixed", + ); + + expect(omp.timeline()).toEqual([ + { type: "user_message", text: "review this", messageId: "user-1" }, + { + type: "tool_call", + callId: "omp-advisor:advisor-live-1", + name: "advisor", + status: "completed", + detail: { + type: "plain_text", + label: "Advisor · 1 note", + text: "[concern] Exercise the failure path.", + icon: "brain", + }, + metadata: { + synthetic: true, + source: "omp_advisor", + noteCount: 1, + blockerCount: 0, + }, + error: null, + }, + { type: "assistant_message", text: "fixed", messageId: "omp-assistant-1" }, + ]); + }); + test("does not accept a follow-up until OMP reports stable idle", async () => { const omp = new OmpHarness(); await omp.start(); diff --git a/packages/server/src/server/agent/providers/omp/agent.ts b/packages/server/src/server/agent/providers/omp/agent.ts index 28ca8784d..39cdca02f 100644 --- a/packages/server/src/server/agent/providers/omp/agent.ts +++ b/packages/server/src/server/agent/providers/omp/agent.ts @@ -91,6 +91,7 @@ import { mapOmpAvailableCommandsUpdate, mapOmpRuntimeSlashCommands } from "./com import { streamOmpHistory } from "./history.js"; import { mapOmpTodoReminderEvent, mapOmpTodoState, mapOmpTodoToolResult } from "./todo-mapper.js"; import { mapOmpRuntimeEventToTimelineItem } from "./event-mapper.js"; +import { mapOmpAdvisorMessageToToolCall } from "./advisor-message.js"; import { clearOmpHostToolState, handleOmpHostToolRuntimeEvent, @@ -511,7 +512,7 @@ function isOmpRequestAbortError(error: unknown): boolean { function resolveThinkingOptionId( cachedThinkingOptionId: string | null, - sessionThinkingLevel: OmpThinkingLevel, + sessionThinkingLevel: OmpThinkingLevel | undefined, ): OmpThinkingLevel | null { const currentThinking = cachedThinkingOptionId ?? sessionThinkingLevel; return normalizeOmpThinkingOption(currentThinking); @@ -1987,11 +1988,12 @@ export class OmpAgentSession implements AgentSession { if (event.message.role === "custom") { const text = getUserMessageText(event.message.content); if (text) { + const advisorItem = mapOmpAdvisorMessageToToolCall(event.message, text); this.emit({ type: "timeline", provider: this.provider, turnId, - item: { type: "assistant_message", text }, + item: advisorItem ?? { type: "assistant_message", text }, }); } if (!this.activeTurnHasUserMessage) { diff --git a/packages/server/src/server/agent/providers/omp/cli-runtime.test.ts b/packages/server/src/server/agent/providers/omp/cli-runtime.test.ts index ae33d5830..cfa874544 100644 --- a/packages/server/src/server/agent/providers/omp/cli-runtime.test.ts +++ b/packages/server/src/server/agent/providers/omp/cli-runtime.test.ts @@ -96,6 +96,23 @@ describe("OMP CLI runtime", () => { }); }); + test("accepts session state without thinkingLevel for non-reasoning models", async () => { + const child = createOmpChild(); + // Models like cursor-grok-4.5-high-fast encode effort in the model ID, so + // OMP marks them reasoning: false and omits thinkingLevel from get_state. + replyToCommands(child, () => ({ + model: null, + isStreaming: false, + isCompacting: false, + sessionId: "session-1", + messageCount: 0, + queuedMessageCount: 0, + })); + const session = await createRuntime(child).startSession({ cwd: "/workspace/project" }); + + await expect(session.getState()).resolves.toMatchObject({ sessionId: "session-1" }); + }); + test("rejects malformed RPC results instead of trusting transport data", async () => { const child = createOmpChild(); replyToCommands(child, () => ({ diff --git a/packages/server/src/server/agent/providers/omp/history-hooks.ts b/packages/server/src/server/agent/providers/omp/history-hooks.ts index 914acc6c6..75b9505c3 100644 --- a/packages/server/src/server/agent/providers/omp/history-hooks.ts +++ b/packages/server/src/server/agent/providers/omp/history-hooks.ts @@ -1,13 +1,15 @@ import type { OmpHistoryMapperHooks } from "./message-history.js"; +import { mapOmpAdvisorMessageToToolCall } from "./advisor-message.js"; import { mapOmpSystemNoticeToToolCall } from "./system-notice.js"; import { mapOmpToolDetail } from "./tool-call-mapper.js"; import { resolveOmpEmittedToolCallId } from "./tool-call-id.js"; export const OMP_HISTORY_MAPPER_HOOKS: OmpHistoryMapperHooks = { mapToolDetail: mapOmpToolDetail, - mapCustomMessage: (text, provider) => { - const noticeItem = mapOmpSystemNoticeToToolCall(text); - return noticeItem ? { type: "timeline", provider, item: noticeItem } : null; + mapCustomMessage: (message, text, provider) => { + const item = + mapOmpAdvisorMessageToToolCall(message, text) ?? mapOmpSystemNoticeToToolCall(text); + return item ? { type: "timeline", provider, item } : null; }, resolveToolCallId: resolveOmpEmittedToolCallId, }; diff --git a/packages/server/src/server/agent/providers/omp/history-mapper.test.ts b/packages/server/src/server/agent/providers/omp/history-mapper.test.ts index 47665869d..353f3c98f 100644 --- a/packages/server/src/server/agent/providers/omp/history-mapper.test.ts +++ b/packages/server/src/server/agent/providers/omp/history-mapper.test.ts @@ -156,6 +156,59 @@ describe("OMP history mapper", () => { ]); }); + test("renders replayed OMP advisor messages as synthetic tool-call blocks", async () => { + await expect( + collectHistory([ + { + role: "custom", + content: [ + { + type: "text", + text: 'Add an authorization check.', + }, + ], + customType: "advisor", + id: "advisor-message-1", + display: true, + details: { + notes: [ + { + note: "Add an authorization check.", + severity: "blocker", + advisor: "security", + }, + { note: "Exercise the failure path.", severity: "concern" }, + ], + }, + }, + ]), + ).resolves.toEqual([ + { + type: "timeline", + provider: "omp", + item: { + type: "tool_call", + callId: "omp-advisor:advisor-message-1", + name: "advisor", + status: "completed", + detail: { + type: "plain_text", + label: "Advisor · 2 notes · 1 blocker", + text: "[blocker] [security] Add an authorization check.\n\n[concern] Exercise the failure path.", + icon: "brain", + }, + metadata: { + synthetic: true, + source: "omp_advisor", + noteCount: 2, + blockerCount: 1, + }, + error: null, + }, + }, + ]); + }); + test("suppresses replayed raw todo tool calls through the OMP detail hook", async () => { await expect( collectHistory([ diff --git a/packages/server/src/server/agent/providers/omp/message-history.ts b/packages/server/src/server/agent/providers/omp/message-history.ts index 562c669ee..04a6b2877 100644 --- a/packages/server/src/server/agent/providers/omp/message-history.ts +++ b/packages/server/src/server/agent/providers/omp/message-history.ts @@ -17,6 +17,7 @@ export interface OmpCapturedUserMessageEntry { export interface OmpHistoryMapperHooks { mapCustomMessage?: ( + message: Extract, text: string, provider: string, ) => Extract | null; @@ -117,7 +118,7 @@ export class OmpHistoryMapper { message: Extract, ): AgentStreamEvent[] { const text = getUserMessageText(message.content); - const mappedEvent = text ? this.hooks.mapCustomMessage?.(text, this.provider) : null; + const mappedEvent = text ? this.hooks.mapCustomMessage?.(message, text, this.provider) : null; if (mappedEvent) { return [mappedEvent]; } diff --git a/packages/server/src/server/agent/providers/omp/rpc-types.ts b/packages/server/src/server/agent/providers/omp/rpc-types.ts index bdd5412a4..c97a3b7f3 100644 --- a/packages/server/src/server/agent/providers/omp/rpc-types.ts +++ b/packages/server/src/server/agent/providers/omp/rpc-types.ts @@ -114,7 +114,7 @@ const OmpContextUsageSchema = z export const OmpSessionStateSchema = z .object({ model: OmpModelSchema.nullable().optional(), - thinkingLevel: OmpThinkingLevelSchema, + thinkingLevel: OmpThinkingLevelSchema.optional(), isStreaming: z.boolean(), isCompacting: z.boolean(), autoCompactionEnabled: z.boolean().optional(), diff --git a/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts b/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts index 817649e26..2675373fa 100644 --- a/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts +++ b/packages/server/src/server/agent/providers/omp/test-utils/omp-harness.ts @@ -12,7 +12,7 @@ import type { } from "../../../agent-sdk-types.js"; import type { PaseoToolCatalog } from "../../../tools/types.js"; import { OmpAgentClient, OmpAgentSession, type OmpProviderIdleScheduler } from "../agent.js"; -import type { OmpRpcSlashCommand } from "../rpc-types.js"; +import type { OmpAgentMessage, OmpRpcSlashCommand } from "../rpc-types.js"; import { FakeOmp } from "./fake-omp.js"; const CWD = "/tmp/paseo-omp-agent-test"; @@ -155,6 +155,24 @@ export class OmpHarness { return await run; } + async runPromptWithCustomMessage( + input: string, + customMessage: Extract, + output: string, + ): Promise { + const session = this.requireSession(); + const promptStarted = this.omp.latestSession().nextPrompt(); + const run = session.run(input); + await promptStarted; + const runtime = this.omp.latestSession(); + runtime.beginTurn(); + runtime.acceptPrompt(input, "user-1"); + runtime.emit({ type: "message_end", message: customMessage }); + runtime.streamAssistantText(output); + runtime.finishTurn(); + return await run; + } + async runPromptAfterExtensionNotice(input: string, output: string): Promise { const session = this.requireSession(); const promptStarted = this.omp.latestSession().nextPrompt();