@circuitwall/jarela 2.3.0 โ 2.4.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 +4 -4
- package/.next/standalone/.next/build-manifest.json +2 -2
- package/.next/standalone/.next/prerender-manifest.json +3 -3
- 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 +2 -2
- package/.next/standalone/.next/server/app/_not-found.rsc +2 -2
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
- 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/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/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/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/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/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 +7 -0
- 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/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 +7 -0
- package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/run/route.js +0 -1
- package/.next/standalone/.next/server/app/api/v1/threads/route.js +17 -5
- 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/page.js +322 -275
- 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 +4 -4
- 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/2471.js +17 -17
- 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/3399.js +525 -525
- package/.next/standalone/.next/server/chunks/3524.js +150 -67
- package/.next/standalone/.next/server/chunks/4189.js +1 -1
- package/.next/standalone/.next/server/chunks/4508.js +2 -2
- package/.next/standalone/.next/server/chunks/46.js +73 -28
- 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/6649.js +1 -1
- 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 +435 -176
- package/.next/standalone/.next/server/chunks/9304.js +51 -12
- package/.next/standalone/.next/server/chunks/{423.js โ 9590.js} +66 -21
- package/.next/standalone/.next/server/middleware-build-manifest.js +2 -2
- package/.next/standalone/.next/server/middleware.js +71 -18
- package/.next/standalone/.next/server/pages/404.html +2 -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/app/{page-213a623b37226452.js โ page-04748847c6ff866f.js} +171 -132
- package/.next/standalone/.next/static/css/{312542a5725ba0d5.css โ 2669e8744cc8c212.css} +2 -2
- package/.next/standalone/docs/adoption/changesets.json +49 -0
- package/.next/standalone/package.json +1 -1
- package/CHANGELOG.md +19 -0
- package/app/api/v1/threads/[thread_id]/run/route.ts +0 -1
- package/components/chat/MessageBubble.tsx +12 -0
- package/components/integrations/NetworkSection.tsx +2 -2
- 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/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/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/support/result-refs.test.ts +131 -0
- package/lib/tools/support/result-refs.ts +41 -9
- 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/{bMRK5y3ruT7c3WtBfCQOG โ hwujW4hR1b-r3k5WdMxZO}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{bMRK5y3ruT7c3WtBfCQOG โ hwujW4hR1b-r3k5WdMxZO}/_ssgManifest.js +0 -0
|
@@ -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
|
+
});
|
|
@@ -130,10 +130,13 @@ function fuzzyUniqueRange(raw: string, oldString: string): MatchRange[] {
|
|
|
130
130
|
if (oldLines.some((line) => line.length === 0)) return [];
|
|
131
131
|
const rawLines = raw.split(/\r\n|\r|\n/);
|
|
132
132
|
const rawLineStarts: number[] = [];
|
|
133
|
+
const eol = /\r\n|\r|\n/g;
|
|
133
134
|
let offset = 0;
|
|
134
135
|
for (const line of rawLines) {
|
|
135
136
|
rawLineStarts.push(offset);
|
|
136
|
-
offset
|
|
137
|
+
eol.lastIndex = offset + line.length;
|
|
138
|
+
const brk = eol.exec(raw);
|
|
139
|
+
offset = offset + line.length + (brk ? brk[0].length : 0);
|
|
137
140
|
}
|
|
138
141
|
const scores: Array<{ score: number; start: number; end: number }> = [];
|
|
139
142
|
for (let i = 0; i + oldLines.length <= rawLines.length; i++) {
|
|
@@ -188,7 +191,9 @@ function findAllExact(haystack: string, needle: string): number[] {
|
|
|
188
191
|
function findWithTransforms(raw: string, oldString: string, fns: Array<(s: string) => Transform>): MatchRange[] {
|
|
189
192
|
const bufT = composeTransforms(raw, fns);
|
|
190
193
|
const oldT = composeTransforms(oldString, fns);
|
|
191
|
-
|
|
194
|
+
// A needle that normalizes to bare whitespace would match an arbitrary
|
|
195
|
+
// line break, so it can never identify an edit target.
|
|
196
|
+
if (oldT.text.trim().length === 0) return [];
|
|
192
197
|
const starts = findAllExact(bufT.text, oldT.text);
|
|
193
198
|
return starts.map((s) => ({
|
|
194
199
|
start: bufT.toOriginal[s],
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
const tmpRoot = mkdtempSync(join(tmpdir(), "jarela-result-refs-"));
|
|
7
|
+
process.env.JARELA_DB_DIR = tmpRoot;
|
|
8
|
+
|
|
9
|
+
const {
|
|
10
|
+
getToolResultMaxBytes,
|
|
11
|
+
parseToolResultReferenceEnvelope,
|
|
12
|
+
postProcessToolResult,
|
|
13
|
+
readToolResultRef,
|
|
14
|
+
serializeToolResult,
|
|
15
|
+
} = await import("./result-refs");
|
|
16
|
+
|
|
17
|
+
afterEach(() => vi.unstubAllEnvs());
|
|
18
|
+
afterAll(() => rmSync(tmpRoot, { recursive: true, force: true }));
|
|
19
|
+
|
|
20
|
+
async function spill(result: unknown, maxBytes = 64) {
|
|
21
|
+
vi.stubEnv("JARELA_TOOL_RESULT_MAX_BYTES", String(maxBytes));
|
|
22
|
+
const envelope = parseToolResultReferenceEnvelope(await postProcessToolResult("my tool/x", result));
|
|
23
|
+
if (!envelope) throw new Error("expected a reference envelope");
|
|
24
|
+
return envelope;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function readAll(name: string, limit: number) {
|
|
28
|
+
const pages: string[] = [];
|
|
29
|
+
let offset: number | null = 0;
|
|
30
|
+
for (let guard = 0; offset !== null && guard < 10_000; guard++) {
|
|
31
|
+
const page = await readToolResultRef({ name, offset, limit });
|
|
32
|
+
expect(page.ok).toBe(true);
|
|
33
|
+
pages.push(page.result as string);
|
|
34
|
+
offset = page.next_offset as number | null;
|
|
35
|
+
}
|
|
36
|
+
return pages;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("max inline bytes", () => {
|
|
40
|
+
it("ignores invalid overrides", () => {
|
|
41
|
+
for (const bad of ["0", "-5", "abc", "Infinity"]) {
|
|
42
|
+
vi.stubEnv("JARELA_TOOL_RESULT_MAX_BYTES", bad);
|
|
43
|
+
expect(getToolResultMaxBytes()).toBe(16 * 1024);
|
|
44
|
+
}
|
|
45
|
+
vi.stubEnv("JARELA_TOOL_RESULT_MAX_BYTES", "100.9");
|
|
46
|
+
expect(getToolResultMaxBytes()).toBe(100);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("postProcessToolResult", () => {
|
|
51
|
+
it("passes small results through unchanged", async () => {
|
|
52
|
+
vi.stubEnv("JARELA_TOOL_RESULT_MAX_BYTES", "1000");
|
|
53
|
+
expect(await postProcessToolResult("t", { a: 1 })).toBe('{"a":1}');
|
|
54
|
+
expect(serializeToolResult(undefined)).toBe("undefined");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("measures the limit in bytes, not characters", async () => {
|
|
58
|
+
vi.stubEnv("JARELA_TOOL_RESULT_MAX_BYTES", "10");
|
|
59
|
+
// 4 characters, 16 bytes.
|
|
60
|
+
const out = await postProcessToolResult("t", "๐๐๐๐");
|
|
61
|
+
expect(parseToolResultReferenceEnvelope(out)?.bytes).toBe(16);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("sanitizes the tool name and records the total array length", async () => {
|
|
65
|
+
const envelope = await spill(Array.from({ length: 50 }, (_, i) => ({ i })));
|
|
66
|
+
expect(envelope.result_ref.mimeType).toBe("application/json");
|
|
67
|
+
expect(envelope.total_count).toBe(50);
|
|
68
|
+
expect((envelope.preview as unknown[]).length).toBe(20);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("keeps array previews within the preview byte budget when rows are huge", async () => {
|
|
72
|
+
const rows = Array.from({ length: 3 }, (_, i) => ({ i, body: "x".repeat(50_000) }));
|
|
73
|
+
const envelope = await spill(rows);
|
|
74
|
+
expect(envelope.preview_bytes).toBeLessThanOrEqual(4 * 1024);
|
|
75
|
+
expect(typeof envelope.preview).toBe("string");
|
|
76
|
+
expect(Buffer.byteLength(envelope.preview as string)).toBe(envelope.preview_bytes);
|
|
77
|
+
expect(envelope.total_count).toBe(3);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("never ends a text preview on half of a surrogate pair", async () => {
|
|
81
|
+
const envelope = await spill("a" + "๐".repeat(3000));
|
|
82
|
+
const preview = envelope.preview as string;
|
|
83
|
+
expect(Buffer.byteLength(preview)).toBeLessThanOrEqual(4 * 1024);
|
|
84
|
+
expect(preview.isWellFormed()).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("readToolResultRef", () => {
|
|
89
|
+
it("pages multi-byte text without corrupting characters at page edges", async () => {
|
|
90
|
+
const text = "hรฉllo wรถrld โ ๆฅๆฌ่ชใฎใใญในใ ๐๐ ".repeat(40);
|
|
91
|
+
const envelope = await spill(text);
|
|
92
|
+
for (const limit of [1, 2, 3, 5, 7, 64]) {
|
|
93
|
+
const pages = await readAll(envelope.result_ref.name, limit);
|
|
94
|
+
expect(pages.join("")).toBe(text);
|
|
95
|
+
expect(pages.every((p) => !p.includes("\uFFFD"))).toBe(true);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("realigns a caller offset that lands inside a character", async () => {
|
|
100
|
+
const envelope = await spill("โฌ".repeat(100)); // 3 bytes each
|
|
101
|
+
const page = await readToolResultRef({ name: envelope.result_ref.name, offset: 4, limit: 6 });
|
|
102
|
+
expect(page).toMatchObject({ offset: 3, result: "โฌโฌ", next_offset: 9 });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("returns an empty final page for offsets past the end", async () => {
|
|
106
|
+
const envelope = await spill("z".repeat(200));
|
|
107
|
+
const page = await readToolResultRef({ name: envelope.result_ref.name, offset: 10_000 });
|
|
108
|
+
expect(page).toMatchObject({ ok: true, result: "", next_offset: null, done: true });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("clamps negative and fractional offsets and limits", async () => {
|
|
112
|
+
const envelope = await spill("abcdefghij".repeat(10));
|
|
113
|
+
const page = await readToolResultRef({ name: envelope.result_ref.name, offset: -3.7, limit: 2.9 });
|
|
114
|
+
expect(page).toMatchObject({ offset: 0, limit: 2, result: "ab", next_offset: 2 });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("rejects path traversal and unknown refs without throwing", async () => {
|
|
118
|
+
expect(await readToolResultRef({ name: "../secrets.json" })).toMatchObject({ ok: false, error: "unsafe result_ref name" });
|
|
119
|
+
expect(await readToolResultRef({ name: "deadbeef.json" })).toMatchObject({ ok: false, status: "unknown" });
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("parseToolResultReferenceEnvelope", () => {
|
|
124
|
+
it("rejects look-alike payloads", () => {
|
|
125
|
+
expect(parseToolResultReferenceEnvelope("not json")).toBeNull();
|
|
126
|
+
expect(parseToolResultReferenceEnvelope({ ok: true, truncated: true, bytes: 1 })).toBeNull();
|
|
127
|
+
expect(parseToolResultReferenceEnvelope({ ok: true, truncated: "yes", bytes: 1, result_ref: { name: "a" } })).toBeNull();
|
|
128
|
+
expect(parseToolResultReferenceEnvelope('{"ok":true,"truncated":true,"bytes":"1","result_ref":{"name":"a"}}')).toBeNull();
|
|
129
|
+
expect(parseToolResultReferenceEnvelope(null)).toBeNull();
|
|
130
|
+
});
|
|
131
|
+
});
|