恢复合并中被回退的 Direct 前端架构
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled

恢复主分支的线程事件消费、聊天投影、历史锚点与分页模块。
恢复过程折叠、工具卡片计时以及对应测试和测试桩。
保留策划 V1/V2 退役清理及 Design Agent 总控面板修复。
修正 Direct 验证文档中的测试入口。
验证通过:60 个 Direct 单元测试、69 个相关界面测试、4 个 Design Agent 测试及静态检查。
完整界面测试仍有 8 个资源 UI 测试失败,涉及旧卡片断言和提示词输入方式,本次未修改。
This commit is contained in:
2026-09-18 11:30:56 +00:00
parent 5a712910b7
commit 8909b18a15
16 changed files with 4547 additions and 2458 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -6,26 +6,32 @@ import {
Terminal,
Wrench,
} from 'lucide-react';
import { useEffect, useId, useState } from 'react';
import { useId, useState } from 'react';
import { AgentMessageContent } from '../../../../../packages/shared/src/components/AgentMessageContent';
import type { GameCreatorDirectToolCall } from '../../app/types';
import { AgentProcessSummary } from '../../../../../packages/shared/src/components/AgentProcessSummary';
import type { DirectChatToolCard } from './directThreadChat';
import {
formatToolCallDuration,
formatTurnDuration,
resolveToolGroupTiming,
toolCallDurationMs,
toolCallGroupSummary,
toolCallInputText,
toolCallRowText,
turnToolCallDurationMs,
turnToolCallTimeLabel,
} from './toolCallGroupPresentation';
import { useLiveNow } from './useLiveNow';
/**
* 一回合的工具调用折叠块(Codex 风格):
* 块头一行汇总 + 该回合总用时 + 结束时间,展开态每行一条工具(行可二级展开看命令 / 文件明细 / 输出)。
* 块头一行汇总 + 本组用时,展开态每行一条工具(行可二级展开看命令 / 文件明细 / 输出)。
* 契约见 `docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md`。
*
* 计时口径(组用时与单条耗时各一套,都是 100ms 粒度、始终一位小数):
* - 本组用时属于**这一组工具**`min(工具开始)` → `max(工具完成)`。同轮另一组还在跑不会让它
* 继续增长;本组工具全部拿到终态就立即冻结。缺开始 / 缺终态边界时隐藏,不借别组或整轮边界。
* 整轮总耗时(用户发送 → 回合终态)只在对话底部渲染一处,不在这里重复。
* - 单条耗时只认该工具的事件级边界(`item.started` → `item.completed`),不看条目展示时间。
*
* 无障碍:块头与每一行都是 `<button aria-expanded>` + `hidden` 控制正文,
* 所以 Tab 可达、Enter/Space 可切换、读屏能读到展开状态与 `aria-label`。
* 文案与耗时规则(纯函数)在 `toolCallGroupPresentation.ts`。
@@ -34,75 +40,53 @@ import {
/** 一回合的工具调用折叠块;空集合不渲染。 */
export function ToolCallGroup({
calls,
userSentAt = 0,
active = false,
className,
}: {
calls: GameCreatorDirectToolCall[];
/** 同一回合用户消息的 `updatedAt`;拿不到就传 0,只显示结束时间。 */
userSentAt?: number | null;
calls: DirectChatToolCard[];
/**
* 这个块是否属于**正在跑的回合**。只有正在跑的回合才允许出现"执行中 / 进行中"
* 已结束的回合里若还留着 `running` 快照(进程被杀、completed 事件没到),
* 是数据残留,不该让用户一直看到"执行中"。
* 这个块是否属于**正在跑的回合**:决定本组是否还能用当前时钟增长,以及残留的
* `running` 快照要不要显示"执行中 / 进行中"。已结束的回合里残留的 `running` 快照
* (进程被杀、completed 事件没到)是数据残留,不该让用户一直看到"执行中"。
*/
active?: boolean;
className?: string;
}) {
const [expanded, setExpanded] = useState(false);
const [tick, setTick] = useState(0);
const bodyId = useId();
const hasRunningSnapshot = calls.some((call) => call.status === 'running');
const running = active && hasRunningSnapshot;
// 正在跑时每秒重算一次"已用时",否则这个数字只在收到新事件时才变。
useEffect(() => {
if (!running) {
return undefined;
}
const timer = setInterval(() => setTick((current) => current + 1), 1000);
return () => clearInterval(timer);
}, [running]);
if (calls.length === 0) {
return null;
}
void tick;
const orderedCalls = [...calls].sort(
(left, right) => left.startedAt - right.startedAt,
);
const summary = toolCallGroupSummary(orderedCalls, running);
const startedAt =
orderedCalls.find((call) => call.startedAt > 0)?.startedAt ?? 0;
// 进行中:用"现在 - 最早开始时间"(会随 tick 每秒变);已结束:用快照里的
// min(startedAt) → max(updatedAt),不掺入本地时钟
const totalDurationMs = running
? startedAt > 0
? (() => {
const latestUpdatedAt = Math.max(
...orderedCalls.map((call) => call.updatedAt || 0),
);
// A running snapshot can still carry a monotonic update time. Use
// that delta when available; fall back to wall-clock elapsed time
// for live events that have no update timestamp yet.
return latestUpdatedAt > startedAt
? latestUpdatedAt - startedAt
: Math.max(0, Date.now() - startedAt);
})()
: null
: turnToolCallDurationMs(orderedCalls);
const durationText = formatTurnDuration(totalDurationMs);
const timeLabel = turnToolCallTimeLabel(orderedCalls, userSentAt);
const status = running
? 'running'
: orderedCalls.some((call) => call.status === 'failed')
? 'failed'
: 'completed';
const headLabel = [
summary,
running ? '进行中' : '',
durationText ? `用时 ${durationText}` : '',
const hasRunningSnapshot = calls.some((call) => call.status === 'running');
// 行级"执行中"与 `data-status` 看**这一组**是否还有工具在跑:同轮另一组在跑、或回合还在
// 推理,都不会让已经跑完的组继续标"进行中"。
const toolRunning = active && hasRunningSnapshot;
// 是否需要跟着墙上时钟走:只取决于本组里"还有工具在跑(且回合还在跑)"
const ticking =
active &&
orderedCalls.some(
(call) => call.status === 'running' && call.startedAt > 0,
);
const now = useLiveNow(ticking);
if (calls.length === 0) {
return null;
}
// 本组用时:只看这一组自己的工具边界;本组全完成即冻结。
const timing = resolveToolGroupTiming(orderedCalls, { running: active, now });
const summary = toolCallGroupSummary(orderedCalls);
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)
.join('');
const headLabel = [summary, headMeta].filter(Boolean).join('');
return (
<AgentMessageContent
as="section"
@@ -115,7 +99,8 @@ export function ToolCallGroup({
data-testid="agent-tool-call-group"
aria-label="陶泥儿执行过程"
data-status={status}
data-duration-ms={totalDurationMs ?? ''}
data-has-failure={hasFailure ? 'true' : undefined}
data-duration-ms={timing.durationMs ?? ''}
>
<button
type="button"
@@ -127,26 +112,17 @@ export function ToolCallGroup({
title={headLabel}
onClick={() => setExpanded((current) => !current)}
>
<span className="agent-tool-call-group-icon" aria-hidden="true">
<ToolCallKindIcon kind={orderedCalls[0]?.kind ?? 'other'} />
</span>
<span className="agent-tool-call-group-summary">{summary}</span>
<span className="agent-tool-call-group-meta">
{running ? (
<span className="agent-tool-call-group-running"></span>
) : timeLabel ? (
<span className="agent-tool-call-group-time">{timeLabel}</span>
) : null}
{durationText ? (
<span className="agent-tool-call-group-duration">
{`用时 ${durationText}`}
</span>
) : null}
</span>
<ChevronDown
className="agent-tool-call-group-chevron"
size={14}
aria-hidden="true"
<AgentProcessSummary
icon={<ToolCallKindIcon kind={orderedCalls[0]?.kind ?? 'other'} />}
preview={summary}
meta={headMeta ? `${headMeta}` : ''}
chevron={
<ChevronDown
className="agent-tool-call-group-chevron"
size={14}
aria-hidden="true"
/>
}
/>
</button>
<div
@@ -156,7 +132,7 @@ export function ToolCallGroup({
>
<ul className="agent-tool-call-group-rows">
{orderedCalls.map((call) => (
<ToolCallRow key={call.id} call={call} active={active} />
<ToolCallRow key={call.id} call={call} active={active} now={now} />
))}
</ul>
</div>
@@ -168,14 +144,18 @@ export function ToolCallGroup({
function ToolCallRow({
call,
active,
now,
}: {
call: GameCreatorDirectToolCall;
call: DirectChatToolCard;
active: boolean;
/** 所属回合的运行时钟;只在"该工具仍在跑"时用得上。 */
now: number;
}) {
const [expanded, setExpanded] = useState(false);
const detailId = useId();
const text = toolCallRowText(call);
const durationMs = toolCallDurationMs(call);
const liveRunning = active && call.status === 'running';
const durationMs = toolCallDurationMs(call, { now, running: liveRunning });
const durationText = formatToolCallDuration(durationMs);
// "执行中"只在**正在跑的回合**里显示;已结束的回合里残留的 running 快照按已完成处理,
// 否则用户会看到一条永远停在"执行中"的记录。
@@ -0,0 +1,59 @@
/**
* 首屏历史锚点闸门:把「订阅回执里的 `lastCompletedItemId`」交给「首屏切片读取」。
*
* 这个 id 是订阅那一刻最后一条已完成条目,也是历史切片与运行态事件的唯一分界:首屏切片只
* 允许取到它为止(含该条),比它更新的条目只能来自运行态事件。回执到达之前首屏必须等它,
* 不能退化成"取文件尾"——那会把回执之后才完成的条目也拉进历史,与运行态事件重叠。
*
* 生命周期:打开项目时首屏读取先开一道闸门(订阅 effect 还没跑),订阅侧复用同一道闸门并在
* 回执到达后 `settle` 它;订阅不可用 / 失败 / 切项目时 settle 成 `null`,首屏退化成取文件尾。
* 一道闸门只服务这次订阅的第一次首屏读取,用过之后置 `consumed`。
*/
export type DirectHistoryAnchorGate = {
projectPath: string;
anchor: Promise<string | null>;
settle: (anchor: string | null) => void;
/** 是否已经被一次首屏读取用掉:同一道闸门只锚定"这次订阅的第一次首屏"。 */
consumed: boolean;
};
/** 为某个项目开一道闸门:`settle` 由订阅侧调用,`anchor` 由首屏读取 `await`。 */
export function openDirectHistoryAnchorGate(
projectPath: string,
): DirectHistoryAnchorGate {
let settle: (anchor: string | null) => void = () => {};
const anchor = new Promise<string | null>((resolve) => {
settle = resolve;
});
return { projectPath, anchor, settle, consumed: false };
}
/** 订阅侧用:已有同项目闸门就复用(首屏可能已经开好),换项目才新开一道。 */
export function reuseOrOpenDirectHistoryAnchorGate(
current: DirectHistoryAnchorGate | null,
projectPath: string,
): DirectHistoryAnchorGate {
if (current && current.projectPath === projectPath) {
return current;
}
return openDirectHistoryAnchorGate(projectPath);
}
/**
* 首屏读取侧用:取这次要等的闸门,`null` 表示这次首屏不该等锚点。
*
* 没有这个项目的闸门(打开项目时订阅 effect 还没跑)就新开一道;同一个订阅下已经用过
* (重开同一个项目)没有新回执可等,返回 `null` 让调用方按当前文件尾取尾屏。
*/
export function directHistoryAnchorGateToWaitFor(
current: DirectHistoryAnchorGate | null,
projectPath: string,
): DirectHistoryAnchorGate | null {
const sameProjectGate =
current && current.projectPath === projectPath ? current : null;
if (!sameProjectGate) {
return openDirectHistoryAnchorGate(projectPath);
}
return sameProjectGate.consumed ? null : sameProjectGate;
}
@@ -0,0 +1,84 @@
/**
* DirectProject 历史分页连拉:一次翻页操作连续取页,直到出现用户看得见的变化。
*
* 可见性判断留在前端聊天投影(后端切片只按原始条目切片):工具卡片与思考文本同样占满一屏,
* 所以一整页完全可能都落进已经渲染的回合里,对用户就是"点了没变化"。判据因此取**合并后
* 聊天投影的回合数增加**(出现新的用户气泡),而不是"这一页里有没有能渲染的条目"。
*
* 这里是该判据的唯一出处:首屏与「显示更早」共用同一条循环,别在调用点各写一份。
*/
import { DIRECT_HISTORY_MAX_PAGES_PER_ACTION } from '../../app/constants';
import {
type DirectChatEntry,
mergeHistoryEntries,
projectDirectHistoryItems,
} from './directThreadChat';
import type {
DirectThreadHistorySlice,
DirectThreadItem,
} from './directThreadEvents';
import { buildDirectChatTurns } from './directTurnPresentation';
export type DirectHistoryPages = {
/** 累积到的条目,保持文件顺序(旧 → 新)。 */
items: DirectThreadItem[];
/** 后端声明的"还有更早的历史";取不动时收口为 false。 */
hasMore: boolean;
/** 下一屏的锚点;取不动或失败时停在最后一个可用的锚点上。 */
firstItemId: string | null;
/** 中断原因;`null` 表示正常停止。 */
error: unknown;
};
function visibleTurnCount(entries: readonly DirectChatEntry[]): number {
return buildDirectChatTurns({ entries }).length;
}
/** 取页是"从新往旧"的,拼回文件顺序要整个翻过来。 */
function inFileOrder(pages: readonly (readonly DirectThreadItem[])[]) {
return [...pages].reverse().flat();
}
export async function readDirectHistoryPages({
existingEntries,
beforeItemId,
readSlice,
}: {
/** 当前聊天视图里的条目:判据必须和用户看到的是同一份。 */
existingEntries: readonly DirectChatEntry[];
beforeItemId: string | null;
readSlice: (beforeItemId: string | null) => Promise<DirectThreadHistorySlice>;
}): Promise<DirectHistoryPages> {
const baseTurnCount = visibleTurnCount(existingEntries);
const pages: DirectThreadItem[][] = [];
let anchor = beforeItemId;
let hasMore = false;
let error: unknown = null;
for (let page = 0; page < DIRECT_HISTORY_MAX_PAGES_PER_ACTION; page += 1) {
let slice: DirectThreadHistorySlice;
try {
slice = await readSlice(anchor);
} catch (thrown) {
// 已经取到的页照常交给调用方:第 N 页失败不该丢掉前 N-1 页。
error = thrown;
break;
}
pages.push([...slice.items]);
hasMore = slice.hasMore;
const nextAnchor = slice.firstItemId;
if (slice.items.length === 0 || !nextAnchor || nextAnchor === anchor) {
// 锚点不前进就再也取不到更早的页:继续只会重复拿回同一个窗口。按"取不动了"收口。
hasMore = false;
break;
}
anchor = nextAnchor;
if (!hasMore) break;
const merged = mergeHistoryEntries(
projectDirectHistoryItems(inFileOrder(pages)),
existingEntries,
);
if (visibleTurnCount(merged) > baseTurnCount) break;
}
return { items: inFileOrder(pages), hasMore, firstItemId: anchor, error };
}
File diff suppressed because it is too large Load Diff
@@ -1,123 +1,17 @@
import type {
ChatMessage,
LocalConversationMessageRecord,
} from '../../app/types';
/**
* DirectProject 运行态事件的线上类型。
*
* 类型由 Rust 侧 ts-rs 导出(改完 Rust 模型后跑 `cargo test export_bindings`),这里只做
* 入口转发:前端不再自己抄一份形状,字段增删必须改 Rust。
*/
export type DirectThreadRawEvent = {
seq: number;
type: string;
turnId: string;
itemId?: string;
payload: Record<string, unknown>;
};
export type DirectThreadSubscriptionBootstrap = {
subscriptionId: string;
lastCompletedItemId: string | null;
events: DirectThreadRawEvent[];
};
export type DirectThreadConsumeResult = {
events: DirectThreadRawEvent[];
};
export type DirectThreadHistorySlice = {
items: unknown[];
hasMore: boolean;
itemTimestamps?: Record<string, number>;
oldestItemId?: string | null;
};
/** 游标取原始响应,而非过滤后的聊天消息;拒绝不能前进的页,避免静默反复回读。 */
export function directThreadHistoryPage(
slice: DirectThreadHistorySlice,
previousCursor: string | null = null,
) {
const first = slice.items[0];
const firstId =
first && typeof first === 'object' && 'id' in first
? (first as { id?: unknown }).id
: null;
const cursor =
slice.oldestItemId ??
(typeof firstId === 'string' && firstId ? firstId : null);
if (slice.hasMore && (!cursor || cursor === previousCursor)) {
throw new Error('对话历史分页游标未前进,请重新读取项目历史');
}
return {
messages: directThreadHistoryItemsToMessages(
slice.items,
slice.itemTimestamps,
),
hasMore: slice.hasMore,
cursor,
};
}
/** 保留当前实时/已显示版本;原始身份相同的回读消息不能插入第二次。 */
export function prependDirectHistoryMessages(
current: readonly ChatMessage[],
older: readonly ChatMessage[],
): ChatMessage[] {
const ids = new Set(
current.flatMap((message) =>
message.messageId ? [message.messageId] : [],
),
);
const additions = older.filter((message) => {
if (!message.messageId) return true;
if (ids.has(message.messageId)) return false;
ids.add(message.messageId);
return true;
});
return [...additions, ...current];
}
export function directThreadHistoryItemsToMessages(
items: unknown[],
itemTimestamps: Readonly<Record<string, number>> = {},
): LocalConversationMessageRecord[] {
return items.flatMap((raw) => {
if (!raw || typeof raw !== 'object') return [];
const item = raw as Record<string, unknown>;
const role = item.role;
if (role !== 'user' && role !== 'assistant') return [];
const messageRole = role as 'user' | 'assistant';
const content = Array.isArray(item.content)
? item.content
.map((part) =>
part && typeof part === 'object' && 'text' in part
? (part as { text?: unknown }).text
: null,
)
.filter((text): text is string => typeof text === 'string')
.join('')
: '';
if (!content) return [];
const messageId = typeof item.id === 'string' ? item.id : undefined;
return [
{
schemaVersion: 'agc-direct-project-context.v1',
role: messageRole,
content,
agentId: null,
messageId,
updatedAt: messageId ? (itemTimestamps[messageId] ?? 0) : 0,
},
];
});
}
/** 只有这些状态表示仍持有活动回合;Provider 回放的终态不是活动快照。 */
export function isDirectTurnInProgress(
status: string | null | undefined,
): status is 'accepted' | 'running' | 'streaming' | 'finalizing' {
return (
status === 'accepted' ||
status === 'running' ||
status === 'streaming' ||
status === 'finalizing'
);
}
export type { DirectThreadEvent, DirectThreadItem } from './generated';
export type {
DirectThreadConsumeResult,
DirectThreadDeltaKind,
DirectThreadEvent,
DirectThreadFileChange,
DirectThreadHistorySlice,
DirectThreadItem,
DirectThreadRequestKind,
DirectThreadSubscriptionBootstrap,
} from './generated';
@@ -0,0 +1,349 @@
/**
* DirectProject「原始条目 → 聊天条目」投影。
*
* 输入是 Rust 侧 ts-rs 导出的 `DirectThreadItem`(脱敏后的 Codex 原始条目),工具卡片的
* `kind`、标题、折叠摘要、状态判定和可见性全部在这里完成。运行态事件与历史切片走同一个
* 函数,因此实时与回读不可能出现两套口径。
*/
import type {
GameCreatorDirectToolCallChange,
GameCreatorDirectToolCallDetail,
GameCreatorDirectToolCallKind,
GameCreatorDirectToolCallStatus,
} from '../../app/types';
import type { DirectChatEntry, DirectChatToolCard } from './directThreadChat';
import type { DirectThreadItem } from './directThreadEvents';
/** 折叠态摘要上限,与卡片契约一致。 */
const TOOL_SUMMARY_MAX_CHARS = 120;
const FAILED_ITEM_STATUS = new Set([
'failed',
'declined',
'cancelled',
'canceled',
'aborted',
]);
function firstLine(value: string): string {
const [line = ''] = value.split('\n');
const trimmed = line.trim();
return trimmed.length > TOOL_SUMMARY_MAX_CHARS
? `${trimmed.slice(0, TOOL_SUMMARY_MAX_CHARS)}`
: trimmed;
}
function toolKindFromFunctionName(name: string): GameCreatorDirectToolCallKind {
switch (name) {
case 'exec_command':
case 'shell':
case 'exec':
return 'command';
case 'apply_patch':
case 'write_file':
case 'edit_file':
case 'create_file':
return 'file_change';
case 'web_search':
case 'web_search_preview':
return 'web_search';
default:
return 'mcp_tool';
}
}
function toolStatus(
status: string | null,
exitCode: number | null,
): GameCreatorDirectToolCallStatus {
// 显式失败 / 取消终态最优先:上游已经说这次调用失败,就不能再被别的字段洗成成功。
if (status && FAILED_ITEM_STATUS.has(status)) return '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';
}
function toolTitle(
kind: GameCreatorDirectToolCallKind,
changes: readonly GameCreatorDirectToolCallChange[],
): string {
switch (kind) {
case 'command':
return '执行命令';
case 'file_change': {
const paths = new Set(changes.map((change) => change.path));
return paths.size > 0 ? `编辑 ${paths.size} 个文件` : '编辑文件';
}
case 'web_search':
return '联网检索';
case 'context_compaction':
return '整理上下文';
default:
return '调用工具';
}
}
function fileChanges(
item: Extract<DirectThreadItem, { itemType: 'fileChange' }>,
): GameCreatorDirectToolCallChange[] {
return item.changes
.filter((change) => change.path.trim().length > 0)
.map((change) => ({
path: change.path,
kind: change.kind || 'update',
}));
}
type ToolCardInput = {
itemId: string;
kind: GameCreatorDirectToolCallKind;
/** 输出条目只带输出:标题与摘要留空,交给先到的调用快照。 */
outputOnly?: boolean;
tool?: string;
command?: string;
output?: string;
status: GameCreatorDirectToolCallStatus;
changes?: GameCreatorDirectToolCallChange[];
};
/**
* 条目的事件级阶段时间:`item.started` / `item.completed` 事件上的 `at`。
*
* 工具卡片的时间边界**只认这里**:条目自身的 `item.at` 是展示时间,不能当起止。
* 缺失(0)就留空:这个边界不知道,由展示层隐藏不能证明的耗时,不猜时间。
*/
export type DirectItemPhaseTime = {
/** 收到 `item.started` 时该事件的时间;其它事件与历史切片不传。 */
startedAt?: number;
/** 收到 `item.completed` 时该事件的时间;其它事件与历史切片不传。 */
completedAt?: number;
};
/** 阶段时间合法性:缺失 / 0 / 非有限一律算"没有这个边界"。 */
function phaseTime(value: number | undefined) {
return typeof value === 'number' && Number.isFinite(value) && value > 0
? value
: 0;
}
/**
* 阶段优先的状态:只有 `item.started` 阶段、而且条目本身没有终态信息时,工具就是"运行中"。
*
* 有些条目(文件变更 / 联网检索 / 上下文整理 / 没有状态字段的 MCP 调用)type 上不带状态,
* 收到开始事件时不能一律当"已完成",否则它们在块里永远不会动态增长;真正结束由同身份的
* `item.completed` 事件给出。条目自带的失败 / 完成状态优先,不被阶段改写。
*/
function toolStatusForPhase(
status: GameCreatorDirectToolCallStatus,
phase: DirectItemPhaseTime,
): GameCreatorDirectToolCallStatus {
if (
phaseTime(phase.startedAt) > 0 &&
phaseTime(phase.completedAt) === 0 &&
status === 'completed'
) {
return 'running';
}
return status;
}
/**
* `function_call` 的时间只表示"这次调用什么时候发起":即使它是随 `item.completed`
* 到达的(调用条目本身完成了),那也不是工具结束——结束由 `function_call_output` 给出。
* 因此这里只认起点,终点留空,不会伪造一个 0 秒的完成。
*/
function callStartPhase(phase: DirectItemPhaseTime): DirectItemPhaseTime {
return {
startedAt: phaseTime(phase.startedAt) || phaseTime(phase.completedAt),
};
}
function buildToolCard(
input: ToolCardInput,
phase: DirectItemPhaseTime,
): DirectChatToolCard | null {
const changes = input.changes ?? [];
const detail: GameCreatorDirectToolCallDetail = {};
if (input.command && !input.outputOnly) detail.command = input.command;
if (input.output) detail.output = input.output;
if (changes.length > 0) detail.changes = changes;
// 没有命令 / 输出 / 文件明细的条目不渲染成卡片:一张空卡片对用户没有信息量。
if (!detail.command && !detail.output && !detail.changes?.length) return null;
const summarySource =
(input.kind === 'mcp_tool' ? (input.tool ?? '') : '') ||
detail.command ||
changes[0]?.path ||
input.tool ||
'';
return {
schemaVersion: 'agc-tool-call.v1',
id: input.itemId,
kind: input.kind,
title: input.outputOnly ? '' : toolTitle(input.kind, changes),
summary: input.outputOnly ? '' : firstLine(summarySource),
// 输出条目本身就是"结果快照",不因阶段被改成运行中。
status: input.outputOnly
? input.status
: toolStatusForPhase(input.status, phase),
detail,
startedAt: phaseTime(phase.startedAt),
// 终点只有一种来源:明确的完成事件时间。运行中的卡片拿不到终点(0),
// 由展示层用宿主当前时钟算增长,绝不用快照更新时间冒充"现在"。
updatedAt: phaseTime(phase.completedAt),
};
}
function toolCardFromItem(
item: DirectThreadItem,
phase: DirectItemPhaseTime,
): DirectChatToolCard | null {
switch (item.itemType) {
case 'function_call':
return buildToolCard(
{
itemId: item.itemId,
kind: toolKindFromFunctionName(item.name),
tool: item.name,
command: item.arguments,
status: 'running',
},
callStartPhase(phase),
);
case 'function_call_output':
return buildToolCard(
{
itemId: item.itemId,
kind: 'other',
outputOnly: true,
output: item.output,
status: 'completed',
},
phase,
);
case 'commandExecution':
return buildToolCard(
{
itemId: item.itemId,
kind: 'command',
command: item.command,
output: item.output ?? '',
status: toolStatus(item.status, item.exitCode),
},
phase,
);
case 'fileChange':
return buildToolCard(
{
itemId: item.itemId,
kind: 'file_change',
changes: fileChanges(item),
status: 'completed',
},
phase,
);
case 'mcpToolCall':
return buildToolCard(
{
itemId: item.itemId,
kind: 'mcp_tool',
tool: item.tool,
command: item.arguments,
output: item.output ?? '',
status: toolStatus(item.status, null),
},
phase,
);
case 'webSearch':
return buildToolCard(
{
itemId: item.itemId,
kind: 'web_search',
command: item.query ?? '',
output: item.output ?? '',
status: 'completed',
},
phase,
);
case 'contextCompaction':
return buildToolCard(
{
itemId: item.itemId,
kind: 'context_compaction',
command: '整理上下文',
status: 'completed',
},
phase,
);
default:
return null;
}
}
/**
* 原始条目投影成聊天条目;不属于聊天内容的条目返回 `null`。
*
* 可见性判定只在这里:系统 / 开发者 message、无正文的空条目、未识别的 item 类型都不进
* 聊天视图。`other` 是 Rust 原样透传的未知类型,要不要显示属于前端可见性决策,当前不显示。
*/
export function projectDirectThreadItem(
item: DirectThreadItem | null | undefined,
phase: DirectItemPhaseTime = {},
): DirectChatEntry | null {
if (!item) return null;
// 身份是条目唯一的主键:拿不到身份的载荷既不能渲染也不能合并,只丢弃这一条。
const itemId = typeof item.itemId === 'string' ? item.itemId.trim() : '';
if (!itemId) return null;
switch (item.itemType) {
case 'message': {
const role =
item.role === 'user'
? 'user'
: item.role === 'assistant'
? 'assistant'
: null;
if (!role || !item.text.trim()) return null;
return {
itemId,
kind: 'message',
role,
text: item.text,
toolCall: null,
at: item.at,
};
}
case 'reasoning': {
if (!item.text.trim()) return null;
return {
itemId,
kind: 'reasoning',
role: null,
text: item.text,
toolCall: null,
at: item.at,
};
}
case 'other':
// TODO(direct-thread): 未识别类型目前不显示;要让它们出现只改这里,别回 Rust 加白名单。
return null;
default: {
const toolCall = toolCardFromItem(item, phase);
if (!toolCall) return null;
return {
itemId,
kind: 'tool',
role: null,
text: null,
toolCall,
at: item.at,
};
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,6 @@
import type {
GameCreatorDirectToolCall,
GameCreatorDirectToolCallKind,
} from '../../app/types';
import { formatElapsedDuration } from '../../../../../packages/shared/src/lib/formatElapsedDuration';
import type { GameCreatorDirectToolCallKind } from '../../app/types';
import type { DirectChatToolCard } from './directThreadChat';
/**
* 工具调用折叠块的纯文案计算:汇总 / 行文案。
@@ -11,25 +10,6 @@ import type {
* 组件文件不导出非组件值,也便于单测直接断言文案规则)。
*/
/** 汇总文案里 kind 的固定顺序:command → file_change → mcp_tool → web_search → context_compaction → other。 */
const TOOL_CALL_KIND_ORDER: GameCreatorDirectToolCallKind[] = [
'command',
'file_change',
'mcp_tool',
'web_search',
'context_compaction',
'other',
];
const TOOL_CALL_KIND_LABELS: Record<GameCreatorDirectToolCallKind, string> = {
command: '命令',
file_change: '文件变更',
mcp_tool: '工具调用',
web_search: '联网搜索',
context_compaction: '上下文整理',
other: '其他操作',
};
/** 行文案动词:`已运行 {summary}` / `已编辑 {summary}` / …,`context_compaction` 不带 summary。 */
export const TOOL_CALL_ROW_VERBS: Partial<
Record<GameCreatorDirectToolCallKind, string>
@@ -41,43 +21,21 @@ export const TOOL_CALL_ROW_VERBS: Partial<
other: '已执行',
};
/** 汇总文案:按 kind 计数、固定顺序拼成 `已执行 5 个命令、2 个文件变更`;空集合返回空串。 */
/**
* 汇总文案:`执行了 N 个操作`(N = 这一组自己的调用数,不按段落数、不拆 kind)。
*
* 与耗时一起构成用户口径的 `执行了 X 个操作,耗时 XXX`:耗时由调用方作为 meta 追加,
* 这里只出前半句。
*/
export function toolCallGroupSummary(
calls: GameCreatorDirectToolCall[],
running = false,
) {
const counts = new Map<string, number>();
for (const call of calls) {
counts.set(call.kind, (counts.get(call.kind) ?? 0) + 1);
}
const parts: string[] = [];
const append = (kind: string) => {
const count = counts.get(kind) ?? 0;
if (count <= 0) {
return;
}
counts.delete(kind);
const label =
TOOL_CALL_KIND_LABELS[kind as GameCreatorDirectToolCallKind] ??
'其他操作';
parts.push(`${count}${label}`);
};
for (const kind of TOOL_CALL_KIND_ORDER) {
append(kind);
}
// 契约外的 kind:不丢计数,落到末尾的「其他操作」。
for (const kind of [...counts.keys()]) {
append(kind);
}
return parts.length > 0
? running
? `已执行 ${parts.join('、')}进行中`
: `已执行 ${parts.join('、')}`
: '';
calls: readonly DirectChatToolCard[],
): string {
if (calls.length === 0) return '';
return `执行了 ${calls.length} 个操作`;
}
/** 一行工具的文案:只用工具本身的摘要(不带"已运行"这类动词前缀),状态由行尾状态列表达。 */
export function toolCallRowText(call: GameCreatorDirectToolCall) {
export function toolCallRowText(call: DirectChatToolCard) {
// 行首不再写"已运行/已编辑"这类动词前缀:命令状态由行尾的状态列表达
// (执行中 / 已执行 / 失败),前缀会和它重复。
if (call.kind === 'context_compaction') {
@@ -86,7 +44,7 @@ export function toolCallRowText(call: GameCreatorDirectToolCall) {
return toolCallRowSummary(call);
}
function toolCallRowSummary(call: GameCreatorDirectToolCall) {
function toolCallRowSummary(call: DirectChatToolCard) {
if (call.kind === 'command') {
// 历史摘要可能已被可执行文件路径占满;优先从完整、已脱敏的输入提取正文。
const script = windowsPowerShellCommandBody(
@@ -114,7 +72,7 @@ function toolCallRowSummary(call: GameCreatorDirectToolCall) {
}
/** 仅格式化卡片输入,不修改执行参数、历史记录或工具输出。 */
export function toolCallInputText(call: GameCreatorDirectToolCall) {
export function toolCallInputText(call: DirectChatToolCard) {
const input = call.detail.command?.trim() ?? '';
return call.kind === 'command'
? (windowsPowerShellCommandBody(input) ?? input)
@@ -175,97 +133,65 @@ function unwrapDisplayArgument(argument: string) {
/**
* 单条工具的耗时(毫秒)。
* `startedAt` 为 0(缺失)或 `updatedAt < startedAt`(时间倒序)时返回 `null`:
* 这两种情况不显示耗时,不显示 `0s` / 负数。
*
* 边界只来自事件级时间:`startedAt` 是 `item.started` 事件的时间,完成态用
* `item.completed` 事件的时间。两者任一缺失(0 / 非有限)或时间倒序时返回 `null`:
* 没有开始事件的完成快照不猜开始时间,缺失与倒序都不显示耗时。
*
* `running` 为真(该工具仍在跑、且所属回合仍在跑)时用 `now` 当终点:以时间戳差值
* 计算,不按 tick 累加,后台节流后回到前台不会累计漂移。合法 0 是有效耗时,照常返回 0。
*/
export function toolCallDurationMs(
call: Pick<GameCreatorDirectToolCall, 'startedAt' | 'updatedAt'>,
call: Pick<DirectChatToolCard, 'startedAt' | 'updatedAt'>,
options?: { now?: number; running?: boolean },
): number | null {
const startedAt = Number.isFinite(call.startedAt) ? call.startedAt : 0;
const updatedAt = Number.isFinite(call.updatedAt) ? call.updatedAt : 0;
if (startedAt <= 0 || updatedAt < startedAt) {
if (startedAt <= 0) {
return null;
}
return updatedAt - startedAt;
const endAt = options?.running
? Number(options.now)
: Number.isFinite(call.updatedAt)
? call.updatedAt
: 0;
if (!Number.isFinite(endAt) || endAt <= 0 || endAt < startedAt) {
return null;
}
return endAt - startedAt;
}
/**
* 单条工具的耗时文案:`0.4s`<1s/ `12.3s`<60s,整秒省略小数)/ `2m 5s`(≥60s
* 无法计算的耗时(`null` / 0 / 负数)返回 `null`。
* 单条工具与整轮、生成任务共用中文时分秒格式;未知耗时隐藏,合法 0 显示 0.0秒
*/
export function formatToolCallDuration(ms: number | null | undefined) {
if (ms === null || ms === undefined || !Number.isFinite(ms) || ms <= 0) {
return null;
}
if (ms < 60000) {
const tenths = Math.max(1, Math.round(ms / 100));
if (tenths < 600) {
const value = tenths / 10;
return Number.isInteger(value) ? `${value}s` : `${value.toFixed(1)}s`;
}
}
const totalSeconds = Math.max(60, Math.round(ms / 1000));
const minutes = Math.floor(totalSeconds / 60);
const restSeconds = totalSeconds % 60;
return restSeconds === 0 ? `${minutes}m` : `${minutes}m ${restSeconds}s`;
return formatElapsedDuration(ms);
}
/** 一回合总用时:该回合所有工具的 `min(startedAt)` → `max(updatedAt)`;取不到返回 `null`。 */
export function turnToolCallDurationMs(
calls: Array<Pick<GameCreatorDirectToolCall, 'startedAt' | 'updatedAt'>>,
): number | null {
let minStartedAt = Number.POSITIVE_INFINITY;
let maxUpdatedAt = Number.NEGATIVE_INFINITY;
for (const call of calls) {
const startedAt = Number.isFinite(call.startedAt) ? call.startedAt : 0;
const updatedAt = Number.isFinite(call.updatedAt) ? call.updatedAt : 0;
if (startedAt > 0) {
minStartedAt = Math.min(minStartedAt, startedAt);
}
if (updatedAt > 0) {
maxUpdatedAt = Math.max(maxUpdatedAt, updatedAt);
}
}
if (!Number.isFinite(minStartedAt) || !Number.isFinite(maxUpdatedAt)) {
return null;
}
if (maxUpdatedAt < minStartedAt) {
return null;
}
return maxUpdatedAt - minStartedAt;
}
/** 块头总用时文案:`42秒` / `4分钟` / `5分钟 45秒`;无法计算的耗时返回 `null`。 */
/**
* 整轮总耗时文案:0.0秒 / 1分02.3秒 / 1时02分05.2秒。
* 无法计算的耗时(`null` / `undefined` / 非有限 / 负数)返回 `null`;合法 0 显示 `0.0秒`。
*/
export function formatTurnDuration(ms: number | null | undefined) {
if (ms === null || ms === undefined || !Number.isFinite(ms) || ms <= 0) {
return null;
}
const seconds = Math.max(1, Math.round(ms / 1000));
if (seconds < 60) {
return `${seconds}`;
}
const minutes = Math.floor(seconds / 60);
const restSeconds = seconds % 60;
return restSeconds === 0
? `${minutes}分钟`
: `${minutes}分钟 ${restSeconds}`;
return formatElapsedDuration(ms);
}
/** 该回合的结束时间:`max(updatedAt)`;取不到返回 0。 */
export function turnToolCallEndedAt(
calls: Array<Pick<GameCreatorDirectToolCall, 'updatedAt'>>,
/**
* 时间戳取整到展示用的 100ms 网格(非法 / 缺失一律回 0)。
*
* 时间范围与总耗时必须来自同一组取整边界:分别对原始毫秒做四舍五入会让
* `14:20:05.9 → 14:20:06.1` 与 `0.1秒` 这类组合互相矛盾。
*/
export function quantizeDisplayMs(value: number | null | undefined) {
return typeof value === 'number' && Number.isFinite(value) && value > 0
? Math.round(value / 100) * 100
: 0;
}
/** 本地 `HH:mm:ss``tenths` 为真时补一位小数(`HH:mm:ss.S`)。缺失(0 / 非法)返回 `null`。 */
export function formatClockTime(
timestamp: number | null | undefined,
options?: { tenths?: boolean },
) {
let maxUpdatedAt = 0;
for (const call of calls) {
if (Number.isFinite(call.updatedAt) && call.updatedAt > maxUpdatedAt) {
maxUpdatedAt = call.updatedAt;
}
}
return maxUpdatedAt;
}
/** 本地 `HH:mm:ss`;时间戳缺失(0 / 非法)返回 `null`,不编造时间。 */
export function formatClockTime(timestamp: number | null | undefined) {
if (
timestamp === null ||
timestamp === undefined ||
@@ -279,21 +205,165 @@ export function formatClockTime(timestamp: number | null | undefined) {
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${hours}:${minutes}:${seconds}`;
if (!options?.tenths) {
return `${hours}:${minutes}:${seconds}`;
}
return `${hours}:${minutes}:${seconds}.${Math.floor(date.getMilliseconds() / 100)}`;
}
/**
* 块头时间文案:该回合结束时间(`max(updatedAt)` 的本地 `HH:mm:ss`);
* 能拿到同回合用户消息时间(`updatedAt > 0`)时显示「发送 → 结束」,取不到就只显示结束时间。
* 整轮耗时(毫秒)= 本轮起点 → 本轮终态。
*
* 起点是该轮实际用户消息的发送时间,缺失时用原生 `turn.started.at`;终点是明确的
* `turn.completed.at`,运行中则是当前时刻(回合还在跑就持续增长,即使组内工具都结束了)。
* 两端任一缺失、非有限或倒序都返回 `null`:不伪造 `0.0秒`。
*/
export function turnToolCallTimeLabel(
calls: Array<Pick<GameCreatorDirectToolCall, 'updatedAt'>>,
userSentAt: number | null | undefined,
) {
const endLabel = formatClockTime(turnToolCallEndedAt(calls));
if (!endLabel) {
export function turnTotalDurationMs({
startedAt,
endedAt,
running = false,
now = 0,
}: {
/** 本轮起点:用户实际发送时间优先,缺失时原生 `turn.started.at`0 = 未知。 */
startedAt: number | null | undefined;
/** 本轮明确终态时间(`turn.completed.at`);运行中忽略。 */
endedAt?: number | null;
/** 本轮是否仍在跑:为真时用 `now` 当终点。 */
running?: boolean;
now?: number;
}): number | null {
const rawEnd = running ? now : endedAt;
if (
typeof startedAt !== 'number' ||
!Number.isFinite(startedAt) ||
startedAt <= 0 ||
typeof rawEnd !== 'number' ||
!Number.isFinite(rawEnd) ||
rawEnd <= 0 ||
rawEnd < startedAt
) {
return null;
}
const sentLabel = formatClockTime(userSentAt ?? 0);
return sentLabel ? `${sentLabel}${endLabel}` : endLabel;
const start = quantizeDisplayMs(startedAt);
const end = quantizeDisplayMs(rawEnd);
if (start <= 0 || end <= 0 || end < start) {
return null;
}
return end - start;
}
export type DirectTurnTiming = {
/** 展示用总耗时(毫秒,已取整到 100ms 网格);null = 边界不完整,隐藏。 */
durationMs: number | null;
/** `总耗时` 后面的文案;null = 隐藏。 */
durationText: string | null;
/** `HH:mm:ss.S → HH:mm:ss.S`;起点未知时只给终点,两端都未知为 null。 */
timeLabel: string | null;
};
export type DirectToolGroupTiming = {
/** 本组用时(毫秒,已取整到 100ms 网格);null = 边界不完整,这一组的用时隐藏。 */
durationMs: number | null;
/** `本组用时` 后面的文案;null = 隐藏。 */
durationText: string | null;
/** 本组此刻是否仍在跑(组内有运行中的工具且回合还在跑):只有它为真才跟时钟增长。 */
running: boolean;
};
/**
* 本组用时:只读**这一组自己**的工具边界 —— `min(工具开始)` → `max(工具完成)`。
*
* 它与整轮总耗时是两件事:整轮是用户发送 → 回合终态(含无工具的 LLM 等待),只在对话里
* 渲染一处;组头只回答"这一组工具跑了多久"。因此这个函数**不接收任何回合边界**,也不会
* 因为同轮另一组还在跑而跟着增长(那是上一版把两组显示成同一用时的原因)。
*
* - 缺任何开始时间(`startedAt <= 0`)、或某个**已结束**的工具缺完成时间(`updatedAt <= 0`
* → 整组用时未知:隐藏,不拿别组或整轮边界补(已知的单行耗时照常显示)。
* - 组内还有运行中的工具且回合在跑时,终点取调用方给的宿主时钟 `now`,组头每 100ms 增长;
* 组内工具全部拿到终态那一刻立即冻结,不等回合收口。
* - 每条工具**自己的**原始边界先成立(终点 >= 起点,运行中的工具用 `now` 当终点),
* 再聚合成 `min(开始)` → `max(完成)`:任何一条倒序 / 非有限都让整组隐藏,不靠别的正常
* 工具体把坏边界掩盖成合法区间;亚 100ms 的倒序也不会被展示量化抹成 `0.0秒`。
* - 合法的同一时刻显示 `0.0秒`。
*/
export function resolveToolGroupTiming(
calls: readonly Pick<
DirectChatToolCard,
'status' | 'startedAt' | 'updatedAt'
>[],
options?: { running?: boolean; now?: number },
): DirectToolGroupTiming {
const hidden: DirectToolGroupTiming = {
durationMs: null,
durationText: null,
running: false,
};
if (calls.length === 0) return hidden;
const turnRunning = Boolean(options?.running);
const now = Number(options?.now);
let startedAt = 0;
let endedAt = 0;
let running = false;
for (const call of calls) {
const start = Number.isFinite(call.startedAt) ? call.startedAt : 0;
if (start <= 0) return hidden;
let end = 0;
if (call.status === 'running') {
// 运行中的工具没有终态时间:只有"组内还在跑且回合在跑"时才用当前时钟当终点。
if (!turnRunning || !Number.isFinite(now) || now <= 0) return hidden;
running = true;
end = now;
} else {
end = Number.isFinite(call.updatedAt) ? call.updatedAt : 0;
if (end <= 0) return hidden;
}
// 单条边界先判:倒序(含量化后会被抹成 0.0 秒的亚 100ms 倒序)整组隐藏,
// 不允许被同组另一条正常工具的区间掩盖。
if (end < start) return hidden;
startedAt = startedAt === 0 ? start : Math.min(startedAt, start);
endedAt = Math.max(endedAt, end);
}
const start = quantizeDisplayMs(startedAt);
const end = quantizeDisplayMs(endedAt);
// 聚合后量化:每条都已成立,聚合区间不会倒序;量化取整按单调函数做,也不会互相矛盾。
if (start <= 0 || end <= 0 || end < start) return hidden;
const durationMs = end - start;
return {
durationMs,
durationText: formatTurnDuration(durationMs),
running,
};
}
/**
* 整轮计时的唯一口径:总耗时与时间范围共用同一组边界。
*
* 只认明确边界:缺字段、非有限、时间戳为 0、结束早于开始一律隐藏耗时(也不会退化成
* `0.0秒`);合法的同一时刻(耗时 0)正常显示 `0.0秒`。
*/
export function resolveTurnTiming({
startedAt,
endedAt,
running = false,
now = 0,
}: {
startedAt: number | null | undefined;
endedAt?: number | null;
running?: boolean;
now?: number;
}): DirectTurnTiming {
const start = quantizeDisplayMs(startedAt);
const end = quantizeDisplayMs(running ? now : endedAt);
const durationMs = turnTotalDurationMs({ startedAt, endedAt, running, now });
const startLabel =
start > 0 ? formatClockTime(start, { tenths: true }) : null;
const endLabel = end > 0 ? formatClockTime(end, { tenths: true }) : null;
return {
durationMs,
durationText: formatTurnDuration(durationMs),
timeLabel:
startLabel && endLabel
? `${startLabel}${endLabel}`
: (endLabel ?? null),
};
}
@@ -482,6 +482,23 @@ function agentRuntimeUserInputRequest({
};
}
/**
* 运行态事件里的条目身份:只有 `item.started` / `item.completed` 带条目。
*
* `item.delta` 只带 itemId 不带条目,属于瞬时事件,不参与 bootstrap 回放。
*/
function directThreadEventItemId(
event: Record<string, unknown>,
): string | null {
if (event.type !== 'item.started' && event.type !== 'item.completed') {
return null;
}
const item = event.item;
if (!item || typeof item !== 'object') return null;
const itemId = (item as Record<string, unknown>).itemId;
return typeof itemId === 'string' && itemId ? itemId : null;
}
function createProjectSupervisorRuntimeHarness({
projectPath = '/tmp/authorized-game',
sessionId = 'supervisor-session-active',
@@ -588,8 +605,31 @@ function createProjectSupervisorRuntimeHarness({
}) => void)
| null = null;
let designAgentUpdateHandler:
| ((event: { payload: Record<string, unknown> }) => void)
| null = null;
((event: { payload: Record<string, unknown> }) => void) | null = null;
let directThreadNotifyHandler:
((event: { payload: { subscriptionId: string } }) => void) | null = null;
let directThreadSubscriptionId: string | null = null;
let directThreadSubscriptionSequence = 0;
// 未消费的运行态事件队列:`subscribe` 的 bootstrap 与 `consume` 都从这里取,
// 与 Rust 侧"游标在队尾、事件按序下发"的语义一致。
let pendingDirectThreadEvents: Array<Record<string, unknown>> = [];
let directThreadHistoryItems: Array<Record<string, unknown>> = [];
let directThreadLastCompletedItemId: string | null = null;
// 运行态事件里只有一个"最新回合是否在跑"的布尔:与 Thread Manager 只保留一条生命周期
// 锚点一致,重复 `turn.started` 在生产里不会出现。
let directThreadTurnRunning = false;
// bootstrap 的回放规则与 Rust 侧 `is_bootstrap_event` 对齐:只补"这一刻还没结束的条目"
// 与最新一条生命周期锚点;已完成条目、增量正文这类瞬时事件都不回放。
const directThreadActiveItemIds = new Set<string>();
const directThreadPendingEventSeqs = new Map<
Record<string, unknown>,
number
>();
let directThreadEventSequence = 0;
let directThreadLifecycleAnchor: {
seq: number;
event: Record<string, unknown>;
} | null = null;
const conversationRecord = (
role: 'user' | 'assistant',
@@ -784,6 +824,93 @@ function createProjectSupervisorRuntimeHarness({
const states = runtimeMapLoader ? await runtimeMapLoader() : [];
return states.map((state) => runtimeResult(state));
}
if (command === 'subscribe_direct_project_thread') {
directThreadSubscriptionSequence += 1;
directThreadSubscriptionId = `direct-thread-${directThreadSubscriptionSequence}`;
const pending = pendingDirectThreadEvents;
pendingDirectThreadEvents = [];
// 游标落在队尾:只有"还没结束的条目"与最新生命周期锚点作为 bootstrap 回放,
// 已完成条目与增量正文不再补发(Rust 侧 `is_bootstrap_event` 的同一口径)。
const replay = pending
.filter((event) => {
const itemId = directThreadEventItemId(event);
return Boolean(itemId && directThreadActiveItemIds.has(itemId));
})
.map((event) => ({
seq: directThreadPendingEventSeqs.get(event) ?? 0,
event,
}));
for (const event of pending) {
directThreadPendingEventSeqs.delete(event);
}
if (
directThreadLifecycleAnchor &&
!replay.some(
(entry) => entry.event === directThreadLifecycleAnchor?.event,
)
) {
replay.push(directThreadLifecycleAnchor);
}
replay.sort((left, right) => left.seq - right.seq);
return {
subscriptionId: directThreadSubscriptionId,
lastCompletedItemId: directThreadLastCompletedItemId,
events: replay.map((entry) => entry.event),
};
}
if (command === 'consume_direct_project_thread') {
if (String(args?.subscriptionId ?? '') !== directThreadSubscriptionId) {
throw new Error('SUBSCRIPTION_EXPIRED');
}
const events = pendingDirectThreadEvents;
pendingDirectThreadEvents = [];
return { events };
}
if (command === 'read_direct_project_history_slice') {
// 生产口径:从文件尾反向取一屏可显示条目——`throughItemId` 是窗口新端边界(含该条,
// 首屏用),`beforeItemId` 是旧端边界(不含该条,翻页用);收满一屏之后再见一条才算
// `hasMore``firstItemId` 是本屏最老一条的 itemId。
const requestedLimit = Number(args?.limit ?? 20);
const limit = Math.min(
Math.max(Number.isFinite(requestedLimit) ? requestedLimit : 20, 1),
200,
);
const throughItemId =
typeof args?.throughItemId === 'string' && args.throughItemId
? args.throughItemId
: null;
const beforeItemId =
typeof args?.beforeItemId === 'string' && args.beforeItemId
? args.beforeItemId
: null;
let end = directThreadHistoryItems.length;
if (throughItemId) {
const anchorIndex = directThreadHistoryItems.findIndex(
(item) => String(item.itemId ?? '') === throughItemId,
);
if (anchorIndex < 0) {
throw new Error(
`DirectProject 历史中不存在 item${throughItemId}`,
);
}
end = anchorIndex + 1;
} else if (beforeItemId) {
const anchorIndex = directThreadHistoryItems.findIndex(
(item) => String(item.itemId ?? '') === beforeItemId,
);
end =
anchorIndex >= 0 ? anchorIndex : directThreadHistoryItems.length;
}
const start = Math.max(0, end - limit);
const window = directThreadHistoryItems.slice(start, end);
const oldest = window[0];
return {
items: [...window],
hasMore: start > 0,
firstItemId:
oldest && typeof oldest.itemId === 'string' ? oldest.itemId : null,
};
}
if (command === 'start_game_creator_supervisor_runtime_task') {
if (args?.runProfile !== expectedRunProfile) {
throw new Error('unexpected Project Supervisor run profile');
@@ -937,6 +1064,10 @@ function createProjectSupervisorRuntimeHarness({
designAgentUpdateHandler =
handler as unknown as typeof designAgentUpdateHandler;
}
if (eventName === 'game-creator-direct-thread-notify') {
directThreadNotifyHandler =
handler as unknown as typeof directThreadNotifyHandler;
}
return () => {
if (runtimeUpdateHandler === handler) {
runtimeUpdateHandler = null;
@@ -950,10 +1081,45 @@ function createProjectSupervisorRuntimeHarness({
if (designAgentUpdateHandler === handler) {
designAgentUpdateHandler = null;
}
if (directThreadNotifyHandler === handler) {
directThreadNotifyHandler = null;
}
};
},
);
/** 追加运行态事件并唤醒订阅者:bootstrap / consume 共用同一份队列。 */
const emitDirectThreadEvents = (
...events: Array<Record<string, unknown>>
) => {
for (const event of events) {
directThreadEventSequence += 1;
directThreadPendingEventSeqs.set(event, directThreadEventSequence);
if (event.type === 'turn.started') directThreadTurnRunning = true;
if (event.type === 'turn.completed') directThreadTurnRunning = false;
if (event.type === 'turn.started' || event.type === 'turn.completed') {
// 生命周期锚点只留最新一条,与 Thread Manager 的 `lifecycle_anchor` 一致。
directThreadLifecycleAnchor = {
seq: directThreadEventSequence,
event,
};
}
const itemId = directThreadEventItemId(event);
if (itemId && event.type === 'item.started') {
directThreadActiveItemIds.add(itemId);
}
if (itemId && event.type === 'item.completed') {
directThreadActiveItemIds.delete(itemId);
}
}
pendingDirectThreadEvents.push(...events);
if (directThreadSubscriptionId) {
directThreadNotifyHandler?.({
payload: { subscriptionId: directThreadSubscriptionId },
});
}
};
return {
invoke,
listen,
@@ -1036,6 +1202,71 @@ function createProjectSupervisorRuntimeHarness({
},
});
},
emitDirectThreadEvents,
/**
* 一轮 Direct 回合的标准事件序列:生命周期 → 落盘用户条目 → 助手正文 → 终态。
*
* 与 Rust 侧一致:消息身份是 `direct-codex:{turnId}:{role}`,工具条目另配 itemId。
* 事件级 `at` 与条目 `item.at` 同源:原生在两个阶段事件上都给时间,计时只读事件级那一份。
*/
completeDirectThreadTurn({
turnId,
reply,
prompt = '',
at = 9_000,
status = 'completed',
}: {
turnId: string;
reply: string;
prompt?: string;
at?: number;
status?: string;
}) {
// 生命周期锚点只有一份:回合已经在跑时不再补 `turn.started`。
const events: Array<Record<string, unknown>> = directThreadTurnRunning
? []
: [{ type: 'turn.started', at }];
if (prompt.trim()) {
events.push({
type: 'item.completed',
at,
item: {
itemType: 'message',
itemId: `direct-codex:${turnId}:user`,
role: 'user',
text: prompt,
at,
},
});
}
events.push({
type: 'item.completed',
at: at + 1,
item: {
itemType: 'message',
itemId: `direct-codex:${turnId}:assistant`,
role: 'assistant',
text: reply,
at: at + 1,
},
});
directThreadLastCompletedItemId = `direct-codex:${turnId}:assistant`;
// 终态时间就是调用方给的这一刻:整轮总耗时读它,不读正文条目的展示时间。
events.push({ type: 'turn.completed', status, at });
emitDirectThreadEvents(...events);
},
setDirectThreadHistory(items: Array<Record<string, unknown>>) {
directThreadHistoryItems = [...items];
// 生产口径:`subscribe` 回执里的 `lastCompletedItemId` 是订阅那一刻文件里最后一条可显示
// 条目(Rust 侧由 `read_direct_project_last_item_id_at` 从磁盘回填)。
const newest = directThreadHistoryItems.at(-1);
directThreadLastCompletedItemId =
typeof newest?.itemId === 'string' ? newest.itemId : null;
},
/** 模拟"订阅回执给的就是这一刻的最后一条已完成条目":之后落盘的条目只应从运行态事件来。 */
setDirectThreadLastCompletedItemId(itemId: string | null) {
directThreadLastCompletedItemId = itemId;
},
};
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,108 @@
import { describe, expect, it } from 'vitest';
import {
directHistoryAnchorGateToWaitFor,
openDirectHistoryAnchorGate,
reuseOrOpenDirectHistoryAnchorGate,
} from '../src/features/project-workspace/directHistoryAnchorGate';
const projectA = '/tmp/项目A';
const projectB = '/tmp/项目B';
/** 订阅侧的回执:一次订阅只 settle 一次。 */
function subscribeReceipt(gate: { settle: (anchor: string | null) => void }) {
gate.settle('turn-2-user');
}
describe('openDirectHistoryAnchorGate', () => {
it('默认不解析:回执到达前首屏必须等它', async () => {
const gate = openDirectHistoryAnchorGate(projectA);
const settled = { value: 'pending' as string | null | 'pending' };
void gate.anchor.then((anchor) => {
settled.value = anchor;
});
// 让微任务跑一轮:没有回执就不该有结果。
await Promise.resolve();
await Promise.resolve();
expect(settled.value).toBe('pending');
expect(gate.projectPath).toBe(projectA);
expect(gate.consumed).toBe(false);
});
it('settle 之后 anchor 解析成回执里的 lastCompletedItemId', async () => {
const gate = openDirectHistoryAnchorGate(projectA);
subscribeReceipt(gate);
await expect(gate.anchor).resolves.toBe('turn-2-user');
});
it('订阅不可用时 settle(null):首屏退化成取文件尾', async () => {
const gate = openDirectHistoryAnchorGate(projectA);
gate.settle(null);
await expect(gate.anchor).resolves.toBeNull();
});
});
describe('reuseOrOpenDirectHistoryAnchorGate', () => {
it('同一个项目复用同一道闸门:订阅侧要 settle 首屏已经开好的那道', () => {
const opened = openDirectHistoryAnchorGate(projectA);
expect(reuseOrOpenDirectHistoryAnchorGate(opened, projectA)).toBe(opened);
});
it('换项目开一道新闸门,不复用旧项目的回执', () => {
const opened = openDirectHistoryAnchorGate(projectA);
const next = reuseOrOpenDirectHistoryAnchorGate(opened, projectB);
expect(next).not.toBe(opened);
expect(next.projectPath).toBe(projectB);
expect(next.consumed).toBe(false);
});
it('还没有闸门时新开一道', () => {
expect(reuseOrOpenDirectHistoryAnchorGate(null, projectA).projectPath).toBe(
projectA,
);
});
});
describe('directHistoryAnchorGateToWaitFor', () => {
it('没有闸门(首屏比订阅 effect 先跑)时开一道给调用方登记', async () => {
const gate = directHistoryAnchorGateToWaitFor(null, projectA);
expect(gate).not.toBeNull();
expect(gate?.projectPath).toBe(projectA);
subscribeReceipt(gate!);
await expect(gate!.anchor).resolves.toBe('turn-2-user');
});
it('同项目未消费的闸门直接复用:首屏等到订阅回执', async () => {
const opened = openDirectHistoryAnchorGate(projectA);
const toWaitFor = directHistoryAnchorGateToWaitFor(opened, projectA);
expect(toWaitFor).toBe(opened);
subscribeReceipt(opened);
await expect(toWaitFor!.anchor).resolves.toBe('turn-2-user');
});
it('同项目已消费返回 null:重开项目没有新回执可等,退回取文件尾', () => {
const opened = openDirectHistoryAnchorGate(projectA);
opened.consumed = true;
expect(directHistoryAnchorGateToWaitFor(opened, projectA)).toBeNull();
});
it('换项目不复用旧闸门:新项目的首屏要等新订阅的回执', () => {
const opened = openDirectHistoryAnchorGate(projectA);
subscribeReceipt(opened);
opened.consumed = true;
const toWaitFor = directHistoryAnchorGateToWaitFor(opened, projectB);
expect(toWaitFor).not.toBeNull();
expect(toWaitFor).not.toBe(opened);
expect(toWaitFor?.projectPath).toBe(projectB);
});
});
@@ -0,0 +1,253 @@
import { describe, expect, it, vi } from 'vitest';
import { DIRECT_HISTORY_MAX_PAGES_PER_ACTION } from '../src/app/constants';
import { readDirectHistoryPages } from '../src/features/project-workspace/directHistoryPaging';
import type { DirectChatEntry } from '../src/features/project-workspace/directThreadChat';
import {
emptyDirectThreadChatState,
mergeDirectHistoryItems,
selectDirectChatEntries,
} from '../src/features/project-workspace/directThreadChat';
import type {
DirectThreadHistorySlice,
DirectThreadItem,
} from '../src/features/project-workspace/directThreadEvents';
function userItem(itemId: string): DirectThreadItem {
return { itemType: 'message', itemId, role: 'user', text: itemId, at: 1000 };
}
function assistantItem(itemId: string): DirectThreadItem {
return {
itemType: 'message',
itemId,
role: 'assistant',
text: `答复 ${itemId}`,
at: 2000,
};
}
function toolItem(itemId: string): DirectThreadItem {
return {
itemType: 'function_call',
itemId,
name: 'exec_command',
arguments: '{"cmd": "ls"}',
at: 1500,
};
}
function reasoningItem(itemId: string): DirectThreadItem {
return { itemType: 'reasoning', itemId, text: '想想', at: 1400 };
}
function slice(
items: DirectThreadItem[],
hasMore: boolean,
firstItemId: string | null,
): DirectThreadHistorySlice {
return { items, hasMore, firstItemId };
}
/** 用生产投影把条目拉成聊天条目:判据必须和视图看到的是同一份。 */
function entriesOf(items: DirectThreadItem[]): DirectChatEntry[] {
return selectDirectChatEntries(
mergeDirectHistoryItems(emptyDirectThreadChatState(), items),
);
}
function readerOf(pages: DirectThreadHistorySlice[]) {
const remaining = [...pages];
return vi.fn(async (_beforeItemId: string | null) => {
const next = remaining.shift();
if (!next) throw new Error('测试桩:不该再取页');
return next;
});
}
describe('DirectProject 历史分页连拉', () => {
it('整页工具与思考条目落进已渲染回合时继续取页,直到出现新的用户气泡', async () => {
// 已渲染的回合是「turn-2-user」,后面跟着它的折叠执行过程。
const existing = entriesOf([userItem('turn-2-user'), toolItem('call-2')]);
const readSlice = readerOf([
slice([toolItem('call-3'), reasoningItem('reason-3')], true, 'reason-3'),
slice(
[userItem('turn-1-user'), assistantItem('turn-1-assistant')],
false,
'turn-1-user',
),
]);
const result = await readDirectHistoryPages({
existingEntries: existing,
beforeItemId: 'turn-2-user',
readSlice,
});
// 第二页才带来新回合:锚点按上一页最老一条推进,条目按文件顺序拼回去。
expect(readSlice.mock.calls).toEqual([['turn-2-user'], ['reason-3']]);
expect(result.items.map((item) => item.itemId)).toEqual([
'turn-1-user',
'turn-1-assistant',
'call-3',
'reason-3',
]);
expect(result.hasMore).toBe(false);
expect(result.firstItemId).toBe('turn-1-user');
expect(result.error).toBeNull();
const merged = selectDirectChatEntries(
mergeDirectHistoryItems(
{ turnRunning: false, history: existing, live: [] },
result.items,
),
);
expect(merged.some((entry) => entry.itemId === 'turn-1-user')).toBe(true);
});
it('第一页就出现新的用户气泡时只取一页', async () => {
const existing = entriesOf([userItem('turn-2-user')]);
const readSlice = readerOf([
slice([userItem('turn-1-user')], true, 'turn-1-user'),
]);
const result = await readDirectHistoryPages({
existingEntries: existing,
beforeItemId: 'turn-2-user',
readSlice,
});
expect(readSlice).toHaveBeenCalledTimes(1);
// 后端说还有更早的:按钮继续留在界面上,用户可以接着往前翻。
expect(result.hasMore).toBe(true);
expect(result.firstItemId).toBe('turn-1-user');
});
it('首屏没有已渲染回合时,第一页本身就构成可见反馈', async () => {
const readSlice = readerOf([slice([toolItem('call-1')], true, 'call-1')]);
const result = await readDirectHistoryPages({
existingEntries: [],
beforeItemId: null,
readSlice,
});
expect(readSlice.mock.calls).toEqual([[null]]);
expect(result.items.map((item) => item.itemId)).toEqual(['call-1']);
});
it('hasMore=false 时即使没有新回合也立即停止', async () => {
const existing = entriesOf([userItem('turn-2-user')]);
const readSlice = readerOf([slice([toolItem('call-3')], false, 'call-3')]);
const result = await readDirectHistoryPages({
existingEntries: existing,
beforeItemId: 'turn-2-user',
readSlice,
});
expect(readSlice).toHaveBeenCalledTimes(1);
expect(result.hasMore).toBe(false);
});
it('一直没有新回合时最多连拉上限页数就停手', async () => {
const existing = entriesOf([userItem('turn-2-user')]);
const readSlice = readerOf(
Array.from(
{ length: DIRECT_HISTORY_MAX_PAGES_PER_ACTION + 2 },
(_, index) =>
slice([toolItem(`call-${index + 3}`)], true, `call-${index + 3}`),
),
);
const result = await readDirectHistoryPages({
existingEntries: existing,
beforeItemId: 'turn-2-user',
readSlice,
});
expect(readSlice).toHaveBeenCalledTimes(
DIRECT_HISTORY_MAX_PAGES_PER_ACTION,
);
// 用满上限但文件里还有更早的历史:按钮留在界面上,用户可再点一次。
expect(result.hasMore).toBe(true);
expect(result.items).toHaveLength(DIRECT_HISTORY_MAX_PAGES_PER_ACTION);
expect(result.firstItemId).toBe(
`call-${DIRECT_HISTORY_MAX_PAGES_PER_ACTION + 2}`,
);
});
it('锚点不前进时立即停止,不空转也不留按钮', async () => {
const existing = entriesOf([userItem('turn-2-user')]);
const readSlice = readerOf([slice([], true, null)]);
const result = await readDirectHistoryPages({
existingEntries: existing,
beforeItemId: 'turn-2-user',
readSlice,
});
expect(readSlice).toHaveBeenCalledTimes(1);
expect(result.items).toEqual([]);
expect(result.hasMore).toBe(false);
expect(result.firstItemId).toBe('turn-2-user');
});
it('锚点原地打转时立即停止,不重复取同一页', async () => {
const existing = entriesOf([userItem('turn-2-user')]);
const readSlice = readerOf([
slice([toolItem('call-3')], true, 'turn-2-user'),
]);
const result = await readDirectHistoryPages({
existingEntries: existing,
beforeItemId: 'turn-2-user',
readSlice,
});
expect(readSlice).toHaveBeenCalledTimes(1);
expect(result.hasMore).toBe(false);
});
it('某一页读取失败时保留已经取到的页', async () => {
const existing = entriesOf([userItem('turn-2-user')]);
const failure = new Error('读取失败');
const readSlice = vi.fn(async (beforeItemId: string | null) => {
if (beforeItemId === 'turn-2-user') {
return slice([toolItem('call-3')], true, 'call-3');
}
throw failure;
});
const result = await readDirectHistoryPages({
existingEntries: existing,
beforeItemId: 'turn-2-user',
readSlice,
});
expect(readSlice).toHaveBeenCalledTimes(2);
expect(result.items.map((item) => item.itemId)).toEqual(['call-3']);
// 失败的页没有推进锚点:下一次点击会重取这一页。
expect(result.firstItemId).toBe('call-3');
expect(result.hasMore).toBe(true);
expect(result.error).toBe(failure);
});
it('首页读取失败时保留首屏锚点并返回错误', async () => {
const failure = new Error('首页读取失败');
const readSlice = vi.fn(async (_beforeItemId: string | null) => {
throw failure;
});
const result = await readDirectHistoryPages({
existingEntries: [],
beforeItemId: null,
readSlice,
});
expect(readSlice).toHaveBeenCalledTimes(1);
expect(result.items).toEqual([]);
expect(result.firstItemId).toBeNull();
expect(result.hasMore).toBe(false);
expect(result.error).toBe(failure);
});
});
File diff suppressed because it is too large Load Diff
@@ -28,8 +28,8 @@
1. `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml direct_thread -- --nocapture`
2. `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml export_bindings`(生成 `src/features/project-workspace/generated/`),随后用 `prettier --write` 格式化生成目录,避免未格式化的 ts-rs 输出混进提交
3. `npx vitest run apps/ai-game-creator-shell/tests/directThreadChat.test.ts apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts apps/ai-game-creator-shell/tests/directHistoryPaging.test.ts`
4. `npx vitest run apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts`
3. `npm run test -- apps/ai-game-creator-shell/tests/directThreadChat.test.ts apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts apps/ai-game-creator-shell/tests/directHistoryPaging.test.ts apps/ai-game-creator-shell/tests/directHistoryAnchorGate.test.ts`
4. `npm run test -- apps/ai-game-creator-shell/tests/appSurface.test.ts``project-development.suite.ts` 由该入口注册,不能作为独立测试入口;同时验证现役 Design Agent 的会话恢复与审批界面。)
5. TypeScript 类型检查与 ESLint(范围同前次 DirectProject 迁移)。
6. `npm run check:encoding``npm run check:doc-index``git diff --check`