@vellumai/assistant 0.12.3 → 0.12.4-dev.202609231620.a15f321
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dockerfile +7 -0
- package/README.md +12 -12
- package/docker/mousepad/90-vellum.gschema.override +4 -0
- package/node_modules/@vellumai/avatar-manifest/src/__tests__/notification-avatar.test.ts +48 -4
- package/node_modules/@vellumai/avatar-manifest/src/notification-avatar.ts +12 -8
- package/openapi.yaml +97 -4
- package/package.json +1 -1
- package/src/__tests__/approval-routes-http.test.ts +4 -2
- package/src/__tests__/call-routes-http.test.ts +4 -2
- package/src/__tests__/channel-readiness-discord.test.ts +8 -0
- package/src/__tests__/channel-readiness-slack-remote.test.ts +8 -0
- package/src/__tests__/config-loader-backfill.test.ts +1 -0
- package/src/__tests__/conversation-pairing.test.ts +10 -0
- package/src/__tests__/conversation-resurface-done.test.ts +289 -0
- package/src/__tests__/default-profile-catalog-fallback.test.ts +1 -0
- package/src/__tests__/gemini-provider.test.ts +31 -0
- package/src/__tests__/inference-profile-validation-jev.test.ts +17 -0
- package/src/__tests__/job-handler-registration-guard.test.ts +1 -0
- package/src/__tests__/llm-catalog-parity.test.ts +4 -0
- package/src/__tests__/llm-resolver-override-or-default.test.ts +43 -0
- package/src/__tests__/openai-responses-prompt-cache.test.ts +3 -1
- package/src/__tests__/platform-callback-registration.test.ts +46 -1
- package/src/__tests__/plugin-api-model-profiles.test.ts +7 -0
- package/src/__tests__/plugin-import-boundary-guard.test.ts +3 -0
- package/src/__tests__/pricing.test.ts +33 -0
- package/src/__tests__/provider-error-scenarios.test.ts +46 -0
- package/src/__tests__/provider-platform-proxy-integration.test.ts +45 -15
- package/src/__tests__/retry-fallback-escalation.test.ts +22 -0
- package/src/__tests__/runtime-attachment-metadata.test.ts +4 -2
- package/src/__tests__/scaffold-managed-skill-tool.test.ts +116 -66
- package/src/__tests__/secret-routes-platform-proxy.test.ts +1 -0
- package/src/__tests__/send-endpoint-busy.test.ts +4 -2
- package/src/__tests__/skill-load-inline-command.test.ts +35 -0
- package/src/__tests__/skill-load-tool.test.ts +42 -11
- package/src/__tests__/skill-tool-factory.test.ts +50 -2
- package/src/__tests__/steer-on-enqueue-question.test.ts +265 -34
- package/src/__tests__/validate-input.test.ts +73 -0
- package/src/__tests__/workspace-migration-159-rename-colliding-jev-profile-name.test.ts +197 -0
- package/src/api/ask-question-tool.ts +50 -0
- package/src/api/events/tool-result.ts +11 -0
- package/src/api/index.ts +7 -0
- package/src/api/requests/dictation.ts +2 -2
- package/src/api/responses/home.ts +27 -0
- package/src/avatar/notification-avatar.test.ts +42 -34
- package/src/avatar/notification-avatar.ts +1 -0
- package/src/calls/__tests__/voice-session-bridge.test.ts +63 -1
- package/src/calls/voice-escalation-judge.ts +6 -5
- package/src/calls/voice-screen-annotation.ts +44 -0
- package/src/calls/voice-session-bridge.ts +22 -0
- package/src/cli/commands/__tests__/plugins.test.ts +29 -1
- package/src/cli/commands/channel-verification-sessions.help.ts +12 -2
- package/src/cli/commands/platform/index.help.ts +2 -1
- package/src/cli/commands/platform/index.ts +8 -2
- package/src/cli/commands/plugins.ts +16 -0
- package/src/cli/lib/__tests__/inspect-plugin.test.ts +29 -0
- package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +92 -2
- package/src/cli/lib/__tests__/plugin-catalog-local.test.ts +15 -0
- package/src/cli/lib/__tests__/plugin-catalog-platform.test.ts +66 -2
- package/src/cli/lib/__tests__/plugin-catalog-resolve.test.ts +21 -0
- package/src/cli/lib/__tests__/plugin-catalog-visibility.test.ts +65 -0
- package/src/cli/lib/__tests__/plugin-details.test.ts +43 -0
- package/src/cli/lib/__tests__/plugins-install-offline.test.ts +26 -0
- package/src/cli/lib/bundled-marketplace.json +4 -4
- package/src/cli/lib/inspect-plugin.ts +5 -0
- package/src/cli/lib/plugin-catalog-cache.ts +41 -4
- package/src/cli/lib/plugin-catalog-local.ts +9 -0
- package/src/cli/lib/plugin-catalog-platform.ts +48 -9
- package/src/cli/lib/plugin-catalog-visibility.ts +41 -0
- package/src/cli/lib/plugin-details.ts +10 -7
- package/src/cli/lib/search-plugins.ts +2 -0
- package/src/config/__tests__/default-profile-catalog.test.ts +61 -1
- package/src/config/__tests__/jev-managed-profile.test.ts +82 -0
- package/src/config/__tests__/sync-gated-profiles.test.ts +71 -0
- package/src/config/bundled-skills/screen-annotation/SKILL.md +6 -3
- package/src/config/call-site-defaults.ts +17 -11
- package/src/config/default-profile-catalog.ts +118 -19
- package/src/config/default-profile-names.ts +69 -0
- package/src/config/feature-flag-registry.json +25 -9
- package/src/config/inference-profile-validation.ts +7 -6
- package/src/config/llm-resolver.ts +16 -0
- package/src/config/profile-text-generation.test.ts +9 -0
- package/src/config/schemas/call-site-catalog.ts +8 -1
- package/src/config/schemas/llm.ts +70 -9
- package/src/config/sidebar-done-gate.ts +27 -0
- package/src/config/sync-gated-profiles.ts +96 -44
- package/src/daemon/__tests__/auto-profile-router.test.ts +228 -0
- package/src/daemon/auto-profile-router.ts +220 -0
- package/src/daemon/conversation-agent-loop.ts +77 -4
- package/src/daemon/conversation-slash.ts +2 -2
- package/src/daemon/conversation-turn-finalize.ts +12 -0
- package/src/daemon/dictation-text-processing.ts +4 -4
- package/src/daemon/handlers/__tests__/conversation-history-search.test.ts +61 -0
- package/src/daemon/handlers/config-channels.ts +3 -1
- package/src/daemon/handlers/conversation-history.ts +11 -1
- package/src/daemon/handlers/conversations.ts +118 -10
- package/src/daemon/pkb-reminder-builder.test.ts +10 -3
- package/src/daemon/pkb-reminder-builder.ts +1 -1
- package/src/desktop/desktop-browser-endpoint.ts +1 -0
- package/src/home/feed-types.ts +2 -0
- package/src/inbound/platform-callback-registration.ts +19 -6
- package/src/ipc/routes/__tests__/conversation-sync-ipc-routes.test.ts +49 -1
- package/src/ipc/routes/conversation-sync-ipc-routes.ts +38 -2
- package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +27 -0
- package/src/live-voice/__tests__/protocol.test.ts +22 -0
- package/src/live-voice/continuation-judge.ts +1 -1
- package/src/live-voice/live-voice-session.ts +7 -6
- package/src/live-voice/protocol.ts +16 -5
- package/src/messaging/providers/gmail/client.ts +4 -0
- package/src/notifications/README.md +11 -3
- package/src/notifications/__tests__/home-feed-side-effect.test.ts +87 -3
- package/src/notifications/conversation-pairing.ts +23 -8
- package/src/notifications/home-feed-mirror.ts +39 -0
- package/src/notifications/home-feed-side-effect.ts +46 -19
- package/src/oauth/AGENTS.md +7 -1
- package/src/permissions/question-prompter.ts +14 -4
- package/src/persistence/conversation-crud.ts +114 -3
- package/src/persistence/conversation-queries.ts +48 -3
- package/src/persistence/embeddings/__tests__/embedding-local-lifecycle.test.ts +228 -32
- package/src/persistence/embeddings/embedding-backend-shutdown.test.ts +47 -0
- package/src/persistence/embeddings/embedding-backend.ts +23 -41
- package/src/persistence/embeddings/embedding-gemini.test.ts +17 -0
- package/src/persistence/embeddings/embedding-gemini.ts +3 -1
- package/src/persistence/embeddings/embedding-local.ts +72 -17
- package/src/persistence/embeddings/embedding-types.ts +0 -19
- package/src/persistence/jobs-store.ts +229 -0
- package/src/persistence/migrations/383-normalize-opencode-host-connections.ts +44 -0
- package/src/persistence/migrations/__tests__/383-normalize-opencode-host-connections.test.ts +102 -0
- package/src/persistence/steps.ts +12 -0
- package/src/platform/sync-avatar.test.ts +29 -0
- package/src/plugin-api/model-profiles.ts +5 -3
- package/src/plugins/defaults/memory/AGENTS.md +5 -3
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-skill-card.test.ts +2 -0
- package/src/plugins/defaults/memory/__tests__/skill-update-receipt-boundary-guard.test.ts +45 -0
- package/src/plugins/defaults/memory/__tests__/skill-update-receipt-job.test.ts +580 -0
- package/src/plugins/defaults/memory/graph/tools.ts +9 -5
- package/src/plugins/defaults/memory/hooks/conversation-deleted.ts +4 -0
- package/src/plugins/defaults/memory/job-handlers.ts +8 -2
- package/src/plugins/defaults/memory/memory-retrospective-skill-card.ts +5 -0
- package/src/plugins/defaults/memory/skill-update-receipt-job.ts +334 -0
- package/src/plugins/defaults/memory/substrate/skill-content.ts +1 -1
- package/src/plugins/defaults/memory/tools.test.ts +17 -0
- package/src/plugins/defaults/memory/v3/__tests__/render-injection.test.ts +3 -0
- package/src/providers/__tests__/connection-model-compat.test.ts +2 -0
- package/src/providers/__tests__/search-provider-catalog.test.ts +8 -0
- package/src/providers/anthropic/client.ts +2 -2
- package/src/providers/gemini/client.ts +17 -1
- package/src/providers/inference/auth.ts +25 -0
- package/src/providers/jev/ask.test.ts +66 -0
- package/src/{calls/typesafe-noul.ts → providers/jev/ask.ts} +75 -36
- package/src/providers/malformed-tool-call.ts +55 -0
- package/src/providers/model-catalog.ts +257 -4
- package/src/providers/openai/__tests__/chat-completions-malformed-tool-call.test.ts +105 -0
- package/src/providers/openai/chat-completions-provider.ts +30 -0
- package/src/providers/openai/codex-models.ts +2 -0
- package/src/providers/platform-proxy/constants.ts +5 -0
- package/src/providers/retry.ts +68 -36
- package/src/providers/search-provider-catalog.ts +10 -0
- package/src/providers/vellum-model-routing.test.ts +1 -0
- package/src/runtime/__tests__/agent-wake-archived-gate.test.ts +90 -0
- package/src/runtime/__tests__/question-resolution.test.ts +32 -0
- package/src/runtime/agent-wake.ts +9 -2
- package/src/runtime/auth/__tests__/middleware.test.ts +138 -0
- package/src/runtime/auth/middleware.ts +15 -11
- package/src/runtime/middleware/auth.ts +7 -1
- package/src/runtime/pending-interactions.ts +8 -8
- package/src/runtime/question-resolution.ts +54 -8
- package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +146 -0
- package/src/runtime/routes/__tests__/dictation-processing.test.ts +174 -0
- package/src/runtime/routes/__tests__/global-search-routes.test.ts +74 -1
- package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +60 -0
- package/src/runtime/routes/__tests__/llm-call-sites-routes.test.ts +16 -1
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +8 -0
- package/src/runtime/routes/conversation-list-routes.ts +16 -14
- package/src/runtime/routes/conversation-query-routes.ts +4 -0
- package/src/runtime/routes/conversation-routes.ts +4 -2
- package/src/runtime/routes/diagnostics-routes.ts +66 -229
- package/src/runtime/routes/global-search-routes.ts +22 -7
- package/src/runtime/routes/inference-provider-connection-routes.ts +25 -8
- package/src/runtime/routes/llm-call-sites-routes.ts +19 -10
- package/src/runtime/routes/platform-routes.ts +6 -1
- package/src/runtime/routes/plugins-routes.ts +5 -2
- package/src/runtime/sync/documents-sidecar-publish.test.ts +50 -4
- package/src/runtime/sync/resource-sync-events.ts +11 -0
- package/src/runtime/sync/worker-daemon-notify.test.ts +30 -0
- package/src/runtime/sync/worker-daemon-notify.ts +44 -0
- package/src/runtime/verification-outbound-actions.ts +2 -2
- package/src/skills/validate-input.ts +75 -11
- package/src/telegram/__tests__/webhook-health.test.ts +8 -0
- package/src/tools/ask-question/ask-question-tool.test.ts +38 -0
- package/src/tools/ask-question/ask-question-tool.ts +12 -1
- package/src/tools/network/__tests__/web-fetch-firecrawl.test.ts +28 -1
- package/src/tools/network/__tests__/web-fetch-metadata.test.ts +34 -0
- package/src/tools/network/__tests__/web-fetch-tinyfish.test.ts +35 -1
- package/src/tools/network/__tests__/web-search.test.ts +96 -0
- package/src/tools/network/web-fetch.ts +30 -9
- package/src/tools/network/web-search.ts +235 -4
- package/src/tools/skills/execute.ts +1 -1
- package/src/tools/skills/format-input-schema.ts +15 -0
- package/src/tools/skills/load.ts +15 -43
- package/src/tools/skills/scaffold-managed.ts +86 -71
- package/src/tools/skills/skill-tool-factory.ts +2 -1
- package/src/tools/workflows/manage-workflows.ts +4 -2
- package/src/util/worker-pipe.test.ts +146 -0
- package/src/util/worker-pipe.ts +49 -0
- package/src/workspace/migrations/158-repoint-opencode-host-provider-fragments.ts +163 -0
- package/src/workspace/migrations/159-rename-colliding-jev-profile-name.ts +344 -0
- package/src/workspace/migrations/__tests__/158-repoint-opencode-host-provider-fragments.test.ts +188 -0
- package/src/workspace/migrations/registry.ts +4 -0
- package/src/runtime/routes/__tests__/dictation-cleanup-guard.test.ts +0 -107
|
@@ -1,45 +1,62 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Enqueue
|
|
3
|
-
* `ask_question` prompt is open for the same conversation, the user has chosen
|
|
4
|
-
* to move on rather than answer it. `steerOnEnqueuedMessageIfQuestionParked`
|
|
5
|
-
* steers to that message — aborting the parked turn (which settles the open
|
|
6
|
-
* question via its turn-abort signal) and draining the message — instead of
|
|
7
|
-
* stranding it behind a prompt no one will answer.
|
|
2
|
+
* Enqueue contract for a message typed while an `ask_question` card is open.
|
|
8
3
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
4
|
+
* The message is read as the answer: `answerParkedQuestionWithEnqueuedMessage`
|
|
5
|
+
* resolves the parked prompt with the typed text as free text and consumes the
|
|
6
|
+
* queued message, so the parked turn keeps running and the model reads the
|
|
7
|
+
* answer once, in the tool result.
|
|
8
|
+
*
|
|
9
|
+
* Everything a single free-text answer cannot honestly stand in for falls back
|
|
10
|
+
* to `steerOnEnqueuedMessageIfQuestionParked`, which supersedes the prompt by
|
|
11
|
+
* aborting the parked turn and draining the message instead. Confirmations and
|
|
12
|
+
* secrets are separate interaction kinds and are untouched by both paths.
|
|
11
13
|
*/
|
|
12
14
|
import { afterEach, describe, expect, test } from "bun:test";
|
|
13
15
|
|
|
14
16
|
import type { Conversation } from "../daemon/conversation.js";
|
|
17
|
+
import type { QueuedMessage } from "../daemon/conversation-queue-manager.js";
|
|
15
18
|
import {
|
|
16
19
|
deleteConversation,
|
|
17
20
|
setConversation,
|
|
18
21
|
} from "../daemon/conversation-registry.js";
|
|
19
22
|
import {
|
|
23
|
+
answerParkedQuestionWithEnqueuedMessage,
|
|
20
24
|
steerOnEnqueuedMessageIfQuestionParked,
|
|
21
25
|
supersedePendingInteractionsOnEnqueue,
|
|
22
26
|
} from "../daemon/handlers/conversations.js";
|
|
27
|
+
import type { QuestionPromptResult } from "../permissions/question-prompter.js";
|
|
23
28
|
import * as pendingInteractions from "../runtime/pending-interactions.js";
|
|
24
29
|
|
|
25
30
|
interface ParkedTurn {
|
|
26
31
|
abortCount: () => number;
|
|
27
32
|
fake: { pendingSteerRepair: boolean };
|
|
33
|
+
/** Queue entries still waiting, by request id. */
|
|
34
|
+
queuedIds: () => string[];
|
|
35
|
+
/** Event types emitted through the queued message's own sink. */
|
|
36
|
+
emittedTypes: () => string[];
|
|
37
|
+
enqueue: (message: Partial<QueuedMessage> & { requestId: string }) => void;
|
|
28
38
|
}
|
|
29
39
|
|
|
30
40
|
/**
|
|
31
41
|
* Register a fake conversation whose in-flight turn is parked. The fake exposes
|
|
32
|
-
* just the surface
|
|
33
|
-
*
|
|
42
|
+
* just the surface the enqueue paths touch: a processing flag, a queue that can
|
|
43
|
+
* be looked up, promoted and removed from, an abort controller that records
|
|
34
44
|
* aborts, and the confirmation-deny hook.
|
|
35
45
|
*/
|
|
36
46
|
function registerParkedTurn(id: string): ParkedTurn {
|
|
37
47
|
let abortCount = 0;
|
|
48
|
+
const emitted: string[] = [];
|
|
49
|
+
const queue = new Map<string, QueuedMessage>();
|
|
38
50
|
const fake = {
|
|
39
51
|
isProcessing: () => true,
|
|
40
52
|
queue: {
|
|
41
|
-
findByRequestId: (requestId: string) => (
|
|
42
|
-
promoteToHead: (requestId: string) => (
|
|
53
|
+
findByRequestId: (requestId: string) => queue.get(requestId),
|
|
54
|
+
promoteToHead: (requestId: string) => queue.get(requestId),
|
|
55
|
+
},
|
|
56
|
+
removeQueuedMessage: (requestId: string) => {
|
|
57
|
+
const item = queue.get(requestId);
|
|
58
|
+
queue.delete(requestId);
|
|
59
|
+
return item;
|
|
43
60
|
},
|
|
44
61
|
pendingSteerRepair: false,
|
|
45
62
|
abortController: {
|
|
@@ -51,23 +68,249 @@ function registerParkedTurn(id: string): ParkedTurn {
|
|
|
51
68
|
denyAllPendingConfirmations: () => {},
|
|
52
69
|
};
|
|
53
70
|
setConversation(id, fake as unknown as Conversation);
|
|
54
|
-
|
|
71
|
+
const enqueue = (
|
|
72
|
+
message: Partial<QueuedMessage> & { requestId: string },
|
|
73
|
+
): void => {
|
|
74
|
+
queue.set(message.requestId, {
|
|
75
|
+
content: "",
|
|
76
|
+
attachments: [],
|
|
77
|
+
onEvent: (event) => {
|
|
78
|
+
emitted.push(event.type);
|
|
79
|
+
},
|
|
80
|
+
sentAt: 0,
|
|
81
|
+
...message,
|
|
82
|
+
} as QueuedMessage);
|
|
83
|
+
};
|
|
84
|
+
// Every case needs a queued message to act on; the plain typed reply is the
|
|
85
|
+
// common one, and cases that need other content re-enqueue over it.
|
|
86
|
+
enqueue({ requestId: "msg-1", content: "next tuesday works" });
|
|
87
|
+
return {
|
|
88
|
+
abortCount: () => abortCount,
|
|
89
|
+
fake,
|
|
90
|
+
queuedIds: () => [...queue.keys()],
|
|
91
|
+
emittedTypes: () => [...emitted],
|
|
92
|
+
enqueue,
|
|
93
|
+
};
|
|
55
94
|
}
|
|
56
95
|
|
|
57
96
|
const registeredRequestIds: string[] = [];
|
|
58
97
|
function registerInteraction(
|
|
59
98
|
conversationId: string,
|
|
60
|
-
kind: "question" | "confirmation",
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
99
|
+
kind: "question" | "confirmation" | "secret",
|
|
100
|
+
options: { requestId?: string; questionIds?: string[] } = {},
|
|
101
|
+
): { requestId: string; result: () => QuestionPromptResult | undefined } {
|
|
102
|
+
const requestId =
|
|
103
|
+
options.requestId ?? `pending-${kind}-${conversationId}-${Date.now()}`;
|
|
104
|
+
let result: QuestionPromptResult | undefined;
|
|
105
|
+
const questionIds = options.questionIds ?? ["q1"];
|
|
106
|
+
pendingInteractions.register(requestId, {
|
|
107
|
+
conversationId,
|
|
108
|
+
kind,
|
|
109
|
+
...(kind === "question"
|
|
110
|
+
? {
|
|
111
|
+
metadata: {
|
|
112
|
+
orderedIds: questionIds,
|
|
113
|
+
optionsById: Object.fromEntries(
|
|
114
|
+
questionIds.map((id) => [id, ["opt-a"]]),
|
|
115
|
+
),
|
|
116
|
+
},
|
|
117
|
+
rpcResolve: (value: unknown) => {
|
|
118
|
+
result = value as QuestionPromptResult;
|
|
119
|
+
},
|
|
120
|
+
}
|
|
121
|
+
: {}),
|
|
122
|
+
});
|
|
64
123
|
registeredRequestIds.push(requestId);
|
|
124
|
+
return { requestId, result: () => result };
|
|
65
125
|
}
|
|
66
126
|
|
|
67
127
|
const QUESTION_CONV = "steer-enqueue-question";
|
|
68
128
|
const CONFIRMATION_CONV = "steer-enqueue-confirmation";
|
|
69
129
|
const NONE_CONV = "steer-enqueue-none";
|
|
70
130
|
|
|
131
|
+
describe("a typed reply answers a parked question", () => {
|
|
132
|
+
afterEach(() => {
|
|
133
|
+
for (const id of registeredRequestIds) {
|
|
134
|
+
pendingInteractions.resolve(id, "cancelled");
|
|
135
|
+
}
|
|
136
|
+
registeredRequestIds.length = 0;
|
|
137
|
+
deleteConversation(QUESTION_CONV);
|
|
138
|
+
deleteConversation(CONFIRMATION_CONV);
|
|
139
|
+
deleteConversation(NONE_CONV);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("resolves a single parked question with the typed text", () => {
|
|
143
|
+
const conv = registerParkedTurn(QUESTION_CONV);
|
|
144
|
+
const question = registerInteraction(QUESTION_CONV, "question");
|
|
145
|
+
|
|
146
|
+
const answered = answerParkedQuestionWithEnqueuedMessage(
|
|
147
|
+
QUESTION_CONV,
|
|
148
|
+
"msg-1",
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
expect(answered).toBe(true);
|
|
152
|
+
expect(question.result()).toEqual({
|
|
153
|
+
entries: [
|
|
154
|
+
{ questionId: "q1", decision: "free_text", text: "next tuesday works" },
|
|
155
|
+
],
|
|
156
|
+
overall: "completed",
|
|
157
|
+
answeredInChat: true,
|
|
158
|
+
});
|
|
159
|
+
// The parked turn keeps running: no abort, no tool-result repair.
|
|
160
|
+
expect(conv.abortCount()).toBe(0);
|
|
161
|
+
expect(conv.fake.pendingSteerRepair).toBe(false);
|
|
162
|
+
// The message reaches the model through the tool result, so it is consumed
|
|
163
|
+
// rather than run as a turn of its own, and the client's queued row is
|
|
164
|
+
// retired the way a queue delete retires it.
|
|
165
|
+
expect(conv.queuedIds()).toEqual([]);
|
|
166
|
+
expect(conv.emittedTypes()).toEqual(["message_queued_deleted"]);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("answers the first question of a batch and leaves the rest unanswered", () => {
|
|
170
|
+
const conv = registerParkedTurn(QUESTION_CONV);
|
|
171
|
+
const question = registerInteraction(QUESTION_CONV, "question", {
|
|
172
|
+
questionIds: ["q1", "q2", "q3"],
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const answered = answerParkedQuestionWithEnqueuedMessage(
|
|
176
|
+
QUESTION_CONV,
|
|
177
|
+
"msg-1",
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
expect(answered).toBe(true);
|
|
181
|
+
expect(question.result()).toEqual({
|
|
182
|
+
entries: [
|
|
183
|
+
{ questionId: "q1", decision: "free_text", text: "next tuesday works" },
|
|
184
|
+
{ questionId: "q2", decision: "skipped" },
|
|
185
|
+
{ questionId: "q3", decision: "skipped" },
|
|
186
|
+
],
|
|
187
|
+
overall: "completed",
|
|
188
|
+
answeredInChat: true,
|
|
189
|
+
});
|
|
190
|
+
expect(conv.abortCount()).toBe(0);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("supersedes instead when the message carries an attachment", () => {
|
|
194
|
+
const conv = registerParkedTurn(QUESTION_CONV);
|
|
195
|
+
const question = registerInteraction(QUESTION_CONV, "question");
|
|
196
|
+
conv.enqueue({
|
|
197
|
+
requestId: "msg-1",
|
|
198
|
+
content: "here it is",
|
|
199
|
+
attachments: [
|
|
200
|
+
{
|
|
201
|
+
id: "att-1",
|
|
202
|
+
filename: "notes.txt",
|
|
203
|
+
mimeType: "text/plain",
|
|
204
|
+
data: "",
|
|
205
|
+
},
|
|
206
|
+
],
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
supersedePendingInteractionsOnEnqueue(QUESTION_CONV, "msg-1");
|
|
210
|
+
|
|
211
|
+
expect(question.result()).toBeUndefined();
|
|
212
|
+
expect(conv.abortCount()).toBe(1);
|
|
213
|
+
expect(conv.queuedIds()).toEqual(["msg-1"]);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("supersedes instead when the message is a slash command", () => {
|
|
217
|
+
const conv = registerParkedTurn(QUESTION_CONV);
|
|
218
|
+
const question = registerInteraction(QUESTION_CONV, "question");
|
|
219
|
+
conv.enqueue({ requestId: "msg-1", content: "/compact" });
|
|
220
|
+
|
|
221
|
+
supersedePendingInteractionsOnEnqueue(QUESTION_CONV, "msg-1");
|
|
222
|
+
|
|
223
|
+
expect(question.result()).toBeUndefined();
|
|
224
|
+
expect(conv.abortCount()).toBe(1);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("supersedes instead when the message is blank", () => {
|
|
228
|
+
const conv = registerParkedTurn(QUESTION_CONV);
|
|
229
|
+
const question = registerInteraction(QUESTION_CONV, "question");
|
|
230
|
+
conv.enqueue({ requestId: "msg-1", content: " " });
|
|
231
|
+
|
|
232
|
+
supersedePendingInteractionsOnEnqueue(QUESTION_CONV, "msg-1");
|
|
233
|
+
|
|
234
|
+
expect(question.result()).toBeUndefined();
|
|
235
|
+
expect(conv.abortCount()).toBe(1);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("supersedes instead when the send is automated rather than typed", () => {
|
|
239
|
+
const conv = registerParkedTurn(QUESTION_CONV);
|
|
240
|
+
const question = registerInteraction(QUESTION_CONV, "question");
|
|
241
|
+
conv.enqueue({
|
|
242
|
+
requestId: "msg-1",
|
|
243
|
+
content: "scheduled follow-up",
|
|
244
|
+
metadata: { automated: true },
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
supersedePendingInteractionsOnEnqueue(QUESTION_CONV, "msg-1");
|
|
248
|
+
|
|
249
|
+
expect(question.result()).toBeUndefined();
|
|
250
|
+
expect(conv.abortCount()).toBe(1);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("supersedes instead when two questions are parked at once", () => {
|
|
254
|
+
// Which prompt the text answers would be a guess, so the old behavior
|
|
255
|
+
// stands: abort the turn and run the message as its own turn.
|
|
256
|
+
const conv = registerParkedTurn(QUESTION_CONV);
|
|
257
|
+
const first = registerInteraction(QUESTION_CONV, "question", {
|
|
258
|
+
requestId: "question-a",
|
|
259
|
+
});
|
|
260
|
+
const second = registerInteraction(QUESTION_CONV, "question", {
|
|
261
|
+
requestId: "question-b",
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
supersedePendingInteractionsOnEnqueue(QUESTION_CONV, "msg-1");
|
|
265
|
+
|
|
266
|
+
expect(first.result()).toBeUndefined();
|
|
267
|
+
expect(second.result()).toBeUndefined();
|
|
268
|
+
expect(conv.abortCount()).toBe(1);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("leaves a parked secret alone while answering the question", () => {
|
|
272
|
+
const conv = registerParkedTurn(QUESTION_CONV);
|
|
273
|
+
const secret = registerInteraction(QUESTION_CONV, "secret");
|
|
274
|
+
const question = registerInteraction(QUESTION_CONV, "question");
|
|
275
|
+
|
|
276
|
+
supersedePendingInteractionsOnEnqueue(QUESTION_CONV, "msg-1");
|
|
277
|
+
|
|
278
|
+
expect(question.result()?.overall).toBe("completed");
|
|
279
|
+
expect(conv.abortCount()).toBe(0);
|
|
280
|
+
expect(pendingInteractions.get(secret.requestId)?.kind).toBe("secret");
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("does not answer from a pending confirmation (not a question)", () => {
|
|
284
|
+
const conv = registerParkedTurn(CONFIRMATION_CONV);
|
|
285
|
+
registerInteraction(CONFIRMATION_CONV, "confirmation");
|
|
286
|
+
|
|
287
|
+
const answered = answerParkedQuestionWithEnqueuedMessage(
|
|
288
|
+
CONFIRMATION_CONV,
|
|
289
|
+
"msg-1",
|
|
290
|
+
);
|
|
291
|
+
const steered = steerOnEnqueuedMessageIfQuestionParked(
|
|
292
|
+
CONFIRMATION_CONV,
|
|
293
|
+
"msg-1",
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
expect(answered).toBe(false);
|
|
297
|
+
expect(steered).toBe(false);
|
|
298
|
+
expect(conv.abortCount()).toBe(0);
|
|
299
|
+
// The message is left on the queue to run as its own turn.
|
|
300
|
+
expect(conv.queuedIds()).toEqual(["msg-1"]);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("does nothing when no prompt is parked", () => {
|
|
304
|
+
const conv = registerParkedTurn(NONE_CONV);
|
|
305
|
+
|
|
306
|
+
supersedePendingInteractionsOnEnqueue(NONE_CONV, "msg-1");
|
|
307
|
+
|
|
308
|
+
expect(conv.abortCount()).toBe(0);
|
|
309
|
+
expect(conv.queuedIds()).toEqual(["msg-1"]);
|
|
310
|
+
expect(conv.emittedTypes()).toEqual([]);
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
71
314
|
describe("steerOnEnqueuedMessageIfQuestionParked", () => {
|
|
72
315
|
afterEach(() => {
|
|
73
316
|
for (const id of registeredRequestIds) {
|
|
@@ -134,27 +377,15 @@ describe("steerOnEnqueuedMessageIfQuestionParked", () => {
|
|
|
134
377
|
expect(steered).toBe(false);
|
|
135
378
|
expect(conv.abortCount()).toBe(0);
|
|
136
379
|
});
|
|
137
|
-
|
|
138
|
-
test("supersedePendingInteractionsOnEnqueue steers a parked question", () => {
|
|
139
|
-
// The centralized routine is invoked by both the HTTP send handler and the
|
|
140
|
-
// CLI signal path. With no pending confirmation it steers a parked question
|
|
141
|
-
// — the behavior the CLI signal path previously lacked.
|
|
142
|
-
const conv = registerParkedTurn(QUESTION_CONV);
|
|
143
|
-
registerInteraction(QUESTION_CONV, "question");
|
|
144
|
-
|
|
145
|
-
supersedePendingInteractionsOnEnqueue(QUESTION_CONV, "msg-1");
|
|
146
|
-
|
|
147
|
-
expect(conv.abortCount()).toBe(1);
|
|
148
|
-
expect(conv.fake.pendingSteerRepair).toBe(true);
|
|
149
|
-
});
|
|
150
380
|
});
|
|
151
381
|
|
|
152
382
|
describe("removeByConversation preserves question interactions", () => {
|
|
153
383
|
// The enqueue path's confirmation auto-deny calls removeByConversation before
|
|
154
|
-
// the
|
|
155
|
-
// steer's turn abort
|
|
156
|
-
//
|
|
157
|
-
//
|
|
384
|
+
// the question is answered or steered to. Questions must survive it (they
|
|
385
|
+
// are settled instead by the answer or by the steer's turn abort) or, when
|
|
386
|
+
// an ask_question and a confirmation are pending concurrently, the queued
|
|
387
|
+
// message would strand behind a question whose entry was cleared (and whose
|
|
388
|
+
// Promise was never settled) before either path fired.
|
|
158
389
|
const CONV = "remove-by-conv-preserve-question";
|
|
159
390
|
const ids: string[] = [];
|
|
160
391
|
function register(kind: "question" | "confirmation"): void {
|
|
@@ -6,6 +6,79 @@ import {
|
|
|
6
6
|
validateInputAgainstSchema,
|
|
7
7
|
} from "../skills/validate-input.js";
|
|
8
8
|
|
|
9
|
+
describe("nested input validation", () => {
|
|
10
|
+
const schema = {
|
|
11
|
+
properties: {
|
|
12
|
+
options: {
|
|
13
|
+
type: "object",
|
|
14
|
+
properties: { label: { type: "string" } },
|
|
15
|
+
required: ["label"],
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
test("reports nested required fields and types with their path", () => {
|
|
21
|
+
expect(
|
|
22
|
+
validateInputAgainstSchema("example", { options: {} }, schema),
|
|
23
|
+
).toEqual({
|
|
24
|
+
ok: false,
|
|
25
|
+
errors: ["options.label is required"],
|
|
26
|
+
});
|
|
27
|
+
expect(
|
|
28
|
+
validateInputAgainstSchema("example", { options: { label: 42 } }, schema),
|
|
29
|
+
).toEqual({
|
|
30
|
+
ok: false,
|
|
31
|
+
errors: ["options.label must be a string"],
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("preserves open nested objects and only rejects explicitly closed extra fields", () => {
|
|
36
|
+
const input = { options: { label: "Example", extra: true } };
|
|
37
|
+
expect(validateInputAgainstSchema("example", input, schema)).toEqual({
|
|
38
|
+
ok: true,
|
|
39
|
+
});
|
|
40
|
+
expect(
|
|
41
|
+
validateInputAgainstSchema("example", input, {
|
|
42
|
+
properties: {
|
|
43
|
+
options: {
|
|
44
|
+
...schema.properties.options,
|
|
45
|
+
additionalProperties: false,
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
}),
|
|
49
|
+
).toEqual({
|
|
50
|
+
ok: false,
|
|
51
|
+
errors: ['Unknown parameter "options.extra". Supported: "label"'],
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("does not treat a richer oneOf as a presence-only union", () => {
|
|
56
|
+
expect(
|
|
57
|
+
validateInputAgainstSchema(
|
|
58
|
+
"example",
|
|
59
|
+
{ options: { label: "Example" } },
|
|
60
|
+
{
|
|
61
|
+
properties: {
|
|
62
|
+
options: {
|
|
63
|
+
...schema.properties.options,
|
|
64
|
+
oneOf: [
|
|
65
|
+
{
|
|
66
|
+
required: ["label"],
|
|
67
|
+
properties: { label: { const: "Example" } },
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
required: ["label"],
|
|
71
|
+
properties: { label: { const: "Other" } },
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
),
|
|
78
|
+
).toEqual({ ok: true });
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
9
82
|
// ---------------------------------------------------------------------------
|
|
10
83
|
// required
|
|
11
84
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
rmSync,
|
|
6
|
+
writeFileSync,
|
|
7
|
+
} from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { Database } from "bun:sqlite";
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
12
|
+
|
|
13
|
+
import { WORKSPACE_MIGRATIONS } from "../workspace/migrations/registry.js";
|
|
14
|
+
import { assertNotLiveDb } from "./assert-not-live-db.js";
|
|
15
|
+
|
|
16
|
+
const migration = WORKSPACE_MIGRATIONS.find(
|
|
17
|
+
(entry) => entry.id === "159-rename-colliding-jev-profile-name",
|
|
18
|
+
)!;
|
|
19
|
+
|
|
20
|
+
let workspaceDir: string;
|
|
21
|
+
|
|
22
|
+
function writeConfig(data: Record<string, unknown>): void {
|
|
23
|
+
writeFileSync(
|
|
24
|
+
join(workspaceDir, "config.json"),
|
|
25
|
+
JSON.stringify(data, null, 2) + "\n",
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function readConfig(): Record<string, any> {
|
|
30
|
+
return JSON.parse(readFileSync(join(workspaceDir, "config.json"), "utf-8"));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function seedProfilePinTables(rows: {
|
|
34
|
+
conversations: string[];
|
|
35
|
+
cronJobs: string[];
|
|
36
|
+
}): void {
|
|
37
|
+
mkdirSync(join(workspaceDir, "data", "db"), { recursive: true });
|
|
38
|
+
const db = new Database(join(workspaceDir, "data", "db", "assistant.db"));
|
|
39
|
+
db.run(
|
|
40
|
+
`CREATE TABLE conversations (id TEXT PRIMARY KEY, inference_profile TEXT)`,
|
|
41
|
+
);
|
|
42
|
+
db.run(
|
|
43
|
+
`CREATE TABLE cron_jobs (id TEXT PRIMARY KEY, inference_profile TEXT)`,
|
|
44
|
+
);
|
|
45
|
+
rows.conversations.forEach((profile, index) => {
|
|
46
|
+
db.query(
|
|
47
|
+
`INSERT INTO conversations (id, inference_profile) VALUES (?, ?)`,
|
|
48
|
+
).run(`conv-${index}`, profile);
|
|
49
|
+
});
|
|
50
|
+
rows.cronJobs.forEach((profile, index) => {
|
|
51
|
+
db.query(`INSERT INTO cron_jobs (id, inference_profile) VALUES (?, ?)`).run(
|
|
52
|
+
`job-${index}`,
|
|
53
|
+
profile,
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
db.close();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function readPins(table: string): string[] {
|
|
60
|
+
const db = new Database(join(workspaceDir, "data", "db", "assistant.db"));
|
|
61
|
+
try {
|
|
62
|
+
return (
|
|
63
|
+
db
|
|
64
|
+
.query(`SELECT inference_profile AS p FROM ${table} ORDER BY id`)
|
|
65
|
+
.all() as Array<{ p: string }>
|
|
66
|
+
).map((row) => row.p);
|
|
67
|
+
} finally {
|
|
68
|
+
db.close();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
beforeEach(() => {
|
|
73
|
+
workspaceDir = join(
|
|
74
|
+
tmpdir(),
|
|
75
|
+
`vellum-migration-159-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
76
|
+
);
|
|
77
|
+
mkdirSync(workspaceDir, { recursive: true });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
afterEach(() => {
|
|
81
|
+
if (existsSync(workspaceDir)) {
|
|
82
|
+
assertNotLiveDb(workspaceDir);
|
|
83
|
+
rmSync(workspaceDir, { recursive: true, force: true });
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("159-rename-colliding-jev-profile-name migration", () => {
|
|
88
|
+
test("is registered under its id with checkpoint retry", () => {
|
|
89
|
+
expect(migration).toBeDefined();
|
|
90
|
+
expect(migration.retryFailedCheckpoint).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("renames a colliding user profile and rewrites every reference", async () => {
|
|
94
|
+
writeConfig({
|
|
95
|
+
llm: {
|
|
96
|
+
defaultProvider: { provider: "vellum" },
|
|
97
|
+
profiles: {
|
|
98
|
+
"jev-managed": {
|
|
99
|
+
source: "user",
|
|
100
|
+
provider: "anthropic",
|
|
101
|
+
model: "claude-opus-4-7",
|
|
102
|
+
label: "Mine",
|
|
103
|
+
},
|
|
104
|
+
scratch: { source: "user", fallbackProfile: "jev-managed" },
|
|
105
|
+
armA: { source: "user", speed: "fast" },
|
|
106
|
+
blend: {
|
|
107
|
+
source: "user",
|
|
108
|
+
mix: [
|
|
109
|
+
{ profile: "jev-managed", weight: 1 },
|
|
110
|
+
{ profile: "armA", weight: 1 },
|
|
111
|
+
],
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
profileOrder: ["balanced", "jev-managed", "blend"],
|
|
115
|
+
activeProfile: "jev-managed",
|
|
116
|
+
advisorProfile: "jev-managed",
|
|
117
|
+
callSites: { recall: { profile: "jev-managed", maxTokens: 4096 } },
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
seedProfilePinTables({
|
|
121
|
+
conversations: ["jev-managed", "balanced"],
|
|
122
|
+
cronJobs: ["jev-managed"],
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
await migration.run(workspaceDir);
|
|
126
|
+
|
|
127
|
+
const llm = readConfig().llm;
|
|
128
|
+
expect(llm.profiles["jev-managed"]).toBeUndefined();
|
|
129
|
+
expect(llm.profiles["jev-managed-custom"]).toEqual({
|
|
130
|
+
source: "user",
|
|
131
|
+
provider: "anthropic",
|
|
132
|
+
model: "claude-opus-4-7",
|
|
133
|
+
label: "Mine",
|
|
134
|
+
});
|
|
135
|
+
expect(Object.keys(llm.profiles)).toEqual([
|
|
136
|
+
"jev-managed-custom",
|
|
137
|
+
"scratch",
|
|
138
|
+
"armA",
|
|
139
|
+
"blend",
|
|
140
|
+
]);
|
|
141
|
+
expect(llm.activeProfile).toBe("jev-managed-custom");
|
|
142
|
+
expect(llm.advisorProfile).toBe("jev-managed-custom");
|
|
143
|
+
expect(llm.profileOrder).toEqual([
|
|
144
|
+
"balanced",
|
|
145
|
+
"jev-managed-custom",
|
|
146
|
+
"blend",
|
|
147
|
+
]);
|
|
148
|
+
expect(llm.callSites.recall.profile).toBe("jev-managed-custom");
|
|
149
|
+
expect(llm.profiles.scratch.fallbackProfile).toBe("jev-managed-custom");
|
|
150
|
+
expect(llm.profiles.blend.mix[0].profile).toBe("jev-managed-custom");
|
|
151
|
+
expect(readPins("conversations")).toEqual([
|
|
152
|
+
"jev-managed-custom",
|
|
153
|
+
"balanced",
|
|
154
|
+
]);
|
|
155
|
+
expect(readPins("cron_jobs")).toEqual(["jev-managed-custom"]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("keeps suffixing past taken and reserved names", async () => {
|
|
159
|
+
writeConfig({
|
|
160
|
+
llm: {
|
|
161
|
+
profiles: {
|
|
162
|
+
"jev-managed": {
|
|
163
|
+
source: "user",
|
|
164
|
+
provider: "openai",
|
|
165
|
+
model: "gpt-5.5",
|
|
166
|
+
},
|
|
167
|
+
"jev-managed-custom": { source: "user", provider: "openai" },
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
await migration.run(workspaceDir);
|
|
173
|
+
|
|
174
|
+
const keys = Object.keys(readConfig().llm.profiles);
|
|
175
|
+
expect(keys).toEqual(["jev-managed-custom-2", "jev-managed-custom"]);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("leaves a managed stub alone and is idempotent", async () => {
|
|
179
|
+
const original = {
|
|
180
|
+
llm: {
|
|
181
|
+
profiles: {
|
|
182
|
+
"jev-managed": { source: "managed", status: "disabled" },
|
|
183
|
+
balanced: { source: "managed" },
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
writeConfig(original);
|
|
188
|
+
|
|
189
|
+
await migration.run(workspaceDir);
|
|
190
|
+
await migration.run(workspaceDir);
|
|
191
|
+
|
|
192
|
+
expect(readConfig()).toEqual(original);
|
|
193
|
+
expect(existsSync(join(workspaceDir, "data", "db", "assistant.db"))).toBe(
|
|
194
|
+
false,
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The `ask_question` tool's input as a recorded call carries it, for a client
|
|
5
|
+
* reading one back.
|
|
6
|
+
*
|
|
7
|
+
* The tool's own schema (`askQuestionInputSchema`) is the authority on what a
|
|
8
|
+
* model may send: it caps the batch, bounds the options, carries the
|
|
9
|
+
* descriptions the model reads, and refuses a call that passes both shapes.
|
|
10
|
+
* This one only has to read what was already accepted, so it validates the
|
|
11
|
+
* shape and nothing else, and tolerates a recorded call from an assistant
|
|
12
|
+
* whose bounds differed.
|
|
13
|
+
*
|
|
14
|
+
* Both shapes are here because both are what was asked. `desktopHelp` becomes
|
|
15
|
+
* a one-question prompt inside the daemon, so a reader that only understood
|
|
16
|
+
* `questions` would have nothing to show for a recorded desktop-help call.
|
|
17
|
+
*
|
|
18
|
+
* Structured records win where they exist: the answered record a settled
|
|
19
|
+
* prompt carries (`AnsweredQuestion`) and the live entries an outstanding one
|
|
20
|
+
* has. This is the fallback for a call that has neither, which is any prompt
|
|
21
|
+
* recorded before those records existed, one that timed out, and one whose
|
|
22
|
+
* outstanding entries a reload could not tie back to it.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const RecordedQuestionOptionSchema = z.looseObject({
|
|
26
|
+
id: z.string().optional().catch(undefined),
|
|
27
|
+
label: z.string(),
|
|
28
|
+
description: z.string().optional().catch(undefined),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const RecordedQuestionSchema = z.looseObject({
|
|
32
|
+
question: z.string(),
|
|
33
|
+
description: z.string().optional().catch(undefined),
|
|
34
|
+
options: z.array(RecordedQuestionOptionSchema).optional().catch(undefined),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
export const AskQuestionInputSchema = z.looseObject({
|
|
38
|
+
questions: z.array(RecordedQuestionSchema).optional().catch(undefined),
|
|
39
|
+
desktopHelp: z
|
|
40
|
+
.looseObject({
|
|
41
|
+
message: z.string(),
|
|
42
|
+
doneLabel: z.string().optional().catch(undefined),
|
|
43
|
+
skipLabel: z.string().optional().catch(undefined),
|
|
44
|
+
})
|
|
45
|
+
.optional()
|
|
46
|
+
.catch(undefined),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export type AskQuestionInput = z.infer<typeof AskQuestionInputSchema>;
|
|
50
|
+
export type RecordedQuestion = z.infer<typeof RecordedQuestionSchema>;
|