@kitlangton/motel 0.2.0 → 0.2.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 (48) hide show
  1. package/AGENTS.md +5 -0
  2. package/package.json +5 -3
  3. package/src/App.tsx +233 -59
  4. package/src/daemon.test.ts +144 -7
  5. package/src/daemon.ts +113 -8
  6. package/src/domain.test.ts +62 -0
  7. package/src/domain.ts +16 -0
  8. package/src/localServer.ts +111 -121
  9. package/src/mcp.ts +172 -0
  10. package/src/motelClient.ts +166 -14
  11. package/src/registry.ts +26 -23
  12. package/src/runtime.ts +8 -2
  13. package/src/server.ts +10 -9
  14. package/src/services/AsyncIngest.ts +52 -0
  15. package/src/services/TelemetryStore.ts +151 -26
  16. package/src/services/TraceQueryService.ts +3 -1
  17. package/src/services/ingestRpc.ts +41 -0
  18. package/src/services/telemetryWorker.ts +62 -0
  19. package/src/storybook/aiChatStory.tsx +243 -0
  20. package/src/storybook/fixtures/errorState.ts +44 -0
  21. package/src/storybook/fixtures/imagePaste.ts +34 -0
  22. package/src/storybook/fixtures/index.ts +62 -0
  23. package/src/storybook/fixtures/kitchenSink.ts +148 -0
  24. package/src/storybook/fixtures/rawPrompt.ts +15 -0
  25. package/src/storybook/fixtures/short.ts +27 -0
  26. package/src/storybook/fixtures/toolHeavy.ts +65 -0
  27. package/src/telemetry.test.ts +28 -0
  28. package/src/ui/AiChatView.tsx +292 -0
  29. package/src/ui/SpanContentView.tsx +181 -0
  30. package/src/ui/SpanDetail.tsx +98 -17
  31. package/src/ui/TraceDetailsPane.tsx +2 -1
  32. package/src/ui/Waterfall.tsx +38 -138
  33. package/src/ui/aiChatModel.test.ts +347 -0
  34. package/src/ui/aiChatModel.ts +736 -0
  35. package/src/ui/aiState.ts +71 -0
  36. package/src/ui/app/TraceWorkspace.tsx +291 -120
  37. package/src/ui/app/useAppLayout.ts +14 -11
  38. package/src/ui/app/useTraceScreenData.ts +173 -39
  39. package/src/ui/atoms.ts +131 -0
  40. package/src/ui/loaders.ts +120 -0
  41. package/src/ui/persistence.ts +41 -0
  42. package/src/ui/primitives.tsx +27 -13
  43. package/src/ui/state.ts +4 -199
  44. package/src/ui/useAttrFilterPicker.ts +63 -23
  45. package/src/ui/useKeyboardNav.ts +552 -364
  46. package/src/ui/waterfallModel.ts +130 -0
  47. package/src/ui/waterfallNav.test.ts +17 -1
  48. package/src/ui/waterfallNav.ts +1 -1
@@ -0,0 +1,44 @@
1
+ import { type ChatFixture, makeDetail, makeSpan } from "./index.ts"
2
+
3
+ // A tool-call that errored mid-stream: finishReason === "error" and a
4
+ // tool-result whose output shape is irregular (object without the
5
+ // expected `type: "text"` wrapper). Covers graceful-degrade rendering.
6
+ export const errorFixture: ChatFixture = {
7
+ id: "error",
8
+ label: "error",
9
+ span: makeSpan({ status: "error", durationMs: 820 }),
10
+ detail: makeDetail({
11
+ status: "error",
12
+ finishReason: "error",
13
+ durationMs: 820,
14
+ promptMessages: {
15
+ messages: [
16
+ { role: "user", content: [{ type: "text", text: "Grab the latest commits from main." }] },
17
+ {
18
+ role: "assistant",
19
+ content: [
20
+ { type: "tool-call", toolCallId: "tc-1", toolName: "bash", input: { command: "git fetch origin main && git log --oneline -5 origin/main" } },
21
+ ],
22
+ },
23
+ {
24
+ role: "tool",
25
+ content: [
26
+ {
27
+ type: "tool-result",
28
+ toolCallId: "tc-1",
29
+ toolName: "bash",
30
+ output: { error: "HTTP 429 rate limited", code: 429, retryable: true },
31
+ },
32
+ ],
33
+ },
34
+ {
35
+ role: "assistant",
36
+ content: [
37
+ { type: "text", text: "The request was rate-limited. I'll retry after a delay — or you can rerun manually." },
38
+ ],
39
+ },
40
+ ],
41
+ },
42
+ responseText: null,
43
+ }),
44
+ }
@@ -0,0 +1,34 @@
1
+ import { type ChatFixture, makeDetail, makeSpan } from "./index.ts"
2
+
3
+ // User pastes an image as a data URL alongside text. The renderer
4
+ // should replace the base64 blob with a compact `[data:image/png
5
+ // base64 NNk]` marker and leave the prose readable. Regression case
6
+ // for the "screen filled with base64" bug.
7
+ const base64Chunk = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAD".repeat(1000)
8
+
9
+ export const imagePasteFixture: ChatFixture = {
10
+ id: "image-paste",
11
+ label: "image",
12
+ span: makeSpan(),
13
+ detail: makeDetail({
14
+ promptMessages: {
15
+ messages: [
16
+ { role: "system", content: "You are a code review assistant." },
17
+ {
18
+ role: "user",
19
+ content: [
20
+ { type: "text", text: `I captured this screenshot of the bug: data:image/png;base64,${base64Chunk}\n\nCan you tell me what's off about the formatting here?` },
21
+ ],
22
+ },
23
+ {
24
+ role: "assistant",
25
+ content: [
26
+ { type: "text", text: "Based on the image, the top padding looks inconsistent between rows. Let me open the component to check." },
27
+ { type: "tool-call", toolCallId: "tc-1", toolName: "read", input: { filePath: "/src/ui/Row.tsx" } },
28
+ ],
29
+ },
30
+ ],
31
+ },
32
+ responseText: "The padding comes from two different tokens; we should unify them.",
33
+ }),
34
+ }
@@ -0,0 +1,62 @@
1
+ // Shared fixture type for the AI-chat storybook. Each fixture is a
2
+ // label + a synthetic span + a synthetic AI call detail, in the same
3
+ // shape the real server returns. Prefer hand-crafted minimal examples
4
+ // over real captures — they exercise renderer cases deliberately and
5
+ // don't leak session content into the repo.
6
+
7
+ import type { AiCallDetail, TraceSpanItem } from "../../domain.ts"
8
+
9
+ export interface ChatFixture {
10
+ readonly id: string
11
+ readonly label: string
12
+ readonly span: TraceSpanItem
13
+ readonly detail: AiCallDetail
14
+ }
15
+
16
+ export const makeSpan = (overrides: Partial<TraceSpanItem> = {}): TraceSpanItem => ({
17
+ spanId: "fixture-span-0000",
18
+ parentSpanId: null,
19
+ operationName: "ai.streamText",
20
+ serviceName: "storybook",
21
+ scopeName: "ai",
22
+ kind: "internal",
23
+ status: "ok",
24
+ startTime: new Date(),
25
+ durationMs: 2400,
26
+ isRunning: false,
27
+ depth: 0,
28
+ tags: {
29
+ "ai.operationId": "ai.streamText",
30
+ "ai.model.id": "claude-opus-4-7",
31
+ "ai.model.provider": "anthropic",
32
+ "ai.prompt.messages": "[]",
33
+ },
34
+ warnings: [],
35
+ events: [],
36
+ ...overrides,
37
+ })
38
+
39
+ export const makeDetail = (overrides: Partial<AiCallDetail> = {}): AiCallDetail => ({
40
+ traceId: "fixture-trace-0000",
41
+ spanId: "fixture-span-0000",
42
+ operation: "streamText",
43
+ service: "storybook",
44
+ functionId: "story.demo",
45
+ provider: "anthropic",
46
+ model: "claude-opus-4-7",
47
+ status: "ok",
48
+ startedAt: new Date().toISOString(),
49
+ durationMs: 2400,
50
+ sessionId: "ses_fixture",
51
+ userId: "kit",
52
+ finishReason: "stop",
53
+ promptMessages: null,
54
+ responseText: null,
55
+ toolCalls: [],
56
+ toolsAvailable: null,
57
+ providerMetadata: null,
58
+ usage: { inputTokens: 1234, outputTokens: 321, totalTokens: 1555, cachedInputTokens: 0, reasoningTokens: null },
59
+ timing: { msToFirstChunk: null, msToFinish: null, avgOutputTokensPerSecond: null },
60
+ logs: [],
61
+ ...overrides,
62
+ })
@@ -0,0 +1,148 @@
1
+ import { type ChatFixture, makeDetail, makeSpan } from "./index.ts"
2
+
3
+ // "Kitchen sink" fixture — every rendering branch in one transcript so
4
+ // we can iterate on styling without flipping between fixtures. Each
5
+ // section below is labelled with the case it exercises.
6
+ //
7
+ // Order roughly mirrors what a realistic conversation looks like so
8
+ // scrolling feels natural, but it's not meant to be a coherent chat.
9
+
10
+ const base64Chunk = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAD".repeat(400)
11
+
12
+ export const kitchenSinkFixture: ChatFixture = {
13
+ id: "kitchen-sink",
14
+ label: "kitchen-sink",
15
+ span: makeSpan({ operationName: "ai.streamText", durationMs: 12_400 }),
16
+ detail: makeDetail({
17
+ durationMs: 12_400,
18
+ finishReason: "tool-calls",
19
+ usage: { inputTokens: 42_000, outputTokens: 1_200, totalTokens: 43_200, cachedInputTokens: 18_500, reasoningTokens: 320 },
20
+ promptMessages: {
21
+ messages: [
22
+ // ── 1. Long system prompt → collapses to 6 lines + hint
23
+ {
24
+ role: "system",
25
+ content: Array.from(
26
+ { length: 120 },
27
+ (_, i) => `System instruction ${i}: long boilerplate that nobody reads inline`,
28
+ ).join("\n"),
29
+ },
30
+
31
+ // ── 2. Plain user text
32
+ { role: "user", content: [{ type: "text", text: "I'd like to refactor the formatter and add a todo list." }] },
33
+
34
+ // ── 3. Assistant turn exercising many inline patterns:
35
+ // reasoning, plain text, bash tool-call with noisy
36
+ // infra keys, read with filePath+offset+limit, todowrite
37
+ // with count, task with description+subagent_type.
38
+ {
39
+ role: "assistant",
40
+ content: [
41
+ { type: "reasoning", text: "I should start by inspecting the current formatter and the test file, then plan the changes in a todo list before touching any code." },
42
+ { type: "text", text: "Plan: check current state, build a todo list, then delegate the heavy read to a subagent." },
43
+ { type: "tool-call", toolCallId: "tc-1", toolName: "bash", input: { command: "git status --short --branch", timeout: 120_000, workdir: "/Users/kit/code/open-source/opencode", description: "ignored" } },
44
+ { type: "tool-call", toolCallId: "tc-2", toolName: "read", input: { filePath: "/src/formatter.ts", offset: 40, limit: 80 } },
45
+ { type: "tool-call", toolCallId: "tc-3", toolName: "todowrite", input: { todos: [{}, {}, {}, {}, {}] } },
46
+ { type: "tool-call", toolCallId: "tc-4", toolName: "task", input: { description: "Find every caller of formatDocument and summarise their shape", subagent_type: "explore" } },
47
+ ],
48
+ },
49
+
50
+ // ── 4. Tool results: short plain, long truncated, and
51
+ // irregular object-shaped output (error-case fallback).
52
+ {
53
+ role: "tool",
54
+ content: [
55
+ { type: "tool-result", toolCallId: "tc-1", toolName: "bash", output: { type: "text", value: "## dev...origin/dev [ahead 8, behind 11]\n M src/formatter.ts" } },
56
+ { type: "tool-result", toolCallId: "tc-2", toolName: "read", output: { type: "text", value: Array.from({ length: 80 }, (_, i) => `${i + 1}: // formatter line ${i} — some body content`).join("\n") } },
57
+ { type: "tool-result", toolCallId: "tc-3", toolName: "todowrite", output: { type: "text", value: "ok (5 todos)" } },
58
+ { type: "tool-result", toolCallId: "tc-4", toolName: "task", output: { error: "rate-limited", code: 429, retryable: true } },
59
+ ],
60
+ },
61
+
62
+ // ── 5. Another assistant turn: edit (primary+secondary
63
+ // summary line), webfetch (url), unknown tool, unknown
64
+ // content-part type.
65
+ {
66
+ role: "assistant",
67
+ content: [
68
+ { type: "text", text: "Now the edit itself, then a quick doc fetch for context:" },
69
+ { type: "tool-call", toolCallId: "tc-5", toolName: "edit", input: { filePath: "/src/formatter.ts", oldString: "function format(doc: Doc)", newString: "export function format(doc: Doc)" } },
70
+ { type: "tool-call", toolCallId: "tc-6", toolName: "webfetch", input: { url: "https://effect.website/docs/observability/tracing", format: "markdown" } },
71
+ { type: "tool-call", toolCallId: "tc-7", toolName: "novel-tool-unknown-to-us", input: { foo: "bar", count: 3 } },
72
+ // Unknown content part kind — should fall through to
73
+ // the `[future-thing] …` hint line.
74
+ { type: "future-thing", payload: { a: 1, b: "x" } } as unknown as { type: string },
75
+ ],
76
+ },
77
+
78
+ // ── 6. User pastes an image (base64 data URL) → scrubbed
79
+ // to a compact marker.
80
+ {
81
+ role: "user",
82
+ content: [
83
+ { type: "text", text: `Here's the bug screenshot: data:image/png;base64,${base64Chunk}\n\nCan you tell me what's wrong with the spacing?` },
84
+ ],
85
+ },
86
+
87
+ // ── 7. Final assistant turn — text only, for the response
88
+ // baseline.
89
+ {
90
+ role: "assistant",
91
+ content: [{ type: "text", text: "The padding token on row 3 is using `spacing.md` instead of `spacing.sm`. Swapping it fixes the alignment." }],
92
+ },
93
+
94
+ // ── 8. Extra cycles below are purely to make the kitchen sink
95
+ // scroll in the story so spacing/selection can be judged in
96
+ // motion rather than only in the first viewport.
97
+ { role: "user", content: [{ type: "text", text: "Can you also check the border color on the warning banner?" }] },
98
+ {
99
+ role: "assistant",
100
+ content: [
101
+ { type: "text", text: "Yep — I’ll inspect the banner component and its token mapping." },
102
+ { type: "tool-call", toolCallId: "tc-8", toolName: "read", input: { filePath: "/src/components/WarningBanner.tsx", offset: 1, limit: 120 } },
103
+ ],
104
+ },
105
+ {
106
+ role: "tool",
107
+ content: [
108
+ { type: "tool-result", toolCallId: "tc-8", toolName: "read", output: { type: "text", value: Array.from({ length: 25 }, (_, i) => `${i + 1}: const borderColor = tokens.warning.border // sample ${i}`).join("\n") } },
109
+ ],
110
+ },
111
+
112
+ { role: "assistant", content: [{ type: "text", text: "The banner is using the right border token, but the hover state swaps in the generic accent border. That’s why it feels inconsistent." }] },
113
+ { role: "user", content: [{ type: "text", text: "What about the empty state in the sidebar — too much top padding there too?" }] },
114
+
115
+ {
116
+ role: "assistant",
117
+ content: [
118
+ { type: "reasoning", text: "This is another quick component read. I should keep the answer short and just point to the token causing the extra top space." },
119
+ { type: "tool-call", toolCallId: "tc-9", toolName: "grep", input: { pattern: "empty state|paddingTop|padding-top", path: "/src" } },
120
+ ],
121
+ },
122
+ {
123
+ role: "tool",
124
+ content: [
125
+ { type: "tool-result", toolCallId: "tc-9", toolName: "grep", output: { type: "text", value: "src/ui/Sidebar.tsx:44: const paddingTop = tokens.spacing.xl\nsrc/ui/Sidebar.tsx:72: <EmptyState style={{ paddingTop }} />" } },
126
+ ],
127
+ },
128
+ { role: "assistant", content: [{ type: "text", text: "Yes — the sidebar empty state is using `spacing.xl`. Dropping it to `spacing.lg` would align it with the rest of the panel chrome." }] },
129
+
130
+ { role: "user", content: [{ type: "text", text: "Could you sketch the three fixes as a compact todo list?" }] },
131
+ {
132
+ role: "assistant",
133
+ content: [
134
+ { type: "tool-call", toolCallId: "tc-10", toolName: "todowrite", input: { todos: [{ title: "fix row-3 padding token" }, { title: "fix warning banner hover border" }, { title: "reduce sidebar empty-state top padding" }] } },
135
+ ],
136
+ },
137
+ {
138
+ role: "tool",
139
+ content: [
140
+ { type: "tool-result", toolCallId: "tc-10", toolName: "todowrite", output: { type: "text", value: "ok (3 todos)" } },
141
+ ],
142
+ },
143
+ { role: "assistant", content: [{ type: "text", text: "1. Fix row 3 padding token. 2. Keep warning banner hover on the warning border token. 3. Reduce sidebar empty-state top padding from `xl` to `lg`." }] },
144
+ ],
145
+ },
146
+ responseText: "The padding token on row 3 is using `spacing.md` instead of `spacing.sm`. Swapping it fixes the alignment.",
147
+ }),
148
+ }
@@ -0,0 +1,15 @@
1
+ import { type ChatFixture, makeDetail, makeSpan } from "./index.ts"
2
+
3
+ // Older AI SDK captures stored `ai.prompt` as a plain string instead
4
+ // of a structured `{ messages: [...] }` object. Renderer should fall
5
+ // back to a single PROMPT (raw) block. Smoke-test for the fallback
6
+ // path.
7
+ export const rawPromptFixture: ChatFixture = {
8
+ id: "raw-prompt",
9
+ label: "raw",
10
+ span: makeSpan(),
11
+ detail: makeDetail({
12
+ promptMessages: "Summarise the following: a long bare prompt with no message structure. Imagine this is how an older ai-sdk version captured the conversation. It's just one opaque text blob that used to flow into the model.",
13
+ responseText: "Here's a one-line summary.",
14
+ }),
15
+ }
@@ -0,0 +1,27 @@
1
+ import { type ChatFixture, makeDetail, makeSpan } from "./index.ts"
2
+
3
+ // Minimal 2-turn Q&A. Serves as the "baseline sanity check" — if this
4
+ // doesn't render cleanly, something's wrong with the headers/wrapping.
5
+ export const shortFixture: ChatFixture = {
6
+ id: "short",
7
+ label: "short",
8
+ span: makeSpan(),
9
+ detail: makeDetail({
10
+ promptMessages: {
11
+ messages: [
12
+ { role: "system", content: "You are a helpful assistant. Keep replies concise." },
13
+ { role: "user", content: [{ type: "text", text: "Hey, can you explain what SQLite WAL mode means in one paragraph?" }] },
14
+ {
15
+ role: "assistant",
16
+ content: [
17
+ {
18
+ type: "text",
19
+ text: "WAL (write-ahead log) is a journaling mode in SQLite where writes are appended to a separate `.wal` file instead of rewriting the main database in place. Readers can keep reading the last committed snapshot from the main file while a writer is appending to the WAL, which means concurrent reads and writes don't block each other. Periodically SQLite checkpoints the WAL back into the main database. The upside: much higher concurrency than the default rollback journal. The downside: you get an extra file per database and slightly more complex crash recovery.",
20
+ },
21
+ ],
22
+ },
23
+ ],
24
+ },
25
+ responseText: "WAL (write-ahead log) is a journaling mode in SQLite where writes are appended to a separate `.wal` file instead of rewriting the main database in place. Readers can keep reading the last committed snapshot from the main file while a writer is appending to the WAL, which means concurrent reads and writes don't block each other.",
26
+ }),
27
+ }
@@ -0,0 +1,65 @@
1
+ import { type ChatFixture, makeDetail, makeSpan } from "./index.ts"
2
+
3
+ // Realistic multi-turn session with bash + read + edit tool calls and
4
+ // tool results. Mirrors the shape we saw in actual opencode traces.
5
+ // Exercises: smart tool-input summaries, consecutive tool calls,
6
+ // bounded tool-result rendering, reasoning blocks.
7
+ export const toolHeavyFixture: ChatFixture = {
8
+ id: "tool-heavy",
9
+ label: "tools",
10
+ span: makeSpan({ operationName: "ai.streamText", durationMs: 9_400 }),
11
+ detail: makeDetail({
12
+ durationMs: 9_400,
13
+ finishReason: "tool-calls",
14
+ usage: { inputTokens: 135_131, outputTokens: 943, totalTokens: 136_074, cachedInputTokens: 133_818, reasoningTokens: null },
15
+ promptMessages: {
16
+ messages: [
17
+ {
18
+ role: "system",
19
+ content: Array.from({ length: 120 }, (_, i) => `System rule ${i}: long boilerplate content goes here that nobody usually needs to read inline`).join("\n"),
20
+ },
21
+ { role: "user", content: [{ type: "text", text: "Can you review the formatter changes in PR 23020 and tell me if it looks like a regression?" }] },
22
+ {
23
+ role: "assistant",
24
+ content: [
25
+ { type: "reasoning", text: "I should inspect the current worktree state and compare it against the PR diff. Starting with `git status` and then grabbing the diff from GitHub." },
26
+ { type: "tool-call", toolCallId: "tc-1", toolName: "bash", input: { command: "git status --short --branch", timeout: 120_000, workdir: "/Users/kit/code/open-source/opencode", description: "Show branch state" } },
27
+ { type: "tool-call", toolCallId: "tc-2", toolName: "bash", input: { command: "git worktree list", timeout: 120_000, workdir: "/Users/kit/code/open-source/opencode" } },
28
+ { type: "tool-call", toolCallId: "tc-3", toolName: "bash", input: { command: "gh pr view 23020 --json title,headRefName,baseRefName,author,url,files", timeout: 120_000 } },
29
+ ],
30
+ },
31
+ {
32
+ role: "tool",
33
+ content: [
34
+ { type: "tool-result", toolCallId: "tc-1", toolName: "bash", output: { type: "text", value: "## dev...origin/dev [ahead 8, behind 11]\n M packages/opencode/specs/effect/loose-ends.md" } },
35
+ { type: "tool-result", toolCallId: "tc-2", toolName: "bash", output: { type: "text", value: ["/Users/kit/code/open-source/opencode 6b7f34df2 [dev]", "/private/tmp/oc-repro-22872 69f7182f0 [kit/repro-write-lsp-hang]", "/private/tmp/opencode-pr22385-base 87b2a9d74 (detached HEAD)", "/Users/kit/code/open-source/opencode-1.4.7 9f201d637 (detached HEAD)", "/Users/kit/code/open-source/opencode-await-thenable 343a56418 [kit/oxlint-await-thenable]", "/Users/kit/code/open-source/opencode-config-httpapi df7e2fe63 [kit/config-httpapi]"].join("\n") } },
36
+ { type: "tool-result", toolCallId: "tc-3", toolName: "bash", output: { type: "text", value: '{"title":"fix: add formatter","headRefName":"pr-23020","baseRefName":"dev","url":"https://github.com/anomalyco/opencode/pull/23020","files":[{"path":"packages/opencode/src/formatter.ts","additions":43,"deletions":12}]}' } },
37
+ ],
38
+ },
39
+ {
40
+ role: "assistant",
41
+ content: [
42
+ { type: "text", text: "Let me read the actual formatter diff to see what changed in detail." },
43
+ { type: "tool-call", toolCallId: "tc-4", toolName: "read", input: { filePath: "/Users/kit/code/open-source/opencode/packages/opencode/src/formatter.ts" } },
44
+ { type: "tool-call", toolCallId: "tc-5", toolName: "read", input: { filePath: "/Users/kit/code/open-source/opencode/packages/opencode/src/formatter.test.ts", offset: 1, limit: 200 } },
45
+ ],
46
+ },
47
+ {
48
+ role: "tool",
49
+ content: [
50
+ { type: "tool-result", toolCallId: "tc-4", toolName: "read", output: { type: "text", value: Array.from({ length: 60 }, (_, i) => `${i + 1}: // formatter source line ${i}`).join("\n") } },
51
+ { type: "tool-result", toolCallId: "tc-5", toolName: "read", output: { type: "text", value: Array.from({ length: 30 }, (_, i) => `${i + 1}: test("case ${i}", () => { expect(x).toBe(y) })`).join("\n") } },
52
+ ],
53
+ },
54
+ {
55
+ role: "assistant",
56
+ content: [
57
+ { type: "text", text: "The diff changes how trailing newlines are emitted. It looks intentional — they're aligning the output with what prettier does downstream. I wouldn't flag it as a regression." },
58
+ { type: "tool-call", toolCallId: "tc-6", toolName: "todowrite", input: { todos: [{ title: "verify tests still pass" }, { title: "comment on the PR" }, { title: "merge if CI green" }] } },
59
+ ],
60
+ },
61
+ ],
62
+ },
63
+ responseText: "Not a regression. The formatter change aligns with how prettier emits trailing newlines; the test still covers the behaviour. I'd approve it.",
64
+ }),
65
+ }
@@ -158,6 +158,23 @@ describe("motel telemetry store", () => {
158
158
  { key: "ai.response.msToFinish", value: { stringValue: "20000" } },
159
159
  ],
160
160
  },
161
+ {
162
+ traceId: "trace-ai",
163
+ spanId: "ai-stream-1-do",
164
+ parentSpanId: "ai-stream-1",
165
+ name: "ai.streamText.doStream",
166
+ kind: 1,
167
+ startTimeUnixNano: String(nowNanos + 21n * oneSecond),
168
+ endTimeUnixNano: String(nowNanos + 39n * oneSecond),
169
+ attributes: [
170
+ { key: "ai.operationId", value: { stringValue: "ai.streamText.doStream" } },
171
+ { key: "ai.telemetry.functionId", value: { stringValue: "session.llm" } },
172
+ { key: "ai.model.provider", value: { stringValue: "openai.responses" } },
173
+ { key: "ai.model.id", value: { stringValue: "gpt-5.4" } },
174
+ { key: "ai.telemetry.metadata.sessionId", value: { stringValue: "ses_test123" } },
175
+ { key: "ai.prompt.messages", value: { stringValue: '[{"role":"user","content":"Tell me a joke about programming"}]' } },
176
+ ],
177
+ },
161
178
  {
162
179
  traceId: "trace-ai",
163
180
  spanId: "ai-tool-1",
@@ -617,6 +634,17 @@ describe("motel telemetry store", () => {
617
634
  expect(streamCall?.usage?.outputTokens).toBe(42)
618
635
  })
619
636
 
637
+ it("dedupes nested doStream spans from AI summaries", async () => {
638
+ const result = await storeRuntime.runPromise(
639
+ Effect.flatMap(TelemetryStore.asEffect(), (store) =>
640
+ store.searchAiCalls({ sessionId: "ses_test123" }),
641
+ ).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
642
+ )
643
+
644
+ expect(result.map((call) => call.spanId)).toEqual(["ai-stream-1"])
645
+ expect(result.map((call) => call.spanId)).not.toContain("ai-stream-1-do")
646
+ })
647
+
620
648
  it("filters AI calls by model", async () => {
621
649
  const result = await storeRuntime.runPromise(
622
650
  Effect.flatMap(TelemetryStore.asEffect(), (store) =>