chore: rebuild release package
This commit is contained in:
@@ -564,7 +564,7 @@ function assertFrontendComposerMcpContract() {
|
||||
assert(source.includes("className = 'msg-mentions'"), 'Frontend should render a dedicated mention strip container');
|
||||
}
|
||||
|
||||
function assertFrontendSlashDraftPreservationContract() {
|
||||
function assertComposerSlashRoutingContract() {
|
||||
const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||||
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
|
||||
assert(source.includes('const pendingSlashDraftsByRequestId = new Map();'), 'Frontend should keep request-scoped slash drafts');
|
||||
@@ -576,19 +576,67 @@ function assertFrontendSlashDraftPreservationContract() {
|
||||
assert(source.includes("send({ type: 'message', text, sessionId: currentSessionId, mode: currentMode, agent: currentAgent, requestId });"), 'Frontend slash sends should carry requestId');
|
||||
assert((source.match(/applyPendingSlashDraftResponse\(msg\);/g) || []).length >= 2, 'Frontend should handle slash draft restoration on system_message and error');
|
||||
|
||||
const frontendCommandsMatch = source.match(/const\s+SLASH_COMMANDS\s*=\s*\[([\s\S]*?)\n\s*\];/);
|
||||
const serverCommandsMatch = serverSource.match(/const\s+COMPOSER_COMMANDS\s*=\s*\[([\s\S]*?)\n\s*\];/);
|
||||
assert(frontendCommandsMatch && serverCommandsMatch, 'Frontend and server should both declare composer slash command lists');
|
||||
const frontendCommandNames = Array.from(frontendCommandsMatch[1].matchAll(/\bcmd:\s*['"]([^'"]+)['"]/g), (match) => match[1]);
|
||||
const serverCommandNames = Array.from(serverCommandsMatch[1].matchAll(/\bname:\s*['"]([^'"]+)['"]/g), (match) => match[1]);
|
||||
assert(frontendCommandNames.length > 0, 'Frontend slash command list should not be empty');
|
||||
assert(
|
||||
JSON.stringify([...frontendCommandNames].sort()) === JSON.stringify([...serverCommandNames].sort()),
|
||||
'Frontend and server should classify the same complete set of known slash commands'
|
||||
);
|
||||
|
||||
const knownSlashSource = extractFunctionSource(source, 'isKnownSlashCommandText');
|
||||
const knownSlashApi = new Function('SLASH_COMMANDS', `
|
||||
${knownSlashSource}
|
||||
return isKnownSlashCommandText;
|
||||
`)(frontendCommandNames.map((cmd) => ({ cmd })));
|
||||
frontendCommandNames.forEach((command) => {
|
||||
assert(knownSlashApi(`${command.toUpperCase()} argument`), `Known slash command ${command} should match case-insensitively`);
|
||||
});
|
||||
assert(!knownSlashApi('/report/mcps?search') && !knownSlashApi('/help/topic'), 'Unknown slash paths should remain ordinary messages');
|
||||
|
||||
const tokenSource = extractFunctionSource(source, 'findActiveComposerToken');
|
||||
const tokenApi = new Function(`
|
||||
let msgInput = null;
|
||||
${tokenSource}
|
||||
return (value, cursor = value.length) => {
|
||||
msgInput = { value, selectionStart: cursor };
|
||||
return findActiveComposerToken();
|
||||
};
|
||||
`)();
|
||||
assert(tokenApi('/rep')?.trigger === '/', 'Slash suggestions should trigger at the composer start');
|
||||
assert(tokenApi('请查看 /rep')?.query === 'rep', 'Slash suggestions should trigger after whitespace');
|
||||
assert(tokenApi('请查看\n/rep')?.query === 'rep', 'Slash suggestions should trigger at a new line');
|
||||
assert(tokenApi('请查看\r\n/rep')?.query === 'rep', 'Slash suggestions should trigger after a CRLF line boundary');
|
||||
assert(tokenApi('请查看 /rep', 3) === null, 'Slash suggestions should follow the cursor and ignore tokens after it');
|
||||
assert(tokenApi('src/foo') === null, 'Slash suggestions should not trigger inside a path token');
|
||||
assert(tokenApi('请查看 @file')?.trigger === '@' && tokenApi('请使用 $skill')?.trigger === '$', '@ and $ trigger behavior should remain unchanged');
|
||||
|
||||
const sendStart = source.indexOf('function sendMessage()');
|
||||
const slashStart = source.indexOf("if (text.startsWith('/'))", sendStart);
|
||||
const slashStart = source.indexOf('if (isKnownSlashCommandText(text))', sendStart);
|
||||
const rememberStart = source.indexOf('rememberPendingSlashDraft(requestId, text, currentSessionId, currentAgent);', slashStart);
|
||||
const modelPickerStart = source.indexOf("if (text === '/model' || text === '/model ')", slashStart);
|
||||
const modePickerStart = source.indexOf("if (text === '/mode' || text === '/mode ')", slashStart);
|
||||
assert(slashStart >= 0 && rememberStart > slashStart, 'Frontend should keep slash send branch explicit');
|
||||
assert(slashStart >= 0 && rememberStart > slashStart, 'Frontend should reserve the slash send branch for known commands');
|
||||
assert(modelPickerStart > slashStart && modelPickerStart < rememberStart, 'Frontend /model picker should stay local and clear normally');
|
||||
assert(modePickerStart > slashStart && modePickerStart < rememberStart, 'Frontend /mode picker should stay local and clear normally');
|
||||
|
||||
assert(serverSource.includes('handleSlashCommand(ws, msg.text.trim(), msg.sessionId, msg.agent, msg);'), 'Server should pass client request metadata into slash handler');
|
||||
assert(/const\s+handled\s*=\s*handleSlashCommand\(ws, msg\.text\.trim\(\), msg\.sessionId, msg\.agent, msg\);/.test(serverSource), 'Server should inspect slash text and retain the handled result');
|
||||
assert(/if\s*\(!handled\)\s*handleMessage\(ws, msg/.test(serverSource), 'Server should pass unknown slash text into the ordinary message pipeline');
|
||||
assert(serverSource.includes('function handleSlashCommand(ws, text, sessionId, fallbackAgent, source = {})'), 'Server slash handler should accept request metadata');
|
||||
assert(serverSource.includes('wsSend(ws, attachClientRequestId(base, source));'), 'Server slash responses should echo requestId');
|
||||
assert(serverSource.includes('preserveComposerDraft: true'), 'Server should explicitly mark slash failures as draft-preserving');
|
||||
assert(/default:\s*\n\s*sendSlashSystemMessage\(`未知指令:[\s\S]*?\n\s*return false;/.test(serverSource), 'Unknown slash hints should not restore the composer and should report an unhandled command');
|
||||
const serverSlashHandler = extractFunctionSource(serverSource, 'handleSlashCommand');
|
||||
const serverHandledCommandNames = Array.from(serverSlashHandler.matchAll(/case\s+['"]([^'"]+)['"]\s*:/g), (match) => match[1]);
|
||||
assert(
|
||||
JSON.stringify([...serverCommandNames].sort()) === JSON.stringify([...serverHandledCommandNames].sort()),
|
||||
'Every server-declared slash command should keep an explicit handler case'
|
||||
);
|
||||
const unknownGuardStart = serverSlashHandler.indexOf('if (!COMPOSER_COMMANDS.some((item) => item.name === cmd))');
|
||||
const runningGuardStart = serverSlashHandler.indexOf('activeCodexAppTurns.has(sessionId)');
|
||||
assert(unknownGuardStart >= 0 && unknownGuardStart < runningGuardStart, 'Unknown slash text should be classified before active Codex App command guards');
|
||||
assert(serverSource.includes('wsSend(ws, attachClientRequestId({') && serverSource.includes('...(msg.preserveComposerDraft ? { preserveComposerDraft: true } : {})'), 'Server runtime errors should echo requestId and draft-preservation metadata');
|
||||
}
|
||||
|
||||
@@ -670,6 +718,7 @@ function assertFrontendSubagentCardMetadataContract() {
|
||||
const collabMergeEnd = source.indexOf(' function collabStateLabel(statusText)', collabMergeStart);
|
||||
const childUpdateStart = source.indexOf(' function applyCcwebMcpChildAgentUpdate(msg)');
|
||||
const childUpdateEnd = source.indexOf(' function getDeleteConfirmMessage(agent)', childUpdateStart);
|
||||
const buildMsgElementSource = extractFunctionSource(source, 'buildMsgElement');
|
||||
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(`
|
||||
@@ -693,16 +742,57 @@ function assertFrontendSubagentCardMetadataContract() {
|
||||
updater(cachedSnapshot);
|
||||
}
|
||||
function updateToolCall() {}
|
||||
function makeNode() {
|
||||
return {
|
||||
children: [],
|
||||
appendChild(child) {
|
||||
this.children.push(child);
|
||||
return child;
|
||||
},
|
||||
insertBefore(child) {
|
||||
this.children.unshift(child);
|
||||
return child;
|
||||
},
|
||||
querySelector() {
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
function createMsgElement() {
|
||||
const bubble = makeNode();
|
||||
return {
|
||||
bubble,
|
||||
querySelector(selector) {
|
||||
return selector === '.msg-bubble' ? bubble : null;
|
||||
},
|
||||
};
|
||||
}
|
||||
function createCcwebPromptElement() { return makeNode(); }
|
||||
function isEmptyReasoningTool() { return false; }
|
||||
function createToolCallElement(toolUseId, tool, done) {
|
||||
return { ...makeNode(), toolUseId, tool, done };
|
||||
}
|
||||
function isGroupableToolCall() { return false; }
|
||||
function _refreshGroupSummary() {}
|
||||
function markSessionMessageElement() {}
|
||||
const document = { createElement: () => makeNode() };
|
||||
function shortChildAgentId(id) {
|
||||
const value = String(id || '');
|
||||
return value.length > 12 ? value.slice(0, 8) : value;
|
||||
}
|
||||
${source.slice(collabMergeStart, collabMergeEnd)}
|
||||
${source.slice(childUpdateStart, childUpdateEnd)}
|
||||
${buildMsgElementSource}
|
||||
return {
|
||||
toolKind,
|
||||
collabStateTone,
|
||||
mergeCollabAgentTools,
|
||||
applyCcwebMcpChildAgentUpdate,
|
||||
rememberCollabAgentState,
|
||||
renderToolCallsForMessage: (toolCalls) => {
|
||||
const el = buildMsgElement({ role: 'assistant', content: '', toolCalls });
|
||||
return el.querySelector('.msg-bubble').children.map((node) => node.tool);
|
||||
},
|
||||
getCachedState: (id) => collabAgentStateCache.get(id),
|
||||
hasCachedState: (id) => collabAgentStateCache.has(id),
|
||||
cacheSize: () => collabAgentStateCache.size,
|
||||
@@ -910,6 +1000,354 @@ function assertFrontendSubagentCardMetadataContract() {
|
||||
});
|
||||
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');
|
||||
|
||||
const rawActivityThreadId = 'agent-thread-plan-reviewer-001';
|
||||
const rawActivityPrompt = '请审查 Phase 8 的前端 helper 行为。';
|
||||
const rawSubAgentActivity = (overrides = {}) => {
|
||||
const input = {
|
||||
kind: overrides.activityKind || 'started',
|
||||
agentThreadId: overrides.agentThreadId || rawActivityThreadId,
|
||||
agentPath: overrides.agentPath || '/root/plan_reviewer',
|
||||
...(overrides.prompt === undefined ? { prompt: rawActivityPrompt } : {}),
|
||||
...(overrides.prompt ? { prompt: overrides.prompt } : {}),
|
||||
...(overrides.input || {}),
|
||||
};
|
||||
return {
|
||||
id: overrides.id || `call_activity_${input.kind}`,
|
||||
name: 'subAgentActivity',
|
||||
kind: 'subAgentActivity',
|
||||
input,
|
||||
...(overrides.result !== undefined ? { result: overrides.result } : {}),
|
||||
done: !!overrides.done,
|
||||
};
|
||||
};
|
||||
const emptyWaitTool = {
|
||||
id: 'call_wait_empty',
|
||||
name: 'wait_agent',
|
||||
kind: 'collab_agent_tool_call',
|
||||
input: { tool: 'wait_agent', receiverThreadIds: [], agentsStates: {} },
|
||||
result: JSON.stringify({ receiverThreadIds: [], agentsStates: {} }),
|
||||
done: false,
|
||||
};
|
||||
const rawActivityMerge = collabApi.mergeCollabAgentTools([
|
||||
rawSubAgentActivity(),
|
||||
emptyWaitTool,
|
||||
]);
|
||||
assert(rawActivityMerge, 'Raw subAgentActivity with an empty wait should produce one merged collab card');
|
||||
assert(
|
||||
rawActivityMerge.input.receiverThreadIds.length === 1
|
||||
&& rawActivityMerge.input.receiverThreadIds[0] === rawActivityThreadId,
|
||||
'Raw subAgentActivity agentThreadId should be the sole receiverThreadId and must not fall back to the wait tool call id'
|
||||
);
|
||||
assert(
|
||||
Object.keys(rawActivityMerge.input.agentsStates).length === 1
|
||||
&& Object.prototype.hasOwnProperty.call(rawActivityMerge.input.agentsStates, rawActivityThreadId),
|
||||
'Raw subAgentActivity agentThreadId should be the sole agentsStates key'
|
||||
);
|
||||
const rawActivityState = rawActivityMerge.input.agentsStates[rawActivityThreadId];
|
||||
assert(rawActivityState.label === 'plan_reviewer', 'Raw subAgentActivity agentPath basename should become the readable card title');
|
||||
assert(rawActivityState.role === '', 'Raw subAgentActivity agentPath should not be duplicated into role when no explicit role is present');
|
||||
assert(!/^ID\s+call_/.test(rawActivityState.label || ''), 'Raw subAgentActivity title must not fall back to a tool call id');
|
||||
assert(rawActivityState.status === 'running', 'Raw subAgentActivity started events should keep the child running');
|
||||
assert(rawActivityMerge.input.status === 'running', 'Raw subAgentActivity aggregate status should match its running child state');
|
||||
assert(rawActivityState.taskDescription === rawActivityPrompt, 'Raw subAgentActivity prompt should be preserved as taskDescription');
|
||||
|
||||
const explicitRoleMerge = collabApi.mergeCollabAgentTools([
|
||||
rawSubAgentActivity({
|
||||
id: 'call_activity_explicit_role',
|
||||
agentThreadId: 'agent-thread-explicit-role',
|
||||
input: { role: 'reviewer' },
|
||||
}),
|
||||
]);
|
||||
assert(explicitRoleMerge.input.agentsStates['agent-thread-explicit-role'].label === 'plan_reviewer', 'Raw subAgentActivity agentPath basename should remain the readable title when role is explicit');
|
||||
assert(explicitRoleMerge.input.agentsStates['agent-thread-explicit-role'].role === 'reviewer', 'Raw subAgentActivity should preserve an explicit role');
|
||||
|
||||
const completedEmptyWaitTool = {
|
||||
...emptyWaitTool,
|
||||
id: 'call_wait_completed_empty',
|
||||
result: JSON.stringify({ status: 'completed', receiverThreadIds: [], agentsStates: {} }),
|
||||
done: true,
|
||||
};
|
||||
const runningStartedWithCompletedWait = collabApi.mergeCollabAgentTools([
|
||||
rawSubAgentActivity({
|
||||
id: 'call_activity_started_done_completed',
|
||||
activityKind: 'started',
|
||||
done: true,
|
||||
input: { status: 'completed' },
|
||||
result: JSON.stringify({ status: 'completed' }),
|
||||
}),
|
||||
completedEmptyWaitTool,
|
||||
]);
|
||||
assert(runningStartedWithCompletedWait.input.receiverThreadIds.length === 1, 'Started raw activity plus completed empty wait should keep one child');
|
||||
assert(runningStartedWithCompletedWait.input.agentsStates[rawActivityThreadId].status === 'running', 'Started raw activity should stay running even when the empty wait is completed');
|
||||
assert(runningStartedWithCompletedWait.input.status === 'running', 'Merged collab status should stay running when the only child is running');
|
||||
|
||||
collabApi.rememberCollabAgentState(
|
||||
'restore-running-child',
|
||||
{ title: '恢复运行代理', status: 'running' },
|
||||
'请保持运行态。',
|
||||
0
|
||||
);
|
||||
const restoredRunningWithCompletedWait = collabApi.mergeCollabAgentTools([
|
||||
completedEmptyWaitTool,
|
||||
], {
|
||||
restoreAgentIds: new Set(['restore-running-child']),
|
||||
});
|
||||
assert(restoredRunningWithCompletedWait.input.agentsStates['restore-running-child'].status === 'running', 'Empty completed wait should not overwrite restored running cache status');
|
||||
assert(restoredRunningWithCompletedWait.input.status === 'running', 'Empty completed wait should not make a restored running child look completed in the aggregate header');
|
||||
|
||||
['started', 'interacted'].forEach((activityKind) => {
|
||||
const threadId = `agent-thread-${activityKind}`;
|
||||
const merged = collabApi.mergeCollabAgentTools([
|
||||
rawSubAgentActivity({
|
||||
id: `call_activity_${activityKind}_only`,
|
||||
activityKind,
|
||||
agentThreadId: threadId,
|
||||
prompt: activityKind === 'started' ? `请处理 ${activityKind} 状态。` : '',
|
||||
}),
|
||||
]);
|
||||
assert(merged, `Raw subAgentActivity ${activityKind} should be recognized as a collab display tool`);
|
||||
assert(merged.input.receiverThreadIds.length === 1 && merged.input.receiverThreadIds[0] === threadId, `Raw ${activityKind} activity should use agentThreadId as receiverThreadId`);
|
||||
assert(merged.input.agentsStates[threadId].status === 'running', `Raw ${activityKind} activity should map to running status`);
|
||||
});
|
||||
|
||||
['completed', 'returned'].forEach((activityKind) => {
|
||||
const threadId = `agent-thread-${activityKind}`;
|
||||
const merged = collabApi.mergeCollabAgentTools([
|
||||
rawSubAgentActivity({
|
||||
id: `call_activity_${activityKind}_only`,
|
||||
activityKind,
|
||||
agentThreadId: threadId,
|
||||
prompt: `请处理 ${activityKind} 状态。`,
|
||||
done: true,
|
||||
}),
|
||||
]);
|
||||
assert(merged, `Raw subAgentActivity ${activityKind} should be recognized as a collab display tool`);
|
||||
assert(merged.input.receiverThreadIds.length === 1 && merged.input.receiverThreadIds[0] === threadId, `Raw ${activityKind} activity should use agentThreadId as receiverThreadId`);
|
||||
assert(collabApi.collabStateTone(merged.input.agentsStates[threadId].status) === 'done', `Raw ${activityKind} activity should map to a completed tone`);
|
||||
});
|
||||
|
||||
const completedWithoutPrompt = collabApi.mergeCollabAgentTools([
|
||||
rawSubAgentActivity(),
|
||||
rawSubAgentActivity({
|
||||
id: 'call_activity_completed_without_prompt',
|
||||
activityKind: 'completed',
|
||||
prompt: '',
|
||||
done: true,
|
||||
}),
|
||||
]);
|
||||
assert(
|
||||
completedWithoutPrompt.input.agentsStates[rawActivityThreadId].taskDescription === rawActivityPrompt,
|
||||
'Raw subAgentActivity updates for the same thread should retain an earlier prompt as taskDescription'
|
||||
);
|
||||
assert(
|
||||
collabApi.collabStateTone(completedWithoutPrompt.input.agentsStates[rawActivityThreadId].status) === 'done',
|
||||
'Raw subAgentActivity completed updates should finish the same child card'
|
||||
);
|
||||
|
||||
const noPromptThreadId = 'agent-thread-no-prompt';
|
||||
const noPromptActivityMerge = collabApi.mergeCollabAgentTools([
|
||||
rawSubAgentActivity({
|
||||
id: 'call_activity_no_prompt',
|
||||
agentThreadId: noPromptThreadId,
|
||||
prompt: '',
|
||||
}),
|
||||
]);
|
||||
assert(noPromptActivityMerge.input.agentsStates[noPromptThreadId].label === 'plan_reviewer', 'Raw subAgentActivity without prompt should still use agentPath as title');
|
||||
assert(!noPromptActivityMerge.input.agentsStates[noPromptThreadId].taskDescription, 'Raw subAgentActivity without prompt should not fabricate taskDescription');
|
||||
|
||||
const renderCandidates = collabApi.renderToolCallsForMessage([
|
||||
rawSubAgentActivity(),
|
||||
emptyWaitTool,
|
||||
]);
|
||||
assert(renderCandidates.length === 1, 'Raw subAgentActivity plus empty wait should render only the merged collab card');
|
||||
assert(renderCandidates[0].kind === 'collab_agent_tool_call', 'Raw subAgentActivity should render through the collab display tool');
|
||||
assert(
|
||||
!renderCandidates.some((tool) => tool?.id === 'call_activity_started' && tool?.kind === 'subAgentActivity'),
|
||||
'Raw subAgentActivity should be filtered out of ordinary rendered tool rows'
|
||||
);
|
||||
|
||||
const nameOnlyOrdinaryTool = {
|
||||
id: 'call_name_only_subagent_activity',
|
||||
name: 'subAgentActivity',
|
||||
kind: 'command_execution',
|
||||
input: { command: 'echo should-stay-ordinary' },
|
||||
done: true,
|
||||
};
|
||||
assert(collabApi.toolKind(nameOnlyOrdinaryTool) === 'command_execution', 'Name-only subAgentActivity command tools without agentThreadId should stay ordinary tools');
|
||||
assert(collabApi.mergeCollabAgentTools([nameOnlyOrdinaryTool]) === null, 'Name-only ordinary subAgentActivity tools should not produce a collab card');
|
||||
const nameOnlyRenderCandidates = collabApi.renderToolCallsForMessage([nameOnlyOrdinaryTool]);
|
||||
assert(nameOnlyRenderCandidates.length === 1 && nameOnlyRenderCandidates[0].kind === 'command_execution', 'Name-only ordinary subAgentActivity tools should not be filtered into a collab render candidate');
|
||||
}
|
||||
|
||||
function assertCodexAppRuntimeSubAgentActivityContract() {
|
||||
const { createCodexAppRuntime } = require(path.join(REPO_DIR, 'lib', 'codex-app-runtime'));
|
||||
const sent = [];
|
||||
const runtime = createCodexAppRuntime({
|
||||
wsSend: (_ws, payload) => sent.push(payload),
|
||||
loadSession: () => null,
|
||||
saveSession: () => {},
|
||||
});
|
||||
const sessionId = 'runtime-subagent-session';
|
||||
const threadId = 'agent-thread-runtime-001';
|
||||
const prompt = '请审查 runtime subAgentActivity 结构。';
|
||||
const entry = {
|
||||
ws: {},
|
||||
toolCalls: [],
|
||||
fullText: '',
|
||||
};
|
||||
|
||||
runtime.processCodexAppNotification(entry, {
|
||||
method: 'item/started',
|
||||
params: {
|
||||
item: {
|
||||
id: 'runtime-activity',
|
||||
type: 'subAgentActivity',
|
||||
kind: 'started',
|
||||
agentThreadId: threadId,
|
||||
agentPath: '/root/plan_reviewer',
|
||||
prompt,
|
||||
},
|
||||
},
|
||||
}, sessionId);
|
||||
|
||||
const started = sent.find((msg) => msg.type === 'tool_start' && msg.toolUseId === 'runtime-activity');
|
||||
assert(started, 'Runtime subAgentActivity item/started should emit tool_start');
|
||||
assert(started.sessionId === sessionId, 'Runtime subAgentActivity tool_start should carry session id');
|
||||
assert(started.name === 'subAgentActivity', 'Runtime subAgentActivity tool_start should preserve activity name');
|
||||
assert(started.kind === 'collab_agent_tool_call', 'Runtime subAgentActivity should surface as a collab agent tool call');
|
||||
assert(started.input?.type === 'subAgentActivity', 'Runtime subAgentActivity input should preserve original type');
|
||||
assert(started.input?.kind === 'started', 'Runtime subAgentActivity input should preserve activity kind');
|
||||
assert(started.input?.agentThreadId === threadId, 'Runtime subAgentActivity input should preserve agentThreadId');
|
||||
assert(started.input?.agentPath === '/root/plan_reviewer', 'Runtime subAgentActivity input should preserve agentPath');
|
||||
assert(started.input?.prompt === prompt, 'Runtime subAgentActivity input should preserve prompt');
|
||||
assert(started.input?.receiverThreadIds?.[0] === threadId, 'Runtime subAgentActivity input should expose receiverThreadIds');
|
||||
assert(started.input?.agentsStates?.[threadId]?.label === 'plan_reviewer', 'Runtime subAgentActivity should derive title from agentPath basename');
|
||||
assert(started.input?.agentsStates?.[threadId]?.role === '', 'Runtime subAgentActivity should not duplicate agentPath title into role');
|
||||
assert(started.input?.agentsStates?.[threadId]?.taskDescription === prompt, 'Runtime subAgentActivity should copy prompt to taskDescription');
|
||||
assert(started.input?.agentsStates?.[threadId]?.status === 'running', 'Runtime started subAgentActivity should map to running');
|
||||
|
||||
runtime.processCodexAppNotification(entry, {
|
||||
method: 'item/completed',
|
||||
params: {
|
||||
item: {
|
||||
id: 'runtime-activity',
|
||||
type: 'subAgentActivity',
|
||||
agentThreadId: threadId,
|
||||
agentPath: '/root/plan_reviewer',
|
||||
},
|
||||
},
|
||||
}, sessionId);
|
||||
|
||||
const completed = sent.find((msg) => msg.type === 'tool_end' && msg.toolUseId === 'runtime-activity');
|
||||
assert(completed, 'Runtime subAgentActivity item/completed should emit tool_end');
|
||||
assert(completed.kind === 'collab_agent_tool_call', 'Runtime completed subAgentActivity should keep collab tool kind');
|
||||
assert(completed.name === 'subAgentActivity', 'Runtime completed subAgentActivity should keep activity name');
|
||||
assert(completed.input?.type === 'subAgentActivity', 'Runtime completed subAgentActivity tool_end should carry input');
|
||||
assert(completed.input?.kind === 'started', 'Runtime lifecycle-completed subAgentActivity input should inherit the started activity kind when completed omits kind');
|
||||
assert(completed.input?.prompt === prompt, 'Runtime completed subAgentActivity input should retain the started prompt when completed omits it');
|
||||
assert(completed.input?.receiverThreadIds?.[0] === threadId, 'Runtime completed subAgentActivity input should keep receiverThreadIds');
|
||||
assert(completed.input?.agentsStates?.[threadId]?.taskDescription === prompt, 'Runtime completed subAgentActivity input should retain the started taskDescription');
|
||||
assert(completed.input?.agentsStates?.[threadId]?.status === 'running', 'Runtime lifecycle-completed subAgentActivity input should keep child running when completed omits kind');
|
||||
const completedResult = JSON.parse(completed.result);
|
||||
assert(completedResult.type === 'subAgentActivity', 'Runtime completed subAgentActivity result should preserve original type');
|
||||
assert(completedResult.kind === 'started', 'Runtime lifecycle-completed subAgentActivity result should inherit the started activity kind when completed omits kind');
|
||||
assert(completedResult.prompt === prompt, 'Runtime completed subAgentActivity result should retain the started prompt when completed omits it');
|
||||
assert(completedResult.receiverThreadIds?.[0] === threadId, 'Runtime completed subAgentActivity result should expose receiverThreadIds');
|
||||
assert(completedResult.agentsStates?.[threadId]?.taskDescription === prompt, 'Runtime completed subAgentActivity result should retain the started taskDescription');
|
||||
assert(completedResult.agentsStates?.[threadId]?.status === 'running', 'Runtime lifecycle-completed subAgentActivity result should keep child running when completed omits kind');
|
||||
assert(entry.toolCalls[0]?.kind === 'collab_agent_tool_call', 'Runtime persisted tool call should keep collab tool kind');
|
||||
assert(entry.toolCalls[0]?.input?.type === 'subAgentActivity', 'Runtime persisted tool call should keep subAgentActivity input type for routing recovery');
|
||||
assert(entry.toolCalls[0]?.input?.prompt === prompt, 'Runtime persisted subAgentActivity input should retain the started prompt');
|
||||
assert(entry.toolCalls[0]?.input?.kind === 'started', 'Runtime persisted lifecycle-completed subAgentActivity input should keep the started activity kind');
|
||||
assert(entry.toolCalls[0]?.input?.agentsStates?.[threadId]?.status === 'running', 'Runtime persisted lifecycle-completed subAgentActivity input should keep child running');
|
||||
|
||||
const explicitCompletedSent = [];
|
||||
const explicitCompletedRuntime = createCodexAppRuntime({
|
||||
wsSend: (_ws, payload) => explicitCompletedSent.push(payload),
|
||||
loadSession: () => null,
|
||||
saveSession: () => {},
|
||||
});
|
||||
const explicitCompletedEntry = { ws: {}, toolCalls: [], fullText: '' };
|
||||
const explicitCompletedThreadId = 'agent-thread-runtime-completed';
|
||||
explicitCompletedRuntime.processCodexAppNotification(explicitCompletedEntry, {
|
||||
method: 'item/started',
|
||||
params: {
|
||||
item: {
|
||||
id: 'runtime-activity-completed',
|
||||
type: 'subAgentActivity',
|
||||
kind: 'started',
|
||||
agentThreadId: explicitCompletedThreadId,
|
||||
agentPath: '/root/plan_reviewer',
|
||||
prompt,
|
||||
},
|
||||
},
|
||||
}, sessionId);
|
||||
explicitCompletedRuntime.processCodexAppNotification(explicitCompletedEntry, {
|
||||
method: 'item/completed',
|
||||
params: {
|
||||
item: {
|
||||
id: 'runtime-activity-completed',
|
||||
type: 'subAgentActivity',
|
||||
kind: 'completed',
|
||||
agentThreadId: explicitCompletedThreadId,
|
||||
agentPath: '/root/plan_reviewer',
|
||||
},
|
||||
},
|
||||
}, sessionId);
|
||||
const explicitCompleted = explicitCompletedSent.find((msg) => msg.type === 'tool_end' && msg.toolUseId === 'runtime-activity-completed');
|
||||
assert(explicitCompleted.input?.kind === 'completed', 'Runtime explicit completed subAgentActivity input should preserve completed activity kind');
|
||||
assert(explicitCompleted.input?.prompt === prompt, 'Runtime explicit completed subAgentActivity input should retain the started prompt when completed omits it');
|
||||
assert(explicitCompleted.input?.agentsStates?.[explicitCompletedThreadId]?.taskDescription === prompt, 'Runtime explicit completed subAgentActivity input should retain the started taskDescription');
|
||||
assert(explicitCompleted.input?.agentsStates?.[explicitCompletedThreadId]?.status === 'completed', 'Runtime explicit completed subAgentActivity input should map child status to completed');
|
||||
const explicitCompletedResult = JSON.parse(explicitCompleted.result);
|
||||
assert(explicitCompletedResult.kind === 'completed', 'Runtime explicit completed subAgentActivity result should preserve completed activity kind');
|
||||
assert(explicitCompletedResult.prompt === prompt, 'Runtime explicit completed subAgentActivity result should retain the started prompt');
|
||||
assert(explicitCompletedResult.agentsStates?.[explicitCompletedThreadId]?.taskDescription === prompt, 'Runtime explicit completed subAgentActivity result should retain the started taskDescription');
|
||||
assert(explicitCompletedResult.agentsStates?.[explicitCompletedThreadId]?.status === 'completed', 'Runtime explicit completed subAgentActivity result should map child status to completed');
|
||||
|
||||
const explicitRoleSent = [];
|
||||
const explicitRoleRuntime = createCodexAppRuntime({
|
||||
wsSend: (_ws, payload) => explicitRoleSent.push(payload),
|
||||
loadSession: () => null,
|
||||
saveSession: () => {},
|
||||
});
|
||||
explicitRoleRuntime.processCodexAppNotification({ ws: {}, toolCalls: [], fullText: '' }, {
|
||||
method: 'item/started',
|
||||
params: {
|
||||
item: {
|
||||
id: 'runtime-activity-role',
|
||||
type: 'subAgentActivity',
|
||||
kind: 'started',
|
||||
agentThreadId: 'agent-thread-runtime-role',
|
||||
agentPath: '/root/plan_reviewer',
|
||||
role: 'reviewer',
|
||||
},
|
||||
},
|
||||
}, sessionId);
|
||||
const explicitRoleStarted = explicitRoleSent.find((msg) => msg.type === 'tool_start' && msg.toolUseId === 'runtime-activity-role');
|
||||
assert(explicitRoleStarted.input?.agentsStates?.['agent-thread-runtime-role']?.role === 'reviewer', 'Runtime subAgentActivity should preserve explicit role');
|
||||
|
||||
const reasoningSent = [];
|
||||
const reasoningRuntime = createCodexAppRuntime({
|
||||
wsSend: (_ws, payload) => reasoningSent.push(payload),
|
||||
loadSession: () => null,
|
||||
saveSession: () => {},
|
||||
});
|
||||
reasoningRuntime.processCodexAppNotification({ ws: {}, toolCalls: [], fullText: '' }, {
|
||||
method: 'item/completed',
|
||||
params: {
|
||||
item: {
|
||||
id: 'runtime-reasoning',
|
||||
type: 'reasoning',
|
||||
content: [{ text: '推理完成' }],
|
||||
},
|
||||
},
|
||||
}, sessionId);
|
||||
const reasoningEnd = reasoningSent.find((msg) => msg.type === 'tool_end' && msg.toolUseId === 'runtime-reasoning');
|
||||
assert(reasoningEnd, 'Runtime reasoning item/completed should still emit tool_end');
|
||||
assert(!Object.prototype.hasOwnProperty.call(reasoningEnd, 'input'), 'Runtime non-subAgentActivity reasoning tool_end should not gain input');
|
||||
assert(!Object.prototype.hasOwnProperty.call(reasoningEnd, 'name'), 'Runtime non-subAgentActivity reasoning tool_end should not gain name');
|
||||
}
|
||||
|
||||
function assertFrontendPrimaryCodexAppUiContract() {
|
||||
@@ -1268,23 +1706,35 @@ async function main() {
|
||||
const targetIndex = process.argv.indexOf('--target');
|
||||
const regressionTarget = targetIndex >= 0 ? String(process.argv[targetIndex + 1] || '').trim() : String(process.env.CC_WEB_REGRESSION_TARGET || '').trim();
|
||||
if (regressionTarget) {
|
||||
if (regressionTarget !== 'codexapp-unrouted-routing') {
|
||||
throw new Error(`Unknown regression target: ${regressionTarget}`);
|
||||
if (regressionTarget === 'composer-slash-routing') {
|
||||
assertComposerSlashRoutingContract();
|
||||
console.log('Composer slash routing regression checks passed.');
|
||||
return;
|
||||
}
|
||||
assertCodexAppUnroutedNotificationRoutingContract();
|
||||
console.log('Codex App unrouted routing regression checks passed.');
|
||||
return;
|
||||
if (regressionTarget === 'codexapp-unrouted-routing') {
|
||||
assertCodexAppUnroutedNotificationRoutingContract();
|
||||
console.log('Codex App unrouted routing regression checks passed.');
|
||||
return;
|
||||
}
|
||||
if (regressionTarget === 'subagent-card-metadata') {
|
||||
assertFrontendSubagentCardMetadataContract();
|
||||
assertCodexAppRuntimeSubAgentActivityContract();
|
||||
console.log('Subagent card metadata regression checks passed.');
|
||||
return;
|
||||
}
|
||||
throw new Error(`Unknown regression target: ${regressionTarget}`);
|
||||
}
|
||||
|
||||
assertUnlimitedImageAttachmentsContract();
|
||||
assertFrontendGenerationControlsContract();
|
||||
assertFrontendComposerMcpContract();
|
||||
assertFrontendSlashDraftPreservationContract();
|
||||
assertComposerSlashRoutingContract();
|
||||
assertFrontendCcwebPromptContract();
|
||||
assertFrontendMarkdownLinkContract();
|
||||
assertMockCodexAppPromptUserNotTextTriggered();
|
||||
assertFrontendMcpReloadContract();
|
||||
assertFrontendSubagentCardMetadataContract();
|
||||
assertCodexAppRuntimeSubAgentActivityContract();
|
||||
assertFrontendPrimaryCodexAppUiContract();
|
||||
assertSetTitleMcpContract();
|
||||
assertSessionSwitchResilienceContract();
|
||||
@@ -1562,11 +2012,29 @@ async function main() {
|
||||
assert(!slashMcpRuntimeComposer.items.some((item) => item.kind === 'mcp' && item.itemType === 'server' && item.name === 'regRuntime'), 'Composer slash suggestions should not infer MCP servers from session tool names');
|
||||
assert(!slashMcpRuntimeComposer.items.some((item) => item.kind === 'mcp' && item.itemType === 'server' && item.name === 'reg-state'), 'Composer slash suggestions should not infer MCP servers from mcp:server labels');
|
||||
|
||||
ws.send(JSON.stringify({ type: 'message', text: '/grilling', sessionId: codexSession.sessionId, mode: 'plan', agent: 'codex', requestId: 'reg-unknown-slash-draft' }));
|
||||
const unknownSlashAttachment = await uploadAttachment(port, token, {
|
||||
filename: 'unknown-slash.png',
|
||||
mime: 'image/png',
|
||||
data: Buffer.from('unknown-slash-image'),
|
||||
});
|
||||
ws.send(JSON.stringify({
|
||||
type: 'message',
|
||||
text: '/report/mcps?search',
|
||||
attachments: [unknownSlashAttachment],
|
||||
sessionId: codexSession.sessionId,
|
||||
mode: 'plan',
|
||||
agent: 'codex',
|
||||
requestId: 'reg-unknown-slash-draft',
|
||||
}));
|
||||
const unknownSlashDraft = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.requestId === 'reg-unknown-slash-draft');
|
||||
assert(unknownSlashDraft.sessionId === codexSession.sessionId, 'Unknown slash draft response should stay scoped to the active session');
|
||||
assert(unknownSlashDraft.preserveComposerDraft === true, 'Unknown slash command should tell the frontend to restore the composer draft');
|
||||
assert(/未知指令: \/grilling/.test(unknownSlashDraft.message || ''), 'Unknown slash command should still show the normal failure hint');
|
||||
assert(!unknownSlashDraft.preserveComposerDraft, 'Unknown slash hints should not restore text that is continuing through ordinary send');
|
||||
assert(/未知指令: \/report\/mcps\?search/.test(unknownSlashDraft.message || ''), 'Unknown slash text should still show the normal hint');
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexSession.sessionId);
|
||||
const storedUnknownSlashSession = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexSession.sessionId}.json`), 'utf8'));
|
||||
const storedUnknownSlashMessage = storedUnknownSlashSession.messages.find((message) => message.role === 'user' && message.content === '/report/mcps?search');
|
||||
assert(storedUnknownSlashMessage, 'Unknown slash text should continue through the ordinary message pipeline');
|
||||
assert(storedUnknownSlashMessage.attachments?.some((attachment) => attachment.filename === unknownSlashAttachment.filename), 'Unknown slash text should preserve ordinary message attachments');
|
||||
|
||||
ws.send(JSON.stringify({ type: 'message', text: '/help', sessionId: codexSession.sessionId, mode: 'plan', agent: 'codex', requestId: 'reg-help-slash-draft' }));
|
||||
const helpSlashDraft = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.requestId === 'reg-help-slash-draft');
|
||||
@@ -2614,6 +3082,38 @@ async function main() {
|
||||
ws.send(JSON.stringify({ type: 'message', text: 'slow codexapp prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((s) => s.id === codexAppSession.sessionId && s.isRunning));
|
||||
await sleep(500);
|
||||
ws.send(JSON.stringify({
|
||||
type: 'message',
|
||||
text: '/runtime/report',
|
||||
sessionId: codexAppSession.sessionId,
|
||||
mode: 'yolo',
|
||||
agent: 'codexapp',
|
||||
clientMessageId: 'regression-unknown-slash-steer',
|
||||
}));
|
||||
const runningUnknownSlashHint = await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'system_message' &&
|
||||
msg.sessionId === codexAppSession.sessionId &&
|
||||
/未知指令: \/runtime\/report/.test(msg.message || '')
|
||||
));
|
||||
assert(!runningUnknownSlashHint.preserveComposerDraft, 'Running Codex App unknown slash hints should not restore a message that continues through steer');
|
||||
await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'codex_app_steer_status' &&
|
||||
msg.sessionId === codexAppSession.sessionId &&
|
||||
msg.clientMessageId === 'regression-unknown-slash-steer' &&
|
||||
msg.status === 'pending'
|
||||
));
|
||||
const runningUnknownSlashDelta = await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'text_delta' &&
|
||||
msg.sessionId === codexAppSession.sessionId &&
|
||||
/steer accepted: \/runtime\/report/.test(msg.text || '')
|
||||
));
|
||||
assert(/\/runtime\/report/.test(runningUnknownSlashDelta.text || ''), 'Running Codex App unknown slash text should continue through turn/steer');
|
||||
await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'codex_app_steer_status' &&
|
||||
msg.sessionId === codexAppSession.sessionId &&
|
||||
msg.clientMessageId === 'regression-unknown-slash-steer' &&
|
||||
msg.status === 'inserted'
|
||||
));
|
||||
ws.send(JSON.stringify({
|
||||
type: 'message',
|
||||
text: 'runtime steer insert',
|
||||
@@ -2648,6 +3148,7 @@ async function main() {
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
|
||||
storedCodexApp = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
|
||||
assert(storedCodexApp.codexAppThreadId === codexAppThreadId, 'Codex App follow-up should resume the same app-server thread');
|
||||
assert(storedCodexApp.messages.some((message) => message.role === 'user' && message.content === '/runtime/report'), 'Running Codex App unknown slash text should persist as ordinary user history');
|
||||
assert(storedCodexApp.messages.some((message) => message.role === 'user' && message.content === 'runtime steer insert'), 'Codex App steer message should be persisted as user history');
|
||||
assert(storedCodexApp.messages.some((message) => message.role === 'assistant' && /runtime steer insert/.test(String(message.content || ''))), 'Codex App steered assistant output should be persisted');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user