mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
Stabilize Notion web sessions and JSON output (#8751)
This commit is contained in:
@@ -96,6 +96,9 @@ const NOTION_CLIENT_VERSION = "23.13.20260720.1949";
|
||||
interface NotionRequestBody {
|
||||
messages?: NotionMessage[];
|
||||
model?: string;
|
||||
/** OpenAI-compatible structured output request. Notion Web has no native param,
|
||||
* so the executor folds it into transcript instructions. */
|
||||
response_format?: unknown;
|
||||
/** Optional client-supplied Notion thread continuity (also via X-Notion-Thread-Id). */
|
||||
notion_thread_id?: string;
|
||||
thread_id?: string;
|
||||
@@ -128,6 +131,41 @@ function readProviderSpecificString(
|
||||
return "";
|
||||
}
|
||||
|
||||
function buildStructuredOutputInstruction(responseFormat: unknown): string {
|
||||
if (!responseFormat || typeof responseFormat !== "object" || Array.isArray(responseFormat)) {
|
||||
return "";
|
||||
}
|
||||
const format = responseFormat as Record<string, unknown>;
|
||||
const type = typeof format.type === "string" ? format.type : "";
|
||||
if (type !== "json_object" && type !== "json_schema") return "";
|
||||
|
||||
const lines = [
|
||||
"Structured output requirement:",
|
||||
"- Return only valid JSON.",
|
||||
"- Do not wrap the JSON in markdown fences.",
|
||||
"- Do not add prose before or after the JSON.",
|
||||
];
|
||||
|
||||
if (type === "json_schema" && format.json_schema && typeof format.json_schema === "object") {
|
||||
try {
|
||||
lines.push(`- Match this JSON schema: ${JSON.stringify(format.json_schema)}`);
|
||||
} catch {
|
||||
lines.push("- Match the requested JSON schema.");
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function appendStructuredOutputInstruction(
|
||||
messages: NotionMessage[],
|
||||
responseFormat: unknown
|
||||
): NotionMessage[] {
|
||||
const instruction = buildStructuredOutputInstruction(responseFormat);
|
||||
if (!instruction) return messages;
|
||||
return [{ role: "system", content: instruction }, ...messages];
|
||||
}
|
||||
|
||||
/** Normalize a pasted credential to a `name=value` cookie pair. Accepts a bare
|
||||
* token or an already-prefixed `token_v2=...` value. */
|
||||
export function normalizeNotionCookieInput(raw: string, cookieName = "token_v2"): string {
|
||||
@@ -561,7 +599,10 @@ export class NotionWebExecutor extends BaseExecutor {
|
||||
// Optional custom agent (workflowId). Empty → default Notion AI (not agentic-specific).
|
||||
const agent = resolveNotionAgentOptions(credentials, cookie);
|
||||
|
||||
const messages = requestBody.messages || [];
|
||||
const messages = appendStructuredOutputInstruction(
|
||||
requestBody.messages || [],
|
||||
requestBody.response_format
|
||||
);
|
||||
if (!messages.some((m) => m.role === "user")) {
|
||||
return makeErrorResult(400, "No user message found", body, NOTION_URL);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* `executors/notion-web.ts` for the upstream transcript/response translation
|
||||
* that consumes this module.
|
||||
*
|
||||
* - History-keyed in-memory session cache (spaceId + conversation prefix hash),
|
||||
* - Space-keyed sticky root cache plus history-prefix fallbacks,
|
||||
* backed by an on-disk snapshot under DATA_DIR so continuity survives restarts.
|
||||
* - Sticky root binding written *before* the upstream call so error retries never
|
||||
* mint a second Notion chat for the same conversation.
|
||||
@@ -54,7 +54,7 @@ export function extractNotionMessageText(content: unknown): string {
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
const THREAD_SESSION_MAX_AGE_MS = 6 * 3600_000; // 6h — agent tool loops can be long
|
||||
const THREAD_SESSION_MAX_AGE_MS = 7 * 24 * 3600_000; // 7d — agent/browser-web sessions can span long workflows
|
||||
const THREAD_SESSION_MAX_ENTRIES = 500;
|
||||
|
||||
interface ThreadSessionEntry {
|
||||
@@ -78,6 +78,8 @@ function getThreadStorePath(): string | null {
|
||||
process.env.DATA_DIR ||
|
||||
process.env.OMNIROUTE_DATA_DIR ||
|
||||
process.env.VIBEPROXY_DATA_DIR ||
|
||||
(process.env.USERPROFILE ? join(process.env.USERPROFILE, ".omniroute") : "") ||
|
||||
(process.env.HOME ? join(process.env.HOME, ".omniroute") : "") ||
|
||||
"";
|
||||
if (!dataDir) return null;
|
||||
return join(dataDir, "notion-web-thread-sessions.json");
|
||||
@@ -260,11 +262,20 @@ function putThreadSession(
|
||||
scheduleThreadStoreFlush();
|
||||
}
|
||||
|
||||
/** Root sticky key for a conversation (space/agent + first user turn). */
|
||||
/** Root sticky key for web-provider continuity.
|
||||
*
|
||||
* Some clients (Claude extension, Codex-like agents, local automation gateways)
|
||||
* call Notion Web as stateless single-turn requests but expect one visible web
|
||||
* session. Keying the optimistic create binding by the first user prompt causes
|
||||
* every new request to mint another Notion sidebar chat ("No user query
|
||||
* provided", auto-generated titles, etc.). Scope the root binding to the caller
|
||||
* namespace prepared by the executor (`caller:<cookieHash>|<spaceId>|wf:<id>`)
|
||||
* so normal prompts reuse one active Notion thread unless the client explicitly
|
||||
* pins a different `notion_thread_id` / `X-Notion-Thread-Id`.
|
||||
*/
|
||||
export function notionThreadRootKey(spaceKey: string, messages: NotionMessage[]): string | null {
|
||||
const first = firstUserMessage(messages);
|
||||
if (!first) return null;
|
||||
return `root:${hashNotionConversation(spaceKey, [first])}`;
|
||||
void messages;
|
||||
return spaceKey ? `root:space:${spaceKey}` : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -391,14 +402,6 @@ export function notionThreadMarkConfirmed(rootKey: string | null, threadId: stri
|
||||
putThreadSession(rootKey, threadId, { createAttempted: true, confirmed: true });
|
||||
}
|
||||
|
||||
function firstUserMessage(messages: NotionMessage[]): NotionMessage | null {
|
||||
for (const m of messages) {
|
||||
const role = (m?.role || "").toLowerCase();
|
||||
if (role === "user" || role === "human") return m;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function conversationHasAssistant(messages: NotionMessage[]): boolean {
|
||||
return messages.some((m) => {
|
||||
const role = (m?.role || "").toLowerCase();
|
||||
|
||||
@@ -114,6 +114,20 @@ describe("Notion thread session continuity", () => {
|
||||
assert.equal(notionThreadSessionLookup("other-space", turn2), null);
|
||||
});
|
||||
|
||||
it("reuses the same Notion thread for new stateless prompts in the same caller scope", () => {
|
||||
__resetNotionThreadSessionsForTests();
|
||||
const spaceId = "caller:cookie-hash|space-stateless";
|
||||
const firstPrompt = [{ role: "user", content: "first standalone prompt" }];
|
||||
const secondPrompt = [{ role: "user", content: "different standalone prompt" }];
|
||||
const threadId = "99999999-8888-7777-6666-555555555555";
|
||||
|
||||
assert.equal(notionThreadSessionLookup(spaceId, firstPrompt), null);
|
||||
notionThreadSessionStore(spaceId, firstPrompt, "assistant reply one", threadId);
|
||||
|
||||
assert.equal(notionThreadSessionLookup(spaceId, secondPrompt), threadId);
|
||||
assert.equal(notionThreadSessionLookup("caller:other|space-stateless", secondPrompt), null);
|
||||
});
|
||||
|
||||
it("reuses thread when turn-1 user was UREW-rewritten but client replays original text", () => {
|
||||
__resetNotionThreadSessionsForTests();
|
||||
const spaceId = "space-urew";
|
||||
@@ -392,4 +406,66 @@ describe("Notion thread session continuity", () => {
|
||||
__resetNotionThreadSessionsForTests();
|
||||
}
|
||||
});
|
||||
|
||||
it("execute: folds response_format into Notion transcript instructions for JSON callers", async () => {
|
||||
__resetNotionThreadSessionsForTests();
|
||||
const executor = new mod.NotionWebExecutor();
|
||||
let capturedContext: Record<string, unknown> | undefined;
|
||||
const restore = installNotionTlsMock(async (_url, opts) => {
|
||||
const body = JSON.parse(String(opts.body)) as {
|
||||
transcript?: Array<{ type?: string; value?: Record<string, unknown> }>;
|
||||
};
|
||||
capturedContext = body.transcript?.find((step) => step.type === "context")?.value;
|
||||
const ndjson = [
|
||||
JSON.stringify({ type: "patch-start", data: { s: [] } }),
|
||||
JSON.stringify({
|
||||
type: "record-map",
|
||||
recordMap: {
|
||||
thread_message: {
|
||||
m1: {
|
||||
value: {
|
||||
value: {
|
||||
step: {
|
||||
type: "agent-inference",
|
||||
value: [{ type: "text", content: "{\"reply\":\"ok\"}" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
].join("\n");
|
||||
return { status: 200, text: ndjson };
|
||||
});
|
||||
try {
|
||||
const result = await executor.execute({
|
||||
model: "fable-5",
|
||||
body: {
|
||||
messages: [{ role: "user", content: "Return a reply object" }],
|
||||
response_format: {
|
||||
type: "json_schema",
|
||||
json_schema: {
|
||||
name: "xpuffer_reply",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: { reply: { type: "string" } },
|
||||
required: ["reply"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
stream: false,
|
||||
credentials: { apiKey: COOKIE_WITH_SPACE },
|
||||
signal: null,
|
||||
} as never);
|
||||
|
||||
assert.equal(result.response.status, 200);
|
||||
assert.match(String(capturedContext?.instructions || ""), /Return only valid JSON/);
|
||||
assert.match(String(capturedContext?.instructions || ""), /xpuffer_reply/);
|
||||
} finally {
|
||||
restore();
|
||||
__resetNotionThreadSessionsForTests();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user