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
This commit is contained in:
@@ -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<OmpAgentMessage, { role: "custom" }>;
|
||||||
|
type OmpAdvisorToolCallItem = Extract<AgentTimelineItem, { type: "tool_call" }>;
|
||||||
|
type OmpAdvisorSeverity = "nit" | "concern" | "blocker";
|
||||||
|
|
||||||
|
interface OmpAdvisorNote {
|
||||||
|
note: string;
|
||||||
|
severity?: OmpAdvisorSeverity;
|
||||||
|
advisor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ADVISOR_SEVERITIES: Record<OmpAdvisorSeverity, true> = {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -88,6 +88,50 @@ describe("OMP agent client and session", () => {
|
|||||||
expect(omp.completedTurnCount()).toBe(1);
|
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: '<advisory severity="concern">Exercise the failure path.</advisory>',
|
||||||
|
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 () => {
|
test("does not accept a follow-up until OMP reports stable idle", async () => {
|
||||||
const omp = new OmpHarness();
|
const omp = new OmpHarness();
|
||||||
await omp.start();
|
await omp.start();
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ import { mapOmpAvailableCommandsUpdate, mapOmpRuntimeSlashCommands } from "./com
|
|||||||
import { streamOmpHistory } from "./history.js";
|
import { streamOmpHistory } from "./history.js";
|
||||||
import { mapOmpTodoReminderEvent, mapOmpTodoState, mapOmpTodoToolResult } from "./todo-mapper.js";
|
import { mapOmpTodoReminderEvent, mapOmpTodoState, mapOmpTodoToolResult } from "./todo-mapper.js";
|
||||||
import { mapOmpRuntimeEventToTimelineItem } from "./event-mapper.js";
|
import { mapOmpRuntimeEventToTimelineItem } from "./event-mapper.js";
|
||||||
|
import { mapOmpAdvisorMessageToToolCall } from "./advisor-message.js";
|
||||||
import {
|
import {
|
||||||
clearOmpHostToolState,
|
clearOmpHostToolState,
|
||||||
handleOmpHostToolRuntimeEvent,
|
handleOmpHostToolRuntimeEvent,
|
||||||
@@ -511,7 +512,7 @@ function isOmpRequestAbortError(error: unknown): boolean {
|
|||||||
|
|
||||||
function resolveThinkingOptionId(
|
function resolveThinkingOptionId(
|
||||||
cachedThinkingOptionId: string | null,
|
cachedThinkingOptionId: string | null,
|
||||||
sessionThinkingLevel: OmpThinkingLevel,
|
sessionThinkingLevel: OmpThinkingLevel | undefined,
|
||||||
): OmpThinkingLevel | null {
|
): OmpThinkingLevel | null {
|
||||||
const currentThinking = cachedThinkingOptionId ?? sessionThinkingLevel;
|
const currentThinking = cachedThinkingOptionId ?? sessionThinkingLevel;
|
||||||
return normalizeOmpThinkingOption(currentThinking);
|
return normalizeOmpThinkingOption(currentThinking);
|
||||||
@@ -1987,11 +1988,12 @@ export class OmpAgentSession implements AgentSession {
|
|||||||
if (event.message.role === "custom") {
|
if (event.message.role === "custom") {
|
||||||
const text = getUserMessageText(event.message.content);
|
const text = getUserMessageText(event.message.content);
|
||||||
if (text) {
|
if (text) {
|
||||||
|
const advisorItem = mapOmpAdvisorMessageToToolCall(event.message, text);
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "timeline",
|
type: "timeline",
|
||||||
provider: this.provider,
|
provider: this.provider,
|
||||||
turnId,
|
turnId,
|
||||||
item: { type: "assistant_message", text },
|
item: advisorItem ?? { type: "assistant_message", text },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!this.activeTurnHasUserMessage) {
|
if (!this.activeTurnHasUserMessage) {
|
||||||
|
|||||||
@@ -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 () => {
|
test("rejects malformed RPC results instead of trusting transport data", async () => {
|
||||||
const child = createOmpChild();
|
const child = createOmpChild();
|
||||||
replyToCommands(child, () => ({
|
replyToCommands(child, () => ({
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import type { OmpHistoryMapperHooks } from "./message-history.js";
|
import type { OmpHistoryMapperHooks } from "./message-history.js";
|
||||||
|
import { mapOmpAdvisorMessageToToolCall } from "./advisor-message.js";
|
||||||
import { mapOmpSystemNoticeToToolCall } from "./system-notice.js";
|
import { mapOmpSystemNoticeToToolCall } from "./system-notice.js";
|
||||||
import { mapOmpToolDetail } from "./tool-call-mapper.js";
|
import { mapOmpToolDetail } from "./tool-call-mapper.js";
|
||||||
import { resolveOmpEmittedToolCallId } from "./tool-call-id.js";
|
import { resolveOmpEmittedToolCallId } from "./tool-call-id.js";
|
||||||
|
|
||||||
export const OMP_HISTORY_MAPPER_HOOKS: OmpHistoryMapperHooks = {
|
export const OMP_HISTORY_MAPPER_HOOKS: OmpHistoryMapperHooks = {
|
||||||
mapToolDetail: mapOmpToolDetail,
|
mapToolDetail: mapOmpToolDetail,
|
||||||
mapCustomMessage: (text, provider) => {
|
mapCustomMessage: (message, text, provider) => {
|
||||||
const noticeItem = mapOmpSystemNoticeToToolCall(text);
|
const item =
|
||||||
return noticeItem ? { type: "timeline", provider, item: noticeItem } : null;
|
mapOmpAdvisorMessageToToolCall(message, text) ?? mapOmpSystemNoticeToToolCall(text);
|
||||||
|
return item ? { type: "timeline", provider, item } : null;
|
||||||
},
|
},
|
||||||
resolveToolCallId: resolveOmpEmittedToolCallId,
|
resolveToolCallId: resolveOmpEmittedToolCallId,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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: '<advisory severity="blocker">Add an authorization check.</advisory>',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
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 () => {
|
test("suppresses replayed raw todo tool calls through the OMP detail hook", async () => {
|
||||||
await expect(
|
await expect(
|
||||||
collectHistory([
|
collectHistory([
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export interface OmpCapturedUserMessageEntry {
|
|||||||
|
|
||||||
export interface OmpHistoryMapperHooks {
|
export interface OmpHistoryMapperHooks {
|
||||||
mapCustomMessage?: (
|
mapCustomMessage?: (
|
||||||
|
message: Extract<OmpAgentMessage, { role: "custom" }>,
|
||||||
text: string,
|
text: string,
|
||||||
provider: string,
|
provider: string,
|
||||||
) => Extract<AgentStreamEvent, { type: "timeline" }> | null;
|
) => Extract<AgentStreamEvent, { type: "timeline" }> | null;
|
||||||
@@ -117,7 +118,7 @@ export class OmpHistoryMapper {
|
|||||||
message: Extract<OmpAgentMessage, { role: "custom" }>,
|
message: Extract<OmpAgentMessage, { role: "custom" }>,
|
||||||
): AgentStreamEvent[] {
|
): AgentStreamEvent[] {
|
||||||
const text = getUserMessageText(message.content);
|
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) {
|
if (mappedEvent) {
|
||||||
return [mappedEvent];
|
return [mappedEvent];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ const OmpContextUsageSchema = z
|
|||||||
export const OmpSessionStateSchema = z
|
export const OmpSessionStateSchema = z
|
||||||
.object({
|
.object({
|
||||||
model: OmpModelSchema.nullable().optional(),
|
model: OmpModelSchema.nullable().optional(),
|
||||||
thinkingLevel: OmpThinkingLevelSchema,
|
thinkingLevel: OmpThinkingLevelSchema.optional(),
|
||||||
isStreaming: z.boolean(),
|
isStreaming: z.boolean(),
|
||||||
isCompacting: z.boolean(),
|
isCompacting: z.boolean(),
|
||||||
autoCompactionEnabled: z.boolean().optional(),
|
autoCompactionEnabled: z.boolean().optional(),
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import type {
|
|||||||
} from "../../../agent-sdk-types.js";
|
} from "../../../agent-sdk-types.js";
|
||||||
import type { PaseoToolCatalog } from "../../../tools/types.js";
|
import type { PaseoToolCatalog } from "../../../tools/types.js";
|
||||||
import { OmpAgentClient, OmpAgentSession, type OmpProviderIdleScheduler } from "../agent.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";
|
import { FakeOmp } from "./fake-omp.js";
|
||||||
|
|
||||||
const CWD = "/tmp/paseo-omp-agent-test";
|
const CWD = "/tmp/paseo-omp-agent-test";
|
||||||
@@ -155,6 +155,24 @@ export class OmpHarness {
|
|||||||
return await run;
|
return await run;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async runPromptWithCustomMessage(
|
||||||
|
input: string,
|
||||||
|
customMessage: Extract<OmpAgentMessage, { role: "custom" }>,
|
||||||
|
output: string,
|
||||||
|
): Promise<unknown> {
|
||||||
|
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<unknown> {
|
async runPromptAfterExtensionNotice(input: string, output: string): Promise<unknown> {
|
||||||
const session = this.requireSession();
|
const session = this.requireSession();
|
||||||
const promptStarted = this.omp.latestSession().nextPrompt();
|
const promptStarted = this.omp.latestSession().nextPrompt();
|
||||||
|
|||||||
Reference in New Issue
Block a user