@vellumai/assistant 0.5.4 → 0.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/Dockerfile +17 -27
  2. package/node_modules/@vellumai/ces-contracts/src/index.ts +1 -0
  3. package/node_modules/@vellumai/ces-contracts/src/trust-rules.ts +42 -0
  4. package/package.json +1 -1
  5. package/src/__tests__/actor-token-service.test.ts +113 -0
  6. package/src/__tests__/config-schema.test.ts +2 -2
  7. package/src/__tests__/context-window-manager.test.ts +78 -0
  8. package/src/__tests__/conversation-title-service.test.ts +30 -1
  9. package/src/__tests__/credential-security-invariants.test.ts +2 -0
  10. package/src/__tests__/docker-signing-key-bootstrap.test.ts +207 -0
  11. package/src/__tests__/memory-regressions.test.ts +8 -30
  12. package/src/__tests__/openai-whisper.test.ts +93 -0
  13. package/src/__tests__/require-fresh-approval.test.ts +4 -0
  14. package/src/__tests__/slack-messaging-token-resolution.test.ts +319 -0
  15. package/src/__tests__/tool-executor-lifecycle-events.test.ts +4 -0
  16. package/src/__tests__/tool-executor.test.ts +4 -0
  17. package/src/__tests__/volume-security-guard.test.ts +155 -0
  18. package/src/cli/commands/conversations.ts +0 -18
  19. package/src/config/bundled-skills/messaging/tools/shared.ts +1 -0
  20. package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +16 -37
  21. package/src/config/env-registry.ts +9 -0
  22. package/src/config/env.ts +8 -2
  23. package/src/config/feature-flag-registry.json +8 -8
  24. package/src/config/schema.ts +0 -12
  25. package/src/config/schemas/memory.ts +0 -4
  26. package/src/config/schemas/platform.ts +1 -1
  27. package/src/config/schemas/security.ts +4 -0
  28. package/src/context/window-manager.ts +53 -2
  29. package/src/credential-execution/managed-catalog.ts +5 -15
  30. package/src/daemon/conversation-agent-loop.ts +0 -60
  31. package/src/daemon/conversation-memory.ts +0 -117
  32. package/src/daemon/conversation-runtime-assembly.ts +0 -2
  33. package/src/daemon/daemon-control.ts +7 -0
  34. package/src/daemon/handlers/conversations.ts +0 -11
  35. package/src/daemon/lifecycle.ts +10 -47
  36. package/src/daemon/providers-setup.ts +2 -1
  37. package/src/followups/followup-store.ts +5 -2
  38. package/src/hooks/manager.ts +7 -0
  39. package/src/instrument.ts +33 -1
  40. package/src/memory/conversation-crud.ts +0 -236
  41. package/src/memory/conversation-title-service.ts +26 -10
  42. package/src/memory/db-init.ts +5 -13
  43. package/src/memory/embedding-local.ts +11 -5
  44. package/src/memory/indexer.ts +15 -106
  45. package/src/memory/job-handlers/conversation-starters.ts +24 -36
  46. package/src/memory/job-handlers/embedding.ts +0 -79
  47. package/src/memory/job-utils.ts +1 -1
  48. package/src/memory/jobs-store.ts +0 -8
  49. package/src/memory/jobs-worker.ts +0 -20
  50. package/src/memory/migrations/189-drop-simplified-memory.ts +42 -0
  51. package/src/memory/migrations/index.ts +1 -3
  52. package/src/memory/qdrant-client.ts +4 -6
  53. package/src/memory/schema/conversations.ts +0 -3
  54. package/src/memory/schema/index.ts +0 -2
  55. package/src/messaging/draft-store.ts +2 -2
  56. package/src/messaging/provider.ts +9 -0
  57. package/src/messaging/providers/slack/adapter.ts +29 -2
  58. package/src/oauth/connection-resolver.test.ts +22 -18
  59. package/src/oauth/connection-resolver.ts +92 -7
  60. package/src/oauth/platform-connection.test.ts +78 -69
  61. package/src/oauth/platform-connection.ts +12 -19
  62. package/src/permissions/defaults.ts +3 -3
  63. package/src/permissions/trust-client.ts +332 -0
  64. package/src/permissions/trust-store-interface.ts +105 -0
  65. package/src/permissions/trust-store.ts +531 -39
  66. package/src/platform/client.test.ts +148 -0
  67. package/src/platform/client.ts +71 -0
  68. package/src/providers/speech-to-text/openai-whisper.test.ts +190 -0
  69. package/src/providers/speech-to-text/openai-whisper.ts +68 -0
  70. package/src/providers/speech-to-text/resolve.ts +9 -0
  71. package/src/providers/speech-to-text/types.ts +17 -0
  72. package/src/runtime/auth/route-policy.ts +14 -0
  73. package/src/runtime/auth/token-service.ts +133 -0
  74. package/src/runtime/http-server.ts +4 -2
  75. package/src/runtime/routes/conversation-management-routes.ts +0 -36
  76. package/src/runtime/routes/conversation-query-routes.ts +44 -2
  77. package/src/runtime/routes/conversation-routes.ts +2 -1
  78. package/src/runtime/routes/inbound-message-handler.ts +27 -3
  79. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +16 -1
  80. package/src/runtime/routes/inbound-stages/transcribe-audio.test.ts +287 -0
  81. package/src/runtime/routes/inbound-stages/transcribe-audio.ts +122 -0
  82. package/src/runtime/routes/log-export-routes.ts +1 -0
  83. package/src/runtime/routes/memory-item-routes.test.ts +221 -3
  84. package/src/runtime/routes/memory-item-routes.ts +124 -2
  85. package/src/runtime/routes/secret-routes.ts +4 -1
  86. package/src/runtime/routes/upgrade-broadcast-routes.ts +151 -0
  87. package/src/schedule/schedule-store.ts +0 -21
  88. package/src/security/ces-credential-client.ts +173 -0
  89. package/src/security/secure-keys.ts +65 -22
  90. package/src/signals/bash.ts +3 -0
  91. package/src/signals/cancel.ts +3 -0
  92. package/src/signals/confirm.ts +3 -0
  93. package/src/signals/conversation-undo.ts +3 -0
  94. package/src/signals/event-stream.ts +7 -0
  95. package/src/signals/shotgun.ts +3 -0
  96. package/src/signals/trust-rule.ts +3 -0
  97. package/src/skills/inline-command-render.ts +5 -1
  98. package/src/skills/inline-command-runner.ts +30 -2
  99. package/src/telemetry/usage-telemetry-reporter.test.ts +23 -36
  100. package/src/telemetry/usage-telemetry-reporter.ts +21 -19
  101. package/src/tools/memory/handlers.ts +1 -129
  102. package/src/tools/permission-checker.ts +18 -0
  103. package/src/tools/skills/load.ts +9 -2
  104. package/src/util/device-id.ts +70 -7
  105. package/src/util/logger.ts +35 -9
  106. package/src/util/platform.ts +29 -5
  107. package/src/util/xml.ts +8 -0
  108. package/src/workspace/heartbeat-service.ts +5 -24
  109. package/src/workspace/migrations/migrate-to-workspace-volume.ts +113 -0
  110. package/src/workspace/migrations/registry.ts +2 -0
  111. package/src/__tests__/archive-recall.test.ts +0 -560
  112. package/src/__tests__/conversation-memory-dirty-tail.test.ts +0 -150
  113. package/src/__tests__/conversation-switch-memory-reduction.test.ts +0 -474
  114. package/src/__tests__/db-memory-archive-migration.test.ts +0 -372
  115. package/src/__tests__/db-memory-brief-state-migration.test.ts +0 -213
  116. package/src/__tests__/db-memory-reducer-checkpoints.test.ts +0 -273
  117. package/src/__tests__/memory-brief-open-loops.test.ts +0 -530
  118. package/src/__tests__/memory-brief-time.test.ts +0 -285
  119. package/src/__tests__/memory-brief-wrapper.test.ts +0 -311
  120. package/src/__tests__/memory-chunk-archive.test.ts +0 -400
  121. package/src/__tests__/memory-chunk-dual-write.test.ts +0 -453
  122. package/src/__tests__/memory-episode-archive.test.ts +0 -370
  123. package/src/__tests__/memory-episode-dual-write.test.ts +0 -626
  124. package/src/__tests__/memory-observation-archive.test.ts +0 -375
  125. package/src/__tests__/memory-observation-dual-write.test.ts +0 -318
  126. package/src/__tests__/memory-reducer-job.test.ts +0 -538
  127. package/src/__tests__/memory-reducer-scheduling.test.ts +0 -473
  128. package/src/__tests__/memory-reducer-store.test.ts +0 -728
  129. package/src/__tests__/memory-reducer-types.test.ts +0 -707
  130. package/src/__tests__/memory-reducer.test.ts +0 -704
  131. package/src/__tests__/memory-simplified-config.test.ts +0 -281
  132. package/src/__tests__/simplified-memory-e2e.test.ts +0 -666
  133. package/src/__tests__/simplified-memory-runtime.test.ts +0 -616
  134. package/src/config/schemas/memory-simplified.ts +0 -101
  135. package/src/memory/archive-recall.ts +0 -516
  136. package/src/memory/archive-store.ts +0 -400
  137. package/src/memory/brief-formatting.ts +0 -33
  138. package/src/memory/brief-open-loops.ts +0 -266
  139. package/src/memory/brief-time.ts +0 -162
  140. package/src/memory/brief.ts +0 -75
  141. package/src/memory/job-handlers/backfill-simplified-memory.ts +0 -462
  142. package/src/memory/job-handlers/reduce-conversation-memory.ts +0 -229
  143. package/src/memory/migrations/185-memory-brief-state.ts +0 -52
  144. package/src/memory/migrations/186-memory-archive.ts +0 -109
  145. package/src/memory/migrations/187-memory-reducer-checkpoints.ts +0 -19
  146. package/src/memory/reducer-scheduler.ts +0 -242
  147. package/src/memory/reducer-store.ts +0 -271
  148. package/src/memory/reducer-types.ts +0 -106
  149. package/src/memory/reducer.ts +0 -467
  150. package/src/memory/schema/memory-archive.ts +0 -121
  151. package/src/memory/schema/memory-brief.ts +0 -55
@@ -540,23 +540,13 @@ describe("Memory regressions", () => {
540
540
 
541
541
  test("memory_save sets verificationState to user_confirmed", async () => {
542
542
  const { handleMemorySave } = await import("../tools/memory/handlers.js");
543
- const legacyConfig = {
544
- ...DEFAULT_CONFIG,
545
- memory: {
546
- ...DEFAULT_CONFIG.memory,
547
- simplified: {
548
- ...DEFAULT_CONFIG.memory.simplified,
549
- enabled: false,
550
- },
551
- },
552
- };
553
543
 
554
544
  const result = await handleMemorySave(
555
545
  {
556
546
  statement: "User explicitly saved this preference",
557
547
  kind: "preference",
558
548
  },
559
- legacyConfig,
549
+ DEFAULT_CONFIG,
560
550
  "conv-verify-save",
561
551
  "msg-verify-save",
562
552
  );
@@ -573,23 +563,13 @@ describe("Memory regressions", () => {
573
563
 
574
564
  test("memory_save in different scopes creates separate items", async () => {
575
565
  const { handleMemorySave } = await import("../tools/memory/handlers.js");
576
- const legacyConfig = {
577
- ...DEFAULT_CONFIG,
578
- memory: {
579
- ...DEFAULT_CONFIG.memory,
580
- simplified: {
581
- ...DEFAULT_CONFIG.memory.simplified,
582
- enabled: false,
583
- },
584
- },
585
- };
586
566
 
587
567
  const sharedArgs = { statement: "I prefer dark mode", kind: "preference" };
588
568
 
589
569
  // Save in the default scope
590
570
  const r1 = await handleMemorySave(
591
571
  sharedArgs,
592
- legacyConfig,
572
+ DEFAULT_CONFIG,
593
573
  "conv-scope-1",
594
574
  "msg-scope-1",
595
575
  "default",
@@ -600,7 +580,7 @@ describe("Memory regressions", () => {
600
580
  // Save the identical statement in a private scope
601
581
  const r2 = await handleMemorySave(
602
582
  sharedArgs,
603
- legacyConfig,
583
+ DEFAULT_CONFIG,
604
584
  "conv-scope-2",
605
585
  "msg-scope-2",
606
586
  "private-abc",
@@ -624,7 +604,7 @@ describe("Memory regressions", () => {
624
604
  // Saving the same statement again in default scope should dedup (not create a third)
625
605
  const r3 = await handleMemorySave(
626
606
  sharedArgs,
627
- legacyConfig,
607
+ DEFAULT_CONFIG,
628
608
  "conv-scope-3",
629
609
  "msg-scope-3",
630
610
  "default",
@@ -3227,9 +3207,8 @@ describe("Memory regressions", () => {
3227
3207
  .filter((j) => JSON.parse(j.payload).messageId === "msg-untrusted-gate");
3228
3208
  expect(extractJobs.length).toBe(0);
3229
3209
 
3230
- // enqueuedJobs reflects legacy embed_segment + archive embed_chunk per
3231
- // segment, plus the summary job, with extract_items gated off.
3232
- const expectedJobs = result.indexedSegments * 2 + 1;
3210
+ // enqueuedJobs should reflect: embed jobs + summary (1), no extract (0)
3211
+ const expectedJobs = result.indexedSegments + 1; // embed per segment + summary
3233
3212
  expect(result.enqueuedJobs).toBe(expectedJobs);
3234
3213
  });
3235
3214
 
@@ -3410,9 +3389,8 @@ describe("Memory regressions", () => {
3410
3389
  .filter((j) => JSON.parse(j.payload).messageId === "msg-unverified-gate");
3411
3390
  expect(extractJobs.length).toBe(0);
3412
3391
 
3413
- // enqueuedJobs reflects legacy embed_segment + archive embed_chunk per
3414
- // segment, plus the summary job, with extract_items gated off.
3415
- const expectedJobs = result.indexedSegments * 2 + 1;
3392
+ // enqueuedJobs should reflect: embed jobs + summary (1), no extract (0)
3393
+ const expectedJobs = result.indexedSegments + 1; // embed per segment + summary
3416
3394
  expect(result.enqueuedJobs).toBe(expectedJobs);
3417
3395
  });
3418
3396
 
@@ -0,0 +1,93 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+
3
+ import { OpenAIWhisperProvider } from "../providers/speech-to-text/openai-whisper.js";
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Mock fetch — capture outgoing FormData so we can assert filenames
7
+ // ---------------------------------------------------------------------------
8
+
9
+ const originalFetch = globalThis.fetch;
10
+
11
+ let capturedFormData: FormData | null = null;
12
+
13
+ function mockFetch(
14
+ _url: string | URL | Request,
15
+ init?: RequestInit,
16
+ ): Promise<Response> {
17
+ if (init?.body instanceof FormData) {
18
+ capturedFormData = init.body;
19
+ }
20
+ return Promise.resolve(
21
+ new Response(JSON.stringify({ text: "hello world" }), {
22
+ status: 200,
23
+ headers: { "Content-Type": "application/json" },
24
+ }),
25
+ );
26
+ }
27
+
28
+ beforeEach(() => {
29
+ capturedFormData = null;
30
+ globalThis.fetch = mockFetch as typeof fetch;
31
+ });
32
+
33
+ afterEach(() => {
34
+ globalThis.fetch = originalFetch;
35
+ });
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Tests
39
+ // ---------------------------------------------------------------------------
40
+
41
+ describe("OpenAIWhisperProvider", () => {
42
+ const provider = new OpenAIWhisperProvider("test-api-key");
43
+ const dummyAudio = Buffer.from("fake-audio-data");
44
+
45
+ describe("extensionFromMime (via transcribe filename)", () => {
46
+ test("plain MIME type resolves to correct extension", async () => {
47
+ await provider.transcribe(dummyAudio, "audio/ogg");
48
+ const file = capturedFormData?.get("file") as File;
49
+ expect(file.name).toBe("audio.ogg");
50
+ });
51
+
52
+ test("MIME type with parameters resolves to correct extension", async () => {
53
+ await provider.transcribe(dummyAudio, "audio/ogg; codecs=opus");
54
+ const file = capturedFormData?.get("file") as File;
55
+ expect(file.name).toBe("audio.ogg");
56
+ });
57
+
58
+ test("MIME type with extra whitespace around parameters", async () => {
59
+ await provider.transcribe(dummyAudio, "audio/mpeg ; bitrate=128");
60
+ const file = capturedFormData?.get("file") as File;
61
+ expect(file.name).toBe("audio.mp3");
62
+ });
63
+
64
+ test("unknown MIME type falls back to .audio", async () => {
65
+ await provider.transcribe(dummyAudio, "audio/unknown-format");
66
+ const file = capturedFormData?.get("file") as File;
67
+ expect(file.name).toBe("audio.audio");
68
+ });
69
+
70
+ test("unknown MIME type with parameters still falls back", async () => {
71
+ await provider.transcribe(dummyAudio, "audio/unknown; foo=bar");
72
+ const file = capturedFormData?.get("file") as File;
73
+ expect(file.name).toBe("audio.audio");
74
+ });
75
+
76
+ test.each([
77
+ ["audio/wav", "audio.wav"],
78
+ ["audio/x-wav", "audio.wav"],
79
+ ["audio/mpeg", "audio.mp3"],
80
+ ["audio/mp3", "audio.mp3"],
81
+ ["audio/ogg", "audio.ogg"],
82
+ ["audio/opus", "audio.opus"],
83
+ ["audio/webm", "audio.webm"],
84
+ ["audio/mp4", "audio.m4a"],
85
+ ["audio/x-m4a", "audio.m4a"],
86
+ ["audio/flac", "audio.flac"],
87
+ ])("%s → %s", async (mime, expectedFilename) => {
88
+ await provider.transcribe(dummyAudio, mime);
89
+ const file = capturedFormData?.get("file") as File;
90
+ expect(file.name).toBe(expectedFilename);
91
+ });
92
+ });
93
+ });
@@ -58,6 +58,10 @@ const mockConfig = {
58
58
  action: "warn" as const,
59
59
  entropyThreshold: 4.0,
60
60
  },
61
+ permissions: {
62
+ mode: "workspace" as const,
63
+ dangerouslySkipPermissions: false,
64
+ },
61
65
  };
62
66
 
63
67
  let fakeToolResult: ToolExecutionResult = { content: "ok", isError: false };
@@ -0,0 +1,319 @@
1
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
2
+
3
+ import type { OAuthConnection } from "../oauth/connection.js";
4
+
5
+ // ── Mocks ───────────────────────────────────────────────────────────────────
6
+
7
+ const getSecureKeyAsyncMock = mock(
8
+ async (_key: string): Promise<string | null> => null,
9
+ );
10
+ const isProviderConnectedMock = mock(
11
+ async (_service: string): Promise<boolean> => false,
12
+ );
13
+ const resolveOAuthConnectionMock = mock(
14
+ async (
15
+ _service: string,
16
+ _opts?: { account?: string },
17
+ ): Promise<OAuthConnection> =>
18
+ ({ accessToken: "oauth-token" }) as unknown as OAuthConnection,
19
+ );
20
+ const getConnectionByProviderMock = mock(
21
+ (_provider: string): { status: string } | undefined => undefined,
22
+ );
23
+
24
+ mock.module("../security/secure-keys.js", () => ({
25
+ getSecureKeyAsync: getSecureKeyAsyncMock,
26
+ }));
27
+
28
+ mock.module("../oauth/oauth-store.js", () => ({
29
+ isProviderConnected: isProviderConnectedMock,
30
+ getConnectionByProvider: getConnectionByProviderMock,
31
+ }));
32
+
33
+ mock.module("../oauth/connection-resolver.js", () => ({
34
+ resolveOAuthConnection: resolveOAuthConnectionMock,
35
+ }));
36
+
37
+ // Telegram adapter imports modules that need more stubs
38
+ mock.module("../config/env.js", () => ({
39
+ getGatewayInternalBaseUrl: () => "http://localhost:3000",
40
+ }));
41
+ mock.module("../memory/conversation-key-store.js", () => ({
42
+ getOrCreateConversation: async () => "conv-1",
43
+ }));
44
+ mock.module("../memory/external-conversation-store.js", () => ({
45
+ getExternalConversation: () => undefined,
46
+ setExternalConversation: () => {},
47
+ }));
48
+ mock.module("../runtime/auth/token-service.js", () => ({
49
+ mintDaemonDeliveryToken: async () => "delivery-token",
50
+ }));
51
+
52
+ // Slack client stubs (not exercised in these tests, but required on import)
53
+ mock.module("../messaging/providers/slack/client.js", () => ({}));
54
+
55
+ // Gmail client stubs
56
+ mock.module("../messaging/providers/gmail/client.js", () => ({}));
57
+ mock.module("../messaging/providers/gmail/people-client.js", () => ({}));
58
+
59
+ // Telegram client stubs
60
+ mock.module("../messaging/providers/telegram-bot/client.js", () => ({}));
61
+
62
+ import {
63
+ getProviderConnection,
64
+ resolveProvider,
65
+ } from "../config/bundled-skills/messaging/tools/shared.js";
66
+ import { gmailMessagingProvider } from "../messaging/providers/gmail/adapter.js";
67
+ import { slackProvider } from "../messaging/providers/slack/adapter.js";
68
+ import { telegramBotMessagingProvider } from "../messaging/providers/telegram-bot/adapter.js";
69
+ import {
70
+ getConnectedProviders,
71
+ registerMessagingProvider,
72
+ } from "../messaging/registry.js";
73
+
74
+ // Register providers for integration tests
75
+ registerMessagingProvider(slackProvider);
76
+ registerMessagingProvider(gmailMessagingProvider);
77
+ registerMessagingProvider(telegramBotMessagingProvider);
78
+
79
+ // ── Helpers ─────────────────────────────────────────────────────────────────
80
+
81
+ function resetAllMocks() {
82
+ getSecureKeyAsyncMock.mockReset();
83
+ getSecureKeyAsyncMock.mockImplementation(async () => null);
84
+ isProviderConnectedMock.mockReset();
85
+ isProviderConnectedMock.mockImplementation(async () => false);
86
+ resolveOAuthConnectionMock.mockReset();
87
+ resolveOAuthConnectionMock.mockImplementation(
88
+ async () => ({ accessToken: "oauth-token" }) as unknown as OAuthConnection,
89
+ );
90
+ getConnectionByProviderMock.mockReset();
91
+ getConnectionByProviderMock.mockImplementation(() => undefined);
92
+ }
93
+
94
+ // ── Tests ───────────────────────────────────────────────────────────────────
95
+
96
+ describe("Slack messaging token resolution", () => {
97
+ beforeEach(resetAllMocks);
98
+
99
+ // ── slackProvider.isConnected() ─────────────────────────────────────────
100
+
101
+ describe("slackProvider.isConnected()", () => {
102
+ test("returns true when slack_channel bot token exists in credential store", async () => {
103
+ getSecureKeyAsyncMock.mockImplementation(async (key: string) =>
104
+ key === "credential/slack_channel/bot_token" ? "xoxb-bot-token" : null,
105
+ );
106
+
107
+ expect(await slackProvider.isConnected!()).toBe(true);
108
+ });
109
+
110
+ test("returns true even if slack_channel connection row is missing (backfill failure resilience)", async () => {
111
+ // Bot token exists but no connection row — isConnected should still return true
112
+ getSecureKeyAsyncMock.mockImplementation(async (key: string) =>
113
+ key === "credential/slack_channel/bot_token" ? "xoxb-bot-token" : null,
114
+ );
115
+ // No getConnectionByProvider call expected — Slack adapter checks token first
116
+
117
+ expect(await slackProvider.isConnected!()).toBe(true);
118
+ });
119
+
120
+ test("returns true when only integration:slack has active OAuth connection (backwards compat)", async () => {
121
+ // No bot token
122
+ getSecureKeyAsyncMock.mockImplementation(async () => null);
123
+ // But OAuth provider is connected
124
+ isProviderConnectedMock.mockImplementation(async (service: string) =>
125
+ service === "integration:slack" ? true : false,
126
+ );
127
+
128
+ expect(await slackProvider.isConnected!()).toBe(true);
129
+ });
130
+
131
+ test("returns false when neither credential path exists", async () => {
132
+ getSecureKeyAsyncMock.mockImplementation(async () => null);
133
+ isProviderConnectedMock.mockImplementation(async () => false);
134
+
135
+ expect(await slackProvider.isConnected!()).toBe(false);
136
+ });
137
+ });
138
+
139
+ // ── slackProvider.resolveConnection() ───────────────────────────────────
140
+
141
+ describe("slackProvider.resolveConnection()", () => {
142
+ test("returns bot token string when Socket Mode credentials exist", async () => {
143
+ getSecureKeyAsyncMock.mockImplementation(async (key: string) =>
144
+ key === "credential/slack_channel/bot_token"
145
+ ? "xoxb-socket-token"
146
+ : null,
147
+ );
148
+
149
+ const result = await slackProvider.resolveConnection!();
150
+ expect(result).toBe("xoxb-socket-token");
151
+ });
152
+
153
+ test("returns bot token string even without a slack_channel connection row (token-only resilience)", async () => {
154
+ getSecureKeyAsyncMock.mockImplementation(async (key: string) =>
155
+ key === "credential/slack_channel/bot_token" ? "xoxb-token-only" : null,
156
+ );
157
+ // No connection row — resolveConnection should still return the token
158
+
159
+ const result = await slackProvider.resolveConnection!();
160
+ expect(result).toBe("xoxb-token-only");
161
+ });
162
+
163
+ test("returns OAuthConnection when only OAuth integration:slack credentials exist (backwards compat)", async () => {
164
+ getSecureKeyAsyncMock.mockImplementation(async () => null);
165
+ const oauthConn = {
166
+ accessToken: "xoxp-oauth-token",
167
+ } as unknown as OAuthConnection;
168
+ resolveOAuthConnectionMock.mockImplementation(async () => oauthConn);
169
+
170
+ const result = await slackProvider.resolveConnection!();
171
+ expect(result).toBe(oauthConn);
172
+ expect(resolveOAuthConnectionMock).toHaveBeenCalledWith(
173
+ "integration:slack",
174
+ { account: undefined },
175
+ );
176
+ });
177
+
178
+ test("throws when no credentials exist at all (no Socket Mode, no OAuth)", async () => {
179
+ getSecureKeyAsyncMock.mockImplementation(async () => null);
180
+ resolveOAuthConnectionMock.mockImplementation(async () => {
181
+ throw new Error("No OAuth connection found for integration:slack");
182
+ });
183
+
184
+ await expect(slackProvider.resolveConnection!()).rejects.toThrow(
185
+ "No OAuth connection found",
186
+ );
187
+ });
188
+ });
189
+
190
+ // ── getProviderConnection() integration ─────────────────────────────────
191
+
192
+ describe("getProviderConnection()", () => {
193
+ test("returns bot token string for Slack when Socket Mode credentials exist", async () => {
194
+ getSecureKeyAsyncMock.mockImplementation(async (key: string) =>
195
+ key === "credential/slack_channel/bot_token" ? "xoxb-conn-token" : null,
196
+ );
197
+
198
+ const result = await getProviderConnection(slackProvider);
199
+ expect(result).toBe("xoxb-conn-token");
200
+ });
201
+
202
+ test("returns OAuthConnection for Slack when only OAuth credentials exist (backwards compat)", async () => {
203
+ getSecureKeyAsyncMock.mockImplementation(async () => null);
204
+ const oauthConn = {
205
+ accessToken: "xoxp-oauth-token",
206
+ } as unknown as OAuthConnection;
207
+ resolveOAuthConnectionMock.mockImplementation(async () => oauthConn);
208
+
209
+ const result = await getProviderConnection(slackProvider);
210
+ expect(result).toBe(oauthConn);
211
+ });
212
+
213
+ test("throws when no Slack credentials exist", async () => {
214
+ getSecureKeyAsyncMock.mockImplementation(async () => null);
215
+ resolveOAuthConnectionMock.mockImplementation(async () => {
216
+ throw new Error("No OAuth connection found");
217
+ });
218
+
219
+ await expect(getProviderConnection(slackProvider)).rejects.toThrow(
220
+ "No OAuth connection found",
221
+ );
222
+ });
223
+
224
+ test('Telegram still returns "" (no resolveConnection, uses isConnected path — regression check)', async () => {
225
+ // Telegram has isConnected but no resolveConnection.
226
+ // When isConnected returns true, getProviderConnection returns ""
227
+ getSecureKeyAsyncMock.mockImplementation(async (key: string) => {
228
+ if (key === "credential/telegram/bot_token") return "bot-token";
229
+ if (key === "credential/telegram/webhook_secret") return "secret";
230
+ return null;
231
+ });
232
+ getConnectionByProviderMock.mockImplementation((provider: string) =>
233
+ provider === "telegram" ? { status: "active" } : undefined,
234
+ );
235
+
236
+ const result = await getProviderConnection(telegramBotMessagingProvider);
237
+ expect(result).toBe("");
238
+ });
239
+
240
+ test("Gmail still calls resolveOAuthConnection (no resolveConnection, no isConnected — regression check)", async () => {
241
+ // Gmail has neither resolveConnection nor isConnected.
242
+ // getProviderConnection falls through to resolveOAuthConnection.
243
+ const oauthConn = {
244
+ accessToken: "gmail-oauth-token",
245
+ } as unknown as OAuthConnection;
246
+ resolveOAuthConnectionMock.mockImplementation(async () => oauthConn);
247
+
248
+ const result = await getProviderConnection(gmailMessagingProvider);
249
+ expect(result).toBe(oauthConn);
250
+ expect(resolveOAuthConnectionMock).toHaveBeenCalledWith(
251
+ "integration:google",
252
+ { account: undefined },
253
+ );
254
+ });
255
+ });
256
+
257
+ // ── resolveProvider() multi-platform behavior ───────────────────────────
258
+
259
+ describe("resolveProvider() multi-platform behavior", () => {
260
+ test('throws "Multiple platforms connected" when both Gmail and Slack (Socket Mode) are connected and no platform is specified', async () => {
261
+ // Slack connected via Socket Mode
262
+ getSecureKeyAsyncMock.mockImplementation(async (key: string) =>
263
+ key === "credential/slack_channel/bot_token" ? "xoxb-token" : null,
264
+ );
265
+ // Gmail connected via OAuth
266
+ isProviderConnectedMock.mockImplementation(async (service: string) =>
267
+ service === "integration:google" ? true : false,
268
+ );
269
+
270
+ await expect(resolveProvider()).rejects.toThrow(
271
+ "Multiple platforms connected",
272
+ );
273
+ });
274
+
275
+ test("auto-selects Slack when it is the only connected provider", async () => {
276
+ getSecureKeyAsyncMock.mockImplementation(async (key: string) =>
277
+ key === "credential/slack_channel/bot_token" ? "xoxb-only" : null,
278
+ );
279
+ isProviderConnectedMock.mockImplementation(async () => false);
280
+
281
+ const provider = await resolveProvider();
282
+ expect(provider.id).toBe("slack");
283
+ });
284
+
285
+ test("auto-selects Gmail when it is the only connected provider (no Slack credentials)", async () => {
286
+ getSecureKeyAsyncMock.mockImplementation(async () => null);
287
+ isProviderConnectedMock.mockImplementation(async (service: string) =>
288
+ service === "integration:google" ? true : false,
289
+ );
290
+
291
+ const provider = await resolveProvider();
292
+ expect(provider.id).toBe("gmail");
293
+ });
294
+ });
295
+
296
+ // ── getConnectedProviders() ─────────────────────────────────────────────
297
+
298
+ describe("getConnectedProviders()", () => {
299
+ test("includes Slack when connected via Socket Mode (slack_channel)", async () => {
300
+ getSecureKeyAsyncMock.mockImplementation(async (key: string) =>
301
+ key === "credential/slack_channel/bot_token" ? "xoxb-token" : null,
302
+ );
303
+ isProviderConnectedMock.mockImplementation(async () => false);
304
+
305
+ const connected = await getConnectedProviders();
306
+ const ids = connected.map((p) => p.id);
307
+ expect(ids).toContain("slack");
308
+ });
309
+
310
+ test("excludes Slack when no bot token exists", async () => {
311
+ getSecureKeyAsyncMock.mockImplementation(async () => null);
312
+ isProviderConnectedMock.mockImplementation(async () => false);
313
+
314
+ const connected = await getConnectedProviders();
315
+ const ids = connected.map((p) => p.id);
316
+ expect(ids).not.toContain("slack");
317
+ });
318
+ });
319
+ });
@@ -32,6 +32,10 @@ const mockConfig = {
32
32
  action: "warn" as const,
33
33
  entropyThreshold: 4.0,
34
34
  },
35
+ permissions: {
36
+ mode: "workspace" as const,
37
+ dangerouslySkipPermissions: false,
38
+ },
35
39
  };
36
40
 
37
41
  let checkerDecision: "allow" | "prompt" | "deny" = "allow";
@@ -50,6 +50,10 @@ const mockConfig = {
50
50
  action: "warn" as const,
51
51
  entropyThreshold: 4.0,
52
52
  },
53
+ permissions: {
54
+ mode: "workspace" as const,
55
+ dangerouslySkipPermissions: false,
56
+ },
53
57
  };
54
58
 
55
59
  let fakeToolResult: ToolExecutionResult = { content: "ok", isError: false };