bfc7eaa794
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>
237 lines
8.2 KiB
TypeScript
237 lines
8.2 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
|
|
import type {
|
|
ChatMessage,
|
|
GameCreatorDirectToolCall,
|
|
TurnStreamItem,
|
|
} from '../src/app/types';
|
|
import {
|
|
buildDirectTurnPresentations,
|
|
directMessageTimestamp,
|
|
normalizeDirectTimestamp,
|
|
splitDirectTurnContent,
|
|
} from '../src/features/project-workspace/directTurnPresentation';
|
|
|
|
const user = (turn: string): ChatMessage => ({
|
|
role: 'user',
|
|
text: '只读检查',
|
|
messageId: `direct-codex:${turn}:user`,
|
|
});
|
|
const assistant = (id: string, text = '完整回复'): ChatMessage => ({
|
|
role: 'assistant',
|
|
text,
|
|
messageId: id,
|
|
});
|
|
const text = (turnId: string, id: string, seq = 1): TurnStreamItem => ({
|
|
schemaVersion: 'agc-turn-stream.v1',
|
|
kind: 'text',
|
|
turnId,
|
|
id: `text:${turnId}:${id}`,
|
|
text: '前缀',
|
|
seq,
|
|
at: 1_800_000_000_000,
|
|
updatedAt: 1_800_000_000_001,
|
|
});
|
|
const tool = (turnId: string): GameCreatorDirectToolCall => ({
|
|
schemaVersion: 'agc-tool-call.v1',
|
|
id: 'call',
|
|
turnId,
|
|
kind: 'mcp_tool',
|
|
title: '调用工具',
|
|
summary: '读取',
|
|
status: 'completed',
|
|
detail: { command: '{"path":"file"}', output: '内容', changes: [] },
|
|
startedAt: 1_800_000_000_000,
|
|
updatedAt: 1_800_000_000_001,
|
|
});
|
|
const build = (
|
|
messages: ChatMessage[],
|
|
items: TurnStreamItem[],
|
|
options: Partial<Parameters<typeof buildDirectTurnPresentations>[0]> = {},
|
|
) =>
|
|
buildDirectTurnPresentations({
|
|
messages,
|
|
visibleMessages: messages,
|
|
items,
|
|
calls: [],
|
|
transientReply: '',
|
|
...options,
|
|
});
|
|
|
|
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')],
|
|
[text('old', 'raw-old'), text('one', 'raw')],
|
|
{
|
|
hasUnloadedHistory: true,
|
|
},
|
|
);
|
|
expect(rows.map((row) => row.turnId)).toEqual(['one']);
|
|
const active = build([], [text('live', 'raw')], {
|
|
hasUnloadedHistory: true,
|
|
activeTurnId: 'live',
|
|
});
|
|
expect(active.map((row) => row.turnId)).toEqual(['live']);
|
|
});
|
|
it('尚未落盘用户的实时回合也只产生一个 owner,不另建 live 与 unmapped 出口', () => {
|
|
const rows = build([], [text('one', 'raw')], {
|
|
activeTurnId: 'one',
|
|
transientReply: '同一份累计回复',
|
|
calls: [tool('one')],
|
|
});
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0].source).toBe('stream');
|
|
expect(rows[0].transientReply).toBe('');
|
|
expect(rows[0].calls).toHaveLength(1);
|
|
});
|
|
it('同一身份用户重复快照不增加回合,不丢用户正文', () => {
|
|
const rows = build([user('one'), user('one')], [text('one', 'raw')]);
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0].messages).toHaveLength(1);
|
|
expect(rows[0].messages[0].role).toBe('user');
|
|
});
|
|
it('用原始 item 身份补齐旧前缀,最终合成消息不另占正文出口', () => {
|
|
const rows = build(
|
|
[user('one'), assistant('raw'), assistant('direct-codex:one:assistant')],
|
|
[text('one', 'raw')],
|
|
);
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0].source).toBe('stream');
|
|
expect(rows[0].items).toHaveLength(1);
|
|
expect(rows[0].items[0].text).toBe('完整回复');
|
|
});
|
|
it('先关联完整历史再分页,首条可见 assistant 不会失去用户归属', () => {
|
|
const messages = [
|
|
user('old'),
|
|
assistant('old-raw'),
|
|
user('one'),
|
|
assistant('raw'),
|
|
];
|
|
const rows = build(messages, [text('old', 'old-raw'), text('one', 'raw')], {
|
|
visibleMessages: messages.slice(3),
|
|
});
|
|
expect(rows.map((row) => row.turnId)).toEqual(['one']);
|
|
expect(rows[0].messages[0].messageId).toBe('direct-codex:one:user');
|
|
});
|
|
it('纯文本旧回合不挤占之后有工具的回合身份', () => {
|
|
const rows = build(
|
|
[user('old'), assistant('plain'), user('one'), assistant('raw')],
|
|
[text('one', 'raw')],
|
|
{ calls: [tool('one')] },
|
|
);
|
|
expect(rows.map((row) => row.turnId)).toEqual(['old', 'one']);
|
|
expect(rows[0].source).toBe('messages');
|
|
expect(rows[0].calls).toEqual([]);
|
|
expect(rows[1].source).toBe('stream');
|
|
});
|
|
it('没有流的原始 assistant 与工具仍归属一个回合,输入输出保留', () => {
|
|
const rows = build([user('one'), assistant('raw')], [], {
|
|
calls: [tool('one')],
|
|
});
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0].source).toBe('messages');
|
|
expect(rows[0].calls[0].detail.output).toBe('内容');
|
|
});
|
|
it('无法证明流覆盖历史时整轮回退,不能混画部分流与全文', () => {
|
|
const rows = build(
|
|
[user('one'), assistant('raw-a'), assistant('raw-b')],
|
|
[text('one', 'raw-b')],
|
|
);
|
|
expect(rows[0].source).toBe('messages');
|
|
expect(
|
|
rows[0].messages.filter((message) => message.role === 'assistant'),
|
|
).toHaveLength(2);
|
|
});
|
|
it('不同回合的相同文字不是重复消息,不做文本去重', () => {
|
|
const rows = build(
|
|
[user('one'), assistant('raw-one'), user('two'), assistant('raw-two')],
|
|
[text('one', 'raw-one'), text('two', 'raw-two')],
|
|
);
|
|
expect(rows).toHaveLength(2);
|
|
expect(rows.every((row) => row.source === 'stream')).toBe(true);
|
|
});
|
|
it('乱序重复快照按 seq 排列且不增加 item', () => {
|
|
const first = text('one', 'a');
|
|
const last = text('one', 'b', 3);
|
|
const marker: TurnStreamItem = {
|
|
...text('one', 'unused', 2),
|
|
kind: 'tool',
|
|
id: 'tool:one:call',
|
|
callId: 'call',
|
|
};
|
|
const rows = build([user('one')], [last, marker, first, first]);
|
|
expect(rows[0].items.map((item) => item.seq)).toEqual([1, 2, 3]);
|
|
});
|
|
it('完成后中间文本和工具进入过程,最终回复独立且分区不重复', () => {
|
|
const marker: TurnStreamItem = {
|
|
...text('one', 'unused', 2),
|
|
kind: 'tool',
|
|
id: 'tool:one:call',
|
|
callId: 'call',
|
|
};
|
|
const row = build(
|
|
[user('one')],
|
|
[text('one', 'start'), marker, text('one', 'final', 3)],
|
|
)[0];
|
|
const parts = splitDirectTurnContent(row);
|
|
expect(parts.processItems.map((item) => item.id)).toEqual([
|
|
'text:one:start',
|
|
'tool:one:call',
|
|
]);
|
|
expect(parts.finalItems.map((item) => item.id)).toEqual(['text:one:final']);
|
|
const active = splitDirectTurnContent({ ...row, active: true });
|
|
expect(active.processItems).toHaveLength(3);
|
|
expect(active.finalItems).toEqual([]);
|
|
});
|
|
it('失败回合保留失败提示,不把末尾过程文本提升为最终回复', () => {
|
|
const row = build(
|
|
[user('one'), assistant('direct-codex:one:failure', '失败')],
|
|
[text('one', 'progress'), { ...text('one', 'failure', 2), text: '失败' }],
|
|
)[0];
|
|
const parts = splitDirectTurnContent(row);
|
|
expect(parts.processItems.map((item) => item.id)).toEqual([
|
|
'text:one:progress',
|
|
]);
|
|
expect(parts.finalItems.map((item) => item.id)).toEqual([
|
|
'text:one:failure',
|
|
]);
|
|
});
|
|
it('无流历史只保留最后一条回复,发送时间不从工具推断', () => {
|
|
const row = build(
|
|
[user('one'), assistant('progress'), assistant('final')],
|
|
[],
|
|
)[0];
|
|
const parts = splitDirectTurnContent(row);
|
|
expect(parts.processMessages.map((message) => message.messageId)).toEqual([
|
|
'progress',
|
|
]);
|
|
expect(parts.finalMessages.map((message) => message.messageId)).toEqual([
|
|
'final',
|
|
]);
|
|
expect(directMessageTimestamp(undefined)).toBe(0);
|
|
expect(directMessageTimestamp(Number.MAX_VALUE)).toBe(0);
|
|
expect(directMessageTimestamp(1_800_000_000_001)).toBe(1_800_000_000_001);
|
|
});
|
|
it('无流活动回合的累计文本只属于该回合,持久 assistant 到达即接管', () => {
|
|
expect(
|
|
build([user('one')], [], {
|
|
activeTurnId: 'one',
|
|
transientReply: '回复',
|
|
})[0].transientReply,
|
|
).toBe('回复');
|
|
expect(
|
|
build([user('one'), assistant('direct-codex:one:assistant')], [], {
|
|
activeTurnId: 'one',
|
|
transientReply: '回复',
|
|
})[0].transientReply,
|
|
).toBe('');
|
|
});
|
|
});
|