@vellumai/assistant 0.11.1-dev.202608032226.351a4e2 → 0.11.1-dev.202608032326.ca2b5a1

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 (37) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/compaction-events.test.ts +139 -2
  3. package/src/__tests__/conversation-error.test.ts +102 -2
  4. package/src/__tests__/conversation-summarize-route.test.ts +7 -1
  5. package/src/__tests__/conversation-summarize-up-to.test.ts +71 -5
  6. package/src/__tests__/guardian-card-withdrawal.test.ts +91 -3
  7. package/src/__tests__/notification-telegram-adapter.test.ts +6 -0
  8. package/src/__tests__/provider-error-scenarios.test.ts +140 -0
  9. package/src/api/events/context-window-usage.ts +31 -0
  10. package/src/api/index.ts +6 -0
  11. package/src/approvals/guardian-card-withdrawal.ts +66 -31
  12. package/src/approvals/guardian-decision-primitive.ts +4 -0
  13. package/src/calls/__tests__/voice-session-bridge.test.ts +187 -8
  14. package/src/calls/voice-session-bridge.ts +167 -25
  15. package/src/daemon/conversation-error.ts +123 -24
  16. package/src/daemon/conversation-process.ts +7 -2
  17. package/src/daemon/conversation.ts +90 -27
  18. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +171 -20
  19. package/src/live-voice/__tests__/live-voice-events.test.ts +4 -4
  20. package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +8 -4
  21. package/src/live-voice/__tests__/live-voice-vad.test.ts +9 -1
  22. package/src/live-voice/activity-label.ts +35 -0
  23. package/src/live-voice/live-voice-session.ts +114 -42
  24. package/src/messaging/providers/slack/withdraw.ts +6 -14
  25. package/src/messaging/providers/telegram-bot/send.test.ts +31 -1
  26. package/src/messaging/providers/telegram-bot/send.ts +23 -2
  27. package/src/messaging/providers/telegram-bot/withdraw.test.ts +152 -0
  28. package/src/messaging/providers/telegram-bot/withdraw.ts +166 -0
  29. package/src/notifications/adapters/telegram.ts +6 -4
  30. package/src/providers/inference/__tests__/adapter-factory-openai-compatible.test.ts +127 -1
  31. package/src/providers/inference/adapter-factory.ts +70 -12
  32. package/src/providers/registry.ts +8 -0
  33. package/src/providers/retry.ts +85 -17
  34. package/src/runtime/channel-approval-types.ts +25 -0
  35. package/src/runtime/routes/conversation-management-routes.ts +8 -1
  36. package/src/runtime/routes/conversation-routes.ts +3 -1
  37. package/src/util/errors.ts +12 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.1-dev.202608032226.351a4e2",
3
+ "version": "0.11.1-dev.202608032326.ca2b5a1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -7,6 +7,10 @@
7
7
  * turn. The `context_compacted` event is the single source of truth for the
8
8
  * indicator — the paired `usage_update` intentionally omits
9
9
  * `contextWindowTokens` to avoid a redundant SwiftUI invalidation.
10
+ *
11
+ * Also covers the `context_window_usage` push, which carries the provider
12
+ * tokenizer's post-compaction count so the web indicator matches the numbers
13
+ * on the `/compact` result card.
10
14
  */
11
15
  import { describe, expect, mock, test } from "bun:test";
12
16
 
@@ -218,7 +222,8 @@ import { Conversation } from "../daemon/conversation.js";
218
222
  // Helpers
219
223
  // ---------------------------------------------------------------------------
220
224
 
221
- function makeProvider() {
225
+ function makeProvider(counts?: number[]) {
226
+ const pending = counts ? [...counts] : null;
222
227
  return {
223
228
  name: "mock-provider",
224
229
  async sendMessage(): Promise<ProviderResponse> {
@@ -229,16 +234,20 @@ function makeProvider() {
229
234
  stopReason: "end_turn",
230
235
  };
231
236
  },
237
+ // Present only when the test supplies counts, so the other cases keep
238
+ // exercising the local-estimate fallback.
239
+ ...(pending ? { countInputTokens: async () => pending.shift() ?? 0 } : {}),
232
240
  };
233
241
  }
234
242
 
235
243
  function makeConversation(
236
244
  collected: AssistantEvent[],
237
245
  id = "conv-compact-events",
246
+ counts?: number[],
238
247
  ): Conversation {
239
248
  return new Conversation(
240
249
  id,
241
- makeProvider(),
250
+ makeProvider(counts),
242
251
  "system prompt",
243
252
  (msg) => {
244
253
  collected.push(msg);
@@ -383,6 +392,134 @@ describe("forceCompact event emission", () => {
383
392
  });
384
393
  });
385
394
 
395
+ describe("forceCompact context-window usage push", () => {
396
+ test("pushes the post-compaction tokenizer count the result card reports", async () => {
397
+ const collected: AssistantEvent[] = [];
398
+ mockCompactResult = {
399
+ messages: [],
400
+ compacted: true,
401
+ // Estimate-based figures the compaction pipeline records internally.
402
+ // The user-facing numbers come from the provider tokenizer instead.
403
+ previousEstimatedInputTokens: 43_000,
404
+ estimatedInputTokens: 12_000,
405
+ maxInputTokens: 200_000,
406
+ thresholdTokens: 160_000,
407
+ compactedMessages: 10,
408
+ compactedPersistedMessages: 5,
409
+ summaryCalls: 1,
410
+ summaryInputTokens: 500,
411
+ summaryOutputTokens: 200,
412
+ summaryModel: "test-model",
413
+ summaryText: "summary text",
414
+ };
415
+
416
+ const conversation = makeConversation(
417
+ collected,
418
+ "conv-compact-usage",
419
+ [56_000, 18_000],
420
+ );
421
+ const result = await conversation.forceCompact();
422
+
423
+ expect(result.previousEstimatedInputTokens).toBe(56_000);
424
+ expect(result.estimatedInputTokens).toBe(18_000);
425
+
426
+ const usage = collected.filter((m) => m.type === "context_window_usage");
427
+ expect(usage.length).toBe(1);
428
+ const usageEvent = usage[0] as Extract<
429
+ AssistantEvent,
430
+ { type: "context_window_usage" }
431
+ >;
432
+ expect(usageEvent.conversationId).toBe("conv-compact-usage");
433
+ // Same figures the `/compact` card renders, so the indicator and the card
434
+ // cannot disagree.
435
+ expect(usageEvent.tokens).toBe(result.estimatedInputTokens);
436
+ expect(usageEvent.maxTokens).toBe(200_000);
437
+ });
438
+
439
+ test("pushes to the caller's sink when the conversation's own sender is dead", async () => {
440
+ // A `/compact` draining behind an interactive turn: `process-message.ts`
441
+ // resets `sendToClient` to a no-op in its `finally`, while the queued item
442
+ // still carries the live sink its result card streams on. The push has to
443
+ // follow the card, not the dead sender.
444
+ const stranded: AssistantEvent[] = [];
445
+ const cardSink: AssistantEvent[] = [];
446
+ mockCompactResult = {
447
+ messages: [],
448
+ compacted: true,
449
+ previousEstimatedInputTokens: 43_000,
450
+ estimatedInputTokens: 12_000,
451
+ maxInputTokens: 200_000,
452
+ thresholdTokens: 160_000,
453
+ compactedMessages: 10,
454
+ compactedPersistedMessages: 5,
455
+ summaryCalls: 1,
456
+ summaryInputTokens: 500,
457
+ summaryOutputTokens: 200,
458
+ summaryModel: "test-model",
459
+ summaryText: "summary text",
460
+ };
461
+
462
+ const conversation = makeConversation(
463
+ stranded,
464
+ "conv-compact-dead-sender",
465
+ [56_000, 18_000],
466
+ );
467
+ conversation.updateClient(() => {}, true);
468
+
469
+ const result = await conversation.forceCompact((msg) => cardSink.push(msg));
470
+
471
+ const usage = cardSink.filter((m) => m.type === "context_window_usage");
472
+ expect(usage.length).toBe(1);
473
+ expect(
474
+ (usage[0] as Extract<AssistantEvent, { type: "context_window_usage" }>)
475
+ .tokens,
476
+ ).toBe(result.estimatedInputTokens);
477
+ expect(
478
+ stranded.filter((m) => m.type === "context_window_usage").length,
479
+ ).toBe(0);
480
+ });
481
+
482
+ test("pushes the current count when there was nothing to compact", async () => {
483
+ const collected: AssistantEvent[] = [];
484
+ mockCompactResult = {
485
+ messages: [],
486
+ compacted: false,
487
+ previousEstimatedInputTokens: 0,
488
+ estimatedInputTokens: 0,
489
+ maxInputTokens: 200_000,
490
+ thresholdTokens: 160_000,
491
+ compactedMessages: 0,
492
+ compactedPersistedMessages: 0,
493
+ summaryCalls: 0,
494
+ summaryInputTokens: 0,
495
+ summaryOutputTokens: 0,
496
+ summaryModel: "",
497
+ summaryText: "",
498
+ };
499
+
500
+ const conversation = makeConversation(
501
+ collected,
502
+ "conv-compact-usage-noop",
503
+ // A no-op re-counts nothing, so the single count stands as before and
504
+ // after.
505
+ [56_000],
506
+ );
507
+ const result = await conversation.forceCompact();
508
+
509
+ expect(result.previousEstimatedInputTokens).toBe(56_000);
510
+ expect(result.estimatedInputTokens).toBe(56_000);
511
+
512
+ const usage = collected.filter((m) => m.type === "context_window_usage");
513
+ expect(usage.length).toBe(1);
514
+ const usageEvent = usage[0] as Extract<
515
+ AssistantEvent,
516
+ { type: "context_window_usage" }
517
+ >;
518
+ expect(usageEvent.tokens).toBe(56_000);
519
+ expect(usageEvent.maxTokens).toBe(200_000);
520
+ });
521
+ });
522
+
386
523
  describe("maybeCompact gate sizing", () => {
387
524
  test("default sizing resolves mainAgent's window; wake sizing resolves the wake's call-site window", async () => {
388
525
  // The auto-threshold gate derives its trip point from the
@@ -777,6 +777,9 @@ describe("classifyConversationError", () => {
777
777
  expect(result.code).toBe("PROVIDER_INVALID_KEY");
778
778
  expect(result.retryable).toBe(false);
779
779
  expect(result.errorCategory).toBe("provider_invalid_key");
780
+ expect(result.userMessage).toBe(
781
+ "Your personal Anthropic API key was rejected by Anthropic. Update that key in Settings → Models & Services.",
782
+ );
780
783
  });
781
784
 
782
785
  it("classifies managed-proxy auth failures as managed credential refresh failures", () => {
@@ -791,12 +794,30 @@ describe("classifyConversationError", () => {
791
794
 
792
795
  expect(result.code).toBe("MANAGED_KEY_INVALID");
793
796
  expect(result.userMessage).toBe(
794
- "Couldn't refresh assistant credentials.",
797
+ "Vellum's managed inference credential was rejected. This isn't a personal provider API key — Vellum provisions this one, so there's nothing to update in Settings.",
795
798
  );
796
799
  expect(result.retryable).toBe(false);
797
800
  expect(result.errorCategory).toBe("managed_key_invalid");
798
801
  });
799
802
 
803
+ it("keeps credential-shaped 4xx failures on the request's managed route", () => {
804
+ providerRoutingSources.anthropic = "user-key";
805
+ const err = new ProviderError(
806
+ "Authentication error: invalid API key",
807
+ "anthropic",
808
+ 400,
809
+ );
810
+ err.attachRouteAttribution({ credentialSource: "vellum-managed" });
811
+
812
+ const result = classifyConversationError(err, baseCtx);
813
+
814
+ expect(result.code).toBe("MANAGED_KEY_INVALID");
815
+ expect(result.errorCategory).toBe("managed_key_invalid");
816
+ expect(result.userMessage).toBe(
817
+ "Vellum's managed inference credential was rejected. This isn't a personal provider API key — Vellum provisions this one, so there's nothing to update in Settings.",
818
+ );
819
+ });
820
+
800
821
  it("classifies ProviderError 401 with 'invalid x-api-key' message as PROVIDER_INVALID_KEY", () => {
801
822
  // Regex-match branch — Anthropic's standard 401 wording.
802
823
  const err = new ProviderError(
@@ -814,6 +835,8 @@ describe("classifyConversationError", () => {
814
835
  const result = classifyConversationError(err, baseCtx);
815
836
  expect(result.code).toBe("PROVIDER_INVALID_KEY");
816
837
  expect(result.errorCategory).toBe("provider_invalid_key");
838
+ expect(result.userMessage).toContain("personal OpenAI API key");
839
+ expect(result.userMessage).toContain("rejected by OpenAI");
817
840
  });
818
841
 
819
842
  it("includes connection/profile attribution in PROVIDER_INVALID_KEY when provided", () => {
@@ -826,7 +849,84 @@ describe("classifyConversationError", () => {
826
849
  expect(result.code).toBe("PROVIDER_INVALID_KEY");
827
850
  expect(result.connectionName).toBe("my-anthropic");
828
851
  expect(result.profileName).toBe("personal");
829
- expect(result.userMessage).toContain("personal");
852
+ expect(result.userMessage).toBe(
853
+ 'Your personal Anthropic API key for profile "personal" (connection "my-anthropic") was rejected by Anthropic. Update that key in Settings → Models & Services.',
854
+ );
855
+ });
856
+
857
+ it("uses the request's BYOK attribution when the same provider also has a managed route", () => {
858
+ providerRoutingSources.anthropic = "managed-proxy";
859
+ const err = new ProviderError("Unauthorized", "anthropic", 401);
860
+ err.attachRouteAttribution({
861
+ credentialSource: "byok",
862
+ connectionName: "anthropic-personal",
863
+ });
864
+
865
+ const result = classifyConversationError(err, baseCtx);
866
+
867
+ expect(result.code).toBe("PROVIDER_INVALID_KEY");
868
+ expect(result.connectionName).toBe("anthropic-personal");
869
+ expect(result.userMessage).toBe(
870
+ 'Your personal Anthropic API key for connection "anthropic-personal" was rejected by Anthropic. Update that key in Settings → Models & Services.',
871
+ );
872
+ });
873
+
874
+ it("uses the request's managed attribution when the same provider also has a BYOK route", () => {
875
+ providerRoutingSources.anthropic = "user-key";
876
+ const err = new ProviderError("Unauthorized", "anthropic", 401);
877
+ err.attachRouteAttribution({
878
+ credentialSource: "vellum-managed",
879
+ connectionName: "vellum",
880
+ });
881
+
882
+ const result = classifyConversationError(err, baseCtx);
883
+
884
+ expect(result.code).toBe("MANAGED_KEY_INVALID");
885
+ expect(result.userMessage).toBe(
886
+ "Vellum's managed inference credential was rejected. This isn't a personal provider API key — Vellum provisions this one, so there's nothing to update in Settings.",
887
+ );
888
+ });
889
+
890
+ it("uses subscription-specific recovery when the same provider also has a managed route", () => {
891
+ providerRoutingSources.openai = "managed-proxy";
892
+ const err = new ProviderError("Unauthorized", "openai", 401, {
893
+ reason: "invalid_credentials",
894
+ });
895
+ err.attachRouteAttribution({
896
+ credentialSource: "oauth-subscription",
897
+ connectionName: "chatgpt-subscription",
898
+ });
899
+
900
+ const result = classifyConversationError(err, baseCtx);
901
+
902
+ expect(result.code).toBe("PROVIDER_API");
903
+ expect(result.errorCategory).toBe("provider_subscription_auth");
904
+ expect(result.retryable).toBe(false);
905
+ expect(result.connectionName).toBe("chatgpt-subscription");
906
+ expect(result.userMessage).toBe(
907
+ 'Your OpenAI subscription login for connection "chatgpt-subscription" was rejected by OpenAI. Reconnect that account in Settings → Models & Services.',
908
+ );
909
+ });
910
+
911
+ it("explains when a no-auth endpoint rejects an unauthenticated request", () => {
912
+ providerRoutingSources["openai-compatible"] = "managed-proxy";
913
+ const err = new ProviderError("Unauthorized", "openai-compatible", 401, {
914
+ reason: "invalid_credentials",
915
+ });
916
+ err.attachRouteAttribution({
917
+ credentialSource: "no-auth",
918
+ connectionName: "local-model-server",
919
+ });
920
+
921
+ const result = classifyConversationError(err, baseCtx);
922
+
923
+ expect(result.code).toBe("PROVIDER_API");
924
+ expect(result.errorCategory).toBe("provider_endpoint_auth_required");
925
+ expect(result.retryable).toBe(false);
926
+ expect(result.connectionName).toBe("local-model-server");
927
+ expect(result.userMessage).toBe(
928
+ 'The OpenAI-compatible endpoint for connection "local-model-server" requires authentication, but that connection is configured without credentials. Configure authentication for that endpoint in Settings → Models & Services.',
929
+ );
830
930
  });
831
931
 
832
932
  it("classifies direct ProviderError with 402 as provider_billing (non-retryable)", () => {
@@ -257,7 +257,13 @@ describe("POST /v1/conversations/summarize", () => {
257
257
 
258
258
  await settle();
259
259
 
260
- expect(ctx.summarizeUpToMessage).toHaveBeenCalledWith("msg-42");
260
+ // The second arg is the sink the context-window usage push rides. This
261
+ // route's conversation may hold the store's no-op sender, so it hands in
262
+ // the broadcast path its result card goes out on.
263
+ expect(ctx.summarizeUpToMessage).toHaveBeenCalledWith(
264
+ "msg-42",
265
+ expect.any(Function),
266
+ );
261
267
  expect(ctx.emitActivityState).toHaveBeenCalledWith(
262
268
  "thinking",
263
269
  "context_compacting",
@@ -15,6 +15,7 @@ import { beforeEach, describe, expect, mock, test } from "bun:test";
15
15
 
16
16
  import { CompactionCircuit } from "../agent/compaction-circuit.js";
17
17
  import type { AgentEvent } from "../agent/loop.js";
18
+ import type { AssistantEvent } from "../api/index.js";
18
19
  import type { CompactionContext } from "../plugins/defaults/compaction/compact.js";
19
20
  import type { ContextWindowResult } from "../plugins/defaults/compaction/window-manager.js";
20
21
  import type { Message, ProviderResponse } from "../providers/types.js";
@@ -212,7 +213,8 @@ import { INTERNAL_GUARDIAN_TRUST_CONTEXT } from "../daemon/trust-context.js";
212
213
  // Helpers
213
214
  // ---------------------------------------------------------------------------
214
215
 
215
- function makeProvider() {
216
+ function makeProvider(counts?: number[]) {
217
+ const pending = counts ? [...counts] : null;
216
218
  return {
217
219
  name: "mock-provider",
218
220
  async sendMessage(): Promise<ProviderResponse> {
@@ -223,15 +225,21 @@ function makeProvider() {
223
225
  stopReason: "end_turn",
224
226
  };
225
227
  },
228
+ // Present only when the test supplies counts, so the other cases keep
229
+ // exercising the local-estimate fallback.
230
+ ...(pending ? { countInputTokens: async () => pending.shift() ?? 0 } : {}),
226
231
  };
227
232
  }
228
233
 
229
- function makeConversation(): Conversation {
234
+ function makeConversation(options?: {
235
+ counts?: number[];
236
+ onEvent?: (msg: AssistantEvent) => void;
237
+ }): Conversation {
230
238
  const conversation = new Conversation(
231
239
  "conv-1",
232
- makeProvider(),
240
+ makeProvider(options?.counts),
233
241
  "system prompt",
234
- () => {},
242
+ options?.onEvent ?? (() => {}),
235
243
  "/tmp",
236
244
  { maxTokens: 4096 },
237
245
  );
@@ -365,7 +373,10 @@ describe("Conversation.summarizeUpToMessage", () => {
365
373
 
366
374
  const result = await conversation.summarizeUpToMessage("m4");
367
375
 
368
- expect(result).toBe(mockCompactResult);
376
+ // The compaction result passes through with its displayed token counts
377
+ // re-measured by the provider tokenizer, which the mocked manager reports
378
+ // as 0 here.
379
+ expect(result).toEqual(mockCompactResult);
369
380
  expect(compactCalls).toHaveLength(1);
370
381
  expect(compactCalls[0].force).toBe(true);
371
382
  // Row index 4 with no compacted prefix and no summary message → 4.
@@ -853,6 +864,61 @@ describe("Conversation.summarizeUpToMessage", () => {
853
864
  expect(recordOutcome).toHaveBeenCalledTimes(1);
854
865
  expect(recordOutcome).toHaveBeenCalledWith(true, expect.anything());
855
866
  });
867
+
868
+ test("reports tokenizer counts and pushes them to the caller's sink", async () => {
869
+ // The management route resolves its conversation outside the send path, so
870
+ // the instance keeps the store's no-op sender: the push has to ride the
871
+ // sink the route hands in, not `sendToClient`.
872
+ const collected: AssistantEvent[] = [];
873
+ const conversation = makeConversation({ counts: [56_000, 18_000] });
874
+ mockCompactResult = {
875
+ ...makeNoopResult(),
876
+ compacted: true,
877
+ // Estimate-based figures the pipeline records internally; the card and
878
+ // the indicator both report the tokenizer counts instead.
879
+ previousEstimatedInputTokens: 43_000,
880
+ estimatedInputTokens: 12_000,
881
+ maxInputTokens: 200_000,
882
+ compactedPersistedMessages: 4,
883
+ };
884
+
885
+ const result = await conversation.summarizeUpToMessage("m4", (msg) =>
886
+ collected.push(msg),
887
+ );
888
+
889
+ expect(result.previousEstimatedInputTokens).toBe(56_000);
890
+ expect(result.estimatedInputTokens).toBe(18_000);
891
+
892
+ const usage = collected.filter((m) => m.type === "context_window_usage");
893
+ expect(usage).toHaveLength(1);
894
+ const usageEvent = usage[0] as Extract<
895
+ AssistantEvent,
896
+ { type: "context_window_usage" }
897
+ >;
898
+ expect(usageEvent.conversationId).toBe("conv-1");
899
+ expect(usageEvent.tokens).toBe(result.estimatedInputTokens);
900
+ expect(usageEvent.maxTokens).toBe(200_000);
901
+ });
902
+
903
+ test("falls back to the conversation's own sender when no sink is passed", async () => {
904
+ const collected: AssistantEvent[] = [];
905
+ const conversation = makeConversation({
906
+ counts: [56_000, 18_000],
907
+ onEvent: (msg) => collected.push(msg),
908
+ });
909
+ mockCompactResult = {
910
+ ...makeNoopResult(),
911
+ compacted: true,
912
+ maxInputTokens: 200_000,
913
+ compactedPersistedMessages: 4,
914
+ };
915
+
916
+ await conversation.summarizeUpToMessage("m4");
917
+
918
+ expect(
919
+ collected.filter((m) => m.type === "context_window_usage"),
920
+ ).toHaveLength(1);
921
+ });
856
922
  });
857
923
 
858
924
  describe("formatSummarizeUpToResult", () => {
@@ -14,6 +14,13 @@ mock.module("../messaging/providers/slack/withdraw.js", () => ({
14
14
  withdrawSlackApprovalCard,
15
15
  }));
16
16
 
17
+ const withdrawTelegramApprovalCard = mock(
18
+ async (_params: Record<string, unknown>) => {},
19
+ );
20
+ mock.module("../messaging/providers/telegram-bot/withdraw.js", () => ({
21
+ withdrawTelegramApprovalCard,
22
+ }));
23
+
17
24
  // The recorder writes through the gateway client; serve that surface from
18
25
  // the in-memory sim the assertions read.
19
26
  import {
@@ -60,6 +67,7 @@ describe("withdrawGuardianRequestCards", () => {
60
67
  completeSurfaceAndNotify.mockClear();
61
68
  markSurfaceCompleted.mockClear();
62
69
  withdrawSlackApprovalCard.mockClear();
70
+ withdrawTelegramApprovalCard.mockClear();
63
71
  });
64
72
 
65
73
  test("withdraws + broadcasts the in-app card when the decision came from another surface", async () => {
@@ -199,7 +207,7 @@ describe("withdrawGuardianRequestCards", () => {
199
207
  expect(withdrawSlackApprovalCard).toHaveBeenCalledTimes(1);
200
208
  });
201
209
 
202
- test("ignores channels without in-place edit support (telegram)", async () => {
210
+ test("withdraws the Telegram card with a status reply when resolved from another surface", async () => {
203
211
  const req = makeRequest({ sourceChannel: "telegram" });
204
212
  bridgeState.seedDelivery({
205
213
  requestId: req.id,
@@ -208,10 +216,90 @@ describe("withdrawGuardianRequestCards", () => {
208
216
  destinationMessageId: "9",
209
217
  });
210
218
 
211
- await withdrawGuardianRequestCards({ request: req, status: "approved" });
219
+ await withdrawGuardianRequestCards({
220
+ request: req,
221
+ status: "approved",
222
+ originChannel: "vellum",
223
+ });
212
224
 
213
225
  expect(withdrawSlackApprovalCard).not.toHaveBeenCalled();
214
- expect(completeSurfaceAndNotify).not.toHaveBeenCalled();
226
+ expect(withdrawTelegramApprovalCard).toHaveBeenCalledTimes(1);
227
+ expect(withdrawTelegramApprovalCard).toHaveBeenCalledWith({
228
+ chatId: "T1",
229
+ messageId: "9",
230
+ status: "approved",
231
+ postStatusReply: true,
232
+ });
233
+ });
234
+
235
+ test("suppresses the Telegram status reply only when the origin flow replies to the guardian", async () => {
236
+ const req = makeRequest({ sourceChannel: "telegram" });
237
+ bridgeState.seedDelivery({
238
+ requestId: req.id,
239
+ destinationChannel: "telegram",
240
+ destinationChatId: "T1",
241
+ destinationMessageId: "9",
242
+ });
243
+
244
+ await withdrawGuardianRequestCards({
245
+ request: req,
246
+ status: "denied",
247
+ originChannel: "telegram",
248
+ decidedAction: "reject",
249
+ hasOriginGuardianReply: true,
250
+ });
251
+
252
+ // The resolver's guardian-facing reply is being delivered in that chat;
253
+ // withdrawal only drops the keyboard.
254
+ expect(withdrawTelegramApprovalCard).toHaveBeenCalledWith({
255
+ chatId: "T1",
256
+ messageId: "9",
257
+ status: "denied",
258
+ decidedAction: "reject",
259
+ postStatusReply: false,
260
+ });
261
+ });
262
+
263
+ test("posts the Telegram status reply for origin decisions without a guardian-facing resolver reply", async () => {
264
+ const req = makeRequest({ sourceChannel: "telegram" });
265
+ bridgeState.seedDelivery({
266
+ requestId: req.id,
267
+ destinationChannel: "telegram",
268
+ destinationChatId: "T1",
269
+ destinationMessageId: "9",
270
+ });
271
+
272
+ // Most resolvers (tool grants, tool approvals, questions) reply to the
273
+ // requester only, so the quoted status reply is the guardian's only
274
+ // durable outcome even for a decision made on Telegram itself.
275
+ await withdrawGuardianRequestCards({
276
+ request: req,
277
+ status: "approved",
278
+ originChannel: "telegram",
279
+ decidedAction: "approve_once",
280
+ hasOriginGuardianReply: false,
281
+ });
282
+
283
+ expect(withdrawTelegramApprovalCard).toHaveBeenCalledWith({
284
+ chatId: "T1",
285
+ messageId: "9",
286
+ status: "approved",
287
+ decidedAction: "approve_once",
288
+ postStatusReply: true,
289
+ });
290
+ });
291
+
292
+ test("skips the Telegram edit when no channel message id was captured", async () => {
293
+ const req = makeRequest({ sourceChannel: "telegram" });
294
+ bridgeState.seedDelivery({
295
+ requestId: req.id,
296
+ destinationChannel: "telegram",
297
+ destinationChatId: "T1",
298
+ });
299
+
300
+ await withdrawGuardianRequestCards({ request: req, status: "approved" });
301
+
302
+ expect(withdrawTelegramApprovalCard).not.toHaveBeenCalled();
215
303
  });
216
304
 
217
305
  test("is best-effort: a failing surface never blocks the others or throws", async () => {
@@ -27,6 +27,9 @@ mock.module("../messaging/providers/telegram-bot/send.js", () => ({
27
27
  text,
28
28
  approval: approval as (typeof sendCalls)[0]["approval"],
29
29
  });
30
+ // Mirror the real send result: the id of the sent message, which the
31
+ // adapter surfaces so the delivery row can address the card later.
32
+ return { lastMessageId: String(1000 + sendCalls.length) };
30
33
  },
31
34
  sendTelegramAttachments: async () => ({
32
35
  allFailed: false,
@@ -196,6 +199,9 @@ describe("TelegramAdapter", () => {
196
199
  const result = await adapter.send(payload, makeDestination());
197
200
 
198
201
  expect(result.success).toBe(true);
202
+ // The sent message's channel-native id is surfaced so the delivery row
203
+ // can address the card for in-place withdrawal later.
204
+ expect(result.messageId).toBe("1001");
199
205
  expect(sendCalls).toHaveLength(1);
200
206
 
201
207
  const call = sendCalls[0]!;