@vellumai/assistant 0.11.1-dev.202608032034.aebd2da → 0.11.1-dev.202608032128.6f59e1c

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 (27) hide show
  1. package/openapi.yaml +1 -0
  2. package/package.json +1 -1
  3. package/src/__tests__/agent-loop-resume-interrupted.test.ts +223 -0
  4. package/src/__tests__/conversation-agent-loop.test.ts +16 -7
  5. package/src/__tests__/workspace-migration-137-repair-retired-fireworks-minimax-model-id.test.ts +6 -6
  6. package/src/__tests__/workspace-migration-138-backfill-home-feed-titles.test.ts +373 -0
  7. package/src/agent/loop.ts +36 -7
  8. package/src/cli/commands/notifications.help.ts +3 -2
  9. package/src/home/__tests__/feed-types.test.ts +43 -8
  10. package/src/home/__tests__/feed-writer.test.ts +59 -0
  11. package/src/home/feed-types.ts +34 -7
  12. package/src/home/feed-writer.ts +15 -6
  13. package/src/notifications/__tests__/copy-composer.test.ts +53 -3
  14. package/src/notifications/__tests__/decision-engine.test.ts +150 -20
  15. package/src/notifications/__tests__/edit-notification.test.ts +330 -0
  16. package/src/notifications/__tests__/home-feed-side-effect.test.ts +170 -12
  17. package/src/notifications/copy-composer.ts +23 -3
  18. package/src/notifications/decision-engine.ts +29 -7
  19. package/src/notifications/edit-notification.ts +8 -4
  20. package/src/notifications/home-feed-side-effect.ts +43 -6
  21. package/src/notifications/notification-utils.ts +8 -2
  22. package/src/persistence/conversation-title-service.ts +5 -170
  23. package/src/runtime/routes/notification-routes.ts +4 -1
  24. package/src/util/__tests__/short-title.test.ts +229 -0
  25. package/src/util/short-title.ts +189 -0
  26. package/src/workspace/migrations/138-backfill-home-feed-titles.ts +179 -0
  27. package/src/workspace/migrations/registry.ts +2 -0
package/openapi.yaml CHANGED
@@ -21436,6 +21436,7 @@ paths:
21436
21436
  minLength: 1
21437
21437
  description: Feed item id (notif:<uuid>) or bare uuid
21438
21438
  title:
21439
+ description: New title. An empty value is ignored, never cleared.
21439
21440
  type: string
21440
21441
  body:
21441
21442
  type: string
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.1-dev.202608032034.aebd2da",
3
+ "version": "0.11.1-dev.202608032128.6f59e1c",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Cover for the loop's interrupted-call recovery.
3
+ *
4
+ * The reported failure: the model runs several tools, the next call streams for
5
+ * a while and then dies (the upstream killed a generation that blew its decode
6
+ * deadline), and the loop surfaces the error and ends the turn — leaving the
7
+ * task half-done until the user types "continue". These tests assert the loop
8
+ * now carries on by itself, and that it still stops when carrying on would be
9
+ * pointless.
10
+ *
11
+ * The recovery is built into {@link AgentLoop}, not contributed by a plugin, so
12
+ * these run it as the loop's own behavior. The default plugin stack is
13
+ * registered because the loop fires hook chains during a turn regardless.
14
+ */
15
+
16
+ import { beforeEach, describe, expect, test } from "bun:test";
17
+
18
+ import type { AgentEvent } from "../agent/loop.js";
19
+ import { AgentLoop } from "../agent/loop.js";
20
+ import type { LLMCallSite } from "../config/schemas/llm.js";
21
+ import { resetPluginRegistryAndRegisterDefaults } from "../plugins/defaults/index.js";
22
+ import type {
23
+ Message,
24
+ Provider,
25
+ ProviderResponse,
26
+ SendMessageOptions,
27
+ ToolDefinition,
28
+ } from "../providers/types.js";
29
+ import { ProviderError } from "../util/errors.js";
30
+
31
+ const tools: ToolDefinition[] = [
32
+ {
33
+ name: "read_file",
34
+ description: "Read a file",
35
+ input_schema: { type: "object", properties: { path: { type: "string" } } },
36
+ },
37
+ ];
38
+
39
+ const userMessage: Message = {
40
+ role: "user",
41
+ content: [{ type: "text", text: "finish the course page" }],
42
+ };
43
+
44
+ /** The rejection from the reported turn, thrown from inside a live stream. */
45
+ function midStreamRejection(): ProviderError {
46
+ return new ProviderError(
47
+ "OpenAI-compatible API error (unknown status): litellm.MidStreamFallbackError: " +
48
+ "litellm.APIConnectionError: OpenAIException - Decode wall clock timeout after 600s",
49
+ "openai-compatible",
50
+ );
51
+ }
52
+
53
+ /** A scripted turn: return a response, or stream `thinking` and then throw. */
54
+ type Turn =
55
+ | { kind: "reply"; response: ProviderResponse }
56
+ | { kind: "interrupt"; thinking: string }
57
+ | { kind: "refuse" };
58
+
59
+ function scriptedProvider(turns: Turn[]): {
60
+ provider: Provider;
61
+ callCount: () => number;
62
+ } {
63
+ let index = 0;
64
+ const provider: Provider = {
65
+ name: "openai-compatible",
66
+ async sendMessage(
67
+ _messages: Message[],
68
+ options?: SendMessageOptions,
69
+ ): Promise<ProviderResponse> {
70
+ const turn = turns[Math.min(index, turns.length - 1)]!;
71
+ index++;
72
+ if (turn.kind === "refuse") {
73
+ // Rejected before generating: nothing ever streams.
74
+ throw midStreamRejection();
75
+ }
76
+ if (turn.kind === "interrupt") {
77
+ options?.onEvent?.({ type: "thinking_delta", thinking: turn.thinking });
78
+ throw midStreamRejection();
79
+ }
80
+ for (const block of turn.response.content) {
81
+ if (block.type === "text") {
82
+ options?.onEvent?.({ type: "text_delta", text: block.text });
83
+ }
84
+ }
85
+ return turn.response;
86
+ },
87
+ };
88
+ return { provider, callCount: () => index };
89
+ }
90
+
91
+ function reply(text: string): Turn {
92
+ return {
93
+ kind: "reply",
94
+ response: {
95
+ content: [{ type: "text", text }],
96
+ model: "glm-5.2",
97
+ usage: { inputTokens: 10, outputTokens: 5 },
98
+ stopReason: "end_turn",
99
+ },
100
+ };
101
+ }
102
+
103
+ function callsTool(id: string): Turn {
104
+ return {
105
+ kind: "reply",
106
+ response: {
107
+ content: [
108
+ { type: "tool_use", id, name: "read_file", input: { path: "app.js" } },
109
+ ],
110
+ model: "glm-5.2",
111
+ usage: { inputTokens: 10, outputTokens: 5 },
112
+ stopReason: "tool_use",
113
+ },
114
+ };
115
+ }
116
+
117
+ function runLoop(provider: Provider, callSite: LLMCallSite = "mainAgent") {
118
+ const events: AgentEvent[] = [];
119
+ const loop = new AgentLoop({
120
+ provider,
121
+ systemPrompt: "system",
122
+ conversationId: "conv-resume-e2e",
123
+ tools,
124
+ toolExecutor: async () => ({ content: "file data", isError: false }),
125
+ });
126
+ return loop
127
+ .run({
128
+ requestId: "req-resume-e2e",
129
+ messages: [userMessage],
130
+ callSite,
131
+ onEvent: (event) => {
132
+ events.push(event);
133
+ },
134
+ trust: { sourceChannel: "vellum", trustClass: "unknown" },
135
+ })
136
+ .then((result) => ({ ...result, events }));
137
+ }
138
+
139
+ function assistantTextOf(history: Message[]): string {
140
+ return history
141
+ .filter((message) => message.role === "assistant")
142
+ .flatMap((message) => message.content)
143
+ .map((block) => (block.type === "text" ? block.text : ""))
144
+ .join("");
145
+ }
146
+
147
+ describe("AgentLoop — a call interrupted mid-generation", () => {
148
+ beforeEach(() => {
149
+ resetPluginRegistryAndRegisterDefaults();
150
+ });
151
+
152
+ test("carries on instead of ending the turn", async () => {
153
+ const { provider, callCount } = scriptedProvider([
154
+ callsTool("call-1"),
155
+ { kind: "interrupt", thinking: "Now I need to understand the layout" },
156
+ reply("Done — the page is wired up."),
157
+ ]);
158
+
159
+ const { history, events } = await runLoop(provider);
160
+
161
+ // The turn finished on its own: tool call, interrupted call, resumed call.
162
+ expect(callCount()).toBe(3);
163
+ expect(assistantTextOf(history)).toContain("Done — the page is wired up.");
164
+ expect(events.filter((event) => event.type === "error")).toHaveLength(0);
165
+ });
166
+
167
+ test("keeps the tool work the interrupted turn had already done", async () => {
168
+ const { provider } = scriptedProvider([
169
+ callsTool("call-1"),
170
+ { kind: "interrupt", thinking: "reading the file" },
171
+ reply("Summary of the file."),
172
+ ]);
173
+
174
+ const { history } = await runLoop(provider);
175
+
176
+ // The resumed call re-sends the same history, so the completed tool call
177
+ // and its result are still there rather than being replayed or dropped.
178
+ const toolUses = history
179
+ .flatMap((message) => message.content)
180
+ .filter((block) => block.type === "tool_use");
181
+ const toolResults = history
182
+ .flatMap((message) => message.content)
183
+ .filter((block) => block.type === "tool_result");
184
+ expect(toolUses).toHaveLength(1);
185
+ expect(toolResults).toHaveLength(1);
186
+ });
187
+
188
+ test("surfaces the error when the resumed call is interrupted too", async () => {
189
+ const { provider, callCount } = scriptedProvider([
190
+ { kind: "interrupt", thinking: "starting" },
191
+ ]);
192
+
193
+ const { events } = await runLoop(provider);
194
+
195
+ // One resume, then the error stands rather than looping.
196
+ expect(callCount()).toBe(2);
197
+ expect(events.filter((event) => event.type === "error")).toHaveLength(1);
198
+ });
199
+
200
+ test("does not resume a background call site", async () => {
201
+ // A compaction, subagent, or other background call answers to a caller
202
+ // that owns its own failure handling; nobody is sitting there to type
203
+ // "continue" at it.
204
+ const { provider, callCount } = scriptedProvider([
205
+ { kind: "interrupt", thinking: "summarizing" },
206
+ ]);
207
+
208
+ await runLoop(provider, "compactionAgent");
209
+
210
+ expect(callCount()).toBe(1);
211
+ });
212
+
213
+ test("does not resume a request the provider refused outright", async () => {
214
+ // Nothing streamed, so re-sending the identical request would fail the same
215
+ // way; the error surfaces on the first rejection.
216
+ const { provider, callCount } = scriptedProvider([{ kind: "refuse" }]);
217
+
218
+ const { events } = await runLoop(provider);
219
+
220
+ expect(callCount()).toBe(1);
221
+ expect(events.filter((event) => event.type === "error")).toHaveLength(1);
222
+ });
223
+ });
@@ -3022,12 +3022,20 @@ describe("session-agent-loop", () => {
3022
3022
  }));
3023
3023
 
3024
3024
  // GIVEN a real loop whose provider streams a delta — landing a debounced
3025
- // partial flush on the reserved row — then rejects, so the loop emits
3026
- // `provider_error` and `error` and exits with no `message_complete`.
3025
+ // partial flush on the reserved row — then rejects. The loop resumes a
3026
+ // call that died after it had started streaming, so the second attempt
3027
+ // is scripted to fail before streaming anything: that keeps exactly one
3028
+ // row carrying partial content, and the turn then exits via
3029
+ // `provider_error` with no `message_complete`.
3030
+ let attempt = 0;
3027
3031
  const ctx = makeCtx({
3028
3032
  loopProvider: {
3029
3033
  name: "mock-provider",
3030
3034
  async sendMessage(_messages, options) {
3035
+ attempt++;
3036
+ if (attempt > 1) {
3037
+ throw new Error("upstream 500");
3038
+ }
3031
3039
  options?.onEvent?.({ type: "text_delta", text: "hello world" });
3032
3040
  await new Promise((resolve) => setTimeout(resolve, 1100));
3033
3041
  throw new Error("upstream 500");
@@ -3051,11 +3059,12 @@ describe("session-agent-loop", () => {
3051
3059
  .split("\n");
3052
3060
  expect(orphanLines).toHaveLength(1);
3053
3061
  expect(updateMessageContentMock).toHaveBeenCalledTimes(0);
3054
- expect(deleteMessageByIdMock).toHaveBeenCalledTimes(1);
3055
- const deleteCall = deleteMessageByIdMock.mock.calls[0] as unknown as [
3056
- string,
3057
- ];
3058
- expect(deleteCall[0]).toBe("msg-orphan-with-partial");
3062
+ // Scoped to the row under test: the resumed attempt reserves a row of
3063
+ // its own, which its own cleanup deletes.
3064
+ const deletedIds = deleteMessageByIdMock.mock.calls.map(
3065
+ (call) => (call as unknown as [string])[0],
3066
+ );
3067
+ expect(deletedIds).toContain("msg-orphan-with-partial");
3059
3068
  });
3060
3069
  });
3061
3070
 
@@ -47,15 +47,15 @@ afterEach(() => {
47
47
  });
48
48
 
49
49
  describe("137-repair-retired-fireworks-minimax-model-id migration", () => {
50
- test("has correct migration id and is registered last", () => {
50
+ test("has correct migration id and is registered", () => {
51
51
  expect(repairRetiredFireworksMinimaxModelIdMigration.id).toBe(
52
52
  "137-repair-retired-fireworks-minimax-model-id",
53
53
  );
54
- // getLastWorkspaceMigrationId() reports the final entry as the registry
55
- // ceiling, so the highest id must stay last.
56
- expect(WORKSPACE_MIGRATIONS[WORKSPACE_MIGRATIONS.length - 1]?.id).toBe(
57
- "137-repair-retired-fireworks-minimax-model-id",
58
- );
54
+ expect(
55
+ WORKSPACE_MIGRATIONS.some(
56
+ (m) => m.id === "137-repair-retired-fireworks-minimax-model-id",
57
+ ),
58
+ ).toBe(true);
59
59
  });
60
60
 
61
61
  test("repairs the retired ID in default, call sites, and profiles", () => {
@@ -0,0 +1,373 @@
1
+ /**
2
+ * Tests for workspace migration `138-backfill-home-feed-titles`.
3
+ *
4
+ * The migration gives every item in `<workspace>/data/home-feed.json` a
5
+ * non-empty `title`, derived from the item's own `summary` when the
6
+ * field is missing or blank.
7
+ *
8
+ * Cases covered:
9
+ * 1. Missing file, malformed JSON, and a non-object root are no-ops.
10
+ * 2. Title-less items gain a first-sentence title from the summary.
11
+ * 3. Items that already have a title are untouched.
12
+ * 4. A long summary truncates to 60 characters with an ellipsis, the
13
+ * same cap the notification pipeline's derivation applies.
14
+ * 5. Markdown-rich and multi-line summaries yield a single line of
15
+ * plain text.
16
+ * 6. `version`, `updatedAt`, and unrelated item fields are preserved.
17
+ * 7. A second run writes nothing.
18
+ */
19
+
20
+ import {
21
+ existsSync,
22
+ mkdirSync,
23
+ mkdtempSync,
24
+ readFileSync,
25
+ rmSync,
26
+ statSync,
27
+ writeFileSync,
28
+ } from "node:fs";
29
+ import { tmpdir } from "node:os";
30
+ import { join } from "node:path";
31
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
32
+
33
+ import { backfillHomeFeedTitlesMigration } from "../workspace/migrations/138-backfill-home-feed-titles.js";
34
+ import { WORKSPACE_MIGRATIONS } from "../workspace/migrations/registry.js";
35
+ import { getLastWorkspaceMigrationId } from "../workspace/migrations/runner.js";
36
+
37
+ let workspaceDir: string;
38
+ let feedPath: string;
39
+
40
+ beforeEach(() => {
41
+ workspaceDir = mkdtempSync(join(tmpdir(), "vellum-migration-138-test-"));
42
+ feedPath = join(workspaceDir, "data", "home-feed.json");
43
+ });
44
+
45
+ afterEach(() => {
46
+ if (existsSync(workspaceDir)) {
47
+ rmSync(workspaceDir, { recursive: true, force: true });
48
+ }
49
+ });
50
+
51
+ function writeFeedFile(contents: unknown): void {
52
+ mkdirSync(join(workspaceDir, "data"), { recursive: true });
53
+ writeFileSync(feedPath, JSON.stringify(contents, null, 2), "utf-8");
54
+ }
55
+
56
+ function readFeedFile(): {
57
+ version: number;
58
+ items: Array<Record<string, unknown>>;
59
+ updatedAt: string;
60
+ } {
61
+ return JSON.parse(readFileSync(feedPath, "utf-8"));
62
+ }
63
+
64
+ function makeItem(
65
+ overrides: Record<string, unknown> & { id: string },
66
+ ): Record<string, unknown> {
67
+ return {
68
+ type: "notification",
69
+ priority: 50,
70
+ summary: "Something happened.",
71
+ timestamp: "2026-08-01T12:00:00.000Z",
72
+ status: "new",
73
+ createdAt: "2026-08-01T12:00:00.000Z",
74
+ ...overrides,
75
+ };
76
+ }
77
+
78
+ function writeFeed(items: Array<Record<string, unknown>>): void {
79
+ writeFeedFile({
80
+ version: 2,
81
+ updatedAt: "2026-08-01T12:00:00.000Z",
82
+ items,
83
+ });
84
+ }
85
+
86
+ describe("workspace migration 138-backfill-home-feed-titles", () => {
87
+ test("has the expected id and description", () => {
88
+ expect(backfillHomeFeedTitlesMigration.id).toBe(
89
+ "138-backfill-home-feed-titles",
90
+ );
91
+ expect(backfillHomeFeedTitlesMigration.description).toContain("title");
92
+ });
93
+
94
+ // getLastWorkspaceMigrationId() reports the final array entry as the
95
+ // registry ceiling to the identity and rollback routes, so the
96
+ // highest-numbered migration must sit last in the registry.
97
+ test("the registry ceiling stays at the highest-numbered migration", () => {
98
+ const numericId = (id: string) => Number.parseInt(id, 10);
99
+ const highest = Math.max(
100
+ ...WORKSPACE_MIGRATIONS.map((m) => numericId(m.id)).filter(
101
+ Number.isFinite,
102
+ ),
103
+ );
104
+ const last = getLastWorkspaceMigrationId(WORKSPACE_MIGRATIONS);
105
+ expect(last).not.toBeNull();
106
+ expect(numericId(last!)).toBe(highest);
107
+ });
108
+
109
+ test("missing file is a no-op (no error, no file created)", () => {
110
+ expect(existsSync(feedPath)).toBe(false);
111
+
112
+ expect(() =>
113
+ backfillHomeFeedTitlesMigration.run(workspaceDir),
114
+ ).not.toThrow();
115
+
116
+ expect(existsSync(feedPath)).toBe(false);
117
+ });
118
+
119
+ test("derives a title from the summary's first sentence", () => {
120
+ writeFeed([
121
+ makeItem({
122
+ id: "a-1",
123
+ summary: "Alice replied to your thread. She needs an answer.",
124
+ }),
125
+ makeItem({ id: "a-2", summary: "Deploy finished!" }),
126
+ makeItem({ id: "a-3", summary: "Ready to review? The draft is up." }),
127
+ ]);
128
+
129
+ backfillHomeFeedTitlesMigration.run(workspaceDir);
130
+
131
+ const titles = readFeedFile().items.map((item) => item.title);
132
+ expect(titles).toEqual([
133
+ "Alice replied to your thread.",
134
+ "Deploy finished!",
135
+ "Ready to review?",
136
+ ]);
137
+ });
138
+
139
+ test("backfills items whose title is missing, empty, blank, or not a string", () => {
140
+ writeFeed([
141
+ makeItem({ id: "missing", summary: "First item summary." }),
142
+ makeItem({ id: "empty", title: "", summary: "Second item summary." }),
143
+ makeItem({ id: "blank", title: " ", summary: "Third item summary." }),
144
+ makeItem({ id: "wrong-type", title: null, summary: "Fourth summary." }),
145
+ ]);
146
+
147
+ backfillHomeFeedTitlesMigration.run(workspaceDir);
148
+
149
+ const titles = readFeedFile().items.map((item) => item.title);
150
+ expect(titles).toEqual([
151
+ "First item summary.",
152
+ "Second item summary.",
153
+ "Third item summary.",
154
+ "Fourth summary.",
155
+ ]);
156
+ });
157
+
158
+ test("leaves items that already have a title untouched", () => {
159
+ writeFeed([
160
+ makeItem({
161
+ id: "titled",
162
+ title: "Existing headline",
163
+ summary: "A completely different summary sentence.",
164
+ }),
165
+ makeItem({ id: "untitled", summary: "Needs a headline." }),
166
+ ]);
167
+
168
+ backfillHomeFeedTitlesMigration.run(workspaceDir);
169
+
170
+ const items = readFeedFile().items;
171
+ expect(items[0]!.title).toBe("Existing headline");
172
+ expect(items[1]!.title).toBe("Needs a headline.");
173
+ });
174
+
175
+ test("truncates a long summary to 60 characters with an ellipsis", () => {
176
+ const summary =
177
+ "Bob shared a very long document that keeps going well past the limit";
178
+ writeFeed([makeItem({ id: "long", summary })]);
179
+
180
+ backfillHomeFeedTitlesMigration.run(workspaceDir);
181
+
182
+ const title = readFeedFile().items[0]!.title as string;
183
+ expect(title).toBe(
184
+ "Bob shared a very long document that keeps going well past t…",
185
+ );
186
+ // The ellipsis sits past the cap, so the retained text is exactly 60.
187
+ expect(title.slice(0, -1).length).toBe(60);
188
+ });
189
+
190
+ test("truncates a long first sentence rather than the whole summary", () => {
191
+ writeFeed([
192
+ makeItem({
193
+ id: "long-sentence",
194
+ summary:
195
+ "This opening sentence runs well beyond the sixty character cap on derived titles. Then another.",
196
+ }),
197
+ ]);
198
+
199
+ backfillHomeFeedTitlesMigration.run(workspaceDir);
200
+
201
+ const title = readFeedFile().items[0]!.title as string;
202
+ expect(title).toBe(
203
+ "This opening sentence runs well beyond the sixty character c…",
204
+ );
205
+ });
206
+
207
+ test("flattens a markdown-rich multi-line summary into one plain line", () => {
208
+ writeFeed([
209
+ makeItem({
210
+ id: "markdown",
211
+ summary:
212
+ "## Nightly run\n\n- **Deploy** finished\n- Tests passed\n\nSee the `report` for details.",
213
+ }),
214
+ ]);
215
+
216
+ backfillHomeFeedTitlesMigration.run(workspaceDir);
217
+
218
+ expect(readFeedFile().items[0]!.title).toBe(
219
+ "Nightly run Deploy finished Tests passed See the report for…",
220
+ );
221
+ });
222
+
223
+ test("unwraps inline markdown before taking the first sentence", () => {
224
+ writeFeed([
225
+ makeItem({
226
+ id: "inline",
227
+ summary:
228
+ "**Alice** shared the [design doc](https://example.com/doc). Review it soon.",
229
+ }),
230
+ ]);
231
+
232
+ backfillHomeFeedTitlesMigration.run(workspaceDir);
233
+
234
+ expect(readFeedFile().items[0]!.title).toBe("Alice shared the design doc.");
235
+ });
236
+
237
+ test("collapses a newline-only summary run into single spaces", () => {
238
+ writeFeed([
239
+ makeItem({ id: "newlines", summary: "Backup complete\n\nNo errors" }),
240
+ ]);
241
+
242
+ backfillHomeFeedTitlesMigration.run(workspaceDir);
243
+
244
+ expect(readFeedFile().items[0]!.title).toBe("Backup complete No errors");
245
+ });
246
+
247
+ test("skips an item whose summary is markdown structure only", () => {
248
+ writeFeed([makeItem({ id: "structure-only", summary: "##\n\n- \n\n> " })]);
249
+
250
+ backfillHomeFeedTitlesMigration.run(workspaceDir);
251
+
252
+ expect(readFeedFile().items[0]!.title).toBeUndefined();
253
+ });
254
+
255
+ test("preserves version, updatedAt, and unrelated item fields", () => {
256
+ writeFeedFile({
257
+ version: 2,
258
+ updatedAt: "2026-08-01T12:00:00.000Z",
259
+ items: [
260
+ makeItem({
261
+ id: "keep-fields",
262
+ summary: "Payment failed.",
263
+ urgency: "high",
264
+ conversationId: "conv-abc",
265
+ actions: [{ label: "Retry", kind: "prompt" }],
266
+ detailPanel: { kind: "text", body: "Details" },
267
+ }),
268
+ ],
269
+ });
270
+
271
+ backfillHomeFeedTitlesMigration.run(workspaceDir);
272
+
273
+ const out = readFeedFile();
274
+ expect(out.version).toBe(2);
275
+ expect(out.updatedAt).toBe("2026-08-01T12:00:00.000Z");
276
+
277
+ const item = out.items[0]!;
278
+ expect(item.title).toBe("Payment failed.");
279
+ expect(item.id).toBe("keep-fields");
280
+ expect(item.type).toBe("notification");
281
+ expect(item.priority).toBe(50);
282
+ expect(item.status).toBe("new");
283
+ expect(item.urgency).toBe("high");
284
+ expect(item.conversationId).toBe("conv-abc");
285
+ expect(item.actions).toEqual([{ label: "Retry", kind: "prompt" }]);
286
+ expect(item.detailPanel).toEqual({ kind: "text", body: "Details" });
287
+ });
288
+
289
+ test("idempotent: a second run writes nothing", () => {
290
+ writeFeed([
291
+ makeItem({ id: "a-1", summary: "Alice replied to your thread." }),
292
+ makeItem({ id: "a-2", title: "Already titled", summary: "Body text." }),
293
+ ]);
294
+
295
+ backfillHomeFeedTitlesMigration.run(workspaceDir);
296
+ const afterFirst = readFileSync(feedPath, "utf-8");
297
+ const afterFirstStat = statSync(feedPath);
298
+
299
+ backfillHomeFeedTitlesMigration.run(workspaceDir);
300
+
301
+ expect(readFileSync(feedPath, "utf-8")).toBe(afterFirst);
302
+ expect(statSync(feedPath).mtimeMs).toBe(afterFirstStat.mtimeMs);
303
+ });
304
+
305
+ test("a fully titled feed is left byte-identical", () => {
306
+ writeFeed([makeItem({ id: "a-1", title: "Titled", summary: "Body." })]);
307
+ const before = readFileSync(feedPath, "utf-8");
308
+ const beforeStat = statSync(feedPath);
309
+
310
+ backfillHomeFeedTitlesMigration.run(workspaceDir);
311
+
312
+ expect(readFileSync(feedPath, "utf-8")).toBe(before);
313
+ expect(statSync(feedPath).mtimeMs).toBe(beforeStat.mtimeMs);
314
+ });
315
+
316
+ test("malformed JSON does not throw and leaves the file alone", () => {
317
+ mkdirSync(join(workspaceDir, "data"), { recursive: true });
318
+ writeFileSync(feedPath, "{not valid json", "utf-8");
319
+ const before = readFileSync(feedPath, "utf-8");
320
+
321
+ expect(() =>
322
+ backfillHomeFeedTitlesMigration.run(workspaceDir),
323
+ ).not.toThrow();
324
+
325
+ expect(readFileSync(feedPath, "utf-8")).toBe(before);
326
+ });
327
+
328
+ test("non-object root is a no-op", () => {
329
+ writeFeedFile([1, 2, 3]);
330
+ const before = readFileSync(feedPath, "utf-8");
331
+
332
+ expect(() =>
333
+ backfillHomeFeedTitlesMigration.run(workspaceDir),
334
+ ).not.toThrow();
335
+
336
+ expect(readFileSync(feedPath, "utf-8")).toBe(before);
337
+ });
338
+
339
+ test("items that cannot yield a title are skipped without throwing", () => {
340
+ writeFeedFile({
341
+ version: 2,
342
+ updatedAt: "2026-08-01T12:00:00.000Z",
343
+ items: [
344
+ "not an object",
345
+ makeItem({ id: "no-summary", summary: undefined }),
346
+ makeItem({ id: "blank-summary", summary: " " }),
347
+ makeItem({ id: "ok", summary: "Has a summary." }),
348
+ ],
349
+ });
350
+
351
+ expect(() =>
352
+ backfillHomeFeedTitlesMigration.run(workspaceDir),
353
+ ).not.toThrow();
354
+
355
+ const items: unknown[] = readFeedFile().items;
356
+ expect(items[0]).toBe("not an object");
357
+ expect((items[1] as Record<string, unknown>).title).toBeUndefined();
358
+ expect((items[2] as Record<string, unknown>).title).toBeUndefined();
359
+ expect((items[3] as Record<string, unknown>).title).toBe("Has a summary.");
360
+ });
361
+
362
+ test("down() is a no-op (forward-only migration)", () => {
363
+ writeFeed([makeItem({ id: "a-1", summary: "Alice replied." })]);
364
+ backfillHomeFeedTitlesMigration.run(workspaceDir);
365
+ const after = readFileSync(feedPath, "utf-8");
366
+
367
+ expect(() =>
368
+ backfillHomeFeedTitlesMigration.down(workspaceDir),
369
+ ).not.toThrow();
370
+
371
+ expect(readFileSync(feedPath, "utf-8")).toBe(after);
372
+ });
373
+ });