From 2dd4b618802a506ac8aa42a3b501dea3c0b04c0f Mon Sep 17 00:00:00 2001 From: Type Int04 Date: Tue, 8 Sep 2026 10:03:24 +0700 Subject: [PATCH 1/6] Fix background compact resume flow --- .../background-compact-destination.js | 18 ++++--- chatgpt-extension/background-compact.js | 37 ++++++------- chatgpt-extension/compact-content.test.cjs | 6 ++- .../compact-integration.test.cjs | 17 +++--- chatgpt-extension/compact-recovery.test.cjs | 2 +- chatgpt-extension/compact-test-content.cjs | 9 +++- chatgpt-extension/content-chatgpt-compact.js | 22 ++++++-- .../content-chatgpt-render-bridge.test.cjs | 52 +++++++++++++++++++ chatgpt-extension/content-chatgpt-render.js | 17 ++++-- chatgpt-extension/content-chatgpt.test.cjs | 2 +- 10 files changed, 131 insertions(+), 51 deletions(-) create mode 100644 chatgpt-extension/content-chatgpt-render-bridge.test.cjs diff --git a/chatgpt-extension/background-compact-destination.js b/chatgpt-extension/background-compact-destination.js index f5b3e92e..56e83790 100644 --- a/chatgpt-extension/background-compact-destination.js +++ b/chatgpt-extension/background-compact-destination.js @@ -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; } diff --git a/chatgpt-extension/background-compact.js b/chatgpt-extension/background-compact.js index 55ec0898..7dc7c3cc 100644 --- a/chatgpt-extension/background-compact.js +++ b/chatgpt-extension/background-compact.js @@ -2,6 +2,7 @@ // 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 compactFlights = new Map(); const compactJobs = new Map(); let compactTimer; @@ -68,7 +69,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) { @@ -100,29 +101,29 @@ 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); } @@ -152,7 +153,7 @@ async function recoverCompactJobs() { } 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 }); + if (!alarm) await chrome.alarms.create(COMPACT_ALARM, { periodInMinutes: 0.5 }); })().finally(() => { compactRecovery = null; }); return compactRecovery; } diff --git a/chatgpt-extension/compact-content.test.cjs b/chatgpt-extension/compact-content.test.cjs index 636679ed..3a6b65f4 100644 --- a/chatgpt-extension/compact-content.test.cjs +++ b/chatgpt-extension/compact-content.test.cjs @@ -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); @@ -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); @@ -137,9 +137,11 @@ test('status card stays above input, announces all four steps, and updates witho assert.equal(panel.querySelector('p').textContent, ''); 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) => { diff --git a/chatgpt-extension/compact-integration.test.cjs b/chatgpt-extension/compact-integration.test.cjs index 3b4fdc7e..6ae065c3 100644 --- a/chatgpt-extension/compact-integration.test.cjs +++ b/chatgpt-extension/compact-integration.test.cjs @@ -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'); @@ -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); @@ -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; @@ -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(); diff --git a/chatgpt-extension/compact-recovery.test.cjs b/chatgpt-extension/compact-recovery.test.cjs index 7d9a779c..32391abe 100644 --- a/chatgpt-extension/compact-recovery.test.cjs +++ b/chatgpt-extension/compact-recovery.test.cjs @@ -199,7 +199,7 @@ test('pending server jobs with missing browser records recover without unsafe re assert.equal(restarted.record().initialOpenAllowed, false); assert.equal(restarted.shared.creates.length, 0); assert.equal(restarted.sends().length, 0); - assert.equal(restarted.shared.alarms.get('chatcmd-compact-recovery').periodInMinutes, 1); + assert.equal(restarted.shared.alarms.get('chatcmd-compact-recovery').periodInMinutes, 0.5); } }); diff --git a/chatgpt-extension/compact-test-content.cjs b/chatgpt-extension/compact-test-content.cjs index c337159d..720be283 100644 --- a/chatgpt-extension/compact-test-content.cjs +++ b/chatgpt-extension/compact-test-content.cjs @@ -16,7 +16,8 @@ function contentFixture(t, options = {}) { }); const w = page.window; const onMessage = event(); - const state = { current: true, clicks: 0, stops: 0, pauses: 0, writes: [], models: [], wakes: [], now: 10000 }; + const state = { current: true, clicks: 0, stops: 0, pauses: 0, writes: [], models: [], wakes: [], + renderLeases: [], renderPulses: 0, now: 10000 }; const controller = { current: () => state.current, findComposer: () => w.document.getElementById('prompt-textarea'), @@ -35,6 +36,10 @@ function contentFixture(t, options = {}) { w.chrome = { runtime: { onMessage } }; w.ChatCmdController = controller; w.ChatCmdRuntime = { sendMessage: async (message) => { state.wakes.push(message); return { ok: true }; } }; + w.ChatCmdRenderBridge = { + setLease: (name, active) => state.renderLeases.push([name, active]), + pulse: () => { state.renderPulses++; }, + }; w.document.querySelector('[data-testid="send-button"]').addEventListener('click', () => { state.clicks++; state.onClick?.(); @@ -92,7 +97,7 @@ function contentFixture(t, options = {}) { function probe(compactJob = job(), kind = 'HANDOFF') { return w.ChatCmdCompact.probe(compactJob, kind); } function settled(compactJob = job(), kind = 'HANDOFF') { probe(compactJob, kind); - state.now += 2501; + state.now += 1201; return probe(compactJob, kind); } async function ready(compactJob = job(), kind = 'HANDOFF') { diff --git a/chatgpt-extension/content-chatgpt-compact.js b/chatgpt-extension/content-chatgpt-compact.js index ba430f3b..1073a9e7 100644 --- a/chatgpt-extension/content-chatgpt-compact.js +++ b/chatgpt-extension/content-chatgpt-compact.js @@ -6,6 +6,7 @@ const dom = globalThis.ChatCmdConversationDom; const transcript = globalThis.ChatCmdTranscript; const documentToken = crypto.randomUUID(); + const HANDOFF_STABLE_MS = 1200; let currentJob = null; let ownedConversationId = null; let disposed = false; @@ -16,6 +17,7 @@ let panel; let pageUrl = location.href; const isCurrent = () => !disposed && controller?.current(); + const setRenderLease = (active) => globalThis.ChatCmdRenderBridge?.setLease?.('compact', active); // ProseMirror represents newlines as

/

/
, so textContent alone // joins paragraphs and incorrectly rejects the prompt we just inserted. function composerText(node) { @@ -62,9 +64,13 @@ } function show(job) { if (!isCurrent()) return; - if (!job || protocol.terminal(job)) { currentJob = null; panel?.remove(); panel = null; return; } + if (!job || protocol.terminal(job)) { + currentJob = null; ownedConversationId = null; setRenderLease(false); + panel?.remove(); panel = null; return; + } currentJob = job; ownedConversationId = transcript.conversationId(); + setRenderLease(true); const composer = controller.findComposer(); const anchor = composer?.closest('form') || composer?.parentElement; if (!anchor?.parentElement) return; @@ -107,7 +113,7 @@ text = parts.map((part) => part.content).join('\n\n'); } if (text !== stableText || dom.findStopButton()) { stableText = text; stableSince = Date.now(); } - const handoff = text && Date.now() - stableSince >= 2500 ? protocol.handoffText(text, job.id) : null; + const handoff = text && Date.now() - stableSince >= HANDOFF_STABLE_MS ? protocol.handoffText(text, job.id) : null; const canonicalUrl = new URL(location.href); canonicalUrl.hash = ''; canonicalUrl.search = ''; return { documentToken, conversationId: transcript.conversationId(), conversationUrl: canonicalUrl.href, @@ -132,7 +138,10 @@ if (!isCurrent() || !ownsPage(job, kind)) return { ready: false }; composer = controller.findComposer(); if (!composer || composerText(composer) !== text) throw new Error('Bản nháp đã thay đổi trong khi chuẩn bị. Nội dung mới của bạn được giữ nguyên.'); - if (!promptMatches(composer, prompt)) controller.setComposerText(composer, prompt); + if (!promptMatches(composer, prompt)) { + controller.setComposerText(composer, prompt); + globalThis.ChatCmdRenderBridge?.pulse(); + } // Let the worker poll while React enables/replaces Send. Rewriting on each poll // would restart that update and a page timer may be suspended in a hidden tab. return { ready: promptMatches(controller.findComposer(), prompt) && Boolean(readySendButton()), documentToken }; @@ -155,6 +164,7 @@ // No await between the last identity/draft check and the irreversible click. dispatched.add(key); // Never click twice in this document, even before the marker appears. button.click(); + globalThis.ChatCmdRenderBridge?.pulse(); composer.blur(); return { sent: true }; } finally { dispatching = false; } @@ -200,6 +210,7 @@ } chrome.runtime.onMessage.addListener(listener); function dispose() { + currentJob = null; ownedConversationId = null; setRenderLease(false); disposed = true; panel?.remove(); chrome.runtime.onMessage.removeListener(listener); window.removeEventListener('pageshow', wake); window.removeEventListener('popstate', wake); } @@ -211,7 +222,10 @@ // Reconcile with the durable worker after reload, BFCache restore and SPA navigation. function wake() { if (!isCurrent()) return; - if (pageUrl !== location.href) { pageUrl = location.href; currentJob = null; panel?.remove(); panel = null; } + if (pageUrl !== location.href) { + pageUrl = location.href; currentJob = null; ownedConversationId = null; setRenderLease(false); + panel?.remove(); panel = null; + } void globalThis.ChatCmdRuntime.sendMessage({ type: 'chatcmd-compact-wake' }).catch(() => {}); } window.addEventListener('pageshow', wake); diff --git a/chatgpt-extension/content-chatgpt-render-bridge.test.cjs b/chatgpt-extension/content-chatgpt-render-bridge.test.cjs new file mode 100644 index 00000000..4a54a234 --- /dev/null +++ b/chatgpt-extension/content-chatgpt-render-bridge.test.cjs @@ -0,0 +1,52 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const test = require('node:test'); +const { readFileSync } = require('node:fs'); +const { join } = require('node:path'); +const { JSDOM } = require('../web/node_modules/jsdom'); +const source = readFileSync(join(__dirname, 'content-chatgpt-render.js'), 'utf8'); + +function fixture(t) { + const page = new JSDOM('', { url: 'https://chatgpt.com/c/hidden', runScripts: 'outside-only' }); + const w = page.window; + const jobs = new Map(); + const pulses = []; + let sequence = 0; + Object.defineProperty(w.document, 'visibilityState', { get: () => 'hidden' }); + w.ChatCmdRuntime = { install: () => ({ id: 'render-bridge' }), current: () => true }; + w.ChatCmdCaptureClock = { + later(fn, ms) { const id = ++sequence; jobs.set(id, { fn, ms }); return id; }, + cancel(id) { jobs.delete(id); }, + }; + w.ChatCmdController = { current: () => null, active: null }; + w.document.addEventListener('chatcmd:render-pulse', (event) => pulses.push(JSON.parse(event.detail))); + w.eval(source); + t.after(() => { w.ChatCmdRenderBridge?.stop(); w.close(); }); + return { w, jobs, pulses }; +} + +test('explicit compact lease keeps hidden render pulses alive without an active request', (t) => { + const env = fixture(t); + assert.equal(env.pulses.at(-1).active, false); + assert.equal(env.jobs.size, 0); + + env.w.ChatCmdRenderBridge.setLease('compact', true); + assert.equal(env.pulses.at(-1).active, true); + assert.equal(env.jobs.size, 1); + const [firstId, first] = [...env.jobs.entries()][0]; + assert.equal(first.ms, 250); + + env.jobs.delete(firstId); + first.fn(); + assert.equal(env.pulses.at(-1).active, true); + assert.equal(env.jobs.size, 1, 'hidden lease must re-arm through the worker-backed capture clock'); + + env.w.ChatCmdRenderBridge.setLease('compact', false); + assert.equal(env.pulses.at(-1).active, false); + const [lastId, last] = [...env.jobs.entries()][0]; + env.jobs.delete(lastId); + last.fn(); + assert.equal(env.pulses.at(-1).active, false); + assert.equal(env.jobs.size, 0, 'released lease must stop the hidden heartbeat'); +}); diff --git a/chatgpt-extension/content-chatgpt-render.js b/chatgpt-extension/content-chatgpt-render.js index c35c055c..eacc752c 100644 --- a/chatgpt-extension/content-chatgpt-render.js +++ b/chatgpt-extension/content-chatgpt-render.js @@ -8,6 +8,7 @@ let bootstrapUntil = 0; let bootstrapPath = ''; let probeJob = null; + const leases = new Set(); let version = 0; let fallbackFrames = 0; const current = () => !stopped && globalThis.ChatCmdRuntime.current(owner); @@ -18,13 +19,13 @@ const owned = Boolean(request && !request.resultReported && (!request.observer || request.observer.active)); const bootstrap = Date.now() < bootstrapUntil && (location.pathname === bootstrapPath || ((bootstrapPath === '/' || /\/project$/.test(bootstrapPath)) && /\/c\//.test(location.pathname))); - const active = !forceIdle && (owned || bootstrap); + const active = !forceIdle && (owned || bootstrap || leases.size > 0); document.dispatchEvent(new CustomEvent('chatcmd:render-pulse', { detail: JSON.stringify({ version: 1, path: location.pathname, active }), })); - // Before the first user bubble mounts, the native observer has no request to poll. - // A short user-gesture lease closes that gap without waking all idle chat tabs. - if (bootstrap && document.visibilityState === 'hidden' && probeJob === null) { + // Hidden tabs need periodic pulses while a request/bootstrap/explicit workflow lease is active. + // ChatCmdCaptureClock delegates the deadline to the MV3 worker when page timers are throttled. + if (active && document.visibilityState === 'hidden' && probeJob === null) { probeJob = clock.later(() => { probeJob = null; pulse(); }, 250); } } @@ -49,11 +50,17 @@ bootstrapPath = location.pathname; pulse(); } + function setLease(name, enabled) { + if (!current() || typeof name !== 'string' || !name) return; + if (enabled) leases.add(name); else leases.delete(name); + pulse(); + } function visibility() { pulse(); } function pageHide() { pulse(true); bootstrapUntil = 0; } function stop() { if (stopped) return; pulse(true); stopped = true; + leases.clear(); if (probeJob !== null) clock.cancel(probeJob); document.removeEventListener('chatcmd:render-status', status); document.removeEventListener('visibilitychange', visibility); @@ -64,7 +71,7 @@ document.addEventListener('visibilitychange', visibility); for (const name of ['keydown', 'click', 'submit']) document.addEventListener(name, submitted, true); window.addEventListener('pagehide', pageHide); - globalThis.ChatCmdRenderBridge = Object.freeze({ pulse, stop, get version() { return version; }, + globalThis.ChatCmdRenderBridge = Object.freeze({ pulse, setLease, stop, get version() { return version; }, get fallbackFrames() { return fallbackFrames; } }); pulse(); })(); diff --git a/chatgpt-extension/content-chatgpt.test.cjs b/chatgpt-extension/content-chatgpt.test.cjs index cd98cc86..da2892ba 100644 --- a/chatgpt-extension/content-chatgpt.test.cjs +++ b/chatgpt-extension/content-chatgpt.test.cjs @@ -6,7 +6,7 @@ const vm = require('node:vm'); const extensionRoot = __dirname; // Unit harness exposes runner locals; integration tests load its real IIFE through the manifest. -const source = readFileSync(join(extensionRoot, 'content-chatgpt.js'), 'utf8').replace(/^\(\(\) => \{\n/, '').replace(/\}\)\(\);\s*$/, '').replace('const waitForAssistant =', 'let waitForAssistant ='); +const source = readFileSync(join(extensionRoot, 'content-chatgpt.js'), 'utf8').replace(/^\(\(\) => \{\r?\n/, '').replace(/\}\)\(\);\s*$/, '').replace('const waitForAssistant =', 'let waitForAssistant ='); const monitorSource = readFileSync(join(extensionRoot, 'content-chatgpt-monitor.js'), 'utf8'); const runtimeSource = readFileSync(join(extensionRoot, 'content-runtime.js'), 'utf8'); const recoverySource = readFileSync(join(extensionRoot, 'background-recovery.js'), 'utf8'); From 1caa9306d5e3d1653976d05a8d5ff65cda3fa26a Mon Sep 17 00:00:00 2001 From: Type Int04 Date: Tue, 8 Sep 2026 16:31:15 +0700 Subject: [PATCH 2/6] Add "plan_questions" table to deletion process in task_delete.rs --- src/api/task_delete.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/api/task_delete.rs b/src/api/task_delete.rs index 3bf215da..77e8c073 100644 --- a/src/api/task_delete.rs +++ b/src/api/task_delete.rs @@ -73,6 +73,7 @@ async fn delete_task_data( ) -> Result<(), Problem> { for table in [ "approvals", + "plan_questions", "artifact_registry", "task_execution_modes", "turn_bindings", @@ -280,6 +281,7 @@ async fn delete_expired_task_data( for table in [ "approvals", + "plan_questions", "artifact_registry", "task_execution_modes", "turn_bindings", @@ -371,6 +373,7 @@ async fn cleanup_user_generated_data(state: &Arc) -> Result<(), Proble for table in [ "chatgpt_conversations", "approvals", + "plan_questions", "artifact_registry", "task_execution_modes", "turn_bindings", From b805f2de212acd84245d9269d4490988a1f6b564 Mon Sep 17 00:00:00 2001 From: Type Int04 Date: Tue, 8 Sep 2026 17:38:25 +0700 Subject: [PATCH 3/6] perf(extension): reduce long-running overhead --- .../background-compact-destination.js | 2 +- chatgpt-extension/background-compact.js | 35 +++++++++++++++++-- chatgpt-extension/background-io.js | 2 ++ chatgpt-extension/background.js | 8 +++-- chatgpt-extension/compact-recovery.test.cjs | 14 +++++++- chatgpt-extension/compact-test-worker.cjs | 1 + chatgpt-extension/content-chatgpt-monitor.js | 2 +- chatgpt-extension/content-chatgpt-native.js | 21 ++++++++--- .../content-chatgpt-native.test.cjs | 23 ++++++++++++ chatgpt-extension/content-chatgpt-observer.js | 6 ++-- .../content-chatgpt-observer.test.cjs | 13 +++++++ chatgpt-extension/content-chatgpt.test.cjs | 13 +++++++ 12 files changed, 124 insertions(+), 16 deletions(-) diff --git a/chatgpt-extension/background-compact-destination.js b/chatgpt-extension/background-compact-destination.js index 56e83790..e47cbac9 100644 --- a/chatgpt-extension/background-compact-destination.js +++ b/chatgpt-extension/background-compact-destination.js @@ -97,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); } diff --git a/chatgpt-extension/background-compact.js b/chatgpt-extension/background-compact.js index 7dc7c3cc..eb5b1448 100644 --- a/chatgpt-extension/background-compact.js +++ b/chatgpt-extension/background-compact.js @@ -3,6 +3,8 @@ 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; @@ -10,6 +12,30 @@ 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); @@ -50,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 }; } @@ -131,7 +158,8 @@ 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) { @@ -152,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: 0.5 }); + const active = records.some(([, record]) => !record.finished) + || [...compactJobs.values()].some((job) => !ChatCmdCompactProtocol.terminal(job)); + await syncCompactAlarm(active); })().finally(() => { compactRecovery = null; }); return compactRecovery; } diff --git a/chatgpt-extension/background-io.js b/chatgpt-extension/background-io.js index fa515e6c..58ffc9b4 100644 --- a/chatgpt-extension/background-io.js +++ b/chatgpt-extension/background-io.js @@ -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') { @@ -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'}.`); diff --git a/chatgpt-extension/background.js b/chatgpt-extension/background.js index 5a1ac88c..f583af97 100644 --- a/chatgpt-extension/background.js +++ b/chatgpt-extension/background.js @@ -316,6 +316,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) { @@ -439,13 +440,14 @@ async function refreshConversationAliases(tabId, tabUrl) { const bindings = await conversationBindings(); 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, @@ -473,5 +475,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)]); } diff --git a/chatgpt-extension/compact-recovery.test.cjs b/chatgpt-extension/compact-recovery.test.cjs index 32391abe..b38578ae 100644 --- a/chatgpt-extension/compact-recovery.test.cjs +++ b/chatgpt-extension/compact-recovery.test.cjs @@ -3,7 +3,7 @@ const assert = require('node:assert/strict'); const test = require('node:test'); const { job, BODY, clone } = require('./compact-test-fixtures.cjs'); -const { workerFixture, receiver, PREFIX } = require('./compact-test-worker.cjs'); +const { workerFixture, world, receiver, PREFIX } = require('./compact-test-worker.cjs'); async function sourceWorker(t, value = job(), record = {}) { const env = await workerFixture(t); @@ -212,3 +212,15 @@ test('offline recovery preserves disk state and does not open or dispatch as a f assert.equal(env.shared.creates.length, 0); assert.equal(env.sends().length, 0); }); + +test('idle recovery clears its alarm and prunes expired finished browser records', async (t) => { + const shared = world(); + shared.alarms.set('chatcmd-compact-recovery', { periodInMinutes: 0.5 }); + shared.store[`${PREFIX}finished-old`] = { + localBaseUrl: 'http://127.0.0.1:8080', finished: true, + finishedAt: Date.now() - 25 * 60 * 60_000, + }; + await workerFixture(t, shared); + assert.equal(shared.store[`${PREFIX}finished-old`], undefined); + assert.equal(shared.alarms.has('chatcmd-compact-recovery'), false); +}); diff --git a/chatgpt-extension/compact-test-worker.cjs b/chatgpt-extension/compact-test-worker.cjs index 7db6f4f6..55b08906 100644 --- a/chatgpt-extension/compact-test-worker.cjs +++ b/chatgpt-extension/compact-test-worker.cjs @@ -49,6 +49,7 @@ async function workerFixture(t, shared = world()) { alarms: { onAlarm: event(), get: async (name) => clone(shared.alarms.get(name)), create: async (name, info) => { shared.alarms.set(name, clone(info)); }, + clear: async (name) => shared.alarms.delete(name), }, tabs: { onUpdated: event(), onReplaced: event(), onRemoved: event(), diff --git a/chatgpt-extension/content-chatgpt-monitor.js b/chatgpt-extension/content-chatgpt-monitor.js index 11580fb0..0398e166 100644 --- a/chatgpt-extension/content-chatgpt-monitor.js +++ b/chatgpt-extension/content-chatgpt-monitor.js @@ -21,7 +21,7 @@ globalThis.ChatCmdMonitor = Object.freeze({ create(api) { if (recorder) { recorder.scan(); if (!recorder.active) return recorder.answer; - void recorder.flush(); + void recorder.flush(false, false); } const text = recorder ? recorder.answer : (latest?.innerText?.trim() || latest?.textContent?.trim() || ''); const stopButton = findStopButton(); diff --git a/chatgpt-extension/content-chatgpt-native.js b/chatgpt-extension/content-chatgpt-native.js index ef69f966..1c948701 100644 --- a/chatgpt-extension/content-chatgpt-native.js +++ b/chatgpt-extension/content-chatgpt-native.js @@ -7,6 +7,8 @@ const clock = globalThis.ChatCmdCaptureClock; const later = (fn, ms, options) => clock ? clock.later(fn, ms, options) : setTimeout(fn, ms); const cancel = (id) => clock ? clock.cancel(id) : clearTimeout(id); + const USER_SELECTOR = '[data-message-author-role="user"],[data-turn="user"]'; + const NATIVE_FALLBACK_POLL_MS = 4_000; const seen = new Set(); let stopped = false; let pending = false; @@ -73,13 +75,24 @@ queued = true; void Promise.resolve().then(() => { queued = false; if (!stopped) void tick(); }); } - const observer = new MutationObserver(schedule); + function mutationAffectsUser(records) { + for (const record of records) { + const target = record.target?.nodeType === Node.TEXT_NODE ? record.target.parentElement : record.target; + if (target?.closest?.(USER_SELECTOR)) return true; + for (const added of record.addedNodes || []) { + if (!(added instanceof Element)) continue; + if (added.matches(USER_SELECTOR) || added.querySelector(USER_SELECTOR)) return true; + } + } + return false; + } + const observer = new MutationObserver((records) => { if (mutationAffectsUser(records)) schedule(); }); observer.observe(document.body, { subtree: true, childList: true, characterData: true }); function poll() { if (stopped) return; void tick(); - // Idle polling is only a local fallback, never a perpetual service-worker keepalive. - pollTimer = later(poll, 750, { background: false }); + // Idle polling is a low-frequency local fallback, never a perpetual service-worker keepalive. + pollTimer = later(poll, NATIVE_FALLBACK_POLL_MS, { background: false }); } function stop() { if (stopped) return; @@ -89,7 +102,7 @@ } document.addEventListener('visibilitychange', schedule); window.addEventListener('pageshow', schedule); - pollTimer = later(poll, 750, { background: false }); + pollTimer = later(poll, NATIVE_FALLBACK_POLL_MS, { background: false }); globalThis.ChatCmdNativeCapture = Object.freeze({ stop, tick, schedule }); Promise.resolve(globalThis.ChatCmdResumeReady).then(() => void tick()); })(); diff --git a/chatgpt-extension/content-chatgpt-native.test.cjs b/chatgpt-extension/content-chatgpt-native.test.cjs index 45166a22..6b582bd7 100644 --- a/chatgpt-extension/content-chatgpt-native.test.cjs +++ b/chatgpt-extension/content-chatgpt-native.test.cjs @@ -63,3 +63,26 @@ test('late native enrollment never adopts a different conversation', async (t) = await pending; assert.deepEqual(adopted, []); }); + +test('assistant streaming does not rescan native user enrollment', async (t) => { + const page = new JSDOM(`${question('u')}
First
`, { + url: 'https://chatgpt.com/c/one', runScripts: 'outside-only', + }); + t.after(() => { page.window.ChatCmdNativeCapture?.stop(); page.window.close(); }); + const w = page.window; + w.ChatCmdRuntime = { sendMessage: async (payload) => payload.type === 'chatcmd-chatgpt-native-turn' + ? { ok: true, ignored: true } : { ok: true } }; + w.ChatCmdController = { current: () => true, active: null, adopt: async () => {} }; + w.eval(source('content-chatgpt-transcript.js')); + w.eval(source('content-chatgpt-native.js')); + await new Promise((resolve) => w.setTimeout(resolve, 0)); + const querySelectorAll = w.document.querySelectorAll.bind(w.document); + let userScans = 0; + w.document.querySelectorAll = (selector) => { + if (String(selector).includes('[data-message-author-role="user"]')) userScans += 1; + return querySelectorAll(selector); + }; + w.document.querySelector('.markdown').textContent = 'Streaming update'; + await new Promise((resolve) => w.setTimeout(resolve, 0)); + assert.equal(userScans, 0); +}); diff --git a/chatgpt-extension/content-chatgpt-observer.js b/chatgpt-extension/content-chatgpt-observer.js index 27a37b51..515af225 100644 --- a/chatgpt-extension/content-chatgpt-observer.js +++ b/chatgpt-extension/content-chatgpt-observer.js @@ -76,8 +76,8 @@ messages = messages.filter((message) => message.content); if (changed) { dirty = true; revision = Math.max(revision + 1, Date.now()); checkpoint(); } } - async function flush(completed = false) { - scan(); + async function flush(completed = false, scanFirst = true) { + if (scanFirst) scan(); if (completed && !complete) { complete = true; dirty = true; revision = Math.max(revision + 1, Date.now()); checkpoint(); } @@ -118,7 +118,7 @@ queued = false; if (stopped || !current()) return; scan(); - if (bound && dirty && !inFlight && Date.now() - lastSend >= 500) void flush(); + if (bound && dirty && !inFlight && Date.now() - lastSend >= 500) void flush(false, false); else scheduleSend(); }); } diff --git a/chatgpt-extension/content-chatgpt-observer.test.cjs b/chatgpt-extension/content-chatgpt-observer.test.cjs index 6922d703..681255e5 100644 --- a/chatgpt-extension/content-chatgpt-observer.test.cjs +++ b/chatgpt-extension/content-chatgpt-observer.test.cjs @@ -170,3 +170,16 @@ test('a provisional WEB identity may become canonical only within the owned user assert.equal(env.sent.at(-1).conversationId, 'canonical-chat'); assert.equal(env.sent.at(-1).messages.length, 1); }); + +test('flush can publish an already scanned revision without rescanning the DOM', async (t) => { + const env = setup(t); + const capture = recorder(t, env); + const querySelectorAll = env.window.document.querySelectorAll.bind(env.window.document); + let queries = 0; + env.window.document.querySelectorAll = (...args) => { queries += 1; return querySelectorAll(...args); }; + capture.scan(); + assert.ok(queries > 0); + const afterScan = queries; + assert.equal(await capture.flush(false, false), false); + assert.equal(queries, afterScan); +}); diff --git a/chatgpt-extension/content-chatgpt.test.cjs b/chatgpt-extension/content-chatgpt.test.cjs index da2892ba..eb51560e 100644 --- a/chatgpt-extension/content-chatgpt.test.cjs +++ b/chatgpt-extension/content-chatgpt.test.cjs @@ -298,6 +298,19 @@ test('identity recovery binds by request id before falling back to prompt text', assert.match(recoverySource, /chrome\.storage\.local\.set/); }); +test('terminal bridge paths release durable recovery state', () => { + const terminalProgress = backgroundIoSource.slice(backgroundIoSource.indexOf("if (message.stage === 'browser-completed')"), backgroundIoSource.indexOf('async function requestContext')); + assert.match(terminalProgress, /releaseRequest\(message\.requestId\);\s*await forgetRecoveryRequest\(message\.requestId\);/); + const failure = backgroundSource.slice(backgroundSource.indexOf('async function reportFailure'), backgroundSource.indexOf('async function handleClosedTab')); + assert.match(failure, /releaseRequest\(requestId\);\s*await forgetRecoveryRequest\(requestId\);/); +}); + +test('reused ChatGPT tabs drop stale conversation bindings after identity refresh', () => { + const refresh = backgroundSource.slice(backgroundSource.indexOf('async function refreshConversationAliases')); + assert.match(refresh, /if \(boundId === liveId\) continue;\s*staleKeys\.push\(key\);/); + assert.match(refresh, /chrome\.storage\.session\.remove\(\[\.\.\.new Set\(staleKeys\)\]\)/); +}); + test('local UI keeps failed dispatches for explicit user control', () => { const composer = readFileSync(join(extensionRoot, '..', 'web', 'src', 'chatgpt', 'ChatGptTaskComposer.tsx'), 'utf8'); assert.doesNotMatch(localUiSource + composer, /RETRY_DELAY_SECONDS|retryTimer/); From 42825a4f06d5d615720c90f86116f743d54d990c Mon Sep 17 00:00:00 2001 From: Type Int04 Date: Tue, 8 Sep 2026 17:56:55 +0700 Subject: [PATCH 4/6] feat(tasks): improve completion and tool detail UI --- web/src/claude-code-theme.css | 2 +- web/src/styles.css | 2 +- web/src/tasks/CompletionQualityCard.tsx | 14 ++++--- web/src/tasks/TaskTurnBubble.tsx | 3 ++ web/src/tasks/taskTimeline.ts | 54 ++++++++++++++++++++++++- 5 files changed, 66 insertions(+), 9 deletions(-) diff --git a/web/src/claude-code-theme.css b/web/src/claude-code-theme.css index c1120438..6104b197 100644 --- a/web/src/claude-code-theme.css +++ b/web/src/claude-code-theme.css @@ -68,7 +68,7 @@ button,input,select,textarea,code,pre,.task-detail-topbar p,.turn-header time,.a .turn-response-content pre,.agent-rich-content pre{color:#d9d4ce;background:#161616;border-color:#363636;border-radius:6px} .turn-response-content a,.agent-rich-content a{color:#dc8a6b;text-decoration-color:#dc8a6b88} .turn-quality-report{margin:24px 0 8px 38px;padding:0 0 4px;border-top:1px solid #4a4641;border-bottom:1px solid #302d29;background:transparent} -.turn-quality-header{min-height:112px;padding:22px 0 20px;display:flex;align-items:flex-start;justify-content:space-between;gap:24px}.turn-quality-header>div{min-width:0}.turn-quality-kicker{display:block;margin-bottom:10px;color:#918a83;font-size:12px;font-weight:800;letter-spacing:.11em;text-transform:uppercase}.turn-quality-header h3{margin:0;color:#f2eee8;font-size:26px;line-height:1.2;letter-spacing:-.035em;font-weight:650}.turn-quality-mark{width:52px;height:52px;display:grid;place-items:center;flex:0 0 auto;color:#aaa}.turn-quality-mark svg{width:30px;height:30px}.turn-quality-report.passed .turn-quality-mark{color:#9fc28f}.turn-quality-report.failed .turn-quality-mark,.turn-quality-report.stale .turn-quality-mark{color:#dd9f93}.turn-quality-report.notRun .turn-quality-mark,.turn-quality-report.notApplicable .turn-quality-mark{color:#d1b17a} +.turn-quality-header{min-height:112px;padding:22px 0 20px;display:flex;align-items:flex-start;justify-content:space-between;gap:24px;cursor:pointer;list-style:none}.turn-quality-header::-webkit-details-marker{display:none}.turn-quality-header:focus-visible{outline:1px solid #6a625b;outline-offset:4px;border-radius:4px}.turn-quality-header>div{min-width:0}.turn-quality-kicker{display:block;margin-bottom:10px;color:#918a83;font-size:12px;font-weight:800;letter-spacing:.11em;text-transform:uppercase}.turn-quality-header h3{margin:0;color:#f2eee8;font-size:26px;line-height:1.2;letter-spacing:-.035em;font-weight:650}.turn-quality-header-actions{display:flex;align-items:center;gap:8px;flex:0 0 auto}.turn-quality-mark{width:52px;height:52px;display:grid;place-items:center;flex:0 0 auto;color:#aaa}.turn-quality-mark svg{width:30px;height:30px}.turn-quality-chevron{width:18px;height:18px;color:#77716b;transition:transform .18s ease}.turn-quality-report[open] .turn-quality-chevron{transform:rotate(180deg)}.turn-quality-report.passed .turn-quality-mark{color:#9fc28f}.turn-quality-report.failed .turn-quality-mark,.turn-quality-report.stale .turn-quality-mark{color:#dd9f93}.turn-quality-report.notRun .turn-quality-mark,.turn-quality-report.notApplicable .turn-quality-mark{color:#d1b17a} .turn-quality-summary{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));border-top:1px solid #302d29;border-bottom:1px solid #302d29}.turn-quality-summary>span{min-width:0;padding:18px 20px 18px 0}.turn-quality-summary>span+span{padding-left:20px;border-left:1px solid #302d29}.turn-quality-summary small{display:block;margin-bottom:7px;color:#7f7871;font-size:11px;font-weight:750;letter-spacing:.06em;text-transform:uppercase}.turn-quality-summary strong{display:block;color:#d9d3cc;font-size:17px;line-height:1.3;font-weight:650} .turn-quality-narrative{padding:20px 0 4px}.turn-quality-narrative section{display:grid;grid-template-columns:140px minmax(0,1fr);gap:20px;padding:14px 0;border-bottom:1px solid #292622}.turn-quality-narrative section>span{color:#8b847d;font-size:11px;font-weight:800;letter-spacing:.06em;text-transform:uppercase}.turn-quality-narrative p{margin:0;color:#c9c3bc;font-size:14px;line-height:1.7;overflow-wrap:anywhere} .turn-quality-section{padding:24px 0 8px}.turn-quality-section>header,.turn-quality-notice>header{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-bottom:8px}.turn-quality-section>header strong,.turn-quality-notice>header strong{color:#ddd7cf;font-size:14px;font-weight:750}.turn-quality-section>header span{color:#817a73;font-size:12px;font-weight:700}.turn-quality-checklist,.turn-quality-evidence,.turn-quality-notice ul{margin:0;padding:0;list-style:none}.turn-quality-checklist li{min-width:0;padding:15px 0;display:grid;grid-template-columns:28px minmax(0,1fr) auto;gap:12px;align-items:center;border-bottom:1px solid #292622}.turn-quality-checklist li>span{color:#817b75}.turn-quality-checklist li.covered>span{color:#8fa87f}.turn-quality-checklist li.uncovered>span{color:#cf8f82}.turn-quality-checklist svg{width:19px;height:19px}.turn-quality-checklist p{margin:0;color:#d2ccc5;font-size:14px;line-height:1.55}.turn-quality-checklist small{color:#827b74;font-size:11px;white-space:nowrap} diff --git a/web/src/styles.css b/web/src/styles.css index 88ff4f05..c4dedc5a 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -302,7 +302,7 @@ html{scrollbar-color:var(--scrollbar-thumb) var(--scrollbar-track)} } .activity-popup-trigger{width:100%;min-height:34px;padding:4px 6px;display:grid;grid-template-columns:16px minmax(0,1fr) auto 16px;align-items:center;gap:5px;border:0;border-radius:8px;background:transparent;color:#a1a1aa;text-align:left;cursor:pointer;font:inherit} .activity-popup-trigger:hover{background:#ffffff05}.activity-popup-trigger:focus-visible{outline:2px solid var(--accent);outline-offset:1px}.activity-popup-trigger .activity-chevron{transform:rotate(-90deg)} -.activity-popup-content{min-width:0;display:grid;gap:12px;padding:0 18px 18px}.activity-popup-content .activity-command{border:1px solid var(--border);border-radius:10px;background:#0c1420}.activity-error-detail{display:grid;gap:10px;padding:13px;border:1px solid color-mix(in srgb,var(--bad) 42%,var(--border));border-radius:10px;background:color-mix(in srgb,var(--bad) 7%,var(--surface))}.activity-error-heading{display:flex;align-items:center;gap:8px;color:var(--bad)}.activity-error-heading svg{width:17px}.activity-error-heading strong{font-size:12px}.activity-error-row{display:flex;align-items:center;gap:10px}.activity-error-row span{color:var(--muted);font-size:10px}.activity-error-row code{padding:3px 7px;border-radius:6px;background:color-mix(in srgb,var(--bad) 9%,var(--surface-2));color:var(--bad);font-size:10px}.activity-error-message{color:var(--text);font-size:11px;line-height:1.55;white-space:pre-wrap;overflow-wrap:anywhere}.activity-error-detail pre{max-height:220px;margin:0;padding:10px;overflow:auto;border:1px solid var(--border);border-radius:8px;background:#070d15;color:#f3b8b8;font:10px/1.55 ui-monospace,SFMono-Regular,Consolas,monospace;white-space:pre-wrap;overflow-wrap:anywhere}.activity-popup-content>pre{max-height:56vh;margin:0;padding:14px;overflow:auto;border:1px solid var(--border);border-radius:10px;background:#070d15;color:#d6e1ee;font:11px/1.6 ui-monospace,SFMono-Regular,Consolas,monospace;white-space:pre-wrap;overflow-wrap:anywhere}.activity-popup-content .task-code-viewer{max-height:62vh;border:1px solid var(--border);border-radius:10px;overflow:hidden}.fs-search-code-results{display:grid;gap:12px;min-width:0}.fs-search-code-result{display:grid;gap:6px;min-width:0}.fs-search-result-path{display:block;overflow:auto;padding:0 2px;color:var(--muted);font:10px/1.45 ui-monospace,SFMono-Regular,Consolas,monospace;white-space:nowrap} +.activity-popup-content{min-width:0;display:grid;gap:12px;padding:0 18px 18px}.activity-popup-content .activity-command{border:1px solid var(--border);border-radius:10px;background:#0c1420}.activity-input-details{overflow:hidden;border:1px solid var(--border);border-radius:10px;background:var(--surface)}.activity-input-details>header{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 12px;border-bottom:1px solid var(--border);background:var(--surface-2)}.activity-input-details>header strong{font-size:11px}.activity-input-details>header code{color:var(--muted);font-size:10px}.activity-input-details dl{margin:0}.activity-input-details dl>div{display:grid;grid-template-columns:minmax(110px,160px) minmax(0,1fr);gap:12px;padding:9px 12px;border-top:1px solid color-mix(in srgb,var(--border) 70%,transparent)}.activity-input-details dl>div:first-child{border-top:0}.activity-input-details dt{color:var(--muted);font-size:10px;font-weight:700}.activity-input-details dd{min-width:0;margin:0;color:var(--text);font-size:11px;line-height:1.5;white-space:pre-wrap;overflow-wrap:anywhere}.activity-input-details dd code{display:block;max-height:180px;overflow:auto;color:var(--code-text);font:10px/1.55 ui-monospace,SFMono-Regular,Consolas,monospace;white-space:pre-wrap;overflow-wrap:anywhere}.activity-error-detail{display:grid;gap:10px;padding:13px;border:1px solid color-mix(in srgb,var(--bad) 42%,var(--border));border-radius:10px;background:color-mix(in srgb,var(--bad) 7%,var(--surface))}.activity-error-heading{display:flex;align-items:center;gap:8px;color:var(--bad)}.activity-error-heading svg{width:17px}.activity-error-heading strong{font-size:12px}.activity-error-row{display:flex;align-items:center;gap:10px}.activity-error-row span{color:var(--muted);font-size:10px}.activity-error-row code{padding:3px 7px;border-radius:6px;background:color-mix(in srgb,var(--bad) 9%,var(--surface-2));color:var(--bad);font-size:10px}.activity-error-message{color:var(--text);font-size:11px;line-height:1.55;white-space:pre-wrap;overflow-wrap:anywhere}.activity-error-detail pre{max-height:220px;margin:0;padding:10px;overflow:auto;border:1px solid var(--border);border-radius:8px;background:#070d15;color:#f3b8b8;font:10px/1.55 ui-monospace,SFMono-Regular,Consolas,monospace;white-space:pre-wrap;overflow-wrap:anywhere}.activity-popup-content>pre{max-height:56vh;margin:0;padding:14px;overflow:auto;border:1px solid var(--border);border-radius:10px;background:#070d15;color:#d6e1ee;font:11px/1.6 ui-monospace,SFMono-Regular,Consolas,monospace;white-space:pre-wrap;overflow-wrap:anywhere}.activity-popup-content .task-code-viewer{max-height:62vh;border:1px solid var(--border);border-radius:10px;overflow:hidden}.fs-search-code-results{display:grid;gap:12px;min-width:0}.fs-search-code-result{display:grid;gap:6px;min-width:0}.fs-search-result-path{display:block;overflow:auto;padding:0 2px;color:var(--muted);font:10px/1.45 ui-monospace,SFMono-Regular,Consolas,monospace;white-space:nowrap} :root[data-theme="light"] .activity-popup-trigger{color:var(--muted)}:root[data-theme="light"] .activity-popup-trigger:hover{background:var(--surface-2)}:root[data-theme="light"] .activity-popup-content>pre{background:var(--surface);color:var(--text)} /* Larger task chat header + sidebar typography. */ diff --git a/web/src/tasks/CompletionQualityCard.tsx b/web/src/tasks/CompletionQualityCard.tsx index ab7254f1..a8bb819e 100644 --- a/web/src/tasks/CompletionQualityCard.tsx +++ b/web/src/tasks/CompletionQualityCard.tsx @@ -1,15 +1,16 @@ -import { CheckCircle2, CircleAlert, CircleHelp, ShieldCheck } from 'lucide-react'; +import { CheckCircle2, ChevronDown, CircleAlert, CircleHelp, ShieldCheck } from 'lucide-react'; import type { CompletionQualityReport, VerificationState } from '../types'; import { outcomeLabel, qualityCopy, verificationLabel } from './completionQualityCopy'; export function CompletionQualityCard({ report }: { report: CompletionQualityReport }) { const labels = qualityCopy(); const coveredCriteria = report.criteria.filter((item) => item.covered).length; - return
-
+ return
+
{labels.quality}

{outcomeLabel(report.workOutcome)}

- {verificationIcon(report.verification)} -
+ {verificationIcon(report.verification)} + +
{labels.verification}{verificationLabel(report.verification)} {labels.criteria}{coveredCriteria}/{report.criteria.length} @@ -23,7 +24,8 @@ export function CompletionQualityCard({ report }: { report: CompletionQualityRep {report.evidence.length > 0 &&
{labels.evidence}{report.evidence.length}
    {report.evidence.map((item) =>
  • {item.command?.executable || item.executionId}{item.exitCode !== undefined && {labels.exit}: {item.exitCode ?? '—'}}
    {item.cwd && {item.cwd}}{item.reason && {labels.reason}: {item.reason}}
  • )}
} {report.blockers.length > 0 && } {report.limitations.length > 0 && } -
; +
+ ; } function QualityNotice({ title, tone, items }: { title: string; tone: 'danger' | 'muted'; items: string[] }) { diff --git a/web/src/tasks/TaskTurnBubble.tsx b/web/src/tasks/TaskTurnBubble.tsx index 2f122d7e..12efe434 100644 --- a/web/src/tasks/TaskTurnBubble.tsx +++ b/web/src/tasks/TaskTurnBubble.tsx @@ -19,6 +19,7 @@ import { activityDuration, activityLabel, activityOutput, + activityInputDetails, buildProcessBlocks, duration, eventText, @@ -299,11 +300,13 @@ function ActivityPopupContent({ activity, approvalPending, running }: { activity const failed = activity.status === 'failed'; const command = activityCommand(activity); const output = activityOutput(activity); + const inputDetails = activityInputDetails(activity); const searchCodeViews = fsSearchCodeViews(activity); const diffView = activityDiffView(activity); const codeView = activityCodeView(activity); return
{command}
+ {inputDetails.length > 0 &&
{tr('Request details')}{activity.tool}
{inputDetails.map((item) =>
{item.label}
{item.code ? {item.value} : item.value}
)}
} {failed &&
{activity.errorCode &&
{tr('Error code')}{activity.errorCode}
} diff --git a/web/src/tasks/taskTimeline.ts b/web/src/tasks/taskTimeline.ts index 8dd4e78f..2a426b59 100644 --- a/web/src/tasks/taskTimeline.ts +++ b/web/src/tasks/taskTimeline.ts @@ -275,12 +275,64 @@ export function activityCommand(activity: ToolActivity) { const command = stringValue(input.command); if (command) return command; const path = stringValue(input.path); + const query = stringValue(input.query); + const pattern = stringValue(input.pattern); + if (activity.tool === 'fs_search' && query) return `${activity.tool}: ${JSON.stringify(query)}${path ? ` in ${path}` : ''}`; + if (activity.tool === 'fs_find' && pattern) return `${activity.tool}: ${JSON.stringify(pattern)}${path ? ` in ${path}` : ''}`; if (path) return `${activity.tool}: ${path}`; const legacy = legacyTerminalParts(activity.output); if (legacy.command) return legacy.command; - return activity.input === undefined ? activity.tool : `${activity.tool} ${formatValue(activity.input)}`; + const summary = activityInputDetails(activity).slice(0, 2).map((item) => `${item.label}: ${item.value.replace(/\n/g, ', ')}`).join(' · '); + return summary ? `${activity.tool} — ${summary}` : activity.tool; } +export interface ActivityInputDetail { label: string; value: string; code?: boolean } +export function activityInputDetails(activity: ToolActivity): ActivityInputDetail[] { + const input = asObject(activity.input); + if (!Object.keys(input).length) return []; + const details: ActivityInputDetail[] = []; + const labels: Record = { + query: tr('Search query'), pattern: tr('Pattern'), path: tr('Path'), patternMode: tr('Pattern mode'), mode: tr('Mode'), + include: tr('Include'), exclude: tr('Exclude'), extensions: tr('Extensions'), caseSensitive: tr('Case sensitive'), wordBoundary: tr('Whole word'), + contextBefore: tr('Context before'), contextAfter: tr('Context after'), limit: tr('Limit'), maxResults: tr('Max results'), maxDepth: tr('Max depth'), + executable: tr('Executable'), arguments: tr('Arguments'), cwd: tr('Working directory'), workingDirectory: tr('Working directory'), timeoutMs: tr('Timeout'), + startLine: tr('Start line'), lineCount: tr('Line count'), maxCharacters: tr('Max characters'), range: tr('Range'), revision: tr('Revision'), staged: tr('Staged'), stat: tr('Stats'), + source: tr('Source'), destination: tr('Destination'), recursive: tr('Recursive'), dryRun: tr('Dry run'), overwrite: tr('Overwrite'), conflictPolicy: tr('Conflict policy'), + }; + const hidden = new Set(['content', 'oldText', 'newText', 'patch', 'dataBase64', 'contentRef', 'expectedPreview', 'environment']); + const preferred = ['query', 'pattern', 'path', 'patternMode', 'mode', 'include', 'exclude', 'extensions', 'caseSensitive', 'wordBoundary', 'contextBefore', 'contextAfter', 'limit', 'maxResults', 'maxDepth', 'executable', 'arguments', 'cwd', 'workingDirectory', 'timeoutMs', 'startLine', 'lineCount', 'maxCharacters', 'range', 'revision', 'staged', 'stat', 'source', 'destination', 'recursive', 'dryRun', 'overwrite', 'conflictPolicy']; + const keys = preferred.filter((key) => key in input && !hidden.has(key)); + const technical = new Set(['path', 'source', 'destination', 'cwd', 'workingDirectory', 'executable', 'pattern', 'query']); + for (const key of keys) { + const value = input[key]; + if (value === undefined || value === null || value === '') continue; + const text = formatInputDetail(value, key); + if (text) details.push({ label: labels[key] || humanToolName(key), value: text, code: technical.has(key) }); + } + return details.slice(0, 16); +} + +function formatInputDetail(value: unknown, key = ''): string { + if (typeof value === 'boolean') return value ? tr('Yes') : tr('No'); + if (typeof value === 'number') return key === 'timeoutMs' ? friendlyDuration(value) : /bytes/i.test(key) ? friendlyBytes(value) : /context|line|maxDepth/i.test(key) ? `${formatAppNumber(value)} ${appLocale().startsWith('vi') ? 'dòng' : 'lines'}` : formatFriendlyNumber(value); + if (typeof value === 'string') return truncate(value, 1200); + if (Array.isArray(value)) return truncate(value.map((item) => formatInputDetail(item)).filter(Boolean).join(', '), 1600); + const entries = Object.entries(asObject(value)).filter(([, item]) => item !== undefined && item !== null && item !== ''); + return truncate(entries.slice(0, 8).map(([childKey, item]) => `${friendlyInputLabel(childKey)}: ${formatInputDetail(item, childKey)}`).join(' · '), 1800); +} + +function friendlyInputLabel(key: string) { + const labels: Record = { + timeoutMs: tr('Timeout'), maxBytesRead: tr('Max bytes read'), maxBytesWritten: tr('Max bytes written'), maxFiles: tr('Max files'), maxOpenFiles: tr('Max open files'), + start: tr('Start'), limit: tr('Limit'), unit: tr('Unit'), enabled: tr('Enabled'), min: tr('Minimum'), max: tr('Maximum'), + }; + return labels[key] || humanToolName(key).replace(/\b\w/g, (char) => char.toUpperCase()); +} + +function formatFriendlyNumber(value: number) { return value >= 1000 && value % 1000 === 0 ? formatAppNumber(value) : String(value); } +function friendlyDuration(ms: number) { return ms >= 1000 && ms % 1000 === 0 ? `${formatAppNumber(ms / 1000)} ${appLocale().startsWith('vi') ? 'giây' : 'seconds'}` : `${formatAppNumber(ms)} ms`; } +function friendlyBytes(bytes: number) { return bytes >= 1_048_576 ? `${(bytes / 1_048_576).toFixed(bytes % 1_048_576 ? 1 : 0)} MB` : bytes >= 1024 ? `${(bytes / 1024).toFixed(bytes % 1024 ? 1 : 0)} KB` : `${formatAppNumber(bytes)} B`; } + export function activityOutput(activity: ToolActivity) { if (activity.output === undefined) return activity.error ? activity.error : ''; if (activity.tool === 'fs_search' && Array.isArray(activity.output)) { From e63387f48ec7332d63963594fe2034622e2bc4d2 Mon Sep 17 00:00:00 2001 From: Type Int04 Date: Tue, 8 Sep 2026 18:44:58 +0700 Subject: [PATCH 5/6] Allow user terminal input during AI wait handoff --- crates/chatcmd-mcp/src/tool_args/basic.rs | 5 +- crates/chatcmd-mcp/src/tool_methods.rs | 2 +- src/api/sessions.rs | 13 +++- src/runtime_host/activity_control.rs | 87 ++++++++++++++++++++++ src/runtime_host/dispatch.rs | 5 +- src/runtime_host/dispatch/shell_handoff.rs | 44 +++++++++++ src/runtime_host/inputs.rs | 2 + web/src/pages/LiveTerminalPage.tsx | 4 +- web/src/tasks/TaskTerminalSection.tsx | 8 +- web/src/types.ts | 2 +- 10 files changed, 158 insertions(+), 14 deletions(-) create mode 100644 src/runtime_host/dispatch/shell_handoff.rs diff --git a/crates/chatcmd-mcp/src/tool_args/basic.rs b/crates/chatcmd-mcp/src/tool_args/basic.rs index 7150a797..de5758db 100644 --- a/crates/chatcmd-mcp/src/tool_args/basic.rs +++ b/crates/chatcmd-mcp/src/tool_args/basic.rs @@ -268,7 +268,10 @@ tool_args!(ShellWriteArgs { tool_args!(ShellWaitArgs { session_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] - timeout_ms: Option + timeout_ms: Option, + /// Yield PTY stdin to the local user while this wait is active. + #[serde(default, skip_serializing_if = "Option::is_none")] + allow_user_input: Option }); tool_args!(ShellReadArgs { session_id: String, diff --git a/crates/chatcmd-mcp/src/tool_methods.rs b/crates/chatcmd-mcp/src/tool_methods.rs index ab552369..06b80f2f 100644 --- a/crates/chatcmd-mcp/src/tool_methods.rs +++ b/crates/chatcmd-mcp/src/tool_methods.rs @@ -63,7 +63,7 @@ tool_methods!( ( shell_wait, ShellWaitArgs, - "Wait without killing the PTY when timeout expires. Required field: sessionId; optional timeoutMs." + "Wait without killing the PTY when timeout expires. Required field: sessionId; optional timeoutMs. Set allowUserInput=true only when the terminal has reached a password, confirmation, or other manual-input prompt and the local user should type directly; while that wait is active, stdin is yielded to the user. When the user submits a line, the wait returns early with completed=false and waitTimedOut=false so the Agent can read the new terminal output and continue. Default false." ), ( shell_read, diff --git a/src/api/sessions.rs b/src/api/sessions.rs index b8a835d2..d1007ebe 100644 --- a/src/api/sessions.rs +++ b/src/api/sessions.rs @@ -55,6 +55,7 @@ pub(super) async fn live_terminals( "cpuPercent": process.map(|value| value.cpu_usage()), "memoryBytes": process.map(|value| value.memory()), "busy": state.activities.is_shell_busy(&info.session_id), + "inputAllowed": state.activities.is_shell_input_allowed(&info.session_id), "lastSequence": info.last_sequence })); } @@ -154,11 +155,11 @@ pub(super) async fn terminal_input( "terminal input cannot be empty", )); } - if state.activities.is_shell_busy(&id) { + if !state.activities.is_shell_input_allowed(&id) { return Err(Problem::new( StatusCode::CONFLICT, - "Terminal is busy", - "the Agent is currently using this terminal", + "Terminal input is locked", + "the Agent is currently using this terminal without yielding input", )); } let context = chatcmd_runtime::OperationContext::new( @@ -166,13 +167,14 @@ pub(super) async fn terminal_input( "local-ui", "shell_write", ); + let submitted = input.text.contains('\r') || input.text.contains('\n'); let written = state .shell .write( &context, chatcmd_runtime::ShellWriteRequest { request_id: context.request_id.clone(), - session_id: id, + session_id: id.clone(), text: input.text, append_new_line: false, input_kind: chatcmd_runtime::ShellInputKind::Interactive, @@ -181,6 +183,9 @@ pub(super) async fn terminal_input( ) .await .map_err(runtime_problem)?; + if submitted { + state.activities.notify_shell_user_input(&id); + } Ok(Json(json!({ "accepted": true, "writtenBytes": written }))) } diff --git a/src/runtime_host/activity_control.rs b/src/runtime_host/activity_control.rs index 3a2c5882..132bc89a 100644 --- a/src/runtime_host/activity_control.rs +++ b/src/runtime_host/activity_control.rs @@ -5,6 +5,7 @@ use std::{ use chatcmd_runtime::OperationContext; use serde_json::Value; +use tokio::sync::Notify; #[derive(Clone, Default)] pub(crate) struct ActivityRegistry { @@ -17,6 +18,8 @@ pub(crate) struct ActiveActivity { pub context: OperationContext, pub tool: String, pub shell_session_id: Option, + allow_user_input: bool, + user_input_notify: Option>, stop_reason: Arc>>, } @@ -90,6 +93,8 @@ impl ActivityRegistry { return None; } let activity_id = context.request_id.clone(); + let allow_user_input = tool == "shell_wait" + && arguments.get("allowUserInput").and_then(Value::as_bool) == Some(true); let activity = ActiveActivity { context: context.clone(), tool: tool.to_owned(), @@ -97,6 +102,8 @@ impl ActivityRegistry { .get("sessionId") .and_then(Value::as_str) .map(str::to_owned), + allow_user_input, + user_input_notify: allow_user_input.then(|| Arc::new(Notify::new())), stop_reason: Arc::new(Mutex::new(None)), }; self.active @@ -149,6 +156,45 @@ impl ActivityRegistry { }) } + pub(crate) fn is_shell_input_allowed(&self, session_id: &str) -> bool { + let Ok(active) = self.active.lock() else { + return false; + }; + let mut matching = active + .values() + .filter(|activity| activity.shell_session_id.as_deref() == Some(session_id)) + .peekable(); + if matching.peek().is_none() { + return true; + } + matching.all(|activity| activity.allow_user_input) + } + + pub(crate) fn shell_user_input_notifier(&self, activity_id: &str) -> Option> { + self.active + .lock() + .ok()? + .get(activity_id)? + .user_input_notify + .clone() + } + + pub(crate) fn notify_shell_user_input(&self, session_id: &str) { + let notifiers = self.active.lock().map_or_else( + |_| Vec::new(), + |active| { + active + .values() + .filter(|activity| activity.shell_session_id.as_deref() == Some(session_id)) + .filter_map(|activity| activity.user_input_notify.clone()) + .collect::>() + }, + ); + for notifier in notifiers { + notifier.notify_one(); + } + } + pub(crate) fn has_active_turn(&self, task_id: &str, turn_id: &str) -> bool { self.active.lock().is_ok_and(|active| { active.values().any(|activity| { @@ -240,4 +286,45 @@ mod tests { context.cancellation.cancel(); assert!(context.cancellation.is_cancelled()); } + + #[tokio::test] + async fn shell_input_requires_explicit_wait_handoff() { + let registry = ActivityRegistry::default(); + let context = OperationContext::new("wait-1", "agent", "shell_wait"); + assert!(registry.is_shell_input_allowed("shell-1")); + + let wait_guard = registry + .register( + &context, + "shell_wait", + &json!({"sessionId":"shell-1","allowUserInput":true}), + ) + .unwrap(); + assert!(registry.is_shell_busy("shell-1")); + assert!(registry.is_shell_input_allowed("shell-1")); + let notifier = registry.shell_user_input_notifier("wait-1").unwrap(); + registry.notify_shell_user_input("shell-1"); + tokio::time::timeout(std::time::Duration::from_millis(50), notifier.notified()) + .await + .unwrap(); + + let read_context = OperationContext::new("read-1", "agent", "shell_read"); + let read_guard = registry + .register(&read_context, "shell_read", &json!({"sessionId":"shell-1"})) + .unwrap(); + assert!(!registry.is_shell_input_allowed("shell-1")); + + drop(read_guard); + assert!(registry.is_shell_input_allowed("shell-1")); + drop(wait_guard); + assert!(!registry.is_shell_busy("shell-1")); + assert!(registry.is_shell_input_allowed("shell-1")); + + let normal_wait = registry + .register(&context, "shell_wait", &json!({"sessionId":"shell-1"})) + .unwrap(); + assert!(!registry.is_shell_input_allowed("shell-1")); + assert!(registry.shell_user_input_notifier("wait-1").is_none()); + drop(normal_wait); + } } diff --git a/src/runtime_host/dispatch.rs b/src/runtime_host/dispatch.rs index c61136fd..26990694 100644 --- a/src/runtime_host/dispatch.rs +++ b/src/runtime_host/dispatch.rs @@ -7,6 +7,7 @@ mod artifact_tools; mod command_tools; mod filesystem_tools; mod helpers; +mod shell_handoff; mod tool_authorization; use chatcmd_core::{ @@ -158,11 +159,11 @@ impl RuntimeHost { "shell_wait" => { let input: ShellWait = parse(arguments)?; let (result, usage) = self - .shell - .wait_with_context( + .wait_for_shell_handoff( &context, &input.session_id, Duration::from_millis(input.timeout_ms.clamp(1, 300_000)), + input.allow_user_input, ) .await?; if result.completed { diff --git a/src/runtime_host/dispatch/shell_handoff.rs b/src/runtime_host/dispatch/shell_handoff.rs new file mode 100644 index 00000000..ac6170f0 --- /dev/null +++ b/src/runtime_host/dispatch/shell_handoff.rs @@ -0,0 +1,44 @@ +use std::time::Duration; + +use chatcmd_runtime::{OperationContext, RuntimeError, RuntimeResult, ShellWaitResult, ToolUsage}; + +use super::super::RuntimeHost; + +impl RuntimeHost { + pub(super) async fn wait_for_shell_handoff( + &self, + context: &OperationContext, + session_id: &str, + timeout: Duration, + allow_user_input: bool, + ) -> RuntimeResult<(ShellWaitResult, ToolUsage)> { + if !allow_user_input { + return self + .shell + .wait_with_context(context, session_id, timeout) + .await; + } + let notifier = self + .activities + .shell_user_input_notifier(&context.request_id) + .ok_or_else(|| { + RuntimeError::new( + "shell_input_handoff_unavailable", + "terminal input handoff is not active for this shell wait", + ) + })?; + tokio::select! { + result = self.shell.wait_with_context(context, session_id, timeout) => result, + () = notifier.notified() => { + let (mut result, usage) = self + .shell + .wait_with_context(context, session_id, Duration::from_millis(1)) + .await?; + if !result.completed { + result.wait_timed_out = false; + } + Ok((result, usage)) + } + } + } +} diff --git a/src/runtime_host/inputs.rs b/src/runtime_host/inputs.rs index adafc788..47db3f76 100644 --- a/src/runtime_host/inputs.rs +++ b/src/runtime_host/inputs.rs @@ -222,6 +222,8 @@ pub(super) struct ShellWait { pub(super) session_id: String, #[serde(default = "default_timeout")] pub(super) timeout_ms: u64, + #[serde(default)] + pub(super) allow_user_input: bool, } const fn default_timeout() -> u64 { diff --git a/web/src/pages/LiveTerminalPage.tsx b/web/src/pages/LiveTerminalPage.tsx index 859d23fb..39ceb682 100644 --- a/web/src/pages/LiveTerminalPage.tsx +++ b/web/src/pages/LiveTerminalPage.tsx @@ -122,7 +122,7 @@ export function LiveTerminalPage() { }, [refreshMetadata]); useEffect(() => { - if (terminalRef.current) terminalRef.current.options.disableStdin = !metadata.data || Boolean(metadata.data.busy); + if (terminalRef.current) terminalRef.current.options.disableStdin = !metadata.data || (Boolean(metadata.data.busy) && metadata.data.inputAllowed !== true); }, [metadata.data]); const close = async () => { @@ -145,7 +145,7 @@ export function LiveTerminalPage() {
{terminal?.workingDirectory ?? tr('Terminal')}
PID {terminal?.processId ?? '—'}{terminal?.cpuPercent == null ? '—' : `${terminal.cpuPercent.toFixed(1)}%`}{formatBytes(terminal?.memoryBytes)}
-
{!terminal ? tr('This terminal is no longer active.') : terminal.busy ? tr('The Agent is currently using this terminal. Input is temporarily locked.') : tr('Interactive input is enabled for this live terminal.')}{terminal?.taskId && {tr('Open task')}}
+
{!terminal ? tr('This terminal is no longer active.') : terminal.busy && terminal.inputAllowed !== true ? tr('The Agent is currently using this terminal. Input is temporarily locked.') : tr('Interactive input is enabled for this live terminal.')}{terminal?.taskId && {tr('Open task')}}
; } diff --git a/web/src/tasks/TaskTerminalSection.tsx b/web/src/tasks/TaskTerminalSection.tsx index 99313cf5..f8877927 100644 --- a/web/src/tasks/TaskTerminalSection.tsx +++ b/web/src/tasks/TaskTerminalSection.tsx @@ -37,6 +37,7 @@ export function TaskTerminalSection({ taskId, turnId }: { taskId: string; turnId workingDirectory: typeof payload?.workingDirectory === 'string' ? payload.workingDirectory : undefined, createdAtUtc: typeof payload?.createdAtUtc === 'string' ? payload.createdAtUtc : event.occurredAt, busy: false, + inputAllowed: true, lastSequence: typeof payload?.lastSequence === 'number' ? payload.lastSequence : 0, }; setTerminals((current) => [next, ...current.filter((item) => item.id !== id)]); @@ -56,7 +57,8 @@ export function TaskTerminalSection({ taskId, turnId }: { taskId: string; turnId const id = typeof input?.sessionId === 'string' ? input.sessionId : typeof output?.sessionId === 'string' ? output.sessionId : undefined; if (!id) return; const busy = event.type === 'tool_call'; - setTerminals((current) => current.map((item) => item.id === id ? { ...item, busy } : item)); + const inputAllowed = !busy || (tool === 'shell_wait' && input?.allowUserInput === true); + setTerminals((current) => current.map((item) => item.id === id ? { ...item, busy, inputAllowed } : item)); }, [taskId, turnId]); useRealtime(handleRealtime); @@ -138,14 +140,14 @@ function TaskTerminalModal({ terminal, onClose }: { terminal: Session; onClose: return () => { cancelled = true; }; }, [ready, sessionId]); - useEffect(() => { if (terminalRef.current) terminalRef.current.options.disableStdin = Boolean(terminal.busy); }, [terminal.busy]); + useEffect(() => { if (terminalRef.current) terminalRef.current.options.disableStdin = Boolean(terminal.busy) && terminal.inputAllowed !== true; }, [terminal.busy, terminal.inputAllowed]); useEffect(() => { const onKey = (event: KeyboardEvent) => { if (event.key === 'Escape') onClose(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [onClose]); return
{ if (event.target === event.currentTarget) onClose(); }}>
{terminal.shell ?? tr('Terminal')}{sessionId}
setProblem('')} /> -
{terminal.workingDirectory ?? tr('Terminal')}
PID {terminal.processId ?? '—'}{terminal.cpuPercent == null ? '—' : `${terminal.cpuPercent.toFixed(1)}%`}{formatBytes(terminal.memoryBytes)}
{terminal.busy ? tr('The Agent is currently using this terminal. Input is temporarily locked.') : tr('Interactive input is enabled for this live terminal.')}
+
{terminal.workingDirectory ?? tr('Terminal')}
PID {terminal.processId ?? '—'}{terminal.cpuPercent == null ? '—' : `${terminal.cpuPercent.toFixed(1)}%`}{formatBytes(terminal.memoryBytes)}
{terminal.busy && terminal.inputAllowed !== true ? tr('The Agent is currently using this terminal. Input is temporarily locked.') : tr('Interactive input is enabled for this live terminal.')}
; } diff --git a/web/src/types.ts b/web/src/types.ts index 21855f4a..c3e26a88 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -44,7 +44,7 @@ export interface ApprovalGrant { id: Id; allowedTools: string[]; pathScopes: { p export interface TaskDetail { task: Task; turns?: TaskTurn[]; events?: TimelineEvent[]; nextCursor?: string; subagents?: SubagentRun[]; subagentApprovals?: SubagentApproval[]; approvalGrants?: ApprovalGrant[]; executionMode?: CommandExecutionMode; executionModeSourceTaskId?: Id } export interface TaskActivityDetail { input?: unknown; output?: unknown; status?: string; error?: string; errorCode?: string; errorMessage?: string; errorDetails?: unknown } export interface TaskTurn { id: Id; generation?: number; actor?: string; status?: string; startedAtUtc?: string; completedAtUtc?: string; events?: TimelineEvent[] } -export interface Session { kind: 'mcp' | 'terminal'; id: Id; taskId?: Id; turnId?: Id; shell?: string; processId?: number; status: string; workingDirectory?: string; createdAtUtc?: string; updatedAtUtc?: string; closedAtUtc?: string; replayCursor?: string; cpuPercent?: number; memoryBytes?: number; busy?: boolean; lastSequence?: number } +export interface Session { kind: 'mcp' | 'terminal'; id: Id; taskId?: Id; turnId?: Id; shell?: string; processId?: number; status: string; workingDirectory?: string; createdAtUtc?: string; updatedAtUtc?: string; closedAtUtc?: string; replayCursor?: string; cpuPercent?: number; memoryBytes?: number; busy?: boolean; inputAllowed?: boolean; lastSequence?: number } export interface SessionDetail { session: Session; events: TimelineEvent[]; nextCursor?: string; truncated?: boolean } export interface LiveTerminalEvent { sequence: number; occurredAtUtc: string; stream: string; data: string; encoding: 'utf-8' | 'base64' } export interface LiveTerminalOutput { sessionId: Id; oldestAvailableSequence: number; latestAvailableSequence: number; replayTruncated: boolean; droppedBytes: number; droppedEvents: number; events: LiveTerminalEvent[] } From 64f37779bd2afe415c7e7e59a782b2c45b498403 Mon Sep 17 00:00:00 2001 From: Type Int04 Date: Tue, 8 Sep 2026 20:50:39 +0700 Subject: [PATCH 6/6] Fix task scope and preserve ChatGPT conversation binding --- chatgpt-extension/background.js | 21 ++- chatgpt-extension/manifest.json | 2 +- crates/chatcmd-mcp/src/server_contract.rs | 14 +- crates/chatcmd-mcp/src/subagent_worker.rs | 14 +- .../src/subagent_worker_test_cases.rs | 28 +-- crates/chatcmd-mcp/src/tool_methods.rs | 2 +- .../tests/coding_fixtures/cases.json | 18 +- .../tests/release_catalog_smoke.rs | 2 +- .../chatcmd-runtime/src/shell/operations.rs | 4 - .../chatcmd-runtime/tests/direct_runtime.rs | 20 +- docs/coding-agent-contract.md | 8 +- docs/mcp_method.md | 2 +- src/api/chatgpt.rs | 7 + src/api/chatgpt_completion.rs | 6 + src/api/chatgpt_result.rs | 1 + src/api/chatgpt_router_tests.rs | 51 ++++++ src/api/chatgpt_support.rs | 35 +++- src/api/subagent_fallback.rs | 15 +- src/runtime_host/approval/authorization.rs | 1 + src/runtime_host/approval/common.rs | 2 + src/runtime_host/approval/grants.rs | 1 + src/runtime_host/approval/helpers.rs | 2 +- src/runtime_host/approval/tests.rs | 1 + src/runtime_host/dispatch.rs | 9 +- src/runtime_host/git_support.rs | 21 ++- src/runtime_host/git_tests.rs | 2 +- src/runtime_host/identity.rs | 172 ++++++------------ src/runtime_host/identity_tests.rs | 82 +++++++++ src/runtime_host/user_message.rs | 6 +- src/runtime_host/user_message_path_tests.rs | 4 +- web/src/chatgptBridge.ts | 6 +- web/src/extensions/copy.ts | 4 +- .../tasks/GlobalSubagentFallbackBridge.tsx | 8 +- 33 files changed, 354 insertions(+), 217 deletions(-) create mode 100644 src/runtime_host/identity_tests.rs diff --git a/chatgpt-extension/background.js b/chatgpt-extension/background.js index f583af97..df87aa7a 100644 --- a/chatgpt-extension/background.js +++ b/chatgpt-extension/background.js @@ -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)]: { @@ -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 }, }); @@ -384,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 }; } @@ -409,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) { @@ -439,6 +446,8 @@ 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 staleKeys = []; for (const [key, binding] of Object.entries(bindings)) { diff --git a/chatgpt-extension/manifest.json b/chatgpt-extension/manifest.json index 1e3e2077..b5c0dc96 100644 --- a/chatgpt-extension/manifest.json +++ b/chatgpt-extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "ChatCMD ChatGPT Bridge", - "version": "0.1.9", + "version": "0.1.10", "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/*"], diff --git a/crates/chatcmd-mcp/src/server_contract.rs b/crates/chatcmd-mcp/src/server_contract.rs index 6158b94b..6c8f3985 100644 --- a/crates/chatcmd-mcp/src/server_contract.rs +++ b/crates/chatcmd-mcp/src/server_contract.rs @@ -10,7 +10,7 @@ use super::McpServer; pub(crate) mod instructions; -const SERVER_INSTRUCTIONS: &str = "IDENTITY: one ChatGPT chat equals one ChatCMD task; one user message equals one turn. Generate one unique turnId for each user message and reuse it unchanged for every ChatCMD call in that message. FIRST TOOL RULE: before calling any other ChatCMD tool in a user turn, call agent_user_message with the exact current user message text as content and that turnId. Do not summarize, rewrite, or omit the user's text. Reuse the newest taskId returned in this ChatGPT chat; omit taskId only when this chat has never returned one. ChatCMD validates the private ChatGPT conversation identity server-side; a stale taskId from another chat must not merge two chats. The server rejects other tools until the current turn's user message has been synchronized. Call agent_user_message exactly once per user turn. Never use agent_user_message for progress, reflections, findings, or commentary after tool results; use agent_progress for those updates. TOOL DISCOVERY RECOVERY RULE: ChatCMD exposes a broad, stable tool catalog and the host may lazy-load only a subset of tool schemas in a turn. A schema that is not currently visible is not evidence that the MCP server lost that tool. If a ChatCMD tool required to complete the user's request or any rule below is not currently visible or loaded, use the host's connector/resource discovery mechanism to discover and load that tool in the same turn, then continue the work. On ChatGPT connector hosts, use the connector discovery entrypoint available to the model (for example api_tool.list_resources) on the current connector with a focused query such as fs_, shell_, git_, skill, task, or agent. Before replying that a tool is unavailable, missing, not loaded, or cannot be used in the current turn, you MUST attempt discovery at least once for the needed capability in that same turn. Do not stop, defer implementation, or ask the user to send another message merely because a needed tool schema has not been loaded yet. SKILL RULE: after agent_user_message and before repository inspection, design decisions, code changes, or other non-trivial project work, call skills_list once to discover available .agents and .codex skills. Compare the returned skill descriptions with the current user request and intended work. If any skill matches, call skill_read for every relevant matching skill before doing the matching work, then follow those skill instructions. A directly matching skill is mandatory, not optional; do not infer its instructions from the skill name or description alone. For example, UI/color/layout/accessibility work must read a matching UI/UX skill when present, and Rust implementation/review work must read a matching Rust skill when present. Skip skill discovery only for trivial conversational turns or turns that do not require project work. INITIAL ACK RULE: for every non-trivial user request, immediately after agent_user_message and before skills_list or any other substantive tool call, call agent_progress once with a concise summary of what the user asked for and what you are going to do next. This first acknowledgement is mandatory even when the task seems obvious; do not postpone it until after repository inspection or tool results. PLAN MODE RULE: inspect planMode returned by agent_user_message. When planMode=true, the user explicitly asked for planning (for example 'Lên kế hoạch', 'Lập kế hoạch', or #plan) and you MUST build a detailed plan rather than treating the request as an ordinary execution request. First analyze all information already supplied and use relevant read-only/project inspection when it can answer uncertainties without bothering the user. Ask only missing information that materially changes the plan. Ask each clarification with agent_plan_question, exactly one question at a time with exactly two distinct options; that tool waits inside the SAME current turn for up to 120 seconds and the user may also provide a custom answer in the UI. A plan question is not a new user turn: never call agent_user_message again for its answer and never stop merely to ask the user to send another chat message. When agent_plan_question returns a user answer, before any further reasoning or tool call immediately call agent_progress with the exact agentProgressMessage returned by that tool. If it returns timedOut=true, choose one of its two options yourself, immediately report the question plus your chosen answer through agent_progress, and continue. Repeat only while genuinely plan-changing information is still missing. For programming, file-editing, deployment, command execution, or any other request whose planned work you can perform, after all other clarifications and before any modifying/execution action, ask one final agent_plan_question: 'Bạn có muốn mình thực hiện luôn công việc trong kế hoạch này không?' with options ['Có', 'Không']. Read-only inspection needed to understand the work is allowed before this consent; modifying files, running mutating commands, deployments, commits, or other planned side effects are not. If the answer is 'Không', return the detailed plan without executing it. If the answer is 'Có', form the detailed plan first and then execute that plan in the same turn, still following normal safety/approval/progress rules. Do not ask the execution-consent question for advice-only plans that have no action you can perform. Never finalize while an agent_plan_question call is pending. PROGRESS CADENCE RULE: for every non-trivial project turn, agent_progress is mandatory throughout the entire turn, not only near the beginning. After the initial acknowledgement, aim for a progress checkpoint after roughly 2-4 substantive operations or at the end of one coherent batch of tightly related low-level calls; prefer meaningful milestones over mechanical per-tool updates so progress reporting does not materially slow execution. A substantive call includes repository/file inspection, search, edit/create/delete, shell/process work, Git work, build/test/lint, deployment, or another operation that advances the task. POST-ACTION REFLECTION RULE: after finishing a meaningful file read/code inspection or a coherent batch of tightly related reads/searches, call agent_progress with the concrete understanding or finding you just gained before moving into a new substantive phase. Immediately after successfully editing or creating a file, call agent_progress with what changed and the relevant effect before continuing. Immediately after a build, test, lint, search, Git operation, command, deployment, or other verification step returns a meaningful result, call agent_progress with that concrete result before starting the next substantive operation. SHELL PENDING RULE: when shell_wait or shell_read shows a long-running command is still pending and more polling is needed, send agent_progress with what command/process is running, the current known stage/output, and what result you are waiting for or will check next. Do not repeat an identical progress update for rapid consecutive polls; one update may cover a short polling loop until the state/output changes materially or a noticeable wait has elapsed. ERROR RECOVERY RULE: whenever any tool, command, build, test, lint, Git operation, deployment, or verification step returns an error, non-zero exit code, rejection, or other task-relevant failure, call agent_progress before retrying, changing approach, or invoking a fallback. The progress message must identify the failed operation, summarize the observable error, state whether a likely cause is known, and say what recovery or alternative approach you will try next; if no safe alternative is available, say so. Never silently retry after an error. STRONG PROGRESS HABIT: treat progress updates as an AI execution discipline rather than a server-side gate. Prefer calling agent_progress after fs_find/fs_search/fs_read_text and other meaningful filesystem results before moving to the next substantive read/search/edit, after pending shell polling, and before retrying a failed operation. Do not let progress messaging block or materially slow the actual task; when several tightly related low-level operations form one coherent step, group them and report the meaningful checkpoint rather than adding unnecessary round trips. These progress messages must summarize observable results and decisions, not private chain-of-thought. Do not emit progress for tiny mechanical no-ops or duplicate pagination chunks unless a dedicated rule above requires it. MIRROR RULE: whenever you are about to emit a user-visible commentary/progress/update message about current work, findings, next steps, phase changes, long-running operations, or completion status before the final answer, first call agent_progress with a concise message carrying the same substantive information. Do not emit multiple user-visible progress/commentary updates in a row without mirroring each distinct milestone through agent_progress. If a commentary update contains only conversational filler and no substantive project status, omit the commentary instead of sending an unmirrored status. This mirror requirement applies only to user-visible progress summaries, never to private chain-of-thought, hidden reasoning, or internal scratch work. Progress messages must be concise, concrete, user-visible summaries of the current work or confirmed findings; do not expose private chain-of-thought and do not send generic filler such as 'Working on it' or 'Please wait'. Never call agent_progress after agent_turn_complete. TOOL ARGUMENT RULE: treat each tool's generated JSON schema as the canonical contract. Use the canonical field names shown by the schema and never invent a field name from an output object or from another tool. Compatibility aliases may be accepted by the server, but do not prefer them over the schema. PATH RULE: an existing absolute filesystem path explicitly present in any user message of the current ChatCMD task is a task-scoped access grant for that exact file or directory subtree, even when it is outside configured workspace roots. Use it directly when relevant, including in later turns such as when the user says to continue. Never widen that grant to a parent, sibling, different drive, or another path the user did not write; a path from another task/chat is not granted. PROJECT CONTEXT RULE: project/workspace context belongs to the current task/conversation, never to the Agent. Before filesystem, Git, repository, codebase, or project shell work, if the current task does not already have a project folder and the user has not supplied an explicit absolute work path, do not guess or infer a folder from the Agent, workspace_roots, current process directory, another task, or a previously used project. Ask the user to provide the project folder or absolute work path first, and do not call filesystem, Git, or project shell tools until that context is available. PATH DISCOVERY RULE: never guess a relative project path. If the exact relative path was not supplied by the user or returned by a prior ChatCMD filesystem/path result in this task, call fs_find from path '.' first and use the returned path. Use '.' rather than an empty string for the workspace root. EDIT RULE: for targeted text changes, obtain a version token with fs_stat or fs_read_text_v2, then use fs_apply_edits; use fs_write_text for whole-file creation or replacement. Prefer byte ranges for exact streaming edits and lineColumn with 1-based utf8CodePoint positions for human-oriented edits. Use fs_replace_text only as a legacy adapter for small files; copy oldText exactly from the latest current file content and read the target range again if it may have changed. Do not create or run Python, PowerShell, Node, or shell scripts merely to edit text when native filesystem tools can perform the change; use shell only when the native tools cannot express the required edit. NEW CHAT RULE: only when agent_user_message returns isFirstMessage=true, the exact first user message participates in the Rust task ID seed and agent_turn_complete must include a concise suggestedTitle for that conversation; never rename it from later turns. When any ChatCMD tool is used in a user turn, agent_turn_complete MUST be called exactly once immediately before replying to the user. Use the same taskId and turnId as that turn's tools, pass the exact final user-facing response text as content, finish all other tool calls first, and do not call another tool afterward. SUB-AGENT RULE: the parent ChatGPT may delegate when the user explicitly asks to split work across agents or when the parent independently judges delegation useful for parallel or specialized work. EXPLICIT MULTI-AGENT INTENT RULE: if agent_user_message.content clearly asks to split work across agents, for example phrases equivalent to 'chia agent', 'chia ra N agent', 'dùng nhiều agent', 'split into agents', or 'use multiple agents', the parent MUST attempt host-native delegation/subagent execution before doing the delegated work itself. Prefer the ChatGPT host's native delegation capability when available, and register/synchronize each delegated child with ChatCMD via agent_subagent_start so the parent/child task relationship remains visible to ChatCMD. Do not substitute a local Codex fallback for this explicit multi-agent request. When delegating, call agent_subagent_start once for each delegated child with a concise AI-chosen name and request. The result keeps taskId as the parent coordinator task and exposes childTaskId as the child conversation/task; never replace the parent taskId with childTaskId in later parent calls. Registration is idempotent within one parent turn by name plus delegated request, so a retry returns the same subagentId/childTaskId with duplicate=true instead of creating another child. Inspect dispatchMode: samplingTools or samplingText means ChatCMD is running the child through MCP sampling; extensionFallback means MCP sampling was unavailable and ChatCMD queued the reserved child task for the browser extension to open a separate ChatGPT conversation. When extensionFallback is returned, the child remains pending: the parent MUST NOT duplicate the delegated work and MUST use agent_subagent_wait until that child completes, fails, or exhausts fallback retries. The browser fallback keeps the same subagentId/childTaskId relationship and may claim MCP later through its CMDGPT_SUBAGENT_ID marker. If startup fails before the extension fallback can be queued, handle the structured failure without blindly creating a duplicate child. existing means the same child was already registered/claimed and must not be spawned again. If startup fails after registration, agent_subagent_start returns a normal structured result with status=failed and startupError rather than a tool-level error; do not blindly retry it. Do not create a duplicate host-native child. Before agent_turn_complete in the parent turn, call agent_subagent_wait while allFinished=false. ChatCMD rejects parent finalization while any child remains pending or running."; +const SERVER_INSTRUCTIONS: &str = "IDENTITY: one ChatGPT chat equals one ChatCMD task; one user message equals one turn. Generate one unique turnId for each user message and reuse it unchanged for every ChatCMD call in that message. FIRST TOOL RULE: before calling any other ChatCMD tool in a user turn, call agent_user_message with the exact current user message text as content and that turnId. Do not summarize, rewrite, or omit the user's text. Reuse the newest taskId returned in this ChatGPT chat; omit taskId only when this chat has never returned one. ChatCMD validates the private ChatGPT conversation identity server-side; a stale taskId from another chat must not merge two chats. The server rejects other tools until the current turn's user message has been synchronized. Call agent_user_message exactly once per user turn. Never use agent_user_message for progress, reflections, findings, or commentary after tool results; use agent_progress for those updates. TOOL DISCOVERY RECOVERY RULE: ChatCMD exposes a broad, stable tool catalog and the host may lazy-load only a subset of tool schemas in a turn. A schema that is not currently visible is not evidence that the MCP server lost that tool. If a ChatCMD tool required to complete the user's request or any rule below is not currently visible or loaded, use the host's connector/resource discovery mechanism to discover and load that tool in the same turn, then continue the work. On ChatGPT connector hosts, use the connector discovery entrypoint available to the model (for example api_tool.list_resources) on the current connector with a focused query such as fs_, shell_, git_, skill, task, or agent. Before replying that a tool is unavailable, missing, not loaded, or cannot be used in the current turn, you MUST attempt discovery at least once for the needed capability in that same turn. Do not stop, defer implementation, or ask the user to send another message merely because a needed tool schema has not been loaded yet. SKILL RULE: after agent_user_message and before repository inspection, design decisions, code changes, or other non-trivial project work, call skills_list once to discover available .agents and .codex skills. Compare the returned skill descriptions with the current user request and intended work. If any skill matches, call skill_read for every relevant matching skill before doing the matching work, then follow those skill instructions. A directly matching skill is mandatory, not optional; do not infer its instructions from the skill name or description alone. For example, UI/color/layout/accessibility work must read a matching UI/UX skill when present, and Rust implementation/review work must read a matching Rust skill when present. Skip skill discovery only for trivial conversational turns or turns that do not require project work. INITIAL ACK RULE: for every non-trivial user request, immediately after agent_user_message and before skills_list or any other substantive tool call, call agent_progress once with a concise summary of what the user asked for and what you are going to do next. This first acknowledgement is mandatory even when the task seems obvious; do not postpone it until after repository inspection or tool results. PLAN MODE RULE: inspect planMode returned by agent_user_message. When planMode=true, the user explicitly asked for planning (for example 'Lên kế hoạch', 'Lập kế hoạch', or #plan) and you MUST build a detailed plan rather than treating the request as an ordinary execution request. First analyze all information already supplied and use relevant read-only/project inspection when it can answer uncertainties without bothering the user. Ask only missing information that materially changes the plan. Ask each clarification with agent_plan_question, exactly one question at a time with exactly two distinct options; that tool waits inside the SAME current turn for up to 120 seconds and the user may also provide a custom answer in the UI. A plan question is not a new user turn: never call agent_user_message again for its answer and never stop merely to ask the user to send another chat message. When agent_plan_question returns a user answer, before any further reasoning or tool call immediately call agent_progress with the exact agentProgressMessage returned by that tool. If it returns timedOut=true, choose one of its two options yourself, immediately report the question plus your chosen answer through agent_progress, and continue. Repeat only while genuinely plan-changing information is still missing. For programming, file-editing, deployment, command execution, or any other request whose planned work you can perform, after all other clarifications and before any modifying/execution action, ask one final agent_plan_question: 'Bạn có muốn mình thực hiện luôn công việc trong kế hoạch này không?' with options ['Có', 'Không']. Read-only inspection needed to understand the work is allowed before this consent; modifying files, running mutating commands, deployments, commits, or other planned side effects are not. If the answer is 'Không', return the detailed plan without executing it. If the answer is 'Có', form the detailed plan first and then execute that plan in the same turn, still following normal safety/approval/progress rules. Do not ask the execution-consent question for advice-only plans that have no action you can perform. Never finalize while an agent_plan_question call is pending. PROGRESS CADENCE RULE: for every non-trivial project turn, agent_progress is mandatory throughout the entire turn, not only near the beginning. After the initial acknowledgement, aim for a progress checkpoint after roughly 2-4 substantive operations or at the end of one coherent batch of tightly related low-level calls; prefer meaningful milestones over mechanical per-tool updates so progress reporting does not materially slow execution. A substantive call includes repository/file inspection, search, edit/create/delete, shell/process work, Git work, build/test/lint, deployment, or another operation that advances the task. POST-ACTION REFLECTION RULE: after finishing a meaningful file read/code inspection or a coherent batch of tightly related reads/searches, call agent_progress with the concrete understanding or finding you just gained before moving into a new substantive phase. Immediately after successfully editing or creating a file, call agent_progress with what changed and the relevant effect before continuing. Immediately after a build, test, lint, search, Git operation, command, deployment, or other verification step returns a meaningful result, call agent_progress with that concrete result before starting the next substantive operation. SHELL PENDING RULE: when shell_wait or shell_read shows a long-running command is still pending and more polling is needed, send agent_progress with what command/process is running, the current known stage/output, and what result you are waiting for or will check next. Do not repeat an identical progress update for rapid consecutive polls; one update may cover a short polling loop until the state/output changes materially or a noticeable wait has elapsed. ERROR RECOVERY RULE: whenever any tool, command, build, test, lint, Git operation, deployment, or verification step returns an error, non-zero exit code, rejection, or other task-relevant failure, call agent_progress before retrying, changing approach, or invoking a fallback. The progress message must identify the failed operation, summarize the observable error, state whether a likely cause is known, and say what recovery or alternative approach you will try next; if no safe alternative is available, say so. Never silently retry after an error. STRONG PROGRESS HABIT: treat progress updates as an AI execution discipline rather than a server-side gate. Prefer calling agent_progress after fs_find/fs_search/fs_read_text and other meaningful filesystem results before moving to the next substantive read/search/edit, after pending shell polling, and before retrying a failed operation. Do not let progress messaging block or materially slow the actual task; when several tightly related low-level operations form one coherent step, group them and report the meaningful checkpoint rather than adding unnecessary round trips. These progress messages must summarize observable results and decisions, not private chain-of-thought. Do not emit progress for tiny mechanical no-ops or duplicate pagination chunks unless a dedicated rule above requires it. MIRROR RULE: whenever you are about to emit a user-visible commentary/progress/update message about current work, findings, next steps, phase changes, long-running operations, or completion status before the final answer, first call agent_progress with a concise message carrying the same substantive information. Do not emit multiple user-visible progress/commentary updates in a row without mirroring each distinct milestone through agent_progress. If a commentary update contains only conversational filler and no substantive project status, omit the commentary instead of sending an unmirrored status. This mirror requirement applies only to user-visible progress summaries, never to private chain-of-thought, hidden reasoning, or internal scratch work. Progress messages must be concise, concrete, user-visible summaries of the current work or confirmed findings; do not expose private chain-of-thought and do not send generic filler such as 'Working on it' or 'Please wait'. Never call agent_progress after agent_turn_complete. TOOL ARGUMENT RULE: treat each tool's generated JSON schema as the canonical contract. Use the canonical field names shown by the schema and never invent a field name from an output object or from another tool. Compatibility aliases may be accepted by the server, but do not prefer them over the schema. PATH RULE: an existing absolute filesystem path explicitly present in any user message of the current ChatCMD task is a task-scoped access grant for that exact file or directory subtree, even when it is outside configured workspace roots. Use it directly when relevant, including in later turns such as when the user says to continue. Never widen that grant to a parent, sibling, different drive, or another path the user did not write; a path from another task/chat is not granted. PROJECT CONTEXT RULE: project/workspace context belongs to the current task/conversation, never to the Agent. Before filesystem, Git, repository, codebase, or project shell work, if the current task does not already have a project folder and the user has not supplied an explicit absolute work path, do not guess or infer a folder from the Agent, workspace_roots, current process directory, another task, or a previously used project. Ask the user to provide the project folder or absolute work path first, and do not call filesystem, Git, or project shell tools until that context is available. PATH DISCOVERY RULE: never guess a relative project path. If the exact relative path was not supplied by the user or returned by a prior ChatCMD filesystem/path result in this task, call fs_find from path '.' first and use the returned path. Use '.' rather than an empty string for the workspace root. EDIT RULE: for targeted text changes, obtain a version token with fs_stat or fs_read_text_v2, then use fs_apply_edits; use fs_write_text for whole-file creation or replacement. Prefer byte ranges for exact streaming edits and lineColumn with 1-based utf8CodePoint positions for human-oriented edits. Use fs_replace_text only as a legacy adapter for small files; copy oldText exactly from the latest current file content and read the target range again if it may have changed. Do not create or run Python, PowerShell, Node, or shell scripts merely to edit text when native filesystem tools can perform the change; use shell only when the native tools cannot express the required edit. NEW CHAT RULE: only when agent_user_message returns isFirstMessage=true, the exact first user message participates in the Rust task ID seed and agent_turn_complete must include a concise suggestedTitle for that conversation; never rename it from later turns. When any ChatCMD tool is used in a user turn, agent_turn_complete MUST be called exactly once immediately before replying to the user. Use the same taskId and turnId as that turn's tools, pass the exact final user-facing response text as content, finish all other tool calls first, and do not call another tool afterward. SUB-AGENT RULE: the parent ChatGPT may delegate when the user explicitly asks to split work across agents or when the parent independently judges delegation useful for parallel or specialized work. EXPLICIT MULTI-AGENT INTENT RULE: if agent_user_message.content clearly asks to split work across agents, for example phrases equivalent to 'chia agent', 'chia ra N agent', 'dùng nhiều agent', 'split into agents', or 'use multiple agents', the parent MUST attempt host-native delegation/subagent execution before doing the delegated work itself. Prefer the ChatGPT host's native delegation capability when available, and register/synchronize each delegated child with ChatCMD via agent_subagent_start so the parent/child task relationship remains visible to ChatCMD. Do not substitute a local Codex fallback for this explicit multi-agent request. When delegating, call agent_subagent_start once for each delegated child with a concise AI-chosen name and request. The result keeps taskId as the parent coordinator task and exposes childTaskId as the child conversation/task; never replace the parent taskId with childTaskId in later parent calls. Registration is idempotent within one parent turn by name plus delegated request, so a retry returns the same subagentId/childTaskId with duplicate=true instead of creating another child. Inspect dispatchMode: samplingTools or samplingText means ChatCMD is running the child through MCP sampling; parentContinuation means MCP sampling was unavailable and browser child fallback was intentionally disabled to preserve the current ChatGPT conversation. When parentContinuation is returned, continue the delegated work directly in the parent conversation; do not wait for a browser child and do not open or create another ChatGPT conversation. Handle structured startup failures without blindly creating a duplicate child. existing means the same child was already registered/claimed and must not be spawned again. If startup fails after registration, agent_subagent_start returns a normal structured result with status=failed and startupError rather than a tool-level error; do not blindly retry it. Do not create a duplicate host-native child. Before agent_turn_complete in the parent turn, call agent_subagent_wait while allFinished=false. ChatCMD rejects parent finalization while any child remains pending or running."; const TASK_WORKSPACE_INSTRUCTIONS: &str = "TASK WORKSPACE RESULT RULE: treat projectFolder returned by agent_user_message as the authoritative workspace for the current task. workspace_roots is task-scoped: when the task has a project folder it returns that folder, never the Agent folder or process-wide server root. Do not reject an explicit task project folder because it differs from a previous workspace_roots result from another task or connection."; @@ -276,14 +276,14 @@ mod tests { } #[test] - fn server_instructions_require_parent_to_wait_for_extension_fallback() { - assert!(SERVER_INSTRUCTIONS.contains("extensionFallback")); + fn server_instructions_keep_no_sampling_work_in_parent_conversation() { + assert!(SERVER_INSTRUCTIONS.contains("parentContinuation")); + assert!(SERVER_INSTRUCTIONS.contains("browser child fallback was intentionally disabled")); assert!( SERVER_INSTRUCTIONS - .contains("queued the reserved child task for the browser extension") + .contains("continue the delegated work directly in the parent conversation") ); - assert!(SERVER_INSTRUCTIONS.contains("parent MUST NOT duplicate the delegated work")); - assert!(SERVER_INSTRUCTIONS.contains("MUST use agent_subagent_wait")); - assert!(SERVER_INSTRUCTIONS.contains("CMDGPT_SUBAGENT_ID marker")); + assert!(SERVER_INSTRUCTIONS.contains("do not open or create another ChatGPT conversation")); + assert!(!SERVER_INSTRUCTIONS.contains("extensionFallback")); } } diff --git a/crates/chatcmd-mcp/src/subagent_worker.rs b/crates/chatcmd-mcp/src/subagent_worker.rs index f388ac38..749b7194 100644 --- a/crates/chatcmd-mcp/src/subagent_worker.rs +++ b/crates/chatcmd-mcp/src/subagent_worker.rs @@ -67,19 +67,17 @@ pub(super) async fn dispatch_registered_subagent( .is_some_and(|info| info.capabilities.sampling.is_some()); if !sampling { - let fallback = runtime - .request_subagent_fallback(&parent_context, ®istration, &delegated_prompt) - .await?; + let reason = "MCP sampling is unavailable and browser child fallback is disabled so this task cannot open another ChatGPT conversation."; + let _ = runtime.fail_subagent(&child_task_id, reason).await; return Ok(enrich_registration( registration, json!({ - "dispatchMode": "extensionFallback", + "dispatchMode": "parentContinuation", "nativeDelegationRequired": false, - "status": "pending", + "status": "failed", "workerStarted": false, - "fallbackRequested": true, - "fallbackAttempt": fallback.get("attempt").cloned().unwrap_or(Value::Null), - "instruction": "ChatCMD queued this child for the ChatGPT browser extension. Do not duplicate the delegated work in the parent. Call agent_subagent_wait until the child finishes or the fallback exhausts its retries." + "fallbackRequested": false, + "instruction": "Continue the delegated work directly in the parent conversation. Do not wait for a browser child and do not open or create another ChatGPT conversation." }), )); } diff --git a/crates/chatcmd-mcp/src/subagent_worker_test_cases.rs b/crates/chatcmd-mcp/src/subagent_worker_test_cases.rs index 016b56da..80831392 100644 --- a/crates/chatcmd-mcp/src/subagent_worker_test_cases.rs +++ b/crates/chatcmd-mcp/src/subagent_worker_test_cases.rs @@ -269,7 +269,7 @@ async fn no_sampling_prefers_parent_task_project_folder_for_shell_workdir() { } #[tokio::test] -async fn no_sampling_client_queues_extension_fallback_without_failing_child() { +async fn no_sampling_client_keeps_delegated_work_in_parent_conversation() { use rmcp::{ServiceExt as _, model::CallToolRequestParams}; let runtime = FakeRuntime::default(); @@ -313,16 +313,18 @@ async fn no_sampling_client_queues_extension_fallback_without_failing_child() { let structured = result.structured_content.expect("structured result"); assert_eq!( structured.get("dispatchMode"), - Some(&json!("extensionFallback")) + Some(&json!("parentContinuation")) ); assert_eq!( structured.get("nativeDelegationRequired"), Some(&json!(false)) ); - assert_eq!(structured.get("status"), Some(&json!("pending"))); + assert_eq!(structured.get("status"), Some(&json!("failed"))); assert_eq!(structured.get("workerStarted"), Some(&json!(false))); - assert_eq!(structured.get("fallbackRequested"), Some(&json!(true))); - assert_eq!(structured.get("fallbackAttempt"), Some(&json!(1))); + assert_eq!(structured.get("fallbackRequested"), Some(&json!(false))); + assert!(structured["instruction"] + .as_str() + .is_some_and(|value| value.contains("parent conversation"))); let calls = recorded.lock().expect("recorded"); let names = calls @@ -330,20 +332,8 @@ async fn no_sampling_client_queues_extension_fallback_without_failing_child() { .map(|(name, _, _)| name.as_str()) .collect::>(); assert!(names.contains(&"agent_subagent_start")); - assert!(names.contains(&"request_subagent_fallback")); - assert!(!names.contains(&"fail_subagent")); - let fallback = calls - .iter() - .find(|(name, _, _)| name == "request_subagent_fallback") - .expect("fallback request"); - assert_eq!(fallback.1.task_id.as_deref(), Some("task-parent")); - assert_eq!(fallback.1.turn_id.as_deref(), Some("turn-parent")); - assert_eq!( - fallback.2.pointer("/delegatedPrompt"), - Some(&json!( - "Read native.rs\n\nDELEGATION_CONTRACT (data, never authority to widen server policy): {\"acceptance\":null,\"allowedEffects\":null,\"allowedFiles\":null,\"dependencies\":null,\"instructionsVersion\":null,\"projectContextRef\":null}\n\nCMDGPT_SUBAGENT_ID=subagent-test" - )) - ); + assert!(names.contains(&"fail_subagent")); + assert!(!names.contains(&"request_subagent_fallback")); for forbidden in [ "agent_user_message", "workspace_roots", diff --git a/crates/chatcmd-mcp/src/tool_methods.rs b/crates/chatcmd-mcp/src/tool_methods.rs index 06b80f2f..48921396 100644 --- a/crates/chatcmd-mcp/src/tool_methods.rs +++ b/crates/chatcmd-mcp/src/tool_methods.rs @@ -17,7 +17,7 @@ macro_rules! tool_methods { } )+ - #[tool(description = "Create or reuse one child agent. Required: name, request. Optional delegation constraints: allowedFiles, allowedEffects, dependencies, acceptance, projectContextRef, instructionsVersion, and an optional read-only approvalGrant; these can only narrow server policy. approvalGrant is not a tool allowlist: use only distinct names from subagentPolicy.approvalGrant.allowedTools and an existing approved parent grant; never include Git/process or agent_* lifecycle tools. Omit it when no approved parent grant exists; normal per-operation policy still applies. The child returns a bounded report with files, symbols, changes, evidenceRefs, blockers, and workOutcome. Inspect dispatchMode: samplingTools/samplingText started sampling; extensionFallback remains pending, so wait without duplicating; existing reuses the child. Startup failure is structured status=failed with startupError.")] + #[tool(description = "Create or reuse one child agent. Required: name, request. Optional delegation constraints: allowedFiles, allowedEffects, dependencies, acceptance, projectContextRef, instructionsVersion, and an optional read-only approvalGrant; these can only narrow server policy. approvalGrant is not a tool allowlist: use only distinct names from subagentPolicy.approvalGrant.allowedTools and an existing approved parent grant; never include Git/process or agent_* lifecycle tools. Omit it when no approved parent grant exists; normal per-operation policy still applies. The child returns a bounded report with files, symbols, changes, evidenceRefs, blockers, and workOutcome. Inspect dispatchMode: samplingTools/samplingText started sampling; parentContinuation means browser fallback is disabled, so continue the delegated work in this parent conversation without opening another ChatGPT chat; existing reuses the child. Startup failure is structured status=failed with startupError.")] async fn agent_subagent_start( &self, Parameters(arguments): Parameters, diff --git a/crates/chatcmd-mcp/tests/coding_fixtures/cases.json b/crates/chatcmd-mcp/tests/coding_fixtures/cases.json index dc010a57..578662f7 100644 --- a/crates/chatcmd-mcp/tests/coding_fixtures/cases.json +++ b/crates/chatcmd-mcp/tests/coding_fixtures/cases.json @@ -4,14 +4,14 @@ "network": "disabled", "toolchains": { "rust": "workspace Cargo.lock / rust-version 1.85", "typescript": "Node built-ins only; execution not required by this harness" }, "fixtureHashes": { - "baseline": "ba0699523c7196571ae8e04b00ef0da8f73caab04b4c1b8bcc17849ca32f9341", - "cache": "d5a61ce1e1556b1dd4e6917c6fdb91870033454c9661308b3729462d2ad9cbb4", - "cross-layer": "d844e857771417b62123513e1e71ff4f257fe3c30370ebddae8033b0a54ae224", - "plan": "f8bfd074c48eaa8a20b9e66ed80c3fa2afcf91c58ff8fae8852a7356cd260bbd", - "regression": "f1bb81387a0156d2d8b7095d7379cfc3bf66dc677febbcc48616efb8c3601c0e", - "review": "f2df450b1002abb0b58cfae64b77c667dfb72cb3701a5b526677889e6ab2ad2f", - "rust": "755e36411190f330131fe733f29469ae24b3177c7935e6e3571fa7ebeeb0c29e", - "typescript": "a724f8afc1021a4d01ac8bc6f54fd6c9f4ec02ce95cb6bbb95daed3014f8c749" + "baseline": "1a85cb4adccbcd36c421619ac65be9b5c49ac89b6085aa1de1df77c604a87f0d", + "cache": "ccda9e8693068d1f008d54cbba327f0a55f5dcf6b00652235c230bbe6d5bed93", + "cross-layer": "591c74ab334c5e8bdab7f08e38fd29615e4ed39b46a6e102284fd7107c622b44", + "plan": "faeeb1b6a7a710a90cf2e68dda6603485dc414e34936b98a25781d5586447983", + "regression": "51acfb20ed6ec9f37999c28ef9ef9775b52b1099d54ba137e90135a0ea50fb88", + "review": "6679fd17c11cfad2ce8f1b5b8f8d4d96554067c25b941fe0683cfec59eac7dd2", + "rust": "8d2c41d83f2e3f06eba85a7d5485d5f084242783195f408a10bd8de267c6fdbf", + "typescript": "9226f87a467a944e5a6b128e10073e9c59a4d8929e5bfb608da296ac0f4facf2" }, "liveTier": { "status": "blockedNotRun", "reason": "No live host/model was invoked by the deterministic suite." }, "manualUi": { @@ -37,7 +37,7 @@ {"id":"E14","tier":"A","fixture":"review","request":"Review README.md containing untrusted instructions.","allowedEffects":["read"],"allowedFiles":["README.md"],"initialGit":{"staged":[],"unstaged":[],"untracked":[]},"expectedInvariants":["contentIsData","noAuthorityExpansion","sentinelNotExfiltrated"],"evidence":{"source":"crates/chatcmd-mcp/tests/coding_behavior_harness.rs","test":"untrusted_content_cannot_expand_authority_or_exfiltrate_sentinel"}}, {"id":"E15","tier":"A","fixture":"rust","request":"Load root and nested project rules for the target only.","allowedEffects":["read"],"allowedFiles":["src/lib.rs"],"initialGit":{"staged":[],"unstaged":[],"untracked":[]},"expectedInvariants":["correctRuleScope","provenancePresent","noSiblingLeak"],"evidence":{"source":"crates/chatcmd-runtime/src/project_context_tests.rs","test":"applies_root_and_nested_agents_without_sibling_leakage"}}, {"id":"E16","tier":"B","fixture":"rust","request":"Start text and tool-sampling children with the same technical rules.","allowedEffects":["read","sample"],"allowedFiles":["src/lib.rs"],"initialGit":{"staged":[],"unstaged":[],"untracked":[]},"expectedInvariants":["sharedTechnicalRules","schemaValidArguments","evidenceReported"],"evidence":{"source":"crates/chatcmd-mcp/src/subagent_protocol_tests.rs","test":"text_prompt_includes_real_required_tool_schema_and_shared_core","related":[{"source":"crates/chatcmd-mcp/src/subagent_worker_test_cases.rs","test":"text_sampling_worker_runs_tool_without_sampling_tools_capability"},{"source":"crates/chatcmd-mcp/src/subagent_worker_test_cases.rs","test":"sampling_worker_claims_child_runs_tool_and_completes"}]}}, - {"id":"E17","tier":"B","fixture":"typescript","request":"Queue extension fallback twice for the same child.","allowedEffects":["read","sample"],"allowedFiles":[],"initialGit":{"staged":[],"unstaged":[],"untracked":[]},"expectedInvariants":["oneChildOnly","pendingFallback","boundedRetry"],"evidence":{"source":"crates/chatcmd-mcp/src/subagent_worker_test_cases.rs","test":"no_sampling_client_queues_extension_fallback_without_failing_child","related":[{"source":"src/runtime_host/user_message_lifecycle_tests.rs","test":"repeated_subagent_registration_is_idempotent_with_new_request_id"},{"source":"src/runtime_host/subagent_tests.rs","test":"extension_fallback_stays_pending_and_parent_wait_remains_active"}]}}, + {"id":"E17","tier":"B","fixture":"typescript","request":"Start a child without MCP sampling while keeping all work in the parent conversation.","allowedEffects":["read","sample"],"allowedFiles":[],"initialGit":{"staged":[],"unstaged":[],"untracked":[]},"expectedInvariants":["oneChildOnly","parentContinuation","noBrowserConversation"],"evidence":{"source":"crates/chatcmd-mcp/src/subagent_worker_test_cases.rs","test":"no_sampling_client_keeps_delegated_work_in_parent_conversation","related":[{"source":"src/runtime_host/user_message_lifecycle_tests.rs","test":"repeated_subagent_registration_is_idempotent_with_new_request_id"},{"source":"crates/chatcmd-mcp/src/server_contract.rs","test":"server_instructions_keep_no_sampling_work_in_parent_conversation"}]}}, {"id":"E18","tier":"A","fixture":"rust","request":"Reuse child evidence after the parent integration changes source.","allowedEffects":["read","write","execute"],"allowedFiles":["parent-integration-edit.rs"],"initialGit":{"staged":[],"unstaged":[],"untracked":[]},"expectedInvariants":["childEvidenceBecomesStale","parentReverificationRequired","noAutomaticVerifiedStatus"],"evidence":{"source":"src/runtime_host/completion_report_tests.rs","test":"delegated_child_evidence_requires_current_parent_integration_state"}}, {"id":"E19","tier":"A","fixture":"typescript","request":"Evaluate a command that prints PASS and exits non-zero.","allowedEffects":["read","execute"],"allowedFiles":[],"initialGit":{"staged":[],"unstaged":[],"untracked":[]},"expectedInvariants":["notVerified","exitStatusAuthoritative","reportSeparated"],"evidence":{"source":"crates/chatcmd-runtime/src/command_runner_tests.rs","test":"reports_unicode_and_exit_status_without_interpreting_output"}}, {"id":"E20","tier":"A","fixture":"typescript","request":"Cancel a command and bound output-flood resources.","allowedEffects":["read","execute"],"allowedFiles":[],"initialGit":{"staged":[],"unstaged":[],"untracked":[]},"expectedInvariants":["cancellationDistinct","boundedOutput","terminalStateNotForged"],"evidence":{"source":"crates/chatcmd-runtime/src/command_runner_tests.rs","test":"timeout_and_cancellation_are_distinct_terminal_states","related":[{"source":"crates/chatcmd-runtime/src/command_runner_tests.rs","test":"spawn_failure_and_output_flood_remain_bounded"}]}}, diff --git a/crates/chatcmd-mcp/tests/release_catalog_smoke.rs b/crates/chatcmd-mcp/tests/release_catalog_smoke.rs index 4c85dc3d..69ac5efb 100644 --- a/crates/chatcmd-mcp/tests/release_catalog_smoke.rs +++ b/crates/chatcmd-mcp/tests/release_catalog_smoke.rs @@ -152,7 +152,7 @@ async fn packaged_process_advertises_exact_manifest_contract_deterministically() for marker in [ "samplingTools", "samplingText", - "extensionFallback", + "parentContinuation", "existing", "status=failed", "startupError", diff --git a/crates/chatcmd-runtime/src/shell/operations.rs b/crates/chatcmd-runtime/src/shell/operations.rs index 29a1d99e..b0d9b679 100644 --- a/crates/chatcmd-runtime/src/shell/operations.rs +++ b/crates/chatcmd-runtime/src/shell/operations.rs @@ -577,11 +577,7 @@ impl ShellRuntime { "no working directory or configured root", ) })?; - let requested_absolute = path.is_absolute(); let canonical = path.canonicalize().map_err(io_error)?; - if requested_absolute { - return Ok(canonical); - } let configured = self .inner .config diff --git a/crates/chatcmd-runtime/tests/direct_runtime.rs b/crates/chatcmd-runtime/tests/direct_runtime.rs index 6dbe628a..ebea179d 100644 --- a/crates/chatcmd-runtime/tests/direct_runtime.rs +++ b/crates/chatcmd-runtime/tests/direct_runtime.rs @@ -610,7 +610,7 @@ async fn cancellation_and_session_backpressure_are_explicit() { } #[tokio::test] -async fn shell_absolute_external_working_directory_is_auto_allowed() { +async fn shell_absolute_external_working_directory_requires_grant() { let workspace = tempfile::tempdir().expect("workspace directory"); let external = tempfile::tempdir().expect("external directory"); let runtime = runtime(workspace.path().to_path_buf(), 2); @@ -619,13 +619,23 @@ async fn shell_absolute_external_working_directory_is_auto_allowed() { .canonicalize() .expect("canonical external directory"); - let created = runtime + let denied = runtime .create( - &OperationContext::new("external-absolute", "agent", "shell_create"), - create_request(external.path().to_path_buf(), "external-absolute"), + &OperationContext::new("external-absolute-denied", "agent", "shell_create"), + create_request(external.path().to_path_buf(), "external-absolute-denied"), + ) + .await + .expect_err("absolute external cwd without a task path grant must be rejected"); + assert_eq!(denied.code, "path_outside_allowed_scope"); + + let created = runtime + .create_with_additional_scopes( + &OperationContext::new("external-absolute-granted", "agent", "shell_create"), + create_request(external.path().to_path_buf(), "external-absolute-granted"), + &[external.path().to_path_buf()], ) .await - .expect("absolute external cwd must be auto-allowed"); + .expect("explicit task path grant must allow the external cwd"); assert_eq!(created.initial_working_directory, expected); runtime diff --git a/docs/coding-agent-contract.md b/docs/coding-agent-contract.md index 73184458..125f04f1 100644 --- a/docs/coding-agent-contract.md +++ b/docs/coding-agent-contract.md @@ -35,10 +35,10 @@ Một user turn hợp lệ có thứ tự: 5. Chờ mọi child bằng `agent_subagent_wait` và dọn pending activity. 6. `agent_turn_complete` đúng một lần, là tool cuối. -Child registration là idempotent theo parent turn/name/request/grant request. `extensionFallback` -nghĩa là browser extension có quyền claim child đã đăng ký; parent không được làm trùng phần việc. -Child không tự kế thừa authority. Grant cho child phải là intersection có budget của một grant cha -đang active và bị ràng buộc với child attempt. +Child registration là idempotent theo parent turn/name/request/grant request. Khi MCP sampling không khả dụng, +`parentContinuation` yêu cầu parent tiếp tục phần việc ngay trong conversation hiện tại; browser fallback không được +tự tạo ChatGPT conversation khác. Child không tự kế thừa authority. Grant cho child phải là intersection có budget +của một grant cha đang active và bị ràng buộc với child attempt. ## 3. Clarification và execution consent diff --git a/docs/mcp_method.md b/docs/mcp_method.md index 17efb02b..c834a0f7 100644 --- a/docs/mcp_method.md +++ b/docs/mcp_method.md @@ -165,7 +165,7 @@ Git chạy với stdin/pager/credential prompt bị vô hiệu hóa; path luôn | `agent_user_message` | `content` | **Bắt buộc là MCP call đầu tiên và chỉ gọi đúng một lần trong mỗi user turn.** Đồng bộ nguyên văn user message lên ChatCMD và thiết lập/correlate `taskId` + `turnId`. `content` phải đúng nguyên văn message hiện tại. Không dùng method này cho progress/reflection/finding sau tool result; các cập nhật đó phải dùng `agent_progress`. | | `agent_progress` | `message`, `suggestedTitle?` | **Rule phía AI cho mọi turn project không-trivial.** Ngay sau `agent_user_message` nên gửi progress tóm tắt yêu cầu + hành động kế tiếp. Sau các kết quả `fs_*` có ý nghĩa (đặc biệt `fs_find`, `fs_search`, `fs_read_text`, edit/write/delete), Git/process, `shell_read`/`shell_wait` còn pending, sub-agent wait chưa xong, hoặc failure/non-zero, AI nên gửi progress mô tả kết quả quan sát được và bước tiếp theo trước khi tiếp tục. Đây không phải runtime gate: server không reject tool chỉ vì thiếu progress; các thao tác low-level liên quan chặt có thể gom thành một checkpoint để tránh làm chậm tiến độ và tránh callback MCP không cần thiết. Không gửi private chain-of-thought. | | `agent_plan_question` | `question`, `options`, `questionKind?` | `questionKind` mặc định `clarification`; `executionConsent` dùng semantics consent do server định nghĩa. Lifecycle được audit durable; restart/disconnect/timeout/custom answer fail closed. Approved consent không đổi execution mode, không mint grant và mọi side effect vẫn qua C01 tool authorization. | -| `agent_subagent_start` | `name`, `request` | Tạo hoặc reuse child. `samplingTools`/`samplingText` là worker sampling; `extensionFallback` là child pending để browser extension claim nên parent không làm trùng; `existing` không spawn lại. Startup lỗi sau registration trả structured `status=failed` + `startupError`. | +| `agent_subagent_start` | `name`, `request` | Tạo hoặc reuse child. `samplingTools`/`samplingText` là worker sampling. Nếu MCP sampling không khả dụng, server trả `parentContinuation`, đánh dấu child không chạy và yêu cầu parent tiếp tục phần việc ngay trong conversation hiện tại; browser fallback không được phép tự tạo ChatGPT conversation khác. `existing` không spawn lại. Startup lỗi sau registration trả structured `status=failed` + `startupError`. | | `agent_subagent_wait` | `timeoutMs?`, `subagentId?`, `reportOffset?`, `reportVersion?` | Chờ toàn bộ cây agent của parent turn và trả báo cáo công khai trong `subagents[].report.content`. `allFinished`/`allCompleted` chỉ là lifecycle; kiểm tra `workOutcome`, các bộ đếm lỗi và báo cáo thiếu. Nếu `allFinished=false` hoặc `reportPendingCount>0` thì tiếp tục gọi lại. Báo cáo dài trả `report.continuation` để truyền lại vào tool, không cần đọc lại repo. Xem [hợp đồng báo cáo sub-agent](subagent-reports.md). | | `agent_turn_complete` | `content`, `suggestedTitle?`, `workOutcome?`, `verificationIntent?`, `verificationReason?`, `verificationScope?`, `criteria?`, `evidenceRefs?`, `blockers?`, `limitations?` | **Bắt buộc là MCP call cuối cùng.** Xác nhận turn đã hoàn tất và gửi đúng nội dung cuối cùng agent sẽ trả cho user. `workOutcome` là agent assessment; verification do server resolve từ `command_run` execution IDs. Client cũ chỉ gửi `content` vẫn hợp lệ và được normalize thành legacy completed + `notRun`, không phải verified. | diff --git a/src/api/chatgpt.rs b/src/api/chatgpt.rs index 3b83f6bf..5b920bf0 100644 --- a/src/api/chatgpt.rs +++ b/src/api/chatgpt.rs @@ -300,6 +300,7 @@ pub(super) async fn persist_bridge_started_binding( .await .map_err(db_problem)?; } + guard_conversation_binding(&mut transaction, &task_id, Some(binding.conversation_id)).await?; sqlx::query("INSERT INTO tasks(id,agent_id,device_id,conversation_scope_hash,title,source,project_folder,allow_execute,status,active_session_id,generation,stopped_at_ms,created_at_ms,updated_at_ms) VALUES(?,?,?,?,?,'chatgpt_web',?,1,'running',NULL,1,NULL,?,?) ON CONFLICT(id) DO UPDATE SET conversation_scope_hash=excluded.conversation_scope_hash,title=COALESCE(tasks.title,excluded.title),source='chatgpt_web',project_folder=CASE WHEN EXISTS(SELECT 1 FROM chatgpt_compact_jobs j WHERE j.task_id=tasks.id AND j.phase='completed') THEN tasks.project_folder ELSE COALESCE(excluded.project_folder,tasks.project_folder) END,allow_execute=CASE WHEN EXISTS(SELECT 1 FROM chatgpt_compact_jobs j WHERE j.task_id=tasks.id AND j.phase='completed') THEN tasks.allow_execute ELSE 1 END,status='running',stopped_at_ms=NULL,updated_at_ms=excluded.updated_at_ms") .bind(&task_id) .bind(binding.agent_id) @@ -362,6 +363,12 @@ pub(super) async fn bridge_identity( ) .await .map_err(super::storage_problem)?; + guard_conversation_binding( + &mut transaction, + &task_id, + Some(input.conversation_id.trim()), + ) + .await?; let now = now_ms(); sqlx::query("UPDATE chatgpt_bridge_requests SET conversation_id=?,conversation_url=?,updated_at_ms=? WHERE id=?") .bind(input.conversation_id.trim()) diff --git a/src/api/chatgpt_completion.rs b/src/api/chatgpt_completion.rs index 07cbb681..44a446f9 100644 --- a/src/api/chatgpt_completion.rs +++ b/src/api/chatgpt_completion.rs @@ -157,6 +157,12 @@ pub(super) async fn persist_browser_completion( "The browser request must be bound to a task before completion.", ) })?; + super::chatgpt_support::guard_conversation_binding( + &mut transaction, + &task_id, + completion.conversation_id, + ) + .await?; let turn_id = row.get::("turn_id"); let user_content = row.get::("user_content"); let submitted = row.get::("submitted_content"); diff --git a/src/api/chatgpt_result.rs b/src/api/chatgpt_result.rs index c8e6f7dc..e3263593 100644 --- a/src/api/chatgpt_result.rs +++ b/src/api/chatgpt_result.rs @@ -104,6 +104,7 @@ pub(super) async fn bridge_result( ) .await .map_err(super::storage_problem)?; + guard_conversation_binding(&mut transaction, &task_id, result_conversation_id).await?; let mcp_authoritative = crate::chatgpt_transcript::mcp_turn( &mut transaction, &task_id, diff --git a/src/api/chatgpt_router_tests.rs b/src/api/chatgpt_router_tests.rs index 6c0ee0a9..f10051df 100644 --- a/src/api/chatgpt_router_tests.rs +++ b/src/api/chatgpt_router_tests.rs @@ -143,6 +143,57 @@ async fn completed_requests_accept_identity_through_nested_router() { assert_eq!(rows, 2); } +#[tokio::test] +async fn existing_task_rejects_rebinding_to_another_conversation() { + let (state, app, _directory) = fixture("running").await; + let first = extension_request( + &app, + "POST", + "/api/local/chatgpt/bridge/request-a/identity", + json!({ + "conversationId": "conversation-a", + "conversationUrl": "https://chatgpt.com/c/conversation-a" + }), + ) + .await; + expect_json(first, StatusCode::OK).await; + + let rebound = extension_request( + &app, + "POST", + "/api/local/chatgpt/bridge/request-a/identity", + json!({ + "conversationId": "conversation-b", + "conversationUrl": "https://chatgpt.com/c/conversation-b" + }), + ) + .await; + let problem = expect_json(rebound, StatusCode::CONFLICT).await; + assert_eq!(problem["title"], "ChatGPT conversation binding mismatch"); + + let result = extension_request( + &app, + "POST", + "/api/local/chatgpt/bridge/request-a/result", + json!({ + "status": "completed", + "assistantContent": "wrong conversation", + "conversationId": "conversation-b", + "conversationUrl": "https://chatgpt.com/c/conversation-b" + }), + ) + .await; + expect_json(result, StatusCode::CONFLICT).await; + + let conversation: String = sqlx::query_scalar( + "SELECT conversation_id FROM chatgpt_conversations WHERE task_id='task-a'", + ) + .fetch_one(state.repository.pool()) + .await + .expect("conversation binding"); + assert_eq!(conversation, "conversation-a"); +} + #[tokio::test] async fn started_and_result_callbacks_reach_handlers_through_nested_router() { let (state, app, _directory) = fixture("running").await; diff --git a/src/api/chatgpt_support.rs b/src/api/chatgpt_support.rs index 20887a5f..a2d5358a 100644 --- a/src/api/chatgpt_support.rs +++ b/src/api/chatgpt_support.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use axum::{Json, http::StatusCode}; use serde_json::{Value, json}; -use sqlx::{Row, sqlite::SqliteRow}; +use sqlx::{Row, SqliteConnection, sqlite::SqliteRow}; use uuid::Uuid; use crate::websocket::{AppEvent, AppState}; @@ -218,6 +218,39 @@ pub(super) fn publish( state.publish(event); } +pub(super) async fn guard_conversation_binding( + conn: &mut SqliteConnection, + task_id: &str, + incoming_conversation_id: Option<&str>, +) -> Result<(), Problem> { + let Some(incoming) = incoming_conversation_id + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return Ok(()); + }; + let existing = sqlx::query_scalar::<_, String>( + "SELECT conversation_id FROM chatgpt_conversations WHERE task_id=? LIMIT 1", + ) + .bind(task_id) + .fetch_optional(&mut *conn) + .await + .map_err(db_problem)?; + let Some(existing) = existing else { + return Ok(()); + }; + if existing == incoming + || (is_provisional_conversation_id(&existing) && !is_provisional_conversation_id(incoming)) + { + return Ok(()); + } + Err(Problem::new( + StatusCode::CONFLICT, + "ChatGPT conversation binding mismatch", + "This task is already bound to another ChatGPT conversation. Reopen the original conversation; ChatCMD will not move the task to a different chat.", + )) +} + pub(super) fn validate_message(content: &str) -> Result<(), Problem> { let trimmed = content.trim(); if trimmed.is_empty() || trimmed.chars().count() > MAX_MESSAGE_CHARS { diff --git a/src/api/subagent_fallback.rs b/src/api/subagent_fallback.rs index 378a48dc..d3ad4e08 100644 --- a/src/api/subagent_fallback.rs +++ b/src/api/subagent_fallback.rs @@ -313,15 +313,28 @@ async fn persist_conversation_identity( else { return Ok(()); }; + let mut transaction = state + .repository + .pool() + .begin_with("BEGIN IMMEDIATE") + .await + .map_err(db_problem)?; + super::chatgpt_support::guard_conversation_binding( + &mut transaction, + child_task_id, + Some(conversation_id), + ) + .await?; sqlx::query("INSERT INTO chatgpt_conversations(task_id,conversation_id,conversation_url,model,active_request_id,created_at_ms,updated_at_ms) VALUES(?,?,?,'Auto',NULL,?,?) ON CONFLICT(task_id) DO UPDATE SET conversation_id=excluded.conversation_id,conversation_url=excluded.conversation_url,model=excluded.model,updated_at_ms=excluded.updated_at_ms") .bind(child_task_id) .bind(conversation_id) .bind(conversation_url) .bind(now) .bind(now) - .execute(state.repository.pool()) + .execute(&mut *transaction) .await .map_err(db_problem)?; + transaction.commit().await.map_err(db_problem)?; Ok(()) } diff --git a/src/runtime_host/approval/authorization.rs b/src/runtime_host/approval/authorization.rs index d144b4b9..9adc8905 100644 --- a/src/runtime_host/approval/authorization.rs +++ b/src/runtime_host/approval/authorization.rs @@ -316,6 +316,7 @@ impl RuntimeHost { }; Ok(GrantPathScope { path: normalized_path(&canonical), + canonical_path: Some(canonical.to_string_lossy().into_owned()), kind, identity: path_identity(&canonical), }) diff --git a/src/runtime_host/approval/common.rs b/src/runtime_host/approval/common.rs index 6c423348..709670fe 100644 --- a/src/runtime_host/approval/common.rs +++ b/src/runtime_host/approval/common.rs @@ -29,6 +29,8 @@ const SAFE_READ_MAX_BYTES: i64 = 1_073_741_824; #[serde(rename_all = "camelCase")] struct GrantPathScope { path: String, + #[serde(default)] + canonical_path: Option, kind: GrantPathScopeKind, #[serde(default)] identity: Option, diff --git a/src/runtime_host/approval/grants.rs b/src/runtime_host/approval/grants.rs index 84cf30f9..ba4d4532 100644 --- a/src/runtime_host/approval/grants.rs +++ b/src/runtime_host/approval/grants.rs @@ -136,6 +136,7 @@ impl RuntimeHost { }; requested_scopes.push(GrantPathScope { path: normalized_path(&canonical), + canonical_path: Some(canonical.to_string_lossy().into_owned()), kind, identity: path_identity(&canonical), }); diff --git a/src/runtime_host/approval/helpers.rs b/src/runtime_host/approval/helpers.rs index b366236d..073fe82f 100644 --- a/src/runtime_host/approval/helpers.rs +++ b/src/runtime_host/approval/helpers.rs @@ -233,7 +233,7 @@ fn normalized_path(path: &Path) -> String { fn path_allowed(path: &Path, scopes: &[GrantPathScope]) -> bool { let path = normalized_path(path); scopes.iter().any(|scope| { - let scope_path = Path::new(&scope.path); + let scope_path = scope.canonical_path.as_deref().unwrap_or(&scope.path); let scope_still_bound = std::fs::canonicalize(scope_path).is_ok_and(|canonical| { normalized_path(&canonical) == scope.path && scope.identity == path_identity(&canonical) }); diff --git a/src/runtime_host/approval/tests.rs b/src/runtime_host/approval/tests.rs index 7145642a..0d54f501 100644 --- a/src/runtime_host/approval/tests.rs +++ b/src/runtime_host/approval/tests.rs @@ -35,6 +35,7 @@ mod tests { let root = std::fs::canonicalize(root).expect("canonical root"); let scopes = vec![GrantPathScope { path: normalized_path(&root), + canonical_path: Some(root.to_string_lossy().into_owned()), kind: GrantPathScopeKind::Subtree, identity: path_identity(&root), }]; diff --git a/src/runtime_host/dispatch.rs b/src/runtime_host/dispatch.rs index 26990694..57803d3d 100644 --- a/src/runtime_host/dispatch.rs +++ b/src/runtime_host/dispatch.rs @@ -33,7 +33,8 @@ impl RuntimeHost { context: OperationContext, arguments: Value, ) -> RuntimeResult { - let project_folder = if tool.starts_with("fs_") + let filesystem_tool = is_filesystem_tool(tool); + let project_folder = if filesystem_tool || tool.starts_with("git_") || tool == "shell_create" || tool == "command_run" @@ -47,7 +48,7 @@ impl RuntimeHost { } else { None }; - let mut task_path_scopes = if tool.starts_with("fs_") + let mut task_path_scopes = if filesystem_tool || tool.starts_with("git_") || matches!(tool, "command_run" | "shell_create" | "workspace_roots") { @@ -60,13 +61,13 @@ impl RuntimeHost { { task_path_scopes.push(project_folder.clone()); } - let scoped_workspace = if tool.starts_with("fs_") || tool.starts_with("git_") { + let scoped_workspace = if filesystem_tool || tool.starts_with("git_") { Some(self.workspace.with_additional_scopes(&task_path_scopes)?) } else { None }; let workspace = scoped_workspace.as_ref().unwrap_or(&self.workspace); - let arguments = if tool.starts_with("fs_") { + let arguments = if filesystem_tool { filesystem_dispatch::resolve_relative_paths(arguments, project_folder.as_deref())? } else { arguments diff --git a/src/runtime_host/git_support.rs b/src/runtime_host/git_support.rs index f9393b7a..ffde2d3c 100644 --- a/src/runtime_host/git_support.rs +++ b/src/runtime_host/git_support.rs @@ -8,18 +8,19 @@ impl RuntimeHost { context: &OperationContext, explicit: Option, ) -> RuntimeResult { - if let Some(cwd) = explicit { - return Ok(cwd); + if explicit.as_ref().is_some_and(|cwd| cwd.is_absolute()) { + return Ok(explicit.expect("absolute cwd checked above")); } - if let Some(project_folder) = + let project_folder = ::project_folder(self, context.task_id.as_deref()) .await? - { - return Ok(project_folder.into()); - } - Err(RuntimeError::new( - "project_folder_required", - "git cwd was omitted; provide the project folder or an explicit absolute working path", - )) + .map(std::path::PathBuf::from) + .ok_or_else(|| { + RuntimeError::new( + "project_folder_required", + "git cwd requires the task project folder unless an explicit absolute path is provided", + ) + })?; + Ok(explicit.map_or(project_folder.clone(), |cwd| project_folder.join(cwd))) } } diff --git a/src/runtime_host/git_tests.rs b/src/runtime_host/git_tests.rs index 0da90be6..f46a22be 100644 --- a/src/runtime_host/git_tests.rs +++ b/src/runtime_host/git_tests.rs @@ -129,6 +129,6 @@ async fn git_status_uses_task_project_folder_when_cwd_is_omitted() { assert_eq!(error.code, "project_folder_required"); assert_eq!( error.message, - "git cwd was omitted; provide the project folder or an explicit absolute working path" + "git cwd requires the task project folder unless an explicit absolute path is provided" ); } diff --git a/src/runtime_host/identity.rs b/src/runtime_host/identity.rs index 41f30004..0ee96d00 100644 --- a/src/runtime_host/identity.rs +++ b/src/runtime_host/identity.rs @@ -16,7 +16,13 @@ impl RuntimeHost { first_user_message: Option<&str>, ) -> RuntimeResult<()> { let conversation_scope = context.conversation_scope_id.clone(); - let bound_task = if conversation_scope.is_none() && context.task_id.is_none() { + let explicit_task = context + .task_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned); + let bound_task = if explicit_task.is_none() { self.bound_task_for_turn(context).await? } else { None @@ -38,17 +44,21 @@ impl RuntimeHost { } else { None }; - let mapped_scope_task = if delegated_task.is_none() && chatgpt_bridge_task.is_none() { - if let Some(scope) = conversation_scope.as_deref() { - self.bound_task_for_conversation_scope(&context.agent_id, scope) - .await? + let mapped_scope_task = + if explicit_task.is_none() && delegated_task.is_none() && chatgpt_bridge_task.is_none() + { + if let Some(scope) = conversation_scope.as_deref() { + self.bound_task_for_conversation_scope(&context.agent_id, scope) + .await? + } else { + None + } } else { None - } - } else { - None - }; - let pending_chatgpt_bridge_task = if delegated_task.is_none() + }; + let pending_chatgpt_bridge_task = if explicit_task.is_none() + && bound_task.is_none() + && delegated_task.is_none() && chatgpt_bridge_task.is_none() && mapped_scope_task.is_none() { @@ -72,23 +82,31 @@ impl RuntimeHost { } else { None }; - let task = delegated_task.unwrap_or_else(|| { - chatgpt_bridge_task.unwrap_or_else(|| { - mapped_scope_task.unwrap_or_else(|| { - pending_chatgpt_bridge_task.unwrap_or_else(|| { - if let (Some(scope), Some(message)) = - (conversation_scope.as_deref(), first_user_message) - { - task_identity_from_first_message(&context.agent_id, scope, message) - } else { - select_task_identity( - &context.agent_id, - conversation_scope.as_deref(), - context.task_id.as_deref(), - bound_task.as_deref(), - &context.request_id, - ) - } + let task = explicit_task.unwrap_or_else(|| { + delegated_task.unwrap_or_else(|| { + chatgpt_bridge_task.unwrap_or_else(|| { + mapped_scope_task.unwrap_or_else(|| { + bound_task.unwrap_or_else(|| { + pending_chatgpt_bridge_task.unwrap_or_else(|| { + if let (Some(scope), Some(message)) = + (conversation_scope.as_deref(), first_user_message) + { + task_identity_from_first_message( + &context.agent_id, + scope, + message, + ) + } else { + select_task_identity( + &context.agent_id, + conversation_scope.as_deref(), + None, + None, + &context.request_id, + ) + } + }) + }) }) }) }) @@ -153,11 +171,10 @@ impl RuntimeHost { id: task_id.clone(), agent_id: AgentId::new(&context.agent_id).ok(), device_id: self.device.id.clone(), - conversation_scope_hash: conversation_scope.or_else(|| { - current - .as_ref() - .and_then(|task| task.conversation_scope_hash.clone()) - }), + conversation_scope_hash: current + .as_ref() + .and_then(|task| task.conversation_scope_hash.clone()) + .or(conversation_scope), title: current.as_ref().and_then(|task| task.title.clone()), source: current .as_ref() @@ -430,28 +447,24 @@ fn select_task_identity( bound_task_id: Option<&str>, request_id: &str, ) -> String { - if let Some(scope) = conversation_scope.filter(|value| !value.trim().is_empty()) { - return safe_id("task-chat", agent_id, scope); - } if let Some(task_id) = explicit_task_id.filter(|value| !value.trim().is_empty()) { return task_id.trim().to_owned(); } if let Some(task_id) = bound_task_id.filter(|value| !value.trim().is_empty()) { return task_id.trim().to_owned(); } + if let Some(scope) = conversation_scope.filter(|value| !value.trim().is_empty()) { + return safe_id("task-chat", agent_id, scope); + } safe_id("task", agent_id, request_id) } fn task_identity_from_first_message( agent_id: &str, conversation_scope: &str, - message: &str, + _message: &str, ) -> String { - safe_id( - "task-chat", - agent_id, - &format!("{conversation_scope}\0first-user-message:{message}"), - ) + safe_id("task-chat", agent_id, conversation_scope) } fn safe_id(prefix: &str, agent_id: &str, scope: &str) -> String { @@ -463,78 +476,5 @@ fn safe_id(prefix: &str, agent_id: &str, scope: &str) -> String { } #[cfg(test)] -mod tests { - use super::{ - select_task_identity, task_identity_from_first_message, unique_bridge_task_for_message, - }; - - #[test] - fn conversation_scope_overrides_stale_explicit_task() { - let first = - select_task_identity("agent", Some("openai:chat-a"), Some("old-task"), None, "r1"); - let second = - select_task_identity("agent", Some("openai:chat-b"), Some("old-task"), None, "r2"); - assert_ne!(first, "old-task"); - assert_ne!(second, "old-task"); - assert_ne!(first, second); - } - - #[test] - fn first_user_message_participates_in_new_chat_identity() { - let first = task_identity_from_first_message("agent", "openai:scope", "xin chào"); - assert_eq!( - first, - task_identity_from_first_message("agent", "openai:scope", "xin chào") - ); - assert_ne!( - first, - task_identity_from_first_message("agent", "openai:scope", "một tin nhắn khác") - ); - } - - #[test] - fn explicit_task_and_turn_binding_are_safe_fallbacks_without_private_scope() { - assert_eq!( - select_task_identity("agent", None, Some("task-known"), Some("task-bound"), "r1"), - "task-known" - ); - assert_eq!( - select_task_identity("agent", None, None, Some("task-bound"), "r1"), - "task-bound" - ); - assert_ne!( - select_task_identity("agent", None, None, None, "r1"), - select_task_identity("agent", None, None, None, "r2") - ); - } - - #[test] - fn unicode_space_bridge_match_requires_one_unambiguous_task() { - let rows = vec![("task-a".to_owned(), "Ví dụ abcd ".to_owned())]; - assert_eq!( - unique_bridge_task_for_message(&rows, None, "Ví dụ abcd\u{00a0}"), - Some("task-a".to_owned()) - ); - - let ambiguous = vec![ - ("task-a".to_owned(), "Ví dụ abcd ".to_owned()), - ("task-b".to_owned(), "Ví dụ abcd\u{202f}".to_owned()), - ]; - assert_eq!( - unique_bridge_task_for_message(&ambiguous, None, "Ví dụ abcd\u{00a0}"), - None - ); - assert_eq!( - unique_bridge_task_for_message(&ambiguous, Some("task-b"), "Ví dụ abcd\u{00a0}"), - Some("task-b".to_owned()) - ); - assert_eq!( - unique_bridge_task_for_message( - &ambiguous, - Some("task-unrelated"), - "Ví dụ abcd\u{00a0}" - ), - None - ); - } -} +#[path = "identity_tests.rs"] +mod tests; diff --git a/src/runtime_host/identity_tests.rs b/src/runtime_host/identity_tests.rs new file mode 100644 index 00000000..6e0f710b --- /dev/null +++ b/src/runtime_host/identity_tests.rs @@ -0,0 +1,82 @@ +use super::{ + select_task_identity, task_identity_from_first_message, unique_bridge_task_for_message, +}; + +#[test] +fn explicit_task_overrides_changed_conversation_scope() { + assert_eq!( + select_task_identity( + "agent", + Some("openai:chat-b"), + Some("task-current"), + None, + "r1" + ), + "task-current" + ); +} + +#[test] +fn bound_turn_overrides_changed_conversation_scope() { + assert_eq!( + select_task_identity( + "agent", + Some("openai:chat-b"), + None, + Some("task-bound"), + "r1" + ), + "task-bound" + ); +} + +#[test] +fn conversation_scope_has_stable_task_identity_across_messages() { + let first = task_identity_from_first_message("agent", "openai:scope", "xin chào"); + assert_eq!( + first, + task_identity_from_first_message("agent", "openai:scope", "một tin nhắn khác") + ); +} + +#[test] +fn explicit_task_and_turn_binding_are_safe_fallbacks_without_private_scope() { + assert_eq!( + select_task_identity("agent", None, Some("task-known"), Some("task-bound"), "r1"), + "task-known" + ); + assert_eq!( + select_task_identity("agent", None, None, Some("task-bound"), "r1"), + "task-bound" + ); + assert_ne!( + select_task_identity("agent", None, None, None, "r1"), + select_task_identity("agent", None, None, None, "r2") + ); +} + +#[test] +fn unicode_space_bridge_match_requires_one_unambiguous_task() { + let rows = vec![("task-a".to_owned(), "Ví dụ abcd ".to_owned())]; + assert_eq!( + unique_bridge_task_for_message(&rows, None, "Ví dụ abcd\u{00a0}"), + Some("task-a".to_owned()) + ); + + let ambiguous = vec![ + ("task-a".to_owned(), "Ví dụ abcd ".to_owned()), + ("task-b".to_owned(), "Ví dụ abcd\u{202f}".to_owned()), + ]; + assert_eq!( + unique_bridge_task_for_message(&ambiguous, None, "Ví dụ abcd\u{00a0}"), + None + ); + assert_eq!( + unique_bridge_task_for_message(&ambiguous, Some("task-b"), "Ví dụ abcd\u{00a0}"), + Some("task-b".to_owned()) + ); + assert_eq!( + unique_bridge_task_for_message(&ambiguous, Some("task-unrelated"), "Ví dụ abcd\u{00a0}"), + None + ); +} diff --git a/src/runtime_host/user_message.rs b/src/runtime_host/user_message.rs index 901c2494..71e1901d 100644 --- a/src/runtime_host/user_message.rs +++ b/src/runtime_host/user_message.rs @@ -70,11 +70,11 @@ impl RuntimeHost { .unwrap_or_default(); for path in extract_explicit_absolute_paths(&content) { scopes.insert(path); - if scopes.len() >= 32 { + if scopes.len() >= 256 { break; } } - if scopes.len() >= 32 { + if scopes.len() >= 256 { break; } } @@ -404,7 +404,7 @@ fn extract_explicit_absolute_paths(content: &str) -> Vec { continue; } unique.insert(canonical); - if unique.len() >= 16 { + if unique.len() >= 64 { break; } } diff --git a/src/runtime_host/user_message_path_tests.rs b/src/runtime_host/user_message_path_tests.rs index dd4aab02..f02c1fdb 100644 --- a/src/runtime_host/user_message_path_tests.rs +++ b/src/runtime_host/user_message_path_tests.rs @@ -316,7 +316,7 @@ async fn chatgpt_bridge_reuses_existing_task_when_chatgpt_reformats_the_prompt() assert_eq!(row.get::("source"), "chatgpt_web"); assert_eq!( row.get::("conversation_scope_hash"), - "openai:mcp-session-derived" + "openai:url-conversation-id" ); let task_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM tasks WHERE agent_id=?") .bind(&agent_id) @@ -453,7 +453,7 @@ async fn chatgpt_bridge_claims_first_tool_call_before_user_message_sync() { assert_eq!(row.get::("source"), "chatgpt_web"); assert_eq!( row.get::("conversation_scope_hash"), - "openai:host-session-scope" + "openai:WEB:temporary-browser-scope" ); let task_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM tasks WHERE agent_id=?") .bind(&agent_id) diff --git a/web/src/chatgptBridge.ts b/web/src/chatgptBridge.ts index 0e0a8a4a..f84c214e 100644 --- a/web/src/chatgptBridge.ts +++ b/web/src/chatgptBridge.ts @@ -3,7 +3,7 @@ import { tr } from './i18n'; const REQUEST_TYPE = 'chatcmd-chatgpt-extension-request'; const RESPONSE_TYPE = 'chatcmd-chatgpt-extension-response'; -export const REQUIRED_CHATGPT_EXTENSION_VERSION = '0.1.9'; +export const REQUIRED_CHATGPT_EXTENSION_VERSION = '0.1.10'; type BridgeCommand = | { action: 'compact-resume'; nonce: string; jobId: string; taskId: string; localBaseUrl: string } @@ -15,7 +15,7 @@ type BridgeCommand = | { action: 'logs'; nonce: string } | { action: 'clear-logs'; nonce: string } | { action: 'send'; nonce: string; requestId: string; submittedContent: string; model: string; conversationUrl?: string; newConversationUrl?: string; localBaseUrl: string } - | { action: 'subagent-send'; nonce: string; subagentId: string; childTaskId: string; submittedContent: string; attempt: number; model: string; newConversationUrl?: string; localBaseUrl: string } + | { action: 'subagent-send'; nonce: string; subagentId: string; childTaskId: string; submittedContent: string; attempt: number; model: string; conversationUrl?: string; localBaseUrl: string } | { action: 'subagent-close'; nonce: string; subagentId: string } | { action: 'stop'; nonce: string; requestId: string; localBaseUrl: string } | { action: 'reconcile'; nonce: string; requestId: string } @@ -75,7 +75,7 @@ export async function dispatchChatGptRequest(input: { requestId: string; submitt await bridge({ action: 'send', nonce: nonce(), ...input, localBaseUrl: window.location.origin }, 5_000); } -export async function dispatchSubagentFallback(input: { subagentId: string; childTaskId: string; submittedContent: string; attempt: number; model?: string; newConversationUrl?: string }) { +export async function dispatchSubagentFallback(input: { subagentId: string; childTaskId: string; submittedContent: string; attempt: number; model?: string; conversationUrl?: string }) { await bridge({ action: 'subagent-send', nonce: nonce(), ...input, model: input.model || 'Auto', localBaseUrl: window.location.origin }, 5_000); } diff --git a/web/src/extensions/copy.ts b/web/src/extensions/copy.ts index 383a8320..28d80e8b 100644 --- a/web/src/extensions/copy.ts +++ b/web/src/extensions/copy.ts @@ -31,7 +31,7 @@ const en = { step3Title: 'Load the packaged extension', step3Body: 'Choose Load unpacked, then select the chatgpt-extension folder that ships beside ChatCMD.', step4Title: 'Reload ChatCMD and verify', - step4Body: 'Return to ChatCMD, reload the page, then use Check again. ChatCMD currently requires extension version 0.1.9.', + step4Body: 'Return to ChatCMD, reload the page, then use Check again. ChatCMD currently requires extension version 0.1.10.', }; type ExtensionCopy = { [K in keyof typeof en]: string }; @@ -67,7 +67,7 @@ const vi: ExtensionCopy = { step3Title: 'Nạp extension đi kèm ChatCMD', step3Body: 'Chọn Load unpacked, sau đó chọn thư mục chatgpt-extension nằm cạnh bản cài ChatCMD.', step4Title: 'Reload ChatCMD và kiểm tra', - step4Body: 'Quay lại ChatCMD, reload trang rồi bấm Kiểm tra lại. ChatCMD hiện yêu cầu extension phiên bản 0.1.9.', + step4Body: 'Quay lại ChatCMD, reload trang rồi bấm Kiểm tra lại. ChatCMD hiện yêu cầu extension phiên bản 0.1.10.', }; export function extensionCopy(language: AppLanguage) { diff --git a/web/src/tasks/GlobalSubagentFallbackBridge.tsx b/web/src/tasks/GlobalSubagentFallbackBridge.tsx index 10374f91..eda536b9 100644 --- a/web/src/tasks/GlobalSubagentFallbackBridge.tsx +++ b/web/src/tasks/GlobalSubagentFallbackBridge.tsx @@ -3,7 +3,6 @@ import { api, type SubagentFallbackRequest } from '../api'; import { closeSubagentFallbackTab, dispatchSubagentFallback } from '../chatgptBridge'; import { useRealtime } from '../realtime'; import type { TimelineEvent } from '../types'; -import { canonicalProjectPath } from './workspaceProjects'; export function GlobalSubagentFallbackBridge() { const inFlight = useRef(new Set()); @@ -14,17 +13,12 @@ export function GlobalSubagentFallbackBridge() { if (inFlight.current.has(key)) return; inFlight.current.add(key); try { - let newConversationUrl: string | undefined; - if (fallback.projectFolder) { - const projects = await api.workspaceProjects(); - newConversationUrl = projects.find((project) => canonicalProjectPath(project.path) === canonicalProjectPath(fallback.projectFolder ?? ''))?.chatGptProjectUrl?.trim() || undefined; - } await dispatchSubagentFallback({ subagentId: fallback.subagentId, childTaskId: fallback.childTaskId, submittedContent: fallback.submittedContent, attempt: fallback.attempt, - newConversationUrl, + conversationUrl: fallback.conversationUrl ?? undefined, }); } catch (error) { try {