修复 AGC CI 契约、JSON 预览与工具调用时间戳 (#380)
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 5m14s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 5m28s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 5m44s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 5m53s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m35s
Project CI / AI game creator shell Rust crates (push) Successful in 1m38s
Project CI / Frontend tests (push) Failing after 3m53s
Project CI / Repository checks (push) Failing after 4m11s
Project CI / Native shell tests (push) Successful in 6m4s
Project CI / Backend tests (push) Successful in 6m38s
Project CI / AI game creator shell web tests (push) Failing after 2m50s

修复 AGC 的 Rust 编译、资源预览及工具调用时间戳问题,并包含当前分支已有的 Rust shard 4 修复。

变更:
- 补齐 Direct 回合事件测试构造中的 reasoning_text、stream_items 字段。
- JSON/JSONC 不再按代码扩展名匹配,JSON 规格恢复文档预览。
- 工具条分隔线测试对齐当前动作组条件,去掉旧 CSS 与变量名断言。
- 实时事件与历史回读的工具调用时间戳在模型入口归一化为毫秒,界面仍显示秒/分钟。
- 防止失效的精灵切片绕过校验重新进入通用资源路径;同步混合历史记录的失败关闭测试契约。

验证:
- 资源预览契约、真实 manifest、工具条测试:12 项通过。
- Direct 回合呈现测试:15 项通过。
- Rust direct_turn_update_payload_is_the_exact_camel_case_contract 通过,test target 编译成功。
- git diff --check 及提交钩子 ESLint、Prettier、Rustfmt 通过。

未运行完整 CI 或完整 appSurface 套件;其余对话区结构/文案断言未在此 PR 中批量调整。rehype-highlight 已在 master 的 package.json 与 lockfile 中声明,本 PR 未重复添加依赖。

Reviewed-on: #380
Co-authored-by: Linghong <ink29535@proton.me>
Co-committed-by: Linghong <ink29535@proton.me>
This commit was merged in pull request #380.
This commit is contained in:
2026-09-16 05:42:20 +08:00
committed by 孔令弘
parent 6ae188334b
commit bfc7eaa794
7 changed files with 45 additions and 52 deletions
@@ -2667,6 +2667,15 @@ fn direct_registered_taonier_runtime_image_paths(root: &Path) -> Vec<String> {
.generation_route
.as_deref()
.is_some_and(|route| route.starts_with("/api/external/v1/editor/"))
// Canonical slices are admitted only through
// `direct_registered_taonier_slice_paths` after the full slice
// manifest/receipt/content validation. This generic fallback
// must not re-admit a slice whose bytes no longer match the
// registered receipt.
&& !(asset.kind == "art-spritesheet-slice"
&& asset
.local_path
.starts_with("assets/art-spritesheet-slices/"))
})
.filter_map(|asset| {
let path = direct_normalized_project_asset_path(&asset.local_path)?;
@@ -5712,6 +5721,8 @@ mod tests {
activity: None,
accumulated_text: Some("partial".to_string()),
tool_calls: None,
reasoning_text: None,
stream_items: None,
updated_at: 42,
})
.expect("serialize direct update");
@@ -8767,11 +8778,6 @@ mod tests {
"const player = new Image(); player.src = '/assets/art-spritesheet-slices/player.png';",
)
.expect("scene");
std::fs::write(
root.join("assets/art-spritesheet-slices/player.png"),
tiny_opaque_png(),
)
.expect("slice");
assert_eq!(
direct_game_sources_referenced_taonier_assets(&root),
@@ -272,7 +272,7 @@ fn project_history_skips_direct_project_rows_but_keeps_other_broken_rows_failing
}
#[test]
fn mixed_project_history_rows_stay_readable_from_both_sides() {
fn mixed_project_history_rows_read_by_generic_chain_but_fail_closed_for_direct_project() {
let root = unique_conversation_test_root();
init_local_game_project_at(&root, "project-1", "写侧统一测试").expect("init project");
write_project_history(&root, &[DIRECT_PROJECT_ROW]);
@@ -309,19 +309,12 @@ fn mixed_project_history_rows_stay_readable_from_both_sides() {
vec!["模式切换后由通用写入器补写的回复", "带 id 的旧格式回复"]
);
// 反向:DirectProject 链把同一份文件里的两种行都读出来,混合文件不构成毒化
let direct_items = crate::agent::read_direct_project_history_items_at(&root)
.expect("DirectProject must keep reading the mixed history");
assert_eq!(
direct_items
.iter()
.map(|item| item["content"][0]["text"].as_str().unwrap_or_default())
.collect::<Vec<_>>(),
vec![
"再加一个按钮",
"模式切换后由通用写入器补写的回复",
"带 id 的旧格式回复",
]
// 反向:DirectProject 链只接受 response_item 信封,混合文件里的 legacy 行让它失败关闭
let error = crate::agent::read_direct_project_history_items_at(&root)
.expect_err("DirectProject must fail closed on legacy rows");
assert!(
error.starts_with("DirectProject 历史记录类型无效"),
"{error}"
);
std::fs::remove_dir_all(root).ok();
+3
View File
@@ -244,6 +244,7 @@ import {
type DirectThreadSubscriptionBootstrap,
isDirectTurnInProgress,
} from './features/project-workspace/directThreadEvents';
import { normalizeDirectTimestamp } from './features/project-workspace/directTurnPresentation';
import type { DirectCodexUserContentPart } from './features/project-workspace/generated';
import {
appendMemoryContent,
@@ -1010,6 +1011,8 @@ export function App({
const normalized: GameCreatorDirectToolCall = {
...call,
id,
startedAt: normalizeDirectTimestamp(call.startedAt),
updatedAt: normalizeDirectTimestamp(call.updatedAt),
detail: call.detail ?? { changes: [] },
};
// 起点时间取更早的那个:`completed` 事件不一定带 startedAt。
@@ -26,7 +26,8 @@ export type DirectTurnPresentation = {
endedAt: number;
};
export function directMessageTimestamp(value: number | undefined) {
/** 统一 Direct 时间戳为 Unix 毫秒;历史/异常 payload 可能传 Unix 秒。 */
export function normalizeDirectTimestamp(value: number | undefined) {
const timestamp =
value && Number.isFinite(value) && value > 0
? value < 100_000_000_000
@@ -36,6 +37,8 @@ export function directMessageTimestamp(value: number | undefined) {
return Number.isFinite(new Date(timestamp).getTime()) ? timestamp : 0;
}
export const directMessageTimestamp = normalizeDirectTimestamp;
/** 从唯一呈现来源分区,完成后只把最终回复及失败提示留在过程折叠区之外。 */
export function splitDirectTurnContent(turn: DirectTurnPresentation) {
const assistants = turn.messages.filter(
@@ -218,11 +218,11 @@ const markdownExtension = /\.(md|markdown|mdx)$/iu;
*
* 与 `resourceProjectionModel` 的 `gameCodeExtension` 是两份口径,刻意不复用:
* 那份用于**筛选与归属**,改动会波及画布栏目与计数;这份只决定**卡面怎么画**。
* 这里按用户口径把 `.json` / `.yaml` / `.toml` / `.xml` / `.html` / `.css` / `.sql`
* 一并算代码 —— 它们在上游被登记成「文档」,但卡面当代码画更干净
* 这里按用户口径把 `.yaml` / `.toml` / `.xml` / `.html` / `.css` / `.sql`
* 一并算代码JSON 规格属于文档预览
*/
const cardCodeExtension =
/\.(ts|tsx|mts|cts|js|jsx|mjs|cjs|rs|py|go|java|kt|kts|cs|cpp|cc|cxx|c|h|hpp|swift|php|rb|lua|sh|bash|zsh|ps1|psm1|json|jsonc|ya?ml|toml|xml|html?|css|scss|less|sql|graphql|gql|vue|svelte)$/iu;
/\.(ts|tsx|mts|cts|js|jsx|mjs|cjs|rs|py|go|java|kt|kts|cs|cpp|cc|cxx|c|h|hpp|swift|php|rb|lua|sh|bash|zsh|ps1|psm1|ya?ml|toml|xml|html?|css|scss|less|sql|graphql|gql|vue|svelte)$/iu;
/** 纯文本:按纯文本处理,显示前几行但不做标记清理。 */
const plainTextExtension =
/\.(txt|text|csv|tsv|log|ini|conf|cfg|properties|env)$/iu;
@@ -256,7 +256,7 @@ export function projectResourceCardPreviewKind(
return 'document';
}
// Markdown / 代码在扩展名这一层就分流,不再依赖上游登记类型:
// 上游把 `.json` / `.tsx` 这类文件登记成「文档」,卡面却该按代码画
// 上游把 JSON 规格登记成「文档」,卡面按文档预览;代码文件按扩展名分流
if (markdownExtension.test(resource.path)) {
return 'document';
}
@@ -8,6 +8,7 @@ import type {
import {
buildDirectTurnPresentations,
directMessageTimestamp,
normalizeDirectTimestamp,
splitDirectTurnContent,
} from '../src/features/project-workspace/directTurnPresentation';
@@ -58,6 +59,12 @@ const build = (
});
describe('DirectProject 回合唯一呈现', () => {
it('把 Unix 秒时间戳归一化为毫秒,已有毫秒值保持不变', () => {
expect(normalizeDirectTimestamp(1_800_000_000)).toBe(1_800_000_000_000);
expect(normalizeDirectTimestamp(1_800_000_000_000)).toBe(1_800_000_000_000);
expect(directMessageTimestamp(1_800_000_000)).toBe(1_800_000_000_000);
});
it('历史仍有未加载切片时,不把那些回合的工具流追加到当前页末尾', () => {
const rows = build(
[user('one')],
@@ -3,21 +3,17 @@ import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
/**
* 选中资源工具条上的「两条分隔线回归钉子。
* 选中资源工具条上的分隔线分组回归钉子。
*
* 成因:共享工具条 `ImageCanvasSelectedLayerToolbarView.tsx` 会在 `showQuickEdit` 时于
* 「快速编辑」之后输出一条分隔线,同时为 `extraActions` 自动生成一条前置分隔线
* `const extraActionDivider = extraActions ? … : null`)。AGC 资源卡是"快速编辑可用 +
* 中间那些动作未接通不渲染"的组合,两条于是直接相邻,用户看到两条竖线。
*
* 修法是样式层去重(相邻的两条只显示一条)。这条用例钉住"规则确实存在",避免以后被
* 顺手删掉又回到两条线;同时钉住成因——若哪天共享工具条不再自动生成前置分隔线
* 这条断言会失败并提醒重新评估去重规则是否还需要。
* 当前实现直接按动作组是否存在输出分隔线,避免相邻分隔线产生。这条用例钉住当前
* 条件分组,避免后续重构时再次出现空动作组之间的重复分隔线
*/
describe('选中资源工具条的分隔线去重', () => {
const repoRootCss = () =>
readFileSync(new URL('../../../src/index.css', import.meta.url), 'utf8');
const sharedToolbarSource = () =>
readFileSync(
new URL(
@@ -27,30 +23,15 @@ describe('选中资源工具条的分隔线去重', () => {
'utf8',
);
it('相邻的两条分隔线只显示一条', () => {
const css = repoRootCss().replace(/\s+/g, ' ');
expect(css).toContain(
'.image-canvas-editor__floating-toolbar-divider + .image-canvas-editor__floating-toolbar-divider { display: none; }',
);
});
it('共享工具条确实会为 extraActions 自动生成前置分隔线(去重规则的成因)', () => {
expect(sharedToolbarSource()).toContain(
'const extraActionDivider = extraActions ?',
);
});
it('「快速编辑」之后那条分隔线仍由共享工具条自己输出(去重的另一端)', () => {
it('分隔线由动作组条件控制,不依赖相邻分隔线的 CSS 去重', () => {
const source = sharedToolbarSource();
expect(source).toContain('const showQuickEdit = isActionSupported(');
// 快速编辑按钮与它后面那条分隔线必须同时存在于 showQuickEdit 分支里。
const quickEditBlock = source.slice(
source.indexOf('{showQuickEdit ? ('),
source.indexOf('{canRasterEdit && isActionSupported('),
expect(source).toContain('const hasEditingActions =');
expect(source).toContain(
'{showQuickEdit && hasEditingActions ? divider : null}',
);
expect(quickEditBlock).toContain('label="快速编辑"');
expect(quickEditBlock).toContain(
'image-canvas-editor__floating-toolbar-divider',
expect(source).toContain(
'{(showQuickEdit || hasEditingActions || canRedraw || hasExtraActions) &&',
);
});
});