diff --git a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz index 557e9d8..d37ded2 100644 Binary files a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz and b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz differ diff --git a/lib/agent-runtime.js b/lib/agent-runtime.js index 16d1c23..ca76884 100644 --- a/lib/agent-runtime.js +++ b/lib/agent-runtime.js @@ -21,6 +21,7 @@ function createAgentRuntime(deps) { saveSession, setRuntimeSessionId, getRuntimeSessionId, + titleToolInstructions, } = deps; function readRuntimePositiveIntEnv(name, fallback, options = {}) { @@ -179,6 +180,9 @@ function createAgentRuntime(deps) { const ccwebMcpEnv = createCcwebMcpEnv(session, options); appendCcwebMcpConfig(args, ccwebMcpEnv); appendProjectMcpConfigs(args, options.projectMcpConfigs); + if (titleToolInstructions) { + args.push('-c', `developer_instructions=${tomlString(titleToolInstructions)}`); + } const permMode = session.permissionMode || 'yolo'; // `-s/--sandbox` is an option for `codex exec`, but not for `codex exec resume`. diff --git a/lib/ccweb-mcp-server.js b/lib/ccweb-mcp-server.js index eea3c79..1b2f503 100644 --- a/lib/ccweb-mcp-server.js +++ b/lib/ccweb-mcp-server.js @@ -36,6 +36,22 @@ const TOOLS = [ additionalProperties: false, }, }, + { + name: 'ccweb_set_title', + description: '设置当前 ccweb 对话标题。只作用于当前来源对话;用户手动重命名后会成功返回但忽略,不再覆盖用户标题。', + inputSchema: { + type: 'object', + properties: { + title: { + type: 'string', + maxLength: 120, + description: '新的当前对话标题。应简短概括用户主要目标。', + }, + }, + required: ['title'], + additionalProperties: false, + }, + }, { name: 'ccweb_create_conversation', description: '创建一个新的 ccweb 持久对话。Agent 固定继承来源对话,不作为参数指定;只用于需要在会话列表中长期追踪、后续可继续对话的工作流;一次性并行研究应优先使用子代能力。', diff --git a/public/app.js b/public/app.js index abfd051..e6be654 100644 --- a/public/app.js +++ b/public/app.js @@ -78,6 +78,7 @@ const SESSION_LOAD_OVERLAY_TIMEOUT_MS = 12_000; const SESSION_LOAD_REQUEST_TIMEOUT_MS = 45_000; const SESSION_RESUME_FALLBACK_MS = 1_500; + const PENDING_SLASH_DRAFT_LIMIT = 30; const MODEL_OPTIONS = [ { value: 'opus', label: 'Opus', desc: '最强大,1M 上下文' }, @@ -237,6 +238,7 @@ const pendingNotesByTarget = new Map(); const queuedMessagesByTarget = new Map(); const userMessageIndex = new Map(); + const pendingSlashDraftsByRequestId = new Map(); const expandedOldSessionGroups = new Set(); document.documentElement.dataset.dividerTime = showAgentDividerTime ? 'show' : 'hide'; @@ -1334,6 +1336,37 @@ return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; } + function rememberPendingSlashDraft(requestId, text, sessionId, agent) { + const id = String(requestId || '').trim(); + if (!id) return; + while (pendingSlashDraftsByRequestId.size >= PENDING_SLASH_DRAFT_LIMIT) { + const oldest = pendingSlashDraftsByRequestId.keys().next().value; + if (!oldest) break; + pendingSlashDraftsByRequestId.delete(oldest); + } + pendingSlashDraftsByRequestId.set(id, { + text, + sessionId: sessionId || null, + agent: normalizeAgent(agent), + createdAt: Date.now(), + }); + } + + function applyPendingSlashDraftResponse(msg) { + const requestId = String(msg?.requestId || '').trim(); + if (!requestId) return false; + const draft = pendingSlashDraftsByRequestId.get(requestId); + if (!draft) return false; + pendingSlashDraftsByRequestId.delete(requestId); + if (!msg.preserveComposerDraft) return false; + if (draft.sessionId && currentSessionId && draft.sessionId !== currentSessionId) return false; + if (normalizeAgent(draft.agent) !== normalizeAgent(currentAgent)) return false; + if (msgInput.value !== '' || pendingAttachments.length > 0) return false; + msgInput.value = draft.text; + autoResize(); + return true; + } + function clearUserMessageIndex() { userMessageIndex.clear(); } @@ -1756,6 +1789,8 @@ model: payload.model || '', agent: normalizeAgent(payload.agent), pinnedAt: payload.pinnedAt || null, + titleSource: payload.titleSource || null, + createdFromKind: payload.createdFromKind || null, hasUnread: !!payload.hasUnread, cwd: payload.cwd || null, projectName: payload.projectName || '', @@ -1860,6 +1895,8 @@ agent: normalizeAgent(snapshot.agent), updated: snapshot.updated || new Date().toISOString(), pinnedAt: snapshot.pinnedAt || null, + titleSource: snapshot.titleSource || null, + createdFromKind: snapshot.createdFromKind || null, hasUnread: !!snapshot.hasUnread, isRunning: !!snapshot.isRunning, waitingOnChildren: !!snapshot.waitingOnChildren, @@ -1880,6 +1917,8 @@ cwd: nextMeta.cwd || session.cwd || '', projectName: nextMeta.cwd ? getPathLeaf(nextMeta.cwd) : session.projectName || nextMeta.projectName || '', title: nextMeta.title || session.title, + titleSource: nextMeta.titleSource || session.titleSource || null, + createdFromKind: nextMeta.createdFromKind || session.createdFromKind || null, }; }); if (!found) { @@ -2496,12 +2535,25 @@ function updateCcwebPromptAnswerFromSelection(questionEl, question) { const textarea = questionEl.querySelector('.ccweb-prompt-answer'); if (!textarea) return; + const previousSelectionText = textarea.dataset.ccwebPromptSelectionText || ''; const selectedIds = Array.from(questionEl.querySelectorAll('.ccweb-prompt-option.is-selected')) .map((button) => button.dataset.optionId || '') .filter(Boolean); const selectedOptions = (question.options || []).filter((option) => selectedIds.includes(option.id)); const answerText = selectedOptions.map((option) => option.answerText || option.label || '').filter(Boolean).join('\n'); - if (answerText) textarea.value = answerText; + if (answerText) { + textarea.value = answerText; + textarea.dataset.ccwebPromptSelectionText = answerText; + return; + } + if (previousSelectionText && textarea.value === previousSelectionText) textarea.value = ''; + delete textarea.dataset.ccwebPromptSelectionText; + } + + function syncCcwebPromptOptionState(questionEl) { + questionEl.querySelectorAll('.ccweb-prompt-option').forEach((button) => { + button.setAttribute('aria-pressed', button.classList.contains('is-selected') ? 'true' : 'false'); + }); } function selectCcwebPromptOption(questionEl, question, optionId) { @@ -2511,10 +2563,20 @@ if (button.dataset.optionId === optionId) button.classList.toggle('is-selected'); }); } else { + const shouldDeselect = buttons.some((button) => button.dataset.optionId === optionId && button.classList.contains('is-selected')); buttons.forEach((button) => { - button.classList.toggle('is-selected', button.dataset.optionId === optionId); + button.classList.toggle('is-selected', !shouldDeselect && button.dataset.optionId === optionId); }); } + syncCcwebPromptOptionState(questionEl); + updateCcwebPromptAnswerFromSelection(questionEl, question); + } + + function clearCcwebPromptSelection(questionEl, question) { + questionEl.querySelectorAll('.ccweb-prompt-option.is-selected').forEach((button) => { + button.classList.remove('is-selected'); + }); + syncCcwebPromptOptionState(questionEl); updateCcwebPromptAnswerFromSelection(questionEl, question); } @@ -2568,6 +2630,7 @@ button.type = 'button'; button.className = 'ccweb-prompt-option'; button.dataset.optionId = option.id || ''; + button.setAttribute('aria-pressed', 'false'); const label = document.createElement('span'); label.className = 'ccweb-prompt-option-label'; label.textContent = option.label || option.id || '选项'; @@ -2587,6 +2650,12 @@ button.addEventListener('click', () => selectCcwebPromptOption(questionEl, question, option.id)); optionList.appendChild(button); }); + const clearChoice = document.createElement('button'); + clearChoice.type = 'button'; + clearChoice.className = 'ccweb-prompt-clear-choice'; + clearChoice.textContent = '不选择'; + clearChoice.addEventListener('click', () => clearCcwebPromptSelection(questionEl, question)); + optionList.appendChild(clearChoice); questionEl.appendChild(optionList); } @@ -2595,6 +2664,9 @@ answer.rows = 4; answer.placeholder = question.answerPlaceholder || '填写你的答案...'; answer.value = question.defaultAnswer || ''; + answer.addEventListener('input', () => { + delete answer.dataset.ccwebPromptSelectionText; + }); questionEl.appendChild(answer); const recommended = ccwebPromptRecommendedOption(question); @@ -3904,10 +3976,11 @@ function createSessionListItem(session) { const item = document.createElement('div'); const isPinned = !!session.pinnedAt; + const isLlmCreated = String(session.createdFromKind || '').toLowerCase() === 'mcp'; const waitingOnChildren = !!session.waitingOnChildren; const readyReplyCount = Number(session.readyReplyCount || 0); const waitingLabel = readyReplyCount > 0 ? `子对话已返回 ${readyReplyCount}` : `等待子对话 ${Number(session.pendingReplyCount || 0) || ''}`.trim(); - item.className = `session-item${session.id === currentSessionId ? ' active' : ''}${isPinned ? ' pinned' : ''}${waitingOnChildren ? ' waiting-children' : ''}`; + item.className = `session-item${session.id === currentSessionId ? ' active' : ''}${isPinned ? ' pinned' : ''}${isLlmCreated ? ' llm-created' : ''}${waitingOnChildren ? ' waiting-children' : ''}`; item.dataset.id = session.id; const sessionCwd = getSessionEffectiveCwd(session); if (sessionCwd) item.title = sessionCwd; @@ -5239,8 +5312,17 @@ break; case 'session_renamed': - sessions = sessions.map((session) => session.id === msg.sessionId ? { ...session, title: msg.title } : session); - updateCachedSession(msg.sessionId, (snapshot) => { snapshot.title = msg.title; }); + sessions = sessions.map((session) => session.id === msg.sessionId ? { + ...session, + title: msg.title, + ...(msg.titleSource !== undefined ? { titleSource: msg.titleSource || null } : {}), + ...(msg.createdFromKind !== undefined ? { createdFromKind: msg.createdFromKind || null } : {}), + } : session); + updateCachedSession(msg.sessionId, (snapshot) => { + snapshot.title = msg.title; + if (msg.titleSource !== undefined) snapshot.titleSource = msg.titleSource || null; + if (msg.createdFromKind !== undefined) snapshot.createdFromKind = msg.createdFromKind || null; + }); if (msg.sessionId === currentSessionId) { chatTitle.textContent = msg.title; } @@ -5348,6 +5430,7 @@ case 'system_message': if (!isCurrentSessionEvent(msg)) break; + applyPendingSlashDraftResponse(msg); appendSystemMessage(msg.message, { tone: msg.tone, transient: msg.transient, @@ -5512,6 +5595,7 @@ break; } if (pendingNewSessionRequest) pendingNewSessionRequest = null; + applyPendingSlashDraftResponse(msg); appendError(msg.message, { transient: msg.transient, autoDismissMs: msg.autoDismissMs, @@ -8328,7 +8412,9 @@ autoResize(); return; } - send({ type: 'message', text, sessionId: currentSessionId, mode: currentMode, agent: currentAgent }); + const requestId = createLocalId('slash'); + rememberPendingSlashDraft(requestId, text, currentSessionId, currentAgent); + send({ type: 'message', text, sessionId: currentSessionId, mode: currentMode, agent: currentAgent, requestId }); msgInput.value = ''; autoResize(); return; diff --git a/public/style.css b/public/style.css index eec9601..c403e77 100644 --- a/public/style.css +++ b/public/style.css @@ -1318,6 +1318,18 @@ body.session-loading-active { border-radius: 999px; background: var(--accent); } +.session-item.llm-created:not(.pinned)::before { + content: ''; + position: absolute; + left: 4px; + top: 7px; + width: 9px; + height: 9px; + border-top: 2px solid var(--accent); + border-left: 2px solid var(--accent); + border-radius: 4px 0 0 0; + opacity: 0.88; +} .session-item-main { flex: 1; min-width: 0; @@ -5139,6 +5151,25 @@ html[data-theme='coolvibe'] .settings-back:hover { font-size: 10px; font-weight: 900; } +.ccweb-prompt-clear-choice { + grid-column: 1 / -1; + justify-self: start; + border: 1px solid rgba(221, 208, 192, 0.9); + border-radius: 7px; + background: rgba(255, 255, 255, 0.56); + color: var(--text-muted); + font: inherit; + font-size: 12px; + line-height: 1.25; + padding: 4px 8px; + cursor: pointer; + transition: border-color 0.16s ease, color 0.16s ease, background 0.16s ease; +} +.ccweb-prompt-clear-choice:hover { + border-color: var(--accent); + background: rgba(255, 249, 242, 0.96); + color: var(--accent); +} .ccweb-prompt-option-label { min-width: 0; overflow-wrap: anywhere; @@ -6372,7 +6403,14 @@ html[data-theme='coolvibe'] .settings-back:hover { border-color: var(--text-muted); } +:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .ccweb-prompt-clear-choice { + background: rgba(0, 0, 0, 0.16); + border-color: var(--theme-card-hover-border); + color: var(--text-muted); +} + :is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .ccweb-prompt-option:hover, +:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .ccweb-prompt-clear-choice:hover, :is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .ccweb-prompt-tab-nav-btn:hover, :is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .ccweb-prompt-secondary:hover, :is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .pending-ccweb-prompt-action:hover { diff --git a/scripts/regression.js b/scripts/regression.js index 30f49d8..c6b41bb 100644 --- a/scripts/regression.js +++ b/scripts/regression.js @@ -11,6 +11,7 @@ const REPO_DIR = path.resolve(__dirname, '..'); const SERVER_PATH = path.join(REPO_DIR, 'server.js'); const PUBLIC_APP_PATH = path.join(REPO_DIR, 'public', 'app.js'); const PUBLIC_INDEX_PATH = path.join(REPO_DIR, 'public', 'index.html'); +const PUBLIC_STYLE_PATH = path.join(REPO_DIR, 'public', 'style.css'); const MOCK_CLAUDE = path.join(REPO_DIR, 'scripts', 'mock-claude.js'); const MOCK_CODEX = path.join(REPO_DIR, 'scripts', 'mock-codex.js'); const MOCK_CODEX_APP_SERVER = path.join(REPO_DIR, 'scripts', 'mock-codex-app-server.js'); @@ -563,6 +564,34 @@ function assertFrontendComposerMcpContract() { assert(source.includes("className = 'msg-mentions'"), 'Frontend should render a dedicated mention strip container'); } +function assertFrontendSlashDraftPreservationContract() { + const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8'); + const serverSource = fs.readFileSync(SERVER_PATH, 'utf8'); + assert(source.includes('const pendingSlashDraftsByRequestId = new Map();'), 'Frontend should keep request-scoped slash drafts'); + assert(source.includes('function rememberPendingSlashDraft(requestId, text, sessionId, agent)'), 'Frontend should define slash draft capture'); + assert(source.includes('function applyPendingSlashDraftResponse(msg)'), 'Frontend should define slash draft response handling'); + assert(source.includes('if (!msg.preserveComposerDraft) return false;'), 'Frontend should restore slash drafts only for explicit failure responses'); + assert(source.includes("if (msgInput.value !== '' || pendingAttachments.length > 0) return false;"), 'Frontend should not overwrite user input typed after a slash request'); + assert(source.includes('rememberPendingSlashDraft(requestId, text, currentSessionId, currentAgent);'), 'Frontend should remember slash draft before clearing the composer'); + assert(source.includes("send({ type: 'message', text, sessionId: currentSessionId, mode: currentMode, agent: currentAgent, requestId });"), 'Frontend slash sends should carry requestId'); + assert((source.match(/applyPendingSlashDraftResponse\(msg\);/g) || []).length >= 2, 'Frontend should handle slash draft restoration on system_message and error'); + + const sendStart = source.indexOf('function sendMessage()'); + const slashStart = source.indexOf("if (text.startsWith('/'))", sendStart); + const rememberStart = source.indexOf('rememberPendingSlashDraft(requestId, text, currentSessionId, currentAgent);', slashStart); + const modelPickerStart = source.indexOf("if (text === '/model' || text === '/model ')", slashStart); + const modePickerStart = source.indexOf("if (text === '/mode' || text === '/mode ')", slashStart); + assert(slashStart >= 0 && rememberStart > slashStart, 'Frontend should keep slash send branch explicit'); + assert(modelPickerStart > slashStart && modelPickerStart < rememberStart, 'Frontend /model picker should stay local and clear normally'); + assert(modePickerStart > slashStart && modePickerStart < rememberStart, 'Frontend /mode picker should stay local and clear normally'); + + assert(serverSource.includes('handleSlashCommand(ws, msg.text.trim(), msg.sessionId, msg.agent, msg);'), 'Server should pass client request metadata into slash handler'); + assert(serverSource.includes('function handleSlashCommand(ws, text, sessionId, fallbackAgent, source = {})'), 'Server slash handler should accept request metadata'); + assert(serverSource.includes('wsSend(ws, attachClientRequestId(base, source));'), 'Server slash responses should echo requestId'); + assert(serverSource.includes('preserveComposerDraft: true'), 'Server should explicitly mark slash failures as draft-preserving'); + assert(serverSource.includes('wsSend(ws, attachClientRequestId({') && serverSource.includes('...(msg.preserveComposerDraft ? { preserveComposerDraft: true } : {})'), 'Server runtime errors should echo requestId and draft-preservation metadata'); +} + function assertMockCodexAppPromptUserNotTextTriggered() { const source = fs.readFileSync(MOCK_CODEX_APP_SERVER, 'utf8'); assert(!source.includes('codexapp runtime prompt mcp'), 'Mock Codex App should not expose a text-triggered ccweb_prompt_user path'); @@ -589,6 +618,10 @@ function assertFrontendCcwebPromptContract() { assert(source.includes('card.dataset.viewMode = normalized'), 'Frontend should switch ccweb prompt card view mode'); assert(source.includes('m.ccwebPrompt'), 'Message rebuild should render persisted ccweb prompt messages'); assert(source.includes("className = 'ccweb-prompt-answer'"), 'Each ccweb prompt question should expose an editable answer textarea'); + assert(source.includes('function clearCcwebPromptSelection(questionEl, question)'), 'ccweb prompt options should expose an explicit no-selection action'); + assert(source.includes("clearChoice.textContent = '不选择';"), 'ccweb prompt should render a visible no-selection control'); + assert(source.includes("const shouldDeselect = buttons.some"), 'Single-select ccweb prompt options should allow clicking the selected option to deselect'); + assert(source.includes("textarea.dataset.ccwebPromptSelectionText"), 'ccweb prompt should clear only option-generated answer text when selections are removed'); } function assertFrontendMarkdownLinkContract() { @@ -614,6 +647,21 @@ function assertFrontendMcpReloadContract() { assert(source.includes('MCP 启动失败'), 'Frontend should expose a failed startup toast'); } +function assertSetTitleMcpContract() { + const serverSource = fs.readFileSync(SERVER_PATH, 'utf8'); + const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8'); + const styleSource = fs.readFileSync(PUBLIC_STYLE_PATH, 'utf8'); + + assert(serverSource.includes("case 'ccweb_set_title':"), 'Server should route ccweb_set_title through internal MCP'); + assert(serverSource.includes('function setCurrentConversationTitle'), 'Server should implement current-conversation title setting'); + assert(serverSource.includes('ignored because title is manually locked'), 'ccweb_set_title should report manual title locks as ignored success'); + assert(serverSource.includes("titleSource = 'manual'"), 'Manual UI rename should mark titleSource as manual'); + assert(serverSource.includes('CCWEB_TITLE_TOOL_INSTRUCTIONS'), 'Server should define model guidance for the title tool'); + assert(frontendSource.includes("createdFromKind || ''") && frontendSource.includes("' llm-created'"), 'Frontend should mark MCP-created sessions with llm-created class'); + assert(frontendSource.includes('snapshot.titleSource') && frontendSource.includes('snapshot.createdFromKind'), 'Frontend should preserve title metadata in session snapshots'); + assert(styleSource.includes('.session-item.llm-created:not(.pinned)::before'), 'LLM-created marker should be hidden when the session is pinned'); +} + function assertSessionSwitchResilienceContract() { const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8'); const serverSource = fs.readFileSync(SERVER_PATH, 'utf8'); @@ -713,10 +761,12 @@ function assertSessionSwitchResilienceContract() { async function main() { assertFrontendGenerationControlsContract(); assertFrontendComposerMcpContract(); + assertFrontendSlashDraftPreservationContract(); assertFrontendCcwebPromptContract(); assertFrontendMarkdownLinkContract(); assertMockCodexAppPromptUserNotTextTriggered(); assertFrontendMcpReloadContract(); + assertSetTitleMcpContract(); assertSessionSwitchResilienceContract(); const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-regression-')); @@ -969,6 +1019,7 @@ async function main() { ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-slash-mcp', trigger: '/', query: 'ccweb', sessionId: codexSession.sessionId, agent: 'codex' })); const slashMcpComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-slash-mcp'); assert(slashMcpComposer.items.some((item) => item.kind === 'mcp' && item.name === 'ccweb_list_conversations'), 'Composer slash suggestions should include ccweb MCP tools'); + assert(slashMcpComposer.items.some((item) => item.kind === 'mcp' && item.name === 'ccweb_set_title'), 'Composer slash suggestions should include ccweb_set_title MCP tool'); ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-slash-mcp-config', trigger: '/', query: 'reg', sessionId: codexSession.sessionId, agent: 'codex' })); const slashMcpConfigComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-slash-mcp-config'); @@ -990,6 +1041,17 @@ async function main() { assert(!slashMcpRuntimeComposer.items.some((item) => item.kind === 'mcp' && item.itemType === 'server' && item.name === 'regRuntime'), 'Composer slash suggestions should not infer MCP servers from session tool names'); assert(!slashMcpRuntimeComposer.items.some((item) => item.kind === 'mcp' && item.itemType === 'server' && item.name === 'reg-state'), 'Composer slash suggestions should not infer MCP servers from mcp:server labels'); + ws.send(JSON.stringify({ type: 'message', text: '/grilling', sessionId: codexSession.sessionId, mode: 'plan', agent: 'codex', requestId: 'reg-unknown-slash-draft' })); + const unknownSlashDraft = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.requestId === 'reg-unknown-slash-draft'); + assert(unknownSlashDraft.sessionId === codexSession.sessionId, 'Unknown slash draft response should stay scoped to the active session'); + assert(unknownSlashDraft.preserveComposerDraft === true, 'Unknown slash command should tell the frontend to restore the composer draft'); + assert(/未知指令: \/grilling/.test(unknownSlashDraft.message || ''), 'Unknown slash command should still show the normal failure hint'); + + ws.send(JSON.stringify({ type: 'message', text: '/help', sessionId: codexSession.sessionId, mode: 'plan', agent: 'codex', requestId: 'reg-help-slash-draft' })); + const helpSlashDraft = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.requestId === 'reg-help-slash-draft'); + assert(!helpSlashDraft.preserveComposerDraft, 'Successful slash command responses should clear pending drafts without restoring input'); + assert(/可用指令/.test(helpSlashDraft.message || ''), 'Slash /help should keep the normal help output'); + ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-skill', trigger: '$', query: 'reg', sessionId: codexSession.sessionId, agent: 'codex' })); const skillComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-skill'); assert(skillComposer.items.some((item) => item.kind === 'skill' && item.name === 'regression-skill'), 'Composer skill suggestions should include local Codex skill'); @@ -1066,6 +1128,72 @@ async function main() { assert(mcpList.body.currentConversationId === codexSession.sessionId, 'MCP list should return current source conversation id'); assert(mcpList.body.conversations.some((item) => item.id === codexSession.sessionId && !item.summary), 'MCP list should return lightweight session metadata without summary'); + const codexTitleSessionPath = path.join(sessionsDir, `${codexSession.sessionId}.json`); + const storedBeforeMcpTitle = JSON.parse(fs.readFileSync(codexTitleSessionPath, 'utf8')); + const mcpSetTitle = await callInternalMcp(port, internalMcpToken, { + tool: 'ccweb_set_title', + sourceSessionId: codexSession.sessionId, + args: { title: 'Concise MCP Title' }, + }); + assert(mcpSetTitle.status === 200 && mcpSetTitle.body?.ok, `MCP set title should succeed: ${JSON.stringify(mcpSetTitle.body)}`); + assert(mcpSetTitle.body.changed === true && mcpSetTitle.body.ignored === false, 'MCP set title should report a real title change'); + assert(mcpSetTitle.body.previousTitle === storedBeforeMcpTitle.title, 'MCP set title should return previousTitle'); + assert(mcpSetTitle.body.lockedByUser === false, 'MCP set title should not report a user lock before manual rename'); + const mcpTitleRenamed = await nextMessage(messages, ws, (msg) => ( + msg.type === 'session_renamed' && + msg.sessionId === codexSession.sessionId && + msg.title === 'Concise MCP Title' + )); + assert(mcpTitleRenamed.titleSource === 'llm', 'MCP set title should push llm titleSource to current viewers'); + const mcpTitleList = await nextMessage(messages, ws, (msg) => ( + msg.type === 'session_list' && + msg.sessions.some((session) => ( + session.id === codexSession.sessionId && + session.title === 'Concise MCP Title' && + session.titleSource === 'llm' + )) + )); + assert(mcpTitleList.sessions.some((session) => session.id === codexSession.sessionId && session.updated === storedBeforeMcpTitle.updated), 'MCP set title should not bump session updated sorting timestamp'); + const storedAfterMcpTitle = JSON.parse(fs.readFileSync(codexTitleSessionPath, 'utf8')); + assert(storedAfterMcpTitle.title === 'Concise MCP Title', 'MCP set title should persist title'); + assert(storedAfterMcpTitle.titleSource === 'llm', 'MCP set title should persist llm titleSource'); + assert(storedAfterMcpTitle.updated === storedBeforeMcpTitle.updated, 'MCP set title should not modify updated timestamp'); + + const mcpEmptyTitle = await callInternalMcp(port, internalMcpToken, { + tool: 'ccweb_set_title', + sourceSessionId: codexSession.sessionId, + args: { title: ' ' }, + }); + assert(mcpEmptyTitle.status === 400 && mcpEmptyTitle.body?.code === 'invalid_title', 'MCP set title should reject empty titles'); + + ws.send(JSON.stringify({ type: 'rename_session', sessionId: codexSession.sessionId, title: 'Manual Locked Title' })); + const manualTitleRenamed = await nextMessage(messages, ws, (msg) => ( + msg.type === 'session_renamed' && + msg.sessionId === codexSession.sessionId && + msg.title === 'Manual Locked Title' + )); + assert(manualTitleRenamed.titleSource === 'manual', 'Manual rename should push manual titleSource'); + await nextMessage(messages, ws, (msg) => ( + msg.type === 'session_list' && + msg.sessions.some((session) => ( + session.id === codexSession.sessionId && + session.title === 'Manual Locked Title' && + session.titleSource === 'manual' + )) + )); + const mcpLockedTitle = await callInternalMcp(port, internalMcpToken, { + tool: 'ccweb_set_title', + sourceSessionId: codexSession.sessionId, + args: { title: 'Ignored LLM Title' }, + }); + assert(mcpLockedTitle.status === 200 && mcpLockedTitle.body?.ok, 'MCP set title should return ok when manually locked'); + assert(mcpLockedTitle.body.changed === false && mcpLockedTitle.body.ignored === true, 'MCP set title should report ignored under manual lock'); + assert(mcpLockedTitle.body.reason === 'ignored because title is manually locked', 'MCP set title should explain manual lock ignores'); + assert(mcpLockedTitle.body.title === 'Manual Locked Title' && mcpLockedTitle.body.lockedByUser === true, 'MCP set title should preserve manual title under lock'); + const storedAfterManualLock = JSON.parse(fs.readFileSync(codexTitleSessionPath, 'utf8')); + assert(storedAfterManualLock.title === 'Manual Locked Title', 'Manual locked title should remain persisted after ignored MCP set title'); + assert(storedAfterManualLock.titleSource === 'manual', 'Manual locked titleSource should remain manual after ignored MCP set title'); + const mcpRelativeCreate = await callInternalMcp(port, internalMcpToken, { tool: 'ccweb_create_conversation', sourceSessionId: codexSession.sessionId, @@ -1092,11 +1220,20 @@ async function main() { await nextMessage(messages, ws, (msg) => msg.type === 'background_done' && msg.sessionId === mcpCreate.body.conversationId); const storedMcpCreated = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${mcpCreate.body.conversationId}.json`), 'utf8')); assert(storedMcpCreated.title === 'MCP Created Conversation', 'MCP created conversation should persist the requested title'); + assert(storedMcpCreated.titleSource === 'system', 'MCP created conversation title should not be treated as user manual title'); assert(storedMcpCreated.agent === 'codex', 'MCP created conversation should persist the inherited source agent'); assert(storedMcpCreated.permissionMode === 'yolo', 'MCP created conversation should persist yolo as the default mode'); + assert(storedMcpCreated.createdFrom?.kind === 'mcp', 'MCP created conversation should persist mcp creation kind'); assert(storedMcpCreated.createdFrom?.sourceSessionId === codexSession.sessionId, 'MCP created conversation should persist source metadata'); assert(storedMcpCreated.messages.some((message) => message.content === 'mcp created initial prompt' && message.crossConversation?.sourceSessionId === codexSession.sessionId), 'MCP created conversation should persist the initial cross-conversation message'); assert(storedMcpCreated.messages.some((message) => message.role === 'assistant' && /mcp created initial prompt/.test(String(message.content || ''))), 'MCP created conversation should run the initial prompt'); + await nextMessage(messages, ws, (msg) => ( + msg.type === 'session_list' && + msg.sessions.some((session) => ( + session.id === mcpCreate.body.conversationId && + session.createdFromKind === 'mcp' + )) + )); const mcpReplyCreateCwd = path.join(tempRoot, 'mcp-create-reply'); mkdirp(mcpReplyCreateCwd); diff --git a/server.js b/server.js index 7075a64..fcbcb11 100644 --- a/server.js +++ b/server.js @@ -705,6 +705,7 @@ let MODEL_MAP = { const VALID_AGENTS = new Set(['claude', 'codex', 'codexapp']); const VALID_PERMISSION_MODES = new Set(['default', 'plan', 'yolo']); +const VALID_TITLE_SOURCES = new Set(['derived', 'llm', 'manual', 'system']); const MCP_CONVERSATION_TITLE_MAX_CHARS = 120; const MCP_PROMPT_TITLE_MAX_CHARS = 160; const MCP_PROMPT_DESCRIPTION_MAX_CHARS = 2000; @@ -718,7 +719,12 @@ const MCP_PROMPT_RESPONSE_MAX_CHARS = 20000; // Codex 默认模型优先读取 ~/.codex/config.toml,缺失时再回退到旧默认值。 const FALLBACK_CODEX_MODEL = 'gpt-5.4'; const CODEX_REASONING_LEVELS = new Set(['low', 'medium', 'high', 'xhigh']); +const CCWEB_TITLE_TOOL_INSTRUCTIONS = [ + 'Use the ccweb title tool sparingly. For a new chat, call "mcp__ccweb__ccweb_set_title" or "ccweb_set_title" once after the user\'s initial request is clear, and set a concise task title.', + 'Do not rename the chat for routine progress, substeps, implementation details, or slightly better wording. Rename only when the user\'s primary objective changes substantially and the existing title would be misleading.', +].join('\n'); const CODEX_APP_COLLABORATION_INSTRUCTIONS = [ + CCWEB_TITLE_TOOL_INSTRUCTIONS, 'Codex sub-agent spawning rules:', '- Treat omitted fork_context the same as fork_context: true: a full-history fork inherits the parent agent type, model, and reasoning effort.', '- If you call spawn_agent with fork_context omitted or true, do not set agent_type, model, or reasoning_effort.', @@ -2889,6 +2895,12 @@ function normalizeSession(session) { session.agent = normalizeAgent(session.agent); if (!Object.prototype.hasOwnProperty.call(session, 'pinnedAt')) session.pinnedAt = null; if (session.pinnedAt && Number.isNaN(new Date(session.pinnedAt).getTime())) session.pinnedAt = null; + const titleSource = String(session.titleSource || '').trim().toLowerCase(); + if (titleSource && VALID_TITLE_SOURCES.has(titleSource)) { + session.titleSource = titleSource; + } else if (Object.prototype.hasOwnProperty.call(session, 'titleSource')) { + delete session.titleSource; + } if (!Object.prototype.hasOwnProperty.call(session, 'claudeSessionId')) session.claudeSessionId = null; if (!Object.prototype.hasOwnProperty.call(session, 'codexThreadId')) session.codexThreadId = null; if (!Object.prototype.hasOwnProperty.call(session, 'codexAppThreadId')) session.codexAppThreadId = null; @@ -3660,6 +3672,18 @@ function jsonStringFieldFromPreview(text, key) { } } +function jsonNestedStringFieldFromPreview(text, objectKey, key) { + const pattern = new RegExp(`"${objectKey}"\\s*:\\s*\\{[\\s\\S]{0,1200}?"${key}"\\s*:\\s*("(?:(?:\\\\.)|[^"\\\\])*"|null)`); + const match = pattern.exec(text); + if (!match) return null; + if (match[1] === 'null') return null; + try { + return JSON.parse(match[1]); + } catch { + return null; + } +} + function jsonBooleanFieldFromPreview(text, key) { const pattern = new RegExp(`"${key}"\\s*:\\s*(true|false)`); const match = pattern.exec(text); @@ -3695,6 +3719,8 @@ function loadSessionMetaFromFile(filePath) { updated: session.updated || session.created || stat.mtime.toISOString(), created: session.created || null, pinnedAt: session.pinnedAt || null, + titleSource: session.titleSource || null, + createdFromKind: sessionCreatedFromKind(session) || null, hasUnread: !!session.hasUnread, agent: getSessionAgent(session), cwd, @@ -3712,6 +3738,8 @@ function loadSessionMetaFromFile(filePath) { updated: jsonStringFieldFromPreview(preview, 'updated') || jsonStringFieldFromPreview(preview, 'updatedAt') || stat.mtime.toISOString(), created: jsonStringFieldFromPreview(preview, 'created') || null, pinnedAt: jsonStringFieldFromPreview(preview, 'pinnedAt') || null, + titleSource: jsonStringFieldFromPreview(preview, 'titleSource') || null, + createdFromKind: jsonNestedStringFieldFromPreview(preview, 'createdFrom', 'kind') || null, hasUnread: jsonBooleanFieldFromPreview(preview, 'hasUnread'), agent: normalizeAgent(jsonStringFieldFromPreview(preview, 'agent')), cwd, @@ -4357,6 +4385,8 @@ function sendSessionList(ws) { title: meta.title || 'Untitled', updated: meta.updated, pinnedAt: meta.pinnedAt || null, + titleSource: meta.titleSource || null, + createdFromKind: meta.createdFromKind || null, hasUnread: !!meta.hasUnread, agent: normalizeAgent(meta.agent), cwd: meta.cwd || '', @@ -5135,6 +5165,8 @@ function callInternalMcpTool(tool, args, sourceSessionId, sourceHopCount) { switch (tool) { case 'ccweb_list_conversations': return listConversationSummaries(args, sanitizeId(sourceSessionId || '')); + case 'ccweb_set_title': + return setCurrentConversationTitle(args, sourceSessionId); case 'ccweb_create_conversation': return createMcpConversation(args, sourceSessionId, sourceHopCount); case 'ccweb_send_message': @@ -6268,7 +6300,7 @@ wss.on('connection', (ws, req) => { switch (msg.type) { case 'message': if (msg.text && msg.text.trim().startsWith('/')) { - handleSlashCommand(ws, msg.text.trim(), msg.sessionId, msg.agent); + handleSlashCommand(ws, msg.text.trim(), msg.sessionId, msg.agent, msg); } else { handleMessage(ws, msg); } @@ -6817,24 +6849,33 @@ function cancelCodexAppGoalCommand(sessionId, ws = null) { return true; } -async function handleCodexAppGoalSlashCommand(ws, text, session) { +async function handleCodexAppGoalSlashCommand(ws, text, session, source = {}) { const command = parseCodexGoalCommand(text); if (!command) return; + const sendGoalResponse = (targetWs, payload, options = {}) => { + wsSend(targetWs, attachClientRequestId({ + ...payload, + ...(options.preserveComposerDraft ? { preserveComposerDraft: true } : {}), + }, source)); + }; + const sendGoalSystemMessage = (targetWs, message, extra = {}, options = {}) => { + sendGoalResponse(targetWs, { type: 'system_message', message, ...extra }, options); + }; if (!session) { - wsSend(ws, { type: 'system_message', message: '请先进入一个 Codex App 会话后再执行 /goal。' }); + sendGoalSystemMessage(ws, '请先进入一个 Codex App 会话后再执行 /goal。', {}, { preserveComposerDraft: true }); return; } if (!isCodexAppSession(session)) { - wsSend(ws, { type: 'system_message', message: '当前 /goal 仅支持 Codex App 会话。旧 Codex/Claude 会话没有 app-server goal RPC。' }); + sendGoalSystemMessage(ws, '当前 /goal 仅支持 Codex App 会话。旧 Codex/Claude 会话没有 app-server goal RPC。', { sessionId: session.id }, { preserveComposerDraft: true }); return; } if (command.error) { - wsSend(ws, { type: 'system_message', sessionId: session.id, message: command.error }); + sendGoalSystemMessage(ws, command.error, { sessionId: session.id }, { preserveComposerDraft: true }); return; } if (activeCodexAppGoalCommands.has(session.id)) { - wsSend(ws, { type: 'system_message', sessionId: session.id, message: 'Codex App Goal 正在同步,请稍候。' }); + sendGoalSystemMessage(ws, 'Codex App Goal 正在同步,请稍候。', { sessionId: session.id }, { preserveComposerDraft: true }); return; } @@ -6846,7 +6887,7 @@ async function handleCodexAppGoalSlashCommand(ws, text, session) { startedAt: new Date().toISOString(), }; activeCodexAppGoalCommands.set(session.id, activeGoalCommand); - wsSend(ws, { type: 'system_message', sessionId: session.id, message: '正在同步 Goal...' }); + sendGoalSystemMessage(ws, '正在同步 Goal...', { sessionId: session.id }); broadcastSessionList(); try { @@ -6857,11 +6898,7 @@ async function handleCodexAppGoalSlashCommand(ws, text, session) { if (!isCurrentCodexAppGoalCommand(session.id, activeGoalCommand)) return; const goal = normalizeCodexThreadGoal(response?.goal, threadId); const targetWs = activeGoalCommand.ws || ws; - wsSend(targetWs, { - type: 'system_message', - sessionId: session.id, - message: goal ? formatCodexGoalUsage(goal) : '用法: /goal <目标描述>', - }); + sendGoalSystemMessage(targetWs, goal ? formatCodexGoalUsage(goal) : '用法: /goal <目标描述>', { sessionId: session.id }); sendSessionList(targetWs); return; } @@ -6870,11 +6907,7 @@ async function handleCodexAppGoalSlashCommand(ws, text, session) { const response = await client.request('thread/goal/clear', { threadId }, 30000); if (!isCurrentCodexAppGoalCommand(session.id, activeGoalCommand)) return; const targetWs = activeGoalCommand.ws || ws; - wsSend(targetWs, { - type: 'system_message', - sessionId: session.id, - message: response?.cleared ? 'Goal cleared' : 'No goal to clear', - }); + sendGoalSystemMessage(targetWs, response?.cleared ? 'Goal cleared' : 'No goal to clear', { sessionId: session.id }); sendSessionList(targetWs); return; } @@ -6887,18 +6920,14 @@ async function handleCodexAppGoalSlashCommand(ws, text, session) { if (!isCurrentCodexAppGoalCommand(session.id, activeGoalCommand)) return; const goal = normalizeCodexThreadGoal(response?.goal, threadId); const targetWs = activeGoalCommand.ws || ws; - wsSend(targetWs, { - type: 'system_message', - sessionId: session.id, - message: goal ? formatCodexGoalUsage(goal) : 'Goal updated', - }); + sendGoalSystemMessage(targetWs, goal ? formatCodexGoalUsage(goal) : 'Goal updated', { sessionId: session.id }); sendSessionList(targetWs); } catch (err) { const message = isCodexGoalUnsupportedError(err) ? '当前 Codex app-server 不支持 /goal,请升级 Codex 或启用 goals feature。' : `Goal failed: ${err?.message || err}`; if (isCurrentCodexAppGoalCommand(session.id, activeGoalCommand)) { - wsSend(activeGoalCommand.ws || ws, { type: 'system_message', sessionId: session.id, message }); + sendGoalSystemMessage(activeGoalCommand.ws || ws, message, { sessionId: session.id }, { preserveComposerDraft: true }); } } finally { finishCodexAppGoalCommand(session.id, activeGoalCommand); @@ -6906,19 +6935,31 @@ async function handleCodexAppGoalSlashCommand(ws, text, session) { } // === Slash Command Handler === -function handleSlashCommand(ws, text, sessionId, fallbackAgent) { +function handleSlashCommand(ws, text, sessionId, fallbackAgent, source = {}) { const parts = text.split(/\s+/); const cmd = parts[0].toLowerCase(); let session = sessionId ? loadSession(sessionId) : null; const agent = session ? getSessionAgent(session) : normalizeAgent(fallbackAgent); const codexLikeAgent = agent === 'codex' || agent === 'codexapp'; + const responseSessionId = session?.id || sessionId || undefined; + const sendSlashResponse = (payload, options = {}) => { + const base = { + ...(responseSessionId && payload.sessionId === undefined ? { sessionId: responseSessionId } : {}), + ...payload, + ...(options.preserveComposerDraft ? { preserveComposerDraft: true } : {}), + }; + wsSend(ws, attachClientRequestId(base, source)); + }; + const sendSlashSystemMessage = (message, extra = {}, options = {}) => { + sendSlashResponse({ type: 'system_message', message, ...extra }, options); + }; if (session && isCodexAppSession(session) && activeCodexAppTurns.has(sessionId)) { - wsSend(ws, { type: 'system_message', message: 'Codex App 运行中暂不支持 slash 指令,请等待完成或点击停止。' }); + sendSlashSystemMessage('Codex App 运行中暂不支持 slash 指令,请等待完成或点击停止。', {}, { preserveComposerDraft: true }); return; } if (session && isCodexAppSession(session) && activeCodexAppGoalCommands.has(sessionId)) { - wsSend(ws, { type: 'system_message', sessionId, message: 'Codex App Goal 正在同步,请稍候。' }); + sendSlashSystemMessage('Codex App Goal 正在同步,请稍候。', { sessionId }, { preserveComposerDraft: true }); return; } @@ -6937,21 +6978,22 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent) { clearRuntimeSessionId(session); session.updated = new Date().toISOString(); saveSession(session); - wsSend(ws, attachClientRequestId({ + sendSlashResponse({ type: 'session_info', sessionId: session.id, messages: [], title: session.title, pinnedAt: session.pinnedAt || null, + ...publicTitleMetadata(session), mode: session.permissionMode || 'yolo', model: sessionModelLabel(session), agent: getSessionAgent(session), cwd: session.cwd || null, totalCost: session.totalCost || 0, totalUsage: session.totalUsage || null, - }, { sessionId })); + }); } - wsSend(ws, { type: 'system_message', message: '会话已清除,上下文已重置。' }); + sendSlashSystemMessage('会话已清除,上下文已重置。'); break; } @@ -6960,7 +7002,7 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent) { if (codexLikeAgent) { if (!modelInput) { const current = session?.model || getDefaultCodexModel(); - wsSend(ws, { type: 'system_message', message: `当前 ${agent === 'codexapp' ? 'Codex App' : 'Codex'} 模型: ${current}\n用法: /model <模型名>` }); + sendSlashSystemMessage(`当前 ${agent === 'codexapp' ? 'Codex App' : 'Codex'} 模型: ${current}\n用法: /model <模型名>`); } else { if (session) { session.model = modelInput; @@ -6968,15 +7010,15 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent) { saveSession(session); } wsSend(ws, { type: 'model_changed', model: modelInput }); - wsSend(ws, { type: 'system_message', message: `${agent === 'codexapp' ? 'Codex App' : 'Codex'} 模型已切换为: ${modelInput}` }); + sendSlashSystemMessage(`${agent === 'codexapp' ? 'Codex App' : 'Codex'} 模型已切换为: ${modelInput}`); } } else if (!modelInput) { const current = session?.model ? modelShortName(session.model) || session.model : 'opus (默认)'; - wsSend(ws, { type: 'system_message', message: `当前模型: ${current}\n可选: opus, sonnet, haiku` }); + sendSlashSystemMessage(`当前模型: ${current}\n可选: opus, sonnet, haiku`); } else { const modelKey = modelInput.toLowerCase(); if (!MODEL_MAP[modelKey]) { - wsSend(ws, { type: 'system_message', message: `无效模型: ${modelInput}\n可选: opus, sonnet, haiku` }); + sendSlashSystemMessage(`无效模型: ${modelInput}\n可选: opus, sonnet, haiku`, {}, { preserveComposerDraft: true }); } else { const model = MODEL_MAP[modelKey]; if (session) { @@ -6985,7 +7027,7 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent) { saveSession(session); } wsSend(ws, { type: 'model_changed', model: modelKey }); - wsSend(ws, { type: 'system_message', message: `模型已切换为: ${modelKey}` }); + sendSlashSystemMessage(`模型已切换为: ${modelKey}`); } } break; @@ -6994,96 +7036,104 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent) { case '/cost': { if (codexLikeAgent) { const usage = session?.totalUsage || { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 }; - wsSend(ws, { + sendSlashResponse({ type: 'system_message', message: `当前会话累计 Token: 输入 ${usage.inputTokens},缓存 ${usage.cachedInputTokens},输出 ${usage.outputTokens}`, }); } else { const cost = session?.totalCost || 0; - wsSend(ws, { type: 'system_message', message: `当前会话累计费用: $${cost.toFixed(4)}` }); + sendSlashSystemMessage(`当前会话累计费用: $${cost.toFixed(4)}`); } break; } case '/goal': { - handleCodexAppGoalSlashCommand(ws, text, session).catch((err) => { - wsSend(ws, { + handleCodexAppGoalSlashCommand(ws, text, session, source).catch((err) => { + sendSlashResponse({ type: 'system_message', sessionId: session?.id, message: `Goal failed: ${err?.message || err}`, - }); + }, { preserveComposerDraft: true }); }); break; } case '/compact': { if (!sessionId || !session) { - wsSend(ws, { type: 'system_message', message: '当前没有可压缩的会话。请先进入一个已进行过对话的会话后再执行 /compact。' }); + sendSlashSystemMessage('当前没有可压缩的会话。请先进入一个已进行过对话的会话后再执行 /compact。', {}, { preserveComposerDraft: true }); break; } if (isSessionRunning(sessionId)) { - wsSend(ws, { type: 'system_message', message: '当前会话正在处理中,请先等待完成或点击停止,再执行 /compact。' }); + sendSlashSystemMessage('当前会话正在处理中,请先等待完成或点击停止,再执行 /compact。', {}, { preserveComposerDraft: true }); break; } if (isCodexAppSession(session)) { - wsSend(ws, { type: 'system_message', message: 'Codex App 模式暂不支持 /compact,请切换到旧 Codex 模式或等待后续接入。' }); + sendSlashSystemMessage('Codex App 模式暂不支持 /compact,请切换到旧 Codex 模式或等待后续接入。', {}, { preserveComposerDraft: true }); break; } const runtimeId = getRuntimeSessionId(session); if (!runtimeId) { - wsSend(ws, { + sendSlashResponse({ type: 'system_message', message: agent === 'codex' ? '当前会话尚未建立 Codex 上下文,暂时无需压缩。' : '当前会话尚未建立 Claude 上下文,暂时无需压缩。', - }); + }, { preserveComposerDraft: true }); break; } - wsSend(ws, { type: 'system_message', message: compactStartMessage(agent) }); + sendSlashSystemMessage(compactStartMessage(agent)); pendingSlashCommands.set(session.id, { kind: 'compact' }); - handleMessage(ws, { text: '/compact', sessionId: session.id, mode: session.permissionMode || 'yolo' }, { hideInHistory: true }); + handleMessage(ws, { + text: '/compact', + sessionId: session.id, + mode: session.permissionMode || 'yolo', + requestId: source?.requestId, + preserveComposerDraft: true, + }, { hideInHistory: true }); break; } case '/init': { if (!sessionId || !session) { - wsSend(ws, { type: 'system_message', message: '请先进入一个会话后再执行 /init。' }); + sendSlashSystemMessage('请先进入一个会话后再执行 /init。', {}, { preserveComposerDraft: true }); break; } if (isSessionRunning(sessionId)) { - wsSend(ws, { type: 'system_message', message: '当前会话正在处理中,请先等待完成或点击停止。' }); + sendSlashSystemMessage('当前会话正在处理中,请先等待完成或点击停止。', {}, { preserveComposerDraft: true }); break; } - wsSend(ws, { type: 'system_message', message: initStartMessage(agent) }); + sendSlashSystemMessage(initStartMessage(agent)); pendingSlashCommands.set(session.id, { kind: 'init' }); handleMessage(ws, { text: codexLikeAgent ? buildCodexInitPrompt(session.cwd) : '/init', sessionId: session.id, mode: session.permissionMode || 'yolo', + requestId: source?.requestId, + preserveComposerDraft: true, }, { hideInHistory: true }); break; } - case '/mode': { - const modeInput = parts[1]; - const VALID_MODES = ['default', 'plan', 'yolo']; - const MODE_DESC = { default: '默认(需权限审批,受限操作)', plan: 'Plan(需确认计划后执行)', yolo: 'YOLO(跳过所有权限检查)' }; - if (!modeInput) { - const cur = session?.permissionMode || 'yolo'; - wsSend(ws, { type: 'system_message', message: `当前模式: ${MODE_DESC[cur] || cur}\n可选: default, plan, yolo` }); - } else if (VALID_MODES.includes(modeInput.toLowerCase())) { - const mode = modeInput.toLowerCase(); - if (session) { - session.permissionMode = mode; - // Mode switching should not reset runtime context (Claude/Codex both resume). - session.updated = new Date().toISOString(); - saveSession(session); - } - wsSend(ws, { type: 'system_message', message: `权限模式已切换为: ${MODE_DESC[mode]}` }); - wsSend(ws, { type: 'mode_changed', mode }); - } else { - wsSend(ws, { type: 'system_message', message: `无效模式: ${modeInput}\n可选: default, plan, yolo` }); + case '/mode': { + const modeInput = parts[1]; + const VALID_MODES = ['default', 'plan', 'yolo']; + const MODE_DESC = { default: '默认(需权限审批,受限操作)', plan: 'Plan(需确认计划后执行)', yolo: 'YOLO(跳过所有权限检查)' }; + if (!modeInput) { + const cur = session?.permissionMode || 'yolo'; + sendSlashSystemMessage(`当前模式: ${MODE_DESC[cur] || cur}\n可选: default, plan, yolo`); + } else if (VALID_MODES.includes(modeInput.toLowerCase())) { + const mode = modeInput.toLowerCase(); + if (session) { + session.permissionMode = mode; + // Mode switching should not reset runtime context (Claude/Codex both resume). + session.updated = new Date().toISOString(); + saveSession(session); + } + sendSlashSystemMessage(`权限模式已切换为: ${MODE_DESC[mode]}`); + wsSend(ws, { type: 'mode_changed', mode }); + } else { + sendSlashSystemMessage(`无效模式: ${modeInput}\n可选: default, plan, yolo`, {}, { preserveComposerDraft: true }); } break; } @@ -7094,7 +7144,7 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent) { '/mode [模式] — 查看/切换权限模式(default, plan, yolo)\n' + '/cost — 查看当前会话累计统计\n' + '/help — 显示本帮助'; - wsSend(ws, { + sendSlashResponse({ type: 'system_message', message: codexLikeAgent ? base + `\n/model [名称] — 查看/切换 ${agent === 'codexapp' ? 'Codex App' : 'Codex'} 模型(自由输入)${agent === 'codexapp' ? '\n/goal [目标] — 设置/查看持久目标;支持 pause/resume/clear' : ''}\n/init — 分析项目并生成/更新 AGENTS.md${agent === 'codexapp' ? '\n/compact — Codex App 模式暂不支持' : '\n/compact — 执行 Codex /compact 压缩上下文'}` @@ -7104,7 +7154,7 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent) { } default: - wsSend(ws, { type: 'system_message', message: `未知指令: ${cmd}\n输入 /help 查看可用指令` }); + sendSlashSystemMessage(`未知指令: ${cmd}\n输入 /help 查看可用指令`, {}, { preserveComposerDraft: true }); } } @@ -7115,6 +7165,80 @@ function normalizeConversationTitle(title, fallback = 'New Chat') { return truncateTextValue(normalized, MCP_CONVERSATION_TITLE_MAX_CHARS, '...'); } +function normalizeSetTitleInput(title) { + const normalized = String(title || '').replace(/\s+/g, ' ').trim(); + return normalized ? truncateTextValue(normalized, MCP_CONVERSATION_TITLE_MAX_CHARS, '...') : ''; +} + +function sessionCreatedFromKind(session) { + return String(session?.createdFrom?.kind || '').trim().toLowerCase(); +} + +function isTitleLockedByUser(session) { + return String(session?.titleSource || '').trim().toLowerCase() === 'manual'; +} + +function publicTitleMetadata(session) { + return { + titleSource: String(session?.titleSource || '').trim().toLowerCase() || null, + createdFromKind: sessionCreatedFromKind(session) || null, + }; +} + +function setCurrentConversationTitle(args = {}, sourceSessionId = '') { + const sourceId = sanitizeId(sourceSessionId || ''); + if (!sourceId) return mcpToolError('missing_source_conversation', '缺少来源对话 ID。'); + + const session = loadSession(sourceId); + if (!session) return mcpToolError('source_not_found', '来源对话不存在。', { sourceConversationId: sourceId }); + + const previousTitle = session.title || 'Untitled'; + const normalizedTitle = normalizeSetTitleInput(args.title); + if (!normalizedTitle) { + return mcpToolError('invalid_title', 'title 不能为空。', { + changed: false, + ignored: false, + title: previousTitle, + previousTitle, + lockedByUser: isTitleLockedByUser(session), + }); + } + + if (isTitleLockedByUser(session)) { + return { + ok: true, + changed: false, + ignored: true, + reason: 'ignored because title is manually locked', + title: previousTitle, + previousTitle, + lockedByUser: true, + }; + } + + const changed = previousTitle !== normalizedTitle; + session.title = normalizedTitle; + session.titleSource = 'llm'; + saveSession(session); + sendSessionEventToViewers(session.id, { + type: 'session_renamed', + sessionId: session.id, + title: session.title, + ...publicTitleMetadata(session), + }); + broadcastSessionList(); + + return { + ok: true, + changed, + ignored: false, + reason: changed ? 'title_updated' : 'unchanged', + title: session.title, + previousTitle, + lockedByUser: false, + }; +} + function resolveConversationAgent(rawAgent, fallbackAgent = 'claude', options = {}) { const value = String(rawAgent || '').trim().toLowerCase(); if (value) { @@ -7223,6 +7347,7 @@ function createPersistentConversationSession(args = {}, options = {}) { const session = { id: crypto.randomUUID(), title: normalizeConversationTitle(args.title), + titleSource: 'system', created: now, updated: now, pinnedAt: null, @@ -7259,6 +7384,7 @@ function buildSessionInfoPayload(session) { messages, title: session.title, pinnedAt: session.pinnedAt || null, + ...publicTitleMetadata(session), mode: session.permissionMode || 'yolo', model: sessionModelLabel(session), agent: getSessionAgent(session), @@ -7474,6 +7600,7 @@ function handleLoadSession(ws, msg) { messages: recentMessages, title: refreshedSession.title, pinnedAt: refreshedSession.pinnedAt || null, + ...publicTitleMetadata(refreshedSession), mode: refreshedSession.permissionMode || 'yolo', model: sessionModelLabel(refreshedSession), agent: getSessionAgent(refreshedSession), @@ -7641,10 +7768,11 @@ function handleRenameSession(ws, sessionId, title) { const session = loadSession(sessionId); if (session) { session.title = String(title).slice(0, 100); + session.titleSource = 'manual'; session.updated = new Date().toISOString(); saveSession(session); sendSessionList(ws); - wsSend(ws, { type: 'session_renamed', sessionId, title: session.title }); + wsSend(ws, { type: 'session_renamed', sessionId, title: session.title, ...publicTitleMetadata(session) }); } } @@ -7791,7 +7919,13 @@ function handleMessage(ws, msg, options = {}) { const { text, sessionId, mode } = msg; const { hideInHistory = false } = options; const fail = (code, message) => { - wsSend(ws, { type: 'error', code, message }); + wsSend(ws, attachClientRequestId({ + type: 'error', + code, + message, + ...(sessionId ? { sessionId } : {}), + ...(msg.preserveComposerDraft ? { preserveComposerDraft: true } : {}), + }, msg)); return { ok: false, code, message }; }; const textValue = typeof text === 'string' ? text : ''; @@ -7852,6 +7986,7 @@ function handleMessage(ws, msg, options = {}) { session = { id, title: derivedTitle, + titleSource: 'derived', created: new Date().toISOString(), updated: new Date().toISOString(), pinnedAt: null, @@ -7890,6 +8025,7 @@ function handleMessage(ws, msg, options = {}) { if (session.title === 'New Chat' || session.title === 'Untitled') { session.title = derivedTitle; + session.titleSource = 'derived'; } let persistedUserMessage = null; @@ -8132,6 +8268,7 @@ const { saveSession, setRuntimeSessionId, getRuntimeSessionId, + titleToolInstructions: CCWEB_TITLE_TOOL_INSTRUCTIONS, }); const codexAppRuntime = createCodexAppRuntime({ @@ -8575,6 +8712,23 @@ function codexAppCommunicationDynamicTools() { additionalProperties: false, }, }, + { + name: 'ccweb_set_title', + namespace: 'ccweb', + description: '设置当前 cc-web 对话标题。只作用于当前来源对话;用户手动重命名后会成功返回但忽略,不再覆盖用户标题。', + inputSchema: { + type: 'object', + required: ['title'], + properties: { + title: { + type: 'string', + maxLength: MCP_CONVERSATION_TITLE_MAX_CHARS, + description: '新的当前对话标题。应简短概括用户主要目标。', + }, + }, + additionalProperties: false, + }, + }, { name: 'ccweb_send_message', namespace: 'ccweb', @@ -8700,6 +8854,7 @@ function handleCodexAppDynamicToolCall(routed, params = {}) { if (namespace && namespace !== 'ccweb') return null; if ( tool !== 'ccweb_list_conversations' && + tool !== 'ccweb_set_title' && tool !== 'ccweb_create_conversation' && tool !== 'ccweb_send_message' && tool !== 'ccweb_list_pending_replies' && @@ -10058,6 +10213,7 @@ function handleImportNativeSession(ws, msg) { messages: transportMessages, title: session.title, pinnedAt: session.pinnedAt || null, + ...publicTitleMetadata(session), mode: session.permissionMode, model: sessionModelLabel(session), agent: getSessionAgent(session), @@ -10230,6 +10386,7 @@ function handleImportCodexSession(ws, msg) { messages: transportMessages, title: session.title, pinnedAt: session.pinnedAt || null, + ...publicTitleMetadata(session), mode: session.permissionMode, model: sessionModelLabel(session), agent: getSessionAgent(session),