@vellumai/assistant 0.11.5 → 0.11.6-staging.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 +5 -1
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
- package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +70 -0
- package/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +16 -1
- package/node_modules/@vellumai/gateway-client/src/index.ts +6 -2
- package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +121 -61
- package/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +118 -0
- package/node_modules/@vellumai/service-contracts/src/ingress.ts +103 -0
- package/openapi.yaml +421 -15
- package/package.json +1 -1
- package/scripts/sync-web-search-catalog.ts +6 -0
- package/src/__tests__/app-pin-store.test.ts +149 -0
- package/src/__tests__/channel-availability-routes.test.ts +23 -1
- package/src/__tests__/channel-readiness-discord.test.ts +231 -0
- package/src/__tests__/channel-readiness-service.test.ts +126 -0
- package/src/__tests__/channel-readiness-slack-remote.test.ts +141 -0
- package/src/__tests__/channel-reply-delivery.test.ts +4 -4
- package/src/__tests__/client-os-metadata-persistence.test.ts +23 -10
- package/src/__tests__/conversation-delete-watch-timeline.test.ts +231 -0
- package/src/__tests__/conversation-error.test.ts +17 -0
- package/src/__tests__/conversation-seed-composer.test.ts +8 -0
- package/src/__tests__/conversation-slash-commands.test.ts +8 -0
- package/src/__tests__/disk-pressure-policy.test.ts +6 -0
- package/src/__tests__/gemini-provider.test.ts +138 -0
- package/src/__tests__/history-repair.test.ts +105 -3
- package/src/__tests__/identity-routes.test.ts +1 -0
- package/src/__tests__/llm-catalog-parity.test.ts +45 -0
- package/src/__tests__/migration-import-from-path.test.ts +349 -0
- package/src/__tests__/notification-telegram-adapter.test.ts +102 -0
- package/src/__tests__/oauth-commands-routes.test.ts +89 -0
- package/src/__tests__/oauth-provider-profiles.test.ts +7 -6
- package/src/__tests__/openai-provider.test.ts +18 -0
- package/src/__tests__/openai-responses-provider.test.ts +18 -0
- package/src/__tests__/platform-callback-registration.test.ts +184 -0
- package/src/__tests__/plugin-api-store-credential.test.ts +71 -3
- package/src/__tests__/pricing.test.ts +2 -2
- package/src/__tests__/public-ingress-urls.test.ts +36 -0
- package/src/__tests__/resolve-trust-class.test.ts +0 -48
- package/src/__tests__/sanitize-config-for-transfer.test.ts +28 -0
- package/src/__tests__/secret-routes-platform-proxy.test.ts +49 -0
- package/src/__tests__/settings-routes.test.ts +85 -3
- package/src/__tests__/web-search-catalog-parity.test.ts +8 -0
- package/src/agent/history-repair/history-repair.ts +45 -14
- package/src/agent/loop.ts +4 -1
- package/src/api/constants/profile-config-validation.ts +60 -0
- package/src/api/events/tool-result.ts +6 -1
- package/src/api/events/watch-retro-completed.ts +52 -0
- package/src/api/index.ts +11 -0
- package/src/apps/app-pin-reconciler.ts +92 -0
- package/src/apps/app-pin-store.ts +125 -0
- package/src/channels/gateway-channel-socket-health.ts +32 -0
- package/src/channels/gateway-discord-admission.ts +32 -0
- package/src/channels/types.ts +20 -0
- package/src/cli/commands/__tests__/conversations-slack.test.ts +1 -1
- package/src/cli/commands/__tests__/inference-profiles.test.ts +16 -4
- package/src/cli/commands/__tests__/inference-providers.test.ts +67 -2
- package/src/cli/commands/channels/__tests__/channels.test.ts +85 -0
- package/src/cli/commands/channels/index.ts +45 -31
- package/src/cli/commands/inference-profiles.ts +56 -3
- package/src/cli/commands/inference-providers.ts +28 -2
- package/src/cli/commands/oauth/index.help.ts +7 -1
- package/src/cli/commands/oauth/request.test.ts +290 -0
- package/src/cli/commands/oauth/request.ts +57 -41
- package/src/cli/lib/bundled-marketplace.json +14 -1
- package/src/cli/lib/open-browser.test.ts +67 -0
- package/src/cli/lib/open-browser.ts +24 -5
- package/src/config/__tests__/profile-materialization.test.ts +26 -0
- package/src/config/bundled-skills/phone-calls/references/TROUBLESHOOTING.md +6 -0
- package/src/config/bundled-skills/schedule/SKILL.md +1 -1
- package/src/config/feature-flag-registry.json +17 -1
- package/src/config/profile-materialization.ts +29 -0
- package/src/config/sanitize-for-transfer.ts +16 -0
- package/src/config/schemas/llm.ts +7 -0
- package/src/config/schemas/services.ts +6 -0
- package/src/context/outbound-sanitize.ts +6 -0
- package/src/daemon/__tests__/lifecycle-watch-timeline-sweep.test.ts +98 -0
- package/src/daemon/conversation-error.ts +24 -2
- package/src/daemon/conversation-slash.ts +6 -15
- package/src/daemon/daemon-control.ts +1 -0
- package/src/daemon/disk-pressure-policy.ts +7 -1
- package/src/daemon/handlers/__tests__/config-ingress-tunnel-records.test.ts +208 -0
- package/src/daemon/handlers/config-ingress.ts +115 -5
- package/src/daemon/lifecycle.ts +26 -0
- package/src/daemon/message-types/web-activity.ts +3 -2
- package/src/daemon/trust-context.ts +0 -37
- package/src/inbound/__tests__/tunnel-probe.test.ts +448 -0
- package/src/inbound/platform-callback-registration.ts +28 -2
- package/src/inbound/public-ingress-urls.ts +12 -0
- package/src/inbound/tunnel-probe.ts +261 -0
- package/src/live-voice/__tests__/live-voice-connection.test.ts +25 -0
- package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +8 -2
- package/src/live-voice/__tests__/live-voice-session-manager.test.ts +212 -10
- package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +5 -2
- package/src/live-voice/live-voice-connection.ts +46 -8
- package/src/live-voice/live-voice-manager.ts +25 -0
- package/src/live-voice/live-voice-session-manager.ts +318 -2
- package/src/live-voice/live-voice-session.ts +52 -2
- package/src/messaging/providers/__tests__/transport-dispatch.test.ts +126 -68
- package/src/messaging/providers/channel-transport.ts +64 -47
- package/src/messaging/providers/discord/send.test.ts +46 -1
- package/src/messaging/providers/discord/send.ts +51 -0
- package/src/messaging/providers/discord/transport.ts +26 -3
- package/src/messaging/providers/index.ts +22 -47
- package/src/messaging/providers/slack/send.test.ts +83 -26
- package/src/messaging/providers/slack/send.ts +120 -51
- package/src/messaging/providers/slack/stream-tasks.test.ts +26 -0
- package/src/messaging/providers/slack/stream-tasks.ts +39 -0
- package/src/messaging/providers/slack/transport.ts +24 -22
- package/src/messaging/providers/telegram-bot/send.test.ts +109 -12
- package/src/messaging/providers/telegram-bot/send.ts +43 -0
- package/src/messaging/providers/telegram-bot/transport.ts +25 -8
- package/src/notifications/__tests__/assistant-reply-producer.test.ts +30 -7
- package/src/notifications/adapters/telegram.ts +48 -1
- package/src/notifications/assistant-reply-producer.ts +7 -7
- package/src/notifications/conversation-seed-composer.ts +7 -2
- package/src/oauth/byo-connection.test.ts +63 -0
- package/src/oauth/byo-connection.ts +16 -15
- package/src/oauth/connection.test.ts +111 -0
- package/src/oauth/connection.ts +142 -1
- package/src/oauth/platform-connection.test.ts +34 -0
- package/src/oauth/platform-connection.ts +28 -5
- package/src/oauth/seed-providers.ts +15 -1
- package/src/permissions/types.ts +3 -1
- package/src/persistence/conversation-crud.ts +46 -0
- package/src/persistence/conversation-types.ts +11 -9
- package/src/persistence/db-async-query.ts +2 -1
- package/src/persistence/db-maintenance.ts +15 -0
- package/src/persistence/embeddings/qdrant-manager.ts +1 -0
- package/src/persistence/migrations/367-create-watch-timeline-entries.ts +46 -0
- package/src/persistence/migrations/368-watch-timeline-screenshot-blob.ts +33 -0
- package/src/persistence/migrations/369-create-app-pins.ts +37 -0
- package/src/persistence/migrations/__tests__/367-create-watch-timeline-entries.test.ts +98 -0
- package/src/persistence/migrations/__tests__/368-watch-timeline-screenshot-blob.test.ts +98 -0
- package/src/persistence/schema/index.ts +1 -0
- package/src/persistence/schema/infrastructure.ts +17 -0
- package/src/persistence/schema/watch.ts +29 -0
- package/src/persistence/steps.ts +6 -0
- package/src/plugins/mtime-cache.ts +11 -0
- package/src/providers/__tests__/retry-network-error.test.ts +84 -0
- package/src/providers/connection-resolution.ts +23 -1
- package/src/providers/content-blocks.ts +9 -0
- package/src/providers/fetch-provider-catalog.ts +19 -0
- package/src/providers/gemini/client.ts +13 -5
- package/src/providers/inference/__tests__/endpoint-probe.test.ts +92 -0
- package/src/providers/inference/__tests__/profile-config-validation.test.ts +39 -0
- package/src/providers/inference/__tests__/profile-probe-classify.test.ts +66 -0
- package/src/providers/inference/adapter-factory.ts +0 -9
- package/src/providers/inference/credential-rotation.ts +61 -0
- package/src/providers/inference/endpoint-probe.ts +115 -0
- package/src/providers/inference/profile-probe.ts +256 -0
- package/src/providers/model-catalog.ts +170 -125
- package/src/providers/openai/__tests__/api-error-normalization.test.ts +17 -1
- package/src/providers/openai/__tests__/chat-completions-provider-reasoning.test.ts +42 -60
- package/src/providers/openai/__tests__/connection-error-wrap.test.ts +44 -0
- package/src/providers/openai/__tests__/orphan-tool-result-guard.test.ts +34 -2
- package/src/providers/openai/api-error-normalization.ts +16 -2
- package/src/providers/openai/chat-completions-provider.ts +75 -29
- package/src/providers/openai/responses-provider.ts +5 -2
- package/src/providers/openrouter/client.ts +0 -1
- package/src/providers/provider-send-message.ts +11 -0
- package/src/providers/retry.ts +6 -0
- package/src/providers/search-provider-catalog.ts +20 -0
- package/src/providers/vercel-ai-gateway/client.ts +0 -1
- package/src/runtime/AGENTS.md +1 -0
- package/src/runtime/__tests__/desktop-presence.test.ts +27 -4
- package/src/runtime/__tests__/host-observe.test.ts +302 -0
- package/src/runtime/channel-readiness-service.ts +214 -14
- package/src/runtime/channel-readiness-types.ts +49 -2
- package/src/runtime/channel-reply-delivery.ts +2 -2
- package/src/runtime/desktop-presence.ts +24 -21
- package/src/runtime/host-observe.ts +246 -0
- package/src/runtime/http-server.ts +181 -1
- package/src/runtime/migrations/__tests__/staged-import-path.test.ts +104 -0
- package/src/runtime/migrations/staged-import-path.ts +116 -0
- package/src/runtime/routes/__tests__/app-pin-routes.test.ts +383 -0
- package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +80 -0
- package/src/runtime/routes/__tests__/inference-profiles-routes.test.ts +118 -0
- package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +20 -0
- package/src/runtime/routes/__tests__/ingress-status-routes.test.ts +508 -0
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +35 -56
- package/src/runtime/routes/__tests__/watch-routes-guardian-cache.test.ts +139 -0
- package/src/runtime/routes/__tests__/watch-routes.test.ts +598 -0
- package/src/runtime/routes/app-management-routes.ts +140 -29
- package/src/runtime/routes/channel-availability-routes.ts +1 -0
- package/src/runtime/routes/channel-readiness-routes.ts +14 -2
- package/src/runtime/routes/conversation-query-routes.ts +10 -0
- package/src/runtime/routes/guardian-approval-interception.ts +24 -33
- package/src/runtime/routes/host-cu-routes.ts +18 -0
- package/src/runtime/routes/identity-routes.ts +2 -0
- package/src/runtime/routes/inbound-message-handler.ts +10 -7
- package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +166 -308
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +158 -335
- package/src/runtime/routes/index.ts +2 -0
- package/src/runtime/routes/inference-profiles-routes.ts +232 -31
- package/src/runtime/routes/inference-provider-connection-routes.ts +24 -4
- package/src/runtime/routes/ingress-status-routes.ts +180 -0
- package/src/runtime/routes/live-voice-routes.test.ts +40 -1
- package/src/runtime/routes/live-voice-routes.ts +34 -0
- package/src/runtime/routes/migration-routes.ts +218 -10
- package/src/runtime/routes/oauth-commands-routes.ts +23 -16
- package/src/runtime/routes/plugins-routes.ts +12 -28
- package/src/runtime/routes/question-routes.ts +6 -0
- package/src/runtime/routes/secret-routes.ts +7 -27
- package/src/runtime/routes/settings-routes.ts +9 -6
- package/src/runtime/routes/watch-routes.ts +807 -0
- package/src/runtime/slack-reply-session.test.ts +230 -121
- package/src/runtime/slack-reply-session.ts +113 -81
- package/src/runtime/{slack-task-progress.test.ts → task-progress.test.ts} +1 -28
- package/src/runtime/{slack-task-progress.ts → task-progress.ts} +30 -51
- package/src/security/__tests__/untrusted-content.test.ts +42 -0
- package/src/security/untrusted-content.ts +28 -9
- package/src/telemetry/__tests__/live-voice-funnel.test.ts +108 -0
- package/src/telemetry/live-voice-funnel.ts +75 -8
- package/src/tools/credentials/store.ts +18 -6
- package/src/tools/network/__tests__/firecrawl-compat.test.ts +77 -0
- package/src/tools/network/__tests__/web-fetch-fastcrw.test.ts +169 -0
- package/src/tools/network/__tests__/web-search.test.ts +97 -2
- package/src/tools/network/firecrawl-compat.ts +90 -0
- package/src/tools/network/web-fetch.ts +142 -62
- package/src/tools/network/web-search.ts +141 -55
- package/src/tools/types.ts +2 -1
- package/src/util/oauth-request-body.test.ts +74 -0
- package/src/util/oauth-request-body.ts +60 -0
- package/src/util/worker-process.ts +1 -0
- package/src/watch/__tests__/watch-retro.test.ts +665 -0
- package/src/watch/__tests__/watch-session-manager.test.ts +566 -0
- package/src/watch/__tests__/watch-timeline.test.ts +670 -0
- package/src/watch/watch-retro.ts +480 -0
- package/src/watch/watch-session-manager.ts +575 -0
- package/src/watch/watch-timeline.ts +848 -0
|
@@ -773,6 +773,144 @@ describe("GeminiProvider", () => {
|
|
|
773
773
|
});
|
|
774
774
|
});
|
|
775
775
|
|
|
776
|
+
test("splits functionResponse from following text in a user Content", async () => {
|
|
777
|
+
/** Gemini serializes function responses separately from following text. */
|
|
778
|
+
|
|
779
|
+
// GIVEN streamed output and a history containing a tool result followed by text
|
|
780
|
+
fakeChunks = [textChunk("Done"), finishChunk("STOP", 20, 10)];
|
|
781
|
+
|
|
782
|
+
const messages: Message[] = [
|
|
783
|
+
{ role: "user", content: [{ type: "text", text: "Read /tmp/test" }] },
|
|
784
|
+
{
|
|
785
|
+
role: "assistant",
|
|
786
|
+
content: [
|
|
787
|
+
{
|
|
788
|
+
type: "tool_use",
|
|
789
|
+
id: "call_split",
|
|
790
|
+
name: "file_read",
|
|
791
|
+
input: { path: "/tmp/test" },
|
|
792
|
+
},
|
|
793
|
+
],
|
|
794
|
+
},
|
|
795
|
+
{
|
|
796
|
+
role: "user",
|
|
797
|
+
content: [
|
|
798
|
+
{
|
|
799
|
+
type: "tool_result",
|
|
800
|
+
tool_use_id: "call_split",
|
|
801
|
+
content: "file content here",
|
|
802
|
+
},
|
|
803
|
+
{ type: "text", text: "Summarize that." },
|
|
804
|
+
],
|
|
805
|
+
},
|
|
806
|
+
];
|
|
807
|
+
|
|
808
|
+
// WHEN the provider sends the message
|
|
809
|
+
await provider.sendMessage(messages);
|
|
810
|
+
|
|
811
|
+
// THEN the function response is isolated in its own user Content
|
|
812
|
+
const contents = lastStreamParams!.contents as Array<{
|
|
813
|
+
role: string;
|
|
814
|
+
parts: Array<Record<string, unknown>>;
|
|
815
|
+
}>;
|
|
816
|
+
expect(contents).toHaveLength(4);
|
|
817
|
+
|
|
818
|
+
// AND the function response Content appears before the text Content
|
|
819
|
+
expect(contents[2]).toMatchObject({
|
|
820
|
+
role: "user",
|
|
821
|
+
parts: [
|
|
822
|
+
{
|
|
823
|
+
functionResponse: {
|
|
824
|
+
name: "file_read",
|
|
825
|
+
response: { output: "file content here" },
|
|
826
|
+
},
|
|
827
|
+
},
|
|
828
|
+
],
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
// AND the text remains in a separate user Content
|
|
832
|
+
expect(contents[3]).toEqual({
|
|
833
|
+
role: "user",
|
|
834
|
+
parts: [{ text: "Summarize that." }],
|
|
835
|
+
});
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
test("orders tool result media before following text", async () => {
|
|
839
|
+
/** Gemini receives tool result attachments before text that refers to them. */
|
|
840
|
+
|
|
841
|
+
// GIVEN streamed output and a history with an image tool result followed by text
|
|
842
|
+
fakeChunks = [textChunk("Done"), finishChunk("STOP", 20, 10)];
|
|
843
|
+
|
|
844
|
+
const messages: Message[] = [
|
|
845
|
+
{ role: "user", content: [{ type: "text", text: "Read /tmp/test" }] },
|
|
846
|
+
{
|
|
847
|
+
role: "assistant",
|
|
848
|
+
content: [
|
|
849
|
+
{
|
|
850
|
+
type: "tool_use",
|
|
851
|
+
id: "call_image",
|
|
852
|
+
name: "file_read",
|
|
853
|
+
input: { path: "/tmp/test" },
|
|
854
|
+
},
|
|
855
|
+
],
|
|
856
|
+
},
|
|
857
|
+
{
|
|
858
|
+
role: "user",
|
|
859
|
+
content: [
|
|
860
|
+
{
|
|
861
|
+
type: "tool_result",
|
|
862
|
+
tool_use_id: "call_image",
|
|
863
|
+
content: "image result",
|
|
864
|
+
contentBlocks: [
|
|
865
|
+
{
|
|
866
|
+
type: "image",
|
|
867
|
+
source: {
|
|
868
|
+
type: "base64",
|
|
869
|
+
media_type: "image/png",
|
|
870
|
+
data: "iVBORw0KGgo=",
|
|
871
|
+
},
|
|
872
|
+
},
|
|
873
|
+
],
|
|
874
|
+
},
|
|
875
|
+
{ type: "text", text: "Describe that image." },
|
|
876
|
+
],
|
|
877
|
+
},
|
|
878
|
+
];
|
|
879
|
+
|
|
880
|
+
// WHEN the provider sends the message
|
|
881
|
+
await provider.sendMessage(messages);
|
|
882
|
+
|
|
883
|
+
const contents = lastStreamParams!.contents as Array<{
|
|
884
|
+
role: string;
|
|
885
|
+
parts: Array<Record<string, unknown>>;
|
|
886
|
+
}>;
|
|
887
|
+
|
|
888
|
+
// THEN the tool result Contents are ordered by function response, media, and text
|
|
889
|
+
expect(contents.slice(2)).toEqual([
|
|
890
|
+
{
|
|
891
|
+
role: "user",
|
|
892
|
+
parts: [
|
|
893
|
+
{
|
|
894
|
+
functionResponse: {
|
|
895
|
+
name: "file_read",
|
|
896
|
+
response: { output: "image result" },
|
|
897
|
+
},
|
|
898
|
+
},
|
|
899
|
+
],
|
|
900
|
+
},
|
|
901
|
+
{
|
|
902
|
+
role: "user",
|
|
903
|
+
parts: [
|
|
904
|
+
{ inlineData: { mimeType: "image/png", data: "iVBORw0KGgo=" } },
|
|
905
|
+
],
|
|
906
|
+
},
|
|
907
|
+
{
|
|
908
|
+
role: "user",
|
|
909
|
+
parts: [{ text: "Describe that image." }],
|
|
910
|
+
},
|
|
911
|
+
]);
|
|
912
|
+
});
|
|
913
|
+
|
|
776
914
|
function toolResultWithAudio(mediaType: string, data: string): Message[] {
|
|
777
915
|
return [
|
|
778
916
|
{ role: "user", content: [{ type: "text", text: "Read clip" }] },
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
import {
|
|
4
|
+
deepRepairHistory,
|
|
5
|
+
isRepairableOrderingError,
|
|
6
|
+
isUserTerminalHistoryError,
|
|
7
|
+
repairHistory,
|
|
8
|
+
} from "../agent/history-repair/history-repair.js";
|
|
6
9
|
import type { Message } from "../providers/types.js";
|
|
7
10
|
|
|
8
11
|
describe("repairHistory", () => {
|
|
@@ -1172,6 +1175,88 @@ describe("deepRepairHistory", () => {
|
|
|
1172
1175
|
expect(repaired[1].content[0]).toEqual({ type: "text", text: "Hi" });
|
|
1173
1176
|
});
|
|
1174
1177
|
|
|
1178
|
+
test("removes blank text messages so blank assistant tails become user-terminal", () => {
|
|
1179
|
+
/** Blank-text assistant turns carry nothing a provider can serialize. */
|
|
1180
|
+
|
|
1181
|
+
// GIVEN a history whose assistant turns hold only whitespace text
|
|
1182
|
+
const messages: Message[] = [
|
|
1183
|
+
{ role: "user", content: [{ type: "text", text: "Hello" }] },
|
|
1184
|
+
{ role: "assistant", content: [{ type: "text", text: " \n" }] },
|
|
1185
|
+
{ role: "user", content: [{ type: "text", text: "Continue" }] },
|
|
1186
|
+
{ role: "assistant", content: [{ type: "text", text: "" }] },
|
|
1187
|
+
];
|
|
1188
|
+
|
|
1189
|
+
// WHEN it is deep-repaired
|
|
1190
|
+
const { messages: repaired } = deepRepairHistory(messages);
|
|
1191
|
+
|
|
1192
|
+
// THEN only the merged user turn survives
|
|
1193
|
+
expect(repaired).toHaveLength(1);
|
|
1194
|
+
expect(repaired[0].role).toBe("user");
|
|
1195
|
+
expect(repaired[0].content).toEqual([
|
|
1196
|
+
{ type: "text", text: "Hello" },
|
|
1197
|
+
{ type: "text", text: "Continue" },
|
|
1198
|
+
]);
|
|
1199
|
+
});
|
|
1200
|
+
|
|
1201
|
+
test("preserves a non-empty assistant tail by default", () => {
|
|
1202
|
+
/** Providers that accept an assistant-terminal history keep the reply. */
|
|
1203
|
+
|
|
1204
|
+
// GIVEN a history ending with a real assistant reply
|
|
1205
|
+
const messages: Message[] = [
|
|
1206
|
+
{ role: "user", content: [{ type: "text", text: "Hello" }] },
|
|
1207
|
+
{ role: "assistant", content: [{ type: "text", text: "Hi" }] },
|
|
1208
|
+
];
|
|
1209
|
+
|
|
1210
|
+
// WHEN it is deep-repaired without a user-terminal requirement
|
|
1211
|
+
const { messages: repaired } = deepRepairHistory(messages);
|
|
1212
|
+
|
|
1213
|
+
// THEN the reply is kept
|
|
1214
|
+
expect(repaired).toEqual(messages);
|
|
1215
|
+
});
|
|
1216
|
+
|
|
1217
|
+
test("drops a non-empty assistant tail when user-terminal history is required", () => {
|
|
1218
|
+
/** Providers rejecting a model-terminal request get a user-terminal history. */
|
|
1219
|
+
|
|
1220
|
+
// GIVEN a history ending with a real assistant reply
|
|
1221
|
+
const messages: Message[] = [
|
|
1222
|
+
{ role: "user", content: [{ type: "text", text: "Hello" }] },
|
|
1223
|
+
{ role: "assistant", content: [{ type: "text", text: "Hi" }] },
|
|
1224
|
+
];
|
|
1225
|
+
|
|
1226
|
+
// WHEN it is deep-repaired for a provider requiring a user-terminal history
|
|
1227
|
+
const { messages: repaired } = deepRepairHistory(messages, {
|
|
1228
|
+
requireUserTerminal: true,
|
|
1229
|
+
});
|
|
1230
|
+
|
|
1231
|
+
// THEN the trailing reply is dropped
|
|
1232
|
+
expect(repaired).toEqual([messages[0]]);
|
|
1233
|
+
});
|
|
1234
|
+
|
|
1235
|
+
test("drops multiple trailing assistant messages and allows an assistant-only history to empty", () => {
|
|
1236
|
+
/** The whole assistant tail is dropped, even when nothing is left. */
|
|
1237
|
+
|
|
1238
|
+
// GIVEN a history ending with several assistant turns
|
|
1239
|
+
const messages: Message[] = [
|
|
1240
|
+
{ role: "user", content: [{ type: "text", text: "Hello" }] },
|
|
1241
|
+
{ role: "assistant", content: [{ type: "text", text: "One" }] },
|
|
1242
|
+
{ role: "assistant", content: [{ type: "text", text: "Two" }] },
|
|
1243
|
+
];
|
|
1244
|
+
|
|
1245
|
+
// WHEN it is deep-repaired for a provider requiring a user-terminal history
|
|
1246
|
+
const { messages: repaired } = deepRepairHistory(messages, {
|
|
1247
|
+
requireUserTerminal: true,
|
|
1248
|
+
});
|
|
1249
|
+
|
|
1250
|
+
// THEN every trailing assistant turn is gone
|
|
1251
|
+
expect(repaired).toEqual([messages[0]]);
|
|
1252
|
+
|
|
1253
|
+
// AND an assistant-only history repairs to an empty history
|
|
1254
|
+
expect(
|
|
1255
|
+
deepRepairHistory(messages.slice(1), { requireUserTerminal: true })
|
|
1256
|
+
.messages,
|
|
1257
|
+
).toEqual([]);
|
|
1258
|
+
});
|
|
1259
|
+
|
|
1175
1260
|
test("applies standard repair after deep pass", () => {
|
|
1176
1261
|
// Consecutive assistant messages with tool_use but missing tool_result
|
|
1177
1262
|
const messages: Message[] = [
|
|
@@ -1242,6 +1327,23 @@ describe("isRepairableOrderingError", () => {
|
|
|
1242
1327
|
).toBe(true);
|
|
1243
1328
|
});
|
|
1244
1329
|
|
|
1330
|
+
test("matches user-terminal history rejections", () => {
|
|
1331
|
+
/** A model-terminal rejection is recognized as repairable. */
|
|
1332
|
+
|
|
1333
|
+
// GIVEN Gemini's rejection of a request ending with a model turn
|
|
1334
|
+
const message = "Requests ending with a model turn are not supported.";
|
|
1335
|
+
|
|
1336
|
+
// WHEN the message is classified
|
|
1337
|
+
// THEN it is repairable and identified as a user-terminal requirement
|
|
1338
|
+
expect(isRepairableOrderingError(message)).toBe(true);
|
|
1339
|
+
expect(isUserTerminalHistoryError(message)).toBe(true);
|
|
1340
|
+
|
|
1341
|
+
// AND an unrelated provider error is not
|
|
1342
|
+
expect(
|
|
1343
|
+
isUserTerminalHistoryError("The provider returned an unrelated error"),
|
|
1344
|
+
).toBe(false);
|
|
1345
|
+
});
|
|
1346
|
+
|
|
1245
1347
|
test("does not match unrelated provider errors", () => {
|
|
1246
1348
|
expect(isRepairableOrderingError("Rate limit exceeded")).toBe(false);
|
|
1247
1349
|
expect(
|
|
@@ -244,6 +244,51 @@ describe("LLM catalog parity: daemon vs client", () => {
|
|
|
244
244
|
}
|
|
245
245
|
});
|
|
246
246
|
|
|
247
|
+
test("OpenRouter supportsCaching requires cache-read pricing", () => {
|
|
248
|
+
const openrouter = PROVIDER_CATALOG.find((entry) => entry.id === "openrouter");
|
|
249
|
+
expect(openrouter).toBeDefined();
|
|
250
|
+
|
|
251
|
+
for (const model of openrouter!.models) {
|
|
252
|
+
if (model.supportsCaching) {
|
|
253
|
+
expect(
|
|
254
|
+
model.pricing?.cacheReadPer1mTokens,
|
|
255
|
+
`openrouter/${model.id} supportsCaching requires cacheReadPer1mTokens`,
|
|
256
|
+
).toBeGreaterThan(0);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("OpenRouter cache-read pricing implies supportsCaching except xAI", () => {
|
|
262
|
+
const openrouter = PROVIDER_CATALOG.find((entry) => entry.id === "openrouter");
|
|
263
|
+
expect(openrouter).toBeDefined();
|
|
264
|
+
|
|
265
|
+
for (const model of openrouter!.models) {
|
|
266
|
+
if (model.pricing?.cacheReadPer1mTokens === undefined) {
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
if (model.id.startsWith("x-ai/")) {
|
|
270
|
+
expect(
|
|
271
|
+
model.supportsCaching,
|
|
272
|
+
`openrouter/${model.id} keeps supportsCaching false: OpenRouter xAI routes do not report cached tokens`,
|
|
273
|
+
).toBe(false);
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
expect(
|
|
277
|
+
model.supportsCaching,
|
|
278
|
+
`openrouter/${model.id} publishes cacheReadPer1mTokens so supportsCaching must be true`,
|
|
279
|
+
).toBe(true);
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("OpenRouter catalog drops ids OpenRouter no longer serves", () => {
|
|
284
|
+
const openrouter = PROVIDER_CATALOG.find((entry) => entry.id === "openrouter");
|
|
285
|
+
expect(openrouter).toBeDefined();
|
|
286
|
+
const ids = new Set(openrouter!.models.map((model) => model.id));
|
|
287
|
+
expect(ids.has("deepseek/deepseek-v3.2-speciale")).toBe(false);
|
|
288
|
+
expect(ids.has("mistralai/devstral-2512")).toBe(false);
|
|
289
|
+
expect(ids.has("openrouter/owl-alpha")).toBe(false);
|
|
290
|
+
});
|
|
291
|
+
|
|
247
292
|
test("every model default context is capped by its context window", () => {
|
|
248
293
|
const json = loadClientCatalog();
|
|
249
294
|
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP tests for JSON `{ path }` on POST /v1/migrations/import and
|
|
3
|
+
* POST /v1/migrations/import-preflight.
|
|
4
|
+
*
|
|
5
|
+
* The daemon only opens a regular `.vbundle` that realpath-resolves
|
|
6
|
+
* under `${workspace}/.restore-staging/`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
existsSync,
|
|
11
|
+
mkdirSync,
|
|
12
|
+
mkdtempSync,
|
|
13
|
+
realpathSync,
|
|
14
|
+
rmSync,
|
|
15
|
+
symlinkSync,
|
|
16
|
+
writeFileSync,
|
|
17
|
+
} from "node:fs";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import {
|
|
21
|
+
afterAll,
|
|
22
|
+
afterEach,
|
|
23
|
+
beforeEach,
|
|
24
|
+
describe,
|
|
25
|
+
expect,
|
|
26
|
+
mock,
|
|
27
|
+
test,
|
|
28
|
+
} from "bun:test";
|
|
29
|
+
|
|
30
|
+
mock.module("../permissions/trust-store.js", () => ({
|
|
31
|
+
getAllRules: () => [],
|
|
32
|
+
isStarterBundleAccepted: () => false,
|
|
33
|
+
clearCache: () => {},
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
mock.module("../config/env.js", () => ({
|
|
37
|
+
isHttpAuthDisabled: () => true,
|
|
38
|
+
hasUngatedHttpAuthDisabled: () => false,
|
|
39
|
+
getGatewayInternalBaseUrl: () => "http://127.0.0.1:7830",
|
|
40
|
+
getGatewayPort: () => 7830,
|
|
41
|
+
getRuntimeHttpPort: () => 7821,
|
|
42
|
+
getRuntimeHttpHost: () => "127.0.0.1",
|
|
43
|
+
getRuntimeGatewayOriginSecret: () => undefined,
|
|
44
|
+
getIngressPublicBaseUrl: () => undefined,
|
|
45
|
+
setIngressPublicBaseUrl: () => {},
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
import { defaultV1Options } from "../runtime/migrations/__tests__/v1-test-helpers.js";
|
|
49
|
+
import { RESTORE_STAGING_DIRNAME } from "../runtime/migrations/staged-import-path.js";
|
|
50
|
+
import { buildVBundle } from "../runtime/migrations/vbundle-builder.js";
|
|
51
|
+
import {
|
|
52
|
+
handleMigrationImport,
|
|
53
|
+
handleMigrationImportPreflight,
|
|
54
|
+
} from "../runtime/routes/migration-routes.js";
|
|
55
|
+
import { callHandler } from "./helpers/call-route-handler.js";
|
|
56
|
+
|
|
57
|
+
const originalWorkspaceDir = process.env.VELLUM_WORKSPACE_DIR;
|
|
58
|
+
|
|
59
|
+
function freshWorkspaceRoot(): string {
|
|
60
|
+
const parent = realpathSync(
|
|
61
|
+
mkdtempSync(join(tmpdir(), "migration-import-from-path-")),
|
|
62
|
+
);
|
|
63
|
+
const workspaceDir = join(parent, "workspace");
|
|
64
|
+
mkdirSync(workspaceDir, { recursive: true });
|
|
65
|
+
return workspaceDir;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function setWorkspaceDir(dir: string): void {
|
|
69
|
+
process.env.VELLUM_WORKSPACE_DIR = dir;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function makeSmallValidBundle(): Uint8Array {
|
|
73
|
+
const { archive } = buildVBundle({
|
|
74
|
+
files: [
|
|
75
|
+
{
|
|
76
|
+
path: "workspace/data/db/assistant.db",
|
|
77
|
+
data: new TextEncoder().encode("SQLite format 3\0"),
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
path: "workspace/config.json",
|
|
81
|
+
data: new TextEncoder().encode(
|
|
82
|
+
JSON.stringify({ provider: "anthropic", model: "test-model" }),
|
|
83
|
+
),
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
...defaultV1Options(),
|
|
87
|
+
});
|
|
88
|
+
return archive;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function stageBundle(workspaceDir: string, archive: Uint8Array): string {
|
|
92
|
+
const staging = join(workspaceDir, RESTORE_STAGING_DIRNAME);
|
|
93
|
+
mkdirSync(staging, { recursive: true });
|
|
94
|
+
const filename = "backup.vbundle";
|
|
95
|
+
writeFileSync(join(staging, filename), archive);
|
|
96
|
+
return `${RESTORE_STAGING_DIRNAME}/${filename}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface ImportCommitResponse {
|
|
100
|
+
success: boolean;
|
|
101
|
+
summary: {
|
|
102
|
+
total_files: number;
|
|
103
|
+
files_created: number;
|
|
104
|
+
files_overwritten: number;
|
|
105
|
+
files_skipped: number;
|
|
106
|
+
backups_created: number;
|
|
107
|
+
};
|
|
108
|
+
files: Array<{ path: string }>;
|
|
109
|
+
manifest: Record<string, unknown>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface PreflightResponse {
|
|
113
|
+
can_import: boolean;
|
|
114
|
+
summary?: {
|
|
115
|
+
files_to_create: number;
|
|
116
|
+
files_to_overwrite: number;
|
|
117
|
+
files_unchanged: number;
|
|
118
|
+
total_files: number;
|
|
119
|
+
};
|
|
120
|
+
validation?: { is_valid: false; errors: Array<{ code: string }> };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
interface BadRequestResponse {
|
|
124
|
+
error: { code: string; message: string };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
let testWorkspaceRoot: string;
|
|
128
|
+
let testParent: string;
|
|
129
|
+
|
|
130
|
+
beforeEach(() => {
|
|
131
|
+
testWorkspaceRoot = freshWorkspaceRoot();
|
|
132
|
+
testParent = join(testWorkspaceRoot, "..");
|
|
133
|
+
setWorkspaceDir(testWorkspaceRoot);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
afterEach(() => {
|
|
137
|
+
try {
|
|
138
|
+
rmSync(testParent, { recursive: true, force: true });
|
|
139
|
+
} catch {
|
|
140
|
+
/* best effort */
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
afterAll(() => {
|
|
145
|
+
if (originalWorkspaceDir !== undefined) {
|
|
146
|
+
process.env.VELLUM_WORKSPACE_DIR = originalWorkspaceDir;
|
|
147
|
+
} else {
|
|
148
|
+
delete process.env.VELLUM_WORKSPACE_DIR;
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("handleMigrationImport — JSON {path} body", () => {
|
|
153
|
+
test("happy path: streams a staged .vbundle and imports it", async () => {
|
|
154
|
+
const relativePath = stageBundle(
|
|
155
|
+
testWorkspaceRoot,
|
|
156
|
+
makeSmallValidBundle(),
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const req = new Request("http://localhost/v1/migrations/import", {
|
|
160
|
+
method: "POST",
|
|
161
|
+
headers: { "Content-Type": "application/json" },
|
|
162
|
+
body: JSON.stringify({ path: relativePath }),
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const res = await callHandler(handleMigrationImport, req);
|
|
166
|
+
const body = (await res.json()) as ImportCommitResponse;
|
|
167
|
+
|
|
168
|
+
expect(res.status).toBe(200);
|
|
169
|
+
expect(body.success).toBe(true);
|
|
170
|
+
expect(body.summary.total_files).toBeGreaterThan(0);
|
|
171
|
+
expect(body.files.length).toBeGreaterThan(0);
|
|
172
|
+
expect(existsSync(join(testWorkspaceRoot, "data", "db", "assistant.db"))).toBe(
|
|
173
|
+
true,
|
|
174
|
+
);
|
|
175
|
+
expect(existsSync(join(testWorkspaceRoot, "config.json"))).toBe(true);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("absolute path inside staging is accepted", async () => {
|
|
179
|
+
const relativePath = stageBundle(
|
|
180
|
+
testWorkspaceRoot,
|
|
181
|
+
makeSmallValidBundle(),
|
|
182
|
+
);
|
|
183
|
+
const absolutePath = join(testWorkspaceRoot, relativePath);
|
|
184
|
+
|
|
185
|
+
const req = new Request("http://localhost/v1/migrations/import", {
|
|
186
|
+
method: "POST",
|
|
187
|
+
headers: { "Content-Type": "application/json" },
|
|
188
|
+
body: JSON.stringify({ path: absolutePath }),
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const res = await callHandler(handleMigrationImport, req);
|
|
192
|
+
expect(res.status).toBe(200);
|
|
193
|
+
const body = (await res.json()) as ImportCommitResponse;
|
|
194
|
+
expect(body.success).toBe(true);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("both url and path returns 400", async () => {
|
|
198
|
+
const req = new Request("http://localhost/v1/migrations/import", {
|
|
199
|
+
method: "POST",
|
|
200
|
+
headers: { "Content-Type": "application/json" },
|
|
201
|
+
body: JSON.stringify({
|
|
202
|
+
url: "https://storage.googleapis.com/b/o?X-Goog-Signature=x",
|
|
203
|
+
path: `${RESTORE_STAGING_DIRNAME}/backup.vbundle`,
|
|
204
|
+
}),
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const res = await callHandler(handleMigrationImport, req);
|
|
208
|
+
const body = (await res.json()) as BadRequestResponse;
|
|
209
|
+
|
|
210
|
+
expect(res.status).toBe(400);
|
|
211
|
+
expect(body.error.code).toBe("BAD_REQUEST");
|
|
212
|
+
expect(body.error.message).toContain("exactly one");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("missing staged file returns 400", async () => {
|
|
216
|
+
const req = new Request("http://localhost/v1/migrations/import", {
|
|
217
|
+
method: "POST",
|
|
218
|
+
headers: { "Content-Type": "application/json" },
|
|
219
|
+
body: JSON.stringify({
|
|
220
|
+
path: `${RESTORE_STAGING_DIRNAME}/missing.vbundle`,
|
|
221
|
+
}),
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const res = await callHandler(handleMigrationImport, req);
|
|
225
|
+
const body = (await res.json()) as BadRequestResponse;
|
|
226
|
+
|
|
227
|
+
expect(res.status).toBe(400);
|
|
228
|
+
expect(body.error.code).toBe("BAD_REQUEST");
|
|
229
|
+
expect(body.error.message.toLowerCase()).toContain("not found");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("traversal out of staging returns 400", async () => {
|
|
233
|
+
writeFileSync(join(testWorkspaceRoot, "secret.vbundle"), "nope");
|
|
234
|
+
|
|
235
|
+
const req = new Request("http://localhost/v1/migrations/import", {
|
|
236
|
+
method: "POST",
|
|
237
|
+
headers: { "Content-Type": "application/json" },
|
|
238
|
+
body: JSON.stringify({
|
|
239
|
+
path: `${RESTORE_STAGING_DIRNAME}/../secret.vbundle`,
|
|
240
|
+
}),
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const res = await callHandler(handleMigrationImport, req);
|
|
244
|
+
const body = (await res.json()) as BadRequestResponse;
|
|
245
|
+
|
|
246
|
+
expect(res.status).toBe(400);
|
|
247
|
+
expect(body.error.code).toBe("BAD_REQUEST");
|
|
248
|
+
expect(body.error.message.toLowerCase()).toContain("staging");
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("path outside the workspace returns 400", async () => {
|
|
252
|
+
const outside = join(testParent, "outside.vbundle");
|
|
253
|
+
writeFileSync(outside, "nope");
|
|
254
|
+
|
|
255
|
+
const req = new Request("http://localhost/v1/migrations/import", {
|
|
256
|
+
method: "POST",
|
|
257
|
+
headers: { "Content-Type": "application/json" },
|
|
258
|
+
body: JSON.stringify({ path: outside }),
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const res = await callHandler(handleMigrationImport, req);
|
|
262
|
+
const body = (await res.json()) as BadRequestResponse;
|
|
263
|
+
|
|
264
|
+
expect(res.status).toBe(400);
|
|
265
|
+
expect(body.error.code).toBe("BAD_REQUEST");
|
|
266
|
+
expect(body.error.message.toLowerCase()).toContain("staging");
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("symlink in staging returns 400", async () => {
|
|
270
|
+
const relativePath = stageBundle(
|
|
271
|
+
testWorkspaceRoot,
|
|
272
|
+
makeSmallValidBundle(),
|
|
273
|
+
);
|
|
274
|
+
const target = join(testWorkspaceRoot, relativePath);
|
|
275
|
+
const link = join(testWorkspaceRoot, RESTORE_STAGING_DIRNAME, "alias.vbundle");
|
|
276
|
+
symlinkSync(target, link);
|
|
277
|
+
|
|
278
|
+
const req = new Request("http://localhost/v1/migrations/import", {
|
|
279
|
+
method: "POST",
|
|
280
|
+
headers: { "Content-Type": "application/json" },
|
|
281
|
+
body: JSON.stringify({
|
|
282
|
+
path: `${RESTORE_STAGING_DIRNAME}/alias.vbundle`,
|
|
283
|
+
}),
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
const res = await callHandler(handleMigrationImport, req);
|
|
287
|
+
const body = (await res.json()) as BadRequestResponse;
|
|
288
|
+
|
|
289
|
+
expect(res.status).toBe(400);
|
|
290
|
+
expect(body.error.code).toBe("BAD_REQUEST");
|
|
291
|
+
expect(body.error.message.toLowerCase()).toContain("symlink");
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
describe("handleMigrationImportPreflight — JSON {path} body", () => {
|
|
296
|
+
test("happy path: analyzes a staged .vbundle without writing files", async () => {
|
|
297
|
+
const relativePath = stageBundle(
|
|
298
|
+
testWorkspaceRoot,
|
|
299
|
+
makeSmallValidBundle(),
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
const req = new Request("http://localhost/v1/migrations/import-preflight", {
|
|
303
|
+
method: "POST",
|
|
304
|
+
headers: { "Content-Type": "application/json" },
|
|
305
|
+
body: JSON.stringify({ path: relativePath }),
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
const res = await callHandler(handleMigrationImportPreflight, req);
|
|
309
|
+
const body = (await res.json()) as PreflightResponse;
|
|
310
|
+
|
|
311
|
+
expect(res.status).toBe(200);
|
|
312
|
+
expect(body.can_import).toBe(true);
|
|
313
|
+
expect(body.summary?.total_files).toBeGreaterThan(0);
|
|
314
|
+
expect(existsSync(join(testWorkspaceRoot, "config.json"))).toBe(false);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("empty path returns 400", async () => {
|
|
318
|
+
const req = new Request("http://localhost/v1/migrations/import-preflight", {
|
|
319
|
+
method: "POST",
|
|
320
|
+
headers: { "Content-Type": "application/json" },
|
|
321
|
+
body: JSON.stringify({ path: "" }),
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
const res = await callHandler(handleMigrationImportPreflight, req);
|
|
325
|
+
const body = (await res.json()) as BadRequestResponse;
|
|
326
|
+
|
|
327
|
+
expect(res.status).toBe(400);
|
|
328
|
+
expect(body.error.code).toBe("BAD_REQUEST");
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("non-.vbundle staging file returns 400", async () => {
|
|
332
|
+
const staging = join(testWorkspaceRoot, RESTORE_STAGING_DIRNAME);
|
|
333
|
+
mkdirSync(staging, { recursive: true });
|
|
334
|
+
writeFileSync(join(staging, "notes.txt"), "nope");
|
|
335
|
+
|
|
336
|
+
const req = new Request("http://localhost/v1/migrations/import-preflight", {
|
|
337
|
+
method: "POST",
|
|
338
|
+
headers: { "Content-Type": "application/json" },
|
|
339
|
+
body: JSON.stringify({ path: `${RESTORE_STAGING_DIRNAME}/notes.txt` }),
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
const res = await callHandler(handleMigrationImportPreflight, req);
|
|
343
|
+
const body = (await res.json()) as BadRequestResponse;
|
|
344
|
+
|
|
345
|
+
expect(res.status).toBe(400);
|
|
346
|
+
expect(body.error.code).toBe("BAD_REQUEST");
|
|
347
|
+
expect(body.error.message.toLowerCase()).toContain(".vbundle");
|
|
348
|
+
});
|
|
349
|
+
});
|