@vellumai/assistant 0.11.1-dev.202608032034.aebd2da → 0.11.1-dev.202608032226.351a4e2
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/openapi.yaml +1 -0
- package/package.json +1 -1
- package/src/__tests__/agent-loop-resume-interrupted.test.ts +223 -0
- package/src/__tests__/conversation-agent-loop.test.ts +16 -7
- package/src/__tests__/conversation-error.test.ts +106 -0
- package/src/__tests__/llm-resolver.test.ts +16 -0
- package/src/__tests__/provider-send-message-override-profile.test.ts +95 -0
- package/src/__tests__/workspace-migration-137-repair-retired-fireworks-minimax-model-id.test.ts +6 -6
- package/src/__tests__/workspace-migration-138-backfill-home-feed-titles.test.ts +373 -0
- package/src/agent/loop.ts +36 -7
- package/src/cli/commands/notifications.help.ts +3 -2
- package/src/config/llm-resolver.ts +19 -3
- package/src/daemon/conversation-agent-loop-handlers.ts +15 -6
- package/src/daemon/conversation-agent-loop.ts +31 -18
- package/src/daemon/conversation-error.ts +40 -24
- package/src/home/__tests__/feed-types.test.ts +43 -8
- package/src/home/__tests__/feed-writer.test.ts +59 -0
- package/src/home/feed-types.ts +34 -7
- package/src/home/feed-writer.ts +15 -6
- package/src/live-voice/__tests__/activity-label.test.ts +68 -0
- package/src/live-voice/__tests__/live-activity-reporter.test.ts +86 -5
- package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +14 -5
- package/src/live-voice/activity-label.ts +106 -0
- package/src/live-voice/live-activity-reporter.ts +35 -6
- package/src/live-voice/live-voice-session.ts +58 -0
- package/src/live-voice/protocol.ts +23 -0
- package/src/notifications/__tests__/copy-composer.test.ts +53 -3
- package/src/notifications/__tests__/decision-engine.test.ts +150 -20
- package/src/notifications/__tests__/edit-notification.test.ts +330 -0
- package/src/notifications/__tests__/home-feed-side-effect.test.ts +170 -12
- package/src/notifications/copy-composer.ts +23 -3
- package/src/notifications/decision-engine.ts +29 -7
- package/src/notifications/edit-notification.ts +8 -4
- package/src/notifications/home-feed-side-effect.ts +43 -6
- package/src/notifications/notification-utils.ts +8 -2
- package/src/persistence/conversation-title-service.ts +5 -170
- package/src/providers/__tests__/dispatch-connection-routing.test.ts +43 -2
- package/src/providers/__tests__/vellum-mismatch-routing.test.ts +8 -0
- package/src/providers/call-site-routing.ts +77 -14
- package/src/providers/connection-resolution.ts +42 -2
- package/src/providers/types.ts +7 -1
- package/src/runtime/routes/notification-routes.ts +4 -1
- package/src/util/__tests__/short-title.test.ts +229 -0
- package/src/util/errors.ts +15 -0
- package/src/util/short-title.ts +189 -0
- package/src/workspace/migrations/138-backfill-home-feed-titles.ts +179 -0
- package/src/workspace/migrations/registry.ts +2 -0
package/openapi.yaml
CHANGED
|
@@ -21436,6 +21436,7 @@ paths:
|
|
|
21436
21436
|
minLength: 1
|
|
21437
21437
|
description: Feed item id (notif:<uuid>) or bare uuid
|
|
21438
21438
|
title:
|
|
21439
|
+
description: New title. An empty value is ignored, never cleared.
|
|
21439
21440
|
type: string
|
|
21440
21441
|
body:
|
|
21441
21442
|
type: string
|
package/package.json
CHANGED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cover for the loop's interrupted-call recovery.
|
|
3
|
+
*
|
|
4
|
+
* The reported failure: the model runs several tools, the next call streams for
|
|
5
|
+
* a while and then dies (the upstream killed a generation that blew its decode
|
|
6
|
+
* deadline), and the loop surfaces the error and ends the turn — leaving the
|
|
7
|
+
* task half-done until the user types "continue". These tests assert the loop
|
|
8
|
+
* now carries on by itself, and that it still stops when carrying on would be
|
|
9
|
+
* pointless.
|
|
10
|
+
*
|
|
11
|
+
* The recovery is built into {@link AgentLoop}, not contributed by a plugin, so
|
|
12
|
+
* these run it as the loop's own behavior. The default plugin stack is
|
|
13
|
+
* registered because the loop fires hook chains during a turn regardless.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
17
|
+
|
|
18
|
+
import type { AgentEvent } from "../agent/loop.js";
|
|
19
|
+
import { AgentLoop } from "../agent/loop.js";
|
|
20
|
+
import type { LLMCallSite } from "../config/schemas/llm.js";
|
|
21
|
+
import { resetPluginRegistryAndRegisterDefaults } from "../plugins/defaults/index.js";
|
|
22
|
+
import type {
|
|
23
|
+
Message,
|
|
24
|
+
Provider,
|
|
25
|
+
ProviderResponse,
|
|
26
|
+
SendMessageOptions,
|
|
27
|
+
ToolDefinition,
|
|
28
|
+
} from "../providers/types.js";
|
|
29
|
+
import { ProviderError } from "../util/errors.js";
|
|
30
|
+
|
|
31
|
+
const tools: ToolDefinition[] = [
|
|
32
|
+
{
|
|
33
|
+
name: "read_file",
|
|
34
|
+
description: "Read a file",
|
|
35
|
+
input_schema: { type: "object", properties: { path: { type: "string" } } },
|
|
36
|
+
},
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
const userMessage: Message = {
|
|
40
|
+
role: "user",
|
|
41
|
+
content: [{ type: "text", text: "finish the course page" }],
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** The rejection from the reported turn, thrown from inside a live stream. */
|
|
45
|
+
function midStreamRejection(): ProviderError {
|
|
46
|
+
return new ProviderError(
|
|
47
|
+
"OpenAI-compatible API error (unknown status): litellm.MidStreamFallbackError: " +
|
|
48
|
+
"litellm.APIConnectionError: OpenAIException - Decode wall clock timeout after 600s",
|
|
49
|
+
"openai-compatible",
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A scripted turn: return a response, or stream `thinking` and then throw. */
|
|
54
|
+
type Turn =
|
|
55
|
+
| { kind: "reply"; response: ProviderResponse }
|
|
56
|
+
| { kind: "interrupt"; thinking: string }
|
|
57
|
+
| { kind: "refuse" };
|
|
58
|
+
|
|
59
|
+
function scriptedProvider(turns: Turn[]): {
|
|
60
|
+
provider: Provider;
|
|
61
|
+
callCount: () => number;
|
|
62
|
+
} {
|
|
63
|
+
let index = 0;
|
|
64
|
+
const provider: Provider = {
|
|
65
|
+
name: "openai-compatible",
|
|
66
|
+
async sendMessage(
|
|
67
|
+
_messages: Message[],
|
|
68
|
+
options?: SendMessageOptions,
|
|
69
|
+
): Promise<ProviderResponse> {
|
|
70
|
+
const turn = turns[Math.min(index, turns.length - 1)]!;
|
|
71
|
+
index++;
|
|
72
|
+
if (turn.kind === "refuse") {
|
|
73
|
+
// Rejected before generating: nothing ever streams.
|
|
74
|
+
throw midStreamRejection();
|
|
75
|
+
}
|
|
76
|
+
if (turn.kind === "interrupt") {
|
|
77
|
+
options?.onEvent?.({ type: "thinking_delta", thinking: turn.thinking });
|
|
78
|
+
throw midStreamRejection();
|
|
79
|
+
}
|
|
80
|
+
for (const block of turn.response.content) {
|
|
81
|
+
if (block.type === "text") {
|
|
82
|
+
options?.onEvent?.({ type: "text_delta", text: block.text });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return turn.response;
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
return { provider, callCount: () => index };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function reply(text: string): Turn {
|
|
92
|
+
return {
|
|
93
|
+
kind: "reply",
|
|
94
|
+
response: {
|
|
95
|
+
content: [{ type: "text", text }],
|
|
96
|
+
model: "glm-5.2",
|
|
97
|
+
usage: { inputTokens: 10, outputTokens: 5 },
|
|
98
|
+
stopReason: "end_turn",
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function callsTool(id: string): Turn {
|
|
104
|
+
return {
|
|
105
|
+
kind: "reply",
|
|
106
|
+
response: {
|
|
107
|
+
content: [
|
|
108
|
+
{ type: "tool_use", id, name: "read_file", input: { path: "app.js" } },
|
|
109
|
+
],
|
|
110
|
+
model: "glm-5.2",
|
|
111
|
+
usage: { inputTokens: 10, outputTokens: 5 },
|
|
112
|
+
stopReason: "tool_use",
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function runLoop(provider: Provider, callSite: LLMCallSite = "mainAgent") {
|
|
118
|
+
const events: AgentEvent[] = [];
|
|
119
|
+
const loop = new AgentLoop({
|
|
120
|
+
provider,
|
|
121
|
+
systemPrompt: "system",
|
|
122
|
+
conversationId: "conv-resume-e2e",
|
|
123
|
+
tools,
|
|
124
|
+
toolExecutor: async () => ({ content: "file data", isError: false }),
|
|
125
|
+
});
|
|
126
|
+
return loop
|
|
127
|
+
.run({
|
|
128
|
+
requestId: "req-resume-e2e",
|
|
129
|
+
messages: [userMessage],
|
|
130
|
+
callSite,
|
|
131
|
+
onEvent: (event) => {
|
|
132
|
+
events.push(event);
|
|
133
|
+
},
|
|
134
|
+
trust: { sourceChannel: "vellum", trustClass: "unknown" },
|
|
135
|
+
})
|
|
136
|
+
.then((result) => ({ ...result, events }));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function assistantTextOf(history: Message[]): string {
|
|
140
|
+
return history
|
|
141
|
+
.filter((message) => message.role === "assistant")
|
|
142
|
+
.flatMap((message) => message.content)
|
|
143
|
+
.map((block) => (block.type === "text" ? block.text : ""))
|
|
144
|
+
.join("");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
describe("AgentLoop — a call interrupted mid-generation", () => {
|
|
148
|
+
beforeEach(() => {
|
|
149
|
+
resetPluginRegistryAndRegisterDefaults();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("carries on instead of ending the turn", async () => {
|
|
153
|
+
const { provider, callCount } = scriptedProvider([
|
|
154
|
+
callsTool("call-1"),
|
|
155
|
+
{ kind: "interrupt", thinking: "Now I need to understand the layout" },
|
|
156
|
+
reply("Done — the page is wired up."),
|
|
157
|
+
]);
|
|
158
|
+
|
|
159
|
+
const { history, events } = await runLoop(provider);
|
|
160
|
+
|
|
161
|
+
// The turn finished on its own: tool call, interrupted call, resumed call.
|
|
162
|
+
expect(callCount()).toBe(3);
|
|
163
|
+
expect(assistantTextOf(history)).toContain("Done — the page is wired up.");
|
|
164
|
+
expect(events.filter((event) => event.type === "error")).toHaveLength(0);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("keeps the tool work the interrupted turn had already done", async () => {
|
|
168
|
+
const { provider } = scriptedProvider([
|
|
169
|
+
callsTool("call-1"),
|
|
170
|
+
{ kind: "interrupt", thinking: "reading the file" },
|
|
171
|
+
reply("Summary of the file."),
|
|
172
|
+
]);
|
|
173
|
+
|
|
174
|
+
const { history } = await runLoop(provider);
|
|
175
|
+
|
|
176
|
+
// The resumed call re-sends the same history, so the completed tool call
|
|
177
|
+
// and its result are still there rather than being replayed or dropped.
|
|
178
|
+
const toolUses = history
|
|
179
|
+
.flatMap((message) => message.content)
|
|
180
|
+
.filter((block) => block.type === "tool_use");
|
|
181
|
+
const toolResults = history
|
|
182
|
+
.flatMap((message) => message.content)
|
|
183
|
+
.filter((block) => block.type === "tool_result");
|
|
184
|
+
expect(toolUses).toHaveLength(1);
|
|
185
|
+
expect(toolResults).toHaveLength(1);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("surfaces the error when the resumed call is interrupted too", async () => {
|
|
189
|
+
const { provider, callCount } = scriptedProvider([
|
|
190
|
+
{ kind: "interrupt", thinking: "starting" },
|
|
191
|
+
]);
|
|
192
|
+
|
|
193
|
+
const { events } = await runLoop(provider);
|
|
194
|
+
|
|
195
|
+
// One resume, then the error stands rather than looping.
|
|
196
|
+
expect(callCount()).toBe(2);
|
|
197
|
+
expect(events.filter((event) => event.type === "error")).toHaveLength(1);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("does not resume a background call site", async () => {
|
|
201
|
+
// A compaction, subagent, or other background call answers to a caller
|
|
202
|
+
// that owns its own failure handling; nobody is sitting there to type
|
|
203
|
+
// "continue" at it.
|
|
204
|
+
const { provider, callCount } = scriptedProvider([
|
|
205
|
+
{ kind: "interrupt", thinking: "summarizing" },
|
|
206
|
+
]);
|
|
207
|
+
|
|
208
|
+
await runLoop(provider, "compactionAgent");
|
|
209
|
+
|
|
210
|
+
expect(callCount()).toBe(1);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("does not resume a request the provider refused outright", async () => {
|
|
214
|
+
// Nothing streamed, so re-sending the identical request would fail the same
|
|
215
|
+
// way; the error surfaces on the first rejection.
|
|
216
|
+
const { provider, callCount } = scriptedProvider([{ kind: "refuse" }]);
|
|
217
|
+
|
|
218
|
+
const { events } = await runLoop(provider);
|
|
219
|
+
|
|
220
|
+
expect(callCount()).toBe(1);
|
|
221
|
+
expect(events.filter((event) => event.type === "error")).toHaveLength(1);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
@@ -3022,12 +3022,20 @@ describe("session-agent-loop", () => {
|
|
|
3022
3022
|
}));
|
|
3023
3023
|
|
|
3024
3024
|
// GIVEN a real loop whose provider streams a delta — landing a debounced
|
|
3025
|
-
// partial flush on the reserved row — then rejects
|
|
3026
|
-
//
|
|
3025
|
+
// partial flush on the reserved row — then rejects. The loop resumes a
|
|
3026
|
+
// call that died after it had started streaming, so the second attempt
|
|
3027
|
+
// is scripted to fail before streaming anything: that keeps exactly one
|
|
3028
|
+
// row carrying partial content, and the turn then exits via
|
|
3029
|
+
// `provider_error` with no `message_complete`.
|
|
3030
|
+
let attempt = 0;
|
|
3027
3031
|
const ctx = makeCtx({
|
|
3028
3032
|
loopProvider: {
|
|
3029
3033
|
name: "mock-provider",
|
|
3030
3034
|
async sendMessage(_messages, options) {
|
|
3035
|
+
attempt++;
|
|
3036
|
+
if (attempt > 1) {
|
|
3037
|
+
throw new Error("upstream 500");
|
|
3038
|
+
}
|
|
3031
3039
|
options?.onEvent?.({ type: "text_delta", text: "hello world" });
|
|
3032
3040
|
await new Promise((resolve) => setTimeout(resolve, 1100));
|
|
3033
3041
|
throw new Error("upstream 500");
|
|
@@ -3051,11 +3059,12 @@ describe("session-agent-loop", () => {
|
|
|
3051
3059
|
.split("\n");
|
|
3052
3060
|
expect(orphanLines).toHaveLength(1);
|
|
3053
3061
|
expect(updateMessageContentMock).toHaveBeenCalledTimes(0);
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3062
|
+
// Scoped to the row under test: the resumed attempt reserves a row of
|
|
3063
|
+
// its own, which its own cleanup deletes.
|
|
3064
|
+
const deletedIds = deleteMessageByIdMock.mock.calls.map(
|
|
3065
|
+
(call) => (call as unknown as [string])[0],
|
|
3066
|
+
);
|
|
3067
|
+
expect(deletedIds).toContain("msg-orphan-with-partial");
|
|
3059
3068
|
});
|
|
3060
3069
|
});
|
|
3061
3070
|
|
|
@@ -170,6 +170,112 @@ describe("classifyConversationError", () => {
|
|
|
170
170
|
expect(result.userMessage).toContain("AI provider");
|
|
171
171
|
expect(result.errorCategory).toBe("rate_limit");
|
|
172
172
|
});
|
|
173
|
+
|
|
174
|
+
it("uses the ChatGPT OAuth route instead of the OpenAI registry default", () => {
|
|
175
|
+
providerRoutingSources.openai = "managed-proxy";
|
|
176
|
+
const err = new ProviderError(
|
|
177
|
+
"OpenAI API error (429): Too many requests",
|
|
178
|
+
"openai",
|
|
179
|
+
429,
|
|
180
|
+
{ reason: "rate_limited" },
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
const result = classifyConversationError(err, {
|
|
184
|
+
...baseCtx,
|
|
185
|
+
connectionName: "chatgpt-subscription",
|
|
186
|
+
isManagedRoute: false,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
expect(result.code).toBe("PROVIDER_RATE_LIMIT");
|
|
190
|
+
expect(result.userMessage).toContain("AI provider");
|
|
191
|
+
expect(result.errorCategory).toBe("rate_limit");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("prefers the failed call's direct route over stale managed turn attribution", () => {
|
|
195
|
+
providerRoutingSources.openai = "managed-proxy";
|
|
196
|
+
const err = new ProviderError(
|
|
197
|
+
"OpenAI API error (429): Too many requests",
|
|
198
|
+
"openai",
|
|
199
|
+
429,
|
|
200
|
+
{ reason: "rate_limited" },
|
|
201
|
+
);
|
|
202
|
+
err.attachRouteAttribution({
|
|
203
|
+
connectionName: "chatgpt-subscription",
|
|
204
|
+
profileName: "chatgpt",
|
|
205
|
+
isManagedRoute: false,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const result = classifyConversationError(err, {
|
|
209
|
+
...baseCtx,
|
|
210
|
+
connectionName: "vellum",
|
|
211
|
+
profileName: "managed",
|
|
212
|
+
isManagedRoute: true,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
expect(result.code).toBe("PROVIDER_RATE_LIMIT");
|
|
216
|
+
expect(result.userMessage).toContain("AI provider");
|
|
217
|
+
expect(result.errorCategory).toBe("rate_limit");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("prefers the failed call's managed fallback over stale direct turn attribution", () => {
|
|
221
|
+
const err = new ProviderError(
|
|
222
|
+
"Anthropic API error (429): Too many requests",
|
|
223
|
+
"anthropic",
|
|
224
|
+
429,
|
|
225
|
+
{ reason: "rate_limited" },
|
|
226
|
+
);
|
|
227
|
+
err.attachRouteAttribution({
|
|
228
|
+
profileName: "direct",
|
|
229
|
+
isManagedRoute: true,
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const result = classifyConversationError(err, {
|
|
233
|
+
...baseCtx,
|
|
234
|
+
connectionName: "anthropic-key",
|
|
235
|
+
profileName: "direct",
|
|
236
|
+
isManagedRoute: false,
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
expect(result.code).toBe("MANAGED_USAGE_LIMIT");
|
|
240
|
+
expect(result.userMessage).toContain("Vellum managed inference");
|
|
241
|
+
expect(result.errorCategory).toBe("managed_usage_limit");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("does not apply a managed LLM route to a plain rate-limit error", () => {
|
|
245
|
+
const result = classifyConversationError(
|
|
246
|
+
new Error("429 Too Many Requests from a tool API"),
|
|
247
|
+
{
|
|
248
|
+
...baseCtx,
|
|
249
|
+
isManagedRoute: true,
|
|
250
|
+
},
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
expect(result.code).toBe("PROVIDER_RATE_LIMIT");
|
|
254
|
+
expect(result.errorCategory).toBe("rate_limit");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("falls back to context for fields the failed call's route omits", () => {
|
|
258
|
+
const err = new ProviderError("Unauthorized", "anthropic", 401);
|
|
259
|
+
err.attachRouteAttribution({ profileName: "direct" });
|
|
260
|
+
|
|
261
|
+
const result = classifyConversationError(err, {
|
|
262
|
+
...baseCtx,
|
|
263
|
+
connectionName: "anthropic-key",
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
expect(result.connectionName).toBe("anthropic-key");
|
|
267
|
+
expect(result.profileName).toBe("direct");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("still recognizes a rewrapped Vellum quota body", () => {
|
|
271
|
+
const result = classifyConversationError(
|
|
272
|
+
new Error('429 {"code":"daily_quota_exceeded"}'),
|
|
273
|
+
baseCtx,
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
expect(result.code).toBe("MANAGED_USAGE_LIMIT");
|
|
277
|
+
expect(result.errorCategory).toBe("managed_usage_limit");
|
|
278
|
+
});
|
|
173
279
|
});
|
|
174
280
|
|
|
175
281
|
describe("provider overloaded errors", () => {
|
|
@@ -6,6 +6,7 @@ import { CODE_DEFAULT_PROFILE_ENTRIES } from "../config/default-profile-catalog.
|
|
|
6
6
|
import {
|
|
7
7
|
type ResolutionFallbackReason,
|
|
8
8
|
resolveCallSiteConfig,
|
|
9
|
+
resolveCallSiteConfigWithProfile,
|
|
9
10
|
resolveDefaultProfileKey,
|
|
10
11
|
resolveEffectiveProfileKey,
|
|
11
12
|
} from "../config/llm-resolver.js";
|
|
@@ -859,6 +860,21 @@ describe("mix profiles", () => {
|
|
|
859
860
|
}
|
|
860
861
|
});
|
|
861
862
|
|
|
863
|
+
test("config and profile attribution share one mix selection", () => {
|
|
864
|
+
const selectedArms: string[] = [];
|
|
865
|
+
const resolved = resolveCallSiteConfigWithProfile("mainAgent", mixLlm, {
|
|
866
|
+
onMixSelected: ({ chosenProfile }) => {
|
|
867
|
+
selectedArms.push(chosenProfile);
|
|
868
|
+
},
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
expect(resolved.profileName).toBe("ab");
|
|
872
|
+
expect(selectedArms).toHaveLength(1);
|
|
873
|
+
expect(resolved.config.model).toBe(
|
|
874
|
+
selectedArms[0] === "a" ? "model-a" : "model-b",
|
|
875
|
+
);
|
|
876
|
+
});
|
|
877
|
+
|
|
862
878
|
test("all dereference spots in a turn agree for the same seed", () => {
|
|
863
879
|
// mainAgent (mix as activeProfile) and a non-main call site resolving the
|
|
864
880
|
// same mix as its call-site profile must pick the same arm when given the
|
|
@@ -27,6 +27,7 @@ import type {
|
|
|
27
27
|
ProviderResponse,
|
|
28
28
|
SendMessageOptions,
|
|
29
29
|
} from "../providers/types.js";
|
|
30
|
+
import { ProviderError } from "../util/errors.js";
|
|
30
31
|
import { setConfig } from "./helpers/set-config.js";
|
|
31
32
|
|
|
32
33
|
const DUMMY_MESSAGES: Message[] = [
|
|
@@ -239,6 +240,91 @@ describe("SendMessageOptions.config.overrideProfile", () => {
|
|
|
239
240
|
expect(response.model).toBe("openai");
|
|
240
241
|
});
|
|
241
242
|
|
|
243
|
+
test("CallSiteRoutingProvider attributes provider errors to the actual resolved connection", async () => {
|
|
244
|
+
setLlmConfig({
|
|
245
|
+
profiles: {
|
|
246
|
+
fast: {
|
|
247
|
+
provider: "openai",
|
|
248
|
+
provider_connection: "openai-conn",
|
|
249
|
+
model: "gpt-5.4",
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const providerError = new ProviderError(
|
|
255
|
+
"OpenAI API error (429): Too many requests",
|
|
256
|
+
"openai",
|
|
257
|
+
429,
|
|
258
|
+
{ reason: "rate_limited" },
|
|
259
|
+
);
|
|
260
|
+
const defaultProvider = makeThrowingProvider(
|
|
261
|
+
"anthropic",
|
|
262
|
+
new Error("default provider should not be called"),
|
|
263
|
+
);
|
|
264
|
+
const altProvider = makeThrowingProvider("openai", providerError);
|
|
265
|
+
altProvider.routeAttribution = {
|
|
266
|
+
connectionName: "openai-recovered",
|
|
267
|
+
isManagedRoute: false,
|
|
268
|
+
};
|
|
269
|
+
const wrapped = new CallSiteRoutingProvider(
|
|
270
|
+
defaultProvider,
|
|
271
|
+
async (connectionName) =>
|
|
272
|
+
connectionName === "openai-conn" ? altProvider : null,
|
|
273
|
+
{ connectionName: "vellum", isManagedRoute: true },
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
await expect(
|
|
277
|
+
wrapped.sendMessage(DUMMY_MESSAGES, {
|
|
278
|
+
config: { callSite: "mainAgent", overrideProfile: "fast" },
|
|
279
|
+
}),
|
|
280
|
+
).rejects.toBe(providerError);
|
|
281
|
+
expect(providerError.routeAttribution).toEqual({
|
|
282
|
+
connectionName: "openai-recovered",
|
|
283
|
+
profileName: "fast",
|
|
284
|
+
isManagedRoute: false,
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("CallSiteRoutingProvider attributes soft fallback errors to the actual default connection", async () => {
|
|
289
|
+
setLlmConfig({
|
|
290
|
+
profiles: {
|
|
291
|
+
fast: {
|
|
292
|
+
provider: "openai",
|
|
293
|
+
provider_connection: "openai-conn",
|
|
294
|
+
model: "gpt-5.4",
|
|
295
|
+
},
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
const providerError = new ProviderError(
|
|
300
|
+
"Anthropic API error (429): Too many requests",
|
|
301
|
+
"anthropic",
|
|
302
|
+
429,
|
|
303
|
+
{ reason: "rate_limited" },
|
|
304
|
+
);
|
|
305
|
+
const defaultProvider = makeThrowingProvider("anthropic", providerError);
|
|
306
|
+
defaultProvider.routeAttribution = {
|
|
307
|
+
connectionName: "anthropic-default",
|
|
308
|
+
isManagedRoute: false,
|
|
309
|
+
};
|
|
310
|
+
const wrapped = new CallSiteRoutingProvider(
|
|
311
|
+
defaultProvider,
|
|
312
|
+
async () => null,
|
|
313
|
+
defaultProvider.routeAttribution,
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
await expect(
|
|
317
|
+
wrapped.sendMessage(DUMMY_MESSAGES, {
|
|
318
|
+
config: { callSite: "mainAgent", overrideProfile: "fast" },
|
|
319
|
+
}),
|
|
320
|
+
).rejects.toBe(providerError);
|
|
321
|
+
expect(providerError.routeAttribution).toEqual({
|
|
322
|
+
connectionName: "anthropic-default",
|
|
323
|
+
profileName: "fast",
|
|
324
|
+
isManagedRoute: false,
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
242
328
|
test("missing overrideProfile name silently falls through to base resolution", async () => {
|
|
243
329
|
setLlmConfig({
|
|
244
330
|
// The call-site tweak applies last in resolution, so it pins the model
|
|
@@ -328,6 +414,15 @@ describe("SendMessageOptions.config.overrideProfile", () => {
|
|
|
328
414
|
});
|
|
329
415
|
});
|
|
330
416
|
|
|
417
|
+
function makeThrowingProvider(name: string, error: Error): Provider {
|
|
418
|
+
return {
|
|
419
|
+
name,
|
|
420
|
+
async sendMessage(): Promise<ProviderResponse> {
|
|
421
|
+
throw error;
|
|
422
|
+
},
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
331
426
|
describe("SendMessageOptions.config.forceOverrideProfile", () => {
|
|
332
427
|
test("CallSiteConfiguredProvider forwards forceOverrideProfile into the send config", async () => {
|
|
333
428
|
let captured: SendMessageOptions | undefined;
|
package/src/__tests__/workspace-migration-137-repair-retired-fireworks-minimax-model-id.test.ts
CHANGED
|
@@ -47,15 +47,15 @@ afterEach(() => {
|
|
|
47
47
|
});
|
|
48
48
|
|
|
49
49
|
describe("137-repair-retired-fireworks-minimax-model-id migration", () => {
|
|
50
|
-
test("has correct migration id and is registered
|
|
50
|
+
test("has correct migration id and is registered", () => {
|
|
51
51
|
expect(repairRetiredFireworksMinimaxModelIdMigration.id).toBe(
|
|
52
52
|
"137-repair-retired-fireworks-minimax-model-id",
|
|
53
53
|
);
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
);
|
|
54
|
+
expect(
|
|
55
|
+
WORKSPACE_MIGRATIONS.some(
|
|
56
|
+
(m) => m.id === "137-repair-retired-fireworks-minimax-model-id",
|
|
57
|
+
),
|
|
58
|
+
).toBe(true);
|
|
59
59
|
});
|
|
60
60
|
|
|
61
61
|
test("repairs the retired ID in default, call sites, and profiles", () => {
|