Compare commits

..

15 Commits

Author SHA1 Message Date
john b93c92a3e2 test(scenario): cover multi-image page generation end to end
Memind CI / Test, build, and release guards (push) Failing after 14m27s
The read_image poisoning fix had no end-to-end guard because scenarios could
only send text. Add an upload_images step that renders and uploads visually
distinct images, let chat steps attach them the same way the WeChat channel
does, and assert the generated page actually embeds every upload.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 10:50:23 +08:00
john ef4ce12bbf fix(vision): stop read_image from poisoning text-provider sessions
Image turns already get a vision-model description injected into the prompt,
but the agent kept calling read_image to "confirm" the pictures. Those tool
results carry base64 image parts that Goose persists, so every later turn
against the text-only chat provider failed with `unknown variant image_url`
before the agent could write the page. WeChat page requests therefore fell
through to the fail-closed delivery message.

Drop read_image for the turn whenever a vision model handles the images, say
so explicitly in the injected prompt, and teach the poison scan to recognise
tool image parts so already-polluted sessions rotate instead of failing again.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 10:40:12 +08:00
john 4d12ea438b feat(billing): add dual formulas, disable 10yuan gift, retry auto-renew
Memind CI / Test, build, and release guards (push) Successful in 3m43s
Turn off the low-balance 10 CNY signup bonus while keeping the 5 CNY
registration credit, split metering into independent formula A/B scopes,
and retry expired auto-renew subscriptions after recharge or hourly worker.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 08:45:43 +08:00
john f57e080b49 docs(ops): register wechat delivery fix branch and add manual resend scripts
Memind CI / Test, build, and release guards (push) Successful in 3m44s
Document branch disposition after 103 release, and add standalone/manual
helpers to resend scheduled-task WeChat delivery when reconcile cannot replay.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 22:35:16 +08:00
john 1ca06e58e7 fix(scheduled-task): deliver verified WeChat links only after page is ready
Memind CI / Test, build, and release guards (push) Successful in 8m42s
Wait for HTML materialization before releasing delivery contracts, pass
verified MindSpace URLs through proactive WeChat sends, resend on reconcile
when pages land late, and report deferred customer-service delivery as unsent.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 22:07:41 +08:00
john 0a7b2df5fe docs(release): close production-inspection-fixes branch and add standalone repair script
Memind CI / Test, build, and release guards (push) Successful in 3m8s
Register branch disposition after 103 deploy/repair, and add a lightweight
103 repair script that avoids heavy runtime module imports.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 08:59:19 +08:00
john 843e4d1002 fix(prod): dedupe scheduled tasks and reconcile orphan delivery contracts
Memind CI / Test, build, and release guards (push) Successful in 2m58s
Prevent duplicate active scheduled tasks, fail static preparing contracts
when HTML is missing, raise MindSpace remote timeout default to 30s, and
add 103 repair scripts for inspection follow-ups.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 08:52:16 +08:00
john f20302fe65 docs: register scheduled-task page delivery fix branch disposition
Memind CI / Test, build, and release guards (push) Successful in 3m36s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 08:33:37 +08:00
john 7c0ed58ae1 fix(scheduled-task): release page delivery contracts before notifying users
Memind CI / Test, build, and release guards (push) Successful in 3m36s
Scheduled automation now prepares and retries MindSpace delivery contracts,
blocks WeChat pushes when public HTML links are not ready, and reconciles
stuck static preparing contracts on each worker scan.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 08:26:21 +08:00
john 2b58cdc2c8 test(wechat): simulate tang image report and follow-up edit flow
Memind CI / Test, build, and release guards (push) Failing after 3m27s
Add a four-step webhook simulation covering reset, image ack, report page
turn, and polluted-session rotation on content-edit follow-ups.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-15 09:19:24 +08:00
john 9f7633be24 fix(wechat): rotate polluted image sessions before follow-up turns
Memind CI / Test, build, and release guards (push) Successful in 3m28s
After a successful image report flow, Goose may keep image_url parts that
DeepSeek rejects on later turns while PUT scrub returns 405. Detect polluted
sessions up front, rotate to a fresh agent route with carried assistant
context, and recover image URLs from recent media on historical retries.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-15 09:06:00 +08:00
john fbd68a36e9 fix(release): stop circular dry-run in fast release gate unit test
Memind CI / Test, build, and release guards (push) Successful in 3m36s
Replace the integration spawn that re-entered run_fast_release_guards with
static script checks so fast release guards can finish reliably.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-15 08:34:53 +08:00
john be8ce867ca Merge branch 'feature/wechat-image-followup-poison-fix'
Memind CI / Test, build, and release guards (push) Failing after 3m29s
Fix WeChat image-then-text report follow-up image_url session poisoning.
2026-08-15 08:21:39 +08:00
john 67b9478155 fix(wechat): avoid image_url session poison on report follow-ups
Store image-only WeChat messages without running Agent, scrub assistant image_url history, and retry historical-image failures with text-only prompts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-15 08:21:28 +08:00
john 4c2bc95462 Merge branch 'feature/portal-fast-release'
Memind CI / Test, build, and release guards (push) Successful in 3m0s
Add standard and fast 103 portal release modes.
2026-08-15 08:06:15 +08:00
41 changed files with 3145 additions and 364 deletions
@@ -67,12 +67,20 @@ node scripts/run-scenario-test.mjs --scenario suzhou-page
|--------|------|
| `login` | HTTP 登录,取 cookie + userId |
| `chat` | 发消息(同 session 多轮),等 run 终态 + assistant 新回复 |
| `upload_images` | 生成并上传测试图片到 MindSpace,供后续 `chat` 步骤引用 |
`upload_images` 字段:`categoryCode`(默认 `oa`)、`images[]`
(每项 `filename` / `background` / `shape` / `accent`,shape 支持
`circle` `square` `triangle` `diamond`)。后续 `chat` 步骤设
`useUploadedAssets: true` 即可把这些图作为本轮附件发出。
`chat.expect` 可选断言:
- `assistantMinChars` / `timeoutMs`
- `replyKeywords`:回复文本关键词
- `forbidReplyPatterns`:回复中不允许出现的字符串
- `page.keywords` / `requirePublicLink` / `requireHttp200` / `requireMindspaceCover`
- `page.requireUploadedImages` / `page.minUploadedImages`:页面必须嵌入 `upload_images` 上传的资产
新增更复杂步骤(截图、微信、多账户)时:先扩展 `scripts/scenario-test-lib.mjs`,再加场景 JSON,**不要**在 skill 里写死一次性命令。
@@ -67,12 +67,20 @@ node scripts/run-scenario-test.mjs --scenario suzhou-page
|--------|------|
| `login` | HTTP 登录,取 cookie + userId |
| `chat` | 发消息(同 session 多轮),等 run 终态 + assistant 新回复 |
| `upload_images` | 生成并上传测试图片到 MindSpace,供后续 `chat` 步骤引用 |
`upload_images` 字段:`categoryCode`(默认 `oa`)、`images[]`
(每项 `filename` / `background` / `shape` / `accent`,shape 支持
`circle` `square` `triangle` `diamond`)。后续 `chat` 步骤设
`useUploadedAssets: true` 即可把这些图作为本轮附件发出。
`chat.expect` 可选断言:
- `assistantMinChars` / `timeoutMs`
- `replyKeywords`:回复文本关键词
- `forbidReplyPatterns`:回复中不允许出现的字符串
- `page.keywords` / `requirePublicLink` / `requireHttp200` / `requireMindspaceCover`
- `page.requireUploadedImages` / `page.minUploadedImages`:页面必须嵌入 `upload_images` 上传的资产
新增更复杂步骤(截图、微信、多账户)时:先扩展 `scripts/scenario-test-lib.mjs`,再加场景 JSON,**不要**在 skill 里写死一次性命令。
+2
View File
@@ -52,6 +52,7 @@ bash scripts/check-release-ready.sh
| H5 SSE 断线续播、Portal/Goose 游标映射与 Finish 终态恢复 | [docs/regression-guards/h5-session-stream-replay.md](docs/regression-guards/h5-session-stream-replay.md) |
| Memory V2 候选表初始化与生命周期灰度作用域 | [docs/regression-guards/memory-v2-candidate-and-lifecycle.md](docs/regression-guards/memory-v2-candidate-and-lifecycle.md) |
| MindSpace SEO/GEO 收录策略与私有页 noindex | [docs/regression-guards/mindspace-seo-geo.md](docs/regression-guards/mindspace-seo-geo.md) |
| 图片轮次禁用 `read_image` 与工具图片污染会话轮换 | [docs/regression-guards/vision-turn-read-image-isolation.md](docs/regression-guards/vision-turn-read-image-isolation.md) |
索引:[docs/regression-guards/README.md](docs/regression-guards/README.md)
@@ -81,6 +82,7 @@ npm run verify:seo-discovery
- `mindspace-page-sync-service.mjs` + `server.mjs` - remote 模式也必须 sync public HTML
- `session-stream.mjs` + `session-stream-store.mjs` + `tkmind-proxy.mjs` - Portal replay ID 不得直接作为 Goose `Last-Event-ID`
- `mindspace-index-policy.mjs` + `mindspace-seo-geo-delivery.mjs` - 私有页必须 noindex;仅 confirmed public 可收录
- `tkmind-proxy.mjs` + `chat-image-turn-scope.mjs` + `wechat-mp.mjs` - 带图轮次必须摘掉 `read_image`;工具图片污染必须触发会话轮换
代码内搜索 `REGRESSION GUARD` 可定位所有内联说明。
+1 -1
View File
@@ -168,7 +168,7 @@ export async function createAdminServices(env = {}) {
});
const notificationDispatcher = createNotificationDispatcher({
sendWechatTextToUser: wechatMpService?.enabled
? (userId, text) => wechatMpService.sendTextToUser(userId, text)
? (userId, text, options) => wechatMpService.sendTextToUser(userId, text, options)
: null,
});
userAuth.setRechargeNotifier(async ({ userId, title, body, dedupeKey }) => {
+197 -63
View File
@@ -1,7 +1,9 @@
import { loadBillingConfig } from './billing.mjs';
const CONFIG_TABLE = 'h5_billing_admin_config';
const CONFIG_SCOPE = 'global';
const CONFIG_SCOPE_LEGACY = 'global';
const CONFIG_SCOPE_A = 'formula-a';
const CONFIG_SCOPE_B = 'formula-b';
const SOURCE_ADMIN_DB = 'admin-db';
const SOURCE_ENV = 'env';
const SOURCE_ENV_OVERRIDE = 'env-override';
@@ -9,6 +11,13 @@ const SOURCE_DEFAULT = 'default';
const CACHE_TTL_MS = 5_000;
const DEFAULT_ESTIMATE_INPUT_USD_PER_1M = 0.27;
const DEFAULT_ESTIMATE_OUTPUT_USD_PER_1M = 1.1;
const DEFAULT_FORMULA = 'A';
const FORMULA_KEYS = ['A', 'B'];
const SCOPE_BY_FORMULA = {
A: CONFIG_SCOPE_A,
B: CONFIG_SCOPE_B,
};
function normalizeBoolean(value, fallback = false) {
if (value == null || value === '') return fallback;
@@ -31,6 +40,11 @@ function normalizeNonNegativeNumber(value, fallback) {
return num;
}
function normalizeFormulaKey(value) {
const key = String(value ?? DEFAULT_FORMULA).trim().toUpperCase();
return FORMULA_KEYS.includes(key) ? key : DEFAULT_FORMULA;
}
function defaultConfigShape(env = process.env) {
const billing = loadBillingConfig(env);
const costEstimateFromTokens = env.H5_COST_ESTIMATE_FROM_TOKENS !== '0';
@@ -160,24 +174,98 @@ async function ensureConfigTable(pool) {
`);
}
async function loadStoredState(pool) {
async function loadStoredStateByScope(pool, scope, env = process.env) {
await ensureConfigTable(pool);
const [rows] = await pool.query(
`SELECT config_json, updated_by, updated_at
FROM ${CONFIG_TABLE}
WHERE config_scope = ?
LIMIT 1`,
[CONFIG_SCOPE],
[scope],
);
const row = rows[0];
if (!row) return null;
return {
config: mergePatch(defaultConfigShape(), parseJsonLike(row.config_json, {})),
config: mergePatch(defaultConfigShape(env), parseJsonLike(row.config_json, {}), env),
updatedAt: Number(row.updated_at ?? 0) || null,
updatedBy: row.updated_by ?? null,
};
}
async function upsertStoredConfig(pool, scope, config, { updatedBy = null } = {}, env = process.env) {
await ensureConfigTable(pool);
const now = Date.now();
await pool.query(
`INSERT INTO ${CONFIG_TABLE}
(config_scope, config_json, updated_by, updated_at)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
config_json = VALUES(config_json),
updated_by = VALUES(updated_by),
updated_at = VALUES(updated_at)`,
[scope, JSON.stringify(cloneConfig(config, env)), updatedBy, now],
);
return {
config: cloneConfig(config, env),
updatedAt: now,
updatedBy,
};
}
function resolveDefaultSource(env = process.env) {
if (envLocked(env)) return SOURCE_ENV_OVERRIDE;
if (envLooksCustomized(env)) return SOURCE_ENV;
return SOURCE_DEFAULT;
}
function buildFormulaState(stored, env = process.env) {
if (stored) {
return {
config: cloneConfig(stored.config, env),
updatedAt: stored.updatedAt,
updatedBy: stored.updatedBy,
source: SOURCE_ADMIN_DB,
};
}
return {
config: defaultConfigShape(env),
updatedAt: null,
updatedBy: null,
source: resolveDefaultSource(env),
};
}
async function migrateLegacyConfig(pool, env = process.env) {
await ensureConfigTable(pool);
const legacy = await loadStoredStateByScope(pool, CONFIG_SCOPE_LEGACY, env);
const formulaA = await loadStoredStateByScope(pool, CONFIG_SCOPE_A, env);
const formulaB = await loadStoredStateByScope(pool, CONFIG_SCOPE_B, env);
const seedConfig = legacy?.config
?? formulaA?.config
?? formulaB?.config
?? defaultConfigShape(env);
if (!formulaA) {
await upsertStoredConfig(
pool,
CONFIG_SCOPE_A,
seedConfig,
{ updatedBy: legacy?.updatedBy ?? formulaB?.updatedBy ?? null },
env,
);
}
if (!formulaB) {
await upsertStoredConfig(
pool,
CONFIG_SCOPE_B,
cloneConfig(formulaA?.config ?? seedConfig, env),
{ updatedBy: legacy?.updatedBy ?? formulaA?.updatedBy ?? null },
env,
);
}
}
export function createBillingAdminConfigService(pool, { env = process.env, cacheTtlMs = CACHE_TTL_MS } = {}) {
let cache = null;
@@ -185,101 +273,142 @@ export function createBillingAdminConfigService(pool, { env = process.env, cache
cache = null;
}
async function loadEffectiveConfig({ bypassCache = false } = {}) {
async function loadAllFormulaStates({ bypassCache = false } = {}) {
const now = Date.now();
if (!bypassCache && cache && now - cache.loadedAt < cacheTtlMs) {
return cache.state;
}
if (envLocked(env)) {
const shared = defaultConfigShape(env);
const state = {
config: defaultConfigShape(env),
updatedAt: null,
updatedBy: null,
source: SOURCE_ENV_OVERRIDE,
formulas: {
A: {
config: cloneConfig(shared, env),
updatedAt: null,
updatedBy: null,
source: SOURCE_ENV_OVERRIDE,
},
B: {
config: cloneConfig(shared, env),
updatedAt: null,
updatedBy: null,
source: SOURCE_ENV_OVERRIDE,
},
},
defaultFormula: DEFAULT_FORMULA,
};
cache = { loadedAt: now, state };
return state;
}
const stored = await loadStoredState(pool);
let state;
if (stored) {
state = {
config: cloneConfig(stored.config, env),
updatedAt: stored.updatedAt,
updatedBy: stored.updatedBy,
source: SOURCE_ADMIN_DB,
};
} else if (envLooksCustomized(env)) {
state = {
config: defaultConfigShape(env),
updatedAt: null,
updatedBy: null,
source: SOURCE_ENV,
};
} else {
state = {
config: defaultConfigShape(env),
updatedAt: null,
updatedBy: null,
source: SOURCE_DEFAULT,
};
}
await migrateLegacyConfig(pool, env);
const storedA = await loadStoredStateByScope(pool, CONFIG_SCOPE_A, env);
const storedB = await loadStoredStateByScope(pool, CONFIG_SCOPE_B, env);
const state = {
formulas: {
A: buildFormulaState(storedA, env),
B: buildFormulaState(storedB, env),
},
defaultFormula: DEFAULT_FORMULA,
};
cache = { loadedAt: now, state };
return state;
}
async function getUserFormulaKey(userId) {
if (!userId) return DEFAULT_FORMULA;
const [rows] = await pool.query(
`SELECT billing_formula FROM h5_users WHERE id = ? LIMIT 1`,
[userId],
);
return normalizeFormulaKey(rows[0]?.billing_formula);
}
async function loadFormulaState(formulaKey, options = {}) {
const all = await loadAllFormulaStates(options);
return all.formulas[normalizeFormulaKey(formulaKey)];
}
return {
async ensureSchema() {
await ensureConfigTable(pool);
await migrateLegacyConfig(pool, env);
},
clearCache,
async getAdminConfig() {
const state = await loadEffectiveConfig({ bypassCache: true });
async getAdminConfig({ formula = DEFAULT_FORMULA } = {}) {
const all = await loadAllFormulaStates({ bypassCache: true });
const activeFormula = normalizeFormulaKey(formula);
const active = all.formulas[activeFormula];
return {
config: state.config,
updatedAt: state.updatedAt,
updatedBy: state.updatedBy,
source: state.source,
envOverrideActive: state.source === SOURCE_ENV_OVERRIDE,
config: active.config,
formulas: {
A: all.formulas.A.config,
B: all.formulas.B.config,
},
formulaMeta: {
A: {
updatedAt: all.formulas.A.updatedAt,
updatedBy: all.formulas.A.updatedBy,
source: all.formulas.A.source,
},
B: {
updatedAt: all.formulas.B.updatedAt,
updatedBy: all.formulas.B.updatedBy,
source: all.formulas.B.source,
},
},
activeFormula,
defaultFormula: DEFAULT_FORMULA,
updatedAt: active.updatedAt,
updatedBy: active.updatedBy,
source: active.source,
envOverrideActive: active.source === SOURCE_ENV_OVERRIDE,
formula: '最终扣费 = 上游成本(USD) × 汇率 × 毛利倍数(成本模式);无上游成本时回退 Token 单价',
};
},
async updateAdminConfig(patch = {}, { updatedBy = null } = {}) {
async updateAdminConfig(patch = {}, { updatedBy = null, formula = DEFAULT_FORMULA } = {}) {
if (envLocked(env)) {
throw Object.assign(new Error('H5_BILLING_CONFIG_SOURCE=env 时不允许通过后台修改'), {
code: 'BILLING_CONFIG_ENV_LOCKED',
});
}
const stored = await loadStoredState(pool);
const formulaKey = normalizeFormulaKey(formula);
const scope = SCOPE_BY_FORMULA[formulaKey];
const stored = await loadStoredStateByScope(pool, scope, env);
const base = stored?.config ?? defaultConfigShape(env);
const nextConfig = mergePatch(base, patch, env);
await ensureConfigTable(pool);
const now = Date.now();
await pool.query(
`INSERT INTO ${CONFIG_TABLE}
(config_scope, config_json, updated_by, updated_at)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
config_json = VALUES(config_json),
updated_by = VALUES(updated_by),
updated_at = VALUES(updated_at)`,
[CONFIG_SCOPE, JSON.stringify(nextConfig), updatedBy, now],
);
await upsertStoredConfig(pool, scope, nextConfig, { updatedBy }, env);
clearCache();
return this.getAdminConfig();
return this.getAdminConfig({ formula: formulaKey });
},
async getRuntimeState() {
const state = await loadEffectiveConfig();
async assignBillingFormula(userIds = [], formula = DEFAULT_FORMULA) {
const formulaKey = normalizeFormulaKey(formula);
const ids = [...new Set((Array.isArray(userIds) ? userIds : []).map((id) => String(id).trim()).filter(Boolean))];
if (!ids.length) {
return { ok: false, message: '请选择至少一个用户', updated: 0 };
}
const now = Date.now();
const placeholders = ids.map(() => '?').join(', ');
const [result] = await pool.query(
`UPDATE h5_users SET billing_formula = ?, updated_at = ? WHERE id IN (${placeholders})`,
[formulaKey, now, ...ids],
);
return { ok: true, updated: Number(result.affectedRows ?? 0), formula: formulaKey };
},
async getRuntimeState({ formula = DEFAULT_FORMULA, userId = null } = {}) {
const formulaKey = userId ? await getUserFormulaKey(userId) : normalizeFormulaKey(formula);
const state = await loadFormulaState(formulaKey);
return {
source: state.source,
updatedAt: state.updatedAt,
updatedBy: state.updatedBy,
formula: formulaKey,
config: state.config,
compute: toComputeBillingConfig(state.config),
estimate: toCostEstimateConfig(state.config),
@@ -287,23 +416,28 @@ export function createBillingAdminConfigService(pool, { env = process.env, cache
};
},
async getEffectiveBillingConfig() {
const state = await loadEffectiveConfig();
async getEffectiveBillingConfig({ userId = null, formula = DEFAULT_FORMULA } = {}) {
const formulaKey = userId ? await getUserFormulaKey(userId) : normalizeFormulaKey(formula);
const state = await loadFormulaState(formulaKey);
return toComputeBillingConfig(state.config);
},
async getEffectiveCostEstimateConfig() {
const state = await loadEffectiveConfig();
async getEffectiveCostEstimateConfig({ userId = null, formula = DEFAULT_FORMULA } = {}) {
const formulaKey = userId ? await getUserFormulaKey(userId) : normalizeFormulaKey(formula);
const state = await loadFormulaState(formulaKey);
return toCostEstimateConfig(state.config);
},
};
}
export const billingAdminConfigInternals = {
CONFIG_SCOPE,
CONFIG_TABLE,
CONFIG_SCOPE_LEGACY,
CONFIG_SCOPE_A,
CONFIG_SCOPE_B,
DEFAULT_FORMULA,
defaultConfigShape,
mergePatch,
normalizeFormulaKey,
toComputeBillingConfig,
toCostEstimateConfig,
envLocked,
+43 -4
View File
@@ -7,13 +7,21 @@ import {
toCostEstimateConfig,
} from './billing-admin-config.mjs';
function createMemoryPool(initialRows = []) {
function createMemoryPool(initialRows = [], userFormulas = new Map()) {
const rows = new Map(
initialRows.map((row) => [row.config_scope, { ...row }]),
);
return {
async query(sql, params = []) {
if (/CREATE TABLE/i.test(sql)) return [{}, undefined];
if (/SELECT billing_formula FROM h5_users/i.test(sql)) {
const userId = params[0];
return [[{ billing_formula: userFormulas.get(userId) ?? 'A' }], undefined];
}
if (/SELECT/i.test(sql) && /config_scope IN/i.test(sql)) {
const scopes = params.slice(0, 3);
return [scopes.map((scope) => rows.get(scope)).filter(Boolean), undefined];
}
if (/SELECT/i.test(sql)) {
const scope = params[0];
const row = rows.get(scope);
@@ -29,9 +37,15 @@ function createMemoryPool(initialRows = []) {
});
return [{ affectedRows: 1 }, undefined];
}
if (/UPDATE h5_users SET billing_formula/i.test(sql)) {
const [formula, , ...ids] = params;
ids.forEach((id) => userFormulas.set(id, formula));
return [{ affectedRows: ids.length }, undefined];
}
return [{}, undefined];
},
_rows: rows,
_userFormulas: userFormulas,
};
}
@@ -89,14 +103,38 @@ test('admin-db config wins over env for effective billing', async () => {
const service = createBillingAdminConfigService(pool, { env, cacheTtlMs: 0 });
await service.updateAdminConfig(
{ marginMultiplier: 2, usdCnyRate: 7.5, useBackendCost: true },
{ updatedBy: 'admin-1' },
{ updatedBy: 'admin-1', formula: 'A' },
);
const effective = await service.getEffectiveBillingConfig();
const effective = await service.getEffectiveBillingConfig({ userId: 'user-a' });
assert.equal(effective.marginMultiplier, 2);
assert.equal(effective.usdCnyRate, 7.5);
const admin = await service.getAdminConfig();
assert.equal(admin.source, 'admin-db');
assert.equal(admin.updatedBy, 'admin-1');
assert.equal(admin.formulas?.A.marginMultiplier, 2);
});
test('formula A and B can diverge', async () => {
const pool = createMemoryPool([], new Map([['user-b', 'B']]));
const service = createBillingAdminConfigService(pool, { env: {}, cacheTtlMs: 0 });
await service.updateAdminConfig({ marginMultiplier: 1.1 }, { formula: 'A' });
await service.updateAdminConfig({ marginMultiplier: 2.2 }, { formula: 'B' });
const admin = await service.getAdminConfig();
assert.equal(admin.formulas.A.marginMultiplier, 1.1);
assert.equal(admin.formulas.B.marginMultiplier, 2.2);
const userA = await service.getEffectiveBillingConfig({ userId: 'user-a' });
const userB = await service.getEffectiveBillingConfig({ userId: 'user-b' });
assert.equal(userA.marginMultiplier, 1.1);
assert.equal(userB.marginMultiplier, 2.2);
});
test('assignBillingFormula updates users', async () => {
const pool = createMemoryPool();
const service = createBillingAdminConfigService(pool, { env: {}, cacheTtlMs: 0 });
const result = await service.assignBillingFormula(['u1', 'u2'], 'B');
assert.equal(result.ok, true);
assert.equal(result.updated, 2);
assert.equal(pool._userFormulas.get('u1'), 'B');
});
test('H5_BILLING_CONFIG_SOURCE=env locks admin writes', async () => {
@@ -125,7 +163,8 @@ test('env fallback used when no admin row', async () => {
cacheTtlMs: 0,
});
const admin = await service.getAdminConfig();
assert.equal(admin.source, 'env');
assert.equal(admin.config.marginMultiplier, 1.2);
assert.equal(admin.config.useBackendCost, true);
assert.equal(admin.formulas.A.marginMultiplier, 1.2);
assert.equal(admin.formulas.B.marginMultiplier, 1.2);
});
+123 -82
View File
@@ -477,99 +477,139 @@ export function createSubscriptionService(pool, { getPlanAsync = null } = {}) {
return { ok: true, updated: result.affectedRows > 0 };
};
// Process auto-renewals for subscriptions that just expired with auto_renew = 1.
// Should be called BEFORE expireStaleSubscriptions in the periodic worker.
const processAutoRenewals = async () => {
const now = Date.now();
// Find active subscriptions that have expired with auto_renew on.
const [rows] = await pool.query(
// Process auto-renewals for subscriptions that expired with auto_renew = 1.
// Retries until balance is sufficient; does not disable auto_renew on failure.
const processAutoRenewalsForUser = async (userId, { now = Date.now() } = {}) => {
const [activeRows] = await pool.query(
`SELECT id FROM h5_subscriptions
WHERE user_id = ? AND status = 'active' AND expires_at > ?
LIMIT 1`,
[userId, now],
);
if (activeRows.length) {
return { ok: false, reason: 'already_active' };
}
const [candidateRows] = await pool.query(
`SELECT s.*, w.balance_cents
FROM h5_subscriptions s
JOIN h5_user_wallets w ON w.user_id = s.user_id
WHERE s.status = 'active' AND s.auto_renew = 1 AND s.expires_at <= ?`,
[now],
WHERE s.user_id = ? AND s.auto_renew = 1 AND s.expires_at <= ?
ORDER BY s.expires_at DESC
LIMIT 1`,
[userId, now],
);
const row = candidateRows[0];
if (!row) {
return { ok: false, reason: 'no_candidate' };
}
const sub = mapSubRow(row);
const plan = await resolvePlan(sub.planType);
if (!plan || plan.priceCents === 0) {
return { ok: false, reason: 'invalid_plan' };
}
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const [walletRows] = await conn.query(
`SELECT balance_cents FROM h5_user_wallets WHERE user_id = ? FOR UPDATE`,
[sub.userId],
);
const balanceCents = Number(walletRows[0]?.balance_cents ?? 0);
if (balanceCents < plan.priceCents) {
await conn.rollback();
console.log(`Auto-renew failed for user ${sub.userId} (${sub.planType}): insufficient balance`);
return { ok: false, reason: 'insufficient_balance', balanceCents, requiredCents: plan.priceCents };
}
await conn.query(
`UPDATE h5_user_wallets SET balance_cents = balance_cents - ?, updated_at = ? WHERE user_id = ?`,
[plan.priceCents, now, sub.userId],
);
await conn.query(
`INSERT INTO h5_billing_ledger (user_id, type, amount_cents, tokens, note, operator_id, created_at)
VALUES (?, 'deduct', ?, 0, ?, NULL, ?)`,
[sub.userId, plan.priceCents, `auto_renew:${sub.planType}`, now],
);
if (sub.status === 'active') {
await conn.query(
`UPDATE h5_subscriptions SET status = 'expired', updated_at = ? WHERE id = ?`,
[now, sub.id],
);
}
const newId = crypto.randomUUID();
const newPeriodEnd = now + plan.periodDays * 24 * 60 * 60 * 1000;
await conn.query(
`INSERT INTO h5_subscriptions
(id, user_id, plan_type, status, period_tokens_limit, period_tokens_used,
period_images_limit, period_images_used, period_images_bonus,
period_start, period_end, expires_at, overage_rate, auto_renew,
operator_id, note, created_at, updated_at)
VALUES (?, ?, ?, 'active', ?, 0, ?, 0, ?, ?, ?, ?, ?, 1, NULL, '自动续费', ?, ?)`,
[
newId, sub.userId, sub.planType, plan.periodTokens,
plan.periodImages ?? 0,
sub.periodImagesBonus ?? 0,
now, newPeriodEnd, newPeriodEnd,
Number(plan.overageRate.toFixed(2)),
now, now,
],
);
await conn.query(
`UPDATE h5_users SET plan_type = ?, updated_at = ? WHERE id = ?`,
[sub.planType, now, sub.userId],
);
await conn.commit();
console.log(`Auto-renewed ${sub.planType} for user ${sub.userId}`);
const [newRows] = await pool.query(`SELECT * FROM h5_subscriptions WHERE id = ?`, [newId]);
return { ok: true, subscription: mapSubRow(newRows[0]), balanceCents: balanceCents - plan.priceCents };
} catch (err) {
await conn.rollback();
console.warn(`Auto-renew error for user ${sub.userId}:`, err);
throw err;
} finally {
conn.release();
}
};
const processAutoRenewals = async () => {
const now = Date.now();
const [rows] = await pool.query(
`SELECT DISTINCT s.user_id
FROM h5_subscriptions s
WHERE s.auto_renew = 1
AND s.expires_at <= ?
AND s.plan_type != 'free'
AND NOT EXISTS (
SELECT 1 FROM h5_subscriptions s2
WHERE s2.user_id = s.user_id
AND s2.status = 'active'
AND s2.expires_at > ?
)`,
[now, now],
);
let renewed = 0;
let failed = 0;
for (const row of rows) {
const sub = mapSubRow(row);
const plan = await resolvePlan(sub.planType);
if (!plan || plan.priceCents === 0) continue;
const balance = Number(row.balance_cents ?? 0);
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
// Re-lock wallet and verify balance
const [walletRows] = await conn.query(
`SELECT balance_cents FROM h5_user_wallets WHERE user_id = ? FOR UPDATE`,
[sub.userId],
);
const balanceCents = Number(walletRows[0]?.balance_cents ?? 0);
if (balanceCents < plan.priceCents) {
// Not enough — let expireStaleSubscriptions handle the reset
await conn.rollback();
failed++;
console.log(`Auto-renew failed for user ${sub.userId} (${sub.planType}): insufficient balance`);
continue;
const result = await processAutoRenewalsForUser(row.user_id, { now });
if (result.ok) {
renewed += 1;
} else if (result.reason === 'insufficient_balance') {
failed += 1;
}
// Deduct wallet
await conn.query(
`UPDATE h5_user_wallets SET balance_cents = balance_cents - ?, updated_at = ? WHERE user_id = ?`,
[plan.priceCents, now, sub.userId],
);
await conn.query(
`INSERT INTO h5_billing_ledger (user_id, type, amount_cents, tokens, note, operator_id, created_at)
VALUES (?, 'deduct', ?, 0, ?, NULL, ?)`,
[sub.userId, plan.priceCents, `auto_renew:${sub.planType}`, now],
);
// Expire old subscription
await conn.query(
`UPDATE h5_subscriptions SET status = 'expired', auto_renew = 0, updated_at = ? WHERE id = ?`,
[now, sub.id],
);
// Create new subscription
const newId = crypto.randomUUID();
const newPeriodEnd = now + plan.periodDays * 24 * 60 * 60 * 1000;
await conn.query(
`INSERT INTO h5_subscriptions
(id, user_id, plan_type, status, period_tokens_limit, period_tokens_used,
period_images_limit, period_images_used, period_images_bonus,
period_start, period_end, expires_at, overage_rate, auto_renew,
operator_id, note, created_at, updated_at)
VALUES (?, ?, ?, 'active', ?, 0, ?, 0, ?, ?, ?, ?, ?, 1, NULL, '自动续费', ?, ?)`,
[
newId, sub.userId, sub.planType, plan.periodTokens,
plan.periodImages ?? 0,
sub.periodImagesBonus ?? 0,
now, newPeriodEnd, newPeriodEnd,
Number(plan.overageRate.toFixed(2)),
now, now,
],
);
// Keep user's plan_type current
await conn.query(
`UPDATE h5_users SET plan_type = ?, updated_at = ? WHERE id = ?`,
[sub.planType, now, sub.userId],
);
await conn.commit();
renewed++;
console.log(`Auto-renewed ${sub.planType} for user ${sub.userId}`);
} catch (err) {
await conn.rollback();
console.warn(`Auto-renew error for user ${sub.userId}:`, err);
failed++;
} finally {
conn.release();
} catch {
failed += 1;
}
}
@@ -901,6 +941,7 @@ export function createSubscriptionService(pool, { getPlanAsync = null } = {}) {
purchaseSubscription,
setAutoRenew,
processAutoRenewals,
processAutoRenewalsForUser,
consumeQuota,
consumeImageQuota,
getImageQuota,
+61 -2
View File
@@ -353,8 +353,11 @@ describe('createSubscriptionService', () => {
describe('processAutoRenewals', () => {
it('uses DB-backed plan definitions for renewed subscriptions', async () => {
const userId = 'user-lite-1';
const expiredSub = makeSubRow({
user_id: userId,
plan_type: 'lite',
status: 'expired',
auto_renew: 1,
expires_at: Date.now() - 1000,
balance_cents: 9999,
@@ -364,6 +367,9 @@ describe('createSubscriptionService', () => {
async query(sql, params) {
this.queries.push({ sql, params });
if (sql.includes('SELECT balance_cents')) return [[{ balance_cents: 9999 }]];
if (sql.includes('SELECT * FROM h5_subscriptions WHERE id = ?')) {
return [[{ ...expiredSub, id: 'new-sub-id', status: 'active' }]];
}
return [{ affectedRows: 1 }];
},
async beginTransaction() {},
@@ -372,8 +378,12 @@ describe('createSubscriptionService', () => {
release() {},
};
const pool = {
async query(sql) {
if (sql.includes('FROM h5_subscriptions s')) return [[expiredSub]];
async query(sql, params = []) {
if (sql.includes('SELECT DISTINCT s.user_id')) return [[{ user_id: userId }]];
if (sql.includes('SELECT id FROM h5_subscriptions') && sql.includes('status = \'active\'')) return [[]];
if (sql.includes('FROM h5_subscriptions s') && sql.includes('ORDER BY s.expires_at DESC')) {
return [[expiredSub]];
}
return [[]];
},
async getConnection() { return conn; },
@@ -407,5 +417,54 @@ describe('createSubscriptionService', () => {
assert.equal(insert.params[5], 0);
assert.equal(insert.params[9], 0.45);
});
it('retries expired subscriptions when balance becomes sufficient', async () => {
const userId = 'user-retry-1';
let balance = 500;
const expiredSub = makeSubRow({
user_id: userId,
plan_type: 'lite',
status: 'expired',
auto_renew: 1,
expires_at: Date.now() - 1000,
});
const conn = {
async query(sql) {
if (sql.includes('SELECT balance_cents')) return [[{ balance_cents: balance }]];
if (sql.includes('SELECT * FROM h5_subscriptions WHERE id = ?')) {
return [[{ ...expiredSub, id: 'renewed-sub', status: 'active' }]];
}
return [{ affectedRows: 1 }];
},
async beginTransaction() {},
async commit() {},
async rollback() {},
release() {},
};
const pool = {
async query(sql) {
if (sql.includes('SELECT id FROM h5_subscriptions') && sql.includes('status = \'active\'')) return [[]];
if (sql.includes('FROM h5_subscriptions s') && sql.includes('ORDER BY s.expires_at DESC')) {
return [[expiredSub]];
}
return [[]];
},
async getConnection() { return conn; },
};
const svc = createSubscriptionService(pool, {
getPlanAsync: async (planType) => planType === 'lite'
? { priceCents: 990, periodTokens: 1000, periodImages: 0, overageRate: 1, periodDays: 30 }
: null,
});
const failed = await svc.processAutoRenewalsForUser(userId);
assert.equal(failed.ok, false);
assert.equal(failed.reason, 'insufficient_balance');
balance = 5000;
const renewed = await svc.processAutoRenewalsForUser(userId);
assert.equal(renewed.ok, true);
assert.equal(renewed.balanceCents, 4010);
});
});
});
+43 -10
View File
@@ -122,11 +122,10 @@ export function detachCurrentTurnImagesForTextProvider(message, canonicalImageUr
}
/**
* Remove image attachments from a persisted user message so later turns cannot reuse them.
* UI displayText / previewImageUrls are preserved for chat history rendering.
* Remove image attachments from a persisted message so later turns cannot reuse them.
*/
export function scrubUserMessageImageAttachments(message) {
if (!message || message.role !== 'user') return { message, changed: false };
function scrubPersistedImageAttachments(message) {
if (!message) return { message, changed: false };
const metadata =
message.metadata && typeof message.metadata === 'object' && !Array.isArray(message.metadata)
@@ -153,15 +152,13 @@ export function scrubUserMessageImageAttachments(message) {
return null;
}
if (item?.type !== 'text' || typeof item.text !== 'string') return item;
const nextText = stripAgentImageText(item.text);
const nextText = message.role === 'user' ? stripAgentImageText(item.text) : item.text;
if (nextText === item.text) return item;
contentChanged = true;
return nextText ? { ...item, text: nextText } : null;
}).filter(Boolean)
: message.content;
const displayText =
typeof metadata.displayText === 'string' ? metadata.displayText : null;
const changed = hadImageMetadata || contentChanged;
if (!changed) return { message, changed: false };
@@ -170,17 +167,54 @@ export function scrubUserMessageImageAttachments(message) {
...message,
content,
metadata,
...(displayText != null ? {} : {}),
},
changed: true,
};
}
/**
* Remove image attachments from a persisted user message so later turns cannot reuse them.
* UI displayText / previewImageUrls are preserved for chat history rendering.
*/
export function scrubUserMessageImageAttachments(message) {
if (!message || message.role !== 'user') return { message, changed: false };
return scrubPersistedImageAttachments(message);
}
export function messageContentHasImageUrl(content) {
if (!Array.isArray(content)) return false;
return content.some((item) => item?.type === 'image_url' && item?.image_url?.url);
}
function toolResultParts(item) {
const result = item?.toolResult ?? item?.tool_result ?? item?.toolResponse ?? item?.tool_response;
const value = result?.value ?? result;
if (Array.isArray(value)) return value;
if (Array.isArray(value?.content)) return value.content;
return [];
}
/**
* REGRESSION GUARD: vision-turn-read-image-isolation
* `read_image` answers with base64 image parts nested in the tool response.
* Those parts never appear as `image_url`, but once Goose persists them a
* text-only chat provider rejects the whole history with
* `unknown variant image_url, expected text`, so they poison the session too.
*/
export function messageContentHasToolImagePart(content) {
if (!Array.isArray(content)) return false;
return content.some((item) => {
const type = String(item?.type ?? '');
if (type !== 'toolResponse' && type !== 'tool_response') return false;
return toolResultParts(item).some((part) => String(part?.type ?? '') === 'image');
});
}
export function conversationHasToolImageContent(conversation) {
if (!Array.isArray(conversation)) return false;
return conversation.some((message) => messageContentHasToolImagePart(message?.content));
}
/**
* Any persisted image_url part will break DeepSeek / other text-only providers.
* User metadata.imageUrls alone is not enough — Goose may have expanded them into
@@ -203,9 +237,8 @@ export function scrubConversationHistoricalImageAttachments(conversation, active
let changed = false;
const nextConversation = conversation.map((message) => {
if (message?.role !== 'user') return message;
if (String(message?.id ?? '').trim() === activeId) return message;
const scrubbed = scrubUserMessageImageAttachments(message);
const scrubbed = scrubPersistedImageAttachments(message);
if (scrubbed.changed) changed = true;
return scrubbed.message;
});
+83
View File
@@ -3,6 +3,7 @@ import test from 'node:test';
import {
buildCurrentTurnImageScopeNote,
conversationHasImageUrlContent,
conversationHasToolImageContent,
dedupeImageUrlsByAssetKey,
extractCurrentTurnImageUrls,
scrubConversationHistoricalImageAttachments,
@@ -103,6 +104,30 @@ test('scrubConversationHistoricalImageAttachments keeps only active turn attachm
]);
});
test('scrubConversationHistoricalImageAttachments removes assistant image_url parts', () => {
const { conversation, changed } = scrubConversationHistoricalImageAttachments(
[
{
id: 'assistant-old',
role: 'assistant',
content: [
{ type: 'text', text: '已识别报告' },
{ type: 'image_url', image_url: { url: 'https://example.com/report.png' } },
],
},
{
id: 'user-new',
role: 'user',
content: [{ type: 'text', text: '解读报告,生成页面' }],
},
],
'user-new',
);
assert.equal(changed, true);
assert.equal(conversation[0].content.some((item) => item.type === 'image_url'), false);
});
test('buildCurrentTurnImageScopeNote states one independent topic per upload', () => {
const note = buildCurrentTurnImageScopeNote([
{
@@ -159,6 +184,64 @@ test('conversationHasImageUrlContent detects historical poison and ignores activ
);
});
test('conversationHasToolImageContent detects read_image base64 poison', () => {
const readImageTurn = [
{
id: 'assistant-read',
role: 'assistant',
content: [
{
type: 'toolRequest',
id: 'call_1',
toolCall: { status: 'success', value: { name: 'read_image', arguments: { source: 'a.jpg' } } },
},
],
},
{
id: 'user-read-result',
role: 'user',
content: [
{
type: 'toolResponse',
id: 'call_1',
toolResult: {
status: 'success',
value: {
content: [
{ type: 'text', text: 'Loaded image from a.jpg (202672 bytes, image/jpeg, 1280x1707).' },
{ type: 'image', data: '/9j/4AAQSkZJRg==', mimeType: 'image/jpeg' },
],
},
},
},
],
},
];
assert.equal(conversationHasToolImageContent(readImageTurn), true);
// image_url scanning alone cannot see this poison, which is why it needs its own check.
assert.equal(conversationHasImageUrlContent(readImageTurn), false);
assert.equal(
conversationHasToolImageContent([
{
id: 'user-text-tool',
role: 'user',
content: [
{
type: 'toolResponse',
id: 'call_2',
toolResult: { status: 'success', value: { content: [{ type: 'text', text: '[文件] a.jpg' }] } },
},
],
},
]),
false,
);
assert.equal(conversationHasToolImageContent([]), false);
assert.equal(conversationHasToolImageContent(null), false);
});
test('detachCurrentTurnImagesForTextProvider archives urls and keeps the VL note', () => {
const detached = detachCurrentTurnImagesForTextProvider(
{
+6
View File
@@ -583,6 +583,12 @@ export async function migrateSchema(pool) {
);
}
if (!(await columnExists(pool, 'h5_users', 'billing_formula'))) {
await pool.query(
`ALTER TABLE h5_users ADD COLUMN billing_formula ENUM('A','B') NOT NULL DEFAULT 'A' AFTER low_balance_gift_granted_at`,
);
}
if (!(await columnExists(pool, 'h5_user_sessions', 'goosed_node'))) {
await pool.query(
`ALTER TABLE h5_user_sessions ADD COLUMN goosed_node TINYINT UNSIGNED NOT NULL DEFAULT 0`,
+72
View File
@@ -3,6 +3,78 @@
本文件记录已经完成迁移、但仍可能因为 Git 拓扑或遗留 worktree 被误判为“尚未进入 `main`”的分支。
它是分支复用、合并、cherry-pick 和清理前的必查清单。
## `feature/scheduled-task-wechat-delivery-fix`
**状态:禁止再次引用。改动已提交并进入 `origin/main`,该分支保留仅用于只读追溯,不是待合并开发分支。**
审计日期:2026-08-17
分支 HEAD:`1ca06e58`
`origin/main` 对应提交:`1ca06e58`
### 原始用途
修复唐用户定时新闻任务微信链接被 guard 误杀、页面未落盘即推送、以及 reconcile 后未补发微信的问题。
### 验证摘要
- `node --test scheduled-task-executor.test.mjs scheduled-task-worker.test.mjs notification-dispatcher.test.mjs`:22 passed
- `node --test wechat-mp.test.mjs`:100 passed(含 verifiedHtmlUrls 用例)
- 103 快速发布绑定 `1ca06e58`
### 最终处置
- 保留本地分支名用于审计追溯。
- 不要从该分支继续开发、merge、cherry-pick 或构建 runtime/artifact。
## `feature/production-inspection-fixes`
**状态:禁止再次引用。改动已提交并进入 `origin/main`,该分支保留仅用于只读追溯,不是待合并开发分支。**
审计日期:2026-08-16
分支 HEAD:`843e4d10`
`origin/main` 对应提交:`843e4d10`
### 原始用途
103 生产巡检后续修复:定时任务去重、orphan 交付契约 reconcile(HTML 缺失 mark failed)、MindSpace remote 超时 30s、103 数据/Goosed harness 修复脚本。
### 验证摘要
- `node --test scheduled-task-executor.test.mjs scheduled-task-service.test.mjs`:29 passed
- 103 快速发布 `memind-portal-runtime-20260816-085224-843e4d10`,Portal 8081 健康
- 103 数据修复:取消唐用户 4 个重复/废弃定时任务,保留 5:30 新闻 + 8:00 天气各 1 个
- Goosed `run-goosed-native.sh` PATH 已修补,18007–18014 plist PATH 已更新
- memind_adm LaunchAgent 脚本已部署,8085/5174 健康
### 最终处置
- 保留本地分支名用于审计追溯。
- 不要从该分支继续开发、merge、cherry-pick 或构建 runtime/artifact。
## `feature/scheduled-task-page-delivery-fix`
**状态:禁止再次引用。改动已提交并进入 `origin/main`,该分支保留仅用于只读追溯,不是待合并开发分支。**
审计日期:2026-08-16
分支 HEAD:`7c0ed58a`
`origin/main` 对应提交:`7c0ed58a`
### 原始用途
修复唐用户每日新闻定时任务推送链接后页面 409(交付契约卡在 `preparing`)的问题:定时任务路径补齐契约 prepare/release、有限重试、推送前门禁,以及 worker 扫描 reconcile。
### 验证摘要
- `node --test scheduled-task-executor.test.mjs scheduled-task-worker.test.mjs`:14 passed
- `node scripts/run-memind-tests.mjs --mode changed`:通过
- 发包前测试 `db/capabilities/llm-providers/wechat-mp`:171 passed
- 103 快速发布 `memind-portal-runtime-20260816-082806-7c0ed58a`,Portal 8081 健康,新闻页 200
### 最终处置
- 保留本地分支名用于审计追溯。
- 不要从该分支继续开发、merge、cherry-pick 或构建 runtime/artifact。
## `feature/gate-data01-delivery-keywords`
**状态:禁止再次引用。改动已提交并进入 `origin/main`,该分支保留仅用于只读追溯,不是待合并开发分支。**
+1
View File
@@ -15,6 +15,7 @@
| [memory-v2-candidate-and-lifecycle.md](./memory-v2-candidate-and-lifecycle.md) | 候选记忆表幂等初始化、Portal fail-open、生命周期 off/canary/active 作用域 |
| [episodic-history-recall.md](./episodic-history-recall.md) | 历史会话召回、用户隔离、旧快照回退、提示注入与 off/canary/active 灰度 |
| [mindspace-seo-geo.md](./mindspace-seo-geo.md) | 公开页 SEO/GEO 注入、私有页 noindex、sitemap/llms.txt 与百度推送开关 |
| [vision-turn-read-image-isolation.md](./vision-turn-read-image-isolation.md) | 图片轮次禁用 `read_image`、视觉提示硬约束、`read_image` 工具图片污染检测与会话轮换 |
## 自动化
@@ -0,0 +1,79 @@
# 图片轮次读图工具隔离守卫
## 已知故障
2026-08-22 服务号用户唐连发 4 张图后要求「把那几张图片做成主题页面」,连续三次没有拿到页面。
链路是这样断的:
1. `attachRecentMediaForFollowup` 正确把 4 张图挂到了这一轮,Qwen VL 也成功产出了图片描述,
并注入进 Agent 提示。
2. Agent 拿到描述后,仍然逐张调用 `read_image` 去「确认图片内容」。
3. `read_image` 的工具结果不是 `image_url`,而是嵌在 `toolResponse.toolResult.value.content[]`
里的 base64 `image` 块,4 张图累计 1.5MB 以上。Goose 会把它们持久化进会话历史。
4. 下一次请求把整段历史发给纯文本聊天模型(DeepSeek),上游直接返回
`unknown variant image_url, expected text`,Agent 还没走到 `write_file` 就中断。
5. 页面交付是 fail-closed 的:没有新落盘的 HTML,就不会发占位链接,用户只看到
「这次页面没有按服务号页面技能真正生成成功」。
原有的两道防线都没拦住:
- `conversationHasImageUrlContent` 只扫 `image_url`,看不见 `read_image` 留下的工具图片块,
所以微信侧的会话轮换不会触发,同一个被污染的会话被反复复用。
- `agent-run-gateway` 的 `SESSION_VISUAL_CONTEXT_UNSUPPORTED` 视觉降级是**事后**补救,
只覆盖 H5 Agent Run,服务号回复路径没有对应保护。
## 必须保留的行为
1. 本轮消息带图且已配置图片模型(`llmProviderService.hasVisionKey()`)时,
`prepareSessionReplyBody` 必须以 `disableImageReading` 下发会话策略,
使这一轮的 `developer` 扩展不含 `read_image`。视觉理解由图片模型独占,
聊天模型不承担读图。
2. 不带图的轮次必须保留 `read_image`。禁用是按轮次生效的,不能把会话永久降级。
3. `buildVisionPayload` 注入的提示必须明确写出「本轮不会再提供读图工具 + 禁止调用
`read_image`」,并说明强行读图会让本轮及后续请求全部失败。
4. 图片模型没能产出描述时,提示必须改口为「不要臆造画面细节」,不能继续声称
「依据上面的描述写文案」。此时仍然禁用读图 —— 纯文本模型看不到图,
放开 `read_image` 只会让整轮崩掉,而图片嵌入路径依然可用。
5. `conversationHasToolImageContent` 必须能识别 `toolResponse` / `tool_response` 里
`toolResult.value.content[].type === 'image'` 的工具图片块,
并覆盖 `toolResult` / `tool_result` / 直接数组等结构变体。
6. `rotateWechatSessionIfImagePolluted` 必须同时检查 `image_url` 污染和工具图片污染,
任一命中都要在回复前换掉会话。历史遗留的被污染会话靠这条自愈。
7. `agent-run-gateway` 的事后视觉降级(`SESSION_VISUAL_CONTEXT_UNSUPPORTED` →
新会话 + `disableImageReading: true`)保留为兜底,不得因为新增事前预防而删除。
## 改动前必跑
```bash
node --test tkmind-proxy.test.mjs chat-image-turn-scope.test.mjs wechat-mp.test.mjs agent-run-gateway.test.mjs
npm run verify:h5-session-patches
```
端到端(需本地 Portal 8081 + goosed):
```bash
node scripts/run-scenario-test.mjs --scenario image-theme-page
```
该场景上传 4 张几何图、要求做成主题页面,断言页面生成、公网 200、4 张图全部嵌入,
并禁止回复里出现 `unknown variant`。2026-08-22 首次通过时会话内 `read_image` 调用为 0、
持久化工具图片块为 0,工具序列是 `load_skill → write_file → read_file`。
## 相关代码与用例
| 位置 | 作用 |
|------|------|
| `tkmind-proxy.mjs` · `prepareSessionReplyBody` | 事前禁用本轮 `read_image` |
| `tkmind-proxy.mjs` · `buildVisionPayload` | 注入禁止读图的硬性提示 |
| `chat-image-turn-scope.mjs` · `conversationHasToolImageContent` | 识别 `read_image` 工具图片污染 |
| `wechat-mp.mjs` · `rotateWechatSessionIfImagePolluted` | 回复前轮换被污染会话 |
| `capabilities.mjs` · `withoutSessionImageRead` | 从会话策略里摘掉 `read_image` |
| 用例 | 覆盖 |
|------|------|
| `tkmind-proxy.test.mjs` · `image turns drop read_image so vision results cannot poison the text provider` | 带图轮次下发的 `developer` 工具不含 `read_image` |
| `tkmind-proxy.test.mjs` · `text-only turns keep read_image available` | 纯文本轮次不降级 |
| `tkmind-proxy.test.mjs` · `submitSessionReplyForUser applies the shared Qwen vision preprocessing path` | 提示中包含禁止读图约束 |
| `chat-image-turn-scope.test.mjs` · `conversationHasToolImageContent detects read_image base64 poison` | 工具图片块识别,且 `image_url` 扫描确实看不见它 |
| `wechat-mp.test.mjs` · `wechat mp rotates a session poisoned by read_image tool results` | 服务号在工具图片污染时换会话 |
+30
View File
@@ -1,4 +1,6 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
export function normalizeDeliveryRelativePath(value) {
const path = String(value ?? '').replace(/\\/g, '/').replace(/^\/+/, '');
@@ -47,17 +49,45 @@ export async function markPageDeliveryContractReady({ pool, userId, relativePath
return Number(result?.affectedRows ?? 0) > 0;
}
export async function markPageDeliveryContractFailed({
pool,
userId,
relativePath,
failureReason = 'delivery_material_missing',
} = {}) {
const workspaceRelativePath = normalizeDeliveryRelativePath(relativePath);
if (!pool || !userId || !workspaceRelativePath) return false;
const now = Date.now();
const [result] = await pool.query(
`UPDATE h5_page_delivery_contracts
SET status = 'failed', failure_reason = ?, updated_at = ?
WHERE user_id = ? AND workspace_relative_path = ? AND status = 'preparing'`,
[String(failureReason ?? 'delivery_material_missing'), now, userId, workspaceRelativePath],
);
return Number(result?.affectedRows ?? 0) > 0;
}
export function scheduledTaskPublicHtmlExists(publishDir, relativePath) {
const workspaceRelativePath = normalizeDeliveryRelativePath(relativePath);
if (!publishDir || !workspaceRelativePath) return false;
return fs.existsSync(path.join(publishDir, workspaceRelativePath));
}
export async function releaseMaterializedPageDeliveryContracts({
pool,
userId,
relativePaths = [],
allowPgRequired = false,
publishDir = null,
} = {}) {
if (!pool || !userId) return [];
const released = [];
for (const rawPath of relativePaths) {
const workspaceRelativePath = normalizeDeliveryRelativePath(rawPath);
if (!workspaceRelativePath) continue;
if (publishDir && !scheduledTaskPublicHtmlExists(publishDir, workspaceRelativePath)) {
continue;
}
const contract = await getPageDeliveryContract({
pool,
userId,
+1 -1
View File
@@ -86,7 +86,7 @@ export function resolveMindSpaceServerRuntimeOptions(h5Root, env = process.env)
authToken: String(env.MINDSPACE_REMOTE_AUTH_TOKEN ?? '').trim(),
operationBasePath: String(env.MINDSPACE_REMOTE_OPERATION_BASE_PATH ?? '/mindspace/v1/adapter')
.trim(),
timeoutMs: Math.max(1000, Number(env.MINDSPACE_REMOTE_TIMEOUT_MS ?? 15_000)),
timeoutMs: Math.max(1000, Number(env.MINDSPACE_REMOTE_TIMEOUT_MS ?? 30_000)),
},
};
}
+13 -5
View File
@@ -1,8 +1,15 @@
function resolveWechatDispatchSent(result) {
if (result && typeof result === 'object') {
return result.sent !== false && !result.deferred && !result.skipped;
}
return result !== false;
}
export function createNotificationDispatcher({ sendWechatTextToUser, logger = console } = {}) {
const sendWechat = async (userId, text) => {
const sendWechat = async (userId, text, options = {}) => {
if (typeof sendWechatTextToUser !== 'function') return false;
await sendWechatTextToUser(userId, text);
return true;
const result = await sendWechatTextToUser(userId, text, options);
return resolveWechatDispatchSent(result);
};
return {
@@ -16,13 +23,14 @@ export function createNotificationDispatcher({ sendWechatTextToUser, logger = co
});
return sent;
},
async sendScheduleNotification({ userId, text }) {
const sent = await sendWechat(userId, text);
async sendScheduleNotification({ userId, text, verifiedHtmlUrls = [] }) {
const sent = await sendWechat(userId, text, { verifiedHtmlUrls });
logger.info?.('Notification dispatch:', {
type: 'schedule_notification',
userId,
dedupeKey: null,
sent,
verifiedHtmlCount: verifiedHtmlUrls.length,
});
return sent;
},
+20
View File
@@ -73,11 +73,30 @@ test('notification dispatcher forwards schedule notification text unchanged', as
userId: 'user-2',
dedupeKey: null,
sent: true,
verifiedHtmlCount: 0,
},
},
]);
});
test('notification dispatcher returns false when wechat sender defers delivery', async () => {
const dispatcher = createNotificationDispatcher({
async sendWechatTextToUser() {
return { sent: false, deferred: true, errcode: 45015 };
},
logger: { info() {} },
});
assert.equal(
await dispatcher.sendScheduleNotification({
userId: 'user-4',
text: '定时任务完成',
verifiedHtmlUrls: ['https://m.tkmind.cn/MindSpace/user-4/public/news.html'],
}),
false,
);
});
test('notification dispatcher returns false when wechat sender is unavailable', async () => {
const logs = [];
const dispatcher = createNotificationDispatcher({
@@ -120,6 +139,7 @@ test('notification dispatcher returns false when wechat sender is unavailable',
userId: 'user-3',
dedupeKey: null,
sent: false,
verifiedHtmlCount: 0,
},
},
]);
+9 -15
View File
@@ -129,22 +129,16 @@ test('production release rejects --skip-tests before repository or network prefl
assert.doesNotMatch(`${result.stdout}\n${result.stderr}`, /103 只读预检/);
});
test('fast production release allows --skip-tests but still requires check-release-ready', () => {
const result = spawnSync(
'bash',
[
path.join(ROOT, 'scripts', 'release-portal-runtime-prod.sh'),
'--mode',
'fast',
'--skip-tests',
'--skip-build',
'--dry-run',
],
{ cwd: ROOT, encoding: 'utf8' },
test('fast production release allows --skip-tests but still requires check-release-ready', async () => {
const source = await fs.readFile(
path.join(ROOT, 'scripts', 'release-portal-runtime-prod.sh'),
'utf8',
);
assert.notEqual(result.status, 0);
assert.doesNotMatch(result.stderr, /标准发布禁止 --skip-tests/);
assert.match(`${result.stdout}\n${result.stderr}`, /快速发布:跳过额外本地测试/);
assert.match(source, /ALLOW_MAIN_RELEASE=1 bash "\$\{ROOT\}\/scripts\/check-release-ready\.sh"/);
assert.match(source, /快速发布:跳过额外本地测试/);
const skipTestsBlockEnd = source.indexOf('fi\n\nif [[ "${DRY_RUN}" -eq 0 ]]; then');
const checkReleaseIdx = source.indexOf('check-release-ready.sh');
assert.ok(skipTestsBlockEnd >= 0 && checkReleaseIdx > skipTestsBlockEnd);
});
test('fast release wrapper delegates to runtime prod script with --mode fast', async () => {
+45
View File
@@ -0,0 +1,45 @@
{
"id": "image-theme-page",
"name": "多图主题页面(视觉链路回归)",
"description": "上传 4 张图 → 要求做成主题页面 → 验证页面生成、公网可访问、4 张图都嵌进页面;复现唐用户 read_image 污染文本模型会话导致页面不生成的故障",
"account": {
"username": "john2",
"password": "888888"
},
"steps": [
{
"action": "login",
"label": "登录 john2 账户"
},
{
"action": "upload_images",
"label": "上传 4 张图片",
"categoryCode": "oa",
"images": [
{ "filename": "theme-1.jpg", "background": "#1e3a8a", "shape": "circle", "accent": "#fbbf24" },
{ "filename": "theme-2.jpg", "background": "#7f1d1d", "shape": "square", "accent": "#fca5a5" },
{ "filename": "theme-3.jpg", "background": "#064e3b", "shape": "triangle", "accent": "#6ee7b7" },
{ "filename": "theme-4.jpg", "background": "#3b0764", "shape": "diamond", "accent": "#e9d5ff" }
]
},
{
"action": "chat",
"label": "要求把 4 张图做成主题页面",
"message": "把我刚才发的这 4 张图片做成一个主题页面,每张图配一段文字",
"useUploadedAssets": true,
"expect": {
"assistantMinChars": 20,
"timeoutMs": 420000,
"page": {
"requirePublicLink": true,
"requireHttp200": true,
"requireUploadedImages": true
},
"forbidReplyPatterns": [
"unknown variant",
"没有按服务号页面技能真正生成成功"
]
}
}
]
}
+410 -31
View File
@@ -1,7 +1,14 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { buildChatSkillPrompt, SCHEDULED_TASK_AUTOMATION_SKILL_NAME } from './chat-skills.mjs';
import { releaseMaterializedPageDeliveryContracts } from './mindspace-delivery-contract.mjs';
import {
markPageDeliveryContractFailed,
markPageDeliveryContractReady,
normalizeDeliveryRelativePath,
preparePageDeliveryContract,
releaseMaterializedPageDeliveryContracts,
} from './mindspace-delivery-contract.mjs';
import {
collectOwnPublicHtmlRelativePaths,
materializeMissingPublicHtmlWrites,
@@ -71,6 +78,23 @@ export function formatScheduledTaskDeliveryMessage(task, deliveryText) {
return `${header}\n\n${body}`.trim();
}
export function resolveScheduledTaskPublicBaseUrl(env = process.env) {
return String(env.H5_PUBLIC_BASE_URL ?? 'https://m.tkmind.cn').replace(/\/+$/, '');
}
export function buildScheduledTaskVerifiedHtmlUrls(userId, readyPaths = [], {
publicBaseUrl = resolveScheduledTaskPublicBaseUrl(),
} = {}) {
const normalizedUserId = String(userId ?? '').trim();
if (!normalizedUserId) return [];
return [...new Set(
(Array.isArray(readyPaths) ? readyPaths : [])
.map((relativePath) => normalizeDeliveryRelativePath(relativePath))
.filter(Boolean)
.map((relativePath) => `${publicBaseUrl}/MindSpace/${normalizedUserId}/${relativePath}`),
)];
}
const SCHEDULED_TASK_CLARIFICATION_PATTERNS = [
/需确认/u,
/请确认/u,
@@ -83,6 +107,50 @@ const SCHEDULED_TASK_CLARIFICATION_PATTERNS = [
/缺(?:少|失)/u,
];
const DEFAULT_SCHEDULED_TASK_DELIVERY_POLL_INTERVAL_MS = 5_000;
export function resolveScheduledTaskDeliveryPollIntervalMs(
env = process.env,
) {
const parsed = Number(
env.H5_SCHEDULED_TASK_DELIVERY_POLL_INTERVAL_MS
?? DEFAULT_SCHEDULED_TASK_DELIVERY_POLL_INTERVAL_MS,
);
return Number.isFinite(parsed) && parsed > 0
? parsed
: DEFAULT_SCHEDULED_TASK_DELIVERY_POLL_INTERVAL_MS;
}
/** @deprecated use resolveScheduledTaskDeliveryPollIntervalMs */
export function resolveScheduledTaskDeliveryRetryDelaysMs(env = process.env) {
const interval = resolveScheduledTaskDeliveryPollIntervalMs(env);
return [0, interval, interval, interval];
}
export function extractPublicHtmlPathsFromText(text) {
const paths = new Set();
const normalized = String(text ?? '');
for (const match of normalized.matchAll(
/public\/[^\s"'<>]+\.html/gi,
)) {
paths.add(match[0].replace(/\\/g, '/'));
}
for (const match of normalized.matchAll(
/\/MindSpace\/[^/\s"'<>]+\/(public\/[^\s"'<>]+\.html)/gi,
)) {
paths.add(match[1].replace(/\\/g, '/'));
}
return [...paths];
}
export function deliveryTextPromisesPublicHtml(text) {
const normalized = String(text ?? '').trim();
if (!normalized) return false;
if (extractPublicHtmlPathsFromText(normalized).length > 0) return true;
return /\/MindSpace\/[^/\s"'<>]+/i.test(normalized)
&& /\.html/i.test(normalized);
}
export function looksLikeScheduledTaskNonDelivery(text, { readyPaths = [] } = {}) {
if (Array.isArray(readyPaths) && readyPaths.length > 0) return false;
const normalized = String(text ?? '').trim();
@@ -90,6 +158,7 @@ export function looksLikeScheduledTaskNonDelivery(text, { readyPaths = [] } = {}
if (SCHEDULED_TASK_CLARIFICATION_PATTERNS.some((pattern) => pattern.test(normalized))) {
return true;
}
if (deliveryTextPromisesPublicHtml(normalized)) return true;
if (/https?:\/\//i.test(normalized)) return false;
if (/public\/[^\s]+\.html/i.test(normalized)) return false;
if (/页面链接/u.test(normalized)) return false;
@@ -100,18 +169,35 @@ export function looksLikeScheduledTaskNonDelivery(text, { readyPaths = [] } = {}
return normalized.length < 12;
}
export async function finalizeScheduledTaskPageDelivery({
pool,
async function refreshScheduledTaskMessages({
userId,
sessionId,
tkmindProxy,
sessionSnapshotService,
}) {
if (typeof tkmindProxy?.fetchSessionConversationForUser === 'function') {
return tkmindProxy
.fetchSessionConversationForUser(userId, sessionId)
.catch(() => []);
}
if (typeof sessionSnapshotService?.get === 'function') {
const snapshot = await sessionSnapshotService.get(sessionId).catch(() => null);
return snapshot?.messages ?? snapshot?.conversation?.messages ?? [];
}
return [];
}
function collectScheduledTaskPageRelativePaths({
messages,
publishDir,
currentUser = null,
logger = console,
} = {}) {
if (!pool || !userId || !publishDir) return [];
const materialized = materializeMissingPublicHtmlWrites({ messages, publishDir });
currentUser,
userId,
deliveryText = '',
}) {
const materialized = materializeMissingPublicHtmlWrites({
messages,
publishDir,
});
const relativePaths = new Set(
collectOwnPublicHtmlRelativePaths({
messages,
@@ -121,6 +207,35 @@ export async function finalizeScheduledTaskPageDelivery({
skipped: materialized.skipped,
}),
);
for (const relativePath of extractPublicHtmlPathsFromText(deliveryText)) {
relativePaths.add(relativePath);
}
return {
materialized,
relativePaths: [...relativePaths],
};
}
export async function finalizeScheduledTaskPageDelivery({
pool,
userId,
sessionId,
messages,
publishDir,
currentUser = null,
deliveryText = '',
logger = console,
} = {}) {
if (!pool || !userId || !publishDir) return [];
const { relativePaths } = collectScheduledTaskPageRelativePaths({
messages,
publishDir,
currentUser,
userId,
deliveryText,
});
const pathsToRelease = new Set(relativePaths);
if (sessionId) {
const [rows] = await pool.query(
@@ -130,15 +245,33 @@ export async function finalizeScheduledTaskPageDelivery({
[userId],
);
for (const row of rows ?? []) {
if (row?.workspace_relative_path) relativePaths.add(row.workspace_relative_path);
if (row?.workspace_relative_path) {
pathsToRelease.add(row.workspace_relative_path);
}
}
}
for (const relativePath of pathsToRelease) {
await preparePageDeliveryContract({
pool,
userId,
requestId: sessionId,
relativePath,
pgRequired: false,
}).catch((error) => {
logger.warn?.(
`[ScheduledTask] prepare delivery contract failed for ${relativePath}:`,
error,
);
});
}
const readyPaths = await releaseMaterializedPageDeliveryContracts({
pool,
userId,
relativePaths: [...relativePaths],
relativePaths: [...pathsToRelease],
allowPgRequired: true,
publishDir,
}).catch((error) => {
logger.warn?.('[ScheduledTask] release delivery contracts failed:', error);
return [];
@@ -153,6 +286,246 @@ export async function finalizeScheduledTaskPageDelivery({
return readyPaths;
}
export async function awaitScheduledTaskPageDelivery({
pool,
userId,
sessionId,
messages,
publishDir,
deliveryText = '',
task = null,
tkmindProxy = null,
sessionSnapshotService = null,
timeoutMs = 15 * 60 * 1000,
pollIntervalMs = resolveScheduledTaskDeliveryPollIntervalMs(),
sleepFn = (delayMs) => new Promise((resolve) => {
setTimeout(resolve, delayMs);
}),
logger = console,
} = {}) {
let currentMessages = Array.isArray(messages) ? messages : [];
let currentDeliveryText = String(deliveryText ?? '');
let readyPaths = [];
const deadline = Date.now() + Math.max(Number(pollIntervalMs) || 0, Number(timeoutMs) || 0);
let attempt = 0;
while (true) {
attempt += 1;
readyPaths = await finalizeScheduledTaskPageDelivery({
pool,
userId,
sessionId,
messages: currentMessages,
publishDir,
deliveryText: currentDeliveryText,
logger,
}).catch((error) => {
logger.warn?.('[ScheduledTask] finalize page delivery failed:', error);
return [];
});
const promisesHtml = deliveryTextPromisesPublicHtml(currentDeliveryText)
|| collectScheduledTaskPageRelativePaths({
messages: currentMessages,
publishDir,
userId,
deliveryText: currentDeliveryText,
}).relativePaths.length > 0;
if (!promisesHtml || readyPaths.length > 0 || Date.now() >= deadline) {
if (promisesHtml && readyPaths.length === 0 && Date.now() >= deadline) {
logger.warn?.('[ScheduledTask] page delivery timed out while preparing', {
userId,
sessionId,
attempt,
timeoutMs,
});
}
break;
}
logger.warn?.('[ScheduledTask] page delivery still preparing', {
userId,
sessionId,
attempt,
nextPollMs: pollIntervalMs,
});
await sleepFn(pollIntervalMs);
currentMessages = await refreshScheduledTaskMessages({
userId,
sessionId,
tkmindProxy,
sessionSnapshotService,
});
if (task) {
currentDeliveryText = extractScheduledTaskDeliveryText(currentMessages, task);
}
}
return {
messages: currentMessages,
readyPaths,
};
}
export async function reconcileStuckStaticPageDeliveryContracts({
pool,
h5Root,
limit = 20,
logger = console,
} = {}) {
if (!pool || !h5Root) return [];
const [rows] = await pool.query(
`SELECT user_id, workspace_relative_path
FROM h5_page_delivery_contracts
WHERE status = 'preparing' AND data_mode = 'static'
ORDER BY updated_at ASC
LIMIT ?`,
[Math.max(1, Number(limit) || 20)],
);
const released = [];
for (const row of rows ?? []) {
const relativePath = normalizeDeliveryRelativePath(
row.workspace_relative_path,
);
if (!relativePath) continue;
const filePath = path.join(
h5Root,
'MindSpace',
row.user_id,
relativePath,
);
if (!fs.existsSync(filePath)) {
if (
await markPageDeliveryContractFailed({
pool,
userId: row.user_id,
relativePath,
failureReason: 'materialized_html_missing',
})
) {
logger.info?.('[ScheduledTask] failed orphan static delivery contract', {
userId: row.user_id,
relativePath,
});
}
continue;
}
if (
await markPageDeliveryContractReady({
pool,
userId: row.user_id,
relativePath,
})
) {
released.push({ userId: row.user_id, relativePath });
logger.info?.('[ScheduledTask] reconciled static delivery contract', {
userId: row.user_id,
relativePath,
});
}
}
return released;
}
export async function resendScheduledTaskWechatForReadyPage({
pool,
userId,
relativePath,
notificationDispatcher = null,
publicBaseUrl = resolveScheduledTaskPublicBaseUrl(),
lookbackMs = 24 * 60 * 60 * 1000,
now = Date.now(),
logger = console,
} = {}) {
const normalizedPath = normalizeDeliveryRelativePath(relativePath);
const normalizedUserId = String(userId ?? '').trim();
if (
!pool
|| !normalizedUserId
|| !normalizedPath
|| typeof notificationDispatcher?.sendScheduleNotification !== 'function'
) {
return false;
}
const [rows] = await pool.query(
`SELECT id, title, notify_channel, last_result_json, last_run_at
FROM h5_scheduled_tasks
WHERE user_id = ? AND last_run_at IS NOT NULL AND last_run_at >= ?
ORDER BY last_run_at DESC
LIMIT 20`,
[normalizedUserId, now - Math.max(lookbackMs, 60_000)],
);
const verifiedUrl = `${publicBaseUrl}/MindSpace/${normalizedUserId}/${normalizedPath}`;
for (const row of rows ?? []) {
const channel = row.notify_channel ?? 'both';
if (channel !== 'wechat' && channel !== 'both') continue;
const lastResult = row.last_result_json && typeof row.last_result_json === 'object'
? row.last_result_json
: null;
const deliveryText = String(lastResult?.deliveryText ?? '');
if (
!deliveryText.includes(normalizedPath)
&& !deliveryText.includes(verifiedUrl)
) {
continue;
}
const priorDelivery = lastResult?.wechatDelivery;
if (
priorDelivery?.sentAt
&& Array.isArray(priorDelivery.relativePaths)
&& priorDelivery.relativePaths.includes(normalizedPath)
) {
continue;
}
const text = formatScheduledTaskDeliveryMessage(
{ title: row.title },
deliveryText,
);
const verifiedHtmlUrls = buildScheduledTaskVerifiedHtmlUrls(
normalizedUserId,
[normalizedPath],
{ publicBaseUrl },
);
const sent = await notificationDispatcher.sendScheduleNotification({
userId: normalizedUserId,
text,
verifiedHtmlUrls,
}).catch((error) => {
logger.warn?.('[ScheduledTask] reconcile wechat resend failed:', error);
return false;
});
if (!sent) continue;
const nextResult = {
...(lastResult ?? {}),
wechatDelivery: {
sentAt: now,
relativePaths: [normalizedPath],
source: 'reconcile',
},
};
await pool.query(
`UPDATE h5_scheduled_tasks
SET last_result_json = ?, updated_at = ?
WHERE id = ? AND user_id = ?`,
[JSON.stringify(nextResult), now, row.id, normalizedUserId],
);
logger.info?.('[ScheduledTask] reconciled wechat delivery resent', {
userId: normalizedUserId,
taskId: row.id,
relativePath: normalizedPath,
});
return true;
}
return false;
}
export async function executeScheduledTask(task, {
userAuth,
tkmindProxy,
@@ -204,37 +577,43 @@ export async function executeScheduledTask(task, {
{ timeoutMs },
);
let messages = [];
if (typeof tkmindProxy.fetchSessionConversationForUser === 'function') {
messages = await tkmindProxy.fetchSessionConversationForUser(task.userId, sessionId).catch(() => []);
} else if (typeof sessionSnapshotService?.get === 'function') {
const snapshot = await sessionSnapshotService.get(sessionId).catch(() => null);
messages = snapshot?.messages ?? snapshot?.conversation?.messages ?? [];
}
let messages = await refreshScheduledTaskMessages({
userId: task.userId,
sessionId,
tkmindProxy,
sessionSnapshotService,
});
const publishDir = h5Root && task.userId
? path.join(h5Root, 'MindSpace', task.userId)
: null;
const readyPaths = await finalizeScheduledTaskPageDelivery({
pool,
userId: task.userId,
sessionId,
messages,
publishDir,
logger,
}).catch((error) => {
logger.warn?.('[ScheduledTask] finalize page delivery failed:', error);
return [];
});
let deliveryText = extractScheduledTaskDeliveryText(messages, task);
const deliveryResult = publishDir
? await awaitScheduledTaskPageDelivery({
pool,
userId: task.userId,
sessionId,
messages,
publishDir,
deliveryText,
task,
tkmindProxy,
sessionSnapshotService,
timeoutMs,
logger,
})
: { messages, readyPaths: [] };
messages = deliveryResult.messages;
const readyPaths = deliveryResult.readyPaths;
deliveryText = extractScheduledTaskDeliveryText(messages, task);
if (
readyPaths.length > 0
&& !/https?:\/\//i.test(deliveryText)
&& task.userId
) {
const links = readyPaths.map(
(relativePath) => `https://m.tkmind.cn/MindSpace/${task.userId}/${relativePath}`,
(relativePath) => `${resolveScheduledTaskPublicBaseUrl()}/MindSpace/${task.userId}/${relativePath}`,
);
deliveryText = `${deliveryText}\n\n页面链接:\n${links.join('\n')}`.trim();
}
+176
View File
@@ -1,10 +1,20 @@
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
buildScheduledTaskExecutionPrompt,
buildScheduledTaskVerifiedHtmlUrls,
deliveryTextPromisesPublicHtml,
extractPublicHtmlPathsFromText,
extractScheduledTaskDeliveryText,
finalizeScheduledTaskPageDelivery,
formatScheduledTaskDeliveryMessage,
looksLikeScheduledTaskNonDelivery,
reconcileStuckStaticPageDeliveryContracts,
resolveScheduledTaskDeliveryPollIntervalMs,
resolveScheduledTaskDeliveryRetryDelaysMs,
} from './scheduled-task-executor.mjs';
test('buildScheduledTaskExecutionPrompt includes task spec and automation marker', () => {
@@ -32,6 +42,12 @@ test('looksLikeScheduledTaskNonDelivery detects clarification replies', () => {
looksLikeScheduledTaskNonDelivery('页面已生成:https://example.com/news.html'),
false,
);
assert.equal(
looksLikeScheduledTaskNonDelivery(
'页面已生成:https://m.tkmind.cn/MindSpace/user-1/public/daily-news.html',
),
true,
);
assert.equal(
looksLikeScheduledTaskNonDelivery('好的', { readyPaths: ['public/news.html'] }),
false,
@@ -41,6 +57,166 @@ test('looksLikeScheduledTaskNonDelivery detects clarification replies', () => {
assert.equal(looksLikeScheduledTaskNonDelivery('好的'), true);
});
test('extractPublicHtmlPathsFromText parses MindSpace public links', () => {
assert.deepEqual(
extractPublicHtmlPathsFromText(
'链接:https://m.tkmind.cn/MindSpace/u/public/daily-news-0816.html',
),
['public/daily-news-0816.html'],
);
});
test('deliveryTextPromisesPublicHtml detects page delivery replies', () => {
assert.equal(
deliveryTextPromisesPublicHtml('今日摘要:天气不错'),
false,
);
assert.equal(
deliveryTextPromisesPublicHtml('public/daily-news.html 已生成'),
true,
);
});
test('resolveScheduledTaskDeliveryPollIntervalMs reads env override', () => {
assert.equal(
resolveScheduledTaskDeliveryPollIntervalMs({
H5_SCHEDULED_TASK_DELIVERY_POLL_INTERVAL_MS: '7500',
}),
7500,
);
});
test('resolveScheduledTaskDeliveryRetryDelaysMs keeps backward-compatible shape', () => {
assert.deepEqual(
resolveScheduledTaskDeliveryRetryDelaysMs({
H5_SCHEDULED_TASK_DELIVERY_POLL_INTERVAL_MS: '100',
}),
[0, 100, 100, 100],
);
});
test('buildScheduledTaskVerifiedHtmlUrls maps ready paths to public urls', () => {
assert.deepEqual(
buildScheduledTaskVerifiedHtmlUrls('user-1', ['public/news.html'], {
publicBaseUrl: 'https://m.tkmind.cn',
}),
['https://m.tkmind.cn/MindSpace/user-1/public/news.html'],
);
});
test('finalizeScheduledTaskPageDelivery prepares and releases static contracts', async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'scheduled-task-finalize-'));
const publishDir = path.join(dir, 'MindSpace', 'user-1');
const relativePath = 'public/news.html';
await fs.mkdir(path.dirname(path.join(publishDir, relativePath)), { recursive: true });
await fs.writeFile(path.join(publishDir, relativePath), '<html></html>', 'utf8');
const calls = [];
const pool = {
async query(sql, params) {
calls.push({ sql, params });
if (sql.includes('FROM h5_page_delivery_contracts')) {
return [[{ workspace_relative_path: relativePath }]];
}
if (sql.includes('SELECT id, data_mode, status')) {
return [[{ id: 'c1', data_mode: 'static', status: 'preparing' }]];
}
if (sql.includes("SET status = 'ready'")) {
return [{ affectedRows: 1 }];
}
return [[]];
},
};
const readyPaths = await finalizeScheduledTaskPageDelivery({
pool,
userId: 'user-1',
sessionId: 'session-1',
messages: [],
publishDir,
deliveryText: 'public/news.html 已生成',
logger: { warn() {}, info() {} },
});
assert.deepEqual(readyPaths, [relativePath]);
assert.ok(calls.some((call) => call.sql.includes('INSERT INTO h5_page_delivery_contracts')));
});
test('finalizeScheduledTaskPageDelivery skips release when html file is missing', async () => {
const publishDir = path.join(await fs.mkdtemp(path.join(os.tmpdir(), 'scheduled-task-finalize-missing-')), 'MindSpace', 'user-1');
await fs.mkdir(publishDir, { recursive: true });
const pool = {
async query(sql) {
if (sql.includes('FROM h5_page_delivery_contracts')) {
return [[{ workspace_relative_path: 'public/news.html' }]];
}
if (sql.includes('SELECT id, data_mode, status')) {
return [[{ id: 'c1', data_mode: 'static', status: 'preparing' }]];
}
if (sql.includes("SET status = 'ready'")) {
throw new Error('should not mark ready without html file');
}
return [[]];
},
};
const readyPaths = await finalizeScheduledTaskPageDelivery({
pool,
userId: 'user-1',
sessionId: 'session-1',
messages: [],
publishDir,
deliveryText: 'public/news.html 已生成',
logger: { warn() {}, info() {} },
});
assert.deepEqual(readyPaths, []);
});
test('reconcileStuckStaticPageDeliveryContracts releases materialized static pages', async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'scheduled-task-'));
const userId = 'user-1';
const relativePath = 'public/news.html';
const filePath = path.join(dir, 'MindSpace', userId, relativePath);
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, '<html></html>', 'utf8');
const pool = {
async query(sql) {
if (sql.includes('FROM h5_page_delivery_contracts')) {
return [[{ user_id: userId, workspace_relative_path: relativePath }]];
}
if (sql.includes("SET status = 'ready'")) {
return [{ affectedRows: 1 }];
}
return [[]];
},
};
const released = await reconcileStuckStaticPageDeliveryContracts({
pool,
h5Root: dir,
logger: { info() {} },
});
assert.deepEqual(released, [{ userId, relativePath }]);
});
test('reconcileStuckStaticPageDeliveryContracts fails orphan contracts without html', async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'scheduled-task-orphan-'));
const userId = 'user-1';
const relativePath = 'public/missing.html';
const pool = {
async query(sql) {
if (sql.includes('FROM h5_page_delivery_contracts')) {
return [[{ user_id: userId, workspace_relative_path: relativePath }]];
}
if (sql.includes("SET status = 'failed'")) {
return [{ affectedRows: 1 }];
}
return [[]];
},
};
const released = await reconcileStuckStaticPageDeliveryContracts({
pool,
h5Root: dir,
logger: { info() {} },
});
assert.deepEqual(released, []);
});
test('extractScheduledTaskDeliveryText reads last assistant message', () => {
const text = extractScheduledTaskDeliveryText([
{ role: 'user', content: [{ type: 'text', text: 'hi' }] },
+24 -3
View File
@@ -137,6 +137,27 @@ export function createScheduledTaskService(pool, { defaultTimezone = DEFAULT_TIM
timezone: tz,
now: clock.now(),
});
const normalizedTitle = safeTitle || safeTaskSpec.slice(0, 80);
const normalizedHour = hour == null ? null : Number(hour);
const normalizedMinute = Number(minute ?? 0);
if (safeRecurrence !== 'once') {
const [duplicateRows] = await pool.query(
`SELECT *
FROM h5_scheduled_tasks
WHERE user_id = ?
AND status = 'active'
AND recurrence = ?
AND hour <=> ?
AND minute = ?
AND title = ?
ORDER BY created_at ASC
LIMIT 1`,
[userId, safeRecurrence, normalizedHour, normalizedMinute, normalizedTitle],
);
if (duplicateRows?.[0]) {
return rowToTask(duplicateRows[0]);
}
}
const id = crypto.randomUUID();
const ts = clock.now();
await pool.query(
@@ -148,11 +169,11 @@ export function createScheduledTaskService(pool, { defaultTimezone = DEFAULT_TIM
[
id,
userId,
safeTitle || safeTaskSpec.slice(0, 80),
normalizedTitle,
safeTaskSpec,
safeRecurrence,
hour == null ? null : Number(hour),
Number(minute ?? 0),
normalizedHour,
normalizedMinute,
weekday == null ? null : Number(weekday),
tz,
nextRunAt,
+56
View File
@@ -19,6 +19,9 @@ test('createTask inserts scheduled automation row', async () => {
const inserts = [];
const service = createScheduledTaskService({
async query(sql, params) {
if (sql.includes('FROM h5_scheduled_tasks') && sql.includes('status = \'active\'')) {
return [[]];
}
if (sql.includes('INSERT INTO h5_scheduled_tasks')) {
inserts.push(params);
return [{ affectedRows: 1 }];
@@ -69,6 +72,59 @@ test('createTask inserts scheduled automation row', async () => {
assert.equal(inserts.length, 1);
});
test('createTask returns existing active duplicate for same schedule', async () => {
let insertCount = 0;
const service = createScheduledTaskService({
async query(sql) {
if (sql.includes('FROM h5_scheduled_tasks') && sql.includes('status = \'active\'')) {
return [[{
id: 'task-existing',
user_id: 'user-1',
title: '每日天气预报播报(上海+武穴)',
task_spec: 'old spec',
recurrence: 'daily',
hour: 8,
minute: 0,
weekday: null,
timezone: 'Asia/Shanghai',
next_run_at: 1780000000000,
last_run_at: null,
notify_channel: 'both',
status: 'active',
attempts: 0,
last_error: null,
last_result_json: null,
source_channel: 'agent',
source_session_id: null,
source_message_id: null,
source_text: null,
created_at: 1780000000000,
updated_at: 1780000000000,
}]];
}
if (sql.includes('INSERT INTO h5_scheduled_tasks')) {
insertCount += 1;
return [{ affectedRows: 1 }];
}
return [[]];
},
}, {
clock: { now: () => 1780000000000 },
});
const task = await service.createTask({
userId: 'user-1',
title: '每日天气预报播报(上海+武穴)',
taskSpec: 'new spec',
recurrence: 'daily',
hour: 8,
minute: 0,
});
assert.equal(task.id, 'task-existing');
assert.equal(insertCount, 0);
});
test('cancelTask updates status to cancelled', async () => {
const service = createScheduledTaskService({
async query(sql, params) {
+58 -5
View File
@@ -1,7 +1,11 @@
import {
buildScheduledTaskVerifiedHtmlUrls,
deliveryTextPromisesPublicHtml,
executeScheduledTask,
formatScheduledTaskDeliveryMessage,
looksLikeScheduledTaskNonDelivery,
reconcileStuckStaticPageDeliveryContracts,
resendScheduledTaskWechatForReadyPage,
} from './scheduled-task-executor.mjs';
export function startScheduledTaskWorker({
@@ -35,9 +39,12 @@ export function startScheduledTaskWorker({
let stopped = false;
let running = false;
const deliverTaskResult = async (task, deliveryText) => {
const deliverTaskResult = async (task, deliveryText, { readyPaths = [] } = {}) => {
const text = formatScheduledTaskDeliveryMessage(task, deliveryText);
const notifyChannel = task.notifyChannel ?? 'both';
const verifiedHtmlUrls = buildScheduledTaskVerifiedHtmlUrls(task.userId, readyPaths);
const promisesHtml = deliveryTextPromisesPublicHtml(deliveryText);
let wechatDelivery = null;
if (scheduleService?.createUserNotification && (notifyChannel === 'web' || notifyChannel === 'both')) {
await scheduleService.createUserNotification({
userId: task.userId,
@@ -57,16 +64,57 @@ export function startScheduledTaskWorker({
(notifyChannel === 'wechat' || notifyChannel === 'both')
&& typeof sendScheduleNotification === 'function'
) {
await sendScheduleNotification({ userId: task.userId, text }).catch((err) => {
logger.warn?.('Scheduled task wechat notification failed:', err);
});
if (promisesHtml && verifiedHtmlUrls.length === 0) {
logger.warn?.('[ScheduledTask] skip wechat until public html is ready', {
taskId: task.id,
userId: task.userId,
});
} else {
const sent = await sendScheduleNotification({
userId: task.userId,
text,
verifiedHtmlUrls,
}).catch((err) => {
logger.warn?.('Scheduled task wechat notification failed:', err);
return false;
});
if (sent && verifiedHtmlUrls.length > 0) {
wechatDelivery = {
sentAt: Date.now(),
relativePaths: readyPaths.map((value) => String(value ?? '').trim()).filter(Boolean),
source: 'scheduled_task_worker',
};
}
}
}
return { wechatDelivery };
};
const runOnce = async () => {
if (running || stopped) return;
running = true;
try {
if (pool && h5Root) {
const reconciled = await reconcileStuckStaticPageDeliveryContracts({
pool,
h5Root,
logger,
}).catch((err) => {
logger.warn?.('Scheduled task delivery reconcile failed:', err);
return [];
});
for (const item of reconciled) {
await resendScheduledTaskWechatForReadyPage({
pool,
userId: item.userId,
relativePath: item.relativePath,
notificationDispatcher,
logger,
}).catch((err) => {
logger.warn?.('Scheduled task reconcile wechat resend failed:', err);
});
}
}
const dueTasks = await scheduledTaskService.listDueTasks({ limit: 10 });
for (const candidate of dueTasks) {
const task = await scheduledTaskService.lockTask(candidate.id);
@@ -89,10 +137,15 @@ export function startScheduledTaskWorker({
err.code = 'SCHEDULED_TASK_NON_DELIVERY';
throw err;
}
await deliverTaskResult(task, result.deliveryText);
const deliveryMeta = await deliverTaskResult(task, result.deliveryText, {
readyPaths: result.readyPaths,
});
await scheduledTaskService.markTaskSucceeded(task, {
result: {
deliveryText: result.deliveryText,
...(deliveryMeta.wechatDelivery
? { wechatDelivery: deliveryMeta.wechatDelivery }
: {}),
},
deliveryText: result.deliveryText,
sessionId: result.sessionId,
+55
View File
@@ -54,6 +54,7 @@ test('scheduled task worker executes due task and completes one-shot task', asyn
sessionId: 'session-1',
requestId: 'req-1',
deliveryText: '页面:https://example.com/news.html',
readyPaths: [],
}),
logger: { warn() {}, info() {} },
runOnStart: false,
@@ -224,6 +225,7 @@ test('scheduled task worker keeps success when wechat notification fails', async
sessionId: 'session-4',
requestId: 'req-4',
deliveryText: '页面已生成:https://example.com/news.html',
readyPaths: [],
}),
logger: { warn() {} },
runOnStart: false,
@@ -234,3 +236,56 @@ test('scheduled task worker keeps success when wechat notification fails', async
assert.deepEqual(calls, ['notify:web', 'success:task-4']);
});
test('scheduled task worker marks failure when page link is not deliverable yet', async () => {
const calls = [];
const task = {
id: 'task-5',
userId: 'user-5',
title: '每日新闻页',
recurrence: 'daily',
notifyChannel: 'web',
attempts: 1,
};
const worker = startScheduledTaskWorker({
intervalMs: 60_000,
userAuth: { id: 'user-auth' },
tkmindProxy: { id: 'proxy' },
scheduledTaskService: {
async listDueTasks() {
return [task];
},
async lockTask() {
return task;
},
async markTaskRunning(input) {
return input;
},
async markTaskSucceeded() {
calls.push('success');
},
async markTaskFailed(input, err) {
calls.push(`failed:${err.code}`);
return { ...input, status: 'failed', lastError: err.message };
},
},
scheduleService: {
async createUserNotification(input) {
calls.push(`notify:${input.notificationType}`);
},
},
executeTask: async () => ({
sessionId: 'session-5',
requestId: 'req-5',
deliveryText: '页面:https://m.tkmind.cn/MindSpace/user-5/public/daily-news.html',
readyPaths: [],
}),
logger: { warn() {} },
runOnStart: false,
});
await worker.runOnce();
worker.stop();
assert.deepEqual(calls, ['failed:SCHEDULED_TASK_NON_DELIVERY', 'notify:scheduled_task_failed']);
});
+1
View File
@@ -13,6 +13,7 @@ CREATE TABLE IF NOT EXISTS h5_users (
workspace_root VARCHAR(512) NOT NULL,
low_balance_gift_eligible TINYINT(1) NOT NULL DEFAULT 0,
low_balance_gift_granted_at BIGINT NULL,
billing_formula ENUM('A','B') NOT NULL DEFAULT 'A',
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
UNIQUE KEY uq_h5_users_slug (slug),
@@ -0,0 +1,94 @@
#!/usr/bin/env node
/**
* Standalone one-shot WeChat resend for a completed scheduled task page.
* Designed to run on 103 with only mysql2 from runtime node_modules.
*/
import process from 'node:process';
const TANG = process.argv.includes('--user-id')
? process.argv[process.argv.indexOf('--user-id') + 1]
: 'a70ff537-8908-486e-9b6c-042e07cc25db';
const RELATIVE = process.argv.includes('--relative-path')
? process.argv[process.argv.indexOf('--relative-path') + 1]
: 'public/daily-news-0817.html';
const TASK_ID = '21799936-0532-420c-b144-65ea3846cde1';
async function getStableAccessToken(config) {
const response = await fetch('https://api.weixin.qq.com/cgi-bin/stable_token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'client_credential',
appid: config.appId,
secret: config.appSecret,
}),
});
const payload = await response.json();
if (!payload?.access_token) {
throw new Error(`stable_token failed: ${JSON.stringify(payload)}`);
}
return payload.access_token;
}
async function main() {
const { default: mysql } = await import('mysql2/promise');
const pool = mysql.createPool({ uri: process.env.DATABASE_URL, connectionLimit: 2 });
const appId = process.env.H5_WECHAT_MP_APP_ID ?? process.env.WECHAT_MP_APP_ID ?? process.env.WX_APP_ID;
const appSecret = process.env.H5_WECHAT_MP_APP_SECRET ?? process.env.WECHAT_MP_APP_SECRET ?? process.env.WX_APP_SECRET;
if (!appId || !appSecret) throw new Error('缺少 WECHAT_MP_APP_ID / WECHAT_MP_APP_SECRET');
const [ident] = await pool.query(
'SELECT openid FROM h5_user_wechat_identities WHERE user_id = ? AND app_id = ? LIMIT 1',
[TANG, appId],
);
const openid = ident?.[0]?.openid;
if (!openid) throw new Error('用户未绑定微信');
const [tasks] = await pool.query(
'SELECT title, last_result_json FROM h5_scheduled_tasks WHERE id = ? LIMIT 1',
[TASK_ID],
);
const task = tasks?.[0];
const deliveryText = String(task?.last_result_json?.deliveryText ?? '').trim();
if (!deliveryText) throw new Error('缺少 deliveryText');
const url = `https://m.tkmind.cn/MindSpace/${TANG}/${RELATIVE.replace(/^\/+/, '')}`;
const text = `定时任务完成:${task.title}\n\n${deliveryText.includes(url) ? deliveryText : `${deliveryText}\n\n页面链接:\n${url}`}`.trim();
const accessToken = await getStableAccessToken({ appId, appSecret });
const response = await fetch(`https://api.weixin.qq.com/cgi-bin/message/custom/send?access_token=${encodeURIComponent(accessToken)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
touser: openid,
msgtype: 'text',
text: { content: text.slice(0, 2048) },
}),
});
const payload = await response.json();
if (Number(payload?.errcode ?? 0) !== 0) {
throw new Error(`微信发送失败 errcode=${payload?.errcode} errmsg=${payload?.errmsg}`);
}
const now = Date.now();
const nextResult = {
...(task.last_result_json ?? {}),
wechatDelivery: {
sentAt: now,
relativePaths: [RELATIVE],
source: 'manual_resend',
},
};
await pool.query(
'UPDATE h5_scheduled_tasks SET last_result_json = ?, updated_at = ? WHERE id = ?',
[JSON.stringify(nextResult), now, TASK_ID],
);
console.log(JSON.stringify({ ok: true, openid: `${openid.slice(0, 8)}...`, url }, null, 2));
await pool.end();
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
@@ -0,0 +1,88 @@
#!/usr/bin/env node
/**
* One-shot proactive WeChat resend for a completed scheduled task page delivery.
* Usage:
* node scripts/manual-resend-scheduled-task-wechat.mjs --user-id <uuid> --relative-path public/foo.html [--dry-run]
*/
import process from 'node:process';
import mysql from 'mysql2/promise';
import { loadH5Environment } from './load-env.mjs';
import {
buildScheduledTaskVerifiedHtmlUrls,
formatScheduledTaskDeliveryMessage,
resendScheduledTaskWechatForReadyPage,
} from '../scheduled-task-executor.mjs';
import { loadWechatMpConfig, createWechatMpService } from '../wechat-mp.mjs';
import { createNotificationDispatcher } from '../notification-dispatcher.mjs';
import { createUserAuth } from '../user-auth.mjs';
loadH5Environment(new URL('.', import.meta.url).pathname);
function parseArgs(argv) {
const args = {
userId: '',
relativePath: '',
dryRun: false,
};
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i];
if (token === '--user-id') args.userId = String(argv[++i] ?? '').trim();
else if (token === '--relative-path') args.relativePath = String(argv[++i] ?? '').trim();
else if (token === '--dry-run') args.dryRun = true;
}
return args;
}
async function main() {
const { userId, relativePath, dryRun } = parseArgs(process.argv.slice(2));
if (!userId || !relativePath) {
throw new Error('用法: node scripts/manual-resend-scheduled-task-wechat.mjs --user-id <uuid> --relative-path public/foo.html');
}
if (!process.env.DATABASE_URL) {
throw new Error('缺少 DATABASE_URL');
}
const pool = mysql.createPool({ uri: process.env.DATABASE_URL, connectionLimit: 2 });
const userAuth = createUserAuth(pool);
const wechatMpService = createWechatMpService({
config: loadWechatMpConfig(),
userAuth,
apiFetch: async () => {
throw new Error('manual resend does not proxy chat sessions');
},
mysqlPool: pool,
});
const notificationDispatcher = createNotificationDispatcher({
sendWechatTextToUser: wechatMpService?.enabled
? (targetUserId, text, options) => wechatMpService.sendTextToUser(targetUserId, text, options)
: null,
});
if (dryRun) {
const verifiedHtmlUrls = buildScheduledTaskVerifiedHtmlUrls(userId, [relativePath]);
console.log(JSON.stringify({ mode: 'dry-run', userId, relativePath, verifiedHtmlUrls }, null, 2));
await pool.end();
return;
}
const sent = await resendScheduledTaskWechatForReadyPage({
pool,
userId,
relativePath,
notificationDispatcher,
logger: console,
});
console.log(JSON.stringify({
mode: 'apply',
userId,
relativePath,
sent,
}, null, 2));
await pool.end();
if (!sent) process.exitCode = 1;
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="${GOOSED_NATIVE_ROOT:-/Users/john/Project/tkmind_go-native}"
LAUNCHD_DIR="${HOME}/Library/LaunchAgents"
GUI="gui/$(id -u)"
PATH_EXPORT='export PATH="/opt/homebrew/opt/node@24/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"'
log() { printf '[repair-goosed-harness-path] %s\n' "$*"; }
patch_run_script() {
local script="${ROOT}/run-goosed-native.sh"
[[ -f "${script}" ]] || { log "missing ${script}"; exit 1; }
if grep -q 'opt/homebrew/opt/node@24/bin' "${script}"; then
log "run-goosed-native.sh already exports node PATH"
return 0
fi
cp "${script}" "${script}.bak-inspection-$(date +%Y%m%d-%H%M%S)"
awk -v path_line="${PATH_EXPORT}" '
/^set -euo pipefail$/ {
print
print path_line
next
}
{ print }
' "${script}" > "${script}.tmp"
mv "${script}.tmp" "${script}"
chmod +x "${script}"
log "patched ${script}"
}
patch_launchd_plist() {
local port="$1"
local plist="${LAUNCHD_DIR}/cn.tkmind.goosed-native-${port}.plist"
[[ -f "${plist}" ]] || return 0
if plutil -extract EnvironmentVariables raw "${plist}" >/dev/null 2>&1; then
plutil -replace EnvironmentVariables.PATH -string "/opt/homebrew/opt/node@24/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" "${plist}"
else
plutil -insert EnvironmentVariables -dictionary "${plist}"
plutil -insert EnvironmentVariables.PATH -string "/opt/homebrew/opt/node@24/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" "${plist}"
fi
launchctl bootout "${GUI}/cn.tkmind.goosed-native-${port}" 2>/dev/null || true
launchctl bootstrap "${GUI}" "${plist}"
log "reloaded ${plist}"
}
patch_run_script
for port in 18006 18007 18008 18009 18010 18011 18012 18013 18014; do
patch_launchd_plist "${port}"
done
log "done"
@@ -0,0 +1,145 @@
#!/usr/bin/env node
/**
* Standalone 103 production repair (no heavy module imports).
* Dry-run by default; pass --apply to mutate database rows.
*/
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
const TANG_USER_ID = 'a70ff537-8908-486e-9b6c-042e07cc25db';
const TASKS_TO_CANCEL = [
'781a77e5-a787-4f37-b864-e21d7e918855',
'9df3f89a-3a06-434d-8b4c-17b1aeb8911f',
'7f953d5f-6fc3-4191-b52a-bffcef48e77f',
'9b537fe8-eea2-4e39-8bcb-e8f169986327',
];
function normalizeDeliveryRelativePath(value) {
const raw = String(value ?? '').trim().replace(/^\/+/, '');
if (!raw) return '';
return raw.startsWith('public/') ? raw : `public/${raw}`;
}
async function loadPool() {
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) throw new Error('缺少 DATABASE_URL');
const { default: mysql } = await import('mysql2/promise');
return mysql.createPool({ uri: databaseUrl, connectionLimit: 2 });
}
async function markContractFailed(pool, { userId, relativePath, reason }) {
const now = Date.now();
await pool.query(
`UPDATE h5_page_delivery_contracts
SET status = 'failed', updated_at = ?, last_error = ?
WHERE user_id = ? AND workspace_relative_path = ? AND status = 'preparing'`,
[now, reason, userId, relativePath],
);
}
async function markContractReady(pool, { userId, relativePath }) {
const now = Date.now();
await pool.query(
`UPDATE h5_page_delivery_contracts
SET status = 'ready', updated_at = ?, last_error = NULL
WHERE user_id = ? AND workspace_relative_path = ? AND status = 'preparing'`,
[now, userId, relativePath],
);
}
async function reconcileStuckContracts(pool, h5Root, apply, limit = 50) {
const [rows] = await pool.query(
`SELECT user_id, workspace_relative_path
FROM h5_page_delivery_contracts
WHERE status = 'preparing' AND data_mode = 'static'
ORDER BY updated_at ASC
LIMIT ?`,
[limit],
);
const summary = [];
for (const row of rows ?? []) {
const relativePath = normalizeDeliveryRelativePath(row.workspace_relative_path);
if (!relativePath) continue;
const filePath = path.join(h5Root, 'MindSpace', row.user_id, relativePath);
const exists = fs.existsSync(filePath);
const action = exists ? 'ready' : 'failed';
summary.push({ userId: row.user_id, relativePath, fileExists: exists, action: apply ? action : `would_${action}` });
if (apply) {
if (exists) {
await markContractReady(pool, { userId: row.user_id, relativePath });
} else {
await markContractFailed(pool, {
userId: row.user_id,
relativePath,
reason: 'reconcile: static html missing',
});
}
}
}
return summary;
}
async function main() {
const apply = process.argv.includes('--apply');
const h5Root = process.env.H5_ROOT || '/Users/john/Project/Memind';
const pool = await loadPool();
const now = Date.now();
const actions = [];
for (const taskId of TASKS_TO_CANCEL) {
const [rows] = await pool.query(
`SELECT id, user_id, title, status, hour, minute
FROM h5_scheduled_tasks WHERE id = ? LIMIT 1`,
[taskId],
);
const row = rows?.[0];
if (!row) {
actions.push({ taskId, action: 'skip', reason: 'not_found' });
continue;
}
if (row.status === 'cancelled') {
actions.push({ taskId, action: 'skip', reason: 'already_cancelled', title: row.title });
continue;
}
actions.push({
taskId,
action: apply ? 'cancel' : 'would_cancel',
title: row.title,
status: row.status,
schedule: `${row.hour ?? '?'}:${String(row.minute ?? 0).padStart(2, '0')}`,
});
if (apply) {
await pool.query(
`UPDATE h5_scheduled_tasks
SET status = 'cancelled', updated_at = ?, last_error = NULL
WHERE id = ? AND user_id = ?`,
[now, taskId, TANG_USER_ID],
);
}
}
const [activeWeather] = await pool.query(
`SELECT id, title, hour, minute, status, last_run_at
FROM h5_scheduled_tasks
WHERE user_id = ? AND title LIKE '%天气预报%' AND status = 'active'
ORDER BY hour, created_at`,
[TANG_USER_ID],
);
const reconcileSummary = await reconcileStuckContracts(pool, h5Root, apply);
console.log(JSON.stringify({
mode: apply ? 'apply' : 'dry-run',
cancelledTasks: actions,
remainingActiveWeatherTasks: activeWeather,
deliveryContracts: reconcileSummary,
}, null, 2));
await pool.end();
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
@@ -0,0 +1,105 @@
#!/usr/bin/env node
/**
* One-shot 103 production repairs from 2026-08-16 inspection.
* Dry-run by default; pass --apply to mutate database rows.
*/
import process from 'node:process';
import { reconcileStuckStaticPageDeliveryContracts } from '../scheduled-task-executor.mjs';
const TANG_USER_ID = 'a70ff537-8908-486e-9b6c-042e07cc25db';
const TASKS_TO_CANCEL = [
'781a77e5-a787-4f37-b864-e21d7e918855',
'9df3f89a-3a06-434d-8b4c-17b1aeb8911f',
'7f953d5f-6fc3-4191-b52a-bffcef48e77f',
'9b537fe8-eea2-4e39-8bcb-e8f169986327',
];
function loadPool() {
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error('缺少 DATABASE_URL');
}
return import('mysql2/promise').then(({ default: mysql }) =>
mysql.createPool({ uri: databaseUrl, connectionLimit: 2 }));
}
async function main() {
const apply = process.argv.includes('--apply');
const h5Root = process.env.H5_ROOT || '/Users/john/Project/Memind';
const pool = await loadPool();
const now = Date.now();
const actions = [];
for (const taskId of TASKS_TO_CANCEL) {
const [rows] = await pool.query(
`SELECT id, user_id, title, status, hour, minute
FROM h5_scheduled_tasks WHERE id = ? LIMIT 1`,
[taskId],
);
const row = rows?.[0];
if (!row) {
actions.push({ taskId, action: 'skip', reason: 'not_found' });
continue;
}
if (row.status === 'cancelled') {
actions.push({ taskId, action: 'skip', reason: 'already_cancelled', title: row.title });
continue;
}
actions.push({
taskId,
action: apply ? 'cancel' : 'would_cancel',
title: row.title,
status: row.status,
schedule: `${row.hour ?? '?'}:${String(row.minute ?? 0).padStart(2, '0')}`,
});
if (apply) {
await pool.query(
`UPDATE h5_scheduled_tasks
SET status = 'cancelled', updated_at = ?, last_error = NULL
WHERE id = ? AND user_id = ?`,
[now, taskId, TANG_USER_ID],
);
}
}
const [activeWeather] = await pool.query(
`SELECT id, title, hour, minute, status, last_run_at
FROM h5_scheduled_tasks
WHERE user_id = ? AND title LIKE '%天气预报%' AND status = 'active'
ORDER BY hour, created_at`,
[TANG_USER_ID],
);
let reconcileSummary = [];
if (apply) {
reconcileSummary = await reconcileStuckStaticPageDeliveryContracts({
pool,
h5Root,
limit: 50,
logger: console,
});
} else {
const [stuck] = await pool.query(
`SELECT user_id, workspace_relative_path, data_mode, status
FROM h5_page_delivery_contracts
WHERE status = 'preparing' AND data_mode = 'static'
ORDER BY updated_at ASC
LIMIT 50`,
);
reconcileSummary = stuck;
}
console.log(JSON.stringify({
mode: apply ? 'apply' : 'dry-run',
cancelledTasks: actions,
remainingActiveWeatherTasks: activeWeather,
deliveryContracts: reconcileSummary,
}, null, 2));
await pool.end();
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
+21
View File
@@ -9,6 +9,7 @@
*/
import { loadH5Environment } from './load-env.mjs';
import {
buildAssetImageUrl,
createAgentRun,
createReporter,
listScenarios,
@@ -17,6 +18,7 @@ import {
logoutViaApi,
resolvePortalBase,
snapshotPublicHtml,
uploadImageAssets,
verifyPageAccess,
verifySurveyDelivery,
verifyChildrenHobbyDietSurvey,
@@ -95,6 +97,7 @@ async function runScenario(scenario, port) {
let assistantCombinedLength = 0;
let publishKey = null;
let htmlBefore = [];
let uploadedAssetIds = [];
for (const step of scenario.steps ?? []) {
const label = step.label ?? step.action;
@@ -119,6 +122,20 @@ async function runScenario(scenario, port) {
continue;
}
if (step.action === 'upload_images') {
if (!auth) {
throw new Error('upload_images 步骤前必须先 login');
}
const assets = await uploadImageAssets(
baseUrl,
auth.cookie,
{ images: step.images ?? [], categoryCode: step.categoryCode ?? 'oa' },
reporter,
);
uploadedAssetIds = assets.map((asset) => asset.id);
continue;
}
if (step.action === 'chat') {
if (!auth) {
throw new Error('chat 步骤前必须先 login');
@@ -133,6 +150,9 @@ async function runScenario(scenario, port) {
sessionId,
selectedChatSkill: step.selectedChatSkill ?? null,
selectedAssetIds: step.selectedAssetIds ?? null,
imageUrls: step.useUploadedAssets
? uploadedAssetIds.map(buildAssetImageUrl)
: null,
});
reporter.pass('提交消息', `"${step.message}" → run ${run.runId}`);
@@ -191,6 +211,7 @@ async function runScenario(scenario, port) {
replyText: reply.combined,
htmlBefore,
expect: step.expect.page,
uploadedAssetIds,
reporter,
});
}
+120 -2
View File
@@ -121,11 +121,16 @@ export async function logoutViaApi(baseUrl, cookie, reporter) {
return body;
}
function buildUserMessage(text, { selectedChatSkill = null } = {}) {
function buildUserMessage(text, { selectedChatSkill = null, imageUrls = null } = {}) {
const metadata = { userVisible: true, displayText: text };
if (selectedChatSkill) {
metadata.memindRun = { selectedChatSkill };
}
// Same shape the WeChat channel attaches for recent uploads, so scenarios can
// exercise the vision turn without the browser upload widget.
if (Array.isArray(imageUrls) && imageUrls.length > 0) {
metadata.imageUrls = imageUrls;
}
return {
id: crypto.randomUUID(),
role: 'user',
@@ -134,16 +139,21 @@ function buildUserMessage(text, { selectedChatSkill = null } = {}) {
};
}
export function buildAssetImageUrl(assetId) {
return `/api/mindspace/v1/assets/${assetId}/download?inline=1`;
}
export async function createAgentRun(baseUrl, cookie, {
message,
sessionId = null,
selectedChatSkill = null,
selectedAssetIds = null,
imageUrls = null,
}) {
const requestId = crypto.randomUUID();
const body = {
request_id: requestId,
user_message: buildUserMessage(message, { selectedChatSkill }),
user_message: buildUserMessage(message, { selectedChatSkill, imageUrls }),
};
if (sessionId) body.session_id = sessionId;
if (Array.isArray(selectedAssetIds) && selectedAssetIds.length > 0) {
@@ -296,6 +306,100 @@ export async function waitForAssistantGrowth(baseUrl, cookie, sessionId, {
return null;
}
const UPLOADABLE_CATEGORY_CODES = ['oa', 'public'];
async function resolveUploadCategoryId(baseUrl, cookie, preferredCode) {
const response = await fetch(`${baseUrl}/api/mindspace/v1/space`, {
headers: { Cookie: cookie },
});
if (!response.ok) {
throw new Error(`读取 MindSpace 空间失败 ${response.status}`);
}
const payload = await response.json();
const categories = payload?.data?.categories ?? payload?.categories ?? [];
const uploadable = categories.filter((item) => UPLOADABLE_CATEGORY_CODES.includes(item?.code));
const picked = uploadable.find((item) => item.code === preferredCode) ?? uploadable[0];
if (!picked?.id) {
throw new Error(`MindSpace 没有可上传的分类(需要 ${UPLOADABLE_CATEGORY_CODES.join(' / ')})`);
}
return picked.id;
}
/**
* Renders a deterministic, visually distinct JPEG so a vision model can tell the
* uploads apart. Shapes and colors only — no text, because the rasterizer has no
* guaranteed CJK font.
*/
async function renderScenarioImage({ background = '#1e3a8a', shape = 'circle', accent = '#fbbf24' }) {
const { default: sharp } = await import('sharp');
const shapes = {
circle: '<circle cx="400" cy="300" r="180" fill="ACCENT"/>',
square: '<rect x="220" y="120" width="360" height="360" fill="ACCENT"/>',
triangle: '<polygon points="400,110 600,480 200,480" fill="ACCENT"/>',
diamond: '<polygon points="400,90 620,300 400,510 180,300" fill="ACCENT"/>',
};
const figure = (shapes[shape] ?? shapes.circle).replace('ACCENT', accent);
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="800" height="600">`
+ `<rect width="800" height="600" fill="${background}"/>${figure}</svg>`;
return sharp(Buffer.from(svg)).jpeg({ quality: 90 }).toBuffer();
}
export async function uploadImageAssets(
baseUrl,
cookie,
{ images = [], categoryCode = 'oa' } = {},
reporter,
) {
const categoryId = await resolveUploadCategoryId(baseUrl, cookie, categoryCode);
const assets = [];
for (const [index, spec] of images.entries()) {
const buffer = await renderScenarioImage(spec ?? {});
const filename = spec?.filename ?? `scenario-image-${index + 1}.jpg`;
const createResponse = await fetch(`${baseUrl}/api/mindspace/v1/uploads`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: cookie },
body: JSON.stringify({
category_id: categoryId,
filename,
size_bytes: buffer.length,
declared_mime_type: 'image/jpeg',
}),
});
if (!createResponse.ok) {
throw new Error(`创建上传失败 ${createResponse.status}: ${await createResponse.text()}`);
}
const upload = (await createResponse.json())?.data;
const uploadUrl = new URL(upload.uploadUrl, baseUrl).href;
const putResponse = await fetch(uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': 'application/octet-stream', Cookie: cookie },
body: buffer,
});
if (!putResponse.ok) {
throw new Error(`上传内容失败 ${putResponse.status}: ${await putResponse.text()}`);
}
const completeResponse = await fetch(
`${baseUrl}/api/mindspace/v1/uploads/${encodeURIComponent(upload.id)}/complete`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: cookie },
body: JSON.stringify({}),
},
);
if (!completeResponse.ok) {
throw new Error(`完成上传失败 ${completeResponse.status}: ${await completeResponse.text()}`);
}
assets.push((await completeResponse.json())?.data);
}
reporter?.pass('上传图片', `${assets.length} 张 → ${assets.map((a) => a.id).join(', ')}`);
return assets;
}
export async function verifyPageAccess({
baseUrl,
cookie,
@@ -303,6 +407,7 @@ export async function verifyPageAccess({
replyText,
htmlBefore = [],
expect = {},
uploadedAssetIds = [],
reporter,
}) {
const keywords = expect.keywords ?? [];
@@ -354,6 +459,19 @@ export async function verifyPageAccess({
reporter.fail('页面元数据', '缺少 mindspace-cover');
return false;
}
if (expect.requireUploadedImages) {
const embedded = uploadedAssetIds.filter((assetId) => assetId && html.includes(assetId));
const required = expect.minUploadedImages ?? uploadedAssetIds.length;
if (embedded.length < required) {
reporter.fail(
'页面嵌入上传图片',
`命中 ${embedded.length}/${uploadedAssetIds.length} 张,要求至少 ${required} 张`,
);
return false;
}
reporter.pass('页面嵌入上传图片', `${embedded.length}/${uploadedAssetIds.length} 张`);
}
}
return true;
@@ -236,10 +236,11 @@ export async function bootstrapPortalIntegrationServices({
const notificationDispatcher =
createNotificationDispatcherFn({
sendWechatTextToUser: wechatMpService?.enabled
? (userId, text) =>
? (userId, text, options) =>
wechatMpService.sendTextToUser(
userId,
text,
options,
)
: null,
});
+35 -5
View File
@@ -966,6 +966,12 @@ export async function buildVisionPayload({
(visionDescription
? `Qwen VL 图片描述:\n${visionDescription}\n\n`
: '') +
'视觉检查已由图片模型完成,本轮不会再提供读图工具。'
+ '禁止调用 read_image 或任何读图工具去「确认图片内容」:当前聊天模型不接受图片工具结果,'
+ '强行读图会让本轮及后续请求全部失败。'
+ (visionDescription
? '直接依据上面的描述写文案。\n'
: '本轮没有拿到图片描述时,不要臆造画面细节,按用户文字要求组织内容,并把下面的图片路径原样嵌进页面。\n') +
'写作约束:不得改写图片里人物的年龄、性别、人数或主体关系;如果用户明确要求儿童语气或童趣风格,也只能调整表达方式,不能把图片主体改写成儿童场景。\n' +
`图片 HTML 嵌入路径(直接写入 <img> 标签;以下都是无需 cookie 的公开压缩标准图片,禁止使用 local://、/users/ 私有路径、原图地址或需要登录态的下载链接):\n${pathList}\n` +
'执行要求:必须先调用 load_skill → static-page-publish(每次生成页面都要调用,不可省略),' +
@@ -1979,11 +1985,20 @@ export function createTkmindProxy({
throw err;
}
// REGRESSION GUARD: vision-turn-read-image-isolation — the vision provider
// already describes this turn's images in the injected prompt. Leaving
// read_image available lets the agent re-read the originals and persist
// base64 image parts that the text-only chat provider then rejects for
// every later turn, so drop the tool before the turn starts.
const visionHandlesThisTurn = Boolean(llmProviderService)
&& messageHasImages(userMessage)
&& await llmProviderService.hasVisionKey().catch(() => false);
await reconcileSessionPolicyForUser(userId, sessionId, {
toolMode,
query: firstUserText(userMessage),
forceDeepReasoning,
disableImageReading,
disableImageReading: disableImageReading || visionHandlesThisTurn,
});
await applySessionLlmProvider(sessionId);
await repairSessionToolHistory(sessionId);
@@ -2009,6 +2024,17 @@ export function createTkmindProxy({
error.code = 'HISTORICAL_IMAGE_SESSION_UPDATE_UNSUPPORTED';
throw error;
}
if (
requireHistoricalImageIsolation
&& imageIsolation.hasImageUrlContent
&& !imageIsolation.updated
) {
const error = new Error(
`historical_image_session_update_unsupported:${imageIsolation.status ?? 'image_url_content_present'}`,
);
error.code = 'HISTORICAL_IMAGE_SESSION_UPDATE_UNSUPPORTED';
throw error;
}
}
let finalUserMessage = userMessage;
if (llmProviderService && messageHasImages(userMessage) && await llmProviderService.hasVisionKey()) {
@@ -2098,12 +2124,12 @@ export function createTkmindProxy({
return upstream.json();
}
async function resolveSessionBillingTokenState(sessionId, tokenStateRaw) {
async function resolveSessionBillingTokenState(sessionId, tokenStateRaw, userId = null) {
return resolveBillingTokenState(tokenStateRaw, {
sessionId,
fetchSession: fetchSessionBillingCost,
loadEstimateConfig: billingConfigService?.getEffectiveCostEstimateConfig
? () => billingConfigService.getEffectiveCostEstimateConfig()
? () => billingConfigService.getEffectiveCostEstimateConfig({ userId })
: null,
});
}
@@ -2207,7 +2233,7 @@ export function createTkmindProxy({
}
if (!finishResult.ok) throw finishResult.error;
const finish = finishResult.value;
const tokenState = await resolveSessionBillingTokenState(sessionId, finish.tokenState);
const tokenState = await resolveSessionBillingTokenState(sessionId, finish.tokenState, userId);
if (tokenState && userAuth.billSessionUsage) {
await userAuth
.billSessionUsage(userId, sessionId, tokenState, requestId)
@@ -2709,7 +2735,11 @@ export function createTkmindProxy({
.catch(() => {});
}
const billingRequestId = event.request_id ?? event.chat_request_id ?? null;
const tokenState = await resolveSessionBillingTokenState(sessionId, event.token_state);
const tokenState = await resolveSessionBillingTokenState(
sessionId,
event.token_state,
req.currentUser.id,
);
const result = await userAuth.billSessionUsage(
req.currentUser.id,
sessionId,
+141 -4
View File
@@ -22,6 +22,8 @@ async function withFakeGoosedSession(
const replyBodies = [];
const startBodies = [];
const updateBodies = [];
const addedExtensions = [];
let activeExtensions = [{ name: 'memory', available_tools: [] }];
let activeConversation = conversation;
let server;
@@ -67,9 +69,7 @@ async function withFakeGoosedSession(
}
if (req.method === 'GET' && req.url === '/sessions/session-1/extensions') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
extensions: [{ name: 'memory', available_tools: [] }],
}));
res.end(JSON.stringify({ extensions: activeExtensions }));
return;
}
if (req.method === 'POST' && req.url === '/sessions/session-1/reply') {
@@ -78,6 +78,23 @@ async function withFakeGoosedSession(
res.end(JSON.stringify({ ok: true }));
return;
}
if (
req.method === 'POST'
&& (req.url === '/agent/add_extension'
|| req.url === '/agent/remove_extension'
|| req.url === '/agent/restart')
) {
if (req.url === '/agent/add_extension') {
addedExtensions.push(body);
activeExtensions = [...activeExtensions, body.config];
}
if (req.url === '/agent/remove_extension') {
activeExtensions = activeExtensions.filter((ext) => ext?.name !== body.name);
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
return;
}
if (req.method === 'POST' && req.url === '/agent/harness_remember') {
harnessEntries.push(body);
res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -102,6 +119,7 @@ async function withFakeGoosedSession(
replyBodies,
startBodies,
updateBodies,
addedExtensions,
});
} finally {
if (server?.listening) {
@@ -1569,7 +1587,7 @@ test('submitSessionReplyForUser rotates when assistant history still has image_u
},
{ requireHistoricalImageIsolation: true },
),
/historical_image_session_update_unsupported:image_url_content_present/,
/historical_image_session_update_unsupported:(405|image_url_content_present)/,
);
assert.equal(replyBodies.length, 0);
}, {
@@ -1707,11 +1725,130 @@ test('submitSessionReplyForUser applies the shared Qwen vision preprocessing pat
const forwardedText = replyBodies[0]?.user_message?.content?.[0]?.text ?? '';
assert.match(forwardedText, /Qwen VL 图片描述/);
assert.match(forwardedText, /一件蓝色产品/);
assert.match(forwardedText, /禁止调用 read_image/);
assert.equal(replyBodies[0]?.user_message?.metadata?.imageUrls, undefined);
assert.ok(Array.isArray(replyBodies[0]?.user_message?.metadata?.archivedImageUrls));
});
});
function createReadImagePolicyUserAuth(workingDir) {
return {
...createMemoryTestUserAuth(workingDir),
async getAgentSessionPolicy() {
return {
gooseMode: 'chat',
enableContextMemory: true,
extensionOverrides: [
{ name: 'memory', available_tools: [] },
{ name: 'developer', available_tools: ['write', 'edit', 'read_image'] },
],
};
},
async ownsSession() {
return true;
},
async canUseChat() {
return { ok: true };
},
async getUserById() {
return { id: 'user-1' };
},
async getUserPublishLayout() {
return { publicUrl: 'https://example.com/MindSpace/user-1' };
},
async resolveUserPolicies() {
return { unrestricted: true, policies: {} };
},
};
}
function developerToolsFromAddedExtensions(addedExtensions) {
const developer = addedExtensions
.map((body) => body?.config)
.find((config) => config?.name === 'developer');
return developer?.available_tools ?? null;
}
test('image turns drop read_image so vision results cannot poison the text provider', async () => {
await withFakeGoosedSession(async ({ apiTarget, workingDir, addedExtensions }) => {
const proxy = createTkmindProxy({
apiTarget,
apiSecret: 'test-secret',
userAuth: createReadImagePolicyUserAuth(workingDir),
localFetchAsset: async () => ({
buffer: Buffer.from('fake-image'),
mimeType: 'image/jpeg',
}),
llmProviderService: {
async applyBestProviderForSession() {
return { ok: true };
},
async hasVisionKey() {
return true;
},
async analyzeImagesWithVision() {
return '四张汉服写真。';
},
},
});
await proxy.submitSessionReplyForUser(
'user-1',
'session-1',
'request-image-turn',
{
id: 'message-image-turn',
role: 'user',
content: [{ type: 'text', text: '用刚才发的4张图片做成主题页面' }],
metadata: {
imageUrls: [
'/api/mindspace/v1/assets/asset-1/download?inline=1',
'/api/mindspace/v1/assets/asset-2/download?inline=1',
],
},
},
);
const developerTools = developerToolsFromAddedExtensions(addedExtensions);
assert.ok(developerTools, 'developer extension should still be provisioned');
assert.deepEqual(developerTools, ['write', 'edit']);
});
});
test('text-only turns keep read_image available', async () => {
await withFakeGoosedSession(async ({ apiTarget, workingDir, addedExtensions }) => {
const proxy = createTkmindProxy({
apiTarget,
apiSecret: 'test-secret',
userAuth: createReadImagePolicyUserAuth(workingDir),
llmProviderService: {
async applyBestProviderForSession() {
return { ok: true };
},
async hasVisionKey() {
return true;
},
},
});
await proxy.submitSessionReplyForUser(
'user-1',
'session-1',
'request-text-turn',
{
id: 'message-text-turn',
role: 'user',
content: [{ type: 'text', text: '把页面标题改成星尘之门' }],
},
);
assert.deepEqual(
developerToolsFromAddedExtensions(addedExtensions),
['write', 'edit', 'read_image'],
);
});
});
test('submitSessionReplyForUser passes current prompt to Memory V2 resolve before existing reply path', async () => {
let resolveInput = null;
await withFakeGoosedSession(async ({ apiTarget, workingDir }) => {
+34 -8
View File
@@ -129,15 +129,19 @@ export function createUserAuth(pool, options = {}) {
const { storageRoot, publicBaseUrl } = resolveMindSpaceRuntimeConfig(h5Root, env);
const skillCatalog = listPlatformSkillCatalog(h5Root);
const defaultSignupBalanceCents = Number(options.defaultSignupBalanceCents ?? 500);
const lowBalanceGiftThresholdCents = Number(options.lowBalanceGiftThresholdCents ?? 100);
const lowBalanceGiftAmountCents = Number(options.lowBalanceGiftAmountCents ?? 1000);
const lowBalanceGiftThresholdCents = Number(
options.lowBalanceGiftThresholdCents ?? env.H5_LOW_BALANCE_GIFT_THRESHOLD_CENTS ?? 100,
);
const lowBalanceGiftAmountCents = Number(
options.lowBalanceGiftAmountCents ?? env.H5_LOW_BALANCE_GIFT_AMOUNT_CENTS ?? 0,
);
const sessionTtlMs = Number(options.sessionTtlMs ?? 7 * 24 * 60 * 60 * 1000);
const loginMaxFailures = Number(options.loginMaxFailures ?? 5);
const loginFailureWindowMs = Number(options.loginFailureWindowMs ?? 5 * 60 * 1000);
const persistSessions = options.persistSessions !== false && Boolean(pool);
let rechargeNotifier =
typeof options.onRechargeNotification === 'function' ? options.onRechargeNotification : null;
const subscriptionService = options.subscriptionService ?? null;
let subscriptionService = options.subscriptionService ?? null;
const provisionUserDataSpace = typeof options.provisionUserDataSpace === 'function'
? options.provisionUserDataSpace
: null;
@@ -190,7 +194,7 @@ export function createUserAuth(pool, options = {}) {
const getUserById = async (userId) => {
const [rows] = await pool.query(
`SELECT u.id, u.username, u.slug, u.email, u.display_name, u.role, u.status,
u.plan_type, u.workspace_root,
u.plan_type, u.workspace_root, u.billing_formula,
s.quota_bytes, s.used_bytes, s.reserved_bytes,
w.balance_cents, w.tokens_used,
(SELECT COALESCE(SUM(ABS(amount_cents)), 0)
@@ -218,6 +222,7 @@ export function createUserAuth(pool, options = {}) {
status: row.status,
planType: row.plan_type ?? 'free',
workspaceRoot: row.workspace_root,
billingFormula: row.billing_formula === 'B' ? 'B' : 'A',
balanceCents,
totalCreditCents: balanceCents + spentCents,
tokensUsed: Number(row.tokens_used ?? 0),
@@ -447,7 +452,7 @@ export function createUserAuth(pool, options = {}) {
(id, username, slug, email, display_name, salt, password_hash, password_algorithm,
role, status, plan_type, workspace_root, low_balance_gift_eligible, low_balance_gift_granted_at,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'user', 'active', 'free', ?, 1, NULL, ?, ?)`,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'user', 'active', 'free', ?, 0, NULL, ?, ?)`,
[
userId,
normalized,
@@ -900,7 +905,7 @@ export function createUserAuth(pool, options = {}) {
);
const [rows] = await pool.query(
`SELECT u.id, u.username, u.slug, u.email, u.display_name, u.role, u.status,
u.plan_type, u.workspace_root,
u.plan_type, u.workspace_root, u.billing_formula,
s.quota_bytes, s.used_bytes, s.reserved_bytes,
u.created_at, u.updated_at, w.balance_cents, w.tokens_used
FROM h5_users u
@@ -1038,6 +1043,14 @@ export function createUserAuth(pool, options = {}) {
fields.push('role = ?');
values.push(patch.role === 'admin' ? 'admin' : 'user');
}
if (patch.billingFormula !== undefined) {
const formula = String(patch.billingFormula).trim().toUpperCase();
if (!['A', 'B'].includes(formula)) {
return { ok: false, message: '计量公式只能是 A 或 B' };
}
fields.push('billing_formula = ?');
values.push(formula);
}
if (fields.length > 0) {
fields.push('updated_at = ?');
@@ -1272,6 +1285,16 @@ export function createUserAuth(pool, options = {}) {
]);
}
if (ownsConnection) await conn.commit();
if (subscriptionService?.processAutoRenewalsForUser) {
try {
await subscriptionService.processAutoRenewalsForUser(userId);
} catch (err) {
console.warn(
'Post-recharge auto-renew retry failed:',
err instanceof Error ? err.message : String(err),
);
}
}
const updated = await getUserById(userId);
if (rechargeNotifier) {
try {
@@ -1336,7 +1359,7 @@ export function createUserAuth(pool, options = {}) {
}
const tokenState = normalizeTokenState(tokenStateRaw);
const config = billingConfigService?.getEffectiveBillingConfig
? await billingConfigService.getEffectiveBillingConfig()
? await billingConfigService.getEffectiveBillingConfig({ userId })
: loadBillingConfig(env);
const normalizedRequestId = requestId ? String(requestId).trim() || null : null;
const now = Date.now();
@@ -2747,7 +2770,7 @@ export function createUserAuth(pool, options = {}) {
(id, username, slug, email, display_name, salt, password_hash, password_algorithm,
role, status, plan_type, workspace_root, signup_source, low_balance_gift_eligible,
low_balance_gift_granted_at, created_at, updated_at)
VALUES (?, ?, ?, NULL, ?, ?, ?, ?, 'user', 'active', 'free', ?, 'wechat', 1, NULL, ?, ?)`,
VALUES (?, ?, ?, NULL, ?, ?, ?, ?, 'user', 'active', 'free', ?, 'wechat', 0, NULL, ?, ?)`,
[
userId,
normalized,
@@ -3079,6 +3102,9 @@ export function createUserAuth(pool, options = {}) {
setRechargeNotifier(callback) {
rechargeNotifier = typeof callback === 'function' ? callback : null;
},
setSubscriptionService(service) {
subscriptionService = service ?? null;
},
findWechatUserByOpenid,
getWechatAgentRoute,
upsertWechatAgentRoute,
+1 -1
View File
@@ -743,7 +743,7 @@ test('billSessionUsage auto gifts low-balance bonus once for eligible new users'
},
};
const auth = createUserAuth(pool, { persistSessions: false });
const auth = createUserAuth(pool, { persistSessions: false, lowBalanceGiftAmountCents: 1000 });
const first = await auth.billSessionUsage(
userRow.id,
+151 -6
View File
@@ -85,6 +85,10 @@ import { resolveMindSpaceUserPublishDir } from './mindspace-runtime-config.mjs';
import {
buildPageDataCollectFailureText,
} from './mindspace-page-data-finish-guard.mjs';
import {
conversationHasImageUrlContent,
conversationHasToolImageContent,
} from './chat-image-turn-scope.mjs';
export { buildWechatAgentPrompt };
@@ -1094,6 +1098,30 @@ export function isWechatHistoricalImageSessionError(message) {
);
}
export const WECHAT_IMAGE_STORED_ACK_TEXT =
'已收到图片。请直接告诉我接下来要做什么,例如:解读报告并生成页面。';
export function resolveWechatRecentMediaPublicUrl(recentMediaEntry) {
if (!recentMediaEntry?.items?.length) return '';
return String(recentMediaEntry.items.at(-1)?.media?.publicUrl ?? '').trim();
}
export function prepareWechatIntentForHistoricalImageRetry(intent, { fallbackImageUrl = '' } = {}) {
if (!intent || typeof intent !== 'object') return intent;
const imageUrl = String(intent.media?.publicUrl ?? fallbackImageUrl ?? '').trim();
const agentText = String(intent.agentText ?? '').trim();
if (imageUrl && !agentText.includes(imageUrl)) {
intent.agentText = agentText
? `${agentText}\n\n[附件图片]: ${imageUrl}`
: `[附件图片]: ${imageUrl}`;
}
delete intent.media;
delete intent.attachment;
delete intent.recentMediaItems;
delete intent.recentMediaBatchId;
return intent;
}
export function isRecoverableWechatAgentSessionError(message) {
const normalized = String(message ?? '').trim();
if (!normalized) return false;
@@ -1992,14 +2020,25 @@ export function createWechatMpService({
});
};
const sendTextToUser = async (userId, content) => {
const sendTextToUser = async (userId, content, { verifiedHtmlUrls = [] } = {}) => {
const openid = await userAuth.getWechatOpenidForUser(userId, config.appId);
if (!openid) {
throw new Error('用户尚未绑定服务号,无法推送提醒');
}
return sendCustomerServiceText(openid, content, null, {
const normalizedVerified = verifiedHtmlUrls
.map((url) => String(url ?? '').trim())
.filter(Boolean);
const sendOptions = {
deliveryPriority: 'formal_reply',
});
verifiedHtmlUrls: normalizedVerified,
};
if (normalizedVerified.length > 0) {
sendOptions.linkExistsForRequest = createPreparedPublicHtmlLinkExists({
userId,
prepared: { validReplyUrls: normalizedVerified },
});
}
return sendCustomerServiceText(openid, content, null, sendOptions);
};
const enforceFreshPageThumbnailDelivery = async ({
@@ -2166,15 +2205,23 @@ export function createWechatMpService({
retainUnlinkedRoute = false,
userContext = null,
}) => {
if (forceNew) {
await userAuth.clearWechatAgentRoute(config.appId, openid);
}
const workingDir = await userAuth.resolveWorkingDir(userId);
let sessionPolicy =
await userAuth.getAgentSessionPolicy(userId);
const publishLayout = await userAuth.getUserPublishLayout(userId);
const addressName = resolveWechatAddressName(userContext);
let carriedSessionContentForNewSession = '';
if (forceNew) {
const routeBeforeForceNew = await userAuth.getWechatAgentRoute(config.appId, openid);
if (routeBeforeForceNew?.agentSessionId) {
carriedSessionContentForNewSession = await fetchLastSubstantiveAssistantFromSession(
fetchForSession,
routeBeforeForceNew.agentSessionId,
);
rememberedWechatContexts.delete(routeBeforeForceNew.agentSessionId);
}
await userAuth.clearWechatAgentRoute(config.appId, openid);
}
const existingRoute = await userAuth.getWechatAgentRoute(config.appId, openid);
if (existingRoute?.agentSessionId) {
const now = Date.now();
@@ -2386,6 +2433,61 @@ export function createWechatMpService({
};
};
const rotateWechatSessionIfImagePolluted = async ({
userId,
openid,
sessionId,
user,
carriedSessionContent = '',
}) => {
if (!sessionId) {
return { sessionId, carriedSessionContent, rotated: false };
}
try {
const response = await fetchForSession(
sessionId,
`/sessions/${encodeURIComponent(sessionId)}`,
);
if (!response.ok) {
return { sessionId, carriedSessionContent, rotated: false };
}
const payload = await readJsonResponse(response);
const conversation = Array.isArray(payload?.conversation) ? payload.conversation : [];
const hasImageUrlContent = conversationHasImageUrlContent(conversation);
// REGRESSION GUARD: vision-turn-read-image-isolation — read_image leaves
// base64 image parts inside tool responses instead of image_url. Goose
// keeps them, so the next turn against a text-only chat provider fails
// before the agent can write any page.
const hasToolImageContent = conversationHasToolImageContent(conversation);
if (!hasImageUrlContent && !hasToolImageContent) {
return { sessionId, carriedSessionContent, rotated: false };
}
logger.warn?.('WeChat MP rotating image-polluted agent session before reply:', {
agentSessionId: sessionId,
conversationLength: conversation.length,
hasImageUrlContent,
hasToolImageContent,
});
const nextRoute = await ensureWechatAgentSession({
userId,
openid,
forceNew: true,
userContext: user,
});
const nextCarried = String(carriedSessionContent ?? '').trim()
|| String(nextRoute.carriedSessionContent ?? '').trim();
return {
sessionId: nextRoute.sessionId,
carriedSessionContent: nextCarried,
rotated: true,
isNewSession: nextRoute.isNewSession,
};
} catch (err) {
logger.warn?.('WeChat MP image-polluted session rotate skipped:', err);
return { sessionId, carriedSessionContent, rotated: false };
}
};
const refreshWechatSessionSnapshot = async (sessionId, userId) => {
if (!sessionId || !userId || typeof refreshSessionSnapshot !== 'function') return;
try {
@@ -2693,6 +2795,26 @@ export function createWechatMpService({
return { sessionId };
}
await rememberWechatUserContext(sessionId, user, { forceBootstrap: route.isNewSession });
const pollutionRotation = await rotateWechatSessionIfImagePolluted({
userId: user.userId,
openid: inbound.fromUserName,
sessionId,
user,
carriedSessionContent,
});
if (pollutionRotation.rotated) {
sessionId = pollutionRotation.sessionId;
route = {
...route,
sessionId,
isNewSession: pollutionRotation.isNewSession ?? true,
};
if (pollutionRotation.carriedSessionContent) {
carriedSessionContent = pollutionRotation.carriedSessionContent;
}
await ensureSessionProvider(sessionId);
await rememberWechatUserContext(sessionId, user, { forceBootstrap: true });
}
if (
wechatIntent.kind === 'page.generate'
&& sessionPageContinuation
@@ -3157,6 +3279,13 @@ export function createWechatMpService({
sessionId = route.sessionId;
await ensureSessionProvider(sessionId);
await rememberWechatUserContext(sessionId, user, { forceBootstrap: route.isNewSession });
if (historicalImageError) {
prepareWechatIntentForHistoricalImageRetry(intent, {
fallbackImageUrl: resolveWechatRecentMediaPublicUrl(
recentMediaByOpenid.get(String(inbound.fromUserName ?? '').trim()),
),
});
}
const retryId = crypto.randomUUID();
const retryStartedAt = Date.now();
const retryPageContinuation = sessionPageContinuation && !historicalImageError;
@@ -3683,6 +3812,22 @@ export function createWechatMpService({
};
intent.agentText = `[图片1]: ${persisted.publicUrl}`;
rememberRecentMedia(inbound.fromUserName, intent);
await persistIntentDetail({ intent, userId: boundUser.userId, rawXmlHash });
if (inbound.msgId && typeof userAuth.finishWechatMpMessage === 'function') {
await userAuth.finishWechatMpMessage({
appId: config.appId,
openid: inbound.fromUserName,
msgId: inbound.msgId,
status: 'done',
agentSessionId: null,
});
}
return {
ok: true,
status: 200,
contentType: 'application/xml; charset=utf-8',
body: await buildPassiveReplyBody(WECHAT_IMAGE_STORED_ACK_TEXT),
};
} catch (error) {
await persistIntentDetail({ intent, userId: boundUser.userId, rawXmlHash });
return {
+531 -115
View File
@@ -14,6 +14,9 @@ import {
isRecoverableWechatAgentSessionError,
isWechatAgentApiErrorText,
isWechatHistoricalImageSessionError,
WECHAT_IMAGE_STORED_ACK_TEXT,
prepareWechatIntentForHistoricalImageRetry,
resolveWechatRecentMediaPublicUrl,
sanitizeWechatAgentOutboundText,
loadWechatMpConfig,
maybeAttachPublishedHtmlLink,
@@ -1330,6 +1333,40 @@ test('wechat mp service splits long agent replies into multiple customer message
assert.equal(combined, longReply);
});
test('wechat mp sendTextToUser preserves verified MindSpace public links', async () => {
const sentBodies = [];
const service = createBoundWechatService({
userAuth: {
async getWechatOpenidForUser() {
return 'openid-1';
},
},
wechatFetch: async (url, init) => {
if (String(url).includes('/cgi-bin/stable_token')) {
return new Response(JSON.stringify({ access_token: 'access-1', expires_in: 7200 }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
sentBodies.push(JSON.parse(String(init?.body ?? '{}')).text.content);
return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
},
});
const url = 'https://m.tkmind.cn/MindSpace/user-1/public/daily-news-0817.html';
const result = await service.sendTextToUser(
'user-1',
`页面已生成:${url}`,
{ verifiedHtmlUrls: [url] },
);
assert.equal(result.sent, true);
assert.match(sentBodies[0], /daily-news-0817\.html/);
assert.doesNotMatch(sentBodies[0], /页面生成未完成/);
});
test('wechat mp service defers 45047 customer-service delivery instead of throwing', async () => {
const service = createBoundWechatService({
userAuth: {
@@ -3498,6 +3535,40 @@ test('isRecoverableWechatAgentSessionError detects poisoned tool_calls history',
assert.equal(isRecoverableWechatAgentSessionError('network timeout'), false);
});
test('prepareWechatIntentForHistoricalImageRetry keeps image url in text only', () => {
const intent = prepareWechatIntentForHistoricalImageRetry({
agentText: '解读报告,生成页面',
media: { publicUrl: 'https://example.com/report.png' },
recentMediaItems: [{ media: { publicUrl: 'https://example.com/report.png' } }],
recentMediaBatchId: 'batch-1',
});
assert.match(intent.agentText, /https:\/\/example\.com\/report\.png/);
assert.equal(intent.media, undefined);
assert.equal(intent.recentMediaItems, undefined);
assert.equal(intent.recentMediaBatchId, undefined);
});
test('prepareWechatIntentForHistoricalImageRetry can recover image url from recent media cache', () => {
const intent = prepareWechatIntentForHistoricalImageRetry(
{ agentText: '帮我把报告再详细点' },
{ fallbackImageUrl: 'https://example.com/report.png' },
);
assert.match(intent.agentText, /https:\/\/example\.com\/report\.png/);
assert.equal(intent.media, undefined);
});
test('resolveWechatRecentMediaPublicUrl reads the latest remembered image', () => {
assert.equal(
resolveWechatRecentMediaPublicUrl({
items: [
{ media: { publicUrl: 'https://example.com/first.png' } },
{ media: { publicUrl: 'https://example.com/second.png' } },
],
}),
'https://example.com/second.png',
);
});
test('wechat mp rotates and retries when historical image isolation is unsupported', async () => {
const token = 'token';
const timestamp = '1710000000';
@@ -4917,7 +4988,7 @@ test('wechat mp wildcard media access persists image and routes image url into a
const originalRandomUuid = crypto.randomUUID;
crypto.randomUUID = () => 'req-image';
try {
const result = await service.handleInboundMessage(
const imageResult = await service.handleInboundMessage(
inboundXml({
msgType: 'image',
content: '',
@@ -4929,9 +5000,15 @@ test('wechat mp wildcard media access persists image and routes image url into a
signature: signatureFor(token, timestamp, nonce),
},
);
assert.equal(result.status, 200);
assert.match(result.body, /<Content>/);
await result.task;
assert.equal(imageResult.status, 200);
assert.match(imageResult.body ?? '', /已收到图片/);
assert.equal(prompts.length, 0);
const followupResult = await service.handleInboundMessage(
inboundXml({ msgType: 'text', content: '请分析刚才图片' }),
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
);
await followupResult.task;
} finally {
crypto.randomUUID = originalRandomUuid;
fs.rmSync(path.join(process.cwd(), 'MindSpace', testUserId), {
@@ -4941,17 +5018,13 @@ test('wechat mp wildcard media access persists image and routes image url into a
}
assert.equal(prompts.length, 1);
assert.match(prompts[0], /【微信服务号图片消息】/);
assert.match(
prompts[0],
/\[图片1\]: https:\/\/example\.com\/MindSpace\/test-user-image\/public\/wechat-mp\//,
);
assert.match(prompts[0], /请分析刚才图片/);
assert.equal(metadataCalls.length, 1);
assert.equal(metadataCalls[0].source, 'wechat_mp');
assert.equal(metadataCalls[0].msgType, 'image');
assert.equal(metadataCalls[0].msgType, 'text');
assert.equal(metadataCalls[0].imageUrls.length, 1);
assert.match(metadataCalls[0].imageUrls[0], /\/public\/wechat-mp\//);
assert.equal(detailCalls.length, 1);
assert.equal(detailCalls.length, 2);
assert.match(detailCalls[0].mediaPublicUrl, /\/wechat-mp\//);
});
@@ -5014,7 +5087,7 @@ test('wechat mp image submission reuses the H5 prepared reply path', async () =>
});
try {
const result = await service.handleInboundMessage(
const imageResult = await service.handleInboundMessage(
inboundXml({
msgType: 'image',
content: '',
@@ -5022,7 +5095,14 @@ test('wechat mp image submission reuses the H5 prepared reply path', async () =>
}),
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
);
await result.task;
assert.match(imageResult.body ?? '', /已收到图片/);
assert.equal(submitCalls.length, 0);
const followupResult = await service.handleInboundMessage(
inboundXml({ msgType: 'text', content: '请分析刚才图片' }),
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
);
await followupResult.task;
} finally {
fs.rmSync(path.join(process.cwd(), 'MindSpace', testUserId), { recursive: true, force: true });
}
@@ -5030,6 +5110,7 @@ test('wechat mp image submission reuses the H5 prepared reply path', async () =>
assert.equal(submitCalls.length, 1);
assert.equal(submitCalls[0].userId, testUserId);
assert.equal(submitCalls[0].sessionId, 'session-1');
assert.equal(submitCalls[0].options?.requireHistoricalImageIsolation, true);
assert.equal(submitCalls[0].userMessage.metadata.imageUrls.length, 1);
assert.match(submitCalls[0].userMessage.metadata.imageUrls[0], /\/public\/wechat-mp\//);
});
@@ -5337,8 +5418,6 @@ test('wechat mp serializes image and follow-up text and reattaches recent image'
const nonce = 'nonce';
const testUserId = 'test-user-image-followup';
const submitCalls = [];
let eventCall = 0;
let releaseFirst = null;
const service = createBoundWechatService({
token,
config: {
@@ -5351,25 +5430,6 @@ test('wechat mp serializes image and follow-up text and reattaches recent image'
},
sessionApiFetch: async (_sessionId, pathname) => {
if (pathname === '/sessions/session-1/events') {
eventCall += 1;
if (eventCall === 1) {
return new Response(
new ReadableStream({
start(controller) {
releaseFirst = () => {
controller.enqueue(
new TextEncoder().encode(
'data: {"type":"Message","message":{"id":"assistant-image","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"图片已识别。"}]}}\n\n' +
'data: {"type":"Finish"}\n\n',
),
);
controller.close();
};
},
}),
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
);
}
return new Response(
[
'data: {"type":"Message","message":{"id":"assistant-followup","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"已结合刚才图片分析主题。"}]}}\n\n',
@@ -5419,28 +5479,21 @@ test('wechat mp serializes image and follow-up text and reattaches recent image'
}),
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
);
while (submitCalls.length === 0) await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(submitCalls.length, 0);
assert.match(imageResult.body ?? '', new RegExp(WECHAT_IMAGE_STORED_ACK_TEXT.slice(0, 8)));
const followupResult = await service.handleInboundMessage(
inboundXml({ msgType: 'text', content: '请根据刚才图片分析主题' }),
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(submitCalls.length, 1);
releaseFirst();
await imageResult.task;
await followupResult.task;
} finally {
fs.rmSync(path.join(process.cwd(), 'MindSpace', testUserId), { recursive: true, force: true });
}
assert.equal(submitCalls.length, 2);
assert.deepEqual(
submitCalls[1].userMessage.metadata.imageUrls,
submitCalls[0].userMessage.metadata.imageUrls,
);
assert.equal(submitCalls[1].userMessage.metadata.msgType, 'text');
assert.equal(submitCalls.length, 1);
assert.ok(Array.isArray(submitCalls[0].userMessage.metadata.imageUrls));
assert.equal(submitCalls[0].userMessage.metadata.msgType, 'text');
});
test('wechat mp reattaches recent image for report interpretation follow-up', async () => {
@@ -5449,8 +5502,6 @@ test('wechat mp reattaches recent image for report interpretation follow-up', as
const nonce = 'nonce';
const testUserId = 'test-user-report-followup';
const submitCalls = [];
let eventCall = 0;
let releaseFirst = null;
const service = createBoundWechatService({
token,
config: {
@@ -5463,25 +5514,6 @@ test('wechat mp reattaches recent image for report interpretation follow-up', as
},
sessionApiFetch: async (_sessionId, pathname) => {
if (pathname === '/sessions/session-1/events') {
eventCall += 1;
if (eventCall === 1) {
return new Response(
new ReadableStream({
start(controller) {
releaseFirst = () => {
controller.enqueue(
new TextEncoder().encode(
'data: {"type":"Message","message":{"id":"assistant-image","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"图片已识别。"}]}}\n\n' +
'data: {"type":"Finish"}\n\n',
),
);
controller.close();
};
},
}),
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
);
}
return new Response(
[
'data: {"type":"Message","message":{"id":"assistant-followup","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"已解读报告。"}]}}\n\n',
@@ -5531,29 +5563,439 @@ test('wechat mp reattaches recent image for report interpretation follow-up', as
}),
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
);
while (submitCalls.length === 0) await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(submitCalls.length, 0);
assert.match(imageResult.body ?? '', /已收到图片/);
const followupResult = await service.handleInboundMessage(
inboundXml({ msgType: 'text', content: '解读详细报告,做成页面' }),
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(submitCalls.length, 1);
releaseFirst();
await imageResult.task;
await followupResult.task;
} finally {
fs.rmSync(path.join(process.cwd(), 'MindSpace', testUserId), { recursive: true, force: true });
}
assert.equal(submitCalls.length, 2);
assert.deepEqual(
submitCalls[1].userMessage.metadata.imageUrls,
submitCalls[0].userMessage.metadata.imageUrls,
assert.equal(submitCalls.length, 1);
assert.ok(Array.isArray(submitCalls[0].userMessage.metadata.imageUrls));
assert.equal(submitCalls[0].userMessage.metadata.msgType, 'text');
assert.equal(submitCalls[0].userMessage.metadata.displayText, '解读详细报告,做成页面');
});
test('wechat mp rotates polluted image session before a content-edit follow-up', async () => {
const token = 'token';
const timestamp = '1710000000';
const nonce = 'nonce';
const testUserId = 'test-user-image-followup-rotate';
const submitCalls = [];
let activeSessionId = 'session-1';
let nextSessionId = 2;
let routeCleared = false;
const pollutedConversation = [
{
id: 'assistant-report',
role: 'assistant',
metadata: { userVisible: true },
content: [{ type: 'text', text: '报告页面已生成。' }],
},
{
id: 'assistant-image',
role: 'assistant',
metadata: { userVisible: false },
content: [{ type: 'image_url', image_url: { url: 'https://example.com/report.png' } }],
},
];
const service = createBoundWechatService({
token,
config: {
mediaAnalysisGrayUsers: [testUserId],
},
userAuth: {
async findWechatUserByOpenid() {
return { userId: testUserId, status: 'active', nickname: '唐' };
},
async getWechatAgentRoute() {
if (routeCleared || !activeSessionId) return null;
return { agentSessionId: activeSessionId, status: 'active', updatedAt: Date.now() };
},
async upsertWechatAgentRoute({ agentSessionId }) {
activeSessionId = agentSessionId;
routeCleared = false;
},
async clearWechatAgentRoute() {
routeCleared = true;
},
},
startAgentSession: async () => ({ id: `session-${nextSessionId++}` }),
sessionApiFetch: async (sessionId, pathname) => {
if (pathname === `/sessions/${sessionId}`) {
const body = sessionId === 'session-1'
? { conversation: pollutedConversation }
: { conversation: [] };
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (pathname === `/sessions/${sessionId}/events`) {
return new Response(
[
'data: {"type":"Message","request_id":"req-followup","message":{"id":"assistant-2","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"已按你的要求补充报告细节。"}]}}\n\n',
'data: {"type":"Finish","request_id":"req-followup","token_state":{"inputTokens":1,"outputTokens":2}}\n\n',
].join(''),
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
);
}
if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') {
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}
throw new Error(`unexpected api path: ${sessionId} ${pathname}`);
},
submitSessionReply: async (input) => {
submitCalls.push(input);
return { ok: true };
},
wechatFetch: async (url, init = {}) => {
if (String(url).includes('/cgi-bin/stable_token')) {
return new Response(JSON.stringify({ access_token: 'access-1', expires_in: 7200 }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (String(url).includes('/cgi-bin/message/custom/send')) {
return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
throw new Error(`unexpected wechat url: ${url}`);
},
});
const originalRandomUuid = crypto.randomUUID;
crypto.randomUUID = (() => {
const ids = ['req-followup'];
return () => ids.shift() ?? 'req-followup';
})();
try {
const result = await service.handleInboundMessage(
inboundXml({ msgType: 'text', content: '帮我把报告再详细一点' }),
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
);
await result.task;
} finally {
crypto.randomUUID = originalRandomUuid;
}
assert.equal(submitCalls.length, 1);
assert.notEqual(submitCalls[0].sessionId, 'session-1');
assert.equal(activeSessionId, submitCalls[0].sessionId);
});
test('wechat mp rotates a session poisoned by read_image tool results', async () => {
const token = 'token';
const timestamp = '1710000000';
const nonce = 'nonce';
const testUserId = 'test-user-read-image-rotate';
const submitCalls = [];
let activeSessionId = 'session-1';
let nextSessionId = 2;
let routeCleared = false;
// read_image hides base64 payloads inside the tool response instead of image_url,
// so the older image_url-only scan let this session keep failing every turn.
const pollutedConversation = [
{
id: 'assistant-read',
role: 'assistant',
content: [
{
type: 'toolRequest',
id: 'call_1',
toolCall: { status: 'success', value: { name: 'read_image', arguments: { path: 'a.jpg' } } },
},
],
},
{
id: 'user-read-result',
role: 'user',
content: [
{
type: 'toolResponse',
id: 'call_1',
toolResult: {
status: 'success',
value: {
content: [
{ type: 'text', text: 'Loaded image from a.jpg (202672 bytes, image/jpeg, 1280x1707).' },
{ type: 'image', data: '/9j/4AAQSkZJRg==', mimeType: 'image/jpeg' },
],
},
},
},
],
},
];
const service = createBoundWechatService({
token,
config: {
mediaAnalysisGrayUsers: [testUserId],
},
userAuth: {
async findWechatUserByOpenid() {
return { userId: testUserId, status: 'active', nickname: '唐' };
},
async getWechatAgentRoute() {
if (routeCleared || !activeSessionId) return null;
return { agentSessionId: activeSessionId, status: 'active', updatedAt: Date.now() };
},
async upsertWechatAgentRoute({ agentSessionId }) {
activeSessionId = agentSessionId;
routeCleared = false;
},
async clearWechatAgentRoute() {
routeCleared = true;
},
},
startAgentSession: async () => ({ id: `session-${nextSessionId++}` }),
sessionApiFetch: async (sessionId, pathname) => {
if (pathname === `/sessions/${sessionId}`) {
const body = sessionId === 'session-1'
? { conversation: pollutedConversation }
: { conversation: [] };
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (pathname === `/sessions/${sessionId}/events`) {
return new Response(
[
'data: {"type":"Message","request_id":"req-read-image","message":{"id":"assistant-2","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"页面已生成。"}]}}\n\n',
'data: {"type":"Finish","request_id":"req-read-image","token_state":{"inputTokens":1,"outputTokens":2}}\n\n',
].join(''),
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
);
}
if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') {
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}
throw new Error(`unexpected api path: ${sessionId} ${pathname}`);
},
submitSessionReply: async (input) => {
submitCalls.push(input);
return { ok: true };
},
wechatFetch: async (url) => {
if (String(url).includes('/cgi-bin/stable_token')) {
return new Response(JSON.stringify({ access_token: 'access-1', expires_in: 7200 }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (String(url).includes('/cgi-bin/message/custom/send')) {
return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
throw new Error(`unexpected wechat url: ${url}`);
},
});
const originalRandomUuid = crypto.randomUUID;
crypto.randomUUID = () => 'req-read-image';
try {
const result = await service.handleInboundMessage(
inboundXml({ msgType: 'text', content: '帮我把内容再详细一点' }),
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
);
await result.task;
} finally {
crypto.randomUUID = originalRandomUuid;
}
assert.equal(submitCalls.length, 1);
assert.notEqual(submitCalls[0].sessionId, 'session-1');
assert.equal(activeSessionId, submitCalls[0].sessionId);
});
test('wechat mp simulates tang image report page and desensitized follow-up', async () => {
const token = 'token';
const timestamp = '1710000000';
const nonce = 'nonce';
const testUserId = 'test-user-tang-report-followup';
const submitCalls = [];
const customerReplies = [];
let activeSessionId = 'session-1';
let nextSessionId = 2;
let routeCleared = false;
let reportPageCompleted = false;
let reportSessionId = null;
const pollutedConversation = [
{
id: 'assistant-report',
role: 'assistant',
metadata: { userVisible: true },
content: [{ type: 'text', text: '已解读化验报告并生成页面链接。' }],
},
{
id: 'assistant-image',
role: 'assistant',
metadata: { userVisible: false },
content: [{ type: 'image_url', image_url: { url: 'https://example.com/report.png' } }],
},
];
const service = createBoundWechatService({
token,
config: {
mediaAnalysisGrayUsers: [testUserId],
},
userAuth: {
async findWechatUserByOpenid() {
return { userId: testUserId, status: 'active', nickname: '唐' };
},
async getWechatAgentRoute() {
if (routeCleared || !activeSessionId) return null;
return { agentSessionId: activeSessionId, status: 'active', updatedAt: Date.now() };
},
async upsertWechatAgentRoute({ agentSessionId }) {
activeSessionId = agentSessionId;
routeCleared = false;
},
async clearWechatAgentRoute() {
routeCleared = true;
},
},
startAgentSession: async () => ({ id: `session-${nextSessionId++}` }),
sessionApiFetch: async (sessionId, pathname) => {
if (pathname === `/sessions/${sessionId}`) {
const conversation =
reportSessionId && sessionId === reportSessionId && reportPageCompleted
? pollutedConversation
: [];
return new Response(JSON.stringify({ conversation }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (pathname === `/sessions/${sessionId}/events`) {
const isReportStream = submitCalls.length === 0;
const text = isReportStream
? '报告页面已生成:https://m.tkmind.cn/MindSpace/test/public/report.html'
: '已按你的要求脱敏并补充报告细节。';
return new Response(
[
`data: {"type":"Message","message":{"id":"assistant-${isReportStream ? 'report' : 'edit'}","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"${text}"}]}}\n\n`,
'data: {"type":"Finish","token_state":{"inputTokens":1,"outputTokens":2}}\n\n',
].join(''),
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
);
}
if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') {
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
}
throw new Error(`unexpected api path: ${sessionId} ${pathname}`);
},
submitSessionReply: async (input) => {
submitCalls.push(input);
if (!reportSessionId) {
reportSessionId = input.sessionId;
reportPageCompleted = true;
}
assert.equal(input.options?.requireHistoricalImageIsolation, true);
return { ok: true };
},
wechatFetch: async (url, init = {}) => {
if (String(url).includes('/cgi-bin/stable_token')) {
return new Response(JSON.stringify({ access_token: 'access-1', expires_in: 7200 }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
if (String(url).includes('/cgi-bin/media/get')) {
return new Response(Buffer.from([0x89, 0x50, 0x4e, 0x47]), {
status: 200,
headers: { 'Content-Type': 'image/png' },
});
}
if (String(url).includes('/cgi-bin/message/custom/send')) {
customerReplies.push(JSON.parse(init.body ?? '{}'));
return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
throw new Error(`unexpected wechat url: ${url}`);
},
});
try {
const resetResult = await service.handleInboundMessage(
inboundXml({ msgType: 'text', content: '换新会话', extraFields: { MsgId: 'tang-1' } }),
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
);
if (resetResult.task) await resetResult.task;
assert.equal(submitCalls.length, 0);
const imageResult = await service.handleInboundMessage(
inboundXml({
msgType: 'image',
content: '',
extraFields: {
MsgId: 'tang-2',
MediaId: 'media-report',
PicUrl: 'https://wx.example.com/report.png',
},
}),
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
);
assert.equal(submitCalls.length, 0);
assert.match(imageResult.body ?? '', /已收到图片/);
const reportResult = await service.handleInboundMessage(
inboundXml({
msgType: 'text',
content: '解读报告并生成页面',
extraFields: { MsgId: 'tang-3' },
}),
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
);
await reportResult.task;
const editResult = await service.handleInboundMessage(
inboundXml({
msgType: 'text',
content: '帮我把真名脱敏,报告再详细点',
extraFields: { MsgId: 'tang-4' },
}),
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
);
await editResult.task;
} finally {
fs.rmSync(path.join(process.cwd(), 'MindSpace', testUserId), { recursive: true, force: true });
}
assert.ok(submitCalls.length >= 2, `submitCalls=${submitCalls.length}`);
const firstReportSubmit = submitCalls.find(
(call) => Array.isArray(call.userMessage.metadata?.imageUrls) && call.userMessage.metadata.imageUrls.length > 0,
);
assert.ok(firstReportSubmit, 'missing report submit with imageUrls');
assert.equal(firstReportSubmit.sessionId, reportSessionId);
assert.notEqual(submitCalls.at(-1).sessionId, reportSessionId);
assert.ok(customerReplies.length >= 2);
const replyTexts = customerReplies.map((payload) => String(payload?.text?.content ?? ''));
for (const text of replyTexts) {
assert.doesNotMatch(text, /unknown variant [`']?image_url/i);
assert.doesNotMatch(text, /Ran into this error:/i);
}
assert.ok(replyTexts.some((text) => /脱敏/.test(text)), replyTexts.join(' | '));
assert.ok(
replyTexts.some((text) => /report\.html/.test(text))
|| submitCalls.some((call) => Array.isArray(call.userMessage.metadata?.imageUrls)),
replyTexts.join(' | '),
);
assert.equal(submitCalls[1].userMessage.metadata.msgType, 'text');
assert.equal(submitCalls[1].userMessage.metadata.displayText, '解读详细报告,做成页面');
});
test('wechat mp groups consecutive images for one follow-up and clears the consumed batch', async () => {
@@ -5562,8 +6004,6 @@ test('wechat mp groups consecutive images for one follow-up and clears the consu
const nonce = 'nonce';
const testUserId = 'test-user-multi-image-followup';
const submitCalls = [];
let eventCall = 0;
let releaseFirst = null;
const service = createBoundWechatService({
token,
config: {
@@ -5576,25 +6016,6 @@ test('wechat mp groups consecutive images for one follow-up and clears the consu
},
sessionApiFetch: async (_sessionId, pathname) => {
if (pathname === '/sessions/session-1/events') {
eventCall += 1;
if (eventCall === 1) {
return new Response(
new ReadableStream({
start(controller) {
releaseFirst = () => {
controller.enqueue(
new TextEncoder().encode(
'data: {"type":"Message","message":{"id":"assistant-first","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"第一张处理完成。"}]}}\n\n' +
'data: {"type":"Finish"}\n\n',
),
);
controller.close();
};
},
}),
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
);
}
return new Response(
[
'data: {"type":"Message","message":{"id":"assistant-ok","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"处理完成。"}]}}\n\n',
@@ -5648,7 +6069,8 @@ test('wechat mp groups consecutive images for one follow-up and clears the consu
}),
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
);
while (submitCalls.length === 0) await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(submitCalls.length, 0);
assert.match(firstImageResult.body ?? '', /已收到图片/);
const secondImageResult = await service.handleInboundMessage(
inboundXml({
@@ -5662,6 +6084,8 @@ test('wechat mp groups consecutive images for one follow-up and clears the consu
}),
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
);
assert.equal(submitCalls.length, 0);
assert.match(secondImageResult.body ?? '', /已收到图片/);
const followupResult = await service.handleInboundMessage(
inboundXml({
@@ -5670,12 +6094,6 @@ test('wechat mp groups consecutive images for one follow-up and clears the consu
}),
{ timestamp, nonce, signature: signatureFor(token, timestamp, nonce) },
);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(submitCalls.length, 1);
releaseFirst();
await firstImageResult.task;
await secondImageResult.task;
await followupResult.task;
const laterResult = await service.handleInboundMessage(
@@ -5690,14 +6108,12 @@ test('wechat mp groups consecutive images for one follow-up and clears the consu
fs.rmSync(path.join(process.cwd(), 'MindSpace', testUserId), { recursive: true, force: true });
}
assert.equal(submitCalls.length, 4);
assert.equal(submitCalls[0].userMessage.metadata.imageUrls.length, 1);
assert.equal(submitCalls[1].userMessage.metadata.imageUrls.length, 1);
assert.equal(submitCalls[2].userMessage.metadata.imageUrls.length, 2);
assert.match(submitCalls[2].userMessage.metadata.imageUrls[0], /media-first/);
assert.match(submitCalls[2].userMessage.metadata.imageUrls[1], /media-second/);
assert.equal(submitCalls[2].userMessage.metadata.msgType, 'text');
assert.equal(submitCalls[3].userMessage.metadata.imageUrls, undefined);
assert.equal(submitCalls.length, 2);
assert.equal(submitCalls[0].userMessage.metadata.imageUrls.length, 2);
assert.match(submitCalls[0].userMessage.metadata.imageUrls[0], /media-first/);
assert.match(submitCalls[0].userMessage.metadata.imageUrls[1], /media-second/);
assert.equal(submitCalls[0].userMessage.metadata.msgType, 'text');
assert.equal(submitCalls[1].userMessage.metadata.imageUrls, undefined);
});
test('wechat mp service persists Word and Excel files in user public area and reuses H5 attachment metadata', async () => {