@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.
- package/openapi.yaml +1 -0
- package/package.json +1 -1
- package/src/__tests__/agent-loop-resume-interrupted.test.ts +223 -0
- package/src/__tests__/conversation-agent-loop.test.ts +16 -7
- package/src/__tests__/workspace-migration-137-repair-retired-fireworks-minimax-model-id.test.ts +6 -6
- package/src/__tests__/workspace-migration-138-backfill-home-feed-titles.test.ts +373 -0
- package/src/agent/loop.ts +36 -7
- package/src/cli/commands/notifications.help.ts +3 -2
- package/src/home/__tests__/feed-types.test.ts +43 -8
- package/src/home/__tests__/feed-writer.test.ts +59 -0
- package/src/home/feed-types.ts +34 -7
- package/src/home/feed-writer.ts +15 -6
- package/src/notifications/__tests__/copy-composer.test.ts +53 -3
- package/src/notifications/__tests__/decision-engine.test.ts +150 -20
- package/src/notifications/__tests__/edit-notification.test.ts +330 -0
- package/src/notifications/__tests__/home-feed-side-effect.test.ts +170 -12
- package/src/notifications/copy-composer.ts +23 -3
- package/src/notifications/decision-engine.ts +29 -7
- package/src/notifications/edit-notification.ts +8 -4
- package/src/notifications/home-feed-side-effect.ts +43 -6
- package/src/notifications/notification-utils.ts +8 -2
- package/src/persistence/conversation-title-service.ts +5 -170
- package/src/runtime/routes/notification-routes.ts +4 -1
- package/src/util/__tests__/short-title.test.ts +229 -0
- package/src/util/short-title.ts +189 -0
- package/src/workspace/migrations/138-backfill-home-feed-titles.ts +179 -0
- 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
|
@@ -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
|
|
3026
|
-
//
|
|
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
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
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
|
|
package/src/__tests__/workspace-migration-137-repair-retired-fireworks-minimax-model-id.test.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
+
});
|