refactor(webui): extract event projection helpers

This commit is contained in:
chengyongru
2026-08-10 16:24:07 +08:00
committed by chengyongru
parent 5d733b1c7c
commit 05d73803e7
5 changed files with 1126 additions and 351 deletions
@@ -0,0 +1,600 @@
{
"cases": [
{
"name": "reasoning_then_streamed_answer",
"chat_id": "fixture-reasoning",
"initial_messages": [
{
"id": "fixture-user-reasoning",
"role": "user",
"content": "Explain event projection.",
"turnId": "turn-reasoning",
"turnPhase": "user",
"turnSeq": 1,
"createdAt": 1700000000000
}
],
"live_events": [
{
"event": "reasoning_delta",
"chat_id": "fixture-reasoning",
"text": "Compare ",
"turn_id": "turn-reasoning",
"turn_phase": "reasoning",
"turn_seq": 2
},
{
"event": "reasoning_delta",
"chat_id": "fixture-reasoning",
"text": "state.",
"turn_id": "turn-reasoning",
"turn_phase": "reasoning",
"turn_seq": 3
},
{
"event": "reasoning_end",
"chat_id": "fixture-reasoning",
"turn_id": "turn-reasoning",
"turn_phase": "reasoning",
"turn_seq": 4
},
{
"event": "delta",
"chat_id": "fixture-reasoning",
"text": "Use one ",
"turn_id": "turn-reasoning",
"turn_phase": "answer",
"turn_seq": 5
},
{
"event": "delta",
"chat_id": "fixture-reasoning",
"text": "fold.",
"turn_id": "turn-reasoning",
"turn_phase": "answer",
"turn_seq": 6
},
{
"event": "stream_end",
"chat_id": "fixture-reasoning",
"turn_id": "turn-reasoning",
"turn_phase": "answer",
"turn_seq": 7
},
{
"event": "turn_end",
"chat_id": "fixture-reasoning",
"latency_ms": 42,
"turn_id": "turn-reasoning",
"turn_phase": "complete",
"turn_seq": 8
}
],
"transcript": [
{
"event": "user",
"chat_id": "fixture-reasoning",
"text": "Explain event projection.",
"turn_id": "turn-reasoning",
"turn_phase": "user",
"turn_seq": 1,
"created_at_ms": 1700000000000
},
{
"event": "reasoning_delta",
"chat_id": "fixture-reasoning",
"text": "Compare ",
"turn_id": "turn-reasoning",
"turn_phase": "reasoning",
"turn_seq": 2
},
{
"event": "reasoning_delta",
"chat_id": "fixture-reasoning",
"text": "state.",
"turn_id": "turn-reasoning",
"turn_phase": "reasoning",
"turn_seq": 3
},
{
"event": "reasoning_end",
"chat_id": "fixture-reasoning",
"turn_id": "turn-reasoning",
"turn_phase": "reasoning",
"turn_seq": 4
},
{
"event": "delta",
"chat_id": "fixture-reasoning",
"text": "Use one ",
"turn_id": "turn-reasoning",
"turn_phase": "answer",
"turn_seq": 5
},
{
"event": "delta",
"chat_id": "fixture-reasoning",
"text": "fold.",
"turn_id": "turn-reasoning",
"turn_phase": "answer",
"turn_seq": 6
},
{
"event": "stream_end",
"chat_id": "fixture-reasoning",
"turn_id": "turn-reasoning",
"turn_phase": "answer",
"turn_seq": 7
},
{
"event": "turn_end",
"chat_id": "fixture-reasoning",
"latency_ms": 42,
"turn_id": "turn-reasoning",
"turn_phase": "complete",
"turn_seq": 8
}
],
"expected": [
{
"role": "user",
"content": "Explain event projection.",
"turnId": "turn-reasoning",
"turnPhase": "user",
"turnSeq": 1
},
{
"role": "assistant",
"content": "Use one fold.",
"reasoning": "Compare state.",
"activitySegmentId": "segment-1",
"latencyMs": 42,
"turnId": "turn-reasoning",
"turnPhase": "answer",
"turnSeq": 6
}
]
},
{
"name": "length_recovery_merges_answer_segments",
"chat_id": "fixture-length",
"initial_messages": [
{
"id": "fixture-user-length",
"role": "user",
"content": "Continue after the limit.",
"turnId": "turn-length",
"turnPhase": "user",
"turnSeq": 1,
"createdAt": 1700000001000
}
],
"live_events": [
{
"event": "delta",
"chat_id": "fixture-length",
"text": "first ",
"turn_id": "turn-length",
"turn_phase": "answer",
"turn_seq": 2
},
{
"event": "stream_end",
"chat_id": "fixture-length",
"text": "first ",
"resuming": true,
"merge_next": true,
"turn_id": "turn-length",
"turn_phase": "answer",
"turn_seq": 3
},
{
"event": "delta",
"chat_id": "fixture-length",
"text": "second",
"turn_id": "turn-length",
"turn_phase": "answer",
"turn_seq": 4
},
{
"event": "stream_end",
"chat_id": "fixture-length",
"turn_id": "turn-length",
"turn_phase": "answer",
"turn_seq": 5
},
{
"event": "turn_end",
"chat_id": "fixture-length",
"latency_ms": 17,
"turn_id": "turn-length",
"turn_phase": "complete",
"turn_seq": 6
}
],
"transcript": [
{
"event": "user",
"chat_id": "fixture-length",
"text": "Continue after the limit.",
"turn_id": "turn-length",
"turn_phase": "user",
"turn_seq": 1,
"created_at_ms": 1700000001000
},
{
"event": "delta",
"chat_id": "fixture-length",
"text": "first ",
"turn_id": "turn-length",
"turn_phase": "answer",
"turn_seq": 2
},
{
"event": "stream_end",
"chat_id": "fixture-length",
"text": "first ",
"resuming": true,
"merge_next": true,
"turn_id": "turn-length",
"turn_phase": "answer",
"turn_seq": 3
},
{
"event": "delta",
"chat_id": "fixture-length",
"text": "second",
"turn_id": "turn-length",
"turn_phase": "answer",
"turn_seq": 4
},
{
"event": "stream_end",
"chat_id": "fixture-length",
"turn_id": "turn-length",
"turn_phase": "answer",
"turn_seq": 5
},
{
"event": "turn_end",
"chat_id": "fixture-length",
"latency_ms": 17,
"turn_id": "turn-length",
"turn_phase": "complete",
"turn_seq": 6
}
],
"expected": [
{
"role": "user",
"content": "Continue after the limit.",
"turnId": "turn-length",
"turnPhase": "user",
"turnSeq": 1
},
{
"role": "assistant",
"content": "first second",
"latencyMs": 17,
"turnId": "turn-length",
"turnPhase": "answer",
"turnSeq": 4
}
]
},
{
"name": "tool_activity_then_complete_answer",
"chat_id": "fixture-activity",
"initial_messages": [
{
"id": "fixture-user-activity",
"role": "user",
"content": "Inspect the projection code.",
"turnId": "turn-activity",
"turnPhase": "user",
"turnSeq": 1,
"createdAt": 1700000002000
}
],
"live_events": [
{
"event": "message",
"chat_id": "fixture-activity",
"kind": "tool_hint",
"text": "search projection helpers",
"turn_id": "turn-activity",
"turn_phase": "activity",
"turn_seq": 2
},
{
"event": "reasoning_delta",
"chat_id": "fixture-activity",
"text": "Review results.",
"turn_id": "turn-activity",
"turn_phase": "reasoning",
"turn_seq": 4
},
{
"event": "reasoning_end",
"chat_id": "fixture-activity",
"turn_id": "turn-activity",
"turn_phase": "reasoning",
"turn_seq": 5
},
{
"event": "message",
"chat_id": "fixture-activity",
"text": "Projection matches.",
"turn_id": "turn-activity",
"turn_phase": "answer",
"turn_seq": 6
},
{
"event": "turn_end",
"chat_id": "fixture-activity",
"latency_ms": 8,
"turn_id": "turn-activity",
"turn_phase": "complete",
"turn_seq": 7
}
],
"transcript": [
{
"event": "user",
"chat_id": "fixture-activity",
"text": "Inspect the projection code.",
"turn_id": "turn-activity",
"turn_phase": "user",
"turn_seq": 1,
"created_at_ms": 1700000002000
},
{
"event": "message",
"chat_id": "fixture-activity",
"kind": "tool_hint",
"text": "search projection helpers",
"turn_id": "turn-activity",
"turn_phase": "activity",
"turn_seq": 2
},
{
"event": "reasoning_delta",
"chat_id": "fixture-activity",
"text": "Review results.",
"turn_id": "turn-activity",
"turn_phase": "reasoning",
"turn_seq": 4
},
{
"event": "reasoning_end",
"chat_id": "fixture-activity",
"turn_id": "turn-activity",
"turn_phase": "reasoning",
"turn_seq": 5
},
{
"event": "message",
"chat_id": "fixture-activity",
"text": "Projection matches.",
"turn_id": "turn-activity",
"turn_phase": "answer",
"turn_seq": 6
},
{
"event": "turn_end",
"chat_id": "fixture-activity",
"latency_ms": 8,
"turn_id": "turn-activity",
"turn_phase": "complete",
"turn_seq": 7
}
],
"expected": [
{
"role": "user",
"content": "Inspect the projection code.",
"turnId": "turn-activity",
"turnPhase": "user",
"turnSeq": 1
},
{
"role": "tool",
"content": "search projection helpers",
"kind": "trace",
"traces": [
"search projection helpers"
],
"activitySegmentId": "segment-1",
"turnId": "turn-activity",
"turnPhase": "activity",
"turnSeq": 2
},
{
"role": "assistant",
"content": "Projection matches.",
"reasoning": "Review results.",
"activitySegmentId": "segment-1",
"latencyMs": 8,
"turnId": "turn-activity",
"turnPhase": "answer",
"turnSeq": 6
}
]
},
{
"name": "file_edit_lifecycle_merges_by_call_and_path",
"chat_id": "fixture-file-edit",
"initial_messages": [
{
"id": "fixture-user-file-edit",
"role": "user",
"content": "Update app.py.",
"turnId": "turn-file-edit",
"turnPhase": "user",
"turnSeq": 1,
"createdAt": 1700000003000
}
],
"live_events": [
{
"event": "file_edit",
"chat_id": "fixture-file-edit",
"edits": [
{
"version": 1,
"call_id": "call-edit",
"tool": "edit_file",
"path": "app.py",
"phase": "start",
"added": 0,
"deleted": 0,
"status": "editing"
}
],
"turn_id": "turn-file-edit",
"turn_phase": "activity",
"turn_seq": 2
},
{
"event": "file_edit",
"chat_id": "fixture-file-edit",
"edits": [
{
"version": 1,
"call_id": "call-edit",
"tool": "edit_file",
"path": "app.py",
"phase": "end",
"added": 3,
"deleted": 1,
"status": "done"
}
],
"turn_id": "turn-file-edit",
"turn_phase": "activity",
"turn_seq": 3
},
{
"event": "message",
"chat_id": "fixture-file-edit",
"text": "Updated app.py.",
"turn_id": "turn-file-edit",
"turn_phase": "answer",
"turn_seq": 4
},
{
"event": "turn_end",
"chat_id": "fixture-file-edit",
"latency_ms": 9,
"turn_id": "turn-file-edit",
"turn_phase": "complete",
"turn_seq": 5
}
],
"transcript": [
{
"event": "user",
"chat_id": "fixture-file-edit",
"text": "Update app.py.",
"turn_id": "turn-file-edit",
"turn_phase": "user",
"turn_seq": 1,
"created_at_ms": 1700000003000
},
{
"event": "file_edit",
"chat_id": "fixture-file-edit",
"edits": [
{
"version": 1,
"call_id": "call-edit",
"tool": "edit_file",
"path": "app.py",
"phase": "start",
"added": 0,
"deleted": 0,
"status": "editing"
}
],
"turn_id": "turn-file-edit",
"turn_phase": "activity",
"turn_seq": 2
},
{
"event": "file_edit",
"chat_id": "fixture-file-edit",
"edits": [
{
"version": 1,
"call_id": "call-edit",
"tool": "edit_file",
"path": "app.py",
"phase": "end",
"added": 3,
"deleted": 1,
"status": "done"
}
],
"turn_id": "turn-file-edit",
"turn_phase": "activity",
"turn_seq": 3
},
{
"event": "message",
"chat_id": "fixture-file-edit",
"text": "Updated app.py.",
"turn_id": "turn-file-edit",
"turn_phase": "answer",
"turn_seq": 4
},
{
"event": "turn_end",
"chat_id": "fixture-file-edit",
"latency_ms": 9,
"turn_id": "turn-file-edit",
"turn_phase": "complete",
"turn_seq": 5
}
],
"expected": [
{
"role": "user",
"content": "Update app.py.",
"turnId": "turn-file-edit",
"turnPhase": "user",
"turnSeq": 1
},
{
"role": "tool",
"content": "",
"kind": "trace",
"traces": [],
"fileEdits": [
{
"version": 1,
"call_id": "call-edit",
"tool": "edit_file",
"path": "app.py",
"phase": "end",
"added": 3,
"deleted": 1,
"status": "done"
}
],
"activitySegmentId": "segment-1",
"turnId": "turn-file-edit",
"turnPhase": "activity",
"turnSeq": 3
},
{
"role": "assistant",
"content": "Updated app.py.",
"latencyMs": 9,
"turnId": "turn-file-edit",
"turnPhase": "answer",
"turnSeq": 4
}
]
}
]
}
+77 -2
View File
@@ -4,10 +4,67 @@ import { describe, expect, it, vi } from "vitest";
import { useNanobotStream } from "@/hooks/useNanobotStream";
import type { StreamError } from "@/lib/nanobot-client";
import type { ConnectionStatus, InboundEvent, GoalStateWsPayload } from "@/lib/types";
import type {
ConnectionStatus,
GoalStateWsPayload,
InboundEvent,
UIMessage,
} from "@/lib/types";
import { ClientProvider } from "@/providers/ClientProvider";
import projectionFixture from "./fixtures/live-replay-event-projection.json";
const EMPTY_MESSAGES: import("@/lib/types").UIMessage[] = [];
const EMPTY_MESSAGES: UIMessage[] = [];
interface ProjectionFixtureCase {
name: string;
chat_id: string;
initial_messages: UIMessage[];
live_events: InboundEvent[];
expected: Array<Record<string, unknown>>;
}
const PROJECTION_FIXTURE_CASES = (
projectionFixture as unknown as { cases: ProjectionFixtureCase[] }
).cases;
const SEMANTIC_MESSAGE_FIELDS = [
"role",
"content",
"kind",
"traces",
"toolEvents",
"fileEdits",
"images",
"media",
"cliApps",
"mcpPresets",
"sessionMentions",
"reasoning",
"latencyMs",
"source",
"turnId",
"turnPhase",
"turnSeq",
] as const satisfies ReadonlyArray<keyof UIMessage>;
function normalizeProjection(messages: UIMessage[]): Array<Record<string, unknown>> {
const segmentAliases = new Map<string, string>();
return messages.map((message) => {
const row: Record<string, unknown> = {};
for (const field of SEMANTIC_MESSAGE_FIELDS) {
const value = message[field];
if (value !== undefined && value !== null) row[field] = value;
}
if (message.activitySegmentId) {
let alias = segmentAliases.get(message.activitySegmentId);
if (!alias) {
alias = `segment-${segmentAliases.size + 1}`;
segmentAliases.set(message.activitySegmentId, alias);
}
row.activitySegmentId = alias;
}
return row;
});
}
function fakeClient() {
const handlers = new Map<string, Set<(ev: InboundEvent) => void>>();
@@ -2841,3 +2898,21 @@ describe("useNanobotStream", () => {
});
});
describe("live/replay projection before canonical-event revision migration", () => {
it.each(PROJECTION_FIXTURE_CASES)("matches the shared $name fixture", (fixtureCase) => {
const fake = fakeClient();
const { result } = renderHook(
() => useNanobotStream(fixtureCase.chat_id, fixtureCase.initial_messages),
{ wrapper: wrap(fake.client) },
);
for (const event of fixtureCase.live_events) {
act(() => {
fake.emit(fixtureCase.chat_id, event);
});
}
expect(normalizeProjection(result.current.messages)).toEqual(fixtureCase.expected);
});
});