mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +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>
195 lines
6.7 KiB
TypeScript
195 lines
6.7 KiB
TypeScript
/**
|
|
* Notion AI Web — `runInferenceTranscript` transcript construction.
|
|
*
|
|
* Extracted from `executors/notion-web.ts` (file-size gate) — builds a Notion
|
|
* transcript array (`config` + `context` + per-message steps) from OpenAI-style
|
|
* chat messages.
|
|
*
|
|
* Live contract (verified 2026-07-19):
|
|
* - Leading `config` (workflow + optional model food-codename)
|
|
* - Leading `context` (spaceId / userId / surface / timezone)
|
|
* - User turns as `type: "user"` (legacy `human` also works with createThread,
|
|
* but `user` matches the current web client)
|
|
* - Assistant turns as `agent-inference` text parts
|
|
*/
|
|
import { randomUUID } from "node:crypto";
|
|
import { extractNotionMessageText, type NotionMessage } from "./notionThreadSessions.ts";
|
|
|
|
/** Custom Notion AI agent (workflow) options from account credential / providerSpecificData. */
|
|
export interface NotionAgentOptions {
|
|
/** UUID of a custom agent workflow. Empty = default Notion AI (ai_module). */
|
|
workflowId?: string;
|
|
/** Optional context page id for custom agents. */
|
|
contextPageId?: string;
|
|
}
|
|
|
|
function isoNow(): string {
|
|
// Millisecond precision matches the browser client.
|
|
return new Date().toISOString().replace(/\.\d{3}Z$/, (m) => m); // keep ms + Z
|
|
}
|
|
|
|
function buildNotionConfigStep(model: string, agent?: NotionAgentOptions): Record<string, unknown> {
|
|
const isCustom = Boolean(agent?.workflowId);
|
|
const configValue: Record<string, unknown> = {
|
|
type: "workflow",
|
|
// Match live browser defaults (2026-07-20 capture) for fewer plan/feature mismatches.
|
|
enableAgentAutomations: true,
|
|
enableAgentIntegrations: true,
|
|
enableCustomAgents: true,
|
|
enableScriptAgent: true,
|
|
enableAgentDiffs: true,
|
|
enableCsvAttachmentSupport: true,
|
|
enableComputer: true,
|
|
enableCreateAndRunThread: true,
|
|
enableAgentGenerateImage: !isCustom,
|
|
useWebSearch: true,
|
|
searchScopes: [{ type: "everything" }],
|
|
availableConnectors: [],
|
|
enableUserSessionContext: false,
|
|
isCustomAgent: isCustom,
|
|
isCustomAgentBuilder: false,
|
|
isCustomAgentCreate: false,
|
|
isAgentResearchRequest: false,
|
|
useCustomAgentDraft: isCustom,
|
|
modelFromUser: !isCustom && Boolean(model),
|
|
databaseAgentConfigMode: false,
|
|
isOnboardingAgent: false,
|
|
isMobile: false,
|
|
};
|
|
if (isCustom && agent?.workflowId) {
|
|
configValue.workflowId = agent.workflowId;
|
|
}
|
|
// Default Notion AI: pin the food codename when the client selected a model.
|
|
// Custom agents usually use the agent-configured model (modelFromUser:false).
|
|
if (!isCustom && model) configValue.model = model;
|
|
return { id: randomUUID(), type: "config", value: configValue };
|
|
}
|
|
|
|
function buildNotionContextValue(opts: {
|
|
spaceId?: string;
|
|
userId?: string;
|
|
now: string;
|
|
agent?: NotionAgentOptions;
|
|
}): Record<string, unknown> {
|
|
const isCustom = Boolean(opts.agent?.workflowId);
|
|
const contextValue: Record<string, unknown> = {
|
|
timezone: "UTC",
|
|
surface: isCustom ? "custom_agent" : "ai_module",
|
|
currentDatetime: opts.now,
|
|
};
|
|
if (opts.spaceId) contextValue.spaceId = opts.spaceId;
|
|
if (opts.userId) contextValue.userId = opts.userId;
|
|
if (isCustom && opts.agent?.workflowId) {
|
|
contextValue.workflowId = opts.agent.workflowId;
|
|
if (opts.agent.contextPageId) {
|
|
contextValue.context_page_id = opts.agent.contextPageId;
|
|
}
|
|
}
|
|
return contextValue;
|
|
}
|
|
|
|
/** Converts one OpenAI-style message into a transcript step, or `null` when it
|
|
* was folded into the context (system prompts). */
|
|
function buildNotionMessageStep(
|
|
m: NotionMessage,
|
|
contextValue: Record<string, unknown>,
|
|
opts: { userId?: string; now: string }
|
|
): Record<string, unknown> | null {
|
|
// Accept string OR content-parts array (agent clients often send parts).
|
|
const text = extractNotionMessageText((m as { content?: unknown })?.content);
|
|
if (!text || text.length === 0) return null;
|
|
const role = (m.role || "").toLowerCase();
|
|
|
|
if (role === "system") {
|
|
// Fold system prompts into context instructions rather than a separate step.
|
|
const existing = typeof contextValue.instructions === "string" ? contextValue.instructions : "";
|
|
contextValue.instructions = existing ? `${existing}\n${text}` : text;
|
|
return null;
|
|
}
|
|
|
|
if (role === "assistant") {
|
|
return {
|
|
id: randomUUID(),
|
|
type: "agent-inference",
|
|
value: [{ type: "text", content: text }],
|
|
};
|
|
}
|
|
|
|
// user (and anything else treated as user)
|
|
const userStep: Record<string, unknown> = {
|
|
id: randomUUID(),
|
|
type: "user",
|
|
value: [[text]],
|
|
createdAt: opts.now,
|
|
};
|
|
if (opts.userId) userStep.userId = opts.userId;
|
|
return userStep;
|
|
}
|
|
|
|
/**
|
|
* For follow-ups, only send steps after the last assistant turn (partial transcript).
|
|
* Notion already has prior steps when createThread:false + sticky threadId.
|
|
* Re-sending the entire agent tool loop every turn triggers temporarily-unavailable.
|
|
*/
|
|
export function messagesForNotionTranscript(
|
|
messages: NotionMessage[],
|
|
isFollowUp: boolean
|
|
): NotionMessage[] {
|
|
if (!isFollowUp || !messages.length) return messages;
|
|
let lastAsst = -1;
|
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
const role = (messages[i]?.role || "").toLowerCase();
|
|
if (role === "assistant" || role === "ai" || role === "model") {
|
|
lastAsst = i;
|
|
break;
|
|
}
|
|
}
|
|
if (lastAsst < 0) return messages;
|
|
const slice = messages.slice(lastAsst + 1);
|
|
// Always include at least the last user message
|
|
if (slice.length === 0) {
|
|
const lastUser = [...messages].reverse().find((m) => {
|
|
const r = (m.role || "").toLowerCase();
|
|
return r === "user" || r === "human";
|
|
});
|
|
return lastUser ? [lastUser] : messages;
|
|
}
|
|
return slice;
|
|
}
|
|
|
|
export function buildNotionTranscript(
|
|
messages: NotionMessage[],
|
|
opts: {
|
|
notionModel?: string;
|
|
spaceId?: string;
|
|
userId?: string;
|
|
agent?: NotionAgentOptions;
|
|
/** When true, only append steps after the last assistant (partial follow-up). */
|
|
isFollowUp?: boolean;
|
|
} = {}
|
|
): Array<Record<string, unknown>> {
|
|
const trimmedModel = typeof opts.notionModel === "string" ? opts.notionModel.trim() : "";
|
|
const model = trimmedModel && trimmedModel !== "notion-ai" ? trimmedModel : "";
|
|
const now = isoNow();
|
|
const agent = opts.agent?.workflowId ? opts.agent : undefined;
|
|
const isFollowUp = Boolean(opts.isFollowUp);
|
|
|
|
const contextValue = buildNotionContextValue({
|
|
spaceId: opts.spaceId,
|
|
userId: opts.userId,
|
|
now,
|
|
agent,
|
|
});
|
|
const entries: Array<Record<string, unknown>> = [
|
|
buildNotionConfigStep(model, agent),
|
|
{ id: randomUUID(), type: "context", value: contextValue },
|
|
];
|
|
|
|
const msgs = messagesForNotionTranscript(messages, isFollowUp);
|
|
for (const m of msgs) {
|
|
const step = buildNotionMessageStep(m, contextValue, { userId: opts.userId, now });
|
|
if (step) entries.push(step);
|
|
}
|
|
return entries;
|
|
}
|