@vellumai/assistant 0.6.0 → 0.6.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.
- package/AGENTS.md +4 -0
- package/ARCHITECTURE.md +68 -15
- package/Dockerfile +2 -2
- package/bun.lock +6 -2
- package/docker-entrypoint.sh +32 -1
- package/docs/architecture/integrations.md +1 -1
- package/docs/architecture/memory.md +21 -24
- package/openapi.yaml +538 -3
- package/package.json +5 -1
- package/src/__tests__/anthropic-provider.test.ts +160 -95
- package/src/__tests__/app-dir-path-guard.test.ts +1 -0
- package/src/__tests__/app-executors.test.ts +47 -1
- package/src/__tests__/app-source-watcher.test.ts +159 -0
- package/src/__tests__/checker.test.ts +38 -6
- package/src/__tests__/config-schema.test.ts +5 -0
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +4 -6
- package/src/__tests__/conversation-agent-loop.test.ts +4 -51
- package/src/__tests__/conversation-history-web-search.test.ts +1 -1
- package/src/__tests__/conversation-runtime-assembly.test.ts +653 -832
- package/src/__tests__/conversation-runtime-workspace.test.ts +1 -93
- package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +17 -4
- package/src/__tests__/conversation-wipe.test.ts +2 -6
- package/src/__tests__/conversation-workspace-cache-state.test.ts +6 -12
- package/src/__tests__/conversation-workspace-injection.test.ts +25 -26
- package/src/__tests__/conversation-workspace-tool-tracking.test.ts +1 -1
- package/src/__tests__/copy-composer-tc-templates.test.ts +335 -0
- package/src/__tests__/date-context.test.ts +76 -210
- package/src/__tests__/db-schedule-syntax-migration.test.ts +16 -1
- package/src/__tests__/file-list-tool.test.ts +219 -0
- package/src/__tests__/first-greeting.test.ts +1 -1
- package/src/__tests__/heartbeat-service.test.ts +180 -3
- package/src/__tests__/identity-routes.test.ts +328 -0
- package/src/__tests__/injection-block.test.ts +24 -0
- package/src/__tests__/install-skill-routing.test.ts +7 -6
- package/src/__tests__/jobs-store-qdrant-breaker.test.ts +15 -14
- package/src/__tests__/list-messages-tool-merge.test.ts +300 -0
- package/src/__tests__/llm-context-normalization.test.ts +18 -18
- package/src/__tests__/llm-context-route-provider.test.ts +101 -0
- package/src/__tests__/llm-request-log-turn-query.test.ts +162 -0
- package/src/__tests__/log-export-workspace.test.ts +72 -105
- package/src/__tests__/mcp-abort-signal.test.ts +5 -0
- package/src/__tests__/mcp-client-auth.test.ts +5 -0
- package/src/__tests__/memory-recall-log-store.test.ts +132 -0
- package/src/__tests__/migration-export-streaming.test.ts +304 -0
- package/src/__tests__/migration-import-commit-http.test.ts +11 -10
- package/src/__tests__/mock-fetch.ts +87 -0
- package/src/__tests__/notification-decision-recipient-context.test.ts +282 -0
- package/src/__tests__/onboarding-template-contract.test.ts +62 -14
- package/src/__tests__/parser.test.ts +32 -0
- package/src/__tests__/permission-checker-host-gate.test.ts +452 -0
- package/src/__tests__/permission-controls-v2-flag.test.ts +55 -0
- package/src/__tests__/permission-mode-sse.test.ts +418 -0
- package/src/__tests__/permission-mode-store.test.ts +277 -0
- package/src/__tests__/permission-mode.test.ts +101 -0
- package/src/__tests__/platform-bash-auto-approve.test.ts +359 -0
- package/src/__tests__/profiler-routes.test.ts +502 -0
- package/src/__tests__/profiler-run-store.test.ts +441 -0
- package/src/__tests__/proxy-approval-callback.test.ts +4 -75
- package/src/__tests__/registry.test.ts +1 -1
- package/src/__tests__/sandbox-host-parity.test.ts +5 -4
- package/src/__tests__/scheduler-reuse-conversation.test.ts +368 -0
- package/src/__tests__/scrub-corrupted-image-attachments.test.ts +278 -0
- package/src/__tests__/search-skills-unified.test.ts +4 -3
- package/src/__tests__/send-endpoint-busy.test.ts +42 -3
- package/src/__tests__/set-permission-mode.test.ts +274 -0
- package/src/__tests__/skill-load-feature-flag.test.ts +12 -0
- package/src/__tests__/skill-memory.test.ts +2 -783
- package/src/__tests__/strip-memory-injections.test.ts +187 -0
- package/src/__tests__/subagent-detail.test.ts +84 -0
- package/src/__tests__/subagent-disposal.test.ts +308 -0
- package/src/__tests__/subagent-manager-notify.test.ts +19 -10
- package/src/__tests__/subagent-notify-parent.test.ts +390 -0
- package/src/__tests__/subagent-role-registry.test.ts +108 -0
- package/src/__tests__/subagent-tool-filtering.test.ts +71 -0
- package/src/__tests__/subagent-tools.test.ts +464 -4
- package/src/__tests__/system-prompt-ask-mode.test.ts +139 -0
- package/src/__tests__/task-memory-cleanup.test.ts +12 -12
- package/src/__tests__/terminal-tools.test.ts +17 -27
- package/src/__tests__/test-preload.ts +4 -0
- package/src/__tests__/tool-executor.test.ts +4 -26
- package/src/__tests__/tool-side-effects-slack-dm.test.ts +1 -0
- package/src/__tests__/top-level-renderer.test.ts +10 -13
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +116 -2
- package/src/__tests__/workspace-migration-028-recover-conversations-from-disk-view.test.ts +387 -0
- package/src/agent/loop.ts +6 -0
- package/src/approvals/guardian-request-resolvers.ts +24 -0
- package/src/avatar/traits-png-sync.ts +3 -3
- package/src/cli/__tests__/run-assistant-command.ts +29 -0
- package/src/cli/commands/__tests__/email-download.test.ts +245 -0
- package/src/cli/commands/__tests__/email-list.test.ts +192 -0
- package/src/cli/commands/__tests__/email-register.test.ts +186 -0
- package/src/cli/commands/__tests__/email-send.test.ts +291 -0
- package/src/cli/commands/__tests__/email-status.test.ts +181 -0
- package/src/cli/commands/__tests__/email-unregister.test.ts +139 -0
- package/src/cli/commands/__tests__/routes.test.ts +562 -0
- package/src/cli/commands/conversations.ts +1 -8
- package/src/cli/commands/email.ts +584 -835
- package/src/cli/commands/memory.ts +1 -34
- package/src/cli/commands/notifications.ts +7 -2
- package/src/cli/commands/oauth/connect.ts +14 -5
- package/src/cli/commands/routes.ts +396 -0
- package/src/cli/commands/skills.ts +130 -20
- package/src/cli/program.ts +2 -0
- package/src/cli.ts +1 -120
- package/src/config/bundled-skills/app-builder/SKILL.md +4 -1
- package/src/config/bundled-skills/gmail/SKILL.md +2 -2
- package/src/config/bundled-skills/messaging/SKILL.md +7 -0
- package/src/config/bundled-skills/schedule/SKILL.md +22 -2
- package/src/config/bundled-skills/schedule/TOOLS.json +8 -0
- package/src/config/bundled-skills/settings/tools/avatar-get.ts +3 -13
- package/src/config/bundled-skills/settings/tools/avatar-remove.ts +2 -4
- package/src/config/bundled-skills/settings/tools/avatar-update.ts +5 -2
- package/src/config/bundled-skills/slack/SKILL.md +2 -0
- package/src/config/bundled-skills/subagent/SKILL.md +43 -3
- package/src/config/bundled-skills/subagent/TOOLS.json +29 -4
- package/src/config/env-registry.ts +63 -0
- package/src/config/feature-flag-registry.json +17 -1
- package/src/config/schema.ts +8 -0
- package/src/config/schemas/filing.ts +51 -0
- package/src/config/schemas/heartbeat.ts +15 -12
- package/src/config/schemas/memory-lifecycle.ts +12 -0
- package/src/config/schemas/security.ts +14 -0
- package/src/daemon/app-source-watcher.ts +93 -0
- package/src/daemon/config-watcher.ts +79 -1
- package/src/daemon/conversation-agent-loop-handlers.ts +20 -0
- package/src/daemon/conversation-agent-loop.ts +158 -65
- package/src/daemon/conversation-history.ts +4 -19
- package/src/daemon/conversation-lifecycle.ts +8 -14
- package/src/daemon/conversation-process.ts +13 -7
- package/src/daemon/conversation-runtime-assembly.ts +300 -306
- package/src/daemon/conversation-tool-setup.ts +44 -14
- package/src/daemon/conversation-workspace.ts +1 -2
- package/src/daemon/conversation.ts +18 -0
- package/src/daemon/date-context.ts +26 -53
- package/src/daemon/first-greeting.ts +1 -1
- package/src/daemon/handlers/conversations.ts +4 -7
- package/src/daemon/handlers/shared.test.ts +143 -0
- package/src/daemon/handlers/shared.ts +63 -5
- package/src/daemon/handlers/skills.ts +11 -18
- package/src/daemon/lifecycle.ts +199 -157
- package/src/daemon/message-types/conversations.ts +25 -6
- package/src/daemon/message-types/messages.ts +9 -1
- package/src/daemon/message-types/schedules.ts +1 -0
- package/src/daemon/message-types/settings.ts +6 -0
- package/src/daemon/profiler-run-store.ts +557 -0
- package/src/daemon/server.ts +89 -9
- package/src/daemon/shutdown-handlers.ts +5 -0
- package/src/daemon/tool-side-effects.ts +23 -3
- package/src/export/transcript-formatter.ts +148 -0
- package/src/filing/filing-service.ts +228 -0
- package/src/heartbeat/heartbeat-service.ts +96 -7
- package/src/mcp/client.ts +6 -0
- package/src/mcp/mcp-oauth-provider.ts +149 -27
- package/src/memory/admin.ts +33 -32
- package/src/memory/app-store.ts +69 -0
- package/src/memory/conversation-bootstrap.ts +1 -1
- package/src/memory/conversation-crud.ts +136 -107
- package/src/memory/conversation-group-migration.ts +1 -1
- package/src/memory/conversation-queries.ts +58 -12
- package/src/memory/conversation-title-service.ts +1 -0
- package/src/memory/db-init.ts +182 -376
- package/src/memory/graph/bootstrap.ts +75 -66
- package/src/memory/graph/capability-seed.ts +167 -15
- package/src/memory/graph/consolidation.ts +38 -4
- package/src/memory/graph/conversation-graph-memory.ts +133 -104
- package/src/memory/graph/extraction-job.ts +9 -4
- package/src/memory/graph/extraction.ts +66 -23
- package/src/memory/graph/graph-memory-state-store.ts +37 -0
- package/src/memory/graph/graph-search.ts +29 -15
- package/src/memory/graph/injection.ts +38 -8
- package/src/memory/graph/inspect.ts +12 -3
- package/src/memory/graph/retriever.ts +365 -262
- package/src/memory/graph/store.test.ts +48 -0
- package/src/memory/graph/store.ts +150 -11
- package/src/memory/graph/tool-handlers.ts +84 -209
- package/src/memory/graph/tools.ts +8 -52
- package/src/memory/graph/types.ts +24 -0
- package/src/memory/job-handlers/cleanup.ts +44 -1
- package/src/memory/jobs-store.ts +70 -60
- package/src/memory/jobs-worker.ts +44 -28
- package/src/memory/llm-request-log-store.ts +96 -12
- package/src/memory/memory-recall-log-store.ts +49 -5
- package/src/memory/migrations/203-drop-memory-items-tables.ts +33 -1
- package/src/memory/migrations/206-memory-graph-node-edits.ts +19 -0
- package/src/memory/migrations/206-scrub-corrupted-image-attachments.ts +131 -0
- package/src/memory/migrations/207-conversation-graph-memory-state.ts +20 -0
- package/src/memory/migrations/208-conversations-last-message-at.ts +35 -0
- package/src/memory/migrations/209-strip-thinking-from-consolidated.ts +85 -0
- package/src/memory/migrations/210-schedule-reuse-conversation.ts +13 -0
- package/src/memory/migrations/211-memory-recall-logs-query-context.ts +21 -0
- package/src/memory/migrations/212-llm-request-logs-created-at-index.ts +19 -0
- package/src/memory/migrations/index.ts +8 -0
- package/src/memory/migrations/registry.ts +8 -0
- package/src/memory/schema/conversations.ts +14 -0
- package/src/memory/schema/infrastructure.ts +8 -1
- package/src/memory/schema/memory-core.ts +0 -51
- package/src/memory/schema/memory-graph.ts +15 -0
- package/src/memory/task-memory-cleanup.ts +30 -11
- package/src/notifications/copy-composer.ts +86 -0
- package/src/notifications/decision-engine.ts +35 -0
- package/src/permissions/checker.ts +12 -1
- package/src/permissions/permission-mode-store.ts +180 -0
- package/src/permissions/permission-mode.ts +31 -0
- package/src/permissions/workspace-policy.ts +9 -0
- package/src/prompts/system-prompt.ts +59 -7
- package/src/prompts/templates/BOOTSTRAP-REFERENCE.md +100 -0
- package/src/prompts/templates/BOOTSTRAP.md +70 -165
- package/src/prompts/templates/HEARTBEAT.md +3 -1
- package/src/prompts/templates/SOUL.md +25 -4
- package/src/prompts/templates/UPDATES.md +8 -0
- package/src/providers/anthropic/client.ts +107 -219
- package/src/runtime/auth/route-policy.ts +23 -0
- package/src/runtime/http-server.ts +32 -2
- package/src/runtime/http-types.ts +12 -1
- package/src/runtime/migrations/vbundle-builder.ts +389 -3
- package/src/runtime/migrations/vbundle-importer.ts +8 -6
- package/src/runtime/routes/__tests__/user-route-dispatcher.test.ts +378 -0
- package/src/runtime/routes/app-management-routes.ts +1 -11
- package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +26 -0
- package/src/runtime/routes/archive-utils.ts +29 -0
- package/src/runtime/routes/avatar-routes.ts +2 -9
- package/src/runtime/routes/btw-routes.ts +14 -1
- package/src/runtime/routes/conversation-analysis-routes.ts +173 -0
- package/src/runtime/routes/conversation-management-routes.ts +1 -14
- package/src/runtime/routes/conversation-query-routes.ts +49 -3
- package/src/runtime/routes/conversation-routes.ts +264 -44
- package/src/runtime/routes/heartbeat-routes.ts +4 -10
- package/src/runtime/routes/identity-routes.ts +53 -18
- package/src/runtime/routes/llm-context-normalization.ts +14 -10
- package/src/runtime/routes/log-export-routes.ts +23 -275
- package/src/runtime/routes/memory-item-routes.test.ts +168 -233
- package/src/runtime/routes/migration-routes.ts +18 -7
- package/src/runtime/routes/profiler-routes.ts +350 -0
- package/src/runtime/routes/schedule-routes.ts +27 -12
- package/src/runtime/routes/settings-routes.ts +95 -8
- package/src/runtime/routes/subagents-routes.ts +28 -7
- package/src/runtime/routes/user-route-dispatcher.ts +223 -0
- package/src/runtime/routes/user-routes.ts +41 -0
- package/src/runtime/routes/workspace-routes.ts +0 -1
- package/src/schedule/schedule-store.ts +30 -0
- package/src/schedule/scheduler.ts +45 -18
- package/src/skills/catalog-install.ts +10 -2
- package/src/skills/managed-store.ts +2 -2
- package/src/skills/skill-memory.ts +1 -293
- package/src/subagent/index.ts +13 -3
- package/src/subagent/manager.ts +308 -29
- package/src/subagent/types.ts +68 -0
- package/src/tasks/task-runner.ts +4 -4
- package/src/tools/apps/executors.ts +29 -4
- package/src/tools/filesystem/list.ts +93 -0
- package/src/tools/permission-checker.ts +78 -0
- package/src/tools/registry.ts +4 -0
- package/src/tools/schedule/create.ts +3 -0
- package/src/tools/schedule/list.ts +1 -0
- package/src/tools/schedule/update.ts +6 -0
- package/src/tools/shared/filesystem/errors.ts +5 -0
- package/src/tools/shared/filesystem/file-ops-service.ts +90 -2
- package/src/tools/shared/filesystem/types.ts +17 -0
- package/src/tools/shared/shell-output.ts +31 -2
- package/src/tools/subagent/abort.ts +12 -2
- package/src/tools/subagent/message.ts +9 -2
- package/src/tools/subagent/notify-parent.ts +79 -0
- package/src/tools/subagent/read.ts +29 -8
- package/src/tools/subagent/resolve.ts +21 -0
- package/src/tools/subagent/spawn.ts +2 -0
- package/src/tools/subagent/status.ts +11 -1
- package/src/tools/system/avatar-generator.ts +3 -3
- package/src/tools/system/register.ts +23 -0
- package/src/tools/system/set-permission-mode.ts +103 -0
- package/src/tools/terminal/parser.ts +30 -5
- package/src/tools/terminal/safe-env.ts +16 -1
- package/src/tools/tool-manifest.ts +6 -0
- package/src/tools/types.ts +2 -0
- package/src/util/logger.ts +1 -1
- package/src/util/platform.ts +50 -17
- package/src/workspace/migrations/023-move-config-files-to-workspace.ts +2 -2
- package/src/workspace/migrations/024-move-runtime-files-to-workspace.ts +2 -2
- package/src/workspace/migrations/028-recover-conversations-from-disk-view.ts +270 -0
- package/src/workspace/migrations/029-seed-pkb.ts +84 -0
- package/src/workspace/migrations/registry.ts +4 -0
- package/src/workspace/top-level-renderer.ts +5 -9
- package/src/__tests__/cli-memory.test.ts +0 -377
- package/src/__tests__/clipboard.test.ts +0 -88
- package/src/cli/cli-memory.ts +0 -179
- package/src/util/clipboard.ts +0 -34
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterAll,
|
|
3
|
+
beforeAll,
|
|
4
|
+
beforeEach,
|
|
5
|
+
describe,
|
|
6
|
+
expect,
|
|
7
|
+
mock,
|
|
8
|
+
test,
|
|
9
|
+
} from "bun:test";
|
|
10
|
+
|
|
11
|
+
mock.module("../util/logger.js", () => ({
|
|
12
|
+
getLogger: () =>
|
|
13
|
+
new Proxy({} as Record<string, unknown>, {
|
|
14
|
+
get: () => () => {},
|
|
15
|
+
}),
|
|
16
|
+
truncateForLog: (value: string) => value,
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
import { deleteConversation } from "../memory/conversation-crud.js";
|
|
20
|
+
import { getDb, initializeDb } from "../memory/db.js";
|
|
21
|
+
import { createSchedule, getScheduleRuns } from "../schedule/schedule-store.js";
|
|
22
|
+
import { startScheduler } from "../schedule/scheduler.js";
|
|
23
|
+
|
|
24
|
+
initializeDb();
|
|
25
|
+
|
|
26
|
+
/** Access the underlying bun:sqlite Database for raw parameterized queries. */
|
|
27
|
+
function getRawDb(): import("bun:sqlite").Database {
|
|
28
|
+
return (getDb() as unknown as { $client: import("bun:sqlite").Database })
|
|
29
|
+
.$client;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Force a schedule to be due by setting next_run_at in the past. */
|
|
33
|
+
function forceScheduleDue(scheduleId: string): void {
|
|
34
|
+
getRawDb().run("UPDATE cron_jobs SET next_run_at = ? WHERE id = ?", [
|
|
35
|
+
Date.now() - 1000,
|
|
36
|
+
scheduleId,
|
|
37
|
+
]);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Build an RRULE expression anchored at the given start date, recurring every minute.
|
|
41
|
+
function buildEveryMinuteRrule(dtstart: Date = new Date()): string {
|
|
42
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
43
|
+
const ds = `${dtstart.getUTCFullYear()}${pad(dtstart.getUTCMonth() + 1)}${pad(
|
|
44
|
+
dtstart.getUTCDate(),
|
|
45
|
+
)}T${pad(dtstart.getUTCHours())}${pad(dtstart.getUTCMinutes())}${pad(
|
|
46
|
+
dtstart.getUTCSeconds(),
|
|
47
|
+
)}Z`;
|
|
48
|
+
return `DTSTART:${ds}\nRRULE:FREQ=MINUTELY;INTERVAL=1`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Replace setTimeout with a zero-delay version so the 500ms scheduler
|
|
52
|
+
// wait calls fire instantly instead of waiting real time.
|
|
53
|
+
let origSetTimeout: typeof globalThis.setTimeout;
|
|
54
|
+
|
|
55
|
+
describe("scheduler conversation reuse", () => {
|
|
56
|
+
beforeAll(() => {
|
|
57
|
+
origSetTimeout = globalThis.setTimeout;
|
|
58
|
+
globalThis.setTimeout = ((
|
|
59
|
+
fn: TimerHandler,
|
|
60
|
+
_ms?: number,
|
|
61
|
+
...args: unknown[]
|
|
62
|
+
) => {
|
|
63
|
+
return origSetTimeout(fn, 200, ...args);
|
|
64
|
+
}) as typeof setTimeout;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
afterAll(() => {
|
|
68
|
+
globalThis.setTimeout = origSetTimeout;
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
beforeEach(() => {
|
|
72
|
+
const db = getDb();
|
|
73
|
+
db.run("DELETE FROM cron_runs");
|
|
74
|
+
db.run("DELETE FROM cron_jobs");
|
|
75
|
+
db.run("DELETE FROM task_runs");
|
|
76
|
+
db.run("DELETE FROM tasks");
|
|
77
|
+
db.run("DELETE FROM messages");
|
|
78
|
+
db.run("DELETE FROM conversations");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("recurring schedule with reuseConversation=true reuses conversation across runs", async () => {
|
|
82
|
+
/**
|
|
83
|
+
* When a recurring schedule has reuseConversation enabled, the second run
|
|
84
|
+
* should reuse the conversation created by the first run.
|
|
85
|
+
*/
|
|
86
|
+
|
|
87
|
+
// GIVEN a recurring schedule with reuseConversation enabled
|
|
88
|
+
const rruleExpr = buildEveryMinuteRrule();
|
|
89
|
+
const schedule = createSchedule({
|
|
90
|
+
name: "Reuse Test",
|
|
91
|
+
cronExpression: rruleExpr,
|
|
92
|
+
message: "Reuse conversation message",
|
|
93
|
+
syntax: "rrule",
|
|
94
|
+
expression: rruleExpr,
|
|
95
|
+
reuseConversation: true,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// WHEN the schedule fires for the first time
|
|
99
|
+
forceScheduleDue(schedule.id);
|
|
100
|
+
|
|
101
|
+
const processedMessages: { conversationId: string; message: string }[] = [];
|
|
102
|
+
const processMessage = async (conversationId: string, message: string) => {
|
|
103
|
+
processedMessages.push({ conversationId, message });
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const scheduler1 = startScheduler(
|
|
107
|
+
processMessage,
|
|
108
|
+
() => {},
|
|
109
|
+
() => {},
|
|
110
|
+
);
|
|
111
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
112
|
+
scheduler1.stop();
|
|
113
|
+
|
|
114
|
+
// THEN a conversation is created and recorded
|
|
115
|
+
expect(processedMessages).toHaveLength(1);
|
|
116
|
+
const firstConversationId = processedMessages[0].conversationId;
|
|
117
|
+
expect(firstConversationId).toBeTruthy();
|
|
118
|
+
|
|
119
|
+
// AND a successful run is recorded
|
|
120
|
+
const runs1 = getScheduleRuns(schedule.id);
|
|
121
|
+
expect(runs1.length).toBe(1);
|
|
122
|
+
expect(runs1[0].status).toBe("ok");
|
|
123
|
+
expect(runs1[0].conversationId).toBe(firstConversationId);
|
|
124
|
+
|
|
125
|
+
// WHEN the schedule fires for the second time
|
|
126
|
+
forceScheduleDue(schedule.id);
|
|
127
|
+
processedMessages.length = 0;
|
|
128
|
+
|
|
129
|
+
const scheduler2 = startScheduler(
|
|
130
|
+
processMessage,
|
|
131
|
+
() => {},
|
|
132
|
+
() => {},
|
|
133
|
+
);
|
|
134
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
135
|
+
scheduler2.stop();
|
|
136
|
+
|
|
137
|
+
// THEN the same conversation is reused
|
|
138
|
+
expect(processedMessages).toHaveLength(1);
|
|
139
|
+
expect(processedMessages[0].conversationId).toBe(firstConversationId);
|
|
140
|
+
|
|
141
|
+
// AND the run references the reused conversation
|
|
142
|
+
const runs2 = getScheduleRuns(schedule.id);
|
|
143
|
+
expect(runs2.length).toBe(2);
|
|
144
|
+
expect(runs2[0].conversationId).toBe(firstConversationId);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("recurring schedule with reuseConversation=false creates new conversation each run", async () => {
|
|
148
|
+
/**
|
|
149
|
+
* Default behavior: each run creates a brand-new conversation.
|
|
150
|
+
*/
|
|
151
|
+
|
|
152
|
+
// GIVEN a recurring schedule with reuseConversation disabled (default)
|
|
153
|
+
const rruleExpr = buildEveryMinuteRrule();
|
|
154
|
+
const schedule = createSchedule({
|
|
155
|
+
name: "No Reuse Test",
|
|
156
|
+
cronExpression: rruleExpr,
|
|
157
|
+
message: "New conv each run",
|
|
158
|
+
syntax: "rrule",
|
|
159
|
+
expression: rruleExpr,
|
|
160
|
+
// reuseConversation defaults to false
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// WHEN the schedule fires for the first time
|
|
164
|
+
forceScheduleDue(schedule.id);
|
|
165
|
+
|
|
166
|
+
const processedMessages: { conversationId: string; message: string }[] = [];
|
|
167
|
+
const processMessage = async (conversationId: string, message: string) => {
|
|
168
|
+
processedMessages.push({ conversationId, message });
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const scheduler1 = startScheduler(
|
|
172
|
+
processMessage,
|
|
173
|
+
() => {},
|
|
174
|
+
() => {},
|
|
175
|
+
);
|
|
176
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
177
|
+
scheduler1.stop();
|
|
178
|
+
|
|
179
|
+
expect(processedMessages).toHaveLength(1);
|
|
180
|
+
const firstConversationId = processedMessages[0].conversationId;
|
|
181
|
+
|
|
182
|
+
// WHEN the schedule fires for the second time
|
|
183
|
+
forceScheduleDue(schedule.id);
|
|
184
|
+
processedMessages.length = 0;
|
|
185
|
+
|
|
186
|
+
const scheduler2 = startScheduler(
|
|
187
|
+
processMessage,
|
|
188
|
+
() => {},
|
|
189
|
+
() => {},
|
|
190
|
+
);
|
|
191
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
192
|
+
scheduler2.stop();
|
|
193
|
+
|
|
194
|
+
// THEN a different conversation is created
|
|
195
|
+
expect(processedMessages).toHaveLength(1);
|
|
196
|
+
expect(processedMessages[0].conversationId).not.toBe(firstConversationId);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("reuseConversation creates a new conversation when prior one is deleted", async () => {
|
|
200
|
+
/**
|
|
201
|
+
* If the conversation from the last successful run has been deleted,
|
|
202
|
+
* a fresh conversation should be bootstrapped.
|
|
203
|
+
*/
|
|
204
|
+
|
|
205
|
+
// GIVEN a recurring schedule with reuseConversation enabled that has already run once
|
|
206
|
+
const rruleExpr = buildEveryMinuteRrule();
|
|
207
|
+
const schedule = createSchedule({
|
|
208
|
+
name: "Deleted Conv Test",
|
|
209
|
+
cronExpression: rruleExpr,
|
|
210
|
+
message: "Handle deleted conv",
|
|
211
|
+
syntax: "rrule",
|
|
212
|
+
expression: rruleExpr,
|
|
213
|
+
reuseConversation: true,
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
forceScheduleDue(schedule.id);
|
|
217
|
+
|
|
218
|
+
const processedMessages: { conversationId: string; message: string }[] = [];
|
|
219
|
+
const processMessage = async (conversationId: string, message: string) => {
|
|
220
|
+
processedMessages.push({ conversationId, message });
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const scheduler1 = startScheduler(
|
|
224
|
+
processMessage,
|
|
225
|
+
() => {},
|
|
226
|
+
() => {},
|
|
227
|
+
);
|
|
228
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
229
|
+
scheduler1.stop();
|
|
230
|
+
|
|
231
|
+
expect(processedMessages).toHaveLength(1);
|
|
232
|
+
const firstConversationId = processedMessages[0].conversationId;
|
|
233
|
+
|
|
234
|
+
// AND the conversation is deleted
|
|
235
|
+
deleteConversation(firstConversationId);
|
|
236
|
+
|
|
237
|
+
// WHEN the schedule fires again
|
|
238
|
+
forceScheduleDue(schedule.id);
|
|
239
|
+
processedMessages.length = 0;
|
|
240
|
+
|
|
241
|
+
const scheduler2 = startScheduler(
|
|
242
|
+
processMessage,
|
|
243
|
+
() => {},
|
|
244
|
+
() => {},
|
|
245
|
+
);
|
|
246
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
247
|
+
scheduler2.stop();
|
|
248
|
+
|
|
249
|
+
// THEN a new conversation is created (not the deleted one)
|
|
250
|
+
expect(processedMessages).toHaveLength(1);
|
|
251
|
+
expect(processedMessages[0].conversationId).not.toBe(firstConversationId);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("one-shot schedule ignores reuseConversation flag", async () => {
|
|
255
|
+
/**
|
|
256
|
+
* One-shot schedules always create a new conversation regardless of the
|
|
257
|
+
* reuseConversation flag since they only fire once.
|
|
258
|
+
*/
|
|
259
|
+
|
|
260
|
+
// GIVEN a one-shot schedule with reuseConversation enabled
|
|
261
|
+
const schedule = createSchedule({
|
|
262
|
+
name: "One-shot Reuse Ignored",
|
|
263
|
+
message: "One-shot with reuse flag",
|
|
264
|
+
mode: "execute",
|
|
265
|
+
nextRunAt: Date.now() - 1000,
|
|
266
|
+
reuseConversation: true,
|
|
267
|
+
// No expression = one-shot
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// WHEN the schedule fires
|
|
271
|
+
const processedMessages: { conversationId: string; message: string }[] = [];
|
|
272
|
+
const processMessage = async (conversationId: string, message: string) => {
|
|
273
|
+
processedMessages.push({ conversationId, message });
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const scheduler = startScheduler(
|
|
277
|
+
processMessage,
|
|
278
|
+
() => {},
|
|
279
|
+
() => {},
|
|
280
|
+
);
|
|
281
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
282
|
+
scheduler.stop();
|
|
283
|
+
|
|
284
|
+
// THEN the message is processed with a new conversation
|
|
285
|
+
expect(processedMessages).toHaveLength(1);
|
|
286
|
+
expect(processedMessages[0].conversationId).toBeTruthy();
|
|
287
|
+
|
|
288
|
+
// AND the schedule is marked as fired
|
|
289
|
+
const runs = getScheduleRuns(schedule.id);
|
|
290
|
+
expect(runs.length).toBeGreaterThanOrEqual(1);
|
|
291
|
+
expect(runs[0].status).toBe("ok");
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("reuseConversation uses the conversation from the most recent successful run", async () => {
|
|
295
|
+
/**
|
|
296
|
+
* When multiple runs exist, reuseConversation should pick the conversation
|
|
297
|
+
* from the most recent successful run (not a failed one).
|
|
298
|
+
*/
|
|
299
|
+
|
|
300
|
+
// GIVEN a recurring schedule with reuseConversation enabled
|
|
301
|
+
const rruleExpr = buildEveryMinuteRrule();
|
|
302
|
+
const schedule = createSchedule({
|
|
303
|
+
name: "Most Recent Success Test",
|
|
304
|
+
cronExpression: rruleExpr,
|
|
305
|
+
message: "Pick latest success",
|
|
306
|
+
syntax: "rrule",
|
|
307
|
+
expression: rruleExpr,
|
|
308
|
+
reuseConversation: true,
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// AND a first successful run
|
|
312
|
+
forceScheduleDue(schedule.id);
|
|
313
|
+
|
|
314
|
+
let shouldFail = false;
|
|
315
|
+
const processedMessages: { conversationId: string; message: string }[] = [];
|
|
316
|
+
const processMessage = async (conversationId: string, message: string) => {
|
|
317
|
+
processedMessages.push({ conversationId, message });
|
|
318
|
+
if (shouldFail) throw new Error("Simulated failure");
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
const scheduler1 = startScheduler(
|
|
322
|
+
processMessage,
|
|
323
|
+
() => {},
|
|
324
|
+
() => {},
|
|
325
|
+
);
|
|
326
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
327
|
+
scheduler1.stop();
|
|
328
|
+
|
|
329
|
+
expect(processedMessages).toHaveLength(1);
|
|
330
|
+
const successConversationId = processedMessages[0].conversationId;
|
|
331
|
+
|
|
332
|
+
// AND a second run that fails
|
|
333
|
+
forceScheduleDue(schedule.id);
|
|
334
|
+
processedMessages.length = 0;
|
|
335
|
+
shouldFail = true;
|
|
336
|
+
|
|
337
|
+
const scheduler2 = startScheduler(
|
|
338
|
+
processMessage,
|
|
339
|
+
() => {},
|
|
340
|
+
() => {},
|
|
341
|
+
);
|
|
342
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
343
|
+
scheduler2.stop();
|
|
344
|
+
|
|
345
|
+
// The failed run created a different conversation (since it failed
|
|
346
|
+
// before the run could reuse — actually it does reuse the same one
|
|
347
|
+
// because the lookup happens before the error). Let's verify the next
|
|
348
|
+
// successful run still uses the original successful conversation.
|
|
349
|
+
|
|
350
|
+
// AND a third run that succeeds
|
|
351
|
+
forceScheduleDue(schedule.id);
|
|
352
|
+
processedMessages.length = 0;
|
|
353
|
+
shouldFail = false;
|
|
354
|
+
|
|
355
|
+
const scheduler3 = startScheduler(
|
|
356
|
+
processMessage,
|
|
357
|
+
() => {},
|
|
358
|
+
() => {},
|
|
359
|
+
);
|
|
360
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
361
|
+
scheduler3.stop();
|
|
362
|
+
|
|
363
|
+
// THEN the third run reuses the conversation from the first successful run
|
|
364
|
+
// (the lookup queries for status="ok", so it picks the first run's conversation)
|
|
365
|
+
expect(processedMessages).toHaveLength(1);
|
|
366
|
+
expect(processedMessages[0].conversationId).toBe(successConversationId);
|
|
367
|
+
});
|
|
368
|
+
});
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { describe, expect, test } from "bun:test";
|
|
3
|
+
|
|
4
|
+
import { drizzle } from "drizzle-orm/bun-sqlite";
|
|
5
|
+
|
|
6
|
+
import { migrateScrubCorruptedImageAttachments } from "../memory/migrations/206-scrub-corrupted-image-attachments.js";
|
|
7
|
+
import * as schema from "../memory/schema.js";
|
|
8
|
+
|
|
9
|
+
function createTestDb() {
|
|
10
|
+
const sqlite = new Database(":memory:");
|
|
11
|
+
sqlite.exec("PRAGMA journal_mode=WAL");
|
|
12
|
+
sqlite.exec("PRAGMA foreign_keys = OFF");
|
|
13
|
+
return drizzle(sqlite, { schema });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type TestDb = ReturnType<typeof createTestDb>;
|
|
17
|
+
|
|
18
|
+
function getRawSqlite(db: TestDb): Database {
|
|
19
|
+
return (db as unknown as { $client: Database }).$client;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function createRequiredTables(raw: Database) {
|
|
23
|
+
raw.exec(/*sql*/ `
|
|
24
|
+
CREATE TABLE memory_checkpoints (
|
|
25
|
+
key TEXT PRIMARY KEY,
|
|
26
|
+
value TEXT NOT NULL,
|
|
27
|
+
updated_at INTEGER NOT NULL
|
|
28
|
+
)
|
|
29
|
+
`);
|
|
30
|
+
|
|
31
|
+
raw.exec(/*sql*/ `
|
|
32
|
+
CREATE TABLE attachments (
|
|
33
|
+
id TEXT PRIMARY KEY,
|
|
34
|
+
original_filename TEXT NOT NULL,
|
|
35
|
+
mime_type TEXT NOT NULL,
|
|
36
|
+
size_bytes INTEGER NOT NULL,
|
|
37
|
+
kind TEXT NOT NULL,
|
|
38
|
+
data_base64 TEXT NOT NULL DEFAULT '',
|
|
39
|
+
content_hash TEXT,
|
|
40
|
+
thumbnail_base64 TEXT,
|
|
41
|
+
file_path TEXT,
|
|
42
|
+
created_at INTEGER NOT NULL
|
|
43
|
+
)
|
|
44
|
+
`);
|
|
45
|
+
|
|
46
|
+
raw.exec(/*sql*/ `
|
|
47
|
+
CREATE TABLE message_attachments (
|
|
48
|
+
id TEXT PRIMARY KEY,
|
|
49
|
+
message_id TEXT NOT NULL,
|
|
50
|
+
attachment_id TEXT NOT NULL,
|
|
51
|
+
position INTEGER NOT NULL DEFAULT 0,
|
|
52
|
+
created_at INTEGER NOT NULL
|
|
53
|
+
)
|
|
54
|
+
`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// A minimal valid PNG header (8 bytes)
|
|
58
|
+
const VALID_PNG_BASE64 = Buffer.from(
|
|
59
|
+
Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 0]),
|
|
60
|
+
).toString("base64");
|
|
61
|
+
|
|
62
|
+
// HTML error page encoded as base64 (simulating Slack CDN auth failure)
|
|
63
|
+
const HTML_ERROR_BASE64 = Buffer.from(
|
|
64
|
+
"<!DOCTYPE html><html><head><title>Sign in</title></head><body>Please sign in</body></html>",
|
|
65
|
+
).toString("base64");
|
|
66
|
+
|
|
67
|
+
// HTML with leading whitespace/BOM
|
|
68
|
+
const HTML_WITH_BOM_BASE64 = Buffer.from(
|
|
69
|
+
"\uFEFF <!DOCTYPE html><html><body>Error</body></html>",
|
|
70
|
+
).toString("base64");
|
|
71
|
+
|
|
72
|
+
const HTML_UPPERCASE_BASE64 = Buffer.from(
|
|
73
|
+
"<HTML><BODY>Error page</BODY></HTML>",
|
|
74
|
+
).toString("base64");
|
|
75
|
+
|
|
76
|
+
describe("migrateScrubCorruptedImageAttachments", () => {
|
|
77
|
+
test("removes corrupted image attachment with HTML data_base64", () => {
|
|
78
|
+
const db = createTestDb();
|
|
79
|
+
const raw = getRawSqlite(db);
|
|
80
|
+
const now = Date.now();
|
|
81
|
+
|
|
82
|
+
createRequiredTables(raw);
|
|
83
|
+
|
|
84
|
+
// Insert corrupted attachment (HTML stored as image/png)
|
|
85
|
+
raw.exec(/*sql*/ `
|
|
86
|
+
INSERT INTO attachments (id, original_filename, mime_type, size_bytes, kind, data_base64, created_at)
|
|
87
|
+
VALUES ('corrupt-1', 'image.png', 'image/png', 100, 'image', '${HTML_ERROR_BASE64}', ${now})
|
|
88
|
+
`);
|
|
89
|
+
|
|
90
|
+
// Insert message_attachments link
|
|
91
|
+
raw.exec(/*sql*/ `
|
|
92
|
+
INSERT INTO message_attachments (id, message_id, attachment_id, position, created_at)
|
|
93
|
+
VALUES ('ma-1', 'msg-1', 'corrupt-1', 0, ${now})
|
|
94
|
+
`);
|
|
95
|
+
|
|
96
|
+
migrateScrubCorruptedImageAttachments(db);
|
|
97
|
+
|
|
98
|
+
const attachmentCount = raw
|
|
99
|
+
.query(`SELECT COUNT(*) AS count FROM attachments`)
|
|
100
|
+
.get() as { count: number };
|
|
101
|
+
expect(attachmentCount.count).toBe(0);
|
|
102
|
+
|
|
103
|
+
const linkCount = raw
|
|
104
|
+
.query(`SELECT COUNT(*) AS count FROM message_attachments`)
|
|
105
|
+
.get() as { count: number };
|
|
106
|
+
expect(linkCount.count).toBe(0);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("does NOT remove valid PNG attachment", () => {
|
|
110
|
+
const db = createTestDb();
|
|
111
|
+
const raw = getRawSqlite(db);
|
|
112
|
+
const now = Date.now();
|
|
113
|
+
|
|
114
|
+
createRequiredTables(raw);
|
|
115
|
+
|
|
116
|
+
// Insert valid PNG attachment
|
|
117
|
+
raw.exec(/*sql*/ `
|
|
118
|
+
INSERT INTO attachments (id, original_filename, mime_type, size_bytes, kind, data_base64, created_at)
|
|
119
|
+
VALUES ('valid-1', 'photo.png', 'image/png', 200, 'image', '${VALID_PNG_BASE64}', ${now})
|
|
120
|
+
`);
|
|
121
|
+
|
|
122
|
+
raw.exec(/*sql*/ `
|
|
123
|
+
INSERT INTO message_attachments (id, message_id, attachment_id, position, created_at)
|
|
124
|
+
VALUES ('ma-valid', 'msg-2', 'valid-1', 0, ${now})
|
|
125
|
+
`);
|
|
126
|
+
|
|
127
|
+
migrateScrubCorruptedImageAttachments(db);
|
|
128
|
+
|
|
129
|
+
const attachmentCount = raw
|
|
130
|
+
.query(`SELECT COUNT(*) AS count FROM attachments`)
|
|
131
|
+
.get() as { count: number };
|
|
132
|
+
expect(attachmentCount.count).toBe(1);
|
|
133
|
+
|
|
134
|
+
const linkCount = raw
|
|
135
|
+
.query(`SELECT COUNT(*) AS count FROM message_attachments`)
|
|
136
|
+
.get() as { count: number };
|
|
137
|
+
expect(linkCount.count).toBe(1);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("removes corrupted and preserves valid attachments together", () => {
|
|
141
|
+
const db = createTestDb();
|
|
142
|
+
const raw = getRawSqlite(db);
|
|
143
|
+
const now = Date.now();
|
|
144
|
+
|
|
145
|
+
createRequiredTables(raw);
|
|
146
|
+
|
|
147
|
+
// Corrupted attachment
|
|
148
|
+
raw.exec(/*sql*/ `
|
|
149
|
+
INSERT INTO attachments (id, original_filename, mime_type, size_bytes, kind, data_base64, created_at)
|
|
150
|
+
VALUES ('corrupt-1', 'slack-img.png', 'image/png', 100, 'image', '${HTML_ERROR_BASE64}', ${now})
|
|
151
|
+
`);
|
|
152
|
+
raw.exec(/*sql*/ `
|
|
153
|
+
INSERT INTO message_attachments (id, message_id, attachment_id, position, created_at)
|
|
154
|
+
VALUES ('ma-corrupt', 'msg-1', 'corrupt-1', 0, ${now})
|
|
155
|
+
`);
|
|
156
|
+
|
|
157
|
+
// Valid attachment
|
|
158
|
+
raw.exec(/*sql*/ `
|
|
159
|
+
INSERT INTO attachments (id, original_filename, mime_type, size_bytes, kind, data_base64, created_at)
|
|
160
|
+
VALUES ('valid-1', 'photo.png', 'image/png', 200, 'image', '${VALID_PNG_BASE64}', ${now})
|
|
161
|
+
`);
|
|
162
|
+
raw.exec(/*sql*/ `
|
|
163
|
+
INSERT INTO message_attachments (id, message_id, attachment_id, position, created_at)
|
|
164
|
+
VALUES ('ma-valid', 'msg-2', 'valid-1', 0, ${now})
|
|
165
|
+
`);
|
|
166
|
+
|
|
167
|
+
migrateScrubCorruptedImageAttachments(db);
|
|
168
|
+
|
|
169
|
+
const remaining = raw.query(`SELECT id FROM attachments`).all() as Array<{
|
|
170
|
+
id: string;
|
|
171
|
+
}>;
|
|
172
|
+
expect(remaining).toEqual([{ id: "valid-1" }]);
|
|
173
|
+
|
|
174
|
+
const links = raw
|
|
175
|
+
.query(`SELECT attachment_id FROM message_attachments`)
|
|
176
|
+
.all() as Array<{ attachment_id: string }>;
|
|
177
|
+
expect(links).toEqual([{ attachment_id: "valid-1" }]);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("detects HTML with leading BOM and whitespace", () => {
|
|
181
|
+
const db = createTestDb();
|
|
182
|
+
const raw = getRawSqlite(db);
|
|
183
|
+
const now = Date.now();
|
|
184
|
+
|
|
185
|
+
createRequiredTables(raw);
|
|
186
|
+
|
|
187
|
+
raw.exec(/*sql*/ `
|
|
188
|
+
INSERT INTO attachments (id, original_filename, mime_type, size_bytes, kind, data_base64, created_at)
|
|
189
|
+
VALUES ('bom-1', 'img.jpg', 'image/jpeg', 50, 'image', '${HTML_WITH_BOM_BASE64}', ${now})
|
|
190
|
+
`);
|
|
191
|
+
|
|
192
|
+
migrateScrubCorruptedImageAttachments(db);
|
|
193
|
+
|
|
194
|
+
const count = raw
|
|
195
|
+
.query(`SELECT COUNT(*) AS count FROM attachments`)
|
|
196
|
+
.get() as { count: number };
|
|
197
|
+
expect(count.count).toBe(0);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("detects uppercase <HTML> tag", () => {
|
|
201
|
+
const db = createTestDb();
|
|
202
|
+
const raw = getRawSqlite(db);
|
|
203
|
+
const now = Date.now();
|
|
204
|
+
|
|
205
|
+
createRequiredTables(raw);
|
|
206
|
+
|
|
207
|
+
raw.exec(/*sql*/ `
|
|
208
|
+
INSERT INTO attachments (id, original_filename, mime_type, size_bytes, kind, data_base64, created_at)
|
|
209
|
+
VALUES ('upper-1', 'img.gif', 'image/gif', 50, 'image', '${HTML_UPPERCASE_BASE64}', ${now})
|
|
210
|
+
`);
|
|
211
|
+
|
|
212
|
+
migrateScrubCorruptedImageAttachments(db);
|
|
213
|
+
|
|
214
|
+
const count = raw
|
|
215
|
+
.query(`SELECT COUNT(*) AS count FROM attachments`)
|
|
216
|
+
.get() as { count: number };
|
|
217
|
+
expect(count.count).toBe(0);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("is idempotent — running twice does not error", () => {
|
|
221
|
+
const db = createTestDb();
|
|
222
|
+
const raw = getRawSqlite(db);
|
|
223
|
+
const now = Date.now();
|
|
224
|
+
|
|
225
|
+
createRequiredTables(raw);
|
|
226
|
+
|
|
227
|
+
raw.exec(/*sql*/ `
|
|
228
|
+
INSERT INTO attachments (id, original_filename, mime_type, size_bytes, kind, data_base64, created_at)
|
|
229
|
+
VALUES ('corrupt-1', 'image.png', 'image/png', 100, 'image', '${HTML_ERROR_BASE64}', ${now})
|
|
230
|
+
`);
|
|
231
|
+
raw.exec(/*sql*/ `
|
|
232
|
+
INSERT INTO message_attachments (id, message_id, attachment_id, position, created_at)
|
|
233
|
+
VALUES ('ma-1', 'msg-1', 'corrupt-1', 0, ${now})
|
|
234
|
+
`);
|
|
235
|
+
|
|
236
|
+
migrateScrubCorruptedImageAttachments(db);
|
|
237
|
+
migrateScrubCorruptedImageAttachments(db);
|
|
238
|
+
|
|
239
|
+
const attachmentCount = raw
|
|
240
|
+
.query(`SELECT COUNT(*) AS count FROM attachments`)
|
|
241
|
+
.get() as { count: number };
|
|
242
|
+
expect(attachmentCount.count).toBe(0);
|
|
243
|
+
|
|
244
|
+
const linkCount = raw
|
|
245
|
+
.query(`SELECT COUNT(*) AS count FROM message_attachments`)
|
|
246
|
+
.get() as { count: number };
|
|
247
|
+
expect(linkCount.count).toBe(0);
|
|
248
|
+
|
|
249
|
+
// The checkpoint should be set to '1' (completed)
|
|
250
|
+
const checkpoint = raw
|
|
251
|
+
.query(
|
|
252
|
+
`SELECT value FROM memory_checkpoints WHERE key = 'migration_scrub_corrupted_image_attachments_v1'`,
|
|
253
|
+
)
|
|
254
|
+
.get() as { value: string } | null;
|
|
255
|
+
expect(checkpoint?.value).toBe("1");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("skips non-image MIME types", () => {
|
|
259
|
+
const db = createTestDb();
|
|
260
|
+
const raw = getRawSqlite(db);
|
|
261
|
+
const now = Date.now();
|
|
262
|
+
|
|
263
|
+
createRequiredTables(raw);
|
|
264
|
+
|
|
265
|
+
// HTML content with text/html MIME type — should NOT be touched
|
|
266
|
+
raw.exec(/*sql*/ `
|
|
267
|
+
INSERT INTO attachments (id, original_filename, mime_type, size_bytes, kind, data_base64, created_at)
|
|
268
|
+
VALUES ('html-1', 'page.html', 'text/html', 100, 'document', '${HTML_ERROR_BASE64}', ${now})
|
|
269
|
+
`);
|
|
270
|
+
|
|
271
|
+
migrateScrubCorruptedImageAttachments(db);
|
|
272
|
+
|
|
273
|
+
const count = raw
|
|
274
|
+
.query(`SELECT COUNT(*) AS count FROM attachments`)
|
|
275
|
+
.get() as { count: number };
|
|
276
|
+
expect(count.count).toBe(1);
|
|
277
|
+
});
|
|
278
|
+
});
|
|
@@ -124,9 +124,10 @@ mock.module("../skills/managed-store.js", () => ({
|
|
|
124
124
|
removeSkillsIndexEntry: () => {},
|
|
125
125
|
validateManagedSkillId: () => null,
|
|
126
126
|
}));
|
|
127
|
-
mock.module("../
|
|
128
|
-
|
|
129
|
-
|
|
127
|
+
mock.module("../memory/graph/capability-seed.js", () => ({
|
|
128
|
+
deleteSkillCapabilityNode: () => {},
|
|
129
|
+
seedSkillGraphNodes: () => {},
|
|
130
|
+
seedUninstalledCatalogSkillMemories: async () => {},
|
|
130
131
|
}));
|
|
131
132
|
mock.module("../util/platform.js", () => ({
|
|
132
133
|
getWorkspaceSkillsDir: () => "/tmp/test-skills",
|