feat: improve conversation timeline and agent plans

This commit is contained in:
shiyue
2026-07-29 16:50:12 +08:00
parent 4d97446a6f
commit 33c9783079
16 changed files with 663 additions and 59 deletions

View File

@@ -1421,15 +1421,24 @@ function assertFrontendSubagentCardMetadataContract() {
return {
attributes: {},
children: [],
dataset: {},
listeners: {},
parentElement: null,
isConnected: true,
appendChild(child) {
if (child && typeof child === 'object') child.parentElement = this;
this.children.push(child);
return child;
},
insertBefore(child) {
if (child && typeof child === 'object') child.parentElement = this;
this.children.unshift(child);
return child;
},
replaceChildren(...children) {
this.children = [];
children.forEach((child) => this.appendChild(child));
},
setAttribute(name, value) {
this.attributes[name] = String(value);
},
@@ -1451,10 +1460,24 @@ function assertFrontendSubagentCardMetadataContract() {
},
};
}
const messagesDiv = makeNode();
function findLatestToolCallElement(root, matcher) {
const matches = [];
const visit = (node) => {
if (!node || typeof node !== 'object') return;
if (node.dataset?.toolUseId && matcher(node)) matches.push(node);
(Array.isArray(node.children) ? node.children : []).forEach(visit);
};
visit(root);
return matches.length > 0 ? matches[matches.length - 1] : null;
}
function createCcwebPromptElement() { return makeNode(); }
function isEmptyReasoningTool() { return false; }
function createToolCallElement(toolUseId, tool, done) {
return { ...makeNode(), toolUseId, tool, done };
const node = { ...makeNode(), toolUseId, tool, done };
node.dataset.toolUseId = String(toolUseId || '');
node.dataset.toolKind = toolKind(tool) || '';
return node;
}
function isGroupableToolCall() { return false; }
function _refreshGroupSummary() {}
@@ -1478,6 +1501,9 @@ function assertFrontendSubagentCardMetadataContract() {
const el = buildMsgElement({ role: 'assistant', content: '', toolCalls });
return el.querySelector('.msg-bubble').children.map((node) => node.tool);
},
renderMessageElement: (toolCalls) => buildMsgElement({ role: 'assistant', content: '', toolCalls }),
mountMessageElement: (element) => messagesDiv.appendChild(element.querySelector('.msg-bubble')),
findCollabAgentToolElement,
renderCollabAgentToolElement: (tool) => createCollabAgentToolElement(tool),
getCachedState: (id) => collabAgentStateCache.get(id),
hasCachedState: (id) => collabAgentStateCache.has(id),
@@ -1512,6 +1538,7 @@ function assertFrontendSubagentCardMetadataContract() {
assert(source.includes('function createPlanProgressElementFromProgress(progress'), 'Frontend should expose reusable plan progress rendering for sub-agent cards');
assert(source.includes("plan.className = 'collab-agent-item-plan'"), 'Sub-agent cards should render a compact plan progress row');
assert(source.includes("currentStep.className = 'collab-agent-item-plan-current'"), 'Sub-agent cards should render the current plan step');
assert(source.includes('findCollabAgentToolElement(toolUseId, tool?.domElement)'), 'Live sub-agent updates should locate the original card before falling back to the latest assistant message');
assert(source.includes('description.title = descriptionTitle'), 'Task intro node should expose the full task intro or fallback in its title attribute');
assert(source.includes('label.textContent = displayTitle;'), 'Rendered card label should use normalized title selection');
assert(/\.collab-agent-item-description\s*\{[\s\S]*?-webkit-line-clamp:\s*2;/.test(styleSource), 'Sub-agent task intro should use two-line truncation');
@@ -1698,6 +1725,54 @@ function assertFrontendSubagentCardMetadataContract() {
assert(planCounts.length === 1 && planCounts[0].textContent === '1/3', 'Sub-agent plan summary should always render completed/total');
assert(planCurrentSteps.length === 1 && planCurrentSteps[0].textContent === '当前:同步父卡片进度', 'Sub-agent plan summary should render the current in-progress step');
const persistedPlanSnapshotTool = {
id: 'tool-persisted-plan-progress',
name: 'subAgentActivity',
kind: 'collab_agent_tool_call',
input: {
tool: 'subAgentActivity',
receiverThreadIds: ['child-thread-persisted-plan'],
agentsStates: {
'child-thread-persisted-plan': {
title: '真实快照验证代理',
status: 'running',
},
},
},
result: JSON.stringify({
tool: 'subAgentActivity',
receiverThreadIds: ['child-thread-persisted-plan'],
agentsStates: {
'child-thread-persisted-plan': {
title: '真实快照验证代理',
status: 'running',
planProgress: { completed: 2, total: 3 },
planCurrentStep: '验证历史卡片增量更新',
},
},
}),
done: false,
};
const persistedMessageElement = collabApi.renderMessageElement([persistedPlanSnapshotTool]);
const persistedMessageBubble = persistedMessageElement.querySelector('.msg-bubble');
const persistedCard = persistedMessageBubble.children[0];
assert(persistedCard?.dataset?.collabMerged === 'true', 'Restored sub-agent cards should be marked as merged cards for later live updates');
assert(
persistedCard?.__collabTools instanceof Map && persistedCard.__collabTools.has(persistedPlanSnapshotTool.id),
'Restored sub-agent cards should retain their original tool snapshots for later result-only plan updates'
);
collabApi.mountMessageElement(persistedMessageElement);
assert(
collabApi.findCollabAgentToolElement(persistedPlanSnapshotTool.id) === persistedCard,
'Live result-only plan updates should find the restored card by its original tool id'
);
const persistedPlanElement = collabApi.renderCollabAgentToolElement(persistedCard.tool);
const persistedPlanCounts = findNodesByClass(persistedPlanElement, 'plan-progress-count');
assert(
persistedPlanCounts.length === 1 && persistedPlanCounts[0].textContent === '2/3',
'Persisted result-only plan progress should survive history reconstruction'
);
collabApi.rememberCollabAgentState(
'unrelated-child',
{ title: '无关历史代理', status: 'running' },
@@ -2283,11 +2358,20 @@ function assertTitleHistoryOutlineContract() {
title: `主题 ${index}`,
changedAt: new Date(Date.UTC(2026, 6, 27, 0, index)).toISOString(),
messageIndex: index,
...(index > 0 ? { anchorMessageIndex: index - 1 } : {}),
source: 'llm',
})),
]);
assert(serverHistory.length === 100, 'Server should retain only the latest 100 valid title history events');
assert(serverHistory[0].title === '主题 2' && serverHistory.at(-1).title === '主题 101', 'Server title history should discard invalid events and trim from the oldest side');
assert(serverHistory[0].anchorMessageIndex === 1 && serverHistory.at(-1).anchorMessageIndex === 100, 'Server title history should preserve valid trigger-message anchors');
const invalidAnchorHistory = normalizeServerHistory([
{ title: '无效锚点仍保留事件', changedAt: '2026-07-27T00:00:00.000Z', messageIndex: 3, anchorMessageIndex: 3, source: 'llm' },
]);
assert(
invalidAnchorHistory.length === 1 && !Object.prototype.hasOwnProperty.call(invalidAnchorHistory[0], 'anchorMessageIndex'),
'Server title history should discard invalid anchors without discarding otherwise valid legacy events'
);
const normalizeOutlineHistorySource = extractFunctionSource(frontendSource, 'normalizeOutlineTitleHistory');
const formatOutlineDateSource = extractFunctionSource(frontendSource, 'formatUserOutlineDate');
@@ -2299,8 +2383,9 @@ function assertTitleHistoryOutlineContract() {
return { normalizeOutlineTitleHistory, formatUserOutlineDate, buildUserOutlineTimelineItems };
`)();
const beforeMidnight = new Date(2026, 6, 27, 23, 59, 0).toISOString();
const titleChangedAt = new Date(2026, 6, 27, 23, 59, 30).toISOString();
const titleChangedAt = new Date(2026, 6, 28, 0, 0, 30).toISOString();
const afterMidnight = new Date(2026, 6, 28, 0, 1, 0).toISOString();
const legacyTitleChangedAt = new Date(2026, 6, 28, 0, 1, 30).toISOString();
assert(outlineApi.formatUserOutlineDate(beforeMidnight) === '2026-07-27', 'Outline dates should use the browser local calendar day before midnight');
assert(outlineApi.formatUserOutlineDate(afterMidnight) === '2026-07-28', 'Outline dates should roll over at browser-local midnight');
@@ -2308,15 +2393,18 @@ function assertTitleHistoryOutlineContract() {
{ type: 'message', id: 'user-1', targetMessageId: 'hapi-message-user-1', label: '第一步', timestamp: beforeMidnight, messageIndex: 0 },
{ type: 'message', id: 'user-2', targetMessageId: 'hapi-message-user-2', label: '第二步', timestamp: afterMidnight, messageIndex: 2 },
], [
{ title: 'SQL 排查主题', changedAt: titleChangedAt, messageIndex: 1, source: 'llm' },
{ title: 'SQL 排查主题', changedAt: titleChangedAt, messageIndex: 1, anchorMessageIndex: 0, source: 'llm' },
{ title: '旧历史兼容主题', changedAt: legacyTitleChangedAt, messageIndex: 3, source: 'llm' },
]);
assert(
JSON.stringify(timeline.map((item) => item.type)) === JSON.stringify(['date', 'message', 'title', 'date', 'message']),
'Outline timeline should merge date, message and title nodes in conversation order'
JSON.stringify(timeline.map((item) => item.type)) === JSON.stringify(['date', 'title', 'message', 'date', 'title', 'message']),
'Outline timeline should render each title as a section heading before its triggering user message'
);
assert(timeline[0].label === '2026-07-27' && timeline[3].label === '2026-07-28', 'Outline should deduplicate dates and show YYYY-MM-DD only');
assert(timeline[1].messageNumber === 1 && timeline[4].messageNumber === 2, 'Only selectable message nodes should consume outline numbering');
assert(timeline[2].label === 'SQL 排查主题' && !timeline[2].targetMessageId, 'Title history nodes should be read-only timeline metadata');
assert(timeline[2].messageNumber === 1 && timeline[5].messageNumber === 2, 'Only selectable message nodes should consume outline numbering');
assert(timeline[1].label === 'SQL 排查主题' && timeline[1].anchorMessageIndex === 0, 'Anchored title history should render before its exact triggering message');
assert(timeline[4].label === '旧历史兼容主题' && !timeline[4].targetMessageId, 'Legacy title history should fall back to the nearest preceding user message and remain read-only');
assert(timeline[0].label !== outlineApi.formatUserOutlineDate(titleChangedAt), 'A title crossing midnight should inherit the triggering message calendar day');
const updateOutlineSource = extractFunctionSource(frontendSource, 'updateUserOutlinePanel');
assert(updateOutlineSource.includes('user-outline-title-event') && updateOutlineSource.includes('user-outline-date'), 'Outline renderer should include dedicated title and date nodes');
@@ -3420,7 +3508,7 @@ function extractFunctionSource(source, name) {
throw new Error(`Could not parse function body for ${name}`);
}
function assertCodexAppChildToolFallbackContract() {
function assertCodexAppChildToolRoutingContract() {
const source = fs.readFileSync(SERVER_PATH, 'utf8');
const helperStart = source.indexOf('function parseMaybeJsonObject(value)');
const helperEnd = source.indexOf('function sendCcwebMcpChildAgentUpdate(sessionId, child)', helperStart);
@@ -3452,22 +3540,48 @@ function assertCodexAppChildToolFallbackContract() {
};
`)();
const oldTool = {
id: 'call-old-reviewer',
name: 'subAgentActivity',
kind: 'collab_agent_tool_call',
input: {
tool: 'subAgentActivity',
receiverThreadIds: ['old-reviewer-thread'],
agentsStates: { 'old-reviewer-thread': { title: '旧审查代理', status: 'returned' } },
},
result: JSON.stringify({
receiverThreadIds: ['old-reviewer-thread'],
agentsStates: { 'old-reviewer-thread': { title: '旧审查代理', status: 'returned' } },
}),
done: true,
};
const currentTool = {
id: 'call-current-plan-demo',
name: 'subAgentActivity',
kind: 'collab_agent_tool_call',
input: {
tool: 'subAgentActivity',
receiverThreadIds: ['current-plan-thread'],
agentsStates: { 'current-plan-thread': { title: '当前计划代理', status: 'running' } },
},
result: JSON.stringify({
receiverThreadIds: ['current-plan-thread'],
agentsStates: { 'current-plan-thread': { title: '当前计划代理', status: 'running' } },
}),
done: false,
};
const session = {
id: 'fallback-session',
id: 'child-tool-routing-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,
},
],
toolCalls: [oldTool],
},
{
role: 'assistant',
content: '',
toolCalls: [currentTool],
},
{
role: 'assistant',
@@ -3487,25 +3601,114 @@ function assertCodexAppChildToolFallbackContract() {
};
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',
threadId: 'current-plan-thread',
spawnToolId: 'call-current-plan-demo',
label: '当前计划代理',
taskDescription: '验证计划只进入当前子代理卡片。',
status: 'running',
planProgress: { completed: 2, total: 4 },
planCurrentStep: '更新当前卡片',
planUpdatedAt: '2026-07-29T00:35: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');
assert(persistedTool?.id === 'call-current-plan-demo', 'Child plan updates should select the exact spawn tool id');
assert(session.messages[2].toolCalls[0].result === 'ordinary output', 'Child plan routing must not merge 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?.['current-plan-thread']?.planProgress?.completed === 2, 'Exact child card should receive plan progress');
assert(result.agentsStates?.['current-plan-thread']?.planCurrentStep === '更新当前卡片', 'Exact child card should receive the current plan step');
assert(!JSON.parse(oldTool.result).agentsStates?.['current-plan-thread'], 'Older unrelated child cards must remain untouched');
assert(api.getSavedSession()?.id === session.id, 'Exact child-card merge should save the session');
const currentResultBeforeMissingId = currentTool.result;
const missingTool = api.updatePersistedCcwebMcpChildTool(session.id, {
threadId: 'missing-route-thread',
spawnToolId: 'call-not-present',
label: '不应串卡代理',
status: 'running',
planProgress: { completed: 1, total: 2 },
});
assert(missingTool === null, 'A non-empty unknown spawn tool id must not fall back to another child card');
assert(currentTool.result === currentResultBeforeMissingId, 'Unknown spawn tool ids must not contaminate the latest child card');
assert(!JSON.parse(oldTool.result).agentsStates?.['missing-route-thread'], 'Unknown spawn tool ids must not contaminate older child cards');
const syncSource = extractFunctionSource(source, 'syncCcwebMcpChildAgentsFromCollabItem');
const syncApi = new Function(`
const ccwebMcpChildThreads = new Map();
const sent = [];
const path = { basename: (value) => String(value || '').split('/').filter(Boolean).pop() || '' };
const SESSION_MESSAGE_CONTENT_MAX_CHARS = 10000;
function normalizeCodexAppThreadId(value) { return String(value || '').trim(); }
function codexAppCollabToolName(value) { return String(value || '').trim(); }
function extractCcwebMcpStringArray(...values) { return values.flat().filter(Boolean).map(String); }
function ccwebMcpChildLabel(state, fallback) { return String(state?.label || state?.title || fallback || ''); }
function normalizeCcwebMcpChildPlanProgress() { return null; }
function extractCcwebMcpChildCandidate() { return ''; }
function truncateTextValue(value) { return String(value || ''); }
function ccwebMcpChildStatus(value, fallback) {
const normalized = String(value || '').toLowerCase();
return normalized === 'started' ? 'running' : (normalized || fallback);
}
function sendCcwebMcpChildAgentUpdate(sessionId, child) {
sent.push({ sessionId, child: { ...child } });
}
${syncSource}
return {
setChild: (id, child) => ccwebMcpChildThreads.set(id, child),
getChild: (id) => ccwebMcpChildThreads.get(id),
sync: syncCcwebMcpChildAgentsFromCollabItem,
sent,
};
`)();
syncApi.setChild('current-plan-thread', {
threadId: 'current-plan-thread',
parentSessionId: session.id,
parentThreadId: 'parent-thread',
spawnToolId: 'call-old-reviewer',
label: '当前计划代理',
status: 'running',
});
syncApi.sync({ sessionId: session.id, role: 'parent', entry: { threadId: 'parent-thread' } }, {
id: 'call-current-plan-demo',
type: 'subAgentActivity',
kind: 'started',
agentThreadId: 'current-plan-thread',
agentPath: '/root/current_plan_demo',
});
assert(
result.agentsStates?.['child-refresh-thread']?.taskDescription === '验证刷新路径不会丢失子代理卡片。',
'Persisted fallback collab tool should store child task description'
syncApi.getChild('current-plan-thread')?.spawnToolId === 'call-current-plan-demo',
'A canonical subAgentActivity started event must rebind an existing child to its current tool id'
);
assert(
syncApi.sent.at(-1)?.child?.spawnToolId === 'call-current-plan-demo',
'The first public update after rebind must target the current child card'
);
syncApi.setChild('parent-child-thread', {
threadId: 'parent-child-thread',
parentSessionId: session.id,
parentThreadId: 'parent-thread',
spawnToolId: 'call-visible-parent-child',
label: '父级子代理',
status: 'running',
});
syncApi.sync({
sessionId: session.id,
role: 'child',
child: syncApi.getChild('parent-child-thread'),
}, {
id: 'grandchild-internal-activity',
type: 'subAgentActivity',
kind: 'started',
agentThreadId: 'grandchild-thread',
agentPath: '/root/parent_child/grandchild',
});
assert(
syncApi.getChild('grandchild-thread')?.spawnToolId === 'call-visible-parent-child',
'A nested child should inherit its nearest visible parent card tool id'
);
assert(
syncApi.getChild('grandchild-thread')?.parentThreadId === 'parent-child-thread',
'A nested child should still retain its immediate parent thread id'
);
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() {
@@ -3676,6 +3879,11 @@ async function main() {
console.log('Windows startup regression checks passed.');
return;
}
if (regressionTarget === 'subagent-card-routing') {
assertCodexAppChildToolRoutingContract();
console.log('Sub-agent card routing regression checks passed.');
return;
}
throw new Error(`Unknown regression target: ${regressionTarget}`);
}
@@ -3699,7 +3907,7 @@ async function main() {
assertTitleHistoryOutlineContract();
assertSessionSwitchResilienceContract();
assertSessionSwitchRaceContract();
assertCodexAppChildToolFallbackContract();
assertCodexAppChildToolRoutingContract();
assertMultiAgentV2CompatibilityContract();
assertWindowsStartupContract();
@@ -4093,6 +4301,14 @@ async function main() {
assert(mcpSetTitle.body.lockedByUser === false, 'MCP set title should not report a user lock before manual rename');
assert(mcpSetTitle.body.titleEvent?.title === 'Concise MCP Title', 'MCP set title should return the persisted title event');
assert(mcpSetTitle.body.titleEvent?.messageIndex === storedBeforeMcpTitle.messages.length, 'Title event should anchor after the messages persisted before the rename');
const expectedTitleAnchorMessageIndex = (() => {
for (let index = storedBeforeMcpTitle.messages.length - 1; index >= 0; index -= 1) {
if (storedBeforeMcpTitle.messages[index]?.role === 'user') return index;
}
return null;
})();
assert(expectedTitleAnchorMessageIndex !== null, 'Title regression fixture should contain a triggering user message');
assert(mcpSetTitle.body.titleEvent?.anchorMessageIndex === expectedTitleAnchorMessageIndex, 'MCP set title should return the triggering user message anchor');
const mcpTitleRenamed = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_renamed' &&
msg.sessionId === codexSession.sessionId &&
@@ -4100,6 +4316,7 @@ async function main() {
));
assert(mcpTitleRenamed.titleSource === 'llm', 'MCP set title should push llm titleSource to current viewers');
assert(mcpTitleRenamed.titleEvent?.title === 'Concise MCP Title', 'MCP set title should push the title event to current viewers');
assert(mcpTitleRenamed.titleEvent?.anchorMessageIndex === expectedTitleAnchorMessageIndex, 'Live session_renamed should preserve the title trigger anchor');
const mcpTitleList = await nextMessage(messages, ws, (msg) => (
msg.type === 'session_list' &&
msg.sessions.some((session) => (
@@ -4116,6 +4333,7 @@ async function main() {
assert(storedAfterMcpTitle.titleHistory?.length === 1, 'MCP set title should append exactly one title history event');
assert(storedAfterMcpTitle.titleHistory[0].source === 'llm' && storedAfterMcpTitle.titleHistory[0].title === 'Concise MCP Title', 'Persisted title history should identify the LLM title change');
assert(!Number.isNaN(Date.parse(storedAfterMcpTitle.titleHistory[0].changedAt)), 'Persisted title history should include a valid change timestamp');
assert(storedAfterMcpTitle.titleHistory[0].anchorMessageIndex === expectedTitleAnchorMessageIndex, 'Persisted title history should retain the triggering user message anchor');
const unchangedMcpTitle = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_set_title',
@@ -4134,6 +4352,7 @@ async function main() {
msg.requestId === 'reg-title-history-load'
));
assert(titleHistorySessionInfo.titleHistory?.length === 1, 'session_info should restore persisted title history');
assert(titleHistorySessionInfo.titleHistory[0].anchorMessageIndex === expectedTitleAnchorMessageIndex, 'Reloaded session_info should retain the title trigger anchor');
const mcpEmptyTitle = await callInternalMcp(port, internalMcpToken, {
tool: 'ccweb_set_title',