Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,6 @@ max_tokens = 2048
| `.ai role [<名称>]` | - | 查看 / 切换角色设定 |
| `.ai model [<模型名>]` | `.ai model deepseek-chat` | 查看 / 设置当前会话模型,`clr` 清除设置恢复默认 |
| `.ai stop` | - | 完全暂停当前对话(打断流式输出/工具链/排队请求,清计时器) |
| `.ai steer <内容>` | - | 不打断对话,向工具链插入方向提示,下一轮生效 |

### 记忆管理命令

Expand Down
6 changes: 3 additions & 3 deletions docs/03-核心模块详解.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@

- `session.ts`:
- `Setting`:priv、standby、regexTrigger、counter、timer、prob、modelName、activeTimeInfo(start/end/segs)。
- `Session`:agentName、sessionId、sessionType、state、context、memory、setting、stream、bucket、tool(state/callCount/listen)、lastCtx(不持久化);运行时字段 `running`(是否有 run/runStream 在跑)、`stopVersion`(.ai stop 自增的中止版本号)、`steerQueue`(.ai steer 方向提示队列)均不持久化。
- `Session`:agentName、sessionId、sessionType、state、context、memory、setting、stream、bucket、tool(state/callCount/listen)、lastCtx(不持久化);运行时字段 `running`(是否有 run/runStream 在跑)、`starting`(是否正在启动 run/runStream,置位期间同会话新消息一律挂起)、`stopVersion`(.ai stop 自增的中止版本号)、`pendingQueue`(会话忙时挂起的待入库消息队列)、`activeRuns`(本会话当前在跑的请求数)均不持久化。
- `toolState` getter:按 `BLOCKED` 与 `DEFAULT_CLOSED` 同步每会话工具开关,并清理已删除工具的残留状态。
- `chat()`:配额检查 → 重置 → 流式判断 → `Agent.run/runStream` → `save()`。
- `reply()`:逐段发送并写入上下文。
Expand Down Expand Up @@ -138,7 +138,7 @@
- `run()`:完整编排循环——组装消息 → 请求 → 工具执行(函数调用或提示词工程)→ 复读检测重试 → 最终回复。
- `runStream()`:流式编排,轮询后端流,检测 ```function 代码块截断,执行工具后递归续流。
- `.ai stop` 中止机制:run/runStream 启动时捕获 `session.stopVersion`,在消息组装、模型请求返回、工具执行后、最终回复前设置检查点,检测到版本变化即中止;并发队列中该会话的等待项由 `requestLimiter.cancelBySession` 丢弃。
- `.ai steer` 注入机制:方向提示进入 `session.steerQueue`,下一轮模型请求组装后以 `system` 消息「【方向提示】…」追加进请求(不入库),实现工具链中不打断地调整方向
- 挂起队列机制:会话运行(或正在启动)时收到的新消息经 `session.deferReceipt` 进入 `pendingQueue` 挂起,`run/runStream` 每轮模型请求前 `flushPending` 统一入库(此时上一轮工具回调已写入上下文,插入位置合法);记录类只入库不续跑,触发类在链结束后由 `resumePending` 用第一条触发消息串行再起一轮;`.ai stop` 清空挂起队列并自增 `stopVersion`,停止后不复活
- `run_context.ts`:`AgentRunContext` 记录单次 run 的观测信息(runId/轮次/工具调用事件/耗时)。
- `api.ts`:对外 API,启动时挂载 `globalThis.aiplugin4`(智能体调用 `chat`/`run`/`getAgent` 与工具注册 `registerTool`),供其他海豹插件使用。
- `agents/`:包含 `compress_agent`(压缩)、`summarize_agent`(摘要);`samples.ts` 的 `sample_agent` 仅作参考,不在 `initAgents` 中初始化。
Expand All @@ -147,7 +147,7 @@

- `root_cmd.ts`:注册根命令 `.ai`(注册到 `cmdMap['ai']` 与 `cmdMap['AI']`),别名经 `aliasToCmd` 归一;子命令统一分发,兜住异步异常并回复错误;支持 `--page`/`--p` 翻页。
- `privilege.ts`:命令权限系统,`CmdPrivInfo { priv: [会话权限, 用户权限, 强触权限] }`,预置 `U`(user)/`M`(master)/`I`(inviter)/`S`(会话需 inviter,否则骰主);`PrivilegeManager` 管理 `cmdPriv` 存储与校验,支持 `*` 通配。
- `sub_cmd/`:17 个文件,其中 16 个注册到根命令,`sample.ts` 未注册(仅开发参考),详见 [05-命令与配置](05-命令与配置.md)。
- `sub_cmd/`:21 个文件,其中 20 个注册到根命令,`sample.ts` 未注册(仅开发参考),详见 [05-命令与配置](05-命令与配置.md)。

## 定时器(src/timer.ts)

Expand Down
1 change: 0 additions & 1 deletion docs/05-命令与配置.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@
| `block` | 黑名单管理(骰主) | `.ai block add <用户ID/群ID> <原因>`;`.ai block rm <用户ID/群ID>`;`.ai block list`;被拉黑对象无法触发 AI 对话,指令不受影响;AI 建议拉黑默认需骰主确认,可在工具配置关闭 |
| `token` | token 用量统计 | 见下文 |
| `stop` | 完全暂停当前对话(打断流式/工具链/排队,清计时器) | `.ai stop` |
| `steer` | 向当前对话插入方向提示(不打断) | `.ai steer <内容>` |
| `model` | 查看/设置会话模型 | `.ai model`;`.ai model <模型名>`;`.ai model clr` |
| `sample` | 示例智能体(未注册到根命令) | 仅开发参考 |

Expand Down
137 changes: 137 additions & 0 deletions scripts/unit-test-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import { InMemoryMemoryStorage } from "../src/memory/v2/storage";
import { createMemoryEngine } from "../src/memory/v2";
import { requestLimiter } from "../src/utils/concurrency";
import { Context } from "../src/context/context";
import Agent from "../src/agent/agent";
import { Session } from "../src/session/session";
import Image from "../src/resource/image";
import Tool, { toolMap } from "../src/tool/tool";
import { registerDispatchTools } from "../src/tool/tools/core/tool_dispatch";
Expand Down Expand Up @@ -907,4 +909,139 @@ export const tests: Record<string, () => void | Promise<void>> = {
TC.intConfigs['请求队列上限'] = 0;
resetConfigCache();
},

/** 会话忙时挂起:deferReceipt 不直接入库,进入 pendingQueue(空闲时 handleReceipt 仍直接入库) */
async testDeferReceiptWhileBusy(): Promise<void> {
const session = new Session();
session.sessionId = 'sess_defer';
session.sessionType = 'user';
session.running = true;
const ctx = makeCtx();
const msg = { message: '测试消息' } as any;
const messageArray = [{ type: 'text', data: { text: '测试消息' } }] as any;
await session.deferReceipt(ctx, msg, messageArray, 'trigger');
assert.equal(session.pendingQueue.length, 1, '忙时应进入挂起队列');
assert.equal(session.context.messages.length, 0, '忙时不应直接写入上下文');
const p = session.pendingQueue[0];
assert.equal(p.kind, 'trigger');
assert.equal(p.content, '测试消息');
assert.equal(p.userId, 'QQ:10000');

// 空闲对照:handleReceipt 直接入库
session.running = false;
await session.handleReceipt(ctx, msg, messageArray);
assert.equal(session.context.messages.length, 1, '空闲时 handleReceipt 应直接入库');
assert.equal(session.pendingQueue.length, 1, '已挂起队列不受空闲路径影响');
},

/** flushPending:统一入库并返回是否存在触发类消息;连续 user 消息自动合并 */
async testFlushPendingAddsMessagesAndReturnsFlag(): Promise<void> {
const session = new Session();
session.sessionId = 'sess_flush';
session.sessionType = 'user';
const ctx = makeCtx();
const msgA = { message: '第一条' } as any;
const msgB = { message: '第二条' } as any;
const arrA = [{ type: 'text', data: { text: '第一条' } }] as any;
const arrB = [{ type: 'text', data: { text: '第二条' } }] as any;

// 仅记录类:flushPending 返回 false(链结束不续跑)
await session.deferReceipt(ctx, msgA, arrA, 'record');
assert.equal(await session.flushPending(), false, '纯记录类不应续跑');
assert.equal(session.pendingQueue.length, 0, 'flush 后队列应清空');

// 触发类 + 记录类混排:返回 true,且全部入库(连续 user 消息合并为同一条)
const session2 = new Session();
session2.sessionId = 'sess_flush2';
session2.sessionType = 'user';
await session2.deferReceipt(ctx, msgA, arrA, 'record');
await session2.deferReceipt(ctx, msgB, arrB, 'trigger');
assert.equal(await session2.flushPending(), true, '含触发类应返回 true');
assert.equal(session2.context.messages.length, 1, '连续 user 消息应合并为同一条');
const userMsg = session2.context.messages[0] as any;
assert.equal(userMsg.role, 'user');
const texts = userMsg.contentItems.map((i: any) => i.text);
assert.deepEqual(texts, ['第一条', '第二条'], '连续 user 消息应合并为同一条');
},

/** flushPending 空队列:返回 false 且不改动上下文 */
async testFlushPendingEmpty(): Promise<void> {
const session = new Session();
session.sessionId = 'sess_empty';
session.sessionType = 'user';
assert.equal(await session.flushPending(), false);
assert.equal(session.context.messages.length, 0);
},

/** AI 设定触发挂起:systemReason 在 flush 时以「触发原因提示」写入用户消息 */
async testFlushPendingAddsSystemReason(): Promise<void> {
const session = new Session();
session.sessionId = 'sess_reason';
session.sessionType = 'user';
const ctx = makeCtx();
const msg = { message: '关键词消息' } as any;
const messageArray = [{ type: 'text', data: { text: '关键词消息' } }] as any;
await session.deferReceipt(ctx, msg, messageArray, 'trigger', '因为你提到了关键词');
assert.equal(await session.flushPending(), true);
const userMsg = session.context.messages[0] as any;
const items = userMsg.contentItems;
const reasonItem = items.find((i: any) => i.systemName === '触发原因提示');
assert.ok(reasonItem, '应写入触发原因提示条目');
assert.equal(reasonItem.text, '因为你提到了关键词');
assert.ok(items.some((i: any) => i.text === '关键词消息'), '用户消息本身也应入库');
},

/** .ai stop:清空挂起队列(停止后不复活) */
async testStopClearsPendingQueue(): Promise<void> {
const session = new Session();
session.sessionId = 'sess_stop';
session.sessionType = 'user';
const ctx = makeCtx();
const msg = { message: '挂起消息' } as any;
const messageArray = [{ type: 'text', data: { text: '挂起消息' } }] as any;
await session.deferReceipt(ctx, msg, messageArray, 'trigger');
assert.equal(session.pendingQueue.length, 1);
await session.stopConversation();
assert.equal(session.pendingQueue.length, 0, 'stop 后挂起队列应清空');
assert.equal(session.context.messages.length, 0, 'stop 后挂起消息不应复活入库');
},

/** 同会话闸门:第一条 run 挂起在 runInternal 时,第二条 run 被 starting 闸门直接拦截,activeRuns 恒 ≤1 */
async testStartingGatePreventsConcurrentRun(): Promise<void> {
TC.intConfigs['请求并发上限'] = 1;
TC.intConfigs['请求队列上限'] = 3;
resetConfigCache();

const agent = new Agent();
const session = new Session();
session.sessionId = 'sess_gate';
const origInternal = (agent as any).runInternal;
let releaseRun = () => { };
const gate = new Promise<void>(resolve => { releaseRun = resolve; });
(agent as any).runInternal = async () => {
assert.ok(session.activeRuns <= 1, '同一会话不应并发多个 run');
await gate;
};
try {
const p1 = agent.run(session, makeCtx(), { message: '1' } as any);
// 等第一条真正进入 runInternal(acquire 完成、running=true、activeRuns=1、挂起在 gate 上)
await new Promise(r => setTimeout(r, 0));
// 第二条同刻到达:run() 同步闸门(running/starting)应直接拦截,不进入 acquire/runInternal
await agent.run(session, makeCtx(), { message: '2' } as any);
assert.equal(session.activeRuns, 1, '并发保护下 activeRuns 恒为 1');
assert.equal(session.running, true, '第一条仍在运行');
assert.equal(session.starting, false, '运行中不再处于启动中');
releaseRun();
await p1;
assert.equal(session.activeRuns, 0, '运行结束后 activeRuns 归零');
assert.equal(session.running, false);
assert.equal(session.starting, false, '结束后 starting 复位');
} finally {
releaseRun();
(agent as any).runInternal = origInternal;
TC.intConfigs['请求并发上限'] = 0;
TC.intConfigs['请求队列上限'] = 0;
resetConfigCache();
}
},
};
2 changes: 1 addition & 1 deletion skills/aiplugin4-test-suite/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ description: aiplugin4 插件综合测试与调试技能:通过 qqmcp 对测

| 用户提到的指令 | 用例文件 |
|---|---|
| `status` `ctxn` `on` `off` `sb/standby` `fgt/forget` `role` `model` `stop` `steer` `ign/ignore` | [control.md](references/control.md) |
| `status` `ctxn` `on` `off` `sb/standby` `fgt/forget` `role` `model` `stop` `ign/ignore` | [control.md](references/control.md) |
| `memo/memory` | [memory.md](references/memory.md) |
| `tool` | [tool.md](references/tool.md) |
| `priv/privilege` `prompt` `tk/token` `timer` | [admin.md](references/admin.md) |
Expand Down
6 changes: 3 additions & 3 deletions skills/aiplugin4-test-suite/references/control.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

权限:U=任意成员;I=邀请者/管理/群主/白名单/骰主;S=骰主(会话权限≥1 时为邀请者)。

## 基础控制(status/ctxn/on/standby/off/forget/role/model/stop/steer
## 基础控制(status/ctxn/on/standby/off/forget/role/model/stop)

| ID | 指令 | 权限 | 预期关键字 | 备注 |
|---|---|---|---|---|
Expand Down Expand Up @@ -32,8 +32,8 @@
| CTRL-24 | `.ai model clr` | U | `已清除当前会话的模型设置` | 结束后恢复快照模型 |
| CTRL-25 | `.ai model 不存在的模型` | U | `不存在` | 错误路径 |
| CTRL-26 | `.ai stop` | U | `当前没有正在进行的对话` | 无进行中对话时(原 shut 用例) |
| CTRL-27 | `.ai steer` | U | `缺少内容` | 无内容错误路径 |
| CTRL-28 | `.ai steer 测试方向` | U | `当前没有正在进行的对话` | 无进行中对话时 |
| CTRL-27 | 运行中连发普通消息 | U | 当前任务结束后自动入库、不并发回复 | 先发一个长工具链任务制造运行中会话,再连发普通消息,全程无并发/排队 |
| CTRL-28 | 运行中发触发消息 | U | 前一轮结束后串行自动再触发一轮 | 运行中发送触发消息,等待前一轮结束并自动续跑回复 |

## 忽略名单(ignore)

Expand Down
Loading
Loading