diff --git a/chatgpt-controller.mjs b/chatgpt-controller.mjs index 8e32af6..6a4d9e1 100644 --- a/chatgpt-controller.mjs +++ b/chatgpt-controller.mjs @@ -416,7 +416,7 @@ export class ChatGPTController { await this.#typeHuman(prompt); } - async #waitForSendSignal({ timeoutMs = 1800, pollMs = 120 } = {}) { + async #waitForSendSignal({ timeoutMs = 1800, pollMs = 120, baselineUserCount = null } = {}) { const stopSel = JSON.stringify(this.selectors.stopButton); const sendSel = JSON.stringify(this.selectors.sendButton); const promptSel = JSON.stringify(this.selectors.promptTextarea); @@ -455,10 +455,11 @@ export class ChatGPTController { break; } } - return { stopVisible, sendDisabled, promptLen }; + const userCount = document.querySelectorAll('[data-message-author-role="user"]').length; + return { stopVisible, sendDisabled, promptLen, userCount }; })()`); - if (snap?.stopVisible || snap?.sendDisabled || snap?.promptLen === 0) return true; + if ((Number.isFinite(baselineUserCount) && snap?.userCount > baselineUserCount) || snap?.stopVisible || snap?.sendDisabled || snap?.promptLen === 0) return true; await sleep(pollMs); } return false; @@ -602,13 +603,14 @@ export class ChatGPTController { throw err; } + const baselineUserCount = await this.#eval(`document.querySelectorAll('[data-message-author-role="user"]').length`).catch(() => null); let sent = false; if (res?.rect?.w > 0 && res?.rect?.h > 0) { this.#throwIfStopRequested(); const cx = Math.round(res.rect.x + res.rect.w / 2); const cy = Math.round(res.rect.y + res.rect.h / 2); await this.#clickAt(cx, cy); - sent = await this.#waitForSendSignal({ timeoutMs: 2200, pollMs: 120 }); + sent = await this.#waitForSendSignal({ timeoutMs: 2200, pollMs: 120, baselineUserCount }); } if (!sent && !res?.fallbackEnter) { @@ -653,7 +655,7 @@ export class ChatGPTController { } return false; })()`); - sent = await this.#waitForSendSignal({ timeoutMs: 1400, pollMs: 120 }); + sent = await this.#waitForSendSignal({ timeoutMs: 1400, pollMs: 120, baselineUserCount }); } if (!sent) { @@ -677,7 +679,7 @@ export class ChatGPTController { this.#throwIfStopRequested(); await sleep(jitter(25, 90)); await this.#sendKey(key, { modifiers }); - sent = await this.#waitForSendSignal({ timeoutMs: 1500, pollMs: 120 }); + sent = await this.#waitForSendSignal({ timeoutMs: 1500, pollMs: 120, baselineUserCount }); if (sent) break; } } @@ -727,14 +729,19 @@ export class ChatGPTController { return r.width > 0 && r.height > 0 && style.visibility !== 'hidden' && style.display !== 'none'; }); const sendEnabled = send ? !send.disabled : true; - const nodes = Array.from(document.querySelectorAll(${assistantSel})); + const isChatgpt = String(location.hostname || '').includes('chatgpt.com'); + const primaryNodes = Array.from(document.querySelectorAll('[data-message-author-role="assistant"]')); + const nodes = isChatgpt ? primaryNodes : (primaryNodes.length ? primaryNodes : Array.from(document.querySelectorAll(${assistantSel}))); const lastNode = nodes[nodes.length - 1]; const fallbackMainText = ((document.querySelector('main') || document.body)?.innerText || '').trim(); - const txt = (lastNode?.innerText || fallbackMainText).trim(); + const assistantText = String(lastNode?.innerText || lastNode?.textContent || '').trim(); + const txt = lastNode ? assistantText : (isChatgpt ? '' : fallbackMainText); const hasContinue = Array.from(document.querySelectorAll('button, a')).some(b => /continue generating/i.test((b.textContent||'').trim())); const hasRegenerate = Array.from(document.querySelectorAll('button, a')).some(b => /regenerate/i.test((b.textContent||'').trim())); const hasError = /something went wrong|try again|error/i.test(txt) && txt.length < 500; - return { stop, sendEnabled, txt, count: nodes.length, usedFallback: !lastNode, hasError, hasContinue, hasRegenerate }; + const assistantTurn = lastNode?.closest?.('section[data-turn="assistant"]') || null; + const assistantActionsReady = !isChatgpt || !!assistantTurn?.querySelector?.('[data-testid="copy-turn-action-button"]'); + return { stop, sendEnabled, txt, count: nodes.length, usedFallback: !lastNode, hasError, hasContinue, hasRegenerate, isChatgpt, assistantActionsReady }; })()`); const txt = String(snap?.txt || ''); @@ -744,8 +751,11 @@ export class ChatGPTController { } // Some providers expose unrelated visible "stop/cancel" controls. - // Treat "generating" as stop-visible only when send is not enabled. - const generating = !!snap?.stop && !snap?.sendEnabled; + // Current ChatGPT can also expose transient assistant placeholder text such as + // "Thinking" before the real response body arrives. Neither state is complete. + const transientAssistant = /^(thinking|working|searching|browsing)(?:[.…]{0,3})$/i.test(txt.trim()); + const chatgptStillFinalizing = !!snap?.isChatgpt && !snap?.assistantActionsReady; + const generating = (!!snap?.stop && !snap?.sendEnabled) || transientAssistant || chatgptStillFinalizing; if (generating) stopGoneAt = null; else if (stopGoneAt == null) stopGoneAt = Date.now(); @@ -771,7 +781,8 @@ export class ChatGPTController { (!generating && fallbackStableLongEnough && (readyByNodes || fallbackWaited)); if (done) { const extra = await this.#eval(`(() => { - const nodes = Array.from(document.querySelectorAll(${assistantSel})); + const primaryNodes = Array.from(document.querySelectorAll('[data-message-author-role="assistant"]')); + const nodes = primaryNodes.length ? primaryNodes : Array.from(document.querySelectorAll(${assistantSel})); const lastNode = nodes[nodes.length - 1]; const codes = Array.from(lastNode?.querySelectorAll('pre code') || []).map(c => { const cls = String(c.className || ''); diff --git a/tests/chatgpt-controller.test.mjs b/tests/chatgpt-controller.test.mjs index f46c932..e0f0347 100644 --- a/tests/chatgpt-controller.test.mjs +++ b/tests/chatgpt-controller.test.mjs @@ -23,6 +23,128 @@ function readyState() { }; } +test('chatgpt-controller: a new user turn confirms send without duplicate fallbacks', async () => { + const events = []; + let waitForSendChecks = 0; + + const page = { + async navigate() {}, + async evaluate(js) { + if (js.includes('const hasTurnstile')) return readyState(); + if (js.includes('missing_prompt_textarea')) return { ok: true, rect: { x: 10, y: 10, w: 200, h: 40 } }; + if (js.trim() === `document.querySelectorAll('[data-message-author-role="user"]').length`) return 1; + if (js.includes('already_generating')) return { ok: true, rect: { x: 220, y: 10, w: 40, h: 40 }, host: 'chatgpt.com' }; + if (js.includes('promptLen')) { + waitForSendChecks += 1; + return { stopVisible: false, sendDisabled: false, promptLen: 7, userCount: 2 }; + } + if (js.includes('form.requestSubmit')) { + events.push('requestSubmit'); + return true; + } + throw new Error(`unexpected_eval:${js.slice(0, 80)}`); + }, + async getUrl() { + return 'https://chatgpt.com/'; + }, + async sendKey(key) { + events.push(`key:${key}`); + }, + async insertText(text) { + events.push(`text:${text}`); + }, + async moveMouse() {}, + async mouseDown() {}, + async mouseUp() {}, + async setFileInputFiles() {} + }; + + const controller = new ChatGPTController({ + page, + selectors: { + promptTextarea: '#prompt-textarea', + sendButton: 'button[data-testid="send-button"]', + stopButton: 'button[data-testid="stop-button"]', + assistantMessage: '[data-message-author-role="assistant"]' + } + }); + + const result = await controller.send({ text: 'agentify', timeoutMs: 5_000 }); + assert.deepEqual(result, { ok: true }); + assert.equal(waitForSendChecks, 1); + assert.equal(events.includes('requestSubmit'), false); + assert.equal(events.includes('key:Enter'), false); +}); + +test('chatgpt-controller: waits for finalized ChatGPT assistant turn instead of transient Thinking text', async () => { + let responseSnapshots = 0; + + const page = { + async navigate() {}, + async evaluate(js) { + if (js.includes('const hasTurnstile')) return readyState(); + if (js.includes('missing_prompt_textarea')) return { ok: true, rect: { x: 10, y: 10, w: 200, h: 40 } }; + if (js.trim() === `document.querySelectorAll('[data-message-author-role="user"]').length`) return 1; + if (js.includes('already_generating')) return { ok: true, rect: { x: 220, y: 10, w: 40, h: 40 }, host: 'chatgpt.com' }; + if (js.includes('promptLen')) return { stopVisible: false, sendDisabled: false, promptLen: 0, userCount: 2 }; + if (js.includes('const codes =')) return { codeBlocks: [] }; + if (js.includes('assistantActionsReady')) { + responseSnapshots += 1; + if (responseSnapshots === 1) { + return { + stop: false, + sendEnabled: true, + txt: 'Thinking', + count: 1, + usedFallback: false, + hasError: false, + hasContinue: false, + hasRegenerate: false, + isChatgpt: true, + assistantActionsReady: false + }; + } + return { + stop: false, + sendEnabled: true, + txt: 'FINAL_OK', + count: 1, + usedFallback: false, + hasError: false, + hasContinue: false, + hasRegenerate: true, + isChatgpt: true, + assistantActionsReady: true + }; + } + throw new Error(`unexpected_eval:${js.slice(0, 80)}`); + }, + async getUrl() { + return 'https://chatgpt.com/'; + }, + async sendKey() {}, + async insertText() {}, + async moveMouse() {}, + async mouseDown() {}, + async mouseUp() {}, + async setFileInputFiles() {} + }; + + const controller = new ChatGPTController({ + page, + selectors: { + promptTextarea: '#prompt-textarea', + sendButton: 'button[data-testid="send-button"]', + stopButton: 'button[data-testid="stop-button"]', + assistantMessage: '[data-message-author-role="assistant"]' + } + }); + + const result = await controller.query({ prompt: 'x', timeoutMs: 5_000 }); + assert.equal(result.text, 'FINAL_OK'); + assert.ok(responseSnapshots >= 4); +}); + test('chatgpt-controller: send falls back to requestSubmit on the active composer before Enter', async () => { const events = []; let waitForSendChecks = 0;