skimpyclaw 0.3.14 → 0.4.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 (222) hide show
  1. package/README.md +47 -37
  2. package/dist/__tests__/adapter-types.test.d.ts +4 -0
  3. package/dist/__tests__/adapter-types.test.js +63 -0
  4. package/dist/__tests__/anthropic-adapter.test.d.ts +4 -0
  5. package/dist/__tests__/anthropic-adapter.test.js +264 -0
  6. package/dist/__tests__/api.test.js +0 -1
  7. package/dist/__tests__/cli.integration.test.js +2 -4
  8. package/dist/__tests__/cli.test.js +0 -1
  9. package/dist/__tests__/code-agents-notifications.test.js +137 -0
  10. package/dist/__tests__/code-agents-parser.test.js +19 -1
  11. package/dist/__tests__/code-agents-preflight.test.js +3 -28
  12. package/dist/__tests__/code-agents-utils.test.js +34 -9
  13. package/dist/__tests__/code-agents-worktrees.test.js +116 -0
  14. package/dist/__tests__/codex-adapter.test.js +184 -0
  15. package/dist/__tests__/codex-auth.test.js +66 -0
  16. package/dist/__tests__/codex-provider-gating.test.js +35 -0
  17. package/dist/__tests__/codex-unified-loop.test.js +111 -0
  18. package/dist/__tests__/config-security.test.js +127 -0
  19. package/dist/__tests__/config.test.js +23 -0
  20. package/dist/__tests__/context-manager.test.js +243 -164
  21. package/dist/__tests__/cron-run.test.js +250 -0
  22. package/dist/__tests__/cron.test.js +12 -38
  23. package/dist/__tests__/digests.test.js +67 -0
  24. package/dist/__tests__/discord-attachments.test.js +211 -0
  25. package/dist/__tests__/discord-docs.test.d.ts +1 -0
  26. package/dist/__tests__/discord-docs.test.js +27 -0
  27. package/dist/__tests__/discord-thread-agents.test.d.ts +1 -0
  28. package/dist/__tests__/discord-thread-agents.test.js +115 -0
  29. package/dist/__tests__/discord-thread-context.test.d.ts +1 -0
  30. package/dist/__tests__/discord-thread-context.test.js +42 -0
  31. package/dist/__tests__/doctor.formatters.test.js +4 -4
  32. package/dist/__tests__/doctor.index.test.js +1 -1
  33. package/dist/__tests__/doctor.runner.test.js +3 -15
  34. package/dist/__tests__/env-sanitizer.test.d.ts +1 -0
  35. package/dist/__tests__/env-sanitizer.test.js +45 -0
  36. package/dist/__tests__/exec-approval.test.js +61 -0
  37. package/dist/__tests__/fetch-tool.test.d.ts +1 -0
  38. package/dist/__tests__/fetch-tool.test.js +85 -0
  39. package/dist/__tests__/gateway-status-auth.test.d.ts +1 -0
  40. package/dist/__tests__/gateway-status-auth.test.js +72 -0
  41. package/dist/__tests__/heartbeat.test.js +3 -3
  42. package/dist/__tests__/interactive-sessions.test.d.ts +1 -0
  43. package/dist/__tests__/interactive-sessions.test.js +96 -0
  44. package/dist/__tests__/langfuse.test.js +6 -18
  45. package/dist/__tests__/model-selection.test.js +3 -4
  46. package/dist/__tests__/providers-init.test.js +2 -8
  47. package/dist/__tests__/providers-routing.test.js +1 -1
  48. package/dist/__tests__/providers-utils.test.js +13 -3
  49. package/dist/__tests__/sessions.test.js +14 -10
  50. package/dist/__tests__/setup.test.js +12 -29
  51. package/dist/__tests__/skills.test.js +10 -7
  52. package/dist/__tests__/stream-formatter.test.d.ts +1 -0
  53. package/dist/__tests__/stream-formatter.test.js +114 -0
  54. package/dist/__tests__/token-efficiency.test.js +131 -15
  55. package/dist/__tests__/tool-loop.test.d.ts +4 -0
  56. package/dist/__tests__/tool-loop.test.js +505 -0
  57. package/dist/__tests__/tools.test.js +101 -276
  58. package/dist/__tests__/utils.test.d.ts +1 -0
  59. package/dist/__tests__/utils.test.js +14 -0
  60. package/dist/__tests__/voice.test.js +21 -0
  61. package/dist/agent.js +35 -4
  62. package/dist/api.js +113 -37
  63. package/dist/channels/discord/attachments.d.ts +50 -0
  64. package/dist/channels/discord/attachments.js +137 -0
  65. package/dist/channels/discord/delegation.d.ts +5 -0
  66. package/dist/channels/discord/delegation.js +136 -0
  67. package/dist/channels/discord/handlers.js +694 -7
  68. package/dist/channels/discord/index.d.ts +16 -1
  69. package/dist/channels/discord/index.js +64 -1
  70. package/dist/channels/discord/thread-agents.d.ts +54 -0
  71. package/dist/channels/discord/thread-agents.js +323 -0
  72. package/dist/channels/discord/threads.d.ts +58 -0
  73. package/dist/channels/discord/threads.js +192 -0
  74. package/dist/channels/discord/types.js +4 -2
  75. package/dist/channels/discord/utils.d.ts +16 -0
  76. package/dist/channels/discord/utils.js +86 -6
  77. package/dist/channels/telegram/index.d.ts +1 -1
  78. package/dist/channels/telegram/types.js +1 -1
  79. package/dist/channels/telegram/utils.js +9 -3
  80. package/dist/channels.d.ts +1 -1
  81. package/dist/cli.js +20 -400
  82. package/dist/code-agents/executor.d.ts +1 -1
  83. package/dist/code-agents/executor.js +101 -45
  84. package/dist/code-agents/index.d.ts +2 -7
  85. package/dist/code-agents/index.js +111 -80
  86. package/dist/code-agents/interactive-resume.d.ts +6 -0
  87. package/dist/code-agents/interactive-resume.js +98 -0
  88. package/dist/code-agents/interactive-sessions.d.ts +20 -0
  89. package/dist/code-agents/interactive-sessions.js +132 -0
  90. package/dist/code-agents/parser.js +5 -1
  91. package/dist/code-agents/registry.d.ts +7 -1
  92. package/dist/code-agents/registry.js +11 -23
  93. package/dist/code-agents/stream-formatter.d.ts +8 -0
  94. package/dist/code-agents/stream-formatter.js +92 -0
  95. package/dist/code-agents/types.d.ts +16 -24
  96. package/dist/code-agents/utils.d.ts +35 -11
  97. package/dist/code-agents/utils.js +349 -95
  98. package/dist/code-agents/worktrees.d.ts +37 -0
  99. package/dist/code-agents/worktrees.js +116 -0
  100. package/dist/config.d.ts +2 -4
  101. package/dist/config.js +123 -23
  102. package/dist/cron.d.ts +1 -6
  103. package/dist/cron.js +175 -82
  104. package/dist/dashboard/assets/index-B345aOO-.js +65 -0
  105. package/dist/dashboard/assets/index-ZWK4dalJ.css +1 -0
  106. package/dist/dashboard/index.html +2 -2
  107. package/dist/digests.d.ts +1 -0
  108. package/dist/digests.js +132 -42
  109. package/dist/doctor/checks.d.ts +0 -3
  110. package/dist/doctor/checks.js +1 -108
  111. package/dist/doctor/runner.js +1 -4
  112. package/dist/env-sanitizer.d.ts +2 -0
  113. package/dist/env-sanitizer.js +61 -0
  114. package/dist/exec-approval.d.ts +11 -1
  115. package/dist/exec-approval.js +17 -4
  116. package/dist/gateway.d.ts +3 -1
  117. package/dist/gateway.js +17 -7
  118. package/dist/heartbeat.js +1 -6
  119. package/dist/langfuse.js +3 -29
  120. package/dist/model-selection.js +3 -1
  121. package/dist/providers/adapter.d.ts +118 -0
  122. package/dist/providers/adapter.js +6 -0
  123. package/dist/providers/adapters/anthropic-adapter.d.ts +22 -0
  124. package/dist/providers/adapters/anthropic-adapter.js +204 -0
  125. package/dist/providers/adapters/codex-adapter.d.ts +26 -0
  126. package/dist/providers/adapters/codex-adapter.js +203 -0
  127. package/dist/providers/anthropic.d.ts +1 -0
  128. package/dist/providers/anthropic.js +10 -272
  129. package/dist/providers/codex.d.ts +21 -0
  130. package/dist/providers/codex.js +149 -330
  131. package/dist/providers/content.d.ts +1 -1
  132. package/dist/providers/content.js +2 -2
  133. package/dist/providers/context-manager.d.ts +18 -6
  134. package/dist/providers/context-manager.js +199 -223
  135. package/dist/providers/index.d.ts +9 -1
  136. package/dist/providers/index.js +73 -64
  137. package/dist/providers/loop-utils.d.ts +20 -0
  138. package/dist/providers/loop-utils.js +30 -0
  139. package/dist/providers/tool-loop.d.ts +12 -0
  140. package/dist/providers/tool-loop.js +251 -0
  141. package/dist/providers/utils.d.ts +19 -3
  142. package/dist/providers/utils.js +100 -29
  143. package/dist/secure-store.d.ts +8 -0
  144. package/dist/secure-store.js +80 -0
  145. package/dist/service.js +3 -28
  146. package/dist/sessions.d.ts +3 -0
  147. package/dist/sessions.js +147 -18
  148. package/dist/setup-templates.js +13 -25
  149. package/dist/setup.d.ts +10 -6
  150. package/dist/setup.js +84 -292
  151. package/dist/skills.js +3 -11
  152. package/dist/tools/agent-delegation.d.ts +19 -0
  153. package/dist/tools/agent-delegation.js +49 -0
  154. package/dist/tools/bash-tool.js +89 -34
  155. package/dist/tools/definitions.d.ts +199 -302
  156. package/dist/tools/definitions.js +70 -123
  157. package/dist/tools/execute-context.d.ts +13 -4
  158. package/dist/tools/fetch-tool.js +109 -13
  159. package/dist/tools/file-tools.js +7 -1
  160. package/dist/tools.d.ts +7 -7
  161. package/dist/tools.js +133 -151
  162. package/dist/types.d.ts +37 -30
  163. package/dist/utils.js +4 -6
  164. package/dist/voice.d.ts +1 -1
  165. package/dist/voice.js +17 -4
  166. package/package.json +33 -23
  167. package/templates/TOOLS.md +0 -27
  168. package/dist/__tests__/audit.test.js +0 -122
  169. package/dist/__tests__/code-agents-orchestrator.test.js +0 -216
  170. package/dist/__tests__/code-agents-sandbox.test.js +0 -163
  171. package/dist/__tests__/orchestrator.test.js +0 -425
  172. package/dist/__tests__/sandbox-bridge.test.js +0 -116
  173. package/dist/__tests__/sandbox-manager.test.js +0 -144
  174. package/dist/__tests__/sandbox-mount-security.test.js +0 -139
  175. package/dist/__tests__/sandbox-runtime.test.js +0 -176
  176. package/dist/__tests__/subagent.test.js +0 -240
  177. package/dist/__tests__/telegram.test.js +0 -42
  178. package/dist/code-agents/orchestrator.d.ts +0 -29
  179. package/dist/code-agents/orchestrator.js +0 -694
  180. package/dist/code-agents/worktree.d.ts +0 -40
  181. package/dist/code-agents/worktree.js +0 -215
  182. package/dist/dashboard/assets/index-BoTHPby4.js +0 -65
  183. package/dist/dashboard/assets/index-D4mufvBg.css +0 -1
  184. package/dist/dashboard.d.ts +0 -8
  185. package/dist/dashboard.js +0 -4071
  186. package/dist/discord.d.ts +0 -8
  187. package/dist/discord.js +0 -792
  188. package/dist/mcp-context-a8c.d.ts +0 -13
  189. package/dist/mcp-context-a8c.js +0 -34
  190. package/dist/orchestrator.d.ts +0 -15
  191. package/dist/orchestrator.js +0 -676
  192. package/dist/providers/openai.d.ts +0 -10
  193. package/dist/providers/openai.js +0 -355
  194. package/dist/sandbox/bridge.d.ts +0 -5
  195. package/dist/sandbox/bridge.js +0 -63
  196. package/dist/sandbox/index.d.ts +0 -5
  197. package/dist/sandbox/index.js +0 -4
  198. package/dist/sandbox/manager.d.ts +0 -7
  199. package/dist/sandbox/manager.js +0 -100
  200. package/dist/sandbox/mount-security.d.ts +0 -12
  201. package/dist/sandbox/mount-security.js +0 -122
  202. package/dist/sandbox/runtime.d.ts +0 -39
  203. package/dist/sandbox/runtime.js +0 -192
  204. package/dist/sandbox-utils.d.ts +0 -6
  205. package/dist/sandbox-utils.js +0 -36
  206. package/dist/subagent.d.ts +0 -19
  207. package/dist/subagent.js +0 -407
  208. package/dist/telegram.d.ts +0 -2
  209. package/dist/telegram.js +0 -11
  210. package/dist/tools/browser-tool.d.ts +0 -3
  211. package/dist/tools/browser-tool.js +0 -266
  212. package/sandbox/Dockerfile +0 -40
  213. /package/dist/__tests__/{audit.test.d.ts → code-agents-notifications.test.d.ts} +0 -0
  214. /package/dist/__tests__/{code-agents-orchestrator.test.d.ts → code-agents-worktrees.test.d.ts} +0 -0
  215. /package/dist/__tests__/{code-agents-sandbox.test.d.ts → codex-adapter.test.d.ts} +0 -0
  216. /package/dist/__tests__/{orchestrator.test.d.ts → codex-auth.test.d.ts} +0 -0
  217. /package/dist/__tests__/{sandbox-bridge.test.d.ts → codex-provider-gating.test.d.ts} +0 -0
  218. /package/dist/__tests__/{sandbox-manager.test.d.ts → codex-unified-loop.test.d.ts} +0 -0
  219. /package/dist/__tests__/{sandbox-mount-security.test.d.ts → config-security.test.d.ts} +0 -0
  220. /package/dist/__tests__/{sandbox-runtime.test.d.ts → cron-run.test.d.ts} +0 -0
  221. /package/dist/__tests__/{subagent.test.d.ts → digests.test.d.ts} +0 -0
  222. /package/dist/__tests__/{telegram.test.d.ts → discord-attachments.test.d.ts} +0 -0
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
- import { estimateTokens, compactAnthropicMessages, compactOpenAIMessages, compactCodexMessages, serializeAnthropicMessages, serializeOpenAIMessages, serializeCodexMessages, } from '../providers/context-manager.js';
2
+ import { estimateTokens, compactMessages, compactAnthropicMessages, compactOpenAIMessages, compactCodexMessages, anthropicFormatHelper, openaiFormatHelper, codexFormatHelper, serializeAnthropicMessages, serializeOpenAIMessages, serializeCodexMessages, } from '../providers/context-manager.js';
3
3
  // Mock the chat function used for LLM summarization
4
4
  vi.mock('../providers/index.js', () => ({
5
5
  chat: vi.fn().mockResolvedValue('Summary of the conversation: the user asked to list files and the assistant ran ls.'),
@@ -51,6 +51,14 @@ function openaiExchange(toolResult) {
51
51
  { role: 'tool', tool_call_id: 'tc_1', content: toolResult },
52
52
  ];
53
53
  }
54
+ // Helper: build many items for compaction tests
55
+ function manyItems(factory, content, count = 30) {
56
+ const items = [];
57
+ for (let i = 0; i < count; i++) {
58
+ items.push(...factory(content));
59
+ }
60
+ return items;
61
+ }
54
62
  describe('estimateTokens', () => {
55
63
  it('returns a positive number for non-empty data', () => {
56
64
  expect(estimateTokens([{ role: 'user', content: 'hello' }])).toBeGreaterThan(0);
@@ -64,25 +72,119 @@ describe('estimateTokens', () => {
64
72
  expect(large).toBeGreaterThan(small);
65
73
  });
66
74
  });
67
- describe('compactAnthropicMessages', () => {
68
- it('passes through unchanged when under threshold', async () => {
75
+ // =====================================================================
76
+ // MessageFormatHelper unit tests
77
+ // =====================================================================
78
+ describe('anthropicFormatHelper', () => {
79
+ it('isToolResult returns true for tool_result content blocks', () => {
80
+ const msg = { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'tu_1', content: 'result' }] };
81
+ expect(anthropicFormatHelper.isToolResult(msg)).toBe(true);
82
+ });
83
+ it('isToolResult returns false for text messages', () => {
84
+ const msg = { role: 'user', content: [{ type: 'text', text: 'hello' }] };
85
+ expect(anthropicFormatHelper.isToolResult(msg)).toBe(false);
86
+ });
87
+ it('isToolResult returns false for string content', () => {
88
+ expect(anthropicFormatHelper.isToolResult({ role: 'user', content: 'hi' })).toBe(false);
89
+ });
90
+ it('truncateToolResult truncates long tool_result content', () => {
91
+ const msg = {
92
+ role: 'user',
93
+ content: [{ type: 'tool_result', tool_use_id: 'tu_1', content: 'x'.repeat(1000) }],
94
+ };
95
+ const truncated = anthropicFormatHelper.truncateToolResult(msg, 100);
96
+ expect(truncated.content[0].content).toContain('[truncated]');
97
+ expect(truncated.content[0].content.length).toBeLessThan(200);
98
+ });
99
+ it('truncateToolResult leaves short content unchanged', () => {
100
+ const msg = {
101
+ role: 'user',
102
+ content: [{ type: 'tool_result', tool_use_id: 'tu_1', content: 'short' }],
103
+ };
104
+ const result = anthropicFormatHelper.truncateToolResult(msg, 500);
105
+ expect(result).toBe(msg); // same reference (no change)
106
+ });
107
+ it('buildSummaryMessage returns Anthropic-format summary', () => {
108
+ const summary = anthropicFormatHelper.buildSummaryMessage('test summary');
109
+ expect(summary.role).toBe('user');
110
+ expect(summary.content[0].type).toBe('text');
111
+ expect(summary.content[0].text).toContain('[Conversation Summary]');
112
+ expect(summary.content[0].text).toContain('test summary');
113
+ });
114
+ });
115
+ describe('openaiFormatHelper', () => {
116
+ it('isToolResult returns true for tool role messages', () => {
117
+ expect(openaiFormatHelper.isToolResult({ role: 'tool', content: 'result' })).toBe(true);
118
+ });
119
+ it('isToolResult returns false for non-tool messages', () => {
120
+ expect(openaiFormatHelper.isToolResult({ role: 'assistant', content: 'hi' })).toBe(false);
121
+ });
122
+ it('truncateToolResult truncates long content', () => {
123
+ const msg = { role: 'tool', tool_call_id: 'tc_1', content: 'x'.repeat(1000) };
124
+ const truncated = openaiFormatHelper.truncateToolResult(msg, 100);
125
+ expect(truncated.content).toContain('[truncated]');
126
+ expect(truncated.content.length).toBeLessThan(200);
127
+ });
128
+ it('truncateToolResult leaves short content unchanged', () => {
129
+ const msg = { role: 'tool', tool_call_id: 'tc_1', content: 'short' };
130
+ const result = openaiFormatHelper.truncateToolResult(msg, 500);
131
+ expect(result).toBe(msg);
132
+ });
133
+ it('buildSummaryMessage returns OpenAI-format summary', () => {
134
+ const summary = openaiFormatHelper.buildSummaryMessage('test summary');
135
+ expect(summary.role).toBe('user');
136
+ expect(summary.content).toContain('[Conversation Summary]');
137
+ expect(summary.content).toContain('test summary');
138
+ });
139
+ });
140
+ describe('codexFormatHelper', () => {
141
+ it('isToolResult returns true for function_call_output items', () => {
142
+ expect(codexFormatHelper.isToolResult({ type: 'function_call_output', output: 'result' })).toBe(true);
143
+ });
144
+ it('isToolResult returns false for function_call items', () => {
145
+ expect(codexFormatHelper.isToolResult({ type: 'function_call', name: 'Bash' })).toBe(false);
146
+ });
147
+ it('isToolResult returns false for message items', () => {
148
+ expect(codexFormatHelper.isToolResult({ type: 'message', role: 'user' })).toBe(false);
149
+ });
150
+ it('truncateToolResult truncates long output', () => {
151
+ const item = { type: 'function_call_output', call_id: 'fc_1', output: 'x'.repeat(1000) };
152
+ const truncated = codexFormatHelper.truncateToolResult(item, 100);
153
+ expect(truncated.output).toContain('[truncated]');
154
+ expect(truncated.output.length).toBeLessThan(200);
155
+ });
156
+ it('truncateToolResult leaves short output unchanged', () => {
157
+ const item = { type: 'function_call_output', call_id: 'fc_1', output: 'short' };
158
+ const result = codexFormatHelper.truncateToolResult(item, 500);
159
+ expect(result).toBe(item);
160
+ });
161
+ it('buildSummaryMessage returns Codex-format summary', () => {
162
+ const summary = codexFormatHelper.buildSummaryMessage('test summary');
163
+ expect(summary.type).toBe('message');
164
+ expect(summary.role).toBe('user');
165
+ expect(summary.content).toContain('[Conversation Summary]');
166
+ expect(summary.content).toContain('test summary');
167
+ });
168
+ });
169
+ // =====================================================================
170
+ // Generic compactMessages() tests
171
+ // =====================================================================
172
+ describe('compactMessages (generic)', () => {
173
+ it('passes through when under threshold', async () => {
69
174
  const messages = anthropicExchange('short result');
70
- const result = await compactAnthropicMessages(messages, { maxContextTokens: 100_000 });
71
- expect(result.messages).toEqual(messages);
175
+ const result = await compactMessages(messages, anthropicFormatHelper, { maxContextTokens: 100_000 });
176
+ expect(result.messages).toBe(messages);
72
177
  expect(result.compacted).toBe(false);
73
178
  });
74
- it('returns same reference when no compaction needed', async () => {
75
- const messages = anthropicExchange('short result');
76
- const result = await compactAnthropicMessages(messages, { maxContextTokens: 100_000 });
179
+ it('passes through when disabled', async () => {
180
+ const messages = manyItems(anthropicExchange, 'x'.repeat(10_000));
181
+ const result = await compactMessages(messages, anthropicFormatHelper, { enabled: false, maxContextTokens: 1 });
77
182
  expect(result.messages).toBe(messages);
183
+ expect(result.compacted).toBe(false);
78
184
  });
79
- it('uses LLM summarization when fullConfig is provided', async () => {
80
- const longResult = 'x'.repeat(10_000);
81
- const messages = [];
82
- for (let i = 0; i < 30; i++) {
83
- messages.push(...anthropicExchange(longResult));
84
- }
85
- const result = await compactAnthropicMessages(messages, { maxContextTokens: 1_000 }, 1, fullConfig);
185
+ it('uses LLM summarization with Anthropic helper', async () => {
186
+ const messages = manyItems(anthropicExchange, 'x'.repeat(10_000));
187
+ const result = await compactMessages(messages, anthropicFormatHelper, { maxContextTokens: 1_000 }, 1, fullConfig);
86
188
  expect(result.compacted).toBe(true);
87
189
  expect(result.method).toBe('llm');
88
190
  expect(result.summary).toBeTruthy();
@@ -90,86 +192,157 @@ describe('compactAnthropicMessages', () => {
90
192
  expect(result.tokensAfter).toBeGreaterThan(0);
91
193
  expect(result.tokensAfter).toBeLessThan(result.tokensBefore);
92
194
  expect(mockChat).toHaveBeenCalledOnce();
93
- // First message should be the summary
195
+ // First message should be the summary in Anthropic format
94
196
  expect(result.messages[0].role).toBe('user');
95
197
  expect(result.messages[0].content[0].text).toContain('[Conversation Summary]');
96
198
  // Last 8 should be preserved
97
199
  expect(result.messages.slice(-8)).toEqual(messages.slice(-8));
98
200
  });
99
- it('falls back to truncation when LLM fails', async () => {
201
+ it('uses LLM summarization with OpenAI helper', async () => {
202
+ const messages = manyItems(openaiExchange, 'x'.repeat(10_000));
203
+ const result = await compactMessages(messages, openaiFormatHelper, { maxContextTokens: 1_000 }, 1, fullConfig);
204
+ expect(result.compacted).toBe(true);
205
+ expect(result.method).toBe('llm');
206
+ expect(result.messages[0].role).toBe('user');
207
+ expect(result.messages[0].content).toContain('[Conversation Summary]');
208
+ });
209
+ it('uses LLM summarization with Codex helper', async () => {
210
+ const messages = manyItems(codexExchange, 'x'.repeat(10_000));
211
+ const result = await compactMessages(messages, codexFormatHelper, { maxContextTokens: 1_000 }, 1, fullConfig);
212
+ expect(result.compacted).toBe(true);
213
+ expect(result.method).toBe('llm');
214
+ expect(result.messages[0].type).toBe('message');
215
+ expect(result.messages[0].content).toContain('[Conversation Summary]');
216
+ });
217
+ it('falls back to truncation when LLM fails (Anthropic)', async () => {
100
218
  mockChat.mockRejectedValueOnce(new Error('API error'));
101
- const longResult = 'x'.repeat(10_000);
102
- const messages = [];
103
- for (let i = 0; i < 30; i++) {
104
- messages.push(...anthropicExchange(longResult));
105
- }
106
- const result = await compactAnthropicMessages(messages, { maxContextTokens: 1_000 }, 1, fullConfig);
219
+ const messages = manyItems(anthropicExchange, 'x'.repeat(10_000));
220
+ const result = await compactMessages(messages, anthropicFormatHelper, { maxContextTokens: 1_000 }, 1, fullConfig);
107
221
  expect(result.compacted).toBe(true);
108
222
  expect(result.method).toBe('truncation');
109
- // Head messages should have truncated tool results
110
223
  const headMessages = result.messages.slice(0, -8);
111
224
  const toolResultMessages = headMessages.filter((m) => Array.isArray(m.content) && m.content.some((b) => b.type === 'tool_result'));
112
225
  for (const msg of toolResultMessages) {
113
226
  const block = msg.content.find((b) => b.type === 'tool_result');
114
227
  expect(block.content).toContain('[truncated]');
115
- expect(block.content.length).toBeLessThan(longResult.length);
116
228
  }
117
229
  });
118
- it('falls back to truncation without fullConfig', async () => {
119
- const longResult = 'x'.repeat(10_000);
120
- const messages = [];
121
- for (let i = 0; i < 30; i++) {
122
- messages.push(...anthropicExchange(longResult));
230
+ it('falls back to truncation when LLM fails (OpenAI)', async () => {
231
+ mockChat.mockRejectedValueOnce(new Error('API error'));
232
+ const messages = manyItems(openaiExchange, 'x'.repeat(10_000));
233
+ const result = await compactMessages(messages, openaiFormatHelper, { maxContextTokens: 1_000 }, 1, fullConfig);
234
+ expect(result.method).toBe('truncation');
235
+ const toolMessages = result.messages.slice(0, -8).filter((m) => m.role === 'tool');
236
+ for (const msg of toolMessages) {
237
+ expect(msg.content).toContain('[truncated]');
238
+ }
239
+ });
240
+ it('falls back to truncation when LLM fails (Codex)', async () => {
241
+ mockChat.mockRejectedValueOnce(new Error('API error'));
242
+ const messages = manyItems(codexExchange, 'x'.repeat(10_000));
243
+ const result = await compactMessages(messages, codexFormatHelper, { maxContextTokens: 1_000 }, 1, fullConfig);
244
+ expect(result.method).toBe('truncation');
245
+ const outputItems = result.messages.slice(0, -8).filter((item) => item.type === 'function_call_output');
246
+ for (const item of outputItems) {
247
+ expect(item.output).toContain('[truncated]');
123
248
  }
124
- const result = await compactAnthropicMessages(messages, { maxContextTokens: 1_000 });
249
+ });
250
+ it('falls back to truncation without fullConfig', async () => {
251
+ const messages = manyItems(anthropicExchange, 'x'.repeat(10_000));
252
+ const result = await compactMessages(messages, anthropicFormatHelper, { maxContextTokens: 1_000 });
125
253
  expect(result.compacted).toBe(true);
126
254
  expect(result.method).toBe('truncation');
127
255
  expect(mockChat).not.toHaveBeenCalled();
128
256
  });
129
- it('keeps last 8 messages intact when compacting', async () => {
130
- const longResult = 'x'.repeat(10_000);
131
- const messages = [];
132
- for (let i = 0; i < 30; i++) {
133
- messages.push(...anthropicExchange(longResult));
257
+ it('keeps last 8 items intact across all formats', async () => {
258
+ for (const [factory, helper] of [
259
+ [anthropicExchange, anthropicFormatHelper],
260
+ [openaiExchange, openaiFormatHelper],
261
+ [codexExchange, codexFormatHelper],
262
+ ]) {
263
+ const items = manyItems(factory, 'x'.repeat(10_000));
264
+ const result = await compactMessages(items, helper, { maxContextTokens: 1_000 }, 1, fullConfig);
265
+ expect(result.messages.slice(-8)).toEqual(items.slice(-8));
266
+ mockChat.mockClear();
267
+ mockChat.mockResolvedValue('Summary of the conversation.');
134
268
  }
135
- const result = await compactAnthropicMessages(messages, { maxContextTokens: 1_000 }, 1, fullConfig);
136
- // Last 8 messages should be untouched
137
- const tail = result.messages.slice(-8);
138
- const originalTail = messages.slice(-8);
139
- expect(tail).toEqual(originalTail);
140
269
  });
141
270
  it('does not mutate the input array', async () => {
142
- const longResult = 'x'.repeat(10_000);
143
- const messages = [];
144
- for (let i = 0; i < 30; i++) {
145
- messages.push(...anthropicExchange(longResult));
146
- }
271
+ const messages = manyItems(anthropicExchange, 'x'.repeat(10_000));
147
272
  const originalJson = JSON.stringify(messages);
148
- await compactAnthropicMessages(messages, { maxContextTokens: 1_000 }, 1, fullConfig);
273
+ await compactMessages(messages, anthropicFormatHelper, { maxContextTokens: 1_000 }, 1, fullConfig);
149
274
  expect(JSON.stringify(messages)).toBe(originalJson);
150
275
  });
151
- it('passes through unchanged when disabled', async () => {
152
- const longResult = 'x'.repeat(10_000);
153
- const messages = [];
154
- for (let i = 0; i < 30; i++) {
155
- messages.push(...anthropicExchange(longResult));
276
+ it('includes token counts in result', async () => {
277
+ const messages = manyItems(anthropicExchange, 'x'.repeat(10_000));
278
+ const result = await compactMessages(messages, anthropicFormatHelper, { maxContextTokens: 1_000 }, 1, fullConfig);
279
+ expect(result.tokensBefore).toBeGreaterThan(1_000);
280
+ expect(result.tokensAfter).toBeDefined();
281
+ });
282
+ it('preserves non-tool-result items during truncation', async () => {
283
+ mockChat.mockRejectedValueOnce(new Error('fail'));
284
+ const items = manyItems(codexExchange, 'x'.repeat(10_000));
285
+ const result = await compactMessages(items, codexFormatHelper, { maxContextTokens: 1_000 }, 1, fullConfig);
286
+ const callItems = result.messages.filter((item) => item.type === 'function_call');
287
+ for (const item of callItems) {
288
+ expect(item.name).toBe('Bash');
156
289
  }
157
- const result = await compactAnthropicMessages(messages, { enabled: false, maxContextTokens: 1 });
158
- expect(result.messages).toBe(messages);
159
- expect(result.compacted).toBe(false);
160
290
  });
161
- it('includes token counts in result', async () => {
162
- const longResult = 'x'.repeat(10_000);
163
- const messages = [];
291
+ it('works with a custom MessageFormatHelper', async () => {
292
+ // Demonstrate that any format helper works with the generic function
293
+ const customHelper = {
294
+ isToolResult: (item) => item.kind === 'result',
295
+ truncateToolResult: (item, maxChars) => ({
296
+ ...item,
297
+ data: item.data.slice(0, maxChars) + ' [truncated]',
298
+ }),
299
+ serialize: (items) => items.map(i => JSON.stringify(i)).join('\n'),
300
+ buildSummaryMessage: (summary) => ({ kind: 'summary', data: summary }),
301
+ };
302
+ const items = [];
164
303
  for (let i = 0; i < 30; i++) {
165
- messages.push(...anthropicExchange(longResult));
304
+ items.push({ kind: 'call', name: 'test' });
305
+ items.push({ kind: 'result', data: 'x'.repeat(10_000) });
166
306
  }
307
+ const result = await compactMessages(items, customHelper, { maxContextTokens: 1_000 }, 1, fullConfig);
308
+ expect(result.compacted).toBe(true);
309
+ expect(result.method).toBe('llm');
310
+ expect(result.messages[0].kind).toBe('summary');
311
+ });
312
+ });
313
+ // =====================================================================
314
+ // Legacy wrapper tests (verify backward compatibility)
315
+ // =====================================================================
316
+ describe('compactAnthropicMessages (legacy wrapper)', () => {
317
+ it('passes through unchanged when under threshold', async () => {
318
+ const messages = anthropicExchange('short result');
319
+ const result = await compactAnthropicMessages(messages, { maxContextTokens: 100_000 });
320
+ expect(result.messages).toEqual(messages);
321
+ expect(result.compacted).toBe(false);
322
+ });
323
+ it('returns same reference when no compaction needed', async () => {
324
+ const messages = anthropicExchange('short result');
325
+ const result = await compactAnthropicMessages(messages, { maxContextTokens: 100_000 });
326
+ expect(result.messages).toBe(messages);
327
+ });
328
+ it('uses LLM summarization when fullConfig is provided', async () => {
329
+ const messages = manyItems(anthropicExchange, 'x'.repeat(10_000));
167
330
  const result = await compactAnthropicMessages(messages, { maxContextTokens: 1_000 }, 1, fullConfig);
168
- expect(result.tokensBefore).toBeGreaterThan(1_000);
169
- expect(result.tokensAfter).toBeDefined();
331
+ expect(result.compacted).toBe(true);
332
+ expect(result.method).toBe('llm');
333
+ expect(result.summary).toBeTruthy();
334
+ expect(result.messages[0].role).toBe('user');
335
+ expect(result.messages[0].content[0].text).toContain('[Conversation Summary]');
336
+ expect(result.messages.slice(-8)).toEqual(messages.slice(-8));
337
+ });
338
+ it('passes through unchanged when disabled', async () => {
339
+ const messages = manyItems(anthropicExchange, 'x'.repeat(10_000));
340
+ const result = await compactAnthropicMessages(messages, { enabled: false, maxContextTokens: 1 });
341
+ expect(result.messages).toBe(messages);
342
+ expect(result.compacted).toBe(false);
170
343
  });
171
344
  });
172
- describe('compactOpenAIMessages', () => {
345
+ describe('compactOpenAIMessages (legacy wrapper)', () => {
173
346
  it('passes through unchanged when under threshold', async () => {
174
347
  const messages = openaiExchange('short result');
175
348
  const result = await compactOpenAIMessages(messages, { maxContextTokens: 100_000 });
@@ -177,63 +350,21 @@ describe('compactOpenAIMessages', () => {
177
350
  expect(result.compacted).toBe(false);
178
351
  });
179
352
  it('uses LLM summarization when fullConfig is provided', async () => {
180
- const longResult = 'x'.repeat(10_000);
181
- const messages = [];
182
- for (let i = 0; i < 30; i++) {
183
- messages.push(...openaiExchange(longResult));
184
- }
353
+ const messages = manyItems(openaiExchange, 'x'.repeat(10_000));
185
354
  const result = await compactOpenAIMessages(messages, { maxContextTokens: 1_000 }, 1, fullConfig);
186
355
  expect(result.compacted).toBe(true);
187
356
  expect(result.method).toBe('llm');
188
357
  expect(result.messages[0].role).toBe('user');
189
358
  expect(result.messages[0].content).toContain('[Conversation Summary]');
190
359
  });
191
- it('falls back to truncation when LLM fails', async () => {
192
- mockChat.mockRejectedValueOnce(new Error('API error'));
193
- const longResult = 'x'.repeat(10_000);
194
- const messages = [];
195
- for (let i = 0; i < 30; i++) {
196
- messages.push(...openaiExchange(longResult));
197
- }
198
- const result = await compactOpenAIMessages(messages, { maxContextTokens: 1_000 }, 1, fullConfig);
199
- expect(result.method).toBe('truncation');
200
- const headItems = result.messages.slice(0, -8);
201
- const toolMessages = headItems.filter((m) => m.role === 'tool');
202
- for (const msg of toolMessages) {
203
- expect(msg.content).toContain('[truncated]');
204
- }
205
- });
206
- it('keeps last 8 messages intact', async () => {
207
- const longResult = 'x'.repeat(10_000);
208
- const messages = [];
209
- for (let i = 0; i < 30; i++) {
210
- messages.push(...openaiExchange(longResult));
211
- }
212
- const result = await compactOpenAIMessages(messages, { maxContextTokens: 1_000 }, 1, fullConfig);
213
- expect(result.messages.slice(-8)).toEqual(messages.slice(-8));
214
- });
215
- it('does not mutate the input array', async () => {
216
- const longResult = 'x'.repeat(10_000);
217
- const messages = [];
218
- for (let i = 0; i < 30; i++) {
219
- messages.push(...openaiExchange(longResult));
220
- }
221
- const original = JSON.stringify(messages);
222
- await compactOpenAIMessages(messages, { maxContextTokens: 1_000 }, 1, fullConfig);
223
- expect(JSON.stringify(messages)).toBe(original);
224
- });
225
360
  it('passes through unchanged when disabled', async () => {
226
- const longResult = 'x'.repeat(10_000);
227
- const messages = [];
228
- for (let i = 0; i < 30; i++) {
229
- messages.push(...openaiExchange(longResult));
230
- }
361
+ const messages = manyItems(openaiExchange, 'x'.repeat(10_000));
231
362
  const result = await compactOpenAIMessages(messages, { enabled: false, maxContextTokens: 1 });
232
363
  expect(result.messages).toBe(messages);
233
364
  expect(result.compacted).toBe(false);
234
365
  });
235
366
  });
236
- describe('compactCodexMessages', () => {
367
+ describe('compactCodexMessages (legacy wrapper)', () => {
237
368
  it('passes through unchanged when under threshold', async () => {
238
369
  const items = codexExchange('short result');
239
370
  const result = await compactCodexMessages(items, { maxContextTokens: 100_000 });
@@ -241,75 +372,23 @@ describe('compactCodexMessages', () => {
241
372
  expect(result.compacted).toBe(false);
242
373
  });
243
374
  it('uses LLM summarization when fullConfig is provided', async () => {
244
- const longOutput = 'x'.repeat(10_000);
245
- const items = [];
246
- for (let i = 0; i < 30; i++) {
247
- items.push(...codexExchange(longOutput));
248
- }
375
+ const items = manyItems(codexExchange, 'x'.repeat(10_000));
249
376
  const result = await compactCodexMessages(items, { maxContextTokens: 1_000 }, 1, fullConfig);
250
377
  expect(result.compacted).toBe(true);
251
378
  expect(result.method).toBe('llm');
252
379
  expect(result.messages[0].type).toBe('message');
253
380
  expect(result.messages[0].content).toContain('[Conversation Summary]');
254
381
  });
255
- it('falls back to truncation when LLM fails', async () => {
256
- mockChat.mockRejectedValueOnce(new Error('API error'));
257
- const longOutput = 'x'.repeat(10_000);
258
- const items = [];
259
- for (let i = 0; i < 30; i++) {
260
- items.push(...codexExchange(longOutput));
261
- }
262
- const result = await compactCodexMessages(items, { maxContextTokens: 1_000 }, 1, fullConfig);
263
- expect(result.method).toBe('truncation');
264
- const headItems = result.messages.slice(0, -8);
265
- const outputItems = headItems.filter((item) => item.type === 'function_call_output');
266
- for (const item of outputItems) {
267
- expect(item.output).toContain('[truncated]');
268
- }
269
- });
270
- it('keeps last 8 items intact when compacting', async () => {
271
- const longOutput = 'x'.repeat(10_000);
272
- const items = [];
273
- for (let i = 0; i < 30; i++) {
274
- items.push(...codexExchange(longOutput));
275
- }
276
- const result = await compactCodexMessages(items, { maxContextTokens: 1_000 }, 1, fullConfig);
277
- expect(result.messages.slice(-8)).toEqual(items.slice(-8));
278
- });
279
- it('does not mutate the input array', async () => {
280
- const longOutput = 'x'.repeat(10_000);
281
- const items = [];
282
- for (let i = 0; i < 30; i++) {
283
- items.push(...codexExchange(longOutput));
284
- }
285
- const originalJson = JSON.stringify(items);
286
- await compactCodexMessages(items, { maxContextTokens: 1_000 }, 1, fullConfig);
287
- expect(JSON.stringify(items)).toBe(originalJson);
288
- });
289
- it('preserves function_call items unchanged', async () => {
290
- mockChat.mockRejectedValueOnce(new Error('fail')); // force truncation
291
- const longOutput = 'x'.repeat(10_000);
292
- const items = [];
293
- for (let i = 0; i < 30; i++) {
294
- items.push(...codexExchange(longOutput));
295
- }
296
- const result = await compactCodexMessages(items, { maxContextTokens: 1_000 }, 1, fullConfig);
297
- const callItems = result.messages.filter((item) => item.type === 'function_call');
298
- for (const item of callItems) {
299
- expect(item.name).toBe('Bash');
300
- }
301
- });
302
382
  it('passes through unchanged when disabled', async () => {
303
- const longOutput = 'x'.repeat(10_000);
304
- const items = [];
305
- for (let i = 0; i < 30; i++) {
306
- items.push(...codexExchange(longOutput));
307
- }
383
+ const items = manyItems(codexExchange, 'x'.repeat(10_000));
308
384
  const result = await compactCodexMessages(items, { enabled: false, maxContextTokens: 1 });
309
385
  expect(result.messages).toBe(items);
310
386
  expect(result.compacted).toBe(false);
311
387
  });
312
388
  });
389
+ // =====================================================================
390
+ // Serializer tests (unchanged — these test the format helpers indirectly)
391
+ // =====================================================================
313
392
  describe('serializers', () => {
314
393
  it('serializeAnthropicMessages produces readable transcript', () => {
315
394
  const messages = [