Skip to content
Merged

Dev #21

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 12 additions & 8 deletions chatgpt-extension/background-compact-destination.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,19 @@ async function compactDestination(job, record, tabs) {
const probe = await compactSend(destination.id, 'probe', job, 'RESUME');
if (probe.markerFound && probe.conversationId && !isProvisionalConversationId(probe.conversationId)) {
if (probe.superseded) throw new Error('Chat mới đã nhận thêm nội dung trước khi chuyển task. Hãy kiểm tra tab trước khi tiếp tục.');
// First publish the recoverable destination URL, then wait for the bootstrap
// acknowledgement to finish. Only the final DB transaction changes task identity.
// Publish the recoverable destination identity first, then immediately re-probe the
// same tab instead of sleeping for another scheduler tick before the final commit.
let confirmed = probe;
if (job.newConversationId !== probe.conversationId) {
await compactCheckpoint(record, job, { newConversationId: probe.conversationId, newConversationUrl: probe.conversationUrl, detail: null });
return;
job = await compactCheckpoint(record, job, { newConversationId: probe.conversationId,
newConversationUrl: probe.conversationUrl, detail: null });
confirmed = await compactSend(destination.id, 'probe', job, 'RESUME');
}
if (probe.generating) return;
job = await compactCheckpoint(record, job, { phase: 'completed', newConversationId: probe.conversationId,
newConversationUrl: probe.conversationUrl, detail: null });
if (confirmed.superseded) throw new Error('Chat mới đã nhận thêm nội dung trước khi chuyển task. Hãy kiểm tra tab trước khi tiếp tục.');
if (!confirmed.markerFound || confirmed.conversationId !== job.newConversationId
|| isProvisionalConversationId(confirmed.conversationId) || confirmed.generating) return;
job = await compactCheckpoint(record, job, { phase: 'completed', newConversationId: confirmed.conversationId,
newConversationUrl: confirmed.conversationUrl, detail: null });
await finishCompactBrowser(job, record);
return;
}
Expand Down Expand Up @@ -93,7 +97,7 @@ async function finishCompactBrowser(job, record) {
if (job.continueAfterCompact === true) record = await resumeCompactWork(job, record);
if (closeError) throw closeError; // Keep cleanup unfinished for durable recovery, not a new compact.
}
await saveCompactRecord(job.id, { ...record, finished: true });
await saveCompactRecord(job.id, { ...record, finished: true, finishedAt: Date.now() });
compactJobs.delete(job.id);
}

Expand Down
70 changes: 50 additions & 20 deletions chatgpt-extension/background-compact.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,40 @@
// Neither a missing tab nor a lost HTTP response is proof that Send did not happen.
const COMPACT_PREFIX = 'chatcmd-compact-job:';
const COMPACT_ALARM = 'chatcmd-compact-recovery';
const COMPACT_TICK_MS = 400;
const COMPACT_FINISHED_RETENTION_MS = 24 * 60 * 60_000;
const COMPACT_FINISHED_MAX = 64;
const compactFlights = new Map();
const compactJobs = new Map();
let compactTimer;
let compactRecovery;
const compactPath = (id) => `/api/local/chatgpt/compact/${encodeURIComponent(id)}`;
async function compactRecord(id) { return (await chrome.storage.local.get(`${COMPACT_PREFIX}${id}`))[`${COMPACT_PREFIX}${id}`] || null; }
async function saveCompactRecord(id, value) { await chrome.storage.local.set({ [`${COMPACT_PREFIX}${id}`]: value }); return value; }
async function pruneFinishedCompactRecords(records) {
const now = Date.now();
const updates = {};
const finished = [];
for (const [key, original] of records) {
if (!original?.finished) continue;
const storedAt = Number(original.finishedAt);
const record = Number.isFinite(storedAt) && storedAt > 0 ? original : { ...original, finishedAt: now };
if (record !== original) updates[key] = record;
finished.push([key, record]);
}
if (Object.keys(updates).length) await chrome.storage.local.set(updates);
finished.sort((left, right) => Number(right[1].finishedAt) - Number(left[1].finishedAt));
const removals = finished.filter(([, record], index) => index >= COMPACT_FINISHED_MAX
|| now - Number(record.finishedAt) >= COMPACT_FINISHED_RETENTION_MS).map(([key]) => key);
if (removals.length) await chrome.storage.local.remove(removals);
const removed = new Set(removals);
return records.map(([key, record]) => [key, updates[key] || record]).filter(([key]) => !removed.has(key));
}
async function syncCompactAlarm(active) {
const alarm = await chrome.alarms.get(COMPACT_ALARM);
if (active && !alarm) await chrome.alarms.create(COMPACT_ALARM, { periodInMinutes: 0.5 });
if (!active && alarm) await chrome.alarms.clear(COMPACT_ALARM);
}
async function compactCheckpoint(record, job, patch) {
const next = await postJson(record.localBaseUrl, `${compactPath(job.id)}/checkpoint`, { expectedRevision: job.revision, ...patch });
compactJobs.set(job.id, next);
Expand Down Expand Up @@ -49,6 +76,7 @@ async function startCompactJob(message, sender) {
initialized: true, initialOpenAllowed: job.phase === 'preparing' });
}
compactJobs.set(job.id, job);
await syncCompactAlarm(true);
void runCompactJob(job.id);
return { jobId: job.id, accepted: true };
}
Expand All @@ -68,7 +96,7 @@ async function runCompactJob(id) {
function scheduleCompactTick() {
clearTimeout(compactTimer);
if ([...compactJobs.values()].some((job) => !ChatCmdCompactProtocol.terminal(job))) {
compactTimer = setTimeout(() => { for (const id of compactJobs.keys()) void runCompactJob(id); }, 2000);
compactTimer = setTimeout(() => { for (const id of compactJobs.keys()) void runCompactJob(id); }, COMPACT_TICK_MS);
}
}
async function compactTick(id) {
Expand Down Expand Up @@ -100,37 +128,38 @@ async function compactTick(id) {
if (probe.superseded) throw new Error('Có tin nhắn mới sau yêu cầu handoff. Không lấy phản hồi của lượt khác; hãy hủy và kiểm tra cuộc trò chuyện.');
if (probe.handoffText) {
job = await compactCheckpoint(record, job, { phase: 'saving_handoff', handoffText: probe.handoffText, detail: null });
} else {
await compactDetail(record, job, probe.threadError
? 'ChatGPT chưa hoàn tất handoff. Mở tab để kiểm tra lỗi; nội dung và task cũ được giữ nguyên.'
: 'Đang chờ ChatGPT viết xong handoff của đúng lượt này. Có thể đóng tab và mở lại sau.');
return;
}
await compactDetail(record, job, probe.threadError
? 'ChatGPT chưa hoàn tất handoff. Mở tab để kiểm tra lỗi; nội dung và task cũ được giữ nguyên.'
: 'Đang chờ ChatGPT viết xong handoff của đúng lượt này. Có thể đóng tab và mở lại sau.');
return;
}
const maySend = (job.phase === 'preparing' && record.sourceSend === 'not-attempted') || record.sourceSend === 'not-sent';
if (!maySend) {
await compactDetail(record, job, 'Đang đối chiếu lần gửi handoff đã ghi nhận. Không tự gửi lần hai khi chưa rõ kết quả; mở lại tab hoặc hủy để kiểm tra.');
} else {
const maySend = (job.phase === 'preparing' && record.sourceSend === 'not-attempted') || record.sourceSend === 'not-sent';
if (!maySend) {
await compactDetail(record, job, 'Đang đối chiếu lần gửi handoff đã ghi nhận. Không tự gửi lần hai khi chưa rõ kết quả; mở lại tab hoặc hủy để kiểm tra.');
return;
}
const ready = await compactSend(source.id, 'prepare', job, 'HANDOFF', probe.documentToken);
if (!ready.ready) { await compactDetail(record, job, 'Đang chuẩn bị: dừng phản hồi hiện tại và chờ ô nhập ChatGPT sẵn sàng.'); return; }
// The server transition is also the cross-document source dispatch permit.
job = await compactCheckpoint(record, job, { phase: 'writing_handoff', detail: null });
await compactDispatch(source.id, job, record, 'HANDOFF', probe.documentToken);
return;
}
const ready = await compactSend(source.id, 'prepare', job, 'HANDOFF', probe.documentToken);
if (!ready.ready) { await compactDetail(record, job, 'Đang chuẩn bị: dừng phản hồi hiện tại và chờ ô nhập ChatGPT sẵn sàng.'); return; }
// The server transition is also the cross-document source dispatch permit.
job = await compactCheckpoint(record, job, { phase: 'writing_handoff', detail: null });
await compactDispatch(source.id, job, record, 'HANDOFF', probe.documentToken);
return;
}
if (job.phase === 'saving_handoff') {
if (!job.handoffText) throw new Error('Handoff chưa được lưu bền vững; không mở chat mới.');
await compactCheckpoint(record, job, { phase: 'opening_new_chat', detail: null });
return;
job = await compactCheckpoint(record, job, { phase: 'opening_new_chat', detail: null });
}
if (job.phase === 'opening_new_chat') await compactDestination(job, record, tabs);
}
async function recoverCompactJobs() {
if (compactRecovery) return compactRecovery;
compactRecovery = (async () => {
const stored = await chrome.storage.local.get(null);
const records = Object.entries(stored).filter(([key]) => key.startsWith(COMPACT_PREFIX));
let records = Object.entries(stored).filter(([key]) => key.startsWith(COMPACT_PREFIX));
records = await pruneFinishedCompactRecords(records);
const origins = new Set(records.map(([, record]) => record.localBaseUrl));
origins.add(approvalBaseUrl);
for (const origin of origins) {
Expand All @@ -151,8 +180,9 @@ async function recoverCompactJobs() {
if (!record.finished) void runCompactJob(key.slice(COMPACT_PREFIX.length));
}
for (const job of compactJobs.values()) if (!ChatCmdCompactProtocol.terminal(job)) void runCompactJob(job.id);
const alarm = await chrome.alarms.get(COMPACT_ALARM);
if (!alarm) await chrome.alarms.create(COMPACT_ALARM, { periodInMinutes: 1 });
const active = records.some(([, record]) => !record.finished)
|| [...compactJobs.values()].some((job) => !ChatCmdCompactProtocol.terminal(job));
await syncCompactAlarm(active);
})().finally(() => { compactRecovery = null; });
return compactRecovery;
}
Expand Down
2 changes: 2 additions & 0 deletions chatgpt-extension/background-io.js
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ async function handleProgress(message, tabId) {
assistantContent: message.assistantContent,
});
await releaseRequest(message.requestId);
await forgetRecoveryRequest(message.requestId);
return { stage: 'browser-completed', browserCompleted: result?.status === 'completed', hasFinalResponse: result?.hasFinalResponse === true };
}
if (message.stage === 'result') {
Expand All @@ -252,6 +253,7 @@ async function handleProgress(message, tabId) {
errorMessage: message.errorMessage,
});
await releaseRequest(message.requestId);
await forgetRecoveryRequest(message.requestId);
return { stage: 'result' };
}
throw new Error(`ChatGPT progress stage không được hỗ trợ: ${message.stage || 'missing'}.`);
Expand Down
29 changes: 20 additions & 9 deletions chatgpt-extension/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -196,9 +196,12 @@ async function startSubagentRequestOnce(message) {
if (!state.active || state.status !== 'pending') return;
if (existing) await closeSubagentRequest(message.subagentId, existing.attempt);

const target = normalizeNewConversationUrl(message.newConversationUrl);
const tab = await chrome.tabs.create({ url: target, active: false });
if (!tab?.id) throw new Error('Không thể mở tab ChatGPT cho sub-agent.');
if (!message.conversationUrl) {
throw new Error('Browser sub-agent fallback không được phép tạo ChatGPT conversation mới.');
}
const target = await conversationTarget(message.conversationUrl);
const tab = await openConversationTab(target);
if (!tab?.id) throw new Error('Không thể mở lại ChatGPT conversation hiện tại cho sub-agent.');
const requestId = `subagent:${message.subagentId}:${attempt}`;
await chrome.storage.session.set({
[requestKey(requestId)]: {
Expand All @@ -208,7 +211,7 @@ async function startSubagentRequestOnce(message) {
subagentId: message.subagentId,
childTaskId: message.childTaskId,
attempt,
conversationUrl: null,
conversationUrl: target,
},
[subagentKey]: { requestId, tabId: tab.id, attempt },
});
Expand Down Expand Up @@ -316,6 +319,7 @@ async function reportFailure(requestId, localBaseUrl, error) {
});
} catch { /* the local app may already be closed */ }
await releaseRequest(requestId);
await forgetRecoveryRequest(requestId);
}

async function handleClosedTab(tabId) {
Expand Down Expand Up @@ -383,9 +387,11 @@ async function migrateTabBindings(removedTabId, addedTabId) {
async function preferredConversationIdentity(tabId, conversationId, conversationUrl) {
const tab = tabId ? await safeTab(tabId) : null;
const liveId = conversationIdFromUrl(tab?.url || '');
if (liveId && !isProvisionalConversationId(liveId)) {
return { conversationId: liveId, conversationUrl: tab.url };
const boundId = conversationId || conversationIdFromUrl(conversationUrl || '');
if (boundId && !isProvisionalConversationId(boundId) && liveId && liveId !== boundId) {
return { conversationId, conversationUrl };
}
if (liveId && !isProvisionalConversationId(liveId)) return { conversationId: liveId, conversationUrl: tab.url };
return { conversationId, conversationUrl };
}

Expand All @@ -408,6 +414,8 @@ async function syncRequestIdentityFromTab(tabId, tabUrl) {
const stored = await chrome.storage.session.get(null);
for (const [key, context] of Object.entries(stored)) {
if (!key.startsWith(REQUEST_PREFIX) || !context || context.tabId !== tabId || !context.localBaseUrl) continue;
const boundId = conversationIdFromUrl(context.conversationUrl || '');
if (boundId && !isProvisionalConversationId(boundId) && boundId !== liveId) continue;
const requestId = key.slice(REQUEST_PREFIX.length);
try {
if (context.mode === 'subagent' && context.subagentId && context.attempt) {
Expand Down Expand Up @@ -438,14 +446,17 @@ async function refreshConversationAliases(tabId, tabUrl) {
}

const bindings = await conversationBindings();
const hasRealConflict = Object.entries(bindings).some(([key, binding]) => binding?.tabId === tabId && !isProvisionalConversationId(key.slice(CONVERSATION_PREFIX.length)) && key.slice(CONVERSATION_PREFIX.length) !== liveId);
if (hasRealConflict) return;
let metadata = {};
const provisionalKeys = [];
const staleKeys = [];
for (const [key, binding] of Object.entries(bindings)) {
if (!binding || binding.tabId !== tabId) continue;
const boundId = key.slice(CONVERSATION_PREFIX.length);
if (boundId === liveId) continue;
staleKeys.push(key);
if (!isProvisionalConversationId(boundId)) continue;
metadata = { ...metadata, ...binding };
provisionalKeys.push(key);
await chrome.storage.local.set({
[`${CONVERSATION_ALIAS_PREFIX}${boundId}`]: {
conversationId: liveId,
Expand Down Expand Up @@ -473,5 +484,5 @@ async function refreshConversationAliases(tabId, tabUrl) {
}
}
await bindConversationTab(liveId, tabId, metadata);
if (provisionalKeys.length) await chrome.storage.session.remove(provisionalKeys);
if (staleKeys.length) await chrome.storage.session.remove([...new Set(staleKeys)]);
}
6 changes: 4 additions & 2 deletions chatgpt-extension/compact-content.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ test('captures only the owned public generation after a complete, stable end fen
env.answer('Earlier answer that must not appear', { id: 'older-answer' });
generation(env, value);
assert.equal(env.probe(value).handoffText, null);
env.advance(2499);
env.advance(1199);
assert.equal(env.probe(value).handoffText, null);
env.advance(1);
assert.equal(env.probe(value).handoffText, BODY);
Expand All @@ -31,7 +31,7 @@ test('streaming and changed text reset stability; missing/wrong end fence never
env.generating(true);
assert.equal(env.settled(value).handoffText, null);
env.generating(false);
env.advance(2500);
env.advance(1200);
assert.equal(env.probe(value).handoffText, BODY);
answer.textContent = BODY + ' Still incomplete';
assert.equal(env.settled(value).handoffText, null);
Expand Down Expand Up @@ -137,9 +137,11 @@ test('status card stays above input, announces all four steps, and updates witho
assert.equal(panel.querySelector('p').textContent, '<script>not executable</script>');
assert.equal(env.w.ChatCmdCompact.busy, true);
}
assert.deepEqual(env.state.renderLeases.at(-1), ['compact', true]);
await env.message('clear');
assert.equal(env.w.document.querySelector('[data-chatcmd-ui="compact"]'), null);
assert.equal(env.w.ChatCmdCompact.busy, false);
assert.deepEqual(env.state.renderLeases.at(-1), ['compact', false]);
});

test('prepare preserves an existing user draft verbatim without model change or send', async (t) => {
Expand Down
17 changes: 6 additions & 11 deletions chatgpt-extension/compact-integration.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -49,16 +49,14 @@ async function integrated(t) {
assert.equal(source.state.clicks, 1);
source.answer(BODY + '\n' + source.protocol.marker('HANDOFF-END', value.id));
source.probe(worker.serverJob());
source.advance(2501);
await worker.tick();
assert.equal(worker.serverJob().phase, 'saving_handoff');
assert.equal(worker.serverJob().handoffText, BODY);
source.advance(1201);
await worker.tick();
assert.equal(worker.serverJob().phase, 'opening_new_chat');
assert.equal(worker.serverJob().handoffText, BODY);
assert.ok(destination, 'destination opens in the same worker flight after the durable handoff checkpoints');
}
async function openDestination() {
await saveHandoff();
await worker.tick();
assert.ok(destination);
assert.equal(destination.state.clicks, 0);
assert.ok(worker.shared.tabs.some((tab) => tab.id === 7), 'source stays until destination is attached');
Expand All @@ -76,10 +74,8 @@ test('real content-worker round trip saves exact handoff, preserves task/model,
assert.equal(dest.state.clicks, 1);
assert.deepEqual(dest.state.models, [env.value.oldModel]);
assert.equal(env.worker.serverJob().newConversationId, null);
await env.worker.tick(); // Discover canonical URL via real RESUME marker.
await env.worker.tick(); // Persist canonical identity, re-probe it, then complete in the same worker flight.
assert.equal(env.worker.serverJob().newConversationId, 'destination-canonical');
assert.equal(env.worker.serverJob().phase, 'opening_new_chat');
await env.worker.tick(); // Complete only after identity was durable.
const completed = env.worker.serverJob();
assert.equal(completed.phase, 'completed');
assert.equal(completed.taskId, env.value.taskId);
Expand Down Expand Up @@ -142,8 +138,7 @@ test('actual destination click with lost response recovers via exact user marker
test('lost final checkpoint response finishes browser cleanup after restart without duplicate dispatch', async (t) => {
const env = await integrated(t);
const dest = await env.openDestination();
await env.worker.tick();
await env.worker.tick();
await env.worker.tick(); // Dispatch resume; the next tick can commit completion.
env.worker.shared.afterCheckpoint = async (patch) => {
if (patch.phase === 'completed') {
env.worker.shared.afterCheckpoint = null;
Expand Down Expand Up @@ -184,7 +179,7 @@ test('parallel tasks retain independent prompts, capture ownership and persisten
assert.equal(page.state.clicks, 1);
page.answer(BODY + '\n' + value.taskId + '\n' + page.protocol.marker('HANDOFF-END', value.id));
page.probe(worker.serverJob(value.id));
page.advance(2501);
page.advance(1201);
}
await Promise.all(jobs.map((value) => worker.run(value.id)));
await worker.restart();
Expand Down
Loading
Loading