@vellumai/assistant 0.12.3 → 0.12.4-dev.202609231620.a15f321
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dockerfile +7 -0
- package/README.md +12 -12
- package/docker/mousepad/90-vellum.gschema.override +4 -0
- package/node_modules/@vellumai/avatar-manifest/src/__tests__/notification-avatar.test.ts +48 -4
- package/node_modules/@vellumai/avatar-manifest/src/notification-avatar.ts +12 -8
- package/openapi.yaml +97 -4
- package/package.json +1 -1
- package/src/__tests__/approval-routes-http.test.ts +4 -2
- package/src/__tests__/call-routes-http.test.ts +4 -2
- package/src/__tests__/channel-readiness-discord.test.ts +8 -0
- package/src/__tests__/channel-readiness-slack-remote.test.ts +8 -0
- package/src/__tests__/config-loader-backfill.test.ts +1 -0
- package/src/__tests__/conversation-pairing.test.ts +10 -0
- package/src/__tests__/conversation-resurface-done.test.ts +289 -0
- package/src/__tests__/default-profile-catalog-fallback.test.ts +1 -0
- package/src/__tests__/gemini-provider.test.ts +31 -0
- package/src/__tests__/inference-profile-validation-jev.test.ts +17 -0
- package/src/__tests__/job-handler-registration-guard.test.ts +1 -0
- package/src/__tests__/llm-catalog-parity.test.ts +4 -0
- package/src/__tests__/llm-resolver-override-or-default.test.ts +43 -0
- package/src/__tests__/openai-responses-prompt-cache.test.ts +3 -1
- package/src/__tests__/platform-callback-registration.test.ts +46 -1
- package/src/__tests__/plugin-api-model-profiles.test.ts +7 -0
- package/src/__tests__/plugin-import-boundary-guard.test.ts +3 -0
- package/src/__tests__/pricing.test.ts +33 -0
- package/src/__tests__/provider-error-scenarios.test.ts +46 -0
- package/src/__tests__/provider-platform-proxy-integration.test.ts +45 -15
- package/src/__tests__/retry-fallback-escalation.test.ts +22 -0
- package/src/__tests__/runtime-attachment-metadata.test.ts +4 -2
- package/src/__tests__/scaffold-managed-skill-tool.test.ts +116 -66
- package/src/__tests__/secret-routes-platform-proxy.test.ts +1 -0
- package/src/__tests__/send-endpoint-busy.test.ts +4 -2
- package/src/__tests__/skill-load-inline-command.test.ts +35 -0
- package/src/__tests__/skill-load-tool.test.ts +42 -11
- package/src/__tests__/skill-tool-factory.test.ts +50 -2
- package/src/__tests__/steer-on-enqueue-question.test.ts +265 -34
- package/src/__tests__/validate-input.test.ts +73 -0
- package/src/__tests__/workspace-migration-159-rename-colliding-jev-profile-name.test.ts +197 -0
- package/src/api/ask-question-tool.ts +50 -0
- package/src/api/events/tool-result.ts +11 -0
- package/src/api/index.ts +7 -0
- package/src/api/requests/dictation.ts +2 -2
- package/src/api/responses/home.ts +27 -0
- package/src/avatar/notification-avatar.test.ts +42 -34
- package/src/avatar/notification-avatar.ts +1 -0
- package/src/calls/__tests__/voice-session-bridge.test.ts +63 -1
- package/src/calls/voice-escalation-judge.ts +6 -5
- package/src/calls/voice-screen-annotation.ts +44 -0
- package/src/calls/voice-session-bridge.ts +22 -0
- package/src/cli/commands/__tests__/plugins.test.ts +29 -1
- package/src/cli/commands/channel-verification-sessions.help.ts +12 -2
- package/src/cli/commands/platform/index.help.ts +2 -1
- package/src/cli/commands/platform/index.ts +8 -2
- package/src/cli/commands/plugins.ts +16 -0
- package/src/cli/lib/__tests__/inspect-plugin.test.ts +29 -0
- package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +92 -2
- package/src/cli/lib/__tests__/plugin-catalog-local.test.ts +15 -0
- package/src/cli/lib/__tests__/plugin-catalog-platform.test.ts +66 -2
- package/src/cli/lib/__tests__/plugin-catalog-resolve.test.ts +21 -0
- package/src/cli/lib/__tests__/plugin-catalog-visibility.test.ts +65 -0
- package/src/cli/lib/__tests__/plugin-details.test.ts +43 -0
- package/src/cli/lib/__tests__/plugins-install-offline.test.ts +26 -0
- package/src/cli/lib/bundled-marketplace.json +4 -4
- package/src/cli/lib/inspect-plugin.ts +5 -0
- package/src/cli/lib/plugin-catalog-cache.ts +41 -4
- package/src/cli/lib/plugin-catalog-local.ts +9 -0
- package/src/cli/lib/plugin-catalog-platform.ts +48 -9
- package/src/cli/lib/plugin-catalog-visibility.ts +41 -0
- package/src/cli/lib/plugin-details.ts +10 -7
- package/src/cli/lib/search-plugins.ts +2 -0
- package/src/config/__tests__/default-profile-catalog.test.ts +61 -1
- package/src/config/__tests__/jev-managed-profile.test.ts +82 -0
- package/src/config/__tests__/sync-gated-profiles.test.ts +71 -0
- package/src/config/bundled-skills/screen-annotation/SKILL.md +6 -3
- package/src/config/call-site-defaults.ts +17 -11
- package/src/config/default-profile-catalog.ts +118 -19
- package/src/config/default-profile-names.ts +69 -0
- package/src/config/feature-flag-registry.json +25 -9
- package/src/config/inference-profile-validation.ts +7 -6
- package/src/config/llm-resolver.ts +16 -0
- package/src/config/profile-text-generation.test.ts +9 -0
- package/src/config/schemas/call-site-catalog.ts +8 -1
- package/src/config/schemas/llm.ts +70 -9
- package/src/config/sidebar-done-gate.ts +27 -0
- package/src/config/sync-gated-profiles.ts +96 -44
- package/src/daemon/__tests__/auto-profile-router.test.ts +228 -0
- package/src/daemon/auto-profile-router.ts +220 -0
- package/src/daemon/conversation-agent-loop.ts +77 -4
- package/src/daemon/conversation-slash.ts +2 -2
- package/src/daemon/conversation-turn-finalize.ts +12 -0
- package/src/daemon/dictation-text-processing.ts +4 -4
- package/src/daemon/handlers/__tests__/conversation-history-search.test.ts +61 -0
- package/src/daemon/handlers/config-channels.ts +3 -1
- package/src/daemon/handlers/conversation-history.ts +11 -1
- package/src/daemon/handlers/conversations.ts +118 -10
- package/src/daemon/pkb-reminder-builder.test.ts +10 -3
- package/src/daemon/pkb-reminder-builder.ts +1 -1
- package/src/desktop/desktop-browser-endpoint.ts +1 -0
- package/src/home/feed-types.ts +2 -0
- package/src/inbound/platform-callback-registration.ts +19 -6
- package/src/ipc/routes/__tests__/conversation-sync-ipc-routes.test.ts +49 -1
- package/src/ipc/routes/conversation-sync-ipc-routes.ts +38 -2
- package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +27 -0
- package/src/live-voice/__tests__/protocol.test.ts +22 -0
- package/src/live-voice/continuation-judge.ts +1 -1
- package/src/live-voice/live-voice-session.ts +7 -6
- package/src/live-voice/protocol.ts +16 -5
- package/src/messaging/providers/gmail/client.ts +4 -0
- package/src/notifications/README.md +11 -3
- package/src/notifications/__tests__/home-feed-side-effect.test.ts +87 -3
- package/src/notifications/conversation-pairing.ts +23 -8
- package/src/notifications/home-feed-mirror.ts +39 -0
- package/src/notifications/home-feed-side-effect.ts +46 -19
- package/src/oauth/AGENTS.md +7 -1
- package/src/permissions/question-prompter.ts +14 -4
- package/src/persistence/conversation-crud.ts +114 -3
- package/src/persistence/conversation-queries.ts +48 -3
- package/src/persistence/embeddings/__tests__/embedding-local-lifecycle.test.ts +228 -32
- package/src/persistence/embeddings/embedding-backend-shutdown.test.ts +47 -0
- package/src/persistence/embeddings/embedding-backend.ts +23 -41
- package/src/persistence/embeddings/embedding-gemini.test.ts +17 -0
- package/src/persistence/embeddings/embedding-gemini.ts +3 -1
- package/src/persistence/embeddings/embedding-local.ts +72 -17
- package/src/persistence/embeddings/embedding-types.ts +0 -19
- package/src/persistence/jobs-store.ts +229 -0
- package/src/persistence/migrations/383-normalize-opencode-host-connections.ts +44 -0
- package/src/persistence/migrations/__tests__/383-normalize-opencode-host-connections.test.ts +102 -0
- package/src/persistence/steps.ts +12 -0
- package/src/platform/sync-avatar.test.ts +29 -0
- package/src/plugin-api/model-profiles.ts +5 -3
- package/src/plugins/defaults/memory/AGENTS.md +5 -3
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-skill-card.test.ts +2 -0
- package/src/plugins/defaults/memory/__tests__/skill-update-receipt-boundary-guard.test.ts +45 -0
- package/src/plugins/defaults/memory/__tests__/skill-update-receipt-job.test.ts +580 -0
- package/src/plugins/defaults/memory/graph/tools.ts +9 -5
- package/src/plugins/defaults/memory/hooks/conversation-deleted.ts +4 -0
- package/src/plugins/defaults/memory/job-handlers.ts +8 -2
- package/src/plugins/defaults/memory/memory-retrospective-skill-card.ts +5 -0
- package/src/plugins/defaults/memory/skill-update-receipt-job.ts +334 -0
- package/src/plugins/defaults/memory/substrate/skill-content.ts +1 -1
- package/src/plugins/defaults/memory/tools.test.ts +17 -0
- package/src/plugins/defaults/memory/v3/__tests__/render-injection.test.ts +3 -0
- package/src/providers/__tests__/connection-model-compat.test.ts +2 -0
- package/src/providers/__tests__/search-provider-catalog.test.ts +8 -0
- package/src/providers/anthropic/client.ts +2 -2
- package/src/providers/gemini/client.ts +17 -1
- package/src/providers/inference/auth.ts +25 -0
- package/src/providers/jev/ask.test.ts +66 -0
- package/src/{calls/typesafe-noul.ts → providers/jev/ask.ts} +75 -36
- package/src/providers/malformed-tool-call.ts +55 -0
- package/src/providers/model-catalog.ts +257 -4
- package/src/providers/openai/__tests__/chat-completions-malformed-tool-call.test.ts +105 -0
- package/src/providers/openai/chat-completions-provider.ts +30 -0
- package/src/providers/openai/codex-models.ts +2 -0
- package/src/providers/platform-proxy/constants.ts +5 -0
- package/src/providers/retry.ts +68 -36
- package/src/providers/search-provider-catalog.ts +10 -0
- package/src/providers/vellum-model-routing.test.ts +1 -0
- package/src/runtime/__tests__/agent-wake-archived-gate.test.ts +90 -0
- package/src/runtime/__tests__/question-resolution.test.ts +32 -0
- package/src/runtime/agent-wake.ts +9 -2
- package/src/runtime/auth/__tests__/middleware.test.ts +138 -0
- package/src/runtime/auth/middleware.ts +15 -11
- package/src/runtime/middleware/auth.ts +7 -1
- package/src/runtime/pending-interactions.ts +8 -8
- package/src/runtime/question-resolution.ts +54 -8
- package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +146 -0
- package/src/runtime/routes/__tests__/dictation-processing.test.ts +174 -0
- package/src/runtime/routes/__tests__/global-search-routes.test.ts +74 -1
- package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +60 -0
- package/src/runtime/routes/__tests__/llm-call-sites-routes.test.ts +16 -1
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +8 -0
- package/src/runtime/routes/conversation-list-routes.ts +16 -14
- package/src/runtime/routes/conversation-query-routes.ts +4 -0
- package/src/runtime/routes/conversation-routes.ts +4 -2
- package/src/runtime/routes/diagnostics-routes.ts +66 -229
- package/src/runtime/routes/global-search-routes.ts +22 -7
- package/src/runtime/routes/inference-provider-connection-routes.ts +25 -8
- package/src/runtime/routes/llm-call-sites-routes.ts +19 -10
- package/src/runtime/routes/platform-routes.ts +6 -1
- package/src/runtime/routes/plugins-routes.ts +5 -2
- package/src/runtime/sync/documents-sidecar-publish.test.ts +50 -4
- package/src/runtime/sync/resource-sync-events.ts +11 -0
- package/src/runtime/sync/worker-daemon-notify.test.ts +30 -0
- package/src/runtime/sync/worker-daemon-notify.ts +44 -0
- package/src/runtime/verification-outbound-actions.ts +2 -2
- package/src/skills/validate-input.ts +75 -11
- package/src/telegram/__tests__/webhook-health.test.ts +8 -0
- package/src/tools/ask-question/ask-question-tool.test.ts +38 -0
- package/src/tools/ask-question/ask-question-tool.ts +12 -1
- package/src/tools/network/__tests__/web-fetch-firecrawl.test.ts +28 -1
- package/src/tools/network/__tests__/web-fetch-metadata.test.ts +34 -0
- package/src/tools/network/__tests__/web-fetch-tinyfish.test.ts +35 -1
- package/src/tools/network/__tests__/web-search.test.ts +96 -0
- package/src/tools/network/web-fetch.ts +30 -9
- package/src/tools/network/web-search.ts +235 -4
- package/src/tools/skills/execute.ts +1 -1
- package/src/tools/skills/format-input-schema.ts +15 -0
- package/src/tools/skills/load.ts +15 -43
- package/src/tools/skills/scaffold-managed.ts +86 -71
- package/src/tools/skills/skill-tool-factory.ts +2 -1
- package/src/tools/workflows/manage-workflows.ts +4 -2
- package/src/util/worker-pipe.test.ts +146 -0
- package/src/util/worker-pipe.ts +49 -0
- package/src/workspace/migrations/158-repoint-opencode-host-provider-fragments.ts +163 -0
- package/src/workspace/migrations/159-rename-colliding-jev-profile-name.ts +344 -0
- package/src/workspace/migrations/__tests__/158-repoint-opencode-host-provider-fragments.test.ts +188 -0
- package/src/workspace/migrations/registry.ts +4 -0
- package/src/runtime/routes/__tests__/dictation-cleanup-guard.test.ts +0 -107
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
|
|
3
|
-
import { CALL_SITE_DEFAULTS } from "../../config/call-site-defaults.js";
|
|
4
3
|
import { getUserSelectableProfilesForProvider } from "../../config/default-profile-catalog.js";
|
|
5
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
resolveDefaultProfileKey,
|
|
6
|
+
resolveShippedDefaultProfileKey,
|
|
7
|
+
} from "../../config/llm-resolver.js";
|
|
6
8
|
import { loadConfig } from "../../config/loader.js";
|
|
7
9
|
import {
|
|
8
10
|
CALL_SITE_CATALOG,
|
|
9
11
|
CALL_SITE_DOMAINS,
|
|
10
12
|
} from "../../config/schemas/call-site-catalog.js";
|
|
11
|
-
import type { LLMCallSite } from "../../config/schemas/llm.js";
|
|
13
|
+
import type { LLMCallSite, LLMConfig } from "../../config/schemas/llm.js";
|
|
12
14
|
import { ACTOR_PRINCIPALS } from "../auth/route-policy.js";
|
|
13
15
|
import type { RouteDefinition } from "./types.js";
|
|
14
16
|
|
|
@@ -30,7 +32,8 @@ const callSiteEntrySchema = z.object({
|
|
|
30
32
|
* `defaultProfile` would scatter pinned or remapped sites across their
|
|
31
33
|
* winners. Profileless call sites report `balanced`: the resolver's
|
|
32
34
|
* fallback anchor consults the balanced remap for them, so they follow
|
|
33
|
-
* the Balanced tier like any balanced-keyed site.
|
|
35
|
+
* the Balanced tier like any balanced-keyed site. A managed-only tier is
|
|
36
|
+
* omitted when it has no body under the workspace's default provider.
|
|
34
37
|
*/
|
|
35
38
|
shippedDefaultProfile: z.string().optional(),
|
|
36
39
|
});
|
|
@@ -40,16 +43,22 @@ const callSiteCatalogResponseSchema = z.object({
|
|
|
40
43
|
callSites: z.array(callSiteEntrySchema),
|
|
41
44
|
});
|
|
42
45
|
|
|
46
|
+
export function buildCallSiteCatalog(llm: LLMConfig) {
|
|
47
|
+
return CALL_SITE_CATALOG.map((entry) => ({
|
|
48
|
+
...entry,
|
|
49
|
+
defaultProfile: resolveDefaultProfileKey(entry.id as LLMCallSite, llm),
|
|
50
|
+
shippedDefaultProfile: resolveShippedDefaultProfileKey(
|
|
51
|
+
entry.id as LLMCallSite,
|
|
52
|
+
llm,
|
|
53
|
+
),
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
|
|
43
57
|
async function handleGetCallSites() {
|
|
44
58
|
const { llm } = loadConfig();
|
|
45
59
|
return {
|
|
46
60
|
domains: CALL_SITE_DOMAINS,
|
|
47
|
-
callSites:
|
|
48
|
-
...entry,
|
|
49
|
-
defaultProfile: resolveDefaultProfileKey(entry.id as LLMCallSite, llm),
|
|
50
|
-
shippedDefaultProfile:
|
|
51
|
-
CALL_SITE_DEFAULTS[entry.id]?.profile ?? "balanced",
|
|
52
|
-
})),
|
|
61
|
+
callSites: buildCallSiteCatalog(llm),
|
|
53
62
|
};
|
|
54
63
|
}
|
|
55
64
|
|
|
@@ -90,7 +90,12 @@ const PlatformStatusResponseSchema = z.object({
|
|
|
90
90
|
isPlatform: z.boolean(),
|
|
91
91
|
baseUrl: z.string(),
|
|
92
92
|
assistantId: z.string(),
|
|
93
|
-
hasAssistantApiKey: z
|
|
93
|
+
hasAssistantApiKey: z
|
|
94
|
+
.boolean()
|
|
95
|
+
.nullable()
|
|
96
|
+
.describe(
|
|
97
|
+
"Whether a managed assistant API key is stored. Null when the credential store could not be read, which is not evidence that no key is stored.",
|
|
98
|
+
),
|
|
94
99
|
hasWebhookSecret: z.boolean(),
|
|
95
100
|
clientInstallationId: z.string().nullable(),
|
|
96
101
|
available: z.boolean(),
|
|
@@ -48,7 +48,10 @@ import {
|
|
|
48
48
|
type InstalledPluginInfo,
|
|
49
49
|
listInstalledPlugins,
|
|
50
50
|
} from "../../cli/lib/list-installed-plugins.js";
|
|
51
|
-
import {
|
|
51
|
+
import {
|
|
52
|
+
getPluginCatalog,
|
|
53
|
+
getPluginCatalogForInstalledMetadata,
|
|
54
|
+
} from "../../cli/lib/plugin-catalog-cache.js";
|
|
52
55
|
import { resolvePluginSourceFromCatalog } from "../../cli/lib/plugin-catalog-resolve.js";
|
|
53
56
|
import {
|
|
54
57
|
DEFAULT_PIN_HISTORY_LIMIT,
|
|
@@ -969,7 +972,7 @@ export async function loadCategoryMapBounded(
|
|
|
969
972
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
970
973
|
try {
|
|
971
974
|
const catalog = await Promise.race([
|
|
972
|
-
|
|
975
|
+
getPluginCatalogForInstalledMetadata(DEFAULT_PLUGIN_REF, {
|
|
973
976
|
fetch: globalThis.fetch.bind(globalThis),
|
|
974
977
|
}),
|
|
975
978
|
new Promise<null>((resolve) => {
|
|
@@ -1,22 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* A scheduled, heartbeat, or background turn runs its document tools
|
|
3
|
-
* sidecar worker, whose local hub has no SSE
|
|
4
|
-
*
|
|
5
|
-
* the user was not looking
|
|
2
|
+
* A scheduled, heartbeat, or background turn runs its document tools and its
|
|
3
|
+
* conversation writes in a sidecar worker, whose local hub has no SSE
|
|
4
|
+
* subscriber. Those broadcasts have to be handed to the daemon there, or the
|
|
5
|
+
* document edited and the conversation changed while the user was not looking
|
|
6
|
+
* stay invisible on every client.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
9
10
|
|
|
10
11
|
let notifyCount = 0;
|
|
12
|
+
const listHandoffs: Array<{ reason: string; conversationIds: string[] }> = [];
|
|
11
13
|
|
|
12
14
|
mock.module("./worker-daemon-notify.js", () => ({
|
|
13
15
|
NOTIFY_CONVERSATION_PERSISTED_IPC_METHOD:
|
|
14
16
|
"notify_conversation_persisted_externally",
|
|
17
|
+
NOTIFY_CONVERSATION_LIST_CHANGED_IPC_METHOD:
|
|
18
|
+
"notify_conversation_list_changed_externally",
|
|
15
19
|
NOTIFY_DOCUMENTS_CHANGED_IPC_METHOD: "notify_documents_changed_externally",
|
|
16
20
|
NOTIFY_ACTIVATION_PROGRESS_CHANGED_IPC_METHOD:
|
|
17
21
|
"notify_activation_progress_changed_externally",
|
|
18
22
|
notifyDaemonActivationProgressChanged: async () => {},
|
|
19
23
|
notifyDaemonConversationPersisted: async () => {},
|
|
24
|
+
notifyDaemonConversationListChanged: async (
|
|
25
|
+
reason: string,
|
|
26
|
+
conversationIds: string[],
|
|
27
|
+
) => {
|
|
28
|
+
listHandoffs.push({ reason, conversationIds });
|
|
29
|
+
},
|
|
20
30
|
notifyDaemonDocumentsChanged: async () => {
|
|
21
31
|
notifyCount++;
|
|
22
32
|
},
|
|
@@ -30,6 +40,7 @@ import {
|
|
|
30
40
|
} from "../assistant-stream-state.js";
|
|
31
41
|
import {
|
|
32
42
|
DOCUMENTS_CHANGED_COALESCE_MS,
|
|
43
|
+
publishConversationListAndMetadataChanged,
|
|
33
44
|
publishDocumentsChanged,
|
|
34
45
|
} from "./resource-sync-events.js";
|
|
35
46
|
|
|
@@ -66,6 +77,7 @@ describe("publishDocumentsChanged in a sidecar worker", () => {
|
|
|
66
77
|
beforeEach(async () => {
|
|
67
78
|
await settle();
|
|
68
79
|
notifyCount = 0;
|
|
80
|
+
listHandoffs.length = 0;
|
|
69
81
|
_resetStreamStateForTesting();
|
|
70
82
|
});
|
|
71
83
|
|
|
@@ -102,3 +114,37 @@ describe("publishDocumentsChanged in a sidecar worker", () => {
|
|
|
102
114
|
expect(events[0].message).toMatchObject({ type: "sync_changed" });
|
|
103
115
|
});
|
|
104
116
|
});
|
|
117
|
+
|
|
118
|
+
describe("publishConversationListAndMetadataChanged in a sidecar worker", () => {
|
|
119
|
+
beforeEach(async () => {
|
|
120
|
+
await settle();
|
|
121
|
+
listHandoffs.length = 0;
|
|
122
|
+
_resetStreamStateForTesting();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("hands the list invalidation to the daemon instead of publishing into a void", async () => {
|
|
126
|
+
disableStreamSeqStamping();
|
|
127
|
+
|
|
128
|
+
const events = await captureLocalPublishes(() => {
|
|
129
|
+
publishConversationListAndMetadataChanged("reordered", "conv-1");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
expect(listHandoffs).toEqual([
|
|
133
|
+
{ reason: "reordered", conversationIds: ["conv-1"] },
|
|
134
|
+
]);
|
|
135
|
+
expect(events).toEqual([]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("publishes on the local hub in the daemon, where subscribers live", async () => {
|
|
139
|
+
const events = await captureLocalPublishes(() => {
|
|
140
|
+
publishConversationListAndMetadataChanged("reordered", "conv-1");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
expect(listHandoffs).toEqual([]);
|
|
144
|
+
expect(events).toHaveLength(1);
|
|
145
|
+
expect(events[0].message).toMatchObject({
|
|
146
|
+
type: "sync_changed",
|
|
147
|
+
tags: ["conversations:list", "conversation:conv-1:metadata"],
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -12,6 +12,7 @@ import { isStreamSeqStampingDisabled } from "../assistant-stream-state.js";
|
|
|
12
12
|
import { publishSyncInvalidation } from "./sync-publisher.js";
|
|
13
13
|
import {
|
|
14
14
|
notifyDaemonActivationProgressChanged,
|
|
15
|
+
notifyDaemonConversationListChanged,
|
|
15
16
|
notifyDaemonConversationPersisted,
|
|
16
17
|
notifyDaemonDocumentsChanged,
|
|
17
18
|
} from "./worker-daemon-notify.js";
|
|
@@ -236,6 +237,16 @@ export function publishConversationListAndMetadataChanged(
|
|
|
236
237
|
? conversationIds
|
|
237
238
|
: [conversationIds];
|
|
238
239
|
|
|
240
|
+
// In a sidecar worker the local hub has no SSE subscribers, so a publish
|
|
241
|
+
// here reaches nobody and the sidebar keeps showing a list the worker has
|
|
242
|
+
// already changed. Hand off to the daemon, which republishes on the hub
|
|
243
|
+
// clients actually subscribe to. A background worker turn has no
|
|
244
|
+
// originating client, so no `originClientId` is forwarded.
|
|
245
|
+
if (isStreamSeqStampingDisabled()) {
|
|
246
|
+
void notifyDaemonConversationListChanged(reason, ids);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
239
250
|
// Shape-changing reasons (`created`, `deleted`, `reordered`) add or
|
|
240
251
|
// remove rows or change the order of the paginated window — web must
|
|
241
252
|
// refetch the list, so include the `conversationsList` umbrella tag.
|
|
@@ -33,13 +33,43 @@ mock.module("../../ipc/cli-client.js", () => ({
|
|
|
33
33
|
|
|
34
34
|
import {
|
|
35
35
|
NOTIFY_ACTIVATION_PROGRESS_CHANGED_IPC_METHOD,
|
|
36
|
+
NOTIFY_CONVERSATION_LIST_CHANGED_IPC_METHOD,
|
|
36
37
|
NOTIFY_CONVERSATION_PERSISTED_IPC_METHOD,
|
|
37
38
|
NOTIFY_DOCUMENTS_CHANGED_IPC_METHOD,
|
|
38
39
|
notifyDaemonActivationProgressChanged,
|
|
40
|
+
notifyDaemonConversationListChanged,
|
|
39
41
|
notifyDaemonConversationPersisted,
|
|
40
42
|
notifyDaemonDocumentsChanged,
|
|
41
43
|
} from "./worker-daemon-notify.js";
|
|
42
44
|
|
|
45
|
+
describe("notifyDaemonConversationListChanged", () => {
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
calls.length = 0;
|
|
48
|
+
nextResult = { ok: true };
|
|
49
|
+
shouldThrow = false;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("hands the reason and ids to the daemon over the shared IPC method", async () => {
|
|
53
|
+
await notifyDaemonConversationListChanged("reordered", ["conv-1"]);
|
|
54
|
+
|
|
55
|
+
expect(calls).toHaveLength(1);
|
|
56
|
+
expect(calls[0]!.method).toBe(NOTIFY_CONVERSATION_LIST_CHANGED_IPC_METHOD);
|
|
57
|
+
expect(calls[0]!.params).toEqual({
|
|
58
|
+
body: { reason: "reordered", conversationIds: ["conv-1"] },
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("swallows a thrown IPC error (best-effort, no throw)", async () => {
|
|
63
|
+
shouldThrow = true;
|
|
64
|
+
|
|
65
|
+
const result = await notifyDaemonConversationListChanged("reordered", [
|
|
66
|
+
"conv-1",
|
|
67
|
+
]);
|
|
68
|
+
expect(result).toBeUndefined();
|
|
69
|
+
expect(calls).toHaveLength(1);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
43
73
|
describe("notifyDaemonConversationPersisted", () => {
|
|
44
74
|
beforeEach(() => {
|
|
45
75
|
calls.length = 0;
|
|
@@ -31,6 +31,14 @@ export const NOTIFY_CONVERSATION_PERSISTED_IPC_METHOD =
|
|
|
31
31
|
export const NOTIFY_DOCUMENTS_CHANGED_IPC_METHOD =
|
|
32
32
|
"notify_documents_changed_externally";
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* IPC method the daemon exposes for the conversation-list hand-off. Shared by
|
|
36
|
+
* the worker caller and the daemon route registration so the wire name cannot
|
|
37
|
+
* drift.
|
|
38
|
+
*/
|
|
39
|
+
export const NOTIFY_CONVERSATION_LIST_CHANGED_IPC_METHOD =
|
|
40
|
+
"notify_conversation_list_changed_externally";
|
|
41
|
+
|
|
34
42
|
/**
|
|
35
43
|
* IPC method the daemon exposes for the activation-progress hand-off. Shared
|
|
36
44
|
* by the worker caller and the daemon route registration so the wire name
|
|
@@ -79,6 +87,42 @@ export async function notifyDaemonConversationPersisted(
|
|
|
79
87
|
}
|
|
80
88
|
}
|
|
81
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Ask the daemon to republish a conversation-list-and-metadata invalidation.
|
|
92
|
+
*
|
|
93
|
+
* A worker turn can change which rows the sidebar should hold, not just what
|
|
94
|
+
* one row contains: clearing `archived_at` on a Done conversation the turn
|
|
95
|
+
* woke is the case that matters. The list tag has to reach real subscribers
|
|
96
|
+
* for the conversation to reappear, and the worker's own hub has none.
|
|
97
|
+
*
|
|
98
|
+
* Best-effort by design, like the other hand-offs: an unreachable daemon is
|
|
99
|
+
* logged at debug and swallowed, and the client picks the change up on its
|
|
100
|
+
* next list fetch.
|
|
101
|
+
*/
|
|
102
|
+
export async function notifyDaemonConversationListChanged(
|
|
103
|
+
reason: string,
|
|
104
|
+
conversationIds: string[],
|
|
105
|
+
): Promise<void> {
|
|
106
|
+
try {
|
|
107
|
+
const result = await cliIpcCall(
|
|
108
|
+
NOTIFY_CONVERSATION_LIST_CHANGED_IPC_METHOD,
|
|
109
|
+
{ body: { reason, conversationIds } },
|
|
110
|
+
{ timeoutMs: NOTIFY_TIMEOUT_MS },
|
|
111
|
+
);
|
|
112
|
+
if (!result.ok) {
|
|
113
|
+
log.debug(
|
|
114
|
+
{ reason, conversationIds, error: result.error },
|
|
115
|
+
"daemon conversation-list-changed notify was not acknowledged",
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
} catch (err) {
|
|
119
|
+
log.debug(
|
|
120
|
+
{ err, reason, conversationIds },
|
|
121
|
+
"daemon conversation-list-changed notify failed",
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
82
126
|
/**
|
|
83
127
|
* Ask the daemon to republish the documents-list invalidation.
|
|
84
128
|
*
|
|
@@ -278,7 +278,7 @@ async function startOutboundTelegram(
|
|
|
278
278
|
success: false,
|
|
279
279
|
error: "already_bound",
|
|
280
280
|
message:
|
|
281
|
-
"A guardian is already bound for this channel. Set rebind: true to
|
|
281
|
+
"A guardian is already bound for this channel. To verify a different account, revoke the existing binding first. Set rebind: true only to verify the same account again.",
|
|
282
282
|
channel,
|
|
283
283
|
};
|
|
284
284
|
}
|
|
@@ -820,7 +820,7 @@ async function startOutboundTextChannel(
|
|
|
820
820
|
success: false,
|
|
821
821
|
error: "already_bound",
|
|
822
822
|
message:
|
|
823
|
-
"A guardian is already bound for this channel. Set rebind: true to
|
|
823
|
+
"A guardian is already bound for this channel. To verify a different account, revoke the existing binding first. Set rebind: true only to verify the same account again.",
|
|
824
824
|
channel,
|
|
825
825
|
};
|
|
826
826
|
}
|
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Deliberate strict subset of JSON Schema: it covers only the keywords
|
|
5
5
|
* actually used by `assistant/src/config/bundled-skills/**\/TOOLS.json`
|
|
6
|
-
* (required / type / enum / items
|
|
7
|
-
* else (`$ref`, `oneOf`, `anyOf`,
|
|
8
|
-
* `
|
|
9
|
-
*
|
|
6
|
+
* (required / type / enum / nested properties and items / required-only oneOf)
|
|
7
|
+
* plus unknown-key detection. Anything else (`$ref`, richer `oneOf`, `anyOf`,
|
|
8
|
+
* `allOf`, `format`, `pattern`, `minimum`, `maximum`, object-shape
|
|
9
|
+
* `additionalProperties`, etc.) is skipped. Nested objects allow extra keys
|
|
10
|
+
* unless their schema explicitly closes them.
|
|
10
11
|
*
|
|
11
12
|
* Each error message is written to be agent-readable and self-correcting
|
|
12
13
|
* (e.g. `surface_id is required`, `mode must be one of "replace", "append"`).
|
|
@@ -313,6 +314,15 @@ export function validateInputAgainstSchema(
|
|
|
313
314
|
_toolName: string,
|
|
314
315
|
input: Record<string, unknown>,
|
|
315
316
|
schema: Record<string, unknown> | undefined,
|
|
317
|
+
): InputValidationResult {
|
|
318
|
+
return validateObjectInput(input, schema, "", true);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function validateObjectInput(
|
|
322
|
+
input: Record<string, unknown>,
|
|
323
|
+
schema: Record<string, unknown> | undefined,
|
|
324
|
+
path: string,
|
|
325
|
+
rejectUnknown: boolean,
|
|
316
326
|
): InputValidationResult {
|
|
317
327
|
// Skip when there's no schema or no properties block — matches today's
|
|
318
328
|
// lenient behaviour for tools that declare only `{ type: "object" }`.
|
|
@@ -338,17 +348,46 @@ export function validateInputAgainstSchema(
|
|
|
338
348
|
continue;
|
|
339
349
|
}
|
|
340
350
|
if (!(key in input)) {
|
|
341
|
-
errors.push(`${key} is required`);
|
|
351
|
+
errors.push(`${path}${key} is required`);
|
|
342
352
|
}
|
|
343
353
|
}
|
|
344
354
|
}
|
|
345
355
|
|
|
356
|
+
// Only enforce alternatives whose complete contract is field presence.
|
|
357
|
+
// Richer unions stay permissive because unsupported keywords can decide
|
|
358
|
+
// which branch matches.
|
|
359
|
+
const alternatives = schema.oneOf;
|
|
360
|
+
if (
|
|
361
|
+
Array.isArray(alternatives) &&
|
|
362
|
+
alternatives.length > 0 &&
|
|
363
|
+
alternatives.every(
|
|
364
|
+
(branch) =>
|
|
365
|
+
isPlainObject(branch) &&
|
|
366
|
+
Object.keys(branch).every(
|
|
367
|
+
(key) => key === "required" || key === "description",
|
|
368
|
+
) &&
|
|
369
|
+
Array.isArray(branch.required) &&
|
|
370
|
+
branch.required.every((key: unknown) => typeof key === "string"),
|
|
371
|
+
)
|
|
372
|
+
) {
|
|
373
|
+
const choices = alternatives as { required: string[] }[];
|
|
374
|
+
if (
|
|
375
|
+
choices.filter((branch) => branch.required.every((key) => key in input))
|
|
376
|
+
.length !== 1
|
|
377
|
+
) {
|
|
378
|
+
errors.push(
|
|
379
|
+
`${path ? path.slice(0, -1) : "input"} must match exactly one required field set: ${choices.map((branch) => quoteList(branch.required)).join(" or ")}`,
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
346
384
|
// 2. Per-property checks: type, enum, items.type.
|
|
347
|
-
for (const [
|
|
348
|
-
if (!(
|
|
385
|
+
for (const [property, rawSubSchema] of Object.entries(properties)) {
|
|
386
|
+
if (!(property in input)) {
|
|
349
387
|
continue;
|
|
350
388
|
}
|
|
351
|
-
const
|
|
389
|
+
const key = `${path}${property}`;
|
|
390
|
+
const value = input[property];
|
|
352
391
|
// Skip type-checking for absent values; presence is enforced by the
|
|
353
392
|
// `required` check above. Note: `null` IS a present value and is
|
|
354
393
|
// type-checked below — only schemas that explicitly opt in to null via
|
|
@@ -381,13 +420,25 @@ export function validateInputAgainstSchema(
|
|
|
381
420
|
if (!matchesType(value, type)) {
|
|
382
421
|
errors.push(
|
|
383
422
|
type === "boolean" && typeof value === "string"
|
|
384
|
-
? `${key} must be a boolean
|
|
423
|
+
? `${key} must be a boolean: pass true or false as a JSON boolean, not a string`
|
|
385
424
|
: `${key} must be ${typeArticle(type)} ${type}`,
|
|
386
425
|
);
|
|
387
426
|
// No point checking enum/items if the base type is wrong.
|
|
388
427
|
continue;
|
|
389
428
|
}
|
|
390
429
|
|
|
430
|
+
if (type === "object" && isPlainObject(value)) {
|
|
431
|
+
const nested = validateObjectInput(
|
|
432
|
+
value,
|
|
433
|
+
rawSubSchema,
|
|
434
|
+
`${key}.`,
|
|
435
|
+
rawSubSchema.additionalProperties === false,
|
|
436
|
+
);
|
|
437
|
+
if (!nested.ok) {
|
|
438
|
+
errors.push(...nested.errors);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
391
442
|
// 2a. Enum (string enums are the only shape used today).
|
|
392
443
|
const enumValues = rawSubSchema.enum;
|
|
393
444
|
if (Array.isArray(enumValues) && enumValues.length > 0) {
|
|
@@ -412,6 +463,17 @@ export function validateInputAgainstSchema(
|
|
|
412
463
|
errors.push(
|
|
413
464
|
`${key}[${index}] must be ${typeArticle(itemTypeName)} ${itemTypeName}`,
|
|
414
465
|
);
|
|
466
|
+
} else if (itemTypeName === "object" && isPlainObject(element)) {
|
|
467
|
+
const itemSchema = rawSubSchema.items as Record<string, unknown>;
|
|
468
|
+
const nested = validateObjectInput(
|
|
469
|
+
element,
|
|
470
|
+
itemSchema,
|
|
471
|
+
`${key}[${index}].`,
|
|
472
|
+
itemSchema.additionalProperties === false,
|
|
473
|
+
);
|
|
474
|
+
if (!nested.ok) {
|
|
475
|
+
errors.push(...nested.errors);
|
|
476
|
+
}
|
|
415
477
|
}
|
|
416
478
|
});
|
|
417
479
|
}
|
|
@@ -420,10 +482,12 @@ export function validateInputAgainstSchema(
|
|
|
420
482
|
}
|
|
421
483
|
|
|
422
484
|
// 3. Unknown keys — parity with the previous `validateNoUnknownParams`.
|
|
423
|
-
const unknownKeys =
|
|
485
|
+
const unknownKeys = rejectUnknown
|
|
486
|
+
? Object.keys(input).filter((k) => !knownKeySet.has(k))
|
|
487
|
+
: [];
|
|
424
488
|
for (const key of unknownKeys) {
|
|
425
489
|
errors.push(
|
|
426
|
-
`Unknown parameter "${key}". Supported: ${quoteList(knownKeys)}`,
|
|
490
|
+
`Unknown parameter "${path}${key}". Supported: ${quoteList(knownKeys)}`,
|
|
427
491
|
);
|
|
428
492
|
}
|
|
429
493
|
|
|
@@ -29,8 +29,16 @@ let emitFails = false;
|
|
|
29
29
|
|
|
30
30
|
// ── Module mocks ──────────────────────────────────────────────────────
|
|
31
31
|
|
|
32
|
+
// Spread the real module: mocks are shared across files in a combined run,
|
|
33
|
+
// and a wholesale replacement drops the exports peer modules import.
|
|
34
|
+
const actualSecureKeys = await import("../../security/secure-keys.js");
|
|
32
35
|
mock.module("../../security/secure-keys.js", () => ({
|
|
36
|
+
...actualSecureKeys,
|
|
33
37
|
getSecureKeyAsync: async (account: string) => secureKeyValues.get(account),
|
|
38
|
+
getSecureKeyResultAsync: async (account: string) => ({
|
|
39
|
+
value: secureKeyValues.get(account),
|
|
40
|
+
unreachable: false,
|
|
41
|
+
}),
|
|
34
42
|
}));
|
|
35
43
|
|
|
36
44
|
mock.module("../../security/credential-key.js", () => ({
|
|
@@ -200,6 +200,44 @@ describe("AskQuestionTool.execute", () => {
|
|
|
200
200
|
expect(result.isError).toBe(false);
|
|
201
201
|
});
|
|
202
202
|
|
|
203
|
+
test("says so when the user answered in the chat instead of the card", async () => {
|
|
204
|
+
const q2 = {
|
|
205
|
+
question: "Preferred time?",
|
|
206
|
+
options: [
|
|
207
|
+
{ id: "morning", label: "Morning" },
|
|
208
|
+
{ id: "afternoon", label: "Afternoon" },
|
|
209
|
+
],
|
|
210
|
+
};
|
|
211
|
+
setNextResult({
|
|
212
|
+
entries: [
|
|
213
|
+
{
|
|
214
|
+
questionId: "q1",
|
|
215
|
+
decision: "free_text",
|
|
216
|
+
text: "banana, after lunch",
|
|
217
|
+
},
|
|
218
|
+
{ questionId: "q2", decision: "skipped" },
|
|
219
|
+
],
|
|
220
|
+
overall: "completed",
|
|
221
|
+
answeredInChat: true,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const result = await askQuestionTool.execute(
|
|
225
|
+
{ questions: [singleQ, q2] },
|
|
226
|
+
makeContext(),
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
expect(result.isError).toBe(false);
|
|
230
|
+
const lines = String(result.content).split("\n");
|
|
231
|
+
// The model is told where the answer came from, then reads the batch: the
|
|
232
|
+
// typed text on the first question, the rest unanswered, so it can map the
|
|
233
|
+
// reply onto them itself.
|
|
234
|
+
expect(lines[0]).toContain("replied in the chat");
|
|
235
|
+
expect(lines.slice(1)).toEqual([
|
|
236
|
+
`Question "${singleQ.question}" → Free text: banana, after lunch`,
|
|
237
|
+
`Question "${q2.question}" → Skipped`,
|
|
238
|
+
]);
|
|
239
|
+
});
|
|
240
|
+
|
|
203
241
|
test("formats skipped result", async () => {
|
|
204
242
|
setNextResult(singleCompleted({ decision: "skipped" }));
|
|
205
243
|
const result = await askQuestionTool.execute(validInput, makeContext());
|
|
@@ -243,6 +243,15 @@ export function toAnsweredQuestion(
|
|
|
243
243
|
};
|
|
244
244
|
}
|
|
245
245
|
|
|
246
|
+
/**
|
|
247
|
+
* Model-facing note prepended when the user answered by typing in the chat
|
|
248
|
+
* instead of using the card. The text lands as free text on the first
|
|
249
|
+
* question and every other question reads as skipped, so the model is told
|
|
250
|
+
* plainly what happened and left to map the reply onto the batch itself.
|
|
251
|
+
*/
|
|
252
|
+
const CHAT_REPLY_PREAMBLE =
|
|
253
|
+
"The user replied in the chat instead of using the question card, so their message is recorded as free text on the first question below and the remaining questions are unanswered. Read their reply as the answer to whichever questions it covers, and ask again only for what it genuinely leaves open.";
|
|
254
|
+
|
|
246
255
|
// ── Tool ────────────────────────────────────────────────────────────
|
|
247
256
|
|
|
248
257
|
/**
|
|
@@ -389,7 +398,9 @@ export const askQuestionTool = {
|
|
|
389
398
|
|
|
390
399
|
switch (result.overall) {
|
|
391
400
|
case "completed": {
|
|
392
|
-
let content =
|
|
401
|
+
let content = result.answeredInChat
|
|
402
|
+
? [CHAT_REPLY_PREAMBLE, ...lines].join("\n")
|
|
403
|
+
: lines.join("\n");
|
|
393
404
|
if (desktopHelp) {
|
|
394
405
|
const entry = result.entries[0];
|
|
395
406
|
if (entry?.decision === "free_text") {
|
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
afterEach,
|
|
3
|
+
beforeEach,
|
|
4
|
+
describe,
|
|
5
|
+
expect,
|
|
6
|
+
mock,
|
|
7
|
+
spyOn,
|
|
8
|
+
test,
|
|
9
|
+
} from "bun:test";
|
|
2
10
|
|
|
3
11
|
import { setConfig } from "../../../__tests__/helpers/set-config.js";
|
|
4
12
|
|
|
@@ -133,6 +141,25 @@ describe("executeFirecrawlScrape", () => {
|
|
|
133
141
|
expect(result.status).toContain("truncated");
|
|
134
142
|
});
|
|
135
143
|
|
|
144
|
+
test("carries the provider's warning and a start past the end in metadata", async () => {
|
|
145
|
+
spyOn(globalThis, "fetch").mockResolvedValue(
|
|
146
|
+
scrapeResponse({
|
|
147
|
+
success: true,
|
|
148
|
+
warning: "The page was served from cache.",
|
|
149
|
+
data: { markdown: "abcdefghij", metadata: { statusCode: 200 } },
|
|
150
|
+
}),
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
const result = await executeFirecrawlScrape(
|
|
154
|
+
{ url: "https://example.com", start_index: 50 },
|
|
155
|
+
{ apiKey: "fc-key" },
|
|
156
|
+
);
|
|
157
|
+
expect(result.isError).toBe(false);
|
|
158
|
+
const meta = result.activityMetadata?.webFetch;
|
|
159
|
+
expect(meta?.providerWarning).toBe("The page was served from cache.");
|
|
160
|
+
expect(meta?.startIndexPastEnd).toBe(true);
|
|
161
|
+
});
|
|
162
|
+
|
|
136
163
|
test("empty markdown yields a no-content marker, not an error", async () => {
|
|
137
164
|
globalThis.fetch = (async () =>
|
|
138
165
|
scrapeResponse({ success: true, data: { markdown: "" } })) as any;
|
|
@@ -130,6 +130,40 @@ describe("web_fetch activityMetadata", () => {
|
|
|
130
130
|
expect(meta?.charCount).toBe(maxChars);
|
|
131
131
|
});
|
|
132
132
|
|
|
133
|
+
test("flags a start_index past the end of a page it downloaded whole", async () => {
|
|
134
|
+
const result = await executeWithMockFetch(
|
|
135
|
+
{ url: "https://example.com/short", start_index: 500 },
|
|
136
|
+
{
|
|
137
|
+
requestExecutor: async () =>
|
|
138
|
+
new Response("x".repeat(100), {
|
|
139
|
+
status: 200,
|
|
140
|
+
headers: { "content-type": "text/plain; charset=utf-8" },
|
|
141
|
+
}),
|
|
142
|
+
},
|
|
143
|
+
);
|
|
144
|
+
expect(result.isError).toBe(false);
|
|
145
|
+
expect(result.activityMetadata?.webFetch?.startIndexPastEnd).toBe(true);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("does not call a start_index past a capped download the end of the page", async () => {
|
|
149
|
+
// Over the 2 MB download cap: only a prefix is read, so an offset past it
|
|
150
|
+
// may still be inside the page.
|
|
151
|
+
const result = await executeWithMockFetch(
|
|
152
|
+
{ url: "https://example.com/huge", start_index: 2_500_000 },
|
|
153
|
+
{
|
|
154
|
+
requestExecutor: async () =>
|
|
155
|
+
new Response("x".repeat(3_000_000), {
|
|
156
|
+
status: 200,
|
|
157
|
+
headers: { "content-type": "text/plain; charset=utf-8" },
|
|
158
|
+
}),
|
|
159
|
+
},
|
|
160
|
+
);
|
|
161
|
+
expect(result.isError).toBe(false);
|
|
162
|
+
const meta = result.activityMetadata?.webFetch;
|
|
163
|
+
expect(meta?.truncated).toBe(true);
|
|
164
|
+
expect(meta?.startIndexPastEnd).toBe(false);
|
|
165
|
+
});
|
|
166
|
+
|
|
133
167
|
test("populates errorMessage and status on a 404 response", async () => {
|
|
134
168
|
globalThis.fetch = (async () =>
|
|
135
169
|
new Response("<title>Not Found</title>not here", {
|