@prestyj/cli 5.6.0 → 5.8.0

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 (246) hide show
  1. package/README.md +2 -2
  2. package/dist/app-sidecar.js +369 -92
  3. package/dist/app-sidecar.js.map +1 -1
  4. package/dist/cli/auth.d.ts.map +1 -1
  5. package/dist/cli/auth.js +5 -2
  6. package/dist/cli/auth.js.map +1 -1
  7. package/dist/cli/shared.d.ts.map +1 -1
  8. package/dist/cli/shared.js +2 -0
  9. package/dist/cli/shared.js.map +1 -1
  10. package/dist/cli.d.ts.map +1 -1
  11. package/dist/cli.js +33 -3
  12. package/dist/cli.js.map +1 -1
  13. package/dist/config.d.ts.map +1 -1
  14. package/dist/config.js +1 -0
  15. package/dist/config.js.map +1 -1
  16. package/dist/config.test.js +7 -0
  17. package/dist/config.test.js.map +1 -1
  18. package/dist/core/agent-session-compaction.test.js +521 -10
  19. package/dist/core/agent-session-compaction.test.js.map +1 -1
  20. package/dist/core/agent-session-memory-tail.test.js +1 -1
  21. package/dist/core/agent-session-memory-tail.test.js.map +1 -1
  22. package/dist/core/agent-session-queue.test.js +1 -1
  23. package/dist/core/agent-session-queue.test.js.map +1 -1
  24. package/dist/core/agent-session-review-coverage.test.d.ts +2 -0
  25. package/dist/core/agent-session-review-coverage.test.d.ts.map +1 -0
  26. package/dist/core/agent-session-review-coverage.test.js +83 -0
  27. package/dist/core/agent-session-review-coverage.test.js.map +1 -0
  28. package/dist/core/agent-session-tool-result-policy.test.d.ts +2 -0
  29. package/dist/core/agent-session-tool-result-policy.test.d.ts.map +1 -0
  30. package/dist/core/agent-session-tool-result-policy.test.js +25 -0
  31. package/dist/core/agent-session-tool-result-policy.test.js.map +1 -0
  32. package/dist/core/agent-session.d.ts +51 -12
  33. package/dist/core/agent-session.d.ts.map +1 -1
  34. package/dist/core/agent-session.js +415 -106
  35. package/dist/core/agent-session.js.map +1 -1
  36. package/dist/core/auth-providers.d.ts.map +1 -1
  37. package/dist/core/auth-providers.js +15 -8
  38. package/dist/core/auth-providers.js.map +1 -1
  39. package/dist/core/compaction/active-context.d.ts +17 -0
  40. package/dist/core/compaction/active-context.d.ts.map +1 -0
  41. package/dist/core/compaction/active-context.js +20 -0
  42. package/dist/core/compaction/active-context.js.map +1 -0
  43. package/dist/core/compaction/active-context.test.d.ts +2 -0
  44. package/dist/core/compaction/active-context.test.d.ts.map +1 -0
  45. package/dist/core/compaction/active-context.test.js +36 -0
  46. package/dist/core/compaction/active-context.test.js.map +1 -0
  47. package/dist/core/compaction/compactor.d.ts +19 -18
  48. package/dist/core/compaction/compactor.d.ts.map +1 -1
  49. package/dist/core/compaction/compactor.js +64 -42
  50. package/dist/core/compaction/compactor.js.map +1 -1
  51. package/dist/core/compaction/compactor.test.js +122 -65
  52. package/dist/core/compaction/compactor.test.js.map +1 -1
  53. package/dist/core/compaction/token-estimator.d.ts +25 -1
  54. package/dist/core/compaction/token-estimator.d.ts.map +1 -1
  55. package/dist/core/compaction/token-estimator.js +86 -1
  56. package/dist/core/compaction/token-estimator.js.map +1 -1
  57. package/dist/core/compaction/token-estimator.test.js +147 -1
  58. package/dist/core/compaction/token-estimator.test.js.map +1 -1
  59. package/dist/core/compaction/tool-result-pruner.d.ts +37 -0
  60. package/dist/core/compaction/tool-result-pruner.d.ts.map +1 -0
  61. package/dist/core/compaction/tool-result-pruner.js +103 -0
  62. package/dist/core/compaction/tool-result-pruner.js.map +1 -0
  63. package/dist/core/compaction/tool-result-pruner.test.d.ts +2 -0
  64. package/dist/core/compaction/tool-result-pruner.test.d.ts.map +1 -0
  65. package/dist/core/compaction/tool-result-pruner.test.js +148 -0
  66. package/dist/core/compaction/tool-result-pruner.test.js.map +1 -0
  67. package/dist/core/event-bus.d.ts +2 -0
  68. package/dist/core/event-bus.d.ts.map +1 -1
  69. package/dist/core/event-bus.js.map +1 -1
  70. package/dist/core/ideal-review.d.ts +33 -0
  71. package/dist/core/ideal-review.d.ts.map +1 -1
  72. package/dist/core/ideal-review.js +78 -0
  73. package/dist/core/ideal-review.js.map +1 -1
  74. package/dist/core/ideal-review.test.js +43 -1
  75. package/dist/core/ideal-review.test.js.map +1 -1
  76. package/dist/core/index.d.ts +1 -1
  77. package/dist/core/index.d.ts.map +1 -1
  78. package/dist/core/index.js +1 -1
  79. package/dist/core/index.js.map +1 -1
  80. package/dist/core/lsp/client.d.ts +3 -0
  81. package/dist/core/lsp/client.d.ts.map +1 -1
  82. package/dist/core/lsp/client.js +20 -0
  83. package/dist/core/lsp/client.js.map +1 -1
  84. package/dist/core/lsp/manager.d.ts +32 -9
  85. package/dist/core/lsp/manager.d.ts.map +1 -1
  86. package/dist/core/lsp/manager.js +89 -37
  87. package/dist/core/lsp/manager.js.map +1 -1
  88. package/dist/core/lsp/manager.test.js +56 -4
  89. package/dist/core/lsp/manager.test.js.map +1 -1
  90. package/dist/core/project-discovery.d.ts +6 -9
  91. package/dist/core/project-discovery.d.ts.map +1 -1
  92. package/dist/core/project-discovery.js +35 -28
  93. package/dist/core/project-discovery.js.map +1 -1
  94. package/dist/core/project-discovery.test.js +148 -0
  95. package/dist/core/project-discovery.test.js.map +1 -1
  96. package/dist/core/resolve-start.test.js +1 -0
  97. package/dist/core/resolve-start.test.js.map +1 -1
  98. package/dist/core/run-lifecycle.d.ts +40 -0
  99. package/dist/core/run-lifecycle.d.ts.map +1 -0
  100. package/dist/core/run-lifecycle.js +95 -0
  101. package/dist/core/run-lifecycle.js.map +1 -0
  102. package/dist/core/run-lifecycle.test.d.ts +2 -0
  103. package/dist/core/run-lifecycle.test.d.ts.map +1 -0
  104. package/dist/core/run-lifecycle.test.js +64 -0
  105. package/dist/core/run-lifecycle.test.js.map +1 -0
  106. package/dist/core/session-compaction.d.ts +3 -0
  107. package/dist/core/session-compaction.d.ts.map +1 -1
  108. package/dist/core/session-compaction.js +14 -1
  109. package/dist/core/session-compaction.js.map +1 -1
  110. package/dist/core/session-compaction.test.js +5 -0
  111. package/dist/core/session-compaction.test.js.map +1 -1
  112. package/dist/core/session-manager.d.ts +33 -2
  113. package/dist/core/session-manager.d.ts.map +1 -1
  114. package/dist/core/session-manager.js +73 -2
  115. package/dist/core/session-manager.js.map +1 -1
  116. package/dist/core/session-manager.test.js +97 -2
  117. package/dist/core/session-manager.test.js.map +1 -1
  118. package/dist/core/session-preview.d.ts +11 -0
  119. package/dist/core/session-preview.d.ts.map +1 -0
  120. package/dist/core/session-preview.js +50 -0
  121. package/dist/core/session-preview.js.map +1 -0
  122. package/dist/core/settings-manager.d.ts +1 -0
  123. package/dist/core/settings-manager.d.ts.map +1 -1
  124. package/dist/core/settings-manager.js +3 -2
  125. package/dist/core/settings-manager.js.map +1 -1
  126. package/dist/core/subagent-manager.d.ts +42 -7
  127. package/dist/core/subagent-manager.d.ts.map +1 -1
  128. package/dist/core/subagent-manager.js +346 -30
  129. package/dist/core/subagent-manager.js.map +1 -1
  130. package/dist/core/subagent-manager.test.js +201 -5
  131. package/dist/core/subagent-manager.test.js.map +1 -1
  132. package/dist/core/subagent-store.d.ts +20 -0
  133. package/dist/core/subagent-store.d.ts.map +1 -0
  134. package/dist/core/subagent-store.js +177 -0
  135. package/dist/core/subagent-store.js.map +1 -0
  136. package/dist/core/subagent-store.test.d.ts +2 -0
  137. package/dist/core/subagent-store.test.d.ts.map +1 -0
  138. package/dist/core/subagent-store.test.js +94 -0
  139. package/dist/core/subagent-store.test.js.map +1 -0
  140. package/dist/modes/agent-home-mode.d.ts.map +1 -1
  141. package/dist/modes/agent-home-mode.js +10 -3
  142. package/dist/modes/agent-home-mode.js.map +1 -1
  143. package/dist/modes/serve-mode.d.ts.map +1 -1
  144. package/dist/modes/serve-mode.js +10 -3
  145. package/dist/modes/serve-mode.js.map +1 -1
  146. package/dist/modes/subagent-worker-mode.d.ts +2 -0
  147. package/dist/modes/subagent-worker-mode.d.ts.map +1 -1
  148. package/dist/modes/subagent-worker-mode.js +13 -3
  149. package/dist/modes/subagent-worker-mode.js.map +1 -1
  150. package/dist/tools/bash.d.ts +9 -0
  151. package/dist/tools/bash.d.ts.map +1 -1
  152. package/dist/tools/bash.js +28 -18
  153. package/dist/tools/bash.js.map +1 -1
  154. package/dist/tools/bash.test.d.ts +2 -0
  155. package/dist/tools/bash.test.d.ts.map +1 -0
  156. package/dist/tools/bash.test.js +56 -0
  157. package/dist/tools/bash.test.js.map +1 -0
  158. package/dist/tools/goals.d.ts +3 -3
  159. package/dist/tools/overflow.d.ts +12 -2
  160. package/dist/tools/overflow.d.ts.map +1 -1
  161. package/dist/tools/overflow.js +49 -4
  162. package/dist/tools/overflow.js.map +1 -1
  163. package/dist/tools/overflow.test.d.ts +2 -0
  164. package/dist/tools/overflow.test.d.ts.map +1 -0
  165. package/dist/tools/overflow.test.js +62 -0
  166. package/dist/tools/overflow.test.js.map +1 -0
  167. package/dist/tools/subagent-shared.d.ts +8 -0
  168. package/dist/tools/subagent-shared.d.ts.map +1 -1
  169. package/dist/tools/subagent-shared.js.map +1 -1
  170. package/dist/tools/subagent.d.ts +3 -8
  171. package/dist/tools/subagent.d.ts.map +1 -1
  172. package/dist/tools/subagent.js +10 -8
  173. package/dist/tools/subagent.js.map +1 -1
  174. package/dist/tools/subagent.test.js +17 -1
  175. package/dist/tools/subagent.test.js.map +1 -1
  176. package/dist/tools/tasks.d.ts +2 -2
  177. package/dist/tools/web-fetch.d.ts +1 -1
  178. package/dist/ui/App.d.ts +7 -3
  179. package/dist/ui/App.d.ts.map +1 -1
  180. package/dist/ui/App.js +68 -98
  181. package/dist/ui/App.js.map +1 -1
  182. package/dist/ui/components/Footer.d.ts +1 -0
  183. package/dist/ui/components/Footer.d.ts.map +1 -1
  184. package/dist/ui/components/Footer.js +3 -3
  185. package/dist/ui/components/Footer.js.map +1 -1
  186. package/dist/ui/components/Footer.test.d.ts +2 -0
  187. package/dist/ui/components/Footer.test.d.ts.map +1 -0
  188. package/dist/ui/components/Footer.test.js +16 -0
  189. package/dist/ui/components/Footer.test.js.map +1 -0
  190. package/dist/ui/components/ModelSelector.d.ts.map +1 -1
  191. package/dist/ui/components/ModelSelector.js +1 -0
  192. package/dist/ui/components/ModelSelector.js.map +1 -1
  193. package/dist/ui/components/SubAgentPanel.d.ts +2 -0
  194. package/dist/ui/components/SubAgentPanel.d.ts.map +1 -1
  195. package/dist/ui/components/SubAgentPanel.js +4 -2
  196. package/dist/ui/components/SubAgentPanel.js.map +1 -1
  197. package/dist/ui/hooks/useAgentLoop.d.ts +9 -6
  198. package/dist/ui/hooks/useAgentLoop.d.ts.map +1 -1
  199. package/dist/ui/hooks/useAgentLoop.js +68 -8
  200. package/dist/ui/hooks/useAgentLoop.js.map +1 -1
  201. package/dist/ui/hooks/useAgentLoop.test.js +31 -0
  202. package/dist/ui/hooks/useAgentLoop.test.js.map +1 -1
  203. package/dist/ui/hooks/useContextCompaction.d.ts +5 -8
  204. package/dist/ui/hooks/useContextCompaction.d.ts.map +1 -1
  205. package/dist/ui/hooks/useContextCompaction.js +77 -16
  206. package/dist/ui/hooks/useContextCompaction.js.map +1 -1
  207. package/dist/ui/hooks/useContextCompaction.test.d.ts +2 -0
  208. package/dist/ui/hooks/useContextCompaction.test.d.ts.map +1 -0
  209. package/dist/ui/hooks/useContextCompaction.test.js +135 -0
  210. package/dist/ui/hooks/useContextCompaction.test.js.map +1 -0
  211. package/dist/ui/hooks/useSessionPersistence.d.ts +4 -2
  212. package/dist/ui/hooks/useSessionPersistence.d.ts.map +1 -1
  213. package/dist/ui/hooks/useSessionPersistence.js +10 -1
  214. package/dist/ui/hooks/useSessionPersistence.js.map +1 -1
  215. package/dist/ui/hooks/useTerminalTitle.d.ts +3 -3
  216. package/dist/ui/hooks/useTerminalTitle.d.ts.map +1 -1
  217. package/dist/ui/hooks/useTerminalTitle.js +5 -9
  218. package/dist/ui/hooks/useTerminalTitle.js.map +1 -1
  219. package/dist/ui/login.d.ts.map +1 -1
  220. package/dist/ui/login.js +7 -2
  221. package/dist/ui/login.js.map +1 -1
  222. package/dist/ui/render.d.ts +7 -2
  223. package/dist/ui/render.d.ts.map +1 -1
  224. package/dist/ui/render.js +4 -4
  225. package/dist/ui/render.js.map +1 -1
  226. package/dist/ui/render.test.js +10 -1
  227. package/dist/ui/render.test.js.map +1 -1
  228. package/dist/ui/terminal-history.js +6 -2
  229. package/dist/ui/terminal-history.js.map +1 -1
  230. package/dist/ui/terminal-history.test.js +2 -2
  231. package/dist/ui/terminal-history.test.js.map +1 -1
  232. package/dist/ui/tui-history-parity.test.js +8 -1
  233. package/dist/ui/tui-history-parity.test.js.map +1 -1
  234. package/dist/utils/git.d.ts +2 -0
  235. package/dist/utils/git.d.ts.map +1 -1
  236. package/dist/utils/git.js +12 -0
  237. package/dist/utils/git.js.map +1 -1
  238. package/dist/utils/git.test.d.ts +2 -0
  239. package/dist/utils/git.test.d.ts.map +1 -0
  240. package/dist/utils/git.test.js +36 -0
  241. package/dist/utils/git.test.js.map +1 -0
  242. package/package.json +5 -5
  243. package/dist/utils/session-title.d.ts +0 -19
  244. package/dist/utils/session-title.d.ts.map +0 -1
  245. package/dist/utils/session-title.js +0 -82
  246. package/dist/utils/session-title.js.map +0 -1
@@ -2,6 +2,8 @@ import fs from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { MODELS } from "./model-registry.js";
6
+ import { estimateConversationTokens } from "./compaction/token-estimator.js";
5
7
  const shouldCompactMock = vi.hoisted(() => vi.fn());
6
8
  const compactMock = vi.hoisted(() => vi.fn());
7
9
  const agentLoopMock = vi.hoisted(() => vi.fn());
@@ -39,6 +41,20 @@ async function writeJson(filePath, value) {
39
41
  await fs.mkdir(path.dirname(filePath), { recursive: true });
40
42
  await fs.writeFile(filePath, JSON.stringify(value, null, 2), "utf-8");
41
43
  }
44
+ function compactionResult(messages, compacted = true) {
45
+ return {
46
+ messages,
47
+ result: {
48
+ compacted,
49
+ ...(compacted ? {} : { reason: "too_few_messages" }),
50
+ originalCount: compacted ? 6 : messages.length,
51
+ newCount: messages.length,
52
+ tokensBeforeEstimate: 180_000,
53
+ tokensAfterEstimate: compacted ? 2_000 : 180_000,
54
+ },
55
+ };
56
+ }
57
+ const providerModels = MODELS.filter((model, index, models) => models.findIndex((candidate) => candidate.provider === model.provider) === index).map((model) => ({ provider: model.provider, model: model.id }));
42
58
  beforeEach(async () => {
43
59
  originalHome = process.env.HOME;
44
60
  tmpHome = await fs.mkdtemp(path.join(os.tmpdir(), "agent-session-home-"));
@@ -47,13 +63,29 @@ beforeEach(async () => {
47
63
  shouldCompactMock.mockReset();
48
64
  compactMock.mockReset();
49
65
  agentLoopMock.mockReset();
50
- await writeJson(path.join(tmpHome, ".ezcoder", "auth.json"), {
51
- anthropic: {
52
- accessToken: "test-access-token",
53
- refreshToken: "test-refresh-token",
66
+ const authProviders = [
67
+ "anthropic",
68
+ "openai",
69
+ "sakana",
70
+ "xai",
71
+ "gemini",
72
+ "moonshot",
73
+ "glm",
74
+ "minimax",
75
+ "xiaomi",
76
+ "xiaomi-credits",
77
+ "deepseek",
78
+ "openrouter",
79
+ ];
80
+ await writeJson(path.join(tmpHome, ".ezcoder", "auth.json"), Object.fromEntries(authProviders.map((provider) => [
81
+ provider,
82
+ {
83
+ accessToken: `test-${provider}-token`,
84
+ refreshToken: `test-${provider}-refresh`,
54
85
  expiresAt: Date.now() + 3_600_000,
86
+ ...(provider === "openai" ? { accountId: "chatgpt-account" } : {}),
55
87
  },
56
- });
88
+ ])));
57
89
  await writeJson(path.join(tmpHome, ".ezcoder", "settings.json"), {
58
90
  autoCompact: true,
59
91
  compactThreshold: 0.1,
@@ -100,15 +132,108 @@ describe("AgentSession worker auto-compaction", () => {
100
132
  await session.initialize();
101
133
  await session.prompt("Do worker task");
102
134
  await session.dispose();
103
- expect(shouldCompactMock).toHaveBeenCalledWith(expect.arrayContaining([{ role: "user", content: "Do worker task" }]), expect.any(Number), 0.1, undefined, expect.any(Number));
135
+ expect(shouldCompactMock).toHaveBeenCalledWith(expect.arrayContaining([{ role: "user", content: "Do worker task" }]), expect.any(Number), 0.1, undefined);
104
136
  expect(compactMock).toHaveBeenCalledWith(expect.arrayContaining([{ role: "user", content: "Do worker task" }]), expect.objectContaining({
105
137
  provider: "anthropic",
106
138
  model: "claude-test",
107
- apiKey: "test-access-token",
139
+ apiKey: "test-anthropic-token",
108
140
  }));
109
141
  expect(agentLoopMock).toHaveBeenCalledWith(expect.arrayContaining([
110
142
  { role: "user", content: "[compacted worker context]\n\nDo worker task" },
111
143
  ]), expect.objectContaining({ provider: "anthropic", model: "claude-test" }));
144
+ }, 15_000);
145
+ });
146
+ describe("AgentSession stale tool-output pruning", () => {
147
+ it("stubs superseded reads in-place during the pre-step transform", async () => {
148
+ shouldCompactMock.mockReturnValue(false);
149
+ let capturedMessages = [];
150
+ agentLoopMock.mockImplementation(async function* (messages, options) {
151
+ messages.push({
152
+ role: "assistant",
153
+ content: [
154
+ { type: "tool_call", id: "old-read", name: "read", args: { file_path: "src/a.ts" } },
155
+ ],
156
+ }, {
157
+ role: "tool",
158
+ content: [{ type: "tool_result", toolCallId: "old-read", content: "x".repeat(150_000) }],
159
+ }, { role: "user", content: "turn 2" }, {
160
+ role: "assistant",
161
+ content: [
162
+ { type: "tool_call", id: "new-read", name: "read", args: { file_path: "src/a.ts" } },
163
+ ],
164
+ }, {
165
+ role: "tool",
166
+ content: [{ type: "tool_result", toolCallId: "new-read", content: "fresh" }],
167
+ }, { role: "user", content: "turn 3" });
168
+ capturedMessages = await options.transformContext(messages, { pendingMessages: [] });
169
+ yield { type: "agent_done" };
170
+ });
171
+ const { AgentSession } = await import("./agent-session.js");
172
+ const session = new AgentSession({
173
+ provider: "anthropic",
174
+ model: "claude-test",
175
+ cwd: tmpProject,
176
+ systemPrompt: "system prompt",
177
+ transient: true,
178
+ });
179
+ await session.initialize();
180
+ await session.prompt("prune me");
181
+ await session.dispose();
182
+ const results = capturedMessages
183
+ .filter((msg) => msg.role === "tool")
184
+ .flatMap((msg) => (Array.isArray(msg.content) ? msg.content : []));
185
+ const oldRead = results.find((r) => r.type === "tool_result" && r.toolCallId === "old-read");
186
+ const newRead = results.find((r) => r.type === "tool_result" && r.toolCallId === "new-read");
187
+ expect(oldRead?.content).toContain("superseded by a newer read");
188
+ expect(newRead?.content).toBe("fresh");
189
+ expect(compactMock).not.toHaveBeenCalled();
190
+ });
191
+ it("discards the turn's provider usage after a prune so freed tokens defer compaction", async () => {
192
+ shouldCompactMock.mockReturnValue(false);
193
+ agentLoopMock.mockImplementation(async function* (messages, options) {
194
+ messages.push({
195
+ role: "assistant",
196
+ content: [
197
+ { type: "tool_call", id: "old-read", name: "read", args: { file_path: "src/a.ts" } },
198
+ ],
199
+ }, {
200
+ role: "tool",
201
+ content: [{ type: "tool_result", toolCallId: "old-read", content: "x".repeat(150_000) }],
202
+ }, { role: "user", content: "turn 2" }, {
203
+ role: "assistant",
204
+ content: [
205
+ { type: "tool_call", id: "new-read", name: "read", args: { file_path: "src/a.ts" } },
206
+ ],
207
+ }, {
208
+ role: "tool",
209
+ content: [{ type: "tool_result", toolCallId: "new-read", content: "fresh" }],
210
+ }, { role: "user", content: "turn 3" });
211
+ // Usage counted the soon-to-be-pruned 150k-char read. After the prune
212
+ // it must NOT reach shouldCompact — estimation on the pruned history
213
+ // takes over instead.
214
+ await options.transformContext(messages, {
215
+ usage: { inputTokens: 180_000, outputTokens: 500 },
216
+ pendingMessages: [],
217
+ });
218
+ yield { type: "agent_done" };
219
+ });
220
+ const { AgentSession } = await import("./agent-session.js");
221
+ const session = new AgentSession({
222
+ provider: "anthropic",
223
+ model: "claude-test",
224
+ cwd: tmpProject,
225
+ systemPrompt: "system prompt",
226
+ transient: true,
227
+ });
228
+ await session.initialize();
229
+ await session.prompt("prune usage fallback");
230
+ await session.dispose();
231
+ // The transform after the in-loop mutation is the last shouldCompact call;
232
+ // its actualTokens argument must be an estimate of the pruned history,
233
+ // far below the stale 180k usage figure.
234
+ const lastCall = shouldCompactMock.mock.calls.at(-1);
235
+ const actualTokens = lastCall[3];
236
+ expect(actualTokens).toBeLessThan(50_000);
112
237
  });
113
238
  });
114
239
  describe("AgentSession overflow recovery", () => {
@@ -138,11 +263,14 @@ describe("AgentSession overflow recovery", () => {
138
263
  let forceResult;
139
264
  agentLoopMock.mockImplementation(async function* (messages, options) {
140
265
  // Non-force pre-call invocation must pass through untouched.
141
- const passthrough = await options.transformContext(messages);
266
+ const passthrough = await options.transformContext(messages, { pendingMessages: [] });
142
267
  expect(passthrough).toBe(messages);
143
268
  expect(compactMock).not.toHaveBeenCalled();
144
269
  // Force invocation (overflow) must compact and return the smaller array.
145
- forceResult = await options.transformContext(messages, { force: true });
270
+ forceResult = await options.transformContext(messages, {
271
+ force: true,
272
+ pendingMessages: [],
273
+ });
146
274
  yield { type: "agent_done" };
147
275
  });
148
276
  const { AgentSession } = await import("./agent-session.js");
@@ -160,8 +288,308 @@ describe("AgentSession overflow recovery", () => {
160
288
  expect(forceResult).toEqual(compactedMessages);
161
289
  });
162
290
  });
291
+ describe("AgentSession mid-turn compaction", () => {
292
+ it("compacts non-forced in-flight history exactly at the configured 80% boundary", async () => {
293
+ await writeJson(path.join(tmpHome, ".ezcoder", "settings.json"), {
294
+ autoCompact: true,
295
+ compactThreshold: 0.8,
296
+ });
297
+ const pendingMessage = {
298
+ role: "tool",
299
+ content: [
300
+ {
301
+ type: "tool_result",
302
+ toolCallId: "t1",
303
+ content: "pending tool output ".repeat(8),
304
+ },
305
+ ],
306
+ };
307
+ const usage = {
308
+ inputTokens: 159_900,
309
+ cacheRead: 30,
310
+ cacheWrite: 20,
311
+ outputTokens: 40,
312
+ };
313
+ shouldCompactMock.mockImplementation((_messages, contextWindow, threshold, actualTokens) => actualTokens !== undefined && actualTokens >= Math.ceil(contextWindow * threshold));
314
+ const compactedMessages = [
315
+ { role: "system", content: "system prompt" },
316
+ { role: "user", content: "[compacted in-flight history]" },
317
+ ];
318
+ compactMock.mockResolvedValue(compactionResult(compactedMessages));
319
+ let transformed;
320
+ agentLoopMock.mockImplementation(async function* (messages, options) {
321
+ messages.push({
322
+ role: "assistant",
323
+ content: [{ type: "tool_call", id: "t1", name: "read", args: {} }],
324
+ });
325
+ messages.push(pendingMessage);
326
+ transformed = await options.transformContext(messages, {
327
+ usage,
328
+ pendingMessages: [pendingMessage],
329
+ });
330
+ yield { type: "agent_done" };
331
+ });
332
+ const { AgentSession } = await import("./agent-session.js");
333
+ const session = new AgentSession({
334
+ provider: "anthropic",
335
+ model: "claude-test",
336
+ cwd: tmpProject,
337
+ systemPrompt: "system prompt",
338
+ transient: true,
339
+ });
340
+ await session.initialize();
341
+ await session.prompt("run a tool loop");
342
+ await session.dispose();
343
+ // Computed after the run: the anchored usage sample calibrates the token
344
+ // estimator, so the pending-message estimate must use the same calibrated
345
+ // ratio the transform used when it called shouldCompact.
346
+ const expectedActiveTokens = usage.inputTokens +
347
+ usage.cacheRead +
348
+ usage.cacheWrite +
349
+ usage.outputTokens +
350
+ estimateConversationTokens([pendingMessage]);
351
+ expect(expectedActiveTokens).toBeGreaterThanOrEqual(160_000);
352
+ expect(transformed).toEqual(compactedMessages);
353
+ expect(shouldCompactMock).toHaveBeenCalledWith(expect.any(Array), 200_000, 0.8, expectedActiveTokens);
354
+ expect(compactMock.mock.calls.at(-1)?.[0]).toContainEqual(pendingMessage);
355
+ });
356
+ it("reuses authoritative usage for the first context check of the next prompt", async () => {
357
+ const usage = { inputTokens: 120_000, outputTokens: 100 };
358
+ shouldCompactMock.mockImplementation((_messages, contextWindow, threshold, actualTokens) => actualTokens !== undefined && actualTokens >= Math.ceil(contextWindow * threshold));
359
+ compactMock.mockResolvedValue(compactionResult([
360
+ { role: "system", content: "system prompt" },
361
+ { role: "user", content: "[compacted across prompts]" },
362
+ ]));
363
+ let run = 0;
364
+ agentLoopMock.mockImplementation(async function* (messages, options) {
365
+ run += 1;
366
+ await options.transformContext(messages, { pendingMessages: [] });
367
+ if (run === 1) {
368
+ messages.push({ role: "assistant", content: "first response" });
369
+ yield {
370
+ type: "turn_end",
371
+ turn: 1,
372
+ stopReason: "end_turn",
373
+ usage,
374
+ timing: {
375
+ startedAt: 1,
376
+ completedAt: 2,
377
+ providerDurationMs: 1,
378
+ },
379
+ };
380
+ }
381
+ yield { type: "agent_done" };
382
+ });
383
+ const { AgentSession } = await import("./agent-session.js");
384
+ const session = new AgentSession({
385
+ provider: "anthropic",
386
+ model: "claude-fable-5",
387
+ cwd: tmpProject,
388
+ systemPrompt: "system prompt",
389
+ transient: true,
390
+ });
391
+ await session.initialize();
392
+ await session.prompt("first prompt");
393
+ await session.prompt("second prompt");
394
+ await session.dispose();
395
+ expect(compactMock).toHaveBeenCalledTimes(1);
396
+ expect(shouldCompactMock.mock.calls.some((call) => typeof call[3] === "number" && call[3] >= usage.inputTokens + usage.outputTokens)).toBe(true);
397
+ });
398
+ it.each(providerModels)("$provider uses the same normalized usage formula", async ({ provider, model }) => {
399
+ await writeJson(path.join(tmpHome, ".ezcoder", "settings.json"), {
400
+ autoCompact: true,
401
+ compactThreshold: 0.8,
402
+ });
403
+ shouldCompactMock.mockReturnValue(false);
404
+ const usage = {
405
+ inputTokens: 100,
406
+ cacheRead: 30,
407
+ cacheWrite: 20,
408
+ outputTokens: 40,
409
+ };
410
+ const expectedActiveTokens = 190;
411
+ agentLoopMock.mockImplementation(async function* (messages, options) {
412
+ await options.transformContext(messages, {
413
+ usage,
414
+ pendingMessages: [],
415
+ });
416
+ yield { type: "agent_done" };
417
+ });
418
+ const { AgentSession } = await import("./agent-session.js");
419
+ const session = new AgentSession({
420
+ provider: provider,
421
+ model,
422
+ cwd: tmpProject,
423
+ systemPrompt: "system prompt",
424
+ transient: true,
425
+ });
426
+ await session.initialize();
427
+ await session.prompt("test normalized usage");
428
+ await session.dispose();
429
+ expect(shouldCompactMock.mock.calls.some((call) => call[3] === expectedActiveTokens)).toBe(true);
430
+ }, 15_000);
431
+ it("honors a custom threshold during a non-forced transform", async () => {
432
+ await writeJson(path.join(tmpHome, ".ezcoder", "settings.json"), {
433
+ autoCompact: true,
434
+ compactThreshold: 0.65,
435
+ });
436
+ shouldCompactMock.mockReturnValue(false);
437
+ const usage = { inputTokens: 1_000, outputTokens: 100 };
438
+ agentLoopMock.mockImplementation(async function* (messages, options) {
439
+ await options.transformContext(messages, { usage, pendingMessages: [] });
440
+ yield { type: "agent_done" };
441
+ });
442
+ const { AgentSession } = await import("./agent-session.js");
443
+ const session = new AgentSession({
444
+ provider: "anthropic",
445
+ model: "claude-test",
446
+ cwd: tmpProject,
447
+ systemPrompt: "system prompt",
448
+ transient: true,
449
+ });
450
+ await session.initialize();
451
+ await session.prompt("custom threshold");
452
+ await session.dispose();
453
+ expect(shouldCompactMock).toHaveBeenCalledWith(expect.any(Array), 200_000, 0.65, 1_100);
454
+ });
455
+ it("honors autoCompact false for non-forced calls but force bypasses settings and cooldown", async () => {
456
+ await writeJson(path.join(tmpHome, ".ezcoder", "settings.json"), {
457
+ autoCompact: false,
458
+ compactThreshold: 0.8,
459
+ });
460
+ const compactedMessages = [
461
+ { role: "system", content: "system prompt" },
462
+ { role: "user", content: "[forced compaction]" },
463
+ ];
464
+ compactMock
465
+ .mockResolvedValueOnce(compactionResult([
466
+ { role: "system", content: "system prompt" },
467
+ { role: "user", content: "too little history" },
468
+ { role: "assistant", content: "reply" },
469
+ ], false))
470
+ .mockResolvedValueOnce(compactionResult(compactedMessages));
471
+ let nonForcedResult;
472
+ let forcedResult;
473
+ agentLoopMock.mockImplementation(async function* (messages, options) {
474
+ nonForcedResult = await options.transformContext(messages, { pendingMessages: [] });
475
+ await options.transformContext(messages, { force: true, pendingMessages: [] });
476
+ forcedResult = await options.transformContext(messages, {
477
+ force: true,
478
+ pendingMessages: [],
479
+ });
480
+ yield { type: "agent_done" };
481
+ });
482
+ const { AgentSession } = await import("./agent-session.js");
483
+ const session = new AgentSession({
484
+ provider: "anthropic",
485
+ model: "claude-test",
486
+ cwd: tmpProject,
487
+ systemPrompt: "system prompt",
488
+ transient: true,
489
+ });
490
+ await session.initialize();
491
+ await session.prompt("force despite settings");
492
+ await session.dispose();
493
+ expect(nonForcedResult).toBeDefined();
494
+ expect(shouldCompactMock).not.toHaveBeenCalled();
495
+ expect(compactMock).toHaveBeenCalledTimes(2);
496
+ expect(forcedResult).toEqual(compactedMessages);
497
+ });
498
+ it("cools down after a non-forced no-op instead of retrying every tool step", async () => {
499
+ shouldCompactMock.mockImplementation((_messages, _contextWindow, _threshold, actualTokens) => actualTokens !== undefined);
500
+ compactMock.mockImplementation(async (messages) => compactionResult([...messages], false));
501
+ agentLoopMock.mockImplementation(async function* (messages, options) {
502
+ const transformOptions = {
503
+ usage: { inputTokens: 180_000, outputTokens: 1_000 },
504
+ pendingMessages: [],
505
+ };
506
+ await options.transformContext(messages, transformOptions);
507
+ await options.transformContext(messages, transformOptions);
508
+ yield { type: "agent_done" };
509
+ });
510
+ const { AgentSession } = await import("./agent-session.js");
511
+ const session = new AgentSession({
512
+ provider: "anthropic",
513
+ model: "claude-test",
514
+ cwd: tmpProject,
515
+ systemPrompt: "system prompt",
516
+ transient: true,
517
+ });
518
+ await session.initialize();
519
+ await session.prompt("no-op cooldown");
520
+ await session.dispose();
521
+ expect(compactMock).toHaveBeenCalledTimes(1);
522
+ });
523
+ it("cools down after a failed proactive summary", async () => {
524
+ shouldCompactMock.mockImplementation((_messages, _contextWindow, _threshold, actualTokens) => actualTokens !== undefined);
525
+ compactMock.mockRejectedValue(new Error("summary unavailable"));
526
+ agentLoopMock.mockImplementation(async function* (messages, options) {
527
+ const transformOptions = {
528
+ usage: { inputTokens: 180_000, outputTokens: 1_000 },
529
+ pendingMessages: [],
530
+ };
531
+ await options.transformContext(messages, transformOptions);
532
+ await options.transformContext(messages, transformOptions);
533
+ yield { type: "agent_done" };
534
+ });
535
+ const { AgentSession } = await import("./agent-session.js");
536
+ const session = new AgentSession({
537
+ provider: "anthropic",
538
+ model: "claude-test",
539
+ cwd: tmpProject,
540
+ systemPrompt: "system prompt",
541
+ transient: true,
542
+ });
543
+ await session.initialize();
544
+ await session.prompt("failure cooldown");
545
+ await session.dispose();
546
+ expect(compactMock).toHaveBeenCalledTimes(1);
547
+ });
548
+ it("continues the run and cools down across prompts after pre-run compaction fails", async () => {
549
+ shouldCompactMock.mockReturnValue(true);
550
+ compactMock.mockRejectedValue(new Error("summary unavailable"));
551
+ agentLoopMock.mockImplementation(async function* () {
552
+ yield { type: "agent_done" };
553
+ });
554
+ const { AgentSession } = await import("./agent-session.js");
555
+ const session = new AgentSession({
556
+ provider: "anthropic",
557
+ model: "claude-test",
558
+ cwd: tmpProject,
559
+ systemPrompt: "system prompt",
560
+ transient: true,
561
+ });
562
+ await session.initialize();
563
+ await session.prompt("first prompt");
564
+ await session.prompt("second prompt");
565
+ await session.dispose();
566
+ expect(compactMock).toHaveBeenCalledTimes(1);
567
+ expect(agentLoopMock).toHaveBeenCalledTimes(2);
568
+ });
569
+ it("cools down across prompts after pre-run compaction is a no-op", async () => {
570
+ shouldCompactMock.mockReturnValue(true);
571
+ compactMock.mockImplementation(async (messages) => compactionResult([...messages], false));
572
+ agentLoopMock.mockImplementation(async function* () {
573
+ yield { type: "agent_done" };
574
+ });
575
+ const { AgentSession } = await import("./agent-session.js");
576
+ const session = new AgentSession({
577
+ provider: "anthropic",
578
+ model: "claude-test",
579
+ cwd: tmpProject,
580
+ systemPrompt: "system prompt",
581
+ transient: true,
582
+ });
583
+ await session.initialize();
584
+ await session.prompt("first prompt");
585
+ await session.prompt("second prompt");
586
+ await session.dispose();
587
+ expect(compactMock).toHaveBeenCalledTimes(1);
588
+ expect(agentLoopMock).toHaveBeenCalledTimes(2);
589
+ });
590
+ });
163
591
  /** Every .jsonl under the ezcoder session store — must stay empty for
164
- * transient sessions (Nolan chat/autopilot, subagent spawns). */
592
+ * transient sessions (Nolan chat/autopilot and subagent spawns). */
165
593
  async function listSessionFiles() {
166
594
  const sessionsDir = path.join(tmpHome, ".ezcoder", "sessions");
167
595
  const out = [];
@@ -249,4 +677,87 @@ describe("transient sessions never leak to the session store", () => {
249
677
  expect(await listSessionFiles()).toEqual([]);
250
678
  });
251
679
  });
680
+ describe("load-time auto-compaction deferral (deferLoadCompaction)", () => {
681
+ // Regression: resuming an over-context session ran a summary LLM call (30s
682
+ // timeout) inline in loadExistingSession — inside initialize(), which the
683
+ // ezcoder-app sidecar's readiness (waitForReady → the whole webview) is gated on.
684
+ // A slow/hanging summary call froze the window for the full timeout. With
685
+ // deferLoadCompaction the resume returns immediately and runLoop()'s
686
+ // pre-run auto-compaction handles it on the first prompt.
687
+ /** Create + persist a real session file, returning its path. */
688
+ async function persistSession() {
689
+ shouldCompactMock.mockReturnValue(false);
690
+ agentLoopMock.mockImplementation(async function* (messages) {
691
+ messages.push({ role: "assistant", content: "first reply" });
692
+ yield { type: "agent_done" };
693
+ });
694
+ const { AgentSession } = await import("./agent-session.js");
695
+ const session = new AgentSession({
696
+ provider: "anthropic",
697
+ model: "claude-test",
698
+ cwd: tmpProject,
699
+ systemPrompt: "system prompt",
700
+ });
701
+ await session.initialize();
702
+ await session.prompt("hello");
703
+ await session.dispose();
704
+ const files = await listSessionFiles();
705
+ expect(files.length).toBeGreaterThan(0);
706
+ return files[0];
707
+ }
708
+ const compactedResult = {
709
+ messages: [
710
+ { role: "system", content: "system prompt" },
711
+ { role: "user", content: "[compacted]" },
712
+ ],
713
+ result: {
714
+ compacted: true,
715
+ originalCount: 3,
716
+ newCount: 2,
717
+ tokensBeforeEstimate: 500_000,
718
+ tokensAfterEstimate: 2_000,
719
+ },
720
+ };
721
+ it("defers compaction out of initialize() and runs it on the first prompt", async () => {
722
+ const sessionPath = await persistSession();
723
+ shouldCompactMock.mockReturnValue(true);
724
+ compactMock.mockResolvedValue(compactedResult);
725
+ agentLoopMock.mockImplementation(async function* (messages) {
726
+ messages.push({ role: "assistant", content: "resumed reply" });
727
+ yield { type: "agent_done" };
728
+ });
729
+ const { AgentSession } = await import("./agent-session.js");
730
+ const resumed = new AgentSession({
731
+ provider: "anthropic",
732
+ model: "claude-test",
733
+ cwd: tmpProject,
734
+ systemPrompt: "system prompt",
735
+ sessionId: sessionPath,
736
+ deferLoadCompaction: true,
737
+ });
738
+ await resumed.initialize();
739
+ // Readiness path must NOT have paid for a summary LLM call.
740
+ expect(compactMock).not.toHaveBeenCalled();
741
+ // First prompt triggers runLoop()'s existing pre-run auto-compaction.
742
+ await resumed.prompt("continue");
743
+ expect(compactMock).toHaveBeenCalledTimes(1);
744
+ await resumed.dispose();
745
+ });
746
+ it("still compacts inline during initialize() without the flag (CLI resume)", async () => {
747
+ const sessionPath = await persistSession();
748
+ shouldCompactMock.mockReturnValue(true);
749
+ compactMock.mockResolvedValue(compactedResult);
750
+ const { AgentSession } = await import("./agent-session.js");
751
+ const resumed = new AgentSession({
752
+ provider: "anthropic",
753
+ model: "claude-test",
754
+ cwd: tmpProject,
755
+ systemPrompt: "system prompt",
756
+ sessionId: sessionPath,
757
+ });
758
+ await resumed.initialize();
759
+ expect(compactMock).toHaveBeenCalledTimes(1);
760
+ await resumed.dispose();
761
+ });
762
+ });
252
763
  //# sourceMappingURL=agent-session-compaction.test.js.map