From 2f4aff381c651670aededcadc5c84618e8a050eb Mon Sep 17 00:00:00 2001 From: 0xheycat <81378817+0xheycat@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:25:43 +0700 Subject: [PATCH] Stabilize Notion web sessions and JSON output (#8751) --- open-sse/executors/notion-web.ts | 43 ++++++++++- open-sse/services/notionThreadSessions.ts | 31 ++++---- ...xecutor-notion-web-thread-sessions.test.ts | 76 +++++++++++++++++++ 3 files changed, 135 insertions(+), 15 deletions(-) diff --git a/open-sse/executors/notion-web.ts b/open-sse/executors/notion-web.ts index 707c403621..b53bd69669 100644 --- a/open-sse/executors/notion-web.ts +++ b/open-sse/executors/notion-web.ts @@ -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; + 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); } diff --git a/open-sse/services/notionThreadSessions.ts b/open-sse/services/notionThreadSessions.ts index f1e123a17f..4e195b8553 100644 --- a/open-sse/services/notionThreadSessions.ts +++ b/open-sse/services/notionThreadSessions.ts @@ -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:||wf:`) + * 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(); diff --git a/tests/unit/executor-notion-web-thread-sessions.test.ts b/tests/unit/executor-notion-web-thread-sessions.test.ts index 14764c3ad4..45a941172c 100644 --- a/tests/unit/executor-notion-web-thread-sessions.test.ts +++ b/tests/unit/executor-notion-web-thread-sessions.test.ts @@ -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 | undefined; + const restore = installNotionTlsMock(async (_url, opts) => { + const body = JSON.parse(String(opts.body)) as { + transcript?: Array<{ type?: string; value?: Record }>; + }; + 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(); + } + }); });