mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 23:32:12 +03:00
* fix(notion-web): reuse threadId across OpenAI multi-turn (no new chat each request) Root cause: every execute() minted a random threadId with createThread:true, so each OpenAI messages[] turn became a brand-new Notion AI chat. That broke multi-turn agent flows (tool result follow-ups looked like cold starts). - History-keyed in-memory session cache (spaceId + conversation prefix hash) - First user turn: createThread true + new UUID - Follow-up with prior turns: createThread false + same threadId - Optional client continuity: body.notion_thread_id / X-Notion-Thread-Id - Echo thread id on chat.completion (notion_thread_id + response header) - Also accept OpenAI content-parts arrays for message content - Unit tests: 34/34 (session lookup/store + createThread false on turn 2) * fix(notion-web): read X-Notion-Thread-Id from clientHeaders ExecuteInput exposes client request headers as clientHeaders, not headers. input.headers was always undefined so client-supplied thread pins were ignored. * fix(notion-web): prefer clientHeaders with defensive headers fallback * fix(notion-web): sticky threads on errors + partial follow-ups - Bind conversation root (first user) to a threadId *before* upstream call so temporarily-unavailable / empty replies never mint a new Notion chat on retry - Persist sticky map under DATA_DIR so multi-turn survives process restarts - Follow-ups use createThread:false, isPartialTranscript:true, and only the steps after the last assistant (full re-transcript was overloading Notion) - Detect in-band Notion error objects (subType temporarily-unavailable) and retry once with the same threadId - Keep custom-agent workflowId support and clientHeaders thread pin * refactor(notion-web): split thread-session/stream-parser/transcript-builder into services The merged notion-web.ts (1490 lines) and its test file (1000 lines) tripped the file-size gate (cap 800 for new/uncapped files). Extract three self-contained pieces into open-sse/services/, no behavior change: - notionThreadSessions.ts: sticky thread-session cache, disk persistence, conversation hashing, client thread-id pin (body/header) - notionStreamParser.ts: NDJSON runInferenceTranscript response parsing + in-band upstream error detection - notionTranscriptBuilder.ts: config/context/message-step transcript building Split the corresponding "Notion thread session continuity" describe block into tests/unit/executor-notion-web-thread-sessions.test.ts. All symbols previously reachable via the notion-web.ts namespace import stay reachable (re-exported) so existing test destructuring is unaffected. 44/44 tests pass. Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouzapw@users.noreply.github.com> --------- Co-authored-by: Artur <artur@local> Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com> Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouzapw@users.noreply.github.com>
276 lines
9.6 KiB
TypeScript
276 lines
9.6 KiB
TypeScript
/**
|
|
* Notion AI Web — NDJSON `runInferenceTranscript` response parsing.
|
|
*
|
|
* Extracted from `executors/notion-web.ts` (file-size gate) — parses Notion's
|
|
* undocumented streaming response format (legacy rich-text tuples, patch-start /
|
|
* patch ops, and terminal record-map agent-inference steps) into plain text, and
|
|
* detects in-band Notion error objects (often shipped with HTTP 200).
|
|
*/
|
|
|
|
/** Strips lang tags / BOM noise Notion sometimes wraps assistant text in. */
|
|
export function sanitizeNotionAssistantText(text: string): string {
|
|
if (!text) return "";
|
|
let clean = text.replace(/^\uFEFF/, "").trim();
|
|
// Self-closing or paired lang tags at the start (and anywhere).
|
|
clean = clean.replace(/<\/?lang\b[^>]*\/?>/gi, "");
|
|
clean = clean.replace(/<\/lang>/gi, "");
|
|
// Incomplete leading <lang… without close
|
|
if (/^<lang\b/i.test(clean) && !clean.includes(">")) return "";
|
|
return clean.trim();
|
|
}
|
|
|
|
/** Extract plain text from Notion's rich-text tuple value: `[[text, marks?]]`. */
|
|
function extractRichText(value: unknown): string {
|
|
if (!Array.isArray(value)) return "";
|
|
return value
|
|
.map((segment) => (Array.isArray(segment) && typeof segment[0] === "string" ? segment[0] : ""))
|
|
.join("");
|
|
}
|
|
|
|
function extractAgentInferenceText(value: unknown): string {
|
|
if (!Array.isArray(value)) return "";
|
|
const parts: string[] = [];
|
|
for (const item of value) {
|
|
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
|
|
const part = item as Record<string, unknown>;
|
|
const t = typeof part.type === "string" ? part.type.toLowerCase() : "";
|
|
if (t === "text" && typeof part.content === "string" && part.content) {
|
|
parts.push(part.content);
|
|
}
|
|
}
|
|
return parts.join("");
|
|
}
|
|
|
|
/** Unwraps `thread_message[key].value.value.step` from a Notion record-map entry. */
|
|
function extractThreadMessageStep(msg: unknown): Record<string, unknown> | null {
|
|
if (!msg || typeof msg !== "object") return null;
|
|
const valueWrapper = (msg as Record<string, unknown>).value;
|
|
if (!valueWrapper || typeof valueWrapper !== "object") return null;
|
|
const inner = (valueWrapper as Record<string, unknown>).value;
|
|
if (!inner || typeof inner !== "object") return null;
|
|
const step = (inner as Record<string, unknown>).step;
|
|
if (!step || typeof step !== "object") return null;
|
|
return step as Record<string, unknown>;
|
|
}
|
|
|
|
/** Extracts the text carried by a single thread-message step, or "" if none. */
|
|
function extractStepText(stepObj: Record<string, unknown>): string {
|
|
const stepType = typeof stepObj.type === "string" ? stepObj.type : "";
|
|
if (stepType === "agent-inference") {
|
|
return extractAgentInferenceText(stepObj.value);
|
|
}
|
|
if (stepType === "markdown-chat" && typeof stepObj.value === "string") {
|
|
return stepObj.value;
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function extractFromRecordMap(recordMap: unknown): string {
|
|
if (!recordMap || typeof recordMap !== "object" || Array.isArray(recordMap)) return "";
|
|
const tm = (recordMap as Record<string, unknown>).thread_message;
|
|
if (!tm || typeof tm !== "object" || Array.isArray(tm)) return "";
|
|
let best = "";
|
|
for (const msg of Object.values(tm as Record<string, unknown>)) {
|
|
const stepObj = extractThreadMessageStep(msg);
|
|
if (!stepObj) continue;
|
|
const text = extractStepText(stepObj);
|
|
if (text && text.length >= best.length) best = text;
|
|
}
|
|
return best;
|
|
}
|
|
|
|
/** Accumulator threaded through {@link parseNotionInferenceStream}'s line parsing. */
|
|
type NotionStreamState = {
|
|
lastLegacy: string;
|
|
lastPatchFinal: string;
|
|
lastIncremental: string;
|
|
lastRecordMap: string;
|
|
};
|
|
|
|
/** Full agent-inference text-part append: `o:"a", p:".../value/-"`. */
|
|
function applyNotionValuePartAppend(v: unknown, state: NotionStreamState): void {
|
|
if (!v || typeof v !== "object" || Array.isArray(v)) return;
|
|
const part = v as Record<string, unknown>;
|
|
if (part.type === "text" && typeof part.content === "string" && part.content) {
|
|
state.lastPatchFinal = part.content;
|
|
}
|
|
if (part.type === "markdown-chat" && typeof part.value === "string" && part.value) {
|
|
state.lastPatchFinal = part.value;
|
|
}
|
|
}
|
|
|
|
/** Step append with markdown-chat / agent-inference: `o:"a", p:".../s/-"`. */
|
|
function applyNotionStepAppend(v: unknown, state: NotionStreamState): void {
|
|
if (!v || typeof v !== "object" || Array.isArray(v)) return;
|
|
const step = v as Record<string, unknown>;
|
|
if (step.type === "markdown-chat" && typeof step.value === "string" && step.value) {
|
|
state.lastPatchFinal = step.value;
|
|
}
|
|
if (step.type === "agent-inference") {
|
|
const text = extractAgentInferenceText(step.value);
|
|
if (text) state.lastPatchFinal = text;
|
|
}
|
|
}
|
|
|
|
function applyNotionPatchOp(rawOp: unknown, state: NotionStreamState): void {
|
|
if (!rawOp || typeof rawOp !== "object") return;
|
|
const op = rawOp as Record<string, unknown>;
|
|
const o = typeof op.o === "string" ? op.o : "";
|
|
const p = typeof op.p === "string" ? op.p : "";
|
|
const v = op.v;
|
|
|
|
if (o === "a" && p.endsWith("/value/-")) {
|
|
applyNotionValuePartAppend(v, state);
|
|
} else if (o === "a" && p.endsWith("/s/-")) {
|
|
applyNotionStepAppend(v, state);
|
|
} else if ((o === "x" || o === "p") && p.includes("/value") && typeof v === "string" && v) {
|
|
// Incremental string patches
|
|
state.lastIncremental += v;
|
|
}
|
|
}
|
|
|
|
/** Applies one parsed NDJSON record (markdown-chat / agent-inference / patch / record-map / legacy). */
|
|
function applyNotionStreamRecord(rec: Record<string, unknown>, state: NotionStreamState): void {
|
|
const type = typeof rec.type === "string" ? rec.type : "";
|
|
|
|
// 1) Direct markdown-chat event
|
|
if (type === "markdown-chat" && typeof rec.value === "string" && rec.value) {
|
|
state.lastPatchFinal = rec.value;
|
|
return;
|
|
}
|
|
|
|
// 2) Direct agent-inference event
|
|
if (type === "agent-inference") {
|
|
const text = extractAgentInferenceText(rec.value);
|
|
if (text) state.lastPatchFinal = text;
|
|
return;
|
|
}
|
|
|
|
// 3) Patch stream
|
|
if (type === "patch" && Array.isArray(rec.v)) {
|
|
for (const rawOp of rec.v) applyNotionPatchOp(rawOp, state);
|
|
return;
|
|
}
|
|
|
|
// 4) record-map terminal
|
|
if (type === "record-map" || rec.recordMap) {
|
|
const text = extractFromRecordMap(rec.recordMap || rec);
|
|
if (text) state.lastRecordMap = text;
|
|
return;
|
|
}
|
|
|
|
// 5) Legacy rich-text value (cumulative)
|
|
const rich = extractRichText(rec.value);
|
|
if (rich) state.lastLegacy = rich;
|
|
}
|
|
|
|
/** Parses one raw NDJSON line (trims / strips SSE `data:` prefix / JSON-parses) into state. */
|
|
function applyNotionStreamLine(rawLine: string, state: NotionStreamState): void {
|
|
const line = rawLine.trim();
|
|
if (!line || line === "[DONE]") return;
|
|
// Strip optional SSE "data:" prefix if a proxy rewrote it.
|
|
const payloadLine = line.startsWith("data:") ? line.slice(5).trim() : line;
|
|
if (!payloadLine) return;
|
|
|
|
let record: unknown;
|
|
try {
|
|
record = JSON.parse(payloadLine);
|
|
} catch {
|
|
return;
|
|
}
|
|
if (!record || typeof record !== "object" || Array.isArray(record)) return;
|
|
applyNotionStreamRecord(record as Record<string, unknown>, state);
|
|
}
|
|
|
|
/**
|
|
* Parse Notion's NDJSON `runInferenceTranscript` response body.
|
|
* Supports:
|
|
* 1. Legacy rich-text tuples on `value` (cumulative snapshots)
|
|
* 2. Modern patch-start / patch streams (text / markdown-chat ops)
|
|
* 3. Terminal record-map with agent-inference steps (authoritative final)
|
|
*/
|
|
export function parseNotionInferenceStream(raw: string): string {
|
|
if (!raw) return "";
|
|
const state: NotionStreamState = {
|
|
lastLegacy: "",
|
|
lastPatchFinal: "",
|
|
lastIncremental: "",
|
|
lastRecordMap: "",
|
|
};
|
|
|
|
for (const rawLine of raw.split("\n")) {
|
|
applyNotionStreamLine(rawLine, state);
|
|
}
|
|
|
|
const candidates = [
|
|
state.lastRecordMap,
|
|
state.lastPatchFinal,
|
|
state.lastIncremental,
|
|
state.lastLegacy,
|
|
]
|
|
.map(sanitizeNotionAssistantText)
|
|
.filter(Boolean);
|
|
// Prefer the longest non-empty candidate; record-map usually wins.
|
|
return candidates.sort((a, b) => b.length - a.length)[0] || "";
|
|
}
|
|
|
|
/**
|
|
* Detect Notion in-band errors (often HTTP 200 with NDJSON/JSON error objects),
|
|
* e.g. `{ type:"error", subType:"temporarily-unavailable", message:"…" }`.
|
|
*/
|
|
export function extractNotionUpstreamError(raw: string): {
|
|
message: string;
|
|
subType?: string;
|
|
isRetryable: boolean;
|
|
} | null {
|
|
if (!raw || !raw.trim()) return null;
|
|
const tryParse = (s: string): Record<string, unknown> | null => {
|
|
try {
|
|
const o = JSON.parse(s) as Record<string, unknown>;
|
|
return o && typeof o === "object" ? o : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const candidates: Record<string, unknown>[] = [];
|
|
const whole = tryParse(raw.trim());
|
|
if (whole) candidates.push(whole);
|
|
for (const line of raw.split("\n")) {
|
|
const t = line.trim();
|
|
if (!t) continue;
|
|
const o = tryParse(t);
|
|
if (o) candidates.push(o);
|
|
}
|
|
|
|
for (const o of candidates) {
|
|
const type = typeof o.type === "string" ? o.type.toLowerCase() : "";
|
|
const subType = typeof o.subType === "string" ? o.subType : undefined;
|
|
const message =
|
|
(typeof o.message === "string" && o.message) ||
|
|
(typeof o.error === "string" && o.error) ||
|
|
"";
|
|
const isError =
|
|
type === "error" ||
|
|
Boolean(subType) ||
|
|
(typeof o.isRetryable === "boolean" && message.toLowerCase().includes("went wrong"));
|
|
if (!isError && !subType) continue;
|
|
|
|
const sub = (subType || "").toLowerCase();
|
|
const retryable =
|
|
o.isRetryable === true ||
|
|
sub.includes("temporarily") ||
|
|
sub.includes("unavailable") ||
|
|
sub.includes("rate") ||
|
|
sub.includes("timeout") ||
|
|
sub.includes("overloaded");
|
|
|
|
return {
|
|
message: message || subType || "Notion upstream error",
|
|
subType,
|
|
isRetryable: retryable,
|
|
};
|
|
}
|
|
return null;
|
|
}
|