@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.
Files changed (69) hide show
  1. package/Dockerfile +7 -0
  2. package/README.md +12 -12
  3. package/docker/mousepad/90-vellum.gschema.override +4 -0
  4. package/openapi.yaml +6 -2
  5. package/package.json +1 -1
  6. package/src/__tests__/approval-routes-http.test.ts +4 -2
  7. package/src/__tests__/call-routes-http.test.ts +4 -2
  8. package/src/__tests__/conversation-resurface-done.test.ts +289 -0
  9. package/src/__tests__/gemini-provider.test.ts +31 -0
  10. package/src/__tests__/provider-error-scenarios.test.ts +46 -0
  11. package/src/__tests__/provider-platform-proxy-integration.test.ts +45 -15
  12. package/src/__tests__/retry-fallback-escalation.test.ts +22 -0
  13. package/src/__tests__/runtime-attachment-metadata.test.ts +4 -2
  14. package/src/__tests__/secret-routes-platform-proxy.test.ts +1 -0
  15. package/src/__tests__/send-endpoint-busy.test.ts +4 -2
  16. package/src/__tests__/steer-on-enqueue-question.test.ts +265 -34
  17. package/src/cli/commands/channel-verification-sessions.help.ts +12 -2
  18. package/src/cli/lib/bundled-marketplace.json +4 -4
  19. package/src/config/feature-flag-registry.json +8 -8
  20. package/src/config/sidebar-done-gate.ts +27 -0
  21. package/src/daemon/conversation-turn-finalize.ts +12 -0
  22. package/src/daemon/handlers/__tests__/conversation-history-search.test.ts +61 -0
  23. package/src/daemon/handlers/config-channels.ts +3 -1
  24. package/src/daemon/handlers/conversation-history.ts +11 -1
  25. package/src/daemon/handlers/conversations.ts +118 -10
  26. package/src/desktop/desktop-browser-endpoint.ts +1 -0
  27. package/src/ipc/routes/__tests__/conversation-sync-ipc-routes.test.ts +49 -1
  28. package/src/ipc/routes/conversation-sync-ipc-routes.ts +38 -2
  29. package/src/permissions/question-prompter.ts +14 -4
  30. package/src/persistence/conversation-crud.ts +114 -3
  31. package/src/persistence/conversation-queries.ts +48 -3
  32. package/src/persistence/embeddings/__tests__/embedding-local-lifecycle.test.ts +157 -32
  33. package/src/persistence/embeddings/embedding-backend-shutdown.test.ts +47 -0
  34. package/src/persistence/embeddings/embedding-backend.ts +23 -41
  35. package/src/persistence/embeddings/embedding-gemini.test.ts +17 -0
  36. package/src/persistence/embeddings/embedding-gemini.ts +3 -1
  37. package/src/persistence/embeddings/embedding-local.ts +72 -17
  38. package/src/persistence/embeddings/embedding-types.ts +0 -19
  39. package/src/plugins/defaults/memory/__tests__/memory-retrospective-skill-card.test.ts +2 -0
  40. package/src/plugins/defaults/memory/memory-retrospective-skill-card.ts +5 -0
  41. package/src/providers/gemini/client.ts +17 -1
  42. package/src/providers/malformed-tool-call.ts +55 -0
  43. package/src/providers/openai/__tests__/chat-completions-malformed-tool-call.test.ts +105 -0
  44. package/src/providers/openai/chat-completions-provider.ts +30 -0
  45. package/src/providers/platform-proxy/constants.ts +5 -0
  46. package/src/providers/retry.ts +68 -36
  47. package/src/providers/vellum-model-routing.test.ts +1 -0
  48. package/src/runtime/__tests__/agent-wake-archived-gate.test.ts +90 -0
  49. package/src/runtime/__tests__/question-resolution.test.ts +32 -0
  50. package/src/runtime/agent-wake.ts +9 -2
  51. package/src/runtime/auth/__tests__/middleware.test.ts +138 -0
  52. package/src/runtime/auth/middleware.ts +15 -11
  53. package/src/runtime/middleware/auth.ts +7 -1
  54. package/src/runtime/pending-interactions.ts +8 -8
  55. package/src/runtime/question-resolution.ts +54 -8
  56. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +146 -0
  57. package/src/runtime/routes/__tests__/global-search-routes.test.ts +74 -1
  58. package/src/runtime/routes/conversation-list-routes.ts +16 -14
  59. package/src/runtime/routes/conversation-query-routes.ts +4 -0
  60. package/src/runtime/routes/global-search-routes.ts +22 -7
  61. package/src/runtime/sync/documents-sidecar-publish.test.ts +50 -4
  62. package/src/runtime/sync/resource-sync-events.ts +11 -0
  63. package/src/runtime/sync/worker-daemon-notify.test.ts +30 -0
  64. package/src/runtime/sync/worker-daemon-notify.ts +44 -0
  65. package/src/runtime/verification-outbound-actions.ts +2 -2
  66. package/src/tools/ask-question/ask-question-tool.test.ts +38 -0
  67. package/src/tools/ask-question/ask-question-tool.ts +12 -1
  68. package/src/util/worker-pipe.test.ts +100 -0
  69. 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, or the `Conversation` to proceed with the wake.
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
- if (existing.archivedAt != null) {
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), JWT
21
- * verification is skipped and a synthetic AuthContext is constructed
22
- * so downstream code always has a typed context to consume.
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
- return authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
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 supersedes an open
269
- * ask_question by steering to it (see the enqueue path in
270
- * conversation-routes.ts), which aborts the parked turn and settles the
271
- * question via its abort signal. Clearing the entry here instead would drop it
272
- * without settling the prompt's Promise (questions carry no `rpcResolve`
273
- * fallback like secrets do) and would strip the steer of the entry it needs to
274
- * fire — which can co-occur with a confirmation, since one model response can
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 two places:
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
- * Both funnel through {@link resolvePendingQuestion} so the validate → consume
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: a batched submission (one entry per
32
- * question) or a close (every question reported as `skipped`).
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 or a
61
- * close. Validation runs BEFORE the interaction is consumed, so an `invalid`
62
- * outcome leaves the pending prompt (and its timer) intact for a retry.
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
+ });