@circuitwall/jarela 2.3.0 → 2.5.0
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/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/app-path-routes-manifest.json +5 -5
- package/.next/standalone/.next/build-manifest.json +2 -2
- package/.next/standalone/.next/prerender-manifest.json +4 -4
- package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +14 -2
- package/.next/standalone/.next/server/app/_not-found.rsc +16 -4
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +16 -4
- package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +16 -4
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/api/v1/access/[identity]/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/access/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/agents/[id]/compact/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/agents/[id]/display-filters/route.js +17 -5
- package/.next/standalone/.next/server/app/api/v1/agents/[id]/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/agents/[id]/thread/route.js +17 -5
- package/.next/standalone/.next/server/app/api/v1/agents/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/allowed-sites/[hostname]/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/allowed-sites/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/artifacts/lifecycle/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/chats/route.js +26 -32
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/chats/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/ignores/[remote_jid]/route.js +18 -7
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/ignores/route.js +18 -7
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/lookup/route.js +26 -32
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/lookup/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/pair/route.js +27 -33
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/pair/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/route.js +26 -32
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/routes/[route_id]/route.js +28 -12
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/routes/route.js +28 -12
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/status/route.js +26 -32
- package/.next/standalone/.next/server/app/api/v1/bridges/[id]/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/v1/bridges/kinds/route.js +9 -2
- package/.next/standalone/.next/server/app/api/v1/bridges/kinds/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/v1/bridges/route.js +19 -7
- package/.next/standalone/.next/server/app/api/v1/bridges/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/v1/dashboard/metrics/route.js +17 -5
- package/.next/standalone/.next/server/app/api/v1/dashboard/pricing/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/dashboard/pricing-task/route.js +1 -1
- package/.next/standalone/.next/server/app/api/v1/documents/search/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/documents/settings/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/documents/sources/[id]/route.js +1 -1
- package/.next/standalone/.next/server/app/api/v1/documents/sources/route.js +1 -1
- package/.next/standalone/.next/server/app/api/v1/env-sync/allowlist/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/env-sync/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/events/route.js +26 -32
- package/.next/standalone/.next/server/app/api/v1/events/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/v1/extension/agents/route.js +1 -1
- package/.next/standalone/.next/server/app/api/v1/extension/browser/activate/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/extension/browser/foreground/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/extension/browser/history/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/extension/browser/poll/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/extension/browser/progress/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/extension/browser/result/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/extension/browser/retry/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/extension/browser/status/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/extension/browser/tabs/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/extension/fill/route.js +1 -1
- package/.next/standalone/.next/server/app/api/v1/extension/refine/route.js +1 -1
- package/.next/standalone/.next/server/app/api/v1/extension/turn/route.js +1 -1
- package/.next/standalone/.next/server/app/api/v1/extensions/route.js +1 -1
- package/.next/standalone/.next/server/app/api/v1/extensions/tools/[name]/config/route.js +1 -1
- package/.next/standalone/.next/server/app/api/v1/extensions/tools/[name]/route.js +1 -1
- package/.next/standalone/.next/server/app/api/v1/extensions/tools/[name]/secrets/route.js +1 -1
- package/.next/standalone/.next/server/app/api/v1/harnesses/[id]/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/harnesses/default/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/harnesses/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/health/route.js +17 -5
- package/.next/standalone/.next/server/app/api/v1/integrations/[name]/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/integrations/gmail/oauth/callback/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/integrations/gmail/oauth/start/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/integrations/gmail/oauth/status/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/integrations/outlook/oauth/callback/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/integrations/outlook/oauth/start/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/integrations/outlook/oauth/status/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/integrations/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/lifecycle/adoption/route.js +17 -5
- package/.next/standalone/.next/server/app/api/v1/maps/embed/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/mcp-servers/[name]/route.js +17 -5
- package/.next/standalone/.next/server/app/api/v1/mcp-servers/route.js +17 -5
- package/.next/standalone/.next/server/app/api/v1/memory/[namespace]/[key]/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/memory/policy/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/memory/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/memory/search/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/models/[name]/compact-threads/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/models/[name]/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/models/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/packages/defaults/[id]/route.js +28 -4
- package/.next/standalone/.next/server/app/api/v1/packages/reload/route.js +28 -4
- package/.next/standalone/.next/server/app/api/v1/packages/route.js +28 -4
- package/.next/standalone/.next/server/app/api/v1/page-capture/route.js +1 -1
- package/.next/standalone/.next/server/app/api/v1/pending-actions/[id]/approve/route.js +27 -12
- package/.next/standalone/.next/server/app/api/v1/profile/location/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/profile/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/providers/[provider]/models/route.js +585 -58
- package/.next/standalone/.next/server/app/api/v1/providers/[provider]/probe/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/providers/github-copilot/auth/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/providers/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/proxy-config/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/redaction/init/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/redaction/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/scheduled-tasks/[id]/route.js +17 -5
- package/.next/standalone/.next/server/app/api/v1/scheduled-tasks/[id]/run/route.js +1 -1
- package/.next/standalone/.next/server/app/api/v1/scheduled-tasks/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/security/idle-timeout/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/security/pin/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/security/state/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/security/unlock/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/security/verify-pin/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/skills/[id]/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/skills/repos/[id]/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/skills/repos/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/skills/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/tasks/[agent_id]/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/tasks/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/activity/route.js +7 -0
- package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/context-pin/route.js +12 -6
- package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/route.js +125 -26
- package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/run/route.js +1 -2
- package/.next/standalone/.next/server/app/api/v1/threads/route.js +17 -5
- package/.next/standalone/.next/server/app/api/v1/tools/route.js +1 -1
- package/.next/standalone/.next/server/app/api/v1/update/apply/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/v1/voice/transcribe/route.js +17 -5
- package/.next/standalone/.next/server/app/api/v1/voice/tts/route.js +17 -5
- package/.next/standalone/.next/server/app/api/v1/watchers/[id]/route.js +17 -5
- package/.next/standalone/.next/server/app/api/v1/watchers/[id]/run/route.js +1 -1
- package/.next/standalone/.next/server/app/api/v1/watchers/route.js +1 -1
- package/.next/standalone/.next/server/app/page.js +27323 -27034
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/setup/page.js +8 -0
- package/.next/standalone/.next/server/app/setup/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app-paths-manifest.json +5 -5
- package/.next/standalone/.next/server/chunks/1388.js +106 -36
- package/.next/standalone/.next/server/chunks/2068.js +47 -10
- package/.next/standalone/.next/server/chunks/210.js +6 -6
- package/.next/standalone/.next/server/chunks/2447.js +10 -5
- package/.next/standalone/.next/server/chunks/2653.js +80 -1
- package/.next/standalone/.next/server/chunks/3042.js +10 -8
- package/.next/standalone/.next/server/chunks/{4540.js → 309.js} +104 -38
- package/.next/standalone/.next/server/chunks/{2350.js → 3272.js} +104 -37
- package/.next/standalone/.next/server/chunks/3524.js +150 -67
- package/.next/standalone/.next/server/chunks/3566.js +1 -1
- package/.next/standalone/.next/server/chunks/4189.js +1 -1
- package/.next/standalone/.next/server/chunks/4596.js +28 -4
- package/.next/standalone/.next/server/chunks/46.js +91 -31
- package/.next/standalone/.next/server/chunks/466.js +53 -7
- package/.next/standalone/.next/server/chunks/4955.js +30 -3
- package/.next/standalone/.next/server/chunks/5585.js +28 -4
- package/.next/standalone/.next/server/chunks/5908.js +63 -42
- package/.next/standalone/.next/server/chunks/6142.js +10 -2
- package/.next/standalone/.next/server/chunks/8461.js +10 -8
- package/.next/standalone/.next/server/chunks/9032.js +5 -5
- package/.next/standalone/.next/server/chunks/9164.js +487 -177
- package/.next/standalone/.next/server/chunks/9304.js +51 -12
- package/.next/standalone/.next/server/chunks/{423.js → 9590.js} +84 -24
- package/.next/standalone/.next/server/middleware-build-manifest.js +2 -2
- package/.next/standalone/.next/server/middleware.js +89 -21
- package/.next/standalone/.next/server/pages/404.html +14 -2
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/server/preview-props.json +3 -3
- package/.next/standalone/.next/server/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/static/chunks/{1395-e4a99912b8f83044.js → 1395-bfe1f3ef760c0f50.js} +54 -8
- package/.next/standalone/.next/static/chunks/app/{layout-9bc0b10127cb7d42.js → layout-7b24ad13faa3944b.js} +69 -2
- package/.next/standalone/.next/static/chunks/app/{page-213a623b37226452.js → page-2ef8d75d40eb4311.js} +10643 -10295
- package/.next/standalone/.next/static/css/c160ea862133acd2.css +5 -0
- package/.next/standalone/docs/adoption/changesets.json +2805 -0
- package/.next/standalone/package.json +1 -1
- package/CHANGELOG.md +52 -0
- package/api/client.models.test.ts +10 -0
- package/api/client.startup.test.ts +129 -0
- package/api/client.ts +51 -9
- package/api/thread-metadata.test.ts +72 -0
- package/api/types.ts +6 -3
- package/app/api/v1/providers/[provider]/models/route.test.ts +216 -6
- package/app/api/v1/providers/[provider]/models/route.ts +259 -40
- package/app/api/v1/threads/[thread_id]/route.ts +26 -23
- package/app/api/v1/threads/[thread_id]/run/route.ts +0 -1
- package/app/globals.css +119 -29
- package/app/layout.tsx +12 -0
- package/components/agents/AgentsPanel.tsx +5 -13
- package/components/chat/ChatView.tsx +9 -18
- package/components/chat/InputBar.tsx +1 -1
- package/components/chat/MessageBubble.tsx +13 -1
- package/components/chat/MessageList.test.tsx +45 -4
- package/components/chat/MessageList.tsx +123 -89
- package/components/chat/ToolList.tsx +88 -84
- package/components/chat/useThreadActivity.test.tsx +2 -2
- package/components/chat/useThreadActivity.ts +2 -2
- package/components/chat/useThreadData.ts +1 -30
- package/components/chat/useThreadMetadataPolling.test.tsx +128 -0
- package/components/chat/useThreadMetadataPolling.ts +50 -0
- package/components/chat/useUserProfileAndAgent.test.tsx +31 -0
- package/components/chat/useUserProfileAndAgent.ts +13 -2
- package/components/dashboard/DashboardPanel.tsx +1 -1
- package/components/dashboard/Series.tsx +34 -21
- package/components/dashboard/SummaryCards.tsx +1 -1
- package/components/integrations/NetworkSection.tsx +2 -2
- package/components/layout/AppShell.tsx +22 -7
- package/components/layout/MenuPanel.tsx +14 -24
- package/components/profile/ProfilePanel.tsx +2 -2
- package/components/settings/AppearancePanel.tsx +70 -3
- package/components/settings/SettingsPanel.tsx +2 -2
- package/components/tools/ToolsPanel.tsx +2 -7
- package/components/ui/AppearancePreferences.test.tsx +68 -0
- package/components/ui/BootScreen.test.tsx +44 -0
- package/components/ui/BootScreen.tsx +24 -11
- package/components/ui/NotificationStatus.test.tsx +2 -2
- package/components/ui/NotificationStatus.tsx +5 -5
- package/components/ui/SubTabBar.tsx +2 -3
- package/contexts/ThemeContext.tsx +84 -1
- package/hooks/useAgentSession.test.ts +12 -1
- package/hooks/useAgentSession.ts +6 -4
- package/hooks/useUrlSync.test.ts +13 -0
- package/hooks/useUrlSync.ts +0 -4
- package/lib/agents/citation-checker.test.ts +40 -2
- package/lib/agents/citation-checker.ts +56 -11
- package/lib/agents/context-boundary.ts +5 -6
- package/lib/agents/instruction-edits.test.ts +120 -0
- package/lib/agents/instruction-edits.ts +65 -42
- package/lib/agents/prepare/request.ts +0 -2
- package/lib/agents/prepare/system-prompt.test.ts +10 -0
- package/lib/agents/prepare/system-prompt.ts +8 -2
- package/lib/agents/proposals.test.ts +76 -0
- package/lib/agents/proposals.ts +13 -5
- package/lib/agents/run-thread.auto-boundary.test.ts +125 -5
- package/lib/agents/run-thread.ts +24 -9
- package/lib/agents/sqlite-checkpoint-saver.test.ts +91 -0
- package/lib/agents/warm-summary-background.ts +22 -2
- package/lib/agents/warm-topics.test.ts +155 -1
- package/lib/agents/warm-topics.ts +27 -7
- package/lib/bridges/dispatcher.ts +1 -0
- package/lib/bridges/message-role.test.ts +81 -0
- package/lib/bridges/message-role.ts +23 -10
- package/lib/bridges/types.ts +6 -0
- package/lib/bridges/whatsapp-link.test.ts +32 -0
- package/lib/bridges/whatsapp-link.ts +21 -0
- package/lib/bridges/whatsapp.inbound.test.ts +239 -0
- package/lib/bridges/whatsapp.sender.test.ts +80 -1
- package/lib/bridges/whatsapp.ts +76 -38
- package/lib/db/migrations.test.ts +61 -0
- package/lib/db/migrations.ts +13 -0
- package/lib/embeddings/performance.test.ts +114 -0
- package/lib/pricing/snapshot.test.ts +70 -0
- package/lib/pricing/snapshot.ts +10 -2
- package/lib/proxy/dispatcher.test.ts +119 -1
- package/lib/proxy/dispatcher.ts +22 -9
- package/lib/stores/agent-configs.ts +10 -5
- package/lib/stores/bridges.test.ts +81 -0
- package/lib/stores/bridges.ts +9 -8
- package/lib/stores/dashboard-metrics-cache.test.ts +99 -0
- package/lib/stores/dashboard-metrics.ts +30 -4
- package/lib/stores/message-embedding-jobs.test.ts +27 -0
- package/lib/stores/message-embedding-jobs.ts +5 -3
- package/lib/stores/message-usage.test.ts +21 -1
- package/lib/stores/pricing-cache.test.ts +31 -0
- package/lib/stores/pricing.ts +3 -0
- package/lib/stores/proxy-config.test.ts +120 -0
- package/lib/stores/proxy-config.ts +22 -4
- package/lib/stores/threads.ts +12 -8
- package/lib/tools/communications/calendar.test.ts +43 -2
- package/lib/tools/communications/calendar.ts +2 -0
- package/lib/tools/communications/gmail.test.ts +51 -1
- package/lib/tools/communications/gmail.ts +44 -2
- package/lib/tools/communications/outlook-calendar.ts +2 -0
- package/lib/tools/communications/outlook.test.ts +33 -2
- package/lib/tools/communications/outlook.ts +2 -0
- package/lib/tools/filesystem/edit-match.test.ts +133 -0
- package/lib/tools/filesystem/edit-match.ts +7 -2
- package/lib/tools/packages/default-packages.test.ts +21 -0
- package/lib/tools/packages/optional-package.test.ts +72 -0
- package/lib/tools/packages/optional-package.ts +33 -4
- package/lib/tools/support/result-refs.test.ts +131 -0
- package/lib/tools/support/result-refs.ts +41 -9
- package/lib/ui/navigate.test.ts +13 -0
- package/lib/ui/navigate.ts +10 -2
- package/lib/utils/source-link.test.ts +51 -0
- package/lib/utils/source-link.ts +45 -0
- package/package.json +1 -1
- package/.next/standalone/.next/static/css/312542a5725ba0d5.css +0 -5
- /package/.next/standalone/.next/static/{bMRK5y3ruT7c3WtBfCQOG → 544OFJV-q0RC6Gjgje4LT}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{bMRK5y3ruT7c3WtBfCQOG → 544OFJV-q0RC6Gjgje4LT}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { afterAll, beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
const tmpRoot = mkdtempSync(join(tmpdir(), "jarela-test-proxy-config-"));
|
|
7
|
+
process.env.JARELA_DB_DIR = tmpRoot;
|
|
8
|
+
|
|
9
|
+
const store = await import("./proxy-config");
|
|
10
|
+
const { getDb } = await import("@/lib/db");
|
|
11
|
+
const { SECRET_MASK } = await import("./integrations");
|
|
12
|
+
|
|
13
|
+
afterAll(() => {
|
|
14
|
+
try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* */ }
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const PEM = "-----BEGIN CERTIFICATE-----\nMIIBfake\n-----END CERTIFICATE-----\n";
|
|
18
|
+
|
|
19
|
+
function storedPasswordColumn(): string | null {
|
|
20
|
+
const row = getDb().prepare("SELECT password FROM proxy_config WHERE id = 1").get() as { password: string | null } | undefined;
|
|
21
|
+
return row?.password ?? null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("proxy-config store", () => {
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
getDb().exec("DELETE FROM proxy_config");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("returns the default off status before anything is saved", () => {
|
|
30
|
+
expect(store.getProxyConfigStatus()).toMatchObject({ mode: "off", scheme: "http", host: null, password: null });
|
|
31
|
+
expect(store.getProxyConfigRaw()).toBeNull();
|
|
32
|
+
expect(store.deleteProxyConfig()).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("encrypts the password at rest, masks it in status and decrypts it for the dispatcher", () => {
|
|
36
|
+
const saved = store.saveProxyConfig({ mode: "manual", host: "proxy.corp", port: 3128, username: "u", password: "p@ss:word" });
|
|
37
|
+
expect(saved).toMatchObject({ mode: "manual", host: "proxy.corp", port: 3128, password: SECRET_MASK });
|
|
38
|
+
expect(storedPasswordColumn()).not.toContain("p@ss:word");
|
|
39
|
+
expect(store.getProxyConfigRaw()?.password).toBe("p@ss:word");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("keeps the existing password when the UI echoes the mask back, without re-encrypting it", () => {
|
|
43
|
+
store.saveProxyConfig({ mode: "manual", host: "proxy.corp", port: 3128, username: "u", password: "secret" });
|
|
44
|
+
const before = storedPasswordColumn();
|
|
45
|
+
store.saveProxyConfig({ mode: "manual", host: "proxy2.corp", port: 8080, username: "u", password: SECRET_MASK });
|
|
46
|
+
expect(storedPasswordColumn()).toBe(before);
|
|
47
|
+
expect(store.getProxyConfigRaw()).toMatchObject({ host: "proxy2.corp", port: 8080, password: "secret" });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("never stores the mask itself as a password when none existed", () => {
|
|
51
|
+
store.saveProxyConfig({ mode: "manual", host: "proxy.corp", port: 3128, password: SECRET_MASK });
|
|
52
|
+
expect(store.getProxyConfigRaw()?.password).toBeNull();
|
|
53
|
+
expect(store.getProxyConfigStatus().password).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("clears the password when an empty string or null is sent", () => {
|
|
57
|
+
store.saveProxyConfig({ mode: "manual", host: "proxy.corp", port: 3128, password: "secret" });
|
|
58
|
+
store.saveProxyConfig({ mode: "manual", host: "proxy.corp", port: 3128, password: "" });
|
|
59
|
+
expect(store.getProxyConfigRaw()?.password).toBeNull();
|
|
60
|
+
store.saveProxyConfig({ mode: "manual", host: "proxy.corp", port: 3128, password: "secret" });
|
|
61
|
+
store.saveProxyConfig({ mode: "manual", host: "proxy.corp", port: 3128, password: null });
|
|
62
|
+
expect(store.getProxyConfigRaw()?.password).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("trims host, username and no_proxy and stores blanks as null", () => {
|
|
66
|
+
store.saveProxyConfig({ mode: "manual", host: " proxy.corp ", port: 3128, username: " ", no_proxy: " " });
|
|
67
|
+
expect(store.getProxyConfigRaw()).toMatchObject({ host: "proxy.corp", username: null, no_proxy: null });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it.each([
|
|
71
|
+
["a URL with a scheme", "http://proxy.corp"],
|
|
72
|
+
["a host with a port", "proxy.corp:8080"],
|
|
73
|
+
["a hex-only host with a port", "dead.beef:3128"],
|
|
74
|
+
["a host with a path", "proxy.corp/pac"],
|
|
75
|
+
["a host with credentials", "user:pw@proxy.corp"],
|
|
76
|
+
["a host with spaces", "proxy corp"],
|
|
77
|
+
])("rejects %s in manual mode instead of building a broken proxy URL", (_label, host) => {
|
|
78
|
+
const result = store.saveProxyConfig({ mode: "manual", host, port: 3128 });
|
|
79
|
+
expect(result).toHaveProperty("error");
|
|
80
|
+
expect((result as { error: string }).error).toMatch(/host/i);
|
|
81
|
+
expect(store.getProxyConfigRaw()).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it.each([
|
|
85
|
+
["fd00::1", "fd00::1"],
|
|
86
|
+
["[fd00::1]", "fd00::1"],
|
|
87
|
+
["10.0.0.5", "10.0.0.5"],
|
|
88
|
+
["proxy-1.corp.example", "proxy-1.corp.example"],
|
|
89
|
+
])("accepts host %s and stores it as %s", (input, stored) => {
|
|
90
|
+
const result = store.saveProxyConfig({ mode: "manual", host: input, port: 3128 });
|
|
91
|
+
expect(result).not.toHaveProperty("error");
|
|
92
|
+
expect(store.getProxyConfigRaw()?.host).toBe(stored);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it.each([0, -1, 65536, 3128.5, Number.NaN, Number.POSITIVE_INFINITY])("rejects port %s", (port) => {
|
|
96
|
+
expect(store.saveProxyConfig({ mode: "manual", host: "proxy.corp", port })).toHaveProperty("error");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("requires host and port only in manual mode", () => {
|
|
100
|
+
expect(store.saveProxyConfig({ mode: "manual", port: 3128 })).toHaveProperty("error");
|
|
101
|
+
expect(store.saveProxyConfig({ mode: "manual", host: "proxy.corp" })).toHaveProperty("error");
|
|
102
|
+
expect(store.saveProxyConfig({ mode: "system" })).toMatchObject({ mode: "system" });
|
|
103
|
+
expect(store.saveProxyConfig({ mode: "off" })).toMatchObject({ mode: "off" });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("rejects an invalid mode and a non-PEM CA bundle, and clears a blank bundle", () => {
|
|
107
|
+
expect(store.saveProxyConfig({ mode: "auto" as never })).toHaveProperty("error");
|
|
108
|
+
expect(store.saveProxyConfig({ mode: "off", ca_bundle: "not a cert" })).toHaveProperty("error");
|
|
109
|
+
store.saveProxyConfig({ mode: "off", ca_bundle: PEM });
|
|
110
|
+
expect(store.getProxyConfigRaw()?.ca_bundle).toBe(PEM);
|
|
111
|
+
store.saveProxyConfig({ mode: "off", ca_bundle: " " });
|
|
112
|
+
expect(store.getProxyConfigRaw()?.ca_bundle).toBeNull();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("normalises an unknown persisted scheme to http", () => {
|
|
116
|
+
store.saveProxyConfig({ mode: "manual", host: "proxy.corp", port: 3128 });
|
|
117
|
+
getDb().prepare("UPDATE proxy_config SET scheme = 'socks5' WHERE id = 1").run();
|
|
118
|
+
expect(store.getProxyConfigRaw()).toMatchObject({ mode: "manual", scheme: "http" });
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
// existing password" so the UI can echo back the masked form without
|
|
16
16
|
// blanking the secret.
|
|
17
17
|
|
|
18
|
+
import { isIPv6 } from "node:net";
|
|
18
19
|
import { getDb } from "@/lib/db";
|
|
19
20
|
import { decryptIfNeeded, encrypt } from "@/lib/crypto/envelope";
|
|
20
21
|
import { SECRET_MASK } from "@/lib/stores/integrations";
|
|
@@ -122,11 +123,18 @@ export function saveProxyConfig(input: ProxyConfigInput): ProxyConfigStatus | {
|
|
|
122
123
|
const scheme: ProxyScheme = input.scheme === "https" ? "https" : "http";
|
|
123
124
|
|
|
124
125
|
// Manual mode requires host + port. system / off don't.
|
|
126
|
+
let host = nullable(input.host);
|
|
125
127
|
if (input.mode === "manual") {
|
|
126
|
-
if (!
|
|
127
|
-
|
|
128
|
-
|
|
128
|
+
if (!host) return { error: "host is required in manual mode" };
|
|
129
|
+
const normalisedHost = normaliseProxyHost(host);
|
|
130
|
+
if (!normalisedHost) {
|
|
131
|
+
return { error: "host must be a hostname or IP address, without scheme, port, path or credentials" };
|
|
129
132
|
}
|
|
133
|
+
host = normalisedHost;
|
|
134
|
+
if (input.port == null) return { error: "port must be between 1 and 65535" };
|
|
135
|
+
}
|
|
136
|
+
if (input.port != null && (!Number.isInteger(input.port) || input.port <= 0 || input.port > 65535)) {
|
|
137
|
+
return { error: "port must be between 1 and 65535" };
|
|
130
138
|
}
|
|
131
139
|
|
|
132
140
|
// CA bundle: empty/null clears; non-empty must look like PEM.
|
|
@@ -167,7 +175,7 @@ export function saveProxyConfig(input: ProxyConfigInput): ProxyConfigStatus | {
|
|
|
167
175
|
).run(
|
|
168
176
|
input.mode,
|
|
169
177
|
scheme,
|
|
170
|
-
|
|
178
|
+
host,
|
|
171
179
|
input.port ?? null,
|
|
172
180
|
nullable(input.username),
|
|
173
181
|
storedPassword,
|
|
@@ -207,6 +215,16 @@ function normaliseScheme(s: string | null | undefined): ProxyScheme {
|
|
|
207
215
|
return s === "https" ? "https" : "http";
|
|
208
216
|
}
|
|
209
217
|
|
|
218
|
+
// The dispatcher builds `${scheme}://${auth}${host}:${port}`, so a pasted URL
|
|
219
|
+
// or host:port would produce an unusable proxy URL. IPv6 is stored unbracketed;
|
|
220
|
+
// the dispatcher adds the brackets.
|
|
221
|
+
function normaliseProxyHost(host: string): string | null {
|
|
222
|
+
const unbracketed = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
|
|
223
|
+
if (/^[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$/.test(unbracketed)) return unbracketed;
|
|
224
|
+
if (isIPv6(unbracketed)) return unbracketed;
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
|
|
210
228
|
function nullable(v: string | null | undefined): string | null {
|
|
211
229
|
if (v == null) return null;
|
|
212
230
|
const trimmed = v.trim();
|
package/lib/stores/threads.ts
CHANGED
|
@@ -157,11 +157,15 @@ export function getThreadMessageBySeq(thread_id: string, seq: number): MessageRo
|
|
|
157
157
|
return (getDb().prepare(MSG_COLS_SQL + " WHERE thread_id=? AND rowid=?").get(thread_id, seq) as unknown as MessageRow | undefined) ?? null;
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
-
|
|
161
|
-
|
|
160
|
+
|
|
161
|
+
// Rows that size compaction can prune: automation-channel rows are kept
|
|
162
|
+
// (see pruneThreadMessages), so they must not count toward its trigger.
|
|
163
|
+
export function countForegroundThreadMessages(thread_id: string, fromSeq = 0): number {
|
|
164
|
+
const row = getDb()
|
|
165
|
+
.prepare("SELECT COUNT(*) AS n FROM messages WHERE thread_id=? AND rowid>=? AND " + foregroundCategoryGuardSql())
|
|
166
|
+
.get(thread_id, fromSeq, ...FOREGROUND_EXCLUDED_CATEGORIES) as { n: number } | undefined;
|
|
162
167
|
return row?.n ?? 0;
|
|
163
168
|
}
|
|
164
|
-
|
|
165
169
|
export function isValidThreadContextCursor(thread_id: string, seq: number): boolean {
|
|
166
170
|
if (getThreadMessageBySeq(thread_id, seq)) return true;
|
|
167
171
|
return getThread(thread_id)?.hot_since_seq === seq;
|
|
@@ -733,11 +737,11 @@ export function setThreadContextPin(thread_id: string, hot_since: string | null,
|
|
|
733
737
|
.run(hot_since, hot_since === null ? null : hot_since_seq!, thread_id);
|
|
734
738
|
}
|
|
735
739
|
|
|
736
|
-
//
|
|
737
|
-
// boundary (drag, or manual /compact)
|
|
738
|
-
//
|
|
739
|
-
// is a one-time write; the
|
|
740
|
-
//
|
|
740
|
+
// Armed by moveThreadContextBoundary whenever the user explicitly moves the
|
|
741
|
+
// boundary (drag, or manual /compact), and by the auto-detector's hot-turn
|
|
742
|
+
// cooldown when a compaction pass ends without a commit (a commit writes the
|
|
743
|
+
// lock in the same transaction). `untilMessageCount` is a one-time write; the
|
|
744
|
+
// read side (lib/agents/run-thread.ts) just compares against it.
|
|
741
745
|
export function setAutoBoundaryLock(thread_id: string, untilMessageCount: number): void {
|
|
742
746
|
getDb()
|
|
743
747
|
.prepare("UPDATE threads SET auto_boundary_locked_until_msg_count=? WHERE thread_id=?")
|
|
@@ -1,6 +1,47 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
|
|
3
|
-
const { calendarListEventsTool } = await import("./calendar");
|
|
3
|
+
const { calendarGetEventTool, calendarListEventsTool } = await import("./calendar");
|
|
4
|
+
const { outlookCalendarGetEventTool } = await import("./outlook-calendar");
|
|
5
|
+
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
vi.unstubAllGlobals();
|
|
8
|
+
vi.unstubAllEnvs();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
function stubApi(tokenHost: string, body: unknown) {
|
|
12
|
+
vi.stubGlobal("fetch", vi.fn(async (url: string) => {
|
|
13
|
+
if (url.includes(tokenHost)) return new Response(JSON.stringify({ access_token: "at", expires_in: 3600 }), { status: 200 });
|
|
14
|
+
return new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } });
|
|
15
|
+
}));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("calendar source links", () => {
|
|
19
|
+
it("returns Google's htmlLink as html_link and cite_as", async () => {
|
|
20
|
+
vi.stubEnv("GMAIL_CLIENT_ID", "test-client");
|
|
21
|
+
vi.stubEnv("GMAIL_CLIENT_SECRET", "test-secret");
|
|
22
|
+
vi.stubEnv("GMAIL_REFRESH_TOKEN", "calendar-source-link-refresh");
|
|
23
|
+
const htmlLink = "https://www.google.com/calendar/event?eid=ZXZ0MSB1c2VyQGV4YW1wbGUudGVzdA";
|
|
24
|
+
stubApi("oauth2", { id: "evt1", summary: "Dentist", htmlLink });
|
|
25
|
+
|
|
26
|
+
const out = JSON.parse(await calendarGetEventTool.invoke({ event_id: "evt1" })) as Record<string, unknown>;
|
|
27
|
+
|
|
28
|
+
expect(out.html_link).toBe(htmlLink);
|
|
29
|
+
expect(out.cite_as).toBe(`[Dentist](${htmlLink})`);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("returns Outlook's webLink as web_link and cite_as", async () => {
|
|
33
|
+
vi.stubEnv("OUTLOOK_CLIENT_ID", "test-client");
|
|
34
|
+
vi.stubEnv("OUTLOOK_CLIENT_SECRET", "test-secret");
|
|
35
|
+
vi.stubEnv("OUTLOOK_REFRESH_TOKEN", "outlook-calendar-source-link-refresh");
|
|
36
|
+
const webLink = "https://outlook.office365.com/owa/?itemid=AAMk%3D&exvsurl=1&path=/calendar/item";
|
|
37
|
+
stubApi("/oauth2/", { id: "evt2", subject: "Standup", webLink });
|
|
38
|
+
|
|
39
|
+
const out = JSON.parse(await outlookCalendarGetEventTool.invoke({ event_id: "evt2" })) as Record<string, unknown>;
|
|
40
|
+
|
|
41
|
+
expect(out.web_link).toBe(webLink);
|
|
42
|
+
expect(out.cite_as).toBe(`[Standup](${webLink})`);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
4
45
|
|
|
5
46
|
describe("calendar_list_events", () => {
|
|
6
47
|
it("rejects date-only time bounds before calling Google Calendar", async () => {
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
import { tool } from "@langchain/core/tools";
|
|
26
26
|
import { z } from "zod";
|
|
27
27
|
import { registerLangChainPackage } from "../packages/langchain-package";
|
|
28
|
+
import { citeAs } from "@/lib/utils/source-link";
|
|
28
29
|
import {
|
|
29
30
|
googleFetch,
|
|
30
31
|
resolveGoogleAuth,
|
|
@@ -94,6 +95,7 @@ function slimEvent(e: CalendarEvent): Record<string, unknown> {
|
|
|
94
95
|
})),
|
|
95
96
|
meet_link: e.hangoutLink ?? null,
|
|
96
97
|
html_link: e.htmlLink ?? null,
|
|
98
|
+
cite_as: citeAs(e.summary, e.htmlLink, "Calendar event"),
|
|
97
99
|
};
|
|
98
100
|
}
|
|
99
101
|
|
|
@@ -2,10 +2,60 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
2
2
|
import { gzipSync } from "node:zlib";
|
|
3
3
|
import { googleFetch } from "@/lib/integrations/gmail-oauth";
|
|
4
4
|
|
|
5
|
-
const { buildRawMessage, gmailCreateDraftTool, gmailModifyMessageTool, gmailSendEmailTool } = await import("./gmail");
|
|
5
|
+
const { buildRawMessage, gmailCreateDraftTool, gmailGetMessageTool, gmailModifyMessageTool, gmailSendEmailTool, gmailWebLink } = await import("./gmail");
|
|
6
6
|
|
|
7
7
|
afterEach(() => {
|
|
8
8
|
vi.unstubAllGlobals();
|
|
9
|
+
vi.unstubAllEnvs();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe("gmail source links", () => {
|
|
13
|
+
it("builds an All Mail thread link scoped to the account", () => {
|
|
14
|
+
expect(gmailWebLink("18c2f0a1b2c3d4e5", "me+x@example.test"))
|
|
15
|
+
.toBe("https://mail.google.com/mail/u/me%2Bx%40example.test/#all/18c2f0a1b2c3d4e5");
|
|
16
|
+
expect(gmailWebLink("18c2f0a1b2c3d4e5", null)).toBe("https://mail.google.com/mail/u/0/#all/18c2f0a1b2c3d4e5");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("refuses ids that are not Gmail API hex ids", () => {
|
|
20
|
+
expect(gmailWebLink(undefined, null)).toBeNull();
|
|
21
|
+
expect(gmailWebLink("../inbox", null)).toBeNull();
|
|
22
|
+
expect(gmailWebLink("18c2f0a1)x", null)).toBeNull();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function stubGmail(profile: Response) {
|
|
26
|
+
vi.stubEnv("GMAIL_CLIENT_ID", "test-client");
|
|
27
|
+
vi.stubEnv("GMAIL_CLIENT_SECRET", "test-secret");
|
|
28
|
+
const json = (body: unknown) => new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } });
|
|
29
|
+
vi.stubGlobal("fetch", vi.fn(async (url: string) => {
|
|
30
|
+
if (url.includes("oauth2")) return json({ access_token: "at", expires_in: 3600 });
|
|
31
|
+
if (url.endsWith("/profile")) return profile;
|
|
32
|
+
return json({
|
|
33
|
+
id: "18c2f0a1b2c3d4e6",
|
|
34
|
+
threadId: "18c2f0a1b2c3d4e5",
|
|
35
|
+
payload: { headers: [{ name: "Subject", value: "Invoice [March]" }] },
|
|
36
|
+
});
|
|
37
|
+
}));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
it("returns web_link and cite_as from gmail_get_message", async () => {
|
|
41
|
+
vi.stubEnv("GMAIL_REFRESH_TOKEN", "gmail-link-ok-refresh-token");
|
|
42
|
+
stubGmail(new Response(JSON.stringify({ emailAddress: "owner@example.test" }), { status: 200, headers: { "content-type": "application/json" } }));
|
|
43
|
+
|
|
44
|
+
const out = JSON.parse(await gmailGetMessageTool.invoke({ id: "18c2f0a1b2c3d4e6" })) as Record<string, unknown>;
|
|
45
|
+
|
|
46
|
+
const link = "https://mail.google.com/mail/u/owner%40example.test/#all/18c2f0a1b2c3d4e5";
|
|
47
|
+
expect(out.web_link).toBe(link);
|
|
48
|
+
expect(out.cite_as).toBe(`[Invoice \\[March\\]](${link})`);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("falls back to the first signed-in account when the profile lookup fails", async () => {
|
|
52
|
+
vi.stubEnv("GMAIL_REFRESH_TOKEN", "gmail-link-fail-refresh-token");
|
|
53
|
+
stubGmail(new Response(JSON.stringify({ error: { message: "forbidden" } }), { status: 403, headers: { "content-type": "application/json" } }));
|
|
54
|
+
|
|
55
|
+
const out = JSON.parse(await gmailGetMessageTool.invoke({ id: "18c2f0a1b2c3d4e6" })) as Record<string, unknown>;
|
|
56
|
+
|
|
57
|
+
expect(out.web_link).toBe("https://mail.google.com/mail/u/0/#all/18c2f0a1b2c3d4e5");
|
|
58
|
+
});
|
|
9
59
|
});
|
|
10
60
|
|
|
11
61
|
describe("gmail tools", () => {
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
type GoogleAuth,
|
|
31
31
|
} from "@/lib/integrations/gmail-oauth";
|
|
32
32
|
import { errorMessage } from "@/lib/utils/error";
|
|
33
|
+
import { citeAs } from "@/lib/utils/source-link";
|
|
33
34
|
|
|
34
35
|
// Re-export under the old name so existing imports (e.g. the integrations
|
|
35
36
|
// test endpoint) keep working unchanged.
|
|
@@ -62,6 +63,38 @@ function resolveGmailAuthForTool(): GmailAuth | { error: string } {
|
|
|
62
63
|
}
|
|
63
64
|
}
|
|
64
65
|
|
|
66
|
+
// ── Source links ────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
// Gmail's web URL scheme is undocumented. `#all/<threadId>` opens the
|
|
69
|
+
// conversation from All Mail whatever its labels, and `/u/<email>/` selects
|
|
70
|
+
// the signed-in account (`/u/0/` is whichever account signed in first).
|
|
71
|
+
const GMAIL_API_ID = /^[0-9a-f]{8,32}$/i;
|
|
72
|
+
|
|
73
|
+
export function gmailWebLink(threadId: string | null | undefined, account: string | null): string | null {
|
|
74
|
+
if (!threadId || !GMAIL_API_ID.test(threadId)) return null;
|
|
75
|
+
const slot = account ? encodeURIComponent(account) : "0";
|
|
76
|
+
return `https://mail.google.com/mail/u/${slot}/#all/${threadId}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const accountEmailCache = new Map<string, Promise<string | null>>();
|
|
80
|
+
|
|
81
|
+
// getProfile works with every Gmail scope; a failure only drops the account
|
|
82
|
+
// from the link, so it is not retried within the process.
|
|
83
|
+
function gmailAccountEmail(auth: GmailAuth): Promise<string | null> {
|
|
84
|
+
const key = auth.refresh_token.slice(0, 20);
|
|
85
|
+
let pending = accountEmailCache.get(key);
|
|
86
|
+
if (!pending) {
|
|
87
|
+
pending = gmailFetch(auth, "/profile")
|
|
88
|
+
.then((p) => {
|
|
89
|
+
const email = (p as { emailAddress?: unknown }).emailAddress;
|
|
90
|
+
return typeof email === "string" && email.includes("@") ? email : null;
|
|
91
|
+
})
|
|
92
|
+
.catch(() => null);
|
|
93
|
+
accountEmailCache.set(key, pending);
|
|
94
|
+
}
|
|
95
|
+
return pending;
|
|
96
|
+
}
|
|
97
|
+
|
|
65
98
|
// ── Decoders / helpers ──────────────────────────────────────────────────────
|
|
66
99
|
|
|
67
100
|
// Gmail uses base64url (no padding, +/ → -_). Node's Buffer handles it directly.
|
|
@@ -164,6 +197,7 @@ export const gmailSearchTool = tool(
|
|
|
164
197
|
messages: [],
|
|
165
198
|
});
|
|
166
199
|
}
|
|
200
|
+
const account = await gmailAccountEmail(auth);
|
|
167
201
|
// Parallel metadata fetch; the API recommends batching but for ≤100 ids
|
|
168
202
|
// parallel single calls are simpler and well within quota.
|
|
169
203
|
const details = await Promise.all(ids.map(async (id) => {
|
|
@@ -172,15 +206,19 @@ export const gmailSearchTool = tool(
|
|
|
172
206
|
`/messages/${id}?format=metadata&metadataHeaders=From&metadataHeaders=To&metadataHeaders=Subject&metadataHeaders=Date`,
|
|
173
207
|
) as { id?: string; threadId?: string; snippet?: string; labelIds?: string[]; payload?: { headers?: Header[] }; error?: string };
|
|
174
208
|
if (m.error) return { id, error: m.error };
|
|
209
|
+
const subject = header(m.payload?.headers, "Subject");
|
|
210
|
+
const webLink = gmailWebLink(m.threadId, account);
|
|
175
211
|
return {
|
|
176
212
|
id: m.id,
|
|
177
213
|
thread_id: m.threadId,
|
|
178
214
|
from: header(m.payload?.headers, "From"),
|
|
179
215
|
to: header(m.payload?.headers, "To"),
|
|
180
|
-
subject
|
|
216
|
+
subject,
|
|
181
217
|
date: header(m.payload?.headers, "Date"),
|
|
182
218
|
snippet: m.snippet ?? null,
|
|
183
219
|
labels: m.labelIds ?? [],
|
|
220
|
+
web_link: webLink,
|
|
221
|
+
cite_as: citeAs(subject, webLink, "Gmail message"),
|
|
184
222
|
};
|
|
185
223
|
}));
|
|
186
224
|
return JSON.stringify({
|
|
@@ -220,14 +258,18 @@ export const gmailGetMessageTool = tool(
|
|
|
220
258
|
if (m.error) return JSON.stringify(m);
|
|
221
259
|
const body = extractBody(m.payload);
|
|
222
260
|
const { text: capped, truncated } = truncateBytes(body, MAX_BODY_BYTES);
|
|
261
|
+
const subject = header(m.payload?.headers, "Subject");
|
|
262
|
+
const webLink = gmailWebLink(m.threadId, await gmailAccountEmail(auth));
|
|
223
263
|
return JSON.stringify({
|
|
224
264
|
id: m.id,
|
|
225
265
|
thread_id: m.threadId,
|
|
266
|
+
web_link: webLink,
|
|
267
|
+
cite_as: citeAs(subject, webLink, "Gmail message"),
|
|
226
268
|
labels: m.labelIds ?? [],
|
|
227
269
|
from: header(m.payload?.headers, "From"),
|
|
228
270
|
to: header(m.payload?.headers, "To"),
|
|
229
271
|
cc: header(m.payload?.headers, "Cc"),
|
|
230
|
-
subject
|
|
272
|
+
subject,
|
|
231
273
|
date: header(m.payload?.headers, "Date"),
|
|
232
274
|
message_id: header(m.payload?.headers, "Message-Id") ?? header(m.payload?.headers, "Message-ID"),
|
|
233
275
|
snippet: m.snippet ?? null,
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import { tool } from "@langchain/core/tools";
|
|
20
20
|
import { z } from "zod";
|
|
21
21
|
import { registerLangChainPackage } from "../packages/langchain-package";
|
|
22
|
+
import { citeAs } from "@/lib/utils/source-link";
|
|
22
23
|
import {
|
|
23
24
|
graphFetch,
|
|
24
25
|
resolveMicrosoftAuth,
|
|
@@ -81,6 +82,7 @@ function slimEvent(e: GraphEvent): Record<string, unknown> {
|
|
|
81
82
|
show_as: e.showAs ?? null,
|
|
82
83
|
teams_link: e.onlineMeeting?.joinUrl ?? null,
|
|
83
84
|
web_link: e.webLink ?? null,
|
|
85
|
+
cite_as: citeAs(e.subject, e.webLink, "Outlook event"),
|
|
84
86
|
};
|
|
85
87
|
}
|
|
86
88
|
|
|
@@ -1,6 +1,37 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
|
|
3
|
-
const { outlookCreateDraftTool, outlookSendEmailTool } = await import("./outlook");
|
|
3
|
+
const { outlookCreateDraftTool, outlookSearchTool, outlookSendEmailTool } = await import("./outlook");
|
|
4
|
+
|
|
5
|
+
afterEach(() => {
|
|
6
|
+
vi.unstubAllGlobals();
|
|
7
|
+
vi.unstubAllEnvs();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
describe("outlook source links", () => {
|
|
11
|
+
it("returns the Graph webLink and a cite_as markdown link per message", async () => {
|
|
12
|
+
vi.stubEnv("OUTLOOK_CLIENT_ID", "test-client");
|
|
13
|
+
vi.stubEnv("OUTLOOK_CLIENT_SECRET", "test-secret");
|
|
14
|
+
vi.stubEnv("OUTLOOK_REFRESH_TOKEN", "outlook-source-link-refresh");
|
|
15
|
+
const webLink = "https://outlook.office365.com/owa/?ItemID=AAMk%2BA%3D&exvsurl=1&viewmodel=ReadMessageItem";
|
|
16
|
+
vi.stubGlobal("fetch", vi.fn(async (url: string) => {
|
|
17
|
+
if (url.includes("/oauth2/")) return new Response(JSON.stringify({ access_token: "at", expires_in: 3600 }), { status: 200 });
|
|
18
|
+
return new Response(JSON.stringify({
|
|
19
|
+
value: [
|
|
20
|
+
{ id: "m1", subject: "Budget [Q3]", webLink },
|
|
21
|
+
{ id: "m2", subject: "No link" },
|
|
22
|
+
],
|
|
23
|
+
}), { status: 200, headers: { "content-type": "application/json" } });
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
const out = JSON.parse(await outlookSearchTool.invoke({ query: "budget" })) as {
|
|
27
|
+
messages: Array<{ web_link: string | null; cite_as: string | null }>;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
expect(out.messages[0].web_link).toBe(webLink);
|
|
31
|
+
expect(out.messages[0].cite_as).toBe(`[Budget \\[Q3\\]](${webLink})`);
|
|
32
|
+
expect(out.messages[1]).toMatchObject({ web_link: null, cite_as: null });
|
|
33
|
+
});
|
|
34
|
+
});
|
|
4
35
|
|
|
5
36
|
describe("outlook_create_draft", () => {
|
|
6
37
|
it("rejects oversized draft bodies before calling Microsoft Graph", async () => {
|
|
@@ -14,6 +14,7 @@ import { tool } from "@langchain/core/tools";
|
|
|
14
14
|
import { z } from "zod";
|
|
15
15
|
import { stripHtml } from "@/lib/utils/html";
|
|
16
16
|
import { truncateBytes } from "@/lib/utils/text";
|
|
17
|
+
import { citeAs } from "@/lib/utils/source-link";
|
|
17
18
|
import { registerLangChainPackage } from "../packages/langchain-package";
|
|
18
19
|
import {
|
|
19
20
|
graphFetch,
|
|
@@ -84,6 +85,7 @@ function summarizeMessage(m: GraphMessage): Record<string, unknown> {
|
|
|
84
85
|
folder_id: m.parentFolderId ?? null,
|
|
85
86
|
snippet: m.bodyPreview ?? null,
|
|
86
87
|
web_link: m.webLink ?? null,
|
|
88
|
+
cite_as: citeAs(m.subject, m.webLink, "Outlook message"),
|
|
87
89
|
};
|
|
88
90
|
}
|
|
89
91
|
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
buildMultipleMatchesDiagnostic,
|
|
4
|
+
buildNotFoundDiagnostic,
|
|
5
|
+
conformEol,
|
|
6
|
+
dominantEol,
|
|
7
|
+
lineNumberAt,
|
|
8
|
+
locateOldString,
|
|
9
|
+
} from "./edit-match";
|
|
10
|
+
|
|
11
|
+
const slice = (raw: string, r: { start: number; end: number }) => raw.slice(r.start, r.end);
|
|
12
|
+
|
|
13
|
+
describe("locateOldString", () => {
|
|
14
|
+
it("returns every non-overlapping exact occurrence", () => {
|
|
15
|
+
const attempt = locateOldString("aaaa", "aa", "exact");
|
|
16
|
+
expect(attempt.ranges).toEqual([{ start: 0, end: 2 }, { start: 2, end: 4 }]);
|
|
17
|
+
expect(attempt).toMatchObject({ usedStrategy: "exact", crlfAdjusted: false });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("never matches an empty old_string", () => {
|
|
21
|
+
expect(locateOldString("abc", "", "fuzzy").ranges).toEqual([]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("maps an LF old_string onto a CRLF file in raw coordinates", () => {
|
|
25
|
+
const raw = "one\r\ntwo\r\nthree\r\n";
|
|
26
|
+
const attempt = locateOldString(raw, "two\nthree\n", "exact");
|
|
27
|
+
expect(attempt.crlfAdjusted).toBe(true);
|
|
28
|
+
expect(slice(raw, attempt.ranges[0])).toBe("two\r\nthree\r\n");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("maps a CRLF old_string onto an LF file", () => {
|
|
32
|
+
const raw = "a\nb\nc";
|
|
33
|
+
expect(slice(raw, locateOldString(raw, "b\r\nc", "exact").ranges[0])).toBe("b\nc");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("does not apply whitespace strategies unless requested", () => {
|
|
37
|
+
const raw = "if (x) {\n y();\n}";
|
|
38
|
+
expect(locateOldString(raw, "if (x) {", "exact").ranges).toEqual([]);
|
|
39
|
+
const attempt = locateOldString(raw, "if (x) {", "normalize_whitespace");
|
|
40
|
+
expect(attempt.usedStrategy).toBe("normalize_whitespace");
|
|
41
|
+
expect(slice(raw, attempt.ranges[0])).toBe("if (x) {");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("matches trailing-whitespace drift and keeps the original text in range", () => {
|
|
45
|
+
const raw = "alpha \t\nbeta\n";
|
|
46
|
+
const attempt = locateOldString(raw, "alpha\nbeta", "trim_trailing");
|
|
47
|
+
expect(attempt.usedStrategy).toBe("trim_trailing");
|
|
48
|
+
expect(slice(raw, attempt.ranges[0])).toBe("alpha \t\nbeta");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("keeps the leading indentation of the file when normalizing whitespace", () => {
|
|
52
|
+
const raw = "class A {\n method() {\n return 1;\n }\n}";
|
|
53
|
+
const attempt = locateOldString(raw, "method() {\n return 1;", "normalize_whitespace");
|
|
54
|
+
expect(slice(raw, attempt.ranges[0])).toBe("method() {\n return 1;");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("returns a fuzzy range covering whole lines in an LF file", () => {
|
|
58
|
+
const raw = "header\nconst total = items.reduce((a, b) => a + b, 0);\nfooter\n";
|
|
59
|
+
const attempt = locateOldString(raw, "const totl = items.reduce((a, b) => a + b, 0);", "fuzzy");
|
|
60
|
+
expect(attempt.usedStrategy).toBe("fuzzy");
|
|
61
|
+
expect(slice(raw, attempt.ranges[0])).toBe("const total = items.reduce((a, b) => a + b, 0);");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("returns a fuzzy range in raw coordinates for a CRLF file", () => {
|
|
65
|
+
const raw = "line one\r\nline two\r\nconst total = items.reduce((a, b) => a + b, 0);\r\nreturn total;\r\n";
|
|
66
|
+
const attempt = locateOldString(raw, "const totl = items.reduce((a, b) => a + b, 0);\nreturn total;", "fuzzy");
|
|
67
|
+
expect(attempt.usedStrategy).toBe("fuzzy");
|
|
68
|
+
expect(slice(raw, attempt.ranges[0])).toBe("const total = items.reduce((a, b) => a + b, 0);\r\nreturn total;");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("returns a fuzzy range in raw coordinates for a classic-Mac CR file", () => {
|
|
72
|
+
const raw = "first\rsecond\rconst total = items.reduce((a, b) => a + b, 0);\rend";
|
|
73
|
+
const attempt = locateOldString(raw, "const totl = items.reduce((a, b) => a + b, 0);", "fuzzy");
|
|
74
|
+
expect(slice(raw, attempt.ranges[0])).toBe("const total = items.reduce((a, b) => a + b, 0);");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("refuses an ambiguous fuzzy match", () => {
|
|
78
|
+
const line = "const total = items.reduce((a, b) => a + b, 0);";
|
|
79
|
+
const raw = `${line}\nmiddle\n${line}\n`;
|
|
80
|
+
expect(locateOldString(raw, line.replace("total", "totl"), "fuzzy").ranges).toEqual([]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("refuses fuzzy matching for short old_strings", () => {
|
|
84
|
+
expect(locateOldString("const x = 1;\n", "const x = 2", "fuzzy").ranges).toEqual([]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("handles non-BMP characters without splitting surrogate pairs", () => {
|
|
88
|
+
const raw = "emoji 😀 here\r\nnext";
|
|
89
|
+
const attempt = locateOldString(raw, "😀 here\nnext", "exact");
|
|
90
|
+
expect(slice(raw, attempt.ranges[0])).toBe("😀 here\r\nnext");
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("EOL helpers", () => {
|
|
95
|
+
it("picks the majority line ending and null for single-line text", () => {
|
|
96
|
+
expect(dominantEol("a\r\nb\r\nc\n")).toBe("\r\n");
|
|
97
|
+
expect(dominantEol("a\nb\r\nc\n")).toBe("\n");
|
|
98
|
+
expect(dominantEol("single line")).toBeNull();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("conforms mixed endings without doubling carriage returns", () => {
|
|
102
|
+
expect(conformEol("a\r\nb\nc", "\r\n")).toBe("a\r\nb\r\nc");
|
|
103
|
+
expect(conformEol("a\r\nb\nc", "\n")).toBe("a\nb\nc");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("counts lines up to an offset and clamps past the end", () => {
|
|
107
|
+
expect(lineNumberAt("a\nb\nc", 0)).toBe(1);
|
|
108
|
+
expect(lineNumberAt("a\nb\nc", 4)).toBe(3);
|
|
109
|
+
expect(lineNumberAt("a\nb", 99)).toBe(2);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("diagnostics", () => {
|
|
114
|
+
it("points at the nearest line and suggests a strategy that would match", () => {
|
|
115
|
+
const raw = "function run() {\n doWork( 1 );\n}\n";
|
|
116
|
+
const diag = buildNotFoundDiagnostic(raw, "doWork( 1 );");
|
|
117
|
+
expect(diag.nearest_match?.line).toBe(2);
|
|
118
|
+
expect(diag.nearest_match?.snippet).toContain("2: doWork( 1 );");
|
|
119
|
+
expect(diag.normalized_hint).toBe('matches uniquely at line 2 if strategy="normalize_whitespace" is used');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("returns no nearest match for whitespace-only old_strings", () => {
|
|
123
|
+
const diag = buildNotFoundDiagnostic("abc\n", " \n ");
|
|
124
|
+
expect(diag.nearest_match).toBeNull();
|
|
125
|
+
expect(diag.normalized_hint).toBeNull();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("reports every occurrence line for multiple matches", () => {
|
|
129
|
+
const raw = "x\nfoo\ny\nfoo\n";
|
|
130
|
+
const { ranges } = locateOldString(raw, "foo", "exact");
|
|
131
|
+
expect(buildMultipleMatchesDiagnostic(raw, ranges)).toEqual({ reason: "multiple_matches", occurrences: [2, 4] });
|
|
132
|
+
});
|
|
133
|
+
});
|