@danceiny/gotry 0.2.0-rc.27 → 0.2.0-rc.29
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/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/bin/gotry-bootstrap.js +56 -12
- package/data/yunnan-pack.json +30 -27
- package/dist/capabilities/anything.js +26 -0
- package/dist/capabilities/channel-registry.js +4 -4
- package/dist/capabilities/doctor.js +14 -7
- package/dist/capabilities/effect.js +1 -1
- package/dist/capabilities/ground-transfer.js +168 -1
- package/dist/capabilities/provider-detail-sanitize.js +64 -0
- package/dist/capabilities/route-provider-conformance.js +341 -0
- package/dist/capabilities/session/extension-bridge.js +3 -3
- package/dist/capabilities/session-search.js +1 -1
- package/dist/scripts/anything-path-measure-tests.js +133 -0
- package/dist/scripts/anything-path-measure.js +150 -0
- package/dist/scripts/anything-tests.js +136 -2
- package/dist/scripts/booking-copilot-dsh-warmer-proof.js +5 -2
- package/dist/scripts/bootstrap-tests.js +77 -7
- package/dist/scripts/city-scenario-tier-tests.js +234 -0
- package/dist/scripts/doctor-tests.js +39 -3
- package/dist/scripts/drill-multiuser-ledger-tests.js +1076 -0
- package/dist/scripts/drill-sdk-descendant-cleanup-tests.js +606 -0
- package/dist/scripts/drill-w2a-sensor-tests.js +788 -0
- package/dist/scripts/dsh-llm-tests.js +437 -0
- package/dist/scripts/g5-guard-tests.js +20 -1
- package/dist/scripts/g5-guard.js +5 -1
- package/dist/scripts/gotry-backend-kernel-tests.js +1 -1
- package/dist/scripts/m3-cohort-tests.js +714 -0
- package/dist/scripts/m3-cohort.js +310 -0
- package/dist/scripts/nightly-evidence-tests.js +44 -2
- package/dist/scripts/nightly-evidence.js +21 -4
- package/dist/scripts/outcome-projection-tests.js +427 -0
- package/dist/scripts/persona-sim-tests.js +621 -0
- package/dist/scripts/persona-sim.js +1081 -0
- package/dist/scripts/replay-async.js +2 -0
- package/dist/scripts/replay.js +2 -0
- package/dist/scripts/route-provider-conformance-tests.js +391 -0
- package/dist/scripts/session-dida-live-e2e.js +1 -1
- package/dist/scripts/session-zone-crash.js +49 -0
- package/dist/scripts/session-zone-ledger-tests.js +432 -0
- package/dist/scripts/session-zone-observation-tests.js +492 -0
- package/dist/scripts/session-zone-observe.js +135 -0
- package/dist/scripts/session-zone-wiring-tests.js +566 -0
- package/dist/scripts/session-zones-tests.js +17 -1
- package/dist/scripts/spawn-bounded-abort-tests.js +3 -0
- package/dist/scripts/state-cli.js +16 -0
- package/dist/scripts/time-eval-tests.js +2 -0
- package/dist/scripts/unified-tests.js +79 -2
- package/dist/scripts/z3-race-tests.js +6 -1
- package/dist/src/backend/modules/session-search.js +1 -1
- package/dist/src/city-scenario-tier.js +378 -0
- package/dist/src/dsh-llm.js +84 -14
- package/dist/src/index.js +166 -7
- package/dist/src/loop.js +16 -5
- package/dist/src/m3-cohort.js +1577 -0
- package/dist/src/outcome-projection.js +746 -0
- package/dist/src/session-zone-ledger.js +312 -0
- package/dist/src/session-zone-observation.js +354 -0
- package/dist/src/session-zone-wiring.js +658 -0
- package/dist/src/session-zones.js +7 -4
- package/dist/src/state-ledger.js +293 -40
- package/dist/src/unified.js +69 -5
- package/extension/README.md +1 -1
- package/extension/README.zh-CN.md +1 -1
- package/extension/background.js +1 -1
- package/extension/content-bridge.js +1 -1
- package/extension/content-main.js +1 -1
- package/extension/manifest.json +2 -2
- package/package.json +1 -1
- package/ts/capabilities/anything.ts +27 -0
- package/ts/capabilities/session/extension-bridge.ts +3 -3
- package/ts/capabilities/session-search.ts +1 -1
- package/ts/scripts/state-cli.ts +18 -0
- package/ts/src/contracts.ts +4 -0
- package/ts/src/dsh-llm.ts +111 -9
- package/ts/src/index.ts +181 -8
- package/ts/src/loop.ts +15 -5
- package/ts/src/state-ledger.ts +294 -40
- package/ts/src/unified.ts +91 -18
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-llm chat() 请求级超时 / 调用方取消 合同测试(run-all §88,issue #618)。
|
|
3
|
+
* 全离线、确定性、零 API 花费:127.0.0.1 上的假 provider 夹具(收下连接却永不应答 / 响应头后正文卡死 /
|
|
4
|
+
* 正常应答),fetch 间谍保证整套测试没有任何请求离开 127.0.0.1。
|
|
5
|
+
*
|
|
6
|
+
* 覆盖:
|
|
7
|
+
* - 预算解析:调用显式值 > GOTRY_LLM_TIMEOUT_MS > 默认 300000;非正整数忽略;超大值夹住(不溢出成 1ms);
|
|
8
|
+
* - 卡死 provider(无响应头 / 响应头后正文卡死 / 非 2xx 的错误正文卡死)在预算内落成
|
|
9
|
+
* LlmRequestError('timeout'),不挂起、usage 不动;
|
|
10
|
+
* - 调用方 signal(中途 abort / 已 abort / 自定义 reason / 带 signal 但先超时)落成 LlmRequestError('aborted'|'timeout');
|
|
11
|
+
* - 正常快应答:请求字节与应答处理逐字不变,usage 照常累计,缺 usage 仍计 responsesMissingUsage(fail-closed 语义不变);
|
|
12
|
+
* - 非 abort 失败(HTTP 500 / 连接被拒 / 缺 key)仍是原样的普通 Error,不被改写;
|
|
13
|
+
* - 调用方不吞:runTurn 与 nightly-evidence 对卡死 provider 抛类型化失败,且 nightly 零写入。
|
|
14
|
+
* 所有等待都套 bounded():断言失败(含「修复被回退后永远挂起」)只会让对应用例失败,绝不会让本测试自己挂起。
|
|
15
|
+
* 运行:cd ts && npx tsx scripts/dsh-llm-tests.ts
|
|
16
|
+
*/
|
|
17
|
+
import assert from 'node:assert/strict';
|
|
18
|
+
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
|
|
19
|
+
import { createServer } from 'node:http';
|
|
20
|
+
import { tmpdir } from 'node:os';
|
|
21
|
+
import { dirname, join, resolve } from 'node:path';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
// 命名空间导入:被测模块的新符号缺失(修复被回退)时只会让对应用例失败(undefined),而不是整个文件在链接期崩掉,
|
|
24
|
+
// 这样红基线能如实显示「卡死用例被 bounded 截住」而不是一个无信息量的 SyntaxError。
|
|
25
|
+
import * as dshLlm from '../src/dsh-llm.js';
|
|
26
|
+
import { newState, runTurn } from '../src/loop.js';
|
|
27
|
+
import { runNightlyEvidence } from './nightly-evidence.js';
|
|
28
|
+
const TS_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
29
|
+
const { createOpenAICompatLlm, LlmRequestError, resolveLlmTimeoutMs, DEFAULT_LLM_TIMEOUT_MS } = dshLlm;
|
|
30
|
+
/** 超过它还没落定 = 挂起(正常卡死用例预算仅数百毫秒;留足慢机/高负载余量)。 */
|
|
31
|
+
const HANG_BOUND_MS = 6_000;
|
|
32
|
+
/** 卡死用例统一的请求预算:够短让套件快,够长不被调度抖动误伤。 */
|
|
33
|
+
const STALL_TIMEOUT_MS = 400;
|
|
34
|
+
const CLOCK = () => new Date('2026-10-05T00:00:00Z');
|
|
35
|
+
const QUESTION = { key: 'workWindow', text: '你这两周的工作时间是几点到几点?', why: '决定每日可玩时段' };
|
|
36
|
+
const OK_CONTENT = '<think>先想一想</think> 润色后的问句 ';
|
|
37
|
+
const FACTS_CONTENT = JSON.stringify({ calendar: { year: 2026, assertedWeekdays: { '2026-10-05': 'mon' } } });
|
|
38
|
+
function completion(content, usage) {
|
|
39
|
+
return JSON.stringify({ choices: [{ message: { role: 'assistant', content } }], ...(usage ? { usage } : {}) });
|
|
40
|
+
}
|
|
41
|
+
async function startProvider() {
|
|
42
|
+
const sockets = new Set();
|
|
43
|
+
const state = { mode: 'ok' };
|
|
44
|
+
const hits = [];
|
|
45
|
+
const server = createServer((request, response) => {
|
|
46
|
+
const chunks = [];
|
|
47
|
+
request.on('data', chunk => chunks.push(chunk));
|
|
48
|
+
request.on('end', () => {
|
|
49
|
+
hits.push({ method: request.method ?? '', url: request.url ?? '', headers: request.headers, body: Buffer.concat(chunks).toString('utf8') });
|
|
50
|
+
const json = (status, text) => {
|
|
51
|
+
response.writeHead(status, { 'content-type': 'application/json' });
|
|
52
|
+
response.end(text);
|
|
53
|
+
};
|
|
54
|
+
switch (state.mode) {
|
|
55
|
+
case 'ok':
|
|
56
|
+
return json(200, completion(OK_CONTENT, { prompt_tokens: 120, completion_tokens: 30, prompt_cache_hit_tokens: 100, prompt_cache_miss_tokens: 20 }));
|
|
57
|
+
case 'ok-no-cache-detail':
|
|
58
|
+
return json(200, completion(OK_CONTENT, { prompt_tokens: 50, completion_tokens: 10 }));
|
|
59
|
+
case 'ok-no-usage':
|
|
60
|
+
return json(200, completion(OK_CONTENT));
|
|
61
|
+
case 'ok-json':
|
|
62
|
+
return json(200, completion(FACTS_CONTENT, { prompt_tokens: 7, completion_tokens: 3 }));
|
|
63
|
+
case 'error-500':
|
|
64
|
+
return json(500, 'fixture upstream exploded');
|
|
65
|
+
case 'stall':
|
|
66
|
+
return; // 永不应答
|
|
67
|
+
case 'stall-body':
|
|
68
|
+
response.writeHead(200, { 'content-type': 'application/json' });
|
|
69
|
+
response.write('{"choices":[{"message":');
|
|
70
|
+
return;
|
|
71
|
+
case 'stall-error-body':
|
|
72
|
+
response.writeHead(500, { 'content-type': 'text/plain' });
|
|
73
|
+
response.write('upstream says');
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
server.on('connection', socket => {
|
|
79
|
+
sockets.add(socket);
|
|
80
|
+
socket.on('close', () => sockets.delete(socket));
|
|
81
|
+
});
|
|
82
|
+
await new Promise((resolveListen, rejectListen) => {
|
|
83
|
+
server.once('error', rejectListen);
|
|
84
|
+
server.listen(0, '127.0.0.1', () => resolveListen());
|
|
85
|
+
});
|
|
86
|
+
const address = server.address();
|
|
87
|
+
return {
|
|
88
|
+
url: `http://127.0.0.1:${address.port}/v1`,
|
|
89
|
+
get mode() { return state.mode; },
|
|
90
|
+
set mode(next) { state.mode = next; },
|
|
91
|
+
hits,
|
|
92
|
+
closeAll: () => new Promise(resolveClose => {
|
|
93
|
+
for (const socket of sockets)
|
|
94
|
+
socket.destroy();
|
|
95
|
+
server.close(() => resolveClose());
|
|
96
|
+
}),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
// ---- 测试脚手架 ------------------------------------------------------------------------------
|
|
100
|
+
class TestHang extends Error {
|
|
101
|
+
}
|
|
102
|
+
/** 等 work 落定;超过 ms 仍未落定则抛 TestHang(挂起 = 测试失败,而不是测试自己挂起)。 */
|
|
103
|
+
async function bounded(label, work, ms = HANG_BOUND_MS) {
|
|
104
|
+
work.catch(() => { }); // 输掉竞速的一方若稍后 reject,不得变成 unhandled rejection
|
|
105
|
+
let timer;
|
|
106
|
+
try {
|
|
107
|
+
return await Promise.race([
|
|
108
|
+
work,
|
|
109
|
+
new Promise((_resolve, reject) => {
|
|
110
|
+
timer = setTimeout(() => reject(new TestHang(`TEST HANG: ${label} had not settled after ${ms}ms (no request timeout / abort took effect)`)), ms);
|
|
111
|
+
}),
|
|
112
|
+
]);
|
|
113
|
+
}
|
|
114
|
+
finally {
|
|
115
|
+
if (timer)
|
|
116
|
+
clearTimeout(timer);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/** 断言 work 以拒绝收场,并返回拒绝原因与耗时;挂起原样上抛为 TestHang,正常返回则判失败。 */
|
|
120
|
+
async function rejection(label, work) {
|
|
121
|
+
const started = performance.now();
|
|
122
|
+
try {
|
|
123
|
+
await bounded(label, work);
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
if (error instanceof TestHang)
|
|
127
|
+
throw error;
|
|
128
|
+
return { error, elapsedMs: performance.now() - started };
|
|
129
|
+
}
|
|
130
|
+
throw new assert.AssertionError({ message: `${label}: expected the call to reject, but it resolved` });
|
|
131
|
+
}
|
|
132
|
+
async function until(label, predicate, ms = 3_000) {
|
|
133
|
+
const deadline = Date.now() + ms;
|
|
134
|
+
while (!predicate()) {
|
|
135
|
+
if (Date.now() > deadline)
|
|
136
|
+
throw new Error(`${label}: condition not reached within ${ms}ms`);
|
|
137
|
+
await new Promise(resolveWait => setTimeout(resolveWait, 10));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
async function withEnv(patch, body) {
|
|
141
|
+
const saved = Object.fromEntries(Object.keys(patch).map(key => [key, process.env[key]]));
|
|
142
|
+
const apply = (values) => {
|
|
143
|
+
for (const [key, value] of Object.entries(values)) {
|
|
144
|
+
if (value === undefined)
|
|
145
|
+
delete process.env[key];
|
|
146
|
+
else
|
|
147
|
+
process.env[key] = value;
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
apply(patch);
|
|
151
|
+
try {
|
|
152
|
+
return await body();
|
|
153
|
+
}
|
|
154
|
+
finally {
|
|
155
|
+
apply(saved);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
function describe(error) {
|
|
159
|
+
return error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
|
160
|
+
}
|
|
161
|
+
function assertTimeoutFailure(error, timeoutMs) {
|
|
162
|
+
assert.ok(error instanceof LlmRequestError, `expected LlmRequestError, got ${describe(error)}`);
|
|
163
|
+
assert.equal(error.code, 'timeout');
|
|
164
|
+
assert.equal(error.name, 'TimeoutError', 'platform name, so name-based classifiers (persona-sim, channel-probe) recognise it');
|
|
165
|
+
assert.equal(error.timeoutMs, timeoutMs);
|
|
166
|
+
assert.match(error.message, new RegExp(`^llm timeout: .*${timeoutMs}ms`), 'module `llm <kind>: <detail>` message convention');
|
|
167
|
+
assert.equal(error.cause?.name, 'TimeoutError', 'original abort error is kept as cause');
|
|
168
|
+
}
|
|
169
|
+
function assertAbortedFailure(error) {
|
|
170
|
+
assert.ok(error instanceof LlmRequestError, `expected LlmRequestError, got ${describe(error)}`);
|
|
171
|
+
assert.equal(error.code, 'aborted');
|
|
172
|
+
assert.equal(error.name, 'AbortError');
|
|
173
|
+
assert.equal(error.timeoutMs, undefined);
|
|
174
|
+
assert.match(error.message, /^llm aborted: /);
|
|
175
|
+
}
|
|
176
|
+
function assertUntouched(usage, label) {
|
|
177
|
+
assert.deepEqual(usage, { calls: 0, inputTokens: 0, outputTokens: 0, inputCacheHitTokens: 0, inputCacheMissTokens: 0, responsesMissingUsage: 0 }, `${label}: a call that produced no response must not touch the usage tracker`);
|
|
178
|
+
}
|
|
179
|
+
// ---- 用例 ------------------------------------------------------------------------------------
|
|
180
|
+
let passed = 0;
|
|
181
|
+
const failures = [];
|
|
182
|
+
async function test(name, body) {
|
|
183
|
+
try {
|
|
184
|
+
await body();
|
|
185
|
+
passed += 1;
|
|
186
|
+
console.log(` ok ${name}`);
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
failures.push(name);
|
|
190
|
+
console.log(` FAIL ${name}\n ${describe(error).split('\n').join('\n ')}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const provider = await startProvider();
|
|
194
|
+
const savedFetch = globalThis.fetch;
|
|
195
|
+
const savedEnv = Object.fromEntries(['LLM_API_KEY', 'LLM_BASE_URL', 'LLM_MODEL', 'DEEPSEEK_API_KEY', 'DEEPSEEK_BASE_URL', 'DEEPSEEK_MODEL', 'GOTRY_LLM_TIMEOUT_MS'].map(key => [key, process.env[key]]));
|
|
196
|
+
// 间谍:任何离开 127.0.0.1 的请求都直接抛错(不是仅记录),回归也带不走凭证
|
|
197
|
+
globalThis.fetch = (async (input, init) => {
|
|
198
|
+
const target = new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url);
|
|
199
|
+
if (target.hostname !== '127.0.0.1')
|
|
200
|
+
throw new Error(`TEST ESCAPE: request to ${target.hostname} left the loopback fixture`);
|
|
201
|
+
return savedFetch(input, init);
|
|
202
|
+
});
|
|
203
|
+
process.env['LLM_API_KEY'] = 'fixture-key';
|
|
204
|
+
process.env['LLM_BASE_URL'] = provider.url;
|
|
205
|
+
process.env['LLM_MODEL'] = 'fixture-model';
|
|
206
|
+
for (const alias of ['DEEPSEEK_API_KEY', 'DEEPSEEK_BASE_URL', 'DEEPSEEK_MODEL', 'GOTRY_LLM_TIMEOUT_MS'])
|
|
207
|
+
delete process.env[alias];
|
|
208
|
+
try {
|
|
209
|
+
console.log('dsh-llm request timeout / abort contract (issue #618)');
|
|
210
|
+
await test('timeout budget: default 300000; call > env > default; invalid ignored; huge value clamped', async () => {
|
|
211
|
+
assert.equal(DEFAULT_LLM_TIMEOUT_MS, 300_000);
|
|
212
|
+
assert.equal(resolveLlmTimeoutMs(), 300_000);
|
|
213
|
+
assert.equal(resolveLlmTimeoutMs(1_500), 1_500);
|
|
214
|
+
await withEnv({ GOTRY_LLM_TIMEOUT_MS: '45000' }, () => {
|
|
215
|
+
assert.equal(resolveLlmTimeoutMs(), 45_000);
|
|
216
|
+
assert.equal(resolveLlmTimeoutMs(1_500), 1_500, 'an explicit per-call value beats the env var');
|
|
217
|
+
for (const bad of [0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY]) {
|
|
218
|
+
assert.equal(resolveLlmTimeoutMs(bad), 45_000, `invalid per-call ${String(bad)} is ignored and falls through to the env var`);
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
await withEnv({ GOTRY_LLM_TIMEOUT_MS: ' 45000 ' }, () => assert.equal(resolveLlmTimeoutMs(), 45_000, 'surrounding whitespace is tolerated'));
|
|
222
|
+
for (const bad of ['0', '-5', '1.5', 'abc', '', ' ', '1e3', '12ms', '0x10', '+5', 'Infinity', 'NaN', '99999999999999999999999']) {
|
|
223
|
+
await withEnv({ GOTRY_LLM_TIMEOUT_MS: bad }, () => assert.equal(resolveLlmTimeoutMs(), 300_000, `env ${JSON.stringify(bad)} is ignored -> default (never "no timeout")`));
|
|
224
|
+
}
|
|
225
|
+
for (const bad of [0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY]) {
|
|
226
|
+
assert.equal(resolveLlmTimeoutMs(bad), 300_000, `invalid per-call ${String(bad)} with no env -> default`);
|
|
227
|
+
}
|
|
228
|
+
// Node 把 > 2^31-1 的定时器延迟溢出成 1ms(「配得越大越立刻超时」);必须夹住而不是透传
|
|
229
|
+
assert.equal(resolveLlmTimeoutMs(2 ** 40), 2_147_483_647);
|
|
230
|
+
await withEnv({ GOTRY_LLM_TIMEOUT_MS: '3000000000' }, () => assert.equal(resolveLlmTimeoutMs(), 2_147_483_647));
|
|
231
|
+
});
|
|
232
|
+
await test('stalled provider (accepts, never answers): per-call timeoutMs -> typed timeout inside the budget, usage untouched', async () => {
|
|
233
|
+
provider.mode = 'stall';
|
|
234
|
+
const before = provider.hits.length;
|
|
235
|
+
const port = createOpenAICompatLlm(undefined, CLOCK, { timeoutMs: STALL_TIMEOUT_MS });
|
|
236
|
+
const { error, elapsedMs } = await rejection('stalled provider / per-call timeoutMs', port.polishQuestion(QUESTION));
|
|
237
|
+
assertTimeoutFailure(error, STALL_TIMEOUT_MS);
|
|
238
|
+
assert.ok(elapsedMs >= STALL_TIMEOUT_MS - 50, `rejected after ${elapsedMs.toFixed(0)}ms: must not fire before the budget`);
|
|
239
|
+
assert.ok(elapsedMs < HANG_BOUND_MS, `rejected after ${elapsedMs.toFixed(0)}ms: must settle inside the budget, not at the hang bound`);
|
|
240
|
+
assertUntouched(port.usage, 'stalled provider');
|
|
241
|
+
await until('the stalled request reached the fixture (a real stall, not a refused connection)', () => provider.hits.length === before + 1);
|
|
242
|
+
});
|
|
243
|
+
await test('stalled provider: GOTRY_LLM_TIMEOUT_MS drives the default port', async () => {
|
|
244
|
+
provider.mode = 'stall';
|
|
245
|
+
await withEnv({ GOTRY_LLM_TIMEOUT_MS: String(STALL_TIMEOUT_MS) }, async () => {
|
|
246
|
+
const port = createOpenAICompatLlm(undefined, CLOCK);
|
|
247
|
+
const { error } = await rejection('stalled provider / env timeout', port.polishQuestion(QUESTION));
|
|
248
|
+
assertTimeoutFailure(error, STALL_TIMEOUT_MS);
|
|
249
|
+
assertUntouched(port.usage, 'env timeout');
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
await test('per-call timeoutMs beats a generous GOTRY_LLM_TIMEOUT_MS', async () => {
|
|
253
|
+
provider.mode = 'stall';
|
|
254
|
+
await withEnv({ GOTRY_LLM_TIMEOUT_MS: '600000' }, async () => {
|
|
255
|
+
const port = createOpenAICompatLlm(undefined, CLOCK, { timeoutMs: STALL_TIMEOUT_MS });
|
|
256
|
+
const { error } = await rejection('stalled provider / per-call over env', port.polishQuestion(QUESTION));
|
|
257
|
+
assertTimeoutFailure(error, STALL_TIMEOUT_MS);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
await test('timeout covers reading the body: 200 headers then a stalled body -> typed timeout', async () => {
|
|
261
|
+
provider.mode = 'stall-body';
|
|
262
|
+
const port = createOpenAICompatLlm(undefined, CLOCK, { timeoutMs: STALL_TIMEOUT_MS });
|
|
263
|
+
const { error } = await rejection('stalled body', port.polishQuestion(QUESTION));
|
|
264
|
+
assertTimeoutFailure(error, STALL_TIMEOUT_MS);
|
|
265
|
+
assertUntouched(port.usage, 'stalled body');
|
|
266
|
+
});
|
|
267
|
+
await test('timeout covers the error body too: 500 headers then a stalled body -> typed timeout (not `llm 500`, not a hang)', async () => {
|
|
268
|
+
provider.mode = 'stall-error-body';
|
|
269
|
+
const port = createOpenAICompatLlm(undefined, CLOCK, { timeoutMs: STALL_TIMEOUT_MS });
|
|
270
|
+
const { error } = await rejection('stalled error body', port.polishQuestion(QUESTION));
|
|
271
|
+
assertTimeoutFailure(error, STALL_TIMEOUT_MS);
|
|
272
|
+
assertUntouched(port.usage, 'stalled error body');
|
|
273
|
+
});
|
|
274
|
+
await test('caller abort mid-flight: rejects promptly with a typed `aborted` failure (default 300s budget is not what ended it)', async () => {
|
|
275
|
+
provider.mode = 'stall';
|
|
276
|
+
const controller = new AbortController();
|
|
277
|
+
const port = createOpenAICompatLlm(undefined, CLOCK, { signal: controller.signal });
|
|
278
|
+
const pending = port.polishQuestion(QUESTION);
|
|
279
|
+
setTimeout(() => controller.abort(), 100);
|
|
280
|
+
const { error, elapsedMs } = await rejection('caller abort', pending);
|
|
281
|
+
assertAbortedFailure(error);
|
|
282
|
+
assert.ok(elapsedMs < HANG_BOUND_MS, `abort took ${elapsedMs.toFixed(0)}ms`);
|
|
283
|
+
assertUntouched(port.usage, 'caller abort');
|
|
284
|
+
});
|
|
285
|
+
await test('caller abort with a custom reason: still `aborted`, and the reason object survives as cause', async () => {
|
|
286
|
+
provider.mode = 'stall';
|
|
287
|
+
const controller = new AbortController();
|
|
288
|
+
const reason = new Error('operator pressed ctrl-c');
|
|
289
|
+
const port = createOpenAICompatLlm(undefined, CLOCK, { signal: controller.signal });
|
|
290
|
+
const pending = port.polishQuestion(QUESTION);
|
|
291
|
+
setTimeout(() => controller.abort(reason), 100);
|
|
292
|
+
const { error } = await rejection('caller abort with reason', pending);
|
|
293
|
+
assertAbortedFailure(error);
|
|
294
|
+
assert.equal(error.cause, reason);
|
|
295
|
+
});
|
|
296
|
+
await test('already-aborted signal: rejects `aborted` without ever reaching the provider', async () => {
|
|
297
|
+
provider.mode = 'ok';
|
|
298
|
+
const controller = new AbortController();
|
|
299
|
+
controller.abort();
|
|
300
|
+
const before = provider.hits.length;
|
|
301
|
+
const port = createOpenAICompatLlm(undefined, CLOCK, { signal: controller.signal });
|
|
302
|
+
const { error } = await rejection('pre-aborted signal', port.polishQuestion(QUESTION));
|
|
303
|
+
assertAbortedFailure(error);
|
|
304
|
+
await new Promise(resolveWait => setTimeout(resolveWait, 100));
|
|
305
|
+
assert.equal(provider.hits.length, before, 'no request may be sent on behalf of a cancelled caller');
|
|
306
|
+
assertUntouched(port.usage, 'pre-aborted');
|
|
307
|
+
});
|
|
308
|
+
await test('caller signal supplied but never aborted: the timeout still wins (AbortSignal.any composition)', async () => {
|
|
309
|
+
provider.mode = 'stall';
|
|
310
|
+
const controller = new AbortController();
|
|
311
|
+
const port = createOpenAICompatLlm(undefined, CLOCK, { signal: controller.signal, timeoutMs: STALL_TIMEOUT_MS });
|
|
312
|
+
const { error } = await rejection('signal + timeout', port.polishQuestion(QUESTION));
|
|
313
|
+
assertTimeoutFailure(error, STALL_TIMEOUT_MS);
|
|
314
|
+
assert.equal(controller.signal.aborted, false, 'the caller\'s own signal is not aborted by our timeout');
|
|
315
|
+
});
|
|
316
|
+
await test('normal fast response: request bytes and reply handling are unchanged, usage accumulates, missing usage still counts', async () => {
|
|
317
|
+
provider.mode = 'ok';
|
|
318
|
+
const port = createOpenAICompatLlm(undefined, CLOCK);
|
|
319
|
+
const out = await bounded('fast response', port.polishQuestion(QUESTION));
|
|
320
|
+
assert.equal(out, '润色后的问句', '<think> block stripped, text trimmed — same as before');
|
|
321
|
+
const hit = provider.hits.at(-1);
|
|
322
|
+
assert.equal(hit.method, 'POST');
|
|
323
|
+
assert.equal(hit.url, '/v1/chat/completions');
|
|
324
|
+
assert.equal(hit.headers['content-type'], 'application/json');
|
|
325
|
+
assert.equal(hit.headers['authorization'], 'Bearer fixture-key');
|
|
326
|
+
const sent = JSON.parse(hit.body);
|
|
327
|
+
assert.deepEqual(Object.keys(sent), ['model', 'messages', 'temperature'], 'same field set and order (no response_format outside JSON mode)');
|
|
328
|
+
assert.equal(hit.body, JSON.stringify(sent), 'raw request bytes are exactly the canonical serialisation');
|
|
329
|
+
assert.equal(sent.model, 'fixture-model');
|
|
330
|
+
assert.equal(sent.temperature, 0.7);
|
|
331
|
+
assert.equal(sent.messages[0]?.role, 'system');
|
|
332
|
+
assert.deepEqual(sent.messages[1], { role: 'user', content: `【${QUESTION.key}】${QUESTION.text}(为什么问:${QUESTION.why})` });
|
|
333
|
+
assert.deepEqual(port.usage, { calls: 1, inputTokens: 120, outputTokens: 30, inputCacheHitTokens: 100, inputCacheMissTokens: 20, responsesMissingUsage: 0 });
|
|
334
|
+
provider.mode = 'ok-no-cache-detail'; // 无缓存明细:全部输入按 miss 记
|
|
335
|
+
await bounded('fast response / no cache detail', port.polishQuestion(QUESTION));
|
|
336
|
+
assert.deepEqual(port.usage, { calls: 2, inputTokens: 170, outputTokens: 40, inputCacheHitTokens: 100, inputCacheMissTokens: 70, responsesMissingUsage: 0 });
|
|
337
|
+
provider.mode = 'ok-no-usage'; // 缺 usage:calls 照计,成本不可证由 responsesMissingUsage 标出(调用方 fail-closed)
|
|
338
|
+
await bounded('fast response / no usage', port.polishQuestion(QUESTION));
|
|
339
|
+
assert.deepEqual(port.usage, { calls: 3, inputTokens: 170, outputTokens: 40, inputCacheHitTokens: 100, inputCacheMissTokens: 70, responsesMissingUsage: 1 });
|
|
340
|
+
// 显式给出(宽松的)选项不改变正常流程:请求字节与第一次逐字相同
|
|
341
|
+
provider.mode = 'ok';
|
|
342
|
+
const optioned = createOpenAICompatLlm(undefined, CLOCK, { timeoutMs: 30_000, signal: new AbortController().signal });
|
|
343
|
+
assert.equal(await bounded('fast response / options', optioned.polishQuestion(QUESTION)), '润色后的问句');
|
|
344
|
+
assert.equal(provider.hits.at(-1).body, hit.body, 'timeout/signal options must not leak into the request');
|
|
345
|
+
assert.equal(optioned.usage.calls, 1);
|
|
346
|
+
});
|
|
347
|
+
await test('JSON mode request bytes are unchanged (response_format + temperature 0) and the reply still parses', async () => {
|
|
348
|
+
provider.mode = 'ok-json';
|
|
349
|
+
const port = createOpenAICompatLlm(undefined, CLOCK);
|
|
350
|
+
const facts = await bounded('extractFacts', port.extractFacts([{ role: 'user', text: '我这两周在大理远程办公' }], newState()));
|
|
351
|
+
assert.deepEqual(facts.calendar, { year: 2026, assertedWeekdays: { '2026-10-05': 'mon' } });
|
|
352
|
+
const hit = provider.hits.at(-1);
|
|
353
|
+
const sent = JSON.parse(hit.body);
|
|
354
|
+
assert.deepEqual(Object.keys(sent), ['model', 'messages', 'response_format', 'temperature']);
|
|
355
|
+
assert.deepEqual(sent.response_format, { type: 'json_object' });
|
|
356
|
+
assert.equal(sent.temperature, 0);
|
|
357
|
+
assert.equal(hit.body, JSON.stringify(sent));
|
|
358
|
+
assert.deepEqual(port.usage, { calls: 1, inputTokens: 7, outputTokens: 3, inputCacheHitTokens: 0, inputCacheMissTokens: 7, responsesMissingUsage: 0 });
|
|
359
|
+
});
|
|
360
|
+
await test('a huge GOTRY_LLM_TIMEOUT_MS does not overflow into an instant timeout (clamped, normal call succeeds)', async () => {
|
|
361
|
+
provider.mode = 'ok';
|
|
362
|
+
await withEnv({ GOTRY_LLM_TIMEOUT_MS: '3000000000' }, async () => {
|
|
363
|
+
const port = createOpenAICompatLlm(undefined, CLOCK);
|
|
364
|
+
assert.equal(await bounded('huge env timeout', port.polishQuestion(QUESTION)), '润色后的问句');
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
await test('non-abort failures are untouched: HTTP 500 stays a plain `llm 500:` Error, refused connection stays a transport error, missing key stays the ADR-8 error', async () => {
|
|
368
|
+
provider.mode = 'error-500';
|
|
369
|
+
const port = createOpenAICompatLlm(undefined, CLOCK);
|
|
370
|
+
const http = await rejection('http 500', port.polishQuestion(QUESTION));
|
|
371
|
+
assert.ok(http.error instanceof Error && !(http.error instanceof LlmRequestError), `HTTP failure must not be rewritten: ${describe(http.error)}`);
|
|
372
|
+
assert.equal(http.error.message, 'llm 500: fixture upstream exploded');
|
|
373
|
+
assertUntouched(port.usage, 'http 500');
|
|
374
|
+
// 连接被拒:先占一个端口再关掉,保证确定性地「没人在听」
|
|
375
|
+
const spare = createServer();
|
|
376
|
+
await new Promise(resolveListen => spare.listen(0, '127.0.0.1', () => resolveListen()));
|
|
377
|
+
const deadPort = spare.address().port;
|
|
378
|
+
await new Promise(resolveClose => spare.close(() => resolveClose()));
|
|
379
|
+
await withEnv({ LLM_BASE_URL: `http://127.0.0.1:${deadPort}/v1` }, async () => {
|
|
380
|
+
const refused = await rejection('connection refused', createOpenAICompatLlm(undefined, CLOCK).polishQuestion(QUESTION));
|
|
381
|
+
assert.ok(refused.error instanceof Error && !(refused.error instanceof LlmRequestError), `transport failure must not be rewritten: ${describe(refused.error)}`);
|
|
382
|
+
assert.notEqual(refused.error.name, 'TimeoutError');
|
|
383
|
+
assert.notEqual(refused.error.name, 'AbortError');
|
|
384
|
+
});
|
|
385
|
+
const before = provider.hits.length;
|
|
386
|
+
await withEnv({ LLM_API_KEY: undefined, DEEPSEEK_API_KEY: undefined }, async () => {
|
|
387
|
+
const keyless = await rejection('missing key', createOpenAICompatLlm(undefined, CLOCK).polishQuestion(QUESTION));
|
|
388
|
+
assert.match(keyless.error.message, /LLM_API_KEY 未设置/);
|
|
389
|
+
assert.ok(!(keyless.error instanceof LlmRequestError));
|
|
390
|
+
});
|
|
391
|
+
assert.equal(provider.hits.length, before, 'no request without a key');
|
|
392
|
+
});
|
|
393
|
+
await test('caller does not swallow it: loop.runTurn rejects with the typed timeout (no degraded "success" reply)', async () => {
|
|
394
|
+
provider.mode = 'stall';
|
|
395
|
+
const port = createOpenAICompatLlm(undefined, CLOCK, { timeoutMs: STALL_TIMEOUT_MS });
|
|
396
|
+
const { error } = await rejection('runTurn over a stalled provider', runTurn(newState(), '我想去大理待两周', port, [], null, CLOCK()));
|
|
397
|
+
assertTimeoutFailure(error, STALL_TIMEOUT_MS);
|
|
398
|
+
});
|
|
399
|
+
await test('caller does not swallow it: nightly-evidence rejects on a stalled provider and writes no evidence', async () => {
|
|
400
|
+
provider.mode = 'stall';
|
|
401
|
+
const evidenceParent = mkdtempSync(join(tmpdir(), 'dsh-llm-nightly-'));
|
|
402
|
+
const evidenceRoot = join(evidenceParent, 'evidence', 'm3');
|
|
403
|
+
const savedCwd = process.cwd();
|
|
404
|
+
process.chdir(TS_ROOT); // nightly-evidence 的价表/提示集/航班包路径相对 ts/
|
|
405
|
+
try {
|
|
406
|
+
await withEnv({ GOTRY_LLM_TIMEOUT_MS: String(STALL_TIMEOUT_MS) }, async () => {
|
|
407
|
+
const { error } = await rejection('nightly over a stalled provider', runNightlyEvidence({ evidenceRoot, dryRun: false, clock: CLOCK }));
|
|
408
|
+
assertTimeoutFailure(error, STALL_TIMEOUT_MS);
|
|
409
|
+
});
|
|
410
|
+
assert.equal(existsSync(join(evidenceRoot, 'cohort.jsonl')), false, 'a timed-out run must not write a cost/evidence record');
|
|
411
|
+
assert.equal(existsSync(evidenceRoot), false, 'and must not even create the evidence directory');
|
|
412
|
+
}
|
|
413
|
+
finally {
|
|
414
|
+
process.chdir(savedCwd);
|
|
415
|
+
rmSync(evidenceParent, { recursive: true, force: true });
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
finally {
|
|
420
|
+
globalThis.fetch = savedFetch;
|
|
421
|
+
for (const [key, value] of Object.entries(savedEnv)) {
|
|
422
|
+
if (value === undefined)
|
|
423
|
+
delete process.env[key];
|
|
424
|
+
else
|
|
425
|
+
process.env[key] = value;
|
|
426
|
+
}
|
|
427
|
+
await provider.closeAll();
|
|
428
|
+
}
|
|
429
|
+
if (failures.length > 0) {
|
|
430
|
+
console.log(`\nDSH-LLM TESTS: ${failures.length} FAILED, ${passed} passed`);
|
|
431
|
+
for (const name of failures)
|
|
432
|
+
console.log(` - ${name}`);
|
|
433
|
+
process.exitCode = 1;
|
|
434
|
+
}
|
|
435
|
+
else {
|
|
436
|
+
console.log(`\nDSH-LLM TESTS OK: ${passed} cases (request timeout/abort contract; offline, loopback only)`);
|
|
437
|
+
}
|
|
@@ -80,6 +80,7 @@ ok(single.test('docs/a.md') && !single.test('docs/sub/b.md'), '`*` 应限单段'
|
|
|
80
80
|
// ── 3. 命名面豁免 ──────────────────────────────────────────────────────────
|
|
81
81
|
ok(NAMING_SURFACES.has('docs/gotry-master-outline.md') && NAMING_SURFACES.has('docs/gotry-master-outline.zh-CN.md'), '门定义对必须在命名面');
|
|
82
82
|
ok(NAMING_SURFACES.has('docs/g5-authorization-ledger.md') && NAMING_SURFACES.has('ts/scripts/g5-guard.ts'), '台账与 guard 自身必须在命名面');
|
|
83
|
+
ok(NAMING_SURFACES.has('CHANGELOG.md'), 'CHANGELOG.md 是 commit 标题的机械镜像(#621),必须在命名面');
|
|
83
84
|
// ── 4. CLI 端到端(临时 git fixture) ───────────────────────────────────────
|
|
84
85
|
const tsRoot = join(import.meta.dirname, '..');
|
|
85
86
|
const guardScript = join(tsRoot, 'scripts', 'g5-guard.ts');
|
|
@@ -128,4 +129,22 @@ rmSync(cleanRoot, { recursive: true, force: true });
|
|
|
128
129
|
const noLedgerRoot = makeFixture({ 'src/adapter.ts': 'export const x = 1\n' });
|
|
129
130
|
ok(runGuard(noLedgerRoot).status === 2, '台账缺失应 exit 2(闸配置错误),不得放行');
|
|
130
131
|
rmSync(noLedgerRoot, { recursive: true, force: true });
|
|
131
|
-
|
|
132
|
+
// #621:CHANGELOG.md 豁免只作用于这一个路径——同一行桥接字样放在 CHANGELOG.md 通过,放在任何其他文件仍然红
|
|
133
|
+
const BRIDGE_LINE = '- mechanical guard requiring explicit authorization before wiring G5 bridge adapters (#348)\n';
|
|
134
|
+
const changelogRoot = makeFixture({
|
|
135
|
+
'CHANGELOG.md': `# Changelog\n\n## [0.0.0]\n\n${BRIDGE_LINE}`,
|
|
136
|
+
'src/adapter.ts': 'export const x = 1\n',
|
|
137
|
+
'docs/g5-authorization-ledger.md': LEDGER_HEADER,
|
|
138
|
+
});
|
|
139
|
+
const changelogOnly = runGuard(changelogRoot);
|
|
140
|
+
ok(changelogOnly.status === 0, `命名 G5 闸的 CHANGELOG 行不应被判未授权引用(status=${changelogOnly.status}, stderr=${changelogOnly.stderr.slice(0, 200)})`);
|
|
141
|
+
rmSync(changelogRoot, { recursive: true, force: true });
|
|
142
|
+
const leakRoot = makeFixture({
|
|
143
|
+
'CHANGELOG.md': `# Changelog\n\n${BRIDGE_LINE}`,
|
|
144
|
+
'docs/notes.md': BRIDGE_LINE,
|
|
145
|
+
'docs/g5-authorization-ledger.md': LEDGER_HEADER,
|
|
146
|
+
});
|
|
147
|
+
const leak = runGuard(leakRoot);
|
|
148
|
+
ok(leak.status === 1 && leak.stderr.includes('docs/notes.md') && !leak.stderr.includes('CHANGELOG.md'), `同一行放在别的文件仍必须红,且只指向那个文件(status=${leak.status}, stderr=${leak.stderr.slice(0, 240)})`);
|
|
149
|
+
rmSync(leakRoot, { recursive: true, force: true });
|
|
150
|
+
console.log(`G5 GUARD TESTS: ${assertions}/${assertions} OK(命中/不命中双向 / 台账 fail-closed+glob / 命名面 / CLI 红绿+exit2 / CHANGELOG 命名面只豁免该路径)`);
|
package/dist/scripts/g5-guard.js
CHANGED
|
@@ -45,7 +45,10 @@ export const G5_BRIDGE_PATTERNS = [
|
|
|
45
45
|
* 它们不是授权——真实桥接落在其它任何文件都仍需台账 GRANT。
|
|
46
46
|
* - master-outline 对:G5 门定义 + 复用矩阵 T 系统行(tracker 本体);
|
|
47
47
|
* - 授权台账对:授权面自身;
|
|
48
|
-
* - guard +
|
|
48
|
+
* - guard + 自测:检测器必须含有自己的检测串;
|
|
49
|
+
* - CHANGELOG.md:由 ts/scripts/build-changelog.ts 从 commit 标题**原样镜像**生成(#621)——描述这条闸的提交
|
|
50
|
+
* 标题一旦进入某个版本段,生成物就会「提到」它。豁免它不削弱闸:真正的桥接代码/配置落在别的文件里,
|
|
51
|
+
* 仍被逐文件扫描(g5-guard-tests 钉死豁免只作用于这一个路径)。
|
|
49
52
|
*/
|
|
50
53
|
export const NAMING_SURFACES = new Set([
|
|
51
54
|
'docs/gotry-master-outline.md',
|
|
@@ -54,6 +57,7 @@ export const NAMING_SURFACES = new Set([
|
|
|
54
57
|
'docs/g5-authorization-ledger.zh-CN.md',
|
|
55
58
|
'ts/scripts/g5-guard.ts',
|
|
56
59
|
'ts/scripts/g5-guard-tests.ts',
|
|
60
|
+
'CHANGELOG.md',
|
|
57
61
|
]);
|
|
58
62
|
/** 排除段:第三方闭包/生成物/创始人私有产品数据(只读红线目录也不扫)。 */
|
|
59
63
|
const EXCLUDED_SEGMENTS = ['node_modules', 'vendor', 'dist', '.git', 'dsh-runtime', 'gotry-state'];
|
|
@@ -90,7 +90,7 @@ async function main() {
|
|
|
90
90
|
evidence: '[会话:dida-portal-needs-extension@test]',
|
|
91
91
|
latencyMs: 1,
|
|
92
92
|
verdict: 'needs-extension',
|
|
93
|
-
error: '
|
|
93
|
+
error: 'Stai Travel Bridge 扩展未连接',
|
|
94
94
|
installUrl: STORE_URL,
|
|
95
95
|
installAction: 'add-to-chrome',
|
|
96
96
|
}),
|