From a1e1f91647bda2db8c0a6d7a0ed05db7e19e48bd Mon Sep 17 00:00:00 2001 From: Marwan Luay Date: Sun, 20 Sep 2026 12:32:25 -0700 Subject: [PATCH] fix(queue): confirm submission and terminal turns before advancing (#43) A send click no longer counts as submission, and idle gaps no longer complete a command. The queue now waits for a matching user turn and a command-bound terminal assistant turn before advancing. --- background.js | 710 +++++++++++++++++--- content.js | 29 +- docs/project-memory/architecture.md | 2 +- docs/project-memory/decisions.md | 4 +- docs/project-memory/known-failures.md | 4 +- provider-adapter.js | 302 ++++++++- test/issue-41-wait-for-idle.test.js | 2 +- test/issue-43-delivery-contract.test.js | 822 ++++++++++++++++++++++++ test/provider-adapter.test.js | 86 ++- types/extension.d.ts | 48 ++ 10 files changed, 1923 insertions(+), 86 deletions(-) create mode 100644 test/issue-43-delivery-contract.test.js diff --git a/background.js b/background.js index 13043e6..cb07b0f 100644 --- a/background.js +++ b/background.js @@ -24,14 +24,25 @@ const QUEUE_WAIT_POLICY = { responseMaxWaitMs: 10 * 60 * 1000, deepResearchMaxWaitMs: 45 * 60 * 1000, deepResearchStaleMs: 5 * 60 * 1000, - checkIntervalMs: 1000 + checkIntervalMs: 1000, + submissionAckTimeoutMs: 8000, + submissionAckPollMs: 250, + terminalConfirmSamples: 2, + interCommandDelayMs: 2000 }; +const WAITING_COMMAND_PHASES = new Set(['waiting', 'awaiting-response', 'active-response']); +const CONFIRMED_DELIVERY_STATES = new Set([ + 'confirmed-submission', + 'active-response', + 'terminal-awaiting-bookkeeping' +]); const RETRYABLE_FAILURE_CLASSES = new Set([ 'transient', 'generation-error', 'retry-visible', 'timeout', - 'stalled-research' + 'stalled-research', + 'submission-unconfirmed' ]); const QUEUE_WAKE_ALARM_NAME = 'queue-wake'; const QUEUE_WAKE_ALARM_PERIOD_MINUTES = 0.5; @@ -514,6 +525,14 @@ function restoreDurableJobs(durableJobs) { lastResearchProgressAt: Number(rawJob.lastResearchProgressAt || 0), sawDeepResearch: rawJob.sawDeepResearch === true, sawGenerating: rawJob.sawGenerating === true, + deliveryState: rawJob.deliveryState || '', + commandId: rawJob.commandId || '', + commandFingerprint: rawJob.commandFingerprint || '', + submittedUserTurnId: rawJob.submittedUserTurnId || null, + assistantTurnId: rawJob.assistantTurnId || null, + submissionAckSource: rawJob.submissionAckSource || '', + terminalAckSource: rawJob.terminalAckSource || '', + lastResponsePhase: rawJob.lastResponsePhase || '', startedAt: Number(rawJob.startedAt || Date.now()), updatedAt: Number(rawJob.updatedAt || Date.now()) }; @@ -524,18 +543,38 @@ function restoreDurableJobs(durableJobs) { job.currentCommandNumber = Number(job.completedCount || 0) + 1; } - if (job.currentPhase === 'sending') { + if (job.currentPhase === 'waiting' && isSubmissionConfirmed(job)) { + job.currentPhase = 'awaiting-response'; + if (!job.deliveryState) { + job.deliveryState = 'confirmed-submission'; + } + } + + if (job.currentPhase === 'terminal' || job.deliveryState === 'terminal-awaiting-bookkeeping') { + job.currentPhase = 'terminal'; + job.deliveryState = 'terminal-awaiting-bookkeeping'; + } else if (isSubmissionConfirmed(job) && (job.currentPhase === 'sending' || job.currentPhase === 'awaiting-submission-ack')) { + logQueueEvent(tabId, 'info', 'Recovered a command with confirmed submission; resuming wait instead of resending.', { + phase: job.currentPhase, + commandNumber: job.currentCommandNumber || 0, + totalMessages: getTotalMessages(job), + ...collectDeliveryDiagnostics(job) + }); + job.currentPhase = 'awaiting-response'; + job.deliveryState = job.deliveryState === 'active-response' ? 'active-response' : 'confirmed-submission'; + } else if (job.currentPhase === 'sending' || job.currentPhase === 'awaiting-submission-ack' || job.deliveryState === 'pre-click' || job.deliveryState === 'unknown-acceptance') { logQueueEvent(tabId, 'warn', 'Recovered a command that was not confirmed submitted; retrying it.', { phase: job.currentPhase, commandNumber: job.currentCommandNumber || 0, totalMessages: getTotalMessages(job), - messagePreview: previewText(job.currentMessage || '', 160) + ...collectDeliveryDiagnostics(job) }); job.queue.unshift(job.currentMessage); job.currentMessage = null; job.currentCommandNumber = 0; job.currentPhase = 'queued'; resetWaitTracking(job); + resetCommandDeliveryState(job); } jobs.set(tabId, job); @@ -728,6 +767,7 @@ function handleStartSequence(request, sendResponse) { lastResearchProgressAt: 0, sawDeepResearch: false, sawGenerating: false, + ...emptyDeliveryFields(), startedAt: Date.now(), updatedAt: Date.now() }); @@ -848,6 +888,7 @@ async function startNewJobFromEnqueueResult(tabId, message, waitForIdleBeforeSta lastResearchProgressAt: 0, sawDeepResearch: false, sawGenerating: false, + ...emptyDeliveryFields(), startedAt: Date.now(), updatedAt: Date.now() }); @@ -992,7 +1033,7 @@ function handleRetryPausedJob(request, sendResponse) { return; } - if (job.queue.length === 0) { + if (job.queue.length === 0 && !job.currentMessage) { logQueueEvent(tabId, 'warn', 'Paused queue had no commands left when retry was requested.', { completedCount: job.completedCount || 0, totalMessages: getTotalMessages(job) @@ -1003,12 +1044,13 @@ function handleRetryPausedJob(request, sendResponse) { return; } + const resumeConfirmed = isSubmissionConfirmed(job) && !!job.currentMessage; job.isPaused = false; job.isRunning = true; job.isStopped = false; job.pausedReason = ''; job.lastError = ''; - job.currentPhase = 'queued'; + job.currentPhase = resumeConfirmed ? 'awaiting-response' : 'queued'; job.updatedAt = Date.now(); resetCommandRetryState(job); resetWaitTracking(job); @@ -1081,7 +1123,17 @@ async function processQueue(tabId) { try { while (job.isRunning && !job.isPaused && !job.isStopped) { - if (job.currentMessage && job.currentPhase === 'waiting') { + if (job.currentMessage && job.currentPhase === 'terminal') { + completeCurrentCommand(tabId, job, getTotalMessages(job), collectDeliveryDiagnostics(job, { + terminalAckSource: job.terminalAckSource || 'durable-recovery' + })); + if (job.queue.length > 0) { + await sleep(Number(QUEUE_WAIT_POLICY.interCommandDelayMs) || 0); + } + continue; + } + + if (job.currentMessage && WAITING_COMMAND_PHASES.has(job.currentPhase)) { const result = await handleProcessWaiting(tabId, job); if (result.action === 'return') return; if (result.action === 'continue') continue; @@ -1093,6 +1145,13 @@ async function processQueue(tabId) { if (result.action === 'continue') continue; } + if (job.currentMessage && isSubmissionConfirmed(job)) { + job.currentPhase = 'awaiting-response'; + const result = await handleProcessWaiting(tabId, job); + if (result.action === 'return') return; + if (result.action === 'continue') continue; + } + if (job.currentMessage) { const result = handleProcessRecovered(tabId, job); if (result.action === 'continue') continue; @@ -1179,7 +1238,8 @@ async function handleProcessWaiting(tabId, job) { const waitResult = await waitForTabResponse(tabId, { commandNumber: job.currentCommandNumber, totalMessages, - queueSettings + queueSettings, + commandBinding: getCommandBinding(job) }); if (!jobs.has(tabId) || job.isStopped || job.isPaused || !job.isRunning) { @@ -1192,7 +1252,7 @@ async function handleProcessWaiting(tabId, job) { if (recoveryResult.action === 'complete') { completeCurrentCommand(tabId, job, totalMessages, recoveryResult.details || {}); if (job.queue.length > 0) { - await sleep(2000); + await sleep(Number(QUEUE_WAIT_POLICY.interCommandDelayMs) || 0); } return { action: 'continue' }; } else if (recoveryResult.action === 'retry') { @@ -1225,7 +1285,7 @@ async function handleProcessWaiting(tabId, job) { completeCurrentCommand(tabId, job, totalMessages, waitResult.details || {}); if (job.queue.length > 0) { - await sleep(2000); + await sleep(Number(QUEUE_WAIT_POLICY.interCommandDelayMs) || 0); } return { action: 'continue' }; @@ -1256,6 +1316,16 @@ async function handleProcessRetryWait(tabId, job) { return { action: 'return' }; } + if (isSubmissionConfirmed(job) && job.currentMessage) { + job.currentPhase = 'awaiting-response'; + job.deliveryState = 'confirmed-submission'; + job.nextRetryAt = 0; + job.updatedAt = Date.now(); + resetWaitTracking(job); + updateRunningJobsStorage({ force: true }); + return { action: 'continue' }; + } + if (job.currentMessage) { job.queue.unshift(job.currentMessage); job.currentMessage = null; @@ -1265,6 +1335,7 @@ async function handleProcessRetryWait(tabId, job) { job.currentPhase = 'queued'; job.nextRetryAt = 0; job.updatedAt = Date.now(); + resetCommandDeliveryState(job); updateRunningJobsStorage({ force: true }); return { action: 'continue' }; } @@ -1325,6 +1396,10 @@ async function handleProcessSending(tabId, job) { job.currentPhase = 'sending'; job.updatedAt = Date.now(); resetWaitTracking(job); + resetCommandDeliveryState(job); + job.deliveryState = 'pre-click'; + job.commandId = `${job.runId || 'run'}:${job.currentCommandNumber}`; + job.commandFingerprint = fingerprintCommandTextForJob(job.currentMessage); const totalMessages = getTotalMessages(job); const queueSettings = await getQueueSettings(); @@ -1333,7 +1408,8 @@ async function handleProcessSending(tabId, job) { totalMessages, remainingBeforeSend: getRemainingCount(job), messagePreview: previewText(job.currentMessage || '', 160), - settings: queueSettings + settings: queueSettings, + ...collectDeliveryDiagnostics(job) }); await updateRunningJobsStorage({ force: true }); @@ -1349,7 +1425,7 @@ async function handleProcessSending(tabId, job) { commandNumber: job.currentCommandNumber, totalMessages, error: sendResult.error || 'Could not send message to ChatGPT.', - diagnostics: sendResult.details || {} + diagnostics: collectDeliveryDiagnostics(job, sendResult.details || {}) }); if (await retryCurrentCommandIfEnabled(tabId, job, 'send', sendResult.error || 'Could not send message to ChatGPT.', sendResult.details || {})) { @@ -1365,20 +1441,28 @@ async function handleProcessSending(tabId, job) { return { action: 'return' }; } - job.currentPhase = 'waiting'; + job.currentPhase = 'awaiting-response'; + job.deliveryState = 'confirmed-submission'; + job.submittedUserTurnId = sendResult.details?.userTurnId || job.submittedUserTurnId || null; + job.submissionAckSource = sendResult.details?.submissionAckSource || job.submissionAckSource || ''; + job.commandFingerprint = sendResult.details?.commandFingerprint || job.commandFingerprint; job.updatedAt = Date.now(); await updateRunningJobsStorage({ force: true }); logQueueEvent(tabId, 'success', `Submitted command ${job.currentCommandNumber}/${totalMessages}.`, { commandNumber: job.currentCommandNumber, totalMessages, - diagnostics: sendResult.details || {} + diagnostics: collectDeliveryDiagnostics(job, { + submissionAckSource: job.submissionAckSource, + userTurnId: job.submittedUserTurnId + }) }); const waitResult = await waitForTabResponse(tabId, { commandNumber: job.currentCommandNumber, totalMessages, - queueSettings + queueSettings, + commandBinding: getCommandBinding(job) }); if (!jobs.has(tabId) || job.isStopped || job.isPaused || !job.isRunning) { @@ -1391,7 +1475,7 @@ async function handleProcessSending(tabId, job) { if (recoveryResult.action === 'complete') { completeCurrentCommand(tabId, job, totalMessages, recoveryResult.details || {}); if (job.queue.length > 0) { - await sleep(2000); + await sleep(Number(QUEUE_WAIT_POLICY.interCommandDelayMs) || 0); } return { action: 'next' }; } else if (recoveryResult.action === 'retry') { @@ -1424,7 +1508,7 @@ async function handleProcessSending(tabId, job) { completeCurrentCommand(tabId, job, totalMessages, waitResult.details || {}); if (job.queue.length > 0) { - await sleep(2000); + await sleep(Number(QUEUE_WAIT_POLICY.interCommandDelayMs) || 0); } return { action: 'next' }; @@ -1432,12 +1516,17 @@ async function handleProcessSending(tabId, job) { function completeCurrentCommand(tabId, job, totalMessages, diagnostics = {}) { job.completedCount = Number(job.completedCount || 0) + 1; + const terminalAckSource = diagnostics.terminalAckSource || job.terminalAckSource || + (diagnostics.recoveredViaBackendCompletion ? 'backend-completion' : ''); logQueueEvent(tabId, 'success', `Completed command ${job.completedCount}/${totalMessages}.`, { commandNumber: job.completedCount, totalMessages, remaining: Math.max(0, job.queue.length), - diagnostics + diagnostics: collectDeliveryDiagnostics(job, { + ...diagnostics, + terminalAckSource + }) }); job.currentMessage = null; @@ -1447,6 +1536,7 @@ function completeCurrentCommand(tabId, job, totalMessages, diagnostics = {}) { job.updatedAt = Date.now(); resetCommandRetryState(job); resetWaitTracking(job); + resetCommandDeliveryState(job); updateRunningJobsStorage({ force: true }); } @@ -1455,10 +1545,13 @@ function pauseJob(tabId, reason, details = {}) { if (!job) return; const failedMessage = job.currentMessage || ''; + const confirmed = isSubmissionConfirmed(job); - if (job.currentMessage) { + if (!confirmed && job.currentMessage) { job.queue.unshift(job.currentMessage); job.currentMessage = null; + job.currentCommandNumber = 0; + resetCommandDeliveryState(job); } job.isRunning = false; @@ -1477,10 +1570,13 @@ function pauseJob(tabId, reason, details = {}) { totalMessages: getTotalMessages(job), remaining: getRemainingCount(job), failedMessagePreview: previewText(failedMessage, 160), + ...collectDeliveryDiagnostics(job), ...details }); - job.currentCommandNumber = 0; + if (!confirmed) { + job.currentCommandNumber = 0; + } updateRunningJobsStorage({ force: true }); @@ -1510,6 +1606,91 @@ function resetWaitTracking(job) { job.sawGenerating = false; } +function emptyDeliveryFields() { + return { + deliveryState: '', + commandId: '', + commandFingerprint: '', + submittedUserTurnId: null, + assistantTurnId: null, + submissionAckSource: '', + terminalAckSource: '', + lastResponsePhase: '' + }; +} + +function fingerprintCommandTextForJob(text) { + if (typeof globalThis !== 'undefined' && typeof globalThis.fingerprintCommandText === 'function') { + return globalThis.fingerprintCommandText(text); + } + const provider = getActiveProviderAdapter('chatgpt'); + if (provider && typeof provider.fingerprintCommandText === 'function') { + return provider.fingerprintCommandText(text); + } + const normalized = String(text || '').replace(/\s+/g, ' ').trim(); + let hash = 2166136261; + for (let i = 0; i < normalized.length; i += 1) { + hash ^= normalized.charCodeAt(i); + hash = Math.imul(hash, 16777619); + } + return `fnv1a:${(hash >>> 0).toString(16)}:len:${normalized.length}`; +} + +function isSubmissionConfirmed(job) { + if (!job) return false; + if (job.submittedUserTurnId) return true; + return CONFIRMED_DELIVERY_STATES.has(String(job.deliveryState || '')); +} + +function resetCommandDeliveryState(job) { + if (!job) return; + const cleared = emptyDeliveryFields(); + job.deliveryState = cleared.deliveryState; + job.commandId = cleared.commandId; + job.commandFingerprint = cleared.commandFingerprint; + job.submittedUserTurnId = cleared.submittedUserTurnId; + job.assistantTurnId = cleared.assistantTurnId; + job.submissionAckSource = cleared.submissionAckSource; + job.terminalAckSource = cleared.terminalAckSource; + job.lastResponsePhase = cleared.lastResponsePhase; +} + +function getCommandBinding(job, context = {}) { + if (context.commandBinding && typeof context.commandBinding === 'object') { + return context.commandBinding; + } + return { + userTurnId: job?.submittedUserTurnId || null, + assistantTurnId: job?.assistantTurnId || null, + conversationId: job?.conversationId || null, + commandFingerprint: job?.commandFingerprint || '', + commandId: job?.commandId || '' + }; +} + +function collectDeliveryDiagnostics(job, extra = {}) { + return { + runId: job?.runId || '', + commandId: job?.commandId || '', + commandNumber: job?.currentCommandNumber || 0, + deliveryState: job?.deliveryState || '', + submissionAckSource: job?.submissionAckSource || '', + terminalAckSource: job?.terminalAckSource || extra.terminalAckSource || '', + userTurnId: job?.submittedUserTurnId || extra.userTurnId || null, + assistantTurnId: job?.assistantTurnId || extra.assistantTurnId || null, + lastResponsePhase: job?.lastResponsePhase || extra.phase || '', + commandFingerprint: job?.commandFingerprint || '', + ...extra + }; +} + +function persistCommandDelivery(tabId, job, patch = {}) { + if (!job) return; + Object.assign(job, patch); + job.updatedAt = Date.now(); + updateRunningJobsStorage({ force: true }); +} + function getRetryBackoffDelayMs(attemptCount, unlimited = false) { if (unlimited) { return Math.max(1, Number(QUEUE_RETRY_POLICY.unlimitedDelayMs) || UNLIMITED_RETRY_DELAY_MS); @@ -1545,6 +1726,12 @@ function classifyQueueFailure(phase, reason, diagnostics = {}) { failureClass = 'retry-visible'; } else if (state.hasDeliveryTimedOut || /delivery time/i.test(message)) { failureClass = 'timeout'; + } else if (/waiting for the user/i.test(message) || state.phase === 'waiting-for-user') { + failureClass = 'waiting-for-user'; + } else if (/response was interrupted/i.test(message) || state.phase === 'interrupted') { + failureClass = 'interrupted'; + } else if (/not acknowledged as a new user turn/i.test(message)) { + failureClass = 'submission-unconfirmed'; } else if (state.hasError || /chatgpt showed an error|error or retry state/i.test(message)) { failureClass = 'generation-error'; } else if (/timed out waiting/i.test(message)) { @@ -1666,12 +1853,23 @@ async function retryCurrentCommandIfEnabled(tabId, job, phase, reason, diagnosti return false; } + if (isSubmissionConfirmed(job) && job.currentMessage) { + job.currentPhase = 'awaiting-response'; + job.deliveryState = 'confirmed-submission'; + job.nextRetryAt = 0; + job.updatedAt = Date.now(); + resetWaitTracking(job); + updateRunningJobsStorage({ force: true }); + return true; + } + job.queue.unshift(job.currentMessage); job.currentMessage = null; job.currentCommandNumber = 0; job.currentPhase = 'queued'; job.nextRetryAt = 0; job.updatedAt = Date.now(); + resetCommandDeliveryState(job); updateRunningJobsStorage({ force: true }); return true; @@ -1777,14 +1975,17 @@ async function recoverFromDeliveryTimeout(tabId, job, waitResult, totalMessages, totalMessages }); - job.currentPhase = 'waiting'; + job.currentPhase = 'awaiting-response'; + job.sawGenerating = job.sawGenerating || !!check?.state?.generating; + job.sawDeepResearch = job.sawDeepResearch || !!check?.state?.deepResearchActive; job.updatedAt = Date.now(); await updateRunningJobsStorage({ force: true }); const retryWait = await waitForTabResponse(tabId, { commandNumber: job.currentCommandNumber, totalMessages, - queueSettings + queueSettings, + commandBinding: getCommandBinding(job) }); if (!jobs.has(tabId) || job.isStopped || job.isPaused || !job.isRunning) { @@ -1849,14 +2050,17 @@ async function recoverFromDeliveryTimeout(tabId, job, waitResult, totalMessages, totalMessages }); - job.currentPhase = 'waiting'; + job.currentPhase = 'awaiting-response'; + job.sawGenerating = job.sawGenerating || !!reloadedState.generating; + job.sawDeepResearch = job.sawDeepResearch || !!reloadedState.deepResearchActive; job.updatedAt = Date.now(); await updateRunningJobsStorage({ force: true }); const postReloadWait = await waitForTabResponse(tabId, { commandNumber: job.currentCommandNumber, totalMessages, - queueSettings + queueSettings, + commandBinding: getCommandBinding(job) }); if (!jobs.has(tabId) || job.isStopped || job.isPaused || !job.isRunning) { @@ -1881,6 +2085,7 @@ async function recoverFromDeliveryTimeout(tabId, job, waitResult, totalMessages, action: 'complete', details: { recoveredViaBackendCompletion: true, + terminalAckSource: 'backend-completion', assistantPreview: previewText(lastAssistant.text, 140) } }; @@ -1902,14 +2107,15 @@ async function recoverFromDeliveryTimeout(tabId, job, waitResult, totalMessages, return { action: 'return' }; } - job.currentPhase = 'waiting'; + job.currentPhase = 'awaiting-response'; job.updatedAt = Date.now(); await updateRunningJobsStorage({ force: true }); const retryWait = await waitForTabResponse(tabId, { commandNumber: job.currentCommandNumber, totalMessages, - queueSettings + queueSettings, + commandBinding: getCommandBinding(job) }); if (!jobs.has(tabId) || job.isStopped || job.isPaused || !job.isRunning) { @@ -1926,7 +2132,19 @@ async function recoverFromDeliveryTimeout(tabId, job, waitResult, totalMessages, } } - // Case C: Turn not completed and no retry button -> re-submit prompt into clean page + // Case C: Turn not completed and no retry button. + if (isSubmissionConfirmed(job)) { + logQueueEvent(tabId, 'warn', `Delivery timeout recovery kept the confirmed command without resending.`, { + commandNumber: job.currentCommandNumber || 0, + totalMessages, + ...collectDeliveryDiagnostics(job) + }); + job.currentPhase = 'awaiting-response'; + job.updatedAt = Date.now(); + await updateRunningJobsStorage({ force: true }); + return { action: 'retry' }; + } + if (job.currentMessage) { logQueueEvent(tabId, 'info', `Re-submitting prompt after reload for command ${job.currentCommandNumber || '?'}/${totalMessages}.`, { commandNumber: job.currentCommandNumber || 0, @@ -1939,6 +2157,7 @@ async function recoverFromDeliveryTimeout(tabId, job, waitResult, totalMessages, job.currentCommandNumber = 0; job.currentPhase = 'queued'; job.updatedAt = Date.now(); + resetCommandDeliveryState(job); await updateRunningJobsStorage({ force: true }); return { action: 'retry' }; @@ -1947,6 +2166,193 @@ async function recoverFromDeliveryTimeout(tabId, job, waitResult, totalMessages, return { action: 'fail' }; } +/** + * @param {number} tabId + * @param {{ expectedText?: string, expectedFingerprint?: string }} [options] + */ +async function inspectTabCommandTurns(tabId, { expectedText, expectedFingerprint } = {}) { + try { + const response = await sendTabMessage(tabId, { + type: 'GET_COMMAND_TURN_SNAPSHOT', + expectedText, + expectedFingerprint + }); + if (response && response.snapshot) { + return { + ok: true, + snapshot: response.snapshot, + source: 'content-script' + }; + } + if (response) { + return { + ok: false, + snapshot: { userTurns: [], assistantTurns: [], latestUserTurnId: null, matchedUserTurnId: null }, + source: 'content-script' + }; + } + } catch { + // Fall through to injected inspection. + } + + try { + const results = await executeScript({ + target: { tabId }, + func: (expected) => { + const normalize = (value) => String(value || '').replace(/\s+/g, ' ').trim(); + const expectedText = normalize(expected); + const nodes = Array.from(document.querySelectorAll('[data-message-author-role="user"], [data-testid*="conversation-turn"], article')); + const userTurns = []; + nodes.forEach((node, index) => { + const role = node.getAttribute('data-message-author-role') || ''; + const hasUserChild = !!node.querySelector?.('[data-message-author-role="user"]'); + if (role !== 'user' && !hasUserChild) { + return; + } + const host = /** @type {HTMLElement} */ (node); + const text = normalize(host.innerText || host.textContent || ''); + if (!text) { + return; + } + userTurns.push({ + turnId: node.getAttribute('data-message-id') || node.getAttribute('data-testid') || `user:${index}`, + index, + fingerprint: `len:${text.length}`, + matchedExpected: !!expectedText && text === expectedText + }); + }); + return { + ok: true, + snapshot: { + userTurns, + assistantTurns: [], + latestUserTurnId: userTurns.length > 0 ? userTurns[userTurns.length - 1].turnId : null, + matchedUserTurnId: (userTurns.find(turn => turn.matchedExpected) || {}).turnId || null, + supportsCommandTurnAck: true + } + }; + }, + args: [expectedText || ''] + }); + const result = results?.[0]?.result; + if (result && result.ok && result.snapshot) { + return { + ok: true, + snapshot: result.snapshot, + source: 'injected-script' + }; + } + } catch { + // Ignore inspect failures; caller treats missing snapshot as unconfirmed. + } + + return { + ok: false, + snapshot: { userTurns: [], assistantTurns: [], latestUserTurnId: null, matchedUserTurnId: null }, + source: 'unavailable' + }; +} + +/** + * @param {number} tabId + * @param {{ expectedText?: string, beforeSnapshot?: { userTurns?: Array<{ turnId?: string, matchedExpected?: boolean, fingerprint?: string }>, latestUserTurnId?: string|null, matchedUserTurnId?: string|null, conversationId?: string|null }, timeoutMs?: number, pollMs?: number }} [options] + */ +async function waitForSubmissionAck(tabId, { expectedText, beforeSnapshot, timeoutMs, pollMs } = {}) { + const job = jobs.get(tabId); + const provider = getActiveProviderAdapter(job?.provider) || getActiveProviderAdapter('chatgpt'); + const timeout = Number(timeoutMs) > 0 ? Number(timeoutMs) : QUEUE_WAIT_POLICY.submissionAckTimeoutMs; + const interval = Number(pollMs) > 0 ? Number(pollMs) : QUEUE_WAIT_POLICY.submissionAckPollMs; + const startedAt = Date.now(); + const beforeIds = new Set((beforeSnapshot?.userTurns || []).map(turn => turn.turnId)); + const supportsTurnAck = provider?.supportsCommandTurnAck !== false; + + if (job) { + persistCommandDelivery(tabId, job, { + currentPhase: 'awaiting-submission-ack', + deliveryState: 'unknown-acceptance' + }); + } + + while (Date.now() - startedAt <= timeout) { + const liveJob = jobs.get(tabId); + if (liveJob && (liveJob.isStopped || liveJob.isPaused || !liveJob.isRunning)) { + return { + ok: false, + error: liveJob.isStopped ? 'Queue was stopped.' : 'Queue was paused.', + details: { failureClass: liveJob.isStopped ? 'user-stop' : 'non-retryable' } + }; + } + + if (supportsTurnAck) { + const inspected = await inspectTabCommandTurns(tabId, { + expectedText, + expectedFingerprint: liveJob?.commandFingerprint || fingerprintCommandTextForJob(expectedText) + }); + const snapshot = inspected.snapshot || { userTurns: [] }; + const matched = (snapshot.userTurns || []).find(turn => turn.matchedExpected && !beforeIds.has(turn.turnId)) || + (snapshot.matchedUserTurnId && !beforeIds.has(snapshot.matchedUserTurnId) + ? (snapshot.userTurns || []).find(turn => turn.turnId === snapshot.matchedUserTurnId) + : null); + + if (matched) { + if (liveJob) { + persistCommandDelivery(tabId, liveJob, { + deliveryState: 'confirmed-submission', + submittedUserTurnId: matched.turnId, + submissionAckSource: inspected.source || 'user-turn', + commandFingerprint: matched.fingerprint || liveJob.commandFingerprint + }); + } + return { + ok: true, + details: { + submissionAckSource: inspected.source || 'user-turn', + userTurnId: matched.turnId, + previousUserTurnId: beforeSnapshot?.latestUserTurnId || null, + conversationId: snapshot.conversationId || liveJob?.conversationId || null, + commandFingerprint: matched.fingerprint || fingerprintCommandTextForJob(expectedText), + userTurnCount: (snapshot.userTurns || []).length + } + }; + } + } else { + try { + const response = await sendTabMessage(tabId, { type: 'CHECK_GENERATION_STATE' }); + const state = response?.state || {}; + if (state.generating || state.deepResearchActive) { + if (liveJob) { + persistCommandDelivery(tabId, liveJob, { + deliveryState: 'confirmed-submission', + submissionAckSource: 'generation-started' + }); + } + return { + ok: true, + details: { + submissionAckSource: 'generation-started', + userTurnId: null, + previousUserTurnId: beforeSnapshot?.latestUserTurnId || null + } + }; + } + } catch { + // Keep polling until timeout. + } + } + + await sleep(interval); + } + + return { + ok: false, + error: 'Send was not acknowledged as a new user turn.', + details: { + failureClass: 'submission-unconfirmed', + supportsCommandTurnAck: supportsTurnAck + } + }; +} + async function sendPromptToSpecificTab(tabId, text) { try { let tabUrl = ''; @@ -1966,6 +2372,17 @@ async function sendPromptToSpecificTab(tabId, text) { ? provider.getCompatibilityContract() : { provider: provider?.id || 'unknown', version: 1, selectors: {}, signals: {} }; + const beforeInspect = await inspectTabCommandTurns(tabId, { + expectedText: text, + expectedFingerprint: job?.commandFingerprint || fingerprintCommandTextForJob(text) + }); + if (job) { + persistCommandDelivery(tabId, job, { + currentPhase: 'sending', + deliveryState: 'pre-click' + }); + } + const results = await executeScript({ target: { tabId }, func: async (msg, contract, providerName) => { @@ -2155,9 +2572,32 @@ async function sendPromptToSpecificTab(tabId, text) { }; } + const ack = await waitForSubmissionAck(tabId, { + expectedText: text, + beforeSnapshot: beforeInspect.snapshot, + timeoutMs: QUEUE_WAIT_POLICY.submissionAckTimeoutMs, + pollMs: QUEUE_WAIT_POLICY.submissionAckPollMs + }); + + if (!ack.ok) { + return { + ok: false, + error: ack.error || 'Send was not acknowledged as a new user turn.', + details: { + ...(result.details || {}), + ...(ack.details || {}), + failureClass: ack.details?.failureClass || 'submission-unconfirmed', + clickOnly: true + } + }; + } + return { ok: true, - details: result.details || {} + details: { + ...(result.details || {}), + ...(ack.details || {}) + } }; } catch (error) { return { @@ -2201,7 +2641,12 @@ async function waitForTabResponse(tabId, context = {}) { let sawDeepResearch = liveJob?.sawDeepResearch === true; let lastResearchPreview = ''; let lastProgressLogAt = startedAt; + let terminalStreak = 0; + let idleStreak = 0; const waitLabel = getWaitContextLabel(context); + const commandBinding = getCommandBinding(liveJob, context); + const hasCommandBinding = !context.waitForExistingGeneration && !!(commandBinding.userTurnId || commandBinding.commandFingerprint); + const requiredConfirmSamples = Math.max(1, Number(context.terminalConfirmSamples || QUEUE_WAIT_POLICY.terminalConfirmSamples) || 2); const persistWaitSignals = () => { const current = jobs.get(tabId); @@ -2247,7 +2692,7 @@ async function waitForTabResponse(tabId, context = {}) { persistWaitSignals(); resolveRaw(value); }; - checkInterval = setInterval(() => { + const pollGeneration = () => { if (settled) { clearInterval(checkInterval); return; @@ -2280,10 +2725,13 @@ async function waitForTabResponse(tabId, context = {}) { ok: false, error: researchTimeout ? 'Timed out waiting for Deep Research to finish.' - : 'Timed out waiting for ChatGPT response.', + : (hasCommandBinding + ? 'No terminal response was confirmed for this command.' + : 'Timed out waiting for ChatGPT response.'), details: buildWaitDetails({ failureClass: 'timeout', - timedOut: true + timedOut: true, + pendingWithoutTerminal: true }) }); return; @@ -2305,12 +2753,14 @@ async function waitForTabResponse(tabId, context = {}) { } try { - sendTabMessage(tabId, { type: 'CHECK_GENERATION_STATE' }).then((response) => { + sendTabMessage(tabId, { type: 'CHECK_GENERATION_STATE', commandBinding }).then((response) => { if (settled) { return; } const state = response?.state || {}; + const responseState = response?.responseState || null; + const phase = String(responseState?.phase || ''); const isDeliveryTimeout = !!state.hasDeliveryTimedOut || (typeof state.matchedError === 'string' && /delivery time(?:d\s*)?out/i.test(state.matchedError)) || @@ -2324,21 +2774,51 @@ async function waitForTabResponse(tabId, context = {}) { error: state.matchedError || 'Message delivery timed out. Please try again.', details: buildWaitDetails({ failureClass: 'timeout', - state + state, + responseState }) }); return; } - if (state.hasError || state.hasTryAgainButton) { + if (state.hasError || state.hasTryAgainButton || phase === 'error') { clearInterval(checkInterval); resolve({ ok: false, isDeliveryTimeout: false, error: 'ChatGPT showed an error or retry state.', details: buildWaitDetails({ - failureClass: state.hasTryAgainButton ? 'retry-visible' : 'generation-error', - state + failureClass: state.hasTryAgainButton || responseState?.source === 'retry-visible' ? 'retry-visible' : 'generation-error', + state, + responseState + }) + }); + return; + } + + if (phase === 'waiting-for-user') { + clearInterval(checkInterval); + resolve({ + ok: false, + error: 'ChatGPT is waiting for the user.', + details: buildWaitDetails({ + failureClass: 'waiting-for-user', + state, + responseState + }) + }); + return; + } + + if (phase === 'interrupted') { + clearInterval(checkInterval); + resolve({ + ok: false, + error: 'The ChatGPT response was interrupted.', + details: buildWaitDetails({ + failureClass: 'interrupted', + state, + responseState }) }); return; @@ -2359,22 +2839,31 @@ async function waitForTabResponse(tabId, context = {}) { job.lastResearchProgressAt = Date.now(); } - if (state.generating || deepResearchActive) { - if (!sawGenerating && state.generating) { + if (state.generating || deepResearchActive || phase === 'active') { + if (!sawGenerating && (state.generating || phase === 'active')) { logQueueEvent(tabId, 'info', `ChatGPT is responding for ${waitLabel}.`, { commandNumber: context.commandNumber || 0, totalMessages: context.totalMessages || 0, elapsedMs: Date.now() - startedAt, - state + commandId: commandBinding.commandId || job.commandId || '', + userTurnId: responseState?.userTurnId || commandBinding.userTurnId || null, + responsePhase: phase || 'active' }); } - sawGenerating = sawGenerating || !!state.generating; + sawGenerating = sawGenerating || !!state.generating || phase === 'active'; sawDeepResearch = sawDeepResearch || deepResearchActive; job.sawGenerating = sawGenerating; job.sawDeepResearch = sawDeepResearch; + job.deliveryState = hasCommandBinding ? 'active-response' : job.deliveryState; + job.lastResponsePhase = phase || 'active'; + if (responseState?.assistantTurnId) { + job.assistantTurnId = responseState.assistantTurnId; + } + terminalStreak = 0; + idleStreak = 0; - if (state.generating || researchPreview !== lastResearchPreview) { + if (state.generating || researchPreview !== lastResearchPreview || phase === 'active') { job.lastResearchProgressAt = Date.now(); lastResearchPreview = researchPreview; } @@ -2390,6 +2879,7 @@ async function waitForTabResponse(tabId, context = {}) { deepResearchActive, sawGenerating, sawDeepResearch, + responsePhase: phase || 'active', settings: queueSettings }); lastProgressLogAt = Date.now(); @@ -2398,9 +2888,31 @@ async function waitForTabResponse(tabId, context = {}) { return; } + if (phase && phase !== job.lastResponsePhase) { + logQueueEvent(tabId, 'info', `Command response phase changed to ${phase}.`, { + commandNumber: context.commandNumber || 0, + commandId: commandBinding.commandId || job.commandId || '', + userTurnId: responseState?.userTurnId || commandBinding.userTurnId || null, + assistantTurnId: responseState?.assistantTurnId || null, + responsePhase: phase, + source: responseState?.source || '' + }); + job.lastResponsePhase = phase; + } + + const pageIsIdle = !state.generating && !deepResearchActive && phase !== 'active'; + if (context.waitForExistingGeneration) { - clearInterval(checkInterval); - setTimeout(() => { + if (!pageIsIdle) { + idleStreak = 0; + persistWaitSignals(); + return; + } + terminalStreak = 0; + idleStreak += 1; + persistWaitSignals(); + const needed = (sawGenerating || sawDeepResearch) ? requiredConfirmSamples : 1; + if (idleStreak >= needed) { resolve({ ok: true, details: { @@ -2409,53 +2921,73 @@ async function waitForTabResponse(tabId, context = {}) { sawGenerating, sawDeepResearch, settings: queueSettings, - state + state, + responseState } }); - }, sawGenerating || sawDeepResearch ? 800 : 0); + } return; } - if (sawGenerating || sawDeepResearch) { - clearInterval(checkInterval); - setTimeout(() => { + if (phase === 'transient-idle' || (hasCommandBinding && !phase && pageIsIdle && !sawGenerating && !sawDeepResearch)) { + terminalStreak = 0; + idleStreak = 0; + job.lastResponsePhase = phase || 'transient-idle'; + persistWaitSignals(); + return; + } + + const boundTerminal = hasCommandBinding && phase === 'terminal' && ( + !commandBinding.userTurnId || + !responseState?.userTurnId || + responseState.userTurnId === commandBinding.userTurnId + ); + + if (boundTerminal) { + idleStreak = 0; + terminalStreak += 1; + job.lastResponsePhase = 'terminal'; + job.assistantTurnId = responseState?.assistantTurnId || job.assistantTurnId || null; + job.terminalAckSource = responseState?.source || 'bound-assistant-turn'; + persistWaitSignals(); + if (terminalStreak >= requiredConfirmSamples) { + job.currentPhase = 'terminal'; + job.deliveryState = 'terminal-awaiting-bookkeeping'; resolve({ ok: true, - details: { - elapsedMs: Date.now() - startedAt, - sawGenerating, - sawDeepResearch, - settings: queueSettings, - state - } + details: buildWaitDetails({ + terminalAckSource: job.terminalAckSource, + userTurnId: responseState?.userTurnId || commandBinding.userTurnId || null, + assistantTurnId: job.assistantTurnId, + responsePhase: 'terminal', + state, + responseState + }) }); - }, 800); + } return; } - if (!queueSettings.queueUnlimitedRetryWait && Date.now() - startedAt > 5000) { - clearInterval(checkInterval); - logQueueEvent(tabId, 'warn', `No generating indicator after ${waitLabel}; assuming it completed.`, { - commandNumber: context.commandNumber || 0, - totalMessages: context.totalMessages || 0, - elapsedMs: Date.now() - startedAt, - settings: queueSettings, - state - }); - resolve({ - ok: true, - details: { - assumedCompleteWithoutGeneratingIndicator: true, - elapsedMs: Date.now() - startedAt, - sawGenerating, - sawDeepResearch, - settings: queueSettings, - state - } - }); + if (!hasCommandBinding && (sawGenerating || sawDeepResearch) && pageIsIdle) { + terminalStreak = 0; + idleStreak += 1; + persistWaitSignals(); + if (idleStreak >= requiredConfirmSamples) { + resolve({ + ok: true, + details: buildWaitDetails({ + terminalAckSource: 'idle-after-generation', + state, + responseState + }) + }); + } return; } + idleStreak = 0; + terminalStreak = 0; + if (queueSettings.queueUnlimitedRetryWait && Date.now() - lastProgressLogAt > 30000) { logQueueEvent(tabId, 'info', `Unlimited wait mode is still waiting for ${waitLabel}.`, { commandNumber: context.commandNumber || 0, @@ -2465,6 +2997,7 @@ async function waitForTabResponse(tabId, context = {}) { deepResearchActive, sawGenerating, sawDeepResearch, + responsePhase: phase || 'pending', settings: queueSettings }); lastProgressLogAt = Date.now(); @@ -2494,7 +3027,11 @@ async function waitForTabResponse(tabId, context = {}) { }) }); } - }, checkIntervalMs); + }; + checkInterval = setInterval(pollGeneration, checkIntervalMs); + if (!context.waitForExistingGeneration) { + pollGeneration(); + } }); } @@ -2554,6 +3091,14 @@ function getRunningJobsSnapshot() { lastResearchProgressAt: Number(job.lastResearchProgressAt || 0), sawDeepResearch: job.sawDeepResearch === true, sawGenerating: job.sawGenerating === true, + deliveryState: job.deliveryState || '', + commandId: job.commandId || '', + commandFingerprint: job.commandFingerprint || '', + submittedUserTurnId: job.submittedUserTurnId || null, + assistantTurnId: job.assistantTurnId || null, + submissionAckSource: job.submissionAckSource || '', + terminalAckSource: job.terminalAckSource || '', + lastResponsePhase: job.lastResponsePhase || '', startedAt: job.startedAt, updatedAt: job.updatedAt }; @@ -2710,6 +3255,14 @@ function getDurableJobsState() { lastResearchProgressAt: Number(job.lastResearchProgressAt || 0), sawDeepResearch: job.sawDeepResearch === true, sawGenerating: job.sawGenerating === true, + deliveryState: job.deliveryState || '', + commandId: job.commandId || '', + commandFingerprint: job.commandFingerprint || '', + submittedUserTurnId: job.submittedUserTurnId || null, + assistantTurnId: job.assistantTurnId || null, + submissionAckSource: job.submissionAckSource || '', + terminalAckSource: job.terminalAckSource || '', + lastResponsePhase: job.lastResponsePhase || '', startedAt: job.startedAt, updatedAt: job.updatedAt }; @@ -3619,6 +4172,9 @@ if (typeof module !== 'undefined' && module.exports) { pauseJob, recoverFromDeliveryTimeout, sendPromptToSpecificTab, + inspectTabCommandTurns, + waitForSubmissionAck, + isSubmissionConfirmed, refreshChatGPTTab, waitForTabToRecover, waitForTabResponse, diff --git a/content.js b/content.js index 2d4055f..f35afb5 100644 --- a/content.js +++ b/content.js @@ -165,7 +165,34 @@ case 'CHECK_GENERATION_STATE': { const state = this.getGenerationState(); - sendResponse({ state }); + let responseState = null; + if (this.provider && typeof this.provider.getCommandResponseState === 'function') { + responseState = this.provider.getCommandResponseState(document, message.commandBinding || {}); + } + sendResponse({ state, responseState }); + break; + } + + case 'GET_COMMAND_TURN_SNAPSHOT': { + if (this.provider && typeof this.provider.getCommandTurnSnapshot === 'function') { + const snapshot = this.provider.getCommandTurnSnapshot(document, { + expectedText: message.expectedText, + expectedFingerprint: message.expectedFingerprint + }); + sendResponse({ ok: true, snapshot }); + break; + } + sendResponse({ ok: false, error: 'Turn snapshot is unavailable.' }); + break; + } + + case 'GET_COMMAND_RESPONSE_STATE': { + if (this.provider && typeof this.provider.getCommandResponseState === 'function') { + const responseState = this.provider.getCommandResponseState(document, message.commandBinding || {}); + sendResponse({ ok: true, responseState }); + break; + } + sendResponse({ ok: false, error: 'Command response state is unavailable.' }); break; } diff --git a/docs/project-memory/architecture.md b/docs/project-memory/architecture.md index 5c23f9e..f6e54f7 100644 --- a/docs/project-memory/architecture.md +++ b/docs/project-memory/architecture.md @@ -4,7 +4,7 @@ - `background.js` owns per-tab queue jobs in a `Map`, accepts runtime messages for start, enqueue, retry, stop, status, and diagnostics, injects prompt scripts into the selected tab, waits for response state with a finite retry/wait policy, and records durable queue state including retry and Deep Research wait metadata in local storage. - `popup.js` selects supported provider tabs through provider-neutral discovery, stores reusable messages and sequences in local storage, stores optimizer and queue settings in sync storage, resolves placeholders, and renders queue instances, status, and diagnostics through runtime messages. - `content.js` owns Enter-to-queue on supported providers and the optimizer on ChatGPT only. It discovers conversation turns through primary and fallback selectors, hides older messages, lazily restores images, adds a load-more banner, observes DOM changes, and can queue Enter-submitted text while the provider is generating. -- Queue response detection in `background.js` is DOM-driven through injected functions and provider adapters. It recognizes generating/streaming indicators, selected error markers, and deep-research progress; prompt submission finds a contenteditable input and a supported send-button selector for the active provider. +- Queue response detection in `background.js` is DOM-driven through injected functions and provider adapters. It requires a confirmed user turn before treating a send as submitted, then waits for a command-bound terminal assistant turn. Generating/streaming/status/error markers remain activity signals, but idle gaps are non-terminal unless the current command's assistant turn is confirmed. - `provider-adapter.js` defines the `ProviderAdapter` interface plus `ChatGPTAdapter`, `GeminiAdapter`, and `ClaudeAdapter`, centralizing site-specific selectors, conversation identity resolution, generation state checks, composer extraction/clearing, and message discovery. Gemini and Claude set `supportsOptimizer: false`. - Local and CI verification is `npm run check`: Node tests, correctness-focused ESLint, and checked-JavaScript/JSDoc analysis. The Manifest V3 extension still loads unpacked from the repository root with no transpile or bundle step. - `Installers/install_chatgpt_queue_optimizer.py` copies the extension source for packaging, creates a Firefox-specific Manifest V2 source from the Manifest V3 input, builds browser packages, and attempts persistent or temporary browser installation paths. diff --git a/docs/project-memory/decisions.md b/docs/project-memory/decisions.md index 07e28aa..7c8db1b 100644 --- a/docs/project-memory/decisions.md +++ b/docs/project-memory/decisions.md @@ -1,8 +1,8 @@ # Decisions - Queue ownership is keyed by tab ID, allowing independent queues while preventing a second sequence from starting on a tab that already has a running or paused queue (`background.js`). -- A command is not completed until `waitForTabResponse()` observes the response cycle or applies its explicit fallback. Failed send/wait phases pause the queue after a finite default of three automatic retries with exponential backoff. `queueUnlimitedRetryWait` is the only explicit unlimited wait/retry mode and still uses a non-zero delay; Deep Research awareness may use a longer progress-aware wait but cannot remove the final bound (`background.js`). -- Queue state is persisted in local storage and periodically woken with an alarms entry. Non-critical snapshots are coalesced and unchanged snapshots are skipped, while start, enqueue, send/recovery, pause, stop, completion, tab removal, and worker-wake boundaries force durable writes. Snapshots include retry budget and Deep Research wait/progress timestamps so a worker wake does not reset them. On worker wake, an unconfirmed `sending` command is put back at the front; `retry-wait` keeps the current command and remaining delay (`background.js`). +- A command is not completed until a new matching user turn is acknowledged and `waitForTabResponse()` confirms a command-bound terminal assistant turn. Transient idle after generation is non-terminal. Failed send/wait phases pause the queue after a finite default of three automatic retries with exponential backoff. `queueUnlimitedRetryWait` is the only explicit unlimited wait/retry mode and still uses a non-zero delay; Deep Research awareness may use a longer progress-aware wait but cannot remove the final bound (`background.js`). +- Queue state is persisted in local storage and periodically woken with an alarms entry. Non-critical snapshots are coalesced and unchanged snapshots are skipped, while start, enqueue, send/recovery, pause, stop, completion, tab removal, and worker-wake boundaries force durable writes. Snapshots include retry budget, Deep Research wait/progress timestamps, and per-command delivery acknowledgement metadata (phase, user/assistant turn ids, ack sources) so a worker wake does not reset them or drop/resend the wrong command. On worker wake, an unconfirmed `sending`/`awaiting-submission-ack` command is put back at the front; a confirmed submission resumes waiting; `retry-wait` keeps the current command and remaining delay (`background.js`). - Queue debug logs remain ordered and bounded while buffered entries flush in batches; logical reads flush pending entries, and clear operations use a generation barrier so an older pending batch cannot repopulate cleared logs (`background.js`). - The optimizer uses layered selectors and fallback discovery because the page is controlled by ChatGPT. Its windowing keeps at least eight recent messages visible and caps the discovered message set at 1,200 (`content.js`). - Extension API helpers try callback and promise forms so popup, content, and background code can use the same operations across supported browser API variants (`popup.js`, `content.js`, `background.js`). diff --git a/docs/project-memory/known-failures.md b/docs/project-memory/known-failures.md index a833ef7..3d7674e 100644 --- a/docs/project-memory/known-failures.md +++ b/docs/project-memory/known-failures.md @@ -1,8 +1,8 @@ # Known failures and limits - Prompt submission depends on each provider’s current contenteditable and send-button selectors. If either is absent or disabled, the injected send operation returns an error and the queue pauses (`background.js`, `provider-adapter.js`). -- Response detection depends on provider generating, streaming, status, and error markers. An injected-script failure, a detected error/retry state, a stalled Deep Research wait, or a finite wait timeout pauses the queue after the automatic retry budget is exhausted (`background.js`). -- In the default wait mode, if no generating indicator appears for five seconds, the worker records that it assumed completion and advances. This is an intentional fallback but can misclassify a response when the page exposes no recognized indicator (`background.js`). +- Response detection depends on provider generating, streaming, status, error, and command-bound turn markers. An injected-script failure, a detected error/retry/interrupted/waiting-for-user state, a stalled Deep Research wait, an unconfirmed send, or a finite wait timeout pauses the queue after the automatic retry budget is exhausted (`background.js`). +- After an accepted submission, missing generating indicators are treated as pending until a command-bound terminal acknowledgement or a bounded timeout with a specific reason. The queue does not assume completion from click success or a short idle gap (`background.js`). - Deep-research-aware waiting uses a longer finite maximum duration and a stale-progress timeout. Progress updates reset the stale timer, but only explicit unlimited retry/wait removes the final bound (`background.js`). - If a worker restart finds only legacy running-job state and no recoverable durable queue, it records that the in-memory queue was lost and clears the stale state (`background.js`). - Gemini and Claude optimizer/message-window support is intentionally unsupported; queue Enter interception still runs on those providers (`provider-adapter.js`, `content.js`). diff --git a/provider-adapter.js b/provider-adapter.js index fc0308e..f8defa1 100644 --- a/provider-adapter.js +++ b/provider-adapter.js @@ -78,6 +78,19 @@ 'network error', 'failed to generate', 'try again later' + ], + waitingForUserMarkers: [ + 'waiting for you', + 'waiting for your response', + 'waiting for your reply', + 'need more information from you', + 'reply when you are ready' + ], + interruptedMarkers: [ + 'stopped generating', + 'response interrupted', + 'generation stopped', + 'you stopped this response' ] }; @@ -166,6 +179,57 @@ return value; } + function normalizeCommandText(text) { + return String(text || '').replace(/\s+/g, ' ').trim(); + } + + function fingerprintCommandText(text) { + const normalized = normalizeCommandText(text); + let hash = 2166136261; + for (let i = 0; i < normalized.length; i += 1) { + hash ^= normalized.charCodeAt(i); + hash = Math.imul(hash, 16777619); + } + return `fnv1a:${(hash >>> 0).toString(16)}:len:${normalized.length}`; + } + + function emptyCommandTurnSnapshot(adapter) { + return { + conversationId: null, + conversationKey: '', + userTurns: [], + assistantTurns: [], + latestUserTurnId: null, + latestAssistantTurnId: null, + matchedUserTurnId: null, + supportsCommandTurnAck: adapter?.supportsCommandTurnAck === true + }; + } + + function getNodeRole(node) { + const attr = String(node?.getAttribute?.('data-message-author-role') || '').toLowerCase(); + if (attr === 'user' || attr === 'assistant') { + return attr; + } + if (node?.querySelector?.('[data-message-author-role="user"]')) { + return 'user'; + } + if (node?.querySelector?.('[data-message-author-role="assistant"]')) { + return 'assistant'; + } + return ''; + } + + function getNodeText(node) { + return normalizeCommandText(node?.innerText || node?.textContent || ''); + } + + function getTurnId(node, index, role, fingerprint) { + return node?.getAttribute?.('data-message-id') || + node?.getAttribute?.('data-testid') || + `turn:${index}:${role || 'unknown'}:${fingerprint || '0'}`; + } + function summarizeSelectorMatch(match) { return { matched: !!match?.element, @@ -180,15 +244,25 @@ this.id = idOrOptions.id; this.name = idOrOptions.name; this.supportsOptimizer = !!idOrOptions.supportsOptimizer; + this.supportsCommandTurnAck = !!idOrOptions.supportsCommandTurnAck; this.selectors = idOrOptions.selectors || {}; } else { this.id = idOrOptions; this.name = maybeName; this.supportsOptimizer = !!options.supportsOptimizer; + this.supportsCommandTurnAck = !!options.supportsCommandTurnAck; this.selectors = options.selectors || {}; } } + normalizeCommandText(text) { + return normalizeCommandText(text); + } + + fingerprintCommandText(text) { + return fingerprintCommandText(text); + } + getCompatibilityContract() { return { provider: this.id, @@ -383,6 +457,68 @@ return null; } + getCommandTurnSnapshot(doc, options = {}) { + return emptyCommandTurnSnapshot(this); + } + + /** + * @param {any} doc + * @param {{ userTurnId?: string|null, assistantTurnId?: string|null, commandFingerprint?: string, conversationId?: string|null }} [binding] + * @returns {{ phase: string, userTurnId: string|null, assistantTurnId: string|null, conversationId: string|null, generating: boolean, deepResearchActive: boolean, hasCompletedAssistant: boolean, source: string }} + */ + getCommandResponseState(doc, binding = {}) { + const generation = this.getGenerationState(doc); + if (generation.hasError || generation.hasTryAgainButton || generation.hasDeliveryTimedOut) { + return { + phase: 'error', + userTurnId: binding.userTurnId || null, + assistantTurnId: null, + conversationId: binding.conversationId || null, + generating: false, + deepResearchActive: !!generation.deepResearchActive, + hasCompletedAssistant: false, + source: generation.hasDeliveryTimedOut ? 'delivery-timeout' : (generation.hasTryAgainButton ? 'retry-visible' : 'generation-error') + }; + } + if (generation.generating || generation.deepResearchActive) { + return { + phase: 'active', + userTurnId: binding.userTurnId || null, + assistantTurnId: null, + conversationId: binding.conversationId || null, + generating: !!generation.generating, + deepResearchActive: !!generation.deepResearchActive, + hasCompletedAssistant: false, + source: generation.deepResearchActive ? 'deep-research' : 'generating' + }; + } + const lastAssistant = typeof this.getLastAssistantTurn === 'function' + ? this.getLastAssistantTurn(doc) + : null; + if (lastAssistant && lastAssistant.isAssistant && lastAssistant.hasCompletedText) { + return { + phase: 'terminal', + userTurnId: binding.userTurnId || null, + assistantTurnId: binding.assistantTurnId || null, + conversationId: binding.conversationId || null, + generating: false, + deepResearchActive: false, + hasCompletedAssistant: true, + source: 'assistant-turn-completed' + }; + } + return { + phase: 'transient-idle', + userTurnId: binding.userTurnId || null, + assistantTurnId: null, + conversationId: binding.conversationId || null, + generating: false, + deepResearchActive: false, + hasCompletedAssistant: false, + source: 'unknown-idle' + }; + } + getMainRoot(doc) { return null; } @@ -412,6 +548,7 @@ constructor() { super('chatgpt', 'ChatGPT', { supportsOptimizer: true, + supportsCommandTurnAck: true, selectors: CHATGPT_SELECTORS }); this.compatibilitySignals = CHATGPT_COMPATIBILITY_SIGNALS; @@ -534,6 +671,165 @@ return matches; } + getCommandTurnSnapshot(doc = (typeof document !== 'undefined' ? document : null), options = {}) { + const snapshot = emptyCommandTurnSnapshot(this); + if (!doc) { + return snapshot; + } + + const identity = typeof this.getConversationIdentity === 'function' + ? this.getConversationIdentity(doc.defaultView?.location || (typeof location !== 'undefined' ? location : '')) + : null; + snapshot.conversationId = identity?.conversationId || null; + snapshot.conversationKey = identity?.key || ''; + + const expectedText = normalizeCommandText(options.expectedText || ''); + const expectedFingerprint = options.expectedFingerprint || (expectedText ? fingerprintCommandText(expectedText) : ''); + const turns = this.getConversationTurns(doc); + const userTurns = []; + const assistantTurns = []; + + turns.forEach((node, index) => { + const role = getNodeRole(node); + const text = getNodeText(node); + const fingerprint = fingerprintCommandText(text); + const turnId = getTurnId(node, index, role, fingerprint); + const matchedExpected = !!expectedText && text === expectedText; + + if (role === 'user' || (!role && matchedExpected)) { + userTurns.push({ + turnId, + index, + fingerprint, + matchedExpected + }); + if (matchedExpected) { + snapshot.matchedUserTurnId = turnId; + } + } + + if (role === 'assistant') { + const streaming = !!(node.querySelector?.('[data-message-streaming="true"], [data-is-streaming="true"], .result-streaming')); + assistantTurns.push({ + turnId, + index, + followingUserTurnId: userTurns.length > 0 ? userTurns[userTurns.length - 1].turnId : null, + fingerprint, + streaming, + hasCompletedText: !streaming && text.length > 0 + }); + } + }); + + snapshot.userTurns = userTurns; + snapshot.assistantTurns = assistantTurns; + snapshot.latestUserTurnId = userTurns.length > 0 ? userTurns[userTurns.length - 1].turnId : null; + snapshot.latestAssistantTurnId = assistantTurns.length > 0 ? assistantTurns[assistantTurns.length - 1].turnId : null; + if (!snapshot.matchedUserTurnId && expectedFingerprint) { + const fingerprintMatch = userTurns.find(turn => turn.fingerprint === expectedFingerprint); + if (fingerprintMatch) { + snapshot.matchedUserTurnId = fingerprintMatch.turnId; + fingerprintMatch.matchedExpected = true; + } + } + return snapshot; + } + + getCommandResponseState(doc = (typeof document !== 'undefined' ? document : null), binding = {}) { + const generation = this.getGenerationState(doc); + const snapshot = this.getCommandTurnSnapshot(doc, { + expectedFingerprint: binding.commandFingerprint || '' + }); + const userTurnId = binding.userTurnId || snapshot.matchedUserTurnId || snapshot.latestUserTurnId || null; + const boundUser = snapshot.userTurns.find(turn => turn.turnId === userTurnId) || null; + const followingAssistant = snapshot.assistantTurns.find(turn => turn.followingUserTurnId === userTurnId) || + (boundUser + ? snapshot.assistantTurns.find(turn => turn.index > boundUser.index) + : null) || + null; + const conversationId = binding.conversationId || snapshot.conversationId || null; + const statusText = String(generation.researchStatusPreview || '').toLowerCase(); + const waitingForUser = (this.compatibilitySignals.waitingForUserMarkers || []).some(marker => statusText.includes(marker)); + const interrupted = (this.compatibilitySignals.interruptedMarkers || []).some(marker => statusText.includes(marker)); + + if (generation.hasError || generation.hasTryAgainButton || generation.hasDeliveryTimedOut) { + return { + phase: 'error', + userTurnId, + assistantTurnId: followingAssistant?.turnId || null, + conversationId, + generating: false, + deepResearchActive: !!generation.deepResearchActive, + hasCompletedAssistant: false, + source: generation.hasDeliveryTimedOut ? 'delivery-timeout' : (generation.hasTryAgainButton ? 'retry-visible' : 'generation-error') + }; + } + + if (waitingForUser) { + return { + phase: 'waiting-for-user', + userTurnId, + assistantTurnId: followingAssistant?.turnId || null, + conversationId, + generating: false, + deepResearchActive: false, + hasCompletedAssistant: false, + source: 'waiting-for-user' + }; + } + + if (interrupted && !(followingAssistant?.hasCompletedText)) { + return { + phase: 'interrupted', + userTurnId, + assistantTurnId: followingAssistant?.turnId || null, + conversationId, + generating: false, + deepResearchActive: false, + hasCompletedAssistant: false, + source: 'interrupted' + }; + } + + const boundActivity = !!(generation.generating || generation.deepResearchActive || followingAssistant?.streaming); + if (boundActivity) { + return { + phase: 'active', + userTurnId, + assistantTurnId: followingAssistant?.turnId || null, + conversationId, + generating: !!generation.generating, + deepResearchActive: !!generation.deepResearchActive, + hasCompletedAssistant: false, + source: generation.deepResearchActive ? 'deep-research' : (followingAssistant?.streaming ? 'streaming' : 'generating') + }; + } + + if (followingAssistant && followingAssistant.hasCompletedText && !followingAssistant.streaming) { + return { + phase: 'terminal', + userTurnId, + assistantTurnId: followingAssistant.turnId, + conversationId, + generating: false, + deepResearchActive: false, + hasCompletedAssistant: true, + source: 'bound-assistant-turn' + }; + } + + return { + phase: 'transient-idle', + userTurnId, + assistantTurnId: followingAssistant?.turnId || null, + conversationId, + generating: false, + deepResearchActive: false, + hasCompletedAssistant: false, + source: 'transient-idle' + }; + } + findMatchingSelector(node, selectorKey) { const selectors = this.selectors?.[selectorKey] || []; return selectors.find((selector) => { @@ -1873,7 +2169,9 @@ PROVIDERS, getProvider, getProviderForUrl, - getConversationIdentity + getConversationIdentity, + normalizeCommandText, + fingerprintCommandText }; if (typeof globalThis !== 'undefined') { @@ -1889,6 +2187,8 @@ globalThis.getProvider = getProvider; globalThis.getProviderForUrl = getProviderForUrl; globalThis.getConversationIdentity = getConversationIdentity; + globalThis.normalizeCommandText = normalizeCommandText; + globalThis.fingerprintCommandText = fingerprintCommandText; } if (typeof module !== 'undefined' && module.exports) { diff --git a/test/issue-41-wait-for-idle.test.js b/test/issue-41-wait-for-idle.test.js index f891d69..9e380a6 100644 --- a/test/issue-41-wait-for-idle.test.js +++ b/test/issue-41-wait-for-idle.test.js @@ -139,7 +139,7 @@ async function runPopupStart({ mode, busy }) { assert.equal(executeScriptCount, 0, `${mode} steered a busy ChatGPT response`); } - await waitUntil(() => executeScriptCount === 1); + await waitUntil(() => executeScriptCount >= 1); } finally { const job = jobs.get(tabId); if (job) { diff --git a/test/issue-43-delivery-contract.test.js b/test/issue-43-delivery-contract.test.js new file mode 100644 index 0000000..fd187fb --- /dev/null +++ b/test/issue-43-delivery-contract.test.js @@ -0,0 +1,822 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const mockTabs = new Map(); +const mockStorageLocal = {}; +let mockSyncStorage = { + queueUnlimitedRetryWait: false, + queueDeepResearchAware: true, + queueDeliveryTimeoutRefresh: true +}; +let executeScriptImpl = async () => [{ result: { ok: true, details: { clickOnly: true } } }]; + +global.chrome = { + runtime: { + lastError: null, + onMessage: { addListener: () => {} }, + onInstalled: { addListener: () => {} }, + getURL: () => '', + sendMessage: (_message, callback) => callback && callback() + }, + browserAction: { + onClicked: { addListener: () => {} } + }, + commands: { + onCommand: { addListener: () => {} } + }, + storage: { + sync: { + get: (defaults, callback) => callback && callback({ ...defaults, ...mockSyncStorage }), + set: (items, callback) => { + Object.assign(mockSyncStorage, items); + callback && callback(); + } + }, + local: { + get: (keys, callback) => { + const requested = Array.isArray(keys) + ? keys + : (typeof keys === 'object' && keys !== null ? Object.keys(keys) : [keys]); + const result = {}; + for (const key of requested) { + if (key in mockStorageLocal) result[key] = mockStorageLocal[key]; + } + callback && callback(result); + }, + set: (items, callback) => { + Object.assign(mockStorageLocal, items); + callback && callback(); + } + } + }, + tabs: { + onRemoved: { addListener: () => {} }, + get: (tabId, callback) => { + const tab = mockTabs.get(tabId) || { id: tabId, url: 'https://chatgpt.com/c/issue-43' }; + callback && callback(tab); + return Promise.resolve(tab); + }, + sendMessage: (tabId, message, callback) => { + const tab = mockTabs.get(tabId); + const response = tab?.onMessage ? tab.onMessage(message) : { ok: true }; + callback && callback(response); + return Promise.resolve(response); + } + }, + alarms: { + onAlarm: { addListener: () => {} }, + create: () => {}, + clear: () => {} + }, + scripting: { + executeScript: (details, callback) => { + const promise = Promise.resolve().then(() => executeScriptImpl(details)); + if (callback) { + promise.then(callback); + } + return promise; + } + } +}; + +require('../utils.js'); + +const { + jobs, + waitForTabResponse, + sendPromptToSpecificTab, + restoreDurableJobs, + getDurableJobsState, + completeCurrentCommand, + pauseJob, + handleStartSequence, + classifyQueueFailure, + QUEUE_WAIT_POLICY, + QUEUE_RETRY_POLICY +} = require('../background.js'); + +const FIXTURE_COMMANDS = [ + '#25 first topic', + '#26 second topic', + '#27 third topic', + '#28 fourth topic', + '#29 fifth topic', + '#30 sixth topic', + '#31 seventh topic', + '#32 eighth topic', + '#33 ninth topic' +]; + +function muteConsole() { + const original = { + log: console.log, + warn: console.warn, + error: console.error, + info: console.info + }; + console.log = () => {}; + console.warn = () => {}; + console.error = () => {}; + console.info = () => {}; + return () => { + console.log = original.log; + console.warn = original.warn; + console.error = original.error; + console.info = original.info; + }; +} + +async function waitUntil(predicate, timeoutMs = 4000) { + const startedAt = Date.now(); + while (!predicate()) { + if (Date.now() - startedAt > timeoutMs) { + assert.fail('Timed out waiting for queue state.'); + } + await new Promise(resolve => { setTimeout(resolve, 10); }); + } +} + +function withWaitPolicy(overrides, fn) { + const original = { ...QUEUE_WAIT_POLICY }; + Object.assign(QUEUE_WAIT_POLICY, overrides); + const finish = async () => { + try { + return await fn(); + } finally { + Object.assign(QUEUE_WAIT_POLICY, original); + } + }; + return finish(); +} + +function createJob(tabId, overrides = {}) { + return { + tabId, + provider: 'chatgpt', + conversationId: `issue-43-${tabId}`, + conversationType: 'existing', + targetKey: `chatgpt:c:issue-43-${tabId}`, + queue: ['next-command'], + currentMessage: 'current-command', + isRunning: true, + isPaused: false, + isStopped: false, + pausedReason: '', + lastError: '', + runId: `run-${tabId}`, + totalMessages: 2, + completedCount: 0, + currentCommandNumber: 1, + currentPhase: 'awaiting-response', + waitForIdleBeforeSend: false, + deliveryTimeoutAttempts: 0, + retryAttemptCount: 0, + lastRetryableReason: '', + retryClass: '', + retryMode: '', + nextRetryDelayMs: 0, + nextRetryAt: 0, + retryExhausted: false, + waitStartedAt: 0, + lastResearchProgressAt: 0, + sawDeepResearch: false, + sawGenerating: false, + deliveryState: 'confirmed-submission', + commandId: `run-${tabId}:1`, + commandFingerprint: 'fnv1a:test:len:16', + submittedUserTurnId: 'user-1', + assistantTurnId: null, + submissionAckSource: 'content-script', + terminalAckSource: '', + lastResponsePhase: '', + startedAt: Date.now(), + updatedAt: Date.now(), + ...overrides + }; +} + +function generationState(overrides = {}) { + return { + generating: false, + hasError: false, + hasTryAgainButton: false, + hasDeliveryTimedOut: false, + deepResearchActive: false, + researchStatusPreview: '', + matchedError: '', + ...overrides + }; +} + +function responseState(overrides = {}) { + return { + phase: 'transient-idle', + userTurnId: 'user-1', + assistantTurnId: null, + conversationId: 'issue-43', + generating: false, + deepResearchActive: false, + hasCompletedAssistant: false, + source: 'transient-idle', + ...overrides + }; +} + +function installTab(tabId, handler) { + mockTabs.set(tabId, { + id: tabId, + url: `https://chatgpt.com/c/issue-43-${tabId}`, + onMessage: handler + }); +} + +function stopJob(tabId) { + const job = jobs.get(tabId); + if (job) { + job.isStopped = true; + job.isRunning = false; + } + jobs.delete(tabId); + mockTabs.delete(tabId); +} + +test('background source removes the assumed-complete wait path', () => { + const source = fs.readFileSync(path.join(__dirname, '..', 'background.js'), 'utf8'); + assert.equal(source.includes('assuming it completed'), false); + assert.equal(source.includes('assumedCompleteWithoutGeneratingIndicator'), false); +}); + +test('click alone never acknowledges submission', async () => { + const restoreConsole = muteConsole(); + const tabId = 4301; + await withWaitPolicy({ submissionAckTimeoutMs: 40, submissionAckPollMs: 10 }, async () => { + executeScriptImpl = async () => [{ result: { ok: true, details: { sendButtonSelector: 'button[data-testid="send-button"]' } } }]; + installTab(tabId, (message) => { + if (message.type === 'GET_COMMAND_TURN_SNAPSHOT') { + return { ok: true, snapshot: { userTurns: [], latestUserTurnId: null, matchedUserTurnId: null } }; + } + return { ok: true }; + }); + const job = createJob(tabId, { + currentPhase: 'sending', + deliveryState: 'pre-click', + submittedUserTurnId: null + }); + jobs.set(tabId, job); + + const sent = await sendPromptToSpecificTab(tabId, '#25 first topic'); + assert.equal(sent.ok, false); + assert.match(sent.error, /not acknowledged as a new user turn/i); + assert.equal(sent.details.failureClass, 'submission-unconfirmed'); + assert.equal(sent.details.clickOnly, true); + assert.equal(job.completedCount, 0); + assert.equal(job.currentMessage, 'current-command'); + assert.equal(JSON.stringify(sent.details).includes('#25 first topic'), false); + }); + stopJob(tabId); + restoreConsole(); +}); + +test('ignored click does not skip the prompt or increment completedCount', async () => { + const restoreConsole = muteConsole(); + const tabId = 4302; + const originalRetry = { ...QUEUE_RETRY_POLICY }; + Object.assign(QUEUE_RETRY_POLICY, { backoffBaseMs: 10, backoffMaxMs: 15, sleepSliceMs: 5, maxAutomaticAttempts: 1 }); + + await withWaitPolicy({ + submissionAckTimeoutMs: 30, + submissionAckPollMs: 10, + checkIntervalMs: 15 + }, async () => { + executeScriptImpl = async () => [{ result: { ok: true, details: {} } }]; + installTab(tabId, (message) => { + if (message.type === 'GET_COMMAND_TURN_SNAPSHOT') { + return { ok: true, snapshot: { userTurns: [], latestUserTurnId: null, matchedUserTurnId: null } }; + } + if (message.type === 'CHECK_GENERATION_STATE') { + return { state: generationState(), responseState: responseState({ phase: 'transient-idle' }) }; + } + return { ok: true }; + }); + + let response = null; + handleStartSequence({ + tabId, + messages: ['#25 first topic', '#26 second topic'] + }, (result) => { response = result; }); + + await waitUntil(() => response?.ok && jobs.has(tabId)); + await waitUntil(() => { + const job = jobs.get(tabId); + return job && (job.isPaused || job.completedCount > 0); + }, 3000); + + const job = jobs.get(tabId); + assert.ok(job); + assert.equal(job.completedCount, 0); + assert.equal(job.queue.includes('#25 first topic') || job.currentMessage === '#25 first topic', true); + assert.equal(job.queue.includes('#26 second topic'), true); + assert.notEqual(job.currentPhase, 'queued'); + stopJob(tabId); + }); + + Object.assign(QUEUE_RETRY_POLICY, originalRetry); + restoreConsole(); +}); + +test('transient idle after generating does not complete a bound command', async () => { + const restoreConsole = muteConsole(); + const tabId = 4303; + const job = createJob(tabId); + jobs.set(tabId, job); + let polls = 0; + installTab(tabId, (message) => { + if (message.type === 'CHECK_GENERATION_STATE') { + polls += 1; + if (polls === 1) { + return { + state: generationState({ generating: true }), + responseState: responseState({ phase: 'active', generating: true }) + }; + } + return { + state: generationState(), + responseState: responseState({ phase: 'transient-idle' }) + }; + } + return { ok: true }; + }); + + const result = await waitForTabResponse(tabId, { + commandNumber: 1, + totalMessages: 1, + maxWaitMs: 90, + checkIntervalMs: 15, + terminalConfirmSamples: 2, + commandBinding: { userTurnId: 'user-1', commandFingerprint: job.commandFingerprint } + }); + + assert.equal(result.ok, false); + assert.equal(result.details.assumedCompleteWithoutGeneratingIndicator, undefined); + assert.equal(job.completedCount, 0); + stopJob(tabId); + restoreConsole(); +}); + +test('multi-phase generating-idle-research-generating-terminal stays on the current command', async () => { + const restoreConsole = muteConsole(); + const tabId = 4304; + const job = createJob(tabId); + jobs.set(tabId, job); + const startedAt = Date.now(); + installTab(tabId, (message) => { + if (message.type === 'CHECK_GENERATION_STATE') { + const elapsed = Date.now() - startedAt; + if (elapsed < 40) { + return { state: generationState({ generating: true }), responseState: responseState({ phase: 'active' }) }; + } + if (elapsed < 80) { + return { state: generationState(), responseState: responseState({ phase: 'transient-idle' }) }; + } + if (elapsed < 120) { + return { + state: generationState({ generating: true, deepResearchActive: true, researchStatusPreview: 'Deep research is searching sources' }), + responseState: responseState({ phase: 'active', deepResearchActive: true, source: 'deep-research' }) + }; + } + if (elapsed < 160) { + return { state: generationState({ generating: true }), responseState: responseState({ phase: 'active' }) }; + } + return { + state: generationState(), + responseState: responseState({ + phase: 'terminal', + assistantTurnId: 'asst-1', + hasCompletedAssistant: true, + source: 'bound-assistant-turn' + }) + }; + } + return { ok: true }; + }); + + const result = await waitForTabResponse(tabId, { + commandNumber: 1, + totalMessages: 1, + maxWaitMs: 500, + checkIntervalMs: 15, + terminalConfirmSamples: 2, + commandBinding: { userTurnId: 'user-1', commandFingerprint: job.commandFingerprint } + }); + + assert.equal(result.ok, true); + assert.equal(result.details.terminalAckSource, 'bound-assistant-turn'); + assert.equal(result.details.assistantTurnId, 'asst-1'); + assert.equal(job.completedCount, 0); + assert.ok(result.details.elapsedMs >= 160); + stopJob(tabId); + restoreConsole(); +}); + +test('terminal confirmation is tied to the current command turn', async () => { + const restoreConsole = muteConsole(); + const tabId = 4305; + const job = createJob(tabId, { submittedUserTurnId: 'user-25' }); + jobs.set(tabId, job); + installTab(tabId, (message) => { + if (message.type === 'CHECK_GENERATION_STATE') { + return { + state: generationState(), + responseState: responseState({ + phase: 'terminal', + userTurnId: 'user-25', + assistantTurnId: 'asst-25', + hasCompletedAssistant: true, + source: 'bound-assistant-turn' + }) + }; + } + return { ok: true }; + }); + + const result = await waitForTabResponse(tabId, { + commandNumber: 1, + totalMessages: 1, + maxWaitMs: 120, + checkIntervalMs: 15, + terminalConfirmSamples: 2, + commandBinding: { userTurnId: 'user-25', commandFingerprint: 'fp-25' } + }); + + assert.equal(result.ok, true); + assert.equal(result.details.userTurnId, 'user-25'); + assert.equal(result.details.assistantTurnId, 'asst-25'); + assert.equal(JSON.stringify(result.details).includes('#25'), false); + stopJob(tabId); + restoreConsole(); +}); + +test('wait for existing generation treats transient idle as idle without completing a command', async () => { + const restoreConsole = muteConsole(); + const tabId = 4307; + const job = createJob(tabId, { + currentPhase: 'waiting-for-idle', + waitForIdleBeforeSend: true, + currentMessage: null, + queue: ['#25 first topic'], + deliveryState: '', + submittedUserTurnId: null, + commandFingerprint: '' + }); + jobs.set(tabId, job); + installTab(tabId, (message) => { + if (message.type === 'CHECK_GENERATION_STATE') { + return { + state: generationState(), + responseState: responseState({ phase: 'transient-idle', userTurnId: null, source: 'transient-idle' }) + }; + } + return { ok: true }; + }); + + const result = await waitForTabResponse(tabId, { + waitForExistingGeneration: true, + maxWaitMs: 120, + checkIntervalMs: 15, + terminalConfirmSamples: 1 + }); + + assert.equal(result.ok, true); + assert.equal(result.details.waitedForExistingGeneration, true); + assert.equal(job.completedCount, 0); + assert.equal(job.currentPhase, 'waiting-for-idle'); + stopJob(tabId); + restoreConsole(); +}); + +test('delayed generation after accepted submission does not skip or duplicate', async () => { + const restoreConsole = muteConsole(); + const tabId = 4306; + const job = createJob(tabId); + jobs.set(tabId, job); + const startedAt = Date.now(); + installTab(tabId, (message) => { + if (message.type === 'CHECK_GENERATION_STATE') { + const elapsed = Date.now() - startedAt; + if (elapsed < 70) { + return { state: generationState(), responseState: responseState({ phase: 'transient-idle' }) }; + } + if (elapsed < 110) { + return { state: generationState({ generating: true }), responseState: responseState({ phase: 'active' }) }; + } + return { + state: generationState(), + responseState: responseState({ + phase: 'terminal', + assistantTurnId: 'asst-delayed', + hasCompletedAssistant: true, + source: 'bound-assistant-turn' + }) + }; + } + return { ok: true }; + }); + + const result = await waitForTabResponse(tabId, { + commandNumber: 1, + totalMessages: 1, + maxWaitMs: 400, + checkIntervalMs: 15, + terminalConfirmSamples: 2, + commandBinding: { userTurnId: 'user-1', commandFingerprint: job.commandFingerprint } + }); + + assert.equal(result.ok, true); + assert.equal(result.details.assumedCompleteWithoutGeneratingIndicator, undefined); + assert.equal(job.queue.filter(message => message === 'current-command').length, 0); + assert.equal(job.currentMessage, 'current-command'); + stopJob(tabId); + restoreConsole(); +}); + +test('error retry stop interrupted and waiting-for-user never complete as success', async () => { + const restoreConsole = muteConsole(); + const cases = [ + { tabId: 4310, responseState: responseState({ phase: 'error', source: 'generation-error' }), state: generationState({ hasError: true }), class: 'generation-error' }, + { tabId: 4311, responseState: responseState({ phase: 'error', source: 'retry-visible' }), state: generationState({ hasTryAgainButton: true, hasError: true }), class: 'retry-visible' }, + { tabId: 4312, responseState: responseState({ phase: 'interrupted', source: 'interrupted' }), state: generationState(), class: 'interrupted' }, + { tabId: 4313, responseState: responseState({ phase: 'waiting-for-user', source: 'waiting-for-user' }), state: generationState(), class: 'waiting-for-user' } + ]; + + for (const testCase of cases) { + const job = createJob(testCase.tabId); + jobs.set(testCase.tabId, job); + installTab(testCase.tabId, (message) => { + if (message.type === 'CHECK_GENERATION_STATE') { + return { state: testCase.state, responseState: testCase.responseState }; + } + return { ok: true }; + }); + const result = await waitForTabResponse(testCase.tabId, { + commandNumber: 1, + totalMessages: 1, + maxWaitMs: 80, + checkIntervalMs: 15, + commandBinding: { userTurnId: 'user-1' } + }); + assert.equal(result.ok, false, testCase.class); + assert.equal(job.completedCount, 0, testCase.class); + const classified = classifyQueueFailure('wait', result.error, result.details); + assert.equal(classified.class, testCase.class); + if (testCase.class === 'waiting-for-user' || testCase.class === 'interrupted') { + assert.equal(classified.retryable, false); + } + stopJob(testCase.tabId); + } + + const stoppedId = 4314; + const stoppedJob = createJob(stoppedId); + jobs.set(stoppedId, stoppedJob); + installTab(stoppedId, (message) => { + if (message.type === 'CHECK_GENERATION_STATE') { + return { state: generationState({ generating: true }), responseState: responseState({ phase: 'active' }) }; + } + return { ok: true }; + }); + const waitPromise = waitForTabResponse(stoppedId, { + commandNumber: 1, + totalMessages: 1, + maxWaitMs: 400, + checkIntervalMs: 15, + commandBinding: { userTurnId: 'user-1' } + }); + stoppedJob.isStopped = true; + stoppedJob.isRunning = false; + const stopped = await waitPromise; + assert.equal(stopped.ok, false); + assert.equal(stoppedJob.completedCount, 0); + stopJob(stoppedId); + restoreConsole(); +}); + +test('durable recovery distinguishes unconfirmed send, confirmed submission, active response, and terminal', () => { + jobs.clear(); + const unconfirmed = restoreDurableJobs({ + 4320: { + tabId: 4320, + provider: 'chatgpt', + conversationId: 'unconfirmed', + conversationType: 'existing', + queue: ['#26 second topic'], + currentMessage: '#25 first topic', + isRunning: true, + currentPhase: 'awaiting-submission-ack', + deliveryState: 'unknown-acceptance', + currentCommandNumber: 1, + completedCount: 0 + } + })[0]; + assert.deepEqual(unconfirmed.queue[0], '#25 first topic'); + assert.equal(unconfirmed.currentMessage, null); + assert.equal(unconfirmed.currentPhase, 'queued'); + assert.equal(unconfirmed.completedCount, 0); + + jobs.clear(); + const confirmed = restoreDurableJobs({ + 4321: { + tabId: 4321, + provider: 'chatgpt', + conversationId: 'confirmed', + conversationType: 'existing', + queue: ['#26 second topic'], + currentMessage: '#25 first topic', + isRunning: true, + currentPhase: 'awaiting-response', + deliveryState: 'confirmed-submission', + submittedUserTurnId: 'user-25', + commandId: 'run:1', + currentCommandNumber: 1, + completedCount: 0 + } + })[0]; + assert.equal(confirmed.currentMessage, '#25 first topic'); + assert.equal(confirmed.queue[0], '#26 second topic'); + assert.equal(confirmed.currentPhase, 'awaiting-response'); + assert.equal(confirmed.submittedUserTurnId, 'user-25'); + + jobs.clear(); + const active = restoreDurableJobs({ + 4322: { + tabId: 4322, + provider: 'chatgpt', + conversationId: 'active', + conversationType: 'existing', + queue: ['#26 second topic'], + currentMessage: '#25 first topic', + isRunning: true, + currentPhase: 'sending', + deliveryState: 'active-response', + submittedUserTurnId: 'user-25', + currentCommandNumber: 1, + completedCount: 0 + } + })[0]; + assert.equal(active.currentMessage, '#25 first topic'); + assert.equal(active.currentPhase, 'awaiting-response'); + assert.equal(active.queue.includes('#25 first topic'), false); + + jobs.clear(); + const terminal = restoreDurableJobs({ + 4323: { + tabId: 4323, + provider: 'chatgpt', + conversationId: 'terminal', + conversationType: 'existing', + queue: ['#26 second topic'], + currentMessage: '#25 first topic', + isRunning: true, + currentPhase: 'terminal', + deliveryState: 'terminal-awaiting-bookkeeping', + submittedUserTurnId: 'user-25', + assistantTurnId: 'asst-25', + terminalAckSource: 'bound-assistant-turn', + currentCommandNumber: 1, + completedCount: 0 + } + })[0]; + assert.equal(terminal.currentPhase, 'terminal'); + assert.equal(terminal.currentMessage, '#25 first topic'); + completeCurrentCommand(4323, terminal, 2, { terminalAckSource: 'bound-assistant-turn' }); + assert.equal(terminal.completedCount, 1); + assert.equal(terminal.currentMessage, null); + assert.equal(terminal.queue[0], '#26 second topic'); + jobs.clear(); +}); + +test('pause after confirmed submission keeps the command instead of duplicating it', () => { + const restoreConsole = muteConsole(); + const tabId = 4324; + const job = createJob(tabId, { queue: ['#26 second topic'], currentMessage: '#25 first topic' }); + jobs.set(tabId, job); + pauseJob(tabId, 'The ChatGPT response was interrupted.', { failureClass: 'interrupted' }); + assert.equal(job.currentMessage, '#25 first topic'); + assert.deepEqual(job.queue, ['#26 second topic']); + assert.equal(job.completedCount, 0); + jobs.clear(); + restoreConsole(); +}); + +test('durable snapshots persist delivery metadata without prompt text', () => { + const tabId = 4325; + const job = createJob(tabId, { currentMessage: '#25 secret prompt text' }); + jobs.set(tabId, job); + const durable = getDurableJobsState(); + assert.equal(durable[tabId].deliveryState, 'confirmed-submission'); + assert.equal(durable[tabId].submittedUserTurnId, 'user-1'); + assert.equal(durable[tabId].commandId, job.commandId); + assert.equal(JSON.stringify(durable[tabId].deliveryState).includes('secret prompt'), false); + jobs.clear(); +}); + +test('topics 25-33 create one confirmed user turn each and preserve order', async () => { + const restoreConsole = muteConsole(); + const tabId = 4333; + const originalRetry = { ...QUEUE_RETRY_POLICY }; + Object.assign(QUEUE_RETRY_POLICY, { backoffBaseMs: 10, backoffMaxMs: 15, sleepSliceMs: 5 }); + + await withWaitPolicy({ + submissionAckTimeoutMs: 200, + submissionAckPollMs: 10, + checkIntervalMs: 15, + terminalConfirmSamples: 2, + interCommandDelayMs: 0 + }, async () => { + const userTurns = []; + const sendStartedAt = []; + let current = null; + + executeScriptImpl = async (details) => { + const text = details?.args?.[0] || ''; + if (current && current.phase !== 'terminal') { + assert.fail(`Command ${userTurns.length + 1} started before the previous command was terminal`); + } + current = { + text, + userTurnId: `user-${userTurns.length + 1}`, + assistantTurnId: `asst-${userTurns.length + 1}`, + phase: 'active', + acceptedAt: Date.now() + }; + sendStartedAt.push(Date.now()); + userTurns.push({ turnId: current.userTurnId, fingerprint: `fp-${userTurns.length}`, matchedExpected: true }); + return [{ result: { ok: true, details: { sendButtonSelector: 'button[data-testid="send-button"]' } } }]; + }; + + installTab(tabId, (message) => { + if (message.type === 'GET_COMMAND_TURN_SNAPSHOT') { + return { + ok: true, + snapshot: { + userTurns: userTurns.map(turn => ({ ...turn })), + latestUserTurnId: userTurns.at(-1)?.turnId || null, + matchedUserTurnId: userTurns.find(turn => turn.matchedExpected && turn.turnId === current?.userTurnId)?.turnId || userTurns.at(-1)?.turnId || null, + conversationId: 'issue-43-fixture' + } + }; + } + if (message.type === 'CHECK_GENERATION_STATE') { + if (!current) { + return { state: generationState(), responseState: responseState({ phase: 'transient-idle', userTurnId: null }) }; + } + const elapsed = Date.now() - current.acceptedAt; + if (elapsed < 25) { + current.phase = 'active'; + return { + state: generationState({ generating: true }), + responseState: responseState({ phase: 'active', userTurnId: current.userTurnId }) + }; + } + if (elapsed < 45) { + current.phase = 'transient-idle'; + return { + state: generationState(), + responseState: responseState({ phase: 'transient-idle', userTurnId: current.userTurnId }) + }; + } + current.phase = 'terminal'; + return { + state: generationState(), + responseState: responseState({ + phase: 'terminal', + userTurnId: current.userTurnId, + assistantTurnId: current.assistantTurnId, + hasCompletedAssistant: true, + source: 'bound-assistant-turn' + }) + }; + } + return { ok: true }; + }); + + let response = null; + handleStartSequence({ + tabId, + messages: FIXTURE_COMMANDS + }, (result) => { response = result; }); + + await waitUntil(() => response?.ok && jobs.has(tabId)); + await waitUntil(() => !jobs.has(tabId), 8000); + + assert.equal(userTurns.length, FIXTURE_COMMANDS.length); + assert.equal(new Set(userTurns.map(turn => turn.turnId)).size, FIXTURE_COMMANDS.length); + for (let index = 1; index < sendStartedAt.length; index += 1) { + assert.ok(sendStartedAt[index] >= sendStartedAt[index - 1]); + } + }); + + Object.assign(QUEUE_RETRY_POLICY, originalRetry); + stopJob(tabId); + restoreConsole(); +}); diff --git a/test/provider-adapter.test.js b/test/provider-adapter.test.js index 57853be..7f19d86 100644 --- a/test/provider-adapter.test.js +++ b/test/provider-adapter.test.js @@ -482,6 +482,8 @@ test('popup queue starts wait for idle while active-queue append ordering stays assert.equal(enqueueJob.currentPhase, 'waiting-for-idle'); assert.equal(enqueueJob.waitForIdleBeforeSend, true); assert.deepEqual(enqueueJob.queue, ['popup next']); + enqueueJob.isStopped = true; + enqueueJob.isRunning = false; jobs.clear(); @@ -506,6 +508,8 @@ test('popup queue starts wait for idle while active-queue append ordering stays assert.equal(sequenceJob.currentPhase, 'waiting-for-idle'); assert.equal(sequenceJob.waitForIdleBeforeSend, true); assert.deepEqual(sequenceJob.queue, ['sequence first', 'sequence second']); + sequenceJob.isStopped = true; + sequenceJob.isRunning = false; jobs.clear(); @@ -839,7 +843,32 @@ test('Queued ChatGPT send uses the canonical composer and reports compatibility canonicalDoc.appendChild(composer); canonicalDoc.appendChild(sendButton); global.document = canonicalDoc; - mockTabs.set(901, { id: 901, url: global.location.href }); + mockTabs.set(901, { + id: 901, + url: global.location.href, + onMessage: (message) => { + if (message.type === 'GET_COMMAND_TURN_SNAPSHOT') { + const chatgpt = getProvider('chatgpt'); + return { + ok: true, + snapshot: chatgpt.getCommandTurnSnapshot(canonicalDoc, { + expectedText: message.expectedText + }) + }; + } + return { ok: true }; + } + }); + + sendButton.click = function clickAndAccept() { + this.clicked = true; + const userTurn = new MockTestElement('article', { + 'data-testid': 'conversation-turn-1', + 'data-message-author-role': 'user', + 'data-message-id': 'user-turn-queued' + }, 'queued prompt'); + canonicalDoc.appendChild(userTurn); + }; const sent = await sendPromptToSpecificTab(901, 'queued prompt'); assert.equal(sent.ok, true); @@ -1359,3 +1388,58 @@ test('waitForTabResponse keeps Deep Research finite unless unlimited retry is en jobs.clear(); }); +test('ChatGPT command turn snapshot matches a new user turn without returning prompt text', () => { + const chatgpt = getProvider('chatgpt'); + const user = new MockTestElement('article', { + 'data-testid': 'conversation-turn-1', + 'data-message-author-role': 'user', + 'data-message-id': 'user-25' + }, '#25 fixture topic'); + const assistant = new MockTestElement('article', { + 'data-testid': 'conversation-turn-2', + 'data-message-author-role': 'assistant', + 'data-message-id': 'asst-25' + }, 'A completed assistant answer for topic 25.'); + const { doc } = createTestDoc({ turns: [user, assistant] }); + doc.defaultView = { location: { href: 'https://chatgpt.com/c/issue-43' } }; + + const snapshot = chatgpt.getCommandTurnSnapshot(doc, { expectedText: '#25 fixture topic' }); + assert.equal(snapshot.matchedUserTurnId, 'user-25'); + assert.equal(snapshot.latestUserTurnId, 'user-25'); + assert.equal(snapshot.latestAssistantTurnId, 'asst-25'); + assert.equal(snapshot.userTurns[0].matchedExpected, true); + assert.equal(JSON.stringify(snapshot).includes('#25 fixture topic'), false); + assert.equal(JSON.stringify(snapshot).includes('completed assistant answer'), false); + + const terminal = chatgpt.getCommandResponseState(doc, { userTurnId: 'user-25' }); + assert.equal(terminal.phase, 'terminal'); + assert.equal(terminal.assistantTurnId, 'asst-25'); + assert.equal(terminal.source, 'bound-assistant-turn'); + assert.equal(JSON.stringify(terminal).includes('#25 fixture topic'), false); +}); + +test('ChatGPT command response state treats idle gaps as transient until the bound assistant turn completes', () => { + const chatgpt = getProvider('chatgpt'); + const user = new MockTestElement('article', { + 'data-testid': 'conversation-turn-1', + 'data-message-author-role': 'user', + 'data-message-id': 'user-26' + }, '#26 fixture topic'); + const { doc } = createTestDoc({ turns: [user] }); + const idle = chatgpt.getCommandResponseState(doc, { userTurnId: 'user-26' }); + assert.equal(idle.phase, 'transient-idle'); + + const streamingAssistant = new MockTestElement('article', { + 'data-testid': 'conversation-turn-2', + 'data-message-author-role': 'assistant', + 'data-message-id': 'asst-26', + 'data-message-streaming': 'true' + }, 'partial'); + const { doc: streamingDoc } = createTestDoc({ + turns: [user, streamingAssistant], + extraNodes: [new MockTestElement('button', { 'data-testid': 'stop-button' })] + }); + const active = chatgpt.getCommandResponseState(streamingDoc, { userTurnId: 'user-26' }); + assert.equal(active.phase, 'active'); +}); + diff --git a/types/extension.d.ts b/types/extension.d.ts index f851980..91e96b9 100644 --- a/types/extension.d.ts +++ b/types/extension.d.ts @@ -4,11 +4,23 @@ type QueueJobPhase = | 'waiting' | 'waiting-for-idle' | 'sending' + | 'awaiting-submission-ack' + | 'awaiting-response' + | 'terminal' | 'retry-wait' | 'paused' | 'complete' | string; +type QueueDeliveryState = + | 'pre-click' + | 'unknown-acceptance' + | 'confirmed-submission' + | 'active-response' + | 'terminal-awaiting-bookkeeping' + | '' + | string; + type QueueFailureClass = | 'transient' | 'generation-error' @@ -18,6 +30,9 @@ type QueueFailureClass = | 'compatibility' | 'user-stop' | 'non-retryable' + | 'submission-unconfirmed' + | 'waiting-for-user' + | 'interrupted' | string; type QueueRetryMode = 'finite' | 'unlimited' | '' | string; @@ -52,6 +67,10 @@ interface QueueWaitPolicy { deepResearchMaxWaitMs: number; deepResearchStaleMs: number; checkIntervalMs: number; + submissionAckTimeoutMs?: number; + submissionAckPollMs?: number; + terminalConfirmSamples?: number; + interCommandDelayMs?: number; } interface QueueJob { @@ -86,6 +105,14 @@ interface QueueJob { lastResearchProgressAt?: number; sawDeepResearch?: boolean; sawGenerating?: boolean; + deliveryState?: QueueDeliveryState; + commandId?: string; + commandFingerprint?: string; + submittedUserTurnId?: string | null; + assistantTurnId?: string | null; + submissionAckSource?: string; + terminalAckSource?: string; + lastResponsePhase?: string; startedAt: number; updatedAt: number; } @@ -122,6 +149,14 @@ interface DurableQueueJob { lastResearchProgressAt?: number; sawDeepResearch?: boolean; sawGenerating?: boolean; + deliveryState?: QueueDeliveryState; + commandId?: string; + commandFingerprint?: string; + submittedUserTurnId?: string | null; + assistantTurnId?: string | null; + submissionAckSource?: string; + terminalAckSource?: string; + lastResponsePhase?: string; startedAt: number; updatedAt: number; } @@ -163,6 +198,14 @@ interface RunningJobSnapshot { lastResearchProgressAt?: number; sawDeepResearch?: boolean; sawGenerating?: boolean; + deliveryState?: QueueDeliveryState; + commandId?: string; + commandFingerprint?: string; + submittedUserTurnId?: string | null; + assistantTurnId?: string | null; + submissionAckSource?: string; + terminalAckSource?: string; + lastResponsePhase?: string; startedAt: number; updatedAt: number; } @@ -293,6 +336,11 @@ interface ProviderAdapterContract { getCompatibilityContract?: (...args: any[]) => any; getConversationIdentity?: (...args: any[]) => ConversationIdentity; getGenerationState?: (...args: any[]) => GenerationState; + getCommandTurnSnapshot?: (...args: any[]) => any; + getCommandResponseState?: (...args: any[]) => any; + fingerprintCommandText?: (...args: any[]) => string; + normalizeCommandText?: (...args: any[]) => string; + supportsCommandTurnAck?: boolean; getComposerFromEventTarget?: (...args: any[]) => any; getComposerMatchFromEventTarget?: (...args: any[]) => any; getSendActionFromEventTarget?: (...args: any[]) => any;