feat: add conversation search and usage dashboard

This commit is contained in:
shiyue
2026-08-03 18:20:33 +08:00
parent cc600bdf31
commit 58d5f816c2
30 changed files with 6316 additions and 22 deletions

View File

@@ -14,6 +14,9 @@ const WINDOWS_START_PATH = path.join(REPO_DIR, 'start.bat');
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 SESSION_SEARCH_INDEX_PATH = path.join(REPO_DIR, 'lib', 'session-search-index.js');
const USAGE_STATISTICS_PATH = path.join(REPO_DIR, 'lib', 'usage-statistics.js');
const USAGE_STATISTICS_UNIT_PATH = path.join(REPO_DIR, 'scripts', 'usage-statistics-unit.js');
const GILDED_THEME_ASSETS = [
{
filename: 'gilded-wasteland.png',
@@ -619,8 +622,8 @@ function assertFrontendSidebarCollapseContract() {
'Rich themes should provide isolated rail treatments on top of the shared semantic fallback'
);
assert(
indexSource.includes('style.css?v=20260730-sidebar-title-refresh-storm')
&& indexSource.includes('app.js?v=20260730-sidebar-title-refresh-storm'),
indexSource.includes('style.css?v=20260803-usage-statistics')
&& indexSource.includes('app.js?v=20260803-usage-statistics'),
'Sidebar interaction assets should share the reviewed cache-busting version'
);
}
@@ -943,8 +946,8 @@ function assertPlanListProgressContract() {
assert(extractorSource.includes('references/source-assets/wasteland-icon-sheet.webp'), 'Plan progress extractor should read the archived source sheet');
assert(!extractorSource.includes('sessions/_attachments'), 'Plan progress extractor should not depend on temporary session attachments');
assert(indexSource.includes('style.css?v=20260730-sidebar-title-refresh-storm'), 'Plan progress CSS should use the current cache-busted URL');
assert(indexSource.includes('app.js?v=20260730-sidebar-title-refresh-storm'), 'Plan progress frontend logic should use the current cache-busted URL');
assert(indexSource.includes('style.css?v=20260803-usage-statistics'), 'Plan progress CSS should use the current cache-busted URL');
assert(indexSource.includes('app.js?v=20260803-usage-statistics'), 'Plan progress frontend logic should use the current cache-busted URL');
}
function assertFrontendGildedThemeContract() {
@@ -1059,8 +1062,8 @@ function assertFrontendGildedThemeContract() {
assert(contrast('#655446', '#fff7ea') >= 4.5, 'Gilded muted text should remain readable on ivory panels');
assert(contrast('#fff7ea', '#7a3f20') >= 7, 'Gilded primary action text should reach AAA contrast on copper');
assert(themeStyle.includes('@media (prefers-reduced-motion: reduce)'), 'Gilded theme motion should respect reduced-motion preferences');
assert(indexSource.includes('style.css?v=20260730-sidebar-title-refresh-storm'), 'Theme bundle stylesheet should use the current cache-busted asset URL');
assert(indexSource.includes('app.js?v=20260730-sidebar-title-refresh-storm'), 'Theme bundle app script should use the current cache-busted asset URL');
assert(indexSource.includes('style.css?v=20260803-usage-statistics'), 'Theme bundle stylesheet should use the current cache-busted asset URL');
assert(indexSource.includes('app.js?v=20260803-usage-statistics'), 'Theme bundle app script should use the current cache-busted asset URL');
}
function assertFrontendWastelandThemeContract() {
@@ -1313,8 +1316,8 @@ function assertFrontendWastelandThemeContract() {
assert(contrast('#c9bda6', backgroundColor) >= 4.5, `Wasteland muted text should reach AA contrast on ${backgroundColor}`);
});
assert(indexSource.includes('style.css?v=20260730-sidebar-title-refresh-storm'), 'Wasteland stylesheet should share the cache-busted theme bundle URL');
assert(indexSource.includes('app.js?v=20260730-sidebar-title-refresh-storm'), 'Wasteland registration should share the cache-busted theme bundle URL');
assert(indexSource.includes('style.css?v=20260803-usage-statistics'), 'Wasteland stylesheet should share the cache-busted theme bundle URL');
assert(indexSource.includes('app.js?v=20260803-usage-statistics'), 'Wasteland registration should share the cache-busted theme bundle URL');
}
function assertFrontendCcwebPromptContract() {
@@ -3321,6 +3324,7 @@ function assertSessionRequestIdRaceContract() {
function cloneMessages(messages) { return messages.slice(); }
function isBlockingSessionLoad() { return false; }
function prependHistoryMessages(messages) { prepended.push(...messages); }
function scheduleAdvancedSearchJump() {}
function finalizeLoadedSession(sessionId, requestId) { finalized.push({ sessionId, requestId }); }
function handleHistoryMessage(msg) {
switch (msg.type) {
@@ -3449,6 +3453,7 @@ function assertRecoverCurrentHistoryMergeContract() {
function cloneMessages(messages) { return messages.map((message) => ({ ...message })); }
function isBlockingSessionLoad() { return false; }
function prependHistoryMessages(messages) { prepended.push(...messages); }
function scheduleAdvancedSearchJump() {}
function cacheSessionSnapshot(snapshot) { cached.push(JSON.parse(JSON.stringify(snapshot))); }
function finishSessionSwitch(sessionId, requestId) { finished.push({ sessionId, requestId }); }
${finalizeLoadedSessionSource}
@@ -3541,6 +3546,9 @@ function assertServerSessionHistoryRequestIdContract() {
const activeProcesses = new Map();
const activeCodexAppTurns = new Map();
const wsSessionMap = new Map();
const INITIAL_HISTORY_COUNT = 12;
const HISTORY_CHUNK_SIZE = 24;
const HISTORY_PREFETCH_CHUNKS = 3;
function sanitizeId(value) { return String(value || ''); }
function reconcilePendingCrossConversationReplies() {}
function loadSession() { return fixture; }
@@ -4425,6 +4433,457 @@ function assertMultiAgentV2CompatibilityContract() {
assert(frontendSource.includes('entry.agentPath ? `路径: ${entry.agentPath}`'), 'Sub-agent cards should expose the canonical agent path');
}
function assertAdvancedSessionSearchContract() {
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
const indexSource = fs.readFileSync(PUBLIC_INDEX_PATH, 'utf8');
const styleSource = fs.readFileSync(PUBLIC_STYLE_PATH, 'utf8');
const searchIndexSource = fs.readFileSync(SESSION_SEARCH_INDEX_PATH, 'utf8');
const sidebarSearchIndex = indexSource.indexOf('id="session-search-input"');
const advancedOpenIndex = indexSource.indexOf('id="advanced-search-open"');
const sessionListIndex = indexSource.indexOf('id="session-list"');
assert(sidebarSearchIndex >= 0 && advancedOpenIndex > sidebarSearchIndex && sessionListIndex > advancedOpenIndex,
'Advanced search button should sit beside the unchanged sidebar search before the session list');
assert(indexSource.includes('id="session-search-clear" class="session-search-clear" type="button" title="清空检索" aria-label="清空检索" hidden'),
'Existing sidebar search clear control contract should remain unchanged');
assert(indexSource.includes('id="advanced-search-panel"') && indexSource.includes('id="advanced-search-results"'),
'Advanced search workspace should expose stable panel and result hooks');
assert(indexSource.includes('style.css?v=20260803-usage-statistics')
&& indexSource.includes('app.js?v=20260803-usage-statistics'),
'Advanced search CSS and frontend script should share the reviewed cache-bust');
const panelStyleStart = styleSource.indexOf('.advanced-search-panel {');
const panelStyleEnd = styleSource.indexOf('.advanced-search-panel[hidden]', panelStyleStart);
const panelStyle = styleSource.slice(panelStyleStart, panelStyleEnd);
assert(panelStyle.includes('position: absolute') && panelStyle.includes('inset: 0'),
'Advanced search should be a chat-main-local overlay rather than a global modal');
assert(styleSource.includes('.advanced-search-results') && styleSource.includes('overflow-y: auto'),
'Advanced search results should own their scroll container');
assert(/html\[data-theme='wasteland'\] \.session-search-row \.session-search\s*\{[^}]*margin-top:\s*0;/.test(styleSource)
&& /html\[data-theme='wasteland'\] \.session-search-row \.advanced-search-open\s*\{[^}]*width:\s*36px;[^}]*height:\s*36px;[^}]*flex-basis:\s*36px;/.test(styleSource),
'Wasteland advanced button and framed search input should share one 36px row axis');
assert(styleSource.includes('@media (max-width: 768px)') && styleSource.includes('@media (prefers-reduced-motion: reduce)'),
'Advanced search should cover mobile and reduced-motion states');
const legacyInputBlock = frontendSource.slice(
frontendSource.indexOf('if (sessionSearchInput) {'),
frontendSource.indexOf('// Split new-chat button'),
);
assert(legacyInputBlock.includes('sessionSearchQuery = sessionSearchInput.value;')
&& legacyInputBlock.includes("if (e.key === 'Escape' && normalizeSessionSearchQuery(sessionSearchQuery))")
&& legacyInputBlock.includes('sessionSearchQuery = \'\';\n renderSessionList();'),
'Existing sidebar search input, Escape and clear behavior should remain intact');
const advancedFunctionStart = frontendSource.indexOf('function normalizeAdvancedSearchQuery');
const advancedFunctionEnd = frontendSource.indexOf('function getProjectCollapseKey', advancedFunctionStart);
const advancedFunctions = frontendSource.slice(advancedFunctionStart, advancedFunctionEnd);
assert(!/sessionSearchQuery\s*=/.test(advancedFunctions),
'Advanced search state machine must not write the existing sidebar query');
assert(advancedFunctions.includes("type: 'search_sessions'")
&& advancedFunctions.includes('createTextNode')
&& advancedFunctions.includes('mark.textContent ='),
'Advanced search should use its independent WS request and DOM-safe highlight rendering');
assert(advancedFunctions.includes('targetMessageIndex') && advancedFunctions.includes('forceSync: true'),
'Advanced search result navigation should carry messageIndex through a fresh session load');
const sessionListBlock = extractFunctionSource(serverSource, 'sendSessionList');
assert(!sessionListBlock.includes('message.content') && !sessionListBlock.includes('session_search_results'),
'Existing session_list payload must remain lightweight and independent from body search');
assert(serverSource.includes("case 'search_sessions':")
&& serverSource.includes("type: 'session_search_results'")
&& serverSource.includes('sessionSearchIndex.scheduleUpsert(session.id)')
&& serverSource.includes('sessionSearchIndex.remove(sessionId)'),
'Server should expose the independent search protocol and synchronize save/delete lifecycle');
const loadSessionBlock = extractFunctionSource(serverSource, 'handleLoadSession');
assert(loadSessionBlock.includes('targetMessageIndex') && loadSessionBlock.includes('targetPrefetchChunks'),
'Targeted session loads should prefetch enough history to expose the matched message');
assert(searchIndexSource.includes("role !== 'user' && role !== 'assistant'")
&& searchIndexSource.includes("type === 'tool_use'")
&& searchIndexSource.includes('MAX_QUERY_CHARS_HARD_LIMIT = 200')
&& searchIndexSource.includes('MAX_RESULTS_HARD_LIMIT = 50')
&& !searchIndexSource.includes('new RegExp('),
'Search index should constrain input and exclude system/tool/attachment content without dynamic regexes');
}
function assertUsageStatisticsContract() {
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
const indexSource = fs.readFileSync(PUBLIC_INDEX_PATH, 'utf8');
const styleSource = fs.readFileSync(PUBLIC_STYLE_PATH, 'utf8');
const usageSource = fs.readFileSync(USAGE_STATISTICS_PATH, 'utf8');
const sessionListIndex = indexSource.indexOf('id="session-list"');
const footerIndex = indexSource.indexOf('class="sidebar-footer"');
const settingsIndex = indexSource.indexOf('id="settings-btn"', footerIndex);
const usageOpenIndex = indexSource.indexOf('id="usage-dashboard-open"', footerIndex);
const chatMainIndex = indexSource.indexOf('class="chat-main"');
const usagePanelIndex = indexSource.indexOf('id="usage-dashboard-panel"');
assert(sessionListIndex >= 0 && footerIndex > sessionListIndex && settingsIndex > footerIndex && usageOpenIndex > settingsIndex,
'Usage entry should stay in the fixed sidebar footer after settings, outside the session list');
assert(chatMainIndex >= 0 && usagePanelIndex > chatMainIndex,
'Usage dashboard should be a chat-main-local workspace');
assert(indexSource.includes('class="usage-dashboard-open"') && !indexSource.includes('class="settings-btn usage-dashboard-open"'),
'Usage entry must use its own class so theme-specific settings pseudo-elements cannot leak');
assert(indexSource.includes('style.css?v=20260803-usage-statistics')
&& indexSource.includes('app.js?v=20260803-usage-statistics'),
'Usage dashboard CSS and frontend script should share the current cache-bust');
const usageFunctionsStart = frontendSource.indexOf('function usageDateInputValue');
const usageFunctionsEnd = frontendSource.indexOf('function getProjectCollapseKey', usageFunctionsStart);
const usageFunctions = frontendSource.slice(usageFunctionsStart, usageFunctionsEnd);
assert(usageFunctions.includes("type: 'usage_stats_query'")
&& usageFunctions.includes('usageDashboardState.requestId')
&& usageFunctions.includes("behavior: reduceMotion ? 'auto' : 'smooth'"),
'Usage dashboard should use an independent request id and respect reduced-motion while navigating details');
assert(!/\bcurrentSessionId\s*=/.test(usageFunctions)
&& !/\bsessions\s*=/.test(usageFunctions)
&& !/\bsessionSearchQuery\s*=/.test(usageFunctions)
&& !/\bisGenerating\s*=/.test(usageFunctions)
&& !/msgInput\.value\s*=/.test(usageFunctions)
&& !/\bpendingAttachments\s*=/.test(usageFunctions)
&& !/messagesDiv\.(?:innerHTML|replaceChildren)/.test(usageFunctions),
'Usage dashboard state must not replace chat/session/search/generation/draft state or mounted messages');
const queryHandler = extractFunctionSource(serverSource, 'handleUsageStatisticsQuery');
assert(serverSource.includes("case 'usage_stats_query':")
&& serverSource.includes("type: 'usage_stats_result'")
&& serverSource.includes("type: 'usage_stats_error'")
&& serverSource.includes('features: { usageStatistics: USAGE_STATISTICS_ENABLED }'),
'Server should expose the versioned usage query protocol and authenticated feature flag');
assert(!queryHandler.includes('sendSessionList') && !queryHandler.includes('broadcastSessionList'),
'Usage queries must not refresh or replace session_list');
assert(serverSource.includes("process.env.CC_WEB_USAGE_STATISTICS")
&& serverSource.includes('scheduleUsageStatisticsUpsert(session.id)')
&& serverSource.includes('removeUsageStatisticsSession(sessionId)'),
'Usage indexing should be independently gated and synchronized through safe save/delete hooks');
assert(usageSource.includes("scope: 'retained_sessions'")
&& usageSource.includes("semantics: '[from,to)'")
&& usageSource.includes("skillBasis: 'explicit_composer_mention'")
&& usageSource.includes("mcpTimestampBasis: 'assistant_message'"),
'Usage response should publish its retained-data scope and exact counting semantics');
assert(usageSource.includes("String(mention.kind || '').toLowerCase() !== 'skill'")
&& usageSource.includes('const meta = isObject(toolCall.meta) ? toolCall.meta : {}')
&& usageSource.includes('normalizeMcpStatus(meta.status || toolCall.status, !!toolCall.done)')
&& !usageSource.includes("content: message.content"),
'Usage index should count explicit Skill mentions and MCP status without retaining message bodies');
assert(usageSource.includes('DEFAULT_MCP_TOOL_LIMIT = 200')
&& usageSource.includes('DEFAULT_SKILL_LIMIT = 100')
&& usageSource.includes('detailCallsReturned')
&& frontendSource.includes('最近 ${formatUsageNumber(returnedCalls)} / 共 ${formatUsageNumber(totalCalls)} 条'),
'Usage result rankings and detail payloads should be bounded and visibly disclose partial detail windows');
assert(styleSource.includes('/* === 使用统计看板:独立工作区 === */')
&& styleSource.includes("html[data-theme='wasteland'] .usage-dashboard-open")
&& styleSource.includes("html[data-theme='gilded'] .usage-dashboard")
&& styleSource.includes("html[data-theme='coolvibe'] .usage-dashboard-open")
&& styleSource.includes('--usage-dashboard-accent-ink: #0d1b1f')
&& /html\[data-theme='wasteland'\][\s\S]*?\.usage-dashboard__dates input\s*\{\s*color-scheme:\s*dark;/.test(styleSource)
&& /@media \(prefers-reduced-motion:\s*reduce\)[\s\S]*?\.usage-dashboard/.test(styleSource),
'Usage dashboard should provide isolated theme surfaces, readable native controls, mobile sizing, and reduced-motion coverage');
}
function assertUsageStatisticsUnitChecks() {
const result = spawnSync(process.execPath, [USAGE_STATISTICS_UNIT_PATH], {
cwd: REPO_DIR,
encoding: 'utf8',
timeout: 60000,
});
assert(result.status === 0,
`Usage statistics unit checks failed: ${result.stderr || result.stdout || result.signal || 'unknown error'}`);
}
async function runUsageStatisticsRegression() {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-usage-regression-'));
const configDir = path.join(tempRoot, 'config');
const sessionsDir = path.join(tempRoot, 'sessions');
const logsDir = path.join(tempRoot, 'logs');
const homeDir = path.join(tempRoot, 'home');
mkdirp(configDir);
mkdirp(sessionsDir);
mkdirp(logsDir);
mkdirp(homeDir);
const sessionId = 'usage-regression-session';
const sessionPath = path.join(sessionsDir, `${sessionId}.json`);
fs.writeFileSync(sessionPath, JSON.stringify({
id: sessionId,
title: '使用统计回归会话',
agent: 'codexapp',
cwd: homeDir,
created: '2026-08-01T00:00:00.000Z',
updated: '2026-08-02T00:00:00.000Z',
messages: [
{
role: 'user',
content: 'private-message-body',
timestamp: '2026-08-01T01:00:00.000Z',
composerMentions: [{ kind: 'skill', name: 'openai-docs', label: '$openai-docs' }],
},
{
role: 'assistant',
content: 'private-assistant-body',
timestamp: '2026-08-01T01:01:00.000Z',
toolCalls: [{
name: 'McpToolCall',
kind: 'mcp_tool_call',
input: { server: 'ccweb', tool: 'ccweb_list_conversations', arguments: { private: true } },
result: 'private-tool-result',
done: true,
meta: { kind: 'mcp_tool_call', status: 'completed' },
}],
},
],
}, null, 2));
const beforeContent = fs.readFileSync(sessionPath, 'utf8');
const beforeMtime = fs.statSync(sessionPath).mtimeMs;
try {
const port = await getFreePort();
const password = 'UsageRegression!234';
await withServer({
PORT: String(port),
CC_WEB_PASSWORD: password,
CC_WEB_INTERNAL_MCP_TOKEN: 'UsageRegressionMcp!234',
CC_WEB_CONFIG_DIR: configDir,
CC_WEB_SESSIONS_DIR: sessionsDir,
CC_WEB_LOGS_DIR: logsDir,
HOME: homeDir,
CLAUDE_PATH: MOCK_CLAUDE,
CODEX_PATH: MOCK_CODEX_APP_SERVER,
}, async () => {
const { ws, messages, receivedMessages } = await connectWs(port, password, { trackReceived: true });
const authResult = receivedMessages.find((msg) => msg.type === 'auth_result');
assert(authResult?.features?.usageStatistics === true,
'Authenticated clients should receive the enabled usageStatistics feature flag');
await nextMessage(messages, ws, (msg) => msg.type === 'session_list');
const baselineSessionLists = receivedMessages.filter((msg) => msg.type === 'session_list').length;
ws.send(JSON.stringify({
type: 'usage_stats_query',
requestId: 'usage-regression-query',
from: '2026-08-01T00:00:00.000Z',
to: '2026-08-02T00:00:00.000Z',
timeZone: 'UTC',
}));
const response = await nextMessage(messages, ws, (msg) => (
msg.type === 'usage_stats_result' && msg.requestId === 'usage-regression-query'
));
assert(response.schemaVersion === 1 && response.range?.semantics === '[from,to)',
'Usage response should carry schemaVersion and half-open range semantics');
assert(response.overview?.newSessions === 1
&& response.overview?.messages === 1
&& response.overview?.mcpCalls === 1
&& response.overview?.skillMentions === 1,
'Usage WebSocket query should return the expected aggregate counts');
const serialized = JSON.stringify(response);
['private-message-body', 'private-assistant-body', 'private-tool-result', 'arguments'].forEach((privateValue) => {
assert(!serialized.includes(privateValue), `Usage response should not expose ${privateValue}`);
});
await sleep(160);
assert(receivedMessages.filter((msg) => msg.type === 'session_list').length === baselineSessionLists,
'Usage query must not trigger an extra session_list response');
assert(fs.readFileSync(sessionPath, 'utf8') === beforeContent && fs.statSync(sessionPath).mtimeMs === beforeMtime,
'Usage query must not modify retained session files');
ws.send(JSON.stringify({
type: 'load_session',
sessionId,
requestId: 'usage-regression-load',
}));
const loaded = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_info' && msg.requestId === 'usage-regression-load'
));
assert(loaded.sessionId === sessionId, 'Session loading should remain available after a usage query');
ws.send(JSON.stringify({
type: 'search_sessions',
requestId: 'usage-regression-search',
query: 'private-message-body',
agent: 'codexapp',
matchMode: 'contains',
}));
const search = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_search_results' && msg.requestId === 'usage-regression-search'
));
assert(search.total === 1 && search.results[0]?.sessionId === sessionId,
'Advanced session search should remain available after a usage query');
assert(receivedMessages.filter((msg) => msg.type === 'session_list').length === baselineSessionLists,
'Usage, load and search requests must not refresh session_list');
ws.close();
});
const disabledPort = await getFreePort();
await withServer({
PORT: String(disabledPort),
CC_WEB_PASSWORD: 'UsageDisabled!234',
CC_WEB_USAGE_STATISTICS: '0',
CC_WEB_INTERNAL_MCP_TOKEN: 'UsageDisabledMcp!234',
CC_WEB_CONFIG_DIR: path.join(tempRoot, 'disabled-config'),
CC_WEB_SESSIONS_DIR: sessionsDir,
CC_WEB_LOGS_DIR: path.join(tempRoot, 'disabled-logs'),
HOME: homeDir,
CLAUDE_PATH: MOCK_CLAUDE,
CODEX_PATH: MOCK_CODEX_APP_SERVER,
}, async () => {
const { ws, messages, receivedMessages } = await connectWs(disabledPort, 'UsageDisabled!234', { trackReceived: true });
const authResult = receivedMessages.find((msg) => msg.type === 'auth_result');
assert(authResult?.features?.usageStatistics === false,
'Disabled usage statistics should be advertised as unavailable');
await nextMessage(messages, ws, (msg) => msg.type === 'session_list');
ws.send(JSON.stringify({
type: 'usage_stats_query',
requestId: 'usage-disabled-query',
from: '2026-08-01T00:00:00.000Z',
to: '2026-08-02T00:00:00.000Z',
timeZone: 'UTC',
}));
const error = await nextMessage(messages, ws, (msg) => (
msg.type === 'usage_stats_error' && msg.requestId === 'usage-disabled-query'
));
assert(error.code === 'disabled', 'Disabled usage statistics should reject queries without affecting the server');
ws.close();
});
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
}
async function runAdvancedSessionSearchRegression() {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-advanced-search-regression-'));
const configDir = path.join(tempRoot, 'config');
const sessionsDir = path.join(tempRoot, 'sessions');
const logsDir = path.join(tempRoot, 'logs');
const homeDir = path.join(tempRoot, 'home');
mkdirp(configDir);
mkdirp(sessionsDir);
mkdirp(logsDir);
mkdirp(homeDir);
const sessionId = 'advanced-search-session';
const storedMessages = Array.from({ length: 180 }, (_, index) => ({
role: index % 2 === 0 ? 'user' : 'assistant',
content: `普通回归消息 ${index}`,
timestamp: new Date(Date.UTC(2026, 7, 3, 0, index % 60)).toISOString(),
}));
storedMessages[0] = {
role: 'user',
content: '这里保存着云杉锚点,点击结果应回到第一条消息。',
timestamp: '2026-08-03T00:00:00.000Z',
};
storedMessages[1] = {
role: 'system',
content: 'tool-only-secret 不应被高级检索索引',
timestamp: '2026-08-03T00:01:00.000Z',
};
storedMessages[2] = {
role: 'assistant',
content: 'anchored-only-token',
timestamp: '2026-08-03T00:02:00.000Z',
};
fs.writeFileSync(path.join(sessionsDir, `${sessionId}.json`), JSON.stringify({
id: sessionId,
title: '高级检索回归会话',
agent: 'codexapp',
cwd: homeDir,
created: '2026-08-03T00:00:00.000Z',
updated: '2026-08-03T03:00:00.000Z',
messages: storedMessages,
}));
const port = await getFreePort();
const password = 'AdvancedSearch!234';
await withServer({
PORT: String(port),
CC_WEB_PASSWORD: password,
CC_WEB_INTERNAL_MCP_TOKEN: 'AdvancedSearchMcp!234',
CC_WEB_CONFIG_DIR: configDir,
CC_WEB_SESSIONS_DIR: sessionsDir,
CC_WEB_LOGS_DIR: logsDir,
HOME: homeDir,
CLAUDE_PATH: MOCK_CLAUDE,
CODEX_PATH: MOCK_CODEX_APP_SERVER,
}, async () => {
const { ws, messages, receivedMessages } = await connectWs(port, password, { trackReceived: true });
await nextMessage(messages, ws, (msg) => msg.type === 'session_list');
const baselineSessionLists = receivedMessages.filter((msg) => msg.type === 'session_list').length;
ws.send(JSON.stringify({
type: 'search_sessions',
requestId: 'advanced-search-hit',
query: '云杉锚点',
agent: 'codexapp',
sort: 'relevance',
matchMode: 'contains',
limit: 50,
}));
const hitResponse = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_search_results' && msg.requestId === 'advanced-search-hit'
));
assert(hitResponse.total === 1 && hitResponse.results[0]?.sessionId === sessionId,
'Advanced body search should return the matching session');
assert(hitResponse.results[0]?.matches?.[0]?.messageIndex === 0
&& /云杉锚点/.test(hitResponse.results[0]?.matches?.[0]?.snippet || ''),
'Advanced body search should return a bounded snippet and stable messageIndex');
await sleep(140);
ws.send(JSON.stringify({
type: 'search_sessions',
requestId: 'advanced-search-private-exclusion',
query: 'tool-only-secret',
agent: 'codexapp',
matchMode: 'contains',
}));
const excludedResponse = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_search_results' && msg.requestId === 'advanced-search-private-exclusion'
));
assert(excludedResponse.total === 0, 'System/tool-only content should not enter the advanced search index');
await sleep(140);
ws.send(JSON.stringify({
type: 'search_sessions',
requestId: 'advanced-search-word-boundary',
query: 'anchored',
agent: 'codexapp',
matchMode: 'word',
}));
const wordResponse = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_search_results' && msg.requestId === 'advanced-search-word-boundary'
));
assert(wordResponse.total === 1, 'Whole-word search should match an exact normalized token');
ws.send(JSON.stringify({
type: 'load_session',
sessionId,
requestId: 'advanced-search-target-load',
targetMessageIndex: 0,
}));
const sessionInfo = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_info' && msg.requestId === 'advanced-search-target-load'
));
assert(sessionInfo.historyPending === true, 'Targeted load fixture should stream older history chunks');
const historyChunks = [];
let finalChunk = null;
do {
finalChunk = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_history_chunk' && msg.requestId === 'advanced-search-target-load'
));
historyChunks.push(finalChunk);
} while (finalChunk.remaining > 0);
assert(Math.min(...historyChunks.map((chunk) => Number(chunk.historyBaseIndex))) === 0,
'Targeted session load should prefetch history through the matched message index');
assert(historyChunks.some((chunk) => (
Number(chunk.historyBaseIndex) === 0 && /云杉锚点/.test(chunk.messages?.[0]?.content || '')
)), 'Targeted history chunks should include the exact matched message');
await sleep(160);
assert(receivedMessages.filter((msg) => msg.type === 'session_list').length === baselineSessionLists,
'Advanced search and targeted history loading must not refresh or replace session_list');
ws.close();
});
}
function assertWindowsStartupContract() {
const source = fs.readFileSync(WINDOWS_START_PATH, 'utf8').replace(/\r\n/g, '\n');
@@ -4514,6 +4973,19 @@ async function main() {
console.log('Sidebar title refresh storm regression checks passed.');
return;
}
if (regressionTarget === 'advanced-session-search') {
assertAdvancedSessionSearchContract();
await runAdvancedSessionSearchRegression();
console.log('Advanced session search regression checks passed.');
return;
}
if (regressionTarget === 'usage-statistics') {
assertUsageStatisticsUnitChecks();
assertUsageStatisticsContract();
await runUsageStatisticsRegression();
console.log('Usage statistics regression checks passed.');
return;
}
if (regressionTarget === 'windows-startup') {
assertWindowsStartupContract();
console.log('Windows startup regression checks passed.');
@@ -4548,6 +5020,9 @@ async function main() {
assertTitleHistoryOutlineContract();
assertSessionSwitchResilienceContract();
assertSessionSwitchRaceContract();
assertAdvancedSessionSearchContract();
assertUsageStatisticsUnitChecks();
assertUsageStatisticsContract();
assertCodexAppChildToolRoutingContract();
assertMultiAgentV2CompatibilityContract();
assertWindowsStartupContract();

View File

@@ -0,0 +1,215 @@
'use strict';
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const {
SCHEMA_VERSION,
UsageStatisticsError,
createUsageStatisticsIndex,
} = require('../lib/usage-statistics');
function writeSession(sessionsDir, session) {
const filePath = path.join(sessionsDir, `${session.id}.json`);
fs.writeFileSync(filePath, JSON.stringify(session, null, 2));
return filePath;
}
function sessionFixture() {
return {
id: 'usage-session-a',
title: '统计夹具 A',
created: '2026-07-27T00:00:00.000Z',
updated: '2026-07-29T03:00:00.000Z',
cwd: '/tmp/usage-project',
agent: 'codexapp',
messages: [
{
role: 'user',
content: '不能出现在统计响应里的正文 secret-body',
timestamp: '2026-07-28T01:00:00.000Z',
composerMentions: [
{ kind: 'skill', name: 'openai-docs', label: '$openai-docs' },
{ kind: 'file', name: 'AGENTS.md', label: '@AGENTS.md' },
],
},
{
role: 'assistant',
content: '已处理',
timestamp: '2026-07-28T02:00:00.000Z',
toolCalls: [
{
name: 'McpToolCall',
kind: 'mcp_tool_call',
input: { server: 'ccweb', tool: 'ccweb_list_conversations', arguments: { secret: true } },
done: true,
result: 'secret-result',
meta: { kind: 'mcp_tool_call', subtitle: 'ccweb.ccweb_list_conversations', status: 'completed' },
},
{
name: 'McpToolCall',
kind: 'mcp_tool_call',
input: { server: 'ccweb', tool: 'ccweb_send_message', arguments: {} },
done: true,
result: 'failed secret-result',
meta: { kind: 'mcp_tool_call', subtitle: 'ccweb.ccweb_send_message', status: 'failed' },
},
{
name: 'Read',
kind: 'function_call',
input: { file: 'SKILL.md' },
done: true,
},
],
},
{
role: 'user',
content: '跨会话消息',
timestamp: '2026-07-29T03:00:00.000Z',
crossConversation: { sourceSessionId: 'source-session' },
},
{
role: 'assistant',
content: '范围外调用',
timestamp: '2026-08-03T00:00:00.000Z',
toolCalls: [{
name: 'McpToolCall',
kind: 'mcp_tool_call',
input: { server: 'outside', tool: 'outside_tool' },
done: true,
meta: { status: 'completed' },
}],
},
],
};
}
async function main() {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-usage-statistics-'));
const sessionsDir = path.join(tempRoot, 'sessions');
const cacheFile = path.join(tempRoot, 'cache', 'index-v1.json');
fs.mkdirSync(sessionsDir, { recursive: true });
try {
const fixture = sessionFixture();
const sourceFile = writeSession(sessionsDir, fixture);
writeSession(sessionsDir, {
id: 'usage-session-boundary',
title: '范围右边界',
created: '2026-08-03T00:00:00.000Z',
updated: '2026-08-03T00:00:00.000Z',
messages: [{ role: 'user', content: '右边界消息', timestamp: '2026-08-03T00:00:00.000Z' }],
});
fs.writeFileSync(path.join(sessionsDir, 'broken-session.json'), '{broken');
const beforeContent = fs.readFileSync(sourceFile, 'utf8');
const beforeMtime = fs.statSync(sourceFile).mtimeMs;
const index = createUsageStatisticsIndex({ sessionsDir, cacheFile });
const initStatus = await index.initialize();
assert(initStatus.ready, '统计索引首次构建失败');
assert.strictEqual(initStatus.indexedSessions, 2, '损坏会话不应进入统计索引');
assert(initStatus.failedFiles >= 1, '损坏会话应记录失败数量');
const result = index.query({
from: '2026-07-27T00:00:00.000Z',
to: '2026-08-03T00:00:00.000Z',
timeZone: 'UTC',
});
assert.strictEqual(result.schemaVersion, SCHEMA_VERSION);
assert.strictEqual(result.range.semantics, '[from,to)');
assert.strictEqual(result.overview.newSessions, 1, '右边界会话不应计入');
assert.strictEqual(result.overview.messages, 2);
assert.strictEqual(result.overview.directMessages, 1);
assert.strictEqual(result.overview.crossConversationMessages, 1);
assert.strictEqual(result.overview.mcpCalls, 2);
assert.strictEqual(result.overview.mcpFailures, 1, 'done=true 的失败调用仍应计入失败');
assert.strictEqual(result.overview.skillMentions, 1);
assert.strictEqual(result.mcpStatus.completed, 1);
assert.strictEqual(result.mcpStatus.failed, 1);
assert.strictEqual(result.mcpTools[0].server, 'ccweb');
assert.strictEqual(result.mcpTools[0].calls, 1, '同调用数时应按工具名稳定排序');
assert.strictEqual(result.mcpTools[0].detailCallsReturned, 1);
assert.strictEqual(result.skills[0].name, 'openai-docs');
assert.strictEqual(result.mcpRecentCalls[0].timestamp, '2026-07-28T02:00:00.000Z');
assert(result.trend.find((row) => row.date === '2026-07-28')?.mcpCalls === 2);
const serialized = JSON.stringify(result);
assert(!serialized.includes('secret-body'));
assert(!serialized.includes('secret-result'));
assert(!serialized.includes('arguments'));
assert.strictEqual(fs.readFileSync(sourceFile, 'utf8'), beforeContent, '统计查询不得修改会话文件');
assert.strictEqual(fs.statSync(sourceFile).mtimeMs, beforeMtime, '统计查询不得触碰会话文件 mtime');
assert(fs.existsSync(cacheFile), '统计派生缓存应被持久化');
const limited = index.query({
from: '2026-07-27T00:00:00.000Z',
to: '2026-08-03T00:00:00.000Z',
timeZone: 'UTC',
mcpToolLimit: 1,
skillLimit: 1,
mcpDetailLimit: 1,
});
assert.strictEqual(limited.coverage.distinctMcpTools, 2);
assert.strictEqual(limited.coverage.returnedMcpTools, 1);
assert.strictEqual(limited.mcpTools.length, 1);
assert.strictEqual(limited.mcpRecentCalls.length, 1);
assert.strictEqual(limited.mcpTools[0].detailCallsReturned, 1);
assert.strictEqual(limited.coverage.distinctSkills, 1);
assert.strictEqual(limited.coverage.returnedSkills, 1);
fixture.messages.push({ role: 'user', content: '增量消息', timestamp: '2026-07-30T04:00:00.000Z' });
writeSession(sessionsDir, fixture);
assert(index.scheduleUpsert(fixture.id), '索引初始化后应接受增量更新');
await index.flush();
assert.strictEqual(index.query({
from: '2026-07-27T00:00:00.000Z',
to: '2026-08-03T00:00:00.000Z',
timeZone: 'UTC',
}).overview.messages, 3, '增量更新未生效');
fs.writeFileSync(sourceFile, '');
assert(index.scheduleUpsert(fixture.id), '空文件更新应进入安全增量路径');
await index.flush();
assert.strictEqual(index.query({
from: '2026-07-27T00:00:00.000Z',
to: '2026-08-03T00:00:00.000Z',
timeZone: 'UTC',
}).overview.messages, 0, '异常空文件不应继续保留旧统计文档');
writeSession(sessionsDir, fixture);
assert(index.scheduleUpsert(fixture.id), '恢复后的会话文件应可重新进入索引');
await index.flush();
fs.unlinkSync(sourceFile);
assert(index.remove(fixture.id), '删除会话应移除统计文档');
await index.flush();
assert.strictEqual(index.query({
from: '2026-07-27T00:00:00.000Z',
to: '2026-08-03T00:00:00.000Z',
timeZone: 'UTC',
}).overview.messages, 0, '删除后的会话仍出现在统计中');
const reused = createUsageStatisticsIndex({ sessionsDir, cacheFile });
const reusedStatus = await reused.initialize();
assert(reusedStatus.ready, '派生缓存无法重新加载');
assert.strictEqual(reusedStatus.indexedSessions, 1);
assert.throws(() => reused.query({
from: '2026-08-03T00:00:00.000Z',
to: '2026-07-27T00:00:00.000Z',
timeZone: 'UTC',
}), (error) => error instanceof UsageStatisticsError && error.code === 'invalid_range');
assert.throws(() => reused.query({
from: '2026-07-27T00:00:00.000Z',
to: '2026-08-03T00:00:00.000Z',
timeZone: 'Invalid/Zone',
}), (error) => error instanceof UsageStatisticsError && error.code === 'invalid_time_zone');
console.log('Usage statistics unit checks passed.');
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
}
main().catch((error) => {
console.error(error.stack || error.message);
process.exit(1);
});