收口思考等待反馈与紧凑过程样式及失败状态
思考展开时隐藏重复预览并使用灯泡图标,首个响应前显示临时思考提示。 统一分钟内小数和分钟以上整数秒,保持内外过程的紧凑间距与正文层级。 失败工具与分组显示错误色,非零退出码不再误判为成功。 补齐状态、格式、样式和交互回归,本批其余需求留待后续PR。
This commit is contained in:
+29
-3
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
ArrowUp,
|
||||
AtSign,
|
||||
Brain,
|
||||
ChevronDown,
|
||||
Lightbulb,
|
||||
Loader2,
|
||||
Settings,
|
||||
Wrench,
|
||||
@@ -125,6 +125,7 @@ function AgentReasoning({
|
||||
label?: string;
|
||||
testId?: string;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
// 折叠态:单行纯文本预览(走 Markdown AST 取文字,链接只留字面文字、不含目标)。
|
||||
const preview = agentProcessPreview(text);
|
||||
return (
|
||||
@@ -134,11 +135,14 @@ function AgentReasoning({
|
||||
className="design-agent-reasoning"
|
||||
aria-label={label}
|
||||
data-testid={testId}
|
||||
onToggle={(event) =>
|
||||
setExpanded((event.currentTarget as HTMLDetailsElement).open)
|
||||
}
|
||||
>
|
||||
<summary>
|
||||
<AgentProcessSummary
|
||||
icon={<Brain size={12} aria-hidden="true" />}
|
||||
preview={preview || '思考过程'}
|
||||
icon={<Lightbulb size={12} aria-hidden="true" />}
|
||||
preview={expanded ? '思考过程' : preview || '思考过程'}
|
||||
chevron={<ChevronDown size={12} aria-hidden="true" />}
|
||||
/>
|
||||
</summary>
|
||||
@@ -319,6 +323,16 @@ export function ProjectSupervisorView({
|
||||
: [];
|
||||
const activeTurnStartedAt =
|
||||
directTurns.find((turn) => turn.active)?.startedAt ?? 0;
|
||||
const latestDirectTurn = directTurns.at(-1);
|
||||
// 仅是首个响应到达前的临时提示,不创建聊天条目或第二套回合状态。
|
||||
const awaitingFirstResponse =
|
||||
directCodex &&
|
||||
(runBusy || directTurnRunning) &&
|
||||
!turnCancelling &&
|
||||
!runtimePanelProps.error &&
|
||||
Boolean(latestDirectTurn?.users.length) &&
|
||||
latestDirectTurn?.process.length === 0 &&
|
||||
latestDirectTurn?.finals.length === 0;
|
||||
// 初始占位气泡只是"最初那条消息还没有正式条目"时的顶位,两种情况下不再渲染:
|
||||
// - 已经翻出更早的历史(`hasEarlierConversationMessages`):这里不是对话开头,不补占位;
|
||||
// - Direct 模式已经有了正式用户条目:正式气泡自己会显示,占位再渲染就是同一条消息出现两次。
|
||||
@@ -648,6 +662,18 @@ export function ProjectSupervisorView({
|
||||
</AgentMessageContent>
|
||||
</div>
|
||||
) : null}
|
||||
{awaitingFirstResponse ? (
|
||||
<AgentMessageContent
|
||||
tone="process"
|
||||
className="agent-awaiting-response"
|
||||
data-testid="agent-awaiting-response"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<Lightbulb size={12} aria-hidden="true" />
|
||||
<span>思考中…</span>
|
||||
</AgentMessageContent>
|
||||
) : null}
|
||||
</div>
|
||||
{directCodex ? null : planningSurfaceActive ? (
|
||||
<PlanningLaneRuntimeStrip
|
||||
|
||||
@@ -74,16 +74,14 @@ export function ToolCallGroup({
|
||||
// 本组用时:只看这一组自己的工具边界;本组全完成即冻结。
|
||||
const timing = resolveToolGroupTiming(orderedCalls, { running: active, now });
|
||||
const summary = toolCallGroupSummary(orderedCalls);
|
||||
const status = toolRunning
|
||||
? 'running'
|
||||
: orderedCalls.some((call) => call.status === 'failed')
|
||||
? 'failed'
|
||||
: 'completed';
|
||||
const hasFailure = orderedCalls.some((call) => call.status === 'failed');
|
||||
const status = toolRunning ? 'running' : hasFailure ? 'failed' : 'completed';
|
||||
// 用户口径:`执行了 X 个操作,耗时 XXX`。耗时属于**这一组**(不是整轮),可见文本里用
|
||||
// 中文逗号连起来,所以分隔符跟着 meta 一起进 DOM。
|
||||
// 顺序按用户口径:`执行了 X 个操作,耗时 XXX`,运行状态挂到最后,不插在中间。
|
||||
const headMeta = [
|
||||
timing.durationText ? `耗时 ${timing.durationText}` : '',
|
||||
hasFailure ? '有操作失败' : '',
|
||||
toolRunning ? '进行中' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
@@ -101,6 +99,7 @@ export function ToolCallGroup({
|
||||
data-testid="agent-tool-call-group"
|
||||
aria-label="陶泥儿执行过程"
|
||||
data-status={status}
|
||||
data-has-failure={hasFailure ? 'true' : undefined}
|
||||
data-duration-ms={timing.durationMs ?? ''}
|
||||
>
|
||||
<button
|
||||
|
||||
+8
-5
@@ -57,12 +57,15 @@ function toolStatus(
|
||||
status: string | null,
|
||||
exitCode: number | null,
|
||||
): GameCreatorDirectToolCallStatus {
|
||||
if (status === 'completed') return 'completed';
|
||||
// 显式失败 / 取消终态最优先:上游已经说这次调用失败,就不能再被别的字段洗成成功。
|
||||
if (status && FAILED_ITEM_STATUS.has(status)) return 'failed';
|
||||
// Codex 的退出码约定:非 0 即失败;缺席时按「已完成」处理。
|
||||
if (typeof exitCode === 'number') {
|
||||
return exitCode === 0 ? 'completed' : 'failed';
|
||||
}
|
||||
// 非零退出码优先于 `completed`:命令跑完了不等于成功(退出码 1 是失败,只认退出码,
|
||||
// 不从 output 的自然语言里推断)。
|
||||
if (typeof exitCode === 'number' && exitCode !== 0) return 'failed';
|
||||
if (status === 'completed') return 'completed';
|
||||
// 退出码 0:正常完成。
|
||||
if (typeof exitCode === 'number') return 'completed';
|
||||
// 还有状态但没有终态信息:仍在跑;完全没有状态字段的调用按「已完成」处理。
|
||||
return status ? 'running' : 'completed';
|
||||
}
|
||||
|
||||
|
||||
@@ -12605,10 +12605,9 @@ button.design-workspace-tree__entry:hover,
|
||||
}
|
||||
|
||||
.message-turn-process-body {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
display: block;
|
||||
min-width: 0;
|
||||
padding-top: 8px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
/* 整轮会话的结束时间与耗时:比消息本身更轻,属于轮次级信息。 */
|
||||
@@ -12705,6 +12704,13 @@ button.design-workspace-tree__entry:hover,
|
||||
|
||||
/* 紧凑过程入口(2026-09-18):思考过程 / 外层执行过程 / 工具组共用一行摘要。
|
||||
放在文件末尾,避免后段规则再把它们恢复成两行或加大底色。 */
|
||||
.agent-awaiting-response {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
.design-agent-reasoning > summary,
|
||||
.message-turn-process > summary,
|
||||
.agent-tool-call-group-head {
|
||||
@@ -12740,6 +12746,18 @@ button.design-workspace-tree__entry:hover,
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 仅最外层摘要使用正文色,内部思考/工具的过程色与行高不变。 */
|
||||
.message-turn-process > summary > .agent-process-summary {
|
||||
color: var(--platform-text-strong, #3d1f10);
|
||||
}
|
||||
|
||||
.message-turn-process
|
||||
> summary
|
||||
> .agent-process-summary
|
||||
:is(.agent-process-summary-icon, .agent-process-summary-meta, .agent-process-summary-chevron) {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.agent-tool-call-group-head {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
@@ -12823,3 +12841,53 @@ details.design-agent-reasoning[open]
|
||||
.agent-process-summary-chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* 思考与工具使用紧凑连续列表;收口后的外层只负责折叠,不额外改变行距。 */
|
||||
.game-workbench-chat
|
||||
.project-supervisor-surface.is-direct-codex
|
||||
.project-supervisor-message-list
|
||||
> :is(.design-agent-reasoning, .agent-tool-call-group),
|
||||
.game-workbench-chat
|
||||
.project-supervisor-surface.is-direct-codex
|
||||
.message-turn-process-body
|
||||
> :is(.design-agent-reasoning, .agent-tool-call-group) {
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.game-workbench-chat
|
||||
.project-supervisor-surface.is-direct-codex
|
||||
.message-turn-process-body
|
||||
> :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.game-workbench-chat
|
||||
.project-supervisor-surface.is-direct-codex
|
||||
.agent-tool-call-row-head {
|
||||
padding: 2px 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.game-workbench-chat
|
||||
.project-supervisor-surface.is-direct-codex
|
||||
.agent-tool-call-group-body {
|
||||
padding: 2px 0 0 18px;
|
||||
}
|
||||
|
||||
/* 失败状态独立于普通过程色;并行组里其它工具仍运行时也不能隐藏失败。 */
|
||||
.game-workbench-chat
|
||||
.project-supervisor-surface.is-direct-codex
|
||||
.agent-tool-call-group[data-has-failure='true']
|
||||
> .agent-tool-call-group-head
|
||||
.agent-process-summary,
|
||||
.game-workbench-chat
|
||||
.project-supervisor-surface.is-direct-codex
|
||||
.agent-tool-call-group[data-has-failure='true']
|
||||
> .agent-tool-call-group-head
|
||||
:is(.agent-process-summary-icon, .agent-process-summary-meta, .agent-process-summary-chevron),
|
||||
.game-workbench-chat
|
||||
.project-supervisor-surface.is-direct-codex
|
||||
.agent-tool-call-group-row[data-status='failed']
|
||||
:is(.agent-tool-call-row-head, .agent-tool-call-row-text, .agent-tool-call-row-status, .agent-tool-call-row-icon, .agent-tool-call-row-duration, .agent-tool-call-row-chevron) {
|
||||
color: var(--platform-button-danger-text, #a6402f);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const styles = readFileSync(
|
||||
new URL('../src/styles.css', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
describe('回合结束后的过程样式', () => {
|
||||
it('折叠层不再用 grid gap 叠加内部条目的原有 margin', () => {
|
||||
const body = styles.match(/\.message-turn-process-body\s*\{([^}]+)\}/)?.[1];
|
||||
expect(body).toBeDefined();
|
||||
expect(body).toMatch(/display:\s*block/);
|
||||
expect(body).not.toMatch(/(?:^|[;\s])gap\s*:/);
|
||||
expect(styles).toMatch(
|
||||
/\.message-turn-process-body\s*>\s*:is\(\.design-agent-reasoning,\s*\.agent-tool-call-group\)\s*\{[^}]*margin:\s*4px 0 0/,
|
||||
);
|
||||
expect(styles).toMatch(
|
||||
/\.project-supervisor-message-list\s*>\s*:is\(\.design-agent-reasoning,\s*\.agent-tool-call-group\),/,
|
||||
);
|
||||
});
|
||||
|
||||
it('仅最外层摘要使用正文色,不覆盖内部过程色', () => {
|
||||
expect(styles).toMatch(
|
||||
/\.message-turn-process\s*>\s*summary\s*>\s*\.agent-process-summary\s*\{[^}]*color:\s*var\(--platform-text-strong/,
|
||||
);
|
||||
expect(styles).not.toMatch(
|
||||
/\.message-turn-process-body\s*\{[^}]*color:\s*var\(--platform-text-strong/,
|
||||
);
|
||||
});
|
||||
|
||||
it('失败工具行与失败组摘要使用错误色', () => {
|
||||
expect(styles).toContain(".agent-tool-call-group[data-has-failure='true']");
|
||||
expect(styles).toMatch(
|
||||
/\.agent-tool-call-group-row\[data-status='failed'\]\s*:is\([^}]+\)\s*\{\s*color:\s*var\(--platform-button-danger-text/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -237,14 +237,23 @@ export function registerDesignAgentSurfaceTests() {
|
||||
expect(summary.textContent).toContain('结论');
|
||||
expect(summary.textContent).not.toContain('##');
|
||||
expect(summary.textContent).not.toContain('`');
|
||||
expect(summary.querySelector('.lucide-lightbulb')).not.toBeNull();
|
||||
expect(summary.querySelector('.lucide-brain')).toBeNull();
|
||||
fireEvent.click(summary);
|
||||
expect(details.open).toBe(true);
|
||||
// 展开态复用助手正文的 Markdown 安全链路:标题 / 列表 / 行内代码都成为真实语义元素。
|
||||
await waitFor(() => {
|
||||
expect(details.querySelector('h2')?.textContent).toBe('结论');
|
||||
expect(summary.textContent).toBe('思考过程');
|
||||
});
|
||||
expect(details.querySelectorAll('li')).toHaveLength(2);
|
||||
expect(details.querySelector('code')?.textContent).toBe('npm run build');
|
||||
expect(details.textContent?.match(/结论/g)).toHaveLength(1);
|
||||
fireEvent.click(summary);
|
||||
await waitFor(() => {
|
||||
expect(details.open).toBe(false);
|
||||
expect(summary.textContent).toContain('结论');
|
||||
});
|
||||
});
|
||||
|
||||
it('renders historical reasoning as independent collapsed sections', async () => {
|
||||
@@ -281,5 +290,6 @@ export function registerDesignAgentSurfaceTests() {
|
||||
fireEvent.click(summaries[0]);
|
||||
expect(details[0].open).toBe(true);
|
||||
expect(details[1].open).toBe(false);
|
||||
await waitFor(() => expect(summaries[0].textContent).toBe('思考过程'));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9034,6 +9034,217 @@ export function registerProjectSupervisorSurfaceTests() {
|
||||
).toContain('总耗时 1.0秒');
|
||||
});
|
||||
|
||||
/** 直接渲染 ProjectSupervisorView 的最小 props(与下面 initial 占位用例同源)。 */
|
||||
function supervisorSurfaceProps(
|
||||
overrides: Record<string, unknown> = {},
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
activeVersionId: null,
|
||||
chatInput: '',
|
||||
chatReferences: [],
|
||||
chatProjectAssets: [],
|
||||
composerRef: createRef(),
|
||||
directCodex: true,
|
||||
directTurnRunning: false,
|
||||
hiddenConversationCount: 0,
|
||||
messagesRef: createRef(),
|
||||
needsUserInput: false,
|
||||
onCancelConfirmation: vi.fn(),
|
||||
onCancelPendingCommand: vi.fn(),
|
||||
onChatInputChange: vi.fn(),
|
||||
onConfirmConfirmation: vi.fn(),
|
||||
onConfirmPendingCommand: vi.fn(),
|
||||
onScroll: vi.fn(),
|
||||
onShowEarlierMessages: vi.fn(),
|
||||
onSubmit: vi.fn(),
|
||||
pendingConfirmation: null,
|
||||
pendingCommand: null,
|
||||
projectPath: '/tmp/launcher-codex-placeholder-game',
|
||||
transientReply: '',
|
||||
visibleMessages: [],
|
||||
visibleProfessionalAgentCards: [],
|
||||
workspaceStatus: '等待指令',
|
||||
planGddState: createPlanGddStateView(),
|
||||
planGddHydrateBusy: false,
|
||||
planGddDecisionBusy: false,
|
||||
planGddError: null,
|
||||
onPlanGddRefresh: vi.fn(),
|
||||
onPlanGddDecision: vi.fn(),
|
||||
runtime: null,
|
||||
error: '',
|
||||
runtimeByAgentId: {},
|
||||
controlBusy: false,
|
||||
professionalResultsByAgentId: {},
|
||||
onToolAction: vi.fn(),
|
||||
onSupervisorRetry: vi.fn(),
|
||||
onProfessionalToolAction: vi.fn(),
|
||||
onProfessionalRetry: vi.fn(),
|
||||
onUserInput: vi.fn(),
|
||||
initialSupervisorMessage: '',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待首个响应时的临时"思考中…":只在 Direct 忙碌、本轮已有用户消息但**还没有任何过程 /
|
||||
* 正文**时,在工具组之外顶一条;本轮任何 reasoning / assistant / tool 到达即消失。
|
||||
*/
|
||||
it('shows a temporary thinking row until the first reasoning, text or tool arrives', () => {
|
||||
const historyUser = {
|
||||
itemId: 'direct-codex:turn-1:user',
|
||||
kind: 'message',
|
||||
role: 'user',
|
||||
text: '上一轮的问题',
|
||||
at: 1_000_000,
|
||||
};
|
||||
const historyFinal = {
|
||||
itemId: 'direct-codex:turn-1:assistant',
|
||||
kind: 'message',
|
||||
role: 'assistant',
|
||||
text: '上一轮的答复',
|
||||
at: 1_001_000,
|
||||
};
|
||||
// 本地乐观新用户消息:还没有任何正式条目,所以本轮 process / finals 都是空的。
|
||||
const optimisticUser = {
|
||||
role: 'user' as const,
|
||||
text: '这一轮的新问题',
|
||||
messageId: 'direct-codex:turn-2:user',
|
||||
updatedAt: 2_000_000,
|
||||
};
|
||||
const busyProps = supervisorSurfaceProps({
|
||||
directTurnRunning: true,
|
||||
directEntries: [historyUser, historyFinal],
|
||||
conversationMessages: [optimisticUser],
|
||||
});
|
||||
const thinkingRows = (surface: HTMLElement) =>
|
||||
Array.from(surface.querySelectorAll('*')).filter(
|
||||
(node) =>
|
||||
node.children.length === 0 &&
|
||||
/思考中/.test(node.textContent ?? '') &&
|
||||
!node.closest('[data-testid="agent-tool-call-group"]'),
|
||||
);
|
||||
|
||||
// 旧历史 final + 本地新用户 + 忙碌:思考中顶位,且不在工具组里。
|
||||
const view = render(
|
||||
React.createElement(ProjectSupervisorView, busyProps as never),
|
||||
);
|
||||
const surface = screen.getByLabelText('陶泥儿项目对话');
|
||||
expect(thinkingRows(surface)).toHaveLength(1);
|
||||
|
||||
// 本轮开口条目落盘后 reasoning 到达 → 消失。
|
||||
const turn2User = {
|
||||
itemId: 'direct-codex:turn-2:user',
|
||||
kind: 'message',
|
||||
role: 'user',
|
||||
text: '这一轮的新问题',
|
||||
at: 2_000_050,
|
||||
};
|
||||
view.rerender(
|
||||
React.createElement(ProjectSupervisorView, {
|
||||
...busyProps,
|
||||
directEntries: [
|
||||
historyUser,
|
||||
historyFinal,
|
||||
turn2User,
|
||||
{
|
||||
itemId: 'direct-codex:turn-2:reasoning',
|
||||
kind: 'reasoning',
|
||||
role: null,
|
||||
text: '先看目录',
|
||||
at: 2_000_100,
|
||||
},
|
||||
],
|
||||
} as never),
|
||||
);
|
||||
expect(thinkingRows(surface)).toHaveLength(0);
|
||||
|
||||
// 本轮 assistant 正文到达 → 消失。
|
||||
view.rerender(
|
||||
React.createElement(ProjectSupervisorView, {
|
||||
...busyProps,
|
||||
directEntries: [
|
||||
historyUser,
|
||||
historyFinal,
|
||||
turn2User,
|
||||
{
|
||||
itemId: 'direct-codex:turn-2:assistant',
|
||||
kind: 'message',
|
||||
role: 'assistant',
|
||||
text: '我先看一下项目',
|
||||
at: 2_000_200,
|
||||
},
|
||||
],
|
||||
} as never),
|
||||
);
|
||||
expect(thinkingRows(surface)).toHaveLength(0);
|
||||
|
||||
// 本轮工具到达(工具组出现)→ 消失,且不是被工具组内文字顶掉的。
|
||||
view.rerender(
|
||||
React.createElement(ProjectSupervisorView, {
|
||||
...busyProps,
|
||||
directEntries: [
|
||||
historyUser,
|
||||
historyFinal,
|
||||
turn2User,
|
||||
{
|
||||
itemId: 'call-2',
|
||||
kind: 'tool',
|
||||
role: null,
|
||||
text: null,
|
||||
at: 0,
|
||||
toolCall: {
|
||||
schemaVersion: 'agc-tool-call.v1',
|
||||
id: 'call-2',
|
||||
kind: 'command',
|
||||
title: '执行命令',
|
||||
summary: 'npm run build',
|
||||
status: 'running',
|
||||
detail: { command: 'npm run build' },
|
||||
startedAt: 2_000_300,
|
||||
updatedAt: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
} as never),
|
||||
);
|
||||
expect(
|
||||
within(surface).getAllByTestId('agent-tool-call-group'),
|
||||
).toHaveLength(1);
|
||||
expect(thinkingRows(surface)).toHaveLength(0);
|
||||
|
||||
// 空闲(不忙)→ 不显示;终止中 → 不显示。
|
||||
view.rerender(
|
||||
React.createElement(ProjectSupervisorView, {
|
||||
...busyProps,
|
||||
directTurnRunning: false,
|
||||
directEntries: [historyUser, historyFinal],
|
||||
directTurnStartedAt: 0,
|
||||
} as never),
|
||||
);
|
||||
expect(thinkingRows(surface)).toHaveLength(0);
|
||||
view.rerender(
|
||||
React.createElement(ProjectSupervisorView, {
|
||||
...busyProps,
|
||||
turnCancelling: true,
|
||||
} as never),
|
||||
);
|
||||
expect(thinkingRows(surface)).toHaveLength(0);
|
||||
view.unmount();
|
||||
|
||||
// 只有旧历史、没有任何本轮用户消息:即使忙碌也不显示。
|
||||
const historyOnly = render(
|
||||
React.createElement(ProjectSupervisorView, {
|
||||
...busyProps,
|
||||
directEntries: [historyUser, historyFinal],
|
||||
conversationMessages: [],
|
||||
} as never),
|
||||
);
|
||||
expect(thinkingRows(screen.getByLabelText('陶泥儿项目对话'))).toHaveLength(
|
||||
0,
|
||||
);
|
||||
historyOnly.unmount();
|
||||
});
|
||||
|
||||
it('keeps the initial supervisor placeholder from duplicating the landed direct user entry', () => {
|
||||
// 初始占位气泡只在"最初那条消息还没有正式条目"时顶位:
|
||||
// Direct 模式的正式条目走 `directEntries`、不进 `conversationMessages`,
|
||||
|
||||
@@ -34,6 +34,34 @@ function toolCall(
|
||||
}
|
||||
|
||||
export function registerToolCallGroupTests() {
|
||||
it('keeps failed operations visible when another tool in the group is running', () => {
|
||||
const view = render(
|
||||
React.createElement(ToolCallGroup, {
|
||||
active: true,
|
||||
calls: [
|
||||
toolCall({ id: 'failed', kind: 'command', status: 'failed' }),
|
||||
toolCall({ id: 'live', kind: 'command', status: 'running' }),
|
||||
toolCall({ id: 'done', kind: 'command', status: 'completed' }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
const group = within(view.container).getByTestId('agent-tool-call-group');
|
||||
const head = within(group).getByTestId('agent-tool-call-group-head');
|
||||
expect(group.getAttribute('data-status')).toBe('running');
|
||||
expect(group.getAttribute('data-has-failure')).toBe('true');
|
||||
expect(head.textContent).toContain('有操作失败');
|
||||
expect(head.textContent).toContain('进行中');
|
||||
fireEvent.click(head);
|
||||
const rows = within(group).getAllByTestId('agent-tool-call-row');
|
||||
expect(rows.map((row) => row.getAttribute('data-status'))).toEqual([
|
||||
'failed',
|
||||
'running',
|
||||
'completed',
|
||||
]);
|
||||
expect(within(rows[0]).getByText('失败')).not.toBeNull();
|
||||
expect(within(rows[2]).queryByText('失败')).toBeNull();
|
||||
});
|
||||
|
||||
it('summarizes the group as its own operation count', () => {
|
||||
// 用户口径:`执行了 X 个操作`,X = 这一组自己的调用数(不拆 kind、不看段落数)。
|
||||
expect(toolCallGroupSummary([toolCall({ id: 'a', kind: 'command' })])).toBe(
|
||||
@@ -233,15 +261,15 @@ export function registerToolCallGroupTests() {
|
||||
}),
|
||||
),
|
||||
).toBeNull();
|
||||
// 合法 0 显示 `0.0s`;两种耗时都始终一位小数。
|
||||
// 合法 0 显示 0.0秒;分钟以内保留小数,达到分钟后显示整数秒。
|
||||
expect(formatToolCallDuration(0)).toBe('0.0秒');
|
||||
expect(formatToolCallDuration(400)).toBe('0.4秒');
|
||||
expect(formatToolCallDuration(950)).toBe('1.0秒');
|
||||
expect(formatToolCallDuration(12300)).toBe('12.3秒');
|
||||
expect(formatToolCallDuration(12000)).toBe('12.0秒');
|
||||
expect(formatToolCallDuration(59900)).toBe('59.9秒');
|
||||
expect(formatToolCallDuration(60000)).toBe('1分00.0秒');
|
||||
expect(formatToolCallDuration(125000)).toBe('2分05.0秒');
|
||||
expect(formatToolCallDuration(60000)).toBe('1分00秒');
|
||||
expect(formatToolCallDuration(125000)).toBe('2分05秒');
|
||||
|
||||
// 整轮总耗时 = 本轮起点 → 本轮终态(不是块内工具的时间跨度)。
|
||||
expect(turnTotalDurationMs({ startedAt: 1000, endedAt: 9000 })).toBe(8000);
|
||||
@@ -254,14 +282,14 @@ export function registerToolCallGroupTests() {
|
||||
expect(turnTotalDurationMs({ startedAt: 1049, endedAt: 1001 })).toBeNull();
|
||||
expect(turnTotalDurationMs({ startedAt: 0, endedAt: 9000 })).toBeNull();
|
||||
expect(turnTotalDurationMs({ startedAt: 1000, endedAt: 0 })).toBeNull();
|
||||
// 总耗时文案:合法 0 显示 `0.0秒`,始终一位小数。
|
||||
// 总耗时复用同一格式,达到分钟后不再显示小数。
|
||||
expect(formatTurnDuration(0)).toBe('0.0秒');
|
||||
expect(formatTurnDuration(8000)).toBe('8.0秒');
|
||||
expect(formatTurnDuration(42000)).toBe('42.0秒');
|
||||
expect(formatTurnDuration(59900)).toBe('59.9秒');
|
||||
expect(formatTurnDuration(60000)).toBe('1分00.0秒');
|
||||
expect(formatTurnDuration(240000)).toBe('4分00.0秒');
|
||||
expect(formatTurnDuration(345000)).toBe('5分45.0秒');
|
||||
expect(formatTurnDuration(60000)).toBe('1分00秒');
|
||||
expect(formatTurnDuration(240000)).toBe('4分00秒');
|
||||
expect(formatTurnDuration(345000)).toBe('5分45秒');
|
||||
expect(formatTurnDuration(null)).toBeNull();
|
||||
expect(formatTurnDuration(undefined)).toBeNull();
|
||||
|
||||
@@ -372,7 +400,7 @@ export function registerToolCallGroupTests() {
|
||||
});
|
||||
|
||||
/**
|
||||
* 动态计时:组用时与单条耗时都每 100ms 刷新、始终一位小数。
|
||||
* 动态计时:每 100ms 更新,分钟以内显示小数,达到分钟后显示整数秒。
|
||||
*
|
||||
* 假时钟同时接管 `Date.now()`:`useLiveNow` 读时间戳算差值,不按 tick 累加。
|
||||
*/
|
||||
@@ -417,11 +445,11 @@ export function registerToolCallGroupTests() {
|
||||
});
|
||||
expect(group.getAttribute('data-duration-ms')).toBe('5400');
|
||||
expect(head.textContent).toContain('耗时 5.4秒');
|
||||
// 分钟进位:65400ms → `1分05.4秒`。
|
||||
// 分钟进位后隐藏小数:65400ms → 1分05秒。
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(60000);
|
||||
});
|
||||
expect(head.textContent).toContain('耗时 1分05.4秒');
|
||||
expect(head.textContent).toContain('耗时 1分05秒');
|
||||
|
||||
// 组内工具全部拿到终态:组用时冻结在**工具终点**上(不是当前时钟),
|
||||
// 继续推进时钟不再变化,也不等回合收口。
|
||||
@@ -444,7 +472,7 @@ export function registerToolCallGroupTests() {
|
||||
vi.advanceTimersByTime(30000);
|
||||
});
|
||||
expect(group.getAttribute('data-duration-ms')).toBe('65000');
|
||||
expect(head.textContent).toContain('耗时 1分05.0秒');
|
||||
expect(head.textContent).toContain('耗时 1分05秒');
|
||||
expect(head.textContent).not.toContain('进行中');
|
||||
view.unmount();
|
||||
vi.useRealTimers();
|
||||
|
||||
@@ -211,6 +211,65 @@ describe('DirectProject 聊天 reducer', () => {
|
||||
expect(selectDirectChatEntries(state)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('退出码非零优先于 completed:命令跑完但失败不标成功', () => {
|
||||
const command = (
|
||||
status: string | null,
|
||||
exitCode: number | null,
|
||||
itemId: string,
|
||||
) => ({
|
||||
itemType: 'commandExecution' as const,
|
||||
itemId,
|
||||
command: 'npm test',
|
||||
output: 'boom',
|
||||
status,
|
||||
exitCode,
|
||||
at: 2_000,
|
||||
});
|
||||
// status=completed + exitCode=1:失败(不能因为"跑完了"就标成功)。
|
||||
const failed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
|
||||
event({
|
||||
type: 'item.completed',
|
||||
at: 2_000,
|
||||
item: command('completed', 1, 'call-fail'),
|
||||
}),
|
||||
]);
|
||||
expect(selectDirectChatEntries(failed)[0]?.toolCall?.status).toBe('failed');
|
||||
|
||||
// status=completed + exitCode=0:成功。
|
||||
const ok = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
|
||||
event({
|
||||
type: 'item.completed',
|
||||
at: 2_000,
|
||||
item: command('completed', 0, 'call-ok'),
|
||||
}),
|
||||
]);
|
||||
expect(selectDirectChatEntries(ok)[0]?.toolCall?.status).toBe('completed');
|
||||
|
||||
// status=inProgress 且没有退出码:仍在跑。
|
||||
const running = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
|
||||
event({
|
||||
type: 'item.started',
|
||||
at: 2_000,
|
||||
item: command('inProgress', null, 'call-running'),
|
||||
}),
|
||||
]);
|
||||
expect(selectDirectChatEntries(running)[0]?.toolCall?.status).toBe(
|
||||
'running',
|
||||
);
|
||||
|
||||
// 上游显式失败终态优先于 completed:即使 exitCode=0 也算失败。
|
||||
const declined = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
|
||||
event({
|
||||
type: 'item.completed',
|
||||
at: 2_000,
|
||||
item: command('declined', 0, 'call-declined'),
|
||||
}),
|
||||
]);
|
||||
expect(selectDirectChatEntries(declined)[0]?.toolCall?.status).toBe(
|
||||
'failed',
|
||||
);
|
||||
});
|
||||
|
||||
it('历史条目与运行态按唯一 id 合并,重复条目只出现一次', () => {
|
||||
const historical = mergeDirectHistoryItems(emptyDirectThreadChatState(), [
|
||||
toolStarted(),
|
||||
|
||||
@@ -12,11 +12,14 @@ describe('统一中文耗时', () => {
|
||||
[0, '0.0秒'],
|
||||
[5200, '5.2秒'],
|
||||
[59_949, '59.9秒'],
|
||||
[59_950, '1分00.0秒'],
|
||||
[125_200, '2分05.2秒'],
|
||||
[3_599_950, '1时00分00.0秒'],
|
||||
[3_725_200, '1时02分05.2秒'],
|
||||
[90_061_200, '25时01分01.2秒'],
|
||||
[59_950, '1分00秒'],
|
||||
[60_000, '1分00秒'],
|
||||
[60_100, '1分00秒'],
|
||||
[65_600, '1分06秒'],
|
||||
[125_200, '2分05秒'],
|
||||
[3_599_950, '1时00分00秒'],
|
||||
[3_725_200, '1时02分05秒'],
|
||||
[90_061_200, '25时01分01秒'],
|
||||
])('%s ms → %s,所有入口一致', (ms, expected) => {
|
||||
expect(formatElapsedDuration(ms)).toBe(expected);
|
||||
expect(formatToolCallDuration(ms)).toBe(expected);
|
||||
|
||||
@@ -209,7 +209,7 @@ describe('生成任务模型', () => {
|
||||
|
||||
test('已耗时文案按分秒呈现', () => {
|
||||
expect(resourceCanvasAssetGenerationElapsedLabel(12_000)).toBe('12.0秒');
|
||||
expect(resourceCanvasAssetGenerationElapsedLabel(72_000)).toBe('1分12.0秒');
|
||||
expect(resourceCanvasAssetGenerationElapsedLabel(72_000)).toBe('1分12秒');
|
||||
expect(resourceCanvasAssetGenerationElapsedLabel(-5)).toBe('—');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -124,7 +124,8 @@ describe('「生成任务」侧栏', () => {
|
||||
expect(within(doneSection).getByText('生成已完成。')).not.toBeNull();
|
||||
expect(within(doneSection).getByRole('alert').textContent).toBe('远端拒绝');
|
||||
expect(
|
||||
screen.getAllByText(/^已耗时 (?:\d+时)?(?:\d+分)?\d+\.\d秒$/).length,
|
||||
screen.getAllByText(/^已耗时 (?:\d+\.\d秒|(?:\d+时)?\d+分\d{2}秒)$/)
|
||||
.length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
## 开发中
|
||||
|
||||
- AGC 思考与执行入口共用共享单行摘要骨架;Markdown 只在展开正文走既有安全渲染,折叠预览只取纯文本,不在 summary 嵌套链接或按钮。耗时统一复用中文时分秒格式(秒一位小数),格式化与各层计时边界分离,不因统一文案改变状态来源。
|
||||
- AGC 思考与执行入口共用共享单行摘要骨架;Markdown 只在展开正文走既有安全渲染,折叠预览只取纯文本,不在 summary 嵌套链接或按钮。耗时统一复用中文时分秒格式(不足一分钟一位小数,达到分钟后整数秒),格式化与各层计时边界分离。过程行在运行中和完成后的折叠层内保持同一紧凑间距;失败状态按明确终态与非零退出码呈现红色,不由自然语言输出猜测。
|
||||
|
||||
- Direct 对话计时区分条目展示时间与生命周期事件时间:整轮用用户发送到明确终态的跨度,工具用各自开始/完成边界;运行时用 100ms 叶子时钟刷新一位小数,终态冻结,旧历史缺边界不推测。不得用整秒时间的大小比较取代 Thread Manager 的事件顺序判定新回合。
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
## 一句话交付
|
||||
|
||||
把 GameAgent 右侧对话面板里的「执行命令 / 写文件 / 调工具」改成可折叠卡片,整轮总耗时只在回合状态/完成小结显示一处,工具组与内部工具分别显示各自耗时;运行中的计时动态增长并保留一位小数。
|
||||
把 GameAgent 右侧对话面板里的「执行命令 / 写文件 / 调工具」改成可折叠卡片,整轮总耗时只在回合状态/完成小结显示一处,工具组与内部工具分别显示各自耗时;运行中的计时动态增长,不足一分钟保留一位小数,达到分钟后显示整数秒。
|
||||
|
||||
## 总耗时与动态工具计时
|
||||
|
||||
@@ -19,8 +19,12 @@
|
||||
|
||||
- 思考过程、执行过程和工具组统一为无大块底色的单行折叠入口:左侧小图标/简短预览,最右侧展开箭头;长文本省略,不把箭头挤出窄聊天列。沿用共享过程色和 12px 层级,键盘可展开、有可见焦点。
|
||||
- 思考折叠态直接显示浅色的内容预览,而不是只有“思考过程”标题;预览不显示 Markdown 控制符、原始 HTML 或链接地址。展开后复用现有安全 Markdown 渲染链路,支持段落、强调、列表、链接、代码块等,不开启原始 HTML 执行。实时与历史、当前 Agent 与策划 Agent 使用同一呈现。
|
||||
- 思考入口使用灯泡图标;展开后入口文字切换为“思考过程”,原内容仅在 Markdown 正文显示一份,不同时保留相同摘要。收起后恢复浅色内容预览;键盘开合与鼠标开合行为一致。
|
||||
- 回合结束后最外层“执行了 N 个操作,耗时 XXX”使用正文颜色;收进该折叠层的内部过程保持运行中原有颜色、行高和块间距,不能因额外 Grid gap 与子元素 margin 叠加而拉大间距。
|
||||
- 思考与工具组按紧凑连续列表呈现,相邻过程块间距 4px,内部工具行使用紧凑的 2px 上下内边距;运行与完成后的容器使用同一口径。失败工具的名称、图标、状态及耗时使用错误红色,所属组摘要有“有操作失败”提示且标红;同组其它工具仍运行时也不能吞掉失败标识,成功行不得被连带染红。
|
||||
- 工具组摘要只统计本组工具条目总数,显示“执行了 N 个操作,耗时 XXX”;运行中另有状态提示,不把操作数称作成功数,失败仍保留明确状态。回合外层执行过程统计所有工具块的操作数,不把思考段落或文本消息当工具操作;缺失耗时不伪造。
|
||||
- 所有 AGC 耗时统一用中文时分秒:`5.2秒`、`2分05.2秒`、`1时02分05.2秒`。省略前导零单位,带小时则保留两位分钟,带分钟则秒补齐两位整数,秒始终一位小数;先整体舍入再拆分单位,避免出现 60 秒/60 分。工具行、组、整轮、生成任务及策划耗时共用一个纯格式化函数,不改变各自计时来源。
|
||||
- 所有 AGC 耗时统一用中文时分秒:不足一分钟显示 `5.2秒`,达到分钟后显示整数秒,如 `2分05秒`、`1时02分05秒`。省略前导零单位,带小时则保留两位分钟,带分钟则秒补齐两位整数;先整体舍入再拆分单位,避免出现 60 秒/60 分。工具行、组、整轮、生成任务及策划耗时共用一个纯格式化函数,不改变各自计时来源和动态刷新。
|
||||
- Direct 对话发送后、本轮首个可见思考/文字/工具响应到达前,在消息列表内、工具调用块外临时显示“思考中…”。收到首个响应后移除,失败、取消和空闲时同样不显示;直接从现有忙碌状态与本轮投影派生,不新增原生事件、定时器或持久化消息。工具间等待不在本次轻量实现范围内。
|
||||
- 独立计时保持不变,组状态/用时在单行中作为次要信息,空间不足可移至展开内容,不能占第二行破坏紧凑入口;整轮总耗时仍只显示一次。工具输入/输出、失败信息与操作明细不因样式变更丢失。
|
||||
- 只改展示与对应测试,不改变消息身份、分组顺序、生成或原生执行语义。验证覆盖 Markdown 安全与语义、预览省略、准确计数、展开/键盘操作、窄屏布局及原有计时冻结。
|
||||
|
||||
@@ -129,9 +133,9 @@ DirectRuntime 写 `<projectRoot>/.agent/conversations/tool-calls.jsonl`;回读
|
||||
```
|
||||
|
||||
- 文案规则(按 kind,不允许自由发挥):
|
||||
- 块头按实际工具条目计总数,形如 `执行了 7 个操作,耗时 1分05.2秒`;空集合不渲染块。操作数不是成功数,失败与运行状态仍单独可见。
|
||||
- 块头按实际工具条目计总数,形如 `执行了 7 个操作,耗时 1分05秒`;空集合不渲染块。操作数不是成功数,失败与运行状态仍单独可见。
|
||||
- 行文案:展示工具摘要,不重复添加动词前缀;`context_compaction` 固定为“整理上下文”。状态单独放在行尾(执行中 / 已执行 / 失败),`failed` 使用现有 `--platform-*` 错误色;已结束回合不因残留 `running` 快照显示“执行中”。
|
||||
- 耗时:执行“总耗时与动态工具计时”合同。块头是本组用时,单条是该工具独立耗时,整轮总耗时只在本轮状态/小结显示;均保留一位小数,运行中每 100 毫秒刷新,各自终态固定。
|
||||
- 耗时:执行“总耗时与动态工具计时”合同。块头是本组用时,单条是该工具独立耗时,整轮总耗时只在本轮状态/小结显示;不足一分钟显示一位小数,达到分钟后显示整数秒,运行中每 100 毫秒刷新,各自终态固定。
|
||||
- 时间:范围与对应层级用时采用同一边界,不把用户发送起点与局部工具组终点混搭。缺失的历史时间不编造。
|
||||
- 回合结束时间与耗时在正文下方右对齐;Direct 对话输入框提示统一为“描述你的想法,或 @ 引用素材”,引用按钮保留输入盒的 12px 内边距,不使用负边距贴边。
|
||||
- 当前 Agent 与策划 Agent 共用 `packages/shared` 的 `AgentMessageContent` 表现组件:正文为 14px / `--platform-text-strong`,思考、中间输出和工具调用为 12px / `--platform-text-soft`。实时与历史思考共用同一个折叠入口;工具输入输出继承过程色,失败状态保留错误色。Markdown 标题、表格及代码高亮在过程区同步弱化,最终回复和文档预览仍保留正常排版,不按 Agent 类型复制样式。输入提示与禁用状态保持原有反馈。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 毫秒耗时的统一显示:5.2秒 / 2分05.2秒 / 1时02分05.2秒。
|
||||
* 保留十分之一秒,先整体舍入再拆单位,避免 60.0 秒或 60 分溢出。
|
||||
* 毫秒耗时的统一显示:5.2秒 / 2分05秒 / 1时02分05秒。
|
||||
* 不足一分钟保留一位小数,达到分钟后显示整数秒;先舍入再拆单位,避免进位溢出。
|
||||
* 非法或未知耗时返回 null,不能以 0 冒充已测得结果。
|
||||
*/
|
||||
export function formatElapsedDuration(
|
||||
@@ -14,13 +14,13 @@ export function formatElapsedDuration(
|
||||
return null;
|
||||
}
|
||||
const tenths = Math.round(milliseconds / 100);
|
||||
const hours = Math.floor(tenths / 36_000);
|
||||
const minutes = Math.floor((tenths % 36_000) / 600);
|
||||
const seconds = ((tenths % 600) / 10).toFixed(1);
|
||||
if (tenths < 600) return `${(tenths / 10).toFixed(1)}秒`;
|
||||
const totalSeconds = Math.round(milliseconds / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = String(totalSeconds % 60).padStart(2, '0');
|
||||
if (hours > 0) {
|
||||
return `${hours}时${String(minutes).padStart(2, '0')}分${seconds.padStart(4, '0')}秒`;
|
||||
return `${hours}时${String(minutes).padStart(2, '0')}分${seconds}秒`;
|
||||
}
|
||||
return minutes > 0
|
||||
? `${minutes}分${seconds.padStart(4, '0')}秒`
|
||||
: `${seconds}秒`;
|
||||
return `${minutes}分${seconds}秒`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user