mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-22 06:42:19 +03:00
Restores ChatGPT Web on a clean-room browser transport, merged on the operator's explicit decision. Worth stating precisely, because this touches a provenance decision: the PR does not revert #11754. It narrows RETIRED_COMMON_CHATGPT_WEB_PROVIDER_IDS to the single GPL-derived alias cgpt-web and registers chatgpt-web as a separate clean-room id. The old implementation stays retired and blocked; the retirement machinery, its error code and its 410 contract are untouched. All four retirement suites agree with that distinction and pass unchanged. The 44 protected agent-instruction surfaces this PR touches (AGENTS.md, llm.txt and its 42 mirrors, README) were verified rather than trusted: masking digits and comparing the removed and added line sets gives 264 lines on each side, identical — every change is a provider-count substitution, with no sentence added, removed or reworded. Reconciled on merge: clean against the tip, with the two chat chokepoints this PR grows (src/sse/handlers/chat.ts +40, open-sse/handlers/chatCore.ts +30) recorded in the file-size baseline under an annotation. Verified that exactly those two caps move and nothing else, so the #12411 ratchet holds. The rebaseline is carried on this branch rather than left in a validation worktree — the propagation mistake that put the 2026-09-02 merge waves base-red in #12434. Verified: 504/504 across the PR's 44 test files plus all four chatgpt-web retirement suites, check:provider-consistency OK (272 REGISTRY entries, 355 canonical providers), check-file-size OK, and every changed TypeScript file parses. Thanks @backryun — separating the clean-room id from the retired alias, instead of reopening the old one, is what made this reviewable.
377 lines
13 KiB
TypeScript
377 lines
13 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { describe, test } from "node:test";
|
|
|
|
import {
|
|
PlaywrightChatGptWebBrowserSession,
|
|
runChatGptWebBrowserTurn,
|
|
type ChatGptWebBrowserSession,
|
|
type ChatGptWebBrowserSessionHandlers,
|
|
} from "../../open-sse/utils/chatgptWebBrowserSession.ts";
|
|
|
|
const HANDOFF_SSE =
|
|
'data: {"type":"resume_conversation_token","kind":"topic",' +
|
|
'"token":"resume-token","conversation_id":"conversation"}\n\n' +
|
|
'data: {"type":"stream_handoff","conversation_id":"conversation",' +
|
|
'"turn_exchange_id":"turn","options":[' +
|
|
'{"type":"resume_sse_endpoint","topic_id":"topic"},' +
|
|
'{"type":"subscribe_ws_topic","topic_id":"topic"}]}\n\n' +
|
|
"data: [DONE]\n\n";
|
|
|
|
function streamItem(id: string, encodedItem: string, topicId = "topic"): string {
|
|
return JSON.stringify([
|
|
{
|
|
type: "message",
|
|
topic_id: topicId,
|
|
payload: {
|
|
type: "conversation-turn-stream",
|
|
payload: {
|
|
type: "stream-item",
|
|
stream_item_id: id,
|
|
parent_stream_item_id: null,
|
|
encoded_item: encodedItem,
|
|
},
|
|
},
|
|
},
|
|
]);
|
|
}
|
|
|
|
function doneFrame(topicId = "topic"): string {
|
|
return JSON.stringify([
|
|
{
|
|
type: "message",
|
|
topic_id: topicId,
|
|
payload: {
|
|
type: "conversation-turn-stream",
|
|
payload: { type: "done" },
|
|
},
|
|
},
|
|
]);
|
|
}
|
|
|
|
class FakeBrowserSession implements ChatGptWebBrowserSession {
|
|
handlers: ChatGptWebBrowserSessionHandlers | null = null;
|
|
submittedPrompt = "";
|
|
cleanupCount = 0;
|
|
|
|
constructor(
|
|
private readonly execute: (handlers: ChatGptWebBrowserSessionHandlers) => void,
|
|
private readonly sessionUrl = "https://chatgpt.com/?temporary-chat=true",
|
|
private readonly renderedAssistantText: string | null = null
|
|
) {}
|
|
|
|
url(): string {
|
|
return this.sessionUrl;
|
|
}
|
|
|
|
async start(handlers: ChatGptWebBrowserSessionHandlers): Promise<() => Promise<void>> {
|
|
this.handlers = handlers;
|
|
return async () => {
|
|
this.cleanupCount += 1;
|
|
};
|
|
}
|
|
|
|
async submitPrompt(request: { prompt: string }): Promise<void> {
|
|
this.submittedPrompt = request.prompt;
|
|
if (!this.handlers) throw new Error("session not started");
|
|
this.execute(this.handlers);
|
|
}
|
|
|
|
async readRenderedAssistantText(): Promise<string | null> {
|
|
return this.renderedAssistantText;
|
|
}
|
|
}
|
|
|
|
describe("ChatGPT Web clean-room browser-owned session", () => {
|
|
test("decodes a direct first-party conversation response without DOM or WebSocket handoff", async () => {
|
|
const directSse =
|
|
'event: delta_encoding\ndata: "v1"\n\n' +
|
|
'event: delta\ndata: {"p":"","o":"add","v":{"message":{' +
|
|
'"id":"assistant-message","author":{"role":"assistant"},' +
|
|
'"content":{"content_type":"text","parts":["DIRECT_OK"]},' +
|
|
'"status":"finished_successfully","end_turn":true}}}\n\n' +
|
|
'data: {"type":"message_stream_complete","conversation_id":"conversation"}\n\n' +
|
|
"data: [DONE]\n\n";
|
|
let submitted: unknown = null;
|
|
const session = {
|
|
url: () => "https://chatgpt.com/?temporary-chat=true",
|
|
start: async () => async () => {},
|
|
submitPrompt: async (request: unknown) => {
|
|
submitted = request;
|
|
return directSse;
|
|
},
|
|
} satisfies ChatGptWebBrowserSession;
|
|
|
|
const result = await runChatGptWebBrowserTurn(session, {
|
|
prompt: "direct prompt",
|
|
attachments: [],
|
|
timeoutMs: 1_000,
|
|
});
|
|
|
|
assert.equal((submitted as { prompt: string }).prompt, "direct prompt");
|
|
assert.deepEqual((submitted as { attachments: unknown[] }).attachments, []);
|
|
assert.ok((submitted as { signal: AbortSignal }).signal instanceof AbortSignal);
|
|
assert.equal(result.text, "DIRECT_OK");
|
|
assert.equal(result.conversationId, "conversation");
|
|
});
|
|
|
|
test("buffers WebSocket frames until handoff and returns only decoded output", async () => {
|
|
const root =
|
|
'event: delta_encoding\ndata: "v1"\n\n' +
|
|
'event: delta\ndata: {"p":"","o":"add","v":{"message":{"author":{"role":"assistant"},' +
|
|
'"content":{"content_type":"text","parts":[""]},"status":"in_progress",' +
|
|
'"end_turn":false}}}\n\n';
|
|
const append =
|
|
'event: delta\ndata: {"p":"/message/content/parts/0","o":"append",' +
|
|
'"v":"BROWSER_OWNED_OK"}\n\n';
|
|
const finish =
|
|
'event: delta\ndata: {"p":"/message/status","o":"replace",' +
|
|
'"v":"finished_successfully"}\n\n' +
|
|
'event: delta\ndata: {"p":"/message/end_turn","o":"replace","v":true}\n\n';
|
|
|
|
const session = new FakeBrowserSession((handlers) => {
|
|
handlers.onWebSocketFrame(streamItem("item-1", root));
|
|
handlers.onBootstrap(HANDOFF_SSE);
|
|
handlers.onWebSocketFrame(streamItem("item-2", append));
|
|
handlers.onWebSocketFrame(streamItem("item-3", finish));
|
|
handlers.onWebSocketFrame(doneFrame());
|
|
});
|
|
|
|
const result = await runChatGptWebBrowserTurn(session, {
|
|
prompt: "clean-room prompt",
|
|
timeoutMs: 1_000,
|
|
});
|
|
|
|
assert.equal(session.submittedPrompt, "clean-room prompt");
|
|
assert.equal(session.cleanupCount, 1);
|
|
assert.deepEqual(result, {
|
|
conversationId: "conversation",
|
|
turnExchangeId: "turn",
|
|
text: "BROWSER_OWNED_OK",
|
|
status: "finished_successfully",
|
|
endTurn: true,
|
|
});
|
|
assert.equal(JSON.stringify(result).includes("resume-token"), false);
|
|
});
|
|
|
|
test("fails closed for non-ChatGPT origins before starting the browser session", async () => {
|
|
const session = new FakeBrowserSession(() => {}, "https://example.com/");
|
|
await assert.rejects(
|
|
runChatGptWebBrowserTurn(session, { prompt: "blocked", timeoutMs: 50 }),
|
|
/first-party chatgpt\.com origin/
|
|
);
|
|
assert.equal(session.handlers, null);
|
|
});
|
|
|
|
test("rejects incomplete terminal documents and always releases listeners", async () => {
|
|
const session = new FakeBrowserSession((handlers) => {
|
|
handlers.onBootstrap(HANDOFF_SSE);
|
|
handlers.onWebSocketFrame(streamItem("item-1", "data: [DONE]\n\n"));
|
|
handlers.onWebSocketFrame(doneFrame());
|
|
});
|
|
|
|
await assert.rejects(
|
|
runChatGptWebBrowserTurn(session, { prompt: "incomplete", timeoutMs: 1_000 }),
|
|
/assistant document is incomplete/
|
|
);
|
|
assert.equal(session.cleanupCount, 1);
|
|
});
|
|
|
|
test("preserves the terminal assistant when a hidden tool document follows it", async () => {
|
|
const assistant =
|
|
'event: delta_encoding\ndata: "v1"\n\n' +
|
|
'event: delta\ndata: {"p":"","o":"add","v":{"message":{' +
|
|
'"author":{"role":"assistant"},"content":{"content_type":"text",' +
|
|
'"parts":["VISIBLE_ASSISTANT"]},"status":"finished_successfully",' +
|
|
'"end_turn":true}}}\n\n' +
|
|
"data: [DONE]\n\n";
|
|
const hiddenTool =
|
|
'event: delta_encoding\ndata: "v1"\n\n' +
|
|
'event: delta\ndata: {"p":"","o":"add","v":{"message":{' +
|
|
'"author":{"role":"tool"},"content":{"content_type":"text",' +
|
|
'"parts":["hidden"]},"status":"in_progress","end_turn":null}}}\n\n' +
|
|
"data: [DONE]\n\n";
|
|
const session = new FakeBrowserSession((handlers) => {
|
|
handlers.onBootstrap(HANDOFF_SSE);
|
|
handlers.onWebSocketFrame(streamItem("assistant", assistant));
|
|
handlers.onWebSocketFrame(streamItem("tool", hiddenTool));
|
|
handlers.onWebSocketFrame(doneFrame());
|
|
});
|
|
|
|
const result = await runChatGptWebBrowserTurn(session, {
|
|
prompt: "multi-document",
|
|
timeoutMs: 1_000,
|
|
});
|
|
|
|
assert.equal(result.text, "VISIBLE_ASSISTANT");
|
|
assert.equal(session.cleanupCount, 1);
|
|
});
|
|
|
|
test("continues through a tool-only topic into the next same-conversation handoff", async () => {
|
|
const tool =
|
|
'event: delta_encoding\ndata: "v1"\n\n' +
|
|
'event: delta\ndata: {"p":"","o":"add","v":{"message":{' +
|
|
'"author":{"role":"tool"},"content":{"content_type":"text",' +
|
|
'"parts":["hidden"]},"status":"in_progress","end_turn":null}}}\n\n' +
|
|
"data: [DONE]\n\n";
|
|
const assistant =
|
|
'event: delta_encoding\ndata: "v1"\n\n' +
|
|
'event: delta\ndata: {"p":"","o":"add","v":{"message":{' +
|
|
'"author":{"role":"assistant"},"content":{"content_type":"text",' +
|
|
'"parts":["MULTI_HANDOFF_OK"]},"status":"finished_successfully",' +
|
|
'"end_turn":true}}}\n\n' +
|
|
"data: [DONE]\n\n";
|
|
const secondHandoff = HANDOFF_SSE.replaceAll('"turn"', '"turn-2"').replaceAll(
|
|
'"topic"',
|
|
'"topic-2"'
|
|
);
|
|
const session = new FakeBrowserSession((handlers) => {
|
|
handlers.onBootstrap(HANDOFF_SSE);
|
|
handlers.onWebSocketFrame(streamItem("tool", tool));
|
|
handlers.onWebSocketFrame(doneFrame());
|
|
handlers.onBootstrap(secondHandoff);
|
|
handlers.onWebSocketFrame(streamItem("assistant", assistant, "topic-2"));
|
|
handlers.onWebSocketFrame(doneFrame("topic-2"));
|
|
});
|
|
|
|
const result = await runChatGptWebBrowserTurn(session, {
|
|
prompt: "multi-handoff",
|
|
timeoutMs: 1_000,
|
|
});
|
|
|
|
assert.equal(result.text, "MULTI_HANDOFF_OK");
|
|
assert.equal(result.conversationId, "conversation");
|
|
assert.equal(result.turnExchangeId, "turn-2");
|
|
assert.equal(session.cleanupCount, 1);
|
|
});
|
|
|
|
test("uses the first-party rendered assistant after a tool-only terminal topic", async () => {
|
|
const tool =
|
|
'event: delta_encoding\ndata: "v1"\n\n' +
|
|
'event: delta\ndata: {"p":"","o":"add","v":{"message":{' +
|
|
'"author":{"role":"tool"},"content":{"content_type":"text",' +
|
|
'"parts":["hidden"]},"status":"in_progress","end_turn":null}}}\n\n' +
|
|
"data: [DONE]\n\n";
|
|
const session = new FakeBrowserSession(
|
|
(handlers) => {
|
|
handlers.onBootstrap(HANDOFF_SSE);
|
|
handlers.onWebSocketFrame(streamItem("tool", tool));
|
|
handlers.onWebSocketFrame(doneFrame());
|
|
},
|
|
"https://chatgpt.com/?temporary-chat=true",
|
|
"DOM_FALLBACK_OK"
|
|
);
|
|
|
|
const result = await runChatGptWebBrowserTurn(session, {
|
|
prompt: "rendered fallback",
|
|
timeoutMs: 50,
|
|
});
|
|
|
|
assert.equal(result.text, "DOM_FALLBACK_OK");
|
|
assert.equal(result.status, "finished_successfully");
|
|
assert.equal(session.cleanupCount, 1);
|
|
});
|
|
|
|
test("aborts without dispatch when the caller signal is already cancelled", async () => {
|
|
const controller = new AbortController();
|
|
controller.abort();
|
|
const session = new FakeBrowserSession(() => {});
|
|
|
|
await assert.rejects(
|
|
runChatGptWebBrowserTurn(session, {
|
|
prompt: "cancelled",
|
|
timeoutMs: 1_000,
|
|
signal: controller.signal,
|
|
}),
|
|
/aborted/
|
|
);
|
|
assert.equal(session.handlers, null);
|
|
assert.equal(session.submittedPrompt, "");
|
|
});
|
|
|
|
test("aborts promptly while browser submission is still pending", async () => {
|
|
const controller = new AbortController();
|
|
let cleanupCount = 0;
|
|
let releaseSubmission: () => void = () => {};
|
|
const session = {
|
|
url: () => "https://chatgpt.com/?temporary-chat=true",
|
|
start: async () => async () => {
|
|
cleanupCount += 1;
|
|
},
|
|
submitPrompt: async () =>
|
|
new Promise<void>((resolve) => {
|
|
releaseSubmission = resolve;
|
|
}),
|
|
} satisfies ChatGptWebBrowserSession;
|
|
|
|
const turn = runChatGptWebBrowserTurn(session, {
|
|
prompt: "cancel pending submit",
|
|
timeoutMs: 1_000,
|
|
signal: controller.signal,
|
|
});
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
controller.abort();
|
|
|
|
const timeoutMarker = Symbol("abort-timeout");
|
|
let timeout: NodeJS.Timeout | undefined;
|
|
const observed = await Promise.race([
|
|
turn.catch((error: unknown) => error),
|
|
new Promise<typeof timeoutMarker>((resolve) => {
|
|
timeout = setTimeout(() => resolve(timeoutMarker), 200);
|
|
}),
|
|
]);
|
|
if (timeout) clearTimeout(timeout);
|
|
releaseSubmission();
|
|
if (observed === timeoutMarker) await turn.catch(() => {});
|
|
|
|
assert.notEqual(observed, timeoutMarker, "abort waited for the pending browser submission");
|
|
assert.match(String(observed), /aborted/);
|
|
assert.equal(cleanupCount, 1);
|
|
});
|
|
|
|
test("aborts the browser-owned request when the turn timeout expires", async () => {
|
|
let submittedSignal: AbortSignal | null | undefined;
|
|
const session = {
|
|
url: () => "https://chatgpt.com/?temporary-chat=true",
|
|
start: async () => async () => {},
|
|
submitPrompt: async (request: { signal?: AbortSignal | null }) => {
|
|
submittedSignal = request.signal;
|
|
return new Promise<string>(() => {});
|
|
},
|
|
} satisfies ChatGptWebBrowserSession;
|
|
|
|
await assert.rejects(
|
|
runChatGptWebBrowserTurn(session, { prompt: "timeout", timeoutMs: 10 }),
|
|
/timed out/
|
|
);
|
|
assert.equal(submittedSignal?.aborted, true);
|
|
});
|
|
|
|
test("Playwright binding delegates to the direct first-party request runner", async () => {
|
|
const observed: unknown[] = [];
|
|
const page = {
|
|
url: () => "https://chatgpt.com/?temporary-chat=true",
|
|
locator() {
|
|
throw new Error("DOM hot path must not be used");
|
|
},
|
|
} as unknown as import("playwright").Page;
|
|
const session = new PlaywrightChatGptWebBrowserSession(page, {
|
|
selection: { kind: "free", thinkEnabled: true },
|
|
executePageRequest: async (_page, input) => {
|
|
observed.push(input);
|
|
return "DIRECT_SSE";
|
|
},
|
|
});
|
|
|
|
const response = await session.submitPrompt({ prompt: "direct", attachments: [] });
|
|
|
|
assert.equal(response, "DIRECT_SSE");
|
|
assert.deepEqual(observed, [
|
|
{
|
|
prompt: "direct",
|
|
attachments: [],
|
|
selection: { kind: "free", thinkEnabled: true },
|
|
},
|
|
]);
|
|
});
|
|
});
|