diff --git a/chatgpt-extension/background-compact-destination.js b/chatgpt-extension/background-compact-destination.js index e47cbac9..be89e9b0 100644 --- a/chatgpt-extension/background-compact-destination.js +++ b/chatgpt-extension/background-compact-destination.js @@ -5,14 +5,22 @@ async function locateCompactDestination(job, record, tabs) { }); if (tagged.length > 1) throw new Error('Có nhiều tab nhận cùng handoff. Không tự chọn để tránh gắn nhầm cuộc trò chuyện.'); if (tagged[0]) return tagged[0]; - if (job.newConversationId) return tabs.find((tab) => conversationIdFromUrl(tab.url || '') === job.newConversationId) || null; + if (job.newConversationId) { + const canonical = tabs.find((tab) => conversationIdFromUrl(tab.url || '') === job.newConversationId); + if (canonical) return canonical; + } const bound = tabs.find((tab) => tab.id === record.destinationTabId); - if (bound && !conversationIdFromUrl(bound.url || '') && isChatGptUrl(bound.url)) return bound; - // A browser restart changes tab ids and ChatGPT may remove the hash. The user marker - // is durable evidence; a title, latest answer or coincidentally blank tab is not. + if (bound && !job.newConversationId && !conversationIdFromUrl(bound.url || '') && isChatGptUrl(bound.url)) return bound; + // Chrome's tab URL can lag behind ChatGPT's SPA navigation, and a browser restart can + // also change tab ids after the operation hash is removed. The exact RESUME user marker + // is durable ownership evidence, so use it even when the browser still reports a home/ + // project URL or when a known canonical id is temporarily absent from chrome.tabs.query. const matches = []; for (const tab of tabs) { - if (!tab.id || !conversationIdFromUrl(tab.url || '') || sameConversationUrl(tab.url, job.oldConversationUrl)) continue; + const conversationId = conversationIdFromUrl(tab.url || ''); + const mayBeStaleDestination = Boolean(job.newConversationId || tab.id === record.destinationTabId); + if (!tab.id || !isChatGptUrl(tab.url) || sameConversationUrl(tab.url, job.oldConversationUrl) + || (!conversationId && !mayBeStaleDestination)) continue; try { const found = await chrome.tabs.sendMessage(tab.id, { type: 'chatcmd-compact-locate', job, kind: 'RESUME' }); if (found?.ok && found.markerFound) matches.push(tab); @@ -44,7 +52,10 @@ async function compactDestination(job, record, tabs) { record = await saveCompactRecord(job.id, { ...record, destinationTabId: destination.id, destinationOpened: true }); const probe = await compactSend(destination.id, 'probe', job, 'RESUME'); if (probe.markerFound && probe.conversationId && !isProvisionalConversationId(probe.conversationId)) { - if (probe.superseded) throw new Error('Chat mới đã nhận thêm nội dung trước khi chuyển task. Hãy kiểm tra tab trước khi tiếp tục.'); + // A later user turn does not invalidate the destination. It is the normal race when the + // user continues immediately after ChatGPT acknowledges RESUME but before the worker's + // final checkpoint. The exact operation marker still proves this conversation owns the + // handoff; RuntimeHost separately prevents local tools from running until attachment. // Publish the recoverable destination identity first, then immediately re-probe the // same tab instead of sleeping for another scheduler tick before the final commit. let confirmed = probe; @@ -53,7 +64,6 @@ async function compactDestination(job, record, tabs) { newConversationUrl: probe.conversationUrl, detail: null }); confirmed = await compactSend(destination.id, 'probe', job, 'RESUME'); } - if (confirmed.superseded) throw new Error('Chat mới đã nhận thêm nội dung trước khi chuyển task. Hãy kiểm tra tab trước khi tiếp tục.'); if (!confirmed.markerFound || confirmed.conversationId !== job.newConversationId || isProvisionalConversationId(confirmed.conversationId) || confirmed.generating) return; job = await compactCheckpoint(record, job, { phase: 'completed', newConversationId: confirmed.conversationId, diff --git a/chatgpt-extension/background.js b/chatgpt-extension/background.js index d0dc4c77..7ffe9ed4 100644 --- a/chatgpt-extension/background.js +++ b/chatgpt-extension/background.js @@ -197,12 +197,17 @@ async function startSubagentRequestOnce(message) { if (!state.active || state.status !== 'pending') return; if (existing) await closeSubagentRequest(message.subagentId, existing.attempt); - if (!message.conversationUrl) { - throw new Error('Browser sub-agent fallback không được phép tạo ChatGPT conversation mới.'); + const target = message.conversationUrl + ? await conversationTarget(message.conversationUrl) + : normalizeNewConversationUrl(message.newConversationUrl); + const tab = message.conversationUrl + ? await openConversationTab(target) + : await chrome.tabs.create({ url: target, active: false }); + if (!tab?.id) { + throw new Error(message.conversationUrl + ? 'Không thể mở lại ChatGPT conversation hiện tại cho sub-agent.' + : 'Không thể mở tab ChatGPT mới cho sub-agent.'); } - const target = await conversationTarget(message.conversationUrl); - const tab = await openConversationTab(target); - if (!tab?.id) throw new Error('Không thể mở lại ChatGPT conversation hiện tại cho sub-agent.'); const requestId = `subagent:${message.subagentId}:${attempt}`; await chrome.storage.session.set({ [requestKey(requestId)]: { @@ -212,7 +217,7 @@ async function startSubagentRequestOnce(message) { subagentId: message.subagentId, childTaskId: message.childTaskId, attempt, - conversationUrl: target, + conversationUrl: message.conversationUrl ? target : null, }, [subagentKey]: { requestId, tabId: tab.id, attempt }, }); diff --git a/chatgpt-extension/compact-content.test.cjs b/chatgpt-extension/compact-content.test.cjs index 3a6b65f4..e41d5b45 100644 --- a/chatgpt-extension/compact-content.test.cjs +++ b/chatgpt-extension/compact-content.test.cjs @@ -80,13 +80,16 @@ test('a later user turn supersedes the owned handoff and cannot contaminate capt assert.equal(result.handoffText, null); }); -test('unidentified user nodes never authorize capture or locate', async (t) => { +test('exact public handoff turn without native message id uses a safe DOM identity', async (t) => { const env = contentFixture(t); const value = job(); env.user(env.protocol.handoffPrompt(value), null); env.answer(BODY + '\n' + env.protocol.marker('HANDOFF-END', value.id)); - assert.equal(env.settled(value).handoffText, null); - assert.equal((await env.message('locate', value)).markerFound, false); + const result = env.settled(value); + assert.equal(result.markerFound, true); + assert.match(result.userMessageId, /^dom-compact:/); + assert.equal(result.handoffText, BODY); + assert.equal((await env.message('locate', value)).markerFound, true); }); test('real transcript parser excludes tool roots, hidden surfaces, commentary and private state', (t) => { diff --git a/chatgpt-extension/compact-destination.test.cjs b/chatgpt-extension/compact-destination.test.cjs index 96bd74fd..83c3d1ae 100644 --- a/chatgpt-extension/compact-destination.test.cjs +++ b/chatgpt-extension/compact-destination.test.cjs @@ -34,6 +34,36 @@ test('destination recovery uses the durable resume marker, not a recycled tab id assert.deepEqual(env.shared.calls.map((call) => call.tabId), [41, 50]); }); +test('known destination identity falls back to the exact RESUME marker when Chrome URL is stale', async (t) => { + const env = await destinationWorker(t, { + newConversationId: 'destination-owned', newConversationUrl: 'https://chatgpt.com/c/destination-owned', + }, { destinationTabId: 9, destinationOpened: true, destinationSend: 'dispatched-unresolved' }); + const tabs = [{ id: 9, url: 'https://chatgpt.com/' }]; + env.shared.route = async (id, message) => { + assert.equal(message.type, 'chatcmd-compact-locate'); + assert.equal(message.kind, 'RESUME'); + return { ok: true, markerFound: id === 9 }; + }; + const found = await env.api.locateCompactDestination(env.serverJob(), env.record(), tabs); + assert.equal(found.id, 9); +}); + +test('opening_new_chat completes when Chrome still reports home for the already attached destination', async (t) => { + const env = await destinationWorker(t, { + newConversationId: 'destination-owned', newConversationUrl: 'https://chatgpt.com/c/destination-owned', + }, { destinationTabId: 9, destinationOpened: true, destinationSend: 'dispatched-unresolved' }); + env.shared.tabs = [{ id: 9, url: 'https://chatgpt.com/' }]; + const probe = receiver({ markerFound: true, generating: false, conversationId: 'destination-owned', + conversationUrl: 'https://chatgpt.com/c/destination-owned' }); + env.shared.route = (id, message) => message.type === 'chatcmd-compact-locate' + ? { ok: true, markerFound: id === 9 } : probe(id, message); + await env.tick(); + assert.equal(env.serverJob().phase, 'completed'); + assert.equal(env.serverJob().taskId, job().taskId); + assert.equal(env.shared.creates.length, 0); + assert.equal(env.sends().length, 0); +}); + test('unrelated blank tabs are never adopted when persisted destination binding is absent', async (t) => { const env = await destinationWorker(t); const tabs = [{ id: 70, url: 'https://chatgpt.com/' }, { id: 71, url: 'https://chatgpt.com/g/g-p-p/project' }]; @@ -256,18 +286,25 @@ test('completed job with closed destination waits to resume work until that exac assert.equal(env.shared.creates.length, 0); }); -test('provisional conversation id and superseded resume cannot complete task rebinding', async (t) => { - for (const observation of [ - { markerFound: true, conversationId: 'WEB:provisional', conversationUrl: 'https://chatgpt.com/c/WEB:provisional' }, - { markerFound: true, superseded: true, conversationId: 'destination-owned', conversationUrl: 'https://chatgpt.com/c/destination-owned' }, - ]) { - const env = await destinationWorker(t); - env.shared.route = receiver(observation); - await assert.rejects(env.tick()); - assert.equal(env.serverJob().phase, 'opening_new_chat'); - assert.equal(env.shared.effects.filter((entry) => entry.type === 'bind').length, 0); - assert.equal(env.sends().length, 0); - } +test('provisional conversation id cannot complete task rebinding', async (t) => { + const env = await destinationWorker(t); + env.shared.route = receiver({ markerFound: true, conversationId: 'WEB:provisional', + conversationUrl: 'https://chatgpt.com/c/WEB:provisional' }); + await assert.rejects(env.tick()); + assert.equal(env.serverJob().phase, 'opening_new_chat'); + assert.equal(env.shared.effects.filter((entry) => entry.type === 'bind').length, 0); + assert.equal(env.sends().length, 0); +}); + +test('a user turn after the exact RESUME marker does not deadlock destination attachment', async (t) => { + const env = await destinationWorker(t); + env.shared.route = receiver({ markerFound: true, superseded: true, generating: false, + conversationId: 'destination-owned', conversationUrl: 'https://chatgpt.com/c/destination-owned' }); + await env.tick(); + assert.equal(env.serverJob().phase, 'completed'); + assert.equal(env.serverJob().newConversationId, 'destination-owned'); + assert.equal(env.serverJob().taskId, job().taskId); + assert.equal(env.sends().length, 0); }); for (const choice of [false, undefined]) { diff --git a/chatgpt-extension/compact-integration.test.cjs b/chatgpt-extension/compact-integration.test.cjs index 6ae065c3..33e87243 100644 --- a/chatgpt-extension/compact-integration.test.cjs +++ b/chatgpt-extension/compact-integration.test.cjs @@ -67,6 +67,74 @@ async function integrated(t) { destination: () => destination }; } +test('source send recovers when ChatGPT renders the owned user turn without data-message-id', async (t) => { + const worker = await workerFixture(t); + const value = worker.seed(); + const source = contentFixture(t); + worker.shared.tabs = [{ id: 7, url: value.oldConversationUrl }]; + source.state.onClick = () => { + source.user(source.composer().value, null); + source.composer().value = ''; + }; + worker.shared.route = (_id, message) => source.message(message.type.replace('chatcmd-compact-', ''), + message.job, message.kind, message.documentToken); + + await worker.tick(); + assert.equal(source.state.clicks, 1); + assert.equal(worker.serverJob().phase, 'writing_handoff'); + assert.equal(worker.record().sourceSend, 'dispatched-unresolved'); + + source.answer(BODY + '\n' + source.protocol.marker('HANDOFF-END', value.id)); + source.probe(worker.serverJob()); + source.advance(1201); + await worker.tick(); + + assert.equal(worker.serverJob().phase, 'opening_new_chat'); + assert.equal(worker.serverJob().handoffText, BODY); + assert.equal(worker.shared.creates.length, 1, 'the worker must leave Writing the handoff instead of stalling on its durable send fence'); + assert.equal(worker.sends('HANDOFF').length, 1, 'recovery must not resend the handoff'); +}); + +test('destination attach recovers when ChatGPT renders the resume turn without data-message-id', async (t) => { + const env = await integrated(t); + const destination = await env.openDestination(); + const tab = env.worker.shared.tabs.find((item) => item.id !== 1 && item.id !== 7); + assert.ok(tab, 'destination tab'); + destination.state.onClick = () => { + destination.user(destination.composer().value, null); + destination.composer().value = ''; + tab.url = 'https://chatgpt.com/c/destination-no-native-id'; + destination.navigate(tab.url); + destination.answer('Handoff received.'); + }; + + await env.worker.tick(); + assert.equal(destination.state.clicks, 1); + assert.equal(env.worker.serverJob().phase, 'opening_new_chat'); + assert.equal(env.worker.record().destinationSend, 'dispatched-unresolved'); + + await env.worker.tick(); + assert.equal(env.worker.serverJob().phase, 'completed'); + assert.equal(env.worker.serverJob().newConversationId, 'destination-no-native-id'); + assert.equal(env.worker.sends('RESUME').length, 1, 'destination recovery must not resend the resume handoff'); +}); + +test('user continuing immediately after RESUME acknowledgement still completes same-task attachment', async (t) => { + const env = await integrated(t); + const destination = await env.openDestination(); + await env.worker.tick(); + assert.equal(destination.state.clicks, 1); + assert.equal(env.worker.serverJob().phase, 'opening_new_chat'); + destination.user('tiếp tục công việc', 'working-user'); + destination.answer('conversation_compacting_or_archived', { id: 'blocked-working-answer' }); + await env.worker.tick(); + assert.equal(env.worker.serverJob().phase, 'completed'); + assert.equal(env.worker.serverJob().taskId, env.value.taskId); + assert.equal(env.worker.serverJob().newConversationId, 'destination-canonical'); + assert.equal(env.worker.sends('RESUME').length, 1); + assert.equal(env.worker.shared.creates.length, 1); +}); + test('real content-worker round trip saves exact handoff, preserves task/model, and sends once per chat', async (t) => { const env = await integrated(t); const dest = await env.openDestination(); diff --git a/chatgpt-extension/content-chatgpt-compact.js b/chatgpt-extension/content-chatgpt-compact.js index 1073a9e7..46840d0c 100644 --- a/chatgpt-extension/content-chatgpt-compact.js +++ b/chatgpt-extension/content-chatgpt-compact.js @@ -59,8 +59,14 @@ if (matches.length !== 1) return { duplicate: matches.length > 1, user: null }; const node = matches[0]; if (!promptsFor(job, kind).some((text) => comparable(read(node)) === comparable(text))) return { duplicate: false, user: null }; - const id = node.getAttribute('data-message-id') || node.querySelector('[data-message-id]')?.getAttribute('data-message-id'); - return { duplicate: false, user: id ? { node, id } : null, last: roots.at(-1) === node }; + // ChatGPT does not consistently expose data-message-id on rendered user turns. + // The unique exact operation prompt already proves ownership; the id is only an + // opaque same-document token used by read/close recovery, so synthesize one when + // the public DOM omits a native message id instead of leaving compact stuck forever. + const nativeId = node.getAttribute('data-message-id') || node.querySelector('[data-message-id]')?.getAttribute('data-message-id'); + const index = roots.indexOf(node); + const id = nativeId || `dom-compact:${job.id}:${index}`; + return { duplicate: false, user: { node, id }, last: roots.at(-1) === node }; } function show(job) { if (!isCurrent()) return; diff --git a/chatgpt-extension/manifest.json b/chatgpt-extension/manifest.json index e7fe60b5..c7a3cc68 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.11", + "version": "0.1.12", "description": "Bridges the local ChatCMD console to an already signed-in chatgpt.com tab.", "permissions": ["tabs", "storage", "scripting", "alarms"], "host_permissions": ["https://chatgpt.com/*", "http://localhost/*", "http://127.0.0.1/*"], diff --git a/crates/chatcmd-mcp/src/server_contract.rs b/crates/chatcmd-mcp/src/server_contract.rs index 6c8f3985..6158b94b 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; 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 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 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_keep_no_sampling_work_in_parent_conversation() { - assert!(SERVER_INSTRUCTIONS.contains("parentContinuation")); - assert!(SERVER_INSTRUCTIONS.contains("browser child fallback was intentionally disabled")); + fn server_instructions_require_parent_to_wait_for_extension_fallback() { + assert!(SERVER_INSTRUCTIONS.contains("extensionFallback")); assert!( SERVER_INSTRUCTIONS - .contains("continue the delegated work directly in the parent conversation") + .contains("queued the reserved child task for the browser extension") ); - assert!(SERVER_INSTRUCTIONS.contains("do not open or create another ChatGPT conversation")); - assert!(!SERVER_INSTRUCTIONS.contains("extensionFallback")); + 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")); } } diff --git a/crates/chatcmd-mcp/src/subagent_worker.rs b/crates/chatcmd-mcp/src/subagent_worker.rs index 749b7194..f388ac38 100644 --- a/crates/chatcmd-mcp/src/subagent_worker.rs +++ b/crates/chatcmd-mcp/src/subagent_worker.rs @@ -67,17 +67,19 @@ pub(super) async fn dispatch_registered_subagent( .is_some_and(|info| info.capabilities.sampling.is_some()); if !sampling { - 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; + let fallback = runtime + .request_subagent_fallback(&parent_context, ®istration, &delegated_prompt) + .await?; return Ok(enrich_registration( registration, json!({ - "dispatchMode": "parentContinuation", + "dispatchMode": "extensionFallback", "nativeDelegationRequired": false, - "status": "failed", + "status": "pending", "workerStarted": false, - "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." + "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." }), )); } diff --git a/crates/chatcmd-mcp/src/subagent_worker_test_cases.rs b/crates/chatcmd-mcp/src/subagent_worker_test_cases.rs index 80831392..016b56da 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_keeps_delegated_work_in_parent_conversation() { +async fn no_sampling_client_queues_extension_fallback_without_failing_child() { use rmcp::{ServiceExt as _, model::CallToolRequestParams}; let runtime = FakeRuntime::default(); @@ -313,18 +313,16 @@ async fn no_sampling_client_keeps_delegated_work_in_parent_conversation() { let structured = result.structured_content.expect("structured result"); assert_eq!( structured.get("dispatchMode"), - Some(&json!("parentContinuation")) + Some(&json!("extensionFallback")) ); assert_eq!( structured.get("nativeDelegationRequired"), Some(&json!(false)) ); - assert_eq!(structured.get("status"), Some(&json!("failed"))); + assert_eq!(structured.get("status"), Some(&json!("pending"))); assert_eq!(structured.get("workerStarted"), Some(&json!(false))); - assert_eq!(structured.get("fallbackRequested"), Some(&json!(false))); - assert!(structured["instruction"] - .as_str() - .is_some_and(|value| value.contains("parent conversation"))); + assert_eq!(structured.get("fallbackRequested"), Some(&json!(true))); + assert_eq!(structured.get("fallbackAttempt"), Some(&json!(1))); let calls = recorded.lock().expect("recorded"); let names = calls @@ -332,8 +330,20 @@ async fn no_sampling_client_keeps_delegated_work_in_parent_conversation() { .map(|(name, _, _)| name.as_str()) .collect::>(); assert!(names.contains(&"agent_subagent_start")); - assert!(names.contains(&"fail_subagent")); - assert!(!names.contains(&"request_subagent_fallback")); + 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" + )) + ); 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 d4d5e7c8..c61e1f85 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; 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.")] + #[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.")] 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 578662f7..70b47783 100644 --- a/crates/chatcmd-mcp/tests/coding_fixtures/cases.json +++ b/crates/chatcmd-mcp/tests/coding_fixtures/cases.json @@ -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":"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":"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":"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 69ac5efb..4c85dc3d 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", - "parentContinuation", + "extensionFallback", "existing", "status=failed", "startupError", diff --git a/docs/coding-agent-contract.md b/docs/coding-agent-contract.md index 125f04f1..73184458 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. 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. +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. ## 3. Clarification và execution consent diff --git a/docs/mcp_method.md b/docs/mcp_method.md index c834a0f7..17efb02b 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. 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_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_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/runtime_host/compact.rs b/src/runtime_host/compact.rs index 129a6549..a4fce389 100644 --- a/src/runtime_host/compact.rs +++ b/src/runtime_host/compact.rs @@ -1,9 +1,120 @@ //! Compact's task identity fence precedes normal MCP identity creation/adoption. -use super::{RuntimeHost, storage_error}; +use super::{RuntimeHost, now_ms, storage_error}; use chatcmd_runtime::{OperationContext, RuntimeError, RuntimeResult}; +use chatcmd_storage::compact::{CompactCheckpoint, CompactPhase}; use serde_json::Value; impl RuntimeHost { + async fn settle_replacement_compact_if_ready( + &self, + tool: &str, + context: &OperationContext, + arguments: &Value, + ) -> RuntimeResult<()> { + if tool != "agent_user_message" || is_resume_bootstrap(arguments) { + return Ok(()); + } + let Some(scope) = context.conversation_scope_id.as_deref() else { + return Ok(()); + }; + + // The browser publishes a canonical destination identity while the acknowledgement + // is still generating. A real user turn from that exact authenticated scope is + // therefore sufficient to finish a delayed opening_new_chat checkpoint before + // normal MCP identity selection. This closes the race where the acknowledgement + // is visible before the extension's next 400 ms recovery tick commits `completed`. + for _ in 0..2 { + let candidate = sqlx::query_as::<_, (String, String, i64, String, String)>( + r#"SELECT j.id,j.task_id,j.revision,j.new_conversation_id,j.new_conversation_url + FROM chatgpt_compact_jobs j + JOIN tasks t ON t.id=j.task_id + WHERE j.phase='opening_new_chat' + AND j.new_scope_hash=? + AND j.new_conversation_id IS NOT NULL + AND j.new_conversation_url IS NOT NULL + AND t.agent_id=? AND t.source='chatgpt_web' + ORDER BY j.updated_at_ms DESC,j.id DESC + LIMIT 1"#, + ) + .bind(scope) + .bind(&context.agent_id) + .fetch_optional(self.repository.pool()) + .await + .map_err(|error| { + storage_error(chatcmd_core::StorageError::Backend(error.to_string())) + })?; + let Some((id, task_id, revision, conversation_id, conversation_url)) = candidate else { + return Ok(()); + }; + if context + .task_id + .as_deref() + .is_some_and(|expected| expected != task_id) + { + return Ok(()); + } + + let checkpoint = CompactCheckpoint { + expected_revision: revision, + phase: Some(CompactPhase::Completed), + new_conversation_id: Some(conversation_id), + new_conversation_url: Some(conversation_url), + detail: Some(None), + ..CompactCheckpoint::default() + }; + let operation_id = format!("compact-mcp-resume-{id}-{revision}"); + match self + .repository + .compact_checkpoint(&id, &checkpoint, &operation_id, now_ms()) + .await + { + Ok(completed) => { + self.publish_event( + format!("chatgpt-compact-{}-{}", completed.id, completed.revision), + "chatgpt_compact_updated", + Some(completed.task_id.clone()), + None, + None, + serde_json::json!({ + "jobId": completed.id, + "taskId": completed.task_id, + "phase": completed.phase, + "revision": completed.revision, + "updatedAtMs": completed.updated_at_ms, + }), + ); + return Ok(()); + } + Err(error) => { + let latest = self + .repository + .compact_job(&id) + .await + .map_err(storage_error)?; + match latest.phase { + CompactPhase::Completed => return Ok(()), + CompactPhase::OpeningNewChat => { + // The extension may have advanced only the CAS revision while this + // user call arrived. Reload once and retry the same safe transition. + continue; + } + CompactPhase::Cancelled => { + return Err(RuntimeError::new( + "compact_resume_cancelled", + "The context handoff was cancelled before this replacement conversation could resume the task.", + )); + } + _ => return Err(storage_error(error)), + } + } + } + } + Err(RuntimeError::new( + "compact_resume_race", + "The replacement conversation changed while ChatCMD was attaching it. No local tool was run.", + )) + } + pub(super) async fn call_compact_checked( &self, tool: &str, @@ -11,6 +122,11 @@ impl RuntimeHost { arguments: Value, ) -> RuntimeResult { let _call = self.activities.track_compact_call(&context)?; + let bootstrap = tool == "agent_user_message" && is_resume_bootstrap(&arguments); + if !bootstrap { + self.settle_replacement_compact_if_ready(tool, &context, &arguments) + .await?; + } let scope = context.conversation_scope_id.as_deref(); let task = context.task_id.as_deref(); // An authenticated conversation scope wins over a transport session that @@ -24,11 +140,6 @@ impl RuntimeHost { ).bind(scope).bind(scope).bind(session).bind(task).bind(scope).bind(scope) .fetch_one(self.repository.pool()).await .map_err(|error| storage_error(chatcmd_core::StorageError::Backend(error.to_string())))?; - let bootstrap = tool == "agent_user_message" - && arguments - .get("content") - .and_then(Value::as_str) - .is_some_and(|text| text.trim_start().starts_with("[[CHATCMD-RESUME:")); if blocked || bootstrap { return Err(RuntimeError::new( "conversation_compacting_or_archived", @@ -39,10 +150,19 @@ impl RuntimeHost { } } +fn is_resume_bootstrap(arguments: &Value) -> bool { + arguments + .get("content") + .and_then(Value::as_str) + .is_some_and(|text| text.trim_start().starts_with("[[CHATCMD-RESUME:")) +} + #[cfg(test)] mod tests { - use crate::runtime_host::ActivityRegistry; + use crate::runtime_host::{ActivityRegistry, user_message_tests}; use chatcmd_runtime::OperationContext; + use chatcmd_storage::compact::{CompactCheckpoint, CompactPhase, openai_scope}; + use serde_json::json; #[test] fn compact_barrier_tracks_lifecycle_calls_and_duplicate_request_ids_until_both_finish() { @@ -58,4 +178,183 @@ mod tests { drop(second); assert!(activities.compact_settled("task", Some("openai:old"))); } + + #[tokio::test] + async fn first_real_turn_in_replacement_scope_finishes_delayed_compact_before_resume() { + let (host, agent_id, _directory) = user_message_tests::test_host().await; + let old_id = "11111111-1111-4111-8111-111111111111"; + let new_id = "22222222-2222-4222-8222-222222222222"; + let old_url = format!("https://chatgpt.com/c/{old_id}"); + let new_url = format!("https://chatgpt.com/c/{new_id}"); + let old_scope = openai_scope(old_id); + let new_scope = openai_scope(new_id); + + let mut initial = OperationContext::new( + "compact-regression-initial", + &agent_id, + "agent_user_message", + ); + initial.turn_id = Some("compact-regression-turn-initial".to_owned()); + initial.conversation_scope_id = Some(old_scope.clone()); + let accepted = host + .call_persisted( + "agent_user_message", + initial, + json!({"content":"Start the compact regression"}), + ) + .await + .expect("create original task"); + let task_id = accepted["taskId"].as_str().expect("task id").to_owned(); + let now = crate::runtime_host::now_ms(); + + sqlx::query("UPDATE tasks SET source='chatgpt_web',conversation_scope_hash=? WHERE id=?") + .bind(&old_scope) + .bind(&task_id) + .execute(host.repository.pool()) + .await + .expect("mark task as ChatGPT web"); + sqlx::query("INSERT INTO chatgpt_conversations(task_id,conversation_id,conversation_url,model,active_request_id,created_at_ms,updated_at_ms) VALUES(?,?,?,?,NULL,?,?)") + .bind(&task_id) + .bind(old_id) + .bind(&old_url) + .bind("gpt-test") + .bind(now) + .bind(now) + .execute(host.repository.pool()) + .await + .expect("bind original ChatGPT conversation"); + + let mut job = host + .repository + .compact_start(&task_id, "compact-regression-start", now + 1) + .await + .expect("start compact"); + job = host + .repository + .compact_checkpoint( + &job.id, + &CompactCheckpoint { + expected_revision: job.revision, + phase: Some(CompactPhase::SavingHandoff), + handoff_text: Some("FACTUAL HANDOFF".to_owned()), + ..CompactCheckpoint::default() + }, + "compact-regression-save-handoff", + now + 2, + ) + .await + .expect("save handoff"); + job = host + .repository + .compact_checkpoint( + &job.id, + &CompactCheckpoint { + expected_revision: job.revision, + phase: Some(CompactPhase::OpeningNewChat), + new_conversation_id: Some(new_id.to_owned()), + new_conversation_url: Some(new_url.clone()), + ..CompactCheckpoint::default() + }, + "compact-regression-open-destination", + now + 3, + ) + .await + .expect("reserve replacement conversation"); + assert_eq!(job.phase, CompactPhase::OpeningNewChat); + + let mut bootstrap = OperationContext::new( + "compact-regression-bootstrap", + &agent_id, + "agent_user_message", + ); + bootstrap.turn_id = Some("compact-regression-turn-bootstrap".to_owned()); + bootstrap.conversation_scope_id = Some(new_scope.clone()); + let bootstrap_error = host + .call_compact_checked( + "agent_user_message", + bootstrap, + json!({"content":format!("[[CHATCMD-RESUME:{}]]\nHandoff", job.id)}), + ) + .await + .expect_err("bootstrap must remain tool-free"); + assert_eq!(bootstrap_error.code, "conversation_compacting_or_archived"); + assert_eq!( + host.repository + .compact_job(&job.id) + .await + .expect("read compact job") + .phase, + CompactPhase::OpeningNewChat + ); + + let mut old_scope_turn = OperationContext::new( + "compact-regression-old-scope", + &agent_id, + "agent_user_message", + ); + old_scope_turn.task_id = Some(task_id.clone()); + old_scope_turn.turn_id = Some("compact-regression-turn-old-scope".to_owned()); + old_scope_turn.conversation_scope_id = Some(old_scope.clone()); + let old_scope_error = host + .call_compact_checked( + "agent_user_message", + old_scope_turn, + json!({"content":"continue from the archived source"}), + ) + .await + .expect_err("old source must stay fenced"); + assert_eq!(old_scope_error.code, "conversation_compacting_or_archived"); + + let mut continued = OperationContext::new( + "compact-regression-continued", + &agent_id, + "agent_user_message", + ); + continued.turn_id = Some("compact-regression-turn-continued".to_owned()); + continued.conversation_scope_id = Some(new_scope.clone()); + let mut events = host.events.subscribe(); + let resumed = host + .call_compact_checked( + "agent_user_message", + continued, + json!({"content":"tiếp tục công việc"}), + ) + .await + .expect("replacement conversation should resume the same task"); + + let compact_event = events + .try_recv() + .expect("compact completion realtime event"); + assert_eq!(compact_event.event_type, "chatgpt_compact_updated"); + assert_eq!(compact_event.task_id.as_deref(), Some(task_id.as_str())); + assert_eq!(compact_event.payload["phase"], "completed"); + assert_eq!(resumed["taskId"], task_id); + assert_eq!( + host.repository + .compact_job(&job.id) + .await + .expect("read completed compact") + .phase, + CompactPhase::Completed + ); + let task_state: (Option, i64, String) = sqlx::query_as( + "SELECT conversation_scope_hash,generation,status FROM tasks WHERE id=?", + ) + .bind(&task_id) + .fetch_one(host.repository.pool()) + .await + .expect("read resumed task"); + assert_eq!(task_state.0.as_deref(), Some(new_scope.as_str())); + assert_eq!(task_state.1, 2); + assert_eq!(task_state.2, "running"); + let archived: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM chatgpt_compact_archives WHERE task_id=? AND scope_hash=?", + ) + .bind(&task_id) + .bind(&old_scope) + .fetch_one(host.repository.pool()) + .await + .expect("read archive fence"); + assert_eq!(archived, 1); + } } diff --git a/src/runtime_host/dispatch.rs b/src/runtime_host/dispatch.rs index 57803d3d..135a9184 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 path_scopes; mod shell_handoff; mod tool_authorization; @@ -61,17 +62,22 @@ impl RuntimeHost { { task_path_scopes.push(project_folder.clone()); } + let arguments = if filesystem_tool { + filesystem_dispatch::resolve_relative_paths(arguments, project_folder.as_deref())? + } else { + arguments + }; + if filesystem_tool || tool.starts_with("git_") { + task_path_scopes.extend(path_scopes::argument_path_scopes(&arguments)); + task_path_scopes.sort(); + task_path_scopes.dedup(); + } 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 filesystem_tool { - filesystem_dispatch::resolve_relative_paths(arguments, project_folder.as_deref())? - } else { - arguments - }; let scoped_git = scoped_workspace .clone() .map(|workspace| self.git.with_workspace(workspace)); @@ -108,6 +114,12 @@ impl RuntimeHost { .clone() .ok_or_else(project_folder_required_for_shell)?, }; + let mut shell_scopes = task_path_scopes.clone(); + if let Some(scope) = path_scopes::scope_for_path(&working_directory) { + shell_scopes.push(scope); + shell_scopes.sort(); + shell_scopes.dedup(); + } self.enable_shell_file_watcher(&context); let info = self .shell @@ -122,7 +134,7 @@ impl RuntimeHost { columns: input.columns, rows: input.rows, }, - &task_path_scopes, + &shell_scopes, ) .await?; self.persist_shell_session(&context, &info).await?; diff --git a/src/runtime_host/dispatch/command_tools.rs b/src/runtime_host/dispatch/command_tools.rs index 7ffbba26..d2a44a26 100644 --- a/src/runtime_host/dispatch/command_tools.rs +++ b/src/runtime_host/dispatch/command_tools.rs @@ -4,6 +4,7 @@ use chatcmd_runtime::{CommandRunRequest, OperationContext, RuntimeError, Runtime use serde_json::Value; use super::super::{RuntimeHost, parse, value}; +use super::path_scopes; impl RuntimeHost { pub(super) async fn dispatch_command_run( @@ -19,7 +20,13 @@ impl RuntimeHost { .map(|folder| folder.join(&input.cwd)) .ok_or_else(project_folder_required)?; } - let workspace = self.workspace.with_additional_scopes(task_path_scopes)?; + let mut scopes = task_path_scopes.to_vec(); + if let Some(scope) = path_scopes::scope_for_path(&input.cwd) { + scopes.push(scope); + scopes.sort(); + scopes.dedup(); + } + let workspace = self.workspace.with_additional_scopes(&scopes)?; let command = self.command.with_workspace(workspace); value(command.run(context, input).await?) } diff --git a/src/runtime_host/dispatch/path_scopes.rs b/src/runtime_host/dispatch/path_scopes.rs new file mode 100644 index 00000000..361f6e1e --- /dev/null +++ b/src/runtime_host/dispatch/path_scopes.rs @@ -0,0 +1,78 @@ +use std::path::{Path, PathBuf}; + +use serde_json::Value; + +pub(super) fn argument_path_scopes(arguments: &Value) -> Vec { + let mut scopes = Vec::new(); + collect_value_paths(arguments, &mut scopes); + scopes.sort(); + scopes.dedup(); + scopes +} + +pub(super) fn scope_for_path(path: &Path) -> Option { + if !path.is_absolute() { + return None; + } + let mut candidate = path.to_path_buf(); + loop { + if candidate.exists() { + let canonical = candidate.canonicalize().ok()?; + return canonical.parent().is_some().then_some(canonical); + } + if !candidate.pop() { + return None; + } + } +} + +fn collect_value_paths(value: &Value, scopes: &mut Vec) { + match value { + Value::String(value) => { + let path = PathBuf::from(value); + if let Some(scope) = scope_for_path(&path) { + scopes.push(scope); + } + } + Value::Array(values) => { + for value in values { + collect_value_paths(value, scopes); + } + } + Value::Object(values) => { + for value in values.values() { + collect_value_paths(value, scopes); + } + } + _ => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use tempfile::TempDir; + + #[test] + fn grants_existing_absolute_path_from_tool_arguments() { + let external = TempDir::new().expect("external"); + let arguments = json!({"path": external.path()}); + let scopes = argument_path_scopes(&arguments); + assert!(scopes.contains(&external.path().canonicalize().expect("canonical"))); + } + + #[test] + fn grants_existing_parent_for_new_absolute_target() { + let external = TempDir::new().expect("external"); + let target = external.path().join("new-folder").join("new-file.txt"); + let arguments = json!({"path": target}); + let scopes = argument_path_scopes(&arguments); + assert!(scopes.contains(&external.path().canonicalize().expect("canonical"))); + } + + #[test] + fn ignores_relative_argument_paths() { + assert!(argument_path_scopes(&json!({"path": "src/main.rs"})).is_empty()); + } +} diff --git a/src/runtime_host/user_message.rs b/src/runtime_host/user_message.rs index 71e1901d..1d8579c1 100644 --- a/src/runtime_host/user_message.rs +++ b/src/runtime_host/user_message.rs @@ -11,7 +11,10 @@ use super::{RuntimeHost, invalid, now_ms, storage_error}; #[path = "user_message_intent.rs"] mod intent; +#[path = "user_message_paths.rs"] +mod paths; use intent::{intent_hint, is_plan_mode_request}; +use paths::extract_explicit_absolute_paths; impl RuntimeHost { pub(super) async fn ensure_user_message_synced( @@ -365,52 +368,6 @@ fn safe_id(prefix: &str, agent_id: &str, scope: &str) -> String { ) } -fn extract_explicit_absolute_paths(content: &str) -> Vec { - let mut candidates = Vec::new(); - let mut quoted = None::<(char, usize)>; - for (index, ch) in content.char_indices() { - if matches!(ch, '`' | '"' | '\'') { - if let Some((delimiter, start)) = quoted { - if delimiter == ch { - candidates.push(&content[start..index]); - quoted = None; - } - } else { - quoted = Some((ch, index + ch.len_utf8())); - } - } - } - candidates.extend(content.split_whitespace()); - - let mut unique = BTreeSet::new(); - for candidate in candidates { - let cleaned = candidate.trim_matches(|ch: char| { - matches!( - ch, - '`' | '"' | '\'' | ',' | ';' | ':' | '(' | ')' | '[' | ']' | '{' | '}' - ) - }); - if cleaned.is_empty() { - continue; - } - let path = PathBuf::from(cleaned); - if !path.is_absolute() || !path.exists() { - continue; - } - let Ok(canonical) = path.canonicalize() else { - continue; - }; - if canonical.parent().is_none() { - continue; - } - unique.insert(canonical); - if unique.len() >= 64 { - break; - } - } - unique.into_iter().collect() -} - fn same_user_message(payload_json: &str, content: &str) -> bool { serde_json::from_str::(payload_json) .ok() diff --git a/src/runtime_host/user_message_paths.rs b/src/runtime_host/user_message_paths.rs new file mode 100644 index 00000000..fcba9d8d --- /dev/null +++ b/src/runtime_host/user_message_paths.rs @@ -0,0 +1,144 @@ +use std::{collections::BTreeSet, path::PathBuf}; + +pub(super) fn extract_explicit_absolute_paths(content: &str) -> Vec { + let mut candidates = quoted_candidates(content); + candidates.extend(content.split_whitespace().map(str::to_owned)); + for line in content.lines() { + candidates.extend(existing_absolute_paths_in_line(line)); + } + + let mut unique = BTreeSet::new(); + for candidate in candidates { + let cleaned = clean_candidate(&candidate); + if cleaned.is_empty() { + continue; + } + let path = PathBuf::from(cleaned); + if !path.is_absolute() || !path.exists() { + continue; + } + let Ok(canonical) = path.canonicalize() else { + continue; + }; + if canonical.parent().is_none() { + continue; + } + unique.insert(canonical); + if unique.len() >= 64 { + break; + } + } + unique.into_iter().collect() +} + +fn quoted_candidates(content: &str) -> Vec { + let mut candidates = Vec::new(); + let mut quoted = None::<(char, usize)>; + for (index, ch) in content.char_indices() { + if matches!(ch, '`' | '"' | '\'') { + if let Some((delimiter, start)) = quoted { + if delimiter == ch { + candidates.push(content[start..index].to_owned()); + quoted = None; + } + } else { + quoted = Some((ch, index + ch.len_utf8())); + } + } + } + candidates +} + +fn existing_absolute_paths_in_line(line: &str) -> Vec { + let mut results = Vec::new(); + let indices = line + .char_indices() + .map(|(index, _)| index) + .collect::>(); + for start in indices { + let tail = &line[start..]; + if !looks_like_absolute_path_start(line, start, tail) { + continue; + } + if let Some(path) = longest_existing_prefix(tail) { + results.push(path); + } + } + results +} + +fn looks_like_absolute_path_start(line: &str, start: usize, tail: &str) -> bool { + let boundary = start == 0 + || line[..start].chars().next_back().is_some_and(|ch| { + ch.is_whitespace() || matches!(ch, ':' | '=' | '(' | '[' | '{' | '`' | '"' | '\'') + }); + if !boundary { + return false; + } + let bytes = tail.as_bytes(); + let windows = bytes.len() >= 3 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && matches!(bytes[2], b'\\' | b'/'); + windows || tail.starts_with('/') +} + +fn longest_existing_prefix(tail: &str) -> Option { + let mut ends = tail + .char_indices() + .map(|(index, _)| index) + .collect::>(); + ends.push(tail.len()); + ends.sort_unstable_by(|left, right| right.cmp(left)); + for end in ends { + let cleaned = clean_candidate(&tail[..end]); + if cleaned.is_empty() { + continue; + } + let path = PathBuf::from(cleaned); + if path.is_absolute() && path.exists() { + return Some(cleaned.to_owned()); + } + } + None +} + +fn clean_candidate(candidate: &str) -> &str { + candidate.trim().trim_matches(|ch: char| { + matches!( + ch, + '`' | '"' | '\'' | ',' | ';' | ':' | '(' | ')' | '[' | ']' | '{' | '}' + ) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn extracts_unquoted_existing_path_with_spaces() { + let root = TempDir::new().expect("temp root"); + let folder = root.path().join("folder with spaces"); + std::fs::create_dir_all(&folder).expect("create folder"); + let content = format!("Thư mục dự án: {} để thực hiện yêu cầu", folder.display()); + let paths = extract_explicit_absolute_paths(&content); + assert!(paths.contains(&folder.canonicalize().expect("canonical folder"))); + } + + #[test] + fn quoted_path_remains_supported() { + let root = TempDir::new().expect("temp root"); + let folder = root.path().join("quoted folder"); + std::fs::create_dir_all(&folder).expect("create folder"); + let content = format!("project `{}`", folder.display()); + let paths = extract_explicit_absolute_paths(&content); + assert!(paths.contains(&folder.canonicalize().expect("canonical folder"))); + } + + #[test] + fn relative_paths_are_not_granted() { + assert!(extract_explicit_absolute_paths("src/runtime_host").is_empty()); + } +} diff --git a/web/src/chatgptBridge.ts b/web/src/chatgptBridge.ts index 4c9db831..8da918b8 100644 --- a/web/src/chatgptBridge.ts +++ b/web/src/chatgptBridge.ts @@ -4,7 +4,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.11'; +export const REQUIRED_CHATGPT_EXTENSION_VERSION = '0.1.12'; type BridgeCommand = | { action: 'compact-resume'; nonce: string; jobId: string; taskId: string; localBaseUrl: string } @@ -16,7 +16,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; attachments?: ChatGptFileAttachmentPayload[]; localBaseUrl: string } - | { action: 'subagent-send'; nonce: string; subagentId: string; childTaskId: string; submittedContent: string; attempt: number; model: string; conversationUrl?: string; localBaseUrl: string } + | { action: 'subagent-send'; nonce: string; subagentId: string; childTaskId: string; submittedContent: string; attempt: number; model: string; conversationUrl?: string; newConversationUrl?: string; localBaseUrl: string } | { action: 'subagent-close'; nonce: string; subagentId: string } | { action: 'stop'; nonce: string; requestId: string; localBaseUrl: string } | { action: 'reconcile'; nonce: string; requestId: string } @@ -76,7 +76,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; conversationUrl?: string }) { +export async function dispatchSubagentFallback(input: { subagentId: string; childTaskId: string; submittedContent: string; attempt: number; model?: string; conversationUrl?: string; newConversationUrl?: 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 28d80e8b..314a74c4 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.10.', + step4Body: 'Return to ChatCMD, reload the page, then use Check again. ChatCMD currently requires extension version 0.1.12.', }; 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.10.', + 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.12.', }; export function extensionCopy(language: AppLanguage) { diff --git a/web/src/tasks/GlobalSubagentFallbackBridge.tsx b/web/src/tasks/GlobalSubagentFallbackBridge.tsx index eda536b9..d8a5f8c9 100644 --- a/web/src/tasks/GlobalSubagentFallbackBridge.tsx +++ b/web/src/tasks/GlobalSubagentFallbackBridge.tsx @@ -3,6 +3,7 @@ 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()); @@ -13,12 +14,20 @@ export function GlobalSubagentFallbackBridge() { if (inFlight.current.has(key)) return; inFlight.current.add(key); try { + let newConversationUrl: string | undefined; + if (!fallback.conversationUrl && 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, conversationUrl: fallback.conversationUrl ?? undefined, + newConversationUrl, }); } catch (error) { try {