新增 DirectProject 聊天 reducer 深模块
- directThreadChat 把运行态事件与历史切片归并成同一份聊天条目,活动回合只由 turn.started/turn.completed 判定 - item.delta 载荷补 kind,reducer 不再靠 itemId 猜条目类型 - bootstrap 原子替换运行态并保留历史窗口,历史与运行态按 callId ?? itemId 去重合并 - 新增 5 条 reducer 单测
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user