@vellumai/assistant 0.9.0-dev.202606181324.0804a69 → 0.9.0-dev.202606181513.fd39213

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.
@@ -53,20 +53,15 @@ export const ADMISSION_FLOOR: Record<AdmissionPolicy, number> = {
53
53
  *
54
54
  * `platform` / `a2a` are peer/internal channels with no human-trust model.
55
55
  *
56
- * NOTE: `phone` is exempt because the Twilio voice-webhook path
57
- * (twilio-voice-webhook → relay-setup-router) does not yet read
58
- * AdmissionPolicyStore / sourceMetadata.admissionPolicy. Storing a policy for
59
- * `phone` would have no runtime effect, so we exclude it from the API surface
60
- * until voice ingress is wired in a follow-up PR. Remove `"phone"` from this
61
- * set once the voice path enforces admission.
56
+ * `phone` is NOT exempt voice ingress enforces the admission floor.
62
57
  *
63
- * `vellum` is NOT exempt — its floor is still enforced at runtime — but it is
64
- * hidden from the configurable UI; see {@link ADMISSION_POLICY_HIDDEN_CHANNELS}.
58
+ * `vellum` / `whatsapp` are NOT exempt — their floors are still enforced at
59
+ * runtime — but they are hidden from the configurable UI; see
60
+ * {@link ADMISSION_POLICY_HIDDEN_CHANNELS}.
65
61
  */
66
62
  export const ADMISSION_POLICY_EXEMPT_CHANNELS: ReadonlySet<string> = new Set([
67
63
  "platform",
68
64
  "a2a",
69
- "phone",
70
65
  ]);
71
66
 
72
67
  export function isAdmissionPolicyExemptChannel(channelType: string): boolean {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.9.0-dev.202606181324.0804a69",
3
+ "version": "0.9.0-dev.202606181513.fd39213",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,10 +1,14 @@
1
1
  /**
2
- * Regression test for the stuck "Thinking…" bug: a user cancel must drive the
3
- * conversation's processing flag to false even when the in-flight turn never
4
- * observes the AbortController signal (a wedged agent loop that never reaches
5
- * its `finally`). The processing flag is the authoritative source for the
6
- * client thinking indicator, so a latched-true flag pins every client on
7
- * "Thinking…" indefinitely.
2
+ * Cancel contract: a user cancel raises the conversation's AbortController and
3
+ * defers clearing the `processing` flag to the in-flight turn's `finally`.
4
+ *
5
+ * `cancelGeneration` no longer force-clears `processing` itself. Abort now
6
+ * propagates into the provider call and tool execution and is backed by the
7
+ * agent loop's abort watchdog — so a cancelled turn always reaches its
8
+ * `finally` within a bounded time and tears down its own state there. The
9
+ * watchdog-driven path (turn reaches `finally`, processing clears) is covered
10
+ * by `conversation-agent-loop.test.ts`; this file pins the handler-side
11
+ * contract: cancel signals abort and leaves the flag to the turn.
8
12
  */
9
13
  import { afterEach, describe, expect, test } from "bun:test";
10
14
 
@@ -15,18 +19,18 @@ import {
15
19
  } from "../daemon/conversation-registry.js";
16
20
  import { cancelGeneration } from "../daemon/handlers/conversations.js";
17
21
 
18
- interface WedgedTurnConversation {
22
+ interface CancelledConversation {
19
23
  isProcessing: () => boolean;
20
24
  setProcessingCalls: () => boolean[];
21
25
  abortCount: () => number;
22
26
  }
23
27
 
24
28
  /**
25
- * Register a conversation whose in-flight turn is wedged: it accepts the abort
26
- * signal but never unwinds, so it leaves the processing flag untouched. This
27
- * is the exact condition that latches `processing` true in production.
29
+ * Register a conversation whose in-flight turn is processing. The fake records
30
+ * abort calls and any `setProcessing` writes so the test can assert that
31
+ * `cancelGeneration` signals abort without flipping the flag itself.
28
32
  */
29
- function registerWedgedTurn(id: string): WedgedTurnConversation {
33
+ function registerProcessingTurn(id: string): CancelledConversation {
30
34
  let processing = true;
31
35
  let abortCount = 0;
32
36
  const setProcessingCalls: boolean[] = [];
@@ -55,10 +59,9 @@ describe("cancelGeneration", () => {
55
59
  deleteConversation(conversationId);
56
60
  });
57
61
 
58
- test("clears the processing flag when the in-flight turn ignores the abort signal", () => {
62
+ test("raises abort and defers clearing processing to the turn's finally", () => {
59
63
  // GIVEN a registered conversation that is processing
60
- // AND whose in-flight turn ignores the abort signal (never clears processing)
61
- const fake = registerWedgedTurn(conversationId);
64
+ const fake = registerProcessingTurn(conversationId);
62
65
  expect(fake.isProcessing()).toBe(true);
63
66
 
64
67
  // WHEN the user cancels generation
@@ -66,12 +69,12 @@ describe("cancelGeneration", () => {
66
69
 
67
70
  // THEN the cancel is acknowledged
68
71
  expect(cancelled).toBe(true);
69
- // AND the abort signal is still raised on the conversation
72
+ // AND the abort signal is raised on the conversation
70
73
  expect(fake.abortCount()).toBe(1);
71
- // AND the processing flag is cleared through setProcessing(false), which
72
- // publishes the metadata sync invalidation that unblocks every client
73
- expect(fake.setProcessingCalls()).toContain(false);
74
- expect(fake.isProcessing()).toBe(false);
74
+ // AND cancelGeneration does NOT force-clear the flag itself the in-flight
75
+ // turn's `finally` owns that teardown once abort drives it there.
76
+ expect(fake.setProcessingCalls()).not.toContain(false);
77
+ expect(fake.isProcessing()).toBe(true);
75
78
  });
76
79
 
77
80
  test("returns false for a conversation that is not registered", () => {
@@ -1749,55 +1749,62 @@ describe("session-agent-loop", () => {
1749
1749
  expect(drainReason).toBe("loop_complete");
1750
1750
  });
1751
1751
 
1752
- test("leaves a newer turn's state intact when superseded during unwind", async () => {
1753
- // GIVEN a fresh AbortController and request id that a newer turn installs
1754
- // (as persistUserMessage would once a user cancel released `processing`)
1755
- const newerTurnController = new AbortController();
1756
- let drained = false;
1757
-
1758
- // AND a provider that, on this turn's single model call, simulates that
1759
- // newer turn taking ownership of the conversation before this turn
1760
- // reaches its finally
1761
- const ctxHolder: { conversation?: Conversation } = {};
1752
+ test("abort watchdog drives a wedged turn to its finally", async () => {
1753
+ // GIVEN a provider whose call wedges: it acknowledges the user cancel
1754
+ // (aborts the signal) but its promise never settles and never observes
1755
+ // the signal the exact condition that latched `processing` true.
1756
+ const events: ServerMessage[] = [];
1757
+ const abortController = new AbortController();
1758
+ let drainReason: string | undefined;
1759
+ // The provider's call wedges on this promise. It settles only on test
1760
+ // teardown so the abandoned `run()` can unwind cleanly instead of leaking
1761
+ // background work (e.g. partial-persist debounce timers) into later tests.
1762
+ let releaseHang: (reason: unknown) => void = () => {};
1763
+ const hang = new Promise<never>((_, reject) => {
1764
+ releaseHang = reject;
1765
+ });
1762
1766
  const provider: Provider = {
1763
1767
  name: "mock-provider",
1764
- async sendMessage(_messages, options) {
1765
- const conversation = ctxHolder.conversation;
1766
- if (conversation) {
1767
- conversation.abortController = newerTurnController;
1768
- conversation.setProcessing(true);
1769
- conversation.currentRequestId = "newer-turn-req";
1770
- }
1771
- options?.onEvent?.({ type: "text_delta", text: "done" });
1772
- return {
1773
- content: [{ type: "text", text: "done" }],
1774
- model: "mock-model",
1775
- usage: { inputTokens: 0, outputTokens: 0 },
1776
- stopReason: "end_turn",
1777
- };
1768
+ sendMessage(_messages, _options) {
1769
+ abortController.abort();
1770
+ // Never observes the signal — the exact condition that latched
1771
+ // `processing` true before the watchdog existed.
1772
+ return hang;
1778
1773
  },
1779
1774
  };
1780
1775
  const ctx = makeCtx({
1781
1776
  loopProvider: provider,
1782
- drainQueue: async () => {
1783
- drained = true;
1777
+ abortController,
1778
+ // Fire the watchdog quickly instead of the ~45s production default.
1779
+ abortWatchdogMs: 30,
1780
+ drainQueue: (reason: string) => {
1781
+ drainReason = reason;
1784
1782
  },
1785
- });
1786
- ctxHolder.conversation = ctx;
1787
-
1788
- // WHEN the superseded turn runs to completion
1789
- await runAgentLoopImpl(ctx, "hi", "msg-1", () => {});
1783
+ } as unknown as Partial<Conversation>);
1790
1784
 
1791
- // THEN the finally leaves the newer turn's per-turn state intact so it
1792
- // stays cancellable and visible
1793
- expect(ctx.abortController).toBe(newerTurnController);
1794
- expect(ctx.isProcessing()).toBe(true);
1795
- expect(ctx.currentRequestId).toBe("newer-turn-req");
1796
- // AND it does not drain the newer turn's queue out from under it
1797
- expect(drained).toBe(false);
1798
- // AND this turn's own finalization that does not touch shared state still
1799
- // runs (the turn is still counted)
1800
- expect(ctx.turnCount).toBe(1);
1785
+ try {
1786
+ // WHEN the orchestrator runs the turn
1787
+ await runAgentLoopImpl(ctx, "hi", "msg-1", (msg) => events.push(msg));
1788
+
1789
+ // THEN the watchdog forces the turn to its finally: processing clears,
1790
+ // the abort controller is torn down, the queue drains, and the user
1791
+ // sees a cancellation (not an error).
1792
+ expect(ctx.isProcessing()).toBe(false);
1793
+ expect(ctx.abortController).toBeNull();
1794
+ expect(drainReason).toBe("loop_complete");
1795
+ expect(
1796
+ events.find((e) => e.type === "generation_cancelled"),
1797
+ ).toBeDefined();
1798
+ expect(
1799
+ events.find((e) => e.type === "conversation_error"),
1800
+ ).toBeUndefined();
1801
+ } finally {
1802
+ // Let the abandoned run() reject and unwind, then flush microtasks.
1803
+ releaseHang(
1804
+ new DOMException("The operation was aborted", "AbortError"),
1805
+ );
1806
+ await new Promise((resolve) => setTimeout(resolve, 0));
1807
+ }
1801
1808
  });
1802
1809
  });
1803
1810