6981648796
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m56s
Project CI / Backend tests (pull_request) Failing after 12s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m48s
Project CI / Native shell tests (pull_request) Failing after 45s
Project CI / Repository checks (pull_request) Failing after 13s
Project CI / Frontend tests (pull_request) Failing after 1m52s
Project CI / AI game creator shell web tests (pull_request) Failing after 1m42s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 6m57s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 8m1s
- 解决 refactor/split-direct-project 与 origin/master 在 App.tsx、立项策划聊天视图、Direct composer/引用输入区、styles.css、Rust direct user item 与 appSurface 用例上的冲突,按「Supervisor 永久退役」口径保留 DirectProject 独立聊天容器与 Design Agent 两条产品路径 - 采纳 master 的策划 Agent V1/V2 退役:删除 GDD 审批卡、策划输入卡、planningLane、planningSessionV2、planningSessionContract、规划展示适配与 Rust planning_*_v2 命令、模块、契约及对应用例,不保留兼容别名或双跑路径 - 把 master「折叠思考显示单行预览」的目的落到当前结构:新增共享表现 chat/components/AgentReasoning/AgentReasoning.tsx(折叠态单行纯文本预览 + 箭头、展开态安全 Markdown),DirectProject 回合与策划回合共用,删掉两处写死的 pre 折叠实现 - 把 master「策划入口可选模型 / 推理档」的目的接到当前策划输入盒:复用 ConversationModelSelect 与 ComposerReasoningEffortSelect,配置写回仍走客户端配置通道 - App.tsx 删除只服务退役 Supervisor / 策划 V2 的 state、ref、effect、回调与死参数,并删除两条读路径都退役后的 workspaceProjectKind;openWorkspace 的工程类型入参保留为未使用契约 - Rust 侧保留本分支 canonical→wire 投影、无审计 Direct 回合与 direct user item 严格校验,并入 master 的 prepare_new_web_project_at 前置复核 - 更新 ADR 与 shared-memory 决策记录:策划当前只有 Design Agent、两条路径的共享表现清单,以及本次合并的口径、代价与验证证据 - 验证:AGC 与仓库 typecheck、check:encoding、check:doc-index、git diff --check、改动文件 eslint 0 error;AGC vitest 168 个文件中除 5 个 jsdom localStorage 环境失败文件与本分支既有 resourceTagStatsRefresh 失败外全绿,appSurface 198 passed / 13 skipped;Rust 定向用例 direct_codex_user_item、skill_pack、sessions 全过(整套分片在本容器受 /sbin -> usr/bin 触发沙箱预检失败,与本合并无关)
345 lines
11 KiB
TypeScript
345 lines
11 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
|
|
import type { ChatMessage } from '../src/app/types';
|
|
import type { DirectChatEntry } from '../src/view/project-development/chat/conversation/directThreadChat';
|
|
import {
|
|
buildDirectChatTurns,
|
|
directChatEntriesToMessages,
|
|
directMessageTimestamp,
|
|
mergeDirectChatMessages,
|
|
normalizeDirectTimestamp,
|
|
} from '../src/view/project-development/chat/conversation/directTurnPresentation';
|
|
|
|
const userEntry = (
|
|
itemId: string,
|
|
at = 1_800_000_000_000,
|
|
): DirectChatEntry => ({
|
|
itemId,
|
|
kind: 'message',
|
|
role: 'user',
|
|
text: `问题 ${itemId}`,
|
|
at,
|
|
});
|
|
|
|
const assistantEntry = (
|
|
itemId: string,
|
|
text: string,
|
|
at = 1_800_000_001_000,
|
|
): DirectChatEntry => ({
|
|
itemId,
|
|
kind: 'message',
|
|
role: 'assistant',
|
|
text,
|
|
at,
|
|
});
|
|
|
|
const toolEntry = (
|
|
itemId: string,
|
|
at = 1_800_000_000_500,
|
|
): DirectChatEntry => ({
|
|
itemId,
|
|
kind: 'tool',
|
|
role: null,
|
|
text: null,
|
|
at,
|
|
toolCall: {
|
|
schemaVersion: 'agc-tool-call.v1',
|
|
id: itemId,
|
|
kind: 'command',
|
|
title: '执行命令',
|
|
summary: 'npm run build',
|
|
status: 'completed',
|
|
detail: { command: 'npm run build' },
|
|
startedAt: at,
|
|
updatedAt: at,
|
|
},
|
|
});
|
|
|
|
/** 工具条目:边界只来自事件级时间,历史切片拿不到就留 0。 */
|
|
const liveToolEntry = (
|
|
itemId: string,
|
|
startedAt: number,
|
|
updatedAt: number,
|
|
status: 'running' | 'completed' = 'completed',
|
|
): DirectChatEntry => ({
|
|
itemId,
|
|
kind: 'tool',
|
|
role: null,
|
|
text: null,
|
|
at: 0,
|
|
toolCall: {
|
|
schemaVersion: 'agc-tool-call.v1',
|
|
id: itemId,
|
|
kind: 'command',
|
|
title: '执行命令',
|
|
summary: 'npm run build',
|
|
status,
|
|
detail: { command: 'npm run build' },
|
|
startedAt,
|
|
updatedAt,
|
|
},
|
|
});
|
|
|
|
const reasoningEntry = (
|
|
itemId: string,
|
|
text = '先看目录',
|
|
at = 1_800_000_000_200,
|
|
): DirectChatEntry => ({
|
|
itemId,
|
|
kind: 'reasoning',
|
|
role: null,
|
|
text,
|
|
at,
|
|
});
|
|
|
|
const localUser = (text: string, messageId?: string): ChatMessage => ({
|
|
role: 'user',
|
|
text,
|
|
...(messageId ? { messageId } : {}),
|
|
updatedAt: 1_800_000_002_000,
|
|
});
|
|
|
|
const localNotice = (text: string, messageId?: string): ChatMessage => ({
|
|
role: 'assistant',
|
|
text,
|
|
...(messageId ? { messageId } : {}),
|
|
updatedAt: 1_800_000_002_500,
|
|
});
|
|
|
|
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(undefined)).toBe(0);
|
|
expect(directMessageTimestamp(Number.MAX_VALUE)).toBe(0);
|
|
expect(directMessageTimestamp(1_800_000_000_001)).toBe(1_800_000_000_001);
|
|
});
|
|
|
|
it('每个用户条目开一个新回合,顺序就是条目顺序', () => {
|
|
const turns = buildDirectChatTurns({
|
|
entries: [
|
|
userEntry('u1'),
|
|
assistantEntry('a1', '第一轮答复'),
|
|
userEntry('u2', 1_800_000_010_000),
|
|
assistantEntry('a2', '第二轮答复', 1_800_000_011_000),
|
|
],
|
|
});
|
|
expect(turns.map((turn) => turn.key)).toEqual(['u1', 'u2']);
|
|
expect(turns[0]?.users[0]).toMatchObject({ text: '问题 u1' });
|
|
expect(turns[0]?.finals[0]).toMatchObject({ text: '第一轮答复' });
|
|
expect(turns[1]?.finals[0]).toMatchObject({ text: '第二轮答复' });
|
|
expect(turns[0]?.startedAt).toBe(1_800_000_000_000);
|
|
// 终点只认明确终态:旧历史条目里没有 `turnEndedAt` 就隐藏,不拿最后一条正文的时间顶替。
|
|
expect(turns[0]?.endedAt).toBe(0);
|
|
});
|
|
|
|
it('已结束的回合只把最后一条助手正文当最终回复,中间正文与工具进过程', () => {
|
|
const turns = buildDirectChatTurns({
|
|
entries: [
|
|
userEntry('u1'),
|
|
reasoningEntry('r1'),
|
|
assistantEntry('a-mid', '我先看一下项目'),
|
|
toolEntry('t1'),
|
|
assistantEntry('a-final', '改好了', 1_800_000_002_000),
|
|
],
|
|
});
|
|
expect(turns[0]?.process.map((block) => block.key)).toEqual([
|
|
'u1:r1',
|
|
'u1:a-mid',
|
|
'u1:t1',
|
|
]);
|
|
expect(turns[0]?.finals.map((block) => block.key)).toEqual(['u1:a-final']);
|
|
});
|
|
|
|
it('连续工具合成一块,夹了正文就另起一块', () => {
|
|
const turns = buildDirectChatTurns({
|
|
entries: [
|
|
userEntry('u1'),
|
|
toolEntry('t1'),
|
|
toolEntry('t2', 1_800_000_000_600),
|
|
assistantEntry('a-mid', '继续', 1_800_000_000_700),
|
|
toolEntry('t3', 1_800_000_000_800),
|
|
],
|
|
turnRunning: true,
|
|
});
|
|
const process = turns[0]?.process ?? [];
|
|
expect(process.map((block) => block.kind)).toEqual([
|
|
'tools',
|
|
'assistant',
|
|
'tools',
|
|
]);
|
|
expect(process[0]).toMatchObject({
|
|
kind: 'tools',
|
|
calls: [{ id: 't1' }, { id: 't2' }],
|
|
});
|
|
expect(process[2]).toMatchObject({ kind: 'tools', calls: [{ id: 't3' }] });
|
|
});
|
|
|
|
it('运行中的回合:过程不折叠,最后的助手正文仍在过程里流式显示', () => {
|
|
const turns = buildDirectChatTurns({
|
|
entries: [
|
|
userEntry('u1'),
|
|
assistantEntry('a-live', '正在写'),
|
|
toolEntry('t1', 1_800_000_001_100),
|
|
],
|
|
turnRunning: true,
|
|
});
|
|
expect(turns[0]?.active).toBe(true);
|
|
expect(turns[0]?.finals).toEqual([]);
|
|
expect(turns[0]?.process.map((block) => block.kind)).toEqual([
|
|
'assistant',
|
|
'tools',
|
|
]);
|
|
});
|
|
|
|
it('整轮边界只落在本轮:旧历史回合没有终态就继续隐藏,不吃最新回合的终点', () => {
|
|
const turns = buildDirectChatTurns({
|
|
entries: [
|
|
// 两个旧回合:历史切片里没有事件级边界。
|
|
userEntry('u1', 1_800_000_000_000),
|
|
assistantEntry('a1', '第一轮答复', 1_800_000_001_000),
|
|
userEntry('u2', 1_800_000_010_000),
|
|
assistantEntry('a2', '第二轮答复', 1_800_000_011_000),
|
|
// 当前回合:用户发送时间 + 明确终态(收口时盖在本轮条目上)。
|
|
{
|
|
...userEntry('u3', 1_800_000_020_000),
|
|
turnEndedAt: 1_800_000_022_500,
|
|
},
|
|
liveToolEntry('t3', 1_800_000_021_000, 1_800_000_022_000),
|
|
{
|
|
...assistantEntry('a3', '第三轮答复', 1_800_000_022_400),
|
|
turnStartedAt: 1_800_000_020_000,
|
|
turnEndedAt: 1_800_000_022_500,
|
|
},
|
|
],
|
|
});
|
|
expect(turns.map((turn) => turn.endedAt)).toEqual([
|
|
0, 0, 1_800_000_022_500,
|
|
]);
|
|
// 旧历史回合并不会因为"拿不到时间"被填上最新回合的终点。
|
|
expect(turns[0]?.startedAt).toBe(1_800_000_000_000);
|
|
expect(turns[2]?.startedAt).toBe(1_800_000_020_000);
|
|
});
|
|
|
|
it('运行中的整轮起点:优先用户实际发送时间,缺失才用原生 turn.started.at', () => {
|
|
const liveTurn = buildDirectChatTurns({
|
|
entries: [
|
|
{ ...userEntry('u1', 0), at: 0 },
|
|
liveToolEntry('t1', 1_800_000_000_500, 0, 'running'),
|
|
],
|
|
turnRunning: true,
|
|
turnStartedAt: 1_800_000_000_100,
|
|
});
|
|
// 用户条目没有发送时间:用原生 turn.started.at 兜底。
|
|
expect(liveTurn[0]?.startedAt).toBe(1_800_000_000_100);
|
|
|
|
const withUserTime = buildDirectChatTurns({
|
|
entries: [
|
|
userEntry('u1', 1_800_000_000_050),
|
|
liveToolEntry('t1', 1_800_000_000_500, 0, 'running'),
|
|
],
|
|
turnRunning: true,
|
|
turnStartedAt: 1_800_000_000_100,
|
|
});
|
|
// 用户发送时间更早且是真实发送:以它为准,不取所有条目的最小时间。
|
|
expect(withUserTime[0]?.startedAt).toBe(1_800_000_000_050);
|
|
});
|
|
|
|
it('正式条目的晚 ack 时间不顶掉本地真实发送时间', () => {
|
|
const sentAt = 1_800_000_000_000;
|
|
// 原生落盘 / 观测到的 ack 时间晚于用户真正按下发送的时刻。
|
|
const ackAt = sentAt + 1_200;
|
|
const turns = buildDirectChatTurns({
|
|
entries: [
|
|
userEntry('direct-codex:turn-1:user', ackAt),
|
|
liveToolEntry('t1', sentAt + 400, sentAt + 900),
|
|
],
|
|
// 同一条消息的本地乐观气泡(messageId 就是原生条目身份,时间是本地发送时刻)。
|
|
localMessages: [
|
|
{
|
|
role: 'user' as const,
|
|
text: '问题 direct-codex:turn-1:user',
|
|
messageId: 'direct-codex:turn-1:user',
|
|
updatedAt: sentAt,
|
|
},
|
|
],
|
|
});
|
|
// 同身份合并保留真实发送时间:既不是正式条目的 ack 时间,也不按所有条目取最小值。
|
|
expect(turns[0]?.startedAt).toBe(sentAt);
|
|
expect(turns[0]?.users[0]).toMatchObject({ at: sentAt });
|
|
});
|
|
|
|
it('运行期失败说明挂到当前回合末尾,不当成最终回复', () => {
|
|
const turns = buildDirectChatTurns({
|
|
entries: [userEntry('u1'), assistantEntry('a1', '正文')],
|
|
localMessages: [localNotice('后台任务失败:端口被占用')],
|
|
});
|
|
expect(turns).toHaveLength(1);
|
|
expect(turns[0]?.finals.map((block) => block.kind)).toEqual([
|
|
'assistant',
|
|
'assistant',
|
|
]);
|
|
expect(turns[0]?.finals[1]).toMatchObject({
|
|
notice: true,
|
|
text: '后台任务失败:端口被占用',
|
|
});
|
|
});
|
|
|
|
it('乐观用户气泡自成回合,已落盘的同一身份不重复渲染', () => {
|
|
const turns = buildDirectChatTurns({
|
|
entries: [userEntry('u1'), assistantEntry('a1', '答复')],
|
|
localMessages: [localUser('问题 u1', 'u1'), localUser('第二条')],
|
|
turnRunning: true,
|
|
});
|
|
expect(turns.map((turn) => turn.key)).toEqual(['u1', 'local:1']);
|
|
expect(turns[1]?.users).toHaveLength(1);
|
|
expect(turns[1]?.active).toBe(true);
|
|
});
|
|
|
|
it('历史无用户条目时也保留一个回合承载正文', () => {
|
|
const turns = buildDirectChatTurns({
|
|
entries: [assistantEntry('a1', '只有回复')],
|
|
});
|
|
expect(turns).toHaveLength(1);
|
|
expect(turns[0]?.users).toEqual([]);
|
|
expect(turns[0]?.finals[0]).toMatchObject({ text: '只有回复' });
|
|
});
|
|
|
|
it('分页切片开头落在半截回合里时并进后面那个回合,不生成没有用户气泡的孤儿回合', () => {
|
|
// 更早的一屏把上一条用户条目切在外面:这三条前导条目属于上一轮,但不能自成回合。
|
|
const turns = buildDirectChatTurns({
|
|
entries: [
|
|
reasoningEntry('r-prev'),
|
|
toolEntry('t-prev'),
|
|
assistantEntry('a-prev', '上一轮的答复', 1_800_000_000_900),
|
|
userEntry('u2', 1_800_000_010_000),
|
|
assistantEntry('a2', '这一轮的答复', 1_800_000_011_000),
|
|
],
|
|
});
|
|
expect(turns.map((turn) => turn.key)).toEqual(['u2']);
|
|
expect(turns[0]?.users.map((block) => block.key)).toEqual(['u2:u2']);
|
|
expect(turns[0]?.process.map((block) => block.key)).toEqual([
|
|
'u2:r-prev',
|
|
'u2:t-prev',
|
|
'u2:a-prev',
|
|
]);
|
|
expect(turns[0]?.finals.map((block) => block.key)).toEqual(['u2:a2']);
|
|
});
|
|
|
|
it('条目转消息只保留用户 / 助手正文,并按身份去重', () => {
|
|
const messages = directChatEntriesToMessages([
|
|
userEntry('u1'),
|
|
reasoningEntry('r1'),
|
|
toolEntry('t1'),
|
|
assistantEntry('a1', '答复'),
|
|
]);
|
|
expect(messages.map((message) => message.messageId)).toEqual(['u1', 'a1']);
|
|
expect(
|
|
mergeDirectChatMessages(messages, [
|
|
{ role: 'assistant', text: '答复', messageId: 'a1' },
|
|
localNotice('只在运行期的失败说明', 'local-1'),
|
|
]).map((message) => message.messageId),
|
|
).toEqual(['u1', 'a1', 'local-1']);
|
|
});
|
|
});
|