@vellumai/assistant 0.9.0-dev.202606171417.d2192ab → 0.9.0-dev.202606171903.06eea08
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/openapi.yaml +99 -3
- package/package.json +1 -1
- package/src/__tests__/agent-loop-compaction-strip.test.ts +17 -16
- package/src/__tests__/app-compiler.test.ts +15 -1
- package/src/__tests__/cancel-clears-processing.test.ts +86 -0
- package/src/__tests__/compactor-summary-call-truncation.test.ts +223 -0
- package/src/__tests__/conversation-agent-loop.test.ts +51 -0
- package/src/__tests__/conversation-load-history-repair.test.ts +101 -0
- package/src/__tests__/dynamic-page-surface.test.ts +51 -0
- package/src/__tests__/plugin-api-provider.test.ts +24 -0
- package/src/__tests__/post-compaction-reinjection-idempotency.test.ts +214 -0
- package/src/__tests__/task-progress-nudge-hook.test.ts +1 -1
- package/src/__tests__/weak-open-model.test.ts +29 -0
- package/src/agent/loop.ts +31 -31
- package/src/context/compactor.ts +83 -47
- package/src/context/strip-injections.ts +58 -10
- package/src/daemon/conversation-agent-loop.ts +40 -25
- package/src/daemon/conversation-history.ts +1 -1
- package/src/daemon/conversation-tool-setup.ts +15 -11
- package/src/daemon/conversation.ts +45 -1
- package/src/daemon/handlers/conversations.ts +60 -31
- package/src/daemon/tool-setup-types.ts +4 -0
- package/src/notifications/broadcaster.ts +6 -2
- package/src/notifications/guardian-question-mode.ts +10 -0
- package/src/notifications/home-feed-side-effect.ts +3 -13
- package/src/notifications/notification-utils.ts +2 -1
- package/src/notifications/signal.ts +79 -43
- package/src/notifications/types.ts +98 -128
- package/src/plugin-api/index.ts +23 -0
- package/src/plugins/defaults/memory-retrieval/hooks/post-compact.ts +22 -9
- package/src/plugins/defaults/memory-retrieval/tail-reinjection-strip.ts +64 -0
- package/src/plugins/defaults/task-progress-nudge/hooks/post-tool-use.ts +2 -12
- package/src/providers/atlascloud/client.ts +1 -3
- package/src/providers/inference/adapter-factory.ts +2 -5
- package/src/providers/weak-open-model.ts +22 -0
- package/src/runtime/__tests__/agent-wake.test.ts +181 -0
- package/src/runtime/access-request-helper.ts +2 -5
- package/src/runtime/agent-wake.ts +42 -0
- package/src/runtime/channel-approval-types.ts +5 -1
- package/src/runtime/confirmation-request-guardian-bridge.ts +4 -3
- package/src/runtime/routes/inbound-stages/escalation-intercept.ts +1 -2
- package/src/runtime/routes/inbound-stages/guardian-activation-intercept.ts +1 -2
- package/src/runtime/routes/notification-routes.ts +122 -133
- package/src/runtime/tool-grant-request-helper.ts +1 -2
- package/src/tools/ui-surface/definitions.ts +25 -2
package/openapi.yaml
CHANGED
|
@@ -17692,6 +17692,7 @@ paths:
|
|
|
17692
17692
|
properties:
|
|
17693
17693
|
deliveryId:
|
|
17694
17694
|
type: string
|
|
17695
|
+
minLength: 1
|
|
17695
17696
|
description: Notification delivery ID
|
|
17696
17697
|
success:
|
|
17697
17698
|
type: boolean
|
|
@@ -17769,9 +17770,100 @@ paths:
|
|
|
17769
17770
|
type: boolean
|
|
17770
17771
|
feedItem:
|
|
17771
17772
|
type: object
|
|
17772
|
-
|
|
17773
|
-
|
|
17774
|
-
|
|
17773
|
+
properties:
|
|
17774
|
+
id:
|
|
17775
|
+
type: string
|
|
17776
|
+
type:
|
|
17777
|
+
type: string
|
|
17778
|
+
const: notification
|
|
17779
|
+
priority:
|
|
17780
|
+
type: integer
|
|
17781
|
+
minimum: 0
|
|
17782
|
+
maximum: 100
|
|
17783
|
+
title:
|
|
17784
|
+
type: string
|
|
17785
|
+
summary:
|
|
17786
|
+
type: string
|
|
17787
|
+
timestamp:
|
|
17788
|
+
type: string
|
|
17789
|
+
status:
|
|
17790
|
+
default: new
|
|
17791
|
+
type: string
|
|
17792
|
+
enum:
|
|
17793
|
+
- new
|
|
17794
|
+
- seen
|
|
17795
|
+
- acted_on
|
|
17796
|
+
- dismissed
|
|
17797
|
+
expiresAt:
|
|
17798
|
+
type: string
|
|
17799
|
+
actions:
|
|
17800
|
+
type: array
|
|
17801
|
+
items:
|
|
17802
|
+
type: object
|
|
17803
|
+
properties:
|
|
17804
|
+
id:
|
|
17805
|
+
type: string
|
|
17806
|
+
label:
|
|
17807
|
+
type: string
|
|
17808
|
+
prompt:
|
|
17809
|
+
type: string
|
|
17810
|
+
required:
|
|
17811
|
+
- id
|
|
17812
|
+
- label
|
|
17813
|
+
- prompt
|
|
17814
|
+
additionalProperties: false
|
|
17815
|
+
urgency:
|
|
17816
|
+
type: string
|
|
17817
|
+
enum:
|
|
17818
|
+
- low
|
|
17819
|
+
- medium
|
|
17820
|
+
- high
|
|
17821
|
+
- critical
|
|
17822
|
+
conversationId:
|
|
17823
|
+
type: string
|
|
17824
|
+
detailPanel:
|
|
17825
|
+
type: object
|
|
17826
|
+
properties:
|
|
17827
|
+
kind:
|
|
17828
|
+
type: string
|
|
17829
|
+
enum:
|
|
17830
|
+
- emailDraft
|
|
17831
|
+
- documentPreview
|
|
17832
|
+
- permissionChat
|
|
17833
|
+
- paymentAuth
|
|
17834
|
+
- toolPermission
|
|
17835
|
+
- updatesList
|
|
17836
|
+
required:
|
|
17837
|
+
- kind
|
|
17838
|
+
additionalProperties: false
|
|
17839
|
+
category:
|
|
17840
|
+
type: string
|
|
17841
|
+
enum:
|
|
17842
|
+
- security
|
|
17843
|
+
- scheduling
|
|
17844
|
+
- background
|
|
17845
|
+
- email
|
|
17846
|
+
- system
|
|
17847
|
+
noteworthy:
|
|
17848
|
+
type: boolean
|
|
17849
|
+
fromAssistant:
|
|
17850
|
+
type: boolean
|
|
17851
|
+
metadata:
|
|
17852
|
+
type: object
|
|
17853
|
+
propertyNames:
|
|
17854
|
+
type: string
|
|
17855
|
+
additionalProperties: {}
|
|
17856
|
+
createdAt:
|
|
17857
|
+
type: string
|
|
17858
|
+
required:
|
|
17859
|
+
- id
|
|
17860
|
+
- type
|
|
17861
|
+
- priority
|
|
17862
|
+
- summary
|
|
17863
|
+
- timestamp
|
|
17864
|
+
- status
|
|
17865
|
+
- createdAt
|
|
17866
|
+
additionalProperties: false
|
|
17775
17867
|
channels:
|
|
17776
17868
|
type: array
|
|
17777
17869
|
items:
|
|
@@ -17826,7 +17918,11 @@ paths:
|
|
|
17826
17918
|
- vellum
|
|
17827
17919
|
- phone
|
|
17828
17920
|
- telegram
|
|
17921
|
+
- whatsapp
|
|
17829
17922
|
- slack
|
|
17923
|
+
- email
|
|
17924
|
+
- platform
|
|
17925
|
+
- a2a
|
|
17830
17926
|
- scheduler
|
|
17831
17927
|
- watcher
|
|
17832
17928
|
sourceContextId:
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The compaction summarizer's input
|
|
2
|
+
* The compaction summarizer's input.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
4
|
+
* Both the budget gate and overflow recovery summarize the *injected* history
|
|
5
|
+
* (the genuine conversation as the agent saw it) so the summary call's prompt
|
|
6
|
+
* prefix matches the agent's warm prefix cache — a cache read rather than a
|
|
7
|
+
* fresh cache write. The agent loop hands the full injected history to
|
|
8
|
+
* compaction unconditionally; the post-compaction re-injection hook owns
|
|
9
|
+
* injection idempotency by stripping the tail's per-turn blocks before
|
|
10
|
+
* re-applying them.
|
|
10
11
|
*/
|
|
11
12
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
12
13
|
|
|
@@ -46,17 +47,17 @@ const testPostCompactPlugin = {
|
|
|
46
47
|
const CONVERSATION_ID = "compaction-strip-conversation";
|
|
47
48
|
|
|
48
49
|
/**
|
|
49
|
-
* A runtime `<workspace>` injection block.
|
|
50
|
-
*
|
|
51
|
-
*
|
|
50
|
+
* A runtime `<workspace>` injection block. Its presence in the summarizer's
|
|
51
|
+
* input proves compaction received the injected history rather than a stripped
|
|
52
|
+
* copy.
|
|
52
53
|
*/
|
|
53
54
|
const WORKSPACE_INJECTION =
|
|
54
55
|
"<workspace>\nActive workspace: project-x\n</workspace>";
|
|
55
56
|
const TURN_BODY = "Hello there, this is the turn body.";
|
|
56
57
|
|
|
57
58
|
/**
|
|
58
|
-
* A user turn carrying a real text block plus a runtime injection block
|
|
59
|
-
*
|
|
59
|
+
* A user turn carrying a real text block plus a runtime injection block, both
|
|
60
|
+
* of which ride into compaction's input as-is.
|
|
60
61
|
*/
|
|
61
62
|
const injectedUserMessage: Message = {
|
|
62
63
|
role: "user",
|
|
@@ -184,7 +185,7 @@ describe("AgentLoop compaction summarizer input", () => {
|
|
|
184
185
|
expect(events.some((e) => e.type === "history_stripped")).toBe(true);
|
|
185
186
|
});
|
|
186
187
|
|
|
187
|
-
test("overflow-driven compaction summarizes the
|
|
188
|
+
test("overflow-driven compaction summarizes the injected history", async () => {
|
|
188
189
|
// GIVEN a history whose user turn carries a runtime-injection block
|
|
189
190
|
const capture: CompactionInputCapture = { budget: null, overflow: null };
|
|
190
191
|
|
|
@@ -229,10 +230,10 @@ describe("AgentLoop compaction summarizer input", () => {
|
|
|
229
230
|
...installCapturingManager(capture),
|
|
230
231
|
});
|
|
231
232
|
|
|
232
|
-
// THEN overflow recovery received the history
|
|
233
|
+
// THEN overflow recovery received the injected history, injection intact
|
|
233
234
|
expect(capture.overflow).not.toBeNull();
|
|
234
|
-
expect(serialize(capture.overflow)).
|
|
235
|
-
// AND the real turn body
|
|
235
|
+
expect(serialize(capture.overflow)).toContain("<workspace>");
|
|
236
|
+
// AND the real turn body is present alongside the injection
|
|
236
237
|
expect(serialize(capture.overflow)).toContain(TURN_BODY);
|
|
237
238
|
// AND the budget summarizer was not invoked
|
|
238
239
|
expect(capture.budget).toBeNull();
|
|
@@ -2,9 +2,17 @@ import { existsSync } from "node:fs";
|
|
|
2
2
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
afterAll,
|
|
7
|
+
beforeAll,
|
|
8
|
+
describe,
|
|
9
|
+
expect,
|
|
10
|
+
setDefaultTimeout,
|
|
11
|
+
test,
|
|
12
|
+
} from "bun:test";
|
|
6
13
|
|
|
7
14
|
import { compileApp } from "../bundler/app-compiler.js";
|
|
15
|
+
import { ensureCompilerTools } from "../bundler/compiler-tools.js";
|
|
8
16
|
import {
|
|
9
17
|
ALLOWED_PACKAGES,
|
|
10
18
|
getCacheDir,
|
|
@@ -19,8 +27,14 @@ import {
|
|
|
19
27
|
|
|
20
28
|
let tempDir: string;
|
|
21
29
|
|
|
30
|
+
// Compiler-tool download (esbuild + preact from npm) can take 10s+ on a cold
|
|
31
|
+
// CI cache. Raise the file-level default so beforeAll isn't killed mid-install.
|
|
32
|
+
// Each test file runs in its own process, so this doesn't leak.
|
|
33
|
+
setDefaultTimeout(30_000);
|
|
34
|
+
|
|
22
35
|
beforeAll(async () => {
|
|
23
36
|
tempDir = await mkdtemp(join(tmpdir(), "app-compiler-test-"));
|
|
37
|
+
await ensureCompilerTools();
|
|
24
38
|
});
|
|
25
39
|
|
|
26
40
|
afterAll(async () => {
|
|
@@ -0,0 +1,86 @@
|
|
|
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.
|
|
8
|
+
*/
|
|
9
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
10
|
+
|
|
11
|
+
import type { Conversation } from "../daemon/conversation.js";
|
|
12
|
+
import {
|
|
13
|
+
deleteConversation,
|
|
14
|
+
setConversation,
|
|
15
|
+
} from "../daemon/conversation-registry.js";
|
|
16
|
+
import { cancelGeneration } from "../daemon/handlers/conversations.js";
|
|
17
|
+
|
|
18
|
+
interface WedgedTurnConversation {
|
|
19
|
+
isProcessing: () => boolean;
|
|
20
|
+
setProcessingCalls: () => boolean[];
|
|
21
|
+
abortCount: () => number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
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.
|
|
28
|
+
*/
|
|
29
|
+
function registerWedgedTurn(id: string): WedgedTurnConversation {
|
|
30
|
+
let processing = true;
|
|
31
|
+
let abortCount = 0;
|
|
32
|
+
const setProcessingCalls: boolean[] = [];
|
|
33
|
+
const fake = {
|
|
34
|
+
isProcessing: () => processing,
|
|
35
|
+
setProcessing: (value: boolean) => {
|
|
36
|
+
setProcessingCalls.push(value);
|
|
37
|
+
processing = value;
|
|
38
|
+
},
|
|
39
|
+
abort: () => {
|
|
40
|
+
abortCount += 1;
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
setConversation(id, fake as unknown as Conversation);
|
|
44
|
+
return {
|
|
45
|
+
isProcessing: () => processing,
|
|
46
|
+
setProcessingCalls: () => setProcessingCalls,
|
|
47
|
+
abortCount: () => abortCount,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("cancelGeneration", () => {
|
|
52
|
+
const conversationId = "cancel-clears-processing-test-conversation";
|
|
53
|
+
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
deleteConversation(conversationId);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("clears the processing flag when the in-flight turn ignores the abort signal", () => {
|
|
59
|
+
// 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);
|
|
62
|
+
expect(fake.isProcessing()).toBe(true);
|
|
63
|
+
|
|
64
|
+
// WHEN the user cancels generation
|
|
65
|
+
const cancelled = cancelGeneration(conversationId);
|
|
66
|
+
|
|
67
|
+
// THEN the cancel is acknowledged
|
|
68
|
+
expect(cancelled).toBe(true);
|
|
69
|
+
// AND the abort signal is still raised on the conversation
|
|
70
|
+
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);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("returns false for a conversation that is not registered", () => {
|
|
78
|
+
// GIVEN no conversation registered under the id
|
|
79
|
+
|
|
80
|
+
// WHEN the user cancels generation for the unknown id
|
|
81
|
+
const cancelled = cancelGeneration("cancel-clears-processing-unknown-id");
|
|
82
|
+
|
|
83
|
+
// THEN the cancel reports that nothing was found
|
|
84
|
+
expect(cancelled).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The compaction summary call bounds its own input to the context window.
|
|
3
|
+
*
|
|
4
|
+
* With no tool pair to anchor an emergency split, an overflow recovery routes
|
|
5
|
+
* the full conversation straight into `runAssistantDrivenCompaction`. If that
|
|
6
|
+
* history exceeds the window, the summary call must front-truncate its own
|
|
7
|
+
* request or it overflows in turn and recovery stalls. Below the window the
|
|
8
|
+
* request is sent untouched so its prefix stays byte-aligned with the agent's
|
|
9
|
+
* warm cache (the budget-path cache reuse must not regress).
|
|
10
|
+
*/
|
|
11
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
12
|
+
|
|
13
|
+
function makeLoggerStub(): Record<string, unknown> {
|
|
14
|
+
const stub: Record<string, unknown> = {};
|
|
15
|
+
for (const m of [
|
|
16
|
+
"info",
|
|
17
|
+
"warn",
|
|
18
|
+
"error",
|
|
19
|
+
"debug",
|
|
20
|
+
"trace",
|
|
21
|
+
"fatal",
|
|
22
|
+
"silent",
|
|
23
|
+
"child",
|
|
24
|
+
]) {
|
|
25
|
+
stub[m] = m === "child" ? () => makeLoggerStub() : () => {};
|
|
26
|
+
}
|
|
27
|
+
return stub;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
mock.module("../util/logger.js", () => ({
|
|
31
|
+
getLogger: () => makeLoggerStub(),
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
mock.module("../memory/conversation-crud.js", () => ({
|
|
35
|
+
getMessages: () => [],
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
mock.module("../memory/attachments-store.js", () => ({
|
|
39
|
+
getAttachmentMetadataForMessage: () => [],
|
|
40
|
+
getAttachmentContent: () => null,
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
mock.module("../memory/llm-request-log-store.js", () => ({
|
|
44
|
+
recordRequestLog: () => {},
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
import { runAssistantDrivenCompaction } from "../context/compactor.js";
|
|
48
|
+
import { estimatePromptTokens } from "../context/token-estimator.js";
|
|
49
|
+
import type { Message, Provider } from "../providers/types.js";
|
|
50
|
+
|
|
51
|
+
const TRUNCATION_MARKER = "summary covers only the visible portion";
|
|
52
|
+
|
|
53
|
+
function turnTimestamp(turn: number): string {
|
|
54
|
+
const hour = String(10 + Math.floor(turn / 60)).padStart(2, "0");
|
|
55
|
+
const minute = String(turn % 60).padStart(2, "0");
|
|
56
|
+
return `2026-05-21 (Thursday) ${hour}:${minute}:00 -05:00 (America/Chicago)`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function userTurn(turn: number, body: string): Message {
|
|
60
|
+
return {
|
|
61
|
+
role: "user",
|
|
62
|
+
content: [
|
|
63
|
+
{
|
|
64
|
+
type: "text",
|
|
65
|
+
text: `<turn_context>\ncurrent_time: ${turnTimestamp(
|
|
66
|
+
turn,
|
|
67
|
+
)}\n</turn_context>\n[U${turn}] ${body}`,
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function assistantTurn(turn: number, body: string): Message {
|
|
74
|
+
return {
|
|
75
|
+
role: "assistant",
|
|
76
|
+
content: [{ type: "text", text: `[A${turn}] ${body}` }],
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const SUMMARY = "Earlier turns summarized in the assistant's own voice.";
|
|
81
|
+
|
|
82
|
+
function compactionResponse(tailTurn: number, preview: string): string {
|
|
83
|
+
return `<compaction_result>
|
|
84
|
+
<summary>
|
|
85
|
+
${SUMMARY}
|
|
86
|
+
</summary>
|
|
87
|
+
<key_state>
|
|
88
|
+
- Nothing critical pending.
|
|
89
|
+
</key_state>
|
|
90
|
+
<tail_start timestamp="${turnTimestamp(tailTurn)}" preview="${preview}" />
|
|
91
|
+
</compaction_result>`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function makeCapturingProvider(response: string): {
|
|
95
|
+
provider: Provider;
|
|
96
|
+
lastRequest: () => Message[] | null;
|
|
97
|
+
} {
|
|
98
|
+
let captured: Message[] | null = null;
|
|
99
|
+
const provider: Provider = {
|
|
100
|
+
name: "mock-provider",
|
|
101
|
+
sendMessage: async (messages: Message[]) => {
|
|
102
|
+
captured = messages;
|
|
103
|
+
return {
|
|
104
|
+
content: [{ type: "text", text: response }],
|
|
105
|
+
model: "mock-model",
|
|
106
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
107
|
+
stopReason: "end_turn",
|
|
108
|
+
};
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
return { provider, lastRequest: () => captured };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function estimate(messages: Message[]): number {
|
|
115
|
+
return estimatePromptTokens(messages, "system", {
|
|
116
|
+
providerName: "mock-provider",
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
describe("runAssistantDrivenCompaction — summary call self-truncation", () => {
|
|
121
|
+
test("front-truncates the outbound request when the full history exceeds the context window", async () => {
|
|
122
|
+
// GIVEN a tool-pair-free history whose estimate exceeds the window, so an
|
|
123
|
+
// overflow recovery would route it straight into the summary call.
|
|
124
|
+
const messages: Message[] = [];
|
|
125
|
+
for (let i = 0; i < 40; i++) {
|
|
126
|
+
messages.push(
|
|
127
|
+
userTurn(
|
|
128
|
+
i,
|
|
129
|
+
"Heavy user turn body that carries real weight. ".repeat(8),
|
|
130
|
+
),
|
|
131
|
+
);
|
|
132
|
+
messages.push(
|
|
133
|
+
assistantTurn(
|
|
134
|
+
i,
|
|
135
|
+
"Heavy assistant reply that also carries weight. ".repeat(8),
|
|
136
|
+
),
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const maxInputTokens = 10_000;
|
|
141
|
+
// Budget mirrors compactor.compactionPrefixBudget: window minus the
|
|
142
|
+
// instruction reserve (800) and the 15% output reserve.
|
|
143
|
+
const prefixBudget =
|
|
144
|
+
maxInputTokens - 800 - Math.floor(maxInputTokens * 0.15);
|
|
145
|
+
expect(estimate(messages)).toBeGreaterThan(prefixBudget);
|
|
146
|
+
|
|
147
|
+
const { provider, lastRequest } = makeCapturingProvider(
|
|
148
|
+
compactionResponse(38, "Heavy user turn body"),
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
// WHEN the summary call runs against that oversized history.
|
|
152
|
+
const result = await runAssistantDrivenCompaction({
|
|
153
|
+
conversationId: "conv-test",
|
|
154
|
+
messages,
|
|
155
|
+
provider,
|
|
156
|
+
systemPrompt: "system",
|
|
157
|
+
compaction: { enabled: true, autoThreshold: 0.7 },
|
|
158
|
+
maxInputTokens,
|
|
159
|
+
force: true,
|
|
160
|
+
previousEstimatedInputTokens: 90_000,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
expect(result.compacted).toBe(true);
|
|
164
|
+
|
|
165
|
+
// THEN the request actually sent fits within the context window.
|
|
166
|
+
const sent = lastRequest();
|
|
167
|
+
expect(sent).not.toBeNull();
|
|
168
|
+
expect(estimate(sent ?? [])).toBeLessThan(maxInputTokens);
|
|
169
|
+
|
|
170
|
+
// AND it opens with a marker noting the dropped leading messages so the
|
|
171
|
+
// model knows the summary covers only the visible portion.
|
|
172
|
+
const firstBlock = sent?.[0]?.content[0];
|
|
173
|
+
const firstText = firstBlock && "text" in firstBlock ? firstBlock.text : "";
|
|
174
|
+
expect(firstText).toContain(TRUNCATION_MARKER);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("sends the full history untouched when it already fits below the budget", async () => {
|
|
178
|
+
// GIVEN a small history whose estimate is well under the window — the
|
|
179
|
+
// common budget-triggered compaction.
|
|
180
|
+
const messages: Message[] = [];
|
|
181
|
+
for (let i = 0; i < 8; i++) {
|
|
182
|
+
messages.push(userTurn(i, "short user turn body"));
|
|
183
|
+
messages.push(assistantTurn(i, "short assistant reply body"));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const maxInputTokens = 200_000;
|
|
187
|
+
const prefixBudget =
|
|
188
|
+
maxInputTokens - 800 - Math.floor(maxInputTokens * 0.15);
|
|
189
|
+
expect(estimate(messages)).toBeLessThan(prefixBudget);
|
|
190
|
+
|
|
191
|
+
const { provider, lastRequest } = makeCapturingProvider(
|
|
192
|
+
compactionResponse(6, "short user turn body"),
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
// WHEN the summary call runs against the below-budget history.
|
|
196
|
+
const result = await runAssistantDrivenCompaction({
|
|
197
|
+
conversationId: "conv-test",
|
|
198
|
+
messages,
|
|
199
|
+
provider,
|
|
200
|
+
systemPrompt: "system",
|
|
201
|
+
compaction: { enabled: true, autoThreshold: 0.7 },
|
|
202
|
+
maxInputTokens,
|
|
203
|
+
force: true,
|
|
204
|
+
previousEstimatedInputTokens: 90_000,
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
expect(result.compacted).toBe(true);
|
|
208
|
+
|
|
209
|
+
// THEN no truncation marker is prepended and the request carries every
|
|
210
|
+
// history message plus the single instruction, with the leading message
|
|
211
|
+
// byte-identical to the original — keeping the prefix cache warm.
|
|
212
|
+
const sent = lastRequest();
|
|
213
|
+
expect(sent).not.toBeNull();
|
|
214
|
+
expect(sent?.length).toBe(messages.length + 1);
|
|
215
|
+
const firstBlock = sent?.[0]?.content[0];
|
|
216
|
+
const firstText = firstBlock && "text" in firstBlock ? firstBlock.text : "";
|
|
217
|
+
expect(firstText).not.toContain(TRUNCATION_MARKER);
|
|
218
|
+
const originalFirst = messages[0]?.content[0];
|
|
219
|
+
expect(firstText).toBe(
|
|
220
|
+
originalFirst && "text" in originalFirst ? originalFirst.text : "",
|
|
221
|
+
);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
@@ -1748,6 +1748,57 @@ describe("session-agent-loop", () => {
|
|
|
1748
1748
|
// THEN the queue is drained with the loop-complete reason
|
|
1749
1749
|
expect(drainReason).toBe("loop_complete");
|
|
1750
1750
|
});
|
|
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 } = {};
|
|
1762
|
+
const provider: Provider = {
|
|
1763
|
+
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
|
+
};
|
|
1778
|
+
},
|
|
1779
|
+
};
|
|
1780
|
+
const ctx = makeCtx({
|
|
1781
|
+
loopProvider: provider,
|
|
1782
|
+
drainQueue: async () => {
|
|
1783
|
+
drained = true;
|
|
1784
|
+
},
|
|
1785
|
+
});
|
|
1786
|
+
ctxHolder.conversation = ctx;
|
|
1787
|
+
|
|
1788
|
+
// WHEN the superseded turn runs to completion
|
|
1789
|
+
await runAgentLoopImpl(ctx, "hi", "msg-1", () => {});
|
|
1790
|
+
|
|
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);
|
|
1801
|
+
});
|
|
1751
1802
|
});
|
|
1752
1803
|
|
|
1753
1804
|
describe("stale pending surface cleanup", () => {
|