thatgfsj-code 3.0.1 → 3.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app/index.d.ts +6 -0
- package/dist/app/index.d.ts.map +1 -1
- package/dist/app/index.js +8 -0
- package/dist/app/index.js.map +1 -1
- package/dist/cache/index.d.ts +1 -1
- package/dist/cache/index.d.ts.map +1 -1
- package/dist/cache/index.js +1 -1
- package/dist/cache/index.js.map +1 -1
- package/dist/cache/smartModel.d.ts +68 -20
- package/dist/cache/smartModel.d.ts.map +1 -1
- package/dist/cache/smartModel.js +98 -20
- package/dist/cache/smartModel.js.map +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +6 -4
- package/dist/config/index.js.map +1 -1
- package/dist/config/types.d.ts +5 -1
- package/dist/config/types.d.ts.map +1 -1
- package/dist/llm/anthropic.d.ts +17 -0
- package/dist/llm/anthropic.d.ts.map +1 -1
- package/dist/llm/anthropic.js +25 -1
- package/dist/llm/anthropic.js.map +1 -1
- package/dist/llm/index.d.ts +9 -0
- package/dist/llm/index.d.ts.map +1 -1
- package/dist/llm/index.js +36 -1
- package/dist/llm/index.js.map +1 -1
- package/dist/llm/provider.d.ts +6 -1
- package/dist/llm/provider.d.ts.map +1 -1
- package/dist/tui/app.d.ts.map +1 -1
- package/dist/tui/app.js +17 -44
- package/dist/tui/app.js.map +1 -1
- package/dist/tui/components/Header.d.ts +7 -0
- package/dist/tui/components/Header.d.ts.map +1 -1
- package/dist/tui/components/Header.js +2 -2
- package/dist/tui/components/Header.js.map +1 -1
- package/dist/tui/components/InitWizard.d.ts +18 -9
- package/dist/tui/components/InitWizard.d.ts.map +1 -1
- package/dist/tui/components/InitWizard.js +76 -52
- package/dist/tui/components/InitWizard.js.map +1 -1
- package/dist/tui/hooks/useCommands.d.ts.map +1 -1
- package/dist/tui/hooks/useCommands.js +38 -0
- package/dist/tui/hooks/useCommands.js.map +1 -1
- package/package.json +1 -1
- package/src/app/index.ts +8 -0
- package/src/cache/index.ts +7 -1
- package/src/cache/smartModel.ts +113 -20
- package/src/config/index.ts +6 -4
- package/src/config/types.ts +5 -1
- package/src/llm/anthropic.ts +26 -1
- package/src/llm/index.ts +38 -1
- package/src/llm/provider.ts +6 -1
- package/src/tui/app.tsx +15 -56
- package/src/tui/components/Header.tsx +12 -2
- package/src/tui/components/InitWizard.tsx +103 -69
- package/src/tui/hooks/useCommands.ts +39 -0
- package/tests/cache/smartModel.test.ts +112 -0
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/** @jsxImportSource react */
|
|
2
|
-
import React, { useState
|
|
3
|
-
import { Box, Text } from 'ink';
|
|
2
|
+
import React, { useState } from 'react';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
4
|
import SelectInput from 'ink-select-input';
|
|
5
|
-
import
|
|
5
|
+
import TextInput from 'ink-text-input';
|
|
6
|
+
import { writeFileSync, existsSync, mkdirSync } from 'fs';
|
|
6
7
|
import { join } from 'path';
|
|
7
8
|
import { homedir } from 'os';
|
|
8
9
|
import { listProviders, getModelsForProvider, isCustomProvider } from '../../config/providers.js';
|
|
@@ -13,14 +14,7 @@ interface Props {
|
|
|
13
14
|
onCancel: () => void;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
type InitStep =
|
|
17
|
-
| 'provider'
|
|
18
|
-
| 'api_key'
|
|
19
|
-
| 'model'
|
|
20
|
-
| 'custom_model'
|
|
21
|
-
| 'custom_url'
|
|
22
|
-
| 'cache_strategy'
|
|
23
|
-
| 'cache_ttl';
|
|
17
|
+
type InitStep = 'provider' | 'custom_url' | 'api_key' | 'model' | 'custom_model' | 'cache_strategy';
|
|
24
18
|
|
|
25
19
|
interface CacheChoice {
|
|
26
20
|
enabled: boolean;
|
|
@@ -28,15 +22,24 @@ interface CacheChoice {
|
|
|
28
22
|
}
|
|
29
23
|
|
|
30
24
|
/**
|
|
31
|
-
* v3.0.
|
|
32
|
-
*
|
|
33
|
-
*
|
|
25
|
+
* v3.0.1: InitWizard is now fully self-contained — it manages its own
|
|
26
|
+
* step state AND its own keyboard input for text-entry steps (api_key,
|
|
27
|
+
* custom_url, custom_model). The previous version had a fatal bug:
|
|
34
28
|
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
29
|
+
* The text-entry steps (`api_key`, `custom_url`, `custom_model`) only
|
|
30
|
+
* rendered a `<Text>` prompt with no actual input box. The host
|
|
31
|
+
* component (app.tsx) had its own UserInput BUT only rendered it when
|
|
32
|
+
* viewMode was NOT 'init_wizard' (see app.tsx ternary). So when the
|
|
33
|
+
* wizard's internal step advanced to 'api_key', the user could SEE the
|
|
34
|
+
* "输入 API Key: 服务商: deepseek" prompt but had NO way to type into
|
|
35
|
+
* it. Ink silently swallowed keystrokes (no useInput handler was
|
|
36
|
+
* active), and the user was stuck on a frozen screen — exactly the
|
|
37
|
+
* bug that prompted the user-reported "选择 deepseek 然后退出":
|
|
38
|
+
* pressing Ctrl+C felt like the only escape.
|
|
39
|
+
*
|
|
40
|
+
* Fix: this file now uses `useInput` and `ink-text-input` for the text
|
|
41
|
+
* steps. The SetViewMode-based input delegation in app.tsx is no longer
|
|
42
|
+
* required for the InitWizard flow.
|
|
40
43
|
*/
|
|
41
44
|
export function InitWizard({ onComplete, onCancel }: Props) {
|
|
42
45
|
const [step, setStep] = useState<InitStep>('provider');
|
|
@@ -61,7 +64,6 @@ export function InitWizard({ onComplete, onCancel }: Props) {
|
|
|
61
64
|
temperature: 0.7,
|
|
62
65
|
maxTokens: 4096,
|
|
63
66
|
contextLength: 50,
|
|
64
|
-
// v3.0.0: cache policy from wizard
|
|
65
67
|
cache: {
|
|
66
68
|
enabled: cacheChoice.enabled,
|
|
67
69
|
ttl: cacheChoice.ttl,
|
|
@@ -70,18 +72,17 @@ export function InitWizard({ onComplete, onCancel }: Props) {
|
|
|
70
72
|
};
|
|
71
73
|
if (url) config.baseUrl = url;
|
|
72
74
|
writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
75
|
+
};
|
|
73
76
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
history.push(model);
|
|
82
|
-
writeFileSync(historyPath, JSON.stringify(history, null, 2));
|
|
77
|
+
// v3.0.1: ESC cancels the wizard from any text-entry step. This is the
|
|
78
|
+
// hook that the previous version was missing — when the user pressed
|
|
79
|
+
// ESC on the frozen "输入 API Key" screen, nothing happened because no
|
|
80
|
+
// useInput was active. Now it bails out cleanly.
|
|
81
|
+
useInput((_input, key) => {
|
|
82
|
+
if (key.escape && (step === 'api_key' || step === 'custom_url' || step === 'custom_model')) {
|
|
83
|
+
onCancel();
|
|
83
84
|
}
|
|
84
|
-
};
|
|
85
|
+
});
|
|
85
86
|
|
|
86
87
|
// Step 1: Provider selection
|
|
87
88
|
if (step === 'provider') {
|
|
@@ -92,8 +93,9 @@ export function InitWizard({ onComplete, onCancel }: Props) {
|
|
|
92
93
|
<SelectInput
|
|
93
94
|
items={items}
|
|
94
95
|
onSelect={(item) => {
|
|
95
|
-
|
|
96
|
-
|
|
96
|
+
const provider = item.value as ProviderName;
|
|
97
|
+
setSelectedProvider(provider);
|
|
98
|
+
if (isCustomProvider(provider)) {
|
|
97
99
|
setStep('custom_url');
|
|
98
100
|
} else {
|
|
99
101
|
setStep('api_key');
|
|
@@ -104,24 +106,57 @@ export function InitWizard({ onComplete, onCancel }: Props) {
|
|
|
104
106
|
);
|
|
105
107
|
}
|
|
106
108
|
|
|
107
|
-
// Custom URL input
|
|
109
|
+
// Custom URL input — v3.0.1 NOW has a real TextInput
|
|
108
110
|
if (step === 'custom_url') {
|
|
109
111
|
return (
|
|
110
112
|
<Box flexDirection="column" paddingLeft={1}>
|
|
111
113
|
<Text color="#06B6D4" bold>输入中转站 URL:</Text>
|
|
112
114
|
<Text dimColor>例如: https://api.example.com/v1</Text>
|
|
113
|
-
<Text color="#F59E0B">(请直接输入 URL
|
|
115
|
+
<Text color="#F59E0B">(请直接输入 URL 并回车,ESC 取消)</Text>
|
|
116
|
+
<Box marginTop={1}>
|
|
117
|
+
<Text color="#06B6D4">❯ </Text>
|
|
118
|
+
<TextInput
|
|
119
|
+
value={customUrl}
|
|
120
|
+
onChange={setCustomUrl}
|
|
121
|
+
onSubmit={(value) => {
|
|
122
|
+
if (value.trim()) {
|
|
123
|
+
setCustomUrl(value.trim());
|
|
124
|
+
setStep('api_key');
|
|
125
|
+
}
|
|
126
|
+
}}
|
|
127
|
+
/>
|
|
128
|
+
</Box>
|
|
114
129
|
</Box>
|
|
115
130
|
);
|
|
116
131
|
}
|
|
117
132
|
|
|
118
|
-
// Step 2: API Key
|
|
133
|
+
// Step 2: API Key — v3.0.1 NOW has a real TextInput
|
|
119
134
|
if (step === 'api_key') {
|
|
135
|
+
const providerLabel = providers.find(p => p.key === selectedProvider)?.name || selectedProvider;
|
|
120
136
|
return (
|
|
121
137
|
<Box flexDirection="column" paddingLeft={1}>
|
|
122
138
|
<Text color="#06B6D4" bold>输入 API Key:</Text>
|
|
123
|
-
<Text dimColor>服务商: {
|
|
124
|
-
<Text color="#F59E0B">(请直接输入 Key
|
|
139
|
+
<Text dimColor>服务商: {providerLabel}</Text>
|
|
140
|
+
<Text color="#F59E0B">(请直接输入 Key 并回车,ESC 取消)</Text>
|
|
141
|
+
<Box marginTop={1}>
|
|
142
|
+
<Text color="#06B6D4">❯ </Text>
|
|
143
|
+
<TextInput
|
|
144
|
+
value={apiKey}
|
|
145
|
+
onChange={setApiKey}
|
|
146
|
+
onSubmit={(value) => {
|
|
147
|
+
if (value.trim()) {
|
|
148
|
+
setApiKey(value.trim());
|
|
149
|
+
if (isCustomProvider(selectedProvider)) {
|
|
150
|
+
// Custom OpenAI/Anthropic: after key, go to model
|
|
151
|
+
setStep('model');
|
|
152
|
+
} else {
|
|
153
|
+
// Standard providers: skip to model selection
|
|
154
|
+
setStep('model');
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}}
|
|
158
|
+
/>
|
|
159
|
+
</Box>
|
|
125
160
|
</Box>
|
|
126
161
|
);
|
|
127
162
|
}
|
|
@@ -142,7 +177,6 @@ export function InitWizard({ onComplete, onCancel }: Props) {
|
|
|
142
177
|
setStep('custom_model');
|
|
143
178
|
} else {
|
|
144
179
|
setSelectedModel(item.value);
|
|
145
|
-
// v3.0.0: prompt the user for cache strategy after model.
|
|
146
180
|
setStep('cache_strategy');
|
|
147
181
|
}
|
|
148
182
|
}}
|
|
@@ -151,56 +185,56 @@ export function InitWizard({ onComplete, onCancel }: Props) {
|
|
|
151
185
|
);
|
|
152
186
|
}
|
|
153
187
|
|
|
154
|
-
// Custom model name
|
|
188
|
+
// Custom model name input — v3.0.1 NOW has a real TextInput
|
|
155
189
|
if (step === 'custom_model') {
|
|
156
190
|
return (
|
|
157
191
|
<Box flexDirection="column" paddingLeft={1}>
|
|
158
192
|
<Text color="#06B6D4" bold>输入模型名称:</Text>
|
|
159
|
-
<Text color="#F59E0B"
|
|
193
|
+
<Text color="#F59E0B">(请直接输入模型名并回车,ESC 取消)</Text>
|
|
194
|
+
<Box marginTop={1}>
|
|
195
|
+
<Text color="#06B6D4">❯ </Text>
|
|
196
|
+
<TextInput
|
|
197
|
+
value={selectedModel}
|
|
198
|
+
onChange={setSelectedModel}
|
|
199
|
+
onSubmit={(value) => {
|
|
200
|
+
if (value.trim()) {
|
|
201
|
+
setSelectedModel(value.trim());
|
|
202
|
+
setStep('cache_strategy');
|
|
203
|
+
}
|
|
204
|
+
}}
|
|
205
|
+
/>
|
|
206
|
+
</Box>
|
|
160
207
|
</Box>
|
|
161
208
|
);
|
|
162
209
|
}
|
|
163
210
|
|
|
164
|
-
// v3.0.
|
|
211
|
+
// v3.0.3: prompt-cache strategy step — auto / 5m / 1h / off.
|
|
212
|
+
// The wizard used to require two questions (on/off + 5m/1h). Now it
|
|
213
|
+
// gives the user a single, clear choice up front, with "自动" as the
|
|
214
|
+
// default. The auto path lets the runtime pick the TTL based on the
|
|
215
|
+
// conversation length (see cache/smartModel.ts decideTTL).
|
|
165
216
|
if (step === 'cache_strategy') {
|
|
166
217
|
const items = [
|
|
167
|
-
{ label: '
|
|
218
|
+
{ label: '🤖 自动(推荐:根据会话长度智能选 5m / 1h)', value: 'auto' },
|
|
219
|
+
{ label: '⏱ 强制 5 分钟(短会话,写入便宜)', value: '5m' },
|
|
220
|
+
{ label: '🕐 强制 1 小时(长会话,命中率优先)', value: '1h' },
|
|
168
221
|
{ label: '✗ 关闭(每次请求都重新计算)', value: 'off' },
|
|
169
222
|
];
|
|
170
223
|
return (
|
|
171
224
|
<Box flexDirection="column" paddingLeft={1}>
|
|
172
|
-
<Text color="#06B6D4" bold
|
|
173
|
-
<Text dimColor>对 Anthropic / DeepSeek / Gemini
|
|
225
|
+
<Text color="#06B6D4" bold>Prompt Caching 策略:</Text>
|
|
226
|
+
<Text dimColor>对 Anthropic / DeepSeek / Gemini 有效。</Text>
|
|
227
|
+
<Text dimColor>自动模式:短会话用 5m(便宜),长会话(>15 轮)自动切 1h。</Text>
|
|
174
228
|
<SelectInput
|
|
175
229
|
items={items}
|
|
176
230
|
onSelect={(item) => {
|
|
177
|
-
if (item.value === '
|
|
178
|
-
setCacheChoice(
|
|
179
|
-
|
|
231
|
+
if (item.value === 'off') {
|
|
232
|
+
setCacheChoice({ enabled: false, ttl: '5m' });
|
|
233
|
+
} else if (item.value === 'auto') {
|
|
234
|
+
setCacheChoice({ enabled: true, ttl: '5m' }); // start at 5m, smartModel will upgrade
|
|
180
235
|
} else {
|
|
181
|
-
setCacheChoice(
|
|
182
|
-
saveConfig(selectedProvider, selectedModel, apiKey, customUrl || undefined);
|
|
183
|
-
onComplete(selectedProvider, selectedModel, apiKey, customUrl || undefined);
|
|
236
|
+
setCacheChoice({ enabled: true, ttl: item.value as '5m' | '1h' });
|
|
184
237
|
}
|
|
185
|
-
}}
|
|
186
|
-
/>
|
|
187
|
-
</Box>
|
|
188
|
-
);
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// v3.0.0: TTL choice
|
|
192
|
-
if (step === 'cache_ttl') {
|
|
193
|
-
const items = [
|
|
194
|
-
{ label: '5 分钟(写入成本低,适合短会话)', value: '5m' },
|
|
195
|
-
{ label: '1 小时(写入成本高,适合长会话)', value: '1h' },
|
|
196
|
-
];
|
|
197
|
-
return (
|
|
198
|
-
<Box flexDirection="column" paddingLeft={1}>
|
|
199
|
-
<Text color="#06B6D4" bold>Cache TTL:</Text>
|
|
200
|
-
<SelectInput
|
|
201
|
-
items={items}
|
|
202
|
-
onSelect={(item) => {
|
|
203
|
-
setCacheChoice(c => ({ ...c, ttl: item.value as '5m' | '1h' }));
|
|
204
238
|
saveConfig(selectedProvider, selectedModel, apiKey, customUrl || undefined);
|
|
205
239
|
onComplete(selectedProvider, selectedModel, apiKey, customUrl || undefined);
|
|
206
240
|
}}
|
|
@@ -20,6 +20,8 @@ const CMD_ALIASES: Record<string, string> = {
|
|
|
20
20
|
'/服务商': '/provider',
|
|
21
21
|
'/思考': '/thinking',
|
|
22
22
|
'/缓存': '/cache',
|
|
23
|
+
'/ttl': '/ttl',
|
|
24
|
+
'/TTL': '/ttl',
|
|
23
25
|
};
|
|
24
26
|
|
|
25
27
|
export const COMMAND_LIST = [
|
|
@@ -28,6 +30,7 @@ export const COMMAND_LIST = [
|
|
|
28
30
|
{ name: '/新建', desc: '新建会话' },
|
|
29
31
|
{ name: '/压缩', desc: '压缩上下文' },
|
|
30
32
|
{ name: '/缓存', desc: '缓存命中率' },
|
|
33
|
+
{ name: '/ttl', desc: '查看/设置 Cache TTL' },
|
|
31
34
|
{ name: '/技能', desc: '管理技能' },
|
|
32
35
|
{ name: '/mcp', desc: 'MCP 设置' },
|
|
33
36
|
{ name: '/帮助', desc: '查看帮助' },
|
|
@@ -142,6 +145,42 @@ export function useCommands(app: App) {
|
|
|
142
145
|
return { handled: true, output: lines.join('\n') };
|
|
143
146
|
}
|
|
144
147
|
|
|
148
|
+
// v3.0.3: /ttl — view or pin the cache TTL.
|
|
149
|
+
//
|
|
150
|
+
// /ttl show current effective TTL + decision reason
|
|
151
|
+
// /ttl 5m|1h pin TTL for this session (sticky, no cache reset)
|
|
152
|
+
// /ttl auto resume auto-decision (clears the pin)
|
|
153
|
+
if (name === '/ttl') {
|
|
154
|
+
const configTtl = (app.config.get() as any).cache?.ttl as 'auto' | '5m' | '1h' | undefined;
|
|
155
|
+
const resolved = app.resolvedTtl;
|
|
156
|
+
if (arg === 'auto') {
|
|
157
|
+
app.config.save({ cache: { ...(app.config.get() as any).cache, ttl: 'auto' } });
|
|
158
|
+
app.resolvedTtl = null;
|
|
159
|
+
return { handled: true, output: '✓ Cache TTL 已重置为自动(下一轮重新评估)' };
|
|
160
|
+
}
|
|
161
|
+
if (arg === '5m' || arg === '1h') {
|
|
162
|
+
app.config.save({ cache: { ...(app.config.get() as any).cache, ttl: arg } });
|
|
163
|
+
app.resolvedTtl = arg;
|
|
164
|
+
return { handled: true, output: `✓ Cache TTL 已固定为 ${arg}(首次请求会重建缓存)` };
|
|
165
|
+
}
|
|
166
|
+
// No arg: show current state
|
|
167
|
+
const lines = [
|
|
168
|
+
'⏱ Cache TTL',
|
|
169
|
+
'─────────────────────────',
|
|
170
|
+
` 配置: ${configTtl ?? 'auto'}`,
|
|
171
|
+
` 当前会话: ${resolved ?? '尚未评估'}`,
|
|
172
|
+
'',
|
|
173
|
+
'说明: '+
|
|
174
|
+
(configTtl === 'auto'
|
|
175
|
+
? '自动模式 – 短会话(≤14 轮)用 5m节省写入成本,' +
|
|
176
|
+
'长会话(≥15 轮 或 ≥50k 字符)自动升级到 1h 复用缓存。'
|
|
177
|
+
: `${configTtl} 模式 – TTL 每次请求都固定。`),
|
|
178
|
+
'',
|
|
179
|
+
'/ttl 5m|1h|auto 调整 TTL',
|
|
180
|
+
];
|
|
181
|
+
return { handled: true, output: lines.join('\n') };
|
|
182
|
+
}
|
|
183
|
+
|
|
145
184
|
// ── /skills [id] ────────────────────────────────────
|
|
146
185
|
if (name === '/skills') {
|
|
147
186
|
if (arg) {
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { decideTTL, totalConversationChars, shouldDowngrade } from '../../src/cache/smartModel.js';
|
|
3
|
+
import type { ChatMessage } from '../../src/types.js';
|
|
4
|
+
|
|
5
|
+
function userMsg(s: string): ChatMessage {
|
|
6
|
+
return { role: 'user', content: s };
|
|
7
|
+
}
|
|
8
|
+
function assistantMsg(s: string): ChatMessage {
|
|
9
|
+
return { role: 'assistant', content: s };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe('decideTTL', () => {
|
|
13
|
+
it('round 0 → 5m (default)', () => {
|
|
14
|
+
const d = decideTTL([], null);
|
|
15
|
+
expect(d.ttl).toBe('5m');
|
|
16
|
+
expect(d.isFirstDecision).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('short session (≤14 turns, <50k chars) → 5m', () => {
|
|
20
|
+
const messages = [
|
|
21
|
+
userMsg('hello'),
|
|
22
|
+
assistantMsg('hi'),
|
|
23
|
+
userMsg('how are you?'),
|
|
24
|
+
assistantMsg('good'),
|
|
25
|
+
];
|
|
26
|
+
const d = decideTTL(messages, null);
|
|
27
|
+
expect(d.ttl).toBe('5m');
|
|
28
|
+
expect(d.reason).toMatch(/short-session/);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('long session (≥15 turns) → 1h', () => {
|
|
32
|
+
// Build 16 user + 16 assistant = 32 messages (16 turns)
|
|
33
|
+
const messages: ChatMessage[] = [];
|
|
34
|
+
for (let i = 0; i < 16; i++) {
|
|
35
|
+
messages.push(userMsg(`question ${i}`));
|
|
36
|
+
messages.push(assistantMsg(`answer ${i} long enough to be content`));
|
|
37
|
+
}
|
|
38
|
+
const d = decideTTL(messages, null);
|
|
39
|
+
expect(d.ttl).toBe('1h');
|
|
40
|
+
expect(d.reason).toMatch(/long-session/);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('large context (>50k chars) → 1h even with few turns', () => {
|
|
44
|
+
const bigContent = 'x'.repeat(60_000);
|
|
45
|
+
const messages: ChatMessage[] = [
|
|
46
|
+
userMsg('hi'),
|
|
47
|
+
{ role: 'assistant', content: bigContent },
|
|
48
|
+
];
|
|
49
|
+
const d = decideTTL(messages, null);
|
|
50
|
+
expect(d.ttl).toBe('1h');
|
|
51
|
+
expect(d.reason).toMatch(/long-context/);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('reuses previous TTL (stability rule)', () => {
|
|
55
|
+
const messages = [userMsg('hello'), assistantMsg('hi')];
|
|
56
|
+
const d1 = decideTTL(messages, null);
|
|
57
|
+
expect(d1.ttl).toBe('5m');
|
|
58
|
+
expect(d1.isFirstDecision).toBe(true);
|
|
59
|
+
|
|
60
|
+
// Now grow to 30 turns without changing TTL
|
|
61
|
+
for (let i = 0; i < 30; i++) {
|
|
62
|
+
messages.push(userMsg(`q${i}`));
|
|
63
|
+
messages.push(assistantMsg(`a${i}`));
|
|
64
|
+
}
|
|
65
|
+
const d2 = decideTTL(messages, '5m');
|
|
66
|
+
expect(d2.ttl).toBe('5m');
|
|
67
|
+
expect(d2.isFirstDecision).toBe(false);
|
|
68
|
+
expect(d2.reason).toBe('reused-from-previous-round');
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('totalConversationChars', () => {
|
|
73
|
+
it('sums string content', () => {
|
|
74
|
+
const total = totalConversationChars([
|
|
75
|
+
userMsg('abc'),
|
|
76
|
+
assistantMsg('defgh'),
|
|
77
|
+
]);
|
|
78
|
+
expect(total).toBe(8);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('handles array content blocks', () => {
|
|
82
|
+
const total = totalConversationChars([
|
|
83
|
+
{ role: 'user', content: [
|
|
84
|
+
{ type: 'text', text: 'hello' },
|
|
85
|
+
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'XXXX' } },
|
|
86
|
+
] as any },
|
|
87
|
+
]);
|
|
88
|
+
expect(total).toBe(5);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('shouldDowngrade', () => {
|
|
93
|
+
it('downgrades short prompts without tool calls', () => {
|
|
94
|
+
const d = shouldDowngrade([], 'hi');
|
|
95
|
+
expect(d.downgrade).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('does not downgrade long prompts', () => {
|
|
99
|
+
const d = shouldDowngrade([], 'x'.repeat(300));
|
|
100
|
+
expect(d.downgrade).toBe(false);
|
|
101
|
+
expect(d.reason).toBe('prompt-too-long');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('does not downgrade when recent tool calls exist', () => {
|
|
105
|
+
const messages: ChatMessage[] = [
|
|
106
|
+
{ role: 'assistant', content: '...', tool_calls: [{ id: '1', type: 'function', function: { name: 'f', arguments: '{}' } }] },
|
|
107
|
+
];
|
|
108
|
+
const d = shouldDowngrade(messages, 'hi');
|
|
109
|
+
expect(d.downgrade).toBe(false);
|
|
110
|
+
expect(d.reason).toBe('recent-tool-call');
|
|
111
|
+
});
|
|
112
|
+
});
|