新增 DirectProject 聊天 reducer 深模块

- directThreadChat 把运行态事件与历史切片归并成同一份聊天条目,活动回合只由 turn.started/turn.completed 判定
- item.delta 载荷补 kind,reducer 不再靠 itemId 猜条目类型
- bootstrap 原子替换运行态并保留历史窗口,历史与运行态按 callId ?? itemId 去重合并
- 新增 5 条 reducer 单测
This commit is contained in:
2026-09-16 18:05:10 +08:00
parent 29d0cbb4df
commit ae0f9376c9
3 changed files with 429 additions and 1 deletions
@@ -3013,7 +3013,12 @@ impl CodexAppServerConnection {
turn_id: turn_id.clone(),
item_id: Some(item_id.clone()),
call_id: None,
payload: serde_json::json!({ "delta": delta.clone() }),
// 事件自足:增量也要说明它是哪类条目的正文,
// 前端 reducer 不允许靠猜 itemId 的来源决定 kind。
payload: serde_json::json!({
"delta": delta.clone(),
"kind": "message",
}),
},
);
}
@@ -0,0 +1,253 @@
/**
* DirectProject 聊天 reducer:把运行态 raw event 与历史切片归并成同一份聊天条目。
*
* 事实源只有一个——项目对话历史;运行态事件只负责"当前回合"。这里不判断哪些条目
* 要显示,也不决定顺序策略之外的任何东西:顺序 = 历史文件顺序 + 运行态独有条目。
*/
import type { GameCreatorDirectToolCall } from '../../app/types';
import type {
DirectThreadRawEvent,
DirectThreadSubscriptionBootstrap,
} from './directThreadEvents';
export type DirectChatEntryKind = 'message' | 'reasoning' | 'tool';
/** 与 Rust `DirectChatEntry` 同形:运行态事件与历史切片共用的唯一形状。 */
export type DirectChatEntry = {
itemId: string;
callId?: string | null;
turnId?: string | null;
kind: DirectChatEntryKind;
role?: 'user' | 'assistant' | null;
text?: string | null;
toolCall?: GameCreatorDirectToolCall | null;
at?: number;
};
export type DirectThreadChatState = {
subscriptionId: string | null;
/** 首屏历史锚点:`subscribe` 给出的最后一个完成条目 id。 */
lastCompletedItemId: string | null;
/** 活动回合:只有出现 `turn.started` 且没有对应 `turn.completed` 才有值。 */
activeTurnId: string | null;
/** 历史切片条目,保持文件顺序。 */
history: DirectChatEntry[];
/** 运行态条目,保持到达顺序。 */
live: DirectChatEntry[];
/** 流式正文累计(itemId → text)。 */
deltaText: Record<string, string>;
};
export function emptyDirectThreadChatState(): DirectThreadChatState {
return {
subscriptionId: null,
lastCompletedItemId: null,
activeTurnId: null,
history: [],
live: [],
deltaText: {},
};
}
/** 合并身份:工具条目的 app-server itemId 与原始 item 的 callId 是同一个值。 */
export function directChatEntryIdentity(entry: DirectChatEntry): string {
return (entry.callId ?? '').trim() || entry.itemId;
}
function mergeToolCall(
left: GameCreatorDirectToolCall,
right: GameCreatorDirectToolCall,
): GameCreatorDirectToolCall {
return {
...left,
...right,
title: right.title?.trim() ? right.title : left.title,
summary: right.summary?.trim() ? right.summary : left.summary,
kind: right.kind && right.kind !== 'other' ? right.kind : left.kind,
detail: {
command: right.detail?.command ?? left.detail?.command,
output: right.detail?.output ?? left.detail?.output,
changes: right.detail?.changes?.length
? right.detail.changes
: left.detail?.changes,
},
startedAt: Math.min(
left.startedAt > 0 ? left.startedAt : right.startedAt,
right.startedAt > 0 ? right.startedAt : left.startedAt,
),
};
}
/** 后到的快照只补空字段:结果快照不能抹掉先到的命令与标题。 */
export function mergeDirectChatEntry(
existing: DirectChatEntry,
incoming: DirectChatEntry,
): DirectChatEntry {
const merged: DirectChatEntry = {
...existing,
...incoming,
itemId: existing.itemId || incoming.itemId,
kind:
incoming.kind === 'tool' || existing.kind === 'tool'
? 'tool'
: incoming.kind,
text: incoming.text ?? existing.text,
role: incoming.role ?? existing.role,
turnId: incoming.turnId ?? existing.turnId,
};
const left = existing.toolCall ?? null;
const right = incoming.toolCall ?? null;
merged.toolCall =
left && right ? mergeToolCall(left, right) : (right ?? left);
return merged;
}
function upsertLiveEntry(
state: DirectThreadChatState,
entry: DirectChatEntry,
): DirectThreadChatState {
const identity = directChatEntryIdentity(entry);
const index = state.live.findIndex(
(existing) => directChatEntryIdentity(existing) === identity,
);
if (index < 0) {
return { ...state, live: [...state.live, entry] };
}
const existing = state.live[index];
if (!existing) {
return { ...state, live: [...state.live, entry] };
}
const live = [...state.live];
live[index] = mergeDirectChatEntry(existing, entry);
return { ...state, live };
}
/** 事件载荷就是聊天条目;不是聊天条目(例如纯工具输出之外的内部 item)返回 null。 */
export function directChatEntryFromEventPayload(
payload: unknown,
): DirectChatEntry | null {
if (!payload || typeof payload !== 'object') return null;
const entry = payload as DirectChatEntry;
if (typeof entry.itemId !== 'string' || !entry.itemId.trim()) return null;
if (!entry.kind) return null;
return entry;
}
function reduceDirectThreadEvent(
state: DirectThreadChatState,
event: DirectThreadRawEvent,
): DirectThreadChatState {
switch (event.type) {
case 'turn.started':
return { ...state, activeTurnId: event.turnId };
case 'turn.completed':
return {
...state,
activeTurnId:
state.activeTurnId === event.turnId ? null : state.activeTurnId,
};
case 'item.delta': {
const itemId = event.itemId?.trim();
if (!itemId) return state;
const payload = event.payload as { delta?: unknown; kind?: unknown };
const delta = typeof payload?.delta === 'string' ? payload.delta : '';
if (!delta) return state;
const text = `${state.deltaText[itemId] ?? ''}${delta}`;
const reasoning = payload?.kind === 'reasoning';
return upsertLiveEntry(
{ ...state, deltaText: { ...state.deltaText, [itemId]: text } },
{
itemId,
kind: reasoning ? 'reasoning' : 'message',
role: reasoning ? null : 'assistant',
turnId: event.turnId,
text,
},
);
}
case 'item.started':
case 'item.completed': {
const entry = directChatEntryFromEventPayload(event.payload);
if (!entry) return state;
return upsertLiveEntry(state, {
...entry,
turnId: entry.turnId ?? event.turnId,
});
}
default:
return state;
}
}
export function reduceDirectThreadEvents(
state: DirectThreadChatState,
events: readonly DirectThreadRawEvent[],
): DirectThreadChatState {
return events.reduce(reduceDirectThreadEvent, state);
}
/** bootstrap 是运行态的唯一权威:原子替换运行态,历史窗口保留。 */
export function resolveDirectThreadBootstrap(
state: DirectThreadChatState,
bootstrap: DirectThreadSubscriptionBootstrap,
): DirectThreadChatState {
return reduceDirectThreadEvents(
{
...emptyDirectThreadChatState(),
history: state.history,
subscriptionId: bootstrap.subscriptionId,
lastCompletedItemId: bootstrap.lastCompletedItemId ?? null,
},
bootstrap.events,
);
}
/** 历史切片并入:重叠条目按身份去重,新切片在前、已有历史在后。 */
export function mergeDirectHistoryEntries(
state: DirectThreadChatState,
entries: readonly DirectChatEntry[],
): DirectThreadChatState {
const byIdentity = new Map<string, DirectChatEntry>();
const order: string[] = [];
for (const entry of [...entries, ...state.history]) {
const identity = directChatEntryIdentity(entry);
const existing = byIdentity.get(identity);
if (existing) {
byIdentity.set(identity, mergeDirectChatEntry(existing, entry));
continue;
}
byIdentity.set(identity, entry);
order.push(identity);
}
return {
...state,
history: order
.map((identity) => byIdentity.get(identity))
.filter((entry): entry is DirectChatEntry => Boolean(entry)),
};
}
/** 聊天投影输入:历史顺序 + 运行态覆盖;运行态独有条目排在最后。 */
export function selectDirectChatEntries(
state: DirectThreadChatState,
): DirectChatEntry[] {
const liveByIdentity = new Map(
state.live.map((entry) => [directChatEntryIdentity(entry), entry]),
);
const seen = new Set<string>();
const entries: DirectChatEntry[] = [];
for (const entry of state.history) {
const identity = directChatEntryIdentity(entry);
seen.add(identity);
const live = liveByIdentity.get(identity);
entries.push(live ? mergeDirectChatEntry(entry, live) : entry);
}
for (const entry of state.live) {
const identity = directChatEntryIdentity(entry);
if (seen.has(identity)) continue;
seen.add(identity);
entries.push(entry);
}
return entries;
}
@@ -0,0 +1,170 @@
import { describe, expect, it } from 'vitest';
import {
type DirectChatEntry,
directChatEntryIdentity,
emptyDirectThreadChatState,
mergeDirectHistoryEntries,
reduceDirectThreadEvents,
resolveDirectThreadBootstrap,
selectDirectChatEntries,
} from '../src/features/project-workspace/directThreadChat';
import type { DirectThreadRawEvent } from '../src/features/project-workspace/directThreadEvents';
function event(
partial: Partial<DirectThreadRawEvent> &
Pick<DirectThreadRawEvent, 'type' | 'turnId'>,
): DirectThreadRawEvent {
return { seq: 1, payload: {}, ...partial };
}
function toolEntry(
overrides: Partial<DirectChatEntry & { status?: string }> = {},
): DirectChatEntry {
return {
itemId: 'call-1',
callId: 'call-1',
turnId: 'turn-1',
kind: 'tool',
toolCall: {
schemaVersion: 'agc-tool-call.v1',
id: 'call-1',
turnId: 'turn-1',
kind: 'command',
title: '执行命令',
summary: 'ls',
status: 'running',
detail: { command: 'ls', output: undefined, changes: [] },
startedAt: 1000,
updatedAt: 1000,
},
...overrides,
};
}
describe('DirectProject 聊天 reducer', () => {
it('只有 turn.started 且未完成的回合才是活动回合', () => {
const started = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event({ type: 'turn.started', turnId: 'turn-1' }),
]);
expect(started.activeTurnId).toBe('turn-1');
const completed = reduceDirectThreadEvents(started, [
event({ type: 'turn.completed', turnId: 'turn-1' }),
]);
expect(completed.activeTurnId).toBeNull();
});
it('bootstrap 原子替换运行态,没有生命周期事件就没有活动回合', () => {
const stale = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event({ type: 'turn.started', turnId: 'turn-old' }),
]);
const bootstrapped = resolveDirectThreadBootstrap(stale, {
subscriptionId: 'sub-1',
lastCompletedItemId: 'msg-9',
events: [],
});
expect(bootstrapped.activeTurnId).toBeNull();
expect(bootstrapped.subscriptionId).toBe('sub-1');
expect(bootstrapped.lastCompletedItemId).toBe('msg-9');
});
it('增量正文按条目累计,完成快照覆盖同一段', () => {
const streamed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event({
type: 'item.delta',
turnId: 'turn-1',
itemId: 'msg-1',
payload: { delta: '你', kind: 'message' },
}),
event({
type: 'item.delta',
turnId: 'turn-1',
itemId: 'msg-1',
payload: { delta: '好', kind: 'message' },
}),
]);
expect(selectDirectChatEntries(streamed)).toHaveLength(1);
expect(selectDirectChatEntries(streamed)[0]?.text).toBe('你好');
const done = reduceDirectThreadEvents(streamed, [
event({
type: 'item.completed',
turnId: 'turn-1',
itemId: 'msg-1',
payload: {
itemId: 'msg-1',
turnId: 'turn-1',
kind: 'message',
role: 'assistant',
text: '你好,我是陶泥儿。',
},
}),
]);
const entries = selectDirectChatEntries(done);
expect(entries).toHaveLength(1);
expect(entries[0]?.text).toBe('你好,我是陶泥儿。');
});
it('工具条目 started/completed 归并成一张卡片', () => {
const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event({
type: 'item.started',
turnId: 'turn-1',
itemId: 'call-1',
payload: toolEntry(),
}),
event({
type: 'item.completed',
turnId: 'turn-1',
itemId: 'call-1',
payload: {
...toolEntry(),
toolCall: {
...toolEntry().toolCall!,
status: 'completed',
detail: { command: 'ls', output: 'assets\ngame', changes: [] },
updatedAt: 2000,
},
},
}),
]);
const entries = selectDirectChatEntries(state);
expect(entries).toHaveLength(1);
expect(entries[0]?.toolCall?.status).toBe('completed');
expect(entries[0]?.toolCall?.detail.output).toBe('assets\ngame');
expect(entries[0]?.toolCall?.detail.command).toBe('ls');
});
it('历史条目与运行态按 callId 合并,重复条目只出现一次', () => {
const historical = mergeDirectHistoryEntries(emptyDirectThreadChatState(), [
toolEntry(),
{
itemId: 'msg-user',
kind: 'message',
role: 'user',
text: '做一个拼图游戏',
},
]);
const live = reduceDirectThreadEvents(historical, [
event({
type: 'item.completed',
turnId: 'turn-1',
itemId: 'call-1',
payload: {
...toolEntry(),
itemId: 'call-1',
toolCall: {
...toolEntry().toolCall!,
status: 'completed',
detail: { command: 'ls', output: 'assets', changes: [] },
},
},
}),
]);
const entries = selectDirectChatEntries(live);
expect(entries).toHaveLength(2);
expect(entries[0]?.toolCall?.status).toBe('completed');
expect(directChatEntryIdentity(entries[0]!)).toBe('call-1');
});
});