@vellumai/assistant 0.12.3-staging.2 → 0.12.4-staging.1
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/openapi.yaml +6 -2
- 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__/conversation-resurface-done.test.ts +289 -0
- package/src/__tests__/gemini-provider.test.ts +31 -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__/secret-routes-platform-proxy.test.ts +1 -0
- package/src/__tests__/send-endpoint-busy.test.ts +4 -2
- package/src/__tests__/steer-on-enqueue-question.test.ts +265 -34
- package/src/cli/commands/channel-verification-sessions.help.ts +12 -2
- package/src/cli/lib/bundled-marketplace.json +4 -4
- package/src/config/feature-flag-registry.json +8 -8
- package/src/config/sidebar-done-gate.ts +27 -0
- package/src/daemon/conversation-turn-finalize.ts +12 -0
- 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/desktop/desktop-browser-endpoint.ts +1 -0
- 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/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 +157 -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/plugins/defaults/memory/__tests__/memory-retrospective-skill-card.test.ts +2 -0
- package/src/plugins/defaults/memory/memory-retrospective-skill-card.ts +5 -0
- package/src/providers/gemini/client.ts +17 -1
- package/src/providers/malformed-tool-call.ts +55 -0
- 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/platform-proxy/constants.ts +5 -0
- package/src/providers/retry.ts +68 -36
- 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__/global-search-routes.test.ts +74 -1
- package/src/runtime/routes/conversation-list-routes.ts +16 -14
- package/src/runtime/routes/conversation-query-routes.ts +4 -0
- package/src/runtime/routes/global-search-routes.ts +22 -7
- 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/tools/ask-question/ask-question-tool.test.ts +38 -0
- package/src/tools/ask-question/ask-question-tool.ts +12 -1
- package/src/util/worker-pipe.test.ts +100 -0
- package/src/util/worker-pipe.ts +44 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `sidebar-done` gate on the wake's archived-conversation rejection.
|
|
3
|
+
*
|
|
4
|
+
* `defaultResolveTarget` is module-private, so it is exercised through
|
|
5
|
+
* `wakeAgentForOpportunity()` called without explicit deps. The hydrate step
|
|
6
|
+
* after the archived check is stubbed to throw, which the resolver catches
|
|
7
|
+
* and reports as `not_found` — a flag-on result of `not_found` therefore
|
|
8
|
+
* proves the wake got PAST the archived check and tried to hydrate, while a
|
|
9
|
+
* result of `archived` proves it did not.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
13
|
+
|
|
14
|
+
let sidebarDoneEnabled = false;
|
|
15
|
+
mock.module("../../config/sidebar-done-gate.js", () => ({
|
|
16
|
+
SIDEBAR_DONE_FLAG_KEY: "sidebar-done",
|
|
17
|
+
isSidebarDoneEnabled: () => sidebarDoneEnabled,
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
const ARCHIVED_CONVERSATION_ID = "conv-archived-wake";
|
|
21
|
+
|
|
22
|
+
const getConversationMock = mock((id: string) =>
|
|
23
|
+
id === ARCHIVED_CONVERSATION_ID
|
|
24
|
+
? { id, archivedAt: 1_700_000_000_000 }
|
|
25
|
+
: undefined,
|
|
26
|
+
);
|
|
27
|
+
const actualCrud = await import("../../persistence/conversation-crud.js");
|
|
28
|
+
mock.module("../../persistence/conversation-crud.js", () => ({
|
|
29
|
+
...actualCrud,
|
|
30
|
+
getConversation: getConversationMock,
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
const getOrCreateConversationMock = mock(async () => {
|
|
34
|
+
throw new Error("hydrate stub");
|
|
35
|
+
});
|
|
36
|
+
const actualStore = await import("../../daemon/conversation-store.js");
|
|
37
|
+
mock.module("../../daemon/conversation-store.js", () => ({
|
|
38
|
+
...actualStore,
|
|
39
|
+
getOrCreateConversation: getOrCreateConversationMock,
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
import { wakeAgentForOpportunity } from "../agent-wake.js";
|
|
43
|
+
|
|
44
|
+
describe("agent-wake archived conversations under sidebar-done", () => {
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
getConversationMock.mockClear();
|
|
47
|
+
getOrCreateConversationMock.mockClear();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("rejects the wake with reason archived when the flag is off", async () => {
|
|
51
|
+
sidebarDoneEnabled = false;
|
|
52
|
+
|
|
53
|
+
const result = await wakeAgentForOpportunity({
|
|
54
|
+
conversationId: ARCHIVED_CONVERSATION_ID,
|
|
55
|
+
hint: "scheduled run",
|
|
56
|
+
source: "schedule",
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
expect(result.invoked).toBe(false);
|
|
60
|
+
expect(result.reason).toBe("archived");
|
|
61
|
+
expect(getOrCreateConversationMock).not.toHaveBeenCalled();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("proceeds past the archived check when the flag is on", async () => {
|
|
65
|
+
sidebarDoneEnabled = true;
|
|
66
|
+
|
|
67
|
+
const result = await wakeAgentForOpportunity({
|
|
68
|
+
conversationId: ARCHIVED_CONVERSATION_ID,
|
|
69
|
+
hint: "scheduled run",
|
|
70
|
+
source: "schedule",
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
expect(result.reason).not.toBe("archived");
|
|
74
|
+
expect(getOrCreateConversationMock).toHaveBeenCalledTimes(1);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("still reports not_found for a conversation that does not exist", async () => {
|
|
78
|
+
sidebarDoneEnabled = true;
|
|
79
|
+
|
|
80
|
+
const result = await wakeAgentForOpportunity({
|
|
81
|
+
conversationId: "conv-missing",
|
|
82
|
+
hint: "scheduled run",
|
|
83
|
+
source: "schedule",
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(result.invoked).toBe(false);
|
|
87
|
+
expect(result.reason).toBe("not_found");
|
|
88
|
+
expect(getOrCreateConversationMock).not.toHaveBeenCalled();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -73,6 +73,38 @@ describe("resolvePendingQuestion", () => {
|
|
|
73
73
|
});
|
|
74
74
|
});
|
|
75
75
|
|
|
76
|
+
test("chat_reply: the typed text answers the first question, the rest stay unanswered", () => {
|
|
77
|
+
const resolved: QuestionPromptResult[] = [];
|
|
78
|
+
registerQuestion(
|
|
79
|
+
"req-1",
|
|
80
|
+
[
|
|
81
|
+
{ id: "q1", options: ["a"] },
|
|
82
|
+
{ id: "q2", options: ["b"] },
|
|
83
|
+
],
|
|
84
|
+
(v) => resolved.push(v as QuestionPromptResult),
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const outcome = resolvePendingQuestion("req-1", {
|
|
88
|
+
kind: "chat_reply",
|
|
89
|
+
text: "tuesday, and keep it short",
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(outcome.status).toBe("resolved");
|
|
93
|
+
expect(resolved[0]).toEqual({
|
|
94
|
+
overall: "completed",
|
|
95
|
+
entries: [
|
|
96
|
+
{
|
|
97
|
+
questionId: "q1",
|
|
98
|
+
decision: "free_text",
|
|
99
|
+
text: "tuesday, and keep it short",
|
|
100
|
+
},
|
|
101
|
+
{ questionId: "q2", decision: "skipped" },
|
|
102
|
+
],
|
|
103
|
+
answeredInChat: true,
|
|
104
|
+
});
|
|
105
|
+
expect(pendingInteractions.get("req-1")).toBeUndefined();
|
|
106
|
+
});
|
|
107
|
+
|
|
76
108
|
test("not_found: unknown or wrong-kind requestId leaves nothing resolved", () => {
|
|
77
109
|
expect(resolvePendingQuestion("missing", { kind: "close" })).toEqual({
|
|
78
110
|
status: "not_found",
|
|
@@ -84,6 +84,7 @@ import {
|
|
|
84
84
|
} from "../config/llm-resolver.js";
|
|
85
85
|
import { getConfig } from "../config/loader.js";
|
|
86
86
|
import type { LLMCallSite } from "../config/schemas/llm.js";
|
|
87
|
+
import { isSidebarDoneEnabled } from "../config/sidebar-done-gate.js";
|
|
87
88
|
import { conversationSupportsDynamicUi } from "../daemon/channel-ui-capability.js";
|
|
88
89
|
import type { Conversation } from "../daemon/conversation.js";
|
|
89
90
|
import type { QueueDrainReason } from "../daemon/conversation-queue-manager.js";
|
|
@@ -506,7 +507,8 @@ export interface WakeDeps {
|
|
|
506
507
|
/**
|
|
507
508
|
* Resolve the live {@link Conversation} for a wake invocation.
|
|
508
509
|
* Returns `null` if the conversation doesn't exist, `"archived"` if it
|
|
509
|
-
* exists but is archived
|
|
510
|
+
* exists but is archived and the `sidebar-done` gate is off, or the
|
|
511
|
+
* `Conversation` to proceed with the wake.
|
|
510
512
|
*
|
|
511
513
|
* Receives the full {@link WakeOptions} so the default resolver can
|
|
512
514
|
* thread `trustContext` into `getOrCreateConversation`. Without that
|
|
@@ -546,7 +548,12 @@ async function defaultResolveTarget(
|
|
|
546
548
|
if (!existing) {
|
|
547
549
|
return null;
|
|
548
550
|
}
|
|
549
|
-
|
|
551
|
+
// Under the `sidebar-done` gate an archived conversation is one the user
|
|
552
|
+
// marked Done, not one they half-deleted: its schedules, channel threads
|
|
553
|
+
// and background tools keep working, and the first message the wake
|
|
554
|
+
// produces for the user brings the conversation back to the list
|
|
555
|
+
// (`resurfaceArchivedConversation`).
|
|
556
|
+
if (existing.archivedAt != null && !isSidebarDoneEnabled()) {
|
|
550
557
|
log.info(
|
|
551
558
|
{ conversationId },
|
|
552
559
|
"agent-wake: conversation is archived; rejecting wake",
|
|
@@ -325,6 +325,144 @@ describe("authenticateRequest for /v1/host-browser-result", () => {
|
|
|
325
325
|
});
|
|
326
326
|
});
|
|
327
327
|
|
|
328
|
+
// ---------------------------------------------------------------------------
|
|
329
|
+
// Dev bypass covers only a request that supplied no Authorization header. A
|
|
330
|
+
// bearer sent to a platform pod (the gateway's exchange token, or a trusted
|
|
331
|
+
// contact's) is verified on its own terms.
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
describe("authenticateRequest with auth disabled and a bearer present", () => {
|
|
335
|
+
beforeEach(() => {
|
|
336
|
+
authDisabled = true;
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("uses the token's claims rather than the dev-bypass context", () => {
|
|
340
|
+
const token = mintValidToken({ sub: "actor:self:contact-principal" });
|
|
341
|
+
|
|
342
|
+
const req = new Request("http://localhost/v1/messages", {
|
|
343
|
+
method: "POST",
|
|
344
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
const result = authenticateRequest(req);
|
|
348
|
+
expect(result.ok).toBe(true);
|
|
349
|
+
if (result.ok) {
|
|
350
|
+
expect(result.context.actorPrincipalId).toBe("contact-principal");
|
|
351
|
+
expect(result.context.policyEpoch).toBe(CURRENT_POLICY_EPOCH);
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test("accepts the gateway's exchange token", () => {
|
|
356
|
+
const token = mintValidToken({
|
|
357
|
+
sub: "svc:gateway:self",
|
|
358
|
+
scope_profile: "gateway_service_v1",
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
const req = new Request("http://localhost/v1/messages", {
|
|
362
|
+
method: "POST",
|
|
363
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
const result = authenticateRequest(req);
|
|
367
|
+
expect(result.ok).toBe(true);
|
|
368
|
+
if (result.ok) {
|
|
369
|
+
expect(result.context.principalType).toBe("svc_gateway");
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test("accepts a gateway-audience token through the fallback", () => {
|
|
374
|
+
const token = mintValidToken({
|
|
375
|
+
aud: "vellum-gateway",
|
|
376
|
+
sub: "actor:self:local-client",
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
const req = new Request("http://localhost/v1/messages", {
|
|
380
|
+
method: "POST",
|
|
381
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
const result = authenticateRequest(req);
|
|
385
|
+
expect(result.ok).toBe(true);
|
|
386
|
+
if (result.ok) {
|
|
387
|
+
expect(result.context.actorPrincipalId).toBe("local-client");
|
|
388
|
+
expect(result.context.assistantId).toBe(DAEMON_INTERNAL_ASSISTANT_ID);
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
test.each([
|
|
393
|
+
["malformed", () => "not-a-token.xxxxxxxxxxxxx"],
|
|
394
|
+
[
|
|
395
|
+
"expired",
|
|
396
|
+
() => mintValidToken({ exp: Math.floor(Date.now() / 1000) - 100 }),
|
|
397
|
+
],
|
|
398
|
+
[
|
|
399
|
+
"wrong-audience",
|
|
400
|
+
() => mintValidToken({ aud: "vellum-other" as TokenAudience }),
|
|
401
|
+
],
|
|
402
|
+
["unparseable-sub", () => mintValidToken({ sub: "garbage" })],
|
|
403
|
+
])("refuses a %s token rather than falling back", (_label, build) => {
|
|
404
|
+
const req = new Request("http://localhost/v1/messages", {
|
|
405
|
+
method: "POST",
|
|
406
|
+
headers: { Authorization: `Bearer ${build()}` },
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
const result = authenticateRequest(req);
|
|
410
|
+
expect(result.ok).toBe(false);
|
|
411
|
+
if (!result.ok) {
|
|
412
|
+
expect(result.response.status).toBe(401);
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
test("still returns the dev-bypass context when no bearer is sent", () => {
|
|
417
|
+
const req = new Request("http://localhost/v1/messages", {
|
|
418
|
+
method: "POST",
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
const result = authenticateRequest(req);
|
|
422
|
+
expect(result.ok).toBe(true);
|
|
423
|
+
if (result.ok) {
|
|
424
|
+
expect(result.context.actorPrincipalId).toBe("dev-bypass");
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
test("verifies a lowercase bearer scheme rather than bypassing it", () => {
|
|
429
|
+
const token = mintValidToken({ sub: "actor:self:contact-principal" });
|
|
430
|
+
|
|
431
|
+
const req = new Request("http://localhost/v1/messages", {
|
|
432
|
+
method: "POST",
|
|
433
|
+
headers: { Authorization: `bearer ${token}` },
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
const result = authenticateRequest(req);
|
|
437
|
+
expect(result.ok).toBe(true);
|
|
438
|
+
if (result.ok) {
|
|
439
|
+
expect(result.context.actorPrincipalId).toBe("contact-principal");
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test.each([
|
|
444
|
+
["lowercase scheme with a bad token", "bearer not-a-jwt.xxxxxxxx"],
|
|
445
|
+
["empty credential", "Bearer "],
|
|
446
|
+
["non-bearer scheme", "Basic dXNlcjpwYXNz"],
|
|
447
|
+
["an empty header value", ""],
|
|
448
|
+
["a whitespace-only header value", " "],
|
|
449
|
+
])(
|
|
450
|
+
"refuses %s instead of granting the dev-bypass context",
|
|
451
|
+
(_label, header) => {
|
|
452
|
+
const req = new Request("http://localhost/v1/messages", {
|
|
453
|
+
method: "POST",
|
|
454
|
+
headers: { Authorization: header },
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
const result = authenticateRequest(req);
|
|
458
|
+
expect(result.ok).toBe(false);
|
|
459
|
+
if (!result.ok) {
|
|
460
|
+
expect(result.response.status).toBe(401);
|
|
461
|
+
}
|
|
462
|
+
},
|
|
463
|
+
);
|
|
464
|
+
});
|
|
465
|
+
|
|
328
466
|
// ---------------------------------------------------------------------------
|
|
329
467
|
// oauth_proxy_v1 grants: the narrow bearer a third-party CLI presents to the
|
|
330
468
|
// OAuth passthrough route. Accepted on either audience, and carrying nothing
|
|
@@ -17,9 +17,10 @@
|
|
|
17
17
|
* Replaces both the legacy bearer shared-secret check and the
|
|
18
18
|
* actor-token HMAC middleware with a single JWT verification path.
|
|
19
19
|
*
|
|
20
|
-
* When DISABLE_HTTP_AUTH is set (platform-managed deployments),
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
* When DISABLE_HTTP_AUTH is set (platform-managed deployments), a request
|
|
21
|
+
* carrying no bearer gets a synthetic AuthContext so downstream code always
|
|
22
|
+
* has a typed context to consume. A request that carries one is verified
|
|
23
|
+
* normally, and a bad token is refused rather than falling back.
|
|
23
24
|
*/
|
|
24
25
|
|
|
25
26
|
import { isHttpAuthDisabled } from "../../config/env.js";
|
|
@@ -73,17 +74,18 @@ function buildDevBypassContext(): AuthContext {
|
|
|
73
74
|
* The caller should return the error Response directly to the client.
|
|
74
75
|
*/
|
|
75
76
|
export function authenticateRequest(req: Request): AuthenticateResult {
|
|
76
|
-
// Dev bypass: skip JWT verification entirely
|
|
77
|
-
if (isHttpAuthDisabled()) {
|
|
78
|
-
return { ok: true, context: buildDevBypassContext() };
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const path = new URL(req.url).pathname;
|
|
82
|
-
|
|
83
77
|
const rawToken = extractBearerToken(req);
|
|
84
78
|
if (!rawToken) {
|
|
79
|
+
// Dev bypass covers only a request that supplied no Authorization header
|
|
80
|
+
// at all. A header that is present but unusable (empty or whitespace-only
|
|
81
|
+
// value, a non-bearer scheme, a malformed credential) is refused rather
|
|
82
|
+
// than handed the guardian context. `has` rather than `get`: an empty
|
|
83
|
+
// value reads back as "" and would otherwise pass as absent.
|
|
84
|
+
if (isHttpAuthDisabled() && !req.headers.has("authorization")) {
|
|
85
|
+
return { ok: true, context: buildDevBypassContext() };
|
|
86
|
+
}
|
|
85
87
|
log.warn(
|
|
86
|
-
{ reason: "missing_token", path },
|
|
88
|
+
{ reason: "missing_token", path: new URL(req.url).pathname },
|
|
87
89
|
"Auth denied: missing Authorization header",
|
|
88
90
|
);
|
|
89
91
|
return {
|
|
@@ -100,6 +102,8 @@ export function authenticateRequest(req: Request): AuthenticateResult {
|
|
|
100
102
|
};
|
|
101
103
|
}
|
|
102
104
|
|
|
105
|
+
const path = new URL(req.url).pathname;
|
|
106
|
+
|
|
103
107
|
// Verify the JWT — prefer vellum-daemon audience (gateway-proxied requests
|
|
104
108
|
// and daemon-minted tokens), but also accept vellum-gateway audience for
|
|
105
109
|
// direct local clients (macOS SettingsStore) that hold a guardian-issued JWT
|
|
@@ -156,8 +156,14 @@ export function isPrivateNetworkOrigin(req: Request): boolean {
|
|
|
156
156
|
/**
|
|
157
157
|
* Extract and validate a bearer token from the Authorization header.
|
|
158
158
|
* Returns the token string if present, or null.
|
|
159
|
+
*
|
|
160
|
+
* The scheme is matched case-insensitively, per RFC 7235 and to match the
|
|
161
|
+
* gateway's own parsing. An empty credential returns null.
|
|
159
162
|
*/
|
|
160
163
|
export function extractBearerToken(req: Request): string | null {
|
|
161
164
|
const authHeader = req.headers.get("authorization");
|
|
162
|
-
|
|
165
|
+
if (!authHeader?.toLowerCase().startsWith("bearer ")) {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
return authHeader.slice(7) || null;
|
|
163
169
|
}
|
|
@@ -265,14 +265,14 @@ export function getByConversation(
|
|
|
265
265
|
* /v1/host-transfer-result after completing the operation, get a 404, and the
|
|
266
266
|
* proxy timer would fire with a spurious timeout error.
|
|
267
267
|
*
|
|
268
|
-
* `question` interactions are also skipped: a new message
|
|
269
|
-
* ask_question
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
* without settling the prompt's
|
|
273
|
-
* fallback like secrets do) and would
|
|
274
|
-
*
|
|
275
|
-
* open both tools concurrently.
|
|
268
|
+
* `question` interactions are also skipped: a new message settles an open
|
|
269
|
+
* ask_question at the enqueue path (see `daemon/handlers/conversations.ts`),
|
|
270
|
+
* either by answering it with the typed text or by steering to the message,
|
|
271
|
+
* which aborts the parked turn and settles the question via its abort signal.
|
|
272
|
+
* Clearing the entry here instead would drop it without settling the prompt's
|
|
273
|
+
* Promise (questions carry no `rpcResolve` fallback like secrets do) and would
|
|
274
|
+
* strip both paths of the entry they need, which can co-occur with a
|
|
275
|
+
* confirmation since one model response can open both tools concurrently.
|
|
276
276
|
*/
|
|
277
277
|
export function removeByConversation(
|
|
278
278
|
conversationId: string,
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Shared resolution core for pending `ask_question` interactions.
|
|
3
3
|
*
|
|
4
4
|
* A pending `question` interaction (registered by {@link QuestionPrompter}) can
|
|
5
|
-
* be resolved from
|
|
5
|
+
* be resolved from three places:
|
|
6
6
|
*
|
|
7
7
|
* - `POST /v1/question-response` — an app/web client submits the user's
|
|
8
8
|
* batched selection (see `routes/question-routes.ts`).
|
|
@@ -10,8 +10,11 @@
|
|
|
10
10
|
* or bare-text answer routes through the guardian reply router to the
|
|
11
11
|
* `pending_question` resolver, which builds the submission (see
|
|
12
12
|
* `approvals/guardian-request-resolvers.ts`).
|
|
13
|
+
* - the enqueue path: a message typed into the chat while the card is open
|
|
14
|
+
* answers the parked question as free text (see
|
|
15
|
+
* `daemon/handlers/conversations.ts`).
|
|
13
16
|
*
|
|
14
|
-
*
|
|
17
|
+
* All funnel through {@link resolvePendingQuestion} so the validate → consume
|
|
15
18
|
* → `rpcResolve` sequence has a single implementation. The helper is
|
|
16
19
|
* transport-agnostic: it returns a discriminated outcome rather than throwing
|
|
17
20
|
* route errors, so each caller maps the outcome to its own surface (HTTP status
|
|
@@ -28,12 +31,20 @@ import {
|
|
|
28
31
|
import * as pendingInteractions from "./pending-interactions.js";
|
|
29
32
|
|
|
30
33
|
/**
|
|
31
|
-
* How to resolve a pending question:
|
|
32
|
-
*
|
|
34
|
+
* How to resolve a pending question:
|
|
35
|
+
*
|
|
36
|
+
* - `submit`: a batched submission, one entry per question.
|
|
37
|
+
* - `close`: the card was dismissed; every question is reported as
|
|
38
|
+
* `skipped`.
|
|
39
|
+
* - `chat_reply`: the user typed a message into the chat while the card was
|
|
40
|
+
* open. The text answers the first question as free text and the rest stay
|
|
41
|
+
* `skipped`; the result is flagged so the tool tells the model where the
|
|
42
|
+
* answer came from.
|
|
33
43
|
*/
|
|
34
44
|
export type QuestionResolutionInput =
|
|
35
45
|
| { kind: "submit"; submissions: QuestionBatchSubmission[] }
|
|
36
|
-
| { kind: "close" }
|
|
46
|
+
| { kind: "close" }
|
|
47
|
+
| { kind: "chat_reply"; text: string };
|
|
37
48
|
|
|
38
49
|
/**
|
|
39
50
|
* Outcome of {@link resolvePendingQuestion}.
|
|
@@ -57,9 +68,10 @@ export type QuestionResolutionOutcome =
|
|
|
57
68
|
| { status: "invalid"; message: string };
|
|
58
69
|
|
|
59
70
|
/**
|
|
60
|
-
* Resolve a pending `question` interaction with a batched submission
|
|
61
|
-
*
|
|
62
|
-
* outcome leaves the pending prompt (and its timer) intact for a
|
|
71
|
+
* Resolve a pending `question` interaction with a batched submission, a close,
|
|
72
|
+
* or a chat reply. Validation runs BEFORE the interaction is consumed, so an
|
|
73
|
+
* `invalid` outcome leaves the pending prompt (and its timer) intact for a
|
|
74
|
+
* retry.
|
|
63
75
|
*/
|
|
64
76
|
export function resolvePendingQuestion(
|
|
65
77
|
requestId: string,
|
|
@@ -81,6 +93,8 @@ export function resolvePendingQuestion(
|
|
|
81
93
|
})),
|
|
82
94
|
overall: "closed",
|
|
83
95
|
};
|
|
96
|
+
} else if (input.kind === "chat_reply") {
|
|
97
|
+
result = buildChatReplyResult(input.text, interaction);
|
|
84
98
|
} else {
|
|
85
99
|
result = buildCompletedResult(input.submissions, interaction);
|
|
86
100
|
}
|
|
@@ -136,6 +150,38 @@ function buildCompletedResult(
|
|
|
136
150
|
return { entries, overall: "completed" };
|
|
137
151
|
}
|
|
138
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Build the result for a chat reply: the typed text answers the first question
|
|
155
|
+
* as free text, every other question stays `skipped`.
|
|
156
|
+
*
|
|
157
|
+
* The daemon holds no partial per-question state (the card keeps the user's
|
|
158
|
+
* in-progress answers locally until the whole batch is posted), so the first
|
|
159
|
+
* question is by construction the first unanswered one. Which question the
|
|
160
|
+
* text really speaks to is the model's call, not the daemon's: the flag on the
|
|
161
|
+
* result tells it the answer came from the chat rather than the card, and it
|
|
162
|
+
* maps the reply onto the batch from there.
|
|
163
|
+
*/
|
|
164
|
+
function buildChatReplyResult(
|
|
165
|
+
text: string,
|
|
166
|
+
interaction: ReturnType<typeof pendingInteractions.get>,
|
|
167
|
+
): QuestionPromptResult {
|
|
168
|
+
const { orderedIds } = readBatchMetadata(interaction);
|
|
169
|
+
if (orderedIds.length === 0) {
|
|
170
|
+
throw new QuestionBatchValidationError(
|
|
171
|
+
"No registered question ids for this batch",
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
entries: orderedIds.map((id, index) =>
|
|
176
|
+
index === 0
|
|
177
|
+
? { questionId: id, decision: "free_text" as const, text }
|
|
178
|
+
: { questionId: id, decision: "skipped" as const },
|
|
179
|
+
),
|
|
180
|
+
overall: "completed",
|
|
181
|
+
answeredInChat: true,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
139
185
|
/**
|
|
140
186
|
* Pull the prompter-stashed batch bookkeeping off a pending interaction.
|
|
141
187
|
* Returns empty defaults if the metadata is absent.
|
|
@@ -1302,3 +1302,149 @@ describe("GET /v1/conversations/sections", () => {
|
|
|
1302
1302
|
expect(sections.some((s) => s.kind === "pinned")).toBe(false);
|
|
1303
1303
|
});
|
|
1304
1304
|
});
|
|
1305
|
+
|
|
1306
|
+
describe("GET /v1/conversations, conversationType=all", () => {
|
|
1307
|
+
beforeEach(() => {
|
|
1308
|
+
clearConversations();
|
|
1309
|
+
});
|
|
1310
|
+
|
|
1311
|
+
function setLastMessageAt(id: string, at: number): void {
|
|
1312
|
+
rawRun(
|
|
1313
|
+
"test:setLastMessageAt",
|
|
1314
|
+
"UPDATE conversations SET last_message_at = ? WHERE id = ?",
|
|
1315
|
+
at,
|
|
1316
|
+
id,
|
|
1317
|
+
);
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
test("returns the standard listing and the background umbrella together", async () => {
|
|
1321
|
+
createConversation("foreground-1");
|
|
1322
|
+
createConversation({ title: "bg-1", conversationType: "background" });
|
|
1323
|
+
createConversation({ title: "sched-1", conversationType: "scheduled" });
|
|
1324
|
+
|
|
1325
|
+
const result = (await invoke({ conversationType: "all" })) as ListResponse;
|
|
1326
|
+
|
|
1327
|
+
expect(result.conversations.map((c) => c.title).sort()).toEqual([
|
|
1328
|
+
"bg-1",
|
|
1329
|
+
"foreground-1",
|
|
1330
|
+
"sched-1",
|
|
1331
|
+
]);
|
|
1332
|
+
});
|
|
1333
|
+
|
|
1334
|
+
test("with archiveStatus=all one page spans live and done rows of every type", async () => {
|
|
1335
|
+
createConversation("foreground-1");
|
|
1336
|
+
createConversation({ title: "bg-1", conversationType: "background" });
|
|
1337
|
+
seedArchived("archived-1");
|
|
1338
|
+
const archivedBackground = createConversation({
|
|
1339
|
+
title: "archived-bg",
|
|
1340
|
+
conversationType: "background",
|
|
1341
|
+
});
|
|
1342
|
+
rawRun(
|
|
1343
|
+
"test:archiveConversation",
|
|
1344
|
+
"UPDATE conversations SET archived_at = ? WHERE id = ?",
|
|
1345
|
+
Date.now(),
|
|
1346
|
+
archivedBackground.id,
|
|
1347
|
+
);
|
|
1348
|
+
|
|
1349
|
+
const result = (await invoke({
|
|
1350
|
+
conversationType: "all",
|
|
1351
|
+
archiveStatus: "all",
|
|
1352
|
+
})) as ListResponse;
|
|
1353
|
+
|
|
1354
|
+
expect(result.conversations.map((c) => c.title).sort()).toEqual([
|
|
1355
|
+
"archived-1",
|
|
1356
|
+
"archived-bg",
|
|
1357
|
+
"bg-1",
|
|
1358
|
+
"foreground-1",
|
|
1359
|
+
]);
|
|
1360
|
+
expect(result.hasMore).toBe(false);
|
|
1361
|
+
});
|
|
1362
|
+
|
|
1363
|
+
test("orders by last message time descending across the types it merges", async () => {
|
|
1364
|
+
const oldest = createConversation("oldest-foreground");
|
|
1365
|
+
const middle = createConversation({
|
|
1366
|
+
title: "middle-background",
|
|
1367
|
+
conversationType: "background",
|
|
1368
|
+
});
|
|
1369
|
+
const newest = createConversation("newest-foreground");
|
|
1370
|
+
setLastMessageAt(oldest.id, 1000);
|
|
1371
|
+
setLastMessageAt(middle.id, 2000);
|
|
1372
|
+
setLastMessageAt(newest.id, 3000);
|
|
1373
|
+
|
|
1374
|
+
const result = (await invoke({ conversationType: "all" })) as ListResponse;
|
|
1375
|
+
|
|
1376
|
+
expect(result.conversations.map((c) => c.title)).toEqual([
|
|
1377
|
+
"newest-foreground",
|
|
1378
|
+
"middle-background",
|
|
1379
|
+
"oldest-foreground",
|
|
1380
|
+
]);
|
|
1381
|
+
});
|
|
1382
|
+
|
|
1383
|
+
test("excludes legacy private rows and subagent runs", async () => {
|
|
1384
|
+
createConversation("foreground-1");
|
|
1385
|
+
const legacyPrivate = createConversation("legacy-private");
|
|
1386
|
+
rawRun(
|
|
1387
|
+
"test:legacyPrivateRow",
|
|
1388
|
+
"UPDATE conversations SET conversation_type = 'private', group_id = 'system:background' WHERE id = ?",
|
|
1389
|
+
legacyPrivate.id,
|
|
1390
|
+
);
|
|
1391
|
+
const subagent = createConversation({
|
|
1392
|
+
title: "subagent-run",
|
|
1393
|
+
conversationType: "background",
|
|
1394
|
+
});
|
|
1395
|
+
rawRun(
|
|
1396
|
+
"test:subagentRun",
|
|
1397
|
+
"UPDATE conversations SET source = 'subagent' WHERE id = ?",
|
|
1398
|
+
subagent.id,
|
|
1399
|
+
);
|
|
1400
|
+
|
|
1401
|
+
const result = (await invoke({ conversationType: "all" })) as ListResponse;
|
|
1402
|
+
|
|
1403
|
+
expect(result.conversations.map((c) => c.title)).toEqual(["foreground-1"]);
|
|
1404
|
+
});
|
|
1405
|
+
|
|
1406
|
+
test("never appends pinned rows outside the page", async () => {
|
|
1407
|
+
// Pinned injection is the compatibility shim for the default sidebar
|
|
1408
|
+
// read. A whole-history page already contains the pinned row in recency
|
|
1409
|
+
// order, so appending it would duplicate it and put hasMore out of step
|
|
1410
|
+
// with the count.
|
|
1411
|
+
const pinned = createConversation("pinned-1");
|
|
1412
|
+
rawRun(
|
|
1413
|
+
"test:pinConversation",
|
|
1414
|
+
"UPDATE conversations SET is_pinned = 1, group_id = 'system:pinned' WHERE id = ?",
|
|
1415
|
+
pinned.id,
|
|
1416
|
+
);
|
|
1417
|
+
const newer = createConversation("newer-1");
|
|
1418
|
+
setLastMessageAt(pinned.id, 1000);
|
|
1419
|
+
setLastMessageAt(newer.id, 2000);
|
|
1420
|
+
|
|
1421
|
+
const result = (await invoke({
|
|
1422
|
+
conversationType: "all",
|
|
1423
|
+
limit: "1",
|
|
1424
|
+
})) as ListResponse;
|
|
1425
|
+
|
|
1426
|
+
expect(result.conversations.map((c) => c.title)).toEqual(["newer-1"]);
|
|
1427
|
+
expect(result.hasMore).toBe(true);
|
|
1428
|
+
});
|
|
1429
|
+
|
|
1430
|
+
test("hasMore describes the combined total, so pagination drains it", async () => {
|
|
1431
|
+
createConversation("foreground-1");
|
|
1432
|
+
createConversation({ title: "bg-1", conversationType: "background" });
|
|
1433
|
+
createConversation({ title: "sched-1", conversationType: "scheduled" });
|
|
1434
|
+
|
|
1435
|
+
const first = (await invoke({
|
|
1436
|
+
conversationType: "all",
|
|
1437
|
+
limit: "2",
|
|
1438
|
+
})) as ListResponse;
|
|
1439
|
+
expect(first.conversations).toHaveLength(2);
|
|
1440
|
+
expect(first.hasMore).toBe(true);
|
|
1441
|
+
|
|
1442
|
+
const second = (await invoke({
|
|
1443
|
+
conversationType: "all",
|
|
1444
|
+
limit: "2",
|
|
1445
|
+
offset: String(first.nextOffset),
|
|
1446
|
+
})) as ListResponse;
|
|
1447
|
+
expect(second.conversations).toHaveLength(1);
|
|
1448
|
+
expect(second.hasMore).toBe(false);
|
|
1449
|
+
});
|
|
1450
|
+
});
|