fix: 消除会话列表解析阻塞并按需加载工具详情
This commit is contained in:
150
scripts/ccweb-display-image-unit.js
Normal file
150
scripts/ccweb-display-image-unit.js
Normal file
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', 'public', 'app.js'), 'utf8');
|
||||
|
||||
function extractFunction(name) {
|
||||
const declaration = new RegExp(`^ function ${name}\\(`, 'm');
|
||||
const start = source.search(declaration);
|
||||
assert(start >= 0, `缺少前端函数 ${name}`);
|
||||
const remaining = source.slice(start + 1);
|
||||
const next = remaining.search(/^ (?:async )?function \w+\(/m);
|
||||
assert(next >= 0, `无法确定前端函数 ${name} 的边界`);
|
||||
return source.slice(start, start + 1 + next);
|
||||
}
|
||||
|
||||
class Element {
|
||||
constructor(tagName) {
|
||||
this.tagName = tagName;
|
||||
this.children = [];
|
||||
this.textContent = '';
|
||||
this.attributes = {};
|
||||
this.listeners = {};
|
||||
}
|
||||
appendChild(child) { this.children.push(child); return child; }
|
||||
setAttribute(name, value) { this.attributes[name] = value; }
|
||||
addEventListener(name, listener) { this.listeners[name] = listener; }
|
||||
insertAdjacentHTML(position, html) { this.html = html; }
|
||||
}
|
||||
|
||||
function createHarness() {
|
||||
// 解析、分支选择和直链图片 DOM 均执行生产函数,只替换附件加载及浏览器环境。
|
||||
return new Function('Element', `
|
||||
const frames = [], rendered = [], hydrated = [];
|
||||
const document = { createElement: (tagName) => new Element(tagName) };
|
||||
function requestAnimationFrame(callback) { frames.push(callback); }
|
||||
function renderAttachmentPreviews(attachments) {
|
||||
rendered.push(attachments);
|
||||
return '<div class="attachment-previews"></div>';
|
||||
}
|
||||
function hydrateAttachmentPreviews(wrapper, attachments) { hydrated.push({ wrapper, attachments }); }
|
||||
${[
|
||||
'parseMaybeJsonObject', 'parseCcwebDisplayImageResult',
|
||||
'createCcwebDisplayImageToolContent', 'createAssistantImageElement',
|
||||
].map(extractFunction).join('\n')}
|
||||
return {
|
||||
parse: parseCcwebDisplayImageResult,
|
||||
render: createCcwebDisplayImageToolContent,
|
||||
rendered,
|
||||
hydrated,
|
||||
flushFrames() { while (frames.length) frames.shift()(); },
|
||||
};
|
||||
`)(Element);
|
||||
}
|
||||
|
||||
function run() {
|
||||
const harness = createHarness();
|
||||
const attachment = { id: 'generated-image', filename: 'generated.png', storageState: 'ready' };
|
||||
const success = {
|
||||
ok: true,
|
||||
image: { url: 'https://example.test/generated.png', title: '生成的图片' },
|
||||
attachment,
|
||||
};
|
||||
const textContent = [{ type: 'text', text: JSON.stringify(success) }];
|
||||
// 与故障会话存档相同的 CallToolResult 结构,包含重复的业务 JSON 和 _meta。
|
||||
const historicalEnvelope = { content: textContent, structuredContent: success, _meta: null };
|
||||
|
||||
const successCases = [
|
||||
['直接业务对象', success],
|
||||
['直接业务 JSON', JSON.stringify(success)],
|
||||
['历史 MCP 对象', historicalEnvelope],
|
||||
['历史 MCP JSON', JSON.stringify(historicalEnvelope)],
|
||||
['仅 structuredContent', { structuredContent: success }],
|
||||
['structuredContent 为 JSON', { structuredContent: JSON.stringify(success) }],
|
||||
['仅 content 文本', { content: textContent }],
|
||||
['content 文本包装的 JSON', JSON.stringify({ content: textContent })],
|
||||
['缺失结构化结果', { structuredContent: null, content: textContent }],
|
||||
['结构化结果无效时回退文本', { structuredContent: '无效结果', content: textContent }],
|
||||
['跳过非业务文本及其他块', { content: [
|
||||
{ type: 'image', data: 'irrelevant' }, { type: 'text', text: '处理中' },
|
||||
{ type: 'text', text: '{invalid json' }, ...textContent,
|
||||
] }],
|
||||
];
|
||||
for (const [label, result] of successCases) {
|
||||
assert.deepEqual(harness.parse(result), success, `${label}应解析为业务结果`);
|
||||
const renderedCount = harness.rendered.length;
|
||||
const node = harness.render({ result, done: true });
|
||||
assert.equal(node.textContent, '', `${label}不能误报图片失败`);
|
||||
assert.equal(harness.rendered.length, renderedCount + 1, `${label}应进入附件预览分支`);
|
||||
assert.deepEqual(harness.rendered.at(-1), [attachment]);
|
||||
harness.flushFrames();
|
||||
assert.equal(harness.hydrated.at(-1).wrapper, node);
|
||||
assert.deepEqual(harness.hydrated.at(-1).attachments, [attachment]);
|
||||
}
|
||||
|
||||
const failure = { ok: false, message: '图片文件不存在' };
|
||||
for (const result of [failure, JSON.stringify(failure),
|
||||
{ structuredContent: failure, content: textContent },
|
||||
{ content: [{ type: 'text', text: JSON.stringify(failure) }] }]) {
|
||||
assert.deepEqual(harness.parse(result), failure, '应保留明确的业务失败');
|
||||
assert.equal(harness.render({ result, done: true }).textContent, failure.message);
|
||||
}
|
||||
|
||||
const transportErrors = [
|
||||
[{ isError: true, content: [{ type: 'text', text: 'MCP 图片工具读取失败' }] }, 'MCP 图片工具读取失败'],
|
||||
[{ isError: true, structuredContent: failure }, failure.message],
|
||||
[{ isError: true, message: '工具调用失败', structuredContent: success }, '工具调用失败'],
|
||||
[{ isError: true, structuredContent: success, content: textContent }, JSON.stringify(success)],
|
||||
[{ isError: true }, '图片渲染失败'],
|
||||
];
|
||||
for (const [result, message] of transportErrors) {
|
||||
assert.equal(harness.parse(result).ok, false, 'MCP 错误不能被成功内容覆盖');
|
||||
assert.equal(harness.render({ result: JSON.stringify(result), done: true }).textContent, message);
|
||||
}
|
||||
|
||||
const malformed = [null, undefined, '', '{invalid', 'null', 'false', '42',
|
||||
'"text"', [], JSON.stringify([success]), {}, { ok: 'true', attachment },
|
||||
{ content: 'invalid' }, { content: [{ type: 'text', text: 'invalid' }] },
|
||||
{ structuredContent: { structuredContent: success } }];
|
||||
const cyclic = {};
|
||||
cyclic.structuredContent = cyclic;
|
||||
malformed.push(cyclic);
|
||||
for (const result of malformed) {
|
||||
assert.equal(harness.parse(result), null, '畸形或循环结果应安全返回空值');
|
||||
assert.equal(harness.render({ result, done: true }).textContent, '图片渲染失败');
|
||||
}
|
||||
assert.equal(harness.render({ done: false }).textContent, '正在准备图片…', '未完成调用应显示等待状态');
|
||||
|
||||
const inlineResult = { ok: true, image: { url: 'https://example.test/inline.png', title: '直链图片' } };
|
||||
for (const result of [inlineResult, { structuredContent: inlineResult },
|
||||
{ content: [{ type: 'text', text: JSON.stringify(inlineResult) }] }]) {
|
||||
const node = harness.render({ result: JSON.stringify(result), done: true });
|
||||
const figure = node.children[0];
|
||||
assert.equal(figure.tagName, 'figure', '直链结果应进入图片分支');
|
||||
assert.equal(figure.children[0].children[0].src, inlineResult.image.url);
|
||||
assert.equal(figure.children[0].children[0].alt, inlineResult.image.title);
|
||||
}
|
||||
for (const image of [{}, { url: 'javascript:alert(1)' }]) {
|
||||
assert.equal(harness.render({ result: { ok: true, image }, done: true }).textContent,
|
||||
'图片来源不可用', '无可用图片来源时应保留兜底提示');
|
||||
}
|
||||
console.log('ccweb_display_image 解析及渲染回归通过');
|
||||
}
|
||||
|
||||
if (require.main === module) run();
|
||||
|
||||
module.exports = { createHarness, run };
|
||||
@@ -684,7 +684,7 @@ function assertFrontendSidebarCollapseContract() {
|
||||
'Rich themes should provide isolated rail treatments on top of the shared semantic fallback'
|
||||
);
|
||||
assert(
|
||||
indexSource.includes('style.css?v=20260913-history-control-position')
|
||||
indexSource.includes('style.css?v=20261017-file-browser-image-gallery')
|
||||
&& indexSource.includes('app.js?v=__CC_WEB_FRONTEND_ASSET_VERSION__'),
|
||||
'Sidebar interaction assets should share the reviewed cache-busting version'
|
||||
);
|
||||
@@ -1231,7 +1231,7 @@ 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=20260913-history-control-position'), 'Plan progress CSS should use the current cache-busted URL');
|
||||
assert(indexSource.includes('style.css?v=20261017-file-browser-image-gallery'), 'Plan progress CSS should use the current cache-busted URL');
|
||||
assert(indexSource.includes('app.js?v=__CC_WEB_FRONTEND_ASSET_VERSION__'), 'Plan progress frontend logic should use the dynamic cache-busted URL');
|
||||
}
|
||||
|
||||
@@ -1347,7 +1347,7 @@ 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=20260913-history-control-position'), 'Theme bundle stylesheet should use the current cache-busted asset URL');
|
||||
assert(indexSource.includes('style.css?v=20261017-file-browser-image-gallery'), 'Theme bundle stylesheet should use the current cache-busted asset URL');
|
||||
assert(indexSource.includes('app.js?v=__CC_WEB_FRONTEND_ASSET_VERSION__'), 'Theme bundle app script should use the dynamic cache-busted asset URL');
|
||||
}
|
||||
|
||||
@@ -1601,7 +1601,7 @@ function assertFrontendWastelandThemeContract() {
|
||||
assert(contrast('#c9bda6', backgroundColor) >= 4.5, `Wasteland muted text should reach AA contrast on ${backgroundColor}`);
|
||||
});
|
||||
|
||||
assert(indexSource.includes('style.css?v=20260913-history-control-position'), 'Wasteland stylesheet should share the cache-busted theme bundle URL');
|
||||
assert(indexSource.includes('style.css?v=20261017-file-browser-image-gallery'), 'Wasteland stylesheet should share the cache-busted theme bundle URL');
|
||||
assert(indexSource.includes('app.js?v=__CC_WEB_FRONTEND_ASSET_VERSION__'), 'Wasteland registration should share the dynamic cache-busted theme bundle URL');
|
||||
}
|
||||
|
||||
@@ -1652,7 +1652,7 @@ function assertFrontendFileBrowserImageContract() {
|
||||
assert(browser.includes('/api/fs/image?sessionId='), 'Image previews should use the authenticated image endpoint');
|
||||
assert(browser.includes('/api/fs/read?sessionId='), 'Text previews should retain the text endpoint');
|
||||
assert(source.includes('async function fetchAuthImage(url, signal)') && source.includes('Authorization: `Bearer ${authToken}`'), 'Image fetch should use Bearer authentication');
|
||||
assert(browser.includes("mimeType === 'image/svg+xml'") && browser.includes('reader.readAsDataURL(blob);'), 'SVG previews should use isolated data URLs');
|
||||
assert(browser.includes("mimeType !== 'image/svg+xml'") && browser.includes('reader.readAsDataURL(blob);'), 'SVG previews should use isolated data URLs');
|
||||
assert(browser.includes('URL.createObjectURL(blob)') && browser.includes('URL.revokeObjectURL(state.previewImageUrl)'), 'Raster previews should manage object URL lifetime');
|
||||
assert(browser.includes('state.previewAbortController?.abort()') && browser.includes('requestId !== state.previewRequestId'), 'Switching previews should abort or reject stale image requests');
|
||||
assert(browser.includes('cancelFileBrowserPreview(state)') && browser.includes('clearFileBrowserImagePreview(fileBrowserState)'), 'Closing and switching views should release image resources');
|
||||
@@ -1680,7 +1680,7 @@ function assertFrontendFileBrowserImageContract() {
|
||||
overlay: { parentNode: { removeChild() {} } },
|
||||
escapeHandler() {},
|
||||
};
|
||||
const createLifecycle = new Function('state', 'URL', 'document', `let fileBrowserState = state;\n${lifecycle}\nreturn { cancelFileBrowserPreview, renderFileBrowserPreviewLoading, closeFileBrowser };`);
|
||||
const createLifecycle = new Function('state', 'URL', 'document', `let fileBrowserState = state;\nlet attachmentPreviewModal = null;\n${lifecycle}\nreturn { cancelFileBrowserPreview, renderFileBrowserPreviewLoading, closeFileBrowser };`);
|
||||
const actions = createLifecycle(state, { revokeObjectURL(url) { released.push(url); } }, { removeEventListener() {} });
|
||||
actions.cancelFileBrowserPreview(state);
|
||||
assert(aborted === 1 && state.previewRequestId === 4 && state.previewImageUrl === '', 'Switching previews should abort the old request and invalidate its response');
|
||||
@@ -1693,6 +1693,121 @@ function assertFrontendFileBrowserImageContract() {
|
||||
assert(released[2] === 'blob:third' && state.previewRequestId === 5, 'Closing the browser should release its image and invalidate pending responses');
|
||||
}
|
||||
|
||||
async function assertFrontendFileBrowserImageGallery() {
|
||||
const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||||
const styles = fs.readFileSync(PUBLIC_STYLE_PATH, 'utf8');
|
||||
const functions = [
|
||||
'isFileBrowserImagePath',
|
||||
'getFileBrowserImageGallery',
|
||||
'clearAttachmentPreviewImage',
|
||||
'closeAttachmentPreviewModal',
|
||||
'stepAttachmentPreviewImage',
|
||||
].map((name) => extractFunctionSource(source, name)).join('\n');
|
||||
const loadImageSource = `async ${extractFunctionSource(source, 'loadAttachmentPreviewImage')}`;
|
||||
const requests = [];
|
||||
const revoked = [];
|
||||
let focusRestored = 0;
|
||||
let removed = 0;
|
||||
const makeElement = () => ({
|
||||
textContent: '',
|
||||
hidden: false,
|
||||
disabled: false,
|
||||
src: '',
|
||||
alt: '',
|
||||
classList: { add() {}, remove() {} },
|
||||
removeAttribute(name) { if (name === 'src') this.src = ''; },
|
||||
});
|
||||
const api = new Function('getFileBrowserImagePreview', 'getAttachmentPreviewUrl', 'URL', 'document', `
|
||||
let attachmentPreviewModal = null;
|
||||
const FILE_BROWSER_IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg', 'avif']);
|
||||
const normalizeBrowserPath = (value) => value;
|
||||
const getPathLeaf = (value) => value.split('/').pop();
|
||||
const formatFileSize = (value) => String(value);
|
||||
${functions}
|
||||
${loadImageSource}
|
||||
return {
|
||||
getFileBrowserImageGallery, loadAttachmentPreviewImage, stepAttachmentPreviewImage,
|
||||
closeAttachmentPreviewModal,
|
||||
activate(state) { attachmentPreviewModal = state; },
|
||||
current() { return attachmentPreviewModal; },
|
||||
};
|
||||
`)(
|
||||
(sessionId, filePath, signal) => new Promise((resolve, reject) => requests.push({ sessionId, filePath, signal, resolve, reject })),
|
||||
async () => 'blob:message-cache',
|
||||
{ revokeObjectURL(url) { revoked.push(url); } },
|
||||
{ removeEventListener() {} }
|
||||
);
|
||||
const gallery = api.getFileBrowserImageGallery({
|
||||
sessionId: 'session-1',
|
||||
directoryEntries: [
|
||||
{ kind: 'directory', path: 'folder.png', imagePreviewableHint: true },
|
||||
{ kind: 'file', path: 'b.png', imagePreviewableHint: true },
|
||||
{ kind: 'file', path: 'text.png', imagePreviewableHint: false },
|
||||
{ kind: 'file', path: 'linked-image.data', name: '链接图片', imagePreviewableHint: true },
|
||||
{ kind: 'file', path: 'notes.txt', imagePreviewableHint: false },
|
||||
{ kind: 'file', path: 'a.SVG' },
|
||||
],
|
||||
});
|
||||
assert(gallery.map((item) => item.filePath).join(',') === 'b.png,linked-image.data,a.SVG', 'Gallery should preserve directory order, include image symlinks, and skip directories/text');
|
||||
assert(gallery[1].filename === '链接图片' && gallery[1].sessionId === 'session-1', 'Gallery items should retain display names and session authentication context');
|
||||
const state = {
|
||||
gallery, index: 0, requestId: 0, imageObjectUrl: '',
|
||||
imgEl: makeElement(), stageEl: makeElement(), placeholderEl: makeElement(),
|
||||
nameEl: makeElement(), descEl: makeElement(),
|
||||
previousBtn: makeElement(), nextBtn: makeElement(),
|
||||
overlay: { remove() { removed += 1; } },
|
||||
returnFocus: { isConnected: true, focus() { focusRestored += 1; } },
|
||||
};
|
||||
api.activate(state);
|
||||
const firstLoad = api.loadAttachmentPreviewImage(state, 0);
|
||||
assert(state.previousBtn.disabled && !state.nextBtn.disabled && state.descEl.textContent.startsWith('1 / 3'), 'First image should disable previous and show its position while loading');
|
||||
const lastLoad = api.loadAttachmentPreviewImage(state, 2);
|
||||
assert(requests[0].signal.aborted && state.nextBtn.disabled && !state.previousBtn.disabled, 'Switching should cancel the old request and disable next at the last image');
|
||||
requests[0].resolve({ url: 'blob:stale', objectUrl: true });
|
||||
await firstLoad;
|
||||
assert(revoked.includes('blob:stale') && state.imgEl.src === '', 'Late raster responses should be released without replacing the current image');
|
||||
requests[1].resolve({ url: 'data:image/svg+xml;base64,PHN2Zy8+', objectUrl: false, blob: { size: 12 }, mimeType: 'image/svg+xml' });
|
||||
await lastLoad;
|
||||
state.imgEl.onload();
|
||||
assert(!state.imgEl.hidden && state.placeholderEl.hidden && state.nameEl.textContent === 'a.SVG' && state.descEl.textContent.startsWith('3 / 3'), 'The selected image should render with its filename and page count');
|
||||
api.stepAttachmentPreviewImage(state, 1);
|
||||
assert(requests.length === 2, 'Next at the last image should not wrap or request another image');
|
||||
api.stepAttachmentPreviewImage(state, -1);
|
||||
requests[2].reject(new Error('图片不存在'));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert(state.placeholderEl.textContent === '图片不存在' && !state.placeholderEl.hidden && state.imgEl.hidden, 'Failed gallery images should retain an error state and allow further navigation');
|
||||
const rasterLoad = api.loadAttachmentPreviewImage(state, 0);
|
||||
requests[3].resolve({ url: 'blob:active', objectUrl: true });
|
||||
await rasterLoad;
|
||||
const pendingLoad = api.loadAttachmentPreviewImage(state, 1);
|
||||
assert(revoked.includes('blob:active'), 'Leaving a raster image should release its Blob URL');
|
||||
api.closeAttachmentPreviewModal();
|
||||
assert(requests[4].signal.aborted && api.current() === null && removed === 1 && focusRestored === 1, 'Closing the gallery should abort loading, remove the overlay and restore focus');
|
||||
requests[4].resolve({ url: 'blob:after-close', objectUrl: true });
|
||||
await pendingLoad;
|
||||
assert(revoked.includes('blob:after-close'), 'A response arriving after close should also be released');
|
||||
const messageState = { ...state, gallery: null, previousBtn: null, nextBtn: null, attachment: { filename: 'message.png', size: 5 }, requestId: 0 };
|
||||
api.activate(messageState);
|
||||
await api.loadAttachmentPreviewImage(messageState, 0);
|
||||
assert(messageState.imgEl.src === 'blob:message-cache', 'Message image previews should keep using the existing attachment cache');
|
||||
api.closeAttachmentPreviewModal();
|
||||
assert(!revoked.includes('blob:message-cache'), 'Closing a message image should preserve the shared attachment cache URL');
|
||||
const singleState = { ...state, gallery: [gallery[0]], requestId: 0 };
|
||||
api.activate(singleState);
|
||||
const singleLoad = api.loadAttachmentPreviewImage(singleState, 0);
|
||||
assert(singleState.previousBtn.disabled && singleState.nextBtn.disabled && singleState.descEl.textContent.startsWith('1 / 1'), 'Single-image folders should disable both navigation controls');
|
||||
api.closeAttachmentPreviewModal();
|
||||
requests[5].resolve({ url: 'data:image/svg+xml;base64,PHN2Zy8+', objectUrl: false });
|
||||
await singleLoad;
|
||||
|
||||
const modalSource = extractFunctionSource(source, 'openAttachmentPreviewModal');
|
||||
const browserSource = extractFunctionSource(source, 'showFileBrowser');
|
||||
assert(modalSource.includes("document.addEventListener('keydown', state.escapeHandler, true)") && modalSource.includes('e.stopImmediatePropagation()'), 'The topmost preview should consume Escape before the file browser handler');
|
||||
assert(modalSource.includes("e.key === 'ArrowLeft'") && modalSource.includes("e.key === 'ArrowRight'"), 'Gallery should support both arrow keys');
|
||||
assert(browserSource.includes('<button class="file-browser-preview-image"') && browserSource.includes("state.previewImageEl.addEventListener('click', openFileBrowserImageGallery)"), 'The file image preview should be a keyboard-accessible button');
|
||||
assert(styles.includes('.attachment-preview-overlay.is-fullscreen') && styles.includes('height: 100dvh;') && styles.includes('touch-action: manipulation;'), 'The gallery should fill the viewport and retain touch-friendly navigation');
|
||||
}
|
||||
|
||||
function assertMarkdownRenderingContract() {
|
||||
const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||||
const indexSource = fs.readFileSync(PUBLIC_INDEX_PATH, 'utf8');
|
||||
@@ -2851,7 +2966,7 @@ function assertGoalModeTitleContract() {
|
||||
assert(styleSource.includes('.msg.user.goal-message .msg-bubble'), 'Goal label styling should remain scoped to user Goal bubbles');
|
||||
assert(styleSource.includes('.goal-message-label'), 'Goal mode label should have a dedicated style');
|
||||
assert(styleSource.includes("html[data-theme='wasteland'] .msg.user.goal-message .goal-message-label"), 'Wasteland should provide a dedicated Goal label treatment');
|
||||
assert(indexSource.includes('style.css?v=20260913-history-control-position'), 'Goal label CSS should use the current cache-busted URL');
|
||||
assert(indexSource.includes('style.css?v=20261017-file-browser-image-gallery'), 'Goal label CSS should use the current cache-busted URL');
|
||||
assert(serverSource.includes('GOAL_DERIVED_TITLE_MAX_CHARS = 60'), 'Goal-derived titles should keep the 60-character limit');
|
||||
assert(serverSource.includes('function isDefaultConversationTitle(session)'), 'Goal title updates should use a default-title guard');
|
||||
assert(serverSource.includes('function applyDerivedGoalConversationTitle(session, objective)'), 'Goal title updates should stay in the /goal-specific server path');
|
||||
@@ -6703,7 +6818,7 @@ function assertAdvancedSessionSearchContract() {
|
||||
'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=20260913-history-control-position')
|
||||
assert(indexSource.includes('style.css?v=20261017-file-browser-image-gallery')
|
||||
&& indexSource.includes('app.js?v=__CC_WEB_FRONTEND_ASSET_VERSION__'),
|
||||
'Advanced search CSS and frontend script should share the reviewed cache-bust');
|
||||
|
||||
@@ -6820,7 +6935,7 @@ function assertUsageStatisticsContract() {
|
||||
'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=20260913-history-control-position')
|
||||
assert(indexSource.includes('style.css?v=20261017-file-browser-image-gallery')
|
||||
&& indexSource.includes('vendor/echarts.min.js?v=5.6.0')
|
||||
&& indexSource.includes('app.js?v=__CC_WEB_FRONTEND_ASSET_VERSION__'),
|
||||
'Usage dashboard CSS, local ECharts runtime, and frontend script should share the current asset contract');
|
||||
@@ -7278,7 +7393,7 @@ function makeLargeSessionPreviewFixture(sessionId, tailFields = {}) {
|
||||
],
|
||||
},
|
||||
],
|
||||
padding: 'x'.repeat(70 * 1024),
|
||||
padding: 'x'.repeat(256 * 1024),
|
||||
id: sessionId,
|
||||
...tailFields,
|
||||
});
|
||||
@@ -7292,7 +7407,7 @@ function makeHeadTailJoinRiskFixture(sessionId, previewBytes) {
|
||||
assert(paddingBytes > 0, 'Boundary fixture should fit the preview head');
|
||||
const head = `${beforePadding}${'x'.repeat(paddingBytes)}${beforeCwd}`;
|
||||
assert(Buffer.byteLength(head) === previewBytes, 'Boundary fixture should end the head preview after cwd colon');
|
||||
return `${head}${' '.repeat(70 * 1024)}"/tmp/joined-preview-cwd","messages":[]}`;
|
||||
return `${head}${' '.repeat(previewBytes + 70 * 1024)}"/tmp/joined-preview-cwd","messages":[]}`;
|
||||
}
|
||||
|
||||
async function runSessionPreviewMetadataRegression() {
|
||||
@@ -7307,7 +7422,7 @@ async function runSessionPreviewMetadataRegression() {
|
||||
mkdirp(homeDir);
|
||||
|
||||
const fullParseBytes = 64 * 1024;
|
||||
const previewBytes = 16 * 1024;
|
||||
const previewBytes = 128 * 1024;
|
||||
const sourceSessionId = 'preview-parent-source';
|
||||
const tailCwd = path.join(homeDir, '上下游协同平台');
|
||||
const tailSessionId = 'preview-tail-top-level';
|
||||
@@ -7356,6 +7471,17 @@ async function runSessionPreviewMetadataRegression() {
|
||||
boundarySessionPath,
|
||||
makeHeadTailJoinRiskFixture(boundarySessionId, previewBytes)
|
||||
);
|
||||
// 尾部高密度嵌套伪字段曾触发重复JSON解析,阻塞整个服务的HTTP读取。
|
||||
const denseIds = Array.from({ length: 8 }, (_, index) => `preview-dense-${index}`);
|
||||
const denseMessages = Array.from({ length: 6500 }, () => ({
|
||||
id: 'tool-id', title: '工具标题', cwd: '/错误目录', agent: 'claude', input: '转义\\"及括号}]',
|
||||
}));
|
||||
for (const id of denseIds) fs.writeFileSync(path.join(sessionsDir, `${id}.json`), JSON.stringify({
|
||||
padding: 'x'.repeat(256 * 1024), messages: denseMessages, id, title: '真实顶层标题', agent: 'codexapp', cwd: tailCwd,
|
||||
}));
|
||||
fs.writeFileSync(path.join(sessionsDir, 'preview-http-target.json'), JSON.stringify({
|
||||
id: 'preview-http-target', title: 'HTTP读取目标', agent: 'codexapp', messages: [{ id: 'target-message', role: 'user', content: '真实正文' }],
|
||||
}));
|
||||
assert(fs.statSync(tailSessionPath).size > fullParseBytes, 'Tail fixture must use oversized preview parsing');
|
||||
assert(fs.statSync(childSessionPath).size > fullParseBytes, 'MCP child fixture must use oversized preview parsing');
|
||||
assert(fs.statSync(nullSessionPath).size > fullParseBytes, 'Null fixture must use oversized preview parsing');
|
||||
@@ -7376,7 +7502,7 @@ async function runSessionPreviewMetadataRegression() {
|
||||
CLAUDE_PATH: MOCK_CLAUDE,
|
||||
CODEX_PATH: MOCK_CODEX_APP_SERVER,
|
||||
}, async () => {
|
||||
const { ws, messages } = await connectWs(port, password);
|
||||
const { ws, messages, token } = await connectWs(port, password);
|
||||
const list = await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'session_list' &&
|
||||
[tailSessionId, childSessionId, nullSessionId, boundarySessionId].every((id) => (
|
||||
@@ -7402,6 +7528,25 @@ async function runSessionPreviewMetadataRegression() {
|
||||
|
||||
assert(boundarySession.cwd === '', `Preview parser should not join head cwd key with tail value, got ${JSON.stringify(boundarySession.cwd)}`);
|
||||
assert(boundarySession.projectName === '', `Boundary preview should not derive a projectName from a joined value, got ${JSON.stringify(boundarySession.projectName)}`);
|
||||
for (const id of denseIds) {
|
||||
assert(sessionById.get(id)?.title === '真实顶层标题', '高密度工具伪标题不能污染会话标题');
|
||||
assert(sessionById.get(id)?.cwd === tailCwd, '高密度工具伪目录不能污染会话目录');
|
||||
}
|
||||
const started = Date.now();
|
||||
const oldListCount = messages.filter(message => message.type === 'session_list').length;
|
||||
ws.send(JSON.stringify({ type: 'list_sessions' }));
|
||||
const [loaded] = await Promise.all([
|
||||
(async () => {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/sessions/preview-http-target?requestId=metadata-concurrent-http`, {
|
||||
headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
assert(response.ok, '刷新列表时HTTP读取必须成功');
|
||||
return response.json();
|
||||
})(),
|
||||
nextMessage(messages, ws, message => message.type === 'session_list' && messages.filter(item => item.type === 'session_list').length > oldListCount, 3000),
|
||||
]);
|
||||
assert(loaded.type === 'session_info' && loaded.messages[0]?.content === '真实正文', '刷新列表时HTTP切换仍须返回完整首屏');
|
||||
assert(Date.now() - started < 3000, '高密度列表解析不得让并发HTTP切换排队数秒');
|
||||
|
||||
const childList = await callInternalMcp(port, 'SessionPreviewMcp!234', {
|
||||
tool: 'ccweb_list_conversations',
|
||||
@@ -7583,6 +7728,7 @@ function assertCcwebDisplayImageContract() {
|
||||
const inline = prepareImagePayload({ source: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==' });
|
||||
assert(inline.ok && inline.image.mimeType === 'image/gif', 'Base64 GIF images should be accepted');
|
||||
assert(prepareImagePayload({ source: 'relative.png' }).code === 'invalid_local_path', 'Local images should require absolute paths');
|
||||
require('./ccweb-display-image-unit').run();
|
||||
}
|
||||
|
||||
function assertCcwebListConversationsScopeContract() {
|
||||
@@ -7795,6 +7941,7 @@ async function main() {
|
||||
}
|
||||
if (regressionTarget === 'file-browser-image') {
|
||||
assertFrontendFileBrowserImageContract();
|
||||
await assertFrontendFileBrowserImageGallery();
|
||||
await runFileBrowserImageRegression();
|
||||
console.log('File browser image regression checks passed.');
|
||||
return;
|
||||
@@ -7856,6 +8003,7 @@ async function main() {
|
||||
assertFrontendCcwebPromptContract();
|
||||
assertFrontendMarkdownLinkContract();
|
||||
assertFrontendFileBrowserImageContract();
|
||||
await assertFrontendFileBrowserImageGallery();
|
||||
assertMarkdownRenderingContract();
|
||||
assertMockCodexAppPromptUserNotTextTriggered();
|
||||
assertFrontendMcpReloadContract();
|
||||
|
||||
@@ -39,7 +39,11 @@ function createServerHarness(sessionsDir, options = {}) {
|
||||
'findSessionMessage',
|
||||
'saveSession', 'sessionHistoryCacheKey', 'cacheSessionHistory', 'loadNativeSessionHistory',
|
||||
'resolveSessionHistory',
|
||||
'listSessionFiles', 'refreshSessionSnapshotCache', 'prepareNativeHistoryImport',
|
||||
'listSessionFiles', 'loadSessionMetaFromFile', 'prepareNativeHistoryImport',
|
||||
'isJsonWhitespace', 'skipJsonWhitespace', 'readJsonStringEnd', 'readJsonContainerEnd', 'readJsonValueEnd',
|
||||
'parseJsonTopLevelPrefixFields', 'skipJsonWhitespaceBackward', 'readJsonStringStart', 'readJsonValueStart',
|
||||
'parseJsonTopLevelSuffixFields', 'readSessionPreviewTopLevelFields', 'previewStringField', 'previewBooleanField',
|
||||
'previewCreatedFromKind', 'normalizeCreatedFromSourceSessionId', 'readSessionPreview',
|
||||
'handleSlashCommand', 'handleDeleteSession', 'findCcwebPromptMessage', 'removeCcwebPromptMessage',
|
||||
'rollbackPersistedCodexAppSteerMessage', 'handleLoadHistoryPage',
|
||||
'buildSessionInfoPayload', 'handleResumeSession',
|
||||
@@ -47,6 +51,8 @@ function createServerHarness(sessionsDir, options = {}) {
|
||||
const api = new Function('fs', 'path', 'crypto', 'SESSIONS_DIR', 'sessionHistoryStore', 'rollouts', 'options', 'logs', `
|
||||
const SESSION_MAX_JSON_BYTES = options.snapshotMaxBytes || 512 * 1024;
|
||||
const SESSION_LOAD_MAX_BYTES = 32 * 1024 * 1024;
|
||||
const SESSION_META_FULL_PARSE_MAX_BYTES = 512 * 1024;
|
||||
const SESSION_META_PREVIEW_BYTES = 128 * 1024;
|
||||
const SESSION_PERSIST_MAX_MESSAGES = options.snapshotMaxMessages || 180;
|
||||
const SESSION_MESSAGE_CONTENT_MAX_CHARS = 96 * 1024;
|
||||
const SESSION_TOOL_INPUT_MAX_CHARS = 16 * 1024;
|
||||
@@ -107,6 +113,9 @@ function createServerHarness(sessionsDir, options = {}) {
|
||||
}
|
||||
function isCodexLikeSession(session) { return session.agent === 'codex' || session.agent === 'codexapp'; }
|
||||
function getSessionAgent(session) { return session.agent || 'claude'; }
|
||||
function sessionCreatedFromKind(session) { return session.createdFrom?.kind || null; }
|
||||
function normalizeGiteaSource(value) { return value || null; }
|
||||
function isCodexThreadGoalActive(value) { return value?.status === 'active'; }
|
||||
function getCodexRolloutFiles() { return options.nativeFiles || []; }
|
||||
function resolveClaudeSessionLocalMeta() { return null; }
|
||||
const parseCodexRolloutFile = rollouts.parseCodexRolloutFile;
|
||||
@@ -119,7 +128,7 @@ function createServerHarness(sessionsDir, options = {}) {
|
||||
? loadOptions.full : options.fullHistory !== false && !explicitWindow;
|
||||
return loadSession(id, { ...loadOptions, full });
|
||||
},
|
||||
saveSession, resolveSessionHistory, listSessionFiles, refreshSessionSnapshotCache, findSessionMessage,
|
||||
saveSession, resolveSessionHistory, listSessionFiles, loadSessionMetaFromFile, findSessionMessage,
|
||||
prepareNativeHistoryImport, handleSlashCommand, handleDeleteSession, removeCcwebPromptMessage,
|
||||
rollbackPersistedCodexAppSteerMessage, handleLoadHistoryPage, responses, logs, sessionHistoryStore,
|
||||
buildSessionInfoPayload, handleResumeSession,
|
||||
@@ -294,7 +303,7 @@ check('完整归档成功而快照失败仍返回成功并恢复最新元数据'
|
||||
assert.equal(reloaded.codexAppThreadId, loaded.codexAppThreadId);
|
||||
});
|
||||
|
||||
check('首次快照失败的孤立档案重开后仍可读取并参与列表且缓存恢复有界', dir => {
|
||||
check('首次快照失败的孤立档案重开后仍可读取并参与列表且不重建正文快照', dir => {
|
||||
const filesystem = new Proxy(fs, {
|
||||
get(target, key) {
|
||||
if (key === 'renameSync') return (from, to) => {
|
||||
@@ -310,11 +319,11 @@ check('首次快照失败的孤立档案重开后仍可读取并参与列表且
|
||||
const failedCacheApi = createServerHarness(dir, { fs: filesystem });
|
||||
assert.deepEqual(failedCacheApi.loadSession('orphan').messages, original.messages);
|
||||
assert(failedCacheApi.listSessionFiles().includes('orphan.json'));
|
||||
assert(failedCacheApi.refreshSessionSnapshotCache('orphan').session.messages.length <= 181);
|
||||
assert.equal(failedCacheApi.loadSessionMetaFromFile(path.join(dir, 'orphan.json')).id, 'orphan');
|
||||
const reopened = createServerHarness(dir);
|
||||
assert(reopened.listSessionFiles().includes('orphan.json'));
|
||||
assert(reopened.refreshSessionSnapshotCache('orphan').session.messages.length <= 181);
|
||||
assert(fs.existsSync(path.join(dir, 'orphan.json')));
|
||||
assert.equal(reopened.loadSessionMetaFromFile(path.join(dir, 'orphan.json')).id, 'orphan');
|
||||
assert.equal(fs.existsSync(path.join(dir, 'orphan.json')), false);
|
||||
});
|
||||
|
||||
check('运行态保留目录和同名旧文件不会干扰完整归档与会话列表', dir => {
|
||||
|
||||
@@ -25,7 +25,7 @@ class FirefoxDriver {
|
||||
}
|
||||
async start() {
|
||||
const value = await this.request('POST', '/session', { capabilities: { alwaysMatch: {
|
||||
browserName: 'firefox', 'moz:firefoxOptions': { binary: FIREFOX, args: ['-headless'] },
|
||||
browserName: 'firefox', pageLoadStrategy: 'eager', 'moz:firefoxOptions': { binary: FIREFOX, args: ['-headless'] },
|
||||
} } });
|
||||
this.id = value.sessionId;
|
||||
await this.request('POST', `/session/${this.id}/window/rect`, { width: 1280, height: 1000 });
|
||||
@@ -38,7 +38,7 @@ class FirefoxDriver {
|
||||
|
||||
async function startRelay(port, targetPort, evidence) {
|
||||
const sockets = new Set();
|
||||
const state = { dropWs: false, blockUpgrades: false, holdId: '', held: [] };
|
||||
const state = { dropWs: false, blockUpgrades: false, holdId: '', held: [], bytesPerSecond: 0 };
|
||||
const relay = http.createServer((request, response) => {
|
||||
if (CDN_FIXTURE_DIR && request.url.startsWith('/fixture-cdn/')) {
|
||||
const name = request.url.slice('/fixture-cdn/'.length);
|
||||
@@ -63,6 +63,23 @@ async function startRelay(port, targetPort, evidence) {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!hold && isQuery && state.bytesPerSecond) {
|
||||
const chunks = [];
|
||||
result.on('data', chunk => chunks.push(chunk));
|
||||
result.on('end', () => {
|
||||
const body = Buffer.concat(chunks);
|
||||
const bytesPerTick = Math.max(1, Math.floor(state.bytesPerSecond / 20));
|
||||
let offset = 0;
|
||||
response.writeHead(result.statusCode, result.headers);
|
||||
const timer = setInterval(() => {
|
||||
const end = Math.min(body.length, offset + bytesPerTick);
|
||||
response.write(body.subarray(offset, end)); offset = end;
|
||||
if (offset === body.length) { clearInterval(timer); response.end(); }
|
||||
}, 50);
|
||||
response.on('close', () => clearInterval(timer));
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!hold) { response.writeHead(result.statusCode, result.headers); result.pipe(response); return; }
|
||||
const chunks = [];
|
||||
result.on('data', chunk => chunks.push(chunk));
|
||||
@@ -161,6 +178,36 @@ async function runBrowser(options) {
|
||||
await click(A);
|
||||
await loaded(A, 12, true);
|
||||
evidence.stages.fakeOpen = { durationMs: Date.now() - started, count: 12, runtimeVisible: true };
|
||||
await driver.execute(`window.__switchErrors=[];window.addEventListener('error',e=>__switchErrors.push(e.message));
|
||||
window.addEventListener('unhandledrejection',e=>__switchErrors.push(String(e.reason)));`);
|
||||
relay.state.bytesPerSecond = 32768;
|
||||
started = Date.now();
|
||||
await click('http-large-tools');
|
||||
await loaded('http-large-tools', 12);
|
||||
const compact = await driver.execute(`return {
|
||||
tools:document.querySelectorAll('#messages .tool-call').length,
|
||||
contents:document.querySelectorAll('#messages .tool-call-content').length,
|
||||
bytes:performance.getEntriesByType('resource').filter(x=>x.name.includes('/api/sessions/http-large-tools?')).at(-1)?.decodedBodySize,
|
||||
};`);
|
||||
assert.equal(compact.tools, 100);
|
||||
assert.equal(compact.contents, 0, '折叠工具不能提前构建详情DOM');
|
||||
assert(Date.now() - started < 8000, '32KB/s 下工具大结果不应卡住首屏');
|
||||
evidence.stages.largeTools = { ...compact, durationMs: Date.now() - started, bytesPerSecond: 32768 };
|
||||
relay.state.bytesPerSecond = 0;
|
||||
await driver.execute(`const tool=document.querySelector('#messages details.tool-call');
|
||||
const group=tool.closest('.tool-group');if(group)group.open=true;tool.querySelector('summary').click();`);
|
||||
await waitFor(() => driver.execute(`return document.querySelector('#messages details.tool-call .tool-call-content')?.textContent.includes('结果终点');`), '完整工具详情未显示');
|
||||
evidence.stages.toolDetail = { complete: true };
|
||||
relay.state.holdId = 'http-large-tools';
|
||||
await driver.execute(`document.querySelectorAll('#messages details.tool-call')[1].querySelector('summary').click();`);
|
||||
await waitFor(() => relay.state.held.length, '工具详情延迟响应未到达');
|
||||
await click(B);
|
||||
await loaded(B, 12);
|
||||
relay.release();
|
||||
await sleep(100);
|
||||
await loaded(B, 12);
|
||||
evidence.stages.detailCancellation = { previousDetailCannotOverwriteNewView: true };
|
||||
assert.deepEqual(await driver.execute('return window.__switchErrors;'), [], '切换及展开不能产生浏览器异常');
|
||||
await click(B);
|
||||
await loaded(B, 12);
|
||||
for (const count of [36, 58]) {
|
||||
@@ -201,6 +248,7 @@ async function runBrowser(options) {
|
||||
await click(A);
|
||||
await waitFor(() => relay.state.held.length, '完成间隙的 HTTP 快照未到达');
|
||||
assert.equal(JSON.parse(relay.state.held[0].body).isRunning, true);
|
||||
const runtimeDetailRef = JSON.parse(relay.state.held[0].body).runtime.toolCalls.find(tool => tool.kind === 'command_execution').detailRef;
|
||||
fs.writeFileSync(fixture.completeFlag, 'complete');
|
||||
await waitFor(async () => {
|
||||
const result = (await query(A)).payload;
|
||||
@@ -215,6 +263,9 @@ async function runBrowser(options) {
|
||||
&& value.text.includes('Codex App mock handled:') ? value : null;
|
||||
}, '读取与订阅之间完成的消息未补齐', 12000);
|
||||
assert(evidence.ws.some(msg => msg.type === 'session_subscribed' && msg.historyChanged));
|
||||
const archivedTool = await query(A, '?toolDetail=' + encodeURIComponent(JSON.stringify(runtimeDetailRef)));
|
||||
assert.equal(archivedTool.response.status, 200, '运行工具归档后,原详情引用仍须可读取');
|
||||
assert(archivedTool.payload.tool.result.includes('运行结果终点'));
|
||||
evidence.stages.completionGap = { count: completed.ids.length, streaming: false, finalVisible: true };
|
||||
await click('http-corrupt-chunk');
|
||||
await waitFor(async () => {
|
||||
@@ -224,7 +275,7 @@ async function runBrowser(options) {
|
||||
evidence.stages.readFailure = { previousViewPreserved: true, loading: false };
|
||||
assert(!evidence.ws.some(msg => msg.direction === 'client' && ['load_session', 'load_history_page', 'resume_session'].includes(msg.type)));
|
||||
fs.writeFileSync(path.join(root, 'browser-evidence.json'), JSON.stringify(evidence, null, 2));
|
||||
console.log('Firefox 通过:假 OPEN/断 WS 首屏与运行输出、HTTP 分页顺序、迟到响应隔离、切换间隙完成对账;浏览器未通过 WS 查询历史');
|
||||
console.log('Firefox 通过:32KB/s 大工具会话首屏、折叠懒建、完整详情、假 OPEN/断 WS 运行恢复、分页、迟到隔离与完成交错');
|
||||
} catch (error) {
|
||||
evidence.failure = error.message;
|
||||
evidence.state = await snapshot().catch(() => null);
|
||||
|
||||
@@ -49,7 +49,7 @@ async function connect(port) {
|
||||
assert(auth.success);
|
||||
return { ws, messages, token: auth.token };
|
||||
}
|
||||
function createFixture(root) {
|
||||
function createFixture(root, options = {}) {
|
||||
const sessionsDir = path.join(root, 'sessions');
|
||||
const homeDir = path.join(root, 'home');
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
@@ -66,6 +66,18 @@ function createFixture(root) {
|
||||
return session;
|
||||
}
|
||||
seed(A); seed(B); seed('http-corrupt-chunk'); seed('http-corrupt-manifest');
|
||||
if (options.largeTools !== false) {
|
||||
const large = seed('http-large-tools', 17);
|
||||
large.messages[15].toolCalls = Array.from({ length: 100 }, (_, index) => ({
|
||||
id: 'large-tool-' + index, name: 'exec_command', kind: 'command_execution', done: true,
|
||||
input: { command: '读取完整工具记录', payload: '输入'.repeat(10000) },
|
||||
result: '结果起点' + '完整输出'.repeat(10000) + '结果终点', meta: { exitCode: 0 },
|
||||
}));
|
||||
large.messages[3].toolCalls = [{ id: 'older-tool', name: 'read_file', done: true,
|
||||
input: { path: '/隔离夹具' }, result: '早期完整结果'.repeat(10000) }];
|
||||
delete large.messages[3].id;
|
||||
store.save(large);
|
||||
}
|
||||
const corruptPath = path.join(sessionsDir, '_history', 'http-corrupt-chunk');
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(corruptPath, 'manifest.json'), 'utf8'));
|
||||
fs.writeFileSync(path.join(corruptPath, 'chunks', manifest.entries.at(-1).file), '{破损块');
|
||||
@@ -97,6 +109,8 @@ function createFixture(root) {
|
||||
assert(mock.includes(timer));
|
||||
fs.writeFileSync(mockPath, mock.replace(timer, [
|
||||
"send({method:'item/agentMessage/delta',params:{threadId:thread.id,turnId,itemId:'agent-msg',delta:" + JSON.stringify(STREAM) + '}});',
|
||||
"send({method:'item/started',params:{threadId:thread.id,turnId,item:{id:'large-runtime-tool',type:'commandExecution',command:'运行工具夹具',status:'inProgress'}}});",
|
||||
"send({method:'item/completed',params:{threadId:thread.id,turnId,item:{id:'large-runtime-tool',type:'commandExecution',command:'运行工具夹具',aggregatedOutput:'运行结果起点'+'完整运行输出'.repeat(100000)+'运行结果终点',exitCode:0,status:'completed'}}});",
|
||||
"thread.timer = setInterval(() => { if (!require('fs').existsSync(" + JSON.stringify(completeFlag) + ')) return;',
|
||||
'clearInterval(thread.timer); completeTurn(thread, turnId, text); }, 50);',
|
||||
].join('\n')));
|
||||
@@ -150,6 +164,21 @@ async function runApi(port, fixture) {
|
||||
assert.equal(result.payload.sessionId, id);
|
||||
}
|
||||
assert.equal((await query('http-legacy-only')).payload.messages.length, 12);
|
||||
const large = (await query('http-large-tools')).payload;
|
||||
const summary = large.messages.find(message => message.toolCalls?.length === 100);
|
||||
assert(summary, '首屏必须保留全部100个工具概要');
|
||||
assert(Buffer.byteLength(JSON.stringify(large)) < 100000, '多兆工具输出不能绑在首屏');
|
||||
assert(!summary.toolCalls[0].result);
|
||||
const detailSuffix = ref => '?toolDetail=' + encodeURIComponent(JSON.stringify(ref));
|
||||
const detail = await query('http-large-tools', detailSuffix(summary.toolCalls[0].detailRef));
|
||||
assert.deepEqual(detail.payload.tool, fixture.store.load('http-large-tools').messages[15].toolCalls[0], '输入输出详情必须完整');
|
||||
assert.equal((await query(B, detailSuffix(summary.toolCalls[0].detailRef))).response.status, 400, '不能跨会话读取');
|
||||
assert.equal((await query('http-large-tools', detailSuffix({ ...summary.toolCalls[0].detailRef, signature: '过期' }))).response.status, 409);
|
||||
assert.equal((await query('http-large-tools', '?toolDetail=broken')).response.status, 400);
|
||||
assert.equal((await query('http-large-tools', detailSuffix(summary.toolCalls[0].detailRef), false)).response.status, 401);
|
||||
const older = (await query('http-large-tools', '/history?before=' + large.historyCursor)).payload;
|
||||
const olderTool = older.messages.find(message => message.toolCalls?.length)?.toolCalls[0];
|
||||
assert.equal((await query('http-large-tools', detailSuffix(olderTool.detailRef))).payload.tool.result, '早期完整结果'.repeat(10000));
|
||||
connection = await connect(port);
|
||||
connection.ws.send(JSON.stringify({ type: 'subscribe_session', sessionId: B,
|
||||
requestId: 'subscribe-before-commit', historyRevision: first.payload.historyRevision }));
|
||||
@@ -174,6 +203,18 @@ async function runApi(port, fixture) {
|
||||
assert.equal(running.historyPending, false);
|
||||
assert.equal(running.messages.length, 12);
|
||||
assert.equal(running.runtime.type, 'resume_generating');
|
||||
const runtime = await waitFor(async () => {
|
||||
const snapshot = (await query(A)).payload.runtime;
|
||||
return snapshot?.toolCalls?.some(tool => tool.kind === 'command_execution' && tool.done && tool.detailRef) ? snapshot : null;
|
||||
}, '运行工具概要缺失');
|
||||
assert(Buffer.byteLength(JSON.stringify(runtime)) < 10000, '运行快照不能捆绑完整工具输出');
|
||||
const runtimeTool = runtime.toolCalls.find(tool => tool.kind === 'command_execution' && tool.done && tool.detailRef);
|
||||
const runtimeDetail = (await query(A, detailSuffix(runtimeTool.detailRef))).payload.tool;
|
||||
assert(runtimeDetail.result.includes('运行结果终点'), '运行工具详情不能被预览限制裁剪');
|
||||
connection.ws.send(JSON.stringify({ type: 'subscribe_session', sessionId: A, requestId: 'compact-runtime' }));
|
||||
const resumed = await waitFor(() => connection.messages.find(msg => msg.type === 'resume_generating' && msg.requestId === 'compact-runtime'), '订阅运行快照缺失');
|
||||
assert(Buffer.byteLength(JSON.stringify(resumed)) < 10000, 'WS不能再次重放完整工具输出');
|
||||
assert(resumed.toolCalls.some(tool => tool.detailRef));
|
||||
assert(!connection.messages.some(msg => msg.type === 'session_history_chunk'));
|
||||
return { connection, token, query };
|
||||
} catch (error) {
|
||||
@@ -216,4 +257,6 @@ async function main() {
|
||||
log.end();
|
||||
}
|
||||
}
|
||||
main().catch(error => { console.error(error.stack || error); process.exitCode = 1; });
|
||||
if (require.main === module) main().catch(error => { console.error(error.stack || error); process.exitCode = 1; });
|
||||
|
||||
module.exports = { createFixture, waitFor, freePort, stopChild, connect, sleep, PASSWORD };
|
||||
|
||||
95
scripts/session-metadata-unit.js
Normal file
95
scripts/session-metadata-unit.js
Normal file
@@ -0,0 +1,95 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { performance } = require('node:perf_hooks');
|
||||
const { createSessionHistoryStore } = require('../lib/session-history-store');
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', 'server.js'), 'utf8');
|
||||
function extract(name) {
|
||||
const start = source.indexOf(`function ${name}(`);
|
||||
assert(start >= 0, `缺少生产函数 ${name}`);
|
||||
const end = source.indexOf('\nfunction ', start + 1);
|
||||
return source.slice(start, end < 0 ? undefined : end);
|
||||
}
|
||||
const keys = source.slice(source.indexOf('const SESSION_META_PREVIEW_TOP_LEVEL_KEYS ='), source.indexOf('\nfunction isJsonWhitespace('));
|
||||
const names = ['isJsonWhitespace', 'skipJsonWhitespace', 'readJsonStringEnd', 'readJsonContainerEnd', 'readJsonValueEnd',
|
||||
'parseJsonTopLevelPrefixFields', 'skipJsonWhitespaceBackward', 'readJsonStringStart', 'readJsonValueStart',
|
||||
'parseJsonTopLevelSuffixFields', 'readSessionPreviewTopLevelFields', 'previewStringField', 'previewBooleanField',
|
||||
'previewCreatedFromKind', 'normalizeCreatedFromSourceSessionId', 'readSessionPreview', 'loadSessionMetaFromFile'];
|
||||
let parses = 0;
|
||||
const json = { parse(value) { parses += 1; return JSON.parse(value); } };
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccweb-session-metadata-'));
|
||||
const observations = [];
|
||||
const trackedFs = new Proxy(fs, { get(target, name) {
|
||||
if (!['readFileSync', 'readSync', 'writeFileSync', 'renameSync'].includes(name)) return target[name];
|
||||
return (...args) => { observations.push({ name, file: String(args[0]) }); return target[name](...args); };
|
||||
} });
|
||||
const store = createSessionHistoryStore({ sessionsDir: root, fs: trackedFs });
|
||||
const api = new Function('fs', 'path', 'sessionHistoryStore', 'JSON', `
|
||||
const SESSION_META_FULL_PARSE_MAX_BYTES = 65536;
|
||||
const SESSION_META_PREVIEW_BYTES = 131072;
|
||||
const SESSION_LOAD_MAX_BYTES = 33554432;
|
||||
function normalizeSession(value) { if (value.messages.length) throw Error('列表不应规范化历史正文'); return value; }
|
||||
function normalizeAgent(value) { return value || 'claude'; }
|
||||
function getSessionAgent(value) { return normalizeAgent(value.agent); }
|
||||
function normalizeGiteaSource(value) { return value || null; }
|
||||
function sessionCreatedFromKind(value) { return value.createdFrom?.kind; }
|
||||
function sanitizeId(value) { return /^[a-zA-Z0-9_-]+$/.test(value) ? value : ''; }
|
||||
function isCodexThreadGoalActive(value) { return value?.status === 'active'; }
|
||||
function plog() {}
|
||||
${keys}
|
||||
${names.map(extract).join('\n')}
|
||||
return { suffix: parseJsonTopLevelSuffixFields, preview: readSessionPreviewTopLevelFields, meta: loadSessionMetaFromFile };
|
||||
`)(trackedFs, path, store, json);
|
||||
|
||||
const fields = { id: '真实会话', title: '带"引号"及\\反斜杠', cwd: '/home/项目', hasUnread: false,
|
||||
createdFrom: { kind: 'mcp', sourceSessionId: 'parent', nested: { title: '不能混入顶层' } },
|
||||
giteaSource: { title: '来源', number: 2 }, pinnedAt: null };
|
||||
const nested = { id: '嵌套ID', title: '工具标题', cwd: '/错误目录', input: '复杂字符串\\\\\"尾部,}]' };
|
||||
const doc = JSON.stringify({ messages: Array.from({ length: 8000 }, () => nested), ...fields });
|
||||
const tail = doc.slice(-131072);
|
||||
parses = 0;
|
||||
const start = performance.now();
|
||||
assert.deepEqual(api.suffix(tail), fields);
|
||||
assert(parses < 40, `尾部解析不应随嵌套伪字段增加而重复调用JSON.parse,实际${parses}`);
|
||||
console.log(`通过:128KB高密度嵌套伪字段只解析${parses}次,耗时${Math.round(performance.now() - start)}ms`);
|
||||
const fieldsStart = doc.lastIndexOf(',"id":"真实会话"') + 1;
|
||||
for (let cut = 0; cut < fieldsStart; cut += 1031) assert.deepEqual(api.suffix(doc.slice(cut)), fields);
|
||||
assert.deepEqual(api.suffix('{"title":"旧","title":"新","messages":[]}'), { title: '新' });
|
||||
assert.deepEqual(api.suffix(JSON.stringify({ messages: [nested] }).slice(6)), {});
|
||||
assert.equal(api.preview({ head: '{"id":"boundary","cwd":', tail: '"/不能拼接","messages":[]}' }).cwd, undefined);
|
||||
for (const value of ['尾部\\', '尾部\\\\', '引号"', '\\"\\\\"\n', '转义括号}]\\"']) {
|
||||
const text = JSON.stringify({ messages: [nested], title: value, createdFrom: { kind: value } });
|
||||
assert.deepEqual(api.suffix(text), { title: value, createdFrom: { kind: value } });
|
||||
}
|
||||
console.log('通过:截断边界、转义引号、重复键、嵌套键和对象元数据');
|
||||
|
||||
const id = 'archive-only';
|
||||
store.save({ id, title: '归档最新标题', agent: 'codexapp', cwd: '/home/真实项目', hasUnread: true,
|
||||
codexAppGoal: { status: 'active' }, messages: [{ id: 'message', role: 'assistant', content: '正文'.repeat(1000000) }] }, { complete: true });
|
||||
observations.length = 0;
|
||||
assert.equal(api.meta(path.join(root, `${id}.json`)).title, '归档最新标题');
|
||||
assert(!fs.existsSync(path.join(root, `${id}.json`)), '列举归档不应生成预览快照');
|
||||
fs.writeFileSync(path.join(root, `${id}.json`), '{这是陈旧且损坏的缓存');
|
||||
const meta = api.meta(path.join(root, `${id}.json`));
|
||||
assert.equal(meta.title, '归档最新标题');
|
||||
assert.equal(meta.hasUnread, true);
|
||||
assert.equal(meta.codexAppGoalActive, true);
|
||||
const manifestReads = observations.filter(item => item.name === 'readFileSync' && /manifest\.json$/.test(item.file)).length;
|
||||
api.meta(path.join(root, `${id}.json`));
|
||||
assert.equal(observations.filter(item => item.name === 'readFileSync' && /manifest\.json$/.test(item.file)).length, manifestReads,
|
||||
'修订未变时列表不能反复解析归档清单');
|
||||
assert(!observations.some(item => /chunks/.test(item.file)), '列表不得读取任何正文块');
|
||||
assert(!observations.some(item => /write|rename/.test(item.name)), '列表不得重建快照或写文件');
|
||||
assert(!observations.some(item => item.name === 'readFileSync' && item.file === path.join(root, `${id}.json`)), '有归档时不得解析快照');
|
||||
const latest = store.loadMetadata(id);
|
||||
latest.title = '修订后的标题';
|
||||
latest.hasUnread = false;
|
||||
store.save(latest);
|
||||
assert.equal(api.meta(path.join(root, `${id}.json`)).title, '修订后的标题');
|
||||
assert.equal(api.meta(path.join(root, `${id}.json`)).hasUnread, false);
|
||||
fs.writeFileSync(path.join(root, 'legacy.json'), JSON.stringify({ id: 'legacy', messages: [nested], padding: 'x'.repeat(140000), ...fields }));
|
||||
assert.equal(api.meta(path.join(root, 'legacy.json')).title, fields.title);
|
||||
console.log('通过:归档列表不读正文、不写快照,缺失/损坏快照与旧格式均可列举');
|
||||
Reference in New Issue
Block a user