fix: 修复会话归组和文件预览编码

This commit is contained in:
shiyue
2026-08-07 10:54:08 +08:00
parent 3ddf21af85
commit 5ad2839b2c
21 changed files with 765 additions and 42 deletions

View File

@@ -5003,6 +5003,180 @@ async function runAdvancedSessionSearchRegression() {
});
}
function makeLargeSessionPreviewFixture(sessionId, tailFields = {}) {
return JSON.stringify({
messages: [
{
role: 'assistant',
content: 'Preview text with escaped pseudo keys: "cwd": "/tmp/string-cwd", "title": "String Title" and a backslash \\ marker.',
toolCalls: [
{
name: 'mcp__preview__cwd',
input: {
arguments: {
cwd: '/home/fineui-web-mcp',
projectName: 'fineui-web-mcp',
},
},
done: true,
},
],
},
],
padding: 'x'.repeat(70 * 1024),
id: sessionId,
...tailFields,
});
}
function makeHeadTailJoinRiskFixture(sessionId, previewBytes) {
const beforePadding = `{"id":${JSON.stringify(sessionId)},"title":"Boundary Join Risk","updated":"2026-08-06T00:02:00.000Z","agent":"codexapp","padding":"`;
const beforeCwd = '","cwd":';
const targetHeadBytes = previewBytes - Buffer.byteLength(beforeCwd);
const paddingBytes = targetHeadBytes - Buffer.byteLength(beforePadding);
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":[]}`;
}
async function runSessionPreviewMetadataRegression() {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-session-preview-metadata-'));
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 fullParseBytes = 64 * 1024;
const previewBytes = 16 * 1024;
const tailCwd = path.join(homeDir, '上下游协同平台');
const tailSessionId = 'preview-tail-top-level';
const nullSessionId = 'preview-null-fields';
const boundarySessionId = 'preview-boundary-join-risk';
const tailSessionPath = path.join(sessionsDir, `${tailSessionId}.json`);
const nullSessionPath = path.join(sessionsDir, `${nullSessionId}.json`);
const boundarySessionPath = path.join(sessionsDir, `${boundarySessionId}.json`);
fs.writeFileSync(tailSessionPath, makeLargeSessionPreviewFixture(tailSessionId, {
title: 'Escaped "Top" \\\\ Title',
updated: '2026-08-06T00:00:00.000Z',
created: '2026-08-05T23:00:00.000Z',
pinnedAt: null,
titleSource: 'llm',
createdFrom: { kind: 'ccweb_prompt_user' },
hasUnread: true,
agent: 'codexapp',
cwd: tailCwd,
}));
fs.writeFileSync(nullSessionPath, makeLargeSessionPreviewFixture(nullSessionId, {
title: null,
updated: '2026-08-06T00:01:00.000Z',
created: null,
pinnedAt: null,
titleSource: null,
createdFrom: { kind: null },
hasUnread: false,
agent: null,
cwd: null,
}));
fs.writeFileSync(
boundarySessionPath,
makeHeadTailJoinRiskFixture(boundarySessionId, previewBytes)
);
assert(fs.statSync(tailSessionPath).size > fullParseBytes, 'Tail fixture must use oversized preview parsing');
assert(fs.statSync(nullSessionPath).size > fullParseBytes, 'Null fixture must use oversized preview parsing');
assert(fs.statSync(boundarySessionPath).size > fullParseBytes, 'Boundary fixture must use oversized preview parsing');
const port = await getFreePort();
const password = 'SessionPreview!234';
await withServer({
PORT: String(port),
CC_WEB_PASSWORD: password,
CC_WEB_INTERNAL_MCP_TOKEN: 'SessionPreviewMcp!234',
CC_WEB_CONFIG_DIR: configDir,
CC_WEB_SESSIONS_DIR: sessionsDir,
CC_WEB_LOGS_DIR: logsDir,
CC_WEB_SESSION_META_FULL_PARSE_MAX_BYTES: String(fullParseBytes),
CC_WEB_SESSION_META_PREVIEW_BYTES: String(previewBytes),
HOME: homeDir,
CLAUDE_PATH: MOCK_CLAUDE,
CODEX_PATH: MOCK_CODEX_APP_SERVER,
}, async () => {
const { ws, messages } = await connectWs(port, password);
const list = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_list' &&
[tailSessionId, nullSessionId, boundarySessionId].every((id) => (
msg.sessions.some((session) => session.id === id)
))
), 5000);
const sessionById = new Map(list.sessions.map((session) => [session.id, session]));
const tailSession = sessionById.get(tailSessionId);
const nullSession = sessionById.get(nullSessionId);
const boundarySession = sessionById.get(boundarySessionId);
assert(tailSession.cwd === tailCwd, `Oversized preview should read tail top-level cwd, got ${JSON.stringify(tailSession.cwd)}`);
assert(tailSession.projectName === path.basename(tailCwd), `Project name should derive from top-level cwd, got ${JSON.stringify(tailSession.projectName)}`);
assert(tailSession.title === 'Escaped "Top" \\\\ Title', `Top-level escaped title should be preserved, got ${JSON.stringify(tailSession.title)}`);
assert(tailSession.createdFromKind === 'ccweb_prompt_user', 'Top-level createdFrom.kind should be read from preview metadata');
assert(tailSession.hasUnread === true, 'Top-level hasUnread should be read from preview metadata');
assert(nullSession.title === 'Untitled', `Null top-level title should fall back safely, got ${JSON.stringify(nullSession.title)}`);
assert(nullSession.cwd === '', `Null top-level cwd should not use nested cwd, got ${JSON.stringify(nullSession.cwd)}`);
assert(nullSession.projectName === '', `Null top-level cwd should keep projectName empty, got ${JSON.stringify(nullSession.projectName)}`);
assert(nullSession.createdFromKind === null, 'Null nested top-level kind should fall back to null');
assert(nullSession.hasUnread === false, 'False top-level hasUnread should be preserved');
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)}`);
ws.close();
});
}
async function runFilePreviewEncodingRegression() {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-file-preview-encoding-'));
const configDir = path.join(tempRoot, 'config');
const sessionsDir = path.join(tempRoot, 'sessions');
const logsDir = path.join(tempRoot, 'logs');
const workspace = path.join(tempRoot, '中文工作区');
mkdirp(configDir); mkdirp(sessionsDir); mkdirp(logsDir); mkdirp(workspace);
const text = '中文注释:文件预览编码兼容';
fs.writeFileSync(path.join(workspace, 'utf8.cs'), text, 'utf8');
fs.writeFileSync(path.join(workspace, 'utf8-bom.cs'), Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from(text)]));
fs.writeFileSync(path.join(workspace, 'utf16le.cs'), Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(text, 'utf16le')]));
fs.writeFileSync(path.join(workspace, 'utf16be.cs'), Buffer.concat([Buffer.from([0xfe, 0xff]), Buffer.from(text, 'utf16le').swap16()]));
fs.writeFileSync(path.join(workspace, 'gb18030.cs'), Buffer.from('d6d0cec4d7a2cacda3bacec4bcfed4a4c0c0b1e0c2ebbce6c8dd', 'hex'));
const previewLimit = 200 * 1024;
fs.writeFileSync(path.join(workspace, 'utf8-truncated.cs'), Buffer.concat([Buffer.alloc(previewLimit - 1, 0x61), Buffer.from('中尾')]));
fs.writeFileSync(path.join(workspace, 'gb18030-truncated.cs'), Buffer.concat([Buffer.alloc(previewLimit - 3, 0x61), Buffer.from('d6d0d600', 'hex')]));
fs.writeFileSync(path.join(workspace, 'binary.bin'), Buffer.from([0x00, 0xff, 0x01, 0x80, 0x00]));
const sessionId = 'file-preview-encoding-session';
fs.writeFileSync(path.join(sessionsDir, `${sessionId}.json`), JSON.stringify({ id: sessionId, cwd: workspace, title: '编码回归', messages: [] }));
const port = await getFreePort();
const password = 'FilePreviewEncoding!234';
await withServer({ PORT: String(port), CC_WEB_PASSWORD: password, CC_WEB_CONFIG_DIR: configDir, CC_WEB_SESSIONS_DIR: sessionsDir, CC_WEB_LOGS_DIR: logsDir, HOME: tempRoot, CLAUDE_PATH: MOCK_CLAUDE, CODEX_PATH: MOCK_CODEX_APP_SERVER }, async () => {
const { ws, messages, token } = await connectWs(port, password);
await nextMessage(messages, ws, (msg) => msg.type === 'session_list');
for (const filename of ['utf8.cs', 'utf8-bom.cs', 'utf16le.cs', 'utf16be.cs', 'gb18030.cs']) {
const payload = await fetchAuthedJson(port, token, `/api/fs/read?sessionId=${sessionId}&path=${encodeURIComponent(filename)}`);
assert(payload.content === text, `${filename} should decode Chinese text correctly, got ${JSON.stringify(payload.content)}`);
assert(typeof payload.encoding === 'string' && payload.encoding.length > 0, `${filename} should report detected encoding`);
}
for (const [filename, encoding] of [['utf8-truncated.cs', 'utf-8'], ['gb18030-truncated.cs', 'gb18030']]) {
const payload = await fetchAuthedJson(port, token, `/api/fs/read?sessionId=${sessionId}&path=${filename}`);
assert(payload.truncated === true, `${filename} should exercise the preview-size truncation branch`);
assert(payload.encoding === encoding, `${filename} should retain ${encoding} detection across a split multibyte character`);
assert(!payload.content.includes('\ufffd'), `${filename} should omit an incomplete trailing character instead of rendering replacement text`);
}
const binaryResponse = await fetch(`http://127.0.0.1:${port}/api/fs/read?sessionId=${sessionId}&path=binary.bin`, { headers: { Authorization: `Bearer ${token}` } });
assert(binaryResponse.status === 415, `Binary preview should remain 415, got ${binaryResponse.status}`);
ws.close();
});
}
function assertWindowsStartupContract() {
const source = fs.readFileSync(WINDOWS_START_PATH, 'utf8').replace(/\r\n/g, '\n');
@@ -5120,6 +5294,16 @@ async function main() {
console.log('Advanced session search regression checks passed.');
return;
}
if (regressionTarget === 'session-preview-metadata') {
await runSessionPreviewMetadataRegression();
console.log('Session preview metadata regression checks passed.');
return;
}
if (regressionTarget === 'file-preview-encoding') {
await runFilePreviewEncodingRegression();
console.log('File preview encoding regression checks passed.');
return;
}
if (regressionTarget === 'usage-statistics') {
assertUsageStatisticsUnitChecks();
assertUsageStatisticsContract();
@@ -5174,6 +5358,7 @@ async function main() {
assertMultiAgentV2CompatibilityContract();
assertWindowsStartupContract();
assertCcwebDisplayImageContract();
await runSessionPreviewMetadataRegression();
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-regression-'));
const configDir = path.join(tempRoot, 'config');