合并 master(bba4f0eb2):DirectProject 聊天三态与快照文档并入运行页入口收口
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
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
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
- 只冲突两份共享记忆的尾部追加(pitfalls / decision-log):保留双方新增条目,直接合并非取舍 - App.tsx / styles.css 自动合并通过;本分支的运行页入口收口与 onRunNotice 通道未受影响
This commit is contained in:
@@ -88,15 +88,21 @@ export function resolveNsisCacheDir(
|
||||
env = process.env,
|
||||
platform = process.platform,
|
||||
) {
|
||||
const pathImpl = platform === 'win32' ? path.win32 : path.posix;
|
||||
const explicit = env.AGC_TAURI_NSIS_CACHE_DIR?.trim();
|
||||
if (explicit) return path.resolve(explicit);
|
||||
if (explicit) return pathImpl.resolve(explicit);
|
||||
// Jenkins Windows 节点以 SYSTEM 运行,ProgramData 稳定可写且不受工作区清理影响;
|
||||
// 缓存里只有待解压的原始归档,不会从该目录执行任何程序。
|
||||
if (platform === 'win32') {
|
||||
const programData = env.ProgramData?.trim() || 'C:\\ProgramData';
|
||||
return path.join(programData, 'genarrative', 'tauri-nsis-cache');
|
||||
return pathImpl.join(programData, 'genarrative', 'tauri-nsis-cache');
|
||||
}
|
||||
return path.join(os.homedir(), '.cache', 'genarrative', 'tauri-nsis-cache');
|
||||
return pathImpl.join(
|
||||
os.homedir(),
|
||||
'.cache',
|
||||
'genarrative',
|
||||
'tauri-nsis-cache',
|
||||
);
|
||||
}
|
||||
|
||||
/** 与 tauri-bundler 相同的镜像开关语义,便于构建机绕过不可达的 GitHub。 */
|
||||
|
||||
@@ -7,6 +7,7 @@ import { test } from 'node:test';
|
||||
import JSZip from 'jszip';
|
||||
|
||||
import {
|
||||
defaultAppRoot,
|
||||
ensureNsisToolset,
|
||||
extractNsisArchive,
|
||||
NSIS_ARCHIVE_ASSET_NAME,
|
||||
@@ -23,10 +24,7 @@ import {
|
||||
verifyNsisToolset,
|
||||
} from './nsis-toolset.mjs';
|
||||
|
||||
const appRoot = path.resolve(
|
||||
path.dirname(new URL(import.meta.url).pathname),
|
||||
'..',
|
||||
);
|
||||
const appRoot = defaultAppRoot();
|
||||
const silentLogger = { log() {}, warn() {} };
|
||||
|
||||
function createSandbox() {
|
||||
@@ -98,6 +96,14 @@ test('NSIS 工具链目录与 Tauri useLocalToolsDir 配置保持一致', () =>
|
||||
});
|
||||
|
||||
test('缓存目录默认落在工作区之外并支持环境变量覆盖', () => {
|
||||
assert.equal(
|
||||
resolveNsisCacheDir({ AGC_TAURI_NSIS_CACHE_DIR: 'D:\\agc-cache' }, 'win32'),
|
||||
'D:\\agc-cache',
|
||||
);
|
||||
assert.equal(
|
||||
resolveNsisCacheDir({ ProgramData: 'D:\\ProgramData' }, 'win32'),
|
||||
path.win32.join('D:\\ProgramData', 'genarrative', 'tauri-nsis-cache'),
|
||||
);
|
||||
assert.equal(
|
||||
resolveNsisCacheDir(
|
||||
{ AGC_TAURI_NSIS_CACHE_DIR: '/tmp/agc-cache' },
|
||||
@@ -105,13 +111,9 @@ test('缓存目录默认落在工作区之外并支持环境变量覆盖', () =>
|
||||
),
|
||||
'/tmp/agc-cache',
|
||||
);
|
||||
assert.equal(
|
||||
resolveNsisCacheDir({ ProgramData: 'D:\\ProgramData' }, 'win32'),
|
||||
path.join('D:\\ProgramData', 'genarrative', 'tauri-nsis-cache'),
|
||||
);
|
||||
assert.ok(
|
||||
resolveNsisCacheDir({}, 'linux').endsWith(
|
||||
path.join('.cache', 'genarrative', 'tauri-nsis-cache'),
|
||||
path.posix.join('.cache', 'genarrative', 'tauri-nsis-cache'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -348,79 +348,113 @@ pub(crate) fn should_skip_project_index_path(relative_path: &str) -> bool {
|
||||
|| should_skip_project_snapshot_path(relative_path)
|
||||
}
|
||||
|
||||
/// 项目索引、checkpoint、Agent 上下文与 git 检查共用的排除口径:`.agent` 是这些结果的
|
||||
/// 本机控制面,不参与其中。
|
||||
pub(crate) fn should_skip_project_snapshot_path(relative_path: &str) -> bool {
|
||||
project_snapshot_path_is_excluded(relative_path, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目快照同步(上传)的排除口径。
|
||||
*
|
||||
* 与 `should_skip_project_snapshot_path` 是同一份组件与后缀规则,唯一区别是 `.agent`:
|
||||
* 它承载项目身份与 Agent 状态(`manifest.json`、`agent.db`、会话、运行日志、checkpoint、
|
||||
* workbench、`project.lock`),必须整目录随快照同步,因此不再把 `.agent` 组件本身当作
|
||||
* 排除项,并放行其中的 Agent 状态数据库(`.db` / `.db-wal` / `.db-shm`)。
|
||||
*
|
||||
* 其余排除项在 `.agent` 内同样生效:版本库、依赖与构建目录、凭据目录、敏感后缀、
|
||||
* `.env*` 与凭据类文件名一律不参与同步;符号链接与重解析点在扫描阶段单独跳过。
|
||||
*/
|
||||
pub(crate) fn should_skip_project_snapshot_sync_path(relative_path: &str) -> bool {
|
||||
project_snapshot_path_is_excluded(relative_path, true)
|
||||
}
|
||||
|
||||
/// 任意层级出现即排除的目录组件。`.agent` 只有项目快照同步会放行。
|
||||
const PROJECT_SNAPSHOT_EXCLUDED_COMPONENTS: &[&str] = &[
|
||||
".agent",
|
||||
".git",
|
||||
".hg",
|
||||
".svn",
|
||||
".ssh",
|
||||
".aws",
|
||||
".azure",
|
||||
".gnupg",
|
||||
".kube",
|
||||
".docker",
|
||||
".gcloud",
|
||||
".terraform",
|
||||
".password-store",
|
||||
".secrets",
|
||||
"secrets",
|
||||
"credentials",
|
||||
"node_modules",
|
||||
"target",
|
||||
"dist",
|
||||
"build",
|
||||
".next",
|
||||
"coverage",
|
||||
".cache",
|
||||
];
|
||||
|
||||
/// 凭据、密钥与数据库转储类文件名后缀。
|
||||
const PROJECT_SNAPSHOT_EXCLUDED_SUFFIXES: &[&str] = &[
|
||||
".pem",
|
||||
".key",
|
||||
".p12",
|
||||
".pfx",
|
||||
".ppk",
|
||||
".jks",
|
||||
".keystore",
|
||||
".kdbx",
|
||||
".db",
|
||||
".db-wal",
|
||||
".db-shm",
|
||||
".sqlite",
|
||||
".sqlite-wal",
|
||||
".sqlite-shm",
|
||||
".sqlite3",
|
||||
".sqlite3-wal",
|
||||
".sqlite3-shm",
|
||||
".sql",
|
||||
".sql.gz",
|
||||
".sql.bz2",
|
||||
".sql.xz",
|
||||
".dump",
|
||||
".dump.gz",
|
||||
".dmp",
|
||||
".bak",
|
||||
".mdb",
|
||||
".accdb",
|
||||
".rdb",
|
||||
".bson",
|
||||
".pgdump",
|
||||
".tfstate",
|
||||
".tfstate.backup",
|
||||
];
|
||||
|
||||
/// `.agent` 内的 Agent 状态数据库(`agent.db` 及其 WAL / SHM 旁文件)属于项目状态,
|
||||
/// 随快照同步;其它数据库与转储后缀仍然排除。
|
||||
const PROJECT_AGENT_STATE_DATABASE_SUFFIXES: &[&str] = &[".db", ".db-wal", ".db-shm"];
|
||||
|
||||
fn project_snapshot_path_is_excluded(relative_path: &str, include_agent_state: bool) -> bool {
|
||||
let components = relative_path
|
||||
.split('/')
|
||||
.filter(|component| !component.is_empty())
|
||||
.map(str::to_ascii_lowercase)
|
||||
.collect::<Vec<_>>();
|
||||
if components.iter().any(|component| {
|
||||
matches!(
|
||||
component.as_str(),
|
||||
".agent"
|
||||
| ".git"
|
||||
| ".hg"
|
||||
| ".svn"
|
||||
| ".ssh"
|
||||
| ".aws"
|
||||
| ".azure"
|
||||
| ".gnupg"
|
||||
| ".kube"
|
||||
| ".docker"
|
||||
| ".gcloud"
|
||||
| ".terraform"
|
||||
| ".password-store"
|
||||
| ".secrets"
|
||||
| "secrets"
|
||||
| "credentials"
|
||||
| "node_modules"
|
||||
| "target"
|
||||
| "dist"
|
||||
| "build"
|
||||
| ".next"
|
||||
| "coverage"
|
||||
| ".cache"
|
||||
)
|
||||
PROJECT_SNAPSHOT_EXCLUDED_COMPONENTS.contains(&component.as_str())
|
||||
&& !(include_agent_state && component == ".agent")
|
||||
}) {
|
||||
return true;
|
||||
}
|
||||
let Some(file_name) = components.last() else {
|
||||
return true;
|
||||
};
|
||||
let sensitive_suffixes = [
|
||||
".pem",
|
||||
".key",
|
||||
".p12",
|
||||
".pfx",
|
||||
".ppk",
|
||||
".jks",
|
||||
".keystore",
|
||||
".kdbx",
|
||||
".db",
|
||||
".db-wal",
|
||||
".db-shm",
|
||||
".sqlite",
|
||||
".sqlite-wal",
|
||||
".sqlite-shm",
|
||||
".sqlite3",
|
||||
".sqlite3-wal",
|
||||
".sqlite3-shm",
|
||||
".sql",
|
||||
".sql.gz",
|
||||
".sql.bz2",
|
||||
".sql.xz",
|
||||
".dump",
|
||||
".dump.gz",
|
||||
".dmp",
|
||||
".bak",
|
||||
".mdb",
|
||||
".accdb",
|
||||
".rdb",
|
||||
".bson",
|
||||
".pgdump",
|
||||
".tfstate",
|
||||
".tfstate.backup",
|
||||
];
|
||||
let agent_state_database = include_agent_state
|
||||
&& components
|
||||
.first()
|
||||
.is_some_and(|first| first.as_str() == ".agent");
|
||||
let structured_secret_suffixes = [".json", ".txt", ".toml", ".yaml", ".yml"];
|
||||
file_name == ".env"
|
||||
|| file_name.starts_with(".env.")
|
||||
@@ -471,9 +505,10 @@ pub(crate) fn should_skip_project_snapshot_path(relative_path: &str) -> bool {
|
||||
|| file_name.starts_with("id_ecdsa")
|
||||
|| file_name.starts_with("id_ed25519")
|
||||
|| file_name.starts_with("id_xmss")
|
||||
|| sensitive_suffixes
|
||||
.iter()
|
||||
.any(|suffix| file_name.ends_with(suffix))
|
||||
|| PROJECT_SNAPSHOT_EXCLUDED_SUFFIXES.iter().any(|suffix| {
|
||||
file_name.ends_with(suffix)
|
||||
&& !(agent_state_database && PROJECT_AGENT_STATE_DATABASE_SUFFIXES.contains(suffix))
|
||||
})
|
||||
|| ((file_name.contains("cookie") || file_name.contains("credential"))
|
||||
&& structured_secret_suffixes
|
||||
.iter()
|
||||
|
||||
@@ -23,9 +23,10 @@ pub(crate) struct ProjectSnapshotScanResult {
|
||||
pub(crate) skipped: Vec<ProjectSnapshotSkippedPath>,
|
||||
}
|
||||
|
||||
/// 扫描项目目录,复用 checkpoint / 项目索引同一份排除口径:
|
||||
/// `.agent`、版本控制目录、依赖与构建产物目录、凭据目录、符号链接与重解析点
|
||||
/// 都不参与同步,超出单文件上限的文件进入跳过清单而不是静默丢弃。
|
||||
/// 扫描项目目录,排除口径见 `should_skip_project_snapshot_sync_path`:
|
||||
/// `.agent` 是项目身份与 Agent 状态的权威位置,整目录参与同步;版本控制目录、
|
||||
/// 依赖与构建产物目录、凭据目录、符号链接与重解析点都不参与同步,超出单文件
|
||||
/// 上限的文件进入跳过清单而不是静默丢弃。
|
||||
pub(crate) fn scan_project_snapshot_files(
|
||||
root: &Path,
|
||||
max_file_bytes: u64,
|
||||
@@ -52,7 +53,7 @@ pub(crate) fn scan_project_snapshot_files(
|
||||
let Ok(relative_path) = relative_project_path(root, &path) else {
|
||||
continue;
|
||||
};
|
||||
if should_skip_project_snapshot_path(&relative_path) {
|
||||
if should_skip_project_snapshot_sync_path(&relative_path) {
|
||||
continue;
|
||||
}
|
||||
let metadata = match fs::symlink_metadata(&path) {
|
||||
|
||||
@@ -95,8 +95,6 @@ fn project_snapshot_scan_skips_excluded_paths_and_oversized_files() {
|
||||
let root = fixture_root();
|
||||
write_fixture_file(root.path(), "game/index.html", b"<html></html>");
|
||||
write_fixture_file(root.path(), "assets/manifest.json", b"{}");
|
||||
write_fixture_file(root.path(), ".agent/runtime/state.json", b"{}");
|
||||
write_fixture_file(root.path(), ".agent/manifest.json", b"{}");
|
||||
write_fixture_file(root.path(), "node_modules/pkg/index.js", b"export {};");
|
||||
write_fixture_file(root.path(), "game/dist/bundle.js", b"bundle");
|
||||
write_fixture_file(root.path(), "secrets/key.pem", b"private-key");
|
||||
@@ -111,7 +109,7 @@ fn project_snapshot_scan_skips_excluded_paths_and_oversized_files() {
|
||||
assert_eq!(
|
||||
scanned,
|
||||
vec!["assets/manifest.json".to_string()],
|
||||
".agent、node_modules、dist 与凭据目录里的文件不能进入候选集合"
|
||||
"node_modules、dist 与凭据目录里的文件不能进入候选集合"
|
||||
);
|
||||
let skipped = scan
|
||||
.skipped
|
||||
@@ -125,6 +123,111 @@ fn project_snapshot_scan_skips_excluded_paths_and_oversized_files() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_snapshot_scan_uploads_whole_agent_directory() {
|
||||
let root = fixture_root();
|
||||
write_fixture_file(root.path(), "game/index.html", b"<html></html>");
|
||||
write_fixture_file(root.path(), ".agent/manifest.json", b"{}");
|
||||
write_fixture_file(root.path(), ".agent/agent.db", b"sqlite");
|
||||
write_fixture_file(root.path(), ".agent/agent.db-wal", b"wal");
|
||||
write_fixture_file(root.path(), ".agent/project.lock", b"{}");
|
||||
write_fixture_file(root.path(), ".agent/.manifest.json.lock", b"");
|
||||
write_fixture_file(root.path(), ".agent/conversations/project.jsonl", b"{}\n");
|
||||
write_fixture_file(root.path(), ".agent/runtime/events/art.jsonl", b"{}\n");
|
||||
write_fixture_file(
|
||||
root.path(),
|
||||
".agent/runtime/command-env/home/.config.json",
|
||||
b"{}",
|
||||
);
|
||||
write_fixture_file(root.path(), ".agent/logs/command.log", b"log");
|
||||
write_fixture_file(
|
||||
root.path(),
|
||||
".agent/checkpoints/0001/manifest.json",
|
||||
b"{\"files\":[]}",
|
||||
);
|
||||
write_fixture_file(
|
||||
root.path(),
|
||||
".agent/workbench/resource-layouts/art.json",
|
||||
b"{}",
|
||||
);
|
||||
|
||||
let scan = scan_fixture(root.path());
|
||||
let scanned = scan
|
||||
.files
|
||||
.iter()
|
||||
.map(|file| file.relative_path.clone())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
scanned,
|
||||
vec![
|
||||
".agent/.manifest.json.lock".to_string(),
|
||||
".agent/agent.db".to_string(),
|
||||
".agent/agent.db-wal".to_string(),
|
||||
".agent/checkpoints/0001/manifest.json".to_string(),
|
||||
".agent/conversations/project.jsonl".to_string(),
|
||||
".agent/logs/command.log".to_string(),
|
||||
".agent/manifest.json".to_string(),
|
||||
".agent/project.lock".to_string(),
|
||||
".agent/runtime/command-env/home/.config.json".to_string(),
|
||||
".agent/runtime/events/art.jsonl".to_string(),
|
||||
".agent/workbench/resource-layouts/art.json".to_string(),
|
||||
"game/index.html".to_string(),
|
||||
],
|
||||
"`.agent` 是项目身份与 Agent 状态的权威位置,必须整目录参与同步"
|
||||
);
|
||||
assert!(
|
||||
scan.skipped.is_empty(),
|
||||
"`.agent` 内的普通文件既不跳过也不延后"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_snapshot_sync_policy_keeps_agent_state_and_still_blocks_credentials() {
|
||||
for relative_path in [
|
||||
".agent/manifest.json",
|
||||
".agent/agent.db",
|
||||
".agent/agent.db-wal",
|
||||
".agent/agent.db-shm",
|
||||
".agent/project.lock",
|
||||
".agent/runtime/events/art.jsonl",
|
||||
".agent/runtime/locks/append/01.lock",
|
||||
".agent/checkpoints/0001/manifest.json",
|
||||
".agent/conversations/project.jsonl",
|
||||
".agent/workbench/resource-layouts/art.json",
|
||||
".AGENT/manifest.json",
|
||||
] {
|
||||
assert!(
|
||||
!should_skip_project_snapshot_sync_path(relative_path),
|
||||
"`.agent` 状态必须参与同步:{relative_path}"
|
||||
);
|
||||
}
|
||||
|
||||
for relative_path in [
|
||||
".agent/credentials/platform.json",
|
||||
".agent/.ssh/id_rsa",
|
||||
".agent/certs/server.pem",
|
||||
".agent/node_modules/pkg/index.js",
|
||||
".agent/runtime/command-env/home/.env",
|
||||
".agent/runtime/command-env/home/.npmrc",
|
||||
".agent/backup/game.sql",
|
||||
".git/config",
|
||||
"node_modules/pkg/index.js",
|
||||
"game/dist/bundle.js",
|
||||
"secrets/key.pem",
|
||||
"",
|
||||
] {
|
||||
assert!(
|
||||
should_skip_project_snapshot_sync_path(relative_path),
|
||||
"凭据、版本库与构建产物仍然排除:{relative_path}"
|
||||
);
|
||||
}
|
||||
|
||||
// 项目索引、checkpoint 与 Agent 上下文继续排除整个 `.agent`,本变更只放开快照同步。
|
||||
assert!(should_skip_project_snapshot_path(".agent/manifest.json"));
|
||||
assert!(should_skip_project_index_path(".agent/manifest.json"));
|
||||
assert!(should_skip_project_index_path(".agent/agent.db"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_snapshot_diff_reuses_metadata_and_reports_a_single_modification() {
|
||||
let root = fixture_root();
|
||||
|
||||
@@ -515,6 +515,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) => ({
|
||||
@@ -523,7 +524,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 (
|
||||
@@ -536,7 +536,6 @@ export function App({
|
||||
role: 'user',
|
||||
text: initialPrompt,
|
||||
runtimeOwned: true,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
return messages;
|
||||
@@ -872,7 +871,15 @@ export function App({
|
||||
if (messageList) {
|
||||
messageList.scrollTop = messageList.scrollHeight;
|
||||
}
|
||||
}, [messages, projectChatError]);
|
||||
}, [
|
||||
messages,
|
||||
projectChatError,
|
||||
designAgentTransientReply,
|
||||
designAgentReasoning,
|
||||
designAgentView,
|
||||
pendingUiConfirmation,
|
||||
chatFileImportNotice,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
latestMessagesRef.current = messages;
|
||||
|
||||
@@ -10117,8 +10117,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);
|
||||
@@ -10138,13 +10138,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;
|
||||
@@ -10152,6 +10163,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);
|
||||
@@ -10172,13 +10193,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,
|
||||
@@ -11015,6 +11047,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
|
||||
@@ -11249,6 +11287,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 {
|
||||
@@ -11263,6 +11302,7 @@ 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 {
|
||||
@@ -11275,6 +11315,7 @@ button.design-workspace-tree__entry:hover,
|
||||
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
|
||||
@@ -11283,6 +11324,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 {
|
||||
@@ -11294,6 +11336,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 {
|
||||
|
||||
+36
-16
@@ -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,
|
||||
@@ -122,6 +129,7 @@ export function DirectProjectChatView({
|
||||
historyHasMore,
|
||||
loadEarlierHistory,
|
||||
localMessages,
|
||||
pendingUserItemId,
|
||||
queuedTurns,
|
||||
removeAttachment,
|
||||
startInitialTurn,
|
||||
@@ -131,7 +139,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 ||
|
||||
@@ -144,7 +172,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)
|
||||
) {
|
||||
@@ -165,9 +193,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;
|
||||
@@ -175,14 +203,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() });
|
||||
@@ -206,7 +226,7 @@ export function DirectProjectChatView({
|
||||
>
|
||||
<div className="project-chat-conversation">
|
||||
<DirectProjectChatHeader
|
||||
busy={busy}
|
||||
busy={turnStatus.displayBusy}
|
||||
statusText={statusText}
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
/>
|
||||
@@ -214,7 +234,7 @@ export function DirectProjectChatView({
|
||||
turns={directTurns}
|
||||
messagesRef={messagesRef}
|
||||
historyHasMore={historyHasMore}
|
||||
running={directTurnRunning}
|
||||
nativeRunning={turnStatus.nativeRunning}
|
||||
activeTurnStartedAt={activeTurnStartedAt}
|
||||
onLoadEarlierHistory={() => void loadEarlierHistory()}
|
||||
onScroll={handleScroll}
|
||||
@@ -227,7 +247,7 @@ export function DirectProjectChatView({
|
||||
attachmentNotice={attachmentNotice}
|
||||
queuedTurns={queuedTurns}
|
||||
composerNotice={composerNotice}
|
||||
busy={busy}
|
||||
busy={turnStatus.displayBusy}
|
||||
turnCancelling={turnCancelling}
|
||||
onCancelQueuedTurn={cancelQueuedTurn}
|
||||
onCancelTurn={() => void cancelTurn()}
|
||||
|
||||
+7
-3
@@ -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"
|
||||
|
||||
+24
-9
@@ -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"
|
||||
/>
|
||||
);
|
||||
|
||||
+96
-11
@@ -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 reducer:A + B 进同一份 state(turnRunning / 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,
|
||||
|
||||
+68
@@ -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],
|
||||
);
|
||||
}
|
||||
+14
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
+66
-2
@@ -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);
|
||||
|
||||
+94
-14
@@ -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;
|
||||
|
||||
+5
-3
@@ -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 会话核对仍列为运行时验收项。
|
||||
|
||||
@@ -38,6 +38,6 @@ Parent Milestone: `【里程碑】AGC项目定时快照上传-2026-09-17.md`
|
||||
## 风险与回滚
|
||||
|
||||
- 上传体积与带宽:首轮全量可能很大,先设单文件与单次同步总量上限并把超限项记入跳过清单;不静默截断。
|
||||
- 数据出境边界:只上传项目目录内普通文件,排除 `.agent/runtime`、`.agent/logs`、`.git`、构建产物与临时文件;凭据类文件不在白名单内。
|
||||
- 数据出境边界(2026-09-22 修订):只上传项目目录内普通文件。`.agent` 承载项目身份与 Agent 状态,整目录上传(含 conversations、logs、runtime、checkpoint、`agent.db`);`.git`、构建产物、临时文件与凭据类文件仍不在白名单内。该修订只作用于快照同步,项目索引与 checkpoint 继续排除整个 `.agent`。
|
||||
- 服务端未配置 bucket 时客户端必须失败关闭,不能把本地索引推进成"已同步",否则后续同步会漏传。
|
||||
- 回滚:客户端可停用触发接线(保留模块与测试)即可回到无上传行为;服务端路由与配置项可单独移除,不影响既有 OSS 前缀与错误报告链路。
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user