diff --git a/src/chat-swarm-runtime.test.ts b/src/chat-swarm-runtime.test.ts index 0bf022b7..7c275535 100644 --- a/src/chat-swarm-runtime.test.ts +++ b/src/chat-swarm-runtime.test.ts @@ -268,6 +268,75 @@ test("CDP prompt delivery keeps textarea fallback on the DOM value/input path", assert.equal(pageCommands, 0); }); +test("CDP managed conversation waits for the initialization turn to become idle before exposing identity", async () => { + const driver = cdpDriverForSelectorTest(); + const calls: string[] = []; + const target = { id: "target-init", url: "https://chatgpt.com/g/g-p-runtime-test/project" }; + (driver as any).ensureRuntime = async () => { calls.push("ensure-runtime"); }; + (driver as any).newTarget = async () => { calls.push("new-target"); return target; }; + (driver as any).waitForComposer = async () => { calls.push("composer-ready"); }; + (driver as any).observeAppBinding = async () => { calls.push("app-binding"); return "READY"; }; + (driver as any).sendPromptToTarget = async () => { calls.push("initialization-sent"); }; + (driver as any).waitForConversationUrl = async () => { + calls.push("conversation-identity"); + return "https://chatgpt.com/g/g-p-runtime-test/c/worker-init"; + }; + (driver as any).waitForConversationIdle = async (_target: unknown, expectedUrl: string) => { + calls.push(`idle:${expectedUrl}`); + }; + + const evidence = await driver.createManagedConversation( + "https://chatgpt.com/g/g-p-runtime-test/project", + new Date(Date.now() + 1_000).toISOString(), + ); + + assert.equal(evidence.conversationUrl, "https://chatgpt.com/g/g-p-runtime-test/c/worker-init"); + assert.deepEqual(calls, [ + "ensure-runtime", + "new-target", + "composer-ready", + "app-binding", + "initialization-sent", + "conversation-identity", + "idle:https://chatgpt.com/g/g-p-runtime-test/c/worker-init", + ]); +}); + +test("CDP initialization idle gate polls busy state and preserves exact-conversation fences", async () => { + const driver = cdpDriverForSelectorTest(); + const expressions: string[] = []; + const states = ["BUSY", "IDLE"]; + (driver as any).evaluate = async (_target: unknown, expression: string) => { + expressions.push(expression); + return states.shift(); + }; + + await (driver as any).waitForConversationIdle( + { id: "target-init", url: "https://chatgpt.com/g/g-p-runtime-test/c/worker-init" }, + "https://chatgpt.com/g/g-p-runtime-test/c/worker-init", + new Date(Date.now() + 1_000).toISOString(), + ); + + assert.equal(expressions.length, 2); + assert.match(expressions[0]!, /location\.href !== expectedUrl/); + assert.match(expressions[0]!, /data-message-author-role="assistant"/); + assert.match(expressions[0]!, /READY_FOR_BOOTSTRAP/); + assert.match(expressions[0]!, /stop \(generating\|streaming\)/); +}); + +test("CDP initialization idle gate fails closed on conversation drift", async () => { + const driver = cdpDriverForSelectorTest(); + (driver as any).evaluate = async () => "DRIFT"; + await assert.rejects( + () => (driver as any).waitForConversationIdle( + { id: "target-init", url: "https://chatgpt.com/g/g-p-runtime-test/c/worker-init" }, + "https://chatgpt.com/g/g-p-runtime-test/c/worker-init", + new Date(Date.now() + 1_000).toISOString(), + ), + /CHATGPT_CONVERSATION_IDENTITY_DRIFT/, + ); +}); + function openCliDriverForTest() { return new OpenCliMacWebDriver({ enabled: true, diff --git a/src/chat-swarm-runtime.ts b/src/chat-swarm-runtime.ts index 28e6abf0..3f6652bc 100644 --- a/src/chat-swarm-runtime.ts +++ b/src/chat-swarm-runtime.ts @@ -1933,6 +1933,7 @@ export class CdpMacWebDriver implements MacWebDriver { deadlineAt, ); const conversationUrl = await this.waitForConversationUrl(target, deadlineAt); + await this.waitForConversationIdle(target, conversationUrl, deadlineAt); return { conversationUrl, conversationFingerprint: conversationFingerprintFromUrl(conversationUrl), @@ -2292,6 +2293,25 @@ export class CdpMacWebDriver implements MacWebDriver { } throw new Error("ChatGPT conversation identity did not become observable before deadline"); } + + private async waitForConversationIdle( + target: CdpTarget, + expectedConversationUrl: string, + deadlineAt: string, + ): Promise { + const expectedUrl = JSON.stringify(expectedConversationUrl); + while (Date.now() < Date.parse(deadlineAt)) { + const state = await this.evaluate<"IDLE" | "BUSY" | "SIGNED_OUT" | "DRIFT">( + target, + `(() => { const expectedUrl=${expectedUrl}; if(location.href !== expectedUrl) return 'DRIFT'; const visible=(el) => { const rect=el.getBoundingClientRect(); const style=getComputedStyle(el); return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden'; }; if ((location.pathname === '/auth' || location.pathname.startsWith('/auth/')) || [...document.querySelectorAll('a,button')].some(el => visible(el) && /^(log in|sign in)$/i.test(el.textContent?.trim()||''))) return 'SIGNED_OUT'; const generating=Boolean([...document.querySelectorAll('button')].find(el => visible(el) && ((el.getAttribute('data-testid')||'').includes('stop') || /stop (generating|streaming)/i.test(el.getAttribute('aria-label')||el.textContent||'')))); const composer=Boolean([...document.querySelectorAll('[contenteditable="true"]')].find(visible) || [...document.querySelectorAll('textarea')].find(visible)); const initialized=[...document.querySelectorAll('[data-message-author-role="assistant"]')].some(el => /(^|\\s)READY_FOR_BOOTSTRAP($|\\s)/.test((el.textContent||'').trim())); return !generating && composer && initialized ? 'IDLE' : 'BUSY'; })()`, + ); + if (state === "SIGNED_OUT") throw new Error("CHATGPT_SIGNED_OUT"); + if (state === "DRIFT") throw new Error("CHATGPT_CONVERSATION_IDENTITY_DRIFT"); + if (state === "IDLE") return; + await new Promise((resolvePromise) => setTimeout(resolvePromise, 100)); + } + throw new Error("ChatGPT initialization turn did not become idle before deadline"); + } } function conversationIdFromUrl(value: string): string {