@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
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Done conversation comes back to the list when a message the user reads
|
|
3
|
+
* lands in it.
|
|
4
|
+
*
|
|
5
|
+
* Two seams enforce the rule: `insertMessageCore`, where every durable append
|
|
6
|
+
* lands, and the turn's finalize effect, where a streamed reply becomes
|
|
7
|
+
* readable. These tests drive the insert seam the way each ingest path does,
|
|
8
|
+
* with `addMessage` and that path's row shape, and drive the finalize seam
|
|
9
|
+
* through `resurfaceArchivedConversation` itself, which is all the finalize
|
|
10
|
+
* effect adds to the reserved row.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
14
|
+
|
|
15
|
+
import { eq } from "drizzle-orm";
|
|
16
|
+
|
|
17
|
+
mock.module("../persistence/embeddings/qdrant-client.js", () => ({
|
|
18
|
+
getQdrantClient: () => ({
|
|
19
|
+
searchWithFilter: async () => [],
|
|
20
|
+
hybridSearch: async () => [],
|
|
21
|
+
upsertPoints: async () => {},
|
|
22
|
+
deletePoints: async () => {},
|
|
23
|
+
}),
|
|
24
|
+
initQdrantClient: () => {},
|
|
25
|
+
resolveQdrantUrl: () => "http://127.0.0.1:6333",
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
let sidebarDoneEnabled = true;
|
|
29
|
+
mock.module("../config/sidebar-done-gate.js", () => ({
|
|
30
|
+
SIDEBAR_DONE_FLAG_KEY: "sidebar-done",
|
|
31
|
+
isSidebarDoneEnabled: () => sidebarDoneEnabled,
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
const listInvalidations: Array<{ reason: string; conversationIds: unknown }> =
|
|
35
|
+
[];
|
|
36
|
+
const actualSyncEvents =
|
|
37
|
+
await import("../runtime/sync/resource-sync-events.js");
|
|
38
|
+
mock.module("../runtime/sync/resource-sync-events.js", () => ({
|
|
39
|
+
...actualSyncEvents,
|
|
40
|
+
publishConversationListAndMetadataChanged: (
|
|
41
|
+
reason: string,
|
|
42
|
+
conversationIds: unknown,
|
|
43
|
+
) => {
|
|
44
|
+
listInvalidations.push({ reason, conversationIds });
|
|
45
|
+
},
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
import {
|
|
49
|
+
addMessage,
|
|
50
|
+
archiveConversation,
|
|
51
|
+
getConversation,
|
|
52
|
+
reserveMessage,
|
|
53
|
+
resurfaceArchivedConversation,
|
|
54
|
+
} from "../persistence/conversation-crud.js";
|
|
55
|
+
import { getDb } from "../persistence/db-connection.js";
|
|
56
|
+
import { initializeDb } from "../persistence/db-init.js";
|
|
57
|
+
import { conversations, messages } from "../persistence/schema/index.js";
|
|
58
|
+
import {
|
|
59
|
+
MEMORY_RETROSPECTIVE_INSTRUCTION_KIND,
|
|
60
|
+
SKILL_CARD_MESSAGE_KIND,
|
|
61
|
+
} from "../plugins/defaults/memory/memory-retrospective-constants.js";
|
|
62
|
+
import { setConfig } from "./helpers/set-config.js";
|
|
63
|
+
|
|
64
|
+
setConfig("memory", { extraction: { useLLM: false } });
|
|
65
|
+
|
|
66
|
+
await initializeDb();
|
|
67
|
+
|
|
68
|
+
const DONE_CONVERSATION_ID = "conv-done";
|
|
69
|
+
const RETRO_FORK_ID = "conv-done-retro-fork";
|
|
70
|
+
|
|
71
|
+
function insertConversation(id: string): void {
|
|
72
|
+
const now = Date.now();
|
|
73
|
+
getDb()
|
|
74
|
+
.insert(conversations)
|
|
75
|
+
.values({
|
|
76
|
+
id,
|
|
77
|
+
title: null,
|
|
78
|
+
createdAt: now,
|
|
79
|
+
updatedAt: now,
|
|
80
|
+
totalInputTokens: 0,
|
|
81
|
+
totalOutputTokens: 0,
|
|
82
|
+
totalEstimatedCost: 0,
|
|
83
|
+
contextSummary: null,
|
|
84
|
+
contextCompactedMessageCount: 0,
|
|
85
|
+
contextCompactedAt: null,
|
|
86
|
+
})
|
|
87
|
+
.run();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function resetTables(): void {
|
|
91
|
+
const db = getDb();
|
|
92
|
+
db.run("DELETE FROM messages");
|
|
93
|
+
db.run("DELETE FROM conversations");
|
|
94
|
+
insertConversation(DONE_CONVERSATION_ID);
|
|
95
|
+
insertConversation(RETRO_FORK_ID);
|
|
96
|
+
archiveConversation(DONE_CONVERSATION_ID);
|
|
97
|
+
listInvalidations.length = 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function archivedAtOf(id: string): number | null | undefined {
|
|
101
|
+
return getConversation(id)?.archivedAt;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function blocks(text: string): string {
|
|
105
|
+
return JSON.stringify([{ type: "text", text }]);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
describe("Done conversations and new activity", () => {
|
|
109
|
+
beforeEach(() => {
|
|
110
|
+
sidebarDoneEnabled = true;
|
|
111
|
+
resetTables();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("assistant output from a scheduled run brings the conversation back", async () => {
|
|
115
|
+
// The wake persists its `<background_event>` trigger first. That row is
|
|
116
|
+
// dropped from the transcript, so it must not resurface anything on its
|
|
117
|
+
// own: the run has produced nothing the user can read yet.
|
|
118
|
+
await addMessage(
|
|
119
|
+
DONE_CONVERSATION_ID,
|
|
120
|
+
"user",
|
|
121
|
+
blocks(
|
|
122
|
+
'<background_event source="schedule">Daily digest</background_event>',
|
|
123
|
+
),
|
|
124
|
+
{
|
|
125
|
+
metadata: {
|
|
126
|
+
kind: "background-event",
|
|
127
|
+
backgroundEventSource: "schedule",
|
|
128
|
+
automated: true,
|
|
129
|
+
},
|
|
130
|
+
skipIndexing: true,
|
|
131
|
+
},
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
expect(archivedAtOf(DONE_CONVERSATION_ID)).not.toBeNull();
|
|
135
|
+
expect(listInvalidations).toHaveLength(0);
|
|
136
|
+
|
|
137
|
+
// The run's own output is what the user reads.
|
|
138
|
+
await addMessage(
|
|
139
|
+
DONE_CONVERSATION_ID,
|
|
140
|
+
"assistant",
|
|
141
|
+
blocks("Here is today's digest."),
|
|
142
|
+
{ metadata: { automated: true }, skipIndexing: true },
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
expect(archivedAtOf(DONE_CONVERSATION_ID)).toBeNull();
|
|
146
|
+
expect(listInvalidations).toEqual([
|
|
147
|
+
{ reason: "reordered", conversationIds: DONE_CONVERSATION_ID },
|
|
148
|
+
]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("the empty row an LLM call reserves leaves the conversation Done", async () => {
|
|
152
|
+
const reserved = await reserveMessage(
|
|
153
|
+
DONE_CONVERSATION_ID,
|
|
154
|
+
"assistant",
|
|
155
|
+
undefined,
|
|
156
|
+
"inflight/ref.jsonl",
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
expect(archivedAtOf(DONE_CONVERSATION_ID)).not.toBeNull();
|
|
160
|
+
expect(listInvalidations).toHaveLength(0);
|
|
161
|
+
|
|
162
|
+
// The finalize seam is what resurfaces it, once the row holds content.
|
|
163
|
+
expect(
|
|
164
|
+
resurfaceArchivedConversation(DONE_CONVERSATION_ID, reserved.createdAt),
|
|
165
|
+
).toBe(true);
|
|
166
|
+
expect(archivedAtOf(DONE_CONVERSATION_ID)).toBeNull();
|
|
167
|
+
expect(listInvalidations).toHaveLength(1);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("a Done mark made after the activity wins over the resurface", () => {
|
|
171
|
+
const activityAt = Date.now() - 60_000;
|
|
172
|
+
|
|
173
|
+
expect(
|
|
174
|
+
resurfaceArchivedConversation(DONE_CONVERSATION_ID, activityAt),
|
|
175
|
+
).toBe(false);
|
|
176
|
+
expect(archivedAtOf(DONE_CONVERSATION_ID)).not.toBeNull();
|
|
177
|
+
expect(listInvalidations).toHaveLength(0);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("an inbound channel message from a person brings the conversation back", async () => {
|
|
181
|
+
await addMessage(DONE_CONVERSATION_ID, "user", "any updates?", {
|
|
182
|
+
metadata: {
|
|
183
|
+
userMessageChannel: "slack",
|
|
184
|
+
provenanceSourceChannel: "slack",
|
|
185
|
+
},
|
|
186
|
+
skipIndexing: true,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
expect(archivedAtOf(DONE_CONVERSATION_ID)).toBeNull();
|
|
190
|
+
expect(listInvalidations).toHaveLength(1);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("rows that never render leave the conversation Done", async () => {
|
|
194
|
+
await addMessage(DONE_CONVERSATION_ID, "user", "internal signal", {
|
|
195
|
+
metadata: { hidden: true },
|
|
196
|
+
skipIndexing: true,
|
|
197
|
+
});
|
|
198
|
+
await addMessage(DONE_CONVERSATION_ID, "user", "subagent finished", {
|
|
199
|
+
metadata: { subagentNotification: { subagentId: "sub-1" } },
|
|
200
|
+
skipIndexing: true,
|
|
201
|
+
});
|
|
202
|
+
await addMessage(DONE_CONVERSATION_ID, "user", "acp session update", {
|
|
203
|
+
metadata: { acpNotification: { sessionId: "acp-1" } },
|
|
204
|
+
skipIndexing: true,
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
expect(archivedAtOf(DONE_CONVERSATION_ID)).not.toBeNull();
|
|
208
|
+
expect(listInvalidations).toHaveLength(0);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("a memory retrospective leaves the conversation it reviews Done", async () => {
|
|
212
|
+
// A retrospective runs in a fork: its instruction and its whole pass are
|
|
213
|
+
// appended to the fork, never to the conversation under review.
|
|
214
|
+
await addMessage(RETRO_FORK_ID, "user", "Review the transcript.", {
|
|
215
|
+
metadata: { kind: MEMORY_RETROSPECTIVE_INSTRUCTION_KIND, hidden: true },
|
|
216
|
+
skipIndexing: true,
|
|
217
|
+
});
|
|
218
|
+
await addMessage(RETRO_FORK_ID, "assistant", blocks("Saved one fact."), {
|
|
219
|
+
metadata: { automated: true },
|
|
220
|
+
skipIndexing: true,
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
expect(archivedAtOf(DONE_CONVERSATION_ID)).not.toBeNull();
|
|
224
|
+
expect(listInvalidations).toHaveLength(0);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("a row its producer marked as bookkeeping leaves the conversation Done", async () => {
|
|
228
|
+
// The retrospective skill card, written the way
|
|
229
|
+
// `memory-retrospective-skill-card.ts` writes it. Persistence cannot
|
|
230
|
+
// import the memory plugin (`persistence-layering-guard`), so the producer
|
|
231
|
+
// declares the row instead of the seam recognising its kind.
|
|
232
|
+
await addMessage(
|
|
233
|
+
DONE_CONVERSATION_ID,
|
|
234
|
+
"assistant",
|
|
235
|
+
JSON.stringify([
|
|
236
|
+
{
|
|
237
|
+
type: "ui_surface",
|
|
238
|
+
surfaceId: "surface-1",
|
|
239
|
+
surfaceType: "skill_card",
|
|
240
|
+
title: "I just learned how to do expense filing",
|
|
241
|
+
display: "inline",
|
|
242
|
+
data: { skills: [] },
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
type: "text",
|
|
246
|
+
text: "I just learned how to do expense filing",
|
|
247
|
+
_surfaceFallback: true,
|
|
248
|
+
},
|
|
249
|
+
]),
|
|
250
|
+
{
|
|
251
|
+
metadata: { kind: SKILL_CARD_MESSAGE_KIND, automated: true },
|
|
252
|
+
skipIndexing: true,
|
|
253
|
+
clientMessageId: "surface-1",
|
|
254
|
+
skipResurface: true,
|
|
255
|
+
},
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
expect(archivedAtOf(DONE_CONVERSATION_ID)).not.toBeNull();
|
|
259
|
+
expect(listInvalidations).toHaveLength(0);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("the flag off keeps every append from clearing archived_at", async () => {
|
|
263
|
+
sidebarDoneEnabled = false;
|
|
264
|
+
|
|
265
|
+
await addMessage(DONE_CONVERSATION_ID, "user", "any updates?", {
|
|
266
|
+
metadata: {
|
|
267
|
+
userMessageChannel: "slack",
|
|
268
|
+
provenanceSourceChannel: "slack",
|
|
269
|
+
},
|
|
270
|
+
skipIndexing: true,
|
|
271
|
+
});
|
|
272
|
+
await addMessage(
|
|
273
|
+
DONE_CONVERSATION_ID,
|
|
274
|
+
"assistant",
|
|
275
|
+
blocks("Here is today's digest."),
|
|
276
|
+
{ metadata: { automated: true }, skipIndexing: true },
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
expect(archivedAtOf(DONE_CONVERSATION_ID)).not.toBeNull();
|
|
280
|
+
expect(listInvalidations).toHaveLength(0);
|
|
281
|
+
expect(
|
|
282
|
+
getDb()
|
|
283
|
+
.select()
|
|
284
|
+
.from(messages)
|
|
285
|
+
.where(eq(messages.conversationId, DONE_CONVERSATION_ID))
|
|
286
|
+
.all(),
|
|
287
|
+
).toHaveLength(2);
|
|
288
|
+
});
|
|
289
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
2
|
|
|
3
|
+
import { MALFORMED_TOOL_CALL_MESSAGE } from "../providers/malformed-tool-call.js";
|
|
3
4
|
import type {
|
|
4
5
|
ContentBlock,
|
|
5
6
|
Message,
|
|
@@ -209,6 +210,36 @@ describe("GeminiProvider", () => {
|
|
|
209
210
|
expect(result.stopReason).toBe("STOP");
|
|
210
211
|
});
|
|
211
212
|
|
|
213
|
+
test("throws a status-less malformed tool-call error on MALFORMED_FUNCTION_CALL", async () => {
|
|
214
|
+
fakeChunks = [
|
|
215
|
+
textChunk(",featuredImage:{_type:image},patch:true}]})"),
|
|
216
|
+
finishChunk("MALFORMED_FUNCTION_CALL", 10, 5),
|
|
217
|
+
];
|
|
218
|
+
|
|
219
|
+
const error = await provider
|
|
220
|
+
.sendMessage([{ role: "user", content: [{ type: "text", text: "Hi" }] }])
|
|
221
|
+
.then(
|
|
222
|
+
() => undefined,
|
|
223
|
+
(e: unknown) => e,
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
expect(error).toBeInstanceOf(ProviderError);
|
|
227
|
+
const providerError = error as ProviderError;
|
|
228
|
+
expect(providerError.statusCode).toBeUndefined();
|
|
229
|
+
expect(providerError.message).toContain(MALFORMED_TOOL_CALL_MESSAGE);
|
|
230
|
+
expect(providerError.message).toContain("MALFORMED_FUNCTION_CALL");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("throws a malformed tool-call error on UNEXPECTED_TOOL_CALL", async () => {
|
|
234
|
+
fakeChunks = [finishChunk("UNEXPECTED_TOOL_CALL", 10, 0)];
|
|
235
|
+
|
|
236
|
+
await expect(
|
|
237
|
+
provider.sendMessage([
|
|
238
|
+
{ role: "user", content: [{ type: "text", text: "Hi" }] },
|
|
239
|
+
]),
|
|
240
|
+
).rejects.toThrow(MALFORMED_TOOL_CALL_MESSAGE);
|
|
241
|
+
});
|
|
242
|
+
|
|
212
243
|
// -----------------------------------------------------------------------
|
|
213
244
|
// Streaming events
|
|
214
245
|
// -----------------------------------------------------------------------
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { validateInferenceProfileKey } from "../config/inference-profile-validation.js";
|
|
4
|
+
import { setConfig } from "./helpers/set-config.js";
|
|
5
|
+
|
|
6
|
+
describe("validateInferenceProfileKey and the managed Jev profile", () => {
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
setConfig("llm", { profiles: {} });
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("a schedule or subagent turn cannot run on the verdict model", () => {
|
|
12
|
+
expect(validateInferenceProfileKey("balanced")).toBeNull();
|
|
13
|
+
expect(validateInferenceProfileKey("jev-managed")).toContain(
|
|
14
|
+
"is not defined in llm.profiles",
|
|
15
|
+
);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
@@ -235,6 +235,10 @@ describe("LLM catalog parity: daemon vs client", () => {
|
|
|
235
235
|
|
|
236
236
|
test("jev-latest opts out of chat text generation", () => {
|
|
237
237
|
expect(catalogModelSupportsText("typesafe", "jev-latest")).toBe(false);
|
|
238
|
+
// A managed Jev profile is configured as `vellum`; the routing identity
|
|
239
|
+
// owns no catalog models, so the lookup must judge by the catalog owner.
|
|
240
|
+
expect(catalogModelSupportsText("vellum", "jev-latest")).toBe(false);
|
|
241
|
+
expect(catalogModelSupportsText("vellum", "claude-opus-5")).toBe(true);
|
|
238
242
|
expect(catalogModelSupportsText("anthropic", "claude-opus-4-8")).toBe(true);
|
|
239
243
|
expect(catalogModelSupportsText("openai-compatible", "local-model")).toBe(
|
|
240
244
|
true,
|
|
@@ -225,6 +225,49 @@ describe("composeCallSiteTweak", () => {
|
|
|
225
225
|
});
|
|
226
226
|
|
|
227
227
|
describe("shipped call-site tuning", () => {
|
|
228
|
+
test("the escalation judge uses managed Jev by default", () => {
|
|
229
|
+
const resolved = resolveCallSiteConfig(
|
|
230
|
+
"voiceEscalationJudge",
|
|
231
|
+
LLMSchema.parse({}),
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
expect(resolved.provider).toBe("vellum");
|
|
235
|
+
expect(resolved.provider_connection).toBeUndefined();
|
|
236
|
+
expect(resolved.model).toBe("jev-latest");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("the escalation judge falls through on a BYOK default provider", () => {
|
|
240
|
+
const resolved = resolveCallSiteConfig(
|
|
241
|
+
"voiceEscalationJudge",
|
|
242
|
+
LLMSchema.parse(anthropicDp),
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
expect(resolved.provider).toBe("anthropic");
|
|
246
|
+
expect(resolved.model).not.toBe("jev-latest");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("a BYOK Jev call-site override replaces the managed default", () => {
|
|
250
|
+
const llm = LLMSchema.parse({
|
|
251
|
+
...anthropicDp,
|
|
252
|
+
profiles: {
|
|
253
|
+
jev: {
|
|
254
|
+
...completeCustom,
|
|
255
|
+
provider: "typesafe",
|
|
256
|
+
provider_connection: "typesafe-personal",
|
|
257
|
+
model: "jev-latest",
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
callSites: {
|
|
261
|
+
voiceEscalationJudge: { profile: "jev" },
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
const resolved = resolveCallSiteConfig("voiceEscalationJudge", llm);
|
|
265
|
+
|
|
266
|
+
expect(resolved.provider).toBe("typesafe");
|
|
267
|
+
expect(resolved.provider_connection).toBe("typesafe-personal");
|
|
268
|
+
expect(resolved.model).toBe("jev-latest");
|
|
269
|
+
});
|
|
270
|
+
|
|
228
271
|
// A workspace entry that only repoints the profile must not cost the call
|
|
229
272
|
// site the tuning it ships with. `recall` carries a full set: a token
|
|
230
273
|
// budget, effort, sampling, thinking, and cache posture.
|
|
@@ -446,7 +446,7 @@ describe("OpenAIResponsesProvider explicit prompt caching (GPT-5.6+)", () => {
|
|
|
446
446
|
expect(JSON.stringify(messages)).not.toContain("prompt_cache_breakpoint");
|
|
447
447
|
});
|
|
448
448
|
|
|
449
|
-
test("catalog flags GPT-5.6 and GPT-6
|
|
449
|
+
test("catalog flags GPT-5.6 and GPT-6 direct-openai rows", () => {
|
|
450
450
|
const openai = PROVIDER_CATALOG.find((p) => p.id === "openai");
|
|
451
451
|
const flagged = (openai?.models ?? [])
|
|
452
452
|
.filter((m) => m.supportsPromptCacheBreakpoints)
|
|
@@ -457,6 +457,8 @@ describe("OpenAIResponsesProvider explicit prompt caching (GPT-5.6+)", () => {
|
|
|
457
457
|
"gpt-5.6-sol",
|
|
458
458
|
"gpt-5.6-terra",
|
|
459
459
|
"gpt-6-astra",
|
|
460
|
+
"gpt-6-luna",
|
|
461
|
+
"gpt-6-sol",
|
|
460
462
|
]);
|
|
461
463
|
});
|
|
462
464
|
});
|
|
@@ -11,6 +11,7 @@ let mockVelayWebhooksEnabled = false;
|
|
|
11
11
|
let mockPlatformBaseUrl = "";
|
|
12
12
|
let mockPlatformAssistantId = "";
|
|
13
13
|
let mockSecureKeys: Record<string, string> = {};
|
|
14
|
+
let mockSecureKeysUnreachable = false;
|
|
14
15
|
let mockConfig: { ingress?: { publicBaseUrl?: string; enabled?: boolean } } =
|
|
15
16
|
{};
|
|
16
17
|
|
|
@@ -65,7 +66,12 @@ mock.module("../ipc/gateway-client.js", () => ({
|
|
|
65
66
|
const actualSecureKeys = await import("../security/secure-keys.js");
|
|
66
67
|
mock.module("../security/secure-keys.js", () => ({
|
|
67
68
|
...actualSecureKeys,
|
|
68
|
-
getSecureKeyAsync: async (key: string) =>
|
|
69
|
+
getSecureKeyAsync: async (key: string) =>
|
|
70
|
+
mockSecureKeysUnreachable ? undefined : (mockSecureKeys[key] ?? undefined),
|
|
71
|
+
getSecureKeyResultAsync: async (key: string) =>
|
|
72
|
+
mockSecureKeysUnreachable
|
|
73
|
+
? { value: undefined, unreachable: true }
|
|
74
|
+
: { value: mockSecureKeys[key] ?? undefined, unreachable: false },
|
|
69
75
|
}));
|
|
70
76
|
|
|
71
77
|
const actualLoader = await import("../config/loader.js");
|
|
@@ -92,6 +98,7 @@ describe("platform callback registration", () => {
|
|
|
92
98
|
mockPlatformBaseUrl = "";
|
|
93
99
|
mockPlatformAssistantId = "";
|
|
94
100
|
mockSecureKeys = {};
|
|
101
|
+
mockSecureKeysUnreachable = false;
|
|
95
102
|
mockConfig = {};
|
|
96
103
|
setIngressPublicBaseUrl(undefined);
|
|
97
104
|
delete process.env.ASSISTANT_API_KEY;
|
|
@@ -156,6 +163,43 @@ describe("platform callback registration", () => {
|
|
|
156
163
|
expect(context.authHeader).toBe("Api-Key env-key");
|
|
157
164
|
});
|
|
158
165
|
|
|
166
|
+
test("reports the key as missing when the store answers and holds none", async () => {
|
|
167
|
+
mockPlatformBaseUrl = "https://platform.example.com";
|
|
168
|
+
mockPlatformAssistantId = "44444444-5555-4666-8777-888888888888";
|
|
169
|
+
|
|
170
|
+
const context = await resolvePlatformCallbackRegistrationContext();
|
|
171
|
+
|
|
172
|
+
expect(context.hasAssistantApiKey).toBe(false);
|
|
173
|
+
expect(context.authHeader).toBeNull();
|
|
174
|
+
expect(context.enabled).toBe(false);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("reports the key state as unknown when the store did not answer", async () => {
|
|
178
|
+
// A client that provisions a key when it sees none must not be told
|
|
179
|
+
// "none" by a daemon whose store did not answer.
|
|
180
|
+
mockSecureKeysUnreachable = true;
|
|
181
|
+
mockPlatformBaseUrl = "https://platform.example.com";
|
|
182
|
+
mockPlatformAssistantId = "44444444-5555-4666-8777-888888888888";
|
|
183
|
+
|
|
184
|
+
const context = await resolvePlatformCallbackRegistrationContext();
|
|
185
|
+
|
|
186
|
+
expect(context.hasAssistantApiKey).toBeNull();
|
|
187
|
+
expect(context.authHeader).toBeNull();
|
|
188
|
+
expect(context.enabled).toBe(false);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("an environment key counts as present even when the store did not answer", async () => {
|
|
192
|
+
mockSecureKeysUnreachable = true;
|
|
193
|
+
process.env.ASSISTANT_API_KEY = "env-key";
|
|
194
|
+
mockPlatformBaseUrl = "https://platform.example.com";
|
|
195
|
+
mockPlatformAssistantId = "44444444-5555-4666-8777-888888888888";
|
|
196
|
+
|
|
197
|
+
const context = await resolvePlatformCallbackRegistrationContext();
|
|
198
|
+
|
|
199
|
+
expect(context.hasAssistantApiKey).toBe(true);
|
|
200
|
+
expect(context.authHeader).toBe("Api-Key env-key");
|
|
201
|
+
});
|
|
202
|
+
|
|
159
203
|
test("registerCallbackRoute falls back to assistant API key auth", async () => {
|
|
160
204
|
mockSecureKeys[credentialKey("vellum", "platform_base_url")] =
|
|
161
205
|
"https://platform.example.com";
|
|
@@ -402,6 +446,7 @@ describe("resolveCallbackUrl resolution order", () => {
|
|
|
402
446
|
mockPlatformBaseUrl = "";
|
|
403
447
|
mockPlatformAssistantId = "";
|
|
404
448
|
mockSecureKeys = {};
|
|
449
|
+
mockSecureKeysUnreachable = false;
|
|
405
450
|
mockConfig = {};
|
|
406
451
|
setIngressPublicBaseUrl(undefined);
|
|
407
452
|
delete process.env.ASSISTANT_API_KEY;
|
|
@@ -40,6 +40,13 @@ describe("getModelProfiles", () => {
|
|
|
40
40
|
writeFixtureConfig({});
|
|
41
41
|
});
|
|
42
42
|
|
|
43
|
+
test("omits the managed Jev profile, whose model returns verdicts rather than text", () => {
|
|
44
|
+
writeFixtureConfig({});
|
|
45
|
+
const keys = getModelProfiles().map((p) => p.key);
|
|
46
|
+
expect(keys).toContain("balanced");
|
|
47
|
+
expect(keys).not.toContain("jev-managed");
|
|
48
|
+
});
|
|
49
|
+
|
|
43
50
|
test("orders profiles by profileOrder then the remaining keys alphabetically", () => {
|
|
44
51
|
// GIVEN a workspace whose profileOrder names some keys, with a duplicate
|
|
45
52
|
// ("balanced") and a key that resolves to no profile ("ghost").
|
|
@@ -196,6 +196,9 @@ const BASELINE: Record<string, readonly string[]> = {
|
|
|
196
196
|
"../../../daemon/trust-context.js",
|
|
197
197
|
"../../../home/job-handlers/conversation-starters.js",
|
|
198
198
|
"../../../media/job-handlers/media-processing.js",
|
|
199
|
+
// The skill-update receipt job announces a burst through the
|
|
200
|
+
// notification pipeline's single entry point. No plugin-api equivalent.
|
|
201
|
+
"../../../notifications/emit-signal.js",
|
|
199
202
|
"../../../permissions/types.js",
|
|
200
203
|
"../../../persistence/auto-analysis-constants.js",
|
|
201
204
|
"../../../persistence/checkpoints.js",
|
|
@@ -135,6 +135,39 @@ describe("resolvePricing", () => {
|
|
|
135
135
|
expect(result.estimatedCostUsd).toBeCloseTo(0.5 + 0.625 + 0.05, 10);
|
|
136
136
|
});
|
|
137
137
|
|
|
138
|
+
test("bills GPT-6 Sol at published short-context rates", () => {
|
|
139
|
+
const result = resolvePricingForUsage("openai", "gpt-6-sol", {
|
|
140
|
+
directInputTokens: 50_000,
|
|
141
|
+
outputTokens: 50_000,
|
|
142
|
+
cacheCreationInputTokens: 50_000,
|
|
143
|
+
cacheReadInputTokens: 50_000,
|
|
144
|
+
anthropicCacheCreation: null,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
expect(result.pricingStatus).toBe("priced");
|
|
148
|
+
// 0.05M x $2 direct + 0.05M x $2.5 write + 0.05M x $0.2 read
|
|
149
|
+
// + 0.05M x $10 output.
|
|
150
|
+
expect(result.estimatedCostUsd).toBeCloseTo(0.1 + 0.125 + 0.01 + 0.5, 10);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("bills GPT-6 Luna at the long-context tier above 272k", () => {
|
|
154
|
+
const result = resolvePricingForUsage("openai", "gpt-6-luna", {
|
|
155
|
+
directInputTokens: 500_000,
|
|
156
|
+
outputTokens: 1_000_000,
|
|
157
|
+
cacheCreationInputTokens: 500_000,
|
|
158
|
+
cacheReadInputTokens: 1_000_000,
|
|
159
|
+
anthropicCacheCreation: null,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
expect(result.pricingStatus).toBe("priced");
|
|
163
|
+
// Tier rates: 0.5M x $0.2 direct + 0.5M x $0.25 write + 1M x $0.02 read
|
|
164
|
+
// + 1M x $0.75 output.
|
|
165
|
+
expect(result.estimatedCostUsd).toBeCloseTo(
|
|
166
|
+
0.1 + 0.125 + 0.02 + 0.75,
|
|
167
|
+
10,
|
|
168
|
+
);
|
|
169
|
+
});
|
|
170
|
+
|
|
138
171
|
test("bills GPT-6 Astra at the long-context tier above 272k", () => {
|
|
139
172
|
const result = resolvePricingForUsage("openai", "gpt-6-astra", {
|
|
140
173
|
directInputTokens: 500_000,
|
|
@@ -118,6 +118,7 @@ mock.module("../util/retry.js", () => {
|
|
|
118
118
|
};
|
|
119
119
|
});
|
|
120
120
|
|
|
121
|
+
import { malformedToolCallError } from "../providers/malformed-tool-call.js";
|
|
121
122
|
import { RetryProvider } from "../providers/retry.js";
|
|
122
123
|
import { createStreamTimeout } from "../providers/stream-timeout.js";
|
|
123
124
|
import {
|
|
@@ -941,6 +942,51 @@ describe("RetryProvider — streaming corruption retries", () => {
|
|
|
941
942
|
}
|
|
942
943
|
});
|
|
943
944
|
|
|
945
|
+
test("malformed tool-call finishes retry once with a corrective note", async () => {
|
|
946
|
+
const providerError = malformedToolCallError(
|
|
947
|
+
"openrouter",
|
|
948
|
+
"MALFORMED_FUNCTION_CALL",
|
|
949
|
+
);
|
|
950
|
+
// Providers wrap stream-loop throws in their generic catch.
|
|
951
|
+
const wrapped = new ProviderError(
|
|
952
|
+
`OpenRouter request failed: ${providerError.message}`,
|
|
953
|
+
"openrouter",
|
|
954
|
+
undefined,
|
|
955
|
+
{ cause: providerError },
|
|
956
|
+
);
|
|
957
|
+
const inner = makeCapturingFlaky(1, wrapped);
|
|
958
|
+
const provider = new RetryProvider(inner);
|
|
959
|
+
|
|
960
|
+
await provider.sendMessage(MESSAGES);
|
|
961
|
+
|
|
962
|
+
expect(inner.calls).toBe(2);
|
|
963
|
+
expect(inner.seen[0]).toBe(MESSAGES);
|
|
964
|
+
const retried = inner.seen[1]!;
|
|
965
|
+
const lastMessage = retried[retried.length - 1]!;
|
|
966
|
+
expect(lastMessage.role).toBe("user");
|
|
967
|
+
expect(lastMessage.content).toHaveLength(2);
|
|
968
|
+
const hint = lastMessage.content[1] as { type: string; text: string };
|
|
969
|
+
expect(hint.type).toBe("text");
|
|
970
|
+
expect(hint.text).toContain("did not parse");
|
|
971
|
+
expect(MESSAGES[MESSAGES.length - 1]!.content).toHaveLength(1);
|
|
972
|
+
});
|
|
973
|
+
|
|
974
|
+
test("a malformed tool-call message with an HTTP status is not retried", async () => {
|
|
975
|
+
const inner = makeFailing(
|
|
976
|
+
new ProviderError(
|
|
977
|
+
malformedToolCallError("gemini", "MALFORMED_FUNCTION_CALL").message,
|
|
978
|
+
"gemini",
|
|
979
|
+
400,
|
|
980
|
+
),
|
|
981
|
+
);
|
|
982
|
+
const provider = new RetryProvider(inner);
|
|
983
|
+
|
|
984
|
+
await expect(provider.sendMessage(MESSAGES)).rejects.toThrow(
|
|
985
|
+
"malformed tool call",
|
|
986
|
+
);
|
|
987
|
+
expect(inner.calls).toBe(1);
|
|
988
|
+
});
|
|
989
|
+
|
|
944
990
|
test("other stream-corruption retries resend the messages untouched", async () => {
|
|
945
991
|
const inner = makeCapturingFlaky(
|
|
946
992
|
1,
|