/**
* PromptQLExecutor — prompt.ql.app playground agent (Unofficial/Experimental)
*
* Reverse-engineered from the SPA (2026-07-20):
* - Mutations start_thread / send_thread_message only return UserMessage
* - AI output is AgentMessage rows on thread_events (Hasura stream or poll)
* - Auth: Bearer JWT (Hasura enrich-token) + projectId claim
* - Models: FetchLlmConfigs; optional llmConfigId on start_thread (String!)
* - Credits: promptql_project_credit_summary on data.pro.ql.app (usage leaf)
*
* OpenAI multi-turn is preserved via sticky PromptQL thread_id:
* - Prefer body.promptql_thread_id / X-PromptQL-Thread-Id from the client
* - Else history-prefix fingerprint (full user+assistant before last user)
* - First turn always start_thread (never first-user-only sticky — that
* collided across SkillsManager/agent sessions and routed follow-ups to
* older chats)
* Response always echoes X-PromptQL-Thread-Id + promptql_thread_id.
*
* Token refresh (POST auth.pro.ql.app/ddn/project/token with session cookies)
* is implemented best-effort and still needs production verification.
*/
import { BaseExecutor, type ExecuteInput } from "./base.ts";
import { makeExecutorErrorResult as makeErrorResult } from "../utils/error.ts";
import {
PROMPTQL_FALLBACK_MODELS,
clientFacingPromptQlModelId,
resolvePromptQlModel,
type PromptQlModel,
} from "../services/promptqlModels.ts";
import {
normalizePromptQlToken,
extractProjectIdFromToken,
isPlaygroundPromptQlToken,
isDdnProjectPromptQlToken,
isJwtExpired,
resolvePromptQlCredentials,
} from "../services/promptql/jwt.ts";
import {
extractMessageText,
extractMessageTextFromMessage,
isUserLikeRole,
type ChatMessage,
} from "./promptql/messageText.ts";
import { extractFinalResponseMessage, isFinalAgentEvent, eventKind } from "./promptql/eventTree.ts";
import {
readClientThreadId,
resolvePromptQlThreadBinding,
storePromptQlThreadAfterTurn,
type PromptQlRequestBody,
} from "./promptql/threadSticky.ts";
// Re-export the full pre-split public surface so external/test consumers keep
// working unchanged (module split for file-size cap — see PR #7911 review).
export {
decodeJwtPayload,
looksLikeUuid,
normalizePromptQlToken,
extractProjectIdFromToken,
isPlaygroundPromptQlToken,
isDdnProjectPromptQlToken,
isJwtExpired,
resolvePromptQlCredentials,
} from "../services/promptql/jwt.ts";
export {
extractMessageText,
extractMessageTextFromMessage,
extractToolCallsText,
isUserLikeRole,
type ChatMessage,
} from "./promptql/messageText.ts";
export {
walkStrings,
extractFinalResponseMessage,
isFinalAgentEvent,
eventKind,
} from "./promptql/eventTree.ts";
export {
normalizeForFingerprint,
extractToolNameSignature,
conversationFingerprint,
lastAssistantStickyKeys,
lastAssistantFingerprint,
historyPrefixBeforeLastUser,
hasAssistantMessage,
clearPromptQlThreadBindingsForTests,
readClientThreadId,
resolvePromptQlThreadBinding,
storePromptQlThreadAfterTurn,
type PromptQlThreadResolve,
type PromptQlRequestBody,
} from "./promptql/threadSticky.ts";
// ─── Constants ──────────────────────────────────────────────────────────────
const PLAYGROUND_GQL =
process.env.PROMPTQL_GRAPHQL_ENDPOINT ||
"https://data.prompt.ql.app/promptql/playground-v2-hge/v1/graphql";
const CREDITS_GQL =
process.env.PROMPTQL_CREDITS_ENDPOINT || "https://data.pro.ql.app/v1/graphql";
const TOKEN_REFRESH_URL =
process.env.PROMPTQL_TOKEN_REFRESH_URL || "https://auth.pro.ql.app/ddn/project/token";
const USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36";
const POLL_INTERVAL_MS = 1200;
const POLL_TIMEOUT_MS = Number(process.env.PROMPTQL_POLL_TIMEOUT_MS || 180_000);
// ─── GraphQL documents ──────────────────────────────────────────────────────
const START_THREAD_WITH_MODEL = `
mutation StartThreadWithModel(
$message: String!
$projectId: String!
$timezone: String!
$llmConfigId: String!
$uploads: [UserUploadInput!]
$agentResponseConfig: String
) {
start_thread(
message: $message
projectId: $projectId
timezone: $timezone
llmConfigId: $llmConfigId
roomless: true
uploads: $uploads
agentResponseConfig: $agentResponseConfig
) {
thread_id
title
created_at
thread_events { thread_event_id created_at event_data }
}
}`;
const START_THREAD_ROOMLESS = `
mutation StartThreadRoomless(
$message: String!
$projectId: String!
$timezone: String!
$uploads: [UserUploadInput!]
$agentResponseConfig: String
) {
start_thread(
message: $message
projectId: $projectId
timezone: $timezone
roomless: true
uploads: $uploads
agentResponseConfig: $agentResponseConfig
) {
thread_id
title
created_at
thread_events { thread_event_id created_at event_data }
}
}`;
const SEND_THREAD_MESSAGE = `
mutation SendThreadMessage(
$message: String!
$timezone: String!
$threadId: String!
$uploads: [UserUploadInput!]
$agentResponseConfig: String
) {
send_thread_message(
threadId: $threadId
timezone: $timezone
message: $message
uploads: $uploads
agentResponseConfig: $agentResponseConfig
) {
thread_event_id
event_data
created_at
}
}`;
const QUERY_THREAD_EVENTS = `
query QueryThreadEvents($thread_id: uuid!, $after_event_id: bigint!) {
thread_events(
where: {
thread_id: {_eq: $thread_id}
thread_event_id: {_gt: $after_event_id}
}
order_by: {thread_event_id: asc}
) {
thread_event_id
thread_id
event_data
created_at
user_id
}
}`;
// ─── Types ──────────────────────────────────────────────────────────────────
interface ThreadEvent {
thread_event_id: string | number;
event_data?: unknown;
created_at?: string;
}
function lastUserText(messages: ChatMessage[]): string {
for (let i = messages.length - 1; i >= 0; i--) {
if (isUserLikeRole(messages[i]?.role || "")) {
return extractMessageTextFromMessage(messages[i]).trim();
}
}
return "";
}
function withAgentMention(text: string): string {
if (!text) return " ";
if (text.includes(" ${text}`;
}
// ─── GraphQL client ─────────────────────────────────────────────────────────
function readStr(v: unknown): string {
if (typeof v !== "string") return "";
const t = v.trim();
return t.length ? t : "";
}
async function gql(
endpoint: string,
token: string,
query: string,
variables: Record,
operationName: string,
signal?: AbortSignal | null
): Promise {
const res = await fetch(endpoint, {
method: "POST",
headers: {
accept: "application/json",
"content-type": "application/json",
authorization: `Bearer ${token}`,
origin: "https://prompt.ql.app",
referer: "https://prompt.ql.app/",
"user-agent": USER_AGENT,
},
body: JSON.stringify({ query, variables, operationName }),
signal: signal ?? undefined,
});
const text = await res.text();
let json: { data?: T; errors?: Array<{ message?: string }> };
try {
json = JSON.parse(text) as typeof json;
} catch {
throw new Error(`Non-JSON GraphQL HTTP ${res.status}: ${text.slice(0, 300)}`);
}
if (!res.ok) {
throw new Error(`GraphQL HTTP ${res.status}: ${text.slice(0, 400)}`);
}
if (json.errors?.length) {
throw new Error(json.errors.map((e) => e.message || "error").join("; "));
}
return json.data as T;
}
/**
* Best-effort JWT refresh. Requires browser session cookies (credentials: include
* in the SPA). Headless callers must store those cookies in providerSpecificData.cookie.
* **Not fully verified in production** — see PR notes.
*/
export async function tryRefreshPromptQlToken(opts: {
projectId: string;
cookie?: string;
signal?: AbortSignal | null;
}): Promise {
if (!opts.cookie || !opts.projectId) return null;
try {
const res = await fetch(TOKEN_REFRESH_URL, {
method: "POST",
headers: {
accept: "*/*",
"x-hasura-project-id": opts.projectId,
origin: "https://prompt.ql.app",
referer: "https://prompt.ql.app/",
cookie: opts.cookie,
"user-agent": USER_AGENT,
},
signal: opts.signal ?? undefined,
});
if (!res.ok) return null;
const text = await res.text();
// Response may be raw JWT or JSON { token / accessToken / ... }
const trimmed = text.trim();
if (trimmed.startsWith("eyJ")) return normalizePromptQlToken(trimmed.replace(/^"|"$/g, ""));
try {
const j = JSON.parse(trimmed) as Record;
const t =
readStr(j.token) ||
readStr(j.accessToken) ||
readStr(j.access_token) ||
readStr(j.jwt);
return t ? normalizePromptQlToken(t) : null;
} catch {
return null;
}
} catch {
return null;
}
}
// ─── OpenAI response helpers ────────────────────────────────────────────────
function estimateUsage(messages: ChatMessage[] | undefined, content: string) {
const prompt = (messages || [])
.map((m) => extractMessageText(m.content))
.join("\n");
const prompt_tokens = Math.max(1, Math.ceil(prompt.length / 4));
const completion_tokens = Math.max(1, Math.ceil(content.length / 4));
return {
prompt_tokens,
completion_tokens,
total_tokens: prompt_tokens + completion_tokens,
estimated: true,
};
}
function chatCompletionResponse(
content: string,
model: string,
messages: ChatMessage[] | undefined,
threadId?: string
) {
const id = threadId ? `chatcmpl-pql-${threadId}` : `chatcmpl-pql-${Date.now()}`;
return new Response(
JSON.stringify({
id,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model,
choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }],
usage: estimateUsage(messages, content),
promptql_thread_id: threadId || undefined,
}),
{
status: 200,
headers: {
"Content-Type": "application/json",
...(threadId ? { "X-PromptQL-Thread-Id": threadId } : {}),
},
}
);
}
function pseudoStreamResponse(content: string, model: string, threadId?: string) {
const encoder = new TextEncoder();
const id = threadId ? `chatcmpl-pql-${threadId}` : `chatcmpl-pql-${Date.now()}`;
const chunk = (delta: string, finishReason: string | null) => ({
id,
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model,
choices: [{ index: 0, delta: delta ? { content: delta } : {}, finish_reason: finishReason }],
});
const readable = new ReadableStream({
start(controller) {
// Emit in ~word-ish slices for slightly better TTFT UX without true token stream
const parts = content.match(/\S+\s*/g) || [content];
let buf = "";
for (const p of parts) {
buf += p;
if (buf.length >= 40) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk(buf, null))}\n\n`));
buf = "";
}
}
if (buf) controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk(buf, null))}\n\n`));
controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk("", "stop"))}\n\n`));
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
controller.close();
},
});
return new Response(readable, {
status: 200,
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
...(threadId ? { "X-PromptQL-Thread-Id": threadId } : {}),
},
});
}
// ─── Poll assistant ─────────────────────────────────────────────────────────
export async function pollAssistantText(opts: {
token: string;
threadId: string;
afterEventId: string;
signal?: AbortSignal | null;
timeoutMs?: number;
intervalMs?: number;
}): Promise<{ text: string; lastEventId: string; events: ThreadEvent[] }> {
const timeoutMs = opts.timeoutMs ?? POLL_TIMEOUT_MS;
const intervalMs = opts.intervalMs ?? POLL_INTERVAL_MS;
const start = Date.now();
let cursor = String(opts.afterEventId || "0");
let best = "";
let sawFinal = false;
const collected: ThreadEvent[] = [];
while (Date.now() - start < timeoutMs) {
if (opts.signal?.aborted) throw new Error("aborted");
const data = await gql<{ thread_events: ThreadEvent[] }>(
PLAYGROUND_GQL,
opts.token,
QUERY_THREAD_EVENTS,
{ thread_id: opts.threadId, after_event_id: cursor },
"QueryThreadEvents",
opts.signal
);
const batch = data.thread_events || [];
for (const ev of batch) {
collected.push(ev);
cursor = String(ev.thread_event_id);
if (eventKind(ev.event_data) !== "AgentMessage") continue;
const msg = extractFinalResponseMessage(ev.event_data);
if (msg) best = msg;
if (isFinalAgentEvent(ev.event_data) && msg) {
sawFinal = true;
}
// Strict stop: final_response_sent
if (JSON.stringify(ev.event_data || {}).includes("final_response_sent") && best) {
return { text: best, lastEventId: cursor, events: collected };
}
}
if (sawFinal && best) {
// one extra idle poll to catch trailing metadata
await new Promise((r) => setTimeout(r, intervalMs));
return { text: best, lastEventId: cursor, events: collected };
}
await new Promise((r) => setTimeout(r, intervalMs));
}
if (best) return { text: best, lastEventId: cursor, events: collected };
throw new Error(
`PromptQL stream timeout after ${timeoutMs}ms (thread ${opts.threadId}, events=${collected.length})`
);
}
// ─── Executor ───────────────────────────────────────────────────────────────
export class PromptQlExecutor extends BaseExecutor {
constructor() {
super("promptql", {
id: "promptql",
baseUrl: PLAYGROUND_GQL,
});
}
async execute(input: ExecuteInput) {
const { model, body, stream: wantStream, credentials, signal } = input;
const requestBody = (body || {}) as PromptQlRequestBody;
let { token, projectId, cookie, timezone } = resolvePromptQlCredentials(credentials);
if (!token) {
return makeErrorResult(
401,
"Missing PromptQL Bearer JWT — paste the Authorization token from prompt.ql.app DevTools (Network → graphql on data.prompt.ql.app → Authorization: Bearer …). Use the enrich-token JWT (iss=enrich-token), not the DDN/project token.",
body,
PLAYGROUND_GQL
);
}
// Best-effort refresh when JWT is near expiry and session cookie is present.
if (isJwtExpired(token) && cookie && projectId) {
const refreshed = await tryRefreshPromptQlToken({ projectId, cookie, signal });
if (refreshed) token = refreshed;
}
if (!projectId) {
projectId = extractProjectIdFromToken(token);
}
if (!projectId) {
return makeErrorResult(
400,
"Missing projectId — set providerSpecificData.projectId, or use a playground JWT with x-hasura-project-id, or a DDN JWT whose aud is the project UUID",
body,
PLAYGROUND_GQL
);
}
// DDN/lux tokens authenticate credits (data.pro.ql.app) but playground GraphQL
// rejects them ("Authentication hook unauthorized"). Fail early with a clear fix.
if (!isPlaygroundPromptQlToken(token) && isDdnProjectPromptQlToken(token)) {
return makeErrorResult(
401,
"This JWT is a DDN/project token (works for Limits/credits only). For chat, open prompt.ql.app → F12 → Network → filter graphql on data.prompt.ql.app → copy Authorization Bearer JWT (iss=enrich-token, claims under https://promptql.hasura.io). Paste that JWT (without the Bearer prefix).",
body,
PLAYGROUND_GQL
);
}
const messages = requestBody.messages || [];
const userText = lastUserText(messages);
if (!userText) {
return makeErrorResult(400, "No user message found", body, PLAYGROUND_GQL);
}
const clientFacing = clientFacingPromptQlModelId(model || requestBody.model);
const resolved: PromptQlModel | null = resolvePromptQlModel(model || requestBody.model);
// Prefer live configId from fallback catalog; discovery map can be extended later
const llmConfigId =
resolved?.configId && !resolved.configId.startsWith("placeholder-")
? resolved.configId
: undefined;
const inboundHeaders =
(input.clientHeaders as Record | null | undefined) ??
((input as { headers?: Record }).headers as
| Record
| undefined);
const clientThreadId = readClientThreadId(requestBody, inboundHeaders ?? undefined);
const binding = resolvePromptQlThreadBinding(projectId, messages, clientThreadId);
let threadId = binding.threadId;
let afterEventId = "0";
const agentMessage = withAgentMention(userText);
try {
if (!binding.isFollowUp || !threadId) {
// New PromptQL thread — never reuse first-user-only sticky from another chat
type StartData = {
start_thread: {
thread_id: string;
thread_events?: ThreadEvent[];
};
};
let start: StartData["start_thread"];
if (llmConfigId) {
try {
const data = await gql(
PLAYGROUND_GQL,
token,
START_THREAD_WITH_MODEL,
{
message: agentMessage,
projectId,
timezone,
llmConfigId,
uploads: [],
agentResponseConfig: "force_respond",
},
"StartThreadWithModel",
signal
);
start = data.start_thread;
} catch {
const data = await gql(
PLAYGROUND_GQL,
token,
START_THREAD_ROOMLESS,
{
message: agentMessage,
projectId,
timezone,
uploads: [],
agentResponseConfig: "force_respond",
},
"StartThreadRoomless",
signal
);
start = data.start_thread;
}
} else {
const data = await gql(
PLAYGROUND_GQL,
token,
START_THREAD_ROOMLESS,
{
message: agentMessage,
projectId,
timezone,
uploads: [],
agentResponseConfig: "force_respond",
},
"StartThreadRoomless",
signal
);
start = data.start_thread;
}
threadId = start.thread_id;
const seed = start.thread_events || [];
if (seed.length) {
afterEventId = String(seed[seed.length - 1]!.thread_event_id);
}
} else {
// Follow-up on existing thread — only the latest user turn
try {
const data = await gql<{
send_thread_message: { thread_event_id: string | number };
}>(
PLAYGROUND_GQL,
token,
SEND_THREAD_MESSAGE,
{
message: agentMessage,
timezone,
threadId,
uploads: [],
agentResponseConfig: "force_respond",
},
"SendThreadMessage",
signal
);
afterEventId = String(data.send_thread_message.thread_event_id);
} catch (sendErr) {
// Stale client thread id / deleted thread → fall back to a fresh start.
// IMPORTANT: do NOT match bare "400"/"invalid" — GraphQL validation errors
// often include those words and would force a new thread every turn
// (observed: every OpenAI multi-turn became start_thread instead of
// send_thread_message like the live SPA send1/send2 captures).
const sendMsg = sendErr instanceof Error ? sendErr.message : String(sendErr);
const isDeadThread =
/thread\s*(not\s*found|deleted|expired|unknown|invalid)/i.test(sendMsg) ||
/unknown\s*thread|no such thread|thread_id/i.test(sendMsg) ||
/\b404\b/.test(sendMsg);
if (!isDeadThread) {
throw sendErr;
}
const data = await gql<{
start_thread: {
thread_id: string;
thread_events?: ThreadEvent[];
};
}>(
PLAYGROUND_GQL,
token,
START_THREAD_ROOMLESS,
{
message: agentMessage,
projectId,
timezone,
uploads: [],
agentResponseConfig: "force_respond",
},
"StartThreadRoomless",
signal
);
threadId = data.start_thread.thread_id;
const seed = data.start_thread.thread_events || [];
afterEventId = seed.length
? String(seed[seed.length - 1]!.thread_event_id)
: "0";
}
}
const { text } = await pollAssistantText({
token,
threadId,
afterEventId,
signal,
});
if (!text) {
return makeErrorResult(
502,
"PromptQL returned empty content",
body,
PLAYGROUND_GQL
);
}
// Sticky for next OpenAI multi-turn request (prefix = this full history)
storePromptQlThreadAfterTurn(projectId, messages, text, threadId);
const response = wantStream
? pseudoStreamResponse(text, clientFacing, threadId)
: chatCompletionResponse(text, clientFacing, messages, threadId);
return {
response,
url: PLAYGROUND_GQL,
headers: { Authorization: "Bearer ***" },
transformedBody: {
threadId,
projectId,
model: clientFacing,
llmConfigId: llmConfigId || null,
},
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const status =
/JWT|expired|unauthorized|401/i.test(msg) ? 401 : /timeout/i.test(msg) ? 504 : 502;
return makeErrorResult(status, `PromptQL: ${msg}`, body, PLAYGROUND_GQL);
}
}
}
// Re-export catalog for tests / registry
export { PROMPTQL_FALLBACK_MODELS, PLAYGROUND_GQL, CREDITS_GQL, TOKEN_REFRESH_URL };