niceeval 0.7.1 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/report/components.d.ts +4 -5
  2. package/dist/report/components.js +7 -7
  3. package/dist/report/compute.d.ts +4 -4
  4. package/dist/report/compute.js +8 -12
  5. package/dist/report/index.d.ts +2 -2
  6. package/dist/report/report.d.ts +23 -1
  7. package/dist/report/report.js +82 -5
  8. package/dist/report/types.d.ts +0 -10
  9. package/dist/results/types.d.ts +0 -11
  10. package/docs-site/zh/how-to/custom-reports.mdx +3 -3
  11. package/docs-site/zh/how-to/publish-report.mdx +43 -9
  12. package/docs-site/zh/how-to/viewing-results.mdx +4 -4
  13. package/docs-site/zh/reference/cli.mdx +2 -3
  14. package/docs-site/zh/reference/report-components.mdx +2 -2
  15. package/docs-site/zh/reference/results-data.mdx +2 -4
  16. package/docs-site/zh/troubleshooting/debugging.mdx +2 -2
  17. package/package.json +7 -6
  18. package/src/cli.ts +1 -5
  19. package/src/context/context.ts +11 -5
  20. package/src/report/components.tsx +13 -15
  21. package/src/report/compute.ts +8 -20
  22. package/src/report/index.ts +1 -1
  23. package/src/report/report.test.ts +2 -20
  24. package/src/report/report.ts +128 -6
  25. package/src/report/shell-head.test.ts +102 -0
  26. package/src/report/types.ts +0 -11
  27. package/src/results/copy.ts +15 -78
  28. package/src/results/publish.ts +4 -146
  29. package/src/results/results.test.ts +8 -8
  30. package/src/results/types.ts +0 -7
  31. package/src/show/report-host.ts +13 -0
  32. package/src/view/app/components/CodeView.test.tsx +142 -0
  33. package/src/view/app/components/CodeView.tsx +15 -1
  34. package/src/view/app/components/Transcript.tsx +28 -1
  35. package/src/view/app/i18n.ts +6 -0
  36. package/src/view/app/lib/guards.test.ts +108 -0
  37. package/src/view/app/lib/guards.ts +13 -3
  38. package/src/view/app/lib/transcript-data.tsx +14 -0
  39. package/src/view/app/types.ts +17 -1
  40. package/src/view/artifact-serving.test.ts +1 -1
  41. package/src/view/client-dist/app.css +1 -1
  42. package/src/view/client-dist/app.js +20 -20
  43. package/src/view/data.ts +54 -14
  44. package/src/view/index.ts +18 -58
  45. package/src/view/server.ts +49 -144
  46. package/src/view/site-head.test.ts +177 -0
  47. package/src/view/site-parity.test.ts +117 -0
  48. package/src/view/site.ts +209 -0
  49. package/src/view/styles.css +10 -0
  50. package/src/view/view-report.test.ts +6 -6
@@ -0,0 +1,142 @@
1
+ // @vitest-environment jsdom
2
+ // cases: docs/engineering/unit-tests/reports/cases.md
3
+ // 「Attempt 详情(view 证据室)」分区——
4
+ // 源码视图是判定与断言的单点:带 loc 的 send 行可点开查看该轮回复(assistant 文本 / thinking),
5
+ // 失败断言行默认展开、展开面直接给 matcher 与 expected / received 的值。
6
+ // 这组测试在 jsdom 里跑真实点击,守住「统一站点管线之后弹窗证据链仍然可用」的行为面
7
+ // (数据侧的字节奇偶由 src/view/site-parity.test.ts 守护,两头合起来 = 线上站点可用)。
8
+
9
+ import { act } from "react";
10
+ import { createRoot, type Root } from "react-dom/client";
11
+ import { afterEach, beforeAll, describe, expect, it } from "vitest";
12
+ import { CodeView } from "./CodeView.tsx";
13
+ import { makeTranslator } from "../i18n.ts";
14
+ import type { Assertion, CodeSource, TranscriptEvent } from "../types.ts";
15
+
16
+ declare global {
17
+ // eslint-disable-next-line no-var
18
+ var IS_REACT_ACT_ENVIRONMENT: boolean;
19
+ }
20
+
21
+ beforeAll(() => {
22
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
23
+ });
24
+
25
+ const t = makeTranslator("en");
26
+
27
+ const FILE = "evals/a.eval.ts";
28
+ const SOURCE: CodeSource = {
29
+ path: FILE,
30
+ content: [
31
+ "import { defineEval } from \"niceeval\";",
32
+ "",
33
+ "await t.send(\"do the task\");",
34
+ "",
35
+ "t.check(source, includes(/use cache/));",
36
+ ].join("\n"),
37
+ };
38
+
39
+ const EVENTS: TranscriptEvent[] = [
40
+ { type: "message", role: "user", text: "do the task", loc: { file: FILE, line: 3 } },
41
+ { type: "thinking", text: "THINKING_MARKER" },
42
+ { type: "message", role: "assistant", text: "REPLY_TEXT_MARKER" },
43
+ ];
44
+
45
+ const FAILED_ASSERT: Assertion = {
46
+ name: "Catalog reads use cache",
47
+ detail: "includes(/use cache/)",
48
+ severity: "gate",
49
+ outcome: "failed",
50
+ score: 0,
51
+ expected: "/use cache/",
52
+ received: "RECEIVED_VALUE_MARKER",
53
+ loc: { file: FILE, line: 5 },
54
+ };
55
+
56
+ let container: HTMLElement | undefined;
57
+ let root: Root | undefined;
58
+
59
+ function render(ui: React.ReactElement): HTMLElement {
60
+ container = document.createElement("div");
61
+ document.body.appendChild(container);
62
+ root = createRoot(container);
63
+ act(() => root!.render(ui));
64
+ return container;
65
+ }
66
+
67
+ afterEach(() => {
68
+ if (root) act(() => root!.unmount());
69
+ container?.remove();
70
+ root = undefined;
71
+ container = undefined;
72
+ });
73
+
74
+ function click(el: Element): void {
75
+ act(() => {
76
+ el.dispatchEvent(new MouseEvent("click", { bubbles: true }));
77
+ });
78
+ }
79
+
80
+ /** 按行号取源码行的 DOM 节点(.code-line 里第一个 .ln 是行号)。 */
81
+ function lineEl(host: HTMLElement, n: number): Element {
82
+ const row = [...host.querySelectorAll(".code-line")].find((el) => el.querySelector(".ln")?.textContent === String(n));
83
+ expect(row, `source line ${n}`).toBeTruthy();
84
+ return row!;
85
+ }
86
+
87
+ describe("CodeView · send 行的回复展开", () => {
88
+ it("带 loc 的 send 行可点开:回复面板显示 assistant 文本与 thinking;再点收起", () => {
89
+ const host = render(<CodeView sources={[SOURCE]} events={EVENTS} assertions={[]} t={t} />);
90
+
91
+ const sendLine = lineEl(host, 3);
92
+ expect(sendLine.className).toContain("line-send");
93
+ // 初始未展开:回复不可见。
94
+ expect(host.textContent).not.toContain("REPLY_TEXT_MARKER");
95
+
96
+ click(sendLine);
97
+ expect(host.querySelector(".reply-panel")).toBeTruthy();
98
+ expect(host.textContent).toContain("REPLY_TEXT_MARKER");
99
+ expect(host.textContent).toContain("THINKING_MARKER");
100
+
101
+ click(lineEl(host, 3));
102
+ expect(host.textContent).not.toContain("REPLY_TEXT_MARKER");
103
+ });
104
+
105
+ it("send 轮没有任何回复事件时,展开面如实显示「无回复」而不是空白", () => {
106
+ const onlySend: TranscriptEvent[] = [EVENTS[0]!];
107
+ const host = render(<CodeView sources={[SOURCE]} events={onlySend} assertions={[]} t={t} />);
108
+ click(lineEl(host, 3));
109
+ expect(host.querySelector(".reply-empty")?.textContent).toBe(t("code.noReply"));
110
+ });
111
+ });
112
+
113
+ describe("CodeView · 断言行的明细展开", () => {
114
+ it("第一条失败断言默认展开:matcher 与 expected / received 的值直接可见,点行可收起", () => {
115
+ const host = render(<CodeView sources={[SOURCE]} events={EVENTS} assertions={[FAILED_ASSERT]} t={t} />);
116
+
117
+ const assertLine = lineEl(host, 5);
118
+ expect(assertLine.className).toContain("line-fail");
119
+ // 默认展开(第一条 failed):不点任何东西就能看到为什么失败。
120
+ expect(host.textContent).toContain("includes(/use cache/)");
121
+ expect(host.textContent).toContain("/use cache/");
122
+ expect(host.textContent).toContain("RECEIVED_VALUE_MARKER");
123
+
124
+ click(assertLine);
125
+ expect(host.textContent).not.toContain("RECEIVED_VALUE_MARKER");
126
+ });
127
+
128
+ it("passed 断言行不默认展开,点开后显示明细", () => {
129
+ const passed: Assertion = { ...FAILED_ASSERT, outcome: "passed", score: 1, loc: { file: FILE, line: 5 } };
130
+ delete (passed as { expected?: string }).expected;
131
+ delete (passed as { received?: string }).received;
132
+ const host = render(<CodeView sources={[SOURCE]} events={EVENTS} assertions={[passed]} t={t} />);
133
+
134
+ const assertLine = lineEl(host, 5);
135
+ expect(assertLine.className).toContain("line-pass");
136
+ expect(host.querySelector(".line-detail")).toBeNull();
137
+
138
+ click(assertLine);
139
+ expect(host.querySelector(".line-detail")).toBeTruthy();
140
+ expect(host.textContent).toContain("includes(/use cache/)");
141
+ });
142
+ });
@@ -4,7 +4,7 @@ import type { T } from "../shared.ts";
4
4
  import type { Assertion, CodeSource, SourceTurn, TranscriptEvent } from "../types.ts";
5
5
  import { highlightTs, indexAsserts, indexTurns, locKey } from "../lib/transcript-data.tsx";
6
6
  import { formatScore } from "../lib/format.ts";
7
- import { InputBlock, ToolBlock, Transcript } from "./Transcript.tsx";
7
+ import { InputBlock, RawEventBlock, ToolBlock, Transcript } from "./Transcript.tsx";
8
8
 
9
9
  /** soft 断言没过阈值不影响 verdict,颜色上跟 gate 失败(红)区分开,用 warn(黄);
10
10
  * unavailable 用独立第三态(非红非绿)。 */
@@ -209,6 +209,13 @@ export function ReplyPanel({ turn, t }: { turn: SourceTurn; t: T }) {
209
209
  <div className="reply-text">{r.text}</div>
210
210
  </div>
211
211
  );
212
+ if (r.kind === "user")
213
+ return (
214
+ <div key={j} className="reply-user">
215
+ <span className="reply-role">{t("transcript.user")}</span>
216
+ <div className="reply-text">{r.text}</div>
217
+ </div>
218
+ );
212
219
  if (r.kind === "thinking")
213
220
  return (
214
221
  <details key={j} className="reply-think">
@@ -218,6 +225,13 @@ export function ReplyPanel({ turn, t }: { turn: SourceTurn; t: T }) {
218
225
  );
219
226
  if (r.kind === "error")
220
227
  return <div key={j} className="reply-err">! {r.text}</div>;
228
+ if (r.kind === "skill")
229
+ return (
230
+ <div key={j} className="reply-skill">
231
+ <span className="reply-role">{t("transcript.skillLoaded")}</span> {r.skill}
232
+ </div>
233
+ );
234
+ if (r.kind === "raw") return <RawEventBlock key={j} raw={r.raw} t={t} />;
221
235
  if (r.kind === "tool")
222
236
  // 和 Transcript 同一个组件:摘要行显示工具名(入参)→ 出参预览,展开看完整出入参。
223
237
  return <ToolBlock key={j} call={r.ev} result={r.result} t={t} />;
@@ -1,5 +1,5 @@
1
1
  import type { T, ToolBlockCall } from "../shared.ts";
2
- import type { ToolResultEvent, TranscriptEvent } from "../types.ts";
2
+ import type { ObjectRecord, ToolResultEvent, TranscriptEvent } from "../types.ts";
3
3
  import { TOOL_VERB, resultBody, toolPrimaryArg } from "../lib/transcript-data.tsx";
4
4
  import { prettyJson, previewText, truncate } from "../lib/format.ts";
5
5
 
@@ -42,6 +42,15 @@ export function Transcript({ events, t }: { events: TranscriptEvent[]; t: T }) {
42
42
  );
43
43
  case "input.requested":
44
44
  return <InputBlock event={event} t={t} key={index} />;
45
+ case "skill.loaded":
46
+ return (
47
+ <div className="ts-skill" key={index}>
48
+ <span className="ts-role">{t("transcript.skillLoaded")}</span>
49
+ <div className="ts-text">{event.skill}</div>
50
+ </div>
51
+ );
52
+ case "view.raw":
53
+ return <RawEventBlock raw={event.raw} t={t} key={index} />;
45
54
  case "compaction":
46
55
  return (
47
56
  <div className="ts-compaction" key={index}>
@@ -62,6 +71,24 @@ export function Transcript({ events, t }: { events: TranscriptEvent[]; t: T }) {
62
71
  );
63
72
  }
64
73
 
74
+ /** 未识别事件的原样展示:摘要行带原始 type,展开是完整 JSON——不静默丢,方便发现新词汇后续补一等呈现。 */
75
+ export function RawEventBlock({ raw, t }: { raw: ObjectRecord; t: T }) {
76
+ const type = typeof raw.type === "string" ? raw.type : "?";
77
+ const body = prettyJson(raw);
78
+ return (
79
+ <details className="ts-tool-d ts-raw">
80
+ <summary className="ts-row">
81
+ <span className="ts-dot pending" />
82
+ <span className="ts-tool">{type}</span>
83
+ <span className="ts-preview">{t("transcript.rawEvent")}</span>
84
+ </summary>
85
+ <div className="ts-body">
86
+ <pre className="attr-pre">{truncate(body, 8000)}</pre>
87
+ </div>
88
+ </details>
89
+ );
90
+ }
91
+
65
92
  export function MessageBlock({ event, t }: { event: Extract<TranscriptEvent, { type: "message" }>; t: T }) {
66
93
  const who = event.role === "assistant" ? "assistant" : "user";
67
94
  return (
@@ -88,6 +88,8 @@ export type MessageKey =
88
88
  | "transcript.inputRequested"
89
89
  | "transcript.awaitingInput"
90
90
  | "transcript.contextCompacted"
91
+ | "transcript.skillLoaded"
92
+ | "transcript.rawEvent"
91
93
  | "transcript.running"
92
94
  | "transcript.input"
93
95
  | "transcript.output"
@@ -218,6 +220,8 @@ const dictionaries: Record<Locale, Dictionary> = {
218
220
  "transcript.inputRequested": "input requested",
219
221
  "transcript.awaitingInput": "(awaiting input)",
220
222
  "transcript.contextCompacted": "context compacted",
223
+ "transcript.skillLoaded": "skill loaded",
224
+ "transcript.rawEvent": "unrecognized event, shown as-is",
221
225
  "transcript.running": "running...",
222
226
  "transcript.input": "input",
223
227
  "transcript.output": "output",
@@ -343,6 +347,8 @@ const dictionaries: Record<Locale, Dictionary> = {
343
347
  "transcript.inputRequested": "请求输入",
344
348
  "transcript.awaitingInput": "(等待输入)",
345
349
  "transcript.contextCompacted": "上下文已压缩",
350
+ "transcript.skillLoaded": "已加载 Skill",
351
+ "transcript.rawEvent": "未识别事件,原样展示",
346
352
  "transcript.running": "运行中...",
347
353
  "transcript.input": "输入",
348
354
  "transcript.output": "输出",
@@ -0,0 +1,108 @@
1
+ // cases: docs/engineering/unit-tests/reports/cases.md
2
+ // 「Attempt 详情」:事件流按条目校验、按条目容错;skill.loaded 是一等回复条目。
3
+ // bug: memory/view-unknown-event-type-drops-whole-transcript.md
4
+ import { describe, expect, it } from "vitest";
5
+ import { asEvents } from "./guards.ts";
6
+ import { indexTurns, locKey } from "./transcript-data.tsx";
7
+ import type { TranscriptEvent } from "../types.ts";
8
+
9
+ const send: TranscriptEvent = {
10
+ type: "message",
11
+ role: "user",
12
+ text: "Migrate every route",
13
+ loc: { file: "evals/m.eval.ts", line: 37, column: 8 },
14
+ };
15
+
16
+ describe("asEvents 按条目容错", () => {
17
+ it("含 skill.loaded 的 events 数组不被判空,全部事件保留", () => {
18
+ const raw = [
19
+ send,
20
+ { type: "skill.loaded", skill: "pdf-export", callId: "c1" },
21
+ { type: "message", role: "assistant", text: "done" },
22
+ ];
23
+ const events = asEvents(raw);
24
+ expect(events).toHaveLength(3);
25
+ });
26
+
27
+ it("未知事件类型包成 view.raw 原样保留,其余事件照常", () => {
28
+ const unknown = { type: "future.event", payload: 1 };
29
+ const events = asEvents([send, unknown, { type: "thinking", text: "hm" }]);
30
+ expect(events?.map((e) => e.type)).toEqual(["message", "view.raw", "thinking"]);
31
+ expect(events?.[1]).toEqual({ type: "view.raw", raw: unknown });
32
+ });
33
+
34
+ it("形状不合的已知类型同样包成 view.raw,不静默丢", () => {
35
+ const malformed = { type: "message", role: "assistant" }; // 缺 text
36
+ const events = asEvents([malformed]);
37
+ expect(events).toEqual([{ type: "view.raw", raw: malformed }]);
38
+ });
39
+
40
+ it("非对象条目丢弃;非数组载荷整体拒绝", () => {
41
+ expect(asEvents(["junk", 42, send])).toHaveLength(1);
42
+ expect(asEvents({ events: [] })).toBeNull();
43
+ expect(asEvents("nope")).toBeNull();
44
+ });
45
+ });
46
+
47
+ describe("skill.loaded 聚合进 send 的回复", () => {
48
+ it("indexTurns 聚出 kind: skill 回复并保留 Skill 名", () => {
49
+ const turns = indexTurns([
50
+ send,
51
+ { type: "skill.loaded", skill: "pdf-export", callId: "c1" },
52
+ { type: "message", role: "assistant", text: "done" },
53
+ ]);
54
+ const turn = turns.byKey.get(locKey("evals/m.eval.ts", 37));
55
+ expect(turn?.replies).toEqual([
56
+ { kind: "skill", skill: "pdf-export" },
57
+ { kind: "text", text: "done" },
58
+ ]);
59
+ });
60
+
61
+ it("view.raw 条目聚成 kind: raw 回复,原始载荷不丢", () => {
62
+ const turns = indexTurns([send, { type: "view.raw", raw: { type: "future.event", payload: 1 } }]);
63
+ const turn = turns.byKey.get(locKey("evals/m.eval.ts", 37));
64
+ expect(turn?.replies).toEqual([{ kind: "raw", raw: { type: "future.event", payload: 1 } }]);
65
+ });
66
+ });
67
+
68
+ describe("轮归属按 loc 判定,无 loc 的 user 消息不开新轮", () => {
69
+ it("send 后紧跟的同文本无 loc 回显被吃掉,回复仍全部聚到 send 行", () => {
70
+ const turns = indexTurns([
71
+ send,
72
+ { type: "message", role: "user", text: send.text },
73
+ { type: "thinking", text: "plan" },
74
+ { type: "message", role: "assistant", text: "done" },
75
+ ]);
76
+ const turn = turns.byKey.get(locKey("evals/m.eval.ts", 37));
77
+ expect(turn?.replies).toEqual([
78
+ { kind: "thinking", text: "plan" },
79
+ { kind: "text", text: "done" },
80
+ ]);
81
+ expect(turns.noloc).toHaveLength(0);
82
+ });
83
+
84
+ it("轮中段的 stop-hook 反馈成为 kind: user 回复,其后的 assistant 回复不脱轮", () => {
85
+ const turns = indexTurns([
86
+ send,
87
+ { type: "message", role: "user", text: send.text },
88
+ { type: "message", role: "assistant", text: "first" },
89
+ { type: "message", role: "user", text: "Stop hook feedback: save notes" },
90
+ { type: "message", role: "assistant", text: "saved" },
91
+ ]);
92
+ const turn = turns.byKey.get(locKey("evals/m.eval.ts", 37));
93
+ expect(turn?.replies).toEqual([
94
+ { kind: "text", text: "first" },
95
+ { kind: "user", text: "Stop hook feedback: save notes" },
96
+ { kind: "text", text: "saved" },
97
+ ]);
98
+ });
99
+
100
+ it("流首无 loc 的 user 消息(旧工件)仍开 noloc 轮", () => {
101
+ const turns = indexTurns([
102
+ { type: "message", role: "user", text: "hi" },
103
+ { type: "message", role: "assistant", text: "hello" },
104
+ ]);
105
+ expect(turns.noloc).toHaveLength(1);
106
+ expect(turns.noloc[0]?.replies).toEqual([{ kind: "text", text: "hello" }]);
107
+ });
108
+ });
@@ -1,4 +1,4 @@
1
- import type { CodeSource, ObjectRecord, Span, TranscriptEvent } from "../types.ts";
1
+ import type { CodeSource, KnownTranscriptEvent, ObjectRecord, Span, TranscriptEvent } from "../types.ts";
2
2
 
3
3
  export function asSources(value: unknown): CodeSource[] | null {
4
4
  if (!Array.isArray(value)) return null;
@@ -11,7 +11,15 @@ export function isCodeSource(value: unknown): value is CodeSource {
11
11
 
12
12
  export function asEvents(value: unknown): TranscriptEvent[] | null {
13
13
  if (!Array.isArray(value)) return null;
14
- return value.every(isTranscriptEvent) ? value : null;
14
+ // 事件词汇会演进(skill.loaded 就是先例):未识别或缺字段的条目不整体判空、
15
+ // 也不静默丢弃——包成 view.raw 原样展示,让新词汇在界面上可被发现、后续补一等呈现。
16
+ // 全有全无判空会让源码视图的 send 行连回复入口都消失;只有非对象条目没有可展示的结构才丢。
17
+ const out: TranscriptEvent[] = [];
18
+ for (const item of value) {
19
+ if (isKnownTranscriptEvent(item)) out.push(item);
20
+ else if (isObjectRecord(item)) out.push({ type: "view.raw", raw: item });
21
+ }
22
+ return out;
15
23
  }
16
24
 
17
25
  export function asSpans(value: unknown): Span[] | null {
@@ -19,7 +27,7 @@ export function asSpans(value: unknown): Span[] | null {
19
27
  return value.every(isSpan) ? value : null;
20
28
  }
21
29
 
22
- export function isTranscriptEvent(value: unknown): value is TranscriptEvent {
30
+ export function isKnownTranscriptEvent(value: unknown): value is KnownTranscriptEvent {
23
31
  if (!isObjectRecord(value) || typeof value.type !== "string") return false;
24
32
  switch (value.type) {
25
33
  case "message":
@@ -32,6 +40,8 @@ export function isTranscriptEvent(value: unknown): value is TranscriptEvent {
32
40
  return typeof value.callId === "string" && typeof value.name === "string";
33
41
  case "subagent.completed":
34
42
  return typeof value.callId === "string";
43
+ case "skill.loaded":
44
+ return typeof value.skill === "string";
35
45
  case "input.requested":
36
46
  return isObjectRecord(value.request);
37
47
  case "thinking":
@@ -22,6 +22,16 @@ export function indexTurns(events: TranscriptEvent[]): IndexedTurns {
22
22
  let cur: SourceTurn | null = null;
23
23
  for (const ev of events || []) {
24
24
  if (ev.type === "message" && ev.role === "user") {
25
+ // 同一条 send 会在流里出现两次:runner 在 send 时记带 loc 的一条,agent 原生
26
+ // transcript 又回显同文本、无 loc 的一条。无 loc 的 user 消息不开新轮——回显
27
+ // (与当前轮 sent 同文本且回复还没开始)直接吃掉,其它(stop-hook 反馈、skill
28
+ // 注入等轮内注入)作为回复条目留在当前轮;否则回复全被回显轮抢走,
29
+ // 带 loc 的 send 行只剩「(无回复)」。
30
+ if (!ev.loc && cur) {
31
+ if (cur.replies.length === 0 && (ev.text || "") === cur.sent) continue;
32
+ cur.replies.push({ kind: "user", text: ev.text || "" });
33
+ continue;
34
+ }
25
35
  cur = { loc: ev.loc, sent: ev.text || "", replies: [] };
26
36
  if (ev.loc) byKey.set(locKey(ev.loc.file, ev.loc.line), cur);
27
37
  else noloc.push(cur);
@@ -38,6 +48,10 @@ export function indexTurns(events: TranscriptEvent[]): IndexedTurns {
38
48
  } else if (ev.type === "action.result") {
39
49
  const tool = toolByCallId.get(ev.callId);
40
50
  if (tool) tool.result = ev;
51
+ } else if (ev.type === "skill.loaded") {
52
+ cur.replies.push({ kind: "skill", skill: ev.skill });
53
+ } else if (ev.type === "view.raw") {
54
+ cur.replies.push({ kind: "raw", raw: ev.raw });
41
55
  } else if (ev.type === "input.requested") {
42
56
  cur.replies.push({ kind: "input", ev });
43
57
  } else if (ev.type === "error") {
@@ -41,8 +41,11 @@ export interface SourceTurn {
41
41
 
42
42
  export type Reply =
43
43
  | { kind: "text"; text: string }
44
+ | { kind: "user"; text: string }
44
45
  | { kind: "thinking"; text: string }
45
46
  | { kind: "error"; text: string }
47
+ | { kind: "skill"; skill: string }
48
+ | { kind: "raw"; raw: ObjectRecord }
46
49
  | { kind: "tool"; ev: ActionCalledEvent; result?: ActionResultEvent }
47
50
  | { kind: "input"; ev: InputRequestedEvent };
48
51
 
@@ -55,7 +58,20 @@ export type ToolResultEvent = ActionResultEvent | SubagentCompletedEvent;
55
58
  export type ViewJson = JsonValue;
56
59
  export type ViewUsage = Usage;
57
60
 
58
- export type TranscriptEvent = StreamEvent;
61
+ /** 标准事件流里前端认识的词汇。 */
62
+ export type KnownTranscriptEvent = StreamEvent;
63
+
64
+ /**
65
+ * 未识别或形状不合的事件条目,由 asEvents 包装:不静默丢弃,渲染面原样展示
66
+ * (带原始 type 标签),让词汇演进在界面上可被发现、后续补一等呈现。
67
+ * `view.raw` 是 view 内部标记,不属于标准事件流词汇。
68
+ */
69
+ export interface RawTranscriptEvent {
70
+ type: "view.raw";
71
+ raw: ObjectRecord;
72
+ }
73
+
74
+ export type TranscriptEvent = KnownTranscriptEvent | RawTranscriptEvent;
59
75
 
60
76
  export interface Indexed<T> {
61
77
  byKey: Map<string, T[]>;
@@ -119,7 +119,7 @@ describe("index.ts · copyFetchedArtifacts(--out 静态导出)对 sources.json
119
119
  await seedDedupedSnapshot(root);
120
120
 
121
121
  const out = join(root, "site");
122
- await buildView({ input: root, out , allowSensitiveArtifacts: true });
122
+ await buildView({ input: root, out });
123
123
 
124
124
  const scan = await loadViewScan(root);
125
125
  const byId = new Map(scan.viewData.snapshots.flatMap((s) => s.results.map((r) => [r.id, r])));