@pensar/apex 2.6.1 → 2.7.0-canary.52c132f4

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 (221) hide show
  1. package/build/{agent-yby11zaq.js → agent-112gdg69.js} +6 -6
  2. package/build/agent-9bh42v64.js +13 -0
  3. package/build/agent-jg7v2yn0.js +13 -0
  4. package/build/{agent-runs-g37vkrjr.js → agent-runs-hhjzsysf.js} +18 -18
  5. package/build/{agent-ayreaj6z.js → agent-ydy5fq92.js} +5 -5
  6. package/build/{apps-xn6km9yn.js → apps-v4jc7eqb.js} +13 -13
  7. package/build/{auth-d105wc64.js → auth-bxkn5pcb.js} +13 -13
  8. package/build/{blackboxAgent-s56sgx9b.js → blackboxAgent-260yqknt.js} +6 -6
  9. package/build/{blackboxPentest-ps78v3w7.js → blackboxPentest-nbn588pb.js} +17 -12
  10. package/build/{cli-3sp6nd8d.js → cli-087apjgk.js} +2 -2
  11. package/build/{cli-1ve18npr.js → cli-1wtxf161.js} +3 -2
  12. package/build/{cli-d37a42dc.js → cli-3158x9wb.js} +101 -18
  13. package/build/{cli-fy6vh2a7.js → cli-35gz0ahm.js} +1 -1
  14. package/build/{cli-65g6rcat.js → cli-5xdefy58.js} +1 -1
  15. package/build/{cli-709rvekb.js → cli-6vpprmxb.js} +4 -2
  16. package/build/{cli-az3xq70v.js → cli-8hrdnq0e.js} +1 -1
  17. package/build/{cli-7eea4e2y.js → cli-ahk627k3.js} +6 -6
  18. package/build/{cli-c05y640n.js → cli-bc793xfp.js} +1 -1
  19. package/build/{cli-ae8jqxqr.js → cli-cs7hsb1w.js} +2 -2
  20. package/build/{cli-qgz1q9g5.js → cli-ene4g09p.js} +223 -76
  21. package/build/{cli-gx71jcke.js → cli-k8m6737j.js} +1 -1
  22. package/build/{cli-dpsw2fe6.js → cli-mm2vxyne.js} +360 -187
  23. package/build/{cli-ngq9dr8n.js → cli-mpmpfce6.js} +3 -3
  24. package/build/{cli-kb21qhvm.js → cli-np9x97h3.js} +4 -3
  25. package/build/{cli-k2nwxta0.js → cli-tb77javm.js} +2 -2
  26. package/build/{cli-xtc2t6me.js → cli-wa52mx4p.js} +103 -39
  27. package/build/{cli-n2en7j5b.js → cli-xe4wq07r.js} +1 -1
  28. package/build/cli.js +70 -40
  29. package/build/{config-zpqkd5cz.js → config-gpxx6m5z.js} +1 -1
  30. package/build/{fastStrike-mcjdvwez.js → fastStrike-v0r461wn.js} +67 -15
  31. package/build/{fixes-kytx5dmj.js → fixes-c51d83b4.js} +13 -13
  32. package/build/{index-qv4cc65p.js → index-269a90mm.js} +13 -13
  33. package/build/{index-wqetp6gq.js → index-3ggmqh6x.js} +12 -7
  34. package/build/{index-4vqdgb42.js → index-e97991ve.js} +1473 -1217
  35. package/build/{index-n65z6v7b.js → index-ffay4gb2.js} +6 -6
  36. package/build/{index-ns4m4s01.js → index-gw6t92t5.js} +4 -4
  37. package/build/{index-dr3tg1w3.js → index-kkt51z6t.js} +1 -1
  38. package/build/{index-fv64eb32.js → index-kmaq2ym2.js} +2 -2
  39. package/build/{index-je6hcsqr.js → index-wgas3zgb.js} +1 -1
  40. package/build/{index-r9yjkfrv.js → index-xfqh0wte.js} +4 -4
  41. package/build/{issues-eb3q9d3a.js → issues-sq3zya6y.js} +13 -13
  42. package/build/{knowledge-fqsvtx0f.js → knowledge-kzd0cgcm.js} +13 -13
  43. package/build/lib/{chunk-r4j8ybn9.js → chunk-0parq6kp.js} +87 -36
  44. package/build/lib/{chunk-v3dk9j7k.js → chunk-2rcfbtpx.js} +80 -21
  45. package/build/lib/{chunk-acgbyf5h.js → chunk-5axh2ztr.js} +3 -2
  46. package/build/lib/{chunk-yrnrbjes.js → chunk-5qdc4rc6.js} +4 -2
  47. package/build/lib/{chunk-xhdpgpkm.js → chunk-61pnyq94.js} +2 -2
  48. package/build/lib/{chunk-1azdancq.js → chunk-7p9f6cxx.js} +1 -1
  49. package/build/lib/{chunk-z137x08b.js → chunk-7sexq62c.js} +7 -5
  50. package/build/lib/{chunk-ba41jh41.js → chunk-7zayxxn6.js} +304 -129
  51. package/build/lib/{chunk-3c00n0cy.js → chunk-83nets1m.js} +8 -3
  52. package/build/lib/{chunk-e8yy2k73.js → chunk-ajfd3s37.js} +28 -2
  53. package/build/lib/{chunk-4hwzqthr.js → chunk-ay1fx18z.js} +3 -2
  54. package/build/lib/{chunk-1fy8azee.js → chunk-bgnq735r.js} +1 -1
  55. package/build/lib/{chunk-mg03jdmt.js → chunk-eaanxcjj.js} +1 -1
  56. package/build/lib/{chunk-w66ce82a.js → chunk-f28sa6x2.js} +4 -2
  57. package/build/lib/{chunk-z96pbjhd.js → chunk-f6xde44n.js} +13 -6
  58. package/build/lib/{chunk-1jvwqnem.js → chunk-fvgehnst.js} +104 -40
  59. package/build/lib/{chunk-2qsq76xq.js → chunk-fy2a1dm3.js} +128 -34
  60. package/build/lib/{chunk-7pwa92rn.js → chunk-jqy0ftnp.js} +1 -1
  61. package/build/lib/{chunk-bb0ekhkw.js → chunk-k6erk6fv.js} +69 -18
  62. package/build/lib/{chunk-9tt2021m.js → chunk-s338j54x.js} +9 -9
  63. package/build/lib/{chunk-1yct6cra.js → chunk-se6sk7e4.js} +1 -1
  64. package/build/lib/{chunk-nhfax4gb.js → chunk-tdprhz5x.js} +1 -1
  65. package/build/lib/{chunk-r2dkp4tb.js → chunk-wbt03786.js} +1 -1
  66. package/build/lib/{chunk-9yksqgzr.js → chunk-yk43cvgb.js} +20 -13
  67. package/build/lib/core/agents/agentRuntime.d.ts +2 -1
  68. package/build/lib/core/agents/defineAgent.d.ts +3 -2
  69. package/build/lib/core/agents/offSecAgent/index.d.ts +3 -1
  70. package/build/lib/core/agents/offSecAgent/index.js +10 -8
  71. package/build/lib/core/agents/offSecAgent/prompt.d.ts +3 -1
  72. package/build/lib/core/agents/offSecAgent/subagentSpawner.d.ts +2 -1
  73. package/build/lib/core/agents/offSecAgent/subagentSpawner.js +5 -5
  74. package/build/lib/core/agents/offSecAgent/tools/index.js +4 -4
  75. package/build/lib/core/agents/offSecAgent/tools/listMemories.d.ts +11 -1
  76. package/build/lib/core/agents/offSecAgent/tools/reportError.d.ts +2 -1
  77. package/build/lib/core/agents/offSecAgent/tools/runPentestWorkflow.d.ts +20 -1
  78. package/build/lib/core/agents/offSecAgent/tools/spawnPentestAgent.d.ts +4 -7
  79. package/build/lib/core/agents/offSecAgent/tools/spawnPentestSwarm.d.ts +7 -1
  80. package/build/lib/core/agents/offSecAgent/tools/types.d.ts +2 -1
  81. package/build/lib/core/agents/offSecAgent/types.d.ts +7 -1
  82. package/build/lib/core/agents/specialized/attackSurface/blackboxAgent.js +9 -9
  83. package/build/lib/core/agents/specialized/attackSurface/schemas.js +9 -9
  84. package/build/lib/core/agents/specialized/authenticationAgent/index.js +10 -10
  85. package/build/lib/core/agents/specialized/codeAgent/agent.js +9 -9
  86. package/build/lib/core/agents/specialized/codeAgent/prompts.d.ts +1 -0
  87. package/build/lib/core/agents/specialized/findingJudge/agent.js +8 -8
  88. package/build/lib/core/agents/specialized/findingJudge/index.js +3 -3
  89. package/build/lib/core/agents/specialized/patching/index.js +10 -10
  90. package/build/lib/core/agents/specialized/pentest/agent.d.ts +18 -4
  91. package/build/lib/core/agents/specialized/pentest/agent.js +11 -9
  92. package/build/lib/core/agents/specialized/whiteboxAttackSurface/index.js +9 -9
  93. package/build/lib/core/ai/ai.d.ts +23 -6
  94. package/build/lib/core/ai/ai.js +2 -2
  95. package/build/lib/core/ai/index.js +2 -2
  96. package/build/lib/core/ai/models/claude-capabilities.d.ts +3 -2
  97. package/build/lib/core/ai/models/index.d.ts +1 -1
  98. package/build/lib/core/ai/utils.d.ts +2 -0
  99. package/build/lib/core/ai/utils.js +4 -2
  100. package/build/lib/core/api/index.d.ts +3 -0
  101. package/build/lib/core/api/index.js +22 -19
  102. package/build/lib/core/api/targetedPentest.d.ts +1 -0
  103. package/build/lib/core/api/targetedPentest.js +10 -10
  104. package/build/lib/core/config/config.d.ts +3 -0
  105. package/build/lib/core/config/index.js +1 -1
  106. package/build/lib/core/findings/registry.js +3 -3
  107. package/build/lib/core/session/index.d.ts +12 -0
  108. package/build/lib/core/session/index.js +2 -2
  109. package/build/lib/core/session/persistence.d.ts +7 -0
  110. package/build/lib/core/session/persistence.js +7 -3
  111. package/build/lib/core/workflows/fastStrike.d.ts +3 -1
  112. package/build/lib/core/workflows/pentest.d.ts +8 -1
  113. package/build/lib/core/workflows/pentest.js +15 -15
  114. package/build/lib/core/workflows/seams.js +4 -4
  115. package/build/lib/core/workflows/whiteboxAttackSurface.js +11 -11
  116. package/build/lib/index.js +22 -22
  117. package/build/{logs-x62b7hk1.js → logs-yk8dk6aa.js} +13 -13
  118. package/build/{native-rollout-evidence-c9yhfqcs.js → native-rollout-evidence-krpdfdv7.js} +3 -3
  119. package/build/{offesecAgent-5whnpeta.js → offesecAgent-aw87gf37.js} +4 -4
  120. package/build/pentest-t0p4nw2a.js +22 -0
  121. package/build/{pentests-e0f32aqx.js → pentests-577n3x75.js} +13 -13
  122. package/build/recordedRunClient-d7947b0z.js +11 -0
  123. package/build/{targetedPentest-rcbs4nvp.js → targetedPentest-7v849dtm.js} +18 -11
  124. package/build/{targets-xj9g3q0z.js → targets-3vjxv3yj.js} +13 -13
  125. package/build/threatModel-n0grwkav.js +21 -0
  126. package/build/{uninstall-7s784q4a.js → uninstall-ctv33v8j.js} +1 -1
  127. package/build/{upload-4gvhwmvr.js → upload-rnec235r.js} +4 -4
  128. package/build/{utils-cjrz64pc.js → utils-6420gkkg.js} +3 -3
  129. package/package.json +1 -1
  130. package/src/cli.ts +48 -14
  131. package/src/core/agents/agentRuntime.test.ts +38 -0
  132. package/src/core/agents/agentRuntime.ts +5 -2
  133. package/src/core/agents/defineAgent.ts +3 -1
  134. package/src/core/agents/offSecAgent/index.ts +3 -0
  135. package/src/core/agents/offSecAgent/offensiveSecurityAgent.test.ts +92 -5
  136. package/src/core/agents/offSecAgent/offensiveSecurityAgent.ts +70 -31
  137. package/src/core/agents/offSecAgent/prompt.test.ts +46 -0
  138. package/src/core/agents/offSecAgent/prompt.ts +52 -8
  139. package/src/core/agents/offSecAgent/selectiveConstruction.publicPath.test.ts +38 -0
  140. package/src/core/agents/offSecAgent/selectiveConstruction.test.ts +64 -0
  141. package/src/core/agents/offSecAgent/subagentSpawner.ts +12 -1
  142. package/src/core/agents/offSecAgent/tools/delegateAuth.ts +1 -0
  143. package/src/core/agents/offSecAgent/tools/listMemories.test.ts +230 -0
  144. package/src/core/agents/offSecAgent/tools/listMemories.ts +73 -6
  145. package/src/core/agents/offSecAgent/tools/reportError.test.ts +6 -0
  146. package/src/core/agents/offSecAgent/tools/reportError.ts +10 -2
  147. package/src/core/agents/offSecAgent/tools/runAttackSurface.ts +2 -0
  148. package/src/core/agents/offSecAgent/tools/runPentestWorkflow.test.ts +57 -0
  149. package/src/core/agents/offSecAgent/tools/runPentestWorkflow.ts +19 -1
  150. package/src/core/agents/offSecAgent/tools/runWhiteboxScan.ts +5 -2
  151. package/src/core/agents/offSecAgent/tools/spawnCodingAgent.ts +1 -0
  152. package/src/core/agents/offSecAgent/tools/spawnPentestAgent.test.ts +72 -2
  153. package/src/core/agents/offSecAgent/tools/spawnPentestAgent.ts +32 -11
  154. package/src/core/agents/offSecAgent/tools/spawnPentestSwarm.ts +25 -5
  155. package/src/core/agents/offSecAgent/tools/threatModelGenerator.ts +1 -0
  156. package/src/core/agents/offSecAgent/tools/types.ts +6 -1
  157. package/src/core/agents/offSecAgent/tools/updateTask.ts +5 -1
  158. package/src/core/agents/offSecAgent/tools/whiteboxCandidates.ts +11 -4
  159. package/src/core/agents/offSecAgent/types.ts +9 -0
  160. package/src/core/agents/specialized/codeAgent/agent.test.ts +7 -0
  161. package/src/core/agents/specialized/codeAgent/agent.ts +9 -2
  162. package/src/core/agents/specialized/codeAgent/prompts.ts +6 -0
  163. package/src/core/agents/specialized/pentest/agent.test.ts +116 -0
  164. package/src/core/agents/specialized/pentest/agent.ts +178 -49
  165. package/src/core/agents/specialized/pentest/results.test.ts +59 -4
  166. package/src/core/agents/specialized/whiteboxAttackSurface/endpointDocumentationAgent.ts +1 -0
  167. package/src/core/ai/ai.operator-context.bench.test.ts +1408 -0
  168. package/src/core/ai/ai.recorded-context.test.ts +74 -0
  169. package/src/core/ai/ai.test.ts +54 -0
  170. package/src/core/ai/ai.ts +299 -80
  171. package/src/core/ai/compactionTelemetry.test.ts +2 -2
  172. package/src/core/ai/models/anthropic.ts +6 -0
  173. package/src/core/ai/models/bedrock.ts +18 -0
  174. package/src/core/ai/models/claude-capabilities.ts +29 -8
  175. package/src/core/ai/models/index.ts +4 -1
  176. package/src/core/ai/models/models.test.ts +23 -0
  177. package/src/core/ai/models/openrouter.ts +6 -0
  178. package/src/core/ai/providers/anthropic-models.test.ts +60 -1
  179. package/src/core/ai/providers/bedrock-models.test.ts +81 -0
  180. package/src/core/ai/providers/gateway-models.test.ts +16 -6
  181. package/src/core/ai/utils.native-rollout-evidence.test.ts +1 -0
  182. package/src/core/ai/utils.ts +189 -143
  183. package/src/core/api/blackboxPentest.ts +9 -1
  184. package/src/core/api/index.ts +9 -0
  185. package/src/core/api/targetedPentest.ts +19 -5
  186. package/src/core/config/config.test.ts +35 -0
  187. package/src/core/config/config.ts +5 -0
  188. package/src/core/observability/telemetry.test.ts +2 -3
  189. package/src/core/session/index.ts +5 -0
  190. package/src/core/session/persistence.test.ts +62 -0
  191. package/src/core/session/persistence.ts +56 -0
  192. package/src/core/workflows/fastStrike.seams.test.ts +149 -0
  193. package/src/core/workflows/fastStrike.test.ts +18 -1
  194. package/src/core/workflows/fastStrike.ts +103 -11
  195. package/src/core/workflows/pentest.test.ts +104 -0
  196. package/src/core/workflows/pentest.ts +110 -7
  197. package/src/core/workflows/seams.test.ts +13 -0
  198. package/src/core/workflows/seams.ts +5 -1
  199. package/src/core/workflows/whiteboxAttackSurface.ts +1 -0
  200. package/src/tui/components/chat/loading-indicator.tsx +20 -4
  201. package/src/tui/components/chat/message-list.tsx +1 -0
  202. package/src/tui/components/commands/__tests__/advanced-settings-lifecycle.tsx +176 -0
  203. package/src/tui/components/commands/advanced-settings.test.ts +28 -0
  204. package/src/tui/components/commands/advanced-settings.tsx +237 -19
  205. package/src/tui/components/operator-dashboard/index.tsx +46 -9
  206. package/src/tui/components/operator-dashboard/logic.test.ts +106 -0
  207. package/src/tui/components/operator-dashboard/logic.ts +42 -2
  208. package/src/tui/components/operator-dashboard/run-events.test.ts +3 -1
  209. package/src/tui/components/shared/__tests__/tool-renderer-lifecycle.tsx +18 -0
  210. package/src/tui/components/shared/action-label.test.ts +9 -0
  211. package/src/tui/components/shared/compacting-icon.tsx +18 -0
  212. package/src/tui/components/shared/index.ts +1 -0
  213. package/src/tui/components/shared/tool-registry.ts +1 -0
  214. package/src/tui/components/shared/tool-renderer.tsx +21 -7
  215. package/src/tui/context/route.tsx +1 -0
  216. package/src/tui/keybindings/registry.ts +2 -0
  217. package/build/agent-6w7egcf1.js +0 -13
  218. package/build/agent-9zx75feh.js +0 -13
  219. package/build/pentest-e61hc38s.js +0 -22
  220. package/build/recordedRunClient-dp032c32.js +0 -11
  221. package/build/threatModel-jkxfa40s.js +0 -21
@@ -0,0 +1,1408 @@
1
+ import { mkdtempSync, rmSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import type { LanguageModelV3StreamPart } from "@ai-sdk/provider";
5
+ import {
6
+ type ModelMessage,
7
+ simulateReadableStream,
8
+ stepCountIs,
9
+ tool,
10
+ } from "ai";
11
+ import { MockLanguageModelV3 } from "ai/test";
12
+ import { expect, it, vi } from "vitest";
13
+ import { z } from "zod";
14
+ import {
15
+ estimateMessageTokens,
16
+ estimateToolsOverheadTokens,
17
+ fitMessagesToContext,
18
+ } from "./contextManagement";
19
+
20
+ const state: { model: MockLanguageModelV3 | null } = vi.hoisted(() => ({
21
+ model: null,
22
+ }));
23
+
24
+ vi.mock("./utils", async () => ({
25
+ ...(await vi.importActual<typeof import("./utils")>("./utils")),
26
+ getProviderModel: () => {
27
+ if (!state.model) throw new Error("synthetic model is unavailable");
28
+ return state.model;
29
+ },
30
+ }));
31
+
32
+ const { streamResponse, ContextLengthExhaustedError } = await import("./ai");
33
+ const { createSummarizationStream } = await import("./utils");
34
+
35
+ const MODEL = "custom:fixture:synthetic";
36
+ const TOOL_STEPS = 72;
37
+ const TOOL_INPUT = "q".repeat(1_400);
38
+ const TOOL_OUTPUT = "e".repeat(4_000);
39
+ const usage = {
40
+ inputTokens: { total: 10, noCache: 10, cacheRead: 0, cacheWrite: 0 },
41
+ outputTokens: { total: 5, text: 5, reasoning: undefined },
42
+ };
43
+
44
+ it("measures a synthetic long tool loop through the real stream and context paths", async () => {
45
+ const sessionPath = mkdtempSync(join(tmpdir(), "apex-context-bench-"));
46
+ const providerInputTokens: number[] = [];
47
+ let providerCalls = 0;
48
+ let summaryCalls = 0;
49
+ let compactionEvents = 0;
50
+ const summaryPrompts: string[] = [];
51
+ const probe = tool({
52
+ description: "Return synthetic evidence",
53
+ inputSchema: z.object({ q: z.string() }),
54
+ execute: async () => TOOL_OUTPUT,
55
+ });
56
+
57
+ state.model = new MockLanguageModelV3({
58
+ doStream: async (options) => {
59
+ providerInputTokens.push(
60
+ estimateMessageTokens(options.prompt as ModelMessage[]),
61
+ );
62
+ const call = providerCalls++;
63
+ const toolStep = call < TOOL_STEPS;
64
+ const chunks: unknown[] = toolStep
65
+ ? [
66
+ {
67
+ type: "tool-call",
68
+ toolCallId: `probe-${call}`,
69
+ toolName: "probe",
70
+ input: JSON.stringify({ q: TOOL_INPUT }),
71
+ },
72
+ ]
73
+ : [
74
+ { type: "text-start", id: "done" },
75
+ { type: "text-delta", id: "done", delta: "done" },
76
+ { type: "text-end", id: "done" },
77
+ ];
78
+ chunks.push({
79
+ type: "finish",
80
+ finishReason: {
81
+ unified: toolStep ? "tool-calls" : "stop",
82
+ raw: toolStep ? "tool_calls" : "stop",
83
+ },
84
+ usage,
85
+ });
86
+ return {
87
+ stream: simulateReadableStream({
88
+ chunks: chunks as LanguageModelV3StreamPart[],
89
+ }),
90
+ };
91
+ },
92
+ doGenerate: async (options) => {
93
+ summaryCalls++;
94
+ summaryPrompts.push(JSON.stringify(options.prompt));
95
+ return {
96
+ content: [
97
+ { type: "text" as const, text: "Synthetic progress: continue." },
98
+ ],
99
+ finishReason: { unified: "stop" as const, raw: "stop" },
100
+ usage,
101
+ warnings: [],
102
+ };
103
+ },
104
+ });
105
+
106
+ try {
107
+ const stream = streamResponse({
108
+ model: MODEL,
109
+ prompt: "Run the synthetic probes and finish.",
110
+ system: "Synthetic benchmark; no external target.",
111
+ tools: { probe },
112
+ stopWhen: stepCountIs(1_000),
113
+ sessionPath,
114
+ silent: true,
115
+ authConfig: {
116
+ customProviders: {
117
+ fixture: {
118
+ baseUrl: "https://example.invalid/v1",
119
+ models: [
120
+ {
121
+ id: "synthetic",
122
+ contextLength: 32_000,
123
+ maxOutputTokens: 4_000,
124
+ },
125
+ ],
126
+ },
127
+ },
128
+ },
129
+ });
130
+ const seen: string[] = [];
131
+ for await (const part of stream.fullStream) {
132
+ if (part.type === "text-delta") seen.push(part.text);
133
+ if (
134
+ part.type === "tool-call" &&
135
+ part.toolName === "summarize_conversation"
136
+ ) {
137
+ compactionEvents++;
138
+ }
139
+ }
140
+
141
+ expect(providerCalls).toBe(TOOL_STEPS + 1);
142
+ expect(seen.at(-1)).toBe("done");
143
+ expect(summaryCalls).toBeGreaterThan(0);
144
+ expect(compactionEvents).toBe(summaryCalls);
145
+ expect(summaryPrompts[0]).toContain("probe-0");
146
+ expect(summaryPrompts[0]).toContain(
147
+ "Synthetic benchmark; no external target.",
148
+ );
149
+ expect(Math.max(...providerInputTokens)).toBeLessThan(18_000);
150
+ expect(
151
+ providerInputTokens.reduce((total, value) => total + value, 0),
152
+ ).toBeLessThan(1_300_000);
153
+ if (process.env.APEX_CONTEXT_BENCH === "1") {
154
+ console.log(
155
+ "OPERATOR_CONTEXT_BENCH",
156
+ JSON.stringify({
157
+ toolSteps: TOOL_STEPS,
158
+ providerCalls,
159
+ summaryCalls,
160
+ compactionEvents,
161
+ peakEstimatedInputTokens: Math.max(...providerInputTokens),
162
+ cumulativeEstimatedInputTokens: providerInputTokens.reduce(
163
+ (total, value) => total + value,
164
+ 0,
165
+ ),
166
+ firstEstimatedInputTokens: providerInputTokens[0],
167
+ lastEstimatedInputTokens: providerInputTokens.at(-1),
168
+ }),
169
+ );
170
+ }
171
+ } finally {
172
+ state.model = null;
173
+ rmSync(sessionPath, { recursive: true, force: true });
174
+ }
175
+ });
176
+
177
+ it.each([
178
+ "auto",
179
+ "required",
180
+ ] as const)("preserves early tool evidence with %s tool choice", async (toolChoice) => {
181
+ const priorMessages: ModelMessage[] = [
182
+ { role: "user", content: "Inspect the fixed sources." },
183
+ ...Array.from({ length: 32 }, (_, index): ModelMessage[] => [
184
+ {
185
+ role: "assistant",
186
+ content: [
187
+ {
188
+ type: "tool-call",
189
+ toolCallId: `read-${index}`,
190
+ toolName: "probe",
191
+ input: {},
192
+ },
193
+ ],
194
+ },
195
+ {
196
+ role: "tool",
197
+ content: [
198
+ {
199
+ type: "tool-result",
200
+ toolCallId: `read-${index}`,
201
+ toolName: "probe",
202
+ output: {
203
+ type: "text",
204
+ value: `source-${index}:${"x".repeat(20_000)}`,
205
+ },
206
+ },
207
+ ],
208
+ },
209
+ ]).flat(),
210
+ ];
211
+ let summaryPrompt = "";
212
+ let resumedPrompt = "";
213
+ let summaryCount = 0;
214
+ state.model = new MockLanguageModelV3({
215
+ doGenerate: async (options) => {
216
+ summaryCount++;
217
+ summaryPrompt = JSON.stringify(options.prompt);
218
+ return {
219
+ content: [
220
+ { type: "text" as const, text: "The first source is source-0." },
221
+ ],
222
+ finishReason: { unified: "stop" as const, raw: "stop" },
223
+ usage,
224
+ warnings: [],
225
+ };
226
+ },
227
+ doStream: async (options) => {
228
+ resumedPrompt = JSON.stringify(options.prompt);
229
+ return {
230
+ stream: simulateReadableStream({
231
+ chunks: [
232
+ { type: "text-start", id: "done" },
233
+ { type: "text-delta", id: "done", delta: "done" },
234
+ { type: "text-end", id: "done" },
235
+ {
236
+ type: "finish",
237
+ finishReason: { unified: "stop", raw: "stop" },
238
+ usage,
239
+ },
240
+ ] as LanguageModelV3StreamPart[],
241
+ }),
242
+ };
243
+ },
244
+ });
245
+
246
+ try {
247
+ const stream = streamResponse({
248
+ model: MODEL,
249
+ prompt: "Continue the inspection.",
250
+ system: "Keep all source evidence available.",
251
+ messages: priorMessages,
252
+ toolChoice,
253
+ silent: true,
254
+ authConfig: {
255
+ customProviders: {
256
+ fixture: {
257
+ baseUrl: "https://example.invalid/v1",
258
+ models: [
259
+ {
260
+ id: "synthetic",
261
+ contextLength: 200_000,
262
+ maxOutputTokens: 20_000,
263
+ },
264
+ ],
265
+ },
266
+ },
267
+ },
268
+ });
269
+ for await (const _part of stream.fullStream) void _part;
270
+ expect(summaryCount).toBe(toolChoice === "auto" ? 1 : 0);
271
+ if (toolChoice === "auto") {
272
+ expect(summaryPrompt.includes("source-0:")).toBe(true);
273
+ expect(summaryPrompt.includes("source-31:")).toBe(true);
274
+ expect(
275
+ summaryPrompt.includes("Keep all source evidence available."),
276
+ ).toBe(true);
277
+ expect(resumedPrompt.includes("The first source is source-0.")).toBe(
278
+ true,
279
+ );
280
+ } else {
281
+ expect(resumedPrompt.includes("source-0:")).toBe(true);
282
+ }
283
+ } finally {
284
+ state.model = null;
285
+ }
286
+ });
287
+
288
+ it.each([
289
+ "empty",
290
+ "ineffective",
291
+ ])("keeps fitting history after an %s proactive summary", async (summaryKind) => {
292
+ const messages: ModelMessage[] = [
293
+ { role: "user", content: "Inspect the fixed sources." },
294
+ ...Array.from({ length: 32 }, (_, index): ModelMessage[] => [
295
+ {
296
+ role: "assistant",
297
+ content: [
298
+ {
299
+ type: "tool-call",
300
+ toolCallId: `read-${index}`,
301
+ toolName: "probe",
302
+ input: {},
303
+ },
304
+ ],
305
+ },
306
+ {
307
+ role: "tool",
308
+ content: [
309
+ {
310
+ type: "tool-result",
311
+ toolCallId: `read-${index}`,
312
+ toolName: "probe",
313
+ output: {
314
+ type: "text",
315
+ value: `source-${index}:${"x".repeat(20_000)}`,
316
+ },
317
+ },
318
+ ],
319
+ },
320
+ ]).flat(),
321
+ ];
322
+ let summaryCalls = 0;
323
+ let resumedPrompt = "";
324
+ let skipped = false;
325
+ const onSummarized = vi.fn();
326
+ state.model = new MockLanguageModelV3({
327
+ doGenerate: async () => {
328
+ summaryCalls++;
329
+ return {
330
+ content:
331
+ summaryKind === "empty"
332
+ ? []
333
+ : [{ type: "text" as const, text: "s".repeat(540_000) }],
334
+ finishReason: { unified: "stop" as const, raw: "stop" },
335
+ usage,
336
+ warnings: [],
337
+ };
338
+ },
339
+ doStream: async (options) => {
340
+ resumedPrompt = JSON.stringify(options.prompt);
341
+ return {
342
+ stream: simulateReadableStream({
343
+ chunks: [
344
+ { type: "text-start", id: "done" },
345
+ { type: "text-delta", id: "done", delta: "done" },
346
+ { type: "text-end", id: "done" },
347
+ {
348
+ type: "finish",
349
+ finishReason: { unified: "stop", raw: "stop" },
350
+ usage,
351
+ },
352
+ ] as LanguageModelV3StreamPart[],
353
+ }),
354
+ };
355
+ },
356
+ });
357
+
358
+ try {
359
+ const stream = streamResponse({
360
+ model: MODEL,
361
+ prompt: "Continue the inspection.",
362
+ messages,
363
+ onSummarized,
364
+ silent: true,
365
+ authConfig: {
366
+ customProviders: {
367
+ fixture: {
368
+ baseUrl: "https://example.invalid/v1",
369
+ models: [
370
+ {
371
+ id: "synthetic",
372
+ contextLength: 200_000,
373
+ maxOutputTokens: 20_000,
374
+ },
375
+ ],
376
+ },
377
+ },
378
+ },
379
+ });
380
+ for await (const part of stream.fullStream) {
381
+ if (
382
+ part.type === "tool-result" &&
383
+ JSON.stringify(part).includes("Compaction skipped")
384
+ ) {
385
+ skipped = true;
386
+ }
387
+ }
388
+ expect(summaryCalls).toBe(summaryKind === "empty" ? 2 : 1);
389
+ expect(skipped).toBe(true);
390
+ expect(resumedPrompt).toContain("source-0:");
391
+ expect(resumedPrompt).toContain("source-31:");
392
+ expect(onSummarized).not.toHaveBeenCalled();
393
+ } finally {
394
+ state.model = null;
395
+ }
396
+ });
397
+
398
+ it("retries proactive compaction after a new tool result", async () => {
399
+ const messages: ModelMessage[] = [
400
+ { role: "user", content: "Keep the first source." },
401
+ ...Array.from({ length: 2 }, (_, index): ModelMessage[] => [
402
+ {
403
+ role: "assistant",
404
+ content: [
405
+ {
406
+ type: "tool-call",
407
+ toolCallId: `old-${index}`,
408
+ toolName: "probe",
409
+ input: {},
410
+ },
411
+ ],
412
+ },
413
+ {
414
+ role: "tool",
415
+ content: [
416
+ {
417
+ type: "tool-result",
418
+ toolCallId: `old-${index}`,
419
+ toolName: "probe",
420
+ output: {
421
+ type: "text",
422
+ value: `source-${index}:${"x".repeat(300_000)}`,
423
+ },
424
+ },
425
+ ],
426
+ },
427
+ ]).flat(),
428
+ ];
429
+ let summaryCalls = 0;
430
+ let providerCalls = 0;
431
+ let retryPrompt = "";
432
+ const onSummarized = vi.fn();
433
+ state.model = new MockLanguageModelV3({
434
+ doGenerate: async (options) => {
435
+ summaryCalls++;
436
+ if (summaryCalls === 3) retryPrompt = JSON.stringify(options.prompt);
437
+ return {
438
+ content:
439
+ summaryCalls < 3
440
+ ? []
441
+ : [
442
+ {
443
+ type: "text" as const,
444
+ text: "The first source is source-0.",
445
+ },
446
+ ],
447
+ finishReason: { unified: "stop" as const, raw: "stop" },
448
+ usage,
449
+ warnings: [],
450
+ };
451
+ },
452
+ doStream: async () => {
453
+ const call = providerCalls++;
454
+ return {
455
+ stream: simulateReadableStream({
456
+ chunks:
457
+ call === 0
458
+ ? ([
459
+ {
460
+ type: "tool-call",
461
+ toolCallId: "new-source",
462
+ toolName: "probe",
463
+ input: "{}",
464
+ },
465
+ {
466
+ type: "finish",
467
+ finishReason: { unified: "tool-calls", raw: "tool_calls" },
468
+ usage,
469
+ },
470
+ ] as LanguageModelV3StreamPart[])
471
+ : ([
472
+ { type: "text-start", id: "done" },
473
+ { type: "text-delta", id: "done", delta: "done" },
474
+ { type: "text-end", id: "done" },
475
+ {
476
+ type: "finish",
477
+ finishReason: { unified: "stop", raw: "stop" },
478
+ usage,
479
+ },
480
+ ] as LanguageModelV3StreamPart[]),
481
+ }),
482
+ };
483
+ },
484
+ });
485
+
486
+ try {
487
+ const stream = streamResponse({
488
+ model: MODEL,
489
+ prompt: "Continue.",
490
+ messages,
491
+ tools: {
492
+ probe: tool({
493
+ description: "Return the next source",
494
+ inputSchema: z.object({}),
495
+ execute: async () => "new-evidence",
496
+ }),
497
+ },
498
+ stopWhen: stepCountIs(2),
499
+ onSummarized,
500
+ silent: true,
501
+ authConfig: {
502
+ customProviders: {
503
+ fixture: {
504
+ baseUrl: "https://example.invalid/v1",
505
+ models: [
506
+ {
507
+ id: "synthetic",
508
+ contextLength: 200_000,
509
+ maxOutputTokens: 20_000,
510
+ },
511
+ ],
512
+ },
513
+ },
514
+ },
515
+ });
516
+ for await (const _part of stream.fullStream) void _part;
517
+ expect(summaryCalls).toBe(3);
518
+ expect(providerCalls).toBe(2);
519
+ expect(retryPrompt).toContain("source-0:");
520
+ expect(retryPrompt).toContain("new-evidence");
521
+ expect(onSummarized).toHaveBeenCalledWith("The first source is source-0.");
522
+ } finally {
523
+ state.model = null;
524
+ }
525
+ });
526
+
527
+ it("retries an oversized summary with fitted history", async () => {
528
+ const messages: ModelMessage[] = [
529
+ { role: "user", content: "Preserve the original task." },
530
+ ...Array.from({ length: 10 }, (_, index): ModelMessage[] => [
531
+ {
532
+ role: "assistant",
533
+ content: [
534
+ {
535
+ type: "tool-call",
536
+ toolCallId: `read-${index}`,
537
+ toolName: "probe",
538
+ input: {},
539
+ },
540
+ ],
541
+ },
542
+ {
543
+ role: "tool",
544
+ content: [
545
+ {
546
+ type: "tool-result",
547
+ toolCallId: `read-${index}`,
548
+ toolName: "probe",
549
+ output: {
550
+ type: "text",
551
+ value: `source-${index}:${"x".repeat(80_000)}`,
552
+ },
553
+ },
554
+ ],
555
+ },
556
+ ]).flat(),
557
+ ];
558
+ const summaryInputs: number[] = [];
559
+ state.model = new MockLanguageModelV3({
560
+ doGenerate: async (options) => {
561
+ const input = estimateMessageTokens(options.prompt as ModelMessage[]);
562
+ summaryInputs.push(input);
563
+ if (input > 140_000 || summaryInputs.length === 2) {
564
+ throw new Error(`prompt is too long: ${input} tokens > 140000 maximum`);
565
+ }
566
+ return {
567
+ content: [{ type: "text" as const, text: "Bounded summary." }],
568
+ finishReason: { unified: "stop" as const, raw: "stop" },
569
+ usage,
570
+ warnings: [],
571
+ };
572
+ },
573
+ doStream: async () => ({
574
+ stream: simulateReadableStream({
575
+ chunks: [
576
+ { type: "text-start", id: "done" },
577
+ { type: "text-delta", id: "done", delta: "done" },
578
+ { type: "text-end", id: "done" },
579
+ {
580
+ type: "finish",
581
+ finishReason: { unified: "stop", raw: "stop" },
582
+ usage,
583
+ },
584
+ ] as LanguageModelV3StreamPart[],
585
+ }),
586
+ }),
587
+ });
588
+
589
+ try {
590
+ const stream = createSummarizationStream(
591
+ messages,
592
+ {
593
+ model: MODEL,
594
+ prompt: "Continue.",
595
+ authConfig: {
596
+ customProviders: {
597
+ fixture: {
598
+ baseUrl: "https://example.invalid/v1",
599
+ models: [
600
+ {
601
+ id: "synthetic",
602
+ contextLength: 200_000,
603
+ maxOutputTokens: 20_000,
604
+ },
605
+ ],
606
+ },
607
+ },
608
+ },
609
+ },
610
+ state.model,
611
+ );
612
+ for await (const _part of stream.fullStream) void _part;
613
+ expect(summaryInputs).toHaveLength(3);
614
+ expect(summaryInputs[0]).toBeGreaterThan(140_000);
615
+ expect(summaryInputs[1]).toBeLessThan(140_000);
616
+ expect(summaryInputs[2]).toBeLessThan(summaryInputs[1]!);
617
+ } finally {
618
+ state.model = null;
619
+ }
620
+ });
621
+
622
+ it("tries shortened summary history even when the retry budget is too small", async () => {
623
+ const messages: ModelMessage[] = [
624
+ { role: "user", content: `Keep this request: ${"p".repeat(550_000)}` },
625
+ ...Array.from({ length: 8 }, (_, index): ModelMessage[] => [
626
+ {
627
+ role: "assistant",
628
+ content: [
629
+ {
630
+ type: "tool-call",
631
+ toolCallId: `source-${index}`,
632
+ toolName: "probe",
633
+ input: {},
634
+ },
635
+ ],
636
+ },
637
+ {
638
+ role: "tool",
639
+ content: [
640
+ {
641
+ type: "tool-result",
642
+ toolCallId: `source-${index}`,
643
+ toolName: "probe",
644
+ output: { type: "text", value: `source:${"e".repeat(10_000)}` },
645
+ },
646
+ ],
647
+ },
648
+ ]).flat(),
649
+ ];
650
+ const fitted = fitMessagesToContext(messages, {
651
+ contextWindow: 150_000,
652
+ maxOutputTokens: 20_000,
653
+ overheadTokens: 1_000,
654
+ });
655
+ expect(fitted.modified).toBe(true);
656
+ expect(fitted.fitsBudget).toBe(false);
657
+
658
+ const summaryInputs: number[] = [];
659
+ state.model = new MockLanguageModelV3({
660
+ doGenerate: async (options) => {
661
+ summaryInputs.push(
662
+ estimateMessageTokens(options.prompt as ModelMessage[]),
663
+ );
664
+ if (summaryInputs.length === 1) {
665
+ throw new Error("prompt is too long: maximum context length exceeded");
666
+ }
667
+ return {
668
+ content: [{ type: "text" as const, text: "Kept the request." }],
669
+ finishReason: { unified: "stop" as const, raw: "stop" },
670
+ usage,
671
+ warnings: [],
672
+ };
673
+ },
674
+ doStream: async () => ({
675
+ stream: simulateReadableStream({
676
+ chunks: [
677
+ { type: "text-start", id: "done" },
678
+ { type: "text-delta", id: "done", delta: "done" },
679
+ { type: "text-end", id: "done" },
680
+ {
681
+ type: "finish",
682
+ finishReason: { unified: "stop", raw: "stop" },
683
+ usage,
684
+ },
685
+ ] as LanguageModelV3StreamPart[],
686
+ }),
687
+ }),
688
+ });
689
+
690
+ try {
691
+ const stream = createSummarizationStream(
692
+ messages,
693
+ {
694
+ model: MODEL,
695
+ prompt: "Continue.",
696
+ authConfig: {
697
+ customProviders: {
698
+ fixture: {
699
+ baseUrl: "https://example.invalid/v1",
700
+ models: [
701
+ {
702
+ id: "synthetic",
703
+ contextLength: 200_000,
704
+ maxOutputTokens: 20_000,
705
+ },
706
+ ],
707
+ },
708
+ },
709
+ },
710
+ },
711
+ state.model,
712
+ );
713
+ for await (const _part of stream.fullStream) void _part;
714
+ expect(summaryInputs).toHaveLength(2);
715
+ expect(summaryInputs[1]).toBeLessThan(summaryInputs[0] ?? 0);
716
+ } finally {
717
+ state.model = null;
718
+ }
719
+ });
720
+
721
+ it.each([
722
+ "tool-only",
723
+ "partial-text",
724
+ ])("retries a %s compaction response without executing the tool", async (firstResponse) => {
725
+ let summaryAttempts = 0;
726
+ let usageEvents = 0;
727
+ let toolExecutions = 0;
728
+ let resumedPrompt = "";
729
+ state.model = new MockLanguageModelV3({
730
+ doGenerate: async () => {
731
+ summaryAttempts++;
732
+ return summaryAttempts === 1
733
+ ? {
734
+ content: [
735
+ ...(firstResponse === "partial-text"
736
+ ? [{ type: "text" as const, text: "Incomplete summary." }]
737
+ : []),
738
+ {
739
+ type: "tool-call" as const,
740
+ toolCallId: "summary-probe",
741
+ toolName: "probe",
742
+ input: "{}",
743
+ },
744
+ ],
745
+ finishReason: { unified: "tool-calls" as const, raw: "tool_calls" },
746
+ usage,
747
+ warnings: [],
748
+ }
749
+ : {
750
+ content: [{ type: "text" as const, text: "Continue safely." }],
751
+ finishReason: { unified: "stop" as const, raw: "stop" },
752
+ usage,
753
+ warnings: [],
754
+ };
755
+ },
756
+ doStream: async (options) => {
757
+ resumedPrompt = JSON.stringify(options.prompt);
758
+ return {
759
+ stream: simulateReadableStream({
760
+ chunks: [
761
+ { type: "text-start", id: "done" },
762
+ { type: "text-delta", id: "done", delta: "done" },
763
+ { type: "text-end", id: "done" },
764
+ {
765
+ type: "finish",
766
+ finishReason: { unified: "stop", raw: "stop" },
767
+ usage,
768
+ },
769
+ ] as LanguageModelV3StreamPart[],
770
+ }),
771
+ };
772
+ },
773
+ });
774
+
775
+ try {
776
+ const stream = createSummarizationStream(
777
+ [{ role: "user", content: "Preserve the task." }],
778
+ {
779
+ model: MODEL,
780
+ prompt: "Continue.",
781
+ tools: {
782
+ probe: tool({
783
+ description: "Must not execute during compaction",
784
+ inputSchema: z.object({}),
785
+ execute: async () => {
786
+ toolExecutions++;
787
+ return "unexpected";
788
+ },
789
+ }),
790
+ },
791
+ onStepFinish: (step) => {
792
+ if (step.response.id === "summarization") usageEvents++;
793
+ },
794
+ authConfig: {
795
+ customProviders: {
796
+ fixture: {
797
+ baseUrl: "https://example.invalid/v1",
798
+ models: [
799
+ {
800
+ id: "synthetic",
801
+ contextLength: 200_000,
802
+ maxOutputTokens: 20_000,
803
+ },
804
+ ],
805
+ },
806
+ },
807
+ },
808
+ },
809
+ state.model,
810
+ );
811
+ for await (const _part of stream.fullStream) void _part;
812
+ expect(summaryAttempts).toBe(2);
813
+ expect(usageEvents).toBe(2);
814
+ expect(toolExecutions).toBe(0);
815
+ expect(resumedPrompt).toContain("Continue safely.");
816
+ expect(resumedPrompt).not.toContain("Incomplete summary.");
817
+ } finally {
818
+ state.model = null;
819
+ }
820
+ });
821
+
822
+ it("fits code-heavy tool history before reported provider usage reaches the context limit", async () => {
823
+ const sessionPath = mkdtempSync(join(tmpdir(), "apex-context-calibration-"));
824
+ const contextLimit = 90_000;
825
+ const requestedToolCalls = 60;
826
+ const toolOutput = "x=foo.bar();\n".repeat(650);
827
+ let providerCalls = 0;
828
+ let toolCalls = 0;
829
+ let rejectedCalls = 0;
830
+ let summaryCalls = 0;
831
+ const observedInputTokens: number[] = [];
832
+
833
+ state.model = new MockLanguageModelV3({
834
+ doStream: async (options) => {
835
+ const estimated = estimateMessageTokens(options.prompt as ModelMessage[]);
836
+ const observed = estimated * 2 + 500;
837
+ if (observed > contextLimit) {
838
+ rejectedCalls++;
839
+ throw new Error(
840
+ `prompt is too long: ${observed} tokens > ${contextLimit} maximum`,
841
+ );
842
+ }
843
+ observedInputTokens.push(observed);
844
+ const call = providerCalls++;
845
+ const hasToolCall = call < requestedToolCalls;
846
+ const chunks: unknown[] = hasToolCall
847
+ ? [
848
+ {
849
+ type: "tool-call",
850
+ toolCallId: `probe-${call}`,
851
+ toolName: "probe",
852
+ input: "{}",
853
+ },
854
+ ]
855
+ : [
856
+ { type: "text-start", id: "done" },
857
+ { type: "text-delta", id: "done", delta: "done" },
858
+ { type: "text-end", id: "done" },
859
+ ];
860
+ chunks.push({
861
+ type: "finish",
862
+ finishReason: {
863
+ unified: hasToolCall ? "tool-calls" : "stop",
864
+ raw: hasToolCall ? "tool_calls" : "stop",
865
+ },
866
+ usage: {
867
+ ...usage,
868
+ inputTokens: {
869
+ total: observed,
870
+ noCache: observed,
871
+ cacheRead: 0,
872
+ cacheWrite: 0,
873
+ },
874
+ },
875
+ });
876
+ return {
877
+ stream: simulateReadableStream({
878
+ chunks: chunks as LanguageModelV3StreamPart[],
879
+ }),
880
+ };
881
+ },
882
+ doGenerate: async () => {
883
+ summaryCalls++;
884
+ return {
885
+ content: [{ type: "text" as const, text: "Continue inspection." }],
886
+ finishReason: { unified: "stop" as const, raw: "stop" },
887
+ usage,
888
+ warnings: [],
889
+ };
890
+ },
891
+ });
892
+
893
+ try {
894
+ const stream = streamResponse({
895
+ model: MODEL,
896
+ prompt: "Inspect the fixed files and finish.",
897
+ tools: {
898
+ probe: tool({
899
+ description: "Read the next source excerpt",
900
+ inputSchema: z.object({}),
901
+ execute: async () => {
902
+ toolCalls++;
903
+ return toolOutput;
904
+ },
905
+ }),
906
+ },
907
+ stopWhen: stepCountIs(100),
908
+ sessionPath,
909
+ silent: true,
910
+ authConfig: {
911
+ customProviders: {
912
+ fixture: {
913
+ baseUrl: "https://example.invalid/v1",
914
+ models: [
915
+ {
916
+ id: "synthetic",
917
+ contextLength: 100_000,
918
+ maxOutputTokens: 10_000,
919
+ },
920
+ ],
921
+ },
922
+ },
923
+ },
924
+ });
925
+ const text: string[] = [];
926
+ for await (const part of stream.fullStream) {
927
+ if (part.type === "text-delta") text.push(part.text);
928
+ }
929
+ expect(toolCalls).toBe(requestedToolCalls);
930
+ expect(providerCalls).toBe(requestedToolCalls + 1);
931
+ expect(rejectedCalls).toBe(0);
932
+ expect(summaryCalls).toBe(0);
933
+ expect(Math.max(...observedInputTokens)).toBeLessThanOrEqual(contextLimit);
934
+ expect(text.at(-1)).toBe("done");
935
+ } finally {
936
+ state.model = null;
937
+ rmSync(sessionPath, { recursive: true, force: true });
938
+ }
939
+ });
940
+
941
+ it("does not count system and tool overhead twice when calibrating", async () => {
942
+ const sessionPath = mkdtempSync(join(tmpdir(), "apex-context-overhead-"));
943
+ const system = "s".repeat(32_000);
944
+ const toolOutput = "e".repeat(140_000);
945
+ const tools = {
946
+ probe: tool({
947
+ description: "d".repeat(32_000),
948
+ inputSchema: z.object({}),
949
+ execute: async () => toolOutput,
950
+ }),
951
+ };
952
+ const preparedInputTokens: number[] = [];
953
+ const reportedInputTokens: number[] = [];
954
+ let providerCalls = 0;
955
+ let summaryCalls = 0;
956
+
957
+ state.model = new MockLanguageModelV3({
958
+ doStream: async (options) => {
959
+ const observed =
960
+ estimateMessageTokens(options.prompt as ModelMessage[]) +
961
+ estimateToolsOverheadTokens(tools) +
962
+ 1_000;
963
+ reportedInputTokens.push(observed);
964
+ const hasToolCall = providerCalls++ === 0;
965
+ const chunks: unknown[] = hasToolCall
966
+ ? [
967
+ {
968
+ type: "tool-call",
969
+ toolCallId: "probe-0",
970
+ toolName: "probe",
971
+ input: "{}",
972
+ },
973
+ ]
974
+ : [
975
+ { type: "text-start", id: "done" },
976
+ { type: "text-delta", id: "done", delta: "done" },
977
+ { type: "text-end", id: "done" },
978
+ ];
979
+ chunks.push({
980
+ type: "finish",
981
+ finishReason: {
982
+ unified: hasToolCall ? "tool-calls" : "stop",
983
+ raw: hasToolCall ? "tool_calls" : "stop",
984
+ },
985
+ usage: {
986
+ ...usage,
987
+ inputTokens: {
988
+ total: observed,
989
+ noCache: observed,
990
+ cacheRead: 0,
991
+ cacheWrite: 0,
992
+ },
993
+ },
994
+ });
995
+ return {
996
+ stream: simulateReadableStream({
997
+ chunks: chunks as LanguageModelV3StreamPart[],
998
+ }),
999
+ };
1000
+ },
1001
+ doGenerate: async () => {
1002
+ summaryCalls++;
1003
+ return {
1004
+ content: [{ type: "text" as const, text: "Continue." }],
1005
+ finishReason: { unified: "stop" as const, raw: "stop" },
1006
+ usage,
1007
+ warnings: [],
1008
+ };
1009
+ },
1010
+ });
1011
+
1012
+ try {
1013
+ const stream = streamResponse({
1014
+ model: MODEL,
1015
+ prompt: "Inspect the fixed source excerpt.",
1016
+ system,
1017
+ tools,
1018
+ stopWhen: stepCountIs(3),
1019
+ sessionPath,
1020
+ silent: true,
1021
+ onContextPrepared: ({ messages }) => {
1022
+ preparedInputTokens.push(estimateMessageTokens(messages));
1023
+ },
1024
+ authConfig: {
1025
+ customProviders: {
1026
+ fixture: {
1027
+ baseUrl: "https://example.invalid/v1",
1028
+ models: [
1029
+ {
1030
+ id: "synthetic",
1031
+ contextLength: 80_000,
1032
+ maxOutputTokens: 10_000,
1033
+ },
1034
+ ],
1035
+ },
1036
+ },
1037
+ },
1038
+ });
1039
+ for await (const _part of stream.fullStream) {
1040
+ void _part;
1041
+ }
1042
+ expect(providerCalls).toBe(2);
1043
+ expect(summaryCalls).toBe(0);
1044
+ expect(preparedInputTokens).toHaveLength(2);
1045
+ expect(preparedInputTokens[1]).toBeGreaterThan(30_000);
1046
+ expect(Math.max(...reportedInputTokens)).toBeLessThan(80_000);
1047
+ } finally {
1048
+ state.model = null;
1049
+ rmSync(sessionPath, { recursive: true, force: true });
1050
+ }
1051
+ });
1052
+
1053
+ it("continues an in-stream turn when its proactive summary is rejected", async () => {
1054
+ let providerCalls = 0;
1055
+ let summaryCalls = 0;
1056
+ let resumedPrompt = "";
1057
+ state.model = new MockLanguageModelV3({
1058
+ doStream: async (options) => {
1059
+ const call = providerCalls++;
1060
+ if (call > 0) resumedPrompt = JSON.stringify(options.prompt);
1061
+ return {
1062
+ stream: simulateReadableStream({
1063
+ chunks:
1064
+ call === 0
1065
+ ? ([
1066
+ {
1067
+ type: "tool-call",
1068
+ toolCallId: "source",
1069
+ toolName: "probe",
1070
+ input: "{}",
1071
+ },
1072
+ {
1073
+ type: "finish",
1074
+ finishReason: { unified: "tool-calls", raw: "tool_calls" },
1075
+ usage,
1076
+ },
1077
+ ] as LanguageModelV3StreamPart[])
1078
+ : ([
1079
+ { type: "text-start", id: "done" },
1080
+ { type: "text-delta", id: "done", delta: "done" },
1081
+ { type: "text-end", id: "done" },
1082
+ {
1083
+ type: "finish",
1084
+ finishReason: { unified: "stop", raw: "stop" },
1085
+ usage,
1086
+ },
1087
+ ] as LanguageModelV3StreamPart[]),
1088
+ }),
1089
+ };
1090
+ },
1091
+ doGenerate: async () => {
1092
+ summaryCalls++;
1093
+ return {
1094
+ content: [],
1095
+ finishReason: { unified: "stop" as const, raw: "stop" },
1096
+ usage,
1097
+ warnings: [],
1098
+ };
1099
+ },
1100
+ });
1101
+
1102
+ try {
1103
+ const stream = streamResponse({
1104
+ model: MODEL,
1105
+ prompt: "Read the source.",
1106
+ tools: {
1107
+ probe: tool({
1108
+ description: "Return synthetic evidence",
1109
+ inputSchema: z.object({}),
1110
+ execute: async () => `source-evidence:${"e".repeat(620_000)}`,
1111
+ }),
1112
+ },
1113
+ stopWhen: stepCountIs(2),
1114
+ silent: true,
1115
+ authConfig: {
1116
+ customProviders: {
1117
+ fixture: {
1118
+ baseUrl: "https://example.invalid/v1",
1119
+ models: [
1120
+ {
1121
+ id: "synthetic",
1122
+ contextLength: 200_000,
1123
+ maxOutputTokens: 20_000,
1124
+ },
1125
+ ],
1126
+ },
1127
+ },
1128
+ },
1129
+ });
1130
+ for await (const _part of stream.fullStream) void _part;
1131
+ expect(providerCalls).toBe(2);
1132
+ expect(summaryCalls).toBe(2);
1133
+ expect(resumedPrompt).toContain("source-evidence:");
1134
+ } finally {
1135
+ state.model = null;
1136
+ }
1137
+ });
1138
+
1139
+ it("uses minimal recovery when a reactive summary is rejected", async () => {
1140
+ let providerCalls = 0;
1141
+ let summaryCalls = 0;
1142
+ let resumedPrompt = "";
1143
+ let syntheticCalls = 0;
1144
+ let syntheticResults = 0;
1145
+ const onSummarized = vi.fn();
1146
+ state.model = new MockLanguageModelV3({
1147
+ doStream: async (options) => {
1148
+ const call = providerCalls++;
1149
+ if (call > 0) resumedPrompt = JSON.stringify(options.prompt);
1150
+ return {
1151
+ stream: simulateReadableStream({
1152
+ chunks:
1153
+ call === 0
1154
+ ? ([
1155
+ {
1156
+ type: "error",
1157
+ error: new Error(
1158
+ "prompt is too long: context length exceeded",
1159
+ ),
1160
+ },
1161
+ ] as LanguageModelV3StreamPart[])
1162
+ : ([
1163
+ { type: "text-start", id: "done" },
1164
+ { type: "text-delta", id: "done", delta: "done" },
1165
+ { type: "text-end", id: "done" },
1166
+ {
1167
+ type: "finish",
1168
+ finishReason: { unified: "stop", raw: "stop" },
1169
+ usage,
1170
+ },
1171
+ ] as LanguageModelV3StreamPart[]),
1172
+ }),
1173
+ };
1174
+ },
1175
+ doGenerate: async () => {
1176
+ summaryCalls++;
1177
+ return {
1178
+ content: [],
1179
+ finishReason: { unified: "stop" as const, raw: "stop" },
1180
+ usage,
1181
+ warnings: [],
1182
+ };
1183
+ },
1184
+ });
1185
+
1186
+ try {
1187
+ const stream = streamResponse({
1188
+ model: MODEL,
1189
+ prompt: "Continue the task.",
1190
+ messages: [{ role: "user", content: "Old synthetic evidence." }],
1191
+ onSummarized,
1192
+ silent: true,
1193
+ authConfig: {
1194
+ customProviders: {
1195
+ fixture: {
1196
+ baseUrl: "https://example.invalid/v1",
1197
+ models: [
1198
+ {
1199
+ id: "synthetic",
1200
+ contextLength: 200_000,
1201
+ maxOutputTokens: 20_000,
1202
+ },
1203
+ ],
1204
+ },
1205
+ },
1206
+ },
1207
+ });
1208
+ for await (const part of stream.fullStream) {
1209
+ if (
1210
+ part.type === "tool-call" &&
1211
+ part.toolName === "summarize_conversation"
1212
+ ) {
1213
+ syntheticCalls++;
1214
+ }
1215
+ if (
1216
+ part.type === "tool-result" &&
1217
+ part.toolName === "summarize_conversation"
1218
+ ) {
1219
+ syntheticResults++;
1220
+ }
1221
+ }
1222
+ expect(providerCalls).toBe(2);
1223
+ expect(summaryCalls).toBe(2);
1224
+ expect(syntheticCalls).toBe(1);
1225
+ expect(syntheticResults).toBe(1);
1226
+ expect(onSummarized).toHaveBeenCalledWith("");
1227
+ expect(resumedPrompt).not.toContain("Old synthetic evidence.");
1228
+ } finally {
1229
+ state.model = null;
1230
+ }
1231
+ });
1232
+
1233
+ it("contains a context error from an in-stream summary", async () => {
1234
+ let providerCalls = 0;
1235
+ state.model = new MockLanguageModelV3({
1236
+ doStream: async () => ({
1237
+ stream: simulateReadableStream({
1238
+ chunks: [
1239
+ {
1240
+ type: "tool-call",
1241
+ toolCallId: `probe-${providerCalls++}`,
1242
+ toolName: "probe",
1243
+ input: JSON.stringify({ q: TOOL_INPUT }),
1244
+ },
1245
+ {
1246
+ type: "finish",
1247
+ finishReason: { unified: "tool-calls", raw: "tool_calls" },
1248
+ usage,
1249
+ },
1250
+ ] as LanguageModelV3StreamPart[],
1251
+ }),
1252
+ }),
1253
+ doGenerate: async () => {
1254
+ throw new Error("prompt is too long: 999999 tokens > 20000 maximum");
1255
+ },
1256
+ });
1257
+
1258
+ try {
1259
+ const stream = streamResponse({
1260
+ model: MODEL,
1261
+ prompt: "Run synthetic probes.",
1262
+ tools: {
1263
+ probe: tool({
1264
+ description: "Return synthetic evidence",
1265
+ inputSchema: z.object({ q: z.string() }),
1266
+ execute: async () => TOOL_OUTPUT,
1267
+ }),
1268
+ },
1269
+ stopWhen: stepCountIs(100),
1270
+ silent: true,
1271
+ _restartDepth: 3,
1272
+ authConfig: {
1273
+ customProviders: {
1274
+ fixture: {
1275
+ baseUrl: "https://example.invalid/v1",
1276
+ models: [
1277
+ {
1278
+ id: "synthetic",
1279
+ contextLength: 20_000,
1280
+ maxOutputTokens: 2_000,
1281
+ },
1282
+ ],
1283
+ },
1284
+ },
1285
+ },
1286
+ });
1287
+ let caught: unknown;
1288
+ try {
1289
+ for await (const part of stream.fullStream) void part;
1290
+ } catch (error) {
1291
+ caught = error;
1292
+ }
1293
+ expect(providerCalls).toBeGreaterThan(1);
1294
+ expect(caught).toBeInstanceOf(ContextLengthExhaustedError);
1295
+ } finally {
1296
+ state.model = null;
1297
+ }
1298
+ });
1299
+
1300
+ it("measures the fitted history handed to legacy transcript persistence", async () => {
1301
+ const sessionPath = mkdtempSync(join(tmpdir(), "apex-history-bench-"));
1302
+ const priorMessages: ModelMessage[] = [
1303
+ { role: "user", content: "Continue the synthetic task." },
1304
+ ...Array.from({ length: 40 }, (_, index): ModelMessage[] => [
1305
+ {
1306
+ role: "assistant",
1307
+ content: [
1308
+ {
1309
+ type: "tool-call",
1310
+ toolCallId: `prior-${index}`,
1311
+ toolName: "probe",
1312
+ input: { q: "q".repeat(100) },
1313
+ },
1314
+ ],
1315
+ },
1316
+ {
1317
+ role: "tool",
1318
+ content: [
1319
+ {
1320
+ type: "tool-result",
1321
+ toolCallId: `prior-${index}`,
1322
+ toolName: "probe",
1323
+ output: { type: "text", value: "e".repeat(4_000) },
1324
+ },
1325
+ ],
1326
+ },
1327
+ ]).flat(),
1328
+ ];
1329
+ let prepared:
1330
+ | { messages: ModelMessage[]; responseMessageOffset: number }
1331
+ | undefined;
1332
+ let responseMessages: ModelMessage[] = [];
1333
+ state.model = new MockLanguageModelV3({
1334
+ doStream: async () => ({
1335
+ stream: simulateReadableStream({
1336
+ chunks: [
1337
+ { type: "text-start", id: "done" },
1338
+ { type: "text-delta", id: "done", delta: "done" },
1339
+ { type: "text-end", id: "done" },
1340
+ {
1341
+ type: "finish",
1342
+ finishReason: { unified: "stop", raw: "stop" },
1343
+ usage,
1344
+ },
1345
+ ] as LanguageModelV3StreamPart[],
1346
+ }),
1347
+ }),
1348
+ });
1349
+
1350
+ try {
1351
+ const stream = streamResponse({
1352
+ model: MODEL,
1353
+ prompt: "Continue the synthetic task.",
1354
+ messages: priorMessages,
1355
+ sessionPath,
1356
+ silent: true,
1357
+ onContextPrepared: (context) => {
1358
+ prepared = context;
1359
+ },
1360
+ onStepFinish: (step) => {
1361
+ responseMessages = step.response.messages;
1362
+ },
1363
+ authConfig: {
1364
+ customProviders: {
1365
+ fixture: {
1366
+ baseUrl: "https://example.invalid/v1",
1367
+ models: [
1368
+ {
1369
+ id: "synthetic",
1370
+ contextLength: 32_000,
1371
+ maxOutputTokens: 4_000,
1372
+ },
1373
+ ],
1374
+ },
1375
+ },
1376
+ },
1377
+ });
1378
+ for await (const _part of stream.fullStream) {
1379
+ void _part;
1380
+ }
1381
+
1382
+ expect(prepared).toBeDefined();
1383
+ const previousTranscript = [...priorMessages, ...responseMessages];
1384
+ const fittedTranscript = [
1385
+ ...(prepared?.messages ?? []),
1386
+ ...responseMessages.slice(prepared?.responseMessageOffset ?? 0),
1387
+ ];
1388
+ const previousEstimate = estimateMessageTokens(previousTranscript);
1389
+ const fittedEstimate = estimateMessageTokens(fittedTranscript);
1390
+ expect(fittedEstimate).toBeLessThan(previousEstimate / 2);
1391
+ expect(JSON.stringify(fittedTranscript)).toContain("full output saved to");
1392
+ expect(responseMessages).toHaveLength(1);
1393
+ if (process.env.APEX_CONTEXT_BENCH === "1") {
1394
+ console.log(
1395
+ "OPERATOR_HISTORY_BENCH",
1396
+ JSON.stringify({
1397
+ previousTranscriptEstimatedTokens: previousEstimate,
1398
+ fittedTranscriptEstimatedTokens: fittedEstimate,
1399
+ previousTranscriptMessages: previousTranscript.length,
1400
+ fittedTranscriptMessages: fittedTranscript.length,
1401
+ }),
1402
+ );
1403
+ }
1404
+ } finally {
1405
+ state.model = null;
1406
+ rmSync(sessionPath, { recursive: true, force: true });
1407
+ }
1408
+ });