@vellumai/assistant 0.5.11 → 0.5.13
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/Dockerfile +42 -9
- package/docs/architecture/integrations.md +34 -32
- package/node_modules/@vellumai/ces-contracts/src/__tests__/grants.test.ts +7 -7
- package/node_modules/@vellumai/ces-contracts/src/handles.ts +5 -4
- package/node_modules/@vellumai/ces-contracts/src/index.ts +7 -0
- package/node_modules/@vellumai/ces-contracts/src/rpc.ts +5 -0
- package/node_modules/@vellumai/credential-storage/src/index.ts +1 -1
- package/openapi.yaml +87 -9
- package/package.json +1 -1
- package/src/__tests__/catalog-cache.test.ts +164 -0
- package/src/__tests__/catalog-search.test.ts +61 -0
- package/src/__tests__/cli-command-risk-guard.test.ts +181 -6
- package/src/__tests__/conversation-delete-schedule-cleanup.test.ts +396 -0
- package/src/__tests__/conversation-error.test.ts +3 -2
- package/src/__tests__/credential-security-invariants.test.ts +9 -15
- package/src/__tests__/credential-vault-unit.test.ts +32 -34
- package/src/__tests__/credential-vault.test.ts +25 -33
- package/src/__tests__/credentials-cli.test.ts +3 -3
- package/src/__tests__/daemon-credential-client.test.ts +2 -2
- package/src/__tests__/first-greeting.test.ts +7 -0
- package/src/__tests__/host-bash-proxy.test.ts +79 -0
- package/src/__tests__/host-cu-proxy.test.ts +90 -0
- package/src/__tests__/host-file-proxy.test.ts +89 -0
- package/src/__tests__/integration-status.test.ts +5 -5
- package/src/__tests__/list-messages-attachments.test.ts +171 -0
- package/src/__tests__/mcp-abort-signal.test.ts +205 -0
- package/src/__tests__/messaging-send-tool.test.ts +5 -5
- package/src/__tests__/navigate-settings-tab.test.ts +6 -2
- package/src/__tests__/notification-telegram-adapter.test.ts +125 -0
- package/src/__tests__/oauth-cli.test.ts +126 -119
- package/src/__tests__/oauth-provider-profiles.test.ts +55 -20
- package/src/__tests__/oauth-scope-policy.test.ts +4 -6
- package/src/__tests__/onboarding-template-contract.test.ts +2 -2
- package/src/__tests__/platform.test.ts +3 -168
- package/src/__tests__/secret-routes-managed-proxy.test.ts +78 -0
- package/src/__tests__/secure-keys-managed-failover.test.ts +73 -0
- package/src/__tests__/skill-feature-flags.test.ts +8 -0
- package/src/__tests__/skill-secret-handling-guard.test.ts +212 -0
- package/src/__tests__/skills-uninstall.test.ts +2 -2
- package/src/__tests__/slack-messaging-token-resolution.test.ts +22 -24
- package/src/__tests__/slack-share-routes.test.ts +5 -5
- package/src/__tests__/system-prompt.test.ts +39 -0
- package/src/__tests__/token-estimator-accuracy.benchmark.test.ts +1 -1
- package/src/__tests__/workspace-migration-backfill-installation-id.test.ts +5 -4
- package/src/cli/AGENTS.md +47 -7
- package/src/cli/commands/browser-relay.ts +2 -17
- package/src/cli/commands/contacts.ts +6 -4
- package/src/cli/commands/conversations.ts +13 -1
- package/src/cli/commands/credential-execution.ts +16 -1
- package/src/cli/commands/credentials.ts +2 -8
- package/src/cli/commands/oauth/__tests__/connect.test.ts +29 -108
- package/src/cli/commands/oauth/__tests__/disconnect.test.ts +13 -87
- package/src/cli/commands/oauth/__tests__/mode.test.ts +22 -69
- package/src/cli/commands/oauth/__tests__/ping.test.ts +20 -79
- package/src/cli/commands/oauth/__tests__/providers-delete.test.ts +574 -0
- package/src/cli/commands/oauth/__tests__/providers-update.test.ts +416 -0
- package/src/cli/commands/oauth/__tests__/status.test.ts +12 -40
- package/src/cli/commands/oauth/__tests__/token.test.ts +3 -50
- package/src/cli/commands/oauth/apps.ts +63 -44
- package/src/cli/commands/oauth/connect.ts +187 -155
- package/src/cli/commands/oauth/disconnect.ts +27 -75
- package/src/cli/commands/oauth/index.ts +36 -46
- package/src/cli/commands/oauth/mode.ts +22 -34
- package/src/cli/commands/oauth/ping.ts +19 -45
- package/src/cli/commands/oauth/providers.ts +569 -62
- package/src/cli/commands/oauth/request.ts +36 -48
- package/src/cli/commands/oauth/shared.ts +1 -19
- package/src/cli/commands/oauth/status.ts +14 -25
- package/src/cli/commands/oauth/token.ts +25 -34
- package/src/cli/commands/platform/__tests__/connect.test.ts +224 -0
- package/src/cli/commands/platform/__tests__/disconnect.test.ts +237 -0
- package/src/cli/commands/platform/__tests__/status.test.ts +246 -0
- package/src/cli/commands/platform/connect.ts +104 -0
- package/src/cli/commands/platform/disconnect.ts +118 -0
- package/src/cli/commands/{platform.ts → platform/index.ts} +108 -38
- package/src/cli/commands/sequence.ts +5 -4
- package/src/cli/commands/shotgun.ts +16 -0
- package/src/cli/commands/skills.ts +173 -41
- package/src/cli/commands/usage.ts +5 -11
- package/src/cli/lib/daemon-credential-client.ts +22 -38
- package/src/cli/program.ts +1 -1
- package/src/config/assistant-feature-flags.ts +3 -7
- package/src/config/bundled-skills/contacts/tools/google-contacts.ts +1 -1
- package/src/config/bundled-skills/conversations/SKILL.md +20 -0
- package/src/config/bundled-skills/conversations/TOOLS.json +23 -0
- package/src/config/bundled-skills/conversations/tools/rename-conversation.ts +66 -0
- package/src/config/bundled-skills/gmail/SKILL.md +13 -13
- package/src/config/bundled-skills/gmail/tools/gmail-archive.ts +3 -3
- package/src/config/bundled-skills/gmail/tools/gmail-attachments.ts +2 -2
- package/src/config/bundled-skills/gmail/tools/gmail-draft.ts +1 -1
- package/src/config/bundled-skills/gmail/tools/gmail-filters.ts +1 -1
- package/src/config/bundled-skills/gmail/tools/gmail-follow-up.ts +1 -1
- package/src/config/bundled-skills/gmail/tools/gmail-forward.ts +1 -1
- package/src/config/bundled-skills/gmail/tools/gmail-label.ts +2 -2
- package/src/config/bundled-skills/gmail/tools/gmail-outreach-scan.ts +1 -1
- package/src/config/bundled-skills/gmail/tools/gmail-send-draft.ts +1 -1
- package/src/config/bundled-skills/gmail/tools/gmail-sender-digest.ts +1 -1
- package/src/config/bundled-skills/gmail/tools/gmail-trash.ts +1 -1
- package/src/config/bundled-skills/gmail/tools/gmail-unsubscribe.ts +1 -1
- package/src/config/bundled-skills/gmail/tools/gmail-vacation.ts +1 -1
- package/src/config/bundled-skills/google-calendar/SKILL.md +10 -4
- package/src/config/bundled-skills/google-calendar/tools/shared.ts +1 -1
- package/src/config/bundled-skills/messaging/SKILL.md +7 -7
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -2
- package/src/config/bundled-skills/messaging/tools/shared.ts +5 -6
- package/src/config/bundled-skills/settings/TOOLS.json +5 -3
- package/src/config/bundled-skills/settings/tools/navigate-settings-tab.ts +4 -2
- package/src/config/bundled-tool-registry.ts +5 -0
- package/src/config/feature-flag-registry.json +2 -2
- package/src/credential-execution/client.ts +15 -3
- package/src/daemon/conversation-agent-loop.ts +2 -0
- package/src/daemon/conversation-error.ts +36 -6
- package/src/daemon/conversation-messaging.ts +9 -0
- package/src/daemon/conversation-runtime-assembly.ts +33 -0
- package/src/daemon/conversation-surfaces.ts +120 -14
- package/src/daemon/conversation.ts +5 -0
- package/src/daemon/first-greeting.ts +6 -1
- package/src/daemon/handlers/skills.ts +148 -3
- package/src/daemon/host-bash-proxy.ts +16 -0
- package/src/daemon/host-cu-proxy.ts +16 -0
- package/src/daemon/host-file-proxy.ts +16 -0
- package/src/daemon/lifecycle.ts +56 -5
- package/src/daemon/message-types/conversations.ts +1 -0
- package/src/daemon/message-types/guardian-actions.ts +2 -0
- package/src/daemon/message-types/host-bash.ts +6 -1
- package/src/daemon/message-types/host-cu.ts +6 -1
- package/src/daemon/message-types/host-file.ts +6 -1
- package/src/daemon/message-types/integrations.ts +0 -1
- package/src/daemon/server.ts +29 -2
- package/src/hooks/cli.ts +74 -0
- package/src/inbound/platform-callback-registration.ts +7 -12
- package/src/index.ts +0 -12
- package/src/mcp/client.ts +6 -1
- package/src/mcp/manager.ts +2 -1
- package/src/memory/conversation-crud.ts +92 -3
- package/src/memory/conversation-key-store.ts +26 -0
- package/src/memory/conversation-queries.ts +6 -6
- package/src/memory/db-init.ts +16 -0
- package/src/memory/journal-memory.ts +8 -2
- package/src/memory/migrations/196-messages-conversation-created-at-index.ts +9 -0
- package/src/memory/migrations/196-strip-integration-prefix-from-provider-keys.ts +186 -0
- package/src/memory/migrations/197-oauth-providers-behavior-columns.ts +29 -0
- package/src/memory/migrations/198-drop-setup-skill-id-column.ts +11 -0
- package/src/memory/migrations/index.ts +4 -0
- package/src/memory/migrations/registry.ts +8 -0
- package/src/memory/schema/oauth.ts +11 -0
- package/src/messaging/provider.ts +13 -12
- package/src/messaging/providers/gmail/adapter.ts +44 -35
- package/src/messaging/providers/slack/adapter.ts +63 -33
- package/src/messaging/providers/telegram-bot/adapter.ts +6 -8
- package/src/messaging/providers/whatsapp/adapter.ts +6 -8
- package/src/notifications/adapters/telegram.ts +78 -2
- package/src/oauth/__tests__/identity-verifier.test.ts +464 -0
- package/src/oauth/byo-connection.test.ts +22 -24
- package/src/oauth/connect-orchestrator.ts +37 -76
- package/src/oauth/connect-types.ts +7 -65
- package/src/oauth/connection-resolver.test.ts +13 -13
- package/src/oauth/connection-resolver.ts +3 -4
- package/src/oauth/identity-verifier.ts +177 -0
- package/src/oauth/oauth-store.ts +228 -3
- package/src/oauth/platform-connection.test.ts +56 -6
- package/src/oauth/platform-connection.ts +8 -1
- package/src/oauth/seed-providers.ts +247 -34
- package/src/permissions/checker.ts +127 -1
- package/src/prompts/journal-context.ts +4 -1
- package/src/prompts/system-prompt.ts +54 -9
- package/src/prompts/templates/BOOTSTRAP.md +16 -5
- package/src/providers/anthropic/client.ts +2 -33
- package/src/runtime/guardian-action-service.ts +7 -2
- package/src/runtime/http-server.ts +12 -18
- package/src/runtime/http-types.ts +8 -1
- package/src/runtime/migrations/rebind-secrets-screen.ts +2 -2
- package/src/runtime/routes/conversation-management-routes.ts +31 -0
- package/src/runtime/routes/conversation-routes.ts +79 -4
- package/src/runtime/routes/guardian-action-routes.ts +15 -2
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +21 -8
- package/src/runtime/routes/integrations/slack/share.ts +1 -1
- package/src/runtime/routes/oauth-apps.ts +2 -1
- package/src/runtime/routes/secret-routes.ts +45 -15
- package/src/runtime/routes/settings-routes.ts +12 -19
- package/src/runtime/routes/skills-routes.ts +45 -4
- package/src/schedule/integration-status.ts +2 -2
- package/src/security/ces-rpc-credential-backend.ts +19 -16
- package/src/security/oauth-completion-page.ts +153 -0
- package/src/security/oauth2.ts +3 -17
- package/src/security/secure-keys.ts +207 -7
- package/src/security/token-manager.ts +3 -6
- package/src/signals/bash.ts +6 -1
- package/src/skills/catalog-cache.ts +44 -0
- package/src/skills/catalog-search.ts +18 -0
- package/src/tools/browser/browser-manager.ts +2 -2
- package/src/tools/credentials/post-connect-hooks.ts +1 -1
- package/src/tools/credentials/vault.ts +34 -45
- package/src/tools/host-terminal/host-shell.ts +16 -3
- package/src/tools/mcp/mcp-tool-factory.ts +2 -1
- package/src/tools/skills/sandbox-runner.ts +16 -3
- package/src/tools/terminal/shell.ts +16 -3
- package/src/util/logger.ts +11 -1
- package/src/util/platform.ts +1 -91
- package/src/util/sentry-log-stream.ts +51 -0
- package/src/watcher/providers/github.ts +2 -2
- package/src/watcher/providers/gmail.ts +1 -1
- package/src/watcher/providers/google-calendar.ts +1 -1
- package/src/watcher/providers/linear.ts +2 -2
- package/src/workspace/migrations/011-backfill-installation-id.ts +5 -3
- package/src/workspace/migrations/020-rename-oauth-skill-dirs.ts +119 -0
- package/src/workspace/migrations/registry.ts +2 -0
- package/src/cli/commands/oauth/connections.ts +0 -255
- package/src/oauth/provider-behaviors.ts +0 -634
|
@@ -187,6 +187,32 @@ describe("HostCuProxy", () => {
|
|
|
187
187
|
expect(proxy.hasPendingRequest(requestId)).toBe(false);
|
|
188
188
|
});
|
|
189
189
|
|
|
190
|
+
test("sends host_cu_cancel to client on abort", async () => {
|
|
191
|
+
setup();
|
|
192
|
+
|
|
193
|
+
const controller = new AbortController();
|
|
194
|
+
const resultPromise = proxy.request(
|
|
195
|
+
"computer_use_click",
|
|
196
|
+
{ element_id: 1 },
|
|
197
|
+
"session-1",
|
|
198
|
+
1,
|
|
199
|
+
undefined,
|
|
200
|
+
controller.signal,
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
const sent = sentMessages[0] as Record<string, unknown>;
|
|
204
|
+
const requestId = sent.requestId as string;
|
|
205
|
+
|
|
206
|
+
controller.abort();
|
|
207
|
+
await resultPromise;
|
|
208
|
+
|
|
209
|
+
// Second message should be the cancel
|
|
210
|
+
expect(sentMessages).toHaveLength(2);
|
|
211
|
+
const cancelMsg = sentMessages[1] as Record<string, unknown>;
|
|
212
|
+
expect(cancelMsg.type).toBe("host_cu_cancel");
|
|
213
|
+
expect(cancelMsg.requestId).toBe(requestId);
|
|
214
|
+
});
|
|
215
|
+
|
|
190
216
|
test("returns immediately if signal already aborted", async () => {
|
|
191
217
|
setup();
|
|
192
218
|
|
|
@@ -684,6 +710,70 @@ describe("HostCuProxy", () => {
|
|
|
684
710
|
expect(proxy.hasPendingRequest(requestId)).toBe(false);
|
|
685
711
|
await expect(resultPromise).rejects.toThrow("Host CU proxy disposed");
|
|
686
712
|
});
|
|
713
|
+
|
|
714
|
+
test("sends host_cu_cancel for each pending request on dispose", () => {
|
|
715
|
+
setup();
|
|
716
|
+
|
|
717
|
+
const p1 = proxy.request(
|
|
718
|
+
"computer_use_click",
|
|
719
|
+
{ element_id: 1 },
|
|
720
|
+
"session-1",
|
|
721
|
+
1,
|
|
722
|
+
);
|
|
723
|
+
const p2 = proxy.request(
|
|
724
|
+
"computer_use_type_text",
|
|
725
|
+
{ text: "hello" },
|
|
726
|
+
"session-1",
|
|
727
|
+
2,
|
|
728
|
+
);
|
|
729
|
+
p1.catch(() => {}); // Expected rejection on dispose
|
|
730
|
+
p2.catch(() => {}); // Expected rejection on dispose
|
|
731
|
+
|
|
732
|
+
const requestIds = (sentMessages as Array<Record<string, unknown>>).map(
|
|
733
|
+
(m) => m.requestId as string,
|
|
734
|
+
);
|
|
735
|
+
expect(requestIds).toHaveLength(2);
|
|
736
|
+
|
|
737
|
+
proxy.dispose();
|
|
738
|
+
|
|
739
|
+
// After the 2 request messages, dispose should have sent 2 cancel messages
|
|
740
|
+
const cancelMessages = sentMessages
|
|
741
|
+
.slice(2)
|
|
742
|
+
.filter(
|
|
743
|
+
(m) => (m as Record<string, unknown>).type === "host_cu_cancel",
|
|
744
|
+
) as Array<Record<string, unknown>>;
|
|
745
|
+
expect(cancelMessages).toHaveLength(2);
|
|
746
|
+
expect(cancelMessages.map((m) => m.requestId)).toContain(requestIds[0]);
|
|
747
|
+
expect(cancelMessages.map((m) => m.requestId)).toContain(requestIds[1]);
|
|
748
|
+
});
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
describe("late resolve after abort", () => {
|
|
752
|
+
test("resolve is a no-op after abort (entry already deleted)", async () => {
|
|
753
|
+
setup();
|
|
754
|
+
|
|
755
|
+
const controller = new AbortController();
|
|
756
|
+
const resultPromise = proxy.request(
|
|
757
|
+
"computer_use_click",
|
|
758
|
+
{ element_id: 1 },
|
|
759
|
+
"session-1",
|
|
760
|
+
1,
|
|
761
|
+
undefined,
|
|
762
|
+
controller.signal,
|
|
763
|
+
);
|
|
764
|
+
|
|
765
|
+
const sent = sentMessages[0] as Record<string, unknown>;
|
|
766
|
+
const requestId = sent.requestId as string;
|
|
767
|
+
|
|
768
|
+
controller.abort();
|
|
769
|
+
const result = await resultPromise;
|
|
770
|
+
expect(result.content).toContain("Aborted");
|
|
771
|
+
|
|
772
|
+
// Late resolve should be silently ignored (no throw, no double-resolve)
|
|
773
|
+
proxy.resolve(requestId, { axTree: "late response" });
|
|
774
|
+
|
|
775
|
+
expect(proxy.hasPendingRequest(requestId)).toBe(false);
|
|
776
|
+
});
|
|
687
777
|
});
|
|
688
778
|
|
|
689
779
|
// -------------------------------------------------------------------------
|
|
@@ -184,6 +184,32 @@ describe("HostFileProxy", () => {
|
|
|
184
184
|
expect(proxy.hasPendingRequest(requestId)).toBe(false);
|
|
185
185
|
});
|
|
186
186
|
|
|
187
|
+
test("sends host_file_cancel to client on abort", async () => {
|
|
188
|
+
setup();
|
|
189
|
+
|
|
190
|
+
const controller = new AbortController();
|
|
191
|
+
const resultPromise = proxy.request(
|
|
192
|
+
{
|
|
193
|
+
operation: "read",
|
|
194
|
+
path: "/tmp/test.txt",
|
|
195
|
+
},
|
|
196
|
+
"session-1",
|
|
197
|
+
controller.signal,
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
const sent = sentMessages[0] as Record<string, unknown>;
|
|
201
|
+
const requestId = sent.requestId as string;
|
|
202
|
+
|
|
203
|
+
controller.abort();
|
|
204
|
+
await resultPromise;
|
|
205
|
+
|
|
206
|
+
// Second message should be the cancel
|
|
207
|
+
expect(sentMessages).toHaveLength(2);
|
|
208
|
+
const cancelMsg = sentMessages[1] as Record<string, unknown>;
|
|
209
|
+
expect(cancelMsg.type).toBe("host_file_cancel");
|
|
210
|
+
expect(cancelMsg.requestId).toBe(requestId);
|
|
211
|
+
});
|
|
212
|
+
|
|
187
213
|
test("returns immediately if signal already aborted", async () => {
|
|
188
214
|
setup();
|
|
189
215
|
|
|
@@ -247,6 +273,69 @@ describe("HostFileProxy", () => {
|
|
|
247
273
|
expect(proxy.hasPendingRequest(requestId)).toBe(false);
|
|
248
274
|
expect(resultPromise).rejects.toThrow("Host file proxy disposed");
|
|
249
275
|
});
|
|
276
|
+
|
|
277
|
+
test("sends host_file_cancel for each pending request on dispose", () => {
|
|
278
|
+
setup();
|
|
279
|
+
|
|
280
|
+
const p1 = proxy.request(
|
|
281
|
+
{ operation: "read", path: "/tmp/a.txt" },
|
|
282
|
+
"session-1",
|
|
283
|
+
);
|
|
284
|
+
const p2 = proxy.request(
|
|
285
|
+
{ operation: "read", path: "/tmp/b.txt" },
|
|
286
|
+
"session-1",
|
|
287
|
+
);
|
|
288
|
+
p1.catch(() => {}); // Expected rejection on dispose
|
|
289
|
+
p2.catch(() => {}); // Expected rejection on dispose
|
|
290
|
+
|
|
291
|
+
const requestIds = (sentMessages as Array<Record<string, unknown>>).map(
|
|
292
|
+
(m) => m.requestId as string,
|
|
293
|
+
);
|
|
294
|
+
expect(requestIds).toHaveLength(2);
|
|
295
|
+
|
|
296
|
+
proxy.dispose();
|
|
297
|
+
|
|
298
|
+
// After the 2 request messages, dispose should have sent 2 cancel messages
|
|
299
|
+
const cancelMessages = sentMessages
|
|
300
|
+
.slice(2)
|
|
301
|
+
.filter(
|
|
302
|
+
(m) => (m as Record<string, unknown>).type === "host_file_cancel",
|
|
303
|
+
) as Array<Record<string, unknown>>;
|
|
304
|
+
expect(cancelMessages).toHaveLength(2);
|
|
305
|
+
expect(cancelMessages.map((m) => m.requestId)).toContain(requestIds[0]);
|
|
306
|
+
expect(cancelMessages.map((m) => m.requestId)).toContain(requestIds[1]);
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
describe("late resolve after abort", () => {
|
|
311
|
+
test("resolve is a no-op after abort (entry already deleted)", async () => {
|
|
312
|
+
setup();
|
|
313
|
+
|
|
314
|
+
const controller = new AbortController();
|
|
315
|
+
const resultPromise = proxy.request(
|
|
316
|
+
{
|
|
317
|
+
operation: "read",
|
|
318
|
+
path: "/tmp/test.txt",
|
|
319
|
+
},
|
|
320
|
+
"session-1",
|
|
321
|
+
controller.signal,
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
const sent = sentMessages[0] as Record<string, unknown>;
|
|
325
|
+
const requestId = sent.requestId as string;
|
|
326
|
+
|
|
327
|
+
controller.abort();
|
|
328
|
+
const result = await resultPromise;
|
|
329
|
+
expect(result.content).toBe("Aborted");
|
|
330
|
+
|
|
331
|
+
// Late resolve should be silently ignored (no throw, no double-resolve)
|
|
332
|
+
proxy.resolve(requestId, {
|
|
333
|
+
content: "late response",
|
|
334
|
+
isError: false,
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
expect(proxy.hasPendingRequest(requestId)).toBe(false);
|
|
338
|
+
});
|
|
250
339
|
});
|
|
251
340
|
|
|
252
341
|
describe("updateSender", () => {
|
|
@@ -56,8 +56,8 @@ describe("integration-status", () => {
|
|
|
56
56
|
});
|
|
57
57
|
|
|
58
58
|
test("returns all connected when all keys are set", async () => {
|
|
59
|
-
setOAuthConnected("
|
|
60
|
-
setOAuthConnected("
|
|
59
|
+
setOAuthConnected("google");
|
|
60
|
+
setOAuthConnected("slack");
|
|
61
61
|
mockTwilioAccountSid = "sid";
|
|
62
62
|
secureKeyValues.set(credentialKey("twilio", "auth_token"), "auth");
|
|
63
63
|
setOAuthConnected("telegram");
|
|
@@ -129,8 +129,8 @@ describe("integration-status", () => {
|
|
|
129
129
|
});
|
|
130
130
|
|
|
131
131
|
test("all connected", async () => {
|
|
132
|
-
setOAuthConnected("
|
|
133
|
-
setOAuthConnected("
|
|
132
|
+
setOAuthConnected("google");
|
|
133
|
+
setOAuthConnected("slack");
|
|
134
134
|
mockTwilioAccountSid = "sid";
|
|
135
135
|
secureKeyValues.set(credentialKey("twilio", "auth_token"), "auth");
|
|
136
136
|
setOAuthConnected("telegram");
|
|
@@ -163,7 +163,7 @@ describe("integration-status", () => {
|
|
|
163
163
|
});
|
|
164
164
|
|
|
165
165
|
test("email category checks Gmail", async () => {
|
|
166
|
-
setOAuthConnected("
|
|
166
|
+
setOAuthConnected("google");
|
|
167
167
|
expect(await hasCapability("email")).toBe(true);
|
|
168
168
|
});
|
|
169
169
|
});
|
|
@@ -286,3 +286,174 @@ describe("handleListMessages no_response filtering", () => {
|
|
|
286
286
|
expect(body.messages[0].content).toBe("What does <no_response/> do?");
|
|
287
287
|
});
|
|
288
288
|
});
|
|
289
|
+
|
|
290
|
+
// ---------------------------------------------------------------------------
|
|
291
|
+
// Pagination
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
interface PaginatedResponse {
|
|
295
|
+
messages: { id: string; content: string; timestamp: string }[];
|
|
296
|
+
hasMore?: boolean;
|
|
297
|
+
oldestTimestamp?: number;
|
|
298
|
+
oldestMessageId?: string;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function createPaginatedUrl(
|
|
302
|
+
conversationId: string,
|
|
303
|
+
params?: { limit?: string; beforeTimestamp?: string },
|
|
304
|
+
): URL {
|
|
305
|
+
const url = new URL(
|
|
306
|
+
`http://localhost/v1/messages?conversationId=${conversationId}`,
|
|
307
|
+
);
|
|
308
|
+
if (params?.limit !== undefined) url.searchParams.set("limit", params.limit);
|
|
309
|
+
if (params?.beforeTimestamp !== undefined)
|
|
310
|
+
url.searchParams.set("beforeTimestamp", params.beforeTimestamp);
|
|
311
|
+
return url;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Helper: insert N messages with distinct, increasing timestamps and return them in insertion order. */
|
|
315
|
+
async function insertMessages(
|
|
316
|
+
conversationId: string,
|
|
317
|
+
count: number,
|
|
318
|
+
): Promise<{ id: string; createdAt: number }[]> {
|
|
319
|
+
const msgs: { id: string; createdAt: number }[] = [];
|
|
320
|
+
for (let i = 0; i < count; i++) {
|
|
321
|
+
const msg = await addMessage(
|
|
322
|
+
conversationId,
|
|
323
|
+
i % 2 === 0 ? "user" : "assistant",
|
|
324
|
+
JSON.stringify([{ type: "text", text: `msg-${i}` }]),
|
|
325
|
+
);
|
|
326
|
+
msgs.push({ id: msg.id, createdAt: msg.createdAt });
|
|
327
|
+
}
|
|
328
|
+
return msgs;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
describe("handleListMessages pagination", () => {
|
|
332
|
+
beforeEach(resetTables);
|
|
333
|
+
|
|
334
|
+
test("no params → all messages, no hasMore field", async () => {
|
|
335
|
+
const conv = createConversation();
|
|
336
|
+
await insertMessages(conv.id, 5);
|
|
337
|
+
|
|
338
|
+
const response = handleListMessages(createTestUrl(conv.id), null);
|
|
339
|
+
const body = (await response.json()) as PaginatedResponse;
|
|
340
|
+
|
|
341
|
+
expect(body.messages).toHaveLength(5);
|
|
342
|
+
expect(body.hasMore).toBeUndefined();
|
|
343
|
+
expect(body.oldestTimestamp).toBeUndefined();
|
|
344
|
+
expect(body.oldestMessageId).toBeUndefined();
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test("limit only (no beforeTimestamp) → all messages, no hasMore", async () => {
|
|
348
|
+
const conv = createConversation();
|
|
349
|
+
await insertMessages(conv.id, 5);
|
|
350
|
+
|
|
351
|
+
const url = createPaginatedUrl(conv.id, { limit: "3" });
|
|
352
|
+
const response = handleListMessages(url, null);
|
|
353
|
+
const body = (await response.json()) as PaginatedResponse;
|
|
354
|
+
|
|
355
|
+
// Option A: without beforeTimestamp, all messages are returned regardless of limit
|
|
356
|
+
expect(body.messages).toHaveLength(5);
|
|
357
|
+
expect(body.hasMore).toBeUndefined();
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test("beforeTimestamp + limit → correct page with hasMore: true", async () => {
|
|
361
|
+
const conv = createConversation();
|
|
362
|
+
const msgs = await insertMessages(conv.id, 10);
|
|
363
|
+
|
|
364
|
+
// Cursor is message[7]'s timestamp; limit=3 → should return messages [4,5,6]
|
|
365
|
+
const url = createPaginatedUrl(conv.id, {
|
|
366
|
+
beforeTimestamp: String(msgs[7].createdAt),
|
|
367
|
+
limit: "3",
|
|
368
|
+
});
|
|
369
|
+
const response = handleListMessages(url, null);
|
|
370
|
+
const body = (await response.json()) as PaginatedResponse;
|
|
371
|
+
|
|
372
|
+
expect(body.messages).toHaveLength(3);
|
|
373
|
+
expect(body.messages.map((m) => m.id)).toEqual([
|
|
374
|
+
msgs[4].id,
|
|
375
|
+
msgs[5].id,
|
|
376
|
+
msgs[6].id,
|
|
377
|
+
]);
|
|
378
|
+
expect(body.hasMore).toBe(true);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test("beforeTimestamp is strictly exclusive", async () => {
|
|
382
|
+
const conv = createConversation();
|
|
383
|
+
const msgs = await insertMessages(conv.id, 3);
|
|
384
|
+
|
|
385
|
+
// Use message[1]'s exact timestamp as cursor — message[1] should NOT appear
|
|
386
|
+
const url = createPaginatedUrl(conv.id, {
|
|
387
|
+
beforeTimestamp: String(msgs[1].createdAt),
|
|
388
|
+
limit: "10",
|
|
389
|
+
});
|
|
390
|
+
const response = handleListMessages(url, null);
|
|
391
|
+
const body = (await response.json()) as PaginatedResponse;
|
|
392
|
+
|
|
393
|
+
const ids = body.messages.map((m) => m.id);
|
|
394
|
+
expect(ids).toContain(msgs[0].id);
|
|
395
|
+
expect(ids).not.toContain(msgs[1].id);
|
|
396
|
+
expect(ids).not.toContain(msgs[2].id);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test("hasMore: false when all older messages fit", async () => {
|
|
400
|
+
const conv = createConversation();
|
|
401
|
+
const msgs = await insertMessages(conv.id, 5);
|
|
402
|
+
|
|
403
|
+
// beforeTimestamp beyond the last message, limit larger than total count
|
|
404
|
+
const url = createPaginatedUrl(conv.id, {
|
|
405
|
+
beforeTimestamp: String(msgs[4].createdAt + 1),
|
|
406
|
+
limit: "10",
|
|
407
|
+
});
|
|
408
|
+
const response = handleListMessages(url, null);
|
|
409
|
+
const body = (await response.json()) as PaginatedResponse;
|
|
410
|
+
|
|
411
|
+
expect(body.messages).toHaveLength(5);
|
|
412
|
+
expect(body.hasMore).toBe(false);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
test("oldestTimestamp and oldestMessageId match oldest returned message", async () => {
|
|
416
|
+
const conv = createConversation();
|
|
417
|
+
const msgs = await insertMessages(conv.id, 5);
|
|
418
|
+
|
|
419
|
+
// Fetch last 3 messages before a cursor past the end
|
|
420
|
+
const url = createPaginatedUrl(conv.id, {
|
|
421
|
+
beforeTimestamp: String(msgs[4].createdAt + 1),
|
|
422
|
+
limit: "3",
|
|
423
|
+
});
|
|
424
|
+
const response = handleListMessages(url, null);
|
|
425
|
+
const body = (await response.json()) as PaginatedResponse;
|
|
426
|
+
|
|
427
|
+
expect(body.messages).toHaveLength(3);
|
|
428
|
+
// Oldest returned message is msgs[2] (messages [2,3,4])
|
|
429
|
+
expect(body.oldestTimestamp).toBe(msgs[2].createdAt);
|
|
430
|
+
expect(body.oldestMessageId).toBe(msgs[2].id);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
test("empty / nonexistent conversation → empty messages, no pagination metadata", async () => {
|
|
434
|
+
const url = createPaginatedUrl("nonexistent-conv-id");
|
|
435
|
+
const response = handleListMessages(url, null);
|
|
436
|
+
const body = (await response.json()) as PaginatedResponse;
|
|
437
|
+
|
|
438
|
+
expect(body.messages).toEqual([]);
|
|
439
|
+
expect(body.hasMore).toBeUndefined();
|
|
440
|
+
expect(body.oldestTimestamp).toBeUndefined();
|
|
441
|
+
expect(body.oldestMessageId).toBeUndefined();
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
test("invalid limit (NaN) → 400", async () => {
|
|
445
|
+
const conv = createConversation();
|
|
446
|
+
const url = createPaginatedUrl(conv.id, { limit: "abc" });
|
|
447
|
+
const response = handleListMessages(url, null);
|
|
448
|
+
|
|
449
|
+
expect(response.status).toBe(400);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
test("invalid beforeTimestamp (NaN) → 400", async () => {
|
|
453
|
+
const conv = createConversation();
|
|
454
|
+
const url = createPaginatedUrl(conv.id, { beforeTimestamp: "abc" });
|
|
455
|
+
const response = handleListMessages(url, null);
|
|
456
|
+
|
|
457
|
+
expect(response.status).toBe(400);
|
|
458
|
+
});
|
|
459
|
+
});
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { describe, expect, jest, mock, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
// Mock secure-keys so McpOAuthProvider doesn't try to access the credential store
|
|
4
|
+
mock.module("../security/secure-keys.js", () => ({
|
|
5
|
+
getSecureKeyAsync: jest.fn().mockResolvedValue(null),
|
|
6
|
+
setSecureKeyAsync: jest.fn().mockResolvedValue(true),
|
|
7
|
+
deleteSecureKeyAsync: jest.fn().mockResolvedValue("deleted"),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
const { McpClient } = await import("../mcp/client.js");
|
|
11
|
+
const { McpServerManager } = await import("../mcp/manager.js");
|
|
12
|
+
const { createMcpTool } = await import("../tools/mcp/mcp-tool-factory.js");
|
|
13
|
+
|
|
14
|
+
describe("MCP AbortSignal threading", () => {
|
|
15
|
+
describe("McpClient.callTool", () => {
|
|
16
|
+
test("forwards signal to the SDK client.callTool options", async () => {
|
|
17
|
+
const client = new McpClient("test-server");
|
|
18
|
+
|
|
19
|
+
const callToolSpy = jest.fn().mockResolvedValue({
|
|
20
|
+
content: [{ type: "text", text: "ok" }],
|
|
21
|
+
isError: false,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// Monkey-patch to mark as connected and capture callTool args
|
|
25
|
+
(client as any).connected = true;
|
|
26
|
+
(client as any).client = { callTool: callToolSpy };
|
|
27
|
+
|
|
28
|
+
const ac = new AbortController();
|
|
29
|
+
await client.callTool("my-tool", { foo: "bar" }, ac.signal);
|
|
30
|
+
|
|
31
|
+
expect(callToolSpy).toHaveBeenCalledTimes(1);
|
|
32
|
+
const [params, resultSchema, options] = callToolSpy.mock.calls[0];
|
|
33
|
+
expect(params).toEqual({ name: "my-tool", arguments: { foo: "bar" } });
|
|
34
|
+
expect(resultSchema).toBeUndefined();
|
|
35
|
+
expect(options).toEqual({ signal: ac.signal });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("passes undefined signal cleanly (no regression)", async () => {
|
|
39
|
+
const client = new McpClient("test-server");
|
|
40
|
+
|
|
41
|
+
const callToolSpy = jest.fn().mockResolvedValue({
|
|
42
|
+
content: [{ type: "text", text: "ok" }],
|
|
43
|
+
isError: false,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
(client as any).connected = true;
|
|
47
|
+
(client as any).client = { callTool: callToolSpy };
|
|
48
|
+
|
|
49
|
+
await client.callTool("my-tool", { foo: "bar" });
|
|
50
|
+
|
|
51
|
+
expect(callToolSpy).toHaveBeenCalledTimes(1);
|
|
52
|
+
const [_params, _resultSchema, options] = callToolSpy.mock.calls[0];
|
|
53
|
+
expect(options).toBeUndefined();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("already-aborted signal causes the SDK call to reject", async () => {
|
|
57
|
+
const client = new McpClient("test-server");
|
|
58
|
+
|
|
59
|
+
const callToolSpy = jest
|
|
60
|
+
.fn()
|
|
61
|
+
.mockImplementation((_p: any, _r: any, opts: any) => {
|
|
62
|
+
if (opts?.signal?.aborted) {
|
|
63
|
+
return Promise.reject(
|
|
64
|
+
new DOMException("The operation was aborted.", "AbortError"),
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
return Promise.resolve({
|
|
68
|
+
content: [{ type: "text", text: "ok" }],
|
|
69
|
+
isError: false,
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
(client as any).connected = true;
|
|
74
|
+
(client as any).client = { callTool: callToolSpy };
|
|
75
|
+
|
|
76
|
+
const ac = new AbortController();
|
|
77
|
+
ac.abort();
|
|
78
|
+
|
|
79
|
+
await expect(client.callTool("my-tool", {}, ac.signal)).rejects.toThrow(
|
|
80
|
+
"The operation was aborted.",
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("McpServerManager.callTool", () => {
|
|
86
|
+
test("forwards signal to the underlying McpClient.callTool", async () => {
|
|
87
|
+
const manager = new McpServerManager();
|
|
88
|
+
|
|
89
|
+
const callToolSpy = jest.fn().mockResolvedValue({
|
|
90
|
+
content: "result",
|
|
91
|
+
isError: false,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const fakeClient = { callTool: callToolSpy, serverId: "test-server" };
|
|
95
|
+
(manager as any).clients.set("test-server", fakeClient);
|
|
96
|
+
|
|
97
|
+
const ac = new AbortController();
|
|
98
|
+
await manager.callTool("test-server", "my-tool", { x: 1 }, ac.signal);
|
|
99
|
+
|
|
100
|
+
expect(callToolSpy).toHaveBeenCalledWith("my-tool", { x: 1 }, ac.signal);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("passes undefined signal when not provided", async () => {
|
|
104
|
+
const manager = new McpServerManager();
|
|
105
|
+
|
|
106
|
+
const callToolSpy = jest.fn().mockResolvedValue({
|
|
107
|
+
content: "result",
|
|
108
|
+
isError: false,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const fakeClient = { callTool: callToolSpy, serverId: "test-server" };
|
|
112
|
+
(manager as any).clients.set("test-server", fakeClient);
|
|
113
|
+
|
|
114
|
+
await manager.callTool("test-server", "my-tool", { x: 1 });
|
|
115
|
+
|
|
116
|
+
expect(callToolSpy).toHaveBeenCalledWith("my-tool", { x: 1 }, undefined);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("createMcpTool execute", () => {
|
|
121
|
+
test("threads context.signal through manager.callTool", async () => {
|
|
122
|
+
const callToolSpy = jest.fn().mockResolvedValue({
|
|
123
|
+
content: "tool result",
|
|
124
|
+
isError: false,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const fakeManager = { callTool: callToolSpy } as any;
|
|
128
|
+
|
|
129
|
+
const tool = createMcpTool(
|
|
130
|
+
{
|
|
131
|
+
name: "my-tool",
|
|
132
|
+
description: "A test tool",
|
|
133
|
+
inputSchema: { type: "object", properties: {} },
|
|
134
|
+
},
|
|
135
|
+
"test-server",
|
|
136
|
+
{
|
|
137
|
+
transport: { type: "stdio", command: "echo", args: [] },
|
|
138
|
+
enabled: true,
|
|
139
|
+
defaultRiskLevel: "high",
|
|
140
|
+
maxTools: 100,
|
|
141
|
+
},
|
|
142
|
+
fakeManager,
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
const ac = new AbortController();
|
|
146
|
+
await tool.execute(
|
|
147
|
+
{ someArg: "value" },
|
|
148
|
+
{
|
|
149
|
+
workingDir: "/tmp",
|
|
150
|
+
conversationId: "conv-1",
|
|
151
|
+
signal: ac.signal,
|
|
152
|
+
trustClass: "guardian",
|
|
153
|
+
},
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
expect(callToolSpy).toHaveBeenCalledWith(
|
|
157
|
+
"test-server",
|
|
158
|
+
"my-tool",
|
|
159
|
+
{ someArg: "value" },
|
|
160
|
+
ac.signal,
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("passes undefined signal when context has no signal", async () => {
|
|
165
|
+
const callToolSpy = jest.fn().mockResolvedValue({
|
|
166
|
+
content: "tool result",
|
|
167
|
+
isError: false,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const fakeManager = { callTool: callToolSpy } as any;
|
|
171
|
+
|
|
172
|
+
const tool = createMcpTool(
|
|
173
|
+
{
|
|
174
|
+
name: "my-tool",
|
|
175
|
+
description: "A test tool",
|
|
176
|
+
inputSchema: { type: "object", properties: {} },
|
|
177
|
+
},
|
|
178
|
+
"test-server",
|
|
179
|
+
{
|
|
180
|
+
transport: { type: "stdio", command: "echo", args: [] },
|
|
181
|
+
enabled: true,
|
|
182
|
+
defaultRiskLevel: "high",
|
|
183
|
+
maxTools: 100,
|
|
184
|
+
},
|
|
185
|
+
fakeManager,
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
await tool.execute(
|
|
189
|
+
{ someArg: "value" },
|
|
190
|
+
{
|
|
191
|
+
workingDir: "/tmp",
|
|
192
|
+
conversationId: "conv-1",
|
|
193
|
+
trustClass: "guardian",
|
|
194
|
+
},
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
expect(callToolSpy).toHaveBeenCalledWith(
|
|
198
|
+
"test-server",
|
|
199
|
+
"my-tool",
|
|
200
|
+
{ someArg: "value" },
|
|
201
|
+
undefined,
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
});
|
|
@@ -24,16 +24,16 @@ const provider: MessagingProvider = {
|
|
|
24
24
|
getHistory: async () => [],
|
|
25
25
|
search: async () => ({ total: 0, messages: [], hasMore: false }),
|
|
26
26
|
sendMessage: (
|
|
27
|
-
|
|
27
|
+
connection: OAuthConnection | undefined,
|
|
28
28
|
conversationId: string,
|
|
29
29
|
text: string,
|
|
30
30
|
options?: SendOptions,
|
|
31
|
-
) => sendMessageMock(
|
|
31
|
+
) => sendMessageMock(connection, conversationId, text, options),
|
|
32
32
|
};
|
|
33
33
|
|
|
34
34
|
mock.module("../config/bundled-skills/messaging/tools/shared.js", () => ({
|
|
35
35
|
resolveProvider: () => provider,
|
|
36
|
-
getProviderConnection: () =>
|
|
36
|
+
getProviderConnection: () => undefined,
|
|
37
37
|
ok: (content: string) => ({ content, isError: false }),
|
|
38
38
|
err: (content: string) => ({ content, isError: true }),
|
|
39
39
|
extractHeader: () => "",
|
|
@@ -65,7 +65,7 @@ describe("messaging-send tool", () => {
|
|
|
65
65
|
|
|
66
66
|
expect(result.isError).toBe(false);
|
|
67
67
|
expect(sendMessageMock).toHaveBeenCalledWith(
|
|
68
|
-
|
|
68
|
+
undefined,
|
|
69
69
|
"+15550004444",
|
|
70
70
|
"test message",
|
|
71
71
|
{
|
|
@@ -95,7 +95,7 @@ describe("messaging-send tool", () => {
|
|
|
95
95
|
|
|
96
96
|
expect(result.isError).toBe(false);
|
|
97
97
|
expect(sendMessageMock).toHaveBeenCalledWith(
|
|
98
|
-
|
|
98
|
+
undefined,
|
|
99
99
|
"conv-1",
|
|
100
100
|
"reply text",
|
|
101
101
|
{
|
|
@@ -9,11 +9,13 @@ function makeContext(sendToClient?: (msg: unknown) => void): ToolContext {
|
|
|
9
9
|
|
|
10
10
|
const CANONICAL_TABS = [
|
|
11
11
|
"General",
|
|
12
|
-
"Channels",
|
|
13
12
|
"Models & Services",
|
|
14
13
|
"Voice",
|
|
14
|
+
"Sounds",
|
|
15
15
|
"Permissions & Privacy",
|
|
16
|
-
"
|
|
16
|
+
"Billing",
|
|
17
|
+
"Archived Conversations",
|
|
18
|
+
"Schedules",
|
|
17
19
|
"Developer",
|
|
18
20
|
];
|
|
19
21
|
|
|
@@ -28,6 +30,8 @@ const LEGACY_TABS = [
|
|
|
28
30
|
"Privacy",
|
|
29
31
|
"Sentry Testing",
|
|
30
32
|
"Automation",
|
|
33
|
+
"Channels",
|
|
34
|
+
"Contacts",
|
|
31
35
|
];
|
|
32
36
|
|
|
33
37
|
describe("navigate-settings-tab", () => {
|