Stabilize Notion web sessions and JSON output (#8751)

This commit is contained in:
0xheycat
2026-07-28 03:25:43 +07:00
committed by GitHub
parent 5840d0961f
commit 2f4aff381c
3 changed files with 135 additions and 15 deletions

View File

@@ -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);
}

View File

@@ -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();

View File

@@ -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();
}
});
});