diff --git a/AGENTS.md b/AGENTS.md index 3f95406..d999f21 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,3 +20,69 @@ If you're using Codex, project-scoped helpers may also live in: Keep this managed block so 'trellis update' can refresh the instructions. + +## Codex App / hapi 对齐经验 + +以下约定来自本项目对 `/home/hdzx/2026/hapi` 中 Codex app-server 接入方式的对比结果。后续如果继续维护 `codexapp`,默认按这些约定实现,避免再走偏到“看起来能跑、但拿不到原生协作能力”的分叉路径。 + +### 1. 初始化链路优先对齐上游协议 + +- `initialize` 成功后,先发送 `initialized` notification。 +- 然后再做 best-effort 的能力探测: + - `experimentalFeature/enablement/set`,当前至少尝试 `{ enablement: { goals: true } }` + - `collaborationMode/list` +- 上述探测失败时: + - 只记录日志 + - 不直接中断 `codexapp` 启动 + +### 2. collaborationMode 的参数形状必须贴近 hapi + +- 一旦本轮使用 `collaborationMode`,`turn/start` 顶层参数应保持精简。 +- `model`、`reasoning_effort`、`developer_instructions` 应放入: + - `collaborationMode.settings.model` + - `collaborationMode.settings.reasoning_effort` + - `collaborationMode.settings.developer_instructions` +- 使用 `collaborationMode` 时,不要再重复传顶层 `model` / `effort`,否则容易让 app-server 落到非原生协作路径。 + +### 3. 自定义跨会话能力走 MCP,不优先走 dynamicTools + +- `ccweb_list_conversations`、`ccweb_send_message` 这类自定义能力,优先通过 `thread/start.config.mcp_servers.*` 挂载。 +- 不要把它们当成 `dynamicTools` 主路径注入给 `codexapp`。 +- 原因: + - `dynamicTools` 容易污染 app-server 原生工具集 + - 会增加拿不到 `spawn_agent` / `wait_agent` / 原生协作工具的风险 + +### 4. MCP 配置要做成“线程级”而不是“进程级” + +- `codexapp` 是长驻 app-server,不是一次一进程的 CLI 调用。 +- 因此 `CC_WEB_SOURCE_SESSION_ID`、`CC_WEB_CROSS_HOP_COUNT` 这类来源上下文,不应只放在 app-server 进程全局环境里。 +- 正确做法是随 `thread/start.config.mcp_servers.ccweb.env` 一起下发,让每个线程拿到自己的来源会话上下文。 + +### 5. guided input 依赖 plan 协作模式 + +- `request_user_input` 类能力默认按“协作 / plan 模式可用”来设计。 +- Default / YOLO 模式下,不要假设引导输入一定可用。 +- 如果要做降级: + - 优先退回普通文本交互 + - 不要让整轮对话因为 guided input 不可用而直接失效 + +### 6. 能力降级要明确而保守 + +- 如果运行时拒绝 `collaborationMode`(例如 `unknown field collaborationMode` 或 `unsupported collaboration mode`): + - 应记录一次能力降级 + - 后续本轮可退回普通 turn 发送 +- 但如果只是 `goals` 或 `collaborationMode/list` 探测失败: + - 不应直接判定整个 app-server 不可用 + +### 7. 回归检查要覆盖协议形状,不只看 UI + +后续涉及 `codexapp` 的改动,至少要检查这些点: + +- `initialize` 后是否真的调用了: + - `experimentalFeature/enablement/set` + - `collaborationMode/list` +- `collaborationMode` 存在时: + - 顶层是否没有重复 `model` + - 顶层是否没有重复 `effort` +- `ccweb` 能力是否走 `mcpToolCall`,而不是再次退回 `dynamicToolCall` +- mock / regression 中是否覆盖了上述断言 diff --git a/lib/codex-app-runtime.js b/lib/codex-app-runtime.js index 2084f2d..9e50a35 100644 --- a/lib/codex-app-runtime.js +++ b/lib/codex-app-runtime.js @@ -235,9 +235,10 @@ function createCodexAppRuntime(deps = {}) { if (!nextText) return ''; if (!entry.agentMessageItems) entry.agentMessageItems = new Map(); const currentItemText = entry.agentMessageItems.get(itemId) || ''; + const separator = agentMessageSeparator(entry, itemId, nextText); entry.agentMessageItems.set(itemId, currentItemText + nextText); - entry.fullText = (entry.fullText || '') + nextText; - return nextText; + entry.fullText = (entry.fullText || '') + separator + nextText; + return separator + nextText; } function appendAgentCompletedText(entry, item) { @@ -252,9 +253,18 @@ function createCodexAppRuntime(deps = {}) { return remainder; } if (currentItemText === text) return ''; + const separator = agentMessageSeparator(entry, item.id, text); entry.agentMessageItems.set(item.id, text); - entry.fullText = (entry.fullText || '') + text; - return text; + entry.fullText = (entry.fullText || '') + separator + text; + return separator + text; + } + + function agentMessageSeparator(entry, itemId, nextText) { + if (entry.agentMessageItems?.get(itemId)) return ''; + const currentText = entry.fullText || ''; + if (!/\S/.test(currentText)) return ''; + const hasVisualBoundary = /\n\s*(?:---|\*\*\*|___)\s*$/.test(currentText) || /^\s*(?:---|\*\*\*|___)\s*\n/.test(String(nextText || '')); + return hasVisualBoundary ? '' : '\n\n---\n\n'; } function updateToolResult(entry, sessionId, itemId, result, done = false, patch = {}) { diff --git a/public/app.js b/public/app.js index 89ea712..854c372 100644 --- a/public/app.js +++ b/public/app.js @@ -122,6 +122,7 @@ let noteMode = false; let noteDraftSeq = 0; const pendingNotesByTarget = new Map(); + const userMessageIndex = new Map(); // --- DOM --- const $ = (sel) => document.querySelector(sel); @@ -149,6 +150,8 @@ const chatAgentMenu = $('#chat-agent-menu'); const chatRuntimeState = $('#chat-runtime-state'); const chatCwd = $('#chat-cwd'); + const userOutlineBtn = $('#user-outline-btn'); + const userOutlinePanel = $('#user-outline-panel'); const costDisplay = $('#cost-display'); const attachmentTray = $('#attachment-tray'); const pendingNotesTray = $('#pending-notes-tray'); @@ -682,6 +685,79 @@ return value ? value.slice(0, 8) : ''; } + function shortMessagePreview(text, maxLength = 60) { + const value = String(text || '').replace(/\s+/g, ' ').trim(); + if (!value) return '空消息'; + return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value; + } + + function createLocalId(prefix = 'local') { + if (window.crypto?.randomUUID) return `${prefix}-${window.crypto.randomUUID()}`; + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + } + + function clearUserMessageIndex() { + userMessageIndex.clear(); + } + + function registerUserMessage(messageId, element, content) { + if (!messageId || !element) return; + userMessageIndex.set(messageId, { + id: messageId, + element, + content: String(content || ''), + }); + } + + function buildUserOutlineItems() { + return Array.from(userMessageIndex.values()).map((entry) => ({ + id: entry.id, + targetMessageId: entry.element?.id || '', + label: shortMessagePreview(entry.content, 64), + })).filter((entry) => entry.targetMessageId); + } + + function updateUserOutlinePanel() { + if (!userOutlinePanel || !userOutlineBtn) return; + const items = buildUserOutlineItems(); + if (items.length === 0) { + userOutlinePanel.innerHTML = '
暂无用户消息
'; + userOutlineBtn.disabled = true; + } else { + userOutlinePanel.innerHTML = items.map((item, index) => ` + + `).join(''); + userOutlineBtn.disabled = false; + } + } + + function closeUserOutlinePanel() { + if (!userOutlinePanel || !userOutlineBtn) return; + userOutlinePanel.hidden = true; + userOutlineBtn.setAttribute('aria-expanded', 'false'); + } + + function toggleUserOutlinePanel() { + if (!userOutlinePanel || !userOutlineBtn) return; + if (userOutlinePanel.hidden) { + updateUserOutlinePanel(); + userOutlinePanel.hidden = false; + userOutlineBtn.setAttribute('aria-expanded', 'true'); + } else { + closeUserOutlinePanel(); + } + } + + function scrollToMessage(anchorId) { + if (!anchorId) return; + const target = document.getElementById(anchorId); + if (!target) return; + target.scrollIntoView({ block: 'start', behavior: 'smooth' }); + } + function updateSessionIdBadge() { if (!chatSessionIdBtn) return; if (!currentSessionId) { @@ -2183,6 +2259,7 @@ function resetChatView(agent) { setCurrentAgent(agent); + closeUserOutlinePanel(); closeFileBrowser(); currentSessionId = null; loadedHistorySessionId = null; @@ -2236,6 +2313,7 @@ migratePendingNotesToSession(snapshot.sessionId, snapshotAgent); setCurrentSessionRunningState(snapshot.isRunning); setStatsDisplay(snapshot); + closeUserOutlinePanel(); currentCwd = snapshot.cwd || null; updateCwdBadge(); if (snapshot.mode && MODE_LABELS[snapshot.mode]) { @@ -2261,6 +2339,7 @@ const targetAgent = normalizeAgent(agent); const { preserveCurrent = true, loadLast = true } = options; setCurrentAgent(targetAgent); + closeUserOutlinePanel(); renderSessionList(); const currentMeta = currentSessionId ? getSessionMeta(currentSessionId) : null; @@ -2353,6 +2432,7 @@ if (currentSessionId && currentSessionId !== sessionId) { send({ type: 'detach_view' }); } + closeUserOutlinePanel(); clearSessionLoading(); touchSessionCache(sessionId); applySessionSnapshot(snapshot, { immediate: true, suppressUnreadToast: true }); @@ -2361,6 +2441,7 @@ function openSession(sessionId, options = {}) { if (!sessionId) return; + closeUserOutlinePanel(); if (options.forceSync) { beginSessionSwitch(sessionId, { blocking: options.blocking !== false, force: true, label: options.label }); return; @@ -3111,7 +3192,12 @@ const div = document.createElement('div'); const isCrossConversation = role === 'user' && !!meta.crossConversation; const isCrossConversationReply = isCrossConversation && !!(meta.crossConversation.reply || meta.crossConversation.replyToRequestId); + const resolvedMessageId = meta?.messageId || meta?.id || createLocalId('user'); div.className = `msg ${role}${role === 'assistant' ? ' agent-' + currentAgent : ''}${isCrossConversation ? ' cross-conversation' : ''}${isCrossConversationReply ? ' cross-conversation-reply' : ''}`; + if (role === 'user') { + div.id = `hapi-message-${resolvedMessageId}`; + div.dataset.messageId = resolvedMessageId; + } if (role === 'system') { const bubble = document.createElement('div'); @@ -3172,6 +3258,23 @@ textNode.style.whiteSpace = 'pre-wrap'; textNode.textContent = content; bubble.appendChild(textNode); + + const copyBtn = document.createElement('button'); + copyBtn.type = 'button'; + copyBtn.className = 'msg-copy-btn'; + copyBtn.title = '复制用户消息'; + copyBtn.setAttribute('aria-label', '复制用户消息'); + copyBtn.innerHTML = ` + + + + + `; + copyBtn.addEventListener('click', (event) => { + event.stopPropagation(); + copyTextToClipboard(content, '用户消息已复制'); + }); + bubble.appendChild(copyBtn); } if (attachments.length > 0) { bubble.insertAdjacentHTML('beforeend', renderAttachmentPreviews(attachments)); @@ -3186,6 +3289,9 @@ hydrateAttachmentPreviews(bubble, attachments); div.appendChild(avatar); div.appendChild(bubble); + if (role === 'user') { + registerUserMessage(resolvedMessageId, div, content); + } return div; } @@ -3429,6 +3535,209 @@ return section; } + function parseMaybeJsonObject(value) { + if (value && typeof value === 'object') return value; + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + if (!trimmed || (!trimmed.startsWith('{') && !trimmed.startsWith('['))) return null; + try { + const parsed = JSON.parse(trimmed); + return parsed && typeof parsed === 'object' ? parsed : null; + } catch { + return null; + } + } + + function summarizePrompt(prompt) { + const text = typeof prompt === 'string' ? prompt.trim().replace(/\s+/g, ' ') : ''; + if (!text) return ''; + return text.length > 140 ? `${text.slice(0, 140)}…` : text; + } + + function normalizeCollabAgentData(tool) { + const inputData = effectiveObject(tool?.input); + const resultData = effectiveObject(tool?.result); + const merged = { + ...inputData, + ...resultData, + agentsStates: resultData.agentsStates || inputData.agentsStates || {}, + receiverThreadIds: resultData.receiverThreadIds || inputData.receiverThreadIds || [], + prompt: inputData.prompt || resultData.prompt || '', + tool: inputData.tool || resultData.tool || tool?.name || '', + status: resultData.status || inputData.status || tool?.meta?.status || null, + }; + return merged; + } + + function effectiveObject(value) { + const parsed = parseMaybeJsonObject(value); + if (parsed && !Array.isArray(parsed)) return parsed; + if (value && typeof value === 'object' && !Array.isArray(value)) return value; + return {}; + } + + function collabAgentStateEntries(data) { + const states = data?.agentsStates; + if (!states || typeof states !== 'object') return []; + return Object.entries(states).map(([id, value], index) => { + const state = value && typeof value === 'object' ? value : { status: value }; + const label = String(state.label || state.title || state.nickname || state.name || `子代理 ${index + 1}`); + const role = String(state.role || state.agent || state.agentType || '').trim(); + const status = String(state.status || state.state || 'pending').trim() || 'pending'; + const detail = String(state.summary || state.lastMessage || state.step || state.description || '').trim(); + return { id, label, role, status, detail }; + }); + } + + function collabStateTone(statusText) { + const normalized = String(statusText || '').toLowerCase(); + if (!normalized) return 'pending'; + if (/(done|completed|success|finished|idle)/.test(normalized)) return 'done'; + if (/(fail|error|cancel|aborted|rejected)/.test(normalized)) return 'error'; + if (/(running|working|active|inprogress|in_progress|executing)/.test(normalized)) return 'running'; + return 'pending'; + } + + function collabStateLabel(statusText) { + const normalized = String(statusText || '').trim(); + if (!normalized) return '等待中'; + const lower = normalized.toLowerCase(); + if (/(done|completed|success|finished)/.test(lower)) return '已完成'; + if (/(fail|error|rejected)/.test(lower)) return '失败'; + if (/(cancel|aborted)/.test(lower)) return '已取消'; + if (/(running|working|active|inprogress|in_progress|executing)/.test(lower)) return '进行中'; + if (/(idle|pending|queued|waiting)/.test(lower)) return '等待中'; + return normalized; + } + + function createCollabAgentToolElement(tool) { + const data = normalizeCollabAgentData(tool); + const wrapper = document.createElement('div'); + wrapper.className = 'tool-call-content collab-agent-content'; + + const stack = document.createElement('div'); + stack.className = 'collab-agent-stack'; + + const header = document.createElement('div'); + header.className = 'collab-agent-header'; + + const titleWrap = document.createElement('div'); + titleWrap.className = 'collab-agent-title-wrap'; + + const kicker = document.createElement('div'); + kicker.className = 'collab-agent-kicker'; + kicker.textContent = 'Codex App 子代理'; + titleWrap.appendChild(kicker); + + const title = document.createElement('div'); + title.className = 'collab-agent-title'; + title.textContent = data.tool || '协作任务'; + titleWrap.appendChild(title); + + const meta = document.createElement('div'); + meta.className = 'collab-agent-meta'; + const threadCount = Array.isArray(data.receiverThreadIds) ? data.receiverThreadIds.length : 0; + const agentCount = collabAgentStateEntries(data).length; + meta.textContent = `${agentCount || threadCount || 0} 个子代理`; + titleWrap.appendChild(meta); + header.appendChild(titleWrap); + + const statusChip = document.createElement('span'); + const overallTone = collabStateTone(data.status || (tool.done ? 'completed' : 'running')); + statusChip.className = `collab-agent-overall-status ${overallTone}`; + statusChip.textContent = collabStateLabel(data.status || (tool.done ? 'completed' : 'running')); + header.appendChild(statusChip); + stack.appendChild(header); + + const promptText = summarizePrompt(data.prompt); + if (promptText) { + const promptBlock = document.createElement('div'); + promptBlock.className = 'collab-agent-prompt'; + promptBlock.textContent = promptText; + stack.appendChild(promptBlock); + } + + const stateEntries = collabAgentStateEntries(data); + if (stateEntries.length > 0) { + const list = document.createElement('div'); + list.className = 'collab-agent-list'; + stateEntries.forEach((entry, index) => { + const item = document.createElement('div'); + item.className = 'collab-agent-item'; + + const row = document.createElement('div'); + row.className = 'collab-agent-item-row'; + + const label = document.createElement('div'); + label.className = 'collab-agent-item-label'; + label.textContent = entry.label || `子代理 ${index + 1}`; + row.appendChild(label); + + const chip = document.createElement('span'); + const tone = collabStateTone(entry.status); + chip.className = `collab-agent-item-status ${tone}`; + chip.textContent = collabStateLabel(entry.status); + row.appendChild(chip); + item.appendChild(row); + + if (entry.role) { + const role = document.createElement('div'); + role.className = 'collab-agent-item-role'; + role.textContent = entry.role; + item.appendChild(role); + } + + if (entry.detail) { + const detail = document.createElement('div'); + detail.className = 'collab-agent-item-detail'; + detail.textContent = entry.detail; + item.appendChild(detail); + } + + if (entry.id) { + const footer = document.createElement('div'); + footer.className = 'collab-agent-item-footer'; + footer.textContent = `ID ${shortSessionId(entry.id)}`; + footer.title = entry.id; + footer.addEventListener('click', () => { + copyTextToClipboard(entry.id, '子代理线程 ID 已复制'); + }); + item.appendChild(footer); + } + + list.appendChild(item); + }); + stack.appendChild(list); + } + + if (Array.isArray(data.receiverThreadIds) && data.receiverThreadIds.length > 0) { + const threads = document.createElement('div'); + threads.className = 'collab-agent-threads'; + data.receiverThreadIds.forEach((threadId) => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'collab-agent-thread-chip'; + btn.textContent = `ID ${shortSessionId(threadId)}`; + btn.title = `复制子代理线程 ID\n${threadId}`; + btn.addEventListener('click', () => { + copyTextToClipboard(threadId, '子代理线程 ID 已复制'); + }); + threads.appendChild(btn); + }); + stack.appendChild(threads); + } + + if (!promptText && stateEntries.length === 0 && (!Array.isArray(data.receiverThreadIds) || data.receiverThreadIds.length === 0)) { + const empty = document.createElement('div'); + empty.className = 'tool-call-empty'; + empty.textContent = tool.done ? '子代理调用已结束,未返回结构化状态。' : '等待子代理状态…'; + stack.appendChild(empty); + } + + wrapper.appendChild(stack); + return wrapper; + } + function isGroupableToolCall(node) { return !!(node?.classList?.contains('tool-call') && node.dataset.toolKind !== 'todo_list'); } @@ -3508,8 +3817,10 @@ renderEpoch++; const epoch = renderEpoch; messagesDiv.innerHTML = ''; + clearUserMessageIndex(); if (messages.length === 0) { messagesDiv.innerHTML = buildWelcomeMarkup(currentAgent); + updateUserOutlinePanel(); renderPendingNotes({ scroll: false }); scrollToBottom(); return; @@ -3518,6 +3829,7 @@ const frag = document.createDocumentFragment(); messages.forEach((message) => frag.appendChild(buildMsgElement(message))); messagesDiv.appendChild(frag); + updateUserOutlinePanel(); renderPendingNotes({ scroll: false }); scrollToBottom(); return; @@ -3540,6 +3852,7 @@ const frag0 = document.createDocumentFragment(); for (let i = batches[0][0]; i < batches[0][1]; i++) frag0.appendChild(buildMsgElement(messages[i])); messagesDiv.appendChild(frag0); + updateUserOutlinePanel(); renderPendingNotes({ scroll: false }); scrollToBottom(); @@ -3556,6 +3869,7 @@ const frag = document.createDocumentFragment(); for (let i = start; i < end; i++) frag.appendChild(buildMsgElement(messages[i])); messagesDiv.insertBefore(frag, messagesDiv.firstChild); + updateUserOutlinePanel(); // Compensate scrollTop so visible area stays unchanged messagesDiv.scrollTop = prevScrollTop + (messagesDiv.scrollHeight - prevHeight); updateScrollbar(); @@ -3573,12 +3887,14 @@ messages.forEach((m) => frag.appendChild(buildMsgElement(m))); if (!preserveScroll) { messagesDiv.insertBefore(frag, messagesDiv.firstChild); + updateUserOutlinePanel(); if (!skipScrollbar) updateScrollbar(); return; } const prevHeight = messagesDiv.scrollHeight; const prevScrollTop = messagesDiv.scrollTop; messagesDiv.insertBefore(frag, messagesDiv.firstChild); + updateUserOutlinePanel(); messagesDiv.scrollTop = prevScrollTop + (messagesDiv.scrollHeight - prevHeight); if (!skipScrollbar) updateScrollbar(); } @@ -3754,6 +4070,10 @@ return wrapper; } + if (kind === 'collab_agent_tool_call') { + return createCollabAgentToolElement(tool); + } + if (effectiveName === 'AskUserQuestion') { const questions = extractAskUserQuestions(effectiveInput); if (questions.length > 0) { @@ -4691,7 +5011,11 @@ function submitUserMessage(text, attachments = []) { const welcome = messagesDiv.querySelector('.welcome-msg'); if (welcome) welcome.remove(); - messagesDiv.appendChild(createMsgElement('user', text, attachments)); + const messageId = createLocalId('user'); + const element = createMsgElement('user', text, attachments, { messageId }); + messagesDiv.appendChild(element); + registerUserMessage(messageId, element, text); + updateUserOutlinePanel(); scrollToBottom(); send({ type: 'message', text, attachments, sessionId: currentSessionId, mode: currentMode, agent: currentAgent }); @@ -4730,7 +5054,11 @@ appendError('Codex App 运行中暂不支持 slash 指令插入。'); return; } - messagesDiv.appendChild(createMsgElement('user', text, [])); + const messageId = createLocalId('user'); + const element = createMsgElement('user', text, [], { messageId }); + messagesDiv.appendChild(element); + registerUserMessage(messageId, element, text); + updateUserOutlinePanel(); scrollToBottom(); send({ type: 'message', text, sessionId: currentSessionId, mode: currentMode, agent: currentAgent }); msgInput.value = ''; @@ -4827,6 +5155,20 @@ }); } + if (userOutlineBtn && userOutlinePanel) { + userOutlineBtn.addEventListener('click', (e) => { + e.stopPropagation(); + toggleUserOutlinePanel(); + }); + userOutlinePanel.addEventListener('click', (e) => { + const target = e.target instanceof HTMLElement ? e.target.closest('.user-outline-item') : null; + if (!target) return; + const anchorId = target.getAttribute('data-target') || ''; + closeUserOutlinePanel(); + scrollToMessage(anchorId); + }); + } + // Split new-chat button newChatBtn.addEventListener('click', () => showNewSessionModal()); newChatArrow.addEventListener('click', (e) => { @@ -4854,6 +5196,11 @@ e.target !== chatAgentBtn) { closeAgentMenu(); } + if (userOutlinePanel && !userOutlinePanel.hidden && + !userOutlinePanel.contains(e.target) && + e.target !== userOutlineBtn) { + closeUserOutlinePanel(); + } }); sendBtn.addEventListener('click', sendMessage); if (noteModeBtn) { diff --git a/public/index.html b/public/index.html index 765f8f9..307f961 100644 --- a/public/index.html +++ b/public/index.html @@ -61,20 +61,15 @@ 新会话 - -