chore: rebuild release package

This commit is contained in:
shiyue
2026-07-15 15:00:58 +08:00
parent 141a266f34
commit 614e222f14
11 changed files with 631 additions and 52 deletions

View File

@@ -666,6 +666,50 @@ function assertFrontendSubagentCardMetadataContract() {
mergeCollabAgentTaskState,
};
`)();
const collabMergeStart = source.indexOf(' function toolKind(tool)');
const collabMergeEnd = source.indexOf(' function collabStateLabel(statusText)', collabMergeStart);
const childUpdateStart = source.indexOf(' function applyCcwebMcpChildAgentUpdate(msg)');
const childUpdateEnd = source.indexOf(' function getDeleteConfirmMessage(agent)', childUpdateStart);
assert(collabMergeStart >= 0 && collabMergeEnd > collabMergeStart, 'Frontend should expose collab merge helpers');
assert(childUpdateStart >= 0 && childUpdateEnd > childUpdateStart, 'Frontend should define child-agent update handling before delete helpers');
const collabApi = new Function(`
let currentCwd = '';
let currentSessionId = 'session-a';
let closedCollabAgentIds = new Set();
let collabAgentStateCache = new Map();
let collabAgentIdsByToolUseId = new Map();
let closedCollabAgentIdsByToolUseId = new Map();
const activeToolCalls = new Map();
let cachedSnapshot = {
messages: [{
toolCalls: [{
id: 'tool-collab',
kind: 'collab_agent_tool_call',
input: {},
}],
}],
};
function updateCachedSession(sessionId, updater) {
updater(cachedSnapshot);
}
function updateToolCall() {}
function shortChildAgentId(id) {
const value = String(id || '');
return value.length > 12 ? value.slice(0, 8) : value;
}
${source.slice(collabMergeStart, collabMergeEnd)}
${source.slice(childUpdateStart, childUpdateEnd)}
return {
mergeCollabAgentTools,
applyCcwebMcpChildAgentUpdate,
rememberCollabAgentState,
getCachedState: (id) => collabAgentStateCache.get(id),
hasCachedState: (id) => collabAgentStateCache.has(id),
cacheSize: () => collabAgentStateCache.size,
agentIdsForTool: (id) => new Set(collabAgentIdsByToolUseId.get(id) || []),
closedIdsForTool: (id) => new Set(closedCollabAgentIdsByToolUseId.get(id) || []),
};
`)();
assert(source.includes('function pickCollabAgentTitle(state, id, index)'), 'Frontend should pick sub-agent titles through a dedicated helper');
assert(
@@ -747,6 +791,125 @@ function assertFrontendSubagentCardMetadataContract() {
const noPrompt = metadataApi.mergeCollabAgentTaskState({}, { label: '子代理' }, '', uuidV7, 0);
assert(/^ID\s/.test(noPrompt.label) && noPrompt.label !== uuidV7, 'Missing prompts should fall back to a short thread id');
const spawnedTool = {
id: 'tool-collab',
name: 'spawn_agent',
kind: 'collab_agent_tool_call',
input: {
tool: 'spawn_agent',
prompt: '请实现子代理卡片关闭状态保留。',
receiverThreadIds: ['child-thread-a'],
agentsStates: {
'child-thread-a': {
title: '关闭验证代理',
taskDescription: '请实现子代理卡片关闭状态保留。',
role: 'implementer',
status: 'running',
},
},
},
done: false,
};
const spawnedMerge = collabApi.mergeCollabAgentTools([spawnedTool]);
assert(spawnedMerge.input.receiverThreadIds.length === 1, 'Spawned child should merge into one visible agent');
assert(collabApi.getCachedState('child-thread-a')?.label === '关闭验证代理', 'Structured child state should be cached by thread id');
collabApi.rememberCollabAgentState(
'unrelated-child',
{ title: '无关历史代理', status: 'running' },
'请处理无关历史任务。',
1
);
collabApi.applyCcwebMcpChildAgentUpdate({
type: 'ccweb_mcp_child_agent_update',
sessionId: 'session-a',
toolUseId: 'tool-collab',
child: {
threadId: 'child-thread-a',
status: 'closed',
},
tool: {
id: 'tool-collab',
name: 'close_agent',
kind: 'collab_agent_tool_call',
input: { tool: 'close_agent', receiverThreadIds: [], agentsStates: {} },
result: JSON.stringify({ status: 'closed', receiverThreadIds: [], agentsStates: {} }),
done: true,
},
});
const closedMerge = collabApi.mergeCollabAgentTools([
{
id: 'tool-collab',
name: 'close_agent',
kind: 'collab_agent_tool_call',
input: { tool: 'close_agent', receiverThreadIds: [], agentsStates: {} },
result: JSON.stringify({ status: 'closed', receiverThreadIds: [], agentsStates: {} }),
done: true,
},
], {
restoreAgentIds: new Set(['child-thread-a', 'unrelated-child']),
closedAgentIds: collabApi.closedIdsForTool('tool-collab'),
});
assert(closedMerge.input.receiverThreadIds.length === 1, 'Empty close tool should keep exactly the closed child card');
assert(closedMerge.input.receiverThreadIds[0] === 'child-thread-a', 'Empty close tool should restore only the closed child thread id');
assert(!closedMerge.input.agentsStates['unrelated-child'], 'Empty close recovery must not mix unrelated cached children into the card');
assert(closedMerge.input.agentsStates['child-thread-a'].label === '关闭验证代理', 'Empty close recovery should preserve the cached child title');
assert(
closedMerge.input.agentsStates['child-thread-a'].taskDescription === '请实现子代理卡片关闭状态保留。',
'Empty close recovery should preserve the cached child introduction'
);
assert(closedMerge.input.agentsStates['child-thread-a'].status === 'closed', 'Empty close recovery should mark the child closed');
collabApi.rememberCollabAgentState(
'wait-child-a',
{ title: '等待验证代理', status: 'running' },
'请等待子代理返回。',
0
);
const emptyWaitMerge = collabApi.mergeCollabAgentTools([
{
id: 'tool-wait',
name: 'wait_agent',
kind: 'collab_agent_tool_call',
input: { tool: 'wait_agent', receiverThreadIds: [], agentsStates: {} },
result: JSON.stringify({ receiverThreadIds: [], agentsStates: {} }),
done: false,
},
], {
restoreAgentIds: new Set(['wait-child-a']),
});
assert(emptyWaitMerge.input.receiverThreadIds.length === 1, 'Empty wait tool should not reset a cached child card count to zero');
assert(emptyWaitMerge.input.agentsStates['wait-child-a'].label === '等待验证代理', 'Empty wait recovery should preserve the cached child title');
const cacheSizeBeforeOrdinaryTool = collabApi.cacheSize();
const ordinaryMerge = collabApi.mergeCollabAgentTools([
{
id: 'ordinary-tool',
name: 'shell',
kind: 'command_execution',
input: {
receiverThreadIds: ['ordinary-child'],
agentsStates: {
'ordinary-child': { title: '普通工具不应进入子代理缓存', status: 'closed' },
},
},
done: true,
},
], {
restoreAgentIds: new Set(['ordinary-child']),
closedAgentIds: new Set(['ordinary-child']),
});
assert(ordinaryMerge === null, 'Non-collab tools should not enter the collab merge path');
collabApi.applyCcwebMcpChildAgentUpdate({
type: 'ccweb_mcp_child_agent_update',
sessionId: 'session-a',
toolUseId: 'ordinary-tool',
child: { threadId: 'ordinary-child', status: 'closed' },
tool: { id: 'ordinary-tool', name: 'shell', kind: 'command_execution', input: {}, result: '', done: true },
});
assert(collabApi.cacheSize() === cacheSizeBeforeOrdinaryTool, 'Non-collab child updates should not write sub-agent state cache');
assert(!collabApi.hasCachedState('ordinary-child'), 'Non-collab tools should not cache child thread state');
}
function assertFrontendPrimaryCodexAppUiContract() {
@@ -914,6 +1077,35 @@ function assertSessionSwitchResilienceContract() {
);
}
function assertUnlimitedImageAttachmentsContract() {
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
const handlerStart = frontendSource.indexOf('async function handleSelectedImageFiles(fileList)');
const handlerEnd = frontendSource.indexOf('\n function getVisibleSessions()', handlerStart);
assert(
handlerStart >= 0 && handlerEnd > handlerStart,
'Frontend should define handleSelectedImageFiles before getVisibleSessions'
);
const handlerBlock = frontendSource.slice(handlerStart, handlerEnd);
assert(
!/pendingAttachments\.length\s*\+\s*files\.length\s*>\s*\d+/.test(handlerBlock),
'Frontend image attachment picker should not enforce a numeric per-message attachment cap'
);
assert(
!/单条消息最多附带\s*\d+\s*张图片/.test(handlerBlock),
'Frontend image attachment picker should not show a per-message image count limit'
);
assert(
!serverSource.includes('MAX_MESSAGE_ATTACHMENTS'),
'Server should not define or use MAX_MESSAGE_ATTACHMENTS'
);
assert(
!/msg\.attachments\s*\.slice\s*\(\s*0\s*,/.test(serverSource),
'Server should not truncate msg.attachments with slice'
);
}
function extractFunctionSource(source, name) {
const start = source.indexOf(`function ${name}(`);
assert(start >= 0, `Server should define ${name}`);
@@ -945,6 +1137,94 @@ function extractFunctionSource(source, name) {
throw new Error(`Could not parse function body for ${name}`);
}
function assertCodexAppChildToolFallbackContract() {
const source = fs.readFileSync(SERVER_PATH, 'utf8');
const helperStart = source.indexOf('function parseMaybeJsonObject(value)');
const helperEnd = source.indexOf('function sendCcwebMcpChildAgentUpdate(sessionId, child)', helperStart);
assert(helperStart >= 0 && helperEnd > helperStart, 'Server should keep ccweb MCP child helper block before sendCcwebMcpChildAgentUpdate');
const helperSource = source.slice(helperStart, helperEnd);
const api = new Function(`
const sessions = new Map();
let savedSession = null;
function truncateTextValue(value, maxLength, suffix = '...') {
const text = String(value || '');
return text.length > maxLength ? text.slice(0, maxLength - suffix.length) + suffix : text;
}
function loadSession(sessionId) {
return sessions.get(sessionId) || null;
}
function saveSession(session) {
savedSession = JSON.parse(JSON.stringify(session));
sessions.set(session.id, session);
}
function findViewingSessionWs() {
return null;
}
${helperSource}
return {
setSession: (session) => sessions.set(session.id, session),
getSession: (sessionId) => sessions.get(sessionId),
getSavedSession: () => savedSession,
updatePersistedCcwebMcpChildTool,
};
`)();
const session = {
id: 'fallback-session',
messages: [
{
role: 'assistant',
content: '',
toolCalls: [
{
id: 'wait-collab-tool',
name: 'wait_agent',
kind: 'collab_agent_tool_call',
input: { tool: 'wait_agent', receiverThreadIds: [], agentsStates: {} },
result: JSON.stringify({ receiverThreadIds: [], agentsStates: {} }),
done: false,
},
],
},
{
role: 'assistant',
content: '',
toolCalls: [
{
id: 'ordinary-command-tool',
name: 'shell',
kind: 'command_execution',
input: { command: 'echo should-not-be-selected' },
result: 'ordinary output',
done: true,
},
],
},
],
};
api.setSession(session);
const persistedTool = api.updatePersistedCcwebMcpChildTool(session.id, {
threadId: 'child-refresh-thread',
spawnToolId: 'missing-spawn-tool',
label: '刷新路径代理',
taskDescription: '验证刷新路径不会丢失子代理卡片。',
status: 'closed',
candidateResult: '子代理已关闭',
closedAt: '2026-07-15T00:00:00.000Z',
});
assert(persistedTool?.id === 'wait-collab-tool', 'Missing spawnToolId should fall back to the latest collab tool');
assert(session.messages[1].toolCalls[0].result === 'ordinary output', 'Fallback must not merge child state into ordinary command_execution tools');
const result = JSON.parse(persistedTool.result);
assert(result.receiverThreadIds.includes('child-refresh-thread'), 'Persisted fallback collab tool should include child thread id');
assert(result.agentsStates?.['child-refresh-thread']?.title === '刷新路径代理', 'Persisted fallback collab tool should store child title');
assert(
result.agentsStates?.['child-refresh-thread']?.taskDescription === '验证刷新路径不会丢失子代理卡片。',
'Persisted fallback collab tool should store child task description'
);
assert(result.agentsStates?.['child-refresh-thread']?.status === 'closed', 'Persisted fallback collab tool should store child closed status');
assert(api.getSavedSession()?.id === session.id, 'Persisted fallback merge should save the session');
}
function assertCodexAppUnroutedNotificationRoutingContract() {
const source = fs.readFileSync(SERVER_PATH, 'utf8');
assert(source.includes('const codexAppThreadSessionIndex = new Map();'), 'Server should keep an O(1) Codex App thread -> session index');
@@ -996,6 +1276,7 @@ async function main() {
return;
}
assertUnlimitedImageAttachmentsContract();
assertFrontendGenerationControlsContract();
assertFrontendComposerMcpContract();
assertFrontendSlashDraftPreservationContract();
@@ -1007,6 +1288,7 @@ async function main() {
assertFrontendPrimaryCodexAppUiContract();
assertSetTitleMcpContract();
assertSessionSwitchResilienceContract();
assertCodexAppChildToolFallbackContract();
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-regression-'));
const configDir = path.join(tempRoot, 'config');
@@ -2387,13 +2669,17 @@ async function main() {
ws.send(JSON.stringify({ type: 'abort', sessionId: codexAppSession.sessionId }));
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
const claudeAttachment = await uploadAttachment(port, token, {
filename: 'claude-test.png',
const tinyPng = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=',
'base64'
);
const claudeAttachments = await Promise.all(Array.from({ length: 5 }, (_, index) => uploadAttachment(port, token, {
filename: `claude-test-${index + 1}.png`,
mime: 'image/png',
data: Buffer.from('claude-image'),
});
ws.send(JSON.stringify({ type: 'message', text: 'describe attachment', attachments: [claudeAttachment], mode: 'yolo', agent: 'claude' }));
const claudeImageSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'claude' && msg.title === 'describe attachment');
data: tinyPng,
})));
ws.send(JSON.stringify({ type: 'message', text: 'describe attachments', attachments: claudeAttachments, mode: 'yolo', agent: 'claude' }));
const claudeImageSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'claude' && msg.title === 'describe attachments');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === claudeImageSession.sessionId);
const claudeSpawnLine = fs.readFileSync(path.join(logsDir, 'process.log'), 'utf8')
.trim()
@@ -2401,7 +2687,15 @@ async function main() {
.find((line) => line.includes(`"event":"process_spawn"`) && line.includes(claudeImageSession.sessionId.slice(0, 8)));
assert(claudeSpawnLine && claudeSpawnLine.includes('--input-format stream-json'), 'Claude image message should switch stdin to stream-json');
const storedClaudeSession = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${claudeImageSession.sessionId}.json`), 'utf8'));
assert(Array.isArray(storedClaudeSession.messages?.[0]?.attachments) && storedClaudeSession.messages[0].attachments.length === 1, 'Claude message should persist attachment metadata');
const storedClaudeUserMessage = storedClaudeSession.messages?.find((message) => message.role === 'user' && message.content === 'describe attachments');
assert(
Array.isArray(storedClaudeUserMessage?.attachments) && storedClaudeUserMessage.attachments.length === claudeAttachments.length,
'Claude message should persist all attachment metadata'
);
const storedClaudeAttachmentNames = storedClaudeUserMessage.attachments.map((attachment) => attachment.filename);
for (const attachment of claudeAttachments) {
assert(storedClaudeAttachmentNames.includes(attachment.filename), `Claude message should preserve attachment ${attachment.filename}`);
}
assert(storedClaudeSession.claudeSessionId, 'Claude session id should be persisted after first run');
const claudeSessionIdBeforeMode = storedClaudeSession.claudeSessionId;