Skip to content
Merged

Dev #25

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
24 changes: 17 additions & 7 deletions chatgpt-extension/background-compact-destination.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,22 @@ async function locateCompactDestination(job, record, tabs) {
});
if (tagged.length > 1) throw new Error('Có nhiều tab nhận cùng handoff. Không tự chọn để tránh gắn nhầm cuộc trò chuyện.');
if (tagged[0]) return tagged[0];
if (job.newConversationId) return tabs.find((tab) => conversationIdFromUrl(tab.url || '') === job.newConversationId) || null;
if (job.newConversationId) {
const canonical = tabs.find((tab) => conversationIdFromUrl(tab.url || '') === job.newConversationId);
if (canonical) return canonical;
}
const bound = tabs.find((tab) => tab.id === record.destinationTabId);
if (bound && !conversationIdFromUrl(bound.url || '') && isChatGptUrl(bound.url)) return bound;
// A browser restart changes tab ids and ChatGPT may remove the hash. The user marker
// is durable evidence; a title, latest answer or coincidentally blank tab is not.
if (bound && !job.newConversationId && !conversationIdFromUrl(bound.url || '') && isChatGptUrl(bound.url)) return bound;
// Chrome's tab URL can lag behind ChatGPT's SPA navigation, and a browser restart can
// also change tab ids after the operation hash is removed. The exact RESUME user marker
// is durable ownership evidence, so use it even when the browser still reports a home/
// project URL or when a known canonical id is temporarily absent from chrome.tabs.query.
const matches = [];
for (const tab of tabs) {
if (!tab.id || !conversationIdFromUrl(tab.url || '') || sameConversationUrl(tab.url, job.oldConversationUrl)) continue;
const conversationId = conversationIdFromUrl(tab.url || '');
const mayBeStaleDestination = Boolean(job.newConversationId || tab.id === record.destinationTabId);
if (!tab.id || !isChatGptUrl(tab.url) || sameConversationUrl(tab.url, job.oldConversationUrl)
|| (!conversationId && !mayBeStaleDestination)) continue;
try {
const found = await chrome.tabs.sendMessage(tab.id, { type: 'chatcmd-compact-locate', job, kind: 'RESUME' });
if (found?.ok && found.markerFound) matches.push(tab);
Expand Down Expand Up @@ -44,7 +52,10 @@ async function compactDestination(job, record, tabs) {
record = await saveCompactRecord(job.id, { ...record, destinationTabId: destination.id, destinationOpened: true });
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.');
// A later user turn does not invalidate the destination. It is the normal race when the
// user continues immediately after ChatGPT acknowledges RESUME but before the worker's
// final checkpoint. The exact operation marker still proves this conversation owns the
// handoff; RuntimeHost separately prevents local tools from running until attachment.
// 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;
Expand All @@ -53,7 +64,6 @@ async function compactDestination(job, record, tabs) {
newConversationUrl: probe.conversationUrl, detail: null });
confirmed = await compactSend(destination.id, 'probe', job, 'RESUME');
}
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,
Expand Down
17 changes: 11 additions & 6 deletions chatgpt-extension/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -197,12 +197,17 @@ async function startSubagentRequestOnce(message) {
if (!state.active || state.status !== 'pending') return;
if (existing) await closeSubagentRequest(message.subagentId, existing.attempt);

if (!message.conversationUrl) {
throw new Error('Browser sub-agent fallback không được phép tạo ChatGPT conversation mới.');
const target = message.conversationUrl
? await conversationTarget(message.conversationUrl)
: normalizeNewConversationUrl(message.newConversationUrl);
const tab = message.conversationUrl
? await openConversationTab(target)
: await chrome.tabs.create({ url: target, active: false });
if (!tab?.id) {
throw new Error(message.conversationUrl
? 'Không thể mở lại ChatGPT conversation hiện tại cho sub-agent.'
: 'Không thể mở tab ChatGPT mới cho sub-agent.');
}
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 @@ -212,7 +217,7 @@ async function startSubagentRequestOnce(message) {
subagentId: message.subagentId,
childTaskId: message.childTaskId,
attempt,
conversationUrl: target,
conversationUrl: message.conversationUrl ? target : null,
},
[subagentKey]: { requestId, tabId: tab.id, attempt },
});
Expand Down
9 changes: 6 additions & 3 deletions chatgpt-extension/compact-content.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -80,13 +80,16 @@ test('a later user turn supersedes the owned handoff and cannot contaminate capt
assert.equal(result.handoffText, null);
});

test('unidentified user nodes never authorize capture or locate', async (t) => {
test('exact public handoff turn without native message id uses a safe DOM identity', async (t) => {
const env = contentFixture(t);
const value = job();
env.user(env.protocol.handoffPrompt(value), null);
env.answer(BODY + '\n' + env.protocol.marker('HANDOFF-END', value.id));
assert.equal(env.settled(value).handoffText, null);
assert.equal((await env.message('locate', value)).markerFound, false);
const result = env.settled(value);
assert.equal(result.markerFound, true);
assert.match(result.userMessageId, /^dom-compact:/);
assert.equal(result.handoffText, BODY);
assert.equal((await env.message('locate', value)).markerFound, true);
});

test('real transcript parser excludes tool roots, hidden surfaces, commentary and private state', (t) => {
Expand Down
61 changes: 49 additions & 12 deletions chatgpt-extension/compact-destination.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,36 @@ test('destination recovery uses the durable resume marker, not a recycled tab id
assert.deepEqual(env.shared.calls.map((call) => call.tabId), [41, 50]);
});

test('known destination identity falls back to the exact RESUME marker when Chrome URL is stale', async (t) => {
const env = await destinationWorker(t, {
newConversationId: 'destination-owned', newConversationUrl: 'https://chatgpt.com/c/destination-owned',
}, { destinationTabId: 9, destinationOpened: true, destinationSend: 'dispatched-unresolved' });
const tabs = [{ id: 9, url: 'https://chatgpt.com/' }];
env.shared.route = async (id, message) => {
assert.equal(message.type, 'chatcmd-compact-locate');
assert.equal(message.kind, 'RESUME');
return { ok: true, markerFound: id === 9 };
};
const found = await env.api.locateCompactDestination(env.serverJob(), env.record(), tabs);
assert.equal(found.id, 9);
});

test('opening_new_chat completes when Chrome still reports home for the already attached destination', async (t) => {
const env = await destinationWorker(t, {
newConversationId: 'destination-owned', newConversationUrl: 'https://chatgpt.com/c/destination-owned',
}, { destinationTabId: 9, destinationOpened: true, destinationSend: 'dispatched-unresolved' });
env.shared.tabs = [{ id: 9, url: 'https://chatgpt.com/' }];
const probe = receiver({ markerFound: true, generating: false, conversationId: 'destination-owned',
conversationUrl: 'https://chatgpt.com/c/destination-owned' });
env.shared.route = (id, message) => message.type === 'chatcmd-compact-locate'
? { ok: true, markerFound: id === 9 } : probe(id, message);
await env.tick();
assert.equal(env.serverJob().phase, 'completed');
assert.equal(env.serverJob().taskId, job().taskId);
assert.equal(env.shared.creates.length, 0);
assert.equal(env.sends().length, 0);
});

test('unrelated blank tabs are never adopted when persisted destination binding is absent', async (t) => {
const env = await destinationWorker(t);
const tabs = [{ id: 70, url: 'https://chatgpt.com/' }, { id: 71, url: 'https://chatgpt.com/g/g-p-p/project' }];
Expand Down Expand Up @@ -256,18 +286,25 @@ test('completed job with closed destination waits to resume work until that exac
assert.equal(env.shared.creates.length, 0);
});

test('provisional conversation id and superseded resume cannot complete task rebinding', async (t) => {
for (const observation of [
{ markerFound: true, conversationId: 'WEB:provisional', conversationUrl: 'https://chatgpt.com/c/WEB:provisional' },
{ markerFound: true, superseded: true, conversationId: 'destination-owned', conversationUrl: 'https://chatgpt.com/c/destination-owned' },
]) {
const env = await destinationWorker(t);
env.shared.route = receiver(observation);
await assert.rejects(env.tick());
assert.equal(env.serverJob().phase, 'opening_new_chat');
assert.equal(env.shared.effects.filter((entry) => entry.type === 'bind').length, 0);
assert.equal(env.sends().length, 0);
}
test('provisional conversation id cannot complete task rebinding', async (t) => {
const env = await destinationWorker(t);
env.shared.route = receiver({ markerFound: true, conversationId: 'WEB:provisional',
conversationUrl: 'https://chatgpt.com/c/WEB:provisional' });
await assert.rejects(env.tick());
assert.equal(env.serverJob().phase, 'opening_new_chat');
assert.equal(env.shared.effects.filter((entry) => entry.type === 'bind').length, 0);
assert.equal(env.sends().length, 0);
});

test('a user turn after the exact RESUME marker does not deadlock destination attachment', async (t) => {
const env = await destinationWorker(t);
env.shared.route = receiver({ markerFound: true, superseded: true, generating: false,
conversationId: 'destination-owned', conversationUrl: 'https://chatgpt.com/c/destination-owned' });
await env.tick();
assert.equal(env.serverJob().phase, 'completed');
assert.equal(env.serverJob().newConversationId, 'destination-owned');
assert.equal(env.serverJob().taskId, job().taskId);
assert.equal(env.sends().length, 0);
});

for (const choice of [false, undefined]) {
Expand Down
68 changes: 68 additions & 0 deletions chatgpt-extension/compact-integration.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,74 @@ async function integrated(t) {
destination: () => destination };
}

test('source send recovers when ChatGPT renders the owned user turn without data-message-id', async (t) => {
const worker = await workerFixture(t);
const value = worker.seed();
const source = contentFixture(t);
worker.shared.tabs = [{ id: 7, url: value.oldConversationUrl }];
source.state.onClick = () => {
source.user(source.composer().value, null);
source.composer().value = '';
};
worker.shared.route = (_id, message) => source.message(message.type.replace('chatcmd-compact-', ''),
message.job, message.kind, message.documentToken);

await worker.tick();
assert.equal(source.state.clicks, 1);
assert.equal(worker.serverJob().phase, 'writing_handoff');
assert.equal(worker.record().sourceSend, 'dispatched-unresolved');

source.answer(BODY + '\n' + source.protocol.marker('HANDOFF-END', value.id));
source.probe(worker.serverJob());
source.advance(1201);
await worker.tick();

assert.equal(worker.serverJob().phase, 'opening_new_chat');
assert.equal(worker.serverJob().handoffText, BODY);
assert.equal(worker.shared.creates.length, 1, 'the worker must leave Writing the handoff instead of stalling on its durable send fence');
assert.equal(worker.sends('HANDOFF').length, 1, 'recovery must not resend the handoff');
});

test('destination attach recovers when ChatGPT renders the resume turn without data-message-id', async (t) => {
const env = await integrated(t);
const destination = await env.openDestination();
const tab = env.worker.shared.tabs.find((item) => item.id !== 1 && item.id !== 7);
assert.ok(tab, 'destination tab');
destination.state.onClick = () => {
destination.user(destination.composer().value, null);
destination.composer().value = '';
tab.url = 'https://chatgpt.com/c/destination-no-native-id';
destination.navigate(tab.url);
destination.answer('Handoff received.');
};

await env.worker.tick();
assert.equal(destination.state.clicks, 1);
assert.equal(env.worker.serverJob().phase, 'opening_new_chat');
assert.equal(env.worker.record().destinationSend, 'dispatched-unresolved');

await env.worker.tick();
assert.equal(env.worker.serverJob().phase, 'completed');
assert.equal(env.worker.serverJob().newConversationId, 'destination-no-native-id');
assert.equal(env.worker.sends('RESUME').length, 1, 'destination recovery must not resend the resume handoff');
});

test('user continuing immediately after RESUME acknowledgement still completes same-task attachment', async (t) => {
const env = await integrated(t);
const destination = await env.openDestination();
await env.worker.tick();
assert.equal(destination.state.clicks, 1);
assert.equal(env.worker.serverJob().phase, 'opening_new_chat');
destination.user('tiếp tục công việc', 'working-user');
destination.answer('conversation_compacting_or_archived', { id: 'blocked-working-answer' });
await env.worker.tick();
assert.equal(env.worker.serverJob().phase, 'completed');
assert.equal(env.worker.serverJob().taskId, env.value.taskId);
assert.equal(env.worker.serverJob().newConversationId, 'destination-canonical');
assert.equal(env.worker.sends('RESUME').length, 1);
assert.equal(env.worker.shared.creates.length, 1);
});

test('real content-worker round trip saves exact handoff, preserves task/model, and sends once per chat', async (t) => {
const env = await integrated(t);
const dest = await env.openDestination();
Expand Down
10 changes: 8 additions & 2 deletions chatgpt-extension/content-chatgpt-compact.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,14 @@
if (matches.length !== 1) return { duplicate: matches.length > 1, user: null };
const node = matches[0];
if (!promptsFor(job, kind).some((text) => comparable(read(node)) === comparable(text))) return { duplicate: false, user: null };
const id = node.getAttribute('data-message-id') || node.querySelector('[data-message-id]')?.getAttribute('data-message-id');
return { duplicate: false, user: id ? { node, id } : null, last: roots.at(-1) === node };
// ChatGPT does not consistently expose data-message-id on rendered user turns.
// The unique exact operation prompt already proves ownership; the id is only an
// opaque same-document token used by read/close recovery, so synthesize one when
// the public DOM omits a native message id instead of leaving compact stuck forever.
const nativeId = node.getAttribute('data-message-id') || node.querySelector('[data-message-id]')?.getAttribute('data-message-id');
const index = roots.indexOf(node);
const id = nativeId || `dom-compact:${job.id}:${index}`;
return { duplicate: false, user: { node, id }, last: roots.at(-1) === node };
}
function show(job) {
if (!isCurrent()) return;
Expand Down
2 changes: 1 addition & 1 deletion chatgpt-extension/manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "ChatCMD ChatGPT Bridge",
"version": "0.1.11",
"version": "0.1.12",
"description": "Bridges the local ChatCMD console to an already signed-in chatgpt.com tab.",
"permissions": ["tabs", "storage", "scripting", "alarms"],
"host_permissions": ["https://chatgpt.com/*", "http://localhost/*", "http://127.0.0.1/*"],
Expand Down
Loading
Loading