diff --git a/background.js b/background.js index fde4694..1e87c3f 100644 --- a/background.js +++ b/background.js @@ -121,6 +121,31 @@ async function validateJobTargetConversation(tabId, job) { job.provider = currentIdentity.provider || 'chatgpt'; } + if (job.rolloverInProgress) { + const adapter = getActiveProviderAdapter(currentIdentity.provider || job.provider || 'chatgpt') || getActiveProviderAdapter('chatgpt'); + const fromId = job.rolloverFromConversationId || null; + if (adapter && typeof adapter.isFreshConversationIdentity === 'function' && adapter.isFreshConversationIdentity(currentIdentity, fromId)) { + if (!job.rolloverRebindApplied) { + applyAuthorizedConversationRebind(job, currentIdentity); + await updateRunningJobsStorage({ force: true }); + } else if (job.conversationType === 'new' && currentIdentity.type === 'existing' && currentIdentity.conversationId) { + job.conversationId = currentIdentity.conversationId; + job.conversationType = 'existing'; + job.targetKey = currentIdentity.key; + job.rolloverToConversationId = currentIdentity.conversationId; + job.updatedAt = Date.now(); + await updateRunningJobsStorage({ force: true }); + } + return { ok: true, currentIdentity, rolloverRebind: true }; + } + return { + ok: false, + awaitingRollover: true, + reason: 'Rollover in progress; waiting for a verified new ChatGPT conversation before queue delivery resumes.', + currentIdentity + }; + } + if (currentIdentity.provider && job.provider && currentIdentity.provider !== 'unknown' && currentIdentity.provider !== job.provider) { return { ok: false, @@ -194,6 +219,316 @@ async function validateJobTargetConversation(tabId, job) { return { ok: true, currentIdentity }; } +function emptyRolloverFields() { + return { + rolloverInProgress: false, + rolloverStage: '', + rolloverReason: '', + rolloverFromConversationId: null, + rolloverToConversationId: null, + rolloverNavigationStarted: false, + rolloverRebindApplied: false, + handoffSubmitted: false, + handoffEstablished: false, + handoffFingerprint: '', + conversationGeneration: 0 + }; +} + +function copyRolloverFields(source = {}) { + return { + rolloverInProgress: source.rolloverInProgress === true, + rolloverStage: String(source.rolloverStage || ''), + rolloverReason: String(source.rolloverReason || ''), + rolloverFromConversationId: source.rolloverFromConversationId || null, + rolloverToConversationId: source.rolloverToConversationId || null, + rolloverNavigationStarted: source.rolloverNavigationStarted === true, + rolloverRebindApplied: source.rolloverRebindApplied === true, + handoffSubmitted: source.handoffSubmitted === true, + handoffEstablished: source.handoffEstablished === true, + handoffFingerprint: String(source.handoffFingerprint || ''), + conversationGeneration: Number(source.conversationGeneration || 0) + }; +} + +function isConversationCapacityFailure(result) { + if (!result) return false; + const details = result.details && typeof result.details === 'object' ? result.details : {}; + const state = details.state && typeof details.state === 'object' ? details.state : {}; + if (details.failureClass === 'conversation-max-length') return true; + if (details.conversationCapacityReached === true || details.requiresNewConversation === true) return true; + if (state.conversationCapacityReached === true || state.requiresNewConversation === true) return true; + if (result.responseState?.source === 'conversation-max-length' || details.responseState?.source === 'conversation-max-length') { + return true; + } + return /conversation-max-length|maximum length for this conversation/i.test(String(result.error || details.error || '')); +} + +function applyAuthorizedConversationRebind(job, identity) { + if (!job || !identity) return job; + job.provider = identity.provider || job.provider || 'chatgpt'; + job.conversationId = identity.conversationId || null; + job.conversationType = identity.type || (identity.conversationId ? 'existing' : 'new'); + job.targetKey = identity.key || (job.conversationId + ? `${job.provider}:c:${job.conversationId}` + : `${job.provider}:${job.conversationType}`); + job.rolloverToConversationId = job.conversationId; + if (!job.rolloverRebindApplied) { + job.conversationGeneration = Number(job.conversationGeneration || 0) + 1; + job.rolloverRebindApplied = true; + } + job.rolloverStage = 'identity-confirmed'; + job.updatedAt = Date.now(); + return job; +} + +function isVerifiedFreshConversation(adapter, identity, fromConversationId, navigationStarted) { + if (adapter && typeof adapter.isFreshConversationIdentity === 'function' && adapter.isFreshConversationIdentity(identity, fromConversationId)) { + return true; + } + return navigationStarted === true && identity?.provider === 'chatgpt' && identity?.type === 'new'; +} + +async function confirmFreshConversationIdentity(tabId, job, adapter, fromConversationId) { + const deadline = Date.now() + 20000; + const pollMs = Math.max(20, Number(QUEUE_WAIT_POLICY.submissionAckPollMs) || 50); + + while (Date.now() <= deadline) { + if (!jobs.has(tabId) || !job || job.isStopped || job.isPaused || !job.isRunning) { + return { ok: false, interrupted: true, identity: null }; + } + const identity = await resolveTabConversationIdentity(tabId); + if (isVerifiedFreshConversation(adapter, identity, fromConversationId, job.rolloverNavigationStarted)) { + return { ok: true, identity }; + } + await sleep(pollMs); + } + + return { ok: false, interrupted: false, identity: await resolveTabConversationIdentity(tabId) }; +} + +async function handleConversationCapacityRollover(tabId, job, waitResult = {}) { + if (!job || job.isStopped || job.isPaused || !job.isRunning) { + return { action: 'return' }; + } + + const adapter = getActiveProviderAdapter(job.provider || 'chatgpt') || getActiveProviderAdapter('chatgpt'); + const startPlan = adapter && typeof adapter.startNewConversation === 'function' + ? adapter.startNewConversation() + : { ok: false, url: '', method: 'unsupported' }; + const newConversationUrl = startPlan?.url || (typeof adapter?.getNewConversationUrl === 'function' ? adapter.getNewConversationUrl() : ''); + + if (!adapter || typeof adapter.isFreshConversationIdentity !== 'function' || !newConversationUrl) { + pauseJob(tabId, 'Conversation reached maximum length, but automatic rollover is unavailable for this provider.', { + phase: 'rollover', + failureClass: 'conversation-max-length' + }); + return { action: 'return' }; + } + + if (!job.rolloverInProgress) { + job.rolloverInProgress = true; + job.rolloverStage = 'detected'; + job.rolloverReason = 'conversation-max-length'; + job.rolloverFromConversationId = job.conversationId || job.rolloverFromConversationId || null; + job.rolloverToConversationId = null; + job.rolloverNavigationStarted = false; + job.rolloverRebindApplied = false; + job.handoffSubmitted = false; + job.handoffEstablished = false; + job.handoffFingerprint = ''; + job.currentPhase = 'rollover-in-progress'; + job.retryClass = 'conversation-max-length'; + job.updatedAt = Date.now(); + await updateRunningJobsStorage({ force: true }); + logQueueEvent(tabId, 'warn', 'Conversation capacity reached; starting automatic rollover.', { + event: 'conversation-capacity-reached', + runId: job.runId || '', + fromConversationId: job.rolloverFromConversationId, + conversationGeneration: Number(job.conversationGeneration || 0), + commandNumber: job.currentCommandNumber || 0, + completedCount: Number(job.completedCount || 0), + remaining: getRemainingCount(job), + currentMessageLength: String(job.currentMessage || '').length + }); + } else { + job.currentPhase = 'rollover-in-progress'; + job.retryClass = 'conversation-max-length'; + } + + resetCommandDeliveryState(job); + resetCommandRetryState(job); + resetWaitTracking(job); + await updateRunningJobsStorage({ force: true }); + + const fromConversationId = job.rolloverFromConversationId || null; + const identityStages = new Set(['identity-confirmed', 'handoff-sending', 'handoff-waiting', 'handoff-established', 'resumed']); + + if (!identityStages.has(job.rolloverStage) || !job.rolloverRebindApplied) { + const currentIdentity = await resolveTabConversationIdentity(tabId); + const alreadyFresh = isVerifiedFreshConversation(adapter, currentIdentity, fromConversationId, job.rolloverNavigationStarted); + + if (!alreadyFresh) { + if (!job.rolloverNavigationStarted) { + job.rolloverNavigationStarted = true; + job.rolloverStage = 'navigating'; + job.updatedAt = Date.now(); + await updateRunningJobsStorage({ force: true }); + logQueueEvent(tabId, 'info', 'Opening a fresh ChatGPT conversation for rollover.', { + event: 'rollover-started', + runId: job.runId || '', + fromConversationId, + method: startPlan.method || 'route', + conversationGeneration: Number(job.conversationGeneration || 0) + }); + await updateTabUrl(tabId, newConversationUrl); + } + + job.rolloverStage = 'awaiting-new-identity'; + job.updatedAt = Date.now(); + await updateRunningJobsStorage({ force: true }); + + const confirmed = await confirmFreshConversationIdentity(tabId, job, adapter, fromConversationId); + if (confirmed.interrupted || !jobs.has(tabId) || job.isStopped || job.isPaused || !job.isRunning) { + return { action: 'return' }; + } + if (!confirmed.ok) { + pauseJob(tabId, 'Could not confirm a fresh ChatGPT conversation after maximum-length rollover.', { + phase: 'rollover', + failureClass: 'conversation-max-length' + }); + return { action: 'return' }; + } + applyAuthorizedConversationRebind(job, confirmed.identity); + } else if (!job.rolloverRebindApplied) { + applyAuthorizedConversationRebind(job, currentIdentity); + } + + await updateRunningJobsStorage({ force: true }); + logQueueEvent(tabId, 'success', 'Fresh ChatGPT conversation confirmed for rollover.', { + event: 'new-conversation-confirmed', + runId: job.runId || '', + fromConversationId, + toConversationId: job.conversationId, + conversationType: job.conversationType, + conversationGeneration: Number(job.conversationGeneration || 0) + }); + } + + if (!job.handoffEstablished) { + const remainingAfterCurrent = Math.max(0, (Array.isArray(job.queue) ? job.queue.length : 0)); + const handoffText = adapter.buildContinuationHandoffMessage({ + runId: job.runId, + currentCommandNumber: job.currentCommandNumber, + completedCount: job.completedCount, + remainingCount: remainingAfterCurrent, + conversationGeneration: job.conversationGeneration, + pendingMessageLength: String(job.currentMessage || job.queue?.[0] || '').length + }); + job.handoffFingerprint = job.handoffFingerprint || fingerprintCommandTextForJob(handoffText); + + if (!job.handoffSubmitted) { + const inspect = await inspectTabCommandTurns(tabId, { + expectedText: handoffText, + expectedFingerprint: job.handoffFingerprint + }); + const alreadyPresent = !!(inspect?.snapshot?.matchedUserTurnId || + (inspect?.snapshot?.userTurns || []).some(turn => turn.matchedExpected)); + + job.rolloverStage = 'handoff-sending'; + job.updatedAt = Date.now(); + await updateRunningJobsStorage({ force: true }); + + if (!alreadyPresent) { + const sendResult = await sendPromptToSpecificTab(tabId, handoffText); + if (!jobs.has(tabId) || job.isStopped || job.isPaused || !job.isRunning) { + return { action: 'return' }; + } + if (!sendResult.ok) { + pauseJob(tabId, sendResult.error || 'Could not establish continuation handoff in the new conversation.', { + phase: 'rollover-handoff', + failureClass: sendResult.details?.failureClass || 'conversation-max-length' + }); + return { action: 'return' }; + } + } + + job.handoffSubmitted = true; + job.rolloverStage = 'handoff-waiting'; + job.updatedAt = Date.now(); + resetCommandDeliveryState(job); + resetWaitTracking(job); + await updateRunningJobsStorage({ force: true }); + } + + const queueSettings = await getQueueSettings(); + const handoffWait = await waitForTabResponse(tabId, { + waitForExistingGeneration: true, + commandNumber: 0, + totalMessages: getTotalMessages(job), + queueSettings, + maxWaitMs: Math.min(Number(QUEUE_WAIT_POLICY.responseMaxWaitMs) || 60000, 60000), + checkIntervalMs: Number(QUEUE_WAIT_POLICY.checkIntervalMs) || 1000 + }); + if (!jobs.has(tabId) || job.isStopped || job.isPaused || !job.isRunning) { + return { action: 'return' }; + } + if (!handoffWait.ok && !isConversationCapacityFailure(handoffWait)) { + pauseJob(tabId, handoffWait.error || 'Continuation handoff did not complete in the new conversation.', { + phase: 'rollover-handoff', + failureClass: handoffWait.details?.failureClass || 'conversation-max-length' + }); + return { action: 'return' }; + } + + job.handoffEstablished = true; + job.rolloverStage = 'handoff-established'; + job.updatedAt = Date.now(); + await updateRunningJobsStorage({ force: true }); + logQueueEvent(tabId, 'success', 'Continuation handoff established in the new conversation.', { + event: 'handoff-established', + runId: job.runId || '', + conversationGeneration: Number(job.conversationGeneration || 0), + handoffLength: String(handoffText || '').length, + completedCount: Number(job.completedCount || 0) + }); + } + + const preservedCurrent = job.currentMessage || null; + const preservedCommandNumber = Number(job.currentCommandNumber || 0); + if (preservedCurrent) { + job.queue.unshift(preservedCurrent); + job.currentMessage = null; + job.currentCommandNumber = 0; + } + + resetCommandDeliveryState(job); + resetCommandRetryState(job); + resetWaitTracking(job); + job.rolloverInProgress = false; + job.rolloverStage = 'resumed'; + job.rolloverNavigationStarted = false; + job.rolloverRebindApplied = false; + job.handoffSubmitted = false; + job.handoffEstablished = false; + job.handoffFingerprint = ''; + job.currentPhase = 'queued'; + job.lastError = ''; + job.updatedAt = Date.now(); + await updateRunningJobsStorage({ force: true }); + logQueueEvent(tabId, 'success', 'Queue resumed after conversation rollover.', { + event: 'queue-resumed-after-rollover', + runId: job.runId || '', + conversationId: job.conversationId, + conversationGeneration: Number(job.conversationGeneration || 0), + completedCount: Number(job.completedCount || 0), + remaining: getRemainingCount(job), + nextCommandNumber: preservedCommandNumber || (Number(job.completedCount || 0) + 1) + }); + + return { action: 'continue' }; +} + let queueStateWrite = Promise.resolve(); let pendingQueueState = null; let queueStateFlushTimer = null; @@ -533,6 +868,7 @@ function restoreDurableJobs(durableJobs) { submissionAckSource: rawJob.submissionAckSource || '', terminalAckSource: rawJob.terminalAckSource || '', lastResponsePhase: rawJob.lastResponsePhase || '', + ...copyRolloverFields(rawJob), startedAt: Number(rawJob.startedAt || Date.now()), updatedAt: Number(rawJob.updatedAt || Date.now()) }; @@ -771,6 +1107,7 @@ function handleStartSequence(request, sendResponse) { sawDeepResearch: false, sawGenerating: false, ...emptyDeliveryFields(), + ...emptyRolloverFields(), startedAt: Date.now(), updatedAt: Date.now() }); @@ -892,6 +1229,7 @@ async function startNewJobFromEnqueueResult(tabId, message, waitForIdleBeforeSta sawDeepResearch: false, sawGenerating: false, ...emptyDeliveryFields(), + ...emptyRolloverFields(), startedAt: Date.now(), updatedAt: Date.now() }); @@ -1126,6 +1464,12 @@ async function processQueue(tabId) { try { while (job.isRunning && !job.isPaused && !job.isStopped) { + if (job.rolloverInProgress || job.currentPhase === 'rollover-in-progress') { + const result = await handleConversationCapacityRollover(tabId, job); + if (result.action === 'return') return; + if (result.action === 'continue') continue; + } + if (job.currentMessage && job.currentPhase === 'terminal') { completeCurrentCommand(tabId, job, getTotalMessages(job), collectDeliveryDiagnostics(job, { terminalAckSource: job.terminalAckSource || 'durable-recovery' @@ -1167,6 +1511,11 @@ async function processQueue(tabId) { if (job.waitForIdleBeforeSend) { const validation = await validateJobTargetConversation(tabId, job); if (!validation.ok) { + if (validation.awaitingRollover || job.rolloverInProgress) { + const rolloverResult = await handleConversationCapacityRollover(tabId, job); + if (rolloverResult.action === 'return') return; + if (rolloverResult.action === 'continue') continue; + } logQueueEvent(tabId, 'error', validation.reason, { phase: 'pre-send-validation', mismatch: true @@ -1181,6 +1530,11 @@ async function processQueue(tabId) { const validation = await validateJobTargetConversation(tabId, job); if (!validation.ok) { + if (validation.awaitingRollover || job.rolloverInProgress) { + const rolloverResult = await handleConversationCapacityRollover(tabId, job); + if (rolloverResult.action === 'return') return; + if (rolloverResult.action === 'continue') continue; + } logQueueEvent(tabId, 'error', validation.reason, { phase: 'pre-send-validation', mismatch: true @@ -1250,6 +1604,10 @@ async function handleProcessWaiting(tabId, job) { } if (!waitResult.ok) { + if (isConversationCapacityFailure(waitResult)) { + return await handleConversationCapacityRollover(tabId, job, waitResult); + } + if (waitResult.isDeliveryTimeout && queueSettings.queueDeliveryTimeoutRefresh !== false) { const recoveryResult = await recoverFromDeliveryTimeout(tabId, job, waitResult, totalMessages, queueSettings); if (recoveryResult.action === 'complete') { @@ -1424,6 +1782,10 @@ async function handleProcessSending(tabId, job) { } if (!sendResult.ok) { + if (isConversationCapacityFailure(sendResult)) { + return await handleConversationCapacityRollover(tabId, job, sendResult); + } + logQueueEvent(tabId, 'error', `Failed to submit command ${job.currentCommandNumber}/${totalMessages}.`, { commandNumber: job.currentCommandNumber, totalMessages, @@ -1473,6 +1835,10 @@ async function handleProcessSending(tabId, job) { } if (!waitResult.ok) { + if (isConversationCapacityFailure(waitResult)) { + return await handleConversationCapacityRollover(tabId, job, waitResult); + } + if (waitResult.isDeliveryTimeout && queueSettings.queueDeliveryTimeoutRefresh !== false) { const recoveryResult = await recoverFromDeliveryTimeout(tabId, job, waitResult, totalMessages, queueSettings); if (recoveryResult.action === 'complete') { @@ -1719,7 +2085,9 @@ function classifyQueueFailure(phase, reason, diagnostics = {}) { let failureClass = forcedClass; if (!failureClass) { - if (details.stopped === true || /queue was stopped/i.test(message)) { + if (details.conversationCapacityReached === true || details.requiresNewConversation === true || state.conversationCapacityReached === true || state.requiresNewConversation === true || details.responseState?.source === 'conversation-max-length' || /conversation-max-length|maximum length for this conversation/i.test(message)) { + failureClass = 'conversation-max-length'; + } else if (details.stopped === true || /queue was stopped/i.test(message)) { failureClass = 'user-stop'; } else if (details.compatibilityFailure || /compatibility failure/i.test(message)) { failureClass = 'compatibility'; @@ -2894,6 +3262,23 @@ async function waitForTabResponse(tabId, context = {}) { return; } + if (state.conversationCapacityReached || state.requiresNewConversation || responseState?.source === 'conversation-max-length') { + clearInterval(checkInterval); + resolve({ + ok: false, + isDeliveryTimeout: false, + error: 'ChatGPT conversation reached maximum length.', + details: buildWaitDetails({ + failureClass: 'conversation-max-length', + conversationCapacityReached: true, + requiresNewConversation: true, + state, + responseState + }) + }); + return; + } + if (state.hasError || state.hasTryAgainButton || phase === 'error') { clearInterval(checkInterval); resolve({ @@ -3212,6 +3597,7 @@ function getRunningJobsSnapshot() { submissionAckSource: job.submissionAckSource || '', terminalAckSource: job.terminalAckSource || '', lastResponsePhase: job.lastResponsePhase || '', + ...copyRolloverFields(job), startedAt: job.startedAt, updatedAt: job.updatedAt }; @@ -3376,6 +3762,7 @@ function getDurableJobsState() { submissionAckSource: job.submissionAckSource || '', terminalAckSource: job.terminalAckSource || '', lastResponsePhase: job.lastResponsePhase || '', + ...copyRolloverFields(job), startedAt: job.startedAt, updatedAt: job.updatedAt }; @@ -3577,6 +3964,13 @@ function getTab(tabId) { ); } +function updateTabUrl(tabId, url) { + return extensionApiPromise( + (done) => chrome.tabs.update(tabId, { url }, done), + () => chrome.tabs.update(tabId, { url }) + ); +} + function queryTabs(queryInfo) { return extensionApiPromise( (done) => chrome.tabs.query(queryInfo, done), @@ -3685,11 +4079,13 @@ function isSensitiveLogKey(key) { normalized === 'errorsnippet' || normalized === 'researchstatuspreview' || normalized === 'recoveredturnpreview' || - normalized === 'assistantpreview' + normalized === 'assistantpreview' || + normalized === 'handofftext' || + normalized === 'handoff' ) { return true; } - return normalized.includes('preview') || normalized.includes('snippet'); + return normalized.includes('preview') || normalized.includes('snippet') || normalized.includes('handofftext'); } function redactSensitiveLogValue(value) { @@ -4353,6 +4749,9 @@ if (typeof module !== 'undefined' && module.exports) { waitForTabResponse, retryCurrentCommandIfEnabled, classifyQueueFailure, + handleConversationCapacityRollover, + isConversationCapacityFailure, + applyAuthorizedConversationRebind, getRetryBackoffDelayMs, handleRetryPausedJob, completeCurrentCommand, diff --git a/popup.js b/popup.js index 7f3df3c..da38e4a 100644 --- a/popup.js +++ b/popup.js @@ -24,6 +24,10 @@ function getPopupQueueStatusLabel(job = {}) { return 'Retrying'; } + if (['rollover-in-progress', 'rollover', 'conversation-rollover'].includes(phase) || job.rolloverInProgress) { + return 'New conversation'; + } + if (job.waitForIdleBeforeSend || ['waiting-for-idle', 'wait-for-idle', 'waiting_idle'].includes(phase)) { return 'Waiting for idle'; } diff --git a/provider-adapter.js b/provider-adapter.js index bcf0570..3f68f60 100644 --- a/provider-adapter.js +++ b/provider-adapter.js @@ -91,6 +91,11 @@ 'response interrupted', 'generation stopped', 'you stopped this response' + ], + conversationCapacityMarkers: [ + "you've reached the maximum length for this conversation", + 'maximum length for this conversation', + 'keep talking by starting a new chat' ] }; @@ -472,11 +477,38 @@ hasTryAgainButton: false, errorSnippet: '', matchedError: '', + conversationCapacityReached: false, + requiresNewConversation: false, url: '', title: '' }; } + getNewConversationUrl() { + return ''; + } + + startNewConversation() { + const url = this.getNewConversationUrl(); + return { + ok: !!url, + url, + method: url ? 'route' : 'unsupported' + }; + } + + isFreshConversationIdentity(identity, previousConversationId = null) { + return false; + } + + matchConversationCapacity(text) { + return ''; + } + + buildContinuationHandoffMessage(context = {}) { + return ''; + } + getRetryButton(doc) { return null; } @@ -802,6 +834,19 @@ const waitingForUser = (this.compatibilitySignals.waitingForUserMarkers || []).some(marker => statusText.includes(marker)); const interrupted = (this.compatibilitySignals.interruptedMarkers || []).some(marker => statusText.includes(marker)); + if (generation.conversationCapacityReached || generation.requiresNewConversation) { + return { + phase: 'error', + userTurnId, + assistantTurnId: followingAssistant?.turnId || null, + conversationId, + generating: false, + deepResearchActive: false, + hasCompletedAssistant: false, + source: 'conversation-max-length' + }; + } + if (generation.hasError || generation.hasTryAgainButton || generation.hasDeliveryTimedOut) { return { phase: 'error', @@ -1030,6 +1075,64 @@ }; } + getNewConversationUrl() { + return 'https://chatgpt.com/'; + } + + startNewConversation() { + return { + ok: true, + url: this.getNewConversationUrl(), + method: 'route' + }; + } + + isFreshConversationIdentity(identity, previousConversationId = null) { + if (!identity || identity.provider !== 'chatgpt') { + return false; + } + if (identity.type === 'unsupported' || identity.type === 'unknown') { + return false; + } + if (identity.type === 'existing' && identity.conversationId && identity.conversationId !== previousConversationId) { + return true; + } + // A new-chat route is a fresh target only after leaving a known exhausted conversation. + return identity.type === 'new' && !!previousConversationId; + } + + matchConversationCapacity(text) { + const haystack = String(text || '').toLowerCase().replace(/\s+/g, ' ').trim(); + if (!haystack) { + return ''; + } + return (this.compatibilitySignals.conversationCapacityMarkers || []).find(marker => haystack.includes(marker)) || ''; + } + + buildContinuationHandoffMessage(context = {}) { + const runId = String(context.runId || '').replace(/\s+/g, ' ').trim().slice(0, 80); + const commandNumber = Number(context.currentCommandNumber || 0); + const completedCount = Number(context.completedCount || 0); + const remainingCount = Number(context.remainingCount || 0); + const generation = Number(context.conversationGeneration || 0); + const pendingLength = Number(context.pendingMessageLength || 0); + const parts = [ + 'Continuation handoff for an automated ChatGPT Queue Optimizer run.', + 'The previous conversation reached ChatGPT maximum length.', + `Logical run id: ${runId || 'unknown'}.`, + `Conversation segment: ${generation}.`, + `Completed original commands: ${completedCount}.`, + `Current original command number: ${commandNumber}.`, + `Remaining original commands after this one: ${remainingCount}.`, + pendingLength > 0 + ? `The next original queued command is pending (length ${pendingLength}) and must be treated as the next user task.` + : 'Resume the remaining original queued commands in order.', + 'Do not treat this handoff as one of the original queued commands.', + 'Preserve prior workflow constraints and continue in FIFO order.' + ]; + return parts.join(' ').slice(0, 1200); + } + getCurrentSurface(doc = (typeof document !== 'undefined' ? document : null), locationOrUrl = null) { const loc = locationOrUrl || doc?.defaultView?.location || @@ -1118,6 +1221,8 @@ hasTryAgainButton: false, errorSnippet: '', matchedError: '', + conversationCapacityReached: false, + requiresNewConversation: false, statusUnknown: true, compatibilityState: 'unknown', matchedSignals: emptySignals, @@ -1196,16 +1301,20 @@ const matchedDeliveryTimeout = this.compatibilitySignals.deliveryTimeoutMarkers.find(marker => errorSearchText.includes(marker)) || ''; const hasDeliveryTimedOut = !!matchedDeliveryTimeout; - const matchedGeneralError = this.compatibilitySignals.errorMarkers.find(marker => errorSearchText.includes(marker)) || ''; - const matchedError = matchedDeliveryTimeout - ? 'Message delivery timed out. Please try again.' - : matchedGeneralError; + const matchedCapacity = this.matchConversationCapacity(`${errorSearchText} ${activeTextLower}`); + const conversationCapacityReached = !!matchedCapacity; + const matchedGeneralError = conversationCapacityReached + ? '' + : (this.compatibilitySignals.errorMarkers.find(marker => errorSearchText.includes(marker)) || ''); + const matchedError = conversationCapacityReached + ? 'conversation-max-length' + : (matchedDeliveryTimeout ? 'Message delivery timed out. Please try again.' : matchedGeneralError); const errorSnippet = matchedError || ''; const retryMatch = this.getRetryButtonMatch(doc); - const hasTryAgainButton = !!retryMatch.element; + const hasTryAgainButton = conversationCapacityReached ? false : !!retryMatch.element; - const hasKnownError = !!matchedError || hasDeliveryTimedOut; + const hasKnownError = !!matchedError || hasDeliveryTimedOut || conversationCapacityReached; const isWorking = hasActiveStopButton || hasResultStreaming || hasActiveToolOrResearch; const generating = !hasKnownError && isWorking; const deepResearchActive = !hasKnownError && isWorking && isDeepResearch; @@ -1226,6 +1335,8 @@ hasError: hasKnownError, hasDeliveryTimedOut, hasTryAgainButton, + conversationCapacityReached, + requiresNewConversation: conversationCapacityReached, errorSnippet, matchedError, statusUnknown, @@ -1236,8 +1347,10 @@ status: statusMatches[0]?.selector || null, spinner: spinnerMatches[0]?.selector || null, research: matchedResearchMarker ? `researchMarkers:${matchedResearchMarker}` : null, - error: alertMatches[0]?.selector || (hasTryAgainButton ? retryMatch.selector || retryMatch.signalKey : null), - retry: retryMatch.element ? (retryMatch.selector || retryMatch.signalKey) : null, + error: conversationCapacityReached + ? (alertMatches[0]?.selector || statusMatches[0]?.selector || 'conversationCapacityMarkers') + : (alertMatches[0]?.selector || (hasTryAgainButton ? retryMatch.selector || retryMatch.signalKey : null)), + retry: hasTryAgainButton ? (retryMatch.selector || retryMatch.signalKey) : null, scope: 'active-response', compatibility: compatibilityState }, diff --git a/test/issue-51-conversation-capacity.test.js b/test/issue-51-conversation-capacity.test.js new file mode 100644 index 0000000..dace1d3 --- /dev/null +++ b/test/issue-51-conversation-capacity.test.js @@ -0,0 +1,564 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const mockTabs = new Map(); +const mockStorageLocal = {}; +let mockSyncStorage = { + queueUnlimitedRetryWait: false, + queueDeepResearchAware: true, + queueDeliveryTimeoutRefresh: 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/' }; + callback && callback(tab); + return Promise.resolve(tab); + }, + update: (tabId, props, callback) => { + const current = mockTabs.get(tabId) || { id: tabId }; + const next = { ...current, ...props, id: tabId }; + mockTabs.set(tabId, next); + callback && callback(next); + return Promise.resolve(next); + }, + 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: () => Promise.resolve([{ result: { ok: true, details: {} } }]) + } +}; + +require('../utils.js'); + +const { + jobs, + waitForTabResponse, + retryCurrentCommandIfEnabled, + classifyQueueFailure, + handleConversationCapacityRollover, + isConversationCapacityFailure, + validateJobTargetConversation, + restoreDurableJobs, + getDurableJobsState, + getRunningJobsSnapshot, + QUEUE_WAIT_POLICY, + QUEUE_RETRY_POLICY +} = require('../background.js'); + +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 withWaitPolicy(overrides, fn) { + const original = { ...QUEUE_WAIT_POLICY }; + Object.assign(QUEUE_WAIT_POLICY, overrides); + try { + return await fn(); + } finally { + Object.assign(QUEUE_WAIT_POLICY, original); + } +} + +function createJob(tabId, overrides = {}) { + return { + tabId, + provider: 'chatgpt', + conversationId: `old-chat-${tabId}`, + conversationType: 'existing', + targetKey: `chatgpt:c:old-chat-${tabId}`, + queue: ['Apply the same process to topic #33', 'topic-34'], + currentMessage: 'current-unresolved-command', + isRunning: true, + isPaused: false, + isStopped: false, + pausedReason: '', + lastError: '', + runId: `run-${tabId}`, + totalMessages: 3, + completedCount: 11, + currentCommandNumber: 12, + currentPhase: 'awaiting-response', + waitForIdleBeforeSend: false, + deliveryTimeoutAttempts: 0, + retryAttemptCount: 1, + lastRetryableReason: 'generation-error', + retryClass: 'generation-error', + retryMode: 'finite', + nextRetryDelayMs: 0, + nextRetryAt: 0, + retryExhausted: false, + waitStartedAt: 0, + lastResearchProgressAt: 0, + sawDeepResearch: false, + sawGenerating: true, + deliveryState: 'confirmed-submission', + commandId: `run-${tabId}:12`, + commandFingerprint: 'old-command', + submittedUserTurnId: 'user-old', + assistantTurnId: null, + submissionAckSource: 'user-turn', + terminalAckSource: '', + lastResponsePhase: 'error', + rolloverInProgress: false, + conversationGeneration: 0, + startedAt: Date.now(), + updatedAt: Date.now(), + ...overrides + }; +} + +function installTab(tabId, { url, handoffTurnId = 'handoff-user-1' } = {}) { + const tabState = { sent: false }; + const previousExecute = chrome.scripting.executeScript; + chrome.scripting.executeScript = async (details) => { + tabState.sent = true; + if (typeof previousExecute === 'function') { + try { + return await previousExecute(details); + } catch { + // Fall through to a successful queued-send result. + } + } + return [{ result: { ok: true, details: {} } }]; + }; + mockTabs.set(tabId, { + id: tabId, + url: url || `https://chatgpt.com/c/old-chat-${tabId}`, + onMessage: (message) => { + if (message.type === 'CHECK_GENERATION_STATE') { + return { + state: { + generating: false, + hasError: false, + conversationCapacityReached: false, + requiresNewConversation: false + }, + responseState: { phase: '' } + }; + } + if (message.type === 'GET_COMMAND_TURN_SNAPSHOT') { + if (tabState.sent && String(message.expectedText || '').includes('Continuation handoff')) { + return { + ok: true, + snapshot: { + userTurns: [{ + turnId: handoffTurnId, + index: 0, + fingerprint: 'handoff', + matchedExpected: true + }], + assistantTurns: [], + latestUserTurnId: handoffTurnId, + matchedUserTurnId: handoffTurnId, + conversationId: null + } + }; + } + return { + ok: true, + snapshot: { + userTurns: [], + assistantTurns: [], + latestUserTurnId: null, + matchedUserTurnId: null + } + }; + } + return { ok: true }; + } + }); + return tabState; +} + +test('conversation-max-length is a non-retryable capacity failure', async () => { + const restoreConsole = muteConsole(); + const classified = classifyQueueFailure( + 'wait', + 'ChatGPT conversation reached maximum length.', + { + failureClass: 'conversation-max-length', + conversationCapacityReached: true, + state: { conversationCapacityReached: true, hasError: true, matchedError: 'conversation-max-length' } + } + ); + assert.equal(classified.class, 'conversation-max-length'); + assert.equal(classified.retryable, false); + assert.equal(isConversationCapacityFailure({ + ok: false, + details: { failureClass: 'conversation-max-length', conversationCapacityReached: true } + }), true); + + const tabId = 5101; + const job = createJob(tabId); + jobs.set(tabId, job); + const retried = await retryCurrentCommandIfEnabled( + tabId, + job, + 'wait', + 'ChatGPT conversation reached maximum length.', + { failureClass: 'conversation-max-length', state: { conversationCapacityReached: true } } + ); + assert.equal(retried, false); + assert.equal(job.retryAttemptCount, 1); + assert.equal(job.currentMessage, 'current-unresolved-command'); + jobs.clear(); + restoreConsole(); +}); + +test('waitForTabResponse reports conversation-max-length before a generic error', async () => { + const restoreConsole = muteConsole(); + const tabId = 5102; + const job = createJob(tabId, { queue: [] }); + jobs.set(tabId, job); + mockTabs.set(tabId, { + id: tabId, + url: 'https://chatgpt.com/c/old-chat-5102', + onMessage: (message) => { + if (message.type === 'CHECK_GENERATION_STATE') { + return { + state: { + generating: false, + hasError: true, + hasTryAgainButton: true, + conversationCapacityReached: true, + requiresNewConversation: true, + matchedError: 'conversation-max-length' + }, + responseState: { phase: 'error', source: 'conversation-max-length' } + }; + } + return { ok: true }; + } + }); + + const result = await waitForTabResponse(tabId, { + commandNumber: 12, + totalMessages: 12, + queueSettings: { queueUnlimitedRetryWait: false, queueDeepResearchAware: true }, + maxWaitMs: 200, + checkIntervalMs: 20 + }); + assert.equal(result.ok, false); + assert.equal(result.details.failureClass, 'conversation-max-length'); + assert.equal(result.details.conversationCapacityReached, true); + assert.match(result.error, /maximum length/i); + jobs.clear(); + restoreConsole(); +}); + +test('ordinary unexpected navigation stays fail-closed while rollover rebind is authorized', async () => { + const restoreConsole = muteConsole(); + const tabId = 5103; + mockTabs.set(tabId, { id: tabId, url: 'https://chatgpt.com/c/other-chat' }); + const ordinary = await validateJobTargetConversation(tabId, { + provider: 'chatgpt', + conversationId: 'old-chat-5103', + conversationType: 'existing', + targetKey: 'chatgpt:c:old-chat-5103', + rolloverInProgress: false + }); + assert.equal(ordinary.ok, false); + assert.match(ordinary.reason, /Conversation mismatch/); + + const rolling = { + provider: 'chatgpt', + conversationId: 'old-chat-5103', + conversationType: 'existing', + targetKey: 'chatgpt:c:old-chat-5103', + rolloverInProgress: true, + rolloverFromConversationId: 'old-chat-5103', + rolloverRebindApplied: false + }; + mockTabs.set(tabId, { id: tabId, url: 'https://chatgpt.com/c/old-chat-5103' }); + const waiting = await validateJobTargetConversation(tabId, rolling); + assert.equal(waiting.ok, false); + assert.equal(waiting.awaitingRollover, true); + + mockTabs.set(tabId, { id: tabId, url: 'https://chatgpt.com/' }); + const rebound = await validateJobTargetConversation(tabId, rolling); + assert.equal(rebound.ok, true); + assert.equal(rebound.rolloverRebind, true); + assert.equal(rolling.conversationType, 'new'); + assert.equal(rolling.conversationGeneration, 1); + restoreConsole(); +}); + +test('capacity rollover preserves FIFO, sends a bounded handoff, and does not count it complete', async () => { + const restoreConsole = muteConsole(); + const tabId = 5104; + const job = createJob(tabId); + jobs.set(tabId, job); + installTab(tabId); + + await withWaitPolicy({ + submissionAckTimeoutMs: 250, + submissionAckPollMs: 15, + checkIntervalMs: 15, + responseMaxWaitMs: 400, + terminalConfirmSamples: 1 + }, async () => { + const result = await handleConversationCapacityRollover(tabId, job, { + ok: false, + details: { failureClass: 'conversation-max-length', conversationCapacityReached: true } + }); + assert.equal(result.action, 'continue'); + assert.equal(job.rolloverInProgress, false); + assert.equal(job.currentPhase, 'queued'); + assert.equal(job.completedCount, 11); + assert.equal(job.conversationGeneration, 1); + assert.equal(job.currentMessage, null); + assert.equal(job.queue[0], 'current-unresolved-command'); + assert.equal(job.queue[1], 'Apply the same process to topic #33'); + assert.equal(job.queue[2], 'topic-34'); + assert.equal(job.retryAttemptCount, 0); + assert.equal(job.conversationType, 'new'); + assert.equal(mockTabs.get(tabId).url, 'https://chatgpt.com/'); + + const snapshot = getRunningJobsSnapshot()[tabId]; + assert.equal(snapshot.currentPhase, 'queued'); + assert.equal(snapshot.conversationGeneration, 1); + assert.equal(snapshot.hasCurrentMessage, false); + + const durable = getDurableJobsState()[tabId]; + assert.equal(durable.queue[0], 'current-unresolved-command'); + assert.equal(durable.completedCount, 11); + assert.equal(durable.queue.includes('Apply the same process to topic #33'), true); + + await new Promise((resolve) => { + setTimeout(resolve, 80); + }); + const debugLogs = JSON.stringify(mockStorageLocal.queueDebugLogs || []); + assert.equal(debugLogs.includes('Apply the same process to topic #33'), false); + assert.equal(debugLogs.includes('current-unresolved-command'), false); + assert.equal(debugLogs.includes('Continuation handoff for an automated'), false); + }); + + jobs.clear(); + restoreConsole(); +}); + +test('worker restart during rollover does not open a second replacement chat', async () => { + const restoreConsole = muteConsole(); + const tabId = 5105; + installTab(tabId, { url: 'https://chatgpt.com/' }); + const restored = restoreDurableJobs({ + [tabId]: { + tabId, + provider: 'chatgpt', + conversationId: 'old-chat-5105', + conversationType: 'existing', + targetKey: 'chatgpt:c:old-chat-5105', + queue: ['next-after-current'], + currentMessage: 'pending-once', + isRunning: true, + isPaused: false, + runId: 'run-5105', + totalMessages: 13, + completedCount: 11, + currentCommandNumber: 12, + currentPhase: 'rollover-in-progress', + rolloverInProgress: true, + rolloverStage: 'awaiting-new-identity', + rolloverReason: 'conversation-max-length', + rolloverFromConversationId: 'old-chat-5105', + rolloverNavigationStarted: true, + rolloverRebindApplied: false, + handoffSubmitted: false, + conversationGeneration: 0 + } + }); + assert.equal(restored.length, 1); + jobs.set(tabId, restored[0]); + const job = restored[0]; + let updateCount = 0; + const originalUpdate = chrome.tabs.update; + chrome.tabs.update = (id, props, callback) => { + updateCount += 1; + return originalUpdate(id, props, callback); + }; + + await withWaitPolicy({ + submissionAckTimeoutMs: 250, + submissionAckPollMs: 15, + checkIntervalMs: 15, + responseMaxWaitMs: 400, + terminalConfirmSamples: 1 + }, async () => { + const result = await handleConversationCapacityRollover(tabId, job); + assert.equal(result.action, 'continue'); + assert.equal(updateCount, 0); + assert.equal(job.queue[0], 'pending-once'); + assert.equal(job.completedCount, 11); + assert.equal(job.conversationGeneration, 1); + }); + + chrome.tabs.update = originalUpdate; + jobs.clear(); + restoreConsole(); +}); + +test('restart after handoff submission does not duplicate the pending command or handoff', async () => { + const restoreConsole = muteConsole(); + const tabId = 5106; + installTab(tabId, { url: 'https://chatgpt.com/', handoffTurnId: 'handoff-user-restart' }); + const restored = restoreDurableJobs({ + [tabId]: { + tabId, + provider: 'chatgpt', + conversationId: null, + conversationType: 'new', + targetKey: 'chatgpt:new', + queue: ['kept-second'], + currentMessage: 'pending-once', + isRunning: true, + isPaused: false, + runId: 'run-5106', + totalMessages: 13, + completedCount: 4, + currentCommandNumber: 5, + currentPhase: 'rollover-in-progress', + rolloverInProgress: true, + rolloverStage: 'handoff-waiting', + rolloverReason: 'conversation-max-length', + rolloverFromConversationId: 'old-chat-5106', + rolloverNavigationStarted: true, + rolloverRebindApplied: true, + handoffSubmitted: true, + handoffEstablished: false, + conversationGeneration: 1 + } + }); + jobs.set(tabId, restored[0]); + const job = restored[0]; + let executeCount = 0; + const originalExecute = chrome.scripting.executeScript; + chrome.scripting.executeScript = async () => { + executeCount += 1; + return [{ result: { ok: true, details: {} } }]; + }; + + await withWaitPolicy({ + submissionAckTimeoutMs: 250, + submissionAckPollMs: 15, + checkIntervalMs: 15, + responseMaxWaitMs: 400, + terminalConfirmSamples: 1 + }, async () => { + const result = await handleConversationCapacityRollover(tabId, job); + assert.equal(result.action, 'continue'); + assert.equal(executeCount, 0); + assert.equal(job.queue.filter((message) => message === 'pending-once').length, 1); + assert.equal(job.queue[0], 'pending-once'); + assert.equal(job.completedCount, 4); + }); + + chrome.scripting.executeScript = originalExecute; + jobs.clear(); + restoreConsole(); +}); + +test('sequential rollovers increment conversation generation and keep the same run', async () => { + const restoreConsole = muteConsole(); + const tabId = 5107; + const job = createJob(tabId, { + conversationId: 'seg-0', + targetKey: 'chatgpt:c:seg-0', + completedCount: 2, + currentCommandNumber: 3, + queue: ['fourth'] + }); + jobs.set(tabId, job); + + await withWaitPolicy({ + submissionAckTimeoutMs: 250, + submissionAckPollMs: 15, + checkIntervalMs: 15, + responseMaxWaitMs: 400, + terminalConfirmSamples: 1 + }, async () => { + installTab(tabId, { url: 'https://chatgpt.com/c/seg-0', handoffTurnId: 'handoff-a' }); + await handleConversationCapacityRollover(tabId, job); + assert.equal(job.conversationGeneration, 1); + assert.equal(job.runId, 'run-5107'); + job.conversationId = 'seg-1'; + job.conversationType = 'existing'; + job.targetKey = 'chatgpt:c:seg-1'; + job.currentMessage = job.queue.shift(); + job.currentCommandNumber = 3; + installTab(tabId, { url: 'https://chatgpt.com/c/seg-1', handoffTurnId: 'handoff-b' }); + await handleConversationCapacityRollover(tabId, job); + assert.equal(job.conversationGeneration, 2); + assert.equal(job.runId, 'run-5107'); + assert.equal(job.completedCount, 2); + assert.equal(job.queue[0], 'current-unresolved-command'); + }); + + jobs.clear(); + restoreConsole(); +}); diff --git a/test/popup-status.test.js b/test/popup-status.test.js index 795b38f..b14bf1f 100644 --- a/test/popup-status.test.js +++ b/test/popup-status.test.js @@ -26,6 +26,8 @@ test('popup queue status labels cover exposed queue phases', () => { assert.equal(getPopupQueueStatusLabel({ status: 'running', currentPhase: 'waiting' }), 'Waiting'); assert.equal(getPopupQueueStatusLabel({ status: 'running', currentPhase: 'waiting-for-idle' }), 'Waiting for idle'); assert.equal(getPopupQueueStatusLabel({ status: 'running', currentPhase: 'retry-wait' }), 'Retrying'); + assert.equal(getPopupQueueStatusLabel({ status: 'running', currentPhase: 'rollover-in-progress' }), 'New conversation'); + assert.equal(getPopupQueueStatusLabel({ status: 'running', rolloverInProgress: true }), 'New conversation'); assert.equal(getPopupQueueStatusLabel({ status: 'paused', isPaused: true }), 'Paused'); assert.equal(getPopupQueueStatusLabel({ status: 'failed' }), 'Failed'); assert.equal(getPopupQueueStatusLabel({ status: 'complete' }), 'Complete'); diff --git a/test/provider-adapter.test.js b/test/provider-adapter.test.js index 788d487..2c593d7 100644 --- a/test/provider-adapter.test.js +++ b/test/provider-adapter.test.js @@ -1787,3 +1787,91 @@ test('ChatGPT command response state treats idle gaps as transient until the bou assert.equal(active.phase, 'active'); }); +test('ChatGPT generation state detects maximum-length on the active surface only', () => { + const chatgpt = getProvider('chatgpt'); + const exact = "You've reached the maximum length for this conversation, but you can keep talking by starting a new chat."; + + const staleUser = new MockTestElement('article', { + 'data-testid': 'conversation-turn-1', + 'data-message-author-role': 'user' + }, 'Quote the warning: you have reached the maximum length for this conversation'); + const staleAssistant = new MockTestElement('article', { + 'data-testid': 'conversation-turn-2', + 'data-message-author-role': 'assistant' + }, exact); + staleAssistant.appendChild(new MockTestElement('div', { role: 'alert' }, exact)); + const idleLatest = new MockTestElement('article', { + 'data-testid': 'conversation-turn-3', + 'data-message-author-role': 'assistant' + }, 'Later successful answer that only mentions starting a new chat in passing.'); + const { doc: staleDoc } = createTestDoc({ turns: [staleUser, staleAssistant, idleLatest] }); + const staleState = chatgpt.getGenerationState(staleDoc); + assert.equal(staleState.conversationCapacityReached, false); + assert.equal(staleState.requiresNewConversation, false); + assert.equal(staleState.hasError, false); + assert.equal(staleState.matchedError, ''); + + const liveTurn = new MockTestElement('article', { + 'data-testid': 'conversation-turn-4', + 'data-message-author-role': 'assistant' + }, 'Partial answer'); + liveTurn.appendChild(new MockTestElement('div', { role: 'alert' }, exact)); + const { doc: liveDoc } = createTestDoc({ turns: [staleUser, liveTurn] }); + const liveState = chatgpt.getGenerationState(liveDoc); + assert.equal(liveState.conversationCapacityReached, true); + assert.equal(liveState.requiresNewConversation, true); + assert.equal(liveState.hasError, true); + assert.equal(liveState.generating, false); + assert.equal(liveState.matchedError, 'conversation-max-length'); + assert.equal(liveState.errorSnippet, 'conversation-max-length'); + assert.equal(JSON.stringify(liveState).includes('topic #33'), false); + + const statusTurn = new MockTestElement('article', { + 'data-testid': 'conversation-turn-5', + 'data-message-author-role': 'assistant' + }, 'Partial'); + statusTurn.appendChild(new MockTestElement('div', { role: 'status' }, exact)); + const { doc: statusDoc } = createTestDoc({ turns: [statusTurn] }); + const statusState = chatgpt.getGenerationState(statusDoc); + assert.equal(statusState.conversationCapacityReached, true); + assert.equal(statusState.matchedError, 'conversation-max-length'); + + const responseState = chatgpt.getCommandResponseState(liveDoc, {}); + assert.equal(responseState.source, 'conversation-max-length'); + assert.equal(responseState.phase, 'error'); + + assert.equal(chatgpt.getNewConversationUrl(), 'https://chatgpt.com/'); + assert.equal(chatgpt.startNewConversation().method, 'route'); + assert.equal(chatgpt.isFreshConversationIdentity({ + provider: 'chatgpt', + type: 'new', + conversationId: null, + key: 'chatgpt:new' + }, 'old-chat'), true); + assert.equal(chatgpt.isFreshConversationIdentity({ + provider: 'chatgpt', + type: 'existing', + conversationId: 'old-chat', + key: 'chatgpt:c:old-chat' + }, 'old-chat'), false); + assert.equal(chatgpt.isFreshConversationIdentity({ + provider: 'chatgpt', + type: 'existing', + conversationId: 'new-chat', + key: 'chatgpt:c:new-chat' + }, 'old-chat'), true); + + const handoff = chatgpt.buildContinuationHandoffMessage({ + runId: 'run-51', + currentCommandNumber: 12, + completedCount: 11, + remainingCount: 40, + conversationGeneration: 2, + pendingMessageLength: 88 + }); + assert.match(handoff, /Continuation handoff/); + assert.match(handoff, /run-51/); + assert.equal(handoff.includes('Apply the same process to topic #33'), false); + assert.ok(handoff.length <= 1200); +}); + diff --git a/types/extension.d.ts b/types/extension.d.ts index 5d675e3..3d6fbcf 100644 --- a/types/extension.d.ts +++ b/types/extension.d.ts @@ -8,6 +8,7 @@ type QueueJobPhase = | 'awaiting-response' | 'terminal' | 'retry-wait' + | 'rollover-in-progress' | 'paused' | 'complete' | string; @@ -33,6 +34,7 @@ type QueueFailureClass = | 'submission-unconfirmed' | 'waiting-for-user' | 'interrupted' + | 'conversation-max-length' | string; type QueueRetryMode = 'finite' | 'unlimited' | '' | string; @@ -113,6 +115,17 @@ interface QueueJob { submissionAckSource?: string; terminalAckSource?: string; lastResponsePhase?: string; + rolloverInProgress?: boolean; + rolloverStage?: string; + rolloverReason?: string; + rolloverFromConversationId?: string | null; + rolloverToConversationId?: string | null; + rolloverNavigationStarted?: boolean; + rolloverRebindApplied?: boolean; + handoffSubmitted?: boolean; + handoffEstablished?: boolean; + handoffFingerprint?: string; + conversationGeneration?: number; startedAt: number; updatedAt: number; } @@ -157,6 +170,17 @@ interface DurableQueueJob { submissionAckSource?: string; terminalAckSource?: string; lastResponsePhase?: string; + rolloverInProgress?: boolean; + rolloverStage?: string; + rolloverReason?: string; + rolloverFromConversationId?: string | null; + rolloverToConversationId?: string | null; + rolloverNavigationStarted?: boolean; + rolloverRebindApplied?: boolean; + handoffSubmitted?: boolean; + handoffEstablished?: boolean; + handoffFingerprint?: string; + conversationGeneration?: number; startedAt: number; updatedAt: number; } @@ -207,6 +231,9 @@ interface RunningJobSnapshot { submissionAckSource?: string; terminalAckSource?: string; lastResponsePhase?: string; + rolloverInProgress?: boolean; + rolloverStage?: string; + conversationGeneration?: number; startedAt: number; updatedAt: number; }