@vellumai/assistant 0.8.9-dev.202606091853.fbaa2ae → 0.8.9-dev.202606091946.122706e
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 +14 -0
- package/package.json +1 -1
- package/scripts/sync-llm-catalog.ts +2 -0
- package/src/__tests__/agent-loop-exit-reason.test.ts +9 -6
- package/src/__tests__/build-persisted-content.test.ts +75 -1
- package/src/__tests__/config-managed-gemini-defaults.test.ts +2 -46
- package/src/__tests__/conversation-runtime-assembly.test.ts +7 -3
- package/src/__tests__/credential-execution-feature-gates.test.ts +5 -4
- package/src/__tests__/embedding-managed-proxy-selection.test.ts +6 -40
- package/src/__tests__/llm-catalog-parity.test.ts +4 -0
- package/src/__tests__/persistence-secret-redaction.test.ts +78 -0
- package/src/__tests__/plugin-bootstrap.test.ts +3 -15
- package/src/__tests__/plugin-types.test.ts +0 -8
- package/src/__tests__/retry-thinking-adaptive-only.test.ts +245 -0
- package/src/__tests__/server-history-render.test.ts +39 -0
- package/src/__tests__/skills.test.ts +123 -0
- package/src/__tests__/tool-start-timestamp.test.ts +218 -0
- package/src/agent/loop.ts +9 -4
- package/src/api/events/tool-result.ts +9 -0
- package/src/api/events/tool-use-start.ts +7 -0
- package/src/api/responses/conversation-message.ts +83 -13
- package/src/config/feature-flag-registry.json +0 -40
- package/src/config/loader.ts +5 -22
- package/src/config/skills.ts +98 -47
- package/src/daemon/conversation-agent-loop-handlers.ts +102 -6
- package/src/daemon/external-plugins-bootstrap.ts +15 -59
- package/src/daemon/handlers/shared.ts +9 -1
- package/src/daemon/handlers/skills.ts +5 -2
- package/src/daemon/message-types/skills.ts +4 -0
- package/src/memory/embedding-backend.ts +5 -22
- package/src/plugin-api/constants.ts +2 -0
- package/src/plugins/defaults/memory-retrieval/hooks/post-compact.ts +18 -10
- package/src/plugins/types.ts +5 -51
- package/src/providers/model-catalog.ts +23 -0
- package/src/providers/retry.ts +35 -7
- package/src/tools/skills/load.ts +11 -6
- package/src/__tests__/plugin-skill-contribution.test.ts +0 -356
- package/src/plugins/plugin-skill-contributions.ts +0 -292
package/openapi.yaml
CHANGED
|
@@ -15945,6 +15945,8 @@ paths:
|
|
|
15945
15945
|
- assistant
|
|
15946
15946
|
content:
|
|
15947
15947
|
type: string
|
|
15948
|
+
deprecated: true
|
|
15949
|
+
description: "Deprecated: superseded by contentBlocks. Flat plain-text body (joined text segments)."
|
|
15948
15950
|
timestamp:
|
|
15949
15951
|
type: string
|
|
15950
15952
|
attachments:
|
|
@@ -16249,6 +16251,8 @@ paths:
|
|
|
16249
16251
|
- name
|
|
16250
16252
|
- input
|
|
16251
16253
|
additionalProperties: false
|
|
16254
|
+
deprecated: true
|
|
16255
|
+
description: "Deprecated: superseded by contentBlocks (the tool_use variant). Flat list of tool calls."
|
|
16252
16256
|
surfaces:
|
|
16253
16257
|
type: array
|
|
16254
16258
|
items:
|
|
@@ -16300,18 +16304,28 @@ paths:
|
|
|
16300
16304
|
- surfaceType
|
|
16301
16305
|
- data
|
|
16302
16306
|
additionalProperties: false
|
|
16307
|
+
deprecated: true
|
|
16308
|
+
description: "Deprecated: superseded by contentBlocks (the surface variant). Flat list of surfaces."
|
|
16303
16309
|
textSegments:
|
|
16304
16310
|
type: array
|
|
16305
16311
|
items:
|
|
16306
16312
|
type: string
|
|
16313
|
+
deprecated: true
|
|
16314
|
+
description: "Deprecated: superseded by contentBlocks. Text segments split by tool-call boundaries."
|
|
16307
16315
|
thinkingSegments:
|
|
16308
16316
|
type: array
|
|
16309
16317
|
items:
|
|
16310
16318
|
type: string
|
|
16319
|
+
deprecated: true
|
|
16320
|
+
description: "Deprecated: superseded by contentBlocks. Reasoning text extracted from thinking blocks."
|
|
16311
16321
|
contentOrder:
|
|
16312
16322
|
type: array
|
|
16313
16323
|
items:
|
|
16314
16324
|
type: string
|
|
16325
|
+
deprecated: true
|
|
16326
|
+
description:
|
|
16327
|
+
'Deprecated: superseded by contentBlocks. Positional "<type>:<index>" content ordering (e.g. "text:0",
|
|
16328
|
+
"thinking:1").'
|
|
16315
16329
|
contentBlocks:
|
|
16316
16330
|
type: array
|
|
16317
16331
|
items:
|
package/package.json
CHANGED
|
@@ -74,6 +74,8 @@ function projectModel(model: CatalogModel): Record<string, unknown> {
|
|
|
74
74
|
projected.longContextMode = model.longContextMode;
|
|
75
75
|
if (model.supportsThinking !== undefined)
|
|
76
76
|
projected.supportsThinking = model.supportsThinking;
|
|
77
|
+
if (model.adaptiveThinkingOnly !== undefined)
|
|
78
|
+
projected.adaptiveThinkingOnly = model.adaptiveThinkingOnly;
|
|
77
79
|
if (model.supportsCaching !== undefined)
|
|
78
80
|
projected.supportsCaching = model.supportsCaching;
|
|
79
81
|
if (model.supportsVision !== undefined)
|
|
@@ -39,18 +39,21 @@ import type {
|
|
|
39
39
|
|
|
40
40
|
// The agent loop invokes the default post-compaction re-injection hook directly
|
|
41
41
|
// when it compacts in place. Stub it so these unit tests can drive the
|
|
42
|
-
// re-injection result without the daemon-level injector chain.
|
|
43
|
-
//
|
|
44
|
-
// the
|
|
42
|
+
// re-injection result without the daemon-level injector chain. The hook writes
|
|
43
|
+
// the re-injected history back onto the context; tests assign `postCompactImpl`
|
|
44
|
+
// to observe the call or force a failure; when unset the hook leaves the
|
|
45
|
+
// history it was handed untouched.
|
|
45
46
|
let postCompactImpl:
|
|
46
47
|
| ((input: PostCompactContext) => Promise<Message[]>)
|
|
47
48
|
| null = null;
|
|
48
49
|
mock.module(
|
|
49
50
|
"../plugins/defaults/memory-retrieval/hooks/post-compact.js",
|
|
50
51
|
() => ({
|
|
51
|
-
default: async (input: PostCompactContext) =>
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
default: async (input: PostCompactContext): Promise<void> => {
|
|
53
|
+
input.history = postCompactImpl
|
|
54
|
+
? await postCompactImpl(input)
|
|
55
|
+
: input.history;
|
|
56
|
+
},
|
|
54
57
|
}),
|
|
55
58
|
);
|
|
56
59
|
|
|
@@ -54,7 +54,10 @@ mock.module("../memory/llm-request-log-store.js", () => ({
|
|
|
54
54
|
}));
|
|
55
55
|
|
|
56
56
|
// ── Imports (after mocks) ─────────────────────────────────────────────────────
|
|
57
|
-
import {
|
|
57
|
+
import {
|
|
58
|
+
buildPersistedAssistantContent,
|
|
59
|
+
stampThinkingTiming,
|
|
60
|
+
} from "../daemon/conversation-agent-loop-handlers.js";
|
|
58
61
|
import type { ToolActivityMetadata } from "../daemon/message-types/web-activity.js";
|
|
59
62
|
import type { ContentBlock } from "../providers/types.js";
|
|
60
63
|
|
|
@@ -182,3 +185,74 @@ describe("buildPersistedAssistantContent — native activityMetadata", () => {
|
|
|
182
185
|
expect(block._activityMetadata).toBeUndefined();
|
|
183
186
|
});
|
|
184
187
|
});
|
|
188
|
+
|
|
189
|
+
describe("stampThinkingTiming", () => {
|
|
190
|
+
test("stamps internal timing onto thinking blocks by position", () => {
|
|
191
|
+
// GIVEN a turn that interleaves text and two thinking blocks AND the
|
|
192
|
+
// per-block timing captured while streaming (one entry per thinking block,
|
|
193
|
+
// in stream order)
|
|
194
|
+
const content = [
|
|
195
|
+
{ type: "thinking", thinking: "first", signature: "s1" },
|
|
196
|
+
{ type: "text", text: "answer" },
|
|
197
|
+
{ type: "thinking", thinking: "second", signature: "s2" },
|
|
198
|
+
] as unknown as ContentBlock[];
|
|
199
|
+
const timings = [
|
|
200
|
+
{ startedAt: 100, completedAt: 250 },
|
|
201
|
+
{ startedAt: 400, completedAt: 480 },
|
|
202
|
+
];
|
|
203
|
+
|
|
204
|
+
// WHEN the content is stamped before persistence
|
|
205
|
+
const stamped = stampThinkingTiming(content, timings) as unknown as Array<
|
|
206
|
+
Record<string, unknown>
|
|
207
|
+
>;
|
|
208
|
+
|
|
209
|
+
// THEN each thinking block carries the `_`-prefixed timing for its position
|
|
210
|
+
expect(stamped[0]).toMatchObject({
|
|
211
|
+
type: "thinking",
|
|
212
|
+
_startedAt: 100,
|
|
213
|
+
_completedAt: 250,
|
|
214
|
+
});
|
|
215
|
+
expect(stamped[2]).toMatchObject({
|
|
216
|
+
type: "thinking",
|
|
217
|
+
_startedAt: 400,
|
|
218
|
+
_completedAt: 480,
|
|
219
|
+
});
|
|
220
|
+
// AND the interleaved text block is left untouched
|
|
221
|
+
expect(stamped[1]).toEqual({ type: "text", text: "answer" });
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("leaves thinking blocks unstamped when no timing was captured", () => {
|
|
225
|
+
// GIVEN thinking content but an empty timing list (thinking streaming was
|
|
226
|
+
// disabled, so no per-block timing was recorded this turn)
|
|
227
|
+
const content = [
|
|
228
|
+
{ type: "thinking", thinking: "first", signature: "s1" },
|
|
229
|
+
] as unknown as ContentBlock[];
|
|
230
|
+
|
|
231
|
+
// WHEN the content is stamped with no timing
|
|
232
|
+
const stamped = stampThinkingTiming(content, []);
|
|
233
|
+
|
|
234
|
+
// THEN the original content is returned unchanged so the UI hides duration,
|
|
235
|
+
// exactly as a tool call with no timing
|
|
236
|
+
expect(stamped).toBe(content);
|
|
237
|
+
expect(stamped[0]).not.toHaveProperty("_startedAt");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("stamps only the thinking blocks that have a matching timing entry", () => {
|
|
241
|
+
// GIVEN two thinking blocks but only one captured timing entry (e.g. the
|
|
242
|
+
// second block opened after the timing array was already finalized)
|
|
243
|
+
const content = [
|
|
244
|
+
{ type: "thinking", thinking: "first", signature: "s1" },
|
|
245
|
+
{ type: "thinking", thinking: "second", signature: "s2" },
|
|
246
|
+
] as unknown as ContentBlock[];
|
|
247
|
+
const timings = [{ startedAt: 100, completedAt: 250 }];
|
|
248
|
+
|
|
249
|
+
// WHEN the content is stamped
|
|
250
|
+
const stamped = stampThinkingTiming(content, timings) as unknown as Array<
|
|
251
|
+
Record<string, unknown>
|
|
252
|
+
>;
|
|
253
|
+
|
|
254
|
+
// THEN the first block is stamped and the unmatched second block is left as-is
|
|
255
|
+
expect(stamped[0]).toMatchObject({ _startedAt: 100, _completedAt: 250 });
|
|
256
|
+
expect(stamped[1]).not.toHaveProperty("_startedAt");
|
|
257
|
+
});
|
|
258
|
+
});
|
|
@@ -57,17 +57,8 @@ mock.module("../util/logger.js", () => ({
|
|
|
57
57
|
getLogger: () => makeLoggerStub(),
|
|
58
58
|
}));
|
|
59
59
|
|
|
60
|
-
// ---------------------------------------------------------------------------
|
|
61
|
-
// Feature flag mock — controls whether managed-gemini-embeddings-enabled is on
|
|
62
|
-
// ---------------------------------------------------------------------------
|
|
63
|
-
|
|
64
|
-
let featureFlagEnabled = false;
|
|
65
|
-
|
|
66
60
|
mock.module("../config/assistant-feature-flags.js", () => ({
|
|
67
|
-
isAssistantFeatureFlagEnabled: (
|
|
68
|
-
if (key === "managed-gemini-embeddings-enabled") return featureFlagEnabled;
|
|
69
|
-
return true;
|
|
70
|
-
},
|
|
61
|
+
isAssistantFeatureFlagEnabled: () => true,
|
|
71
62
|
clearFeatureFlagOverridesCache: () => {},
|
|
72
63
|
initFeatureFlagOverrides: async () => {},
|
|
73
64
|
getAssistantFeatureFlagDefaults: () => ({}),
|
|
@@ -119,8 +110,6 @@ describe("managed Gemini embedding defaults (via loadConfig)", () => {
|
|
|
119
110
|
setStorePathForTesting(join(WORKSPACE_DIR, "keys.enc"));
|
|
120
111
|
invalidateConfigCache();
|
|
121
112
|
|
|
122
|
-
// Reset mock state
|
|
123
|
-
featureFlagEnabled = false;
|
|
124
113
|
originalIsPlatform = process.env.IS_PLATFORM;
|
|
125
114
|
delete process.env.IS_PLATFORM;
|
|
126
115
|
});
|
|
@@ -137,10 +126,9 @@ describe("managed Gemini embedding defaults (via loadConfig)", () => {
|
|
|
137
126
|
}
|
|
138
127
|
});
|
|
139
128
|
|
|
140
|
-
test("applies managed Gemini defaults when
|
|
129
|
+
test("applies managed Gemini defaults when IS_PLATFORM + provider auto", () => {
|
|
141
130
|
writeConfig({});
|
|
142
131
|
|
|
143
|
-
featureFlagEnabled = true;
|
|
144
132
|
process.env.IS_PLATFORM = "true";
|
|
145
133
|
|
|
146
134
|
const config = loadConfig();
|
|
@@ -162,22 +150,9 @@ describe("managed Gemini embedding defaults (via loadConfig)", () => {
|
|
|
162
150
|
expect(qdrantRaw.vectorSize).toBe(3072);
|
|
163
151
|
});
|
|
164
152
|
|
|
165
|
-
test("does NOT apply when feature flag is OFF", () => {
|
|
166
|
-
writeConfig({});
|
|
167
|
-
|
|
168
|
-
featureFlagEnabled = false;
|
|
169
|
-
process.env.IS_PLATFORM = "true";
|
|
170
|
-
|
|
171
|
-
const config = loadConfig();
|
|
172
|
-
|
|
173
|
-
expect(config.memory.embeddings.provider).toBe("auto");
|
|
174
|
-
expect(config.memory.qdrant.vectorSize).toBe(384);
|
|
175
|
-
});
|
|
176
|
-
|
|
177
153
|
test("does NOT apply when IS_PLATFORM is not set", () => {
|
|
178
154
|
writeConfig({});
|
|
179
155
|
|
|
180
|
-
featureFlagEnabled = true;
|
|
181
156
|
delete process.env.IS_PLATFORM;
|
|
182
157
|
|
|
183
158
|
const config = loadConfig();
|
|
@@ -191,7 +166,6 @@ describe("managed Gemini embedding defaults (via loadConfig)", () => {
|
|
|
191
166
|
memory: { embeddings: { provider: "local" } },
|
|
192
167
|
});
|
|
193
168
|
|
|
194
|
-
featureFlagEnabled = true;
|
|
195
169
|
process.env.IS_PLATFORM = "true";
|
|
196
170
|
|
|
197
171
|
const config = loadConfig();
|
|
@@ -204,7 +178,6 @@ describe("managed Gemini embedding defaults (via loadConfig)", () => {
|
|
|
204
178
|
memory: { embeddings: { provider: "openai" } },
|
|
205
179
|
});
|
|
206
180
|
|
|
207
|
-
featureFlagEnabled = true;
|
|
208
181
|
process.env.IS_PLATFORM = "true";
|
|
209
182
|
|
|
210
183
|
const config = loadConfig();
|
|
@@ -219,7 +192,6 @@ describe("managed Gemini embedding defaults (via loadConfig)", () => {
|
|
|
219
192
|
},
|
|
220
193
|
});
|
|
221
194
|
|
|
222
|
-
featureFlagEnabled = true;
|
|
223
195
|
process.env.IS_PLATFORM = "true";
|
|
224
196
|
|
|
225
197
|
const config = loadConfig();
|
|
@@ -235,7 +207,6 @@ describe("managed Gemini embedding defaults (via loadConfig)", () => {
|
|
|
235
207
|
memory: { embeddings: { provider: "ollama" } },
|
|
236
208
|
});
|
|
237
209
|
|
|
238
|
-
featureFlagEnabled = true;
|
|
239
210
|
process.env.IS_PLATFORM = "true";
|
|
240
211
|
|
|
241
212
|
const config = loadConfig();
|
|
@@ -245,7 +216,6 @@ describe("managed Gemini embedding defaults (via loadConfig)", () => {
|
|
|
245
216
|
test("is idempotent — second loadConfig is a no-op after migration", () => {
|
|
246
217
|
writeConfig({});
|
|
247
218
|
|
|
248
|
-
featureFlagEnabled = true;
|
|
249
219
|
process.env.IS_PLATFORM = "true";
|
|
250
220
|
|
|
251
221
|
const config = loadConfig();
|
|
@@ -274,7 +244,6 @@ describe("managed Gemini embedding defaults (via loadConfig)", () => {
|
|
|
274
244
|
},
|
|
275
245
|
});
|
|
276
246
|
|
|
277
|
-
featureFlagEnabled = true;
|
|
278
247
|
process.env.IS_PLATFORM = "true";
|
|
279
248
|
|
|
280
249
|
const config = loadConfig();
|
|
@@ -295,22 +264,9 @@ describe("managed Gemini embedding defaults (via loadConfig)", () => {
|
|
|
295
264
|
expect(qdrantRaw.onDisk).toBe(false);
|
|
296
265
|
});
|
|
297
266
|
|
|
298
|
-
test("does NOT apply when both FF off and IS_PLATFORM not set", () => {
|
|
299
|
-
writeConfig({});
|
|
300
|
-
|
|
301
|
-
featureFlagEnabled = false;
|
|
302
|
-
delete process.env.IS_PLATFORM;
|
|
303
|
-
|
|
304
|
-
const config = loadConfig();
|
|
305
|
-
|
|
306
|
-
expect(config.memory.embeddings.provider).toBe("auto");
|
|
307
|
-
expect(config.memory.qdrant.vectorSize).toBe(384);
|
|
308
|
-
});
|
|
309
|
-
|
|
310
267
|
test("applies when IS_PLATFORM is '1'", () => {
|
|
311
268
|
writeConfig({});
|
|
312
269
|
|
|
313
|
-
featureFlagEnabled = true;
|
|
314
270
|
process.env.IS_PLATFORM = "1";
|
|
315
271
|
|
|
316
272
|
const config = loadConfig();
|
|
@@ -107,7 +107,9 @@ import {
|
|
|
107
107
|
writeSlackMetadata,
|
|
108
108
|
} from "../messaging/providers/slack/message-metadata.js";
|
|
109
109
|
import { parentAlias } from "../messaging/providers/slack/render-transcript.js";
|
|
110
|
-
import postCompact
|
|
110
|
+
import postCompact, {
|
|
111
|
+
type PostCompactContext,
|
|
112
|
+
} from "../plugins/defaults/memory-retrieval/hooks/post-compact.js";
|
|
111
113
|
import {
|
|
112
114
|
buildUnifiedTurnContextBlock,
|
|
113
115
|
type UnifiedTurnContextOptions,
|
|
@@ -1029,7 +1031,7 @@ describe("applyRuntimeInjections — injection mode", () => {
|
|
|
1029
1031
|
// only resolve when `applyRuntimeInjections` finds it by conversation id
|
|
1030
1032
|
// AND the agent loop hands the post-compaction hook the turn-identity fields
|
|
1031
1033
|
// flat (`requestId`, `conversationId`, `trust`)
|
|
1032
|
-
const
|
|
1034
|
+
const postCompactCtx: PostCompactContext = {
|
|
1033
1035
|
history: baseMessages,
|
|
1034
1036
|
requestId: "reinject-req",
|
|
1035
1037
|
conversationId: "injection-mode-conv",
|
|
@@ -1038,7 +1040,9 @@ describe("applyRuntimeInjections — injection mode", () => {
|
|
|
1038
1040
|
mode: "full",
|
|
1039
1041
|
modelProfile: null,
|
|
1040
1042
|
actorContext: null,
|
|
1041
|
-
}
|
|
1043
|
+
};
|
|
1044
|
+
await postCompact(postCompactCtx);
|
|
1045
|
+
const result = postCompactCtx.history;
|
|
1042
1046
|
const allText = result[0].content
|
|
1043
1047
|
.filter((b): b is { type: "text"; text: string } => b.type === "text")
|
|
1044
1048
|
.map((b) => b.text)
|
|
@@ -165,10 +165,11 @@ describe("CES flags do not affect unrelated flags", () => {
|
|
|
165
165
|
setOverridesForTesting(overrides);
|
|
166
166
|
const config = makeConfig();
|
|
167
167
|
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
168
|
+
// Flags with defaultEnabled: true in the registry should stay true
|
|
169
|
+
// regardless of CES overrides.
|
|
170
|
+
expect(
|
|
171
|
+
isAssistantFeatureFlagEnabled("platform-features-in-local-mode", config),
|
|
172
|
+
).toBe(true);
|
|
172
173
|
});
|
|
173
174
|
|
|
174
175
|
test("enabling all CES flags does not change unrelated fail-closed flags", () => {
|
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
* Tests for managed proxy Gemini embedding backend selection.
|
|
3
3
|
*
|
|
4
4
|
* Verifies that selectEmbeddingBackend correctly routes through the
|
|
5
|
-
* managed proxy when
|
|
6
|
-
* prerequisites are satisfied.
|
|
5
|
+
* managed proxy when managed proxy prerequisites are satisfied.
|
|
7
6
|
*/
|
|
8
7
|
|
|
9
8
|
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
@@ -42,13 +41,9 @@ mock.module("../security/secure-keys.js", () => ({
|
|
|
42
41
|
},
|
|
43
42
|
}));
|
|
44
43
|
|
|
45
|
-
// Feature flag mock
|
|
46
|
-
const mockFeatureFlags: Record<string, boolean> = {};
|
|
47
|
-
|
|
44
|
+
// Feature flag mock — always returns true (flag is GA'ed)
|
|
48
45
|
mock.module("../config/assistant-feature-flags.js", () => ({
|
|
49
|
-
isAssistantFeatureFlagEnabled: (
|
|
50
|
-
return mockFeatureFlags[key] ?? false;
|
|
51
|
-
},
|
|
46
|
+
isAssistantFeatureFlagEnabled: () => true,
|
|
52
47
|
}));
|
|
53
48
|
|
|
54
49
|
import type { AssistantConfig } from "../config/types.js";
|
|
@@ -75,14 +70,6 @@ function disableManagedProxy() {
|
|
|
75
70
|
mockAssistantApiKey = null;
|
|
76
71
|
}
|
|
77
72
|
|
|
78
|
-
function enableFlag() {
|
|
79
|
-
mockFeatureFlags["managed-gemini-embeddings-enabled"] = true;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function disableFlag() {
|
|
83
|
-
mockFeatureFlags["managed-gemini-embeddings-enabled"] = false;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
73
|
function makeConfig(
|
|
87
74
|
overrides: {
|
|
88
75
|
provider?: string;
|
|
@@ -116,7 +103,6 @@ function makeConfig(
|
|
|
116
103
|
|
|
117
104
|
beforeEach(() => {
|
|
118
105
|
disableManagedProxy();
|
|
119
|
-
disableFlag();
|
|
120
106
|
mockProviderKeys = {};
|
|
121
107
|
clearEmbeddingBackendCache();
|
|
122
108
|
});
|
|
@@ -126,9 +112,8 @@ afterEach(() => {
|
|
|
126
112
|
});
|
|
127
113
|
|
|
128
114
|
describe("managed proxy Gemini embedding selection", () => {
|
|
129
|
-
test("selects managed proxy Gemini when
|
|
115
|
+
test("selects managed proxy Gemini when proxy context available", async () => {
|
|
130
116
|
enableManagedProxy();
|
|
131
|
-
enableFlag();
|
|
132
117
|
const config = makeConfig();
|
|
133
118
|
|
|
134
119
|
const { backend, reason } = await selectEmbeddingBackend(config);
|
|
@@ -141,7 +126,6 @@ describe("managed proxy Gemini embedding selection", () => {
|
|
|
141
126
|
|
|
142
127
|
test("managed proxy backend uses default 3072 dimensions when geminiDimensions not set", async () => {
|
|
143
128
|
enableManagedProxy();
|
|
144
|
-
enableFlag();
|
|
145
129
|
const config = makeConfig();
|
|
146
130
|
|
|
147
131
|
const { backend } = await selectEmbeddingBackend(config);
|
|
@@ -155,7 +139,6 @@ describe("managed proxy Gemini embedding selection", () => {
|
|
|
155
139
|
|
|
156
140
|
test("managed proxy backend uses explicit geminiDimensions when set", async () => {
|
|
157
141
|
enableManagedProxy();
|
|
158
|
-
enableFlag();
|
|
159
142
|
const config = makeConfig({ geminiDimensions: 768 });
|
|
160
143
|
|
|
161
144
|
const { backend } = await selectEmbeddingBackend(config);
|
|
@@ -168,7 +151,6 @@ describe("managed proxy Gemini embedding selection", () => {
|
|
|
168
151
|
|
|
169
152
|
test("managed proxy backend uses managedBaseUrl (not direct Google API)", async () => {
|
|
170
153
|
enableManagedProxy();
|
|
171
|
-
enableFlag();
|
|
172
154
|
const config = makeConfig();
|
|
173
155
|
|
|
174
156
|
const { backend } = await selectEmbeddingBackend(config);
|
|
@@ -179,32 +161,19 @@ describe("managed proxy Gemini embedding selection", () => {
|
|
|
179
161
|
expect(managedBaseUrl).toBe(`${PLATFORM_BASE}/v1/runtime-proxy/gemini`);
|
|
180
162
|
});
|
|
181
163
|
|
|
182
|
-
test("falls back to local when flag is disabled (no managed proxy)", async () => {
|
|
183
|
-
enableManagedProxy();
|
|
184
|
-
disableFlag();
|
|
185
|
-
const config = makeConfig();
|
|
186
|
-
|
|
187
|
-
const { backend } = await selectEmbeddingBackend(config);
|
|
188
|
-
|
|
189
|
-
// With auto and no provider keys, falls through to local
|
|
190
|
-
expect(backend).not.toBeNull();
|
|
191
|
-
expect(backend!.provider).toBe("local");
|
|
192
|
-
});
|
|
193
|
-
|
|
194
164
|
test("falls back to local when managed proxy context unavailable", async () => {
|
|
195
165
|
disableManagedProxy();
|
|
196
|
-
enableFlag();
|
|
197
166
|
const config = makeConfig();
|
|
198
167
|
|
|
199
168
|
const { backend } = await selectEmbeddingBackend(config);
|
|
200
169
|
|
|
170
|
+
// With auto and no provider keys or proxy, falls through to local
|
|
201
171
|
expect(backend).not.toBeNull();
|
|
202
172
|
expect(backend!.provider).toBe("local");
|
|
203
173
|
});
|
|
204
174
|
|
|
205
175
|
test("selects managed proxy when provider is explicitly gemini", async () => {
|
|
206
176
|
enableManagedProxy();
|
|
207
|
-
enableFlag();
|
|
208
177
|
const config = makeConfig({ provider: "gemini" });
|
|
209
178
|
|
|
210
179
|
const { backend } = await selectEmbeddingBackend(config);
|
|
@@ -217,7 +186,6 @@ describe("managed proxy Gemini embedding selection", () => {
|
|
|
217
186
|
|
|
218
187
|
test("does not use managed proxy when provider is explicitly local", async () => {
|
|
219
188
|
enableManagedProxy();
|
|
220
|
-
enableFlag();
|
|
221
189
|
const config = makeConfig({ provider: "local" });
|
|
222
190
|
|
|
223
191
|
const { backend } = await selectEmbeddingBackend(config);
|
|
@@ -228,7 +196,6 @@ describe("managed proxy Gemini embedding selection", () => {
|
|
|
228
196
|
|
|
229
197
|
test("does not use managed proxy when provider is explicitly openai", async () => {
|
|
230
198
|
enableManagedProxy();
|
|
231
|
-
enableFlag();
|
|
232
199
|
mockProviderKeys[credentialKey("openai", "api_key")] = "user-openai-key";
|
|
233
200
|
const config = makeConfig({ provider: "openai" });
|
|
234
201
|
|
|
@@ -238,9 +205,8 @@ describe("managed proxy Gemini embedding selection", () => {
|
|
|
238
205
|
expect(backend!.provider).toBe("openai");
|
|
239
206
|
});
|
|
240
207
|
|
|
241
|
-
test("direct Gemini key still works
|
|
208
|
+
test("direct Gemini key still works without managed proxy", async () => {
|
|
242
209
|
disableManagedProxy();
|
|
243
|
-
disableFlag();
|
|
244
210
|
mockProviderKeys[credentialKey("gemini", "api_key")] = "user-gemini-key";
|
|
245
211
|
const config = makeConfig({ provider: "gemini" });
|
|
246
212
|
|
|
@@ -53,6 +53,7 @@ interface ClientCatalogModel {
|
|
|
53
53
|
longContextPricingThresholdTokens?: number;
|
|
54
54
|
longContextMode?: "native-model" | "provider-request-option" | "unsupported";
|
|
55
55
|
supportsThinking?: boolean;
|
|
56
|
+
adaptiveThinkingOnly?: boolean;
|
|
56
57
|
supportsCaching?: boolean;
|
|
57
58
|
supportsVision?: boolean;
|
|
58
59
|
supportsToolUse?: boolean;
|
|
@@ -194,6 +195,9 @@ describe("LLM catalog parity: daemon vs client", () => {
|
|
|
194
195
|
);
|
|
195
196
|
expect(clientModel.longContextMode).toBe(daemonModel.longContextMode);
|
|
196
197
|
expect(clientModel.supportsThinking).toBe(daemonModel.supportsThinking);
|
|
198
|
+
expect(clientModel.adaptiveThinkingOnly).toBe(
|
|
199
|
+
daemonModel.adaptiveThinkingOnly,
|
|
200
|
+
);
|
|
197
201
|
expect(clientModel.supportsCaching).toBe(daemonModel.supportsCaching);
|
|
198
202
|
expect(clientModel.supportsVision).toBe(daemonModel.supportsVision);
|
|
199
203
|
expect(clientModel.supportsToolUse).toBe(daemonModel.supportsToolUse);
|
|
@@ -370,3 +370,81 @@ describe("persistence-layer secret redaction", () => {
|
|
|
370
370
|
expect(textBlock?.text).toBe(text);
|
|
371
371
|
});
|
|
372
372
|
});
|
|
373
|
+
|
|
374
|
+
describe("thinking timing persistence", () => {
|
|
375
|
+
let state: EventHandlerState;
|
|
376
|
+
|
|
377
|
+
beforeEach(() => {
|
|
378
|
+
addMessageCalls.length = 0;
|
|
379
|
+
state = createEventHandlerState();
|
|
380
|
+
state.turnStartedAt = 1_700_000_000_000;
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
afterEach(() => {
|
|
384
|
+
addMessageCalls.length = 0;
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
function makeThinkingCompleteEvent(): Extract<
|
|
388
|
+
AgentEvent,
|
|
389
|
+
{ type: "message_complete" }
|
|
390
|
+
> {
|
|
391
|
+
return {
|
|
392
|
+
type: "message_complete",
|
|
393
|
+
message: {
|
|
394
|
+
role: "assistant",
|
|
395
|
+
content: [
|
|
396
|
+
{ type: "thinking", thinking: "let me reason", signature: "sig" },
|
|
397
|
+
{ type: "text", text: "the answer" },
|
|
398
|
+
],
|
|
399
|
+
},
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
test("stamps per-block thinking timing onto the persisted thinking block", async () => {
|
|
404
|
+
// GIVEN a turn whose row was reserved at llm_call_started
|
|
405
|
+
await handleLlmCallStarted(state, makeDeps());
|
|
406
|
+
|
|
407
|
+
// AND streaming then captured timing for one thinking block (startedAt when
|
|
408
|
+
// it opened, completedAt at its last reasoning delta). The reserve resets
|
|
409
|
+
// the accumulator, so deltas — and thus the timing — land after it.
|
|
410
|
+
state.currentThinkingTimestamps = [{ startedAt: 1000, completedAt: 1750 }];
|
|
411
|
+
|
|
412
|
+
// WHEN the message completes and content is persisted
|
|
413
|
+
await handleMessageComplete(state, makeDeps(), makeThinkingCompleteEvent());
|
|
414
|
+
|
|
415
|
+
// THEN the persisted thinking block carries the internal `_`-prefixed
|
|
416
|
+
// timing so a history reload can surface the duration + "Started at" hover
|
|
417
|
+
const persisted = lastPersisted("assistant");
|
|
418
|
+
const blocks = JSON.parse(persisted.content) as Array<{
|
|
419
|
+
type: string;
|
|
420
|
+
_startedAt?: number;
|
|
421
|
+
_completedAt?: number;
|
|
422
|
+
}>;
|
|
423
|
+
const thinkingBlock = blocks.find((b) => b.type === "thinking");
|
|
424
|
+
expect(thinkingBlock?._startedAt).toBe(1000);
|
|
425
|
+
expect(thinkingBlock?._completedAt).toBe(1750);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
test("persists no thinking timing when none was captured this turn", async () => {
|
|
429
|
+
// GIVEN a turn that produced a thinking block but captured no timing
|
|
430
|
+
// (thinking streaming disabled, so the timing list stays empty)
|
|
431
|
+
expect(state.currentThinkingTimestamps).toEqual([]);
|
|
432
|
+
|
|
433
|
+
// WHEN the message completes and content is persisted
|
|
434
|
+
await handleLlmCallStarted(state, makeDeps());
|
|
435
|
+
await handleMessageComplete(state, makeDeps(), makeThinkingCompleteEvent());
|
|
436
|
+
|
|
437
|
+
// THEN the thinking block is persisted without timing, so the client hides
|
|
438
|
+
// the duration exactly as a tool call with no timing
|
|
439
|
+
const persisted = lastPersisted("assistant");
|
|
440
|
+
const blocks = JSON.parse(persisted.content) as Array<{
|
|
441
|
+
type: string;
|
|
442
|
+
_startedAt?: number;
|
|
443
|
+
_completedAt?: number;
|
|
444
|
+
}>;
|
|
445
|
+
const thinkingBlock = blocks.find((b) => b.type === "thinking");
|
|
446
|
+
expect(thinkingBlock).toBeDefined();
|
|
447
|
+
expect(thinkingBlock?._startedAt).toBeUndefined();
|
|
448
|
+
expect(thinkingBlock?._completedAt).toBeUndefined();
|
|
449
|
+
});
|
|
450
|
+
});
|
|
@@ -399,11 +399,11 @@ describe("plugin bootstrap", () => {
|
|
|
399
399
|
expect(initFired).toBe(true);
|
|
400
400
|
});
|
|
401
401
|
|
|
402
|
-
test("requiresFlag disabled: init does not fire and no tools/routes
|
|
402
|
+
test("requiresFlag disabled: init does not fire and no tools/routes are registered", async () => {
|
|
403
403
|
setOverridesForTesting({ "plugin-gated-disabled": false });
|
|
404
404
|
|
|
405
405
|
let initFired = false;
|
|
406
|
-
// Attach tool/route
|
|
406
|
+
// Attach tool/route contributions alongside init. If gating works,
|
|
407
407
|
// none of them should land in their respective registries.
|
|
408
408
|
const plugin = buildPlugin(
|
|
409
409
|
"gated-off",
|
|
@@ -430,24 +430,14 @@ describe("plugin bootstrap", () => {
|
|
|
430
430
|
handler: async () => new Response("ok"),
|
|
431
431
|
},
|
|
432
432
|
],
|
|
433
|
-
skills: [
|
|
434
|
-
{
|
|
435
|
-
id: "gated-off/skill",
|
|
436
|
-
name: "gated-off-skill",
|
|
437
|
-
description: "should not be catalogued",
|
|
438
|
-
body: "# unused",
|
|
439
|
-
},
|
|
440
|
-
],
|
|
441
433
|
},
|
|
442
434
|
{ requiresFlag: ["plugin-gated-disabled"] },
|
|
443
435
|
);
|
|
444
436
|
registerPlugin(plugin);
|
|
445
437
|
|
|
446
|
-
// Grab tool / route
|
|
438
|
+
// Grab tool / route introspection helpers lazily so the import
|
|
447
439
|
// side effect happens after `mock.module` has taken effect.
|
|
448
440
|
const { getTool } = await import("../tools/registry.js");
|
|
449
|
-
const { getPluginSkillRefCount } =
|
|
450
|
-
await import("../plugins/plugin-skill-contributions.js");
|
|
451
441
|
const { matchSkillRoute } =
|
|
452
442
|
await import("../runtime/skill-route-registry.js");
|
|
453
443
|
|
|
@@ -459,8 +449,6 @@ describe("plugin bootstrap", () => {
|
|
|
459
449
|
expect(getTool("gated-off-tool")).toBeUndefined();
|
|
460
450
|
// No route wired up — `matchSkillRoute` returns null when nothing matches.
|
|
461
451
|
expect(matchSkillRoute("/_plugin/gated-off/status", "GET")).toBeNull();
|
|
462
|
-
// No skill catalogued under this plugin's name — ref count stays 0.
|
|
463
|
-
expect(getPluginSkillRefCount("gated-off")).toBe(0);
|
|
464
452
|
});
|
|
465
453
|
|
|
466
454
|
test("requiresFlag absent: plugin activates unconditionally", async () => {
|
|
@@ -78,14 +78,6 @@ describe("plugin core types", () => {
|
|
|
78
78
|
handler: async () => new Response("ok", { status: 200 }),
|
|
79
79
|
},
|
|
80
80
|
],
|
|
81
|
-
skills: [
|
|
82
|
-
{
|
|
83
|
-
id: "sample-skill",
|
|
84
|
-
name: "Sample Skill",
|
|
85
|
-
description: "Demo plugin-contributed skill",
|
|
86
|
-
body: "## Sample\n\nPlugin-provided skill body.",
|
|
87
|
-
},
|
|
88
|
-
],
|
|
89
81
|
} satisfies Plugin;
|
|
90
82
|
|
|
91
83
|
// Minimal runtime check so the test body is non-empty.
|