@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,228 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import type { ProfileEntry } from "../../config/schemas/llm.js";
|
|
4
|
+
import type {
|
|
5
|
+
Message,
|
|
6
|
+
Provider,
|
|
7
|
+
ProviderResponse,
|
|
8
|
+
SendMessageOptions,
|
|
9
|
+
} from "../../providers/types.js";
|
|
10
|
+
import {
|
|
11
|
+
AUTO_PROFILE_FALLBACK,
|
|
12
|
+
autoProfileCandidates,
|
|
13
|
+
autoProfileFallback,
|
|
14
|
+
recentConversationForRouter,
|
|
15
|
+
routeAutoProfile,
|
|
16
|
+
} from "../auto-profile-router.js";
|
|
17
|
+
|
|
18
|
+
function text(role: "user" | "assistant", body: string): Message {
|
|
19
|
+
return { role, content: [{ type: "text", text: body }] };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function jevResponse(answers: Record<string, unknown>): ProviderResponse {
|
|
23
|
+
return {
|
|
24
|
+
content: [{ type: "text", text: JSON.stringify(answers) }],
|
|
25
|
+
model: "jev-latest",
|
|
26
|
+
usage: { inputTokens: 1, outputTokens: 1 },
|
|
27
|
+
stopReason: "end_turn",
|
|
28
|
+
rawResponse: { model: "jev-latest", answers },
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function fakeProvider(
|
|
33
|
+
respond: (
|
|
34
|
+
messages: Message[],
|
|
35
|
+
options?: SendMessageOptions,
|
|
36
|
+
) => Promise<ProviderResponse>,
|
|
37
|
+
): Provider {
|
|
38
|
+
return { name: "typesafe", sendMessage: respond } as Provider;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const managed = (label: string, status?: "disabled"): ProfileEntry => ({
|
|
42
|
+
source: "managed",
|
|
43
|
+
provider: "vellum",
|
|
44
|
+
model: "m",
|
|
45
|
+
label,
|
|
46
|
+
...(status ? { status } : {}),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const profiles: Record<string, ProfileEntry> = {
|
|
50
|
+
auto: managed("Auto"),
|
|
51
|
+
balanced: managed("Balanced"),
|
|
52
|
+
"quality-optimized": managed("Quality"),
|
|
53
|
+
"cost-optimized": managed("Budget"),
|
|
54
|
+
"latency-optimized": managed("Fast"),
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const base = {
|
|
58
|
+
conversationId: "conv-1",
|
|
59
|
+
history: [text("user", "hi"), text("assistant", "hello")],
|
|
60
|
+
userMessage: "Refactor the auth module and fix the race in token refresh",
|
|
61
|
+
profiles,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
describe("autoProfileCandidates", () => {
|
|
65
|
+
test("offers the default profiles that exist and are not disabled", () => {
|
|
66
|
+
expect(
|
|
67
|
+
autoProfileCandidates({
|
|
68
|
+
...profiles,
|
|
69
|
+
"cost-optimized": managed("Budget", "disabled"),
|
|
70
|
+
}),
|
|
71
|
+
).toEqual(["balanced", "quality-optimized", "latency-optimized"]);
|
|
72
|
+
expect(autoProfileCandidates({ balanced: managed("Balanced") })).toEqual([
|
|
73
|
+
"balanced",
|
|
74
|
+
]);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("recentConversationForRouter", () => {
|
|
79
|
+
test("drops injected blocks and the current message, keeps roles", () => {
|
|
80
|
+
const history: Message[] = [
|
|
81
|
+
text("user", "hey"),
|
|
82
|
+
{ role: "user", content: [{ type: "text", text: "<memory>x</memory>" }] },
|
|
83
|
+
text("assistant", "Hi there"),
|
|
84
|
+
text("user", "do it"),
|
|
85
|
+
];
|
|
86
|
+
expect(recentConversationForRouter(history, "do it")).toBe(
|
|
87
|
+
"user: hey\nassistant: Hi there",
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("routeAutoProfile", () => {
|
|
93
|
+
test("pins the turn to Jev's choice and sends the candidates as criteria", async () => {
|
|
94
|
+
let sent: unknown;
|
|
95
|
+
const route = await routeAutoProfile({
|
|
96
|
+
...base,
|
|
97
|
+
resolveProvider: async () =>
|
|
98
|
+
fakeProvider(async (messages) => {
|
|
99
|
+
const block = messages[0]?.content[0];
|
|
100
|
+
sent = JSON.parse(block?.type === "text" ? block.text : "{}");
|
|
101
|
+
return jevResponse({
|
|
102
|
+
profile: {
|
|
103
|
+
type: "choice",
|
|
104
|
+
choice: "quality-optimized",
|
|
105
|
+
confidence: 0.8,
|
|
106
|
+
probabilities: { "quality-optimized": 0.85, balanced: 0.15 },
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
}),
|
|
110
|
+
});
|
|
111
|
+
expect(route).toMatchObject({
|
|
112
|
+
profile: "quality-optimized",
|
|
113
|
+
outcome: "routed",
|
|
114
|
+
confidence: 0.8,
|
|
115
|
+
});
|
|
116
|
+
const request = sent as {
|
|
117
|
+
state: Record<string, string>;
|
|
118
|
+
questions: {
|
|
119
|
+
profile: { type: string; criteria: Record<string, string> };
|
|
120
|
+
};
|
|
121
|
+
};
|
|
122
|
+
expect(request.state.latest_user_message).toBe(base.userMessage);
|
|
123
|
+
expect(request.state.recent_conversation).toBe(
|
|
124
|
+
"user: hi\nassistant: hello",
|
|
125
|
+
);
|
|
126
|
+
expect(request.questions.profile.type).toBe("choice");
|
|
127
|
+
expect(Object.keys(request.questions.profile.criteria)).toEqual([
|
|
128
|
+
"balanced",
|
|
129
|
+
"quality-optimized",
|
|
130
|
+
"cost-optimized",
|
|
131
|
+
"latency-optimized",
|
|
132
|
+
]);
|
|
133
|
+
expect(
|
|
134
|
+
request.questions.profile.criteria.balanced.startsWith("Balanced: "),
|
|
135
|
+
).toBe(true);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("falls back to Balanced when the choice names no candidate", async () => {
|
|
139
|
+
const route = await routeAutoProfile({
|
|
140
|
+
...base,
|
|
141
|
+
resolveProvider: async () =>
|
|
142
|
+
fakeProvider(async () =>
|
|
143
|
+
jevResponse({ profile: { type: "choice", choice: "auto" } }),
|
|
144
|
+
),
|
|
145
|
+
});
|
|
146
|
+
expect(route).toMatchObject({
|
|
147
|
+
profile: AUTO_PROFILE_FALLBACK,
|
|
148
|
+
outcome: "error",
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("falls back to Balanced when no TypeSafe route resolves", async () => {
|
|
153
|
+
const route = await routeAutoProfile({
|
|
154
|
+
...base,
|
|
155
|
+
resolveProvider: async () => null,
|
|
156
|
+
});
|
|
157
|
+
expect(route).toEqual({
|
|
158
|
+
profile: AUTO_PROFILE_FALLBACK,
|
|
159
|
+
outcome: "unavailable",
|
|
160
|
+
latencyMs: expect.any(Number),
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("falls back to Balanced on timeout", async () => {
|
|
165
|
+
const route = await routeAutoProfile({
|
|
166
|
+
...base,
|
|
167
|
+
timeoutMs: 5,
|
|
168
|
+
resolveProvider: async () =>
|
|
169
|
+
fakeProvider(
|
|
170
|
+
(_messages, options) =>
|
|
171
|
+
new Promise((_resolve, reject) => {
|
|
172
|
+
options?.signal?.addEventListener("abort", () =>
|
|
173
|
+
reject(new Error("aborted")),
|
|
174
|
+
);
|
|
175
|
+
}),
|
|
176
|
+
),
|
|
177
|
+
});
|
|
178
|
+
expect(route.profile).toBe(AUTO_PROFILE_FALLBACK);
|
|
179
|
+
expect(route.outcome).toBe("timeout");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("does not ask when fewer than two candidates remain", async () => {
|
|
183
|
+
let asked = false;
|
|
184
|
+
const route = await routeAutoProfile({
|
|
185
|
+
...base,
|
|
186
|
+
profiles: { auto: managed("Auto"), balanced: managed("Balanced") },
|
|
187
|
+
resolveProvider: async () => {
|
|
188
|
+
asked = true;
|
|
189
|
+
return null;
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
expect(asked).toBe(false);
|
|
193
|
+
expect(route).toEqual({
|
|
194
|
+
profile: AUTO_PROFILE_FALLBACK,
|
|
195
|
+
outcome: "fallback",
|
|
196
|
+
latencyMs: 0,
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("falls back to an enabled candidate when Balanced is disabled", async () => {
|
|
201
|
+
const withoutBalanced = {
|
|
202
|
+
...profiles,
|
|
203
|
+
balanced: managed("Balanced", "disabled"),
|
|
204
|
+
};
|
|
205
|
+
expect(autoProfileFallback(autoProfileCandidates(withoutBalanced))).toBe(
|
|
206
|
+
"quality-optimized",
|
|
207
|
+
);
|
|
208
|
+
const unavailable = await routeAutoProfile({
|
|
209
|
+
...base,
|
|
210
|
+
profiles: withoutBalanced,
|
|
211
|
+
resolveProvider: async () => null,
|
|
212
|
+
});
|
|
213
|
+
expect(unavailable.profile).toBe("quality-optimized");
|
|
214
|
+
const single = await routeAutoProfile({
|
|
215
|
+
...base,
|
|
216
|
+
profiles: {
|
|
217
|
+
auto: managed("Auto"),
|
|
218
|
+
balanced: managed("Balanced", "disabled"),
|
|
219
|
+
"cost-optimized": managed("Budget"),
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
expect(single).toMatchObject({
|
|
223
|
+
profile: "cost-optimized",
|
|
224
|
+
outcome: "fallback",
|
|
225
|
+
});
|
|
226
|
+
expect(autoProfileFallback([])).toBe(AUTO_PROFILE_FALLBACK);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Picks which default profile answers a turn that runs on the Auto profile.
|
|
3
|
+
*
|
|
4
|
+
* The agent loop asks TypeSafe's System One model (Jev) one choice question
|
|
5
|
+
* over the user's latest message, with the recent conversation for context,
|
|
6
|
+
* and pins the turn to the answer. Every other outcome (no managed Jev route,
|
|
7
|
+
* provider error, timeout, an answer naming no candidate) pins the turn to
|
|
8
|
+
* Balanced, which is also the Auto profile's own body, so a turn the router
|
|
9
|
+
* cannot judge runs exactly as a Balanced turn would.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
AUTO_PROFILE_ROUTER_CALL_SITE,
|
|
14
|
+
DEFAULT_PROFILE_KEYS,
|
|
15
|
+
type DefaultProfileKey,
|
|
16
|
+
isDefaultProfileKey,
|
|
17
|
+
} from "../config/default-profile-names.js";
|
|
18
|
+
import type { ProfileEntry } from "../config/schemas/llm.js";
|
|
19
|
+
import { askTypesafe } from "../providers/jev/ask.js";
|
|
20
|
+
import type { ContentBlock, Message, Provider } from "../providers/types.js";
|
|
21
|
+
import { getLogger } from "../util/logger.js";
|
|
22
|
+
import { safeStringSlice } from "../util/unicode.js";
|
|
23
|
+
|
|
24
|
+
const log = getLogger("auto-profile-router");
|
|
25
|
+
|
|
26
|
+
export const AUTO_PROFILE_FALLBACK: DefaultProfileKey = "balanced";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The profile a turn runs on when the router cannot judge it: Balanced while
|
|
30
|
+
* it is a candidate, else the first candidate the user left enabled, else
|
|
31
|
+
* Balanced (the Auto body itself) when nothing is enabled.
|
|
32
|
+
*/
|
|
33
|
+
export function autoProfileFallback(
|
|
34
|
+
candidates: readonly DefaultProfileKey[],
|
|
35
|
+
): DefaultProfileKey {
|
|
36
|
+
if (candidates.includes(AUTO_PROFILE_FALLBACK)) {
|
|
37
|
+
return AUTO_PROFILE_FALLBACK;
|
|
38
|
+
}
|
|
39
|
+
return candidates[0] ?? AUTO_PROFILE_FALLBACK;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Budget before the turn proceeds on Balanced. The router runs before the
|
|
44
|
+
* turn's first provider call, so a Jev outage costs at most this much
|
|
45
|
+
* time-to-first-token per Auto turn and never blocks the reply. Jev's
|
|
46
|
+
* measured round trip sits well under it (the voice judge runs on 800ms).
|
|
47
|
+
*/
|
|
48
|
+
export const AUTO_PROFILE_ROUTER_TIMEOUT_MS = 1_000;
|
|
49
|
+
|
|
50
|
+
const HISTORY_TURNS = 6;
|
|
51
|
+
const HISTORY_TURN_MAX_CHARS = 600;
|
|
52
|
+
const MESSAGE_MAX_CHARS = 4_000;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* What each default profile is for, phrased so the options exclude each
|
|
56
|
+
* other. The picker copy on the profiles describes a tier to a person;
|
|
57
|
+
* these describe the kind of message that belongs on it.
|
|
58
|
+
*/
|
|
59
|
+
const ROUTING_CRITERIA: Record<DefaultProfileKey, string> = {
|
|
60
|
+
balanced:
|
|
61
|
+
"Everyday requests: questions, drafting, summaries, ordinary coding, tasks that use tools. The choice when nothing else clearly applies.",
|
|
62
|
+
"quality-optimized":
|
|
63
|
+
"Hard problems: multi-step reasoning, subtle debugging, large or delicate code changes, careful analysis, writing where quality matters most.",
|
|
64
|
+
"cost-optimized":
|
|
65
|
+
"Simple, mechanical work: short factual answers, reformatting, extraction, small edits where a modest model is plenty.",
|
|
66
|
+
"latency-optimized":
|
|
67
|
+
"Quick conversational replies: greetings, acknowledgements, one-line answers where speed matters more than depth.",
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const ROUTING_INSTRUCTIONS =
|
|
71
|
+
"Which model profile should answer the user's latest message? Judge the work the reply needs, using the recent conversation to interpret short follow-ups. Pick the cheapest profile that will still produce a good answer.";
|
|
72
|
+
|
|
73
|
+
export type AutoProfileRouteOutcome =
|
|
74
|
+
| "routed"
|
|
75
|
+
| "unavailable"
|
|
76
|
+
| "timeout"
|
|
77
|
+
| "error"
|
|
78
|
+
| "fallback";
|
|
79
|
+
|
|
80
|
+
export interface AutoProfileRoute {
|
|
81
|
+
profile: DefaultProfileKey;
|
|
82
|
+
outcome: AutoProfileRouteOutcome;
|
|
83
|
+
/** Jev's confidence in the pick, when it answered. */
|
|
84
|
+
confidence?: number;
|
|
85
|
+
latencyMs: number;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A message's spoken text: its text blocks minus injected `<...>` blocks. */
|
|
89
|
+
export function plainTextOf(content: readonly ContentBlock[]): string {
|
|
90
|
+
return content
|
|
91
|
+
.map((block) => (block.type === "text" ? block.text : ""))
|
|
92
|
+
.filter(
|
|
93
|
+
(text) => text.trim().length > 0 && !text.trimStart().startsWith("<"),
|
|
94
|
+
)
|
|
95
|
+
.join(" ")
|
|
96
|
+
.replace(/\s+/g, " ")
|
|
97
|
+
.trim();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The router's view of the conversation: the last few turns as plain text,
|
|
102
|
+
* without injected context blocks, tool traffic, or the current message
|
|
103
|
+
* (which the router receives on its own).
|
|
104
|
+
*/
|
|
105
|
+
export function recentConversationForRouter(
|
|
106
|
+
history: readonly Message[],
|
|
107
|
+
userMessage: string,
|
|
108
|
+
): string {
|
|
109
|
+
const lines: string[] = [];
|
|
110
|
+
for (const message of history) {
|
|
111
|
+
const text = plainTextOf(message.content);
|
|
112
|
+
if (text.length === 0) {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
lines.push(
|
|
116
|
+
`${message.role}: ${safeStringSlice(text, 0, HISTORY_TURN_MAX_CHARS)}`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
const current = `user: ${userMessage.replace(/\s+/g, " ").trim()}`;
|
|
120
|
+
if (lines.length > 0 && lines[lines.length - 1] === current) {
|
|
121
|
+
lines.pop();
|
|
122
|
+
}
|
|
123
|
+
return lines.slice(-HISTORY_TURNS).join("\n");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The default profiles the router may pick from: the ones the conversation
|
|
128
|
+
* view offers and the user has not disabled.
|
|
129
|
+
*/
|
|
130
|
+
export function autoProfileCandidates(
|
|
131
|
+
profiles: Readonly<Record<string, ProfileEntry>>,
|
|
132
|
+
): DefaultProfileKey[] {
|
|
133
|
+
return DEFAULT_PROFILE_KEYS.filter((key) => {
|
|
134
|
+
const entry = profiles[key];
|
|
135
|
+
return entry != null && entry.status !== "disabled";
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Route one turn. Never rejects: every failure returns the fallback profile.
|
|
141
|
+
*/
|
|
142
|
+
export async function routeAutoProfile(args: {
|
|
143
|
+
conversationId: string;
|
|
144
|
+
history: readonly Message[];
|
|
145
|
+
userMessage: string;
|
|
146
|
+
profiles: Readonly<Record<string, ProfileEntry>>;
|
|
147
|
+
signal?: AbortSignal;
|
|
148
|
+
timeoutMs?: number;
|
|
149
|
+
/** Provider resolver, injectable for tests. */
|
|
150
|
+
resolveProvider?: () => Promise<Provider | null>;
|
|
151
|
+
}): Promise<AutoProfileRoute> {
|
|
152
|
+
const userMessage = args.userMessage.trim();
|
|
153
|
+
const candidates = autoProfileCandidates(args.profiles);
|
|
154
|
+
const fallback = autoProfileFallback(candidates);
|
|
155
|
+
// A choice needs two options; with fewer there is nothing to route.
|
|
156
|
+
if (userMessage.length === 0 || candidates.length < 2) {
|
|
157
|
+
return { profile: fallback, outcome: "fallback", latencyMs: 0 };
|
|
158
|
+
}
|
|
159
|
+
const criteria = Object.fromEntries(
|
|
160
|
+
candidates.map((key) => [
|
|
161
|
+
key,
|
|
162
|
+
`${args.profiles[key]?.label ?? key}: ${ROUTING_CRITERIA[key]}`,
|
|
163
|
+
]),
|
|
164
|
+
);
|
|
165
|
+
const result = await askTypesafe({
|
|
166
|
+
callSite: AUTO_PROFILE_ROUTER_CALL_SITE,
|
|
167
|
+
conversationId: args.conversationId,
|
|
168
|
+
state: {
|
|
169
|
+
recent_conversation:
|
|
170
|
+
recentConversationForRouter(args.history, userMessage) ||
|
|
171
|
+
"(start of conversation)",
|
|
172
|
+
latest_user_message: safeStringSlice(userMessage, 0, MESSAGE_MAX_CHARS),
|
|
173
|
+
},
|
|
174
|
+
questions: {
|
|
175
|
+
profile: {
|
|
176
|
+
type: "choice",
|
|
177
|
+
instructions: ROUTING_INSTRUCTIONS,
|
|
178
|
+
criteria,
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
timeoutMs: args.timeoutMs ?? AUTO_PROFILE_ROUTER_TIMEOUT_MS,
|
|
182
|
+
...(args.signal ? { signal: args.signal } : {}),
|
|
183
|
+
...(args.resolveProvider ? { resolveProvider: args.resolveProvider } : {}),
|
|
184
|
+
});
|
|
185
|
+
if (result.outcome !== "answered") {
|
|
186
|
+
return {
|
|
187
|
+
profile: fallback,
|
|
188
|
+
outcome: result.outcome,
|
|
189
|
+
latencyMs: result.latencyMs,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
const answer = result.answers?.profile;
|
|
193
|
+
const choice =
|
|
194
|
+
typeof answer === "object" && answer !== null && "choice" in answer
|
|
195
|
+
? (answer as { choice?: unknown }).choice
|
|
196
|
+
: undefined;
|
|
197
|
+
const confidence =
|
|
198
|
+
typeof answer === "object" && answer !== null && "confidence" in answer
|
|
199
|
+
? (answer as { confidence?: unknown }).confidence
|
|
200
|
+
: undefined;
|
|
201
|
+
if (
|
|
202
|
+
typeof choice !== "string" ||
|
|
203
|
+
!isDefaultProfileKey(choice) ||
|
|
204
|
+
!candidates.includes(choice)
|
|
205
|
+
) {
|
|
206
|
+
log.warn(
|
|
207
|
+
{ conversationId: args.conversationId, choice },
|
|
208
|
+
"Auto profile router answered with no candidate profile",
|
|
209
|
+
);
|
|
210
|
+
return { profile: fallback, outcome: "error", latencyMs: result.latencyMs };
|
|
211
|
+
}
|
|
212
|
+
// Argmax with no confidence floor. A floor belongs here if flat
|
|
213
|
+
// distributions turn out to send trivial messages to Quality.
|
|
214
|
+
return {
|
|
215
|
+
profile: choice,
|
|
216
|
+
outcome: "routed",
|
|
217
|
+
...(typeof confidence === "number" ? { confidence } : {}),
|
|
218
|
+
latencyMs: result.latencyMs,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
@@ -25,6 +25,8 @@ import type {
|
|
|
25
25
|
TurnChannelContext,
|
|
26
26
|
TurnInterfaceContext,
|
|
27
27
|
} from "../channels/types.js";
|
|
28
|
+
import { getConversationProfilesForProvider } from "../config/default-profile-catalog.js";
|
|
29
|
+
import { AUTO_PROFILE_KEY } from "../config/default-profile-names.js";
|
|
28
30
|
import {
|
|
29
31
|
contextWindowConfigFromEffective,
|
|
30
32
|
type EffectiveContextWindow,
|
|
@@ -99,6 +101,11 @@ import {
|
|
|
99
101
|
DEFAULT_TURN_COMMIT_MAX_WAIT_MS,
|
|
100
102
|
} from "./abort-watchdog.js";
|
|
101
103
|
import { cleanAssistantContent } from "./assistant-attachments.js";
|
|
104
|
+
import {
|
|
105
|
+
type AutoProfileRoute,
|
|
106
|
+
plainTextOf,
|
|
107
|
+
routeAutoProfile,
|
|
108
|
+
} from "./auto-profile-router.js";
|
|
102
109
|
import { conversationSupportsDynamicUi } from "./channel-ui-capability.js";
|
|
103
110
|
import type { Conversation } from "./conversation.js";
|
|
104
111
|
import {
|
|
@@ -647,16 +654,82 @@ export async function runAgentLoopImpl(
|
|
|
647
654
|
// live conversation (hydrated on load, kept current by the HTTP setters and
|
|
648
655
|
// the expiry reaper), so the derivation reads `ctx` rather than re-fetching
|
|
649
656
|
// the row.
|
|
650
|
-
const userExplicitOverride =
|
|
651
|
-
options?.overrideProfile ?? resolveOverrideProfile(ctx);
|
|
652
|
-
|
|
653
657
|
const config = getConfig();
|
|
654
658
|
|
|
659
|
+
// With no explicit override, the resolver's own chain (the active profile
|
|
660
|
+
// for the main agent, then the call site's pin) may still select the Auto
|
|
661
|
+
// profile. That winner is named here so the router below covers every path
|
|
662
|
+
// that would otherwise dispatch the Auto body.
|
|
663
|
+
const readRequestedOverrideProfile = (): string | undefined => {
|
|
664
|
+
const explicit = options?.overrideProfile ?? resolveOverrideProfile(ctx);
|
|
665
|
+
if (explicit !== undefined) {
|
|
666
|
+
return explicit;
|
|
667
|
+
}
|
|
668
|
+
const winner = selectWinningProfile(inferenceCallSite, config.llm, {
|
|
669
|
+
selectionSeed: ctx.conversationId,
|
|
670
|
+
}).profileName;
|
|
671
|
+
return winner === AUTO_PROFILE_KEY ? AUTO_PROFILE_KEY : undefined;
|
|
672
|
+
};
|
|
673
|
+
|
|
674
|
+
// The Auto profile is a selection over the default profiles, made once per
|
|
675
|
+
// turn: the routed profile stands in for the Auto name on every read, so
|
|
676
|
+
// the whole turn (and any subagent inheriting the override) runs on one
|
|
677
|
+
// profile. A user profile that shadows the managed name is a profile of
|
|
678
|
+
// its own and is not routed. Nothing here may throw: the turn's processing
|
|
679
|
+
// state is already held and its release lives in the loop's `finally`
|
|
680
|
+
// further down, so a routing failure degrades to the Auto body instead.
|
|
681
|
+
const routeAutoProfileForTurn =
|
|
682
|
+
async (): Promise<AutoProfileRoute | null> => {
|
|
683
|
+
try {
|
|
684
|
+
const profiles = getConversationProfilesForProvider(
|
|
685
|
+
config.llm.profiles,
|
|
686
|
+
config.llm.defaultProvider ?? null,
|
|
687
|
+
);
|
|
688
|
+
if (profiles[AUTO_PROFILE_KEY]?.source !== "managed") {
|
|
689
|
+
return null;
|
|
690
|
+
}
|
|
691
|
+
const route = await routeAutoProfile({
|
|
692
|
+
conversationId: ctx.conversationId,
|
|
693
|
+
history: ctx.messages,
|
|
694
|
+
userMessage: plainTextOf(
|
|
695
|
+
getMessageById(userMessageId)?.content ?? [],
|
|
696
|
+
),
|
|
697
|
+
profiles,
|
|
698
|
+
signal: abortController.signal,
|
|
699
|
+
});
|
|
700
|
+
rlog.info(
|
|
701
|
+
{
|
|
702
|
+
outcome: route.outcome,
|
|
703
|
+
profile: route.profile,
|
|
704
|
+
confidence: route.confidence ?? null,
|
|
705
|
+
latencyMs: route.latencyMs,
|
|
706
|
+
},
|
|
707
|
+
"Auto profile routed the turn",
|
|
708
|
+
);
|
|
709
|
+
return route;
|
|
710
|
+
} catch (err) {
|
|
711
|
+
rlog.warn(
|
|
712
|
+
{ err },
|
|
713
|
+
"Auto profile routing failed; running the Auto body",
|
|
714
|
+
);
|
|
715
|
+
return null;
|
|
716
|
+
}
|
|
717
|
+
};
|
|
718
|
+
const requestedOverrideProfile = readRequestedOverrideProfile();
|
|
719
|
+
const autoRoute =
|
|
720
|
+
requestedOverrideProfile === AUTO_PROFILE_KEY
|
|
721
|
+
? await routeAutoProfileForTurn()
|
|
722
|
+
: null;
|
|
723
|
+
const withAutoRoute = (profile: string | undefined): string | undefined =>
|
|
724
|
+
profile === AUTO_PROFILE_KEY && autoRoute ? autoRoute.profile : profile;
|
|
725
|
+
|
|
726
|
+
const userExplicitOverride = withAutoRoute(requestedOverrideProfile);
|
|
727
|
+
|
|
655
728
|
const turnOverrideProfile = userExplicitOverride;
|
|
656
729
|
const forceOverrideProfile = options?.forceOverrideProfile === true;
|
|
657
730
|
|
|
658
731
|
const readCurrentOverrideProfile = (): string | undefined =>
|
|
659
|
-
|
|
732
|
+
withAutoRoute(readRequestedOverrideProfile());
|
|
660
733
|
|
|
661
734
|
// Best-effort attribution for error classification: names the resolved
|
|
662
735
|
// connection and profile so credential/connection errors point at the
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { InterfaceId } from "../channels/types.js";
|
|
2
|
-
import {
|
|
2
|
+
import { getConversationProfilesForProvider } from "../config/default-profile-catalog.js";
|
|
3
3
|
import { resolveEffectiveContextWindow } from "../config/llm-context-resolution.js";
|
|
4
4
|
import { resolveCallSiteConfig } from "../config/llm-resolver.js";
|
|
5
5
|
import {
|
|
@@ -148,7 +148,7 @@ async function resolveModelCommand(
|
|
|
148
148
|
parse: ModelCommandParse,
|
|
149
149
|
): Promise<SlashResolution> {
|
|
150
150
|
const config = getConfig();
|
|
151
|
-
const profiles =
|
|
151
|
+
const profiles = getConversationProfilesForProvider(
|
|
152
152
|
config.llm.profiles,
|
|
153
153
|
config.llm.defaultProvider ?? null,
|
|
154
154
|
);
|
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
getConversation,
|
|
41
41
|
getMessageById,
|
|
42
42
|
parseMessageMetadata,
|
|
43
|
+
resurfaceArchivedConversation,
|
|
43
44
|
} from "../persistence/conversation-crud.js";
|
|
44
45
|
import { getResolvedConversationDirPath } from "../persistence/conversation-directories.js";
|
|
45
46
|
import { syncMessageToDisk } from "../persistence/conversation-disk-view.js";
|
|
@@ -150,6 +151,17 @@ export function buildDeferredFinalizeEffect(params: {
|
|
|
150
151
|
"Failed to project assistant message for attention tracking (non-fatal)",
|
|
151
152
|
);
|
|
152
153
|
}
|
|
154
|
+
// The row this turn reserved was empty at insert, so this is where its
|
|
155
|
+
// content becomes readable and a Done conversation earns its way back to
|
|
156
|
+
// the list. A run that never got this far produced nothing to read.
|
|
157
|
+
try {
|
|
158
|
+
resurfaceArchivedConversation(conversationId, finalizedRow.createdAt);
|
|
159
|
+
} catch (err) {
|
|
160
|
+
rlog.warn(
|
|
161
|
+
{ err, conversationId, messageId: assistantMessageId },
|
|
162
|
+
"Failed to resurface Done conversation after finalize (non-fatal)",
|
|
163
|
+
);
|
|
164
|
+
}
|
|
153
165
|
};
|
|
154
166
|
}
|
|
155
167
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pure text-processing functions for dictation profiles.
|
|
3
3
|
*
|
|
4
|
-
* - expandSnippets:
|
|
5
|
-
* - applyDictionary:
|
|
4
|
+
* - expandSnippets: dictation mode only
|
|
5
|
+
* - applyDictionary: dictation and selected-text edits (including fallback paths)
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type {
|
|
@@ -33,7 +33,7 @@ function wrapWordBoundary(
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
|
-
* Expand snippet triggers in text
|
|
36
|
+
* Expand user-configured snippet triggers in dictated text.
|
|
37
37
|
*
|
|
38
38
|
* - Only enabled snippets are considered
|
|
39
39
|
* - Sorted by trigger length descending (longest match wins)
|
|
@@ -80,7 +80,7 @@ export function expandSnippets(
|
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
/**
|
|
83
|
-
* Apply dictionary
|
|
83
|
+
* Apply user-configured dictionary replacements to dictated or edited text.
|
|
84
84
|
*
|
|
85
85
|
* - Sorted by spoken length descending
|
|
86
86
|
* - Respects wholeWord (default true) and caseSensitive (default false) per entry
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the `includeArchived` option on the agent-facing conversation
|
|
3
|
+
* history search.
|
|
4
|
+
*
|
|
5
|
+
* The `"*"` wildcard takes its own branch (FTS reads `*` as a literal), so it
|
|
6
|
+
* has to honor the option through the list read rather than through
|
|
7
|
+
* `searchConversations`, which has its own coverage in
|
|
8
|
+
* `persistence/__tests__/conversation-queries-search.test.ts`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
12
|
+
|
|
13
|
+
import { createConversation } from "../../../persistence/conversation-crud.js";
|
|
14
|
+
import { getDb } from "../../../persistence/db-connection.js";
|
|
15
|
+
import { initializeDb } from "../../../persistence/db-init.js";
|
|
16
|
+
import { rawRun } from "../../../persistence/raw-query.js";
|
|
17
|
+
import { conversations } from "../../../persistence/schema/index.js";
|
|
18
|
+
import { performConversationSearch } from "../conversation-history.js";
|
|
19
|
+
|
|
20
|
+
await initializeDb();
|
|
21
|
+
|
|
22
|
+
function seedArchived(title: string): string {
|
|
23
|
+
const conv = createConversation({ title });
|
|
24
|
+
rawRun(
|
|
25
|
+
"test:archiveConversation",
|
|
26
|
+
"UPDATE conversations SET archived_at = ? WHERE id = ?",
|
|
27
|
+
Date.now(),
|
|
28
|
+
conv.id,
|
|
29
|
+
);
|
|
30
|
+
return conv.id;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("performConversationSearch · wildcard listing", () => {
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
getDb().delete(conversations).run();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("omits archived conversations by default", async () => {
|
|
39
|
+
createConversation("live-1");
|
|
40
|
+
seedArchived("archived-1");
|
|
41
|
+
|
|
42
|
+
const results = await performConversationSearch({ query: "*" });
|
|
43
|
+
|
|
44
|
+
expect(results.map((r) => r.conversationTitle)).toEqual(["live-1"]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("includeArchived reaches archived conversations", async () => {
|
|
48
|
+
createConversation("live-1");
|
|
49
|
+
seedArchived("archived-1");
|
|
50
|
+
|
|
51
|
+
const results = await performConversationSearch({
|
|
52
|
+
query: "*",
|
|
53
|
+
includeArchived: true,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(results.map((r) => r.conversationTitle).sort()).toEqual([
|
|
57
|
+
"archived-1",
|
|
58
|
+
"live-1",
|
|
59
|
+
]);
|
|
60
|
+
});
|
|
61
|
+
});
|