@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.
- package/package.json +1 -1
- package/src/__tests__/compaction-events.test.ts +139 -2
- package/src/__tests__/conversation-error.test.ts +102 -2
- package/src/__tests__/conversation-summarize-route.test.ts +7 -1
- package/src/__tests__/conversation-summarize-up-to.test.ts +71 -5
- package/src/__tests__/guardian-card-withdrawal.test.ts +91 -3
- package/src/__tests__/notification-telegram-adapter.test.ts +6 -0
- package/src/__tests__/provider-error-scenarios.test.ts +140 -0
- package/src/api/events/context-window-usage.ts +31 -0
- package/src/api/index.ts +6 -0
- package/src/approvals/guardian-card-withdrawal.ts +66 -31
- package/src/approvals/guardian-decision-primitive.ts +4 -0
- package/src/calls/__tests__/voice-session-bridge.test.ts +187 -8
- package/src/calls/voice-session-bridge.ts +167 -25
- package/src/daemon/conversation-error.ts +123 -24
- package/src/daemon/conversation-process.ts +7 -2
- package/src/daemon/conversation.ts +90 -27
- package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +171 -20
- package/src/live-voice/__tests__/live-voice-events.test.ts +4 -4
- package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +8 -4
- package/src/live-voice/__tests__/live-voice-vad.test.ts +9 -1
- package/src/live-voice/activity-label.ts +35 -0
- package/src/live-voice/live-voice-session.ts +114 -42
- package/src/messaging/providers/slack/withdraw.ts +6 -14
- package/src/messaging/providers/telegram-bot/send.test.ts +31 -1
- package/src/messaging/providers/telegram-bot/send.ts +23 -2
- package/src/messaging/providers/telegram-bot/withdraw.test.ts +152 -0
- package/src/messaging/providers/telegram-bot/withdraw.ts +166 -0
- package/src/notifications/adapters/telegram.ts +6 -4
- package/src/providers/inference/__tests__/adapter-factory-openai-compatible.test.ts +127 -1
- package/src/providers/inference/adapter-factory.ts +70 -12
- package/src/providers/registry.ts +8 -0
- package/src/providers/retry.ts +85 -17
- package/src/runtime/channel-approval-types.ts +25 -0
- package/src/runtime/routes/conversation-management-routes.ts +8 -1
- package/src/runtime/routes/conversation-routes.ts +3 -1
- package/src/util/errors.ts +12 -0
package/package.json
CHANGED
|
@@ -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
|
-
"
|
|
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).
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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("
|
|
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({
|
|
219
|
+
await withdrawGuardianRequestCards({
|
|
220
|
+
request: req,
|
|
221
|
+
status: "approved",
|
|
222
|
+
originChannel: "vellum",
|
|
223
|
+
});
|
|
212
224
|
|
|
213
225
|
expect(withdrawSlackApprovalCard).not.toHaveBeenCalled();
|
|
214
|
-
expect(
|
|
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]!;
|