合并 master:DirectProject 回合三态与 AGC release 每日调度
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
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

- 合并 origin/master 11 个提交(DirectProject 回合三态与投影 memo、宿主崩掉后的回合收口、权限拒绝后的忙态、策划对话布局修复、AGC release 每日调度)。
- 本次自动合并无冲突;游戏分发发布入口(App 的 requestGamePublish、DirectProjectChatView 透传、聊天头「发布到游戏广场」按钮)在新聊天状态机下保持接线。
- 验证:root / AGC / admin-web 三端 typecheck、全量 vitest、游戏分发 Rust 测试、encoding、doc-index、rustfmt 与 SpacetimeDB schema guard。
This commit is contained in:
2026-09-22 16:49:34 +08:00
31 changed files with 1542 additions and 99 deletions
+10 -3
View File
@@ -519,6 +519,7 @@ export function App({
texts.push(entry.text);
reasoningByMessageId.set(entry.messageId, texts);
}
// 持久策划消息没有发送时间,不能把读取时刻显示成历史发送时间。
const messages: ChatMessage[] = view.messages
.filter((message) => message.text.trim())
.map((message) => ({
@@ -527,7 +528,6 @@ export function App({
runtimeOwned: true,
messageId: message.id,
reasoningText: reasoningByMessageId.get(message.id)?.join('\n\n'),
updatedAt: Date.now(),
}));
const initialPrompt = initialPlanningPromptLatchRef.current.prompt;
if (
@@ -540,7 +540,6 @@ export function App({
role: 'user',
text: initialPrompt,
runtimeOwned: true,
updatedAt: Date.now(),
});
}
return messages;
@@ -878,7 +877,15 @@ export function App({
if (messageList) {
messageList.scrollTop = messageList.scrollHeight;
}
}, [messages, projectChatError]);
}, [
messages,
projectChatError,
designAgentTransientReply,
designAgentReasoning,
designAgentView,
pendingUiConfirmation,
chatFileImportNotice,
]);
useEffect(() => {
latestMessagesRef.current = messages;
+62 -19
View File
@@ -10486,8 +10486,8 @@ button.design-workspace-tree__entry:hover,
justify-self: start;
}
/* 策划聊天区包含阶段控制卡消息Runtime 状态和输入框GameAgent 资源工作台的
消息列表默认占满整个聊天区策划模式需要单独恢复五行布局避免输入框被推到视口外 */
/* 阶段状态条和可选待办按内容排布只有消息列表吸收剩余高度
不按子节点序号分配 grid 避免状态条或待办占用消息的伸缩空间 */
/* 策划工作台保留标题行,避免共用跨行规则将标题挤到底部。 */
.game-workbench-layout--design .game-workbench-chat {
grid-template-rows: auto minmax(0, 1fr);
@@ -10507,13 +10507,24 @@ button.design-workspace-tree__entry:hover,
}
.game-workbench-layout--design .project-chat-conversation {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto auto auto;
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: visible;
}
.game-workbench-layout--design .project-chat-conversation > * {
flex: 0 0 auto;
min-width: 0;
}
.game-workbench-layout--design
.project-chat-conversation
> .project-chat-message-list {
flex: 1 1 0;
}
.game-workbench-layout--design .project-chat-message-list {
height: auto;
min-height: 0;
@@ -10521,6 +10532,16 @@ button.design-workspace-tree__entry:hover,
padding-bottom: 12px;
}
.game-workbench-layout--design .design-agent-controls,
.game-workbench-layout--design .design-agent-pending-actions,
.game-workbench-layout--design .pending-command {
flex-shrink: 1;
min-height: 0;
max-height: 30%;
overflow: auto;
overflow-wrap: anywhere;
}
.game-workbench-layout--design .project-chat-composer {
min-height: 0;
grid-template-columns: minmax(0, 1fr);
@@ -10541,13 +10562,24 @@ button.design-workspace-tree__entry:hover,
}
@media (max-width: 760px) {
.game-workbench-layout--design {
height: auto;
.game-project-workbench--design {
overflow-x: hidden;
overflow-y: auto;
}
/* 两块面板在固定外壳内滚动;覆盖外壳的 height: 100%,避免第二行被裁切。 */
.game-project-workbench--design .game-workbench-layout--design {
height: auto;
grid-template-columns: minmax(0, 1fr);
}
.game-workbench-layout--design .game-workbench-stage {
height: 560px;
}
.game-workbench-layout--design .game-workbench-stage,
.game-workbench-layout--design .game-workbench-chat {
min-height: 560px;
height: clamp(560px, calc(100dvh - 154px), 900px);
}
.game-workbench-layout--design .project-chat-surface,
@@ -11384,6 +11416,12 @@ button.design-workspace-tree__entry:hover,
font-variant-numeric: tabular-nums;
}
.game-workbench-layout--design .project-chat-composer-notice {
max-height: 96px;
overflow: auto;
overflow-wrap: anywhere;
}
.game-workbench-layout--design .project-chat-composer-notice,
.game-workbench-chat
.project-chat-surface.is-direct-codex
@@ -11618,6 +11656,7 @@ button.design-workspace-tree__entry:hover,
/* 顶部极简条44px 无独立背景块只有一条极细分隔线
左右内缩与消息内容 / 输入盒统一为 16px面板自身 padding 已归 0 */
.game-workbench-layout--design .project-chat-topbar,
.game-workbench-chat
.project-chat-surface.is-direct-codex
.project-chat-topbar {
@@ -11632,6 +11671,19 @@ button.design-workspace-tree__entry:hover,
background: transparent;
}
.game-workbench-layout--design .project-chat-topbar-status,
.game-workbench-chat
.project-chat-surface.is-direct-codex
.project-chat-topbar-status {
display: flex;
align-items: center;
gap: 7px;
min-width: 0;
color: var(--platform-text-base);
font-size: 12px;
line-height: 1.5;
}
.game-workbench-chat
.project-chat-surface.is-direct-codex
.project-chat-topbar-actions {
@@ -11678,18 +11730,7 @@ button.design-workspace-tree__entry:hover,
cursor: not-allowed;
}
.game-workbench-chat
.project-chat-surface.is-direct-codex
.project-chat-topbar-status {
display: flex;
align-items: center;
gap: 7px;
min-width: 0;
color: var(--platform-text-base);
font-size: 12px;
line-height: 1.5;
}
.game-workbench-layout--design .project-chat-topbar-status > span:last-child,
.game-workbench-chat
.project-chat-surface.is-direct-codex
.project-chat-topbar-status
@@ -11698,6 +11739,7 @@ button.design-workspace-tree__entry:hover,
overflow-wrap: anywhere;
}
.game-workbench-layout--design .project-chat-topbar-dot,
.game-workbench-chat
.project-chat-surface.is-direct-codex
.project-chat-topbar-dot {
@@ -11709,6 +11751,7 @@ button.design-workspace-tree__entry:hover,
background: var(--platform-neutral-bg);
}
.game-workbench-layout--design .project-chat-topbar-dot.is-busy,
.game-workbench-chat
.project-chat-surface.is-direct-codex
.project-chat-topbar-dot.is-busy {
@@ -1,6 +1,12 @@
import type { UIEventHandler } from 'react';
import type { Ref } from 'react';
import { useEffect, useImperativeHandle, useRef, useState } from 'react';
import {
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import { AGENT_CHAT_SCROLL_BOTTOM_THRESHOLD } from '../../../app/constants';
import { claimInitialTurnForPage } from '../../../app/initialTurnClaims';
@@ -18,6 +24,7 @@ import {
useDirectProjectChatController,
} from './controller/useDirectProjectChatController';
import { useDirectProjectManifest } from './controller/useDirectProjectManifest';
import { useDirectProjectTurnStatus } from './controller/useDirectProjectTurnStatus';
import {
createDirectProjectTurnId,
directCodexConversationMessageId,
@@ -125,6 +132,7 @@ export function DirectProjectChatView({
historyHasMore,
loadEarlierHistory,
localMessages,
pendingUserItemId,
queuedTurns,
removeAttachment,
startInitialTurn,
@@ -134,7 +142,27 @@ export function DirectProjectChatView({
turnCancelling,
uploadFiles,
} = chat;
const busy = turnBusy || directTurnRunning;
// 这份投影要参与下游 memo 的判据,必须自己先缓存:不缓存的话每次渲染都是一份新数组,
// `useDirectProjectTurnStatus` 里以 `turns` 为判据的 `useMemo` 永远命中不了,那层 memo
// 就成了死代码,读代码的人还会误以为 `turnStatus` 是引用稳定的。
const directTurns = useMemo(
() =>
buildDirectChatTurns({
entries: directEntries,
localMessages,
turnRunning: directTurnRunning,
pendingUserItemId,
}),
[directEntries, localMessages, directTurnRunning, pendingUserItemId],
);
// 「这一轮在跑吗」只从这一个派生入口读:原生真相 / 本地命令在飞 / 最新一轮三态。
const turnStatus = useDirectProjectTurnStatus({
turnRunning: directTurnRunning,
turnBusy,
turns: directTurns,
});
const activeTurnStartedAt =
directTurns.find((turn) => turn.state === 'running')?.startedAt ?? 0;
const statusText =
runtimeNotice ||
statusNotice ||
@@ -147,7 +175,7 @@ export function DirectProjectChatView({
return;
if (projectPath !== initialTurn.projectPath) return;
// projectId 来自项目清单:清单还没到位时不能先认领,否则首轮需求会被空项目吃掉。
if (!projectId || busy) return;
if (!projectId || turnStatus.displayBusy) return;
if (
!claimInitialTurnForPage(initialTurn.projectPath, initialTurn.claimScope)
) {
@@ -168,9 +196,9 @@ export function DirectProjectChatView({
: {}),
userItem,
});
// 首轮需求只由入口 payload、项目身份、清单就绪和回合态驱动。
// 首轮需求只由入口 payload、项目身份、清单就绪和回合态驱动。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [busy, initialTurn, projectId, projectPath]);
}, [turnStatus.displayBusy, initialTurn, projectId, projectPath]);
useEffect(() => {
if (!shouldFollowLatestRef.current) return;
@@ -178,14 +206,6 @@ export function DirectProjectChatView({
if (list) list.scrollTop = list.scrollHeight;
}, [directEntries, localMessages]);
const directTurns = buildDirectChatTurns({
entries: directEntries,
localMessages,
turnRunning: directTurnRunning,
});
const activeTurnStartedAt =
directTurns.find((turn) => turn.active)?.startedAt ?? 0;
useImperativeHandle(ref, () => ({
announce: (text: string) => {
appendLocalMessage({ role: 'assistant', text, updatedAt: Date.now() });
@@ -209,7 +229,7 @@ export function DirectProjectChatView({
>
<div className="project-chat-conversation">
<DirectProjectChatHeader
busy={busy}
busy={turnStatus.displayBusy}
statusText={statusText}
onOpenSettings={() => setSettingsOpen(true)}
onRequestGamePublish={onRequestGamePublish}
@@ -218,7 +238,7 @@ export function DirectProjectChatView({
turns={directTurns}
messagesRef={messagesRef}
historyHasMore={historyHasMore}
running={directTurnRunning}
nativeRunning={turnStatus.nativeRunning}
activeTurnStartedAt={activeTurnStartedAt}
onLoadEarlierHistory={() => void loadEarlierHistory()}
onScroll={handleScroll}
@@ -231,7 +251,7 @@ export function DirectProjectChatView({
attachmentNotice={attachmentNotice}
queuedTurns={queuedTurns}
composerNotice={composerNotice}
busy={busy}
busy={turnStatus.displayBusy}
turnCancelling={turnCancelling}
onCancelQueuedTurn={cancelQueuedTurn}
onCancelTurn={() => void cancelTurn()}
@@ -19,7 +19,7 @@ export function DirectProjectConversation({
turns,
messagesRef,
historyHasMore,
running,
nativeRunning,
activeTurnStartedAt,
onLoadEarlierHistory,
onScroll,
@@ -27,7 +27,11 @@ export function DirectProjectConversation({
turns: DirectChatTurn[];
messagesRef: RefObject<HTMLDivElement | null>;
historyHasMore: boolean;
running: boolean;
/**
* 原生回合是否在跑(reducer 的 `turnRunning`):只决定这张"正在处理"卡片。
* 本地命令在飞但原生还没认领的窗口见 `DirectProjectTurnStatus`。
*/
nativeRunning: boolean;
activeTurnStartedAt: number;
onLoadEarlierHistory: () => void;
onScroll: UIEventHandler<HTMLDivElement>;
@@ -53,7 +57,7 @@ export function DirectProjectConversation({
<DirectProjectTurn key={turn.key} turn={turn} />
))}
</div>
{running ? (
{nativeRunning ? (
<AgentMessageContent
as="section"
tone="process"
@@ -20,14 +20,22 @@ import {
/**
* 一个完整回合的分区表现:用户发言、执行过程(工具/思考)与最终答复。
*
* 运行中的回合把执行过程平铺出来,已结束的回合折叠进「执行过程」;这一层只做投影到
* 表现的渲染,不拥有任何回合状态。
* 未结束的回合(`running` / `awaiting-start`)把执行过程平铺出来并隐藏终态文案,
* `finished` 才折叠进「执行过程」;这一层只做投影到表现的渲染,不拥有任何回合状态。
*
* 三态的判据分两类,不要对调(三态定义与真值表见
* `../../conversation/directTurnPresentation.ts` 的 `DirectChatTurnState`):
* - **否定式**(不要说它结束、不要折叠、不要显示终态文案)读 `state !== 'finished'`
* `awaiting-start` 时轮次确实还没结束,只是宿主还没确认。
* - **肯定式**(哪一段正文在流式、"正在处理"这类断言)读 `state === 'running'`
* `awaiting-start` 只说明本地已发出,不能据此断言宿主已经在跑。
*/
export function DirectProjectTurn({ turn }: { turn: DirectChatTurn }) {
const streamingKey = turn.active
? ([...turn.process].reverse().find((block) => block.kind === 'assistant')
?.key ?? null)
: null;
const streamingKey =
turn.state === 'running'
? ([...turn.process].reverse().find((block) => block.kind === 'assistant')
?.key ?? null)
: null;
return (
<Fragment>
{turn.users.map((block) =>
@@ -48,7 +56,7 @@ function renderTurnProcess(turn: DirectChatTurn, streamingKey: string | null) {
const blocks = turn.process.map((block) =>
renderBlock(turn, block, 'process', streamingKey),
);
if (turn.active) return blocks;
if (turn.state !== 'finished') return blocks;
return (
<details className="message-turn-process" data-testid="turn-process">
<summary></summary>
@@ -58,7 +66,14 @@ function renderTurnProcess(turn: DirectChatTurn, streamingKey: string | null) {
}
function DirectProjectTurnUsage({ turn }: { turn: DirectChatTurn }) {
if (turn.active || !turn.startedAt) return null;
// 否定式判据:未结束的回合不显示终态文案。`awaiting-start` 走这一条,所以"本地已发出、
// 原生还没认领"的窗口里不会再出现「本轮结束于 … 耗时 0.0秒」。
// 仍未修的另一半(A):`finished` 但没有可证明终态时间的回合,会被下面的 `Math.max` 兜底
// 量化成 0.0 秒,共两类——① 重进项目后读回来的历史回合(`turnEndedAt` 只活在本次会话里,
// 不会随 `project.jsonl` 持久化);② 本地已发出却一个原生事件都没产生的回合(发送失败)。
// 修法是只在 `turn.endedAt > 0` 时渲染终态文案、耗时改由 `turnTotalDurationMs()` 出(边界缺失
// 就隐藏),属于产品口径变化(宁可隐藏也不编),确认后单独改;改完把这半段注释删掉。
if (turn.state !== 'finished' || !turn.startedAt) return null;
const endedAt = Math.max(turn.endedAt, turn.startedAt);
return (
<p
@@ -84,7 +99,7 @@ function renderBlock(
<ToolCallGroup
key={block.key}
calls={block.calls}
active={turn.active}
active={turn.state === 'running'}
className="message-tool-call"
/>
);
@@ -91,6 +91,56 @@ export type DirectProjectChatControllerProps = {
*
* 订阅、首屏锚点与历史分页、发送与 FIFO 队列、附件、终止、草稿和本地消息都归这里;
* 工作台壳只注入项目上下文与两条权限门,不再持有 Direct 专属 state/ref/effect。
*
* ## 数据流(改判据前先读这一段)
*
* ```
* Rust 宿主(事实的产生地)
* ├─ project.jsonl 持久化原始条目(AGC 写;app-server 回显的用户消息被过滤)
* └─ Thread Manager 事件队列(内存) per-thread 事件序列 + 每 subscriber 游标 + lifecycle_anchor
* │ append_direct_thread_event() → notify(只带 subscriptionId,纯唤醒)
* ▼
* 传输层(三条通道,前端各拉各的)
* A 运行态:notify → invoke consume_direct_project_thread → events[](实时)
* B 历史: invoke read_direct_project_history_slice → items[](分页,文件尾反向扫描)
* C 本地: 前端自己造(乐观用户气泡、忙态、失败 / 终止说明)
* ▼
* 前端
* useDirectThreadChatSubscription reducerA + B 进同一份 stateturnRunning / history / live
* ▼
* useDirectProjectChatController 本地状态:localMessages / turnBusy / pendingUserItemId / 队列
* ▼
* DirectProjectChatView turns = buildDirectChatTurns(...)status = useDirectProjectTurnStatus(...)
* ▼
* DirectProjectTurn 用户气泡 / 过程块 / 最终回复 / 本轮耗时
* ```
*
* 三份原始输入各自是什么、带什么、活多久:
* - 项目对话历史(`.agent/conversations/project.jsonl`):持久,只有条目、**没有回合边界**,
* 经历史切片读取(首屏按 `lastCompletedItemId` 锚定)。
* - 运行态事件(subscribe / consume / notify):进程内;`turn.started` / `turn.completed` 是原生回合
* 活跃与否的**唯一**判据;可回收事件被回收后靠 `lifecycle_anchor` 保住最新一条生命周期事件。
* - 本地发送:只存在于本次会话,`projectPath` 变化即清空;乐观气泡与原生条目同身份
* `direct-codex:{clientTurnId}:user`),所以两边按**身份**合并,不按时间戳猜。
*
* 一次发送的时序(第 2 → 3 步之间就是「本地已发出、宿主还没确认」的空窗):
* 1. 按下发送:`localMessages += 乐观气泡`、`turnBusy=true`、`pendingUserItemId=本轮身份`(同帧)。
* 2. `invoke('chat_with_game_creator_direct_codex')`Rust 先落盘用户条目,再发 `turn/start`
* **应答返回后**才 append `turn.started` 并 notify。
* 3. notify → consume → `turn.started`reducer 的 `turnRunning=true`、`turnStartedAt`、`turnUserItemId`。
* 4. `item.completed`(本轮用户条目回显):同身份条目已在历史里就合并进去,否则进 `live`;本地气泡此时被去重。
* 5. `item.delta` / `item.started` / `item.completed`:正文追加、工具卡片 upsert(先到定形、后到只补空)。
* 6. `turn.completed``live` 并入 `history` 后清空,`turnEndedAt` 冻结,边界按身份盖到本轮开口条目上。
* 7. 命令收尾(`finally`):刷新清单 → `endTurnCommand()` 清掉忙态与在途身份 → 出队下一轮。
* **顺序是契约**:出队会同步开始下一轮并设上它自己的忙态,所以清忙态必须早于出队;
* 权限被拒那种「本轮从未发出但要继续出队」的情况,也只标记 `queueAdvance`、由这里统一收口。
*
* 状态变量归属:reducer 的三个回合字段与 `history` / `live` 只由 `directThreadChat.ts` 写;
* 本文件的 `turnBusy` / `pendingUserItemId`(同生共死,唯一入口 `beginTurnCommand` /
* `endTurnCommand`)、`localMessages`、发送队列与分页 ref 只服务发送与展示;界面上的
* 「这一轮在跑吗」只有一个派生入口 `useDirectProjectTurnStatus()`,三态判据与真值表在
* `../conversation/directTurnPresentation.ts` 的 `DirectChatTurnState`,渲染时否定式读
* `state !== 'finished'`、肯定式读 `state === 'running'`(见 `DirectProjectTurn.tsx`)。
*/
export function useDirectProjectChatController({
assets,
@@ -113,6 +163,9 @@ export function useDirectProjectChatController({
const [statusNotice, setStatusNotice] = useState('');
const [turnCancelling, setTurnCancelling] = useState(false);
const [turnBusy, setTurnBusy] = useState(false);
// 本地已发出、原生还没认领的那一轮用户条目身份:只服务投影的 `awaiting-start` 展示态,
// 生命周期与 `turnBusy` 完全一致(命令在飞期间有值,收尾即清)。
const [pendingUserItemId, setPendingUserItemId] = useState<string>('');
const [localMessages, setLocalMessages] = useState<ChatMessage[]>([]);
// 订阅(subscribe/consume/notify)与聊天 reducer 状态在自己的 hook 里:
// controller 只读投影后的条目与回合忙态,不再直接持有线程状态。
@@ -143,6 +196,7 @@ export function useDirectProjectChatController({
setQueuedTurns([]);
queuedTurnsRef.current = [];
setLocalMessages([]);
setPendingUserItemId('');
setHistoryHasMore(false);
historyOldestItemIdRef.current = null;
}, [projectPath]);
@@ -194,9 +248,21 @@ export function useDirectProjectChatController({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled, projectPath]);
function markTurnBusy(busy: boolean) {
turnBusyRef.current = busy;
setTurnBusy(busy);
/**
* 「本地这一轮的命令在飞」的唯一起止点:按下发送时带上本轮用户条目身份,收尾时一起清掉。
*
* 忙态与待认领身份必须同生共死,否则投影会拿一个过期的身份去判 `awaiting-start`。
*/
function beginTurnCommand(userItemId: string) {
turnBusyRef.current = true;
setTurnBusy(true);
setPendingUserItemId(userItemId);
}
function endTurnCommand() {
turnBusyRef.current = false;
setTurnBusy(false);
setPendingUserItemId('');
}
function appendLocalMessage(message: ChatMessage) {
@@ -388,9 +454,14 @@ export function useDirectProjectChatController({
updatedAt: Date.now(),
});
}
markTurnBusy(true);
beginTurnCommand(
directCodexConversationMessageId(input.clientTurnId, 'user'),
);
void (async () => {
let invoked = false;
// 权限被拒也要继续出队(见下),但出队必须发生在 finally 的 endTurnCommand() 之后:
// 在这里出队的话,下一轮刚设上的忙态会被紧接着的 finally 清掉。
let queueAdvance = false;
try {
onRuntimeError('');
if (!input.directPolicyChecked) {
@@ -404,10 +475,9 @@ export function useDirectProjectChatController({
});
if (!allowed) {
// 写权限门返回 false 且没有调用 onConfirmed(被策略拒绝、或读策略失败),
// 说明这一轮不会重跑;它已经被 dispatchNextQueuedTurn 出队,必须自己把
// 忙态放下并继续出队,否则后面的排队消息会永久卡住。
markTurnBusy(false);
dispatchNextQueuedTurn();
// 说明这一轮不会重跑;它已经被出队,必须继续出队,否则后面的排队消息会
// 永久卡住。放忙态与出队都交给 finally 收口,这里只做标记
queueAdvance = true;
return;
}
}
@@ -429,8 +499,15 @@ export function useDirectProjectChatController({
if (invoked) {
await refreshDirectManifest(nextProjectPath);
}
markTurnBusy(false);
if (invoked && projectPathRef.current === nextProjectPath) {
// 忙态与在途身份每轮只在这里放一次,且必须早于出队:出队会同步开始下一轮并设上
// 它自己的忙态,清在它后面就等于把下一轮的忙态抹掉(composer 会以为可以并发发送,
// 下一轮的三态也会因为身份被清空而掉回 finished)。
endTurnCommand();
// 出队条件保持原样:真发出过的一轮要求项目没被换掉;权限被拒的一轮从未发出,
// 不受项目切换影响,照旧出队。
const invokedInSameProject =
invoked && projectPathRef.current === nextProjectPath;
if (queueAdvance || invokedInSameProject) {
dispatchNextQueuedTurn();
}
}
@@ -478,6 +555,13 @@ export function useDirectProjectChatController({
});
return;
}
// 真失败:这条命令返回就说明这一轮在宿主那边已经收场,但终态事件可能永远不来
// app-server 崩了、任务被中止、panic 都只留下一条开着的 `turn.started`)。
// 按本轮身份放掉原生忙态,否则界面会一直显示「正在处理」、输入盒一直排队。
// 主动终止与「正在跑的是另一轮」不走这里:前者宿主必然补终态,后者不是这一轮。
directThread.stopCommandTurn(
directCodexConversationMessageId(input.clientTurnId, 'user'),
);
void captureAgentRuntimeError(error, DIRECT_CODEX_AGENT_ID);
const message = error instanceof Error ? error.message : String(error);
let persistedDetail = '';
@@ -541,7 +625,7 @@ export function useDirectProjectChatController({
const message = result?.message?.trim();
if (result?.outcome === 'released') {
directThread.markTurnStopped();
markTurnBusy(false);
endTurnCommand();
onRuntimeError('');
setComposerNotice(message ?? '已结束这一轮占用,可以直接重新发送消息');
} else if (message) {
@@ -714,6 +798,7 @@ export function useDirectProjectChatController({
historyHasMore,
loadEarlierHistory,
localMessages,
pendingUserItemId,
queuedTurns,
reloadHistory,
removeAttachment,
@@ -0,0 +1,68 @@
import { useMemo } from 'react';
import type {
DirectChatTurn,
DirectChatTurnState,
} from '../conversation/directTurnPresentation';
/**
* DirectProject「这一轮在跑吗」的唯一派生入口。
*
* 同一件事此前在四层里各叫一个名字(reducer 的 `turnRunning`、controller 的 `turnBusy`、
* 视图里手拼的 `busy`、投影里的 `active`),读代码时无法判断谁该信谁。这里把它们的语义
* 一次讲清楚,组件只读这一个对象:
*
* - `nativeRunning`**原生真相**。只由订阅 reducer 的 `turnRunning` 给出(`turn.started`
* 已到、`turn.completed` 未到)。它决定"陶泥儿正在处理"这类原生过程提示。
* - `commandInFlight`**本地真相**。本次会话的发送命令是否在飞(写权限门 → invoke →
* 收尾);它从按下发送那一刻就为真,与原生是否已经开始无关。
* - `displayBusy`header / composer 该读的忙态,就是两者的并集:只要有一条成立就不能再
* 接受新的发送。
* - `latestTurnState`:最新一轮在界面上的三态(投影结果);没有回合时为 null。
*
* 约定:新增"忙/在跑"类判据一律先落进这里,不要在组件里再拼布尔。
* `latestTurnState` 三态各自的含义、判据输入与真值表写在
* `../conversation/directTurnPresentation.ts` 的 `DirectChatTurnState`。
* 数据流、变量归属与一次发送的时序见 `useDirectProjectChatController.ts` 的模块注释。
*/
export type DirectProjectTurnStatus = {
nativeRunning: boolean;
commandInFlight: boolean;
displayBusy: boolean;
latestTurnState: DirectChatTurnState | null;
};
export function deriveDirectProjectTurnStatus({
turnRunning,
turnBusy,
turns,
}: {
turnRunning: boolean;
turnBusy: boolean;
turns: readonly DirectChatTurn[];
}): DirectProjectTurnStatus {
const nativeRunning = Boolean(turnRunning);
const commandInFlight = Boolean(turnBusy);
const latest = turns.length > 0 ? turns[turns.length - 1] : null;
return {
nativeRunning,
commandInFlight,
displayBusy: nativeRunning || commandInFlight,
latestTurnState: latest ? latest.state : null,
};
}
export function useDirectProjectTurnStatus({
turnRunning,
turnBusy,
turns,
}: {
turnRunning: boolean;
turnBusy: boolean;
turns: readonly DirectChatTurn[];
}): DirectProjectTurnStatus {
return useMemo(
() => deriveDirectProjectTurnStatus({ turnRunning, turnBusy, turns }),
[turnRunning, turnBusy, turns],
);
}
@@ -19,6 +19,7 @@ import {
mergeDirectHistoryItems,
resolveDirectThreadBootstrap,
selectDirectChatEntries,
stopDirectThreadTurn,
} from '../conversation/directThreadChat';
import type { DirectThreadConsumeResult } from '../generated/DirectThreadConsumeResult';
import type { DirectThreadItem } from '../generated/DirectThreadItem';
@@ -40,6 +41,11 @@ export type DirectThreadChatSubscription = {
mergeHistoryItems: (items: readonly DirectThreadItem[]) => void;
/** 终止成功(`released`)时手动放掉回合占用:订阅可能要等下一个事件才知道。 */
markTurnStopped: () => void;
/**
* 本地命令失败收场时按身份放掉这一轮:宿主的终态事件可能永远不会来(进程崩了 /
* 任务被中止),不能一直挂在 `turn.started` 上显示「正在处理」。
*/
stopCommandTurn: (userItemId: string) => void;
};
/**
@@ -185,6 +191,13 @@ export function useDirectThreadChatSubscription({
[],
);
const stopCommandTurn = useMemo(
() => (userItemId: string) => {
setState((current) => stopDirectThreadTurn(current, userItemId));
},
[],
);
const entries = useMemo(() => selectDirectChatEntries(state), [state]);
return {
@@ -194,5 +207,6 @@ export function useDirectThreadChatSubscription({
anchorGateRef,
mergeHistoryItems,
markTurnStopped,
stopCommandTurn,
};
}
@@ -45,7 +45,14 @@ export type DirectChatEntry = {
};
export type DirectThreadChatState = {
/** 最新回合是否还在跑;只由生命周期事件的先后决定。 */
/**
* 最新**原生**回合是否还在跑;只由生命周期事件(`turn.started` / `turn.completed`
* 的先后决定。
*
* 它不等于界面上的「这一轮在跑吗」:本地已发出、宿主还没回 `turn.started` 的那一段
* 窗口里它为假,但那一轮在界面上是"待认领"而不是"已结束"。界面侧的三态与判据见
* `directTurnPresentation.ts` 的 `DirectChatTurnState`。
*/
turnRunning: boolean;
/** 原生 `turn.started.at`:本轮用户实际发送时间缺失时的起点兜底;0 = 缺失。 */
turnStartedAt: number;
@@ -58,6 +65,16 @@ export type DirectThreadChatState = {
* 也不用时间戳近似。空串 = 原生没给身份(旧事件),此时不猜历史归属。
*/
turnUserItemId: string;
/**
* 「本地命令已经返回、宿主却一直没给终态」的那一轮身份(见 `stopDirectThreadTurn`)。
*
* `turn.started` 与 `turn.completed` 是原生回合唯一的开闭配对,但**进程崩了、任务被
* 中止、panic** 这类收场不会补终态事件,只留一条永远开着的 `turn.started`:界面上就
* 一直显示「正在处理」,输入盒也一直忙。本地那一条命令(`chat_with_game_creator_direct_codex`
* 返回时说到底就是"这一轮在宿主那边已经收场",这条身份就是它的记录:同身份的
* `turn.started` 迟到 / 重放回来不再复活这一轮,避免收口之后又被拉回运行态。
*/
commandClosedTurnUserItemId: string;
/** 历史切片条目,保持文件顺序。 */
history: DirectChatEntry[];
/** 当前回合的运行态条目,保持到达顺序;回合结束即并入历史并清空。 */
@@ -70,11 +87,40 @@ export function emptyDirectThreadChatState(): DirectThreadChatState {
turnStartedAt: 0,
turnEndedAt: 0,
turnUserItemId: '',
commandClosedTurnUserItemId: '',
history: [],
live: [],
};
}
/**
* 本地命令失败收场:这一轮命令已经返回,宿主却还在事件流里挂着 `turn.started`。
*
* 只放掉"是否在跑",**不写终态时间**——命令返回不等于我们知道这一轮真正的结束时刻,
* 编一个只会让耗时变成假数。收口后同身份的 `turn.started` 迟到 / 重放回来不再复活,
* 免得刚修好的"还在处理"又被拉起来。宿主随后真发来 `turn.completed` 时照旧正常收口。
*
* 身份不同的轮次不动:宿主同时只允许一条回合,但"正在跑的是另一轮"`another-turn-running`
* 这种拒绝也要能原样报给用户,不能顺手把别人那轮抹掉。空身份(宿主没能落上身份)时按
* 本轮处理,否则这条兜底永远盖不住协议早期失败。
*/
export function stopDirectThreadTurn(
state: DirectThreadChatState,
userItemId: string,
): DirectThreadChatState {
if (state.turnUserItemId !== '' && state.turnUserItemId !== userItemId) {
return state;
}
if (!state.turnRunning && state.commandClosedTurnUserItemId === userItemId) {
return state;
}
return {
...state,
turnRunning: false,
commandClosedTurnUserItemId: userItemId,
};
}
/** 时间戳合法性:缺失 / 0 / 非有限都算没有这个边界,不用它计任何耗时。 */
function validBoundaryAt(value: number | null | undefined): number {
return typeof value === 'number' && Number.isFinite(value) && value > 0
@@ -295,6 +341,14 @@ export function reduceDirectThreadEvent(
// 本轮的 canonical user identity 跟着事件走:新回合就换成新的;旧原生不带身份时
// 清空而不是继承上一轮,避免上一轮迟到的终态按身份匹配到这一轮。
const turnUserItemId = readDirectThreadEventUserItemId(event);
// 本地命令已经收过场的那一轮:迟到的 `turn.started` 不得把它拉回运行态(见
// `commandClosedTurnUserItemId`)。身份按 clientTurnId 唯一,只挡它自己那一轮。
if (
turnUserItemId !== '' &&
turnUserItemId === state.commandClosedTurnUserItemId
) {
return state;
}
return {
...state,
turnRunning: true,
@@ -316,7 +370,17 @@ export function reduceDirectThreadEvent(
return state;
}
// 已经收口、而且没有新的运行态条目:重复 / 迟到的终态事件不改动时间,也不复活运行态。
if (!state.turnRunning && state.live.length === 0) {
// 例外是"本地命令兜底收口"的那一轮(`commandClosedTurnUserItemId` 命中且还没有终态
// 时间):那次收口本来就没写时间,宿主这份迟到的终态要拿来补上真正的结束时刻。
const lateTerminalForCommandClosedTurn =
eventUserItemId !== '' &&
eventUserItemId === state.commandClosedTurnUserItemId &&
state.turnEndedAt <= 0;
if (
!state.turnRunning &&
state.live.length === 0 &&
!lateTerminalForCommandClosedTurn
) {
return state;
}
return finishDirectThreadTurn(state, eventAt);
@@ -36,6 +36,57 @@ export type DirectChatBlock =
| { kind: 'reasoning'; key: string; text: string }
| { kind: 'tools'; key: string; calls: DirectChatToolCard[] };
/**
* 界面上一轮的三态。它是**展示态**,不是第二套回合生命周期。
*
* 三态各自能断言什么(渲染时按这个分两类,不要对调):
* - `running`:**宿主已确认这一轮开始了**(订阅流里出现过 `turn.started`、还没出现
* `turn.completed`)。它是唯一能做肯定式断言的态。
* - `awaiting-start`**本地已把这轮交出去、宿主还没确认**(乐观气泡已出现,`turn.started`
* 未到)。只支持否定式断言:"它还没结束",不能说"它正在跑"。
* - `finished`:其余全部 —— 拿到终态的、身份不匹配的、不是最新一轮的,以及**拿不到边界的
* 历史回合**(这类最容易被误判成"还在跑",必须落在这一态)。
*
* 判据用四个输入(下方 `buildDirectChatTurns` 里那几句 if 就是全部实现):
* - `turn.nativeRunning` ← 入参 `turnRunning` ← reducer 的 `state.turnRunning`
* (只由 `turn.started` / `turn.completed` 决定;`if (current)` 只赋给最后一条回合,
* 所以「非最新一轮 + nativeRunning」不可达)。
* - `pendingUserItemId` ← controller 在 `beginTurnCommand` / `endTurnCommand` 之间维护,
* 生命周期与 `turnBusy` 一致;空串 = 没有在途的本地回合。
* - `turn.key` ← 开这一轮的条目身份:原生用户条目用 `entry.itemId`,本地乐观气泡用
* `message.messageId` —— 两者是**同一个** `direct-codex:{clientTurnId}:user`。
* - `stampedEnd` ← 本轮条目上盖的终态时间,只有 `turn.completed` / 终止收口才写。
*
* 真值表:
*
* | nativeRunning | 最新一轮 && pendingUserItemId 身份命中 | stampedEnd > 0 | → state |
* | true | — | — | running |
* | false | false | 任意 | finished |
* | false | true | true | finished |
* | false | true | false | awaiting-start |
*
* 判据一律用**身份与显式事件**,不用时间戳大小:原生阶段时间是秒级精度、同一秒里可能连开
* 两轮,回显条目的 `at` 还是宿主 ack 的观测时间(晚于用户真实发送)。这也是为什么
* `awaiting-start` 在"原生条目已回显、`turn.started` 未到"的次窗口里同样成立。
*
* 两个容易读错的地方:
* - `pendingUserItemId` 有值 **≠** `awaiting-start``invoke` 直到整轮结束才返回,所以
* `turn.started` 之后它仍在,但那时 `nativeRunning` 已经把它接成 `running`。
* - `endedAt === 0` **≠** 还在跑:历史回合没有边界元数据(`turnEndedAt` 只是会话内展示
* 缓存),它们必须落 `finished`。
*
* 三态在渲染上的映射(否定式 / 肯定式)见 `DirectProjectTurn.tsx` 顶部注释;数据流、变量归属与
* 一次发送的时序见 `../controller/useDirectProjectChatController.ts` 的模块注释。
*
* 已知边界(改 `finished` 判据时要连着一起看):`finished` 只断言"不再有理由认为它在跑",
* **不断言"拿得到终态时间"**。有两类回合没有可证明的边界时间,只被
* `Math.max(turn.endedAt, turn.startedAt)` 兜底量化成 0.0 秒——① 重进项目后读回来的历史回合
* `turnEndedAt` 只是会话内展示缓存,不随 `project.jsonl` 持久化);② 本地已发出却一个原生事件
* 都没产生的回合(发送失败、`turn.started` 没来)。要不要把这两类的终态文案藏掉是产品口径问题
* (宁可隐藏也不编),需要单独确认后单独改,不要顺手塞进三态判据。
*/
export type DirectChatTurnState = 'running' | 'awaiting-start' | 'finished';
export type DirectChatTurn = {
key: string;
/** 用户气泡:顺序即发出顺序。 */
@@ -44,7 +95,8 @@ export type DirectChatTurn = {
process: DirectChatBlock[];
/** 最终回复,以及失败 / 终止这类只存在于运行期的说明。 */
finals: DirectChatBlock[];
active: boolean;
/** 这一轮在界面上的状态(三态,取代原来的 `active` 布尔)。 */
state: DirectChatTurnState;
/**
* 本轮起点:该轮**实际用户消息的发送时间**优先(与气泡显示的时间同源),
* 缺失时用原生 `turn.started.at`,都拿不到是 0(此时隐藏不能证明的总耗时)。
@@ -60,7 +112,8 @@ type DirectChatTurnEntries = {
/** 本地乐观用户气泡:还没有任何落盘条目时的用户消息。 */
localUsers: DirectChatBlock[];
notices: ChatMessage[];
active: boolean;
/** 原生回合是否在跑;只有一个来源——reducer 的 `turnRunning`。 */
nativeRunning: boolean;
};
function blockFromEntry(
@@ -178,7 +231,7 @@ function newTurn(key: string): DirectChatTurnEntries {
entries: [],
localUsers: [],
notices: [],
active: false,
nativeRunning: false,
};
}
@@ -193,6 +246,7 @@ export function buildDirectChatTurns({
localMessages = [],
turnRunning = false,
turnStartedAt = 0,
pendingUserItemId = '',
}: {
entries: readonly DirectChatEntry[];
localMessages?: readonly ChatMessage[];
@@ -202,6 +256,14 @@ export function buildDirectChatTurns({
* 不会覆盖用户实际发送时间,也不参与已完成回合。
*/
turnStartedAt?: number;
/**
* 本地已发出、原生还没认领的那一轮用户条目身份(`direct-codex:{clientTurnId}:user`)。
*
* 只服务 `awaiting-start` 这一个展示态:身份命中、且本轮还没有明确终态时,最新一轮按
* 「待认领」而不是「已结束」呈现。原生 `turn.started` 一到,`turnRunning` 就把这一轮接
* 过去,这个入参不再参与判定;空串 = 没有在途的本地回合。
*/
pendingUserItemId?: string;
}): DirectChatTurn[] {
const turns: DirectChatTurnEntries[] = [];
let current: DirectChatTurnEntries | null = null;
@@ -247,10 +309,11 @@ export function buildDirectChatTurns({
}
current.notices.push(message);
});
if (current) current.active = turnRunning;
if (current) current.nativeRunning = turnRunning;
return turns.map((turn) => {
const lastAssistant = turn.active
const newestTurnIndex = turns.length - 1;
return turns.map((turn, turnIndex) => {
const lastAssistant = turn.nativeRunning
? -1
: turn.entries.reduce(
(found, entry, index) =>
@@ -301,21 +364,38 @@ export function buildDirectChatTurns({
(found, entry) => found || normalizeDirectTimestamp(entry.turnEndedAt),
0,
);
const startedAt =
userSentAt > 0
? userSentAt
: turn.active
? normalizeDirectTimestamp(turnStartedAt)
: stampedStart;
// 起点优先级(逐级覆盖,不嵌套三元):条目上盖的起点 → 运行中改读原生
// `turn.started.at`(拿不到就是 0,不退回去用条目兜底)→ 该轮用户气泡自己的发送
// 时间最高优先。
let startedAt = stampedStart;
if (turn.nativeRunning) {
startedAt = normalizeDirectTimestamp(turnStartedAt);
}
if (userSentAt > 0) {
startedAt = userSentAt;
}
// 终态只读**本轮条目**上盖的边界:跨轮 fallback 会把最新回合的终点填进所有
// 拿不到时间的旧历史回合,等于给未知耗时编一个值。
const endedAt = turn.active ? 0 : stampedEnd;
const endedAt = turn.nativeRunning ? 0 : stampedEnd;
// 三态只在这里产生:原生在跑 = running;最新一轮是本地在途身份且没有终态 =
// awaiting-start;其余都是 finished。判据是身份(`itemId`)而不是时间戳大小。
let state: DirectChatTurnState = 'finished';
if (turn.nativeRunning) {
state = 'running';
} else if (
turnIndex === newestTurnIndex &&
pendingUserItemId !== '' &&
turn.key === pendingUserItemId &&
stampedEnd <= 0
) {
state = 'awaiting-start';
}
return {
key: turn.key,
users,
process: mergeToolBlocks(process),
finals,
active: turn.active,
state,
startedAt,
endedAt,
} satisfies DirectChatTurn;
@@ -199,9 +199,11 @@ export function PlanningChatView({
className={`project-chat-topbar-dot${controlBusy ? ' is-busy' : ''}`}
aria-hidden="true"
/>
{controlBusy
? '策划 Agent 正在处理'
: workspaceStatusForDisplay(workspaceStatus)}
<span>
{controlBusy
? '策划 Agent 正在处理'
: workspaceStatusForDisplay(workspaceStatus)}
</span>
</span>
</header>
<div
@@ -21,6 +21,7 @@ import {
act,
createGameCreationAppManifest,
createProjectChatRuntimeHarness,
emptyProjectPolicy,
expect,
fireEvent,
it,
@@ -469,6 +470,134 @@ export function registerChatComposerControlTests() {
});
});
it('does not report a finished turn while the host has not acknowledged the send yet', async () => {
const pending: Array<{ resolve: (value: string) => void }> = [];
const { invoke, surface } = await openDirectCodexSurface({
chat_with_game_creator_direct_codex: () =>
new Promise<string>((resolve) => {
pending.push({ resolve });
}),
});
const composer = within(surface).getByLabelText('陶泥儿对话内容');
await submitDirectTurn(surface, composer, '窗口期的消息');
// 写权限门是异步的,先等命令真的发出去,否则下面的窗口期断言会在 invoke 还没发生时就
// 通过、收尾的 `pending[0]?.resolve` 也变成空操作,用例根本没盖住它要盖的窗口。
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith(
'chat_with_game_creator_direct_codex',
expectDirectTurnWithText('窗口期的消息'),
);
});
// 本地乐观气泡立刻可见;此刻原生既没回 turn.started,也没回显用户条目,
// 这一轮属于「本地已发出、宿主未确认」,不得渲染成已结束。
await waitFor(() => {
expect(within(surface).getByText('窗口期的消息')).not.toBeNull();
});
expect(within(surface).queryByText(/本轮结束于/)).toBeNull();
expect(within(surface).queryByTestId('turn-usage')).toBeNull();
await act(async () => {
pending[0]?.resolve('回复');
});
});
it('stops claiming the turn is running when a failed send left turn.started open', async () => {
let harness: ReturnType<typeof createProjectChatRuntimeHarness> | null =
null;
const { surface } = await openDirectCodexSurface(
{
chat_with_game_creator_direct_codex: (
args: Record<string, unknown> | undefined,
) => {
// 宿主先认领了这一轮(turn.started),随后崩掉:没有终态事件,命令以失败返回。
harness?.emitDirectThreadEvents({
type: 'turn.started',
at: 5_000,
userItemId: `direct-codex:${String(args?.clientTurnId ?? '')}:user`,
});
throw new Error('模拟宿主崩溃:turn.started 之后没有终态事件');
},
},
(directHarness) => {
harness = directHarness;
},
);
const composer = within(surface).getByLabelText('陶泥儿对话内容');
await submitDirectTurn(surface, composer, '崩掉的那条');
await waitFor(() => {
expect(
within(surface).getAllByText('陶泥儿智能创作 执行失败,请稍后重试')
.length,
).toBeGreaterThan(0);
});
// 命令已经收场:卡片和输入区都不能再声称"还在处理"。
expect(within(surface).queryAllByText('陶泥儿正在处理')).toHaveLength(0);
expect(within(surface).queryByRole('button', { name: '终止' })).toBeNull();
expect(
within(surface).getByRole('button', { name: '发送' }),
).not.toBeNull();
});
it('keeps the next queued turn busy when the write gate refuses the running one', async () => {
const pending: Array<{ resolve: (value: string) => void }> = [];
const deferredPolicies: Array<(value: unknown) => void> = [];
let policyAllowsWrite = false;
const { invoke, surface } = await openDirectCodexSurface({
read_project_permission_policy: () => {
if (policyAllowsWrite) return Promise.resolve(emptyProjectPolicy());
return new Promise((resolve) => {
deferredPolicies.push(resolve);
});
},
chat_with_game_creator_direct_codex: () =>
new Promise<string>((resolve) => {
pending.push({ resolve });
}),
});
const composer = within(surface).getByLabelText('陶泥儿对话内容');
await submitDirectTurn(surface, composer, '被拒的那条');
// 权限门还没回,先把第二条排进队列:后面那条要等被拒的这一轮出队才会发出去。
await setComposerText(composer, '后面那条');
submitComposerForm(composer);
const queue = await within(surface).findByLabelText('待发送消息队列');
expect(within(queue).getByText('后面那条')).not.toBeNull();
// 写权限门拒绝这一轮(策略要求确认,且此刻没有 onConfirmed):这一轮不会重跑,
// 队列必须继续走,而它出队后那一轮仍要算「命令在飞」。
policyAllowsWrite = true;
await act(async () => {
for (const resolve of deferredPolicies.splice(0)) {
resolve({
path: '.agent/policy.json',
policy: {
deniedCommands: [],
confirmCommands: ['conversation.write'],
},
});
}
});
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith(
'chat_with_game_creator_direct_codex',
expectDirectTurnWithText('后面那条'),
);
});
// 被出队的那一轮还在飞:上一轮的收尾不得把它刚设上的忙态清掉。
expect(within(surface).queryByRole('button', { name: '发送' })).toBeNull();
expect(
within(surface).getByRole('button', { name: '终止' }),
).not.toBeNull();
await act(async () => {
pending[0]?.resolve('回复');
});
});
it('restores a running DirectProject turn, queues the next message, and dispatches it on turn.completed', async () => {
const pending: Array<{ resolve: (value: string) => void }> = [];
const { invoke, surface, harness } = await openDirectCodexSurface(
@@ -405,6 +405,148 @@ export function registerDesignAgentSurfaceTests() {
);
});
it('follows streamed Design Agent content until the user scrolls up', async () => {
const harness = createProjectChatRuntimeHarness({
designAgentView: designConversationView(),
designAgentContinueView: designConversationView(),
});
renderDesignAgent(harness);
const input = await screen.findByLabelText('项目需求');
await setComposerText(input, '请继续完善方案');
fireEvent.submit(input.closest('form') as HTMLFormElement);
await waitFor(() =>
expect(harness.invoke).toHaveBeenCalledWith(
'continue_design_agent_session',
expect.objectContaining({
input: { type: 'message', text: '请继续完善方案' },
}),
),
);
const continueCall = [...harness.invoke.mock.calls]
.reverse()
.find(([command]) => command === 'continue_design_agent_session');
const clientTurnId = String(
(continueCall?.[1] as { clientTurnId?: string }).clientTurnId,
);
const messageList = screen.getByLabelText('立项策划消息');
Object.defineProperty(messageList, 'scrollHeight', {
configurable: true,
value: 1000,
});
Object.defineProperty(messageList, 'clientHeight', {
configurable: true,
value: 200,
});
messageList.scrollTop = 800;
fireEvent.scroll(messageList);
act(() =>
harness.emitDesignAgentEvent({
projectPath: harness.projectPath,
clientTurnId,
kind: 'reasoning',
reasoningText: '先核对玩法循环',
}),
);
await screen.findAllByText('先核对玩法循环');
await waitFor(() => expect(messageList.scrollTop).toBe(1000));
messageList.scrollTop = 800;
fireEvent.scroll(messageList);
act(() =>
harness.emitDesignAgentEvent({
projectPath: harness.projectPath,
clientTurnId,
kind: 'text',
text: '正在补充关卡节奏',
}),
);
await screen.findByLabelText('策划 Agent 实时回复');
await waitFor(() => expect(messageList.scrollTop).toBe(1000));
messageList.scrollTop = 120;
fireEvent.scroll(messageList);
act(() => {
harness.emitDesignAgentEvent({
projectPath: harness.projectPath,
clientTurnId,
kind: 'reasoning',
reasoningText: '继续检查多人规则',
});
harness.emitDesignAgentEvent({
projectPath: harness.projectPath,
clientTurnId,
kind: 'text',
text: '正在补充关卡节奏与多人规则',
});
});
await screen.findAllByText('继续检查多人规则');
await waitFor(() =>
expect(
screen.getByLabelText('策划 Agent 实时回复').textContent,
).toContain('多人规则'),
);
expect(messageList.scrollTop).toBe(120);
});
it('shows only known optimistic send times and does not invent persisted times', async () => {
const hydratedView = {
...designConversationView(),
messages: [{ id: 'user-history', role: 'user', text: '历史策划需求' }],
};
const continuedView = {
...designConversationView(),
messages: [
...hydratedView.messages,
{ id: 'user-current', role: 'user', text: '当前补充需求' },
],
};
const harness = createProjectChatRuntimeHarness({
designAgentView: hydratedView,
designAgentContinueView: continuedView,
});
const originalInvoke = harness.invoke.getMockImplementation()!;
let finishContinue!: () => void;
const continueGate = new Promise<void>((resolve) => {
finishContinue = resolve;
});
harness.invoke.mockImplementation(async (command, args) => {
if (command === 'continue_design_agent_session') {
await continueGate;
}
return originalInvoke(command, args);
});
renderDesignAgent(harness);
const historicalText = await screen.findByText('历史策划需求');
expect(
historicalText.closest('.message')?.querySelector('time'),
).toBeNull();
const input = screen.getByLabelText('项目需求');
await setComposerText(input, '当前补充需求');
fireEvent.submit(input.closest('form') as HTMLFormElement);
const optimisticText = await screen.findByText('当前补充需求');
const optimisticTime = optimisticText
.closest('.message')
?.querySelector('time');
expect(optimisticTime).not.toBeNull();
expect(optimisticTime?.getAttribute('dateTime')).toBeTruthy();
await act(async () => finishContinue());
await waitFor(() => {
const currentText = screen.getByText('当前补充需求');
expect(currentText.closest('.message')?.querySelector('time')).toBeNull();
});
expect(
screen
.getByText('历史策划需求')
.closest('.message')
?.querySelector('time'),
).toBeNull();
});
it('hydrates an existing design session and decides approval through design commands', async () => {
const harness = createProjectChatRuntimeHarness({
designAgentView: designApprovalView(),
@@ -416,3 +416,138 @@ describe('陶泥儿对话区:Codex 三段式(顶栏 / 唯一滚动区 / 文
expect(pixelValue(listInPanelDirectCodex, 'min-height')).toBe(0);
});
});
describe('策划对话:消息伸缩与长内容边界', () => {
function planningDeclarations(className: string, width: number) {
const root = document.createElement('div');
root.innerHTML = `<div class="window-chrome"><div class="window-chrome__content">
<div class="launcher-shell platform-theme"><aside></aside><main class="launcher-main">
<section class="launcher-page launcher-project-development game-project-workbench game-project-workbench--design">
<div class="game-workbench-layout game-workbench-layout--design">
<section class="game-workbench-stage"><div class="design-workspace-panel">
<div class="design-workspace-panel__body"><div class="design-workspace-tree"></div></div>
</div></section>
<aside class="game-workbench-chat"><header>策划</header>
<section class="project-chat-surface"><div class="project-chat-conversation">
<section class="design-agent-controls"></section>
<header class="project-chat-topbar"><span class="project-chat-topbar-status">
<span class="project-chat-topbar-dot"></span><span>已打开:项目</span>
</span></header>
<div class="message-list project-chat-message-list"></div>
<section class="design-agent-pending-actions"></section>
<form class="project-chat-composer"><p class="project-chat-composer-notice"></p></form>
</div></section>
</aside></div></section></main></div></div></div>`;
const element = root.querySelector(className)!;
// 从 DOM 匹配完整选择器,防止遗漏基础规则或误把 Direct 专用样式算到策划入口。
const selectors = rules.flatMap((rule) =>
rule.selectors.filter((selector) => {
try {
return element.matches(selector);
} catch {
// jsdom 不支持的浏览器伪类与本面板无关。
return false;
}
}),
);
return resolveDeclarations(rules, selectors, width);
}
it.each([1440, 390])(
'%ipx:消息吸收剩余高度,状态和输入区不参与拉伸',
(width) => {
const conversation = planningDeclarations(
'.project-chat-conversation',
width,
);
const list = planningDeclarations('.project-chat-message-list', width);
const composer = planningDeclarations('.project-chat-composer', width);
const topbar = planningDeclarations('.project-chat-topbar', width);
expect(declaration(conversation, 'display')).toBe('flex');
expect(declaration(conversation, 'flex-direction')).toBe('column');
expect(declaration(conversation, 'height')).toBe('100%');
expect(declaration(conversation, 'overflow')).toBe('visible');
expect(declaration(list, 'flex')).toBe('1 1 0');
expect(declaration(list, 'min-height')).toBe('0');
expect(declaration(list, 'overflow-y')).toBe('auto');
expect(declaration(composer, 'flex')).toBe('0 0 auto');
expect(declaration(topbar, 'flex')).toBe('0 0 auto');
expect(declaration(topbar, 'min-height')).toBe('44px');
expect(
declaration(
planningDeclarations('.project-chat-topbar-status', width),
'font-size',
),
).toBe('12px');
expect(
declaration(
planningDeclarations(
'.project-chat-topbar-status > span:last-child',
width,
),
'overflow-wrap',
),
).toBe('anywhere');
for (const selector of [
'.design-agent-controls',
'.design-agent-pending-actions',
]) {
const panel = planningDeclarations(selector, width);
expect(declaration(panel, 'max-height')).toBe('30%');
expect(declaration(panel, 'min-height')).toBe('0');
expect(declaration(panel, 'overflow')).toBe('auto');
}
},
);
it.each([390, 760])(
'%ipx:固定外壳内工作台可滚动到对话,两块面板保持明确高度',
(width) => {
const workbench = planningDeclarations('.game-project-workbench', width);
const layout = planningDeclarations('.game-workbench-layout', width);
const stage = planningDeclarations('.game-workbench-stage', width);
const chat = planningDeclarations('.game-workbench-chat', width);
expect(declaration(workbench, 'min-height')).toBe('0');
expect(declaration(workbench, 'overflow-y')).toBe('auto');
expect(declaration(workbench, 'overflow-x')).toBe('hidden');
expect(declaration(layout, 'height')).toBe('auto');
expect(declaration(layout, 'grid-template-columns')).toBe(
'minmax(0, 1fr)',
);
expect(declaration(stage, 'height')).toBe('560px');
expect(declaration(chat, 'height')).toBe(
'clamp(560px, calc(100dvh - 154px), 900px)',
);
expect(
declaration(
planningDeclarations('.design-workspace-panel', width),
'height',
),
).toBe('100%');
expect(
declaration(
planningDeclarations('.design-workspace-tree', width),
'overflow',
),
).toBe('auto');
},
);
it.each([761, 1280])('%ipx:双栏继续填满外壳剩余高度', (width) => {
const workbench = planningDeclarations('.game-project-workbench', width);
const layout = planningDeclarations('.game-workbench-layout', width);
expect(declaration(workbench, 'overflow')).toBe('hidden');
expect(workbench.has('overflow-y')).toBe(false);
expect(declaration(layout, 'height')).toBe('100%');
expect(declaration(layout, 'grid-template-columns')).toBe(
'minmax(0, 1fr) minmax(380px, 0.42fr)',
);
});
it('批量导入失败提示可滚动且可换行,不撑高输入区或撑宽面板', () => {
const notice = planningDeclarations('.project-chat-composer-notice', 390);
expect(declaration(notice, 'max-height')).toBe('96px');
expect(declaration(notice, 'overflow')).toBe('auto');
expect(declaration(notice, 'overflow-wrap')).toBe('anywhere');
});
});
@@ -0,0 +1,61 @@
/** @vitest-environment jsdom */
import { render } from '@testing-library/react';
import React from 'react';
import { expect, it } from 'vitest';
import { DirectProjectTurn } from '../src/view/project-development/chat/components/DirectProjectConversation/DirectProjectTurn';
import type {
DirectChatTurn,
DirectChatTurnState,
} from '../src/view/project-development/chat/conversation/directTurnPresentation';
const SENT_AT = 1_800_000_000_000;
const ENDED_AT = SENT_AT + 12_400;
const turn = (
state: DirectChatTurnState,
overrides: Partial<DirectChatTurn> = {},
): DirectChatTurn => ({
key: 'direct-codex:turn-1:user',
users: [{ kind: 'user', key: 'u', text: '帮我做一个跳跃动作', at: SENT_AT }],
process: [
{ kind: 'reasoning', key: 'r', text: '先看目录' },
{ kind: 'assistant', key: 'a', text: '正在写' },
],
finals: [{ kind: 'assistant', key: 'a-final', text: '改好了', at: ENDED_AT }],
state,
startedAt: SENT_AT,
endedAt: state === 'finished' ? ENDED_AT : 0,
...overrides,
});
it('awaiting-start:本地已发出、原生还没认领时不显示终态文案,也不折叠过程', () => {
const view = render(
React.createElement(DirectProjectTurn, {
turn: turn('awaiting-start'),
}),
);
expect(view.queryByTestId('turn-usage')).toBeNull();
expect(view.queryByText(/本轮结束于/)).toBeNull();
// 否定式判据:未结束的回合把过程平铺出来,不折进「执行过程」。
expect(view.queryByTestId('turn-process')).toBeNull();
expect(view.getByLabelText('思考过程')).not.toBeNull();
});
it('running:原生回合在跑时同样不显示终态文案', () => {
const view = render(
React.createElement(DirectProjectTurn, { turn: turn('running') }),
);
expect(view.queryByTestId('turn-usage')).toBeNull();
expect(view.queryByTestId('turn-process')).toBeNull();
});
it('finished 且有明确终态:显示结束时间与耗时,过程折叠', () => {
const view = render(
React.createElement(DirectProjectTurn, { turn: turn('finished') }),
);
const usage = view.getByTestId('turn-usage');
expect(usage.textContent).toContain('本轮结束于');
expect(usage.textContent).toContain('耗时 12.4秒');
expect(view.queryByTestId('turn-process')).not.toBeNull();
});
@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest';
import { deriveDirectProjectTurnStatus } from '../src/view/project-development/chat/controller/useDirectProjectTurnStatus';
import type { DirectChatTurn } from '../src/view/project-development/chat/conversation/directTurnPresentation';
const turn = (key: string, state: DirectChatTurn['state']): DirectChatTurn => ({
key,
users: [],
process: [],
finals: [],
state,
startedAt: 1_800_000_000_000,
endedAt: 0,
});
describe('DirectProject 回合状态派生', () => {
it('displayBusy 是原生真相与本地命令在飞的并集', () => {
expect(
deriveDirectProjectTurnStatus({
turnRunning: true,
turnBusy: false,
turns: [],
}).displayBusy,
).toBe(true);
expect(
deriveDirectProjectTurnStatus({
turnRunning: false,
turnBusy: true,
turns: [],
}).displayBusy,
).toBe(true);
expect(
deriveDirectProjectTurnStatus({
turnRunning: false,
turnBusy: false,
turns: [],
}).displayBusy,
).toBe(false);
});
it('两个来源各自独立暴露,不被并集吃掉', () => {
const status = deriveDirectProjectTurnStatus({
turnRunning: true,
turnBusy: true,
turns: [],
});
expect(status.nativeRunning).toBe(true);
expect(status.commandInFlight).toBe(true);
});
it('latestTurnState 取最新一轮的三态;没有回合时为 null', () => {
expect(
deriveDirectProjectTurnStatus({
turnRunning: false,
turnBusy: false,
turns: [turn('u1', 'finished'), turn('u2', 'awaiting-start')],
}).latestTurnState,
).toBe('awaiting-start');
expect(
deriveDirectProjectTurnStatus({
turnRunning: false,
turnBusy: false,
turns: [],
}).latestTurnState,
).toBeNull();
});
it('本地命令在飞不等于原生在跑', () => {
const status = deriveDirectProjectTurnStatus({
turnRunning: false,
turnBusy: true,
turns: [turn('u1', 'awaiting-start')],
});
expect(status.nativeRunning).toBe(false);
expect(status.latestTurnState).toBe('awaiting-start');
});
});
@@ -11,6 +11,7 @@ import {
reduceDirectThreadEvents,
resolveDirectThreadBootstrap,
selectDirectChatEntries,
stopDirectThreadTurn,
} from '../src/view/project-development/chat/conversation/directThreadChat';
import type { DirectThreadItem } from '../src/view/project-development/chat/conversation/directThreadItemProjection';
import type { DirectThreadEvent } from '../src/view/project-development/chat/generated/DirectThreadEvent';
@@ -176,6 +177,54 @@ describe('DirectProject 聊天 reducer', () => {
expect(selectDirectChatEntries(done)).toHaveLength(2);
});
it('本地命令兜底收口后,迟到的同名 turn.started 不复活这一轮', () => {
const identity = 'direct-codex:client-turn-1:user';
const running = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event(withUserItemId({ type: 'turn.started', at: 1_000 }, identity)),
event({ type: 'item.completed', item: messageItem() }),
]);
expect(running.turnRunning).toBe(true);
const stopped = stopDirectThreadTurn(running, identity);
expect(stopped.turnRunning).toBe(false);
// 兜底收口不写终态时间:命令返回不等于知道这一轮真正的结束时刻。
expect(stopped.turnEndedAt).toBe(0);
// 同一轮迟到的 turn.started 不再把它拉回运行态,运行态条目也没丢。
const revived = reduceDirectThreadEvents(stopped, [
event(withUserItemId({ type: 'turn.started', at: 2_000 }, identity)),
]);
expect(revived.turnRunning).toBe(false);
expect(selectDirectChatEntries(revived)).toHaveLength(1);
// 宿主随后补上的真终态照旧收口,并把真正的结束时间补上。
const late = reduceDirectThreadEvents(revived, [
event(
withUserItemId(
{ type: 'turn.completed', status: 'failed', at: 3_000 },
identity,
),
),
]);
expect(late.turnRunning).toBe(false);
expect(late.turnEndedAt).toBe(3_000);
});
it('本地命令兜底收口不碰身份不同的那轮', () => {
const other = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event(
withUserItemId(
{ type: 'turn.started', at: 1_000 },
'direct-codex:client-turn-9:user',
),
),
]);
expect(
stopDirectThreadTurn(other, 'direct-codex:client-turn-1:user')
.turnRunning,
).toBe(true);
});
it('历史切片搬运层不合并,合并发生在前端投影', () => {
const state = mergeDirectHistoryItems(emptyDirectThreadChatState(), [
toolStarted(),
@@ -184,7 +184,7 @@ describe('DirectProject 聊天分区', () => {
],
turnRunning: true,
});
expect(turns[0]?.active).toBe(true);
expect(turns[0]?.state).toBe('running');
expect(turns[0]?.finals).toEqual([]);
expect(turns[0]?.process.map((block) => block.kind)).toEqual([
'assistant',
@@ -293,7 +293,63 @@ describe('DirectProject 聊天分区', () => {
});
expect(turns.map((turn) => turn.key)).toEqual(['u1', 'local:1']);
expect(turns[1]?.users).toHaveLength(1);
expect(turns[1]?.active).toBe(true);
expect(turns[1]?.state).toBe('running');
});
it('三态:本地已发出、原生还没认领的那一轮是 awaiting-start,不是已结束', () => {
const turns = buildDirectChatTurns({
entries: [],
localMessages: [localUser('本轮提问', 'direct-codex:turn-1:user')],
pendingUserItemId: 'direct-codex:turn-1:user',
});
expect(turns.map((turn) => turn.state)).toEqual(['awaiting-start']);
expect(turns[0]?.endedAt).toBe(0);
expect(turns[0]?.startedAt).toBe(1_800_000_002_000);
});
it('三态:原生用户条目先到、turn.started 还没到时仍是 awaiting-start', () => {
const turns = buildDirectChatTurns({
// 同身份的原生条目已经到了(本地气泡被去重),但原生回合还没开始。
entries: [userEntry('direct-codex:turn-1:user')],
localMessages: [localUser('本轮提问', 'direct-codex:turn-1:user')],
pendingUserItemId: 'direct-codex:turn-1:user',
});
expect(turns.map((turn) => turn.state)).toEqual(['awaiting-start']);
});
it('三态:拿到明确终态后,在途身份不再把这一轮判成待认领', () => {
const turns = buildDirectChatTurns({
entries: [
{
...userEntry('direct-codex:turn-1:user'),
turnEndedAt: 1_800_000_010_000,
},
],
pendingUserItemId: 'direct-codex:turn-1:user',
});
expect(turns[0]?.state).toBe('finished');
expect(turns[0]?.endedAt).toBe(1_800_000_010_000);
});
it('三态:只有最新一轮能是 awaiting-start,身份不匹配也不影响判定', () => {
const notNewest = buildDirectChatTurns({
entries: [userEntry('direct-codex:turn-1:user')],
localMessages: [localUser('第二条', 'direct-codex:turn-2:user')],
pendingUserItemId: 'direct-codex:turn-1:user',
});
expect(notNewest.map((turn) => turn.state)).toEqual([
'finished',
'finished',
]);
const mismatch = buildDirectChatTurns({
entries: [userEntry('u1')],
localMessages: [localUser('第二条', 'direct-codex:turn-2:user')],
pendingUserItemId: 'direct-codex:turn-9:user',
});
expect(mismatch.map((turn) => turn.state)).toEqual([
'finished',
'finished',
]);
});
it('历史无用户条目时也保留一个回合承载正文', () => {
@@ -51,5 +51,7 @@ AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件(
- 旧项目磁盘上遗留的 `turn-stream.jsonl` / `tool-calls.jsonl` 保留不动,不迁移、不清理、不再由 DirectProject 聊天框读取。
- 工具卡片的脱敏与截断必须在读取期执行一次,不能因为"原始条目已在磁盘"就把未脱敏内容直接渲染到界面。
- 回合结束语义务必由 `turn.completed` 判定;缺少该事件的残留回合不得被渲染成运行中。
- 「活动回合的唯一判据」约束的是**原生回合**:界面上的「本地已发出、原生还没认领」是投影的展示态(`DirectChatTurn.state = 'awaiting-start'`),由本地在途用户条目身份派生,不构成第二套原生生命周期,也不参与 `turnRunning` 的判定。
- 三层数据流、变量归属与一次发送的时序写在代码里:`apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts` 的模块注释;回合三态的定义与判据真值表在 `apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts``DirectChatTurnState`。改判据时同步这两处与对应测试。
- 验收证据是端到端行为,不是单元测试:回合进行中杀掉应用进程后重开项目,应看到部分文本与工具卡片按原顺序出现且不显示忙碌;正常结束后重进应与实时渲染一致;文件系统不得再新增 `turn-stream.jsonl` / `tool-calls.jsonl`
- id 空间已用源码核对:codex-rs `app-server-protocol/src/protocol/thread_history.rs` 中所有工具 item 都是 `id: payload.call_id.clone()`,而 `project.jsonl` 落盘的是原始 response item。真实 app-server 会话核对仍列为运行时验收项。
@@ -110,6 +110,9 @@
- 同轮修掉 master 自带的红灯断言:`src/config/viteProxyConfig.test.ts` 曾断言 `/api/creation-entry` 会被代理,但配置无该代理项且 api-server 已把 `/api/creation-entry/config` 列为退役路由,测试改为断言退役路径不进入代理。
- 覆盖率与门禁(合并后):全量 `npm test` 392 文件 / 4371 用例通过(appSurface 重构后 215 用例)、两端与 admin-web typecheck、`cargo check`api-server / spacetime-module / spacetime-client)、`cargo test`api-server 游戏分发 17、module-game-distribution 11)、`check:encoding``check:doc-index``check:rustfmt`、SpacetimeDB schema guard(对比 `origin/master`)。
- 第三次合并 master`500835407``fdc14404b`DirectProject 回合三态、投影 memo、宿主崩溃后的回合收口、策划对话布局修复、AGC release 每日调度):git 自动合并无冲突,但产生了**静默拼接缺陷**——新加的聊天头 CSS 被并进了 master 策划态分组选择器中间,导致 `.project-chat-topbar-status` 在策划态丢失 `font-size``chatDialogFrameLayout` 用例抓到)。修复方式是按 master 原文重建分组规则、把发布入口规则独立成块,并把重复的状态规则删掉。
- 合并后复核:全量 `npm test` 393 文件 / 4374 用例通过,root / AGC / admin-web 三端 typecheck 通过,发布入口(聊天头「发布到游戏广场」→ 试玩包导出 → 发布面板)在新回合三态下保持接线。
## 尚未完成
- 真实独立发行域名、通配 TLS 与 CDN 仍属部署侧:边缘模板与门禁已就绪,本地已用真实 nginx 验证按主机映射、Cookie 403 与命名空间隔离,但仍需在真实域名/证书下跑一次“审核通过 → 游玩 → 换版 → 下架”并确认 CDN TTL 不超过 60 秒窗口。
@@ -1,5 +1,13 @@
# 决策记录
## 2026-09-22 AGC release 增加每日调度,dev 调度保持双平台
- 背景:原有 `Genarrative-Scheduled-Revision-Trigger` 每小时跟随 revision 发布 dev 客户端,但没有对应的 release 渠道自动入口;Mac 节点此前已纳入小时 dev 调度,需要避免新增 release 调度时再退回 Windows-only。
- 决策:新增 `Genarrative-Scheduled-Release-Trigger`,每天 04:00 检查 `SOURCE_BRANCH`,使用独立的 `.jenkins-last-release-revision` 与客户端相关路径白名单;上一轮 release 调度后有客户端变更时,先经 `Genarrative-Agc-Global-Version-Issue` 发统一总号,再以同一固定 `COMMIT_HASH` 触发 `Genarrative-Agc-Windows-Build``Genarrative-Agc-MacOS-Build``AGC_UPDATE_CHANNEL=release` 分区,Mac 继续带 `SKIP_IF_SUPERSEDED=true`。小时 dev 调度保持同时触发 Windows 与 macOS dev。
- 原因:release 与 dev 是不同渠道和发布节奏,不能靠同一个小时 Job 隐式切换;独立 Job 能分别去重、记录状态和审计发号。release 调度不触发 Full Build,避免把桌面客户端发布与线上全栈部署绑定。
- 影响范围:`jenkins/Jenkinsfile.scheduled-release-trigger``jenkins/scheduled-release-trigger-job-config.xml``jenkins/Jenkinsfile.agc-global-version-issue``scripts/check-production-ops-guardrails.mjs`、开发运维文档、共享开发工作流与 AGC 总版本号技术方案。
- 验证方式:`npm run check:production-ops` 校验 release Job 的 cron、发号、双平台 release 参数、Mac 让位和 Copy Artifact 授权;`npm run check:encoding``git diff --check` 校验文件与补丁。
## 2026-09-21 合并 origin/masterSupervisor 永久退役,策划 V1V2 退役落到当前两条产品路径
- 背景:`refactor/split-direct-project`DirectProject 独立聊天容器)与 `origin/master`#355 退役策划 Agent V1/V2)在 2026-09-18 之后各走一条线:本分支删掉 Supervisor 前端链路、把立项策划收敛到 `view/project-development/planning/`master 删掉整套策划 V1/V2(前端会话 / 审批卡 / 适配器 / 类型与 Rust `planning_*_v2` 命令、`planning_gdd_model.rs``planning_policy_v2.rs``planning_session_v2.rs`)只保留 Design Agent。两边都在删 Supervisor,冲突集中在 `App.tsx`、聊天视图(`PlanningChatView``DirectProjectTurn``ToolCallGroup`)、Direct composer / 引用输入区、`styles.css`、Rust direct user item 与 appSurface 用例。
@@ -9348,3 +9356,30 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 决策(空白口径):`directCodexContentToPromptText` 逐字投影、不再 `trim`(前端只在整条 content 上判空);出站提示词的收边规范化收敛成一个共享函数 `resourceCanvasAssetGenerationPromptText`,面板校验与任务落账共用,图集走 Unicode White_Space、其余走 JS 口径。
- 影响面:`apps/ai-game-creator-shell/src/features/{project-workspace/resourceReferences.ts,project-workspace/ResourceReferenceInput.tsx,resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx,resource-canvas/resourceCanvasAssetGenerationTaskModel.ts,resource-canvas/resourceCanvasAssetGenerationReferenceModel.ts}``apps/ai-game-creator-shell/src/view/project-development/index.tsx` 与对应 6 个定向测试文件。
- 验证:定向 `resourceCanvasAssetGenerationReferences` / `resourceCanvasAssetGenerationBackgroundClose` / `resourceCanvasBottomToolbar` / `resourceCanvasGenerationFloatingPanel(Chrome)` / `resourceReferenceInput` / `resourceReferences` / `resourceCanvasAssetGenerationTasksPanel` 全绿;全量 `npm run test -- apps/ai-game-creator-shell/tests` 只剩 `clientHttp` / `clientApi` / `clientAuthStorage` / `projectCreationDirectory` / `recentProjectsHook` 五个 jsdom `localStorage` 环境用例红(与本次改动无调用关系);TS typecheck、`check:encoding``git diff --check` 通过。未复核真实客户端观感。
## 2026-09-22 DirectProject 聊天状态显式化:回合三态 + 「在跑吗」唯一派生入口 + 数据流地图
- 背景:DirectProject 聊天框只有三份真相源(`project.jsonl` 历史切片、Thread Manager 运行态事件、本地乐观消息),但「这一轮在跑吗」在四层里各叫一个名字——reducer 的 `turnRunning`、controller 的 `turnBusy`、视图里手拼的 `busy`、投影里的 `active`。定位发送后空窗缺陷时,读代码无法判断某个窗口期的界面表现是否有依据,也说不清谁该信谁。
- 决策(三态取代布尔):`DirectChatTurn.active` 改为 `DirectChatTurn.state: 'running' | 'awaiting-start' | 'finished'``running` 只由 reducer 的 `turnRunning` 决定;`awaiting-start` 由「最新一轮的用户条目身份 = 本地在途的 `pendingUserItemId``direct-codex:{clientTurnId}:user`)、且本轮还没有明确终态」决定;其余是 `finished`。判据是身份不是时间戳,`pendingUserItemId` 由 controller 在 `beginTurnCommand()` / `endTurnCommand()` 里与 `turnBusy` 同生共死。
- 决策(单一派生入口):新增 `useDirectProjectTurnStatus()`,返回 `{ nativeRunning, commandInFlight, displayBusy, latestTurnState }`。header / composer 只读 `displayBusy`(两者并集,语义与原来的 `turnBusy || directTurnRunning` 完全一致),「陶泥儿正在处理」卡片只读 `nativeRunning`。约定:新增「忙 / 在跑」类判据先落进这里,不在组件里另拼布尔。
- 决策(地图落代码):三层数据流、三份原始输入、一次发送的时序(含空窗步骤)与状态变量归属写进 `apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts` 的模块注释;回合三态的定义与判据真值表写进 `.../conversation/directTurnPresentation.ts``DirectChatTurnState`。不另开技术方案文档——这套说明是给改这块代码的人看的,放代码里才不会与实现脱节。ADR 补一条「活动回合唯一判据约束的是**原生回合**」的澄清与代码指针。
- 口径更正(2026-09-22,同日第二条):本条记录的「`awaiting-start` 暂时与 `finished` 同渲染」已由随后的三态接入渲染改动修掉,见下面那条。
- 边界(本次不修):`awaiting-start` 暂时与 `finished` 同渲染,所以空窗期内仍会显示「本轮结束于 <用户发送时间> · 耗时 0.0秒」;`Math.max(turn.endedAt, turn.startedAt)` 的兜底与 `DirectProjectTurnUsage` 的渲染条件都没动。同源的第二条缺陷也记录在案:`turnEndedAt` 只是会话内展示缓存,页面重进后所有已结束回合都会走同一条兜底显示 0.0 秒(临时渲染用例实测确认,用例未入库)。修法与证据要求见下面那条(三态接入渲染)以及 `DirectProjectTurn.tsx` 里标注未修范围的注释。
- 验证:`npx vitest run` 定向 `directTurnPresentation`17 条,新增 4 条三态用例)、`directProjectTurnStatus`4 条)、`directHistoryPaging`9 条)全绿;`appSurface.test.ts` 202 passed / 13 skipped`tsc -p apps/ai-game-creator-shell/tsconfig.json --noEmit``npm run check:encoding``npm run check:doc-index``git diff --check` 通过。真实客户端观感与窗口期表现未在客户端复核。
## 2026-09-22 空窗期不再谎报「本轮结束」:DirectProject 三态接入渲染
- 背景:上一条只把回合三态显式化,渲染层仍按 `state === 'running'` 判断,于是「本地已发出、宿主还没回 `turn.started`」的窗口里 `awaiting-start` 被当成 `finished` 渲染,显示「本轮结束于 <用户发送时间> · 耗时 0.0秒」(用户现场反馈的现象)。
- 决策(判据分两类,不许对调):**否定式**判断(不要说它结束、不要折叠过程、不要显示终态文案)读 `state !== 'finished'`;**肯定式**判断(哪段正文在流式、「陶泥儿正在处理」卡片与滚动已耗时)读 `state === 'running'`。理由是 `awaiting-start` 能支持"还没结束",但不能支持"宿主已经在跑"——后者只有 `turn.started` 能证明。
- 决策(卡片口径取保守):`DirectProjectConversation` 的「正在处理」卡片与 `activeTurnStartedAt` 仍只认 `turnStatus.nativeRunning`,窗口期不出现这张卡片。文案是「陶泥儿正在处理」,在 `turn.started` 之前无法断言宿主已经开始,这与空窗缺陷是同一个病根(把"本地已发出"当成"宿主已在跑");窗口期用户看到的是"消息已发出 + 输入框忙",语义诚实。若将来改成窗口期也显示卡片,`running` 在渲染层就没有消费者了,那时应把投影压成 `unfinished: boolean`,不要留一个没人读的状态成员。
- 边界(A 仍未修):`DirectProjectTurnUsage``Math.max(turn.endedAt, turn.startedAt)` 兜底没动,所以两类 `finished` 回合仍显示「耗时 0.0秒」——① 页面重进后读回来的历史回合(`turnEndedAt` 只是会话内展示缓存);② 发送后没有产生任何原生事件 / 发送失败的本地回合。为什么会有这两类、修法与要产品确认的口径都写在代码里(`DirectProjectTurn.tsx``DirectProjectTurnUsage` 注释与 `directTurnPresentation.ts``DirectChatTurnState` 注释),改完删掉那段注释。
- 验证:`tests/directProjectTurn.test.tsx`(新增 3 条渲染契约:`awaiting-start``running` 不显示终态文案且不折叠、`finished` 有终态时显示结束时间与耗时);`tests/appSurface/chat-composer.suite.ts` 新增 `does not report a finished turn while the host has not acknowledged the send yet`(invoke 挂起、无任何原生事件时断言不出现「本轮结束于」);变异验证:把 `state !== 'finished'` 退回 `state === 'running'` 后渲染契约用例变红,恢复即绿。定向 vitest、`appSurface.test.ts`203 passed / 13 skipped)、`tsc`、ESLint、Prettier、`check:encoding``check:doc-index``git diff --check` 通过。真实客户端观感未复核。
## 2026-09-22 宿主崩掉不再留下永远开着的回合:本地命令失败时按身份兜底收口
- 背景:`turn.started` / `turn.completed` 是原生回合唯一的开闭配对,界面上的「正在处理」卡片与输入盒忙态都读 reducer 的 `turnRunning`。但 app-server 崩了、回合任务被中止或 panic 时没人补终态事件,事件流里就留一条永远开着的 `turn.started`:界面一直显示「陶泥儿正在处理」、输入盒一直排队(用户现场反馈)。
- 决策(本地命令返回即这一轮在宿主那边收场):`chat_with_game_creator_direct_codex` 以真失败返回时,controller 按本轮身份调用 `stopDirectThreadTurn`,只放掉「是否在跑」,**不写终态时间**——命令返回不等于知道这一轮真正的结束时刻,编一个只会让耗时变成假数。用户主动终止与「正在跑的是另一轮」两条不适用:前者宿主必然补终态,后者不是这一轮(不能顺手抹掉别人的回合)。
- 决策(身份作用域 + 不复活):`stopDirectThreadTurn` 只在 reducer 里的运行身份相同或为空时生效;收口记进 `commandClosedTurnUserItemId`,同身份迟到的 `turn.started` 不再把这一轮拉回运行态(迟到的 `turn.completed` 例外放行,仍要拿它补上真正的结束时间)。身份按 clientTurnId 唯一,所以这条记忆只挡它自己那一轮。
- 影响面:`apps/ai-game-creator-shell/src/view/project-development/chat/{conversation/directThreadChat.ts,controller/useDirectThreadChatSubscription.ts,controller/useDirectProjectChatController.ts}``apps/ai-game-creator-shell/tests/{directThreadChat.test.ts,appSurface/chat-composer.suite.ts}`
- 验证:reducer 新增 2 条用例(兜底收口后同名 `turn.started` 不复活且真终态仍能补上结束时间;身份不同的回合不动),appSurface 新增 `stops claiming the turn is running when a failed send left turn.started open`;变异验证:拿掉 controller 里的兜底收口调用后该用例变红(界面仍显示「陶泥儿正在处理」),恢复即绿。
- 边界(未做):根因仍在宿主侧——要在进程内保证开闭配对,应由 Rust 在回合函数退出(含 panic / 任务中止)时补一条终态事件(drop 守卫);本次只做到前端不再跟着说谎。另:兜底收口的回合没有终态时间,仍会落进「`finished` 但拿不到终态时间」那个已知缺口(终态文案要不要藏,见 `DirectProjectTurn.tsx``DirectChatTurnState` 注释里的 A 项)。
@@ -92,7 +92,7 @@ SpacetimeDB 任务统一先读取 `.codex/skills/genarrative-spacetimedb/SKILL.m
## Jenkins 定时版本调度
定时与版本比较只保留在 `Genarrative-Scheduled-Revision-Trigger` 一处:每小时用 `git ls-remote` 解析 `SOURCE_BRANCH` 远端 HEAD,与上一次触发过的 revision 比较,变化时才把同一个 `COMMIT_HASH` 传给 `Genarrative-Full-Build-And-Deploy`,并先经 `Genarrative-Agc-Global-Version-Issue` 发号、再把同一个总版本号透传给 `Genarrative-Agc-Windows-Build``Genarrative-Agc-MacOS-Build`,保证两个客户端的平台分区发布同一个版本。这三个下游 Job 都不得自带 `triggers` / `cron`,也不得在管线内再做一套版本去重;`npm run check:production-ops` 会拦住这两类回退。macOS 节点是日常办公机,调度触发它时置 `SKIP_IF_SUPERSEDED=true`:节点离线期间排队的旧构建在恢复后会自行让位,不发布过期版本。调度状态与生效步骤见 `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
定时与版本比较收口到两条调度器:`Genarrative-Scheduled-Revision-Trigger` 每小时处理 dev 渠道,变化时触发 Full Build、AGC Windows/macOS dev,并让两个客户端平台共用同一个总版本号;`Genarrative-Scheduled-Release-Trigger` 每天 04:00 处理 AGC release 渠道,只在上一轮 release 调度后出现客户端相关路径变化时,经 `Genarrative-Agc-Global-Version-Issue` 发同一个总号并触发 AGC Windows/macOS release。各下游 Job 都不得自带 `triggers` / `cron`,也不得在管线内再做一套版本去重;`npm run check:production-ops` 会拦住回退。macOS 节点是日常办公机,两条调度触发它时`SKIP_IF_SUPERSEDED=true`:节点离线期间排队的旧构建在恢复后会自行让位,不发布过期版本。调度状态与生效步骤见 `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## Gitea CI 依赖闭合
@@ -5909,6 +5909,13 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
- **验证**:修复后同一台机器、同一路径下 35 秒内新增 `Maximum update depth` **0 条**renderer 工作集 **254 MB**(修复前 4.24.4 GB);`apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx` 断言轮询返回值不变时快照引用不变。
- **关联**`apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx``apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts``apps/ai-game-creator-shell/src/components/WindowChrome.tsx``apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts`
## 2026-09-22 自动合并"无冲突"也可能静默拼坏 CSS 分组选择器
- **现象**:合并 master 时 git 报告 0 冲突,但 `apps/ai-game-creator-shell/src/styles.css` 里新加的头条规则被并进了 master 策划态分组选择器的中间——`.game-workbench-layout--design .project-chat-topbar-status,` 后面直接跟了别的选择器,策划态的 `font-size` / `color` 等声明整块丢失;类型检查与多数用例都不受影响,只有 `chatDialogFrameLayout` 这类 CSS 级联用例报"缺少生效声明"。
- **原因**:双方在同一分组选择器附近各自插入规则时,hunk 可以"兼容"地拼在一起,git 不会报冲突,但选择器列表被拆散。
- **处理**:改完 CSS 后按 master 原文重建分组规则、把新增规则独立成块;顺手删掉重复规则时要用带上下文的精确片段,避免删到分组选择器的第二个选择器。
- **验证**`npx vitest run apps/ai-game-creator-shell/tests/chatDialogFrameLayout.test.ts`12 用例)与全量 `npm test` 通过。
## 2026-09-22 合并 master 时“双方保留”不是通用解法
- **现象**:把分支与 master 的同一批冲突统一按“ours + theirs 依次保留”处理后,`apps/admin-web/src/api/adminApiTypes.ts``api/adminApiClient.ts``app/adminRoutes.test.ts` 都出现语法/结构错误(接口少闭合、函数体被截断、用例少 `});`)。prettier 能通过、vitest 里被转译的纯类型文件也不报错,只有 `tsc`admin-web typecheck)与随后单文件跑测试才暴露。
@@ -5986,6 +5993,10 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
`append_application_log_line` 在落盘前对整行做 `sanitize_diagnostic_message`:行内只要出现 `token=``bearer ``authorization``credential``api key` / `apikey` / `api_key` 这类标记,**整行**就被换成 `<sensitive diagnostic details redacted>`,只留下时间戳与 `RUST module:` 前缀;同时每行还会被截到 2048 字符。于是把“身份字段 + 诊断正文”拼成一行 `app_log!` 时,正文里一个凭据词就可能让整条记录连 `eventId``code` 一起消失(2026-09-21 加统一错误事件的日志投影时按两行落:身份行只放程序生成与调用方常量字段,summary / hint / detail 等自由文本一律只放详情行,且自由文本先自行压平换行——裸词标记脱敏消不掉,自由文本放错行会把 eventId、code 一起带走)。
## 策划聊天不能按可选子节点序号分配消息高度
策划与开发 Agent 共用聊天类名,但布局合同不同。策划新增状态条后,按第二个子节点分配 `1fr` 会把空白给状态条、让消息框随回复增长;只给 `.is-direct-codex` 的状态样式也不会覆盖策划。策划使用纵向 Flex,仅消息列表伸缩,阶段/待处理区域限高滚动;改共用样式时同时核对两种入口。jsdom 交互通过不代表布局正确,须匹配完整 CSS 层叠并用真实浏览器核对空/短/长消息与长待办。窄屏上下堆叠必须在固定外壳内提供工作台滚动容器,两块面板明确限高;低优先级的 `height: auto` 不能覆盖外壳后代规则的 `height: 100%`。布局夹具必须包含窗口外壳与启动器层级,并实际滚动验证输入框可见可操作,不能只检查输入框在聊天面板内部。实时正文/思考不更新持久消息数组,滚动跟随必须覆盖这些独立状态并保留用户上滚门禁;历史消息缺失的时间不得用读取时刻填补。详见 AGC 实施计划的“策划 Agent 对话显示与滚动合同”。
## 2026-09-22 Rust 对象快照必须对齐 CI 编译目录和 Cargo 环境
- 现象:sccache 快照已包含数百 MiB 对象,但全新 target 的“热缓存”仍然全部 miss,甚至比直接 rustc 更慢。
@@ -1,6 +1,6 @@
# AGC 总版本号与发号
更新时间:`2026-09-20`
更新时间:`2026-09-22`
## 背景
@@ -18,7 +18,7 @@ AGC 客户端此前按「渠道各自比高水位自增」发号:`dev-win` 与
- `next = 总号 + 1`;总号不存在时先一次性播种,见下节。
- 顺序固定为「先写总号 → 再构建 → 再发渠道清单」;任何一步失败都不回滚,只烧号。
- 统一构建:发一次号,通过 `AGC_RELEASE_VERSION` 同时传给 `dev-win``dev-mac`(以及后续渠道),各渠道共用同一个号。
- 统一构建:发一次号,通过 `AGC_RELEASE_VERSION` 同时传给同一渠道组的两个平台;dev 调度写 `dev-win` / `dev-mac`,每日 release 调度写 `release-win` / `release-mac`,组内共用同一个号。
- 单渠道热修:发一次号,只传给该渠道;其他渠道清单保持原值。
- 显式传入 `AGC_RELEASE_VERSION` 的构建不再自行发号,只做高水位断言。
@@ -39,8 +39,8 @@ AGC 客户端此前按「渠道各自比高水位自增」发号:`dev-win` 与
- 发号模块:`apps/ai-game-creator-shell/scripts/agc-global-version.mjs`(读总号 / 播种 / 发号 / 写后回读 / 渠道高水位断言 / dry-run 预览)。
- 发号入口:`apps/ai-game-creator-shell/scripts/issue-global-version.mjs`CI 与本地共用,输出固定为 `AGC_GLOBAL_VERSION=<version>`)。
- 构建侧:`build-release.mjs``prepareReleaseVersion()` 优先采用传入总号,未传入时现场发号;`resolveRemoteHighWaterVersion()` 降级为断言来源,只用于「请求号低于本渠道清单版本即失败关闭」。
- CI`jenkins/Jenkinsfile.agc-global-version-issue` + `jenkins/agc-global-version-issue-job-config.xml`;调度管线 `jenkins/Jenkinsfile.scheduled-revision-trigger` 与手动管线先调发号 Job,再把号透传给 AGC Build。
- 发号 Job 的 Copy Artifact 采用生产权限模式,`options` 里的 `copyArtifactPermission(...)` 必须显式列出**全部**消费者:定时调度 `Genarrative-Scheduled-Revision-Trigger` 与手动发布 `Genarrative-Manual-Build-And-Deploy`。用户触发的构建按「认证用户」判权(SYSTEM 定时构建短路放行),漏列时手动发布会报 `Unable to find project for artifact copy: Genarrative-Agc-Global-Version-Issue`,且失败点在发号之后。改完该 `options` 后必须先单独跑一次发号 Job,让 Declarative Pipeline 把 Job property 写回 Jenkins。
- CI`jenkins/Jenkinsfile.agc-global-version-issue` + `jenkins/agc-global-version-issue-job-config.xml`dev 调度管线 `jenkins/Jenkinsfile.scheduled-revision-trigger`、每日 release 调度管线 `jenkins/Jenkinsfile.scheduled-release-trigger` 与手动管线先调发号 Job,再把号透传给 AGC Build。
- 发号 Job 的 Copy Artifact 采用生产权限模式,`options` 里的 `copyArtifactPermission(...)` 必须显式列出**全部**消费者:小时 dev 调度 `Genarrative-Scheduled-Revision-Trigger`、每日 release 调度 `Genarrative-Scheduled-Release-Trigger` 与手动发布 `Genarrative-Manual-Build-And-Deploy`。用户触发的构建按「认证用户」判权(SYSTEM 定时构建短路放行),漏列时手动发布会报 `Unable to find project for artifact copy: Genarrative-Agc-Global-Version-Issue`,且失败点在发号之后。改完该 `options` 后必须先单独跑一次发号 Job,让 Declarative Pipeline 把 Job property 写回 Jenkins。
## 不改的东西
@@ -241,6 +241,25 @@ Rust 侧在 `server-rs/crates/shared-contracts` 维护唯一权威 `GameCreation
Rust 分片日志在失败时输出有界 stdout 尾部中的失败段,保留 panic 位置、断言详情和 Rust 汇总;selected 只表示该分片选中的用例数量。成功分片保留简短摘要。定向验证使用相关 Rust 用例与 `node --test apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.test.mjs`
## 策划 Agent 对话显示与滚动合同
策划工作台的对话区保留阶段状态、工作区状态、消息、可选确认/澄清和输入区。面板高度由工作台和视口决定;空消息、短回复和流式长回复不能改变消息区域的上下边界。阶段、状态条、输入区按内容排列,消息区独占剩余空间并内部滚动;待处理卡片出现时只让出其实际所需且有上限的空间,不依赖可选节点的序号分配伸缩高度。
- 工作区状态条复用现有聊天状态条的字号、间距和状态点。长项目名允许换行,不能撑宽面板;长错误、澄清和批量文件导入结果在受限区域内完整可读,不能挤走输入框。输入编辑器保留原有高度上限,模型/推理菜单继续允许弹出面板。
- 已在消息底部时,实时正文与思考增高后继续跟随;用户主动上滚阅读历史后不自动抢回底部,发送新消息后恢复跟随。阶段/待处理卡片改变消息可用高度时,仍遵守同一跟随意图。滚动仅为临时 UI 状态,不写入正式会话。
- 历史消息只有收到真实发送时间才能显示时间。现有策划持久消息不含逐消息时间,读取或刷新时保持未知,不以当前时刻补造。当前会话刚提交的乐观消息可以显示已知发送时间;正式快照覆盖后不补造或猜配旧消息时间。
- ≤760px 时资源区与对话区单列排列,策划工作台在固定外壳内纵向滚动,用户向下滚动可到达输入区。布局使用内容高度,并以同等或更高选择器优先级覆盖外壳的 `height: 100%`;资源区明确为 560px,对话区高度为 `clamp(560px, calc(100dvh - 154px), 900px)`。文件树与消息列表各自内部滚动,长内容不增加两块面板高度。>760px 继续共用外壳剩余高度,不启用工作台整体滚动。正式主窗最小宽度不变,窄屏验收覆盖浏览器响应式布局。
- 审批、澄清、导入期间禁用、错误重试、项目归属和发送权限沿用现有行为。此次调整不改变 Runtime、API、持久协议或数据库,无数据迁移;不重做开发 Agent 的对话布局。
验收使用策划组件与现有界面测试覆盖流式/历史跟随、未知时间、审批澄清和导入禁用;使用真实浏览器与当前样式核对桌面/窄屏的空、短、长消息,长项目名、长错误与长澄清,以及输入框和菜单位置。布局验收必须包含 `window-chrome → window-chrome__content → launcher-shell → launcher-main → game-project-workbench` 的完整外壳层级,覆盖 390px、760px、761px 和桌面尺寸;窄屏实际滚动工作台后,输入框须进入可视范围且可操作,长文件树、长消息和待确认内容不能使面板失去高度边界。不能只断言输入框位于聊天面板内部。开发 Agent 的既有布局回归同批执行。真实 Provider 请求不作为纯展示修复的前置条件,验证记录须区分模拟事件与真实 Provider。
| 验收面 | 对应证据 |
| --- | --- |
| 策划与开发 Agent 布局隔离、伸缩与窄屏滚动可达 | `chatDialogFrameLayout.test.ts` 包含外壳的选择器层叠回归;Chrome 实际组件、完整外壳与完整样式的 390px/760px/761px/桌面几何、滚动和输入操作检查 |
| 实时正文/思考跟随、上滚停留、发送恢复 | `appSurface/design-agent.suite.ts` 的实时事件与发送恢复用例;还原旧滚动依赖时新增用例失败 |
| 历史未知时间与乐观发送时间 | 同套界面测试覆盖读取、乐观发送和正式快照覆盖;恢复 `Date.now()` 补造时新增用例失败 |
| 审批/澄清、文件导入禁用及共用控件 | `appSurface.test.ts` 现有交互回归与 Chrome 模型菜单位置检查;不改变原生调用和业务状态 |
## 策划 Agent 批量局部修改
`patch_file` 的所有 edits 均匹配同一份原文件,参数顺序不影响结果。完成唯一匹配与不重叠校验后,按原文起点升序拼接未修改片段与替换文本,最后一次性写入;任一校验失败时不写文件。回归用例覆盖乱序 edits、中文内容与替换长度增减,并核对完整落盘内容。此行为仅属于策划 Agent 文件工具。

Some files were not shown because too many files have changed in this diff Show More